Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

417 lines
15 KiB
TypeScript

import { createHash } from 'node:crypto';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import logger from '../../../src/logger';
import { mockProcessEnv } from '../../util/utils';
const mocks = vi.hoisted(() => ({
execFileSync: vi.fn(),
existsSync: vi.fn(),
readFileSync: vi.fn(),
homedir: vi.fn(),
}));
vi.mock('node:child_process', () => ({
execFileSync: mocks.execFileSync,
}));
vi.mock('node:fs', async () => ({
default: {
existsSync: mocks.existsSync,
readFileSync: mocks.readFileSync,
},
existsSync: mocks.existsSync,
readFileSync: mocks.readFileSync,
}));
vi.mock('node:os', async () => ({
default: { homedir: mocks.homedir },
homedir: mocks.homedir,
}));
import {
CLAUDE_CODE_IDENTITY_PROMPT,
CLAUDE_CODE_OAUTH_BETA_FEATURES,
isCredentialExpired,
loadClaudeCodeCredential,
} from '../../../src/providers/anthropic/claudeCodeAuth';
describe('claudeCodeAuth', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!;
const envRestores: Array<() => void> = [];
function setPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
}
function setEnv(overrides: Record<string, string | undefined>) {
envRestores.push(mockProcessEnv(overrides));
}
function keychainServiceSuffix(configDir: string): string {
return createHash('sha256').update(configDir).digest('hex').slice(0, 8);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.execFileSync.mockReset();
mocks.existsSync.mockReset();
mocks.readFileSync.mockReset();
mocks.homedir.mockReset();
mocks.homedir.mockReturnValue('/home/tester');
// Isolate every test from a CLAUDE_CONFIG_DIR set on the host machine —
// it changes both the credentials-file path and the keychain service.
setEnv({ CLAUDE_CONFIG_DIR: undefined });
});
afterEach(() => {
Object.defineProperty(process, 'platform', originalPlatform);
while (envRestores.length > 0) {
envRestores.pop()!();
}
});
describe('constants', () => {
it('exposes the Claude Code identity prompt expected by the Anthropic API', () => {
expect(CLAUDE_CODE_IDENTITY_PROMPT).toBe(
"You are Claude Code, Anthropic's official CLI for Claude.",
);
});
it('requires both the claude-code and oauth beta features', () => {
expect([...CLAUDE_CODE_OAUTH_BETA_FEATURES]).toEqual([
'claude-code-20250219',
'oauth-2025-04-20',
]);
});
});
describe('loadClaudeCodeCredential', () => {
it('returns a credential parsed from the macOS keychain on darwin', () => {
setPlatform('darwin');
const blob = JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-oat-kc',
refreshToken: 'refresh-kc',
expiresAt: 2_000_000_000_000,
subscriptionType: 'max',
},
});
mocks.execFileSync.mockReturnValue(`${blob}\n`);
const credential = loadClaudeCodeCredential();
expect(credential).toEqual({
accessToken: 'sk-ant-oat-kc',
refreshToken: 'refresh-kc',
expiresAt: 2_000_000_000_000,
subscriptionType: 'max',
});
expect(mocks.execFileSync).toHaveBeenCalledWith(
'security',
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
expect.objectContaining({ encoding: 'utf-8', timeout: 5000 }),
);
// Should short-circuit before touching the filesystem.
expect(mocks.existsSync).not.toHaveBeenCalled();
});
it('queries the profile-specific keychain service when CLAUDE_CONFIG_DIR is set', () => {
// Claude Code CLI stores each CLAUDE_CONFIG_DIR profile's credential
// under `Claude Code-credentials-<sha256(dir)[:8]>` — querying the
// plain service would silently return the default profile's token.
setPlatform('darwin');
setEnv({ CLAUDE_CONFIG_DIR: '/custom/claude-config' });
mocks.execFileSync.mockReturnValue(
JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-oat-profile' } }),
);
const credential = loadClaudeCodeCredential();
expect(credential?.accessToken).toBe('sk-ant-oat-profile');
expect(mocks.execFileSync).toHaveBeenCalledWith(
'security',
[
'find-generic-password',
'-s',
`Claude Code-credentials-${keychainServiceSuffix('/custom/claude-config')}`,
'-w',
],
expect.objectContaining({ encoding: 'utf-8', timeout: 5000 }),
);
});
it('derives the keychain service from a provider-scoped CLAUDE_CONFIG_DIR override', () => {
setPlatform('darwin');
setEnv({ CLAUDE_CONFIG_DIR: '/process-env/claude-config' });
mocks.execFileSync.mockReturnValue(
JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-oat-profile' } }),
);
loadClaudeCodeCredential({ CLAUDE_CONFIG_DIR: '/provider-env/claude-config' });
expect(mocks.execFileSync).toHaveBeenCalledWith(
'security',
[
'find-generic-password',
'-s',
`Claude Code-credentials-${keychainServiceSuffix('/provider-env/claude-config')}`,
'-w',
],
expect.objectContaining({ encoding: 'utf-8', timeout: 5000 }),
);
});
it('falls back to the CLAUDE_CONFIG_DIR credentials file when the profile keychain entry is missing', () => {
setPlatform('darwin');
setEnv({ CLAUDE_CONFIG_DIR: '/custom/claude-config' });
mocks.execFileSync.mockImplementation(() => {
const err = new Error('The specified item could not be found in the keychain.') as Error & {
status?: number;
};
err.status = 44;
throw err;
});
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue(
JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-oat-profile-file' } }),
);
const credential = loadClaudeCodeCredential();
expect(credential?.accessToken).toBe('sk-ant-oat-profile-file');
expect(mocks.readFileSync).toHaveBeenCalledWith(
path.join('/custom/claude-config', '.credentials.json'),
'utf-8',
);
});
it('falls back to ~/.claude/.credentials.json when the keychain lookup fails', () => {
setPlatform('darwin');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
mocks.execFileSync.mockImplementation(() => {
throw new Error('user denied keychain access');
});
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue(
JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-oat-file',
refreshToken: 'refresh-file',
expiresAt: 1_999_000_000_000,
},
}),
);
const credential = loadClaudeCodeCredential();
expect(credential).toEqual({
accessToken: 'sk-ant-oat-file',
refreshToken: 'refresh-file',
expiresAt: 1_999_000_000_000,
});
// Use a platform-agnostic matcher so the test passes on Windows, where
// the path separator is `\` rather than `/`.
expect(mocks.readFileSync).toHaveBeenCalledWith(
expect.stringMatching(/[/\\]\.claude[/\\]\.credentials\.json$/),
'utf-8',
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to read Claude Code credential from macOS keychain'),
expect.objectContaining({ error: expect.stringContaining('user denied keychain access') }),
);
});
it('falls through silently when the macOS keychain entry is simply missing (exit 44)', () => {
setPlatform('darwin');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
mocks.execFileSync.mockImplementation(() => {
const err = new Error('The specified item could not be found in the keychain.') as Error & {
status?: number;
};
err.status = 44;
throw err;
});
mocks.existsSync.mockReturnValue(false);
expect(loadClaudeCodeCredential()).toBeNull();
// Exit 44 is "entry not present" — we should NOT warn the user about
// this; it's the common "first run, not logged in yet" path.
expect(warnSpy).not.toHaveBeenCalled();
});
it('warns when the macOS keychain returns an empty entry', () => {
setPlatform('darwin');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
mocks.execFileSync.mockReturnValue(' \n');
mocks.existsSync.mockReturnValue(false);
expect(loadClaudeCodeCredential()).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('keychain entry is empty'));
});
it('warns with the parse reason when the macOS keychain entry is malformed', () => {
setPlatform('darwin');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
mocks.execFileSync.mockReturnValue('{"claudeAiOauth": {}}');
mocks.existsSync.mockReturnValue(false);
expect(loadClaudeCodeCredential()).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('missing `claudeAiOauth.accessToken` string'),
);
});
it('reads from the credentials file on Linux and skips the keychain', () => {
setPlatform('linux');
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue(
JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-oat-linux',
expiresAt: 1_900_000_000_000,
},
}),
);
const credential = loadClaudeCodeCredential();
expect(credential?.accessToken).toBe('sk-ant-oat-linux');
expect(credential?.expiresAt).toBe(1_900_000_000_000);
expect(mocks.execFileSync).not.toHaveBeenCalled();
});
it('reads from CLAUDE_CONFIG_DIR instead of ~/.claude when set', () => {
setPlatform('linux');
setEnv({ CLAUDE_CONFIG_DIR: '/custom/claude-config' });
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue(
JSON.stringify({
claudeAiOauth: { accessToken: 'sk-ant-oat-custom-dir' },
}),
);
const credential = loadClaudeCodeCredential();
expect(credential?.accessToken).toBe('sk-ant-oat-custom-dir');
expect(mocks.readFileSync).toHaveBeenCalledWith(
path.join('/custom/claude-config', '.credentials.json'),
'utf-8',
);
});
it('prefers a provider-scoped CLAUDE_CONFIG_DIR env override over the process environment', () => {
setPlatform('linux');
setEnv({ CLAUDE_CONFIG_DIR: '/process-env/claude-config' });
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue(
JSON.stringify({
claudeAiOauth: { accessToken: 'sk-ant-oat-provider-env' },
}),
);
const credential = loadClaudeCodeCredential({
CLAUDE_CONFIG_DIR: '/provider-env/claude-config',
});
expect(credential?.accessToken).toBe('sk-ant-oat-provider-env');
expect(mocks.readFileSync).toHaveBeenCalledWith(
path.join('/provider-env/claude-config', '.credentials.json'),
'utf-8',
);
});
it('falls back to ~/.claude/.credentials.json when CLAUDE_CONFIG_DIR is unset', () => {
setPlatform('linux');
setEnv({ CLAUDE_CONFIG_DIR: undefined });
mocks.existsSync.mockReturnValue(false);
loadClaudeCodeCredential();
expect(mocks.existsSync).toHaveBeenCalledWith(
path.join('/home/tester', '.claude', '.credentials.json'),
);
});
it('falls back to ~/.claude/.credentials.json when CLAUDE_CONFIG_DIR is an empty string', () => {
// Empty string must behave like "unset" (`||` semantics), not resolve
// the credentials file to a cwd-relative `.credentials.json`.
setPlatform('linux');
setEnv({ CLAUDE_CONFIG_DIR: '' });
mocks.existsSync.mockReturnValue(false);
loadClaudeCodeCredential();
expect(mocks.existsSync).toHaveBeenCalledWith(
path.join('/home/tester', '.claude', '.credentials.json'),
);
});
it('returns null silently when the credentials file is missing', () => {
setPlatform('linux');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
mocks.existsSync.mockReturnValue(false);
expect(loadClaudeCodeCredential()).toBeNull();
expect(mocks.readFileSync).not.toHaveBeenCalled();
// Missing file is the common "not logged in" path — do not warn here,
// the caller surfaces a single user-facing warning instead.
expect(warnSpy).not.toHaveBeenCalled();
});
it('warns with the parse reason when the credential shape is malformed', () => {
setPlatform('linux');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue('{"claudeAiOauth": {}}');
expect(loadClaudeCodeCredential()).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('missing `claudeAiOauth.accessToken` string'),
);
});
it('warns with the JSON error when the credentials file is not valid JSON', () => {
setPlatform('linux');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue('{not json');
expect(loadClaudeCodeCredential()).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid JSON'));
});
it('warns when the credentials file exists but is unreadable', () => {
setPlatform('linux');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockImplementation(() => {
const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});
expect(loadClaudeCodeCredential()).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('exists but could not be read'),
expect.objectContaining({ error: expect.stringContaining('EACCES') }),
);
});
});
describe('isCredentialExpired', () => {
it('returns false for credentials without an expiry', () => {
expect(isCredentialExpired({ accessToken: 'x' })).toBe(false);
});
it('returns true once the expiry has passed', () => {
expect(isCredentialExpired({ accessToken: 'x', expiresAt: Date.now() - 1000 })).toBe(true);
});
it('returns false for an expiry in the future', () => {
expect(isCredentialExpired({ accessToken: 'x', expiresAt: Date.now() + 60_000 })).toBe(false);
});
});
});