Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 264 additions & 2 deletions src/utils/__tests__/usage-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ process.stdout.write(JSON.stringify({
fs.mkdirSync(bin, { recursive: true });
fs.mkdirSync(claudeConfig, { recursive: true });

fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
// Keep the macOS Keychain stub in sync with credential changes made by
// the tests, so every platform exercises the same login and tokens.
fs.writeFileSync(securityScript, '#!/bin/sh\n/bin/cat "$CLAUDE_CONFIG_DIR/.credentials.json"\n');
fs.chmodSync(securityScript, 0o755);
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));

Expand All @@ -196,7 +198,9 @@ process.stdout.write(JSON.stringify({
function runProbe(options: ProbeOptions): UsageProbeResult {
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => {
const normalizedKey = key.toUpperCase();
return normalizedKey !== 'CLAUDE_CONFIG_DIR' && normalizedKey !== 'HTTPS_PROXY';
return normalizedKey !== 'CLAUDE_CONFIG_DIR'
&& normalizedKey !== 'CLAUDE_SECURESTORAGE_CONFIG_DIR'
&& normalizedKey !== 'HTTPS_PROXY';
}));

Object.assign(env, {
Expand Down Expand Up @@ -256,6 +260,20 @@ function parseLockContents(lockContents: string | null): { blockedUntil: number;
return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null;
}

// createTokenHome writes an access-token-only credentials file; these tests
// need a refresh token alongside it to exercise the account fingerprint.
function writeUsageCredentials(claudeConfig: string, claudeAiOauth: { accessToken: string; refreshToken?: string }): void {
fs.writeFileSync(path.join(claudeConfig, '.credentials.json'), JSON.stringify({ claudeAiOauth }));
}

function seedUsageCache(home: string, contents: Record<string, unknown>): { cacheFile: string; mtimeMs: number } {
const cacheDir = path.join(home, '.cache', 'ccstatusline');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, 'usage.json');
fs.writeFileSync(cacheFile, JSON.stringify(contents));
return { cacheFile, mtimeMs: fs.statSync(cacheFile).mtimeMs };
}

describe('fetchUsageData error handling', () => {
const nowMs = 2200000000000;
const successResponseBody = JSON.stringify({
Expand Down Expand Up @@ -1102,6 +1120,250 @@ describe('fetchUsageData error handling', () => {
}
});

it.each([
['fresh', 5000],
['stale', 200000]
])('serves a %s legacy cache during backoff without rewriting the cache or lock', (_state, cacheAgeMs) => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('legacy-cache-backoff');
writeUsageCredentials(home.claudeConfig, {
accessToken: 'current-access-token',
refreshToken: 'current-refresh-token'
});
const legacyHash = createHash('sha256').update('current-access-token').digest('hex').slice(0, 16);
const { cacheFile, mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: legacyHash });
const cacheContents = fs.readFileSync(cacheFile, 'utf8');
const probeNowMs = mtimeMs + cacheAgeMs;
const lockFile = path.join(home.home, '.cache', 'ccstatusline', 'usage.lock');
const lockContents = JSON.stringify({
blockedUntil: Math.floor(probeNowMs / 1000) + 3600,
error: 'rate-limited'
});
fs.writeFileSync(lockFile, lockContents);
const lockMtimeMs = fs.statSync(lockFile).mtimeMs;

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: probeNowMs,
pathDir: home.bin,
requiredFields: ['sessionUsage']
});

expect(result.first).toEqual({ sessionUsage: 5 });
expect(result.second).toEqual(result.first);
expect(result.requestCount).toBe(0);
expect(result.lockContents).toBe(lockContents);
expect(fs.statSync(lockFile).mtimeMs).toBe(lockMtimeMs);
expect(fs.readFileSync(cacheFile, 'utf8')).toBe(cacheContents);
expect(fs.statSync(cacheFile).mtimeMs).toBe(mtimeMs);
} finally {
harness.cleanup();
}
});

it.each(['other-access-token', 'other-refresh-token', undefined])('rejects a cache fingerprint from %s when both current tokens are available', (cachedToken) => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('unrelated-cache-backoff');
writeUsageCredentials(home.claudeConfig, {
accessToken: 'current-access-token',
refreshToken: 'current-refresh-token'
});
const tokenHash = cachedToken === undefined
? undefined
: createHash('sha256').update(cachedToken).digest('hex').slice(0, 16);
const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash });
const probeNowMs = mtimeMs + 5000;
fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({
blockedUntil: Math.floor(probeNowMs / 1000) + 3600,
error: 'rate-limited'
}));

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: probeNowMs,
pathDir: home.bin,
requiredFields: ['sessionUsage']
});

