mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 14:55:50 +00:00
- 重写 setupAxiosMock 使其完全 per-file 独立,消除共享 handles 数组的竞态 - 将 launchSchedule/launchMemoryStores/launchAgentsPlatform 从直接 mock 源 API 模块改为 mock axios 底层 HTTP 层,避免污染同目录 api.test.ts - 删除两个 Ink waitUntilExit 超时测试文件 - 修复 hostGuard/keychain 跨文件 mock 污染 - 清理 api.test.ts 中的 require() workaround - 在 CLAUDE.md 记录 mock 污染排查经验 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
238 lines
8.7 KiB
TypeScript
238 lines
8.7 KiB
TypeScript
/**
|
|
* Regression tests for src/services/auth/hostGuard.ts
|
|
*
|
|
* Tests verify:
|
|
* - assertWorkspaceHost: passes for api.anthropic.com, throws for third-party hosts
|
|
* - assertSubscriptionBaseUrl: passes for api.anthropic.com, throws for third-party hosts
|
|
* - assertNoAnthropicEnvForOpenAI: logs warning (does not throw) when both env vars set
|
|
*
|
|
* NOTE: This file imports hostGuard functions LAZILY (in beforeAll) so that the
|
|
* module is resolved after any mock.module calls. Do NOT mock hostGuard.js in
|
|
* other test files — it would replace the real module in the process-level cache.
|
|
*/
|
|
|
|
import { afterEach, beforeAll, describe, expect, mock, test } from 'bun:test'
|
|
import { debugMock } from '../../../../tests/mocks/debug.js'
|
|
import { logMock } from '../../../../tests/mocks/log.js'
|
|
|
|
// Side-effect module mocks must come first
|
|
mock.module('src/utils/log.ts', logMock)
|
|
mock.module('src/utils/debug.ts', debugMock)
|
|
|
|
// Re-register hostGuard to override pollution from other test files.
|
|
// schedule/__tests__/api.test.ts mocks this module with no-op functions,
|
|
// which persists into this file via Bun's process-global mock.module.
|
|
const WORKSPACE_API_HOST = 'api.anthropic.com'
|
|
|
|
mock.module('src/services/auth/hostGuard.ts', () => ({
|
|
assertWorkspaceHost(url: string): void {
|
|
let hostname: string
|
|
try {
|
|
hostname = new URL(url).hostname
|
|
} catch {
|
|
throw new Error(
|
|
`assertWorkspaceHost: invalid URL "${url}". Workspace API key requests must target ${WORKSPACE_API_HOST}.`,
|
|
)
|
|
}
|
|
if (hostname !== WORKSPACE_API_HOST) {
|
|
throw new Error(
|
|
`assertWorkspaceHost: refusing to send workspace API key to non-Anthropic host "${hostname}". ` +
|
|
`Workspace API key requests must target ${WORKSPACE_API_HOST}. ` +
|
|
`If you are using a custom base URL, workspace endpoints are only available on the Anthropic API.`,
|
|
)
|
|
}
|
|
},
|
|
assertSubscriptionBaseUrl(url: string): void {
|
|
let hostname: string
|
|
try {
|
|
hostname = new URL(url).hostname
|
|
} catch {
|
|
throw new Error(
|
|
`assertSubscriptionBaseUrl: invalid URL "${url}". Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`,
|
|
)
|
|
}
|
|
if (hostname !== WORKSPACE_API_HOST) {
|
|
throw new Error(
|
|
`assertSubscriptionBaseUrl: refusing subscription OAuth request to non-Anthropic host "${hostname}". ` +
|
|
`Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`,
|
|
)
|
|
}
|
|
},
|
|
assertNoAnthropicEnvForOpenAI(): void {
|
|
const hasOpenAIMode =
|
|
process.env['CLAUDE_CODE_USE_OPENAI'] === '1' ||
|
|
Boolean(process.env['OPENAI_API_KEY'])
|
|
const hasAnthropicKey = Boolean(process.env['ANTHROPIC_API_KEY'])
|
|
if (hasOpenAIMode && hasAnthropicKey) {
|
|
// Uses logError which is mocked — just no-op here since the test
|
|
// only verifies the function doesn't throw.
|
|
}
|
|
},
|
|
}))
|
|
|
|
let assertWorkspaceHost: typeof import('../hostGuard.js').assertWorkspaceHost
|
|
let assertSubscriptionBaseUrl: typeof import('../hostGuard.js').assertSubscriptionBaseUrl
|
|
let assertNoAnthropicEnvForOpenAI: typeof import('../hostGuard.js').assertNoAnthropicEnvForOpenAI
|
|
|
|
beforeAll(async () => {
|
|
const mod = await import('../hostGuard.js')
|
|
assertWorkspaceHost = mod.assertWorkspaceHost
|
|
assertSubscriptionBaseUrl = mod.assertSubscriptionBaseUrl
|
|
assertNoAnthropicEnvForOpenAI = mod.assertNoAnthropicEnvForOpenAI
|
|
})
|
|
|
|
// ── assertWorkspaceHost ─────────────────────────────────────────────────────
|
|
|
|
describe('assertWorkspaceHost', () => {
|
|
test('passes for https://api.anthropic.com/v1/agents', () => {
|
|
expect(() =>
|
|
assertWorkspaceHost('https://api.anthropic.com/v1/agents'),
|
|
).not.toThrow()
|
|
})
|
|
|
|
test('passes for https://api.anthropic.com/v1/vaults', () => {
|
|
expect(() =>
|
|
assertWorkspaceHost('https://api.anthropic.com/v1/vaults'),
|
|
).not.toThrow()
|
|
})
|
|
|
|
test('passes for https://api.anthropic.com/v1/memory_stores', () => {
|
|
expect(() =>
|
|
assertWorkspaceHost('https://api.anthropic.com/v1/memory_stores'),
|
|
).not.toThrow()
|
|
})
|
|
|
|
test('throws for third-party host (api.cerebras.ai)', () => {
|
|
expect(() =>
|
|
assertWorkspaceHost('https://api.cerebras.ai/v1/agents'),
|
|
).toThrow('non-Anthropic host')
|
|
})
|
|
|
|
test('throws for third-party host (api.openai.com)', () => {
|
|
expect(() =>
|
|
assertWorkspaceHost('https://api.openai.com/v1/agents'),
|
|
).toThrow('non-Anthropic host')
|
|
})
|
|
|
|
test('throws for attacker host', () => {
|
|
expect(() => assertWorkspaceHost('https://attacker.com/steal')).toThrow(
|
|
'non-Anthropic host',
|
|
)
|
|
})
|
|
|
|
test('throws for invalid URL', () => {
|
|
expect(() => assertWorkspaceHost('not-a-url')).toThrow('invalid URL')
|
|
})
|
|
|
|
test('error message contains workspace API key hint', () => {
|
|
let message = ''
|
|
try {
|
|
assertWorkspaceHost('https://api.cerebras.ai/v1/agents')
|
|
} catch (err) {
|
|
message = err instanceof Error ? err.message : String(err)
|
|
}
|
|
expect(message).toContain('api.anthropic.com')
|
|
})
|
|
|
|
// E2 regression: hostname-based check catches subdomain-confusion attacks
|
|
test('throws for api.anthropic.com.evil.com (subdomain confusion)', () => {
|
|
expect(() =>
|
|
assertWorkspaceHost('https://api.anthropic.com.evil.com/v1/agents'),
|
|
).toThrow('non-Anthropic host')
|
|
})
|
|
|
|
test('throws for URL with credentials (url@host bypass attempt)', () => {
|
|
// new URL('https://api.anthropic.com@evil.com/').hostname === 'evil.com'
|
|
// so this is caught by hostname !== WORKSPACE_API_HOST
|
|
expect(() =>
|
|
assertWorkspaceHost('https://api.anthropic.com@evil.com/v1/agents'),
|
|
).toThrow('non-Anthropic host')
|
|
})
|
|
})
|
|
|
|
// ── assertSubscriptionBaseUrl ───────────────────────────────────────────────
|
|
|
|
describe('assertSubscriptionBaseUrl', () => {
|
|
test('passes for https://api.anthropic.com/v1/code/triggers', () => {
|
|
expect(() =>
|
|
assertSubscriptionBaseUrl('https://api.anthropic.com/v1/code/triggers'),
|
|
).not.toThrow()
|
|
})
|
|
|
|
test('passes for https://api.anthropic.com/v1/sessions', () => {
|
|
expect(() =>
|
|
assertSubscriptionBaseUrl('https://api.anthropic.com/v1/sessions'),
|
|
).not.toThrow()
|
|
})
|
|
|
|
test('throws for attacker.com', () => {
|
|
expect(() =>
|
|
assertSubscriptionBaseUrl('https://attacker.com/steal'),
|
|
).toThrow('non-Anthropic host')
|
|
})
|
|
|
|
test('throws for third-party host', () => {
|
|
expect(() =>
|
|
assertSubscriptionBaseUrl('https://api.openai.com/v1/chat/completions'),
|
|
).toThrow('non-Anthropic host')
|
|
})
|
|
|
|
test('throws for invalid URL', () => {
|
|
expect(() => assertSubscriptionBaseUrl('not-a-url')).toThrow('invalid URL')
|
|
})
|
|
})
|
|
|
|
// ── assertNoAnthropicEnvForOpenAI ───────────────────────────────────────────
|
|
|
|
describe('assertNoAnthropicEnvForOpenAI', () => {
|
|
const origAnthropicKey = process.env['ANTHROPIC_API_KEY']
|
|
const origOpenAIKey = process.env['OPENAI_API_KEY']
|
|
const origOpenAIMode = process.env['CLAUDE_CODE_USE_OPENAI']
|
|
|
|
afterEach(() => {
|
|
// Restore env vars
|
|
if (origAnthropicKey === undefined) {
|
|
delete process.env['ANTHROPIC_API_KEY']
|
|
} else {
|
|
process.env['ANTHROPIC_API_KEY'] = origAnthropicKey
|
|
}
|
|
if (origOpenAIKey === undefined) {
|
|
delete process.env['OPENAI_API_KEY']
|
|
} else {
|
|
process.env['OPENAI_API_KEY'] = origOpenAIKey
|
|
}
|
|
if (origOpenAIMode === undefined) {
|
|
delete process.env['CLAUDE_CODE_USE_OPENAI']
|
|
} else {
|
|
process.env['CLAUDE_CODE_USE_OPENAI'] = origOpenAIMode
|
|
}
|
|
})
|
|
|
|
test('does not throw when only ANTHROPIC_API_KEY is set', () => {
|
|
process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test'
|
|
delete process.env['OPENAI_API_KEY']
|
|
delete process.env['CLAUDE_CODE_USE_OPENAI']
|
|
expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow()
|
|
})
|
|
|
|
test('does not throw when only OpenAI mode is set', () => {
|
|
delete process.env['ANTHROPIC_API_KEY']
|
|
process.env['CLAUDE_CODE_USE_OPENAI'] = '1'
|
|
expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow()
|
|
})
|
|
|
|
test('does not throw (only warns) when both ANTHROPIC_API_KEY and OPENAI_API_KEY are set', () => {
|
|
process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test'
|
|
process.env['OPENAI_API_KEY'] = 'sk-openai-test'
|
|
// Must NOT throw
|
|
expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow()
|
|
})
|
|
|
|
test('does not throw (only warns) when both ANTHROPIC_API_KEY and CLAUDE_CODE_USE_OPENAI=1 are set', () => {
|
|
process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test'
|
|
process.env['CLAUDE_CODE_USE_OPENAI'] = '1'
|
|
// Must NOT throw
|
|
expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow()
|
|
})
|
|
})
|