use super::harness::{audit_lines, machine, owner, recover, state_file};
use super::*;
use crate::api::scripted::Trouble;
use crate::service::Permit;
#[test]
fn a_parked_login_anthropic_refuses_is_not_installed() {
let m = machine("refused-park");
m.api
.token_trouble("access-there-refresh", Trouble::Unauthorized);
m.api.renew_trouble("there-refresh", Trouble::InvalidGrant);
let live_before = m.mem.live().peek(&m.service);
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let failed = switch(settled, &Key::new(ProviderId::Claude, "there"))
.expect_err("a refused login is not a switch");
assert!(
matches!(failed, Error::ParkedLoginRefused { .. }),
"got {failed:?}"
);
assert_eq!(
m.mem.live().peek(&m.service),
live_before,
"the login that was working is exactly where it was"
);
let state = state::load(&m.ctx).expect("state");
assert!(
state
.get(&Key::new(ProviderId::Claude, "here"))
.expect("account")
.parked
.is_none(),
"and it was never parked, because nothing was taken away"
);
let there = state
.get(&Key::new(ProviderId::Claude, "there"))
.expect("account");
assert_eq!(there.email, "there@example.com", "the account is kept");
assert!(there.parked.is_none(), "the copy that cannot work is not");
assert!(
state
.discarded
.iter()
.any(|d| d.service.starts_with("pitboard-park-there-")),
"the dead copy is listed for deletion, so a delete that fails is retried"
);
recover(&m).expect("the next command settles");
assert!(
m.mem.vault().services().is_empty(),
"and that is when it goes"
);
}
#[cfg(windows)]
#[test]
fn a_login_too_large_for_credential_manager_is_refused_before_anything_moves() {
let m = machine("too-large-for-pieces");
let mut login: Value =
serde_json::from_str(&m.mem.live().peek(&m.service).expect("a live login")).expect("JSON");
login["mcpOAuth"]["large"] =
serde_json::json!("x".repeat(provider::claude::pieces::MOST_BYTES));
m.mem.live().plant(&m.service, &login.to_string());
let (live_before, vault_before) = (m.mem.live().services(), m.mem.vault().services());
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let failed = switch(settled, &Key::new(ProviderId::Claude, "there")).expect_err("too large");
assert_eq!(failed.code(), "credential_too_large");
assert_eq!(m.mem.live().services(), live_before);
assert_eq!(m.mem.vault().services(), vault_before);
assert!(!journal::pending(&m.ctx));
}
#[test]
fn a_parked_login_that_belongs_to_another_account_is_refused() {
let m = machine("misfiled-park");
m.api
.owned_by("access-there-refresh", owner("somebody-else"));
let live_before = m.mem.live().peek(&m.service);
let parked_before = m.mem.vault().services();
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let failed = switch(settled, &Key::new(ProviderId::Claude, "there"))
.expect_err("it is not that account's login");
assert!(
matches!(failed, Error::ParkedLoginBelongsElsewhere { .. }),
"got {failed:?}"
);
assert_eq!(m.mem.live().peek(&m.service), live_before);
assert_eq!(
m.mem.vault().services(),
parked_before,
"nothing is deleted over a park Pitboard will not use"
);
recover(&m).expect("and the machine is untouched");
}
#[test]
fn a_switch_will_not_install_a_login_it_could_not_ask_about() {
let m = machine("offline-in");
m.api
.token_trouble("access-there-refresh", Trouble::Offline);
let live_before = m.mem.live().peek(&m.service);
let parked_before = m.mem.vault().services();
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let failed =
switch(settled, &Key::new(ProviderId::Claude, "there")).expect_err("nobody could be asked");
assert!(
matches!(
failed,
Error::IdentityUnverifiable { .. } | Error::RenewalFailed { .. }
),
"got {failed:?}"
);
assert_eq!(m.mem.live().peek(&m.service), live_before);
assert_eq!(m.mem.vault().services(), parked_before);
assert!(!journal::pending(&m.ctx), "and nothing was begun");
}
#[test]
fn a_park_that_answers_for_its_own_account_is_installed() {
let m = machine("proved");
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let (outcome, _) = switch(settled, &Key::new(ProviderId::Claude, "there")).expect("a switch");
assert!(matches!(outcome, Outcome::Switched { .. }), "{outcome:?}");
let asked = m.api.asked();
assert!(
asked.iter().any(
|q| matches!(q, crate::api::scripted::Asked::Identity(t) if t == "access-there-refresh")
),
"the login going in is asked about, not only the one coming out: {asked:?}"
);
}
#[test]
fn a_switch_from_a_login_already_identified_asks_only_about_the_login_going_in() {
let m = machine("known-going-out");
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let (outcome, _) = switch(settled, &m.key("there")).expect("a switch");
assert!(matches!(outcome, Outcome::Switched { .. }), "{outcome:?}");
assert_eq!(
m.api.asked(),
[crate::api::scripted::Asked::Identity(
"access-there-refresh".into()
)]
);
}
#[test]
fn a_login_pitboard_cannot_find_is_not_the_same_as_nobody_being_signed_in() {
let m = machine("elsewhere");
m.mem.live().delete_everything();
let recorded = state_file(&m);
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let failed = switch(settled, &Key::new(ProviderId::Claude, "there"))
.expect_err("there is nothing to move");
match &failed {
Error::LiveCredentialElsewhere { email } => assert_eq!(email, "here@example.com"),
other => panic!("got {other:?}"),
}
assert_eq!(failed.code(), "live_credential_elsewhere");
assert_eq!(state_file(&m), recorded);
assert!(crate::audit::read(&m.ctx, 100).is_empty());
std::fs::write(m.ctx_home().join(".claude.json"), "{}").expect("a config");
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let failed =
switch(settled, &Key::new(ProviderId::Claude, "there")).expect_err("still nothing to move");
assert!(
matches!(failed, Error::LiveCredentialAbsent { .. }),
"got {failed:?}"
);
assert_eq!(
audit_lines(&m, "in-use"),
[
(String::new(), "signed_out_outside".to_string()),
("here".to_string(), "login_replaced".to_string()),
]
);
}
#[test]
#[cfg_attr(
target_os = "linux",
ignore = "on Linux the file is Claude Code's only store, never behind a keychain"
)]
fn a_switch_with_a_file_behind_the_keychain_says_sessions_take_it_at_renewal() {
let switched = |m: &harness::Machine| {
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
switch(settled, &m.key("there")).expect("a switch").0
};
let m = machine("adoption-nothing-behind");
assert!(
matches!(
switched(&m),
Outcome::Switched {
adoption: provider::Adoption::PollingWithin(33),
..
}
),
"nothing behind the keychain"
);
for (name, unreadable) in [
("adoption-file-behind", false),
("adoption-unreadable-behind", true),
] {
let m = machine(name);
let file = provider::claude::live::credential_file(&m.ctx);
let behind = m.mem.file_at(file.clone());
behind.plant(&m.service, "{}");
if unreadable {
behind.fault(
&m.service,
store::memory::Fault::UnreadableContents("permission denied".into()),
);
}
let outcome = switched(&m);
let Outcome::Switched { adoption, .. } = outcome else {
panic!("{name}: a switch, not {outcome:?}");
};
assert_eq!(adoption, provider::Adoption::AtRenewal { file }, "{name}");
}
}
fn config_names_now(m: &harness::Machine) -> String {
let config: Value = serde_json::from_str(
&std::fs::read_to_string(m.ctx_home().join(".claude.json")).expect("a config"),
)
.expect("JSON");
config["oauthAccount"]["accountUuid"]
.as_str()
.unwrap_or_default()
.to_owned()
}
#[test]
fn using_the_account_in_use_writes_claude_codes_config_again() {
let m = machine("use-names-again");
harness::config_names(&m, "there");
let live = m.live();
let pitboard = crate::service::Pitboard::new(m.ctx.clone());
let done = pitboard.switch_to("here").expect("already in use");
assert!(
matches!(
done.value,
Outcome::AlreadyActive {
config_updated: true,
..
}
),
"{:?}",
done.value
);
assert!(done.warnings.is_empty(), "{:?}", done.warnings);
assert_eq!(config_names_now(&m), "here");
assert_eq!(m.live(), live, "no login moved");
let backups = std::fs::read_dir(home::dir(&m.ctx).join("backups"))
.map(Iterator::count)
.unwrap_or_default();
assert_eq!(backups, 1, "a copy of the config is kept first");
assert_eq!(
audit_lines(&m, "use"),
[("here".to_string(), "config_updated".to_string())]
);
let read = pitboard.status_offline().expect("a read of what is known");
let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
assert!(codes.is_empty(), "{codes:?}");
let again = pitboard.switch_to("here").expect("already in use");
assert!(
matches!(
again.value,
Outcome::AlreadyActive {
config_updated: false,
..
}
),
"{:?}",
again.value
);
assert!(again.warnings.is_empty(), "{:?}", again.warnings);
assert_eq!(
audit_lines(&m, "use"),
[
("here".to_string(), "config_updated".to_string()),
("here".to_string(), "already_active".to_string()),
]
);
}
#[test]
fn a_config_that_could_not_be_written_again_is_said() {
let m = machine("use-names-again-refused");
harness::config_names(&m, "there");
std::fs::write(home::dir(&m.ctx).join("backups"), "").expect("in the way");
let config = std::fs::read(m.ctx_home().join(".claude.json")).expect("a config");
let pitboard = crate::service::Pitboard::new(m.ctx.clone());
let done = pitboard.switch_to("here").expect("already in use");
assert!(
matches!(
done.value,
Outcome::AlreadyActive {
config_updated: false,
..
}
),
"{:?}",
done.value
);
let codes: Vec<&str> = done.warnings.iter().map(Warning::code).collect();
assert_eq!(codes, ["config_backup_failed"]);
assert_eq!(
std::fs::read(m.ctx_home().join(".claude.json")).expect("a config"),
config
);
assert_eq!(
audit_lines(&m, "use"),
[("here".to_string(), "already_active".to_string())]
);
let read = pitboard.status_offline().expect("a read of what is known");
let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
assert_eq!(codes, ["config_names_another"]);
}
#[test]
fn a_switch_whose_config_could_not_be_written_is_said_to_name_another() {
let m = machine("switch-config-refused");
std::fs::write(home::dir(&m.ctx).join("backups"), "").expect("in the way");
let pitboard = crate::service::Pitboard::new(m.ctx.clone());
let done = pitboard.switch_to("there").expect("a switch");
assert!(
matches!(done.value, Outcome::Switched { .. }),
"{:?}",
done.value
);
let codes: Vec<&str> = done.warnings.iter().map(Warning::code).collect();
assert_eq!(codes, ["config_backup_failed"]);
assert_eq!(config_names_now(&m), "here");
let read = pitboard.status_offline().expect("a read of what is known");
let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
assert_eq!(codes, ["config_names_another"]);
}
#[test]
fn the_config_is_written_only_while_the_account_is_in_use() {
let m = machine("name-again");
harness::config_names(&m, "there");
let pitboard = crate::service::Pitboard::new(m.ctx.clone());
let done = pitboard.update_config("here").expect("in use");
assert!(
matches!(
done.value,
Outcome::AlreadyActive {
config_updated: true,
..
}
),
"{:?}",
done.value
);
assert_eq!(config_names_now(&m), "here");
pitboard.switch_to("there").expect("a switch");
let live = m.live();
let config = std::fs::read(m.ctx_home().join(".claude.json")).expect("a config");
let refused = pitboard.update_config("here").expect_err("not in use");
assert!(
matches!(refused.error, Error::AccountNotInUse { .. }),
"{:?}",
refused.error
);
assert_eq!(m.live(), live, "no login moved");
assert_eq!(
std::fs::read(m.ctx_home().join(".claude.json")).expect("a config"),
config
);
assert_eq!(
audit_lines(&m, "use"),
[
("here".to_string(), "config_updated".to_string()),
("there".to_string(), "ok".to_string()),
("here".to_string(), "account_not_in_use".to_string()),
]
);
}
fn settled(m: &harness::Machine) -> Settled {
settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0
}
fn dead_in_use(name: &str) -> harness::Machine {
let m = machine(name);
m.sign_in(&harness::lapsed("here-dead"));
m.api.renew_trouble("here-dead", Trouble::InvalidGrant);
m
}
#[test]
fn a_switch_from_a_login_anthropic_refuses_for_good_asks_first_and_moves_nothing() {
let m = dead_in_use("replace-asks");
let before = m.mem.live().peek(&m.service);
let asked = switch(settled(&m), &m.key("there")).expect_err("it asks first");
assert!(
matches!(&asked, Error::LiveLoginRefused { label, .. } if label == "there"),
"{asked:?}"
);
assert_eq!(asked.code(), "live_login_refused");
assert_eq!(m.mem.live().peek(&m.service), before, "nothing moved");
}
#[test]
fn a_login_anthropic_refuses_for_good_is_replaced_once_agreed_to() {
let m = dead_in_use("replace-agreed");
let (outcome, _) = replace(settled(&m), &m.key("there")).expect("replaced");
assert!(
matches!(&outcome, Outcome::Installed { to, .. } if to == "there"),
"{outcome:?}"
);
assert_eq!(
m.live()
.and_then(|live| live.pointer("/claudeAiOauth/refreshToken").cloned()),
Some(serde_json::json!("there-refresh"))
);
let state = state::load(&m.ctx).expect("state");
assert!(state.get(&m.key("there")).expect("there").parked.is_none());
assert!(state.get(&m.key("here")).expect("here").parked.is_none());
assert_eq!(
state
.account_in_use(ProviderId::Claude)
.map(|a| a.label.as_str()),
Some("there")
);
}
#[test]
fn a_login_signed_in_since_the_refusal_is_parked_not_written_over() {
let m = dead_in_use("replace-signed-in-since");
switch(settled(&m), &m.key("there")).expect_err("it asks first");
m.api.owned_by("access-here-again", owner("here"));
m.sign_in(&harness::document("here-again"));
let (outcome, _) = replace(settled(&m), &m.key("there")).expect("a switch");
assert!(matches!(outcome, Outcome::Switched { .. }), "{outcome:?}");
let state = state::load(&m.ctx).expect("state");
let parked = state.get(&m.key("here")).and_then(|a| a.parked.clone());
assert_eq!(
parked.map(|p| p.login),
Some(store::fingerprint("here-again")),
"the login signed in since is parked"
);
}
#[test]
fn an_account_on_hold_or_an_outage_is_never_offered_a_replacement() {
for (trouble, code) in [
(Trouble::OnHold, "account_on_hold"),
(Trouble::Offline, "identity_unverifiable"),
] {
let m = machine(&format!("replace-not-{code}"));
m.sign_in(&harness::lapsed("here-held"));
m.api.renew_trouble("here-held", trouble);
let before = m.mem.live().peek(&m.service);
let refused = switch(settled(&m), &m.key("there")).expect_err("no switch");
assert_eq!(refused.code(), code, "{refused:?}");
let refused = replace(settled(&m), &m.key("there")).expect_err("no replacement");
assert_eq!(refused.code(), code, "{refused:?}");
assert_eq!(
m.mem.live().peek(&m.service),
before,
"{code}: nothing moved"
);
}
}
#[test]
fn a_login_anthropic_will_not_name_is_never_offered_for_replacing() {
let m = machine("replace-unnamed");
m.sign_in(&harness::document("here-unnamed"));
m.api.token_trouble(
"access-here-unnamed",
Trouble::Answered(
400,
r#"{"error":{"type":"invalid_request_error","message":"no"}}"#,
),
);
let before = m.mem.live().peek(&m.service);
for change in [switch, replace] {
let refused = change(settled(&m), &m.key("there")).expect_err("no switch");
assert_eq!(refused.code(), "identity_unverifiable", "{refused:?}");
}
assert_eq!(m.mem.live().peek(&m.service), before, "nothing moved");
}
#[test]
fn a_login_renewed_then_refused_a_name_is_never_written_over() {
let m = machine("replace-renewed-then-refused");
m.sign_in(&harness::lapsed("here-lapsed"));
harness::renews(&m, "here-lapsed", "here-renewed");
m.api.token_trouble(
"access-here-renewed",
Trouble::Answered(404, r#"{"error":{"type":"not_found_error"}}"#),
);
let refused = replace(settled(&m), &m.key("there")).expect_err("no replacement");
assert_eq!(refused.code(), "identity_unverifiable", "{refused:?}");
assert_eq!(
m.live()
.and_then(|live| live.pointer("/claudeAiOauth/refreshToken").cloned()),
Some(serde_json::json!("here-renewed")),
"the renewed login is where the renewal saved it"
);
}
#[test]
fn a_login_validated_without_an_organisation_is_told_by_claude_codes_record() {
let m = machine("validate-unorganised");
m.sign_in(&harness::document("here-again"));
m.api.owned_by("access-here-again", owner("here"));
m.api.validated_without_organisation("access-here-again");
m.api.profile_trouble(
"access-here-again",
Trouble::Answered(400, r#"{"error":{"type":"invalid_request_error"}}"#),
);
let (outcome, _) = switch(settled(&m), &m.key("there")).expect("a switch");
assert!(matches!(outcome, Outcome::Switched { .. }), "{outcome:?}");
}
#[test]
fn a_replacement_killed_once_written_is_finished_by_the_next_change() {
for rotated in [false, true] {
let m = dead_in_use(&format!("replace-killed-{rotated}"));
let died = fault::killing("replace.installed", || {
replace(settled(&m), &m.key("there")).map(|_| ())
});
assert_eq!(died.err().as_deref(), Some("replace.installed"));
if rotated {
m.api.owned_by("access-there-rotated", owner("there"));
m.sign_in(&harness::document("there-rotated"));
}
assert!(
journal::pending(&m.ctx),
"{rotated}: the replacement waits, which holds every renewal of a park back"
);
assert!(
renew_due(&m.ctx, Permit::for_a_test(), Due::ToStayAlive).is_empty(),
"{rotated}"
);
drop(settled(&m));
let state = state::load(&m.ctx).expect("state");
assert!(
state.get(&m.key("there")).expect("there").parked.is_none(),
"{rotated}: the park now in use is dropped"
);
assert_eq!(
state
.account_in_use(ProviderId::Claude)
.map(|a| a.label.as_str()),
Some("there"),
"{rotated}"
);
}
}
#[test]
fn a_login_whose_email_cannot_be_learned_is_not_offered_for_replacing() {
let m = machine("replace-unnamed-email");
m.sign_in(&harness::document("stranger"));
m.api.owned_by("access-stranger", owner("stranger"));
m.api.profile_trouble(
"access-stranger",
Trouble::Answered(400, r#"{"error":{"type":"invalid_request_error"}}"#),
);
let refused = switch(settled(&m), &m.key("there")).expect_err("no switch");
assert!(
matches!(
&refused,
Error::IdentityUnverifiable {
login: crate::error::Unidentified::Unnamed,
..
}
),
"{refused:?}"
);
}
#[test]
fn a_replacement_of_a_login_with_no_refresh_token_killed_before_its_write_is_undone() {
let m = machine("replace-no-refresh");
let mut lapsed = harness::lapsed("here-none");
lapsed["claudeAiOauth"]
.as_object_mut()
.expect("a login")
.remove("refreshToken");
m.sign_in(&lapsed);
let asked = switch(settled(&m), &m.key("there")).expect_err("it asks first");
assert_eq!(asked.code(), "live_login_refused", "{asked:?}");
let died = fault::killing("replace.journal_written", || {
replace(settled(&m), &m.key("there")).map(|_| ())
});
assert_eq!(died.err().as_deref(), Some("replace.journal_written"));
let (settled, warnings) = settle(&m.ctx, Permit::for_a_test(), None).expect("settled");
drop(settled);
let said: Vec<String> = warnings.iter().map(ToString::to_string).collect();
assert!(
said.iter()
.any(|w| w.starts_with("an earlier switch to `there` was interrupted; it had not")),
"{said:?}"
);
let state = state::load(&m.ctx).expect("state");
assert!(
state.get(&m.key("there")).expect("there").parked.is_some(),
"the park going in is kept"
);
assert!(!journal::pending(&m.ctx));
}