mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
- Add 31 unit tests for fetchLocalSyncStatus, fetchProfiles, and performLocalSync - Cover success, error, timeout, and network failure scenarios - Remove unused --force flag from sync handler (YAGNI)
523 lines
17 KiB
TypeScript
523 lines
17 KiB
TypeScript
/**
|
|
* Unit tests for management-api-client module
|
|
*/
|
|
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
|
import { ManagementApiClient } from '../../../src/cliproxy/management-api-client';
|
|
import type {
|
|
ManagementClientConfig,
|
|
ManagementHealthStatus,
|
|
ClaudeKey,
|
|
} from '../../../src/cliproxy/management-api-types';
|
|
|
|
describe('management-api-client', () => {
|
|
describe('ManagementApiClient', () => {
|
|
let config: ManagementClientConfig;
|
|
|
|
beforeEach(() => {
|
|
config = {
|
|
host: 'localhost',
|
|
port: 8317,
|
|
protocol: 'http',
|
|
managementKey: 'test-management-key-12345',
|
|
timeout: 2000,
|
|
allowSelfSigned: false,
|
|
};
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create client with provided config', () => {
|
|
const client = new ManagementApiClient(config);
|
|
expect(client).toBeDefined();
|
|
expect(client.getBaseUrl()).toBe('http://localhost:8317');
|
|
});
|
|
|
|
it('should use default timeout if not provided', () => {
|
|
const configWithoutTimeout = { ...config };
|
|
delete configWithoutTimeout.timeout;
|
|
const client = new ManagementApiClient(configWithoutTimeout);
|
|
expect(client).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('getBaseUrl', () => {
|
|
it('should construct HTTP URL with port', () => {
|
|
const client = new ManagementApiClient(config);
|
|
expect(client.getBaseUrl()).toBe('http://localhost:8317');
|
|
});
|
|
|
|
it('should construct HTTPS URL with custom port', () => {
|
|
const httpsConfig = { ...config, protocol: 'https' as const, port: 8443 };
|
|
const client = new ManagementApiClient(httpsConfig);
|
|
expect(client.getBaseUrl()).toBe('https://localhost:8443');
|
|
});
|
|
|
|
it('should omit standard HTTP port 80', () => {
|
|
const configPort80 = { ...config, port: 80 };
|
|
const client = new ManagementApiClient(configPort80);
|
|
expect(client.getBaseUrl()).toBe('http://localhost');
|
|
});
|
|
|
|
it('should omit standard HTTPS port 443', () => {
|
|
const configPort443 = { ...config, protocol: 'https' as const, port: 443 };
|
|
const client = new ManagementApiClient(configPort443);
|
|
expect(client.getBaseUrl()).toBe('https://localhost');
|
|
});
|
|
|
|
it('should use default port 8317 for HTTP when port is undefined', () => {
|
|
const configNoPort = { ...config };
|
|
delete configNoPort.port;
|
|
const client = new ManagementApiClient(configNoPort);
|
|
expect(client.getBaseUrl()).toBe('http://localhost:8317');
|
|
});
|
|
|
|
it('should use default port 443 for HTTPS when port is undefined', () => {
|
|
const configNoPort = { ...config, protocol: 'https' as const };
|
|
delete configNoPort.port;
|
|
const client = new ManagementApiClient(configNoPort);
|
|
expect(client.getBaseUrl()).toBe('https://localhost');
|
|
});
|
|
});
|
|
|
|
describe('error code mapping', () => {
|
|
it('should map ENOTFOUND to DNS_FAILED', () => {
|
|
const error = new Error('getaddrinfo ENOTFOUND example.com') as NodeJS.ErrnoException;
|
|
error.code = 'ENOTFOUND';
|
|
|
|
// Test via health check which uses mapErrorToCode internally
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() => Promise.reject(error));
|
|
|
|
client.health().then((status: ManagementHealthStatus) => {
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('DNS_FAILED');
|
|
});
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map ECONNREFUSED to CONNECTION_REFUSED', () => {
|
|
const error = new Error('connect ECONNREFUSED') as NodeJS.ErrnoException;
|
|
error.code = 'ECONNREFUSED';
|
|
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() => Promise.reject(error));
|
|
|
|
client.health().then((status: ManagementHealthStatus) => {
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('CONNECTION_REFUSED');
|
|
});
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map ETIMEDOUT to TIMEOUT', () => {
|
|
const error = new Error('request timeout') as NodeJS.ErrnoException;
|
|
error.code = 'ETIMEDOUT';
|
|
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() => Promise.reject(error));
|
|
|
|
client.health().then((status: ManagementHealthStatus) => {
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('TIMEOUT');
|
|
});
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map ENETUNREACH to NETWORK_UNREACHABLE', () => {
|
|
const error = new Error('network unreachable') as NodeJS.ErrnoException;
|
|
error.code = 'ENETUNREACH';
|
|
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() => Promise.reject(error));
|
|
|
|
client.health().then((status: ManagementHealthStatus) => {
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('NETWORK_UNREACHABLE');
|
|
});
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map 401 status to AUTH_FAILED', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(''),
|
|
} as Response)
|
|
);
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('AUTH_FAILED');
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map 403 status to AUTH_FAILED', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
status: 403,
|
|
statusText: 'Forbidden',
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(''),
|
|
} as Response)
|
|
);
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('AUTH_FAILED');
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map 404 status to NOT_FOUND', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(''),
|
|
} as Response)
|
|
);
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('NOT_FOUND');
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map 400 status to BAD_REQUEST', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(''),
|
|
} as Response)
|
|
);
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('BAD_REQUEST');
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map 500+ status to SERVER_ERROR', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(''),
|
|
} as Response)
|
|
);
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('SERVER_ERROR');
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should map unknown errors to UNKNOWN', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() => Promise.reject(new Error('Something weird happened')));
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('UNKNOWN');
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
});
|
|
|
|
describe('authentication header', () => {
|
|
it('should include Bearer token in Authorization header', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
let capturedHeaders: Record<string, string> = {};
|
|
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock((url: string, options?: RequestInit) => {
|
|
if (options?.headers) {
|
|
capturedHeaders = options.headers as Record<string, string>;
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve('{"claude-api-key":[]}'),
|
|
} as Response);
|
|
});
|
|
|
|
await client.getClaudeKeys();
|
|
expect(capturedHeaders['Authorization']).toBe('Bearer test-management-key-12345');
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should mask management key in logs/errors', () => {
|
|
// Management key should never appear in plain text in error messages
|
|
const sensitiveKey = 'super-secret-key-abc123';
|
|
const clientConfig = { ...config, managementKey: sensitiveKey };
|
|
const client = new ManagementApiClient(clientConfig);
|
|
|
|
// The key should be used internally but not exposed
|
|
expect(client.getBaseUrl()).not.toContain(sensitiveKey);
|
|
});
|
|
});
|
|
|
|
describe('timeout handling', () => {
|
|
it('should respect custom timeout value', () => {
|
|
const customTimeout = 10000;
|
|
const configWithTimeout = { ...config, timeout: customTimeout };
|
|
const client = new ManagementApiClient(configWithTimeout);
|
|
expect(client).toBeDefined();
|
|
});
|
|
|
|
it('should abort request on timeout', async () => {
|
|
const client = new ManagementApiClient({ ...config, timeout: 100 });
|
|
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(
|
|
() =>
|
|
new Promise((_, reject) => {
|
|
setTimeout(() => {
|
|
const error = new Error('The operation was aborted');
|
|
error.name = 'AbortError';
|
|
reject(error);
|
|
}, 150);
|
|
})
|
|
);
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.errorCode).toBe('TIMEOUT');
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
});
|
|
|
|
describe('self-signed certificate option', () => {
|
|
it('should use fetch for HTTP regardless of allowSelfSigned', async () => {
|
|
const client = new ManagementApiClient({ ...config, allowSelfSigned: true });
|
|
|
|
const originalFetch = global.fetch;
|
|
let fetchCalled = false;
|
|
global.fetch = mock(() => {
|
|
fetchCalled = true;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve('{"claude-api-key":[]}'),
|
|
} as Response);
|
|
});
|
|
|
|
await client.getClaudeKeys();
|
|
expect(fetchCalled).toBe(true);
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should use https module for HTTPS with allowSelfSigned', () => {
|
|
const httpsConfig = {
|
|
...config,
|
|
protocol: 'https' as const,
|
|
allowSelfSigned: true,
|
|
};
|
|
const client = new ManagementApiClient(httpsConfig);
|
|
expect(client).toBeDefined();
|
|
// Actual HTTPS request would use native https module with rejectUnauthorized: false
|
|
});
|
|
|
|
it('should use fetch for HTTPS without allowSelfSigned', async () => {
|
|
const httpsConfig = {
|
|
...config,
|
|
protocol: 'https' as const,
|
|
allowSelfSigned: false,
|
|
};
|
|
const client = new ManagementApiClient(httpsConfig);
|
|
|
|
const originalFetch = global.fetch;
|
|
let fetchCalled = false;
|
|
global.fetch = mock(() => {
|
|
fetchCalled = true;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve('{"claude-api-key":[]}'),
|
|
} as Response);
|
|
});
|
|
|
|
await client.getClaudeKeys();
|
|
expect(fetchCalled).toBe(true);
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
});
|
|
|
|
describe('health check', () => {
|
|
it('should return healthy status with version info', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({
|
|
'x-cpa-version': '1.2.3',
|
|
'x-cpa-commit': 'abc123',
|
|
}),
|
|
text: () => Promise.resolve('{"claude-api-key":[]}'),
|
|
} as Response)
|
|
);
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(true);
|
|
expect(status.version).toBe('1.2.3');
|
|
expect(status.commit).toBe('abc123');
|
|
expect(status.latencyMs).toBeGreaterThanOrEqual(0);
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should return unhealthy status on error', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() => Promise.reject(new Error('Connection failed')));
|
|
|
|
const status = await client.health();
|
|
expect(status.healthy).toBe(false);
|
|
expect(status.error).toBeDefined();
|
|
expect(status.errorCode).toBeDefined();
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
});
|
|
|
|
describe('CRUD operations', () => {
|
|
it('should get claude keys', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const mockKeys: ClaudeKey[] = [
|
|
{ 'api-key': 'sk-test-123', prefix: 'glm-' },
|
|
];
|
|
|
|
const originalFetch = global.fetch;
|
|
global.fetch = mock(() =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(JSON.stringify({ 'claude-api-key': mockKeys })),
|
|
} as Response)
|
|
);
|
|
|
|
const keys = await client.getClaudeKeys();
|
|
expect(keys).toEqual(mockKeys);
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should put claude keys', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const mockKeys: ClaudeKey[] = [
|
|
{ 'api-key': 'sk-test-456', prefix: 'kimi-' },
|
|
];
|
|
|
|
const originalFetch = global.fetch;
|
|
let requestBody: string | undefined;
|
|
global.fetch = mock((url: string, options?: RequestInit) => {
|
|
requestBody = options?.body as string;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(''),
|
|
} as Response);
|
|
});
|
|
|
|
await client.putClaudeKeys(mockKeys);
|
|
expect(requestBody).toBe(JSON.stringify(mockKeys));
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should patch claude key', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const patch = {
|
|
index: 0,
|
|
value: { prefix: 'updated-' },
|
|
};
|
|
|
|
const originalFetch = global.fetch;
|
|
let requestBody: string | undefined;
|
|
global.fetch = mock((url: string, options?: RequestInit) => {
|
|
requestBody = options?.body as string;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(''),
|
|
} as Response);
|
|
});
|
|
|
|
await client.patchClaudeKey(patch);
|
|
expect(requestBody).toBe(JSON.stringify(patch));
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('should delete claude key', async () => {
|
|
const client = new ManagementApiClient(config);
|
|
const apiKey = 'sk-test-to-delete';
|
|
|
|
const originalFetch = global.fetch;
|
|
let requestUrl = '';
|
|
global.fetch = mock((url: string) => {
|
|
requestUrl = url;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers(),
|
|
text: () => Promise.resolve(''),
|
|
} as Response);
|
|
});
|
|
|
|
await client.deleteClaudeKey(apiKey);
|
|
expect(requestUrl).toContain(encodeURIComponent(apiKey));
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
});
|
|
});
|
|
});
|