|
| 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