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