feat(dev-variables): 项目开发变量与测试账户补给(V1–V11) #23

Merged
lanrtop merged 15 commits from feat/project-dev-variables into master 2026-09-30 13:35:23 +08:00
2 changed files with 463 additions and 14 deletions
Showing only changes of commit d559b7917b - Show all commits

View File

@ -1,6 +1,6 @@
//! 项目级开发变量本体。SQLite 只保存元数据和随机引用;值仅驻留受保护存储。 //! 项目级开发变量本体。SQLite 只保存元数据和随机引用;值仅驻留受保护存储。
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use serde::Serialize; use serde::{Deserialize, Serialize};
use crate::credential_store::{ use crate::credential_store::{
delete_references, put_verified, restore_references, CredentialStore, delete_references, put_verified, restore_references, CredentialStore,
@ -11,10 +11,22 @@ pub struct DevVariableStatus {
pub project_id: String, pub project_id: String,
pub name: String, pub name: String,
pub purpose: String, pub purpose: String,
pub kind: String,
pub service: Option<String>,
pub environment: Option<String>,
pub configured: bool, pub configured: bool,
pub updated_at: String, pub updated_at: String,
} }
#[derive(Serialize, Deserialize)]
pub struct TestAccountValue {
pub username: String,
pub password: String,
}
const SCALAR_KIND: &str = "scalar";
const TEST_ACCOUNT_KIND: &str = "test_account";
fn normalized_name(name: &str) -> Result<String, String> { fn normalized_name(name: &str) -> Result<String, String> {
let name = name.trim().to_ascii_uppercase(); let name = name.trim().to_ascii_uppercase();
if name.is_empty() if name.is_empty()
@ -52,6 +64,34 @@ fn reference(conn: &Connection, project_id: &str, name: &str) -> Result<Option<S
.map_err(|_| "开发变量未登记".to_string()) .map_err(|_| "开发变量未登记".to_string())
} }
fn require_kind(
conn: &Connection,
project_id: &str,
name: &str,
expected_kind: &str,
) -> Result<(), String> {
let kind: String = conn
.query_row(
"SELECT kind FROM project_dev_variables WHERE project_id=?1 AND name=?2",
params![project_id, name],
|row| row.get(0),
)
.map_err(|_| "开发需求未登记".to_string())?;
if kind != expected_kind {
return Err("开发需求类型不匹配".into());
}
Ok(())
}
fn metadata(value: &str, label: &str, max_chars: usize) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value.chars().count() > max_chars || value.chars().any(char::is_control)
{
return Err(format!("{label}须为 1–{max_chars} 字且不含控制字符"));
}
Ok(value.to_string())
}
pub fn register( pub fn register(
conn: &Connection, conn: &Connection,
project_id: &str, project_id: &str,
@ -60,16 +100,58 @@ pub fn register(
) -> Result<(), String> { ) -> Result<(), String> {
require_project(conn, project_id)?; require_project(conn, project_id)?;
let name = normalized_name(name)?; let name = normalized_name(name)?;
let purpose = purpose.trim(); let purpose = metadata(purpose, "用途说明", 240)?;
if purpose.is_empty() || purpose.chars().count() > 240 || purpose.chars().any(char::is_control)
{
return Err("用途说明须为 1–240 字且不含控制字符".into());
}
conn.execute( conn.execute(
"INSERT OR IGNORE INTO project_dev_variables (project_id,name,purpose) VALUES (?1,?2,?3)", "INSERT OR IGNORE INTO project_dev_variables (project_id,name,purpose) VALUES (?1,?2,?3)",
params![project_id, name, purpose], params![project_id, name, purpose],
) )
.map_err(|_| "登记开发变量失败".to_string())?; .map_err(|_| "登记开发变量失败".to_string())?;
require_kind(conn, project_id, &name, SCALAR_KIND)?;
Ok(())
}
pub fn register_test_account(
conn: &Connection,
project_id: &str,
name: &str,
service: &str,
environment: &str,
purpose: &str,
) -> Result<(), String> {
require_project(conn, project_id)?;
let name = normalized_name(name)?;
let service = metadata(service, "目标系统", 80)?;
let environment = metadata(environment, "测试环境", 80)?;
if matches!(
environment.to_ascii_lowercase().as_str(),
"prod" | "production" | "live"
) || environment.contains("生产")
{
return Err("测试账户不得登记生产环境".into());
}
let purpose = metadata(purpose, "用途说明", 240)?;
conn.execute(
"INSERT OR IGNORE INTO project_dev_variables
(project_id,name,purpose,kind,service,environment)
VALUES (?1,?2,?3,'test_account',?4,?5)",
params![project_id, name, purpose, service, environment],
)
.map_err(|_| "登记测试账户需求失败".to_string())?;
let existing: (String, String, Option<String>, Option<String>) = conn
.query_row(
"SELECT kind,purpose,service,environment FROM project_dev_variables
WHERE project_id=?1 AND name=?2",
params![project_id, name],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.map_err(|_| "查询测试账户需求失败".to_string())?;
if existing.0 != TEST_ACCOUNT_KIND
|| existing.1 != purpose
|| existing.2.as_deref() != Some(service.as_str())
|| existing.3.as_deref() != Some(environment.as_str())
{
return Err("同名需求的类型或用途不同,请更换名称".into());
}
Ok(()) Ok(())
} }
@ -81,7 +163,7 @@ pub fn list(
require_project(conn, project_id)?; require_project(conn, project_id)?;
let mut statement = conn let mut statement = conn
.prepare( .prepare(
"SELECT name,purpose,credential_ref,updated_at FROM project_dev_variables "SELECT name,purpose,kind,service,environment,credential_ref,updated_at FROM project_dev_variables
WHERE project_id=?1 ORDER BY name", WHERE project_id=?1 ORDER BY name",
) )
.map_err(|_| "查询开发变量失败".to_string())?; .map_err(|_| "查询开发变量失败".to_string())?;
@ -90,23 +172,31 @@ pub fn list(
Ok(( Ok((
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?, row.get::<_, String>(2)?,
row.get::<_, String>(3)?, row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, String>(6)?,
)) ))
}) })
.map_err(|_| "查询开发变量失败".to_string())?; .map_err(|_| "查询开发变量失败".to_string())?;
let mut statuses = Vec::new(); let mut statuses = Vec::new();
for row in rows { for row in rows {
let (name, purpose, reference, updated_at) = let (name, purpose, kind, service, environment, reference, updated_at) =
row.map_err(|_| "查询开发变量失败".to_string())?; row.map_err(|_| "查询开发变量失败".to_string())?;
let configured = match reference { let configured = match reference {
Some(reference) => { Some(reference) => {
store let bytes = store
.get(&reference) .get(&reference)
.map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())? .map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())?
.ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())? .ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())?;
.len() if kind == TEST_ACCOUNT_KIND {
> 0 serde_json::from_slice::<TestAccountValue>(&bytes)
.map(|value| !value.username.is_empty() && !value.password.is_empty())
.map_err(|_| "测试账户状态 needs_repair:值格式无效".to_string())?
} else {
!bytes.is_empty()
}
} }
None => false, None => false,
}; };
@ -114,6 +204,9 @@ pub fn list(
project_id: project_id.to_string(), project_id: project_id.to_string(),
name, name,
purpose, purpose,
kind,
service,
environment,
configured, configured,
updated_at, updated_at,
}); });
@ -126,9 +219,35 @@ pub fn read(
store: &impl CredentialStore, store: &impl CredentialStore,
project_id: &str, project_id: &str,
name: &str, name: &str,
) -> Result<String, String> {
read_value(conn, store, project_id, name, SCALAR_KIND)
}
pub fn read_test_account(
conn: &Connection,
store: &impl CredentialStore,
project_id: &str,
name: &str,
) -> Result<TestAccountValue, String> {
let value = read_value(conn, store, project_id, name, TEST_ACCOUNT_KIND)?;
let account = serde_json::from_str::<TestAccountValue>(&value)
.map_err(|_| "测试账户状态 needs_repair:值格式无效".to_string())?;
if account.username.is_empty() || account.password.is_empty() {
return Err("测试账户状态 needs_repair:字段缺失".into());
}
Ok(account)
}
fn read_value(
conn: &Connection,
store: &impl CredentialStore,
project_id: &str,
name: &str,
expected_kind: &str,
) -> Result<String, String> { ) -> Result<String, String> {
require_project(conn, project_id)?; require_project(conn, project_id)?;
let name = normalized_name(name)?; let name = normalized_name(name)?;
require_kind(conn, project_id, &name, expected_kind)?;
let reference = let reference =
reference(conn, project_id, &name)?.ok_or_else(|| "开发变量尚未由用户填写".to_string())?; reference(conn, project_id, &name)?.ok_or_else(|| "开发变量尚未由用户填写".to_string())?;
let bytes = store let bytes = store
@ -144,9 +263,41 @@ pub fn save(
project_id: &str, project_id: &str,
name: &str, name: &str,
value: &str, value: &str,
) -> Result<(), String> {
save_value(conn, store, project_id, name, value, SCALAR_KIND)
}
pub fn save_test_account(
conn: &Connection,
store: &impl CredentialStore,
project_id: &str,
name: &str,
username: &str,
password: &str,
) -> Result<(), String> {
if username.is_empty() || password.is_empty() || username.len() > 1024 || password.len() > 1024
{
return Err("测试账号与密码均须为 1–1024 字节".into());
}
let value = serde_json::to_string(&TestAccountValue {
username: username.to_string(),
password: password.to_string(),
})
.map_err(|_| "序列化测试账户失败".to_string())?;
save_value(conn, store, project_id, name, &value, TEST_ACCOUNT_KIND)
}
fn save_value(
conn: &Connection,
store: &impl CredentialStore,
project_id: &str,
name: &str,
value: &str,
expected_kind: &str,
) -> Result<(), String> { ) -> Result<(), String> {
require_project(conn, project_id)?; require_project(conn, project_id)?;
let name = normalized_name(name)?; let name = normalized_name(name)?;
require_kind(conn, project_id, &name, expected_kind)?;
if value.is_empty() || value.len() > 2560 { if value.is_empty() || value.len() > 2560 {
return Err("开发变量值长度须为 1–2560 字节".into()); return Err("开发变量值长度须为 1–2560 字节".into());
} }
@ -273,6 +424,26 @@ pub fn save_project_dev_variable(
) )
} }
#[tauri::command]
pub fn save_project_test_account(
project_id: String,
name: String,
username: String,
password: String,
) -> Result<(), String> {
let conn = crate::db::pool()
.get()
.map_err(|_| "数据库不可用".to_string())?;
save_test_account(
&conn,
&crate::credential_store::WindowsCredentialStore,
&project_id,
&name,
&username,
&password,
)
}
#[tauri::command] #[tauri::command]
pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> { pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> {
let conn = crate::db::pool() let conn = crate::db::pool()
@ -336,6 +507,137 @@ mod tests {
assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err()); assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err());
} }
#[test]
fn test_account_is_one_idempotent_request_and_one_protected_value() {
let conn = fixture();
let store = MemoryCredentialStore::new();
register_test_account(
&conn,
"p1",
"E2E_LOGIN",
"enterprise-system",
"test",
"登录验收",
)
.unwrap();
register_test_account(
&conn,
"p1",
"e2e_login",
"enterprise-system",
"test",
"登录验收",
)
.unwrap();
assert!(register_test_account(
&conn,
"p1",
"E2E_LOGIN",
"another-system",
"test",
"登录验收"
)
.is_err());
assert!(register_test_account(
&conn,
"p1",
"PROD_LOGIN",
"enterprise-system",
"production",
"登录验收"
)
.is_err());
assert!(register(&conn, "p1", "E2E_LOGIN", "普通变量").is_err());
let statuses = list(&conn, &store, "p1").unwrap();
assert_eq!(statuses.len(), 1);
assert_eq!(statuses[0].kind, TEST_ACCOUNT_KIND);
assert_eq!(statuses[0].service.as_deref(), Some("enterprise-system"));
assert!(!statuses[0].configured);
save_test_account(
&conn,
&store,
"p1",
"E2E_LOGIN",
"qa-only-user",
"qa-only-password",
)
.unwrap();
let status_json = serde_json::to_string(&list(&conn, &store, "p1").unwrap()).unwrap();
assert!(!status_json.contains("qa-only-user"));
assert!(!status_json.contains("qa-only-password"));
assert!(read(&conn, &store, "p1", "E2E_LOGIN").is_err());
assert!(save(&conn, &store, "p1", "E2E_LOGIN", "wrong-type").is_err());
let account = read_test_account(&conn, &store, "p1", "E2E_LOGIN").unwrap();
assert_eq!(account.username, "qa-only-user");
assert_eq!(account.password, "qa-only-password");
revoke(&conn, &store, "p1", "E2E_LOGIN").unwrap();
assert!(read_test_account(&conn, &store, "p1", "E2E_LOGIN").is_err());
}
#[test]
fn failed_test_account_overwrite_preserves_both_old_fields() {
let conn = fixture();
register_test_account(
&conn,
"p1",
"E2E_LOGIN",
"enterprise-system",
"test",
"登录验收",
)
.unwrap();
let old_ref = "dev-variable/qa-only-account-old";
let store = MemoryCredentialStore::failing_delete_for(old_ref);
save_test_account(&conn, &store, "p1", "E2E_LOGIN", "old-user", "old-password").unwrap();
let generated_ref: String = conn
.query_row(
"SELECT credential_ref FROM project_dev_variables WHERE project_id='p1' AND name='E2E_LOGIN'",
[],
|row| row.get(0),
)
.unwrap();
store
.put(old_ref, &store.get(&generated_ref).unwrap().unwrap())
.unwrap();
conn.execute(
"UPDATE project_dev_variables SET credential_ref=?1 WHERE project_id='p1' AND name='E2E_LOGIN'",
[old_ref],
)
.unwrap();
assert!(
save_test_account(&conn, &store, "p1", "E2E_LOGIN", "new-user", "new-password")
.is_err()
);
let account = read_test_account(&conn, &store, "p1", "E2E_LOGIN").unwrap();
assert_eq!(account.username, "old-user");
assert_eq!(account.password, "old-password");
}
#[test]
fn failed_initial_test_account_write_stays_pending() {
let conn = fixture();
register_test_account(
&conn,
"p1",
"E2E_LOGIN",
"enterprise-system",
"test",
"登录验收",
)
.unwrap();
let store = MemoryCredentialStore::failing();
assert!(save_test_account(&conn, &store, "p1", "E2E_LOGIN", "user", "password").is_err());
assert!(!list(&conn, &MemoryCredentialStore::new(), "p1").unwrap()[0].configured);
let reference: Option<String> = conn
.query_row(
"SELECT credential_ref FROM project_dev_variables WHERE project_id='p1' AND name='E2E_LOGIN'",
[],
|row| row.get(0),
)
.unwrap();
assert!(reference.is_none());
}
#[test] #[test]
fn failed_write_or_delete_preserves_old_value_and_reference() { fn failed_write_or_delete_preserves_old_value_and_reference() {
let conn = fixture(); let conn = fixture();
@ -455,6 +757,58 @@ mod tests {
std::fs::remove_file(path).unwrap(); std::fs::remove_file(path).unwrap();
} }
#[test]
fn test_account_can_be_reused_after_database_reopen() {
let path =
std::env::temp_dir().join(format!("lianjing-test-account-{}.db", uuid::Uuid::new_v4()));
let store = MemoryCredentialStore::new();
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
crate::db::migrate(&conn);
conn.execute(
"INSERT INTO project_profiles (id,name) VALUES ('profile-1','QA')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO project_workspaces (id,profile_id) VALUES ('p1','profile-1')",
[],
)
.unwrap();
register_test_account(
&conn,
"p1",
"E2E_LOGIN",
"enterprise-system",
"test",
"登录验收",
)
.unwrap();
save_test_account(
&conn,
&store,
"p1",
"E2E_LOGIN",
"reused-user",
"reused-password",
)
.unwrap();
}
{
let conn = Connection::open(&path).unwrap();
crate::db::migrate(&conn);
let statuses = list(&conn, &store, "p1").unwrap();
assert_eq!(statuses.len(), 1);
assert!(statuses[0].configured);
let account = read_test_account(&conn, &store, "p1", "E2E_LOGIN").unwrap();
assert_eq!(account.username, "reused-user");
assert_eq!(account.password, "reused-password");
revoke(&conn, &store, "p1", "E2E_LOGIN").unwrap();
}
std::fs::remove_file(path).unwrap();
}
#[cfg(windows)] #[cfg(windows)]
#[test] #[test]
fn tauri_commands_use_same_state_and_never_return_value_in_list() { fn tauri_commands_use_same_state_and_never_return_value_in_list() {
@ -495,4 +849,54 @@ mod tests {
conn.execute("DELETE FROM project_profiles WHERE id=?1", [&profile_id]) conn.execute("DELETE FROM project_profiles WHERE id=?1", [&profile_id])
.unwrap(); .unwrap();
} }
#[cfg(windows)]
#[test]
fn tauri_test_account_command_uses_same_state_without_listing_values() {
crate::db::init_test_pool();
let project_id = format!("qa-only-account-{}", uuid::Uuid::new_v4());
let profile_id = format!("qa-only-profile-{}", uuid::Uuid::new_v4());
{
let conn = crate::db::pool().get().unwrap();
conn.execute(
"INSERT INTO project_profiles (id,name) VALUES (?1,'QA')",
[&profile_id],
)
.unwrap();
conn.execute(
"INSERT INTO project_workspaces (id,profile_id) VALUES (?1,?2)",
params![project_id, profile_id],
)
.unwrap();
register_test_account(
&conn,
&project_id,
"E2E_LOGIN",
"enterprise-system",
"test",
"合成登录验收",
)
.unwrap();
}
assert!(!list_project_dev_variables(project_id.clone()).unwrap()[0].configured);
save_project_test_account(
project_id.clone(),
"E2E_LOGIN".into(),
"qa-only-command-user".into(),
"qa-only-command-password".into(),
)
.unwrap();
let statuses = list_project_dev_variables(project_id.clone()).unwrap();
assert!(statuses[0].configured);
let serialized = serde_json::to_string(&statuses).unwrap();
assert!(!serialized.contains("qa-only-command-user"));
assert!(!serialized.contains("qa-only-command-password"));
revoke_project_dev_variable(project_id.clone(), "E2E_LOGIN".into()).unwrap();
assert!(!list_project_dev_variables(project_id.clone()).unwrap()[0].configured);
let conn = crate::db::pool().get().unwrap();
conn.execute("DELETE FROM project_workspaces WHERE id=?1", [&project_id])
.unwrap();
conn.execute("DELETE FROM project_profiles WHERE id=?1", [&profile_id])
.unwrap();
}
} }

