返回 CodeWhale
tests.rs
根目录 / crates / tui / src / credentials / tests.rs
1 //! Coverage for the ported credential store contract.
2
3 use super::context::MapAuthContext;
4 use super::store::{CredentialStore, InMemoryCredentialStore, SecretStoreCredentials};
5 use super::{AuthContext, Credential, CredentialKind};
6 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore, Secrets, SecretsError};
7 use std::sync::Arc;
8
9 #[test]
10 fn credential_debug_never_prints_secret_material() {
11 let api_key = Credential::ApiKey {
12 key: "sk-super-secret-value".to_string(),
13 };
14 let rendered = format!("{api_key:?}");
15 assert!(!rendered.contains("sk-super-secret-value"), "{rendered}");
16 assert!(rendered.contains("redacted"), "{rendered}");
17
18 let oauth = Credential::OAuth {
19 access: "oauth-secret-token".to_string(),
20 expires_at_unix_secs: Some(42),
21 };
22 let rendered = format!("{oauth:?}");
23 assert!(!rendered.contains("oauth-secret-token"), "{rendered}");
24 assert!(rendered.contains("42"), "{rendered}");
25 }
26
27 #[test]
28 fn modify_sees_the_current_credential_and_is_the_write_path() {
29 let store = InMemoryCredentialStore::new();
30 assert_eq!(store.read("deepseek").unwrap(), None);
31
32 let written = store
33 .modify("deepseek", &mut |current| {
34 assert_eq!(current, None, "first write must observe an empty slot");
35 Ok(Some(Credential::ApiKey {
36 key: "first".to_string(),
37 }))
38 })
39 .unwrap();
40 assert_eq!(
41 written.as_ref().map(Credential::kind),
42 Some(CredentialKind::ApiKey)
43 );
44
45 let mut observed = None;
46 store
47 .modify("deepseek", &mut |current| {
48 observed = current.clone();
49 Ok(Some(Credential::ApiKey {
50 key: "second".to_string(),
51 }))
52 })
53 .unwrap();
54 assert_eq!(
55 observed.as_ref().map(Credential::expose_secret),
56 Some("first"),
57 "the closure must see the credential it is replacing"
58 );
59 assert_eq!(
60 store
61 .read("deepseek")
62 .unwrap()
63 .as_ref()
64 .map(Credential::expose_secret),
65 Some("second")
66 );
67 }
68
69 #[test]
70 fn modify_returning_none_leaves_the_entry_unchanged() {
71 let store = InMemoryCredentialStore::new();
72 store
73 .modify("xai", &mut |_| {
74 Ok(Some(Credential::OAuth {
75 access: "token".to_string(),
76 expires_at_unix_secs: Some(100),
77 }))
78 })
79 .unwrap();
80 let kept = store.modify("xai", &mut |_| Ok(None)).unwrap();
81 assert_eq!(
82 kept.as_ref().map(Credential::expose_secret),
83 Some("token"),
84 "a no-op modify must not clear the slot"
85 );
86 }
87
88 /// pi's whole reason for making `modify` the only write path: a refresh that
89 /// runs inside it cannot be interleaved with a concurrent one. Two threads
90 /// both observing the same near-expiry token must produce exactly one refresh.
91 #[test]
92 fn concurrent_modify_on_one_provider_refreshes_once() {
93 use std::sync::Arc;
94 use std::sync::atomic::{AtomicUsize, Ordering};
95
96 let store = Arc::new(InMemoryCredentialStore::new());
97 store
98 .modify("concurrent-provider", &mut |_| {
99 Ok(Some(Credential::OAuth {
100 access: "stale".to_string(),
101 expires_at_unix_secs: Some(0),
102 }))
103 })
104 .unwrap();
105
106 let refreshes = Arc::new(AtomicUsize::new(0));
107 let handles: Vec<_> = (0..8)
108 .map(|_| {
109 let store = Arc::clone(&store);
110 let refreshes = Arc::clone(&refreshes);
111 std::thread::spawn(move || {
112 store
113 .modify("concurrent-provider", &mut |current| {
114 // Double-checked under the lock, exactly as pi does.
115 let needs_refresh = matches!(
116 current,
117 Some(Credential::OAuth {
118 expires_at_unix_secs: Some(0),
119 ..
120 })
121 );
122 if !needs_refresh {
123 return Ok(None);
124 }
125 refreshes.fetch_add(1, Ordering::SeqCst);
126 Ok(Some(Credential::OAuth {
127 access: "rotated".to_string(),
128 expires_at_unix_secs: Some(9_999),
129 }))
130 })
131 .unwrap();
132 })
133 })
134 .collect();
135 for handle in handles {
136 handle.join().unwrap();
137 }
138
139 assert_eq!(
140 refreshes.load(Ordering::SeqCst),
141 1,
142 "modify must serialize per provider so only one thread refreshes"
143 );
144 assert_eq!(
145 store
146 .read("concurrent-provider")
147 .unwrap()
148 .as_ref()
149 .map(Credential::expose_secret),
150 Some("rotated")
151 );
152 }
153
154 /// The store module used to claim `xai_oauth` refresh was unlocked. That is
155 /// false: refresh holds `with_xai_oauth_lifecycle_lock` (see
156 /// `oauth::concurrent_refreshes_share_one_rotated_epoch`). A doc comment
157 /// that misdescribes neighbouring code is how the next person gets misled.
158 #[test]
159 fn store_docs_name_the_xai_lifecycle_lock_instead_of_an_unlocked_refresh() {
160 let source = include_str!("store.rs");
161 assert!(
162 source.contains("with_xai_oauth_lifecycle_lock"),
163 "store.rs must name the lock xAI OAuth refresh actually holds"
164 );
165 assert!(
166 !source.contains("with no lock held"),
167 "store.rs still claims xAI OAuth refresh writes back unlocked"
168 );
169 }
170
171 #[test]
172 fn list_reports_metadata_without_secrets() {
173 let store = InMemoryCredentialStore::new();
174 store
175 .modify("alpha", &mut |_| {
176 Ok(Some(Credential::ApiKey {
177 key: "alpha-secret".to_string(),
178 }))
179 })
180 .unwrap();
181 store
182 .modify("beta", &mut |_| {
183 Ok(Some(Credential::OAuth {
184 access: "beta-secret".to_string(),
185 expires_at_unix_secs: None,
186 }))
187 })
188 .unwrap();
189
190 let listed = store.list().unwrap();
191 let rendered = format!("{listed:?}");
192 assert!(!rendered.contains("alpha-secret"), "{rendered}");
193 assert!(!rendered.contains("beta-secret"), "{rendered}");
194 assert_eq!(listed.len(), 2);
195 assert_eq!(listed[0].provider_id, "alpha");
196 assert_eq!(listed[0].kind, CredentialKind::ApiKey);
197 assert_eq!(listed[1].provider_id, "beta");
198 assert_eq!(listed[1].kind, CredentialKind::OAuth);
199 }
200
201 /// One slot whose backend `get` fails must not hide the others. The adapter
202 /// that replaced the old `.ok().flatten()` probe loop used `read(slot)?`,
203 /// which turned a single corrupt entry into an empty `/provider` list.
204 struct UnreadableSlotStore {
205 inner: InMemoryKeyringStore,
206 unreadable: &'static str,
207 }
208
209 impl KeyringStore for UnreadableSlotStore {
210 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
211 if key == self.unreadable {
212 return Err(SecretsError::Keyring(format!("slot {key} is unreadable")));
213 }
214 self.inner.get(key)
215 }
216
217 fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
218 self.inner.set(key, value)
219 }
220
221 fn delete(&self, key: &str) -> Result<(), SecretsError> {
222 self.inner.delete(key)
223 }
224
225 fn backend_name(&self) -> &'static str {
226 "unreadable-slot (test)"
227 }
228 }
229
230 #[test]
231 fn list_skips_an_unreadable_slot_instead_of_failing_the_enumeration() {
232 let backend = UnreadableSlotStore {
233 inner: InMemoryKeyringStore::new(),
234 unreadable: "deepseek",
235 };
236 backend
237 .set("deepseek", "deepseek-secret")
238 .expect("seed unreadable slot");
239 backend
240 .set("openrouter", "openrouter-secret")
241 .expect("seed readable slot");
242 let store = SecretStoreCredentials::new(
243 Secrets::new(Arc::new(backend)),
244 vec![
245 "deepseek".to_string(),
246 "openrouter".to_string(),
247 "xai".to_string(),
248 ],
249 );
250
251 assert!(
252 store.read("deepseek").is_err(),
253 "the bad slot must still fail when asked for by name"
254 );
255 let listed = store
256 .list()
257 .expect("one unreadable slot must not fail the whole list");
258 assert_eq!(
259 listed
260 .iter()
261 .map(|info| info.provider_id.as_str())
262 .collect::<Vec<_>>(),
263 ["openrouter"],
264 "the readable slot must still appear; the empty slot and the bad slot must not"
265 );
266 }
267
268 #[test]
269 fn delete_removes_the_entry() {
270 let store = InMemoryCredentialStore::new();
271 store
272 .modify("gamma", &mut |_| {
273 Ok(Some(Credential::ApiKey {
274 key: "value".to_string(),
275 }))
276 })
277 .unwrap();
278 store.delete("gamma").unwrap();
279 assert_eq!(store.read("gamma").unwrap(), None);
280 }
281
282 #[test]
283 fn map_auth_context_answers_without_touching_the_process() {
284 let ctx = MapAuthContext::new()
285 .with_env("DEEPSEEK_API_KEY", "value")
286 .with_env("BLANK_KEY", " ");
287 assert_eq!(ctx.env("DEEPSEEK_API_KEY").as_deref(), Some("value"));
288 assert_eq!(
289 ctx.env("BLANK_KEY"),
290 None,
291 "blank exports are not credentials"
292 );
293 assert_eq!(ctx.env("UNSET_KEY"), None);
294 }
295
295 lines RUST