expect(result.first).toEqual({ error: 'rate-limited' });
expect(result.second).toEqual(result.first);
expect(result.requestCount).toBe(0);
} finally {
harness.cleanup();
}
});

it.each(['current-refresh-token', ''])('writes the preferred fingerprint after a successful fetch with refresh token %j', (refreshToken) => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('legacy-cache-migration');
writeUsageCredentials(home.claudeConfig, { accessToken: 'current-access-token', refreshToken });
const legacyHash = createHash('sha256').update('current-access-token').digest('hex').slice(0, 16);
const { cacheFile, mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: legacyHash });

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs: mtimeMs + 200000,
pathDir: home.bin,
requiredFields: ['sessionUsage'],
responseBody: successResponseBody
});

expect(result.first.sessionUsage).toBe(42);
expect(result.requestCount).toBe(1);
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) as Record<string, unknown>;
const expectedToken = refreshToken || 'current-access-token';
expect(cache.tokenHash).toBe(createHash('sha256').update(expectedToken).digest('hex').slice(0, 16));
} finally {
harness.cleanup();
}
});

it.each([
['current-access-token', { sessionUsage: 5 }],
['', { error: 'rate-limited' }]
])('uses only the access-token fingerprint when the refresh token is empty (cached token %j)', (cachedToken, expected) => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('empty-refresh-token');
writeUsageCredentials(home.claudeConfig, { accessToken: 'current-access-token', refreshToken: '' });
const tokenHash = createHash('sha256').update(cachedToken).digest('hex').slice(0, 16);
const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash });
const probeNowMs = mtimeMs + 5000;
fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({
blockedUntil: Math.floor(probeNowMs / 1000) + 3600,
error: 'rate-limited'
}));

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: probeNowMs,
pathDir: home.bin,
requiredFields: ['sessionUsage']
});

expect(result.first).toEqual(expected);
expect(result.second).toEqual(result.first);
expect(result.requestCount).toBe(0);
} finally {
harness.cleanup();
}
});

it('serves a fresh cache after the access token was refreshed (same login)', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('access-token-refreshed');
// The access token was reissued since the cache was written; the
// refresh token, which identifies the login, is unchanged.
writeUsageCredentials(home.claudeConfig, {
accessToken: 'reissued-access-token',
refreshToken: 'stable-refresh-token'
});
const fingerprint = createHash('sha256').update('stable-refresh-token').digest('hex').slice(0, 16);
const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: fingerprint });

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: mtimeMs + 5000,
pathDir: home.bin,
requiredFields: ['sessionUsage']
});

// A refresh must not look like an account switch: the cache stands
// and no request is made.
expect(result.requestCount).toBe(0);
expect(result.first.sessionUsage).toBe(5);
} finally {
harness.cleanup();
}
});

it('serves a stale cache through a rate-limit backoff after the access token was refreshed', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('access-token-refreshed-rate-limit');
writeUsageCredentials(home.claudeConfig, {
accessToken: 'reissued-access-token',
refreshToken: 'stable-refresh-token'
});
const fingerprint = createHash('sha256').update('stable-refresh-token').digest('hex').slice(0, 16);
const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: fingerprint });

