You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

260 lines
7.5 KiB
TypeScript

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