|
1 | 1 | //! ai_credentials.rs 单测(AAA 模式;凭据库 roundtrip 走内存桩—— |
2 | 2 | //! DPAPI 为系统调用不单测,与 model_downloader 网络路径同口径)。 |
| 3 | +//! |
| 4 | +//! @ai-context: 2026-09-13 批 8 T25(用户裁决 U2 = c):遗留槽 "default" 不再是 |
| 5 | +//! 合法槽位 ⇒ 原「全部走 default 条目」的用例改用**合法** per-provider |
| 6 | +//! 槽(语义不变:roundtrip/覆盖/幂等/空值拒绝),并新增两组判据: |
| 7 | +//! ① **写/读/清三路全拒**(V1);② **旧数据仍在**(V2——物理层键与值 |
| 8 | +//! 可读 + 旧 blob 文件字节不变)。测试串全为**虚构值**,不含任何真密钥。 |
3 | 9 |
|
4 | | -use crate::ai_credentials::{CredentialStore, MemoryCredentialStore}; |
| 10 | +#[cfg(target_os = "windows")] |
| 11 | +use std::path::PathBuf; |
| 12 | + |
| 13 | +use crate::ai_credentials::{CredentialStore, MemoryCredentialStore, LEGACY_DEFAULT_SCOPE}; |
5 | 14 |
|
6 | 15 | fn store() -> MemoryCredentialStore { |
7 | 16 | MemoryCredentialStore::default() |
8 | 17 | } |
9 | 18 |
|
10 | | -/// 默认 scope(与 command 层调用一致——旧测试全部走 "default" 条目)。 |
11 | | -const DEFAULT_SCOPE: &str = "default"; |
| 19 | +/// 合法槽位(per-provider;旧测试走 "default" 条目,U2 = c 后该槽已废弃)。 |
| 20 | +const TEST_SCOPE: &str = "provider:test"; |
12 | 21 |
|
13 | 22 | #[test] |
14 | 23 | fn roundtrip_save_load() { |
15 | 24 | let s = store(); |
16 | | - s.save_key(DEFAULT_SCOPE, "sk-test-123").expect("保存成功"); |
17 | | - assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功").as_deref(), Some("sk-test-123")); |
| 25 | + s.save_key(TEST_SCOPE, "sk-test-123").expect("保存成功"); |
| 26 | + assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功").as_deref(), Some("sk-test-123")); |
18 | 27 | } |
19 | 28 |
|
20 | 29 | #[test] |
21 | 30 | fn load_empty_is_none() { |
22 | 31 | // 未保存 → None(与 DPAPI 文件不存在语义一致) |
23 | 32 | let s = store(); |
24 | | - assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None); |
| 33 | + assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功"), None); |
25 | 34 | } |
26 | 35 |
|
27 | 36 | #[test] |
28 | 37 | fn clear_removes_key() { |
29 | 38 | let s = store(); |
30 | | - s.save_key(DEFAULT_SCOPE, "sk-abc").expect("保存成功"); |
31 | | - s.clear_key(DEFAULT_SCOPE).expect("清除成功"); |
32 | | - assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None); |
| 39 | + s.save_key(TEST_SCOPE, "sk-abc").expect("保存成功"); |
| 40 | + s.clear_key(TEST_SCOPE).expect("清除成功"); |
| 41 | + assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功"), None); |
33 | 42 | // 幂等:重复清除不报错(文件不存在视为已清除) |
34 | | - s.clear_key(DEFAULT_SCOPE).expect("重复清除成功"); |
| 43 | + s.clear_key(TEST_SCOPE).expect("重复清除成功"); |
35 | 44 | } |
36 | 45 |
|
37 | 46 | #[test] |
38 | 47 | fn empty_key_rejected() { |
39 | 48 | // 空/空白密钥拒绝保存(防御:不写坏凭据) |
40 | 49 | let s = store(); |
41 | | - assert!(s.save_key(DEFAULT_SCOPE, "").is_err()); |
42 | | - assert!(s.save_key(DEFAULT_SCOPE, " ").is_err()); |
43 | | - assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None); |
| 50 | + assert!(s.save_key(TEST_SCOPE, "").is_err()); |
| 51 | + assert!(s.save_key(TEST_SCOPE, " ").is_err()); |
| 52 | + assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功"), None); |
44 | 53 | } |
45 | 54 |
|
46 | 55 | #[test] |
47 | 56 | fn save_overwrites_previous() { |
48 | 57 | let s = store(); |
49 | | - s.save_key(DEFAULT_SCOPE, "sk-old").expect("保存成功"); |
50 | | - s.save_key(DEFAULT_SCOPE, "sk-new").expect("覆盖保存成功"); |
51 | | - assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功").as_deref(), Some("sk-new")); |
| 58 | + s.save_key(TEST_SCOPE, "sk-old").expect("保存成功"); |
| 59 | + s.save_key(TEST_SCOPE, "sk-new").expect("覆盖保存成功"); |
| 60 | + assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功").as_deref(), Some("sk-new")); |
52 | 61 | } |
53 | 62 |
|
54 | 63 | #[test] |
55 | 64 | fn scoped_store_isolates_keys_by_scope() { |
56 | 65 | let store = crate::ai_credentials::MemoryCredentialStore::default(); |
57 | | - store.save_key("default", "sk-a").unwrap(); |
58 | | - store.save_key("provider:p1", "sk-b").unwrap(); |
59 | | - assert_eq!(store.load_key("default").unwrap().as_deref(), Some("sk-a")); |
60 | | - assert_eq!(store.load_key("provider:p1").unwrap().as_deref(), Some("sk-b")); |
| 66 | + store.save_key("provider:p1", "sk-a").unwrap(); |
| 67 | + store.save_key("provider:p2", "sk-b").unwrap(); |
| 68 | + assert_eq!(store.load_key("provider:p1").unwrap().as_deref(), Some("sk-a")); |
| 69 | + assert_eq!(store.load_key("provider:p2").unwrap().as_deref(), Some("sk-b")); |
61 | 70 | store.clear_key("provider:p1").unwrap(); |
62 | 71 | assert_eq!(store.load_key("provider:p1").unwrap(), None); |
63 | | - assert_eq!(store.load_key("default").unwrap().as_deref(), Some("sk-a")); |
| 72 | + assert_eq!(store.load_key("provider:p2").unwrap().as_deref(), Some("sk-b"), "清一个槽不得动另一个"); |
| 73 | +} |
| 74 | + |
| 75 | +// ──────────────────────────────────────────────────────────── |
| 76 | +// U2 = c 判据(V1:遗留槽不再是合法槽位——写/读/清三路全拒) |
| 77 | +// ──────────────────────────────────────────────────────────── |
| 78 | + |
| 79 | +#[test] |
| 80 | +fn legacy_default_slot_write_is_rejected() { |
| 81 | + // Arrange:合法槽位先写一条(证明拒绝不是"整个库坏了") |
| 82 | + let s = store(); |
| 83 | + s.save_key(TEST_SCOPE, "sk-live-fixture").expect("合法槽位可写"); |
| 84 | + // Act |
| 85 | + let err = s.save_key(LEGACY_DEFAULT_SCOPE, "sk-legacy-fixture").expect_err("遗留槽必须拒绝写入"); |
| 86 | + // Assert:具名断言——拒绝**且**报的是遗留槽 |
| 87 | + assert!( |
| 88 | + err.contains(LEGACY_DEFAULT_SCOPE), |
| 89 | + "拒绝理由必须点名遗留槽,实得: {}", |
| 90 | + err |
| 91 | + ); |
| 92 | + assert_eq!(s.read_legacy_default_for_tests(), None, "被拒的写入不得落进存储"); |
| 93 | +} |
| 94 | + |
| 95 | +#[test] |
| 96 | +fn legacy_default_slot_read_is_rejected() { |
| 97 | + // V1 的「读取不再把它当合法值」:连原始读取都拒绝(显式 Err,不是静默 None) |
| 98 | + let s = store(); |
| 99 | + s.seed_legacy_default_for_tests("sk-legacy-fixture"); |
| 100 | + assert!(s.load_key(LEGACY_DEFAULT_SCOPE).is_err(), "遗留槽读取必须显式拒绝"); |
| 101 | +} |
| 102 | + |
| 103 | +#[test] |
| 104 | +fn legacy_default_slot_clear_is_rejected() { |
| 105 | + // (ii) 的代码级防线:删不掉 ⇒ 没有任何 API 能抹除旧数据 |
| 106 | + let s = store(); |
| 107 | + s.seed_legacy_default_for_tests("sk-legacy-fixture"); |
| 108 | + assert!(s.clear_key(LEGACY_DEFAULT_SCOPE).is_err(), "遗留槽清除必须显式拒绝"); |
| 109 | +} |
| 110 | + |
| 111 | +// ──────────────────────────────────────────────────────────── |
| 112 | +// U2 = c 判据(V2:旧数据仍在——物理层键与值可读 / 旧 blob 字节不变) |
| 113 | +// ──────────────────────────────────────────────────────────── |
| 114 | + |
| 115 | +#[test] |
| 116 | +fn legacy_default_slot_value_survives_code_removal() { |
| 117 | + // Arrange:旧版本写下的遗留槽数据(只能落物理层——槽位 API 已关门) |
| 118 | + const LEGACY_VALUE: &str = "sk-legacy-fixture-value"; |
| 119 | + let s = store(); |
| 120 | + s.seed_legacy_default_for_tests(LEGACY_VALUE); |
| 121 | + // Act:走一圈现存**可达**的操作(合法槽位读写清 + 尝试抹除旧槽) |
| 122 | + s.save_key("provider:p1", "sk-p1-fixture").expect("合法槽位可写"); |
| 123 | + assert!(s.clear_key(LEGACY_DEFAULT_SCOPE).is_err(), "旧槽不可通过 API 抹除"); |
| 124 | + s.clear_key("provider:p1").expect("合法槽位可清"); |
| 125 | + // Assert:旧键与旧值**都还在**(键与值都可读 ⇒ 没有 DELETE / 覆盖写动过它) |
| 126 | + assert_eq!( |
| 127 | + s.read_legacy_default_for_tests().as_deref(), |
| 128 | + Some(LEGACY_VALUE), |
| 129 | + "已存数据必须仍在(本次不抹除任何已存数据)" |
| 130 | + ); |
| 131 | +} |
| 132 | + |
| 133 | +/// 旧凭据 blob 的**物理文件**:合法槽位操作一律不得碰它(字节级对拍)。 |
| 134 | +#[cfg(target_os = "windows")] |
| 135 | +#[test] |
| 136 | +fn legacy_blob_file_untouched_by_legal_slot_ops() { |
| 137 | + // Arrange:造一个"数据目录",旧 blob 落哨兵字节(不调 DPAPI——只做文件 I/O) |
| 138 | + const SENTINEL: &[u8] = b"legacy-blob-fixture-bytes"; |
| 139 | + let dir = std::env::temp_dir().join(format!("ed-t25-legacy-{}", std::process::id())); |
| 140 | + std::fs::create_dir_all(&dir).expect("建临时目录"); |
| 141 | + let legacy_path: PathBuf = dir.join("ai_credentials.bin"); |
| 142 | + std::fs::write(&legacy_path, SENTINEL).expect("写旧 blob 哨兵"); |
| 143 | + let store = crate::ai_credentials::DpapiCredentialStore { path: legacy_path.clone() }; |
| 144 | + // Act:合法槽位操作 + 对旧槽的三种尝试(全拒) |
| 145 | + assert_eq!(store.load_key("provider:p1").expect("合法槽位读取"), None); |
| 146 | + store.clear_key("provider:p1").expect("合法槽位清除幂等"); |
| 147 | + assert!(store.save_key(LEGACY_DEFAULT_SCOPE, "x").is_err(), "旧槽写入被拒"); |
| 148 | + assert!(store.load_key(LEGACY_DEFAULT_SCOPE).is_err(), "旧槽读取被拒"); |
| 149 | + assert!(store.clear_key(LEGACY_DEFAULT_SCOPE).is_err(), "旧槽清除被拒"); |
| 150 | + // Assert:旧 blob 文件仍在且**字节不变**(物理键未被删/未被覆写) |
| 151 | + let after = std::fs::read(&legacy_path).expect("旧 blob 文件必须仍在"); |
| 152 | + assert_eq!(after, SENTINEL, "旧凭据文件字节必须原样保留(不删、不覆写)"); |
| 153 | + std::fs::remove_dir_all(&dir).ok(); |
64 | 154 | } |
0 commit comments