#![allow(clippy::unwrap_used, clippy::expect_used)]
#![cfg(unix)]
use super::*;
use crate::claude::{LinkState, classify_credentials_link};
use crate::profile::{
AppConfig, AppState, ClaudeCredentials, DivergenceChoice, OAuthToken, Profile, profile_dir,
read_json_file, save_profile,
};
use crate::testutil::HomeSandbox;
fn creds(access: &str, refresh: &str) -> ClaudeCredentials {
ClaudeCredentials {
claude_ai_oauth: Some(OAuthToken {
access_token: access.to_string(),
refresh_token: Some(refresh.to_string()),
expires_at: None,
scopes: None,
subscription_type: None,
}),
}
}
fn creds_expired(access: &str, refresh: &str) -> ClaudeCredentials {
ClaudeCredentials {
claude_ai_oauth: Some(OAuthToken {
access_token: access.to_string(),
refresh_token: Some(refresh.to_string()),
expires_at: Some(crate::usage::now_ms() as i64 - 60_000),
scopes: None,
subscription_type: None,
}),
}
}
fn no_network(
_rt: &str,
) -> std::result::Result<crate::oauth::TokenResponse, crate::oauth::RefreshError> {
Err(crate::oauth::RefreshError::Transient(anyhow::anyhow!(
"no network in tests"
)))
}
fn handle(config: AppConfig) -> crate::profile::ConfigHandle {
std::sync::Arc::new(crate::lockorder::RankedMutex::new(config))
}
fn stored_profile(name: &str, c: Option<ClaudeCredentials>) -> Profile {
let mut p = Profile::new(name.to_string(), None, None);
p.credentials = c;
save_profile(&p).expect("save profile");
p
}
fn seed_diverged(
active: &str,
active_stored: ClaudeCredentials,
live: &ClaudeCredentials,
target: &str,
target_stored: Option<ClaudeCredentials>,
) -> AppConfig {
let active_profile = stored_profile(active, Some(active_stored));
let target_profile = stored_profile(target, target_stored);
let live_path = crate::profile::claude_dir()
.expect("claude dir")
.join(".credentials.json");
std::fs::create_dir_all(live_path.parent().expect("parent")).expect("mkdir .claude");
std::fs::write(&live_path, serde_json::to_vec(live).expect("ser live")).expect("write live");
let mut config = AppConfig {
state: AppState::default(),
profiles: vec![active_profile, target_profile],
};
config.state.active_profile = Some(active.into());
config.state.profiles = vec![active.into(), target.into()];
config
}
#[test]
fn divergence_without_default_errors() {
let _home = HomeSandbox::new();
let live = creds("relogin-a", "relogin-r");
let config = handle(seed_diverged(
"active",
creds("stored-a", "stored-r"),
&live,
"target",
Some(creds("target-a", "target-r")),
));
let err = switch_profile_noninteractive(&config, "target", None, no_network);
assert!(
err.is_err(),
"diverged active with no default must error, never prompt"
);
}
#[test]
fn divergence_new_profile_default_errors() {
let _home = HomeSandbox::new();
let live = creds("relogin-a", "relogin-r");
let config = handle(seed_diverged(
"active",
creds("stored-a", "stored-r"),
&live,
"target",
Some(creds("target-a", "target-r")),
));
let err = switch_profile_noninteractive(
&config,
"target",
Some(DivergenceChoice::NewProfile),
no_network,
);
assert!(
err.is_err(),
"'save as new profile' needs an interactive name prompt; headless must error",
);
}
#[test]
fn divergence_overwrite_captures_relogin_into_outgoing() {
let _home = HomeSandbox::new();
let live = creds("relogin-a", "relogin-r");
let config = handle(seed_diverged(
"active",
creds("stored-a", "stored-r"),
&live,
"target",
Some(creds("target-a", "target-r")),
));
let (previous, active) = switch_profile_noninteractive(
&config,
"target",
Some(DivergenceChoice::Overwrite),
no_network,
)
.expect("overwrite switch");
assert_eq!(previous.as_deref(), Some("active"));
assert_eq!(active, "target");
let on_disk: ClaudeCredentials =
read_json_file(&profile_dir("active").unwrap().join("credentials.json"))
.expect("read outgoing creds");
assert_eq!(
on_disk.refresh_token(),
Some("relogin-r"),
"overwrite must capture the live re-login into the outgoing profile",
);
let live_now: ClaudeCredentials = read_json_file(
&crate::profile::claude_dir()
.unwrap()
.join(".credentials.json"),
)
.expect("read live creds");
assert_eq!(
live_now.refresh_token(),
Some("target-r"),
"overwrite must end with the active link pointing at target",
);
}
#[test]
fn divergence_discard_drops_relogin_and_relinks_target() {
let _home = HomeSandbox::new();
let live = creds("relogin-a", "relogin-r");
let config = handle(seed_diverged(
"active",
creds("stored-a", "stored-r"),
&live,
"target",
Some(creds("target-a", "target-r")),
));
let (previous, active) = switch_profile_noninteractive(
&config,
"target",
Some(DivergenceChoice::Discard),
no_network,
)
.expect("discard switch succeeds — it force-relinks past the divergence guard");
assert_eq!(previous.as_deref(), Some("active"));
assert_eq!(active, "target");
let live_now: ClaudeCredentials = read_json_file(
&crate::profile::claude_dir()
.unwrap()
.join(".credentials.json"),
)
.expect("read live creds");
assert_eq!(
live_now.refresh_token(),
Some("target-r"),
"discard relinks the live file to target's stored creds",
);
let outgoing: ClaudeCredentials =
read_json_file(&profile_dir("active").unwrap().join("credentials.json"))
.expect("read outgoing creds");
assert_eq!(
outgoing.refresh_token(),
Some("stored-r"),
"discard must not capture the live login into the outgoing profile",
);
}
#[test]
fn divergence_discard_to_a_ghost_target_bails_before_side_effects() {
let _home = HomeSandbox::new();
let live = creds("relogin-a", "relogin-r");
let config = handle(seed_diverged(
"active",
creds("stored-a", "stored-r"),
&live,
"target",
Some(creds("target-a", "target-r")),
));
let err = switch_profile_noninteractive(
&config,
"ghost",
Some(DivergenceChoice::Discard),
no_network,
)
.expect_err("a ghost target must bail");
assert!(
err.to_string().contains("not found"),
"bail names the cause, got: {err}"
);
let live_path = crate::profile::claude_dir()
.unwrap()
.join(".credentials.json");
let live_now: ClaudeCredentials = read_json_file(&live_path).expect("live file survives");
assert_eq!(
live_now.refresh_token(),
Some("relogin-r"),
"the ghost bail must not touch the uncaptured live login",
);
assert!(matches!(
classify_credentials_link("active").expect("classify"),
LinkState::Diverged
));
assert_eq!(
config.lock().unwrap().state.active_profile.as_deref(),
Some("active"),
"active profile unchanged after the bail",
);
}
#[test]
fn divergence_overwrite_to_a_ghost_target_bails_before_capture() {
let _home = HomeSandbox::new();
let live = creds("relogin-a", "relogin-r");
let config = handle(seed_diverged(
"active",
creds("stored-a", "stored-r"),
&live,
"target",
Some(creds("target-a", "target-r")),
));
let err = switch_profile_noninteractive(
&config,
"ghost",
Some(DivergenceChoice::Overwrite),
no_network,
)
.expect_err("a ghost target must bail");
assert!(
err.to_string().contains("not found"),
"bail names the cause, got: {err}"
);
let outgoing: ClaudeCredentials =
read_json_file(&profile_dir("active").unwrap().join("credentials.json"))
.expect("read outgoing creds");
assert_eq!(
outgoing.refresh_token(),
Some("stored-r"),
"the ghost bail must not capture the live login into outgoing",
);
let live_now: ClaudeCredentials = read_json_file(
&crate::profile::claude_dir()
.unwrap()
.join(".credentials.json"),
)
.expect("live file survives");
assert_eq!(live_now.refresh_token(), Some("relogin-r"));
}
#[test]
fn non_diverged_switch_takes_plain_path() {
let _home = HomeSandbox::new();
let active_stored = creds("stored-a", "stored-r");
let active_profile = stored_profile("active", Some(active_stored.clone()));
let target_profile = stored_profile("target", Some(creds("target-a", "target-r")));
crate::claude::force_link_profile_credentials("active").expect("link active");
let config = handle(AppConfig {
state: AppState {
active_profile: Some("active".into()),
profiles: vec!["active".into(), "target".into()],
..Default::default()
},
profiles: vec![active_profile, target_profile],
});
assert_eq!(
classify_credentials_link("active").expect("classify"),
LinkState::LinkedTo,
"precondition: active is cleanly linked, not diverged",
);
let (previous, active) =
switch_profile_noninteractive(&config, "target", None, no_network).expect("plain switch");
assert_eq!(previous.as_deref(), Some("active"));
assert_eq!(active, "target");
}
#[test]
fn noninteractive_switch_refuses_a_dead_target_with_login_hint() {
let _home = HomeSandbox::new();
let active_profile = stored_profile("active", Some(creds("stored-a", "stored-r")));
let target_profile = stored_profile("target", Some(creds_expired("dead-a", "dead-r")));
crate::claude::force_link_profile_credentials("active").expect("link active");
let config = handle(AppConfig {
state: AppState {
active_profile: Some("active".into()),
profiles: vec!["active".into(), "target".into()],
..Default::default()
},
profiles: vec![active_profile, target_profile],
});
let revoked = |_: &str| {
Err(crate::oauth::RefreshError::Invalid(
"HTTP 400: refresh token not found or invalid".into(),
))
};
let err = switch_profile_noninteractive(&config, "target", None, revoked)
.expect_err("a revoked target must refuse the switch");
assert!(
err.to_string().contains("clauth login target"),
"the refusal names the recovery, got: {err}"
);
assert!(config.lock().unwrap().is_auth_broken("target"));
assert!(config.lock().unwrap().is_active("active"));
let live_now: ClaudeCredentials = read_json_file(
&crate::profile::claude_dir()
.unwrap()
.join(".credentials.json"),
)
.expect("read live creds");
assert_eq!(
live_now.refresh_token(),
Some("stored-r"),
"the dead target's credentials must never reach the live link",
);
}
#[test]
fn noninteractive_switch_transient_failure_refuses_without_quarantine() {
let _home = HomeSandbox::new();
let active_profile = stored_profile("active", Some(creds("stored-a", "stored-r")));
let target_profile = stored_profile("target", Some(creds_expired("t-a", "t-r")));
crate::claude::force_link_profile_credentials("active").expect("link active");
let config = handle(AppConfig {
state: AppState {
active_profile: Some("active".into()),
profiles: vec!["active".into(), "target".into()],
..Default::default()
},
profiles: vec![active_profile, target_profile],
});
let err = switch_profile_noninteractive(&config, "target", None, no_network)
.expect_err("a transient refresh failure must refuse the switch");
assert!(
err.to_string().contains("could not refresh"),
"the refusal explains the transient cause, got: {err}"
);
assert!(
!config.lock().unwrap().is_auth_broken("target"),
"a transient blip must never quarantine"
);
assert!(config.lock().unwrap().is_active("active"));
}
#[test]
fn switch_to_the_active_profile_never_gates() {
let _home = HomeSandbox::new();
let active_profile = stored_profile("active", Some(creds_expired("live-a", "live-r")));
crate::claude::force_link_profile_credentials("active").expect("link active");
let config = handle(AppConfig {
state: AppState {
active_profile: Some("active".into()),
profiles: vec!["active".into()],
..Default::default()
},
profiles: vec![active_profile],
});
let revoked = |_: &str| {
Err(crate::oauth::RefreshError::Invalid(
"HTTP 400: refresh token not found or invalid".into(),
))
};
let (previous, active) = switch_profile_noninteractive(&config, "active", None, revoked)
.expect("switch-to-active must no-op, never gate");
assert_eq!(previous.as_deref(), Some("active"));
assert_eq!(active, "active");
assert!(
!config.lock().unwrap().is_auth_broken("active"),
"the exempt path must never quarantine the active profile"
);
}