453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
/**
|
|
* Tests for API key authentication feature
|
|
*
|
|
* Tests the resolution logic for Anthropic and OpenAI API keys:
|
|
* - Environment variable priority over config.yaml
|
|
* - Config.yaml fallback when env var is not set
|
|
* - Undefined when neither is set
|
|
* - Schema validation for API key fields
|
|
* - GlobalConfig load/save round-trip with API keys
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { mkdirSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { GlobalConfigSchema } from '../core/models/index.js';
|
|
|
|
// Mock paths module to redirect config to temp directory
|
|
const testId = randomUUID();
|
|
const testDir = join(tmpdir(), `takt-api-key-test-${testId}`);
|
|
const taktDir = join(testDir, '.takt');
|
|
const configPath = join(taktDir, 'config.yaml');
|
|
|
|
function createExecutableFile(filename: string): string {
|
|
const filePath = join(testDir, filename);
|
|
writeFileSync(filePath, '#!/bin/sh\necho codex\n', 'utf-8');
|
|
chmodSync(filePath, 0o755);
|
|
return filePath;
|
|
}
|
|
|
|
function createNonExecutableFile(filename: string): string {
|
|
const filePath = join(testDir, filename);
|
|
writeFileSync(filePath, '#!/bin/sh\necho codex\n', 'utf-8');
|
|
chmodSync(filePath, 0o644);
|
|
return filePath;
|
|
}
|
|
|
|
vi.mock('../infra/config/paths.js', async (importOriginal) => {
|
|
const original = await importOriginal() as Record<string, unknown>;
|
|
return {
|
|
...original,
|
|
getGlobalConfigPath: () => configPath,
|
|
getTaktDir: () => taktDir,
|
|
};
|
|
});
|
|
|
|
// Import after mocking
|
|
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveCodexCliPath, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
|
|
|
|
describe('GlobalConfigSchema API key fields', () => {
|
|
it('should accept config without API keys', () => {
|
|
const result = GlobalConfigSchema.parse({
|
|
language: 'en',
|
|
});
|
|
expect(result.anthropic_api_key).toBeUndefined();
|
|
expect(result.openai_api_key).toBeUndefined();
|
|
});
|
|
|
|
it('should accept config with anthropic_api_key', () => {
|
|
const result = GlobalConfigSchema.parse({
|
|
language: 'en',
|
|
anthropic_api_key: 'sk-ant-test-key',
|
|
});
|
|
expect(result.anthropic_api_key).toBe('sk-ant-test-key');
|
|
});
|
|
|
|
it('should accept config with openai_api_key', () => {
|
|
const result = GlobalConfigSchema.parse({
|
|
language: 'en',
|
|
openai_api_key: 'sk-openai-test-key',
|
|
});
|
|
expect(result.openai_api_key).toBe('sk-openai-test-key');
|
|
});
|
|
|
|
it('should accept config with both API keys', () => {
|
|
const result = GlobalConfigSchema.parse({
|
|
language: 'en',
|
|
anthropic_api_key: 'sk-ant-key',
|
|
openai_api_key: 'sk-openai-key',
|
|
});
|
|
expect(result.anthropic_api_key).toBe('sk-ant-key');
|
|
expect(result.openai_api_key).toBe('sk-openai-key');
|
|
});
|
|
});
|
|
|
|
describe('GlobalConfig load/save with API keys', () => {
|
|
beforeEach(() => {
|
|
invalidateGlobalConfigCache();
|
|
mkdirSync(taktDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load config with API keys from YAML', () => {
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
'anthropic_api_key: sk-ant-from-yaml',
|
|
'openai_api_key: sk-openai-from-yaml',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const config = loadGlobalConfig();
|
|
expect(config.anthropicApiKey).toBe('sk-ant-from-yaml');
|
|
expect(config.openaiApiKey).toBe('sk-openai-from-yaml');
|
|
});
|
|
|
|
it('should load config without API keys', () => {
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const config = loadGlobalConfig();
|
|
expect(config.anthropicApiKey).toBeUndefined();
|
|
expect(config.openaiApiKey).toBeUndefined();
|
|
});
|
|
|
|
it('should save and reload config with API keys', () => {
|
|
// Write initial config
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const config = loadGlobalConfig();
|
|
config.anthropicApiKey = 'sk-ant-saved';
|
|
config.openaiApiKey = 'sk-openai-saved';
|
|
saveGlobalConfig(config);
|
|
|
|
const reloaded = loadGlobalConfig();
|
|
expect(reloaded.anthropicApiKey).toBe('sk-ant-saved');
|
|
expect(reloaded.openaiApiKey).toBe('sk-openai-saved');
|
|
});
|
|
|
|
it('should not persist API keys when not set', () => {
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const config = loadGlobalConfig();
|
|
saveGlobalConfig(config);
|
|
|
|
const content = readFileSync(configPath, 'utf-8');
|
|
expect(content).not.toContain('anthropic_api_key');
|
|
expect(content).not.toContain('openai_api_key');
|
|
});
|
|
});
|
|
|
|
describe('resolveAnthropicApiKey', () => {
|
|
const originalEnv = process.env['TAKT_ANTHROPIC_API_KEY'];
|
|
|
|
beforeEach(() => {
|
|
invalidateGlobalConfigCache();
|
|
mkdirSync(taktDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalEnv !== undefined) {
|
|
process.env['TAKT_ANTHROPIC_API_KEY'] = originalEnv;
|
|
} else {
|
|
delete process.env['TAKT_ANTHROPIC_API_KEY'];
|
|
}
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should return env var when set', () => {
|
|
process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env';
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
'anthropic_api_key: sk-ant-from-yaml',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveAnthropicApiKey();
|
|
expect(key).toBe('sk-ant-from-env');
|
|
});
|
|
|
|
it('should fall back to config when env var is not set', () => {
|
|
delete process.env['TAKT_ANTHROPIC_API_KEY'];
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
'anthropic_api_key: sk-ant-from-yaml',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveAnthropicApiKey();
|
|
expect(key).toBe('sk-ant-from-yaml');
|
|
});
|
|
|
|
it('should return undefined when neither env var nor config is set', () => {
|
|
delete process.env['TAKT_ANTHROPIC_API_KEY'];
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveAnthropicApiKey();
|
|
expect(key).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined when config file does not exist', () => {
|
|
delete process.env['TAKT_ANTHROPIC_API_KEY'];
|
|
// No config file created
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
|
|
const key = resolveAnthropicApiKey();
|
|
expect(key).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('resolveOpenaiApiKey', () => {
|
|
const originalEnv = process.env['TAKT_OPENAI_API_KEY'];
|
|
|
|
beforeEach(() => {
|
|
invalidateGlobalConfigCache();
|
|
mkdirSync(taktDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalEnv !== undefined) {
|
|
process.env['TAKT_OPENAI_API_KEY'] = originalEnv;
|
|
} else {
|
|
delete process.env['TAKT_OPENAI_API_KEY'];
|
|
}
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should return env var when set', () => {
|
|
process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env';
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
'openai_api_key: sk-openai-from-yaml',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveOpenaiApiKey();
|
|
expect(key).toBe('sk-openai-from-env');
|
|
});
|
|
|
|
it('should fall back to config when env var is not set', () => {
|
|
delete process.env['TAKT_OPENAI_API_KEY'];
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
'openai_api_key: sk-openai-from-yaml',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveOpenaiApiKey();
|
|
expect(key).toBe('sk-openai-from-yaml');
|
|
});
|
|
|
|
it('should return undefined when neither env var nor config is set', () => {
|
|
delete process.env['TAKT_OPENAI_API_KEY'];
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveOpenaiApiKey();
|
|
expect(key).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('resolveCodexCliPath', () => {
|
|
const originalEnv = process.env['TAKT_CODEX_CLI_PATH'];
|
|
|
|
beforeEach(() => {
|
|
invalidateGlobalConfigCache();
|
|
mkdirSync(taktDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalEnv !== undefined) {
|
|
process.env['TAKT_CODEX_CLI_PATH'] = originalEnv;
|
|
} else {
|
|
delete process.env['TAKT_CODEX_CLI_PATH'];
|
|
}
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should return env var path when set', () => {
|
|
const envCodexPath = createExecutableFile('env-codex');
|
|
const configCodexPath = createExecutableFile('config-codex');
|
|
process.env['TAKT_CODEX_CLI_PATH'] = envCodexPath;
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: codex',
|
|
`codex_cli_path: ${configCodexPath}`,
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const path = resolveCodexCliPath();
|
|
expect(path).toBe(envCodexPath);
|
|
});
|
|
|
|
it('should fall back to config path when env var is not set', () => {
|
|
delete process.env['TAKT_CODEX_CLI_PATH'];
|
|
const configCodexPath = createExecutableFile('config-codex');
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: codex',
|
|
`codex_cli_path: ${configCodexPath}`,
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const path = resolveCodexCliPath();
|
|
expect(path).toBe(configCodexPath);
|
|
});
|
|
|
|
it('should return undefined when neither env var nor config is set', () => {
|
|
delete process.env['TAKT_CODEX_CLI_PATH'];
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: codex',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const path = resolveCodexCliPath();
|
|
expect(path).toBeUndefined();
|
|
});
|
|
|
|
it('should throw when env path is empty', () => {
|
|
process.env['TAKT_CODEX_CLI_PATH'] = '';
|
|
expect(() => resolveCodexCliPath()).toThrow(/must not be empty/i);
|
|
});
|
|
|
|
it('should throw when env path does not exist', () => {
|
|
process.env['TAKT_CODEX_CLI_PATH'] = join(testDir, 'missing-codex');
|
|
expect(() => resolveCodexCliPath()).toThrow(/does not exist/i);
|
|
});
|
|
|
|
it('should throw when env path points to a directory', () => {
|
|
const dirPath = join(testDir, 'codex-dir');
|
|
mkdirSync(dirPath, { recursive: true });
|
|
process.env['TAKT_CODEX_CLI_PATH'] = dirPath;
|
|
expect(() => resolveCodexCliPath()).toThrow(/executable file/i);
|
|
});
|
|
|
|
it('should throw when env path points to a non-executable file', () => {
|
|
process.env['TAKT_CODEX_CLI_PATH'] = createNonExecutableFile('non-executable-codex');
|
|
expect(() => resolveCodexCliPath()).toThrow(/not executable/i);
|
|
});
|
|
|
|
it('should throw when env path is relative', () => {
|
|
process.env['TAKT_CODEX_CLI_PATH'] = 'bin/codex';
|
|
expect(() => resolveCodexCliPath()).toThrow(/absolute path/i);
|
|
});
|
|
|
|
it('should throw when env path contains control characters', () => {
|
|
process.env['TAKT_CODEX_CLI_PATH'] = '/tmp/codex\nbad';
|
|
expect(() => resolveCodexCliPath()).toThrow(/control characters/i);
|
|
});
|
|
|
|
it('should throw when config path is invalid', () => {
|
|
delete process.env['TAKT_CODEX_CLI_PATH'];
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: codex',
|
|
`codex_cli_path: ${join(testDir, 'missing-codex-from-config')}`,
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
expect(() => resolveCodexCliPath()).toThrow(/does not exist/i);
|
|
});
|
|
});
|
|
|
|
describe('resolveOpencodeApiKey', () => {
|
|
const originalEnv = process.env['TAKT_OPENCODE_API_KEY'];
|
|
|
|
beforeEach(() => {
|
|
invalidateGlobalConfigCache();
|
|
mkdirSync(taktDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalEnv !== undefined) {
|
|
process.env['TAKT_OPENCODE_API_KEY'] = originalEnv;
|
|
} else {
|
|
delete process.env['TAKT_OPENCODE_API_KEY'];
|
|
}
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should return env var when set', () => {
|
|
process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env';
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
'opencode_api_key: sk-opencode-from-yaml',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveOpencodeApiKey();
|
|
expect(key).toBe('sk-opencode-from-env');
|
|
});
|
|
|
|
it('should fall back to config when env var is not set', () => {
|
|
delete process.env['TAKT_OPENCODE_API_KEY'];
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
'opencode_api_key: sk-opencode-from-yaml',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveOpencodeApiKey();
|
|
expect(key).toBe('sk-opencode-from-yaml');
|
|
});
|
|
|
|
it('should return undefined when neither env var nor config is set', () => {
|
|
delete process.env['TAKT_OPENCODE_API_KEY'];
|
|
const yaml = [
|
|
'language: en',
|
|
'log_level: info',
|
|
'provider: claude',
|
|
].join('\n');
|
|
writeFileSync(configPath, yaml, 'utf-8');
|
|
|
|
const key = resolveOpencodeApiKey();
|
|
expect(key).toBeUndefined();
|
|
});
|
|
});
|