Skip to content

Commit 1db879d

Browse files
authored
refactor: split server.test.js into focused test files (#2969)
* Initial plan * test: split api-proxy server tests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 4a335b2 commit 1db879d

5 files changed

Lines changed: 1722 additions & 1696 deletions

File tree

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
/**
2+
* Tests for API key validation and billing header extraction.
3+
*
4+
* Extracted from server.test.js during test-file refactoring.
5+
*/
6+
7+
const https = require('https');
8+
const { EventEmitter } = require('events');
9+
10+
const { validateApiKeys, keyValidationResults, resetKeyValidationState, extractBillingHeaders } = require('./server');
11+
12+
// ── Helpers for validateApiKeys tests ──────────────────────────────────────────
13+
14+
/**
15+
* Create a mock https.request implementation that responds with the given status code.
16+
*/
17+
function mockHttpsRequestWithStatus(statusCode) {
18+
return jest.spyOn(https, 'request').mockImplementation((options, callback) => {
19+
const req = new EventEmitter();
20+
req.write = jest.fn();
21+
req.end = jest.fn(() => {
22+
setImmediate(() => {
23+
const res = new EventEmitter();
24+
res.statusCode = statusCode;
25+
res.resume = jest.fn();
26+
callback(res);
27+
setImmediate(() => res.emit('end'));
28+
});
29+
});
30+
req.destroy = jest.fn();
31+
return req;
32+
});
33+
}
34+
35+
/**
36+
* Collect structured log lines emitted by logRequest() (written to process.stdout).
37+
*/
38+
function collectLogOutput() {
39+
const lines = [];
40+
const spy = jest.spyOn(process.stdout, 'write').mockImplementation((data) => {
41+
try {
42+
lines.push(JSON.parse(data.toString()));
43+
} catch {
44+
// ignore non-JSON writes
45+
}
46+
return true;
47+
});
48+
return { lines, spy };
49+
}
50+
51+
describe('validateApiKeys', () => {
52+
afterEach(() => {
53+
jest.restoreAllMocks();
54+
resetKeyValidationState();
55+
});
56+
57+
// ── OpenAI ─────────────────────────────────────────────────────────────────
58+
59+
it('marks OpenAI valid when probe returns 200', async () => {
60+
const { lines } = collectLogOutput();
61+
mockHttpsRequestWithStatus(200);
62+
await validateApiKeys({ openaiKey: 'sk-test', openaiTarget: 'api.openai.com' });
63+
expect(keyValidationResults.openai.status).toBe('valid');
64+
const log = lines.find(l => l.provider === 'openai' && l.status === 'valid');
65+
expect(log).toBeDefined();
66+
});
67+
68+
it('marks OpenAI auth_rejected when probe returns 401', async () => {
69+
const { lines } = collectLogOutput();
70+
mockHttpsRequestWithStatus(401);
71+
await validateApiKeys({ openaiKey: 'sk-bad', openaiTarget: 'api.openai.com' });
72+
expect(keyValidationResults.openai.status).toBe('auth_rejected');
73+
const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'openai');
74+
expect(failLog).toBeDefined();
75+
expect(failLog.level).toBe('error');
76+
});
77+
78+
it('skips OpenAI for custom API target', async () => {
79+
const { lines } = collectLogOutput();
80+
await validateApiKeys({ openaiKey: 'sk-test', openaiTarget: 'my-llm-router.internal' });
81+
expect(keyValidationResults.openai.status).toBe('skipped');
82+
const log = lines.find(l => l.provider === 'openai' && l.status === 'skipped');
83+
expect(log).toBeDefined();
84+
});
85+
86+
it('does not validate OpenAI when key is not provided', async () => {
87+
collectLogOutput();
88+
const spy = jest.spyOn(https, 'request');
89+
await validateApiKeys({ openaiKey: undefined });
90+
expect(keyValidationResults.openai).toBeUndefined();
91+
expect(spy).not.toHaveBeenCalled();
92+
});
93+
94+
// ── Anthropic ──────────────────────────────────────────────────────────────
95+
96+
it('marks Anthropic valid when probe returns 400 (key valid, body incomplete)', async () => {
97+
const { lines } = collectLogOutput();
98+
mockHttpsRequestWithStatus(400);
99+
await validateApiKeys({ anthropicKey: 'sk-ant-test', anthropicTarget: 'api.anthropic.com' });
100+
expect(keyValidationResults.anthropic.status).toBe('valid');
101+
const log = lines.find(l => l.provider === 'anthropic' && l.status === 'valid');
102+
expect(log).toBeDefined();
103+
expect(log.note).toContain('probe body rejected');
104+
});
105+
106+
it('marks Anthropic auth_rejected when probe returns 401', async () => {
107+
const { lines } = collectLogOutput();
108+
mockHttpsRequestWithStatus(401);
109+
await validateApiKeys({ anthropicKey: 'sk-ant-bad', anthropicTarget: 'api.anthropic.com' });
110+
expect(keyValidationResults.anthropic.status).toBe('auth_rejected');
111+
const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'anthropic');
112+
expect(failLog).toBeDefined();
113+
});
114+
115+
it('marks Anthropic auth_rejected when probe returns 403', async () => {
116+
mockHttpsRequestWithStatus(403);
117+
await validateApiKeys({ anthropicKey: 'sk-ant-bad', anthropicTarget: 'api.anthropic.com' });
118+
expect(keyValidationResults.anthropic.status).toBe('auth_rejected');
119+
});
120+
121+
it('skips Anthropic for custom API target', async () => {
122+
const { lines } = collectLogOutput();
123+
await validateApiKeys({ anthropicKey: 'sk-ant-test', anthropicTarget: 'proxy.corp.internal' });
124+
expect(keyValidationResults.anthropic.status).toBe('skipped');
125+
const log = lines.find(l => l.provider === 'anthropic' && l.status === 'skipped');
126+
expect(log).toBeDefined();
127+
});
128+
129+
// ── Copilot ────────────────────────────────────────────────────────────────
130+
131+
it('marks Copilot valid when probe returns 200 with non-classic token', async () => {
132+
const { lines } = collectLogOutput();
133+
mockHttpsRequestWithStatus(200);
134+
await validateApiKeys({
135+
copilotGithubToken: 'ghu_valid_token',
136+
copilotTarget: 'api.githubcopilot.com',
137+
copilotIntegrationId: 'copilot-developer-cli',
138+
});
139+
expect(keyValidationResults.copilot.status).toBe('valid');
140+
const log = lines.find(l => l.provider === 'copilot' && l.status === 'valid');
141+
expect(log).toBeDefined();
142+
});
143+
144+
it('marks Copilot auth_rejected when probe returns 401', async () => {
145+
const { lines } = collectLogOutput();
146+
mockHttpsRequestWithStatus(401);
147+
await validateApiKeys({
148+
copilotGithubToken: 'ghu_invalid',
149+
copilotTarget: 'api.githubcopilot.com',
150+
copilotIntegrationId: 'copilot-developer-cli',
151+
});
152+
expect(keyValidationResults.copilot.status).toBe('auth_rejected');
153+
const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'copilot');
154+
expect(failLog).toBeDefined();
155+
});
156+
157+
it('skips Copilot for custom API target', async () => {
158+
const { lines } = collectLogOutput();
159+
await validateApiKeys({
160+
copilotGithubToken: 'ghu_valid',
161+
copilotTarget: 'copilot-api.mycompany.ghe.com',
162+
copilotIntegrationId: 'copilot-developer-cli',
163+
});
164+
expect(keyValidationResults.copilot.status).toBe('skipped');
165+
const log = lines.find(l => l.provider === 'copilot' && l.status === 'skipped');
166+
expect(log).toBeDefined();
167+
});
168+
169+
it('skips Copilot when only COPILOT_API_KEY is set (BYOK mode)', async () => {
170+
collectLogOutput();
171+
const spy = jest.spyOn(https, 'request');
172+
await validateApiKeys({
173+
copilotGithubToken: undefined,
174+
copilotApiKey: 'sk-byok-key',
175+
copilotTarget: 'api.githubcopilot.com',
176+
});
177+
expect(keyValidationResults.copilot.status).toBe('skipped');
178+
expect(keyValidationResults.copilot.message).toContain('COPILOT_API_KEY');
179+
expect(spy).not.toHaveBeenCalled();
180+
});
181+
182+
// ── Gemini ─────────────────────────────────────────────────────────────────
183+
184+
it('marks Gemini valid when probe returns 200', async () => {
185+
const { lines } = collectLogOutput();
186+
mockHttpsRequestWithStatus(200);
187+
await validateApiKeys({ geminiKey: 'ai-test-key', geminiTarget: 'generativelanguage.googleapis.com' });
188+
expect(keyValidationResults.gemini.status).toBe('valid');
189+
const log = lines.find(l => l.provider === 'gemini' && l.status === 'valid');
190+
expect(log).toBeDefined();
191+
});
192+
193+
it('marks Gemini auth_rejected when probe returns 403', async () => {
194+
mockHttpsRequestWithStatus(403);
195+
await validateApiKeys({ geminiKey: 'ai-bad-key', geminiTarget: 'generativelanguage.googleapis.com' });
196+
expect(keyValidationResults.gemini.status).toBe('auth_rejected');
197+
});
198+
199+
it('skips Gemini for custom API target', async () => {
200+
const { lines } = collectLogOutput();
201+
await validateApiKeys({ geminiKey: 'ai-test', geminiTarget: 'my-vertex-endpoint.internal' });
202+
expect(keyValidationResults.gemini.status).toBe('skipped');
203+
const log = lines.find(l => l.provider === 'gemini' && l.status === 'skipped');
204+
expect(log).toBeDefined();
205+
});
206+
207+
// ── Cross-cutting ──────────────────────────────────────────────────────────
208+
209+
it('handles network_error when probe times out', async () => {
210+
collectLogOutput();
211+
jest.spyOn(https, 'request').mockImplementation((options, callback) => {
212+
const req = new EventEmitter();
213+
req.write = jest.fn();
214+
req.end = jest.fn(); // never responds
215+
req.destroy = jest.fn((err) => {
216+
setImmediate(() => req.emit('error', err || new Error('socket hang up')));
217+
});
218+
// Simulate Node's built-in timeout: fire 'timeout' event after the requested delay
219+
if (options.timeout) {
220+
setTimeout(() => req.emit('timeout'), options.timeout);
221+
}
222+
return req;
223+
});
224+
await validateApiKeys({
225+
openaiKey: 'sk-test',
226+
openaiTarget: 'api.openai.com',
227+
timeoutMs: 50,
228+
});
229+
expect(keyValidationResults.openai.status).toBe('network_error');
230+
}, 5000);
231+
232+
it('does not validate any provider when no keys are provided', async () => {
233+
collectLogOutput();
234+
const spy = jest.spyOn(https, 'request');
235+
await validateApiKeys({
236+
openaiKey: undefined,
237+
anthropicKey: undefined,
238+
copilotGithubToken: undefined,
239+
copilotApiKey: undefined,
240+
geminiKey: undefined,
241+
});
242+
expect(Object.keys(keyValidationResults)).toHaveLength(0);
243+
expect(spy).not.toHaveBeenCalled();
244+
});
245+
});
246+
247+
describe('extractBillingHeaders', () => {
248+
it('returns null when no billing headers present', () => {
249+
expect(extractBillingHeaders({ 'content-type': 'application/json' })).toBeNull();
250+
});
251+
252+
it('extracts X-Quota-Snapshot-Premium-Chat header', () => {
253+
const headers = {
254+
'x-quota-snapshot-premium-chat': 'ent=50&ov=0.0&ovPerm=false&rem=48.5&rst=2025-12-15T23%3A59%3A59Z',
255+
};
256+
const result = extractBillingHeaders(headers);
257+
expect(result).not.toBeNull();
258+
expect(result['quota_premium-chat']).toEqual({
259+
ent: '50',
260+
ov: '0.0',
261+
ovPerm: 'false',
262+
rem: '48.5',
263+
rst: '2025-12-15T23:59:59Z',
264+
});
265+
});
266+
267+
it('extracts multiple quota snapshot headers', () => {
268+
const headers = {
269+
'x-quota-snapshot-chat': 'ent=-1&ov=0.0&ovPerm=true&rem=0.0',
270+
'x-quota-snapshot-premium-chat': 'ent=50&ov=2.0&ovPerm=false&rem=40.0',
271+
};
272+
const result = extractBillingHeaders(headers);
273+
expect(result['quota_chat']).toEqual({ ent: '-1', ov: '0.0', ovPerm: 'true', rem: '0.0' });
274+
expect(result['quota_premium-chat']).toEqual({ ent: '50', ov: '2.0', ovPerm: 'false', rem: '40.0' });
275+
});
276+
277+
it('extracts rate limit headers', () => {
278+
const headers = {
279+
'x-ratelimit-limit': '100',
280+
'x-ratelimit-remaining': '95',
281+
'x-ratelimit-reset': '1700000000',
282+
};
283+
const result = extractBillingHeaders(headers);
284+
expect(result.rate_limit).toBe('100');
285+
expect(result.rate_remaining).toBe('95');
286+
expect(result.rate_reset).toBe('1700000000');
287+
});
288+
289+
it('handles malformed quota snapshot gracefully', () => {
290+
const headers = {
291+
'x-quota-snapshot-premium-chat': 'not-valid-url-params=%%invalid',
292+
};
293+
// URLSearchParams is lenient — it won't throw on most strings
294+
const result = extractBillingHeaders(headers);
295+
expect(result).not.toBeNull();
296+
});
297+
});

0 commit comments

Comments
 (0)