Skip to content

Commit fc6fcbd

Browse files
lpcoxCopilotCopilot
committed
test(api-proxy): add validateApiKeys orchestration tests
Port and adapt 18 tests from PR #2199 (copilot-swe-agent) covering the validateApiKeys orchestrator for all four providers: - OpenAI: valid (200), auth_rejected (401), skipped (custom target), no-op (no key) - Anthropic: valid (400 = key accepted), auth_rejected (401, 403), skipped (custom target) - Copilot: valid (200 with ghu_ token), auth_rejected (401), skipped (custom target, BYOK mode) - Gemini: valid (200), auth_rejected (403), skipped (custom target) - Cross-cutting: network_error (timeout), no-op (no keys at all) To make validateApiKeys testable without module-level state: - Added overrides parameter for injecting keys/targets in tests - Exported keyValidationResults and resetKeyValidationState() - Used 'in' operator for override resolution (supports explicit undefined) Co-authored-by: copilot-swe-agent[bot] <198982749+copilot-swe-agent[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bb7bc60 commit fc6fcbd

2 files changed

Lines changed: 297 additions & 26 deletions

File tree

containers/api-proxy/server.js

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,14 @@ const keyValidationResults = {};
900900
/** Set to true once validateApiKeys() has finished (regardless of outcome). */
901901
let keyValidationComplete = false;
902902

