Skip to main content

agent_core/core/auth/
storage.rs

1use std::path::PathBuf;
2
3use super::{AuthFile, OAuthCredentials};
4
5/// Get the path to auth.json (~/.synaps-cli/auth.json).
6pub fn auth_file_path() -> PathBuf {
7    crate::config::resolve_read_path("auth.json")
8}
9
10/// Load credentials from auth.json.
11pub fn load_auth() -> std::result::Result<Option<AuthFile>, String> {
12    let path = auth_file_path();
13    if !path.exists() {
14        return Ok(None);
15    }
16    let content = std::fs::read_to_string(&path)
17        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
18    let auth: AuthFile = serde_json::from_str(&content)
19        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
20    Ok(Some(auth))
21}
22
23/// Load one provider's OAuth credential from auth.json.
24pub fn load_provider_auth(provider: &str) -> std::result::Result<Option<OAuthCredentials>, String> {
25    let path = auth_file_path();
26    if !path.exists() {
27        return Ok(None);
28    }
29    let content = std::fs::read_to_string(&path)
30        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
31    let value: serde_json::Value = serde_json::from_str(&content)
32        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
33    let Some(raw) = value.get(provider) else {
34        return Ok(None);
35    };
36    let creds: OAuthCredentials = serde_json::from_value(raw.clone())
37        .map_err(|e| format!("Failed to parse {} credential: {}", provider, e))?;
38    Ok(Some(creds))
39}
40
41/// Save credentials to auth.json.
42pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
43    save_provider_auth("anthropic", creds)
44}
45
46/// Save one provider credential while preserving other auth.json entries.
47pub fn save_provider_auth(
48    provider: &str,
49    creds: &OAuthCredentials,
50) -> std::result::Result<(), String> {
51    let path = crate::config::resolve_write_path("auth.json");
52    save_provider_auth_at(&path, provider, creds)
53}
54
55/// Load an opaque broker-owned cloud provider state object.
56pub fn load_cloud_state(provider: &str) -> std::result::Result<Option<serde_json::Value>, String> {
57    let path = auth_file_path();
58    if !path.exists() {
59        return Ok(None);
60    }
61    let root: serde_json::Value = serde_json::from_str(
62        &std::fs::read_to_string(&path)
63            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?,
64    )
65    .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
66    Ok(root
67        .get(provider)
68        .and_then(|v| v.get("cloud_state"))
69        .cloned())
70}
71
72/// Atomically persist opaque cloud state while preserving every unrelated field.
73pub fn save_cloud_state(
74    provider: &str,
75    state: &serde_json::Value,
76) -> std::result::Result<(), String> {
77    let path = crate::config::resolve_write_path("auth.json");
78    let mut fields = serde_json::Map::new();
79    fields.insert("type".into(), serde_json::Value::String("cloud".into()));
80    fields.insert("cloud_state".into(), state.clone());
81    save_provider_fields_at(&path, provider, &fields)
82}
83
84/// Load one provider's broker-owned static API key from auth.json.
85///
86/// Static keys are persisted as `{"type":"api_key","key":"…"}` entries in the
87/// same open JSON object as OAuth credentials, so unknown providers and
88/// provider metadata round-trip through the exact same merge writer.
89pub fn load_static_key(provider: &str) -> std::result::Result<Option<String>, String> {
90    load_static_key_at(&auth_file_path(), provider)
91}
92
93pub(crate) fn load_static_key_at(
94    path: &std::path::Path,
95    provider: &str,
96) -> std::result::Result<Option<String>, String> {
97    if !path.exists() {
98        return Ok(None);
99    }
100    let content = std::fs::read_to_string(path)
101        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
102    let value: serde_json::Value = serde_json::from_str(&content)
103        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
104    let Some(entry) = value.get(provider) else {
105        return Ok(None);
106    };
107    if entry.get("type").and_then(|t| t.as_str()) != Some("api_key") {
108        return Ok(None);
109    }
110    Ok(entry
111        .get("key")
112        .and_then(|k| k.as_str())
113        .filter(|k| !k.is_empty())
114        .map(str::to_string))
115}
116
117/// Persist a broker-owned static API key while preserving every other
118/// auth.json entry and any provider metadata.
119pub fn save_static_key(provider: &str, key: &str) -> std::result::Result<(), String> {
120    let path = crate::config::resolve_write_path("auth.json");
121    save_static_key_at(&path, provider, key)
122}
123
124pub(crate) fn save_static_key_at(
125    path: &std::path::Path,
126    provider: &str,
127    key: &str,
128) -> std::result::Result<(), String> {
129    let mut fields = serde_json::Map::new();
130    fields.insert(
131        "type".to_string(),
132        serde_json::Value::String("api_key".to_string()),
133    );
134    fields.insert(
135        "key".to_string(),
136        serde_json::Value::String(key.to_string()),
137    );
138    save_provider_fields_at(path, provider, &fields)
139}
140
141/// Path-explicit variant of `save_provider_auth`. Splits out the I/O so
142/// the corrupt-file fallback path can be unit-tested without touching the
143/// user's real `~/.synaps-cli/auth.json`.
144fn save_provider_auth_at(
145    path: &std::path::Path,
146    provider: &str,
147    creds: &OAuthCredentials,
148) -> std::result::Result<(), String> {
149    let encoded =
150        serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
151    let fields = encoded
152        .as_object()
153        .ok_or_else(|| "credential did not serialize to an object".to_string())?;
154    save_provider_fields_at(path, provider, fields)
155}
156
157/// Shared merge writer: lock, read-merge every provider entry, merge `fields`
158/// into the one provider object (preserving unknown metadata), atomic rename.
159fn save_provider_fields_at(
160    path: &std::path::Path,
161    provider: &str,
162    fields: &serde_json::Map<String, serde_json::Value>,
163) -> std::result::Result<(), String> {
164    use fs4::fs_std::FileExt;
165
166    // Ensure parent directory exists
167    if let Some(parent) = path.parent() {
168        std::fs::create_dir_all(parent)
169            .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
170    }
171
172    // Hold an exclusive lock for the entire read-modify-write cycle.
173    // Without this, two concurrent `synaps login` processes can race:
174    // both read the same file, each adds their provider, second write
175    // silently drops the first's credential.
176    let lock_path = path.with_extension("json.lock");
177    let lock_file = std::fs::OpenOptions::new()
178        .create(true)
179        .truncate(true)
180        .write(true)
181        .open(&lock_path)
182        .map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
183    FileExt::lock_exclusive(&lock_file)
184        .map_err(|e| format!("Failed to lock {}: {}", lock_path.display(), e))?;
185
186    let mut root = if path.exists() {
187        let content = std::fs::read_to_string(path)
188            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
189        // Corrupt-file recovery: if the existing auth.json is not a JSON
190        // object (truncated write, manual edit error, swap-file detritus),
191        // log a warning and start fresh rather than refusing to save the
192        // new credential. The alternative is permanently locking the user
193        // out of `synaps login`.
194        match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content) {
195            Ok(map) => map,
196            Err(e) => {
197                tracing::warn!(
198                    path = %path.display(),
199                    error = %e,
200                    "auth.json could not be parsed as a JSON object; replacing with a fresh structure"
201                );
202                // Back up the corrupt file so credentials are potentially recoverable.
203                let ts = std::time::SystemTime::now()
204                    .duration_since(std::time::UNIX_EPOCH)
205                    .map(|d| d.as_secs())
206                    .unwrap_or(0);
207                let backup = path.with_extension(format!("json.corrupt.{}", ts));
208                match std::fs::copy(path, &backup) {
209                    Ok(_) => {
210                        eprintln!(
211                            "[warn] auth.json was corrupt and has been reset. Backup saved to: {}",
212                            backup.display()
213                        );
214                    }
215                    Err(copy_err) => {
216                        eprintln!(
217                            "[warn] auth.json was corrupt and has been reset, but backup failed: {}",
218                            copy_err
219                        );
220                    }
221                }
222                serde_json::Map::new()
223            }
224        }
225    } else {
226        serde_json::Map::new()
227    };
228
229    // Merge known credential fields into an existing provider object rather
230    // than replacing it. Providers may add metadata (including nested objects)
231    // that must survive refresh/login writes.
232    let mut provider_value = root
233        .remove(provider)
234        .filter(serde_json::Value::is_object)
235        .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
236    if let Some(target) = provider_value.as_object_mut() {
237        for (key, value) in fields {
238            target.insert(key.clone(), value.clone());
239        }
240    }
241    root.insert(provider.to_string(), provider_value);
242
243    let json = serde_json::to_string_pretty(&root)
244        .map_err(|e| format!("Failed to serialize auth: {}", e))?;
245
246    // Atomic write: write to .tmp then rename. rename(2) is atomic on POSIX.
247    // This prevents a crash/kill between truncate and write from zeroing auth.json.
248    // Create with restrictive permissions from the start so credentials are never
249    // world-readable, even briefly.
250    let tmp_path = path.with_extension("json.tmp");
251    {
252        use std::io::Write;
253        let mut file = std::fs::OpenOptions::new()
254            .write(true)
255            .create(true)
256            .truncate(true)
257            .open(&tmp_path)
258            .map_err(|e| format!("Failed to create {}: {}", tmp_path.display(), e))?;
259
260        #[cfg(unix)]
261        {
262            use std::os::unix::fs::PermissionsExt;
263            let perms = std::fs::Permissions::from_mode(0o600);
264            file.set_permissions(perms).map_err(|e| {
265                format!("Failed to set permissions on {}: {}", tmp_path.display(), e)
266            })?;
267        }
268
269        file.write_all(json.as_bytes())
270            .map_err(|e| format!("Failed to write {}: {}", tmp_path.display(), e))?;
271        file.sync_all()
272            .map_err(|e| format!("Failed to fsync {}: {}", tmp_path.display(), e))?;
273    }
274
275    std::fs::rename(&tmp_path, path)
276        .map_err(|e| format!("Failed to atomically replace {}: {}", path.display(), e))?;
277
278    Ok(())
279}
280
281/// Test-only re-export of `save_provider_auth_at` so that `token::tests`
282/// can exercise the same function `ensure_fresh_token` now delegates to,
283/// without making the path-explicit variant part of the public API.
284#[cfg(test)]
285pub(super) fn save_provider_auth_at_test_hook(
286    path: &std::path::Path,
287    provider: &str,
288    creds: &OAuthCredentials,
289) -> std::result::Result<(), String> {
290    save_provider_auth_at(path, provider, creds)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn fresh_creds() -> OAuthCredentials {
298        OAuthCredentials {
299            auth_type: "oauth".to_string(),
300            refresh: "r".to_string(),
301            access: "a".to_string(),
302            expires: 1,
303            account_id: None,
304        }
305    }
306
307    #[test]
308    fn save_preserves_unknown_nested_provider_metadata() {
309        let dir = tempfile::tempdir().expect("tempdir");
310        let path = dir.path().join("auth.json");
311        std::fs::write(&path, r#"{"openai-codex":{"type":"oauth","refresh":"old","access":"old","expires":1,"metadata":{"tenant":"t1","flags":{"beta":true}}}}"#).unwrap();
312        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).unwrap();
313        let value: serde_json::Value =
314            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
315        assert_eq!(value["openai-codex"]["metadata"]["tenant"], "t1");
316        assert_eq!(value["openai-codex"]["metadata"]["flags"]["beta"], true);
317        assert_eq!(value["openai-codex"]["access"], "a");
318    }
319
320    #[test]
321    fn save_provider_auth_at_creates_file_when_absent() {
322        let dir = tempfile::tempdir().expect("tempdir");
323        let path = dir.path().join("auth.json");
324        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
325        assert!(path.exists());
326        let content = std::fs::read_to_string(&path).unwrap();
327        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
328        assert!(parsed.get("openai-codex").is_some());
329    }
330
331    #[test]
332    fn save_provider_auth_at_preserves_other_providers() {
333        let dir = tempfile::tempdir().expect("tempdir");
334        let path = dir.path().join("auth.json");
335        std::fs::write(
336            &path,
337            r#"{"anthropic":{"type":"oauth","refresh":"r2","access":"a2","expires":2}}"#,
338        )
339        .unwrap();
340        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
341        let content = std::fs::read_to_string(&path).unwrap();
342        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
343        assert!(
344            parsed.get("anthropic").is_some(),
345            "must keep anthropic entry"
346        );
347        assert!(parsed.get("openai-codex").is_some());
348    }
349
350    #[test]
351    fn save_provider_auth_at_recovers_from_corrupt_file() {
352        // Pre-fix: a corrupt auth.json would lock the user out of
353        // `synaps login` entirely because save_provider_auth would fail
354        // to parse and bail. After fix: corrupt content is replaced with
355        // a fresh structure containing the new credential.
356        let dir = tempfile::tempdir().expect("tempdir");
357        let path = dir.path().join("auth.json");
358        std::fs::write(&path, "this is not json {{{").unwrap();
359        save_provider_auth_at(&path, "openai-codex", &fresh_creds())
360            .expect("save must succeed even on corrupt input");
361        let content = std::fs::read_to_string(&path).unwrap();
362        let parsed: serde_json::Value =
363            serde_json::from_str(&content).expect("file must now contain valid JSON");
364        assert!(parsed.get("openai-codex").is_some());
365        assert!(
366            parsed.get("anthropic").is_none(),
367            "corrupt fallback discards old (unrecoverable) entries"
368        );
369    }
370
371    #[test]
372    fn save_provider_auth_at_recovers_from_array_root() {
373        // auth.json was a JSON array (perhaps from a botched migration).
374        // Treat it as corrupt — same recovery as garbage input.
375        let dir = tempfile::tempdir().expect("tempdir");
376        let path = dir.path().join("auth.json");
377        std::fs::write(&path, "[1, 2, 3]").unwrap();
378        save_provider_auth_at(&path, "openai-codex", &fresh_creds())
379            .expect("save must succeed against non-object root");
380        let parsed: serde_json::Value =
381            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
382        assert!(parsed.is_object());
383        assert!(parsed.get("openai-codex").is_some());
384    }
385
386    // ── Broker-owned static-key storage ──────────────────────────────────────
387
388    /// Saving a static key must preserve existing OAuth entries (cross-provider
389    /// isolation at the storage layer), and vice versa.
390    #[test]
391    fn static_key_save_preserves_oauth_entries_and_roundtrips() {
392        let dir = tempfile::tempdir().expect("tempdir");
393        let path = dir.path().join("auth.json");
394        save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
395        save_static_key_at(&path, "groq", "gsk-secret-1").expect("save static key");
396
397        assert_eq!(
398            load_static_key_at(&path, "groq").unwrap().as_deref(),
399            Some("gsk-secret-1")
400        );
401        let parsed: serde_json::Value =
402            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
403        assert_eq!(
404            parsed["anthropic"]["refresh"], "r",
405            "OAuth entry must survive"
406        );
407        assert_eq!(parsed["groq"]["type"], "api_key");
408    }
409
410    /// An OAuth save must not disturb a broker-owned static key.
411    #[test]
412    fn oauth_save_preserves_static_key_entries() {
413        let dir = tempfile::tempdir().expect("tempdir");
414        let path = dir.path().join("auth.json");
415        save_static_key_at(&path, "openrouter", "sk-or-1").expect("save static key");
416        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save oauth");
417        assert_eq!(
418            load_static_key_at(&path, "openrouter").unwrap().as_deref(),
419            Some("sk-or-1")
420        );
421    }
422
423    /// Static-key updates merge in place and keep unknown metadata on the entry.
424    #[test]
425    fn static_key_upsert_preserves_entry_metadata() {
426        let dir = tempfile::tempdir().expect("tempdir");
427        let path = dir.path().join("auth.json");
428        std::fs::write(
429            &path,
430            r#"{"groq":{"type":"api_key","key":"old","note":{"source":"migrated"}}}"#,
431        )
432        .unwrap();
433        save_static_key_at(&path, "groq", "new-key").expect("upsert");
434        let parsed: serde_json::Value =
435            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
436        assert_eq!(parsed["groq"]["key"], "new-key");
437        assert_eq!(parsed["groq"]["note"]["source"], "migrated");
438    }
439
440    /// Loading a static key from an OAuth entry must return None — the two
441    /// credential kinds never blur (cross-kind isolation).
442    #[test]
443    fn load_static_key_ignores_oauth_entries() {
444        let dir = tempfile::tempdir().expect("tempdir");
445        let path = dir.path().join("auth.json");
446        save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
447        assert_eq!(load_static_key_at(&path, "anthropic").unwrap(), None);
448        assert_eq!(load_static_key_at(&path, "missing").unwrap(), None);
449    }
450
451    // ── Bug #159 regression suite ────────────────────────────────────────────
452    //
453    // Repro: `synaps login --provider openai-codex` (saves "openai-codex"),
454    // then `synaps login` (Anthropic OAuth, saves "anthropic") → the first
455    // provider's credential was wiped because save_auth() rebuilt AuthFile
456    // with only the new entry and did a whole-file overwrite.
457    //
458    // Root cause (pre-fix): src/core/auth/storage.rs @ 90f8f71 — save_auth()
459    // constructed `AuthFile { anthropic: creds }` (no read of existing file)
460    // then called `fs::write(path, json)`, discarding every other provider.
461    //
462    // All three multi-provider tests below would FAIL against that code.
463
464    /// Primary repro (bug #159): save openai-codex, then save anthropic via
465    /// the same write path `synaps login` (Anthropic OAuth) takes.
466    /// openai-codex MUST survive the second login.
467    #[test]
468    fn bug159_anthropic_login_preserves_existing_openai_codex() {
469        let dir = tempfile::tempdir().expect("tempdir");
470        let path = dir.path().join("auth.json");
471
472        // Step 1: user runs `synaps login --provider openai-codex`
473        let codex_creds = OAuthCredentials {
474            auth_type: "oauth".to_string(),
475            refresh: "codex-refresh".to_string(),
476            access: "codex-access".to_string(),
477            expires: 9_999_999_999_000,
478            account_id: Some("acct_codex_123".to_string()),
479        };
480        save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
481
482        // Step 2: user then runs `synaps login` (Anthropic Claude OAuth)
483        let anthropic_creds = OAuthCredentials {
484            auth_type: "oauth".to_string(),
485            refresh: "anth-refresh".to_string(),
486            access: "anth-access".to_string(),
487            expires: 9_999_999_999_000,
488            account_id: None,
489        };
490        save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
491
492        // Both providers must be present — bug #159 would wipe openai-codex here
493        let content = std::fs::read_to_string(&path).unwrap();
494        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
495
496        assert!(
497            parsed.get("anthropic").is_some(),
498            "anthropic credential must be present after login"
499        );
500        assert!(
501            parsed.get("openai-codex").is_some(),
502            "bug #159: openai-codex credential must NOT be wiped when logging in as anthropic"
503        );
504        assert_eq!(
505            parsed["openai-codex"]["refresh"].as_str(),
506            Some("codex-refresh"),
507            "openai-codex refresh token must be unchanged"
508        );
509        assert_eq!(
510            parsed["anthropic"]["refresh"].as_str(),
511            Some("anth-refresh"),
512            "anthropic refresh token must be correctly saved"
513        );
514    }
515
516    /// Reverse of primary repro: anthropic first, then openai-codex login.
517    /// anthropic MUST survive.
518    #[test]
519    fn bug159_openai_codex_login_preserves_existing_anthropic() {
520        let dir = tempfile::tempdir().expect("tempdir");
521        let path = dir.path().join("auth.json");
522
523        // Step 1: anthropic already logged in
524        let anthropic_creds = OAuthCredentials {
525            auth_type: "oauth".to_string(),
526            refresh: "anth-refresh-first".to_string(),
527            access: "anth-access-first".to_string(),
528            expires: 9_999_999_999_000,
529            account_id: None,
530        };
531        save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
532
533        // Step 2: user runs `synaps login --provider openai-codex`
534        let codex_creds = OAuthCredentials {
535            auth_type: "oauth".to_string(),
536            refresh: "codex-refresh-second".to_string(),
537            access: "codex-access-second".to_string(),
538            expires: 9_999_999_999_000,
539            account_id: Some("acct_456".to_string()),
540        };
541        save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
542
543        let content = std::fs::read_to_string(&path).unwrap();
544        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
545
546        assert!(
547            parsed.get("anthropic").is_some(),
548            "anthropic credential must survive openai-codex login"
549        );
550        assert!(
551            parsed.get("openai-codex").is_some(),
552            "openai-codex credential must be present"
553        );
554        assert_eq!(
555            parsed["anthropic"]["refresh"].as_str(),
556            Some("anth-refresh-first"),
557            "anthropic refresh token must be unchanged"
558        );
559    }
560
561    /// Three-provider scenario: saving a third provider preserves the first two.
562    /// Guards against regressions if/when more OAuth providers are added.
563    #[test]
564    fn bug159_third_provider_login_preserves_both_existing_providers() {
565        let dir = tempfile::tempdir().expect("tempdir");
566        let path = dir.path().join("auth.json");
567
568        save_provider_auth_at(
569            &path,
570            "anthropic",
571            &OAuthCredentials {
572                auth_type: "oauth".to_string(),
573                refresh: "anth-r".to_string(),
574                access: "anth-a".to_string(),
575                expires: 1,
576                account_id: None,
577            },
578        )
579        .expect("save anthropic");
580
581        save_provider_auth_at(
582            &path,
583            "openai-codex",
584            &OAuthCredentials {
585                auth_type: "oauth".to_string(),
586                refresh: "codex-r".to_string(),
587                access: "codex-a".to_string(),
588                expires: 1,
589                account_id: Some("acct_codex".to_string()),
590            },
591        )
592        .expect("save openai-codex");
593
594        save_provider_auth_at(
595            &path,
596            "future-provider",
597            &OAuthCredentials {
598                auth_type: "oauth".to_string(),
599                refresh: "future-r".to_string(),
600                access: "future-a".to_string(),
601                expires: 1,
602                account_id: None,
603            },
604        )
605        .expect("save future-provider");
606
607        let content = std::fs::read_to_string(&path).unwrap();
608        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
609
610        assert!(parsed.get("anthropic").is_some(), "anthropic must survive");
611        assert!(
612            parsed.get("openai-codex").is_some(),
613            "openai-codex must survive"
614        );
615        assert!(
616            parsed.get("future-provider").is_some(),
617            "future-provider must be present"
618        );
619    }
620
621    /// Upsert: saving the same provider twice updates the credential in place
622    /// without duplicating or corrupting the entry.
623    #[test]
624    fn bug159_same_provider_login_upserts_in_place() {
625        let dir = tempfile::tempdir().expect("tempdir");
626        let path = dir.path().join("auth.json");
627
628        save_provider_auth_at(
629            &path,
630            "anthropic",
631            &OAuthCredentials {
632                auth_type: "oauth".to_string(),
633                refresh: "old-refresh".to_string(),
634                access: "old-access".to_string(),
635                expires: 1,
636                account_id: None,
637            },
638        )
639        .expect("initial save");
640
641        save_provider_auth_at(
642            &path,
643            "anthropic",
644            &OAuthCredentials {
645                auth_type: "oauth".to_string(),
646                refresh: "new-refresh".to_string(),
647                access: "new-access".to_string(),
648                expires: 2,
649                account_id: None,
650            },
651        )
652        .expect("upsert save");
653
654        let content = std::fs::read_to_string(&path).unwrap();
655        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
656        let obj = parsed.as_object().unwrap();
657
658        assert_eq!(obj.len(), 1, "only one anthropic entry — no duplicates");
659        assert_eq!(
660            parsed["anthropic"]["refresh"].as_str(),
661            Some("new-refresh"),
662            "credential must be updated to latest value"
663        );
664    }
665}