use super::harness::{
NOW, POINTS, codex_machine, document, hold, machine, owner, recover, signed_in,
};
use super::*;
use crate::api::scripted::{ScriptedApi, Trouble};
use crate::fault;
use crate::store::memory::Fault;
use serde_json::json;
use std::sync::Arc;
#[test]
fn a_switch_killed_at_any_step_recovers_to_something_whole() {
type Make = fn(&str) -> super::harness::Machine;
let machines: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
for (tool, make) in machines {
for point in POINTS {
let m = make(&point.replace('.', "-"));
let settled = settle(&m.ctx, None).expect("nothing to recover yet").0;
let died = fault::killing(point, || switch(settled, &m.key("there")));
assert_eq!(
died.unwrap_err(),
point,
"{tool}: the switch must reach {point} on this machine, or the case proves \
nothing"
);
let at = format!("{tool}, {point}");
recover(&m).unwrap_or_else(|e| panic!("{at}: recovery refused: {e}"));
hold(&m, &at);
recover(&m).unwrap_or_else(|e| panic!("{at}: the second recovery refused: {e}"));
hold(&m, &format!("{at}, recovered twice"));
}
}
}
#[test]
fn a_switch_killed_with_nobody_to_ask_is_recovered_from_the_record() {
for point in POINTS {
let m = machine(&format!("offline-{}", point.replace('.', "-")));
let settled = settle(&m.ctx, None).expect("nothing to recover yet").0;
let died = fault::killing(point, || {
switch(
settled,
&crate::state::Key::new(crate::provider::ProviderId::Claude, "there"),
)
});
assert_eq!(died.unwrap_err(), point);
let offline = ScriptedApi::new();
let ctx = m.ctx.clone().with_scripted_api(Arc::clone(&offline));
offline.token_trouble("access-here-refresh", Trouble::Offline);
offline.token_trouble("access-there-refresh", Trouble::Offline);
let decided = settle(&ctx, None).is_ok();
assert!(
decided,
"{point}: recovery should read the live login's fingerprint rather than \
needing Anthropic"
);
assert_eq!(
offline.calls(),
0,
"{point}: and it should not have asked at all"
);
hold(&m, &format!("{point}, recovered with no network"));
recover(&m).unwrap_or_else(|e| panic!("{point}: recovery refused once back: {e}"));
hold(&m, &format!("{point}, then online"));
}
}
#[test]
fn a_switch_whose_token_rotated_while_it_was_interrupted_still_needs_anthropic() {
let m = machine("rotated-offline");
let settled = settle(&m.ctx, None).expect("nothing to recover yet").0;
let died = fault::killing("switch.park_recorded", || {
switch(
settled,
&crate::state::Key::new(crate::provider::ProviderId::Claude, "there"),
)
});
assert_eq!(died.unwrap_err(), "switch.park_recorded");
m.mem.live().plant(
&m.service,
&json!({"claudeAiOauth": {
"refreshToken": "rotated-since",
"accessToken": "access-here-refresh",
"expiresAt": (NOW + 3600) * 1000,
"refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000,
}})
.to_string(),
);
let before = m.mem.vault().services();
let live_before = m.mem.live().peek(&m.service);
let offline = ScriptedApi::new();
let ctx = m.ctx.clone().with_scripted_api(Arc::clone(&offline));
offline.token_trouble("access-here-refresh", Trouble::Offline);
assert!(
settle(&ctx, None).is_err(),
"with nothing to read off and nobody to ask, this must not be guessed at"
);
assert_eq!(m.mem.vault().services(), before, "nothing may be deleted");
assert_eq!(m.mem.live().peek(&m.service), live_before);
assert!(
journal::pending(&ctx),
"and the record is kept for a later run"
);
}
#[test]
fn enrolling_killed_between_the_write_and_the_record_leaves_nothing_unnamed() {
for point in ["enroll.park_stored", "enroll.park_recorded"] {
let m = machine(&point.replace('.', "-"));
m.api.owned_by("access-third-refresh", owner("third"));
let settled = settle(&m.ctx, None).expect("nothing to recover").0;
let login = enroll::planted(&m.ctx, ProviderId::Claude, document("third-refresh"))
.expect("a sign-in");
let died = fault::killing(point, || {
enroll(
settled,
&crate::state::Key::new(crate::provider::ProviderId::Claude, "third"),
Some(login),
)
});
assert_eq!(died.unwrap_err(), point);
recover(&m).unwrap_or_else(|e| panic!("{point}: recovery refused: {e}"));
hold(&m, point);
}
}
#[test]
fn signing_in_again_killed_at_any_step_recovers_to_something_whole() {
type Make = fn(&str) -> super::harness::Machine;
let machines: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
for (tool, make) in machines {
for point in ["enroll.installed", "enroll.recorded"] {
let m = make(&format!("again-{}", point.replace('.', "-")));
let login = signed_in(&m, "here", "here-refresh-2");
let settled = settle(&m.ctx, None).expect("nothing to recover yet").0;
let died = fault::killing(point, || enroll(settled, &m.key("here"), Some(login)));
assert_eq!(
died.unwrap_err(),
point,
"{tool}: the sign-in must reach {point} on this machine, or the case proves \
nothing"
);
let at = format!("{tool}, {point}");
recover(&m).unwrap_or_else(|e| panic!("{at}: recovery refused: {e}"));
hold(&m, &at);
let live = m.live().expect("a login in use");
assert_eq!(
crate::provider::of(m.which).fingerprint(&live),
store::fingerprint("here-refresh-2"),
"{at}: the new login is the one in use"
);
recover(&m).unwrap_or_else(|e| panic!("{at}: the second recovery refused: {e}"));
hold(&m, &format!("{at}, recovered twice"));
}
}
}
#[test]
fn a_switch_whose_login_was_removed_again_does_not_report_a_switch() {
let m = machine("did-not-hold");
let parked_before = m.mem.vault().services();
let settled = settle(&m.ctx, None).expect("nothing to recover yet").0;
m.mem.live().fault(&m.service, Fault::DeletedAfterWrite);
let failed = switch(
settled,
&crate::state::Key::new(crate::provider::ProviderId::Claude, "there"),
)
.expect_err("the login did not stay");
assert!(
matches!(failed, Error::SwitchDidNotHold { .. }),
"got {failed:?}"
);
let state = state::load(&m.ctx).expect("state");
assert!(
state
.get(&crate::state::Key::new(
crate::provider::ProviderId::Claude,
"here"
))
.expect("account")
.parked
.is_some(),
"the outgoing login was parked and stays parked"
);
assert!(
state
.get(&crate::state::Key::new(
crate::provider::ProviderId::Claude,
"there"
))
.expect("account")
.parked
.is_some(),
"the incoming login is not discarded over a switch that did not stand"
);
assert!(m.mem.vault().services().len() > parked_before.len());
assert!(
!journal::pending(&m.ctx),
"and there is nothing half-done to finish"
);
hold(&m, "did not hold");
}
#[test]
fn a_switch_that_cannot_read_the_store_back_keeps_every_copy_and_its_record() {
let m = machine("unverified");
let before = m.mem.live().peek(&m.service).expect("a live login");
let parked_before = m.mem.vault().services();
let settled = settle(&m.ctx, None).expect("nothing to recover yet").0;
m.mem.live().fault(&m.service, Fault::LocksOnWrite);
let failed = switch(
settled,
&crate::state::Key::new(crate::provider::ProviderId::Claude, "there"),
)
.expect_err("a keychain that locked partway");
assert!(
matches!(failed, Error::SwitchUnverified { .. }),
"got {failed:?}"
);
assert!(
journal::pending(&m.ctx),
"the record of intent stays, because nobody can say what happened"
);
assert_eq!(
m.mem.live().peek(&m.service).as_deref(),
Some(before.as_str()),
"and nothing was written"
);
let state = state::load(&m.ctx).expect("state");
assert!(
state.discarded.is_empty(),
"nothing may be listed for deletion on a guess"
);
assert!(
m.mem.vault().services().len() >= parked_before.len(),
"and no parked login was thrown away"
);
m.mem.live().heal_all();
recover(&m).expect("recovery once the keychain is unlocked");
hold(&m, "unverified, then unlocked");
}
#[test]
fn renewing_killed_between_the_write_and_the_record_leaves_nothing_unnamed() {
let m = machine("renew-park-stored");
let mut state = state::load(&m.ctx).expect("state");
let park = state
.get(&crate::state::Key::new(
crate::provider::ProviderId::Claude,
"there",
))
.expect("account")
.parked
.clone()
.expect("parked");
m.mem.vault().plant(
&park.service,
&json!({
"accessToken": "access-there-refresh",
"refreshToken": "there-refresh",
"expiresAt": (NOW - 60) * 1000,
"refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
})
.to_string(),
);
state.park(
&crate::state::Key::new(crate::provider::ProviderId::Claude, "there"),
park::describe(
crate::provider::ProviderId::Claude,
&park.service,
NOW,
&json!({
"refreshToken": "there-refresh",
"expiresAt": (NOW - 60) * 1000,
"refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
}),
),
);
state::save(&m.ctx, &state).expect("saved");
m.api.renews(
"there-refresh",
crate::api::Renewed {
access_token: "access-fresh".into(),
refresh_token: Some("fresh".into()),
expires_in: 3600,
refresh_token_expires_in: Some(30 * 86_400),
scopes: None,
at: None,
},
);
let died = fault::killing("renew.park_stored", || renew::renew_parked(&m.ctx));
assert_eq!(died.unwrap_err(), "renew.park_stored");
recover(&m).expect("recovery");
hold(&m, "renew.park_stored");
let kept = state::load(&m.ctx)
.expect("state")
.get(&crate::state::Key::new(
crate::provider::ProviderId::Claude,
"there",
))
.and_then(|a| a.parked.clone())
.expect("`there` still holds a login");
assert_eq!(
kept.refresh_fingerprint,
crate::provider::claude::document::fingerprint_of(&json!({"refreshToken": "fresh"})),
"the fresh login is kept and the spent one dropped, not the other way round"
);
assert_eq!(m.mem.vault().services(), vec![kept.service]);
}
#[test]
fn a_renewal_that_cannot_record_its_answer_keeps_it_for_the_next_run() {
use std::os::unix::fs::PermissionsExt;
let m = machine("renew-save-fails");
let key = crate::state::Key::new(crate::provider::ProviderId::Claude, "there");
let mut state = state::load(&m.ctx).expect("state");
let held = state.get(&key).unwrap().parked.clone().unwrap();
let lapsed = json!({
"accessToken": "access-there-refresh",
"refreshToken": "there-refresh",
"expiresAt": (NOW - 60) * 1000,
"refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
});
m.mem.vault().plant(&held.service, &lapsed.to_string());
state.park(
&key,
park::describe(
crate::provider::ProviderId::Claude,
&held.service,
NOW,
&lapsed,
),
);
state::save(&m.ctx, &state).expect("saved");
m.api.renews(
"there-refresh",
crate::api::Renewed {
access_token: "access-fresh".into(),
refresh_token: Some("fresh".into()),
expires_in: 3600,
refresh_token_expires_in: Some(30 * 86_400),
scopes: None,
at: None,
},
);
let home = crate::home::dir(&m.ctx);
let locked = home.clone();
let outcomes = fault::meanwhile(
"renew.park_stored",
move || {
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).unwrap();
},
|| renew::renew_parked(&m.ctx),
);
std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(
outcomes
.iter()
.any(|(_, r)| matches!(r, renew::Renewal::Failed(_))),
"the save failed, and says so"
);
recover(&m).expect("the next change");
let kept = state::load(&m.ctx)
.expect("state")
.get(&key)
.and_then(|a| a.parked.clone())
.expect("`there` still holds a login");
assert_eq!(
kept.refresh_fingerprint,
crate::provider::claude::document::fingerprint_of(&json!({"refreshToken": "fresh"})),
);
hold(&m, "after a renewal that could not be recorded");
}
#[test]
fn forgetting_killed_after_the_record_still_deletes_the_park() {
let m = machine("forget-recorded");
let settled = settle(&m.ctx, None).expect("nothing to recover").0;
let died = fault::killing("forget.recorded", || {
forget::forget(
settled,
&crate::state::Key::new(crate::provider::ProviderId::Claude, "there"),
)
});
assert_eq!(died.unwrap_err(), "forget.recorded");
recover(&m).expect("recovery");
hold(&m, "forget.recorded");
assert!(
m.mem.vault().services().is_empty(),
"a forgotten account's login is deleted, not left in the vault"
);
}