diff --git a/src-tauri/src/commands/dev_variables.rs b/src-tauri/src/commands/dev_variables.rs index 1353d3d..8640fd3 100644 --- a/src-tauri/src/commands/dev_variables.rs +++ b/src-tauri/src/commands/dev_variables.rs @@ -1,6 +1,6 @@ //! 项目级开发变量本体。SQLite 只保存元数据和随机引用;值仅驻留受保护存储。 use rusqlite::{params, Connection}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::credential_store::{ delete_references, put_verified, restore_references, CredentialStore, @@ -11,10 +11,22 @@ pub struct DevVariableStatus { pub project_id: String, pub name: String, pub purpose: String, + pub kind: String, + pub service: Option, + pub environment: Option, pub configured: bool, 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 { let name = name.trim().to_ascii_uppercase(); if name.is_empty() @@ -52,6 +64,34 @@ fn reference(conn: &Connection, project_id: &str, name: &str) -> Result 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 { + 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( conn: &Connection, project_id: &str, @@ -60,16 +100,58 @@ pub fn register( ) -> Result<(), String> { require_project(conn, project_id)?; let name = normalized_name(name)?; - let purpose = purpose.trim(); - if purpose.is_empty() || purpose.chars().count() > 240 || purpose.chars().any(char::is_control) - { - return Err("用途说明须为 1–240 字且不含控制字符".into()); - } + let purpose = metadata(purpose, "用途说明", 240)?; conn.execute( "INSERT OR IGNORE INTO project_dev_variables (project_id,name,purpose) VALUES (?1,?2,?3)", params![project_id, name, purpose], ) .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, Option) = 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(()) } @@ -81,7 +163,7 @@ pub fn list( require_project(conn, project_id)?; let mut statement = conn .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", ) .map_err(|_| "查询开发变量失败".to_string())?; @@ -90,23 +172,31 @@ pub fn list( Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, String>(3)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, )) }) .map_err(|_| "查询开发变量失败".to_string())?; let mut statuses = Vec::new(); for row in rows { - let (name, purpose, reference, updated_at) = + let (name, purpose, kind, service, environment, reference, updated_at) = row.map_err(|_| "查询开发变量失败".to_string())?; let configured = match reference { Some(reference) => { - store + let bytes = store .get(&reference) .map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())? - .ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())? - .len() - > 0 + .ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())?; + if kind == TEST_ACCOUNT_KIND { + serde_json::from_slice::(&bytes) + .map(|value| !value.username.is_empty() && !value.password.is_empty()) + .map_err(|_| "测试账户状态 needs_repair:值格式无效".to_string())? + } else { + !bytes.is_empty() + } } None => false, }; @@ -114,6 +204,9 @@ pub fn list( project_id: project_id.to_string(), name, purpose, + kind, + service, + environment, configured, updated_at, }); @@ -126,9 +219,35 @@ pub fn read( store: &impl CredentialStore, project_id: &str, name: &str, +) -> Result { + 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 { + let value = read_value(conn, store, project_id, name, TEST_ACCOUNT_KIND)?; + let account = serde_json::from_str::(&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 { require_project(conn, project_id)?; let name = normalized_name(name)?; + require_kind(conn, project_id, &name, expected_kind)?; let reference = reference(conn, project_id, &name)?.ok_or_else(|| "开发变量尚未由用户填写".to_string())?; let bytes = store @@ -144,9 +263,41 @@ pub fn save( project_id: &str, name: &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> { require_project(conn, project_id)?; let name = normalized_name(name)?; + require_kind(conn, project_id, &name, expected_kind)?; if value.is_empty() || value.len() > 2560 { 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] pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> { let conn = crate::db::pool() @@ -336,6 +507,137 @@ mod tests { 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 = 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] fn failed_write_or_delete_preserves_old_value_and_reference() { let conn = fixture(); @@ -455,6 +757,58 @@ mod tests { 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)] #[test] 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]) .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(); + } } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index a8e7927..5fe41c8 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1064,11 +1064,26 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) { name TEXT NOT NULL, purpose TEXT NOT NULL, credential_ref TEXT, + kind TEXT NOT NULL DEFAULT 'scalar', + service TEXT, + environment TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (project_id, name) );", ) .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)] @@ -1173,6 +1188,36 @@ mod tests { 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, Option) = 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] fn sync_state_touch_then_read() { let conn = conn_with_schema();