View File

@ -1064,11 +1064,26 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) {
name TEXT NOT NULL, name TEXT NOT NULL,
purpose TEXT NOT NULL, purpose TEXT NOT NULL,
credential_ref TEXT, credential_ref TEXT,
kind TEXT NOT NULL DEFAULT 'scalar',
service TEXT,
environment TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, name) PRIMARY KEY (project_id, name)
);", );",
) )
.expect("migrate project dev variables"); .expect("migrate project dev variables");
let _ = conn.execute(
"ALTER TABLE project_dev_variables ADD COLUMN kind TEXT NOT NULL DEFAULT 'scalar'",
[],
);
let _ = conn.execute(
"ALTER TABLE project_dev_variables ADD COLUMN service TEXT",
[],
);
let _ = conn.execute(
"ALTER TABLE project_dev_variables ADD COLUMN environment TEXT",
[],
);
} }
#[cfg(test)] #[cfg(test)]
@ -1173,6 +1188,36 @@ mod tests {
assert!(!columns.contains(&"value".to_string())); assert!(!columns.contains(&"value".to_string()));
} }
#[test]
fn test_account_columns_upgrade_existing_dev_variable_rows() {
let conn = conn_with_schema();
conn.execute_batch(
"DROP TABLE project_dev_variables;
CREATE TABLE project_dev_variables (
project_id TEXT NOT NULL,
name TEXT NOT NULL,
purpose TEXT NOT NULL,
credential_ref TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, name)
);
INSERT INTO project_dev_variables (project_id,name,purpose)
VALUES ('p1','LEGACY_VALUE','legacy');",
)
.unwrap();
migrate(&conn);
migrate(&conn);
let row: (String, Option<String>, Option<String>) = conn
.query_row(
"SELECT kind,service,environment FROM project_dev_variables
WHERE project_id='p1' AND name='LEGACY_VALUE'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(row, ("scalar".to_string(), None, None));
}
#[test] #[test]
fn sync_state_touch_then_read() { fn sync_state_touch_then_read() {
let conn = conn_with_schema(); let conn = conn_with_schema();