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