| 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 |