/** * 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 } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { GlobalConfigSchema } from '../models/schemas.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'); vi.mock('../config/paths.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, getGlobalConfigPath: () => configPath, getTaktDir: () => taktDir, }; }); // Import after mocking const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey } = await import('../config/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(() => { mkdirSync(taktDir, { recursive: true }); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); it('should load config with API keys from YAML', () => { const yaml = [ 'language: en', 'trusted_directories: []', 'default_workflow: default', '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', 'trusted_directories: []', 'default_workflow: default', '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', 'trusted_directories: []', 'default_workflow: default', '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', 'trusted_directories: []', 'default_workflow: default', '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(() => { 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', 'trusted_directories: []', 'default_workflow: default', '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', 'trusted_directories: []', 'default_workflow: default', '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', 'trusted_directories: []', 'default_workflow: default', '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(() => { 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', 'trusted_directories: []', 'default_workflow: default', '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', 'trusted_directories: []', 'default_workflow: default', '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', 'trusted_directories: []', 'default_workflow: default', 'log_level: info', 'provider: claude', ].join('\n'); writeFileSync(configPath, yaml, 'utf-8'); const key = resolveOpenaiApiKey(); expect(key).toBeUndefined(); }); });