// Cache is past CACHE_MAX_AGE and a server-issued backoff is active,
// so the stale-cache fallback is the only thing standing between the
// widgets and error text for the rest of the window.
const backedOffNowMs = mtimeMs + 200000;
fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({
blockedUntil: Math.floor(backedOffNowMs / 1000) + 3600,
error: 'rate-limited'
}));

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: backedOffNowMs,
pathDir: home.bin,
requiredFields: ['sessionUsage']
});

expect(result.first).toEqual({ sessionUsage: 5 });
expect(result.second).toEqual(result.first);
expect(result.requestCount).toBe(0);
} finally {
harness.cleanup();
}
});

it('refetches when the refresh token changes (account switch)', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('refresh-token-switched');
writeUsageCredentials(home.claudeConfig, {
accessToken: 'other-account-access-token',
refreshToken: 'other-account-refresh-token'
});
const previousFingerprint = createHash('sha256').update('previous-refresh-token').digest('hex').slice(0, 16);
const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: previousFingerprint });

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs: mtimeMs + 5000,
pathDir: home.bin,
requiredFields: ['sessionUsage'],
responseBody: successResponseBody
});

// A different login still invalidates the cache immediately.
expect(result.requestCount).toBe(1);
expect(result.first.sessionUsage).toBe(42);
} finally {
harness.cleanup();
}
});

it('treats enabled extra usage without a monthly limit as complete for extra usage widget fields', () => {
const harness = createProbeHarness();

Expand Down
19 changes: 15 additions & 4 deletions src/utils/__tests__/usage-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import * as claudeSettings from '../claude-settings';
import {
getMacKeychainConfigDirService,
getUsageCredentials,
getUsageToken,
parseMacKeychainCredentialCandidates
} from '../usage-fetch';
Expand Down Expand Up @@ -52,8 +53,8 @@ function makeConfigDirService(configDir: string): string {
return `Claude Code-credentials-${createHash('sha256').update(configDir).digest('hex').slice(0, 8)}`;
}

function makeTokenPayload(token: string): string {
return JSON.stringify({ claudeAiOauth: { accessToken: token } });
function makeTokenPayload(token: string, refreshToken?: string): string {
return JSON.stringify({ claudeAiOauth: { accessToken: token, refreshToken } });
}

function encodeAsciiAsHex(value: string): string {
Expand Down Expand Up @@ -258,14 +259,19 @@ describe('getUsageToken', () => {
mockCredentialsFile();
mockedExecFileSync.mockImplementation((command: string, args?: string[]) => {
if (command === 'security' && args?.[0] === 'find-generic-password' && args[2] === configDirService) {
return makeTokenPayload('profile-token');
return makeTokenPayload('profile-token', 'profile-refresh-token');
}

throw new Error(`Unexpected security args: ${args?.join(' ')}`);
});

expect(getUsageToken()).toBe('profile-token');
expect(getUsageCredentials()).toEqual({
accessToken: 'profile-token',
refreshToken: 'profile-refresh-token'
});
expect(getSecurityCallLog()).toEqual([
`find-generic-password -s ${configDirService} -w`,
`find-generic-password -s ${configDirService} -w`
]);
});
Expand All @@ -275,7 +281,7 @@ describe('getUsageToken', () => {

process.env.CLAUDE_CONFIG_DIR = '/fake/claude';
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
mockCredentialsFile(makeTokenPayload('file-token'));
mockCredentialsFile(makeTokenPayload('file-token', 'file-refresh-token'));
mockedExecFileSync.mockImplementation((command: string, args?: string[]) => {
if (command === 'security' && args?.[0] === 'find-generic-password' && args[2] === configDirService) {
throw new Error('missing profile credential');
Expand All @@ -285,7 +291,12 @@ describe('getUsageToken', () => {
});

expect(getUsageToken()).toBe('file-token');
expect(getUsageCredentials()).toEqual({
accessToken: 'file-token',
refreshToken: 'file-refresh-token'
});
expect(getSecurityCallLog()).toEqual([
`find-generic-password -s ${configDirService} -w`,
`find-generic-password -s ${configDirService} -w`
]);
});
Expand Down
Loading