|
3 | 3 | */ |
4 | 4 |
|
5 | 5 | const http = require('http'); |
| 6 | +const https = require('https'); |
6 | 7 | const tls = require('tls'); |
7 | 8 | const { EventEmitter } = require('events'); |
8 | | -const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, httpProbe } = require('./server'); |
| 9 | +const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState } = require('./server'); |
9 | 10 |
|
10 | 11 | describe('normalizeApiTarget', () => { |
11 | 12 | it('should strip https:// prefix', () => { |
@@ -1063,3 +1064,238 @@ describe('httpProbe', () => { |
1063 | 1064 | ).rejects.toThrow(/timed out/i); |
1064 | 1065 | }); |
1065 | 1066 | }); |
| 1067 | + |
| 1068 | +// ── Helpers for validateApiKeys tests ────────────────────────────────────────── |
| 1069 | + |
| 1070 | +/** |
| 1071 | + * Create a mock https.request implementation that responds with the given status code. |
| 1072 | + */ |
| 1073 | +function mockHttpsRequestWithStatus(statusCode) { |
| 1074 | + return jest.spyOn(https, 'request').mockImplementation((options, callback) => { |
| 1075 | + const req = new EventEmitter(); |
| 1076 | + req.write = jest.fn(); |
| 1077 | + req.end = jest.fn(() => { |
| 1078 | + setImmediate(() => { |
| 1079 | + const res = new EventEmitter(); |
| 1080 | + res.statusCode = statusCode; |
| 1081 | + res.resume = jest.fn(); |
| 1082 | + callback(res); |
| 1083 | + setImmediate(() => res.emit('end')); |
| 1084 | + }); |
| 1085 | + }); |
| 1086 | + req.destroy = jest.fn(); |
| 1087 | + return req; |
| 1088 | + }); |
| 1089 | +} |
| 1090 | + |
| 1091 | +/** |
| 1092 | + * Collect structured log lines emitted by logRequest() (written to process.stdout). |
| 1093 | + */ |
| 1094 | +function collectLogOutput() { |
| 1095 | + const lines = []; |
| 1096 | + const spy = jest.spyOn(process.stdout, 'write').mockImplementation((data) => { |
| 1097 | + try { |
| 1098 | + lines.push(JSON.parse(data.toString())); |
| 1099 | + } catch { |
| 1100 | + // ignore non-JSON writes |
| 1101 | + } |
| 1102 | + return true; |
| 1103 | + }); |
| 1104 | + return { lines, spy }; |
| 1105 | +} |
| 1106 | + |
| 1107 | +describe('validateApiKeys', () => { |
| 1108 | + afterEach(() => { |
| 1109 | + jest.restoreAllMocks(); |
| 1110 | + resetKeyValidationState(); |
| 1111 | + }); |
| 1112 | + |
| 1113 | + // ── OpenAI ───────────────────────────────────────────────────────────────── |
| 1114 | + |
| 1115 | + it('marks OpenAI valid when probe returns 200', async () => { |
| 1116 | + const { lines } = collectLogOutput(); |
| 1117 | + mockHttpsRequestWithStatus(200); |
| 1118 | + await validateApiKeys({ openaiKey: 'sk-test', openaiTarget: 'api.openai.com' }); |
| 1119 | + expect(keyValidationResults.openai.status).toBe('valid'); |
| 1120 | + const log = lines.find(l => l.provider === 'openai' && l.status === 'valid'); |
| 1121 | + expect(log).toBeDefined(); |
| 1122 | + }); |
| 1123 | + |
| 1124 | + it('marks OpenAI auth_rejected when probe returns 401', async () => { |
| 1125 | + const { lines } = collectLogOutput(); |
| 1126 | + mockHttpsRequestWithStatus(401); |
| 1127 | + await validateApiKeys({ openaiKey: 'sk-bad', openaiTarget: 'api.openai.com' }); |
| 1128 | + expect(keyValidationResults.openai.status).toBe('auth_rejected'); |
| 1129 | + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'openai'); |
| 1130 | + expect(failLog).toBeDefined(); |
| 1131 | + expect(failLog.level).toBe('error'); |
| 1132 | + }); |
| 1133 | + |
| 1134 | + it('skips OpenAI for custom API target', async () => { |
| 1135 | + const { lines } = collectLogOutput(); |
| 1136 | + await validateApiKeys({ openaiKey: 'sk-test', openaiTarget: 'my-llm-router.internal' }); |
| 1137 | + expect(keyValidationResults.openai.status).toBe('skipped'); |
| 1138 | + const log = lines.find(l => l.provider === 'openai' && l.status === 'skipped'); |
| 1139 | + expect(log).toBeDefined(); |
| 1140 | + }); |
| 1141 | + |
| 1142 | + it('does not validate OpenAI when key is not provided', async () => { |
| 1143 | + collectLogOutput(); |
| 1144 | + const spy = jest.spyOn(https, 'request'); |
| 1145 | + await validateApiKeys({ openaiKey: undefined }); |
| 1146 | + expect(keyValidationResults.openai).toBeUndefined(); |
| 1147 | + expect(spy).not.toHaveBeenCalled(); |
| 1148 | + }); |
| 1149 | + |
| 1150 | + // ── Anthropic ────────────────────────────────────────────────────────────── |
| 1151 | + |
| 1152 | + it('marks Anthropic valid when probe returns 400 (key valid, body incomplete)', async () => { |
| 1153 | + const { lines } = collectLogOutput(); |
| 1154 | + mockHttpsRequestWithStatus(400); |
| 1155 | + await validateApiKeys({ anthropicKey: 'sk-ant-test', anthropicTarget: 'api.anthropic.com' }); |
| 1156 | + expect(keyValidationResults.anthropic.status).toBe('valid'); |
| 1157 | + const log = lines.find(l => l.provider === 'anthropic' && l.status === 'valid'); |
| 1158 | + expect(log).toBeDefined(); |
| 1159 | + expect(log.note).toContain('probe body rejected'); |
| 1160 | + }); |
| 1161 | + |
| 1162 | + it('marks Anthropic auth_rejected when probe returns 401', async () => { |
| 1163 | + const { lines } = collectLogOutput(); |
| 1164 | + mockHttpsRequestWithStatus(401); |
| 1165 | + await validateApiKeys({ anthropicKey: 'sk-ant-bad', anthropicTarget: 'api.anthropic.com' }); |
| 1166 | + expect(keyValidationResults.anthropic.status).toBe('auth_rejected'); |
| 1167 | + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'anthropic'); |
| 1168 | + expect(failLog).toBeDefined(); |
| 1169 | + }); |
| 1170 | + |
| 1171 | + it('marks Anthropic auth_rejected when probe returns 403', async () => { |
| 1172 | + mockHttpsRequestWithStatus(403); |
| 1173 | + await validateApiKeys({ anthropicKey: 'sk-ant-bad', anthropicTarget: 'api.anthropic.com' }); |
| 1174 | + expect(keyValidationResults.anthropic.status).toBe('auth_rejected'); |
| 1175 | + }); |
| 1176 | + |
| 1177 | + it('skips Anthropic for custom API target', async () => { |
| 1178 | + const { lines } = collectLogOutput(); |
| 1179 | + await validateApiKeys({ anthropicKey: 'sk-ant-test', anthropicTarget: 'proxy.corp.internal' }); |
| 1180 | + expect(keyValidationResults.anthropic.status).toBe('skipped'); |
| 1181 | + const log = lines.find(l => l.provider === 'anthropic' && l.status === 'skipped'); |
| 1182 | + expect(log).toBeDefined(); |
| 1183 | + }); |
| 1184 | + |
| 1185 | + // ── Copilot ──────────────────────────────────────────────────────────────── |
| 1186 | + |
| 1187 | + it('marks Copilot valid when probe returns 200 with non-classic token', async () => { |
| 1188 | + const { lines } = collectLogOutput(); |
| 1189 | + mockHttpsRequestWithStatus(200); |
| 1190 | + await validateApiKeys({ |
| 1191 | + copilotGithubToken: 'ghu_valid_token', |
| 1192 | + copilotTarget: 'api.githubcopilot.com', |
| 1193 | + copilotIntegrationId: 'copilot-developer-cli', |
| 1194 | + }); |
| 1195 | + expect(keyValidationResults.copilot.status).toBe('valid'); |
| 1196 | + const log = lines.find(l => l.provider === 'copilot' && l.status === 'valid'); |
| 1197 | + expect(log).toBeDefined(); |
| 1198 | + }); |
| 1199 | + |
| 1200 | + it('marks Copilot auth_rejected when probe returns 401', async () => { |
| 1201 | + const { lines } = collectLogOutput(); |
| 1202 | + mockHttpsRequestWithStatus(401); |
| 1203 | + await validateApiKeys({ |
| 1204 | + copilotGithubToken: 'ghu_invalid', |
| 1205 | + copilotTarget: 'api.githubcopilot.com', |
| 1206 | + copilotIntegrationId: 'copilot-developer-cli', |
| 1207 | + }); |
| 1208 | + expect(keyValidationResults.copilot.status).toBe('auth_rejected'); |
| 1209 | + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'copilot'); |
| 1210 | + expect(failLog).toBeDefined(); |
| 1211 | + }); |
| 1212 | + |
| 1213 | + it('skips Copilot for custom API target', async () => { |
| 1214 | + const { lines } = collectLogOutput(); |
| 1215 | + await validateApiKeys({ |
| 1216 | + copilotGithubToken: 'ghu_valid', |
| 1217 | + copilotTarget: 'copilot-api.mycompany.ghe.com', |
| 1218 | + copilotIntegrationId: 'copilot-developer-cli', |
| 1219 | + }); |
| 1220 | + expect(keyValidationResults.copilot.status).toBe('skipped'); |
| 1221 | + const log = lines.find(l => l.provider === 'copilot' && l.status === 'skipped'); |
| 1222 | + expect(log).toBeDefined(); |
| 1223 | + }); |
| 1224 | + |
| 1225 | + it('skips Copilot when only COPILOT_API_KEY is set (BYOK mode)', async () => { |
| 1226 | + collectLogOutput(); |
| 1227 | + const spy = jest.spyOn(https, 'request'); |
| 1228 | + await validateApiKeys({ |
| 1229 | + copilotGithubToken: undefined, |
| 1230 | + copilotApiKey: 'sk-byok-key', |
| 1231 | + copilotTarget: 'api.githubcopilot.com', |
| 1232 | + }); |
| 1233 | + expect(keyValidationResults.copilot.status).toBe('skipped'); |
| 1234 | + expect(keyValidationResults.copilot.message).toContain('COPILOT_API_KEY'); |
| 1235 | + expect(spy).not.toHaveBeenCalled(); |
| 1236 | + }); |
| 1237 | + |
| 1238 | + // ── Gemini ───────────────────────────────────────────────────────────────── |
| 1239 | + |
| 1240 | + it('marks Gemini valid when probe returns 200', async () => { |
| 1241 | + const { lines } = collectLogOutput(); |
| 1242 | + mockHttpsRequestWithStatus(200); |
| 1243 | + await validateApiKeys({ geminiKey: 'ai-test-key', geminiTarget: 'generativelanguage.googleapis.com' }); |
| 1244 | + expect(keyValidationResults.gemini.status).toBe('valid'); |
| 1245 | + const log = lines.find(l => l.provider === 'gemini' && l.status === 'valid'); |
| 1246 | + expect(log).toBeDefined(); |
| 1247 | + }); |
| 1248 | + |
| 1249 | + it('marks Gemini auth_rejected when probe returns 403', async () => { |
| 1250 | + mockHttpsRequestWithStatus(403); |
| 1251 | + await validateApiKeys({ geminiKey: 'ai-bad-key', geminiTarget: 'generativelanguage.googleapis.com' }); |
| 1252 | + expect(keyValidationResults.gemini.status).toBe('auth_rejected'); |
| 1253 | + }); |
| 1254 | + |
| 1255 | + it('skips Gemini for custom API target', async () => { |
| 1256 | + const { lines } = collectLogOutput(); |
| 1257 | + await validateApiKeys({ geminiKey: 'ai-test', geminiTarget: 'my-vertex-endpoint.internal' }); |
| 1258 | + expect(keyValidationResults.gemini.status).toBe('skipped'); |
| 1259 | + const log = lines.find(l => l.provider === 'gemini' && l.status === 'skipped'); |
| 1260 | + expect(log).toBeDefined(); |
| 1261 | + }); |
| 1262 | + |
| 1263 | + // ── Cross-cutting ────────────────────────────────────────────────────────── |
| 1264 | + |
| 1265 | + it('handles network_error when probe times out', async () => { |
| 1266 | + collectLogOutput(); |
| 1267 | + jest.spyOn(https, 'request').mockImplementation((options, callback) => { |
| 1268 | + const req = new EventEmitter(); |
| 1269 | + req.write = jest.fn(); |
| 1270 | + req.end = jest.fn(); // never responds |
| 1271 | + req.destroy = jest.fn((err) => { |
| 1272 | + setImmediate(() => req.emit('error', err || new Error('socket hang up'))); |
| 1273 | + }); |
| 1274 | + // Simulate Node's built-in timeout: fire 'timeout' event after the requested delay |
| 1275 | + if (options.timeout) { |
| 1276 | + setTimeout(() => req.emit('timeout'), options.timeout); |
| 1277 | + } |
| 1278 | + return req; |
| 1279 | + }); |
| 1280 | + await validateApiKeys({ |
| 1281 | + openaiKey: 'sk-test', |
| 1282 | + openaiTarget: 'api.openai.com', |
| 1283 | + timeoutMs: 50, |
| 1284 | + }); |
| 1285 | + expect(keyValidationResults.openai.status).toBe('network_error'); |
| 1286 | + }, 5000); |
| 1287 | + |
| 1288 | + it('does not validate any provider when no keys are provided', async () => { |
| 1289 | + collectLogOutput(); |
| 1290 | + const spy = jest.spyOn(https, 'request'); |
| 1291 | + await validateApiKeys({ |
| 1292 | + openaiKey: undefined, |
| 1293 | + anthropicKey: undefined, |
| 1294 | + copilotGithubToken: undefined, |
| 1295 | + copilotApiKey: undefined, |
| 1296 | + geminiKey: undefined, |
| 1297 | + }); |
| 1298 | + expect(Object.keys(keyValidationResults)).toHaveLength(0); |
| 1299 | + expect(spy).not.toHaveBeenCalled(); |
| 1300 | + }); |
| 1301 | +}); |
0 commit comments