use crate::cli_runner::{
BIN,
run_cs_with_env,
stdout, stderr, assert_exit,
write_account, write_account_with_token,
write_account_owner,
write_live_credentials_with_token, require_live_api,
FAR_FUTURE_MS, PAST_MS,
};
use claude_profile::output::jwt_exp_ms;
use tempfile::TempDir;
#[ test ]
fn ft01_missing_access_token_short_error()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account( dir.path(), "noaccess@test.com", "max", "default", FAR_FUTURE_MS, true );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!(
text.contains( "noaccess@test.com" ),
"account row must appear in the table, got:\n{text}",
);
assert!(
!text.contains( "HTTP transport error:" ),
"error must be shortened — must NOT contain verbose 'HTTP transport error:', got:\n{text}",
);
}
fn write_account_with_expired_token( home : &std::path::Path, name : &str, token : &str )
{
let store = home.join( ".persistent" ).join( "claude" ).join( "credential" );
std::fs::create_dir_all( &store ).unwrap();
let json = format!(
r#"{{"oauthAccount":{{"subscriptionType":"max","rateLimitTier":"default_claude_max_20x"}},"expiresAt":{PAST_MS},"accessToken":"{token}"}}"#,
);
std::fs::write( store.join( format!( "{name}.credentials.json" ) ), json ).unwrap();
}
#[ test ]
fn ft02_lim_it_http_401_shortens_to_auth_expired()
{
if !require_live_api( "ft02" ) { return; }
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account_with_expired_token( dir.path(), "expired@test.com", "invalid-token-for-401-test" );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!(
text.contains( "expired@test.com" ),
"account row must appear in the table, got:\n{text}",
);
assert!(
text.contains( "EXPIRED" ),
"account with PAST_MS expiresAt must show EXPIRED in Expires column, got:\n{text}",
);
assert!(
text.contains( "auth expired (401)" ),
"HTTP 401 must shorten to 'auth expired (401)', got:\n{text}",
);
assert!(
!text.contains( "HTTP transport error:" ),
"verbose HTTP error string must NOT appear in output, got:\n{text}",
);
}
#[ test ]
fn ft03_both_accounts_appear_regardless_of_active()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account( dir.path(), "alice@a.com", "max", "default", FAR_FUTURE_MS, false );
write_account( dir.path(), "bob@a.com", "max", "default", FAR_FUTURE_MS, false );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!(
text.contains( "alice@a.com" ),
"alice@a.com must appear in output regardless of _active marker, got:\n{text}",
);
assert!(
text.contains( "bob@a.com" ),
"bob@a.com must appear in output regardless of _active marker, got:\n{text}",
);
}
#[ test ]
fn ft04_check_mark_follows_live_token_not_active()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account_with_token( dir.path(), "alice@a.com", "tok-alice", true );
write_account_with_token( dir.path(), "work@a.com", "tok-work", false );
write_live_credentials_with_token( dir.path(), "tok-work" );
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
let work_has_check = text.lines().any( |l| l.contains( '\u{2713}' ) && l.contains( "work@a.com" ) );
assert!(
work_has_check,
"work@a.com must have ✓ (live token match), got:\n{text}",
);
let alice_has_check = text.lines().any( |l| l.contains( '\u{2713}' ) && l.contains( "alice@a.com" ) );
assert!(
!alice_has_check,
"alice@a.com must NOT have ✓ (only _active, not live token match), got:\n{text}",
);
}
#[ cfg( unix ) ]
#[ test ]
fn ft05_unreadable_credential_store_exits_2()
{
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
let store = dir.path()
.join( ".persistent" )
.join( "claude" )
.join( "credential" );
std::fs::create_dir_all( &store ).unwrap();
std::fs::set_permissions( &store, std::fs::Permissions::from_mode( 0o000 ) ).unwrap();
let out = run_cs_with_env( &[ ".usage" ], &[ ( "HOME", home ) ] );
std::fs::set_permissions( &store, std::fs::Permissions::from_mode( 0o755 ) ).unwrap();
assert_exit( &out, 2 );
assert!( !stderr( &out ).is_empty(), "unreadable store must produce error on stderr" );
}
#[ doc = "bug_reproducer(BUG-162)" ]
#[ test ]
fn mre_bug_162_jwt_exp_ms()
{
let creds = r#"{"claudeAiOauth":{"accessToken":"eyJhbGciOiJub25lIn0.eyJleHAiOjIwMDAwMDAwMDB9.fakesig","expiresAt":1000000000000}}"#;
assert_eq!( jwt_exp_ms( creds ), Some( 2_000_000_000_000 ) );
}
#[ test ]
fn f18_ft01_live_0_single_fetch()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account( dir.path(), "no-token@test.com", "max", "default", FAR_FUTURE_MS, false );
let out = run_cs_with_env( &[ ".usage", "live::0" ], &[ ( "HOME", home ) ] );
assert_exit( &out, 0 );
let text = stdout( &out );
assert!(
text.contains( "no-token@test.com" ),
"live::0 must render the account row on single fetch, got:\n{text}",
);
assert!(
!text.contains( "Next update" ),
"live::0 must not emit countdown footer, got:\n{text}",
);
}
#[ cfg( unix ) ]
#[ test ]
fn f18_ft06_live_stagger_per_account_trace()
{
use std::process::Stdio;
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account( dir.path(), "alpha@test.com", "max", "default", FAR_FUTURE_MS, false );
write_account( dir.path(), "beta@test.com", "max", "default", FAR_FUTURE_MS, false );
let child = std::process::Command::new( BIN )
.args( [ ".usage", "live::1", "trace::1", "interval::30", "jitter::0" ] )
.env( "HOME", home )
.env_remove( "PRO" )
.stdout( Stdio::piped() )
.stderr( Stdio::piped() )
.spawn()
.expect( "failed to spawn clp binary" );
std::thread::sleep( core::time::Duration::from_secs( 5 ) );
let _ = std::process::Command::new( "kill" )
.args( [ "-INT", &child.id().to_string() ] )
.status();
let out = child.wait_with_output().expect( "failed to wait on clp binary" );
let err = String::from_utf8_lossy( &out.stderr );
assert_eq!(
out.status.code(),
Some( 0 ),
"SIGINT must cause clean exit 0, got: {:?}\nstdout: {}\nstderr: {err}",
out.status,
String::from_utf8_lossy( &out.stdout ),
);
let trace_reading_count = err
.lines()
.filter( |l| l.contains( "[trace]" ) && l.contains( "reading" ) )
.count();
assert!(
trace_reading_count >= 2,
"stagger must fire before each account fetch — expected ≥ 2 '[trace] … reading' lines on stderr, \
got {trace_reading_count}:\n{err}",
);
assert!(
err.contains( "alpha@test.com" ),
"trace must include alpha@test.com, got:\n{err}",
);
assert!(
err.contains( "beta@test.com" ),
"trace must include beta@test.com, got:\n{err}",
);
}
#[ test ]
fn f37_ft02_usage_accepts_32_params()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account( dir.path(), "alice@acme.com", "max", "default", FAR_FUTURE_MS, false );
let out = run_cs_with_env(
&[
".usage",
"trace::1",
"format::text",
"cols::+host,-sub",
"sort::name",
"desc::0",
"no_color::1",
"count::10",
"offset::0",
"only_active::0",
"only_next::0",
"min_5h::0",
"min_7d::0",
"only_valid::0",
"exclude_exhausted::0",
"abs::0",
],
&[ ( "HOME", home ) ],
);
assert_exit( &out, 0 );
let out = run_cs_with_env(
&[ ".usage", "prefer::any", "imodel::auto", "effort::auto" ],
&[ ( "HOME", home ) ],
);
assert_exit( &out, 0 );
let out = run_cs_with_env(
&[ ".usage", "assign::1", "name::alice@acme.com", "dry::1" ],
&[ ( "HOME", home ) ],
);
assert_exit( &out, 0 );
let out = run_cs_with_env(
&[ ".usage", "unknown_foobar_xyz::1" ],
&[ ( "HOME", home ) ],
);
assert_exit( &out, 1 );
}
#[ test ]
fn f37_ft04_usage_default_profile()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account( dir.path(), "alice@acme.com", "max", "default", FAR_FUTURE_MS, false );
write_account_owner( dir.path(), "alice@acme.com", "testuser@testmachine" );
let out = run_cs_with_env(
&[ ".usage" ],
&[ ( "HOME", home ) ],
);
assert_exit( &out, 0 );
let text = stdout( &out );
assert!(
text.contains( "Owner" ),
"f37-FT-04: Owner column must appear in default .usage output; got:\n{text}",
);
assert!(
text.contains( "testuser@testmachine" ),
"f37-FT-04: owner value must appear for alice; got:\n{text}",
);
}
#[ test ]
fn f37_ft16_usage_unclaim_mirrors_accounts()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account( dir.path(), "alice@acme.com", "pro", "standard", FAR_FUTURE_MS, false );
write_account_owner( dir.path(), "alice@acme.com", "testuser@testmachine" );
let out = run_cs_with_env(
&[ ".usage", "unclaim::1", "name::alice@acme.com" ],
&[ ( "HOME", home ), ( "USER", "testuser" ), ( "HOSTNAME", "testmachine" ) ],
);
assert_exit( &out, 0 );
let store = dir.path().join( ".persistent" ).join( "claude" ).join( "credential" );
let meta = std::fs::read_to_string( store.join( "alice@acme.com.json" ) ).unwrap();
let val : serde_json::Value = serde_json::from_str( &meta ).unwrap();
assert_eq!(
val[ "owner" ].as_str().unwrap_or( "MISSING" ),
"",
"f37-FT-16: .usage unclaim::1 must clear owner to \"\"",
);
}
#[ test ]
fn f37_ft17_usage_assign_mirrors_accounts()
{
let dir = TempDir::new().unwrap();
let home = dir.path().to_str().unwrap();
write_account( dir.path(), "alice@acme.com", "pro", "standard", FAR_FUTURE_MS, false );
let out = run_cs_with_env(
&[ ".usage", "assign::1", "name::alice@acme.com" ],
&[ ( "HOME", home ), ( "USER", "testuser" ), ( "HOSTNAME", "testmachine" ) ],
);
assert_exit( &out, 0 );
let store = dir.path().join( ".persistent" ).join( "claude" ).join( "credential" );
let content = std::fs::read_to_string( store.join( "_active_testmachine_testuser" ) )
.expect( "f37-FT-17: .usage assign::1 must write per-machine marker" );
assert_eq!(
content.trim(),
"alice@acme.com",
"f37-FT-17: marker must contain alice@acme.com",
);
}