#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::profile::AppState;
use crate::testutil::HomeSandbox;
fn acct_config() -> AppConfig {
AppConfig {
state: AppState::default(),
profiles: vec![Profile::new("acct".to_string(), None, None)],
}
}
#[test]
fn classify_env_key_flags_managed_keys() {
let p = Profile::new("acct".to_string(), None, None);
assert!(matches!(
classify_env_key(&p, &[], "ANTHROPIC_BASE_URL"),
Some(EnvKeyCollision::Managed(_))
));
assert!(matches!(
classify_env_key(&p, &[], "CLAUDE_CODE_SUBAGENT_MODEL"),
Some(EnvKeyCollision::Managed(_))
));
assert_eq!(classify_env_key(&p, &[], "ANTHROPIC_CUSTOM_FLAG"), None);
}
#[test]
fn classify_env_key_flags_own_field_by_sorted_index() {
let mut p = Profile::new("acct".to_string(), None, None);
p.env.insert("ZED".to_string(), "1".to_string());
p.env.insert("ALPHA".to_string(), "2".to_string());
assert_eq!(
classify_env_key(&p, &[], "ALPHA"),
Some(EnvKeyCollision::ProfileField(0))
);
assert_eq!(
classify_env_key(&p, &[], "ZED"),
Some(EnvKeyCollision::ProfileField(1))
);
}
#[test]
fn classify_env_key_base_settings_only_for_external_keys() {
let mut p = Profile::new("acct".to_string(), None, None);
p.env.insert("OWN".to_string(), "1".to_string());
let base = vec![
"OWN".to_string(),
"EXTERNAL".to_string(),
"ANTHROPIC_BASE_URL".to_string(),
];
assert_eq!(
classify_env_key(&p, &base, "EXTERNAL"),
Some(EnvKeyCollision::BaseSettings)
);
assert_eq!(
classify_env_key(&p, &base, "OWN"),
Some(EnvKeyCollision::ProfileField(0))
);
assert!(matches!(
classify_env_key(&p, &base, "ANTHROPIC_BASE_URL"),
Some(EnvKeyCollision::Managed(_))
));
assert_eq!(classify_env_key(&p, &base, "FRESH"), None);
}
#[test]
fn switch_replaces_active_account_mirror_without_refusing() {
let _home = HomeSandbox::new();
let mk = |name: &str, access: &str| {
let mut p = Profile::new(name.to_string(), None, None);
p.credentials = Some(crate::profile::ClaudeCredentials {
claude_ai_oauth: Some(crate::profile::OAuthToken {
access_token: access.to_string(),
refresh_token: Some(format!("{access}-refresh")),
expires_at: None,
scopes: None,
subscription_type: None,
}),
});
crate::profile::save_profile(&p).expect("save profile");
p
};
let active = mk("cl-ax", "cl-ax-access");
let target = mk("xfx", "xfx-access");
let live_path = crate::profile::claude_dir()
.unwrap()
.join(".credentials.json");
std::fs::create_dir_all(live_path.parent().unwrap()).unwrap();
std::fs::write(
&live_path,
serde_json::to_vec(active.credentials.as_ref().unwrap()).unwrap(),
)
.unwrap();
let mut config = AppConfig {
state: AppState::default(),
profiles: vec![active, target],
};
config.state.active_profile = Some("cl-ax".into());
switch_profile(&mut config, "xfx").expect("switch replaces the active-account mirror");
assert!(config.is_active("xfx"));
assert_eq!(
classify_credentials_link("xfx").expect("classify"),
LinkState::LinkedTo,
"after the switch the live path resolves to xfx's stored creds",
);
}
#[test]
fn edit_profile_env_persists_to_config_toml() {
let _home = HomeSandbox::new();
let mut config = acct_config();
let mut env = BTreeMap::new();
env.insert("FOO".to_string(), "bar".to_string());
edit_profile_env(&mut config, "acct", env).expect("set env");
assert_eq!(
config.find("acct").unwrap().env.get("FOO"),
Some(&"bar".to_string())
);
let toml = std::fs::read_to_string(profile_dir("acct").unwrap().join("config.toml"))
.expect("config.toml written");
assert!(
toml.contains("FOO"),
"custom env key persisted to config.toml"
);
edit_profile_env(&mut config, "acct", BTreeMap::new()).expect("clear env");
assert!(config.find("acct").unwrap().env.is_empty());
}
#[test]
fn edit_profile_env_strips_removed_keys_from_live_settings_when_active() {
let _home = HomeSandbox::new();
let mut config = acct_config();
config.state.active_profile = Some("acct".into());
let mut env = BTreeMap::new();
env.insert("KEEP".to_string(), "1".to_string());
env.insert("DROP".to_string(), "2".to_string());
edit_profile_env(&mut config, "acct", env).expect("write both");
let live = crate::claude::claude_settings_env_keys().expect("read settings");
assert!(live.contains(&"KEEP".to_string()) && live.contains(&"DROP".to_string()));
let mut env2 = BTreeMap::new();
env2.insert("KEEP".to_string(), "1".to_string());
edit_profile_env(&mut config, "acct", env2).expect("drop one");
let live = crate::claude::claude_settings_env_keys().expect("read settings");
assert!(live.contains(&"KEEP".to_string()));
assert!(
!live.contains(&"DROP".to_string()),
"a removed key is stripped from the live settings on re-apply"
);
}
#[test]
fn set_profile_default_model_persists_to_config_toml() {
let _home = HomeSandbox::new();
let mut config = acct_config();
set_profile_default_model(&mut config, "acct", "opus").expect("set model");
assert_eq!(
config.find("acct").unwrap().models.default.as_deref(),
Some("opus")
);
let toml = std::fs::read_to_string(profile_dir("acct").unwrap().join("config.toml"))
.expect("config.toml written");
assert!(toml.contains("opus"), "model persisted to config.toml");
}
#[test]
fn set_profile_default_model_preserves_alias_overrides() {
let _home = HomeSandbox::new();
let mut config = acct_config();
edit_profile_model(
&mut config,
"acct",
ModelSettings {
opus: Some("claude-opus-4-8".to_string()),
..ModelSettings::default()
},
)
.expect("seed opus alias");
set_profile_default_model(&mut config, "acct", "sonnet").expect("set default");
let profile = config.find("acct").unwrap();
assert_eq!(profile.models.default.as_deref(), Some("sonnet"));
assert_eq!(
profile.models.opus.as_deref(),
Some("claude-opus-4-8"),
"setting the default must not clobber an existing alias override"
);
}
#[test]
fn set_profile_default_model_blank_clears_default() {
let _home = HomeSandbox::new();
let mut config = acct_config();
set_profile_default_model(&mut config, "acct", "opus").expect("set model");
set_profile_default_model(&mut config, "acct", " ").expect("clear model");
assert!(
config.find("acct").unwrap().models.default.is_none(),
"blank input clears the default, mirroring the Setup tab's ⏎ commit"
);
}
#[test]
fn validate_profile_name_accepts_email_rejects_path_chars() {
for name in [
"claude@domain.com",
"user2@domain.com",
"claude+work@gmail.com",
] {
assert!(
validate_profile_name(name, &[], None).is_ok(),
"{name} rejected"
);
}
for name in ["a/b", "a\\b", "a:b", ".lead", "a b"] {
assert!(
validate_profile_name(name, &[], None).is_err(),
"{name} accepted"
);
}
}
#[test]
fn overwrite_captured_profile_keeps_config_and_history_swaps_credentials() {
let _home = HomeSandbox::new();
let first = Profile::new("first".to_string(), None, None);
save_profile(&first).expect("save first");
let last = Profile::new("last".to_string(), None, None);
save_profile(&last).expect("save last");
let mut target = Profile::new("acme".to_string(), None, None);
target.auto_start = true;
target.env.insert("FOO".to_string(), "bar".to_string());
target.fallback_threshold = Some(42.0);
target.bell_threshold = Some(77.0);
target.models.opus = Some("claude-opus-4".to_string());
target.credentials = Some(ClaudeCredentials {
claude_ai_oauth: Some(crate::profile::OAuthToken {
access_token: "old-access".to_string(),
refresh_token: Some("old-refresh".to_string()),
expires_at: None,
scopes: None,
subscription_type: None,
}),
});
save_profile(&target).expect("save target");
let history_path = profile_dir("acme").unwrap().join("usage_history.jsonl");
std::fs::write(&history_path, b"{\"ts\":1}\n").expect("seed usage history");
for file in [
crate::profile_cache::USAGE_CACHE_FILE,
crate::profile_cache::THIRD_PARTY_CACHE_FILE,
crate::throughput::THROUGHPUT_CACHE_FILE,
] {
crate::profile_cache::write_profile_cache("acme", file, &"stale");
}
let mut config = AppConfig {
state: AppState {
profiles: vec!["first".into(), "acme".into(), "last".into()],
fallback_chain: vec!["first".into(), "acme".into(), "last".into()],
active_profile: Some("first".into()),
..AppState::default()
},
profiles: vec![first, target, last],
};
let snapshot = CaptureSnapshot {
credentials: Some(ClaudeCredentials {
claude_ai_oauth: Some(crate::profile::OAuthToken {
access_token: "new-access".to_string(),
refresh_token: Some("new-refresh".to_string()),
expires_at: None,
scopes: None,
subscription_type: None,
}),
}),
base_url: Some("https://api.example.com".to_string()),
api_key: Some("new-api-key".to_string()),
};
overwrite_captured_profile(&mut config, "acme", snapshot).expect("overwrite in place");
assert_eq!(
config.profiles.len(),
3,
"no duplicate entry from a blind append"
);
let acme = config
.find("acme")
.expect("profile still present under the same name");
assert_eq!(
acme.access_token(),
Some("new-access"),
"credentials replaced"
);
assert_eq!(
acme.base_url.as_deref(),
Some("https://api.example.com"),
"base_url replaced"
);
assert_eq!(
acme.api_key.as_deref(),
Some("new-api-key"),
"api_key replaced"
);
assert!(acme.auto_start, "auto_start config preserved");
assert_eq!(
acme.env.get("FOO"),
Some(&"bar".to_string()),
"env map preserved"
);
assert_eq!(
acme.fallback_threshold,
Some(42.0),
"fallback_threshold preserved"
);
assert_eq!(acme.bell_threshold, Some(77.0), "bell_threshold preserved");
assert_eq!(
acme.models.opus.as_deref(),
Some("claude-opus-4"),
"model settings preserved"
);
assert!(
acme.usage.is_none() && acme.fetch_status.is_none() && acme.third_party_usage.is_none(),
"transient fetch state cleared"
);
assert_eq!(
config.state.fallback_chain,
vec![
crate::profile::ProfileName::from("first"),
crate::profile::ProfileName::from("acme"),
crate::profile::ProfileName::from("last"),
],
"chain position must survive an in-place overwrite, not delete+append"
);
assert_eq!(
std::fs::read_to_string(&history_path).unwrap(),
"{\"ts\":1}\n",
"usage_history.jsonl is the persisted log, not a cache — must survive"
);
for file in [
crate::profile_cache::USAGE_CACHE_FILE,
crate::profile_cache::THIRD_PARTY_CACHE_FILE,
crate::throughput::THROUGHPUT_CACHE_FILE,
] {
let path = crate::profile_cache::profile_cache_path("acme", file).unwrap();
assert!(
!path.exists(),
"{file} must be dropped — it describes the old account"
);
}
}
#[test]
fn overwrite_captured_profile_reapplies_live_state_when_active() {
let _home = HomeSandbox::new();
let mut acme = Profile::new("acme".to_string(), None, None);
acme.credentials = Some(ClaudeCredentials {
claude_ai_oauth: Some(crate::profile::OAuthToken {
access_token: "old-access".to_string(),
refresh_token: Some("old-refresh".to_string()),
expires_at: None,
scopes: None,
subscription_type: None,
}),
});
save_profile(&acme).expect("save acme");
crate::claude::link_profile_credentials("acme").expect("link acme live");
let mut config = AppConfig {
state: AppState {
profiles: vec!["acme".into()],
fallback_chain: vec!["acme".into()],
active_profile: Some("acme".into()),
..AppState::default()
},
profiles: vec![acme],
};
let snapshot = CaptureSnapshot {
credentials: None,
base_url: Some("https://api.example.com".to_string()),
api_key: Some("new-api-key".to_string()),
};
overwrite_captured_profile(&mut config, "acme", snapshot).expect("overwrite active profile");
let live_endpoint = crate::claude::read_claude_endpoint_config().expect("read live endpoint");
assert_eq!(
live_endpoint.base_url.as_deref(),
Some("https://api.example.com"),
"live settings.json must pick up the new base_url immediately, not on next switch"
);
assert_eq!(
live_endpoint.api_key.as_deref(),
Some("new-api-key"),
"live settings.json must pick up the new api_key immediately, not on next switch"
);
let live_path = crate::profile::claude_dir()
.unwrap()
.join(".credentials.json");
assert!(
live_path.symlink_metadata().is_err(),
"no dangling .credentials.json symlink after credentials go to None while active"
);
}
#[test]
fn clear_profile_credentials_blanks_active_profile_keeping_shell() {
let _home = HomeSandbox::new();
let mut acct = Profile::new("acct".to_string(), None, None);
acct.auto_start = true;
acct.env.insert("FOO".to_string(), "bar".to_string());
acct.models.opus = Some("claude-opus-4".to_string());
acct.credentials = Some(ClaudeCredentials {
claude_ai_oauth: Some(crate::profile::OAuthToken {
access_token: "acc".to_string(),
refresh_token: Some("ref".to_string()),
expires_at: None,
scopes: None,
subscription_type: None,
}),
});
save_profile(&acct).expect("save acct");
crate::claude::link_profile_credentials("acct").expect("link acct live");
for file in [
crate::profile_cache::USAGE_CACHE_FILE,
crate::profile_cache::THIRD_PARTY_CACHE_FILE,
crate::throughput::THROUGHPUT_CACHE_FILE,
] {
crate::profile_cache::write_profile_cache("acct", file, &"stale");
}
let mut config = AppConfig {
state: AppState {
profiles: vec!["acct".into()],
fallback_chain: vec!["acct".into()],
active_profile: Some("acct".into()),
..AppState::default()
},
profiles: vec![acct],
};
clear_profile_credentials(&mut config, "acct").expect("clear credentials");
let profile = config.find("acct").expect("profile still present");
assert!(profile.credentials.is_none(), "credentials dropped");
assert!(profile.auto_start, "shell preserved: auto_start");
assert_eq!(
profile.env.get("FOO"),
Some(&"bar".to_string()),
"shell preserved: env"
);
assert_eq!(
profile.models.opus.as_deref(),
Some("claude-opus-4"),
"shell preserved: model"
);
assert!(
config.state.active_profile.is_none(),
"active profile deactivated"
);
let cred_path = profile_dir("acct").unwrap().join("credentials.json");
assert!(!cred_path.exists(), "credentials.json removed");
for file in [
crate::profile_cache::USAGE_CACHE_FILE,
crate::profile_cache::THIRD_PARTY_CACHE_FILE,
crate::throughput::THROUGHPUT_CACHE_FILE,
] {
let path = crate::profile_cache::profile_cache_path("acct", file).unwrap();
assert!(!path.exists(), "{file} must be dropped");
}
let live_path = crate::profile::claude_dir()
.unwrap()
.join(".credentials.json");
assert!(
live_path.symlink_metadata().is_err(),
"live .credentials.json link cleared on blanking the active profile"
);
}
#[test]
fn clear_profile_credentials_non_active_and_no_sidecar_resurrection() {
let _home = HomeSandbox::new();
let creds = || ClaudeCredentials {
claude_ai_oauth: Some(crate::profile::OAuthToken {
access_token: "acc".to_string(),
refresh_token: Some("ref".to_string()),
expires_at: None,
scopes: None,
subscription_type: None,
}),
};
let mut acct = Profile::new("acct".to_string(), None, None);
acct.credentials = Some(creds());
save_profile(&acct).expect("save acct");
crate::profile::stage_rotated_credentials("acct", &creds()).expect("stage sidecar");
let mut other = Profile::new("other".to_string(), None, None);
other.credentials = Some(creds());
save_profile(&other).expect("save other");
crate::claude::link_profile_credentials("other").expect("link other live");
let mut config = AppConfig {
state: AppState {
profiles: vec!["acct".into(), "other".into()],
fallback_chain: vec!["acct".into(), "other".into()],
active_profile: Some("other".into()),
..AppState::default()
},
profiles: vec![acct, other],
};
crate::profile::save_app_state(&config.state).expect("persist state");
clear_profile_credentials(&mut config, "acct").expect("clear credentials");
assert_eq!(
config.state.active_profile.as_deref(),
Some("other"),
"blanking a non-active profile leaves the active one set"
);
let live_path = crate::profile::claude_dir()
.unwrap()
.join(".credentials.json");
assert!(
live_path.symlink_metadata().is_ok(),
"the active profile's live link survives a non-active blank"
);
let reloaded = crate::profile::load_config().expect("reload config");
let acct = reloaded.find("acct").expect("acct still present");
assert!(
acct.credentials.is_none(),
"a lingering sidecar must not resurrect the blanked login"
);
let cred_path = profile_dir("acct").unwrap().join("credentials.json");
assert!(
!cred_path.exists(),
"credentials.json stays gone after reload (sidecar not adopted)"
);
}
fn home_claude_json_path() -> std::path::PathBuf {
crate::profile::home_dir().unwrap().join(".claude.json")
}
fn write_home_claude_json_with_identity() {
std::fs::write(
home_claude_json_path(),
serde_json::to_vec_pretty(&serde_json::json!({
"oauthAccount": {"emailAddress": "stale@x"},
"numStartups": 7,
}))
.unwrap(),
)
.expect("write home .claude.json");
}
#[test]
fn finish_switch_deletes_stale_oauth_account_block() {
let _home = HomeSandbox::new();
write_home_claude_json_with_identity();
let mut config = acct_config();
finish_switch(&mut config, "acct").expect("finish_switch");
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(home_claude_json_path()).unwrap()).unwrap();
assert!(
after.get("oauthAccount").is_none(),
"the outgoing account's identity block must be gone after a switch"
);
assert_eq!(
after["numStartups"],
serde_json::json!(7),
"unrelated keys must survive the switch untouched"
);
}
#[test]
fn switch_off_also_deletes_stale_oauth_account_block() {
let _home = HomeSandbox::new();
write_home_claude_json_with_identity();
let profile = Profile::new("acct".to_string(), None, None);
save_profile(&profile).expect("save profile");
crate::claude::link_profile_credentials("acct").expect("link acct live");
let mut config = AppConfig {
state: AppState {
profiles: vec!["acct".into()],
active_profile: Some("acct".into()),
..AppState::default()
},
profiles: vec![profile],
};
switch_off(&mut config).expect("switch_off");
assert!(config.state.active_profile.is_none());
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(home_claude_json_path()).unwrap()).unwrap();
assert!(
after.get("oauthAccount").is_none(),
"no active account remains, so the stale identity block must be gone too"
);
assert_eq!(after["numStartups"], serde_json::json!(7));
}