takt/src/__tests__/globalConfig-resolvers.test.ts

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();
});
});