use std::process::Command;
use std::sync::{Arc, Mutex};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_swapdex")
}
fn fake_oauth(sink: Arc<Mutex<Vec<String>>>, response: &'static str) -> String {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
std::thread::spawn(move || {
for mut rq in server.incoming_requests() {
let mut body = String::new();
std::io::Read::read_to_string(rq.as_reader(), &mut body).ok();
sink.lock().unwrap().push(body);
let _ = rq.respond(tiny_http::Response::from_string(response));
}
});
format!("http://127.0.0.1:{port}/v1/oauth/token")
}
fn refusing_oauth(sink: Arc<Mutex<Vec<String>>>, status: u16, body: &'static str) -> String {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
std::thread::spawn(move || {
for mut rq in server.incoming_requests() {
let mut seen = String::new();
std::io::Read::read_to_string(rq.as_reader(), &mut seen).ok();
sink.lock().unwrap().push(seen);
let _ = rq.respond(tiny_http::Response::from_string(body).with_status_code(status));
}
});
format!("http://127.0.0.1:{port}/v1/oauth/token")
}
fn seed_lapsed_account(root: &std::path::Path, name: &str, id: &str) -> std::path::PathBuf {
let store = root.join(".local/share/swapdex");
let slot = store.join("slots").join(id);
std::fs::create_dir_all(&slot).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
std::fs::write(
slot.join(".credentials.json"),
format!(
r#"{{"claudeAiOauth":{{"accessToken":"OLD-AT","refreshToken":"OLD-RT",
"expiresAt":{},"refreshTokenExpiresAt":{},"subscriptionType":"max",
"scopes":["user:inference"]}},"mcpOAuth":{{"keep":"me"}}}}"#,
now_ms - 3_600_000,
now_ms + 30 * 86_400_000
),
)
.unwrap();
std::fs::write(
slot.join(".claude.json"),
format!(r#"{{"oauthAccount":{{"accountUuid":"u-{name}","emailAddress":"{name}@x.com"}}}}"#),
)
.unwrap();
std::fs::write(
store.join("slots.json"),
serde_json::to_vec(&serde_json::json!([{
"name": name, "id": id, "config_dir": slot, "adopted": false,
"tool": "claude-code"
}]))
.unwrap(),
)
.unwrap();
slot
}
fn credential(slot: &std::path::Path) -> serde_json::Value {
let bytes = std::fs::read(slot.join(".credentials.json")).unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[test]
fn a_lapsed_account_is_renewed_in_place() {
let root = tempfile::tempdir().unwrap();
let slot = seed_lapsed_account(root.path(), "work", "aaaa1111");
let asked = Arc::new(Mutex::new(Vec::new()));
let url = fake_oauth(
asked.clone(),
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#,
);
let out = Command::new(bin())
.args(["refresh", "work"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.env("HOME", root.path())
.output()
.unwrap();
let said =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
assert!(said.contains("work renewed"), "reported success: {said}");
let body = asked.lock().unwrap().first().cloned().expect("one request");
let req: serde_json::Value = serde_json::from_str(&body).expect("json body");
assert_eq!(req["grant_type"], "refresh_token");
assert_eq!(req["refresh_token"], "OLD-RT", "the token it held");
assert!(req["client_id"].is_string(), "the client is named: {body}");
let c = credential(&slot);
let o = &c["claudeAiOauth"];
assert_eq!(o["accessToken"], "NEW-AT");
assert_eq!(o["refreshToken"], "NEW-RT");
assert_eq!(o["subscriptionType"], "max", "untouched fields survive");
assert_eq!(
c["mcpOAuth"]["keep"], "me",
"and so does the rest of the file"
);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let exp = o["expiresAt"].as_i64().expect("an expiry");
assert!(
exp > now_ms && exp <= now_ms + 3_600_000,
"an hour ahead, not 3600: {exp}"
);
let after = Command::new(bin())
.args(["refresh", "work"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.env("HOME", root.path())
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&after.stdout).contains("already current"),
"a renewed account is not renewed again: {:?}",
String::from_utf8_lossy(&after.stdout)
);
assert_eq!(
asked.lock().unwrap().len(),
1,
"and no second request was made"
);
}
#[test]
fn a_renewal_without_a_new_refresh_token_keeps_the_old_one() {
let root = tempfile::tempdir().unwrap();
let slot = seed_lapsed_account(root.path(), "work", "bbbb2222");
let asked = Arc::new(Mutex::new(Vec::new()));
let url = fake_oauth(
asked.clone(),
r#"{"access_token":"NEW-AT","expires_in":3600}"#,
);
let out = Command::new(bin())
.args(["refresh", "work"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.env("HOME", root.path())
.output()
.unwrap();
assert!(String::from_utf8_lossy(&out.stdout).contains("renewed"));
let o = credential(&slot);
assert_eq!(o["claudeAiOauth"]["accessToken"], "NEW-AT");
assert_eq!(o["claudeAiOauth"]["refreshToken"], "OLD-RT");
}
#[test]
fn a_refused_renewal_changes_nothing() {
let root = tempfile::tempdir().unwrap();
let slot = seed_lapsed_account(root.path(), "work", "cccc3333");
let before = std::fs::read(slot.join(".credentials.json")).unwrap();
let asked = Arc::new(Mutex::new(Vec::new()));
let url = fake_oauth(asked.clone(), r#"{"error":"invalid_grant"}"#);
let out = Command::new(bin())
.args(["refresh", "work"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.env("HOME", root.path())
.output()
.unwrap();
let said = String::from_utf8_lossy(&out.stdout).into_owned();
assert_eq!(
out.status.code(),
Some(4),
"failed renewal must fail the command: {said}"
);
assert!(
said.contains("could not be renewed"),
"the refusal is reported: {said}"
);
assert_eq!(
std::fs::read(slot.join(".credentials.json")).unwrap(),
before,
"the credential is untouched"
);
}
#[test]
fn keep_alive_renews_by_the_refresh_tokens_end_not_the_access_tokens() {
let root = tempfile::tempdir().unwrap();
let slot = seed_lapsed_account(root.path(), "idle", "aaaa1111");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let write = |refresh_end: i64| {
std::fs::write(
slot.join(".credentials.json"),
format!(
r#"{{"claudeAiOauth":{{"accessToken":"OLD-AT","refreshToken":"OLD-RT",
"expiresAt":{},"refreshTokenExpiresAt":{}}}}}"#,
now_ms + 2 * 3_600_000,
refresh_end
),
)
.unwrap();
};
let asked = Arc::new(Mutex::new(Vec::new()));
let url = fake_oauth(
asked.clone(),
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#,
);
let sweep = || {
let out = Command::new(bin())
.args(["refresh", "--keep-alive"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.output()
.unwrap();
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
};
write(now_ms + 30 * 86_400_000);
let said = sweep();
assert_eq!(
asked.lock().unwrap().len(),
0,
"a refresh token weeks from its end was rotated anyway: {said}"
);
write(now_ms + 2 * 86_400_000);
let said = sweep();
assert_eq!(
asked.lock().unwrap().len(),
1,
"a refresh token near its end was not renewed: {said}"
);
let after = std::fs::read_to_string(slot.join(".credentials.json")).unwrap();
assert!(
after.contains("NEW-AT"),
"the renewed token was written: {after}"
);
assert!(
after.contains("NEW-RT"),
"and the rotated refresh token replaced the spent one: {after}"
);
assert!(
said.contains("idle"),
"it says which account it renewed: {said}"
);
}
fn seed_lapsed_codex(root: &std::path::Path, name: &str, id: &str) -> std::path::PathBuf {
let store = root.join(".local/share/swapdex");
let slot = store.join("slots").join(id);
std::fs::create_dir_all(&slot).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let b64 = |s: &str| {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(s)
};
let lapsed = format!(
"{}.{}.sig",
b64(r#"{"alg":"none"}"#),
b64(&format!(r#"{{"exp":{}}}"#, now - 3_600))
);
std::fs::write(
slot.join("auth.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": serde_json::Value::Null,
"tokens": {
"id_token": "OLD-ID", "access_token": lapsed,
"refresh_token": "OLD-RT", "account_id": "acct-1"
},
"last_refresh": "2026-08-19T07:41:56Z"
}))
.unwrap(),
)
.unwrap();
std::fs::write(
store.join("slots.json"),
serde_json::to_vec(&serde_json::json!([{
"name": name, "id": id, "config_dir": slot, "adopted": false, "tool": "codex"
}]))
.unwrap(),
)
.unwrap();
slot
}
fn codex_auth(slot: &std::path::Path) -> serde_json::Value {
serde_json::from_slice(&std::fs::read(slot.join("auth.json")).unwrap()).unwrap()
}
#[cfg(target_os = "linux")]
struct ReapedChild(std::process::Child);
#[cfg(target_os = "linux")]
impl Drop for ReapedChild {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[cfg(target_os = "linux")]
fn running_tool(root: &std::path::Path, tool: &str, config_dir: &std::path::Path) -> ReapedChild {
let bin = root.join(format!("fake-{tool}")).join(tool);
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
let sleep = if std::path::Path::new("/bin/sleep").exists() {
"/bin/sleep"
} else {
"/usr/bin/sleep"
};
std::os::unix::fs::symlink(sleep, &bin).unwrap();
let config_var = if tool == "claude" {
"CLAUDE_CONFIG_DIR"
} else {
"CODEX_HOME"
};
let mut child = std::process::Command::new(&bin)
.arg("30")
.env("HOME", root)
.env(config_var, config_dir)
.spawn()
.unwrap();
let environ = format!("/proc/{}/environ", child.id());
for _ in 0..300 {
if std::fs::read(&environ).is_ok_and(|b| {
String::from_utf8_lossy(&b)
.contains(&format!("{config_var}={}", config_dir.to_string_lossy()))
}) {
return ReapedChild(child);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let _ = child.kill();
let _ = child.wait();
panic!("the fake {tool} process never exposed its isolated environment");
}
#[cfg(target_os = "linux")]
fn seed_external_identity(dir: &std::path::Path, tool: &str, account: &str) {
std::fs::create_dir_all(dir).unwrap();
if tool == "claude" {
std::fs::write(
dir.join(".claude.json"),
serde_json::to_vec(&serde_json::json!({
"oauthAccount": {"accountUuid": account}
}))
.unwrap(),
)
.unwrap();
} else {
std::fs::write(
dir.join("auth.json"),
serde_json::to_vec(&serde_json::json!({
"tokens": {"account_id": account}
}))
.unwrap(),
)
.unwrap();
}
}
#[cfg(target_os = "linux")]
fn fake_refresh_curl(root: &std::path::Path, answer: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = root.join("fake-refresh-curl");
std::fs::write(
&path,
format!(
"#!/bin/sh\ncat > /dev/null\nprintf x >> \"$SWAPDEX_TEST_REFRESH_COUNT\"\nprintf '{answer}\\n200'\n"
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(target_os = "linux")]
fn refresh_calls(root: &std::path::Path) -> usize {
std::fs::read(root.join("refresh-count"))
.map(|b| b.len())
.unwrap_or(0)
}
#[cfg(target_os = "linux")]
fn check_explicit_active_twin(tool: &str, candidate_account: &str, url_var: &str) {
for (running_account, should_refresh) in [(candidate_account, false), ("someone-else", true)] {
let root = tempfile::tempdir().unwrap();
let candidate = if tool == "claude" {
seed_lapsed_account(root.path(), "work", "candidate")
} else {
seed_lapsed_codex(root.path(), "work", "candidate")
};
let external = root.path().join(format!(".{tool}-external"));
seed_external_identity(&external, tool, running_account);
let _running = running_tool(root.path(), tool, &external);
let answer = if tool == "claude" {
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#
} else {
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","id_token":"NEW-ID"}"#
};
let curl = fake_refresh_curl(root.path(), answer);
let out = Command::new(bin())
.args(["refresh", "work"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_CURL", curl)
.env(
"SWAPDEX_TEST_REFRESH_COUNT",
root.path().join("refresh-count"),
)
.env(url_var, "http://127.0.0.1:1/fake-oauth")
.output()
.unwrap();
let said = String::from_utf8_lossy(&out.stdout).into_owned()
+ &String::from_utf8_lossy(&out.stderr);
assert_eq!(
refresh_calls(root.path()),
usize::from(should_refresh),
"{tool}: active account {running_account:?} gave the wrong refresh verdict:\n{said}"
);
assert_eq!(
out.status.code(),
Some(if should_refresh { 0 } else { 4 }),
"{tool}: {said}"
);
if should_refresh {
assert!(said.contains("work renewed"), "{tool}: {said}");
} else {
assert!(
!said.contains("work renewed"),
"{tool}: refreshed a twin held by a live process:\n{said}"
);
let unchanged = if tool == "claude" {
credential(&candidate)["claudeAiOauth"]["accessToken"] == "OLD-AT"
} else {
codex_auth(&candidate)["tokens"]["access_token"] != "NEW-AT"
};
assert!(unchanged, "{tool}: the protected credential changed");
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn explicit_refresh_protects_an_active_claude_twin_but_not_an_unrelated_account() {
check_explicit_active_twin("claude", "u-work", "SWAPDEX_OAUTH_URL");
}
#[cfg(target_os = "linux")]
#[test]
fn explicit_refresh_protects_an_active_codex_twin_but_not_an_unrelated_account() {
check_explicit_active_twin("codex", "acct-1", "SWAPDEX_CODEX_OAUTH_URL");
}
#[cfg(target_os = "linux")]
fn check_keep_alive_active_twin(tool: &str, account: &str, url_var: &str) {
let root = tempfile::tempdir().unwrap();
if tool == "claude" {
seed_lapsed_account(root.path(), "idle", "candidate");
} else {
seed_lapsed_codex(root.path(), "idle", "candidate");
}
let external = root.path().join(format!(".{tool}-external"));
seed_external_identity(&external, tool, account);
let _running = running_tool(root.path(), tool, &external);
let answer = if tool == "claude" {
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#
} else {
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","id_token":"NEW-ID"}"#
};
let curl = fake_refresh_curl(root.path(), answer);
let out = Command::new(bin())
.args(["refresh", "--keep-alive"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_CURL", curl)
.env(
"SWAPDEX_TEST_REFRESH_COUNT",
root.path().join("refresh-count"),
)
.env(url_var, "http://127.0.0.1:1/fake-oauth")
.output()
.unwrap();
let said =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
assert_eq!(
refresh_calls(root.path()),
0,
"{tool}: keep-alive refreshed an account held through a twin:\n{said}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn keep_alive_sweep_protects_an_active_claude_twin() {
check_keep_alive_active_twin("claude", "u-idle", "SWAPDEX_OAUTH_URL");
}
#[cfg(target_os = "linux")]
#[test]
fn keep_alive_sweep_protects_an_active_codex_twin() {
check_keep_alive_active_twin("codex", "acct-1", "SWAPDEX_CODEX_OAUTH_URL");
}
#[test]
fn a_lapsed_codex_account_is_renewed_in_place() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
let slot = seed_lapsed_codex(root, "work", "codex-1");
let asked = Arc::new(Mutex::new(Vec::new()));
let url = fake_oauth(
asked.clone(),
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","id_token":"NEW-ID"}"#,
);
let out = Command::new(bin())
.args(["refresh", "work"])
.env("SWAPDEX_ROOT", root)
.env("SWAPDEX_CODEX_OAUTH_URL", &url)
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(text.contains("work renewed"), "stdout was:\n{text}");
let body = asked.lock().unwrap().first().cloned().unwrap_or_default();
assert!(body.contains("grant_type=refresh_token"), "body: {body}");
assert!(body.contains("refresh_token=OLD-RT"), "body: {body}");
assert!(
body.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"),
"body: {body}"
);
let a = codex_auth(&slot);
assert_eq!(a["tokens"]["access_token"], "NEW-AT");
assert_eq!(
a["tokens"]["refresh_token"], "NEW-RT",
"the ROTATED token must be written, or the slot holds one the server retired"
);
assert_eq!(a["tokens"]["id_token"], "NEW-ID");
assert_eq!(
a["tokens"]["account_id"], "acct-1",
"untouched fields survive"
);
assert_eq!(a["auth_mode"], "chatgpt");
assert_ne!(a["last_refresh"], "2026-08-19T07:41:56Z", "stamped anew");
}
#[test]
fn a_codex_renewal_the_server_refuses_changes_nothing() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
let slot = seed_lapsed_codex(root, "work", "codex-1");
let before = codex_auth(&slot);
let url = fake_oauth(
Arc::new(Mutex::new(Vec::new())),
r#"{"error":"invalid_grant"}"#,
);
let out = Command::new(bin())
.args(["refresh", "work"])
.env("SWAPDEX_ROOT", root)
.env("SWAPDEX_CODEX_OAUTH_URL", &url)
.output()
.unwrap();
assert_eq!(out.status.code(), Some(4));
assert_eq!(
codex_auth(&slot),
before,
"an answer with no access token must leave the credential alone"
);
}
fn rotating_oauth(sink: Arc<Mutex<Vec<String>>>) -> String {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
std::thread::spawn(move || {
let mut spent = false;
for mut rq in server.incoming_requests() {
let mut body = String::new();
std::io::Read::read_to_string(rq.as_reader(), &mut body).ok();
let holds_retired = body.contains("OLD-RT");
sink.lock().unwrap().push(body);
let resp = if holds_retired && !spent {
spent = true;
tiny_http::Response::from_string(
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#,
)
.with_status_code(200)
} else {
tiny_http::Response::from_string(r#"{"error":"invalid_grant"}"#)
.with_status_code(400)
};
let _ = rq.respond(resp);
}
});
format!("http://127.0.0.1:{port}/v1/oauth/token")
}
fn seed_lapsed_twins(root: &std::path::Path, slots: &[(&str, &str, &str)]) {
let store = root.join(".local/share/swapdex");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let mut reg = Vec::new();
for (name, id, uuid) in slots {
let slot = store.join("slots").join(id);
std::fs::create_dir_all(&slot).unwrap();
std::fs::write(
slot.join(".credentials.json"),
format!(
r#"{{"claudeAiOauth":{{"accessToken":"OLD-AT","refreshToken":"OLD-RT",
"expiresAt":{},"refreshTokenExpiresAt":{},"subscriptionType":"max",
"scopes":["user:inference"]}}}}"#,
now_ms - 3_600_000,
now_ms + 30 * 86_400_000
),
)
.unwrap();
std::fs::write(
slot.join(".claude.json"),
format!(
r#"{{"oauthAccount":{{"accountUuid":"{uuid}","emailAddress":"shared@x.com"}}}}"#
),
)
.unwrap();
reg.push(serde_json::json!({
"name": name, "id": id, "config_dir": slot,
"adopted": false, "tool": "claude-code"
}));
}
std::fs::write(store.join("slots.json"), serde_json::to_vec(®).unwrap()).unwrap();
}
#[test]
fn two_slots_on_one_account_are_renewed_once() {
let root = tempfile::tempdir().unwrap();
seed_lapsed_twins(
root.path(),
&[
("work", "aaaa1111", "u-shared"),
("work-copy", "bbbb2222", "u-shared"),
],
);
let asked = Arc::new(Mutex::new(Vec::new()));
let url = rotating_oauth(asked.clone());
let out = Command::new(bin())
.args(["refresh"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.env("HOME", root.path())
.output()
.unwrap();
let said =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
let bodies = asked.lock().unwrap().clone();
let spends = bodies.iter().filter(|b| b.contains("OLD-RT")).count();
assert_eq!(
spends, 1,
"the retired refresh token was spent {spends} times:\n{said}\nrequests: {bodies:?}"
);
assert!(
!said.contains("idle too long"),
"the twin was blamed for being idle when its account had just been renewed:\n{said}"
);
}
#[test]
fn slots_on_different_accounts_are_each_renewed() {
let root = tempfile::tempdir().unwrap();
seed_lapsed_twins(
root.path(),
&[("one", "aaaa1111", "u-one"), ("two", "bbbb2222", "u-two")],
);
let asked = Arc::new(Mutex::new(Vec::new()));
let url = fake_oauth(
asked.clone(),
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#,
);
let out = Command::new(bin())
.args(["refresh"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.env("HOME", root.path())
.output()
.unwrap();
let said =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
assert!(
said.contains("one renewed"),
"'one' was not renewed:\n{said}"
);
assert!(
said.contains("two renewed"),
"'two' was not renewed:\n{said}"
);
assert_eq!(
asked.lock().unwrap().len(),
2,
"two accounts, two renewals:\n{said}"
);
}
#[test]
fn the_keep_alive_sweep_renews_an_account_once() {
let root = tempfile::tempdir().unwrap();
seed_lapsed_twins(
root.path(),
&[
("work", "aaaa1111", "u-shared"),
("work-copy", "bbbb2222", "u-shared"),
],
);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
for id in ["aaaa1111", "bbbb2222"] {
let path = root
.path()
.join(".local/share/swapdex/slots")
.join(id)
.join(".credentials.json");
let mut v: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
v["claudeAiOauth"]["refreshTokenExpiresAt"] = (now_ms + 2 * 86_400_000).into();
std::fs::write(&path, serde_json::to_vec(&v).unwrap()).unwrap();
}
let asked = Arc::new(Mutex::new(Vec::new()));
let url = rotating_oauth(asked.clone());
let out = Command::new(bin())
.args(["refresh", "--keep-alive"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.env("HOME", root.path())
.output()
.unwrap();
let said =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
let bodies = asked.lock().unwrap().clone();
let spends = bodies.iter().filter(|b| b.contains("OLD-RT")).count();
assert_eq!(
spends, 1,
"the sweep spent the retired refresh token {spends} times:\n{said}\nrequests: {bodies:?}"
);
}
#[test]
fn slots_with_unreadable_identity_are_each_renewed() {
let root = tempfile::tempdir().unwrap();
seed_lapsed_twins(
root.path(),
&[("one", "aaaa1111", "u-one"), ("two", "bbbb2222", "u-two")],
);
for id in ["aaaa1111", "bbbb2222"] {
std::fs::remove_file(
root.path()
.join(".local/share/swapdex/slots")
.join(id)
.join(".claude.json"),
)
.unwrap();
}
let asked = Arc::new(Mutex::new(Vec::new()));
let url = fake_oauth(
asked.clone(),
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#,
);
let out = Command::new(bin())
.args(["refresh"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.env("HOME", root.path())
.output()
.unwrap();
let said =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
assert_eq!(
asked.lock().unwrap().len(),
2,
"two unidentifiable slots were treated as one account:\n{said}"
);
}
#[test]
fn restore_says_when_the_backup_it_puts_back_was_retired() {
let root = tempfile::tempdir().unwrap();
let store = root.path().join(".local/share/swapdex");
std::fs::create_dir_all(store.join("slots/live")).unwrap();
std::fs::write(store.join("onboarded"), b"1").unwrap();
let slot = store.join("slots/live");
std::fs::write(
slot.join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"AT","refreshToken":"RT-NEW","expiresAt":9999999999999,"subscriptionType":"max"}}"#,
)
.unwrap();
std::fs::write(
slot.join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":"u-shared","emailAddress":"shared@example.com"}}"#,
)
.unwrap();
std::fs::write(
store.join("slots.json"),
format!(
r#"[{{"name":"live","id":"live","config_dir":"{}","adopted":false,"tool":"claude-code"}}]"#,
slot.display()
),
)
.unwrap();
let backup = store.join("backups/claude-code/1");
std::fs::create_dir_all(&backup).unwrap();
std::fs::write(
backup.join("credentials"),
br#"{"claudeAiOauth":{"accessToken":"AT-OLD","refreshToken":"RT-OLD"}}"#,
)
.unwrap();
std::fs::write(
backup.join("oauth_account"),
br#"{"accountUuid":"u-shared","emailAddress":"shared@example.com"}"#,
)
.unwrap();
let out = Command::new(bin())
.args(["restore", "--tool", "claude"])
.env("SWAPDEX_ROOT", root.path())
.env("HOME", root.path())
.output()
.unwrap();
let said =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
assert!(
said.contains("retired") || said.contains("rotated") || said.contains("renew"),
"restore put back a retired login without a word:\n{said}"
);
}
#[test]
fn a_refused_claude_refresh_is_recorded_shown_and_not_swept_again() {
let root = tempfile::tempdir().unwrap();
let slot = seed_lapsed_account(root.path(), "gone", "cccc3333");
let path = slot.join(".credentials.json");
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
v["claudeAiOauth"]["refreshTokenExpiresAt"] = (now_ms + 86_400_000).into();
std::fs::write(&path, serde_json::to_vec(&v).unwrap()).unwrap();
let asked = Arc::new(Mutex::new(Vec::new()));
let url = refusing_oauth(
asked.clone(),
400,
r#"{"error":"invalid_grant","error_description":"Refresh token not found or invalid"}"#,
);
let swapdex = |args: &[&str]| {
let out = Command::new(bin())
.args(args)
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr)
};
let said = swapdex(&["refresh", "--keep-alive"]);
assert_eq!(asked.lock().unwrap().len(), 1, "{said}");
let listing = swapdex(&["ls"]);
assert!(
listing.contains("claude-code refresh rejected (invalid_grant) - re-login required"),
"the refusal and its reason are not shown:\n{listing}"
);
let said = swapdex(&["refresh", "--keep-alive"]);
assert_eq!(
asked.lock().unwrap().len(),
1,
"the sweep spent another request on a refused token: {said}"
);
let said = swapdex(&["refresh", "gone"]);
assert_eq!(
asked.lock().unwrap().len(),
2,
"asking by name did not re-check the refused token: {said}"
);
}
#[test]
fn a_claude_400_without_invalid_grant_is_not_recorded_as_a_refusal() {
let root = tempfile::tempdir().unwrap();
let slot = seed_lapsed_account(root.path(), "odd", "dddd4444");
let path = slot.join(".credentials.json");
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
v["claudeAiOauth"]["refreshTokenExpiresAt"] = (now_ms + 86_400_000).into();
std::fs::write(&path, serde_json::to_vec(&v).unwrap()).unwrap();
let asked = Arc::new(Mutex::new(Vec::new()));
let url = refusing_oauth(
asked.clone(),
400,
r#"{"error":"invalid_request","error_description":"malformed"}"#,
);
let sweep = || {
let out = Command::new(bin())
.args(["refresh", "--keep-alive"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr)
};
sweep();
assert!(
!slot.join(".swapdex-claude-refresh-status.json").exists(),
"a 400 without invalid_grant was recorded as a refusal"
);
let said = sweep();
assert_eq!(
asked.lock().unwrap().len(),
2,
"the sweep stopped renewing after a 400 that was no verdict: {said}"
);
}
#[cfg(unix)]
#[test]
fn a_renewal_that_could_not_be_saved_is_kept_and_adopted_next_time() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let slot = seed_lapsed_account(root.path(), "keep", "eeee5555");
let asked = Arc::new(Mutex::new(Vec::new()));
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let (sink, locked) = (asked.clone(), slot.clone());
std::thread::spawn(move || {
for mut rq in server.incoming_requests() {
let mut seen = String::new();
std::io::Read::read_to_string(rq.as_reader(), &mut seen).ok();
sink.lock().unwrap().push(seen);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).unwrap();
let _ = rq.respond(tiny_http::Response::from_string(
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":28800}"#,
));
}
});
let url = format!("http://127.0.0.1:{port}/v1/oauth/token");
let refresh = || {
let out = Command::new(bin())
.args(["refresh", "keep"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr)
};
let first = refresh();
std::fs::set_permissions(&slot, std::fs::Permissions::from_mode(0o700)).unwrap();
let _ = std::fs::remove_dir(slot.join(".oauth_refresh.lock"));
assert_eq!(asked.lock().unwrap().len(), 1, "{first}");
let saved = std::fs::read_to_string(slot.join(".credentials.json")).unwrap();
assert!(
saved.contains("OLD-RT"),
"the failed save changed the file: {first}"
);
let second = refresh();
let saved = std::fs::read_to_string(slot.join(".credentials.json")).unwrap();
assert!(
saved.contains("NEW-RT"),
"the renewal the server already made was lost:\n{first}\n{second}"
);
assert_eq!(
asked.lock().unwrap().len(),
1,
"the spent token was sent again instead of adopting the kept answer: {second}"
);
}
#[cfg(unix)]
#[test]
fn a_kept_renewal_never_overwrites_a_newer_sign_in() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let slot = seed_lapsed_account(root.path(), "relog", "ffff6666");
let asked = Arc::new(Mutex::new(Vec::new()));
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let (sink, locked) = (asked.clone(), slot.clone());
std::thread::spawn(move || {
for mut rq in server.incoming_requests() {
let mut seen = String::new();
std::io::Read::read_to_string(rq.as_reader(), &mut seen).ok();
let first = {
let mut sink = sink.lock().unwrap();
sink.push(seen);
sink.len() == 1
};
if first {
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).unwrap();
}
let body = if first {
r#"{"access_token":"KEPT-AT","refresh_token":"KEPT-RT","expires_in":28800}"#
} else {
r#"{"access_token":"FRESH-AT","refresh_token":"FRESH-RT","expires_in":28800}"#
};
let _ = rq.respond(tiny_http::Response::from_string(body));
}
});
let url = format!("http://127.0.0.1:{port}/v1/oauth/token");
let refresh = || {
let out = Command::new(bin())
.args(["refresh", "relog"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr)
};
refresh();
std::fs::set_permissions(&slot, std::fs::Permissions::from_mode(0o700)).unwrap();
let _ = std::fs::remove_dir(slot.join(".oauth_refresh.lock"));
let path = slot.join(".credentials.json");
let relogged = std::fs::read_to_string(&path)
.unwrap()
.replace("OLD-RT", "SIGNED-IN-RT");
std::fs::write(&path, relogged).unwrap();
let second = refresh();
let saved = std::fs::read_to_string(&path).unwrap();
assert!(
!saved.contains("KEPT-RT"),
"an obsolete kept renewal overwrote a newer sign-in: {second}"
);
let asked = asked.lock().unwrap();
assert!(
asked.len() == 2 && asked[1].contains("SIGNED-IN-RT"),
"the new sign-in was not renewed with its own token: {second}"
);
}