903+
/** Reset validation state (used in tests). */
904+
function resetKeyValidationState() {
905+
for (const key of Object.keys(keyValidationResults)) {
906+
delete keyValidationResults[key];
907+
}
908+
keyValidationComplete = false;
909+
}
910+
903911
/**
904912
* Perform a lightweight probe against the provider's API to check if the
905913
* configured key is still accepted. Results are logged and stored in
@@ -911,61 +919,88 @@ let keyValidationComplete = false;
911919
*
912920
* Only validates against known default targets. Custom/enterprise targets
913921
* are skipped because we don't know what probe endpoints they expose.
922+
*
923+
* @param {object} [overrides={}] - Optional key/target overrides (used in tests)
924+
* @param {string} [overrides.openaiKey] - Override OPENAI_API_KEY
925+
* @param {string} [overrides.openaiTarget] - Override OPENAI_API_TARGET
926+
* @param {string} [overrides.anthropicKey] - Override ANTHROPIC_API_KEY
927+
* @param {string} [overrides.anthropicTarget] - Override ANTHROPIC_API_TARGET
928+
* @param {string} [overrides.copilotGithubToken] - Override COPILOT_GITHUB_TOKEN
929+
* @param {string} [overrides.copilotApiKey] - Override COPILOT_API_KEY
930+
* @param {string} [overrides.copilotAuthToken] - Override COPILOT_AUTH_TOKEN
931+
* @param {string} [overrides.copilotTarget] - Override COPILOT_API_TARGET
932+
* @param {string} [overrides.copilotIntegrationId] - Override COPILOT_INTEGRATION_ID
933+
* @param {string} [overrides.geminiKey] - Override GEMINI_API_KEY
934+
* @param {string} [overrides.geminiTarget] - Override GEMINI_API_TARGET
935+
* @param {number} [overrides.timeoutMs] - Override probe timeout
914936
*/
915-
async function validateApiKeys() {
937+
async function validateApiKeys(overrides = {}) {
916938
const mode = (process.env.AWF_VALIDATE_KEYS || 'warn').toLowerCase(); // off | warn | strict
917939
if (mode === 'off') {
918940
logRequest('info', 'key_validation', { message: 'Key validation disabled (AWF_VALIDATE_KEYS=off)' });
919941
keyValidationComplete = true;
920942
return;
921943
}
922944

923-
const TIMEOUT_MS = 10_000;
945+
const ov = (key, fallback) => key in overrides ? overrides[key] : fallback;
946+
const openaiKey = ov('openaiKey', OPENAI_API_KEY);
947+
const openaiTarget = ov('openaiTarget', OPENAI_API_TARGET);
948+
const anthropicKey = ov('anthropicKey', ANTHROPIC_API_KEY);
949+
const anthropicTarget = ov('anthropicTarget', ANTHROPIC_API_TARGET);
950+
const copilotGithubToken = ov('copilotGithubToken', COPILOT_GITHUB_TOKEN);
951+
const copilotApiKey = ov('copilotApiKey', COPILOT_API_KEY);
952+
const copilotAuthToken = ov('copilotAuthToken', COPILOT_AUTH_TOKEN);
953+
const copilotTarget = ov('copilotTarget', COPILOT_API_TARGET);
954+
const copilotIntegrationId = ov('copilotIntegrationId', COPILOT_INTEGRATION_ID);
955+
const geminiKey = ov('geminiKey', GEMINI_API_KEY);
956+
const geminiTarget = ov('geminiTarget', GEMINI_API_TARGET);
957+
const TIMEOUT_MS = ov('timeoutMs', 10_000);
958+
924959
const probes = [];
925960

926961
// --- Copilot (COPILOT_GITHUB_TOKEN only — COPILOT_API_KEY has no probe endpoint) ---
927-
if (COPILOT_GITHUB_TOKEN) {
928-
if (COPILOT_API_TARGET !== 'api.githubcopilot.com') {
929-
keyValidationResults.copilot = { status: 'skipped', message: `Custom target ${COPILOT_API_TARGET}; validation skipped` };
962+
if (copilotGithubToken) {
963+
if (copilotTarget !== 'api.githubcopilot.com') {
964+
keyValidationResults.copilot = { status: 'skipped', message: `Custom target ${copilotTarget}; validation skipped` };
930965
logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot });
931966
} else {
932-
probes.push(probeProvider('copilot', `https://${COPILOT_API_TARGET}/models`, {
967+
probes.push(probeProvider('copilot', `https://${copilotTarget}/models`, {
933968
method: 'GET',
934969
headers: {
935-
'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`,
936-
'Copilot-Integration-Id': COPILOT_INTEGRATION_ID,
970+
'Authorization': `Bearer ${copilotGithubToken}`,
971+
'Copilot-Integration-Id': copilotIntegrationId,
937972
},
938973
}, TIMEOUT_MS));
939974
}
940-
} else if (COPILOT_API_KEY && !COPILOT_GITHUB_TOKEN) {
975+
} else if (copilotApiKey && !copilotGithubToken) {
941976
keyValidationResults.copilot = { status: 'skipped', message: 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode' };
942977
logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot });
943978
}
944979

945980
// --- OpenAI ---
946-
if (OPENAI_API_KEY) {
947-
if (OPENAI_API_TARGET !== 'api.openai.com') {
948-
keyValidationResults.openai = { status: 'skipped', message: `Custom target ${OPENAI_API_TARGET}; validation skipped` };
981+
if (openaiKey) {
982+
if (openaiTarget !== 'api.openai.com') {
983+
keyValidationResults.openai = { status: 'skipped', message: `Custom target ${openaiTarget}; validation skipped` };
949984
logRequest('info', 'key_validation', { provider: 'openai', ...keyValidationResults.openai });
950985
} else {
951-
probes.push(probeProvider('openai', `https://${OPENAI_API_TARGET}/v1/models`, {
986+
probes.push(probeProvider('openai', `https://${openaiTarget}/v1/models`, {
952987
method: 'GET',
953-
headers: { 'Authorization': `Bearer ${OPENAI_API_KEY}` },
988+
headers: { 'Authorization': `Bearer ${openaiKey}` },
954989
}, TIMEOUT_MS));
955990
}
956991
}
957992

958993
// --- Anthropic ---
959-
if (ANTHROPIC_API_KEY) {
960-
if (ANTHROPIC_API_TARGET !== 'api.anthropic.com') {
961-
keyValidationResults.anthropic = { status: 'skipped', message: `Custom target ${ANTHROPIC_API_TARGET}; validation skipped` };
994+
if (anthropicKey) {
995+
if (anthropicTarget !== 'api.anthropic.com') {
996+
keyValidationResults.anthropic = { status: 'skipped', message: `Custom target ${anthropicTarget}; validation skipped` };
962997
logRequest('info', 'key_validation', { provider: 'anthropic', ...keyValidationResults.anthropic });
963998
} else {
964999
// POST /v1/messages with an empty body — 400 = key valid (bad body), 401 = key invalid
965-
probes.push(probeProvider('anthropic', `https://${ANTHROPIC_API_TARGET}/v1/messages`, {
1000+
probes.push(probeProvider('anthropic', `https://${anthropicTarget}/v1/messages`, {
9661001
method: 'POST',
9671002
headers: {
968-
'x-api-key': ANTHROPIC_API_KEY,
1003+
'x-api-key': anthropicKey,
9691004
'anthropic-version': '2023-06-01',
9701005
'content-type': 'application/json',
9711006
},
@@ -975,14 +1010,14 @@ async function validateApiKeys() {
9751010
}
9761011

9771012
// --- Gemini ---
978-
if (GEMINI_API_KEY) {
979-
if (GEMINI_API_TARGET !== 'generativelanguage.googleapis.com') {
980-
keyValidationResults.gemini = { status: 'skipped', message: `Custom target ${GEMINI_API_TARGET}; validation skipped` };
1013+
if (geminiKey) {
1014+
if (geminiTarget !== 'generativelanguage.googleapis.com') {
1015+
keyValidationResults.gemini = { status: 'skipped', message: `Custom target ${geminiTarget}; validation skipped` };
9811016
logRequest('info', 'key_validation', { provider: 'gemini', ...keyValidationResults.gemini });
9821017
} else {
983-
probes.push(probeProvider('gemini', `https://${GEMINI_API_TARGET}/v1beta/models`, {
1018+
probes.push(probeProvider('gemini', `https://${geminiTarget}/v1beta/models`, {
9841019
method: 'GET',
985-
headers: { 'x-goog-api-key': GEMINI_API_KEY },
1020+
headers: { 'x-goog-api-key': geminiKey },
9861021
}, TIMEOUT_MS));
9871022
}
9881023
}
@@ -1451,4 +1486,4 @@ if (require.main === module) {
14511486
}
14521487

14531488
// Export for testing
1454-
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateApiKeys, probeProvider, httpProbe };
1489+
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateApiKeys, probeProvider, httpProbe, keyValidationResults, resetKeyValidationState };

containers/api-proxy/server.test.js

Lines changed: 237 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
*/
44

55
const http = require('http');
6+
const https = require('https');
67
const tls = require('tls');
78
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');
910

1011
describe('normalizeApiTarget', () => {
1112
it('should strip https:// prefix', () => {
@@ -1063,3 +1064,238 @@ describe('httpProbe', () => {
10631064
).rejects.toThrow(/timed out/i);
10641065
});
10651066
});
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

Comments
 (0)