Skip to main content

leviath_cli/commands/
auth.rs

1//! `lev auth` - inspect and move the secrets Leviath holds.
2//!
3//! Leviath keeps two kinds of long-lived secret: provider API keys and MCP OAuth
4//! grants. `[security] credential_store` decides whether they live in Leviath's
5//! own `0600` files or in the OS credential store; this command reports which,
6//! checks that the OS store is actually reachable, and moves secrets between the
7//! two.
8
9use crate::config::Config;
10use clap::{Args, Subcommand};
11use leviath_core::{CredentialStore, CredentialStoreKind};
12
13#[derive(Debug, Args)]
14pub struct AuthArgs {
15    #[command(subcommand)]
16    command: AuthCommand,
17}
18
19#[derive(Debug, Subcommand)]
20enum AuthCommand {
21    /// Show which credential backend is in use and what it holds
22    Status,
23
24    /// Move stored secrets into the OS credential store
25    ///
26    /// Reads the keys currently in `~/.leviath/config.toml`, writes them to the
27    /// OS store, and rewrites the config without them.
28    Migrate {
29        /// Move secrets back out of the OS store into `~/.leviath/config.toml`
30        #[arg(long)]
31        to_file: bool,
32
33        /// Show what would move without changing anything
34        #[arg(long)]
35        dry_run: bool,
36    },
37}
38
39impl AuthArgs {
40    /// A `status` invocation, for routing tests in `dispatch`.
41    #[cfg(test)]
42    pub(crate) fn status_for_test() -> Self {
43        Self {
44            command: AuthCommand::Status,
45        }
46    }
47
48    /// A `migrate` invocation, for driving the command end to end.
49    #[cfg(test)]
50    pub(crate) fn migrate_for_test(to_file: bool, dry_run: bool) -> Self {
51        Self {
52            command: AuthCommand::Migrate { to_file, dry_run },
53        }
54    }
55}
56
57/// Run `lev auth`.
58pub async fn execute(args: AuthArgs) -> anyhow::Result<()> {
59    let path = Config::config_path();
60    match args.command {
61        AuthCommand::Status => {
62            let config = Config::load_from_path_public(&path)?;
63            print!("{}", render_status(&status(&config, &path)));
64            Ok(())
65        }
66        AuthCommand::Migrate { to_file, dry_run } => migrate(&path, to_file, dry_run),
67    }
68}
69
70/// What `lev auth status` found, separated from how it is printed so the report
71/// itself is testable.
72#[derive(Debug, PartialEq)]
73pub(crate) struct Status {
74    /// The configured backend.
75    pub kind: CredentialStoreKind,
76    /// Whether this build was compiled with OS credential store support.
77    pub supported: bool,
78    /// `None` if the store is reachable, `Some(reason)` if it is not. Always
79    /// `None` for the file backend, which needs no store.
80    pub unavailable: Option<String>,
81    /// Providers whose key is set, from any source.
82    pub providers: Vec<String>,
83    /// MCP servers with a stored OAuth grant.
84    pub mcp_servers: Vec<String>,
85    /// Providers whose key is present in *both* the config file and the OS
86    /// store. A duplicate is not an error, but it is worth saying: the file
87    /// copy wins, so rotating the keychain entry would appear to do nothing.
88    pub duplicated: Vec<String>,
89    /// The config file path, for the report.
90    pub config_path: String,
91}
92
93/// Inspect the current credential situation.
94pub(crate) fn status(config: &Config, path: &std::path::Path) -> Status {
95    let resolved = crate::credentials::store_for(config.security.credential_store);
96    status_with(config, path, resolved)
97}
98
99/// Core of [`status`] with the backend already resolved.
100///
101/// The resolution is the caller's because "this machine has no credential
102/// store" cannot be produced in a test by *not installing* one: the real probe
103/// would install the platform store and read the developer's actual login
104/// keychain. Passing the outcome in is what makes the unavailable case testable.
105pub(crate) fn status_with(
106    config: &Config,
107    path: &std::path::Path,
108    resolved: crate::credentials::Resolved,
109) -> Status {
110    let kind = config.security.credential_store;
111    let supported = leviath_sys::keychain::is_supported();
112
113    let providers: Vec<String> = config
114        .provider_secrets()
115        .into_iter()
116        .map(|(account, _)| account)
117        .collect();
118
119    // Read the file directly rather than through `Config::load`: the loader
120    // already folded the keychain in, so it cannot tell the two sources apart.
121    let on_disk = providers_in_file(path);
122    let (unavailable, in_store) = match resolved {
123        Ok(Some(store)) => {
124            let accounts: Vec<String> = crate::credentials::PROVIDER_KEYS
125                .iter()
126                .map(|p| leviath_core::provider_account(p))
127                .collect();
128            (None, store.read_all(&accounts).into_keys().collect())
129        }
130        Ok(None) => (None, Vec::new()),
131        Err(e) => (Some(e), Vec::new()),
132    };
133
134    let duplicated = on_disk
135        .iter()
136        .filter(|a| in_store.contains(a))
137        .cloned()
138        .collect();
139
140    // MCP grants live in their own store, keyed by server name. A load failure
141    // is reported as "none" rather than propagated: `lev auth status` is the
142    // command a user runs *because* something is wrong, so it has to answer.
143    let mcp_servers = mcp_server_names(leviath_mcp::AuthStore::default_path().as_deref(), None);
144
145    Status {
146        kind,
147        supported,
148        unavailable,
149        providers,
150        mcp_servers,
151        duplicated,
152        config_path: path.display().to_string(),
153    }
154}
155
156/// The provider accounts that have a key written in the config *file*.
157///
158/// Parsed straight out of the TOML because `Config::load` merges the
159/// environment and the credential store in, which is exactly the distinction
160/// this needs to make.
161fn providers_in_file(path: &std::path::Path) -> Vec<String> {
162    let Ok(text) = std::fs::read_to_string(path) else {
163        return Vec::new();
164    };
165    let Ok(value) = text.parse::<toml::Table>() else {
166        return Vec::new();
167    };
168    crate::credentials::PROVIDER_KEYS
169        .iter()
170        .filter(|p| file_has_key(&value, p))
171        .map(|p| leviath_core::provider_account(p))
172        .collect()
173}
174
175/// Whether the parsed config file carries a key for `provider`.
176///
177/// `openrouter_api_key` sits at the top level while the other three live under
178/// `[providers]` - a historical split the config struct still reflects.
179fn file_has_key(value: &toml::Table, provider: &str) -> bool {
180    let field = format!("{provider}_api_key");
181    if provider == "openrouter" {
182        return value.get(&field).and_then(|v| v.as_str()).is_some();
183    }
184    value
185        .get("providers")
186        .and_then(|p| p.get(&field))
187        .and_then(|v| v.as_str())
188        .is_some()
189}
190
191/// Render a [`Status`] for the terminal.
192pub(crate) fn render_status(s: &Status) -> String {
193    let mut out = String::new();
194    let backend = match s.kind {
195        CredentialStoreKind::File => "file (Leviath's own 0600 files)",
196        CredentialStoreKind::Keychain => "keychain (OS credential store)",
197    };
198    out.push_str(&format!("Credential store: {backend}\n"));
199    out.push_str(&format!("Config file:      {}\n", s.config_path));
200
201    if !s.supported {
202        out.push_str(
203            "\nThis build has no OS credential store support (the `keychain` feature is off).\n",
204        );
205    }
206    if let Some(reason) = &s.unavailable {
207        out.push_str(&format!("\n! {reason}\n"));
208    }
209
210    out.push('\n');
211    if s.providers.is_empty() {
212        out.push_str("No provider API keys are configured. Run `lev setup` to add one.\n");
213    } else {
214        out.push_str("Provider keys configured:\n");
215        for p in &s.providers {
216            out.push_str(&format!("  - {p}\n"));
217        }
218    }
219
220    if !s.mcp_servers.is_empty() {
221        out.push_str("\nMCP servers logged in:\n");
222        for m in &s.mcp_servers {
223            out.push_str(&format!("  - {m}\n"));
224        }
225    }
226
227    if !s.duplicated.is_empty() {
228        out.push_str(
229            "\n! These are stored in BOTH the config file and the OS keychain. The file copy\n  \
230             wins, so changing the keychain entry will appear to have no effect. Run\n  \
231             `lev auth migrate` to remove the file copies.\n",
232        );
233        for p in &s.duplicated {
234            out.push_str(&format!("  - {p}\n"));
235        }
236    }
237
238    if s.kind == CredentialStoreKind::File && s.supported {
239        out.push_str(
240            "\nTo move these into the OS keychain, set `[security] credential_store = \"keychain\"`\n\
241             in the config file and run `lev auth migrate`.\n",
242        );
243    }
244    out
245}
246
247/// Move secrets between the config file and the OS credential store.
248fn migrate(path: &std::path::Path, to_file: bool, dry_run: bool) -> anyhow::Result<()> {
249    let config = Config::load_from_path_public(path)?;
250    let plan = plan_migration(&config, to_file);
251
252    if plan.moving.is_empty() {
253        println!("{}", plan.summary);
254        return Ok(());
255    }
256
257    println!("{}", plan.summary);
258    for account in &plan.moving {
259        println!("  - {account}");
260    }
261    if dry_run {
262        println!("\nDry run: nothing was changed.");
263        return Ok(());
264    }
265
266    apply_migration(&config, path, to_file)?;
267    println!("\nDone. {}", plan.done);
268    Ok(())
269}
270
271/// What a migration would do, computed without changing anything.
272#[derive(Debug, PartialEq)]
273pub(crate) struct MigrationPlan {
274    pub moving: Vec<String>,
275    pub summary: String,
276    pub done: String,
277}
278
279pub(crate) fn plan_migration(config: &Config, to_file: bool) -> MigrationPlan {
280    let moving: Vec<String> = config
281        .provider_secrets()
282        .into_iter()
283        .map(|(account, _)| account)
284        .collect();
285
286    if moving.is_empty() {
287        return MigrationPlan {
288            moving,
289            summary: "No provider API keys are configured; there is nothing to move.".to_string(),
290            done: String::new(),
291        };
292    }
293
294    let (summary, done) = if to_file {
295        (
296            "Moving these secrets out of the OS keychain and into the config file:",
297            "The config file now holds these keys (mode 0600). Set `[security] \
298             credential_store = \"file\"` if you have not already.",
299        )
300    } else {
301        (
302            "Moving these secrets into the OS keychain:",
303            "The config file no longer contains these keys. Set `[security] \
304             credential_store = \"keychain\"` if you have not already.",
305        )
306    };
307    MigrationPlan {
308        moving,
309        summary: summary.to_string(),
310        done: done.to_string(),
311    }
312}
313
314/// Perform the move.
315///
316/// The order matters in both directions: write the destination first, verify it
317/// took, and only then remove the source. A migration that cleared the config
318/// file before the keychain write succeeded would destroy the user's API keys.
319fn apply_migration(config: &Config, path: &std::path::Path, to_file: bool) -> anyhow::Result<()> {
320    let resolved = crate::credentials::store_for(CredentialStoreKind::Keychain);
321    apply_migration_with(
322        config,
323        path,
324        to_file,
325        resolved,
326        leviath_mcp::AuthStore::default_path().as_deref(),
327    )
328}
329
330/// Core of [`apply_migration`] with the keychain already resolved - see
331/// [`status_with`] for why the resolution is the caller's.
332fn apply_migration_with(
333    config: &Config,
334    path: &std::path::Path,
335    to_file: bool,
336    resolved: crate::credentials::Resolved,
337    mcp_path: Option<&std::path::Path>,
338) -> anyhow::Result<()> {
339    let secrets = config.provider_secrets();
340
341    if to_file {
342        // The keys are already in `config` (the loader folded them in), so
343        // saving with the file backend writes them out. Clear the keychain only
344        // after that write has succeeded.
345        let mut file_config = config.clone();
346        file_config.security.credential_store = CredentialStoreKind::File;
347        file_config.save_to_path_public(path)?;
348
349        if let Ok(Some(store)) = resolved {
350            for (account, _) in &secrets {
351                // A failure to clean up is not a failure to migrate: the keys
352                // are safely in the file, and a leftover keychain entry is
353                // reported by `lev auth status` as a duplicate.
354                if let Err(e) = store.delete(account) {
355                    tracing::warn!("could not remove {account} from the keychain: {e}");
356                }
357            }
358            // The MCP grants move the same direction: out of the keychain and
359            // back into their own file.
360            let names = mcp_server_names(mcp_path, Some(store.as_ref()));
361            migrate_mcp_grants(mcp_path, Some(store.as_ref()), None)?;
362            for name in names {
363                if let Err(e) = store.delete(&leviath_core::mcp_account(&name)) {
364                    tracing::warn!("could not remove the grant for '{name}': {e}");
365                }
366            }
367        }
368        return Ok(());
369    }
370
371    let store = resolved
372        .map_err(|e| anyhow::anyhow!("{e}"))?
373        .ok_or_else(|| anyhow::anyhow!("no OS credential store is available"))?;
374
375    for (account, secret) in &secrets {
376        store
377            .set(account, secret)
378            .map_err(|e| anyhow::anyhow!("failed to store {account}: {e}"))?;
379        // Read it back before trusting it. A store that accepts a write and
380        // returns nothing would otherwise lose the key when the file copy is
381        // removed below.
382        match store.get(account) {
383            Ok(Some(v)) if &v == secret => {}
384            _ => anyhow::bail!(
385                "{account} did not read back correctly from the credential store; \
386                 the config file has been left unchanged"
387            ),
388        }
389    }
390
391    // Only now is it safe to drop the file copies.
392    let mut stripped = config.clone();
393    stripped.security.credential_store = CredentialStoreKind::Keychain;
394    stripped.save_to_path_public(path)?;
395
396    migrate_mcp_grants(mcp_path, None, Some(store.as_ref()))
397}
398
399/// Rewrite the MCP auth store at `path`, moving its grants from `source` to
400/// `destination`.
401///
402/// `None` on either side means the file itself. The grants are read through
403/// whichever backend holds them today and written to the other, so this is the
404/// same operation in both directions.
405///
406/// A missing path or a store that was never created is not an error: a user who
407/// has never run `lev mcp login` has nothing to move.
408fn migrate_mcp_grants(
409    path: Option<&std::path::Path>,
410    source: Option<&dyn CredentialStore>,
411    destination: Option<&dyn CredentialStore>,
412) -> anyhow::Result<()> {
413    let Some(path) = path else {
414        return Ok(());
415    };
416    if !path.exists() {
417        return Ok(());
418    }
419    let store = leviath_mcp::AuthStore::load_with(path, source)?;
420    store.save_with(path, destination)
421}
422
423/// The MCP servers with a stored grant, read through `store`.
424fn mcp_server_names(
425    path: Option<&std::path::Path>,
426    store: Option<&dyn CredentialStore>,
427) -> Vec<String> {
428    path.and_then(|p| leviath_mcp::AuthStore::load_with(p, store).ok())
429        .map(|s| {
430            let mut names: Vec<String> = s
431                .server_names()
432                .into_iter()
433                .map(str::to_string)
434                .chain(s.keychain_server_names().iter().cloned())
435                .collect();
436            names.sort();
437            names.dedup();
438            names
439        })
440        .unwrap_or_default()
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    use crate::credentials::test_store;
448
449    fn with_mock_store() -> std::sync::MutexGuard<'static, ()> {
450        test_store::with_mock()
451    }
452
453    /// The keychain backend, resolved against whatever store is installed.
454    fn keychain() -> crate::credentials::Resolved {
455        crate::credentials::store_for(CredentialStoreKind::Keychain)
456    }
457
458    /// A store whose three operations answer however a test needs.
459    ///
460    /// One configurable stub rather than a bespoke struct per test: a struct
461    /// with a `delete` no test ever calls is an uncovered method, and the point
462    /// here is the *combination* of answers, not the type.
463    struct Stub {
464        get: fn(&str) -> Result<Option<String>, String>,
465        set: fn(&str, &str) -> Result<(), String>,
466        delete: fn(&str) -> Result<bool, String>,
467    }
468
469    impl CredentialStore for Stub {
470        fn get(&self, account: &str) -> Result<Option<String>, String> {
471            (self.get)(account)
472        }
473        fn set(&self, account: &str, secret: &str) -> Result<(), String> {
474            (self.set)(account, secret)
475        }
476        fn delete(&self, account: &str) -> Result<bool, String> {
477            (self.delete)(account)
478        }
479    }
480
481    fn absent(_: &str) -> Result<Option<String>, String> {
482        Ok(None)
483    }
484    fn accepts_write(_: &str, _: &str) -> Result<(), String> {
485        Ok(())
486    }
487    fn refuses_write(_: &str, _: &str) -> Result<(), String> {
488        Err("read-only keychain".to_string())
489    }
490    fn refuses_delete(_: &str) -> Result<bool, String> {
491        Err("cannot delete".to_string())
492    }
493
494    /// Make `path` unwritable, and undo it.
495    ///
496    /// `set_readonly` rather than a `0400` chmod: it clears the write bits on
497    /// Unix *and* sets the read-only attribute on Windows, so the "the rewrite
498    /// failed" tests run on every platform. Gated to Unix they left the `?` arms
499    /// they cover unexercised on Windows, which the gate then failed on.
500    fn set_readonly(path: &std::path::Path, readonly: bool) {
501        let mut perms = std::fs::metadata(path).unwrap().permissions();
502        perms.set_readonly(readonly);
503        std::fs::set_permissions(path, perms).unwrap();
504    }
505
506    /// The keychain backend on a machine that has none.
507    fn no_keychain() -> crate::credentials::Resolved {
508        Err(
509            "`[security] credential_store = \"keychain\"` is set, but OS \
510             credential store unavailable: no default store"
511                .to_string(),
512        )
513    }
514
515    fn config_with_keys(kind: CredentialStoreKind) -> Config {
516        let mut c = Config::default();
517        c.security.credential_store = kind;
518        c.providers.anthropic_api_key = Some("sk-ant-secret".into());
519        c.openrouter_api_key = Some("sk-or-secret".into());
520        c
521    }
522
523    /// The end-to-end move: keys start in the file, end in the keychain, and
524    /// the file no longer contains them.
525    #[test]
526    fn migrating_to_the_keychain_moves_the_secrets_out_of_the_file() {
527        let _guard = with_mock_store();
528        let dir = tempfile::tempdir().unwrap();
529        let path = dir.path().join("config.toml");
530
531        let config = config_with_keys(CredentialStoreKind::File);
532        config.save_to_path_public(&path).unwrap();
533        let before = std::fs::read_to_string(&path).unwrap();
534        assert!(before.contains("sk-ant-secret"), "the file starts with it");
535
536        apply_migration_with(&config, &path, false, keychain(), None).unwrap();
537
538        let after = std::fs::read_to_string(&path).unwrap();
539        assert!(
540            !after.contains("sk-ant-secret") && !after.contains("sk-or-secret"),
541            "no secret may remain in the file: {after}"
542        );
543
544        let store = crate::credentials::store_for(CredentialStoreKind::Keychain)
545            .unwrap()
546            .unwrap();
547        assert_eq!(
548            store
549                .get(&leviath_core::provider_account("anthropic"))
550                .unwrap()
551                .as_deref(),
552            Some("sk-ant-secret")
553        );
554        assert_eq!(
555            store
556                .get(&leviath_core::provider_account("openrouter"))
557                .unwrap()
558                .as_deref(),
559            Some("sk-or-secret")
560        );
561    }
562
563    /// And back again, so the keychain is not a one-way door.
564    #[test]
565    fn migrating_to_the_file_restores_the_secrets_and_clears_the_keychain() {
566        let _guard = with_mock_store();
567        let dir = tempfile::tempdir().unwrap();
568        let path = dir.path().join("config.toml");
569
570        let config = config_with_keys(CredentialStoreKind::Keychain);
571        apply_migration_with(&config, &path, false, keychain(), None).unwrap();
572        apply_migration_with(&config, &path, true, keychain(), None).unwrap();
573
574        let after = std::fs::read_to_string(&path).unwrap();
575        assert!(after.contains("sk-ant-secret"), "back in the file: {after}");
576
577        let store = crate::credentials::store_for(CredentialStoreKind::Keychain)
578            .unwrap()
579            .unwrap();
580        assert_eq!(
581            store
582                .get(&leviath_core::provider_account("anthropic"))
583                .unwrap(),
584            None,
585            "and gone from the keychain"
586        );
587    }
588
589    /// The safety property that matters most: if the credential store cannot be
590    /// written, the config file must be left alone. Losing the user's API keys
591    /// to a half-finished migration is the worst outcome available here.
592    #[test]
593    fn a_failing_store_leaves_the_config_file_untouched() {
594        let dir = tempfile::tempdir().unwrap();
595        let path = dir.path().join("config.toml");
596        let config = config_with_keys(CredentialStoreKind::File);
597        config.save_to_path_public(&path).unwrap();
598        let before = std::fs::read_to_string(&path).unwrap();
599
600        assert!(
601            apply_migration_with(&config, &path, false, no_keychain(), None).is_err(),
602            "no store means no migration"
603        );
604        assert_eq!(
605            std::fs::read_to_string(&path).unwrap(),
606            before,
607            "the file must be byte-identical after a failed migration"
608        );
609    }
610
611    /// A store that silently drops writes must be caught by the read-back,
612    /// before the file copies are removed. Without it, `set` succeeding would be
613    /// taken as proof and the only copy of the key would be deleted.
614    #[test]
615    fn a_store_that_does_not_persist_aborts_before_the_file_is_stripped() {
616        let dir = tempfile::tempdir().unwrap();
617        let path = dir.path().join("config.toml");
618        let config = config_with_keys(CredentialStoreKind::File);
619        config.save_to_path_public(&path).unwrap();
620        let before = std::fs::read_to_string(&path).unwrap();
621
622        // Accepts the write, then reports nothing back.
623        let amnesiac = Stub {
624            get: absent,
625            set: accepts_write,
626            delete: refuses_delete,
627        };
628        let err = apply_migration_with(&config, &path, false, Ok(Some(Box::new(amnesiac))), None)
629            .expect_err("a store that does not persist must not be trusted");
630        assert!(err.to_string().contains("did not read back"), "{err}");
631        assert_eq!(
632            std::fs::read_to_string(&path).unwrap(),
633            before,
634            "and the file is untouched"
635        );
636    }
637
638    /// A store that refuses the write at all is caught the same way.
639    #[test]
640    fn a_store_that_refuses_the_write_aborts_the_migration() {
641        let dir = tempfile::tempdir().unwrap();
642        let path = dir.path().join("config.toml");
643        let config = config_with_keys(CredentialStoreKind::File);
644
645        let refuses = Stub {
646            get: absent,
647            set: refuses_write,
648            delete: refuses_delete,
649        };
650        let err = apply_migration_with(&config, &path, false, Ok(Some(Box::new(refuses))), None)
651            .expect_err("a refused write is not a migration");
652        assert!(err.to_string().contains("failed to store"), "{err}");
653    }
654
655    /// Migrating *to* the file is not blocked by a keychain that cannot be
656    /// cleaned up: the keys are already safely written, and a leftover entry is
657    /// reported by `lev auth status` as a duplicate rather than lost data.
658    #[test]
659    fn cleanup_failures_do_not_fail_a_migration_to_the_file() {
660        let dir = tempfile::tempdir().unwrap();
661        let path = dir.path().join("config.toml");
662        let config = config_with_keys(CredentialStoreKind::Keychain);
663
664        let undeletable = Stub {
665            get: absent,
666            set: accepts_write,
667            delete: refuses_delete,
668        };
669        // A real MCP store too, so the grant cleanup runs and its failure is
670        // shown to be non-fatal as well.
671        let mcp = dir.path().join("mcp-auth.json");
672        write_mcp_store(&mcp, "github");
673
674        apply_migration_with(
675            &config,
676            &path,
677            true,
678            Ok(Some(Box::new(undeletable))),
679            Some(&mcp),
680        )
681        .expect("the keys are in the file; cleanup is best effort");
682        let after = std::fs::read_to_string(&path).unwrap();
683        assert!(after.contains("sk-ant-secret"), "{after}");
684    }
685
686    /// The ordinary to-file path: both the provider keys and the MCP grants come
687    /// back, and the keychain entries are cleaned up without incident.
688    #[test]
689    fn migrating_to_the_file_also_brings_back_the_mcp_grants() {
690        use leviath_core::CredentialStore as _;
691
692        let dir = tempfile::tempdir().unwrap();
693        let path = dir.path().join("config.toml");
694        let mcp = dir.path().join("mcp-auth.json");
695        let config = config_with_keys(CredentialStoreKind::Keychain);
696
697        // Put a grant in the store, and leave the file holding only the index.
698        let store = leviath_core::MemoryStore::new();
699        write_mcp_store(&mcp, "github");
700        migrate_mcp_grants(Some(&mcp), None, Some(&store)).unwrap();
701        assert!(!std::fs::read_to_string(&mcp).unwrap().contains("rt-SECRET"));
702        store
703            .set(
704                &leviath_core::provider_account("anthropic"),
705                "sk-ant-secret",
706            )
707            .unwrap();
708
709        apply_migration_with(&config, &path, true, Ok(Some(Box::new(store))), Some(&mcp)).unwrap();
710
711        assert!(
712            std::fs::read_to_string(&mcp).unwrap().contains("rt-SECRET"),
713            "the grant is back in its own file"
714        );
715    }
716
717    /// A corrupt MCP store must fail the migration rather than be reported as a
718    /// completed move that silently dropped every login.
719    #[test]
720    fn a_corrupt_mcp_store_fails_a_migration_to_the_file() {
721        let dir = tempfile::tempdir().unwrap();
722        let path = dir.path().join("config.toml");
723        let mcp = dir.path().join("mcp-auth.json");
724        std::fs::write(&mcp, "not json").unwrap();
725
726        let config = config_with_keys(CredentialStoreKind::Keychain);
727        let store = leviath_core::MemoryStore::new();
728        let err = apply_migration_with(&config, &path, true, Ok(Some(Box::new(store))), Some(&mcp))
729            .expect_err("a corrupt MCP store is not a successful migration");
730        assert!(!err.to_string().is_empty());
731    }
732
733    /// And a migration to the file still works when there is no keychain at all
734    /// to clean up.
735    #[test]
736    fn migrating_to_the_file_works_without_a_keychain() {
737        let dir = tempfile::tempdir().unwrap();
738        let path = dir.path().join("config.toml");
739        let config = config_with_keys(CredentialStoreKind::Keychain);
740        apply_migration_with(&config, &path, true, no_keychain(), None).unwrap();
741        assert!(
742            std::fs::read_to_string(&path)
743                .unwrap()
744                .contains("sk-ant-secret")
745        );
746    }
747
748    /// The rewrite that completes a move into the keychain has to be able to
749    /// fail: the secrets are already in the store, but the config still names
750    /// them, and reporting success would be a lie.
751    #[test]
752    fn a_failed_final_rewrite_fails_the_migration() {
753        let _guard = with_mock_store();
754        let dir = tempfile::tempdir().unwrap();
755        let path = dir.path().join("config.toml");
756        let config = config_with_keys(CredentialStoreKind::File);
757        config.save_to_path_public(&path).unwrap();
758        set_readonly(&path, true);
759
760        let err = apply_migration_with(&config, &path, false, keychain(), None)
761            .expect_err("an unwritable config cannot complete the move");
762        assert!(!err.to_string().is_empty());
763
764        set_readonly(&path, false);
765    }
766
767    /// `Ok(None)` - the file backend where a keychain was expected - is a
768    /// refusal, not a silent no-op that would strip the file.
769    #[test]
770    fn migrating_to_a_backend_that_is_not_a_store_is_refused() {
771        let dir = tempfile::tempdir().unwrap();
772        let path = dir.path().join("config.toml");
773        let config = config_with_keys(CredentialStoreKind::File);
774        let err = apply_migration_with(&config, &path, false, Ok(None), None)
775            .expect_err("there is nowhere to migrate to");
776        assert!(err.to_string().contains("no OS credential store"), "{err}");
777    }
778
779    #[test]
780    fn the_plan_lists_every_configured_key_and_says_nothing_when_there_are_none() {
781        let plan = plan_migration(&config_with_keys(CredentialStoreKind::File), false);
782        assert_eq!(plan.moving.len(), 2);
783        assert!(plan.summary.contains("into the OS keychain"));
784
785        let back = plan_migration(&config_with_keys(CredentialStoreKind::Keychain), true);
786        assert!(back.summary.contains("out of the OS keychain"));
787        assert!(back.done.contains("credential_store = \"file\""));
788
789        let empty = plan_migration(&Config::default(), false);
790        assert!(empty.moving.is_empty());
791        assert!(empty.summary.contains("nothing to move"));
792    }
793
794    /// The duplicate warning: a key in both places is not an error, but the file
795    /// copy wins, so rotating the keychain entry would silently do nothing.
796    #[test]
797    fn status_reports_a_secret_stored_in_both_places() {
798        let _guard = with_mock_store();
799        let dir = tempfile::tempdir().unwrap();
800        let path = dir.path().join("config.toml");
801
802        // Written with the file backend, so the key lands in the TOML...
803        let config = config_with_keys(CredentialStoreKind::File);
804        config.save_to_path_public(&path).unwrap();
805        // ...and also placed in the keychain.
806        let store = crate::credentials::store_for(CredentialStoreKind::Keychain)
807            .unwrap()
808            .unwrap();
809        store
810            .set(
811                &leviath_core::provider_account("anthropic"),
812                "sk-ant-secret",
813            )
814            .unwrap();
815
816        let mut keychain_config = config.clone();
817        keychain_config.security.credential_store = CredentialStoreKind::Keychain;
818        let s = status_with(&keychain_config, &path, keychain());
819
820        assert_eq!(s.duplicated, vec!["provider/anthropic".to_string()]);
821        let rendered = render_status(&s);
822        assert!(rendered.contains("BOTH"), "{rendered}");
823        assert!(rendered.contains("lev auth migrate"), "{rendered}");
824    }
825
826    #[test]
827    fn status_on_a_plain_file_install_says_so_and_offers_the_keychain() {
828        let _guard = with_mock_store();
829        let dir = tempfile::tempdir().unwrap();
830        let path = dir.path().join("config.toml");
831        let config = config_with_keys(CredentialStoreKind::File);
832        config.save_to_path_public(&path).unwrap();
833
834        let s = status_with(&config, &path, Ok(None));
835        assert_eq!(s.kind, CredentialStoreKind::File);
836        assert!(s.unavailable.is_none(), "the file backend needs no store");
837        assert!(s.duplicated.is_empty());
838        assert_eq!(s.providers.len(), 2);
839
840        let rendered = render_status(&s);
841        assert!(rendered.contains("file (Leviath's own 0600 files)"));
842        assert!(rendered.contains("credential_store = \"keychain\""));
843    }
844
845    /// An unavailable keychain has to be reported rather than looking like an
846    /// empty one.
847    #[test]
848    fn status_reports_an_unreachable_keychain() {
849        let dir = tempfile::tempdir().unwrap();
850        let path = dir.path().join("config.toml");
851
852        let s = status_with(
853            &config_with_keys(CredentialStoreKind::Keychain),
854            &path,
855            no_keychain(),
856        );
857        assert!(s.unavailable.is_some());
858        let rendered = render_status(&s);
859        assert!(
860            rendered.contains("credential store unavailable"),
861            "{rendered}"
862        );
863    }
864
865    #[test]
866    fn status_with_no_keys_points_at_setup() {
867        let _guard = with_mock_store();
868        let dir = tempfile::tempdir().unwrap();
869        let path = dir.path().join("config.toml");
870        let s = status_with(&Config::default(), &path, Ok(None));
871        assert!(s.providers.is_empty());
872        let rendered = render_status(&s);
873        assert!(rendered.contains("lev setup"), "{rendered}");
874    }
875
876    /// A build compiled without keychain support must say so rather than
877    /// offering a migration that cannot work.
878    #[test]
879    fn a_build_without_keychain_support_says_so() {
880        let s = Status {
881            kind: CredentialStoreKind::File,
882            supported: false,
883            unavailable: None,
884            providers: vec!["provider/anthropic".into()],
885            mcp_servers: Vec::new(),
886            duplicated: Vec::new(),
887            config_path: "/x/config.toml".into(),
888        };
889        let rendered = render_status(&s);
890        assert!(
891            rendered.contains("no OS credential store support"),
892            "{rendered}"
893        );
894        assert!(
895            !rendered.contains("credential_store = \"keychain\""),
896            "and must not suggest a backend it cannot use: {rendered}"
897        );
898    }
899
900    /// The file scan has to tell the top-level `openrouter_api_key` apart from
901    /// the three under `[providers]`, and must not fall over on an unreadable or
902    /// malformed file.
903    #[test]
904    fn the_file_scan_finds_keys_in_both_shapes_and_tolerates_a_bad_file() {
905        let dir = tempfile::tempdir().unwrap();
906        let path = dir.path().join("config.toml");
907
908        assert!(
909            providers_in_file(&path).is_empty(),
910            "a missing file is empty"
911        );
912
913        std::fs::write(&path, "this is not toml = = =").unwrap();
914        assert!(providers_in_file(&path).is_empty(), "so is a broken one");
915
916        std::fs::write(
917            &path,
918            "openrouter_api_key = \"a\"\n[providers]\nanthropic_api_key = \"b\"\n",
919        )
920        .unwrap();
921        let found = providers_in_file(&path);
922        assert!(found.contains(&"provider/openrouter".to_string()));
923        assert!(found.contains(&"provider/anthropic".to_string()));
924        assert_eq!(found.len(), 2, "and nothing else: {found:?}");
925    }
926
927    /// `migrate` must propagate a failed migration rather than reporting
928    /// success. Here the config file itself is read-only, so the rewrite that
929    /// completes the move cannot happen.
930    #[test]
931    fn migrate_propagates_a_failed_move() {
932        let _guard = with_mock_store();
933        let dir = tempfile::tempdir().unwrap();
934        let path = dir.path().join("config.toml");
935        config_with_keys(CredentialStoreKind::File)
936            .save_to_path_public(&path)
937            .unwrap();
938        // Readable, so the load succeeds; unwritable, so the rewrite does not.
939        set_readonly(&path, true);
940
941        let err = run_auth(&path, AuthArgs::migrate_for_test(true, false))
942            .expect_err("an unwritable config cannot be migrated");
943        assert!(!err.to_string().is_empty());
944
945        set_readonly(&path, false);
946    }
947
948    /// Writing a grant into a temporary MCP auth store, so the migration
949    /// helpers have something to move.
950    fn write_mcp_store(path: &std::path::Path, server: &str) {
951        let mut store = leviath_mcp::AuthStore::default();
952        store.set(
953            server,
954            leviath_mcp::ServerAuth {
955                resource: "https://example.test/mcp".to_string(),
956                issuer: "https://example.test".to_string(),
957                authorization_endpoint: "https://example.test/authorize".to_string(),
958                token_endpoint: "https://example.test/token".to_string(),
959                client_id: "cid".to_string(),
960                access_token: "at-SECRET".to_string(),
961                refresh_token: Some("rt-SECRET".to_string()),
962                expires_at: 9_999_999_999,
963                scope: String::new(),
964            },
965        );
966        store.save(path).unwrap();
967    }
968
969    /// MCP OAuth grants move with the provider keys: tokens out of the file,
970    /// only the server name left behind as an index.
971    #[test]
972    fn mcp_grants_move_into_the_credential_store_and_back() {
973        let dir = tempfile::tempdir().unwrap();
974        let mcp = dir.path().join("mcp-auth.json");
975        write_mcp_store(&mcp, "github");
976        assert!(std::fs::read_to_string(&mcp).unwrap().contains("rt-SECRET"));
977
978        let store = leviath_core::MemoryStore::new();
979        migrate_mcp_grants(Some(&mcp), None, Some(&store)).unwrap();
980
981        let on_disk = std::fs::read_to_string(&mcp).unwrap();
982        assert!(!on_disk.contains("rt-SECRET"), "{on_disk}");
983        assert!(!on_disk.contains("at-SECRET"), "{on_disk}");
984        assert!(on_disk.contains("github"), "the index remains: {on_disk}");
985        assert_eq!(mcp_server_names(Some(&mcp), Some(&store)), ["github"]);
986
987        // ...and back again.
988        migrate_mcp_grants(Some(&mcp), Some(&store), None).unwrap();
989        let restored = std::fs::read_to_string(&mcp).unwrap();
990        assert!(restored.contains("rt-SECRET"), "{restored}");
991    }
992
993    /// Nothing to move is not an error - a user who has never run
994    /// `lev mcp login` has no store, and no home is not a failure either.
995    #[test]
996    fn migrating_mcp_grants_is_a_no_op_when_there_is_nothing_to_move() {
997        let dir = tempfile::tempdir().unwrap();
998        let missing = dir.path().join("mcp-auth.json");
999
1000        migrate_mcp_grants(None, None, None).expect("no path, nothing to do");
1001        migrate_mcp_grants(Some(&missing), None, None).expect("no file, nothing to do");
1002        assert!(mcp_server_names(None, None).is_empty());
1003        assert!(mcp_server_names(Some(&missing), None).is_empty());
1004    }
1005
1006    /// A corrupt MCP store fails the migration rather than silently discarding
1007    /// every stored grant.
1008    #[test]
1009    fn a_corrupt_mcp_store_fails_the_migration() {
1010        let dir = tempfile::tempdir().unwrap();
1011        let mcp = dir.path().join("mcp-auth.json");
1012        std::fs::write(&mcp, "not json").unwrap();
1013
1014        assert!(migrate_mcp_grants(Some(&mcp), None, None).is_err());
1015        // The reporting path is more forgiving: `lev auth status` is the command
1016        // a user runs *because* something is wrong, so it answers with "none"
1017        // rather than refusing to run.
1018        assert!(mcp_server_names(Some(&mcp), None).is_empty());
1019    }
1020
1021    /// The status report lists logged-in MCP servers.
1022    #[test]
1023    fn status_lists_mcp_servers() {
1024        let s = Status {
1025            kind: CredentialStoreKind::Keychain,
1026            supported: true,
1027            unavailable: None,
1028            providers: vec!["provider/anthropic".into()],
1029            mcp_servers: vec!["github".into(), "linear".into()],
1030            duplicated: Vec::new(),
1031            config_path: "/x/config.toml".into(),
1032        };
1033        let rendered = render_status(&s);
1034        assert!(rendered.contains("MCP servers logged in"), "{rendered}");
1035        assert!(rendered.contains("- github"), "{rendered}");
1036        assert!(rendered.contains("- linear"), "{rendered}");
1037    }
1038
1039    /// A config file that cannot be parsed must fail the command rather than
1040    /// being treated as an empty install - both entry points read it.
1041    #[test]
1042    fn a_broken_config_file_fails_both_subcommands() {
1043        let _guard = with_mock_store();
1044        let dir = tempfile::tempdir().unwrap();
1045        let path = dir.path().join("config.toml");
1046        std::fs::write(&path, "this is not = = toml").unwrap();
1047
1048        assert!(
1049            run_auth(&path, AuthArgs::status_for_test()).is_err(),
1050            "status must not report a broken config as an empty one"
1051        );
1052        assert!(
1053            run_auth(&path, AuthArgs::migrate_for_test(false, false)).is_err(),
1054            "and migrate must not act on one"
1055        );
1056    }
1057
1058    /// A migration whose write fails has to surface through `migrate`, not just
1059    /// through `apply_migration_with`.
1060    #[test]
1061    fn migrate_reports_a_failing_store() {
1062        let _guard = test_store::lock();
1063        // No store installed: `store_for` probes, and on a machine with a real
1064        // keychain that would reach it - so drive the seam directly instead.
1065        let dir = tempfile::tempdir().unwrap();
1066        let path = dir.path().join("config.toml");
1067        let config = config_with_keys(CredentialStoreKind::File);
1068        config.save_to_path_public(&path).unwrap();
1069
1070        let refuses = Stub {
1071            get: absent,
1072            set: refuses_write,
1073            delete: refuses_delete,
1074        };
1075        assert!(
1076            apply_migration_with(&config, &path, false, Ok(Some(Box::new(refuses))), None).is_err()
1077        );
1078    }
1079
1080    /// The `to_file` direction writes the config first; an unwritable path has
1081    /// to fail rather than silently clearing the keychain.
1082    #[test]
1083    fn migrating_to_an_unwritable_path_fails_before_touching_the_keychain() {
1084        let dir = tempfile::tempdir().unwrap();
1085        // A file where a parent directory would have to be.
1086        let blocker = dir.path().join("blocker");
1087        std::fs::write(&blocker, b"x").unwrap();
1088        let path = blocker.join("config.toml");
1089
1090        let config = config_with_keys(CredentialStoreKind::Keychain);
1091        assert!(
1092            apply_migration_with(&config, &path, true, no_keychain(), None).is_err(),
1093            "an unwritable destination is not a migration"
1094        );
1095    }
1096
1097    /// Drive `execute` - the real entry point - for each subcommand, against a
1098    /// config path of our choosing.
1099    ///
1100    /// Plain `#[test]`s driving their own runtime rather than `#[tokio::test]`:
1101    /// the mock-store guard has to be held across the whole call, and holding a
1102    /// `std` guard across an `.await` is a deadlock the scheduler is free to
1103    /// arrange.
1104    fn run_auth(path: &std::path::Path, args: AuthArgs) -> anyhow::Result<()> {
1105        let rt = tokio::runtime::Builder::new_current_thread()
1106            .enable_all()
1107            .build()
1108            .unwrap();
1109        temp_env::with_var("LEVIATH_CONFIG_PATH", Some(path.as_os_str()), || {
1110            rt.block_on(execute(args))
1111        })
1112    }
1113
1114    #[test]
1115    fn execute_status_reads_the_configured_path() {
1116        let _guard = with_mock_store();
1117        let dir = tempfile::tempdir().unwrap();
1118        let path = dir.path().join("config.toml");
1119        config_with_keys(CredentialStoreKind::File)
1120            .save_to_path_public(&path)
1121            .unwrap();
1122
1123        run_auth(&path, AuthArgs::status_for_test()).expect("status succeeds");
1124    }
1125
1126    /// `--dry-run` reports the plan and changes nothing.
1127    #[test]
1128    fn execute_migrate_dry_run_changes_nothing() {
1129        let _guard = with_mock_store();
1130        let dir = tempfile::tempdir().unwrap();
1131        let path = dir.path().join("config.toml");
1132        config_with_keys(CredentialStoreKind::File)
1133            .save_to_path_public(&path)
1134            .unwrap();
1135        let before = std::fs::read_to_string(&path).unwrap();
1136
1137        run_auth(&path, AuthArgs::migrate_for_test(false, true)).expect("dry run succeeds");
1138        assert_eq!(
1139            std::fs::read_to_string(&path).unwrap(),
1140            before,
1141            "a dry run must not touch the file"
1142        );
1143    }
1144
1145    /// And the real thing, through the command rather than the helper.
1146    #[test]
1147    fn execute_migrate_moves_the_keys() {
1148        let _guard = with_mock_store();
1149        let dir = tempfile::tempdir().unwrap();
1150        let path = dir.path().join("config.toml");
1151        config_with_keys(CredentialStoreKind::File)
1152            .save_to_path_public(&path)
1153            .unwrap();
1154
1155        run_auth(&path, AuthArgs::migrate_for_test(false, false)).expect("migrate succeeds");
1156        let after = std::fs::read_to_string(&path).unwrap();
1157        assert!(!after.contains("sk-ant-secret"), "{after}");
1158    }
1159
1160    /// With nothing configured there is nothing to move, and that is reported
1161    /// rather than treated as an error.
1162    #[test]
1163    fn execute_migrate_with_no_keys_is_a_no_op() {
1164        let _guard = with_mock_store();
1165        let dir = tempfile::tempdir().unwrap();
1166        let path = dir.path().join("config.toml");
1167        Config::default().save_to_path_public(&path).unwrap();
1168
1169        run_auth(&path, AuthArgs::migrate_for_test(false, false)).expect("nothing to do succeeds");
1170    }
1171}