import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; describe('API Keys API', () => { const originalFetch = globalThis.fetch; beforeEach(() => { vi.resetModules(); Object.defineProperty(window, 'location', { value: { href: '' }, writable: true, }); }); afterEach(() => { globalThis.fetch = originalFetch; }); describe('list', () => { it('should call GET /api/v1/user/api-keys and parse response', async () => { const mockKeys = [ { id: 'key-1', provider_name: 'gemini', key_prefix: 'AIza...xQ', created_at: '2026-03-01T00:00:00Z', updated_at: '2026-03-01T00:00:00Z', }, { id: 'key-2', provider_name: 'openai', key_prefix: 'sk-ab...cd', created_at: '2026-03-02T00:00:00Z', updated_at: '2026-03-02T00:00:00Z', }, ]; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockKeys), }); const { apiKeysApi } = await import('~/api/apiKeys'); const result = await apiKeysApi.list(); expect(globalThis.fetch).toHaveBeenCalledWith( '/api/v1/user/api-keys', expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ 'X-Requested-With': 'XMLHttpRequest', }), credentials: 'same-origin', }), ); expect(result).toEqual(mockKeys); expect(result).toHaveLength(2); expect(result[0].provider_name).toBe('gemini'); expect(result[0].key_prefix).toBe('AIza...xQ'); expect(result[1].provider_name).toBe('openai'); expect(result[1].key_prefix).toBe('sk-ab...cd'); }); it('should return empty array when no keys configured', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve([]), }); const { apiKeysApi } = await import('~/api/apiKeys'); const result = await apiKeysApi.list(); expect(result).toEqual([]); expect(result).toHaveLength(0); }); it('should never include full API key in list response', async () => { const mockKeys = [ { id: 'key-1', provider_name: 'gemini', key_prefix: 'AIza...xQ', created_at: '2026-03-01T00:00:00Z', updated_at: '2026-03-01T00:00:00Z', }, ]; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockKeys), }); const { apiKeysApi } = await import('~/api/apiKeys'); const result = await apiKeysApi.list(); // Verify no key field with full API key exists in the response for (const key of result) { expect(key).not.toHaveProperty('api_key'); expect(key).not.toHaveProperty('encrypted_key'); expect(key).toHaveProperty('key_prefix'); } }); }); describe('create', () => { it('should call POST /api/v1/user/api-keys with body', async () => { const mockResponse = { id: 'key-3', provider_name: 'anthropic', key_prefix: 'sk-an...ef', created_at: '2026-03-10T00:00:00Z', updated_at: '2026-03-10T00:00:00Z', }; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockResponse), }); const { apiKeysApi } = await import('~/api/apiKeys'); const result = await apiKeysApi.create({ provider_name: 'anthropic', api_key: 'sk-ant-full-key-here', }); expect(globalThis.fetch).toHaveBeenCalledWith( '/api/v1/user/api-keys', expect.objectContaining({ method: 'POST', body: JSON.stringify({ provider_name: 'anthropic', api_key: 'sk-ant-full-key-here', }), headers: expect.objectContaining({ 'Content-Type': 'application/json', }), }), ); expect(result).toEqual(mockResponse); expect(result.key_prefix).toBe('sk-an...ef'); }); }); describe('remove', () => { it('should call DELETE /api/v1/user/api-keys/:provider', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204, }); const { apiKeysApi } = await import('~/api/apiKeys'); await apiKeysApi.remove('gemini'); expect(globalThis.fetch).toHaveBeenCalledWith( '/api/v1/user/api-keys/gemini', expect.objectContaining({ method: 'DELETE' }), ); }); it('should encode provider name in URL', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204, }); const { apiKeysApi } = await import('~/api/apiKeys'); await apiKeysApi.remove('provider with spaces'); expect(globalThis.fetch).toHaveBeenCalledWith( '/api/v1/user/api-keys/provider%20with%20spaces', expect.objectContaining({ method: 'DELETE' }), ); }); }); describe('test', () => { it('should call POST /api/v1/user/api-keys/:provider/test', async () => { const mockResponse = { success: true, message: 'Connection successful', }; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockResponse), }); const { apiKeysApi } = await import('~/api/apiKeys'); const result = await apiKeysApi.test('gemini'); expect(globalThis.fetch).toHaveBeenCalledWith( '/api/v1/user/api-keys/gemini/test', expect.objectContaining({ method: 'POST', credentials: 'same-origin', }), ); expect(result.success).toBe(true); expect(result.message).toBe('Connection successful'); }); it('should return failure result for invalid key', async () => { const mockResponse = { success: false, message: 'Invalid API key', }; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockResponse), }); const { apiKeysApi } = await import('~/api/apiKeys'); const result = await apiKeysApi.test('openai'); expect(result.success).toBe(false); expect(result.message).toBe('Invalid API key'); }); }); }); describe('Key prefix display formatting', () => { it('should display prefix as-is from the API response', () => { // The backend is responsible for generating the prefix (e.g., "sk-ab...cd") // The frontend displays it verbatim; this test documents that contract. const keyPrefix = 'sk-ab...cd'; expect(keyPrefix).toMatch(/^.{2,6}\.\.\..{2,4}$/); }); it('should handle various prefix formats', () => { const prefixes = [ 'AIza...xQ', // Gemini-style 'sk-ab...cd', // OpenAI-style 'sk-an...ef', // Anthropic-style ]; for (const prefix of prefixes) { // All prefixes should contain the ellipsis marker expect(prefix).toContain('...'); // No prefix should be longer than 12 characters (prefix + ... + suffix) expect(prefix.length).toBeLessThanOrEqual(12); } }); it('should never contain a full key value', () => { // A full API key is typically 30+ characters const prefix = 'sk-ab...cd'; expect(prefix.length).toBeLessThan(20); expect(prefix).toContain('...'); }); });