1use crate::config::Config;
10use clap::{Args, Subcommand};
11use leviath_core::{CredentialStore, CredentialStoreKind};
12
13#[derive(Debug, Args)]
15pub struct AuthArgs {
16 #[command(subcommand)]
17 command: AuthCommand,
18}
19
20#[derive(Debug, Subcommand)]
21enum AuthCommand {
22 Status,
24
25 Migrate {
30 #[arg(long)]
32 to_file: bool,
33
34 #[arg(long)]
36 dry_run: bool,
37 },
38}
39
40impl AuthArgs {
41 #[cfg(test)]
43 pub(crate) fn status_for_test() -> Self {
44 Self {
45 command: AuthCommand::Status,
46 }
47 }
48
49 #[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
58pub 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#[derive(Debug, PartialEq)]
74pub(crate) struct Status {
75 pub kind: CredentialStoreKind,
77 pub supported: bool,
79 pub unavailable: Option<String>,
82 pub providers: Vec<String>,
84 pub mcp_servers: Vec<String>,
86 pub duplicated: Vec<String>,
90 pub config_path: String,
92}
93
94pub(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
100pub(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 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 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
157fn 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
176fn 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
192pub(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
248fn 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#[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
315fn 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
331fn 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 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 if let Err(e) = store.delete(account) {
356 tracing::warn!("could not remove {account} from the keychain: {e}");
357 }
358 }
359 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 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 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
400fn 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
424fn 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 fn keychain() -> crate::credentials::Resolved {
456 crate::credentials::store_for(CredentialStoreKind::Keychain)
457 }
458
459 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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 let config = config_with_keys(CredentialStoreKind::File);
805 config.save_to_path_public(&path).unwrap();
806 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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 assert!(mcp_server_names(Some(&mcp), None).is_empty());
1020 }
1021
1022 #[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 #[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 #[test]
1062 fn migrate_reports_a_failing_store() {
1063 let _guard = test_store::lock();
1064 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 #[test]
1084 fn migrating_to_an_unwritable_path_fails_before_touching_the_keychain() {
1085 let dir = tempfile::tempdir().unwrap();
1086 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 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 #[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 #[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 #[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}