1use 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 Status,
23
24 Migrate {
29 #[arg(long)]
31 to_file: bool,
32
33 #[arg(long)]
35 dry_run: bool,
36 },
37}
38
39impl AuthArgs {
40 #[cfg(test)]
42 pub(crate) fn status_for_test() -> Self {
43 Self {
44 command: AuthCommand::Status,
45 }
46 }
47
48 #[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
57pub 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#[derive(Debug, PartialEq)]
73pub(crate) struct Status {
74 pub kind: CredentialStoreKind,
76 pub supported: bool,
78 pub unavailable: Option<String>,
81 pub providers: Vec<String>,
83 pub mcp_servers: Vec<String>,
85 pub duplicated: Vec<String>,
89 pub config_path: String,
91}
92
93pub(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
99pub(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 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 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
156fn 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
175fn 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
191pub(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
247fn 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#[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
314fn 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
330fn 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 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 if let Err(e) = store.delete(account) {
355 tracing::warn!("could not remove {account} from the keychain: {e}");
356 }
357 }
358 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 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 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
399fn 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
423fn 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 fn keychain() -> crate::credentials::Resolved {
455 crate::credentials::store_for(CredentialStoreKind::Keychain)
456 }
457
458 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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 let config = config_with_keys(CredentialStoreKind::File);
804 config.save_to_path_public(&path).unwrap();
805 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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 assert!(mcp_server_names(Some(&mcp), None).is_empty());
1019 }
1020
1021 #[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 #[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 #[test]
1061 fn migrate_reports_a_failing_store() {
1062 let _guard = test_store::lock();
1063 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 #[test]
1083 fn migrating_to_an_unwritable_path_fails_before_touching_the_keychain() {
1084 let dir = tempfile::tempdir().unwrap();
1085 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 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 #[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 #[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 #[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}