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 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!(
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_an_account_that_has_not_lapsed_yet() {
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;
std::fs::write(
slot.join(".credentials.json"),
format!(
r#"{{"claudeAiOauth":{{"accessToken":"OLD-AT","refreshToken":"OLD-RT",
"expiresAt":{},"refreshTokenExpiresAt":{}}}}}"#,
now_ms + 2 * 3_600_000,
now_ms + 30 * 86_400_000
),
)
.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", "--keep-alive"])
.env("SWAPDEX_ROOT", root.path())
.env("SWAPDEX_OAUTH_URL", &url)
.output()
.unwrap();
let said = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
asked.lock().unwrap().len(),
1,
"a token still valid for two hours was exercised anyway: {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()
}
#[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!(out.status.success());
assert_eq!(
codex_auth(&slot),
before,
"an answer with no access token must leave the credential alone"
);
}