Files
wehub-resource-sync f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:07:03 +08:00

289 lines
12 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import * as childProcess from 'child_process';
import * as fs from 'fs';
import { join } from 'path';
import {
readClaudeOAuthToken,
decodeJwtExpMs,
writeStaleMarker,
clearStaleMarker,
readStaleMarker,
} from '../../src/shared/oauth-token.js';
import { paths } from '../../src/shared/paths.js';
import { buildIsolatedEnvWithFreshOAuth } from '../../src/shared/EnvManager.js';
/**
* The implementation uses promisify(execFile), which captures execFile at
* module-load time. To intercept those calls in tests we replace the export
* on `child_process` and restore it afterwards. We also redirect DATA_DIR
* to a per-test temp dir for marker/sidecar tests.
*/
const ORIGINAL_EXEC_FILE = childProcess.execFile;
const ORIGINAL_PLATFORM = process.platform;
const ORIGINAL_ENV_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
const ORIGINAL_DATA_DIR = process.env.CLAUDE_MEM_DATA_DIR;
let dataDirSpy: ReturnType<typeof spyOn> | undefined;
let tempDir: string;
function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value, configurable: true });
}
function restorePlatform(): void {
Object.defineProperty(process, 'platform', { value: ORIGINAL_PLATFORM, configurable: true });
}
/**
* Patch promisify(execFile) by replacing the underlying execFile with a stub
* that calls back like the real Node API. Because oauth-token.ts already
* captured the original at import time, we instead intercept the cached
* promisified function via the module's internal binding by re-importing.
*
* Simpler approach: spy on childProcess.execFile and route calls to a fake
* callback. Because promisify wraps execFile by reference at import time,
* we can't intercept post-hoc. Instead we exercise the parsing logic
* directly via parseKeychainPayload-equivalent paths: we inject results by
* calling readClaudeOAuthToken() with platform spoofed AND the expected
* `security`/`secret-tool` binary spy via mocking the `execFile` hostpath.
*
* Bun's spyOn lets us replace properties on the `child_process` module
* object, but the promisified handle inside oauth-token.ts already holds a
* reference. So we test the parsing layer by exercising decodeJwtExpMs
* directly and rely on environment-fallback path for the integration shape.
*/
beforeEach(() => {
// Redirect DATA_DIR to a temp directory for marker file tests.
tempDir = fs.mkdtempSync(join(fs.realpathSync(require('os').tmpdir()), 'claude-mem-oauth-test-'));
dataDirSpy = spyOn(paths, 'dataDir').mockImplementation(() => tempDir);
});
afterEach(() => {
dataDirSpy?.mockRestore();
restorePlatform();
if (ORIGINAL_ENV_TOKEN === undefined) {
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
} else {
process.env.CLAUDE_CODE_OAUTH_TOKEN = ORIGINAL_ENV_TOKEN;
}
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.CLAUDE_MEM_DATA_DIR;
} else {
process.env.CLAUDE_MEM_DATA_DIR = ORIGINAL_DATA_DIR;
}
// Clean up temp dir
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// best effort
}
});
describe('decodeJwtExpMs', () => {
it('returns undefined for non-JWT tokens', () => {
expect(decodeJwtExpMs('sk-ant-oat01-bare-token')).toBeUndefined();
expect(decodeJwtExpMs('not.a.jwt.really')).toBeUndefined();
expect(decodeJwtExpMs('')).toBeUndefined();
});
it('extracts exp claim from a real JWT and converts seconds to ms', () => {
// header.payload.signature where payload is {"exp": 9999999999}
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ exp: 9999999999 })).toString('base64url');
const signature = 'sig';
const jwt = `${header}.${payload}.${signature}`;
expect(decodeJwtExpMs(jwt)).toBe(9999999999 * 1000);
});
it('returns undefined when JWT payload has no exp claim', () => {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'user' })).toString('base64url');
const jwt = `${header}.${payload}.sig`;
expect(decodeJwtExpMs(jwt)).toBeUndefined();
});
it('returns undefined for malformed JWT', () => {
expect(decodeJwtExpMs('not-base64.not-base64.sig')).toBeUndefined();
});
});
describe('marker file scheme', () => {
it('writeStaleMarker creates the marker file with the reason', () => {
writeStaleMarker('token expired at 2026-01-01');
const markerPath = join(tempDir, 'oauth-stale.marker');
expect(fs.existsSync(markerPath)).toBe(true);
expect(fs.readFileSync(markerPath, 'utf-8')).toBe('token expired at 2026-01-01');
});
it('readStaleMarker returns undefined when no marker exists', () => {
expect(readStaleMarker()).toBeUndefined();
});
it('readStaleMarker returns the reason after writeStaleMarker', () => {
writeStaleMarker('refresh me');
expect(readStaleMarker()).toBe('refresh me');
});
it('clearStaleMarker removes an existing marker', () => {
writeStaleMarker('temporary');
expect(readStaleMarker()).toBe('temporary');
clearStaleMarker();
expect(readStaleMarker()).toBeUndefined();
});
it('clearStaleMarker is a no-op when no marker exists', () => {
expect(() => clearStaleMarker()).not.toThrow();
});
});
describe('readClaudeOAuthToken — env-fallback branch', () => {
// These tests exercise the env-fallback path which is reachable on every
// platform when the keychain returns absent. We force absent by spoofing
// the platform to an unsupported value.
beforeEach(() => {
setPlatform('aix' as NodeJS.Platform); // unsupported -> always absent
});
it('returns absent when no env token is set', async () => {
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
const result = await readClaudeOAuthToken();
expect(result.kind).toBe('absent');
if (result.kind === 'absent') {
expect(result.reason).toContain('Unsupported platform');
}
});
it('returns present (env-fallback) when env token is set and not expired', async () => {
// Non-JWT bare token, no sidecar -> no expiresAt detectable -> not expired.
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'sk-ant-oat01-fallback';
const result = await readClaudeOAuthToken();
expect(result.kind).toBe('present');
if (result.kind === 'present') {
expect(result.token).toBe('sk-ant-oat01-fallback');
expect(result.source).toBe('env-fallback');
}
});
it('returns expired when env token JWT exp claim is in the past', async () => {
// Build a JWT with exp=1 (1970) — definitely expired.
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ exp: 1 })).toString('base64url');
const expiredJwt = `${header}.${payload}.sig`;
process.env.CLAUDE_CODE_OAUTH_TOKEN = expiredJwt;
const result = await readClaudeOAuthToken();
expect(result.kind).toBe('expired');
if (result.kind === 'expired') {
expect(result.reason).toContain('expired');
expect(result.expiresAt).toBe(1000); // 1 sec * 1000
}
});
it('returns expired when sidecar metadata indicates env token is stale', async () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'sk-ant-oat01-bare';
// Write a sidecar with expiresAt in the past (well beyond grace window).
const sidecarPath = join(tempDir, 'oauth-token-meta.json');
const stalePastMs = Date.now() - 60 * 60 * 1000; // 1 hour ago
fs.writeFileSync(sidecarPath, JSON.stringify({ expiresAt: stalePastMs }));
const result = await readClaudeOAuthToken();
expect(result.kind).toBe('expired');
if (result.kind === 'expired') {
expect(result.expiresAt).toBe(stalePastMs);
}
});
it('returns present when sidecar expiresAt is in the future', async () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'sk-ant-oat01-bare';
const sidecarPath = join(tempDir, 'oauth-token-meta.json');
const futureMs = Date.now() + 60 * 60 * 1000; // 1 hour from now
fs.writeFileSync(sidecarPath, JSON.stringify({ expiresAt: futureMs }));
const result = await readClaudeOAuthToken();
expect(result.kind).toBe('present');
if (result.kind === 'present') {
expect(result.expiresAt).toBe(futureMs);
expect(result.source).toBe('env-fallback');
}
});
});
describe('readClaudeOAuthToken — macOS keychain branch', () => {
// We can't easily intercept the cached promisified execFile from inside
// oauth-token.ts (it captured a reference at module load). Instead we
// verify the macOS branch dispatches by checking that on darwin without
// a real keychain entry, the fallback path is reached.
it('on macOS, falls back to env when keychain access fails or returns nothing', async () => {
if (process.platform !== 'darwin') {
// Skip on non-macOS — we only run this test where security CLI exists.
return;
}
// Use an env token; if the real keychain has a fresh entry, we get
// 'present' with source='keychain'. If no keychain entry, we fall back
// to env-fallback. Either way, kind='present' with a non-empty token
// (or 'expired' if the real keychain entry happens to be stale).
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'sk-ant-oat01-test-fallback';
setPlatform('darwin');
const result = await readClaudeOAuthToken();
// Whatever the keychain says, the result should be a valid kind.
expect(['present', 'expired', 'absent']).toContain(result.kind);
if (result.kind === 'present') {
expect(result.token.length).toBeGreaterThan(0);
expect(['keychain', 'env-fallback']).toContain(result.source);
}
});
});
describe('readClaudeOAuthToken — Linux branch', () => {
it('on linux without secret-tool, returns absent gracefully', async () => {
if (process.platform !== 'linux') return; // skip on non-linux
setPlatform('linux');
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
const result = await readClaudeOAuthToken();
// If secret-tool is not installed or has no entry, returns absent.
// If somehow present, we accept that too.
expect(['present', 'expired', 'absent']).toContain(result.kind);
});
});
describe('readClaudeOAuthToken — Windows branch', () => {
it('on win32 without keychain entry, returns absent or env-fallback', async () => {
if (process.platform !== 'win32') return; // skip on non-windows
setPlatform('win32');
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
const result = await readClaudeOAuthToken();
expect(['present', 'expired', 'absent']).toContain(result.kind);
});
});
// CodeRabbit Minor (PR #2282 follow-up): when the OAuth token is absent, any
// previously-written stale marker is no longer accurate (the token is gone,
// not expired). buildIsolatedEnvWithFreshOAuth must clear it on the absent
// branch the same way it does on present.
describe('buildIsolatedEnvWithFreshOAuth — absent token clears stale marker', () => {
beforeEach(() => {
setPlatform('aix' as NodeJS.Platform); // unsupported -> always absent
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
});
it('clears a pre-existing stale marker when token is absent', async () => {
// Pre-existing marker from an earlier "expired" pass.
writeStaleMarker('left over from previous run');
expect(readStaleMarker()).toBe('left over from previous run');
// Force the absent path: ANTHROPIC_API_KEY must NOT be set in either the
// env file or the process env, otherwise the early-return branch fires
// before we ever reach the OAuth resolution.
const origAnthropicKey = process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
try {
await buildIsolatedEnvWithFreshOAuth(true);
} finally {
if (origAnthropicKey !== undefined) {
process.env.ANTHROPIC_API_KEY = origAnthropicKey;
}
}
expect(readStaleMarker()).toBeUndefined();
});
});