use crate::config::Config;
use clap::{Args, Subcommand};
use leviath_core::{CredentialStore, CredentialStoreKind};
#[derive(Debug, Args)]
pub struct AuthArgs {
#[command(subcommand)]
command: AuthCommand,
}
#[derive(Debug, Subcommand)]
enum AuthCommand {
Status,
Migrate {
#[arg(long)]
to_file: bool,
#[arg(long)]
dry_run: bool,
},
}
impl AuthArgs {
#[cfg(test)]
pub(crate) fn status_for_test() -> Self {
Self {
command: AuthCommand::Status,
}
}
#[cfg(test)]
pub(crate) fn migrate_for_test(to_file: bool, dry_run: bool) -> Self {
Self {
command: AuthCommand::Migrate { to_file, dry_run },
}
}
}
pub async fn execute(args: AuthArgs) -> anyhow::Result<()> {
let path = Config::config_path();
match args.command {
AuthCommand::Status => {
let config = Config::load_from_path_public(&path)?;
print!("{}", render_status(&status(&config, &path)));
Ok(())
}
AuthCommand::Migrate { to_file, dry_run } => migrate(&path, to_file, dry_run),
}
}
#[derive(Debug, PartialEq)]
pub(crate) struct Status {
pub kind: CredentialStoreKind,
pub supported: bool,
pub unavailable: Option<String>,
pub providers: Vec<String>,
pub mcp_servers: Vec<String>,
pub duplicated: Vec<String>,
pub config_path: String,
}
pub(crate) fn status(config: &Config, path: &std::path::Path) -> Status {
let resolved = crate::credentials::store_for(config.security.credential_store);
status_with(config, path, resolved)
}
pub(crate) fn status_with(
config: &Config,
path: &std::path::Path,
resolved: crate::credentials::Resolved,
) -> Status {
let kind = config.security.credential_store;
let supported = leviath_sys::keychain::is_supported();
let providers: Vec<String> = config
.provider_secrets()
.into_iter()
.map(|(account, _)| account)
.collect();
let on_disk = providers_in_file(path);
let (unavailable, in_store) = match resolved {
Ok(Some(store)) => {
let accounts: Vec<String> = crate::credentials::PROVIDER_KEYS
.iter()
.map(|p| leviath_core::provider_account(p))
.collect();
(None, store.read_all(&accounts).into_keys().collect())
}
Ok(None) => (None, Vec::new()),
Err(e) => (Some(e), Vec::new()),
};
let duplicated = on_disk
.iter()
.filter(|a| in_store.contains(a))
.cloned()
.collect();
let mcp_servers = mcp_server_names(leviath_mcp::AuthStore::default_path().as_deref(), None);
Status {
kind,
supported,
unavailable,
providers,
mcp_servers,
duplicated,
config_path: path.display().to_string(),
}
}
fn providers_in_file(path: &std::path::Path) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let Ok(value) = text.parse::<toml::Table>() else {
return Vec::new();
};
crate::credentials::PROVIDER_KEYS
.iter()
.filter(|p| file_has_key(&value, p))
.map(|p| leviath_core::provider_account(p))
.collect()
}
fn file_has_key(value: &toml::Table, provider: &str) -> bool {
let field = format!("{provider}_api_key");
if provider == "openrouter" {
return value.get(&field).and_then(|v| v.as_str()).is_some();
}
value
.get("providers")
.and_then(|p| p.get(&field))
.and_then(|v| v.as_str())
.is_some()
}
pub(crate) fn render_status(s: &Status) -> String {
let mut out = String::new();
let backend = match s.kind {
CredentialStoreKind::File => "file (Leviath's own 0600 files)",
CredentialStoreKind::Keychain => "keychain (OS credential store)",
};
out.push_str(&format!("Credential store: {backend}\n"));
out.push_str(&format!("Config file: {}\n", s.config_path));
if !s.supported {
out.push_str(
"\nThis build has no OS credential store support (the `keychain` feature is off).\n",
);
}
if let Some(reason) = &s.unavailable {
out.push_str(&format!("\n! {reason}\n"));
}
out.push('\n');
if s.providers.is_empty() {
out.push_str("No provider API keys are configured. Run `lev setup` to add one.\n");
} else {
out.push_str("Provider keys configured:\n");
for p in &s.providers {
out.push_str(&format!(" - {p}\n"));
}
}
if !s.mcp_servers.is_empty() {
out.push_str("\nMCP servers logged in:\n");
for m in &s.mcp_servers {
out.push_str(&format!(" - {m}\n"));
}
}
if !s.duplicated.is_empty() {
out.push_str(
"\n! These are stored in BOTH the config file and the OS keychain. The file copy\n \
wins, so changing the keychain entry will appear to have no effect. Run\n \
`lev auth migrate` to remove the file copies.\n",
);
for p in &s.duplicated {
out.push_str(&format!(" - {p}\n"));
}
}
if s.kind == CredentialStoreKind::File && s.supported {
out.push_str(
"\nTo move these into the OS keychain, set `[security] credential_store = \"keychain\"`\n\
in the config file and run `lev auth migrate`.\n",
);
}
out
}
fn migrate(path: &std::path::Path, to_file: bool, dry_run: bool) -> anyhow::Result<()> {
let config = Config::load_from_path_public(path)?;
let plan = plan_migration(&config, to_file);
if plan.moving.is_empty() {
println!("{}", plan.summary);
return Ok(());
}
println!("{}", plan.summary);
for account in &plan.moving {
println!(" - {account}");
}
if dry_run {
println!("\nDry run: nothing was changed.");
return Ok(());
}
apply_migration(&config, path, to_file)?;
println!("\nDone. {}", plan.done);
Ok(())
}
#[derive(Debug, PartialEq)]
pub(crate) struct MigrationPlan {
pub moving: Vec<String>,
pub summary: String,
pub done: String,
}
pub(crate) fn plan_migration(config: &Config, to_file: bool) -> MigrationPlan {
let moving: Vec<String> = config
.provider_secrets()
.into_iter()
.map(|(account, _)| account)
.collect();
if moving.is_empty() {
return MigrationPlan {
moving,
summary: "No provider API keys are configured; there is nothing to move.".to_string(),
done: String::new(),
};
}
let (summary, done) = if to_file {
(
"Moving these secrets out of the OS keychain and into the config file:",
"The config file now holds these keys (mode 0600). Set `[security] \
credential_store = \"file\"` if you have not already.",
)
} else {
(
"Moving these secrets into the OS keychain:",
"The config file no longer contains these keys. Set `[security] \
credential_store = \"keychain\"` if you have not already.",
)
};
MigrationPlan {
moving,
summary: summary.to_string(),
done: done.to_string(),
}
}
fn apply_migration(config: &Config, path: &std::path::Path, to_file: bool) -> anyhow::Result<()> {
let resolved = crate::credentials::store_for(CredentialStoreKind::Keychain);
apply_migration_with(
config,
path,
to_file,
resolved,
leviath_mcp::AuthStore::default_path().as_deref(),
)
}
fn apply_migration_with(
config: &Config,
path: &std::path::Path,
to_file: bool,
resolved: crate::credentials::Resolved,
mcp_path: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let secrets = config.provider_secrets();
if to_file {
let mut file_config = config.clone();
file_config.security.credential_store = CredentialStoreKind::File;
file_config.save_to_path_public(path)?;
if let Ok(Some(store)) = resolved {
for (account, _) in &secrets {
if let Err(e) = store.delete(account) {
tracing::warn!("could not remove {account} from the keychain: {e}");
}
}
let names = mcp_server_names(mcp_path, Some(store.as_ref()));
migrate_mcp_grants(mcp_path, Some(store.as_ref()), None)?;
for name in names {
if let Err(e) = store.delete(&leviath_core::mcp_account(&name)) {
tracing::warn!("could not remove the grant for '{name}': {e}");
}
}
}
return Ok(());
}
let store = resolved
.map_err(|e| anyhow::anyhow!("{e}"))?
.ok_or_else(|| anyhow::anyhow!("no OS credential store is available"))?;
for (account, secret) in &secrets {
store
.set(account, secret)
.map_err(|e| anyhow::anyhow!("failed to store {account}: {e}"))?;
match store.get(account) {
Ok(Some(v)) if &v == secret => {}
_ => anyhow::bail!(
"{account} did not read back correctly from the credential store; \
the config file has been left unchanged"
),
}
}
let mut stripped = config.clone();
stripped.security.credential_store = CredentialStoreKind::Keychain;
stripped.save_to_path_public(path)?;
migrate_mcp_grants(mcp_path, None, Some(store.as_ref()))
}
fn migrate_mcp_grants(
path: Option<&std::path::Path>,
source: Option<&dyn CredentialStore>,
destination: Option<&dyn CredentialStore>,
) -> anyhow::Result<()> {
let Some(path) = path else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let store = leviath_mcp::AuthStore::load_with(path, source)?;
store.save_with(path, destination)
}
fn mcp_server_names(
path: Option<&std::path::Path>,
store: Option<&dyn CredentialStore>,
) -> Vec<String> {
path.and_then(|p| leviath_mcp::AuthStore::load_with(p, store).ok())
.map(|s| {
let mut names: Vec<String> = s
.server_names()
.into_iter()
.map(str::to_string)
.chain(s.keychain_server_names().iter().cloned())
.collect();
names.sort();
names.dedup();
names
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::test_store;
fn with_mock_store() -> std::sync::MutexGuard<'static, ()> {
test_store::with_mock()
}
fn keychain() -> crate::credentials::Resolved {
crate::credentials::store_for(CredentialStoreKind::Keychain)
}
struct Stub {
get: fn(&str) -> Result<Option<String>, String>,
set: fn(&str, &str) -> Result<(), String>,
delete: fn(&str) -> Result<bool, String>,
}
impl CredentialStore for Stub {
fn get(&self, account: &str) -> Result<Option<String>, String> {
(self.get)(account)
}
fn set(&self, account: &str, secret: &str) -> Result<(), String> {
(self.set)(account, secret)
}
fn delete(&self, account: &str) -> Result<bool, String> {
(self.delete)(account)
}
}
fn absent(_: &str) -> Result<Option<String>, String> {
Ok(None)
}
fn accepts_write(_: &str, _: &str) -> Result<(), String> {
Ok(())
}
fn refuses_write(_: &str, _: &str) -> Result<(), String> {
Err("read-only keychain".to_string())
}
fn refuses_delete(_: &str) -> Result<bool, String> {
Err("cannot delete".to_string())
}
fn set_readonly(path: &std::path::Path, readonly: bool) {
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_readonly(readonly);
std::fs::set_permissions(path, perms).unwrap();
}
fn no_keychain() -> crate::credentials::Resolved {
Err(
"`[security] credential_store = \"keychain\"` is set, but OS \
credential store unavailable: no default store"
.to_string(),
)
}
fn config_with_keys(kind: CredentialStoreKind) -> Config {
let mut c = Config::default();
c.security.credential_store = kind;
c.providers.anthropic_api_key = Some("sk-ant-secret".into());
c.openrouter_api_key = Some("sk-or-secret".into());
c
}
#[test]
fn migrating_to_the_keychain_moves_the_secrets_out_of_the_file() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
config.save_to_path_public(&path).unwrap();
let before = std::fs::read_to_string(&path).unwrap();
assert!(before.contains("sk-ant-secret"), "the file starts with it");
apply_migration_with(&config, &path, false, keychain(), None).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(
!after.contains("sk-ant-secret") && !after.contains("sk-or-secret"),
"no secret may remain in the file: {after}"
);
let store = crate::credentials::store_for(CredentialStoreKind::Keychain)
.unwrap()
.unwrap();
assert_eq!(
store
.get(&leviath_core::provider_account("anthropic"))
.unwrap()
.as_deref(),
Some("sk-ant-secret")
);
assert_eq!(
store
.get(&leviath_core::provider_account("openrouter"))
.unwrap()
.as_deref(),
Some("sk-or-secret")
);
}
#[test]
fn migrating_to_the_file_restores_the_secrets_and_clears_the_keychain() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::Keychain);
apply_migration_with(&config, &path, false, keychain(), None).unwrap();
apply_migration_with(&config, &path, true, keychain(), None).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("sk-ant-secret"), "back in the file: {after}");
let store = crate::credentials::store_for(CredentialStoreKind::Keychain)
.unwrap()
.unwrap();
assert_eq!(
store
.get(&leviath_core::provider_account("anthropic"))
.unwrap(),
None,
"and gone from the keychain"
);
}
#[test]
fn a_failing_store_leaves_the_config_file_untouched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
config.save_to_path_public(&path).unwrap();
let before = std::fs::read_to_string(&path).unwrap();
assert!(
apply_migration_with(&config, &path, false, no_keychain(), None).is_err(),
"no store means no migration"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"the file must be byte-identical after a failed migration"
);
}
#[test]
fn a_store_that_does_not_persist_aborts_before_the_file_is_stripped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
config.save_to_path_public(&path).unwrap();
let before = std::fs::read_to_string(&path).unwrap();
let amnesiac = Stub {
get: absent,
set: accepts_write,
delete: refuses_delete,
};
let err = apply_migration_with(&config, &path, false, Ok(Some(Box::new(amnesiac))), None)
.expect_err("a store that does not persist must not be trusted");
assert!(err.to_string().contains("did not read back"), "{err}");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"and the file is untouched"
);
}
#[test]
fn a_store_that_refuses_the_write_aborts_the_migration() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
let refuses = Stub {
get: absent,
set: refuses_write,
delete: refuses_delete,
};
let err = apply_migration_with(&config, &path, false, Ok(Some(Box::new(refuses))), None)
.expect_err("a refused write is not a migration");
assert!(err.to_string().contains("failed to store"), "{err}");
}
#[test]
fn cleanup_failures_do_not_fail_a_migration_to_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::Keychain);
let undeletable = Stub {
get: absent,
set: accepts_write,
delete: refuses_delete,
};
let mcp = dir.path().join("mcp-auth.json");
write_mcp_store(&mcp, "github");
apply_migration_with(
&config,
&path,
true,
Ok(Some(Box::new(undeletable))),
Some(&mcp),
)
.expect("the keys are in the file; cleanup is best effort");
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("sk-ant-secret"), "{after}");
}
#[test]
fn migrating_to_the_file_also_brings_back_the_mcp_grants() {
use leviath_core::CredentialStore as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mcp = dir.path().join("mcp-auth.json");
let config = config_with_keys(CredentialStoreKind::Keychain);
let store = leviath_core::MemoryStore::new();
write_mcp_store(&mcp, "github");
migrate_mcp_grants(Some(&mcp), None, Some(&store)).unwrap();
assert!(!std::fs::read_to_string(&mcp).unwrap().contains("rt-SECRET"));
store
.set(
&leviath_core::provider_account("anthropic"),
"sk-ant-secret",
)
.unwrap();
apply_migration_with(&config, &path, true, Ok(Some(Box::new(store))), Some(&mcp)).unwrap();
assert!(
std::fs::read_to_string(&mcp).unwrap().contains("rt-SECRET"),
"the grant is back in its own file"
);
}
#[test]
fn a_corrupt_mcp_store_fails_a_migration_to_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mcp = dir.path().join("mcp-auth.json");
std::fs::write(&mcp, "not json").unwrap();
let config = config_with_keys(CredentialStoreKind::Keychain);
let store = leviath_core::MemoryStore::new();
let err = apply_migration_with(&config, &path, true, Ok(Some(Box::new(store))), Some(&mcp))
.expect_err("a corrupt MCP store is not a successful migration");
assert!(!err.to_string().is_empty());
}
#[test]
fn migrating_to_the_file_works_without_a_keychain() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::Keychain);
apply_migration_with(&config, &path, true, no_keychain(), None).unwrap();
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("sk-ant-secret")
);
}
#[test]
fn a_failed_final_rewrite_fails_the_migration() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
config.save_to_path_public(&path).unwrap();
set_readonly(&path, true);
let err = apply_migration_with(&config, &path, false, keychain(), None)
.expect_err("an unwritable config cannot complete the move");
assert!(!err.to_string().is_empty());
set_readonly(&path, false);
}
#[test]
fn migrating_to_a_backend_that_is_not_a_store_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
let err = apply_migration_with(&config, &path, false, Ok(None), None)
.expect_err("there is nowhere to migrate to");
assert!(err.to_string().contains("no OS credential store"), "{err}");
}
#[test]
fn the_plan_lists_every_configured_key_and_says_nothing_when_there_are_none() {
let plan = plan_migration(&config_with_keys(CredentialStoreKind::File), false);
assert_eq!(plan.moving.len(), 2);
assert!(plan.summary.contains("into the OS keychain"));
let back = plan_migration(&config_with_keys(CredentialStoreKind::Keychain), true);
assert!(back.summary.contains("out of the OS keychain"));
assert!(back.done.contains("credential_store = \"file\""));
let empty = plan_migration(&Config::default(), false);
assert!(empty.moving.is_empty());
assert!(empty.summary.contains("nothing to move"));
}
#[test]
fn status_reports_a_secret_stored_in_both_places() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
config.save_to_path_public(&path).unwrap();
let store = crate::credentials::store_for(CredentialStoreKind::Keychain)
.unwrap()
.unwrap();
store
.set(
&leviath_core::provider_account("anthropic"),
"sk-ant-secret",
)
.unwrap();
let mut keychain_config = config.clone();
keychain_config.security.credential_store = CredentialStoreKind::Keychain;
let s = status_with(&keychain_config, &path, keychain());
assert_eq!(s.duplicated, vec!["provider/anthropic".to_string()]);
let rendered = render_status(&s);
assert!(rendered.contains("BOTH"), "{rendered}");
assert!(rendered.contains("lev auth migrate"), "{rendered}");
}
#[test]
fn status_on_a_plain_file_install_says_so_and_offers_the_keychain() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
config.save_to_path_public(&path).unwrap();
let s = status_with(&config, &path, Ok(None));
assert_eq!(s.kind, CredentialStoreKind::File);
assert!(s.unavailable.is_none(), "the file backend needs no store");
assert!(s.duplicated.is_empty());
assert_eq!(s.providers.len(), 2);
let rendered = render_status(&s);
assert!(rendered.contains("file (Leviath's own 0600 files)"));
assert!(rendered.contains("credential_store = \"keychain\""));
}
#[test]
fn status_reports_an_unreachable_keychain() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let s = status_with(
&config_with_keys(CredentialStoreKind::Keychain),
&path,
no_keychain(),
);
assert!(s.unavailable.is_some());
let rendered = render_status(&s);
assert!(
rendered.contains("credential store unavailable"),
"{rendered}"
);
}
#[test]
fn status_with_no_keys_points_at_setup() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let s = status_with(&Config::default(), &path, Ok(None));
assert!(s.providers.is_empty());
let rendered = render_status(&s);
assert!(rendered.contains("lev setup"), "{rendered}");
}
#[test]
fn a_build_without_keychain_support_says_so() {
let s = Status {
kind: CredentialStoreKind::File,
supported: false,
unavailable: None,
providers: vec!["provider/anthropic".into()],
mcp_servers: Vec::new(),
duplicated: Vec::new(),
config_path: "/x/config.toml".into(),
};
let rendered = render_status(&s);
assert!(
rendered.contains("no OS credential store support"),
"{rendered}"
);
assert!(
!rendered.contains("credential_store = \"keychain\""),
"and must not suggest a backend it cannot use: {rendered}"
);
}
#[test]
fn the_file_scan_finds_keys_in_both_shapes_and_tolerates_a_bad_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
assert!(
providers_in_file(&path).is_empty(),
"a missing file is empty"
);
std::fs::write(&path, "this is not toml = = =").unwrap();
assert!(providers_in_file(&path).is_empty(), "so is a broken one");
std::fs::write(
&path,
"openrouter_api_key = \"a\"\n[providers]\nanthropic_api_key = \"b\"\n",
)
.unwrap();
let found = providers_in_file(&path);
assert!(found.contains(&"provider/openrouter".to_string()));
assert!(found.contains(&"provider/anthropic".to_string()));
assert_eq!(found.len(), 2, "and nothing else: {found:?}");
}
#[test]
fn migrate_propagates_a_failed_move() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
config_with_keys(CredentialStoreKind::File)
.save_to_path_public(&path)
.unwrap();
set_readonly(&path, true);
let err = run_auth(&path, AuthArgs::migrate_for_test(true, false))
.expect_err("an unwritable config cannot be migrated");
assert!(!err.to_string().is_empty());
set_readonly(&path, false);
}
fn write_mcp_store(path: &std::path::Path, server: &str) {
let mut store = leviath_mcp::AuthStore::default();
store.set(
server,
leviath_mcp::ServerAuth {
resource: "https://example.test/mcp".to_string(),
issuer: "https://example.test".to_string(),
authorization_endpoint: "https://example.test/authorize".to_string(),
token_endpoint: "https://example.test/token".to_string(),
client_id: "cid".to_string(),
access_token: "at-SECRET".to_string(),
refresh_token: Some("rt-SECRET".to_string()),
expires_at: 9_999_999_999,
scope: String::new(),
},
);
store.save(path).unwrap();
}
#[test]
fn mcp_grants_move_into_the_credential_store_and_back() {
let dir = tempfile::tempdir().unwrap();
let mcp = dir.path().join("mcp-auth.json");
write_mcp_store(&mcp, "github");
assert!(std::fs::read_to_string(&mcp).unwrap().contains("rt-SECRET"));
let store = leviath_core::MemoryStore::new();
migrate_mcp_grants(Some(&mcp), None, Some(&store)).unwrap();
let on_disk = std::fs::read_to_string(&mcp).unwrap();
assert!(!on_disk.contains("rt-SECRET"), "{on_disk}");
assert!(!on_disk.contains("at-SECRET"), "{on_disk}");
assert!(on_disk.contains("github"), "the index remains: {on_disk}");
assert_eq!(mcp_server_names(Some(&mcp), Some(&store)), ["github"]);
migrate_mcp_grants(Some(&mcp), Some(&store), None).unwrap();
let restored = std::fs::read_to_string(&mcp).unwrap();
assert!(restored.contains("rt-SECRET"), "{restored}");
}
#[test]
fn migrating_mcp_grants_is_a_no_op_when_there_is_nothing_to_move() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("mcp-auth.json");
migrate_mcp_grants(None, None, None).expect("no path, nothing to do");
migrate_mcp_grants(Some(&missing), None, None).expect("no file, nothing to do");
assert!(mcp_server_names(None, None).is_empty());
assert!(mcp_server_names(Some(&missing), None).is_empty());
}
#[test]
fn a_corrupt_mcp_store_fails_the_migration() {
let dir = tempfile::tempdir().unwrap();
let mcp = dir.path().join("mcp-auth.json");
std::fs::write(&mcp, "not json").unwrap();
assert!(migrate_mcp_grants(Some(&mcp), None, None).is_err());
assert!(mcp_server_names(Some(&mcp), None).is_empty());
}
#[test]
fn status_lists_mcp_servers() {
let s = Status {
kind: CredentialStoreKind::Keychain,
supported: true,
unavailable: None,
providers: vec!["provider/anthropic".into()],
mcp_servers: vec!["github".into(), "linear".into()],
duplicated: Vec::new(),
config_path: "/x/config.toml".into(),
};
let rendered = render_status(&s);
assert!(rendered.contains("MCP servers logged in"), "{rendered}");
assert!(rendered.contains("- github"), "{rendered}");
assert!(rendered.contains("- linear"), "{rendered}");
}
#[test]
fn a_broken_config_file_fails_both_subcommands() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "this is not = = toml").unwrap();
assert!(
run_auth(&path, AuthArgs::status_for_test()).is_err(),
"status must not report a broken config as an empty one"
);
assert!(
run_auth(&path, AuthArgs::migrate_for_test(false, false)).is_err(),
"and migrate must not act on one"
);
}
#[test]
fn migrate_reports_a_failing_store() {
let _guard = test_store::lock();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = config_with_keys(CredentialStoreKind::File);
config.save_to_path_public(&path).unwrap();
let refuses = Stub {
get: absent,
set: refuses_write,
delete: refuses_delete,
};
assert!(
apply_migration_with(&config, &path, false, Ok(Some(Box::new(refuses))), None).is_err()
);
}
#[test]
fn migrating_to_an_unwritable_path_fails_before_touching_the_keychain() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"x").unwrap();
let path = blocker.join("config.toml");
let config = config_with_keys(CredentialStoreKind::Keychain);
assert!(
apply_migration_with(&config, &path, true, no_keychain(), None).is_err(),
"an unwritable destination is not a migration"
);
}
fn run_auth(path: &std::path::Path, args: AuthArgs) -> anyhow::Result<()> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
temp_env::with_var("LEVIATH_CONFIG_PATH", Some(path.as_os_str()), || {
rt.block_on(execute(args))
})
}
#[test]
fn execute_status_reads_the_configured_path() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
config_with_keys(CredentialStoreKind::File)
.save_to_path_public(&path)
.unwrap();
run_auth(&path, AuthArgs::status_for_test()).expect("status succeeds");
}
#[test]
fn execute_migrate_dry_run_changes_nothing() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
config_with_keys(CredentialStoreKind::File)
.save_to_path_public(&path)
.unwrap();
let before = std::fs::read_to_string(&path).unwrap();
run_auth(&path, AuthArgs::migrate_for_test(false, true)).expect("dry run succeeds");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"a dry run must not touch the file"
);
}
#[test]
fn execute_migrate_moves_the_keys() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
config_with_keys(CredentialStoreKind::File)
.save_to_path_public(&path)
.unwrap();
run_auth(&path, AuthArgs::migrate_for_test(false, false)).expect("migrate succeeds");
let after = std::fs::read_to_string(&path).unwrap();
assert!(!after.contains("sk-ant-secret"), "{after}");
}
#[test]
fn execute_migrate_with_no_keys_is_a_no_op() {
let _guard = with_mock_store();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
Config::default().save_to_path_public(&path).unwrap();
run_auth(&path, AuthArgs::migrate_for_test(false, false)).expect("nothing to do succeeds");
}
}