use std::path::Path;
#[cfg(unix)]
use std::path::PathBuf;
use super::{VendorCli, link_digest};
#[cfg(unix)]
use crate::credential_store::CredentialStore;
use crate::subscription::{SubscriptionProvider, SubscriptionReader, SubscriptionToken};
const NOW_MS: i64 = 1_700_000_000_000;
fn seed_credential(home: &Path, access: &str, refresh: &str) {
let document = serde_json::json!({
"claudeAiOauth": {
"accessToken": access,
"refreshToken": refresh,
"expiresAt": NOW_MS - 1,
"scopes": ["user:inference"],
}
});
std::fs::write(
home.join(".credentials.json"),
serde_json::to_vec_pretty(&document).expect("serialize"),
)
.expect("seed credential");
}
fn token(access: &str, refresh: &str) -> SubscriptionToken {
SubscriptionToken {
access_token: access.into(),
refresh_token: Some(refresh.into()),
expires_at_ms: Some(NOW_MS - 1),
account_id: None,
resource_url: None,
}
}
#[cfg(unix)]
fn stub_cli(dir: &Path, script: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt as _;
let path = dir.join("stub-vendor-cli");
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).expect("write stub");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod stub");
path
}
#[cfg(unix)]
#[tokio::test]
async fn a_client_that_rotates_the_credential_hands_the_new_link_back() {
let home = tempfile::tempdir().expect("temp home");
seed_credential(home.path(), "access-1", "refresh-1");
let reader = SubscriptionReader::new(SubscriptionProvider::Claude, home.path());
let cli = VendorCli::claude(
stub_cli(
home.path(),
r#"printf 'POST /v1/oauth/token 200\nrefresh_token: [REDACTED]\nsession start\n' > "$2"
cat > "$CLAUDE_CONFIG_DIR/.credentials.json" <<'JSON'
{"claudeAiOauth":{"accessToken":"access-2","refreshToken":"refresh-2","expiresAt":9999999999999,"scopes":["user:inference"]}}
JSON"#,
),
home.path(),
);
let rotated = cli
.rotate(&reader, &token("access-1", "refresh-1"))
.await
.expect("the client rotated the chain");
assert_eq!(rotated.access_token, "access-2");
assert_eq!(rotated.refresh_token.as_deref(), Some("refresh-2"));
}
#[cfg(unix)]
#[tokio::test]
async fn a_client_that_changes_nothing_recovers_nothing() {
let home = tempfile::tempdir().expect("temp home");
seed_credential(home.path(), "access-1", "refresh-1");
let reader = SubscriptionReader::new(SubscriptionProvider::Claude, home.path());
let cli = VendorCli::claude(
stub_cli(
home.path(),
r#"printf 'session start
event loop stall
' > "$2"; exit 1"#,
),
home.path(),
);
assert!(
cli.rotate(&reader, &token("access-1", "refresh-1"))
.await
.is_none()
);
assert_eq!(
CredentialStore::reload(&reader)
.expect("credential")
.refresh_token
.as_deref(),
Some("refresh-1")
);
}
#[tokio::test]
async fn a_client_that_cannot_be_run_is_not_fatal() {
let home = tempfile::tempdir().expect("temp home");
seed_credential(home.path(), "access-1", "refresh-1");
let reader = SubscriptionReader::new(SubscriptionProvider::Claude, home.path());
let cli = VendorCli::claude(home.path().join("no-such-binary"), home.path());
assert!(
cli.rotate(&reader, &token("access-1", "refresh-1"))
.await
.is_none()
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_client_that_hangs_is_given_up_on() {
let home = tempfile::tempdir().expect("temp home");
seed_credential(home.path(), "access-1", "refresh-1");
let reader = SubscriptionReader::new(SubscriptionProvider::Claude, home.path());
let cli = VendorCli::claude(stub_cli(home.path(), "sleep 30"), home.path())
.with_timeout(std::time::Duration::from_millis(200));
let started = std::time::Instant::now();
assert!(
cli.rotate(&reader, &token("access-1", "refresh-1"))
.await
.is_none()
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"the rung waited for the whole child instead of its own timeout"
);
}
#[test]
fn a_chain_link_is_named_without_being_disclosed() {
let secret = "sk-ant-oat01-a-very-secret-refresh-token";
let digest = link_digest(&token("access-1", secret));
assert!(!digest.contains(secret));
assert_eq!(digest.len(), 8, "four bytes of SHA-256, hex encoded");
assert_eq!(digest, link_digest(&token("access-2", secret)), "stable");
assert_ne!(digest, link_digest(&token("access-1", "another-link")));
assert_eq!(
link_digest(&SubscriptionToken {
access_token: "access-1".into(),
refresh_token: None,
expires_at_ms: None,
account_id: None,
resource_url: None,
}),
"none"
);
}
#[test]
fn the_fallback_record_reproduces_the_request_without_its_secrets() {
let shape = crate::refresh::direct_exchange_shape(SubscriptionProvider::Claude);
assert!(
shape.contains("POST https://platform.claude.com/v1/oauth/token"),
"{shape}"
);
assert!(shape.contains("content-type: application/json"), "{shape}");
assert!(
shape.contains("anthropic-beta: oauth-2025-04-20"),
"{shape}"
);
assert!(shape.contains("user-agent: "), "{shape}");
for field in ["grant_type", "refresh_token", "client_id"] {
assert!(
shape.contains(field),
"body field {field} missing from {shape}"
);
}
assert!(shape.contains("values omitted"), "{shape}");
}