diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index f07530ef..392de29d 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -107,7 +107,10 @@ const GLM_QUOTA_URLS: Record = { }; async function getGlmUsage(apiKey: string, providerSpecificData?: Record) { - const region = providerSpecificData?.apiRegion || "international"; + const region = + typeof providerSpecificData?.apiRegion === "string" + ? providerSpecificData.apiRegion + : "international"; const quotaUrl = GLM_QUOTA_URLS[region] || GLM_QUOTA_URLS.international; const res = await fetch(quotaUrl, { diff --git a/package.json b/package.json index 011d537d..405d898f 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,8 @@ "electron:build:win": "npm run build && cd electron && npm run build:win", "electron:build:mac": "npm run build && cd electron && npm run build:mac", "electron:build:linux": "npm run build && cd electron && npm run build:linux", - "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/*.test.mjs", - "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/*.test.mjs", + "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.mjs", + "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.mjs", "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/plan3-p0.test.mjs", "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/fixes-p1.test.mjs", "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/security-fase01.test.mjs", @@ -72,7 +72,7 @@ "test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs", "test:vitest": "vitest run --config vitest.mcp.config.ts", "test:ecosystem": "node scripts/run-ecosystem-tests.mjs", - "test:coverage": "c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs", + "test:coverage": "c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.mjs", "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs", "coverage:report": "c8 report --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", "coverage:summary": "node scripts/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md --threshold 60", diff --git a/tests/integration/api-keys.test.mjs b/tests/integration/api-keys.test.mjs index b60247d2..b58ed4fa 100644 --- a/tests/integration/api-keys.test.mjs +++ b/tests/integration/api-keys.test.mjs @@ -81,6 +81,32 @@ test("API keys routes require management auth when login protection is enabled", assert.equal(invalidTokenBody.error.message, "Invalid management token"); }); +test("API keys POST also requires management auth when login protection is enabled", async () => { + await enableManagementAuth(); + + const unauthenticated = await listRoute.POST( + makeRequest("http://localhost/api/keys", { + method: "POST", + body: { name: "Blocked Create" }, + }) + ); + const invalidToken = await listRoute.POST( + makeRequest("http://localhost/api/keys", { + method: "POST", + token: "sk-invalid", + body: { name: "Blocked Create" }, + }) + ); + + const unauthenticatedBody = await unauthenticated.json(); + const invalidTokenBody = await invalidToken.json(); + + assert.equal(unauthenticated.status, 401); + assert.equal(unauthenticatedBody.error.message, "Authentication required"); + assert.equal(invalidToken.status, 403); + assert.equal(invalidTokenBody.error.message, "Invalid management token"); +}); + test("POST /api/keys creates a key, preserves special characters, and persists noLog", async () => { await enableManagementAuth(); const authKey = await createManagementKey(); @@ -196,6 +222,31 @@ test("GET /api/keys falls back to default pagination for invalid query params", assert.equal(body.keys[0].name, "management"); }); +test("GET /api/keys uses default pagination when query params are absent and reports reveal support", async () => { + await enableManagementAuth(); + process.env.ALLOW_API_KEY_REVEAL = "true"; + const authKey = await createManagementKey(); + const createdA = await apiKeysDb.createApiKey("Alpha", MACHINE_ID); + const createdB = await apiKeysDb.createApiKey("Beta", MACHINE_ID); + + const response = await listRoute.GET( + makeRequest("http://localhost/api/keys", { + token: authKey.key, + }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.total, 3); + assert.equal(body.allowKeyReveal, true); + assert.equal(body.keys.length, 3); + assert.deepEqual( + body.keys.map((entry) => entry.id).sort(), + [authKey.id, createdA.id, createdB.id].sort() + ); + assert.ok(body.keys.every((entry) => entry.key !== undefined && entry.key !== "")); +}); + test("POST /api/keys triggers cloud sync when cloud mode is enabled", async () => { await enableManagementAuth(); await localDb.updateSettings({ cloudEnabled: true }); @@ -229,6 +280,38 @@ test("POST /api/keys triggers cloud sync when cloud mode is enabled", async () = } }); +test("POST /api/keys still succeeds when cloud sync fails after creation", async () => { + await enableManagementAuth(); + await localDb.updateSettings({ cloudEnabled: true }); + const authKey = await createManagementKey(); + const originalFetch = globalThis.fetch; + let syncAttempts = 0; + + globalThis.fetch = async () => { + syncAttempts += 1; + throw new Error("cloud sync offline"); + }; + + try { + const response = await listRoute.POST( + makeRequest("http://localhost/api/keys", { + method: "POST", + token: authKey.key, + body: { name: "Cloud Failure Tolerated" }, + }) + ); + const body = await response.json(); + const stored = await apiKeysDb.getApiKeyById(body.id); + + assert.equal(response.status, 201); + assert.equal(body.name, "Cloud Failure Tolerated"); + assert.equal(syncAttempts, 1); + assert.equal(stored?.name, "Cloud Failure Tolerated"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("GET /api/keys/[id] returns 404 for an unknown key and reveal is gated by the feature flag", async () => { await enableManagementAuth(); const authKey = await createManagementKey(); diff --git a/tests/integration/api-routes-critical.test.mjs b/tests/integration/api-routes-critical.test.mjs index f054369b..fb5846d8 100644 --- a/tests/integration/api-routes-critical.test.mjs +++ b/tests/integration/api-routes-critical.test.mjs @@ -261,6 +261,70 @@ test("critical routes: v1 management proxies validates create payloads and clamp assert.equal(missingDeleteBody.error.message, "Proxy not found"); }); +test("critical routes: v1 management proxies requires auth on mutating routes", async () => { + await enableManagementAuth(); + + const unauthenticatedPost = await proxiesRoute.POST( + makeRequest("http://localhost/api/v1/management/proxies", { + method: "POST", + body: { + name: "Denied Proxy", + type: "http", + host: "denied.local", + port: 8080, + }, + }) + ); + const invalidPost = await proxiesRoute.POST( + makeRequest("http://localhost/api/v1/management/proxies", { + method: "POST", + token: "sk-invalid", + body: { + name: "Denied Proxy", + type: "http", + host: "denied.local", + port: 8080, + }, + }) + ); + const unauthenticatedPatch = await proxiesRoute.PATCH( + makeRequest("http://localhost/api/v1/management/proxies", { + method: "PATCH", + body: { id: "proxy-1", host: "patched.local" }, + }) + ); + const invalidPatch = await proxiesRoute.PATCH( + makeRequest("http://localhost/api/v1/management/proxies", { + method: "PATCH", + token: "sk-invalid", + body: { id: "proxy-1", host: "patched.local" }, + }) + ); + const unauthenticatedDelete = await proxiesRoute.DELETE( + makeRequest("http://localhost/api/v1/management/proxies?id=proxy-1", { + method: "DELETE", + }) + ); + const invalidDelete = await proxiesRoute.DELETE( + makeRequest("http://localhost/api/v1/management/proxies?id=proxy-1", { + method: "DELETE", + token: "sk-invalid", + }) + ); + + for (const response of [unauthenticatedPost, unauthenticatedPatch, unauthenticatedDelete]) { + const body = await response.json(); + assert.equal(response.status, 401); + assert.equal(body.error.message, "Authentication required"); + } + + for (const response of [invalidPost, invalidPatch, invalidDelete]) { + const body = await response.json(); + assert.equal(response.status, 403); + assert.equal(body.error.message, "Invalid management token"); + } +}); + test("critical routes: settings proxy resolves config, validates payloads, and deletes scoped entries", async () => { const connection = await localDb.createProviderConnection({ provider: "openai", diff --git a/tests/unit/auth-clear-provider-routes.test.mjs b/tests/unit/auth-clear-provider-routes.test.mjs index 49befc2e..5dfc6b7d 100644 --- a/tests/unit/auth-clear-provider-routes.test.mjs +++ b/tests/unit/auth-clear-provider-routes.test.mjs @@ -12,6 +12,25 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const moderationRoute = await import("../../src/app/api/v1/moderations/route.ts"); const embeddingsRoute = await import("../../src/app/api/v1/embeddings/route.ts"); +async function withEnv(name, value, fn) { + const previous = process.env[name]; + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + + try { + return await fn(); + } finally { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } + } +} + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -76,6 +95,74 @@ test("moderations route clears stale provider error metadata on success", async } }); +test("moderations route covers CORS, API key auth, validation, and missing credential branches", async () => { + await resetStorage(); + + const optionsResponse = await moderationRoute.OPTIONS(); + assert.equal(optionsResponse.status, 200); + assert.equal(optionsResponse.headers.get("Access-Control-Allow-Methods"), "POST, OPTIONS"); + + await withEnv("REQUIRE_API_KEY", "true", async () => { + const missingKeyResponse = await moderationRoute.POST( + new Request("http://localhost/v1/moderations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: "hello" }), + }) + ); + + assert.equal(missingKeyResponse.status, 401); + assert.match(await missingKeyResponse.text(), /Missing API key/i); + + const invalidKeyResponse = await moderationRoute.POST( + new Request("http://localhost/v1/moderations", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer invalid-test-key", + }, + body: JSON.stringify({ input: "hello" }), + }) + ); + + assert.equal(invalidKeyResponse.status, 401); + assert.match(await invalidKeyResponse.text(), /Invalid API key/i); + }); + + const invalidJsonResponse = await moderationRoute.POST( + new Request("http://localhost/v1/moderations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + ); + assert.equal(invalidJsonResponse.status, 400); + assert.match(await invalidJsonResponse.text(), /Invalid JSON body/i); + + const invalidBodyResponse = await moderationRoute.POST( + new Request("http://localhost/v1/moderations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + ); + assert.equal(invalidBodyResponse.status, 400); + assert.match(await invalidBodyResponse.text(), /Invalid request/i); + + const noCredentialsResponse = await moderationRoute.POST( + new Request("http://localhost/v1/moderations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: "hello", + model: "anthropic/omni-moderation-latest", + }), + }) + ); + assert.equal(noCredentialsResponse.status, 400); + assert.match(await noCredentialsResponse.text(), /No credentials for provider: openai/i); +}); + test("embeddings route clears stale provider error metadata on success", async () => { await resetStorage(); const created = await seedOpenAIConnection("embeddings@example.com"); diff --git a/tests/unit/cc-compatible-provider.test.mjs b/tests/unit/cc-compatible-provider.test.mjs index f0d00a61..e7d3963c 100644 --- a/tests/unit/cc-compatible-provider.test.mjs +++ b/tests/unit/cc-compatible-provider.test.mjs @@ -725,6 +725,160 @@ test("provider-nodes validate route rejects CC mode when feature flag is disable assert.equal(response.status, 403); }); +test("provider-nodes validate route rejects invalid JSON and schema errors", async () => { + const invalidJsonResponse = await providerNodesValidateRoute.POST( + new Request("http://localhost/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + ); + + assert.equal(invalidJsonResponse.status, 400); + assert.deepEqual(await invalidJsonResponse.json(), { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }); + + const invalidBodyResponse = await providerNodesValidateRoute.POST( + new Request("http://localhost/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: "", + apiKey: "", + }), + }) + ); + + assert.equal(invalidBodyResponse.status, 400); + const invalidBodyPayload = await invalidBodyResponse.json(); + assert.equal(invalidBodyPayload.error.message, "Invalid request"); + assert.equal(invalidBodyPayload.error.details.length >= 2, true); +}); + +test("provider-nodes validate route validates anthropic compatible providers against the models endpoint", async () => { + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const response = await providerNodesValidateRoute.POST( + new Request("http://localhost/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: "https://proxy.example.com/v1/messages?beta=true", + apiKey: "sk-anthropic-test", + type: "anthropic-compatible", + modelsPath: "/catalog", + }), + }) + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + valid: true, + error: null, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://proxy.example.com/v1/catalog"); + assert.equal(calls[0].init.method, "GET"); + assert.equal(calls[0].init.headers["x-api-key"], "sk-anthropic-test"); + assert.equal(calls[0].init.headers["anthropic-version"], "2023-06-01"); + assert.equal(calls[0].init.headers.Authorization, "Bearer sk-anthropic-test"); +}); + +test("provider-nodes validate route supports enabled CC validation and OpenAI-style failures", async () => { + process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true"; + + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url, init }); + if (calls.length === 1) { + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + }; + + const ccResponse = await providerNodesValidateRoute.POST( + new Request("http://localhost/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: "https://proxy.example.com/v1/messages?beta=true", + apiKey: "sk-cc-test", + type: "anthropic-compatible", + compatMode: "cc", + chatPath: CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH, + }), + }) + ); + + assert.equal(ccResponse.status, 200); + assert.deepEqual(await ccResponse.json(), { + valid: true, + error: null, + warning: null, + method: "models_endpoint", + }); + assert.equal(String(calls[0].url).includes("/v1/messages"), false); + assert.equal(calls[0].init.method, "GET"); + + const openAiResponse = await providerNodesValidateRoute.POST( + new Request("http://localhost/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: "https://proxy.example.com/", + apiKey: "sk-openai-test", + }), + }) + ); + + assert.equal(openAiResponse.status, 200); + assert.deepEqual(await openAiResponse.json(), { + valid: false, + error: "Invalid API key", + }); + assert.equal(calls[1].url, "https://proxy.example.com/models"); + assert.equal(calls[1].init.headers.Authorization, "Bearer sk-openai-test"); +}); + +test("provider-nodes validate route reports unexpected upstream failures", async () => { + globalThis.fetch = async () => { + throw new Error("boom"); + }; + + const response = await providerNodesValidateRoute.POST( + new Request("http://localhost/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: "https://proxy.example.com", + apiKey: "sk-openai-test", + }), + }) + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { + error: "Validation failed", + }); +}); + test("provider-nodes list route exposes CC flag state from server env", async () => { process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true"; diff --git a/tests/unit/chat-combo-live-test.test.mjs b/tests/unit/chat-combo-live-test.test.mjs index 6e3c7536..107d601c 100644 --- a/tests/unit/chat-combo-live-test.test.mjs +++ b/tests/unit/chat-combo-live-test.test.mjs @@ -10,24 +10,20 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); -const { - generateSignature, - invalidateBySignature, - setCachedResponse, -} = await import("../../src/lib/semanticCache.ts"); -const { - clearModelUnavailability, - resetAllAvailability, - setModelUnavailable, -} = await import("../../src/domain/modelAvailability.ts"); -const { - getCircuitBreaker, - resetAllCircuitBreakers, - STATE, -} = await import("../../src/shared/utils/circuitBreaker.ts"); +const { generateSignature, invalidateBySignature, setCachedResponse } = + await import("../../src/lib/semanticCache.ts"); +const { clearModelUnavailability, resetAllAvailability, setModelUnavailable } = + await import("../../src/domain/modelAvailability.ts"); +const { getCircuitBreaker, resetAllCircuitBreakers, STATE } = + await import("../../src/shared/utils/circuitBreaker.ts"); const originalFetch = globalThis.fetch; +async function flushBackgroundWork() { + await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setImmediate(resolve)); +} + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -80,13 +76,15 @@ test.beforeEach(async () => { await resetStorage(); }); -test.afterEach(() => { +test.afterEach(async () => { + await flushBackgroundWork(); globalThis.fetch = originalFetch; resetAllAvailability(); resetAllCircuitBreakers(); }); -test.after(() => { +test.after(async () => { + await flushBackgroundWork(); globalThis.fetch = originalFetch; resetAllAvailability(); resetAllCircuitBreakers(); diff --git a/tests/unit/console-log-levels.test.mjs b/tests/unit/console-log-levels.test.mjs index ab90ab12..014f0481 100644 --- a/tests/unit/console-log-levels.test.mjs +++ b/tests/unit/console-log-levels.test.mjs @@ -52,3 +52,69 @@ test("console log API normalizes numeric pino levels correctly", async () => { ["info", "warn"] ); }); + +test("console log API filters by component, time window, and result limit", async () => { + fs.writeFileSync( + TEST_LOG_PATH, + [ + JSON.stringify({ + time: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + level: "warn", + component: "router", + msg: "too old", + }), + "not-json", + JSON.stringify({ + time: new Date().toISOString(), + level: "debug", + component: "router", + msg: "below level", + }), + JSON.stringify({ + time: new Date().toISOString(), + level: "error", + component: "router-core", + msg: "match one", + }), + JSON.stringify({ + timestamp: new Date().toISOString(), + level: "fatal", + module: "router-worker", + msg: "match two", + }), + ].join("\n") + "\n", + "utf8" + ); + + const response = await route.GET( + new Request("http://localhost/api/logs/console?level=warn&component=router&limit=1") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.length, 1); + assert.equal(body[0].level, "fatal"); + assert.equal(body[0].timestamp !== undefined, true); +}); + +test("console log API returns an empty list for a missing file and surfaces read errors", async () => { + fs.rmSync(TEST_LOG_PATH, { force: true }); + + const missingResponse = await route.GET(new Request("http://localhost/api/logs/console")); + assert.equal(missingResponse.status, 200); + assert.deepEqual(await missingResponse.json(), []); + + const brokenPath = path.join(TEST_LOG_DIR, "dir-log"); + fs.mkdirSync(brokenPath, { recursive: true }); + process.env.APP_LOG_FILE_PATH = brokenPath; + + try { + const brokenResponse = await route.GET(new Request("http://localhost/api/logs/console")); + assert.equal(brokenResponse.status, 500); + const payload = await brokenResponse.json(); + assert.equal(typeof payload.error, "string"); + assert.equal(payload.error.length > 0, true); + } finally { + process.env.APP_LOG_FILE_PATH = TEST_LOG_PATH; + } +}); diff --git a/tests/unit/cursor-protobuf.test.mjs b/tests/unit/cursor-protobuf.test.mjs index c24f82eb..fbf1b7bd 100644 --- a/tests/unit/cursor-protobuf.test.mjs +++ b/tests/unit/cursor-protobuf.test.mjs @@ -2,8 +2,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + buildChatRequest, decodeMessage, + decodeField, encodeField, + encodeMcpTool, extractTextFromResponse, generateCursorBody, parseConnectRPCFrame, @@ -59,6 +62,54 @@ test("parseConnectRPCFrame returns null for truncated frames", () => { assert.equal(parseConnectRPCFrame(frame.slice(0, frame.length - 1)), null); }); +test("parseConnectRPCFrame keeps the raw payload when a compressed frame is not valid gzip", () => { + const payload = textEncoder.encode("not-gzip"); + const frame = new Uint8Array(5 + payload.length); + frame[0] = 0x01; + frame[1] = (payload.length >> 24) & 0xff; + frame[2] = (payload.length >> 16) & 0xff; + frame[3] = (payload.length >> 8) & 0xff; + frame[4] = payload.length & 0xff; + frame.set(payload, 5); + + const parsed = parseConnectRPCFrame(frame); + + assert.equal(parsed.flags, 0x01); + assert.equal(parsed.consumed, frame.length); + assert.equal(textDecoder.decode(parsed.payload), "not-gzip"); +}); + +test("decodeField handles fixed-width wire types and past-end offsets", () => { + const buffer = Uint8Array.from([ + (1 << 3) | 1, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + (2 << 3) | 5, + 9, + 10, + 11, + 12, + ]); + + const [field1, wire1, value1, pos1] = decodeField(buffer, 0); + const [field2, wire2, value2, pos2] = decodeField(buffer, pos1); + const pastEnd = decodeField(buffer, pos2); + + assert.equal(field1, 1); + assert.equal(wire1, 1); + assert.deepEqual([...value1], [1, 2, 3, 4, 5, 6, 7, 8]); + assert.equal(field2, 2); + assert.equal(wire2, 5); + assert.deepEqual([...value2], [9, 10, 11, 12]); + assert.deepEqual(pastEnd, [null, null, null, pos2]); +}); + test("extractTextFromResponse reads MCP nested tool metadata and alternate last-tool flag", () => { const toolCallPayload = encodeField( TOP_LEVEL_TOOL_CALL, @@ -91,6 +142,40 @@ test("extractTextFromResponse reads MCP nested tool metadata and alternate last- assert.equal(extracted.toolCall.isLast, true); }); +test("extractTextFromResponse falls back to raw args when MCP metadata is absent", () => { + const toolCallPayload = encodeField( + TOP_LEVEL_TOOL_CALL, + LEN, + concatArrays( + encodeField(TOOL_ID, LEN, "call_fallback"), + encodeField(TOOL_NAME, LEN, "read_file"), + encodeField(TOOL_RAW_ARGS, LEN, '{"path":"/tmp/fallback"}') + ) + ); + + const extracted = extractTextFromResponse(toolCallPayload); + + assert.equal(extracted.toolCall.id, "call_fallback"); + assert.equal(extracted.toolCall.function.name, "read_file"); + assert.equal(extracted.toolCall.function.arguments, '{"path":"/tmp/fallback"}'); + assert.equal(extracted.toolCall.isLast, false); +}); + +test("extractTextFromResponse ignores incomplete tool call payloads", () => { + const toolCallPayload = encodeField( + TOP_LEVEL_TOOL_CALL, + LEN, + encodeField(TOOL_NAME, LEN, "read_file") + ); + + assert.deepEqual(extractTextFromResponse(toolCallPayload), { + text: null, + error: null, + toolCall: null, + thinking: null, + }); +}); + test("extractTextFromResponse returns text and thinking blocks from response payloads", () => { const responsePayload = encodeField( TOP_LEVEL_RESPONSE, @@ -160,3 +245,74 @@ test("generateCursorBody encodes tool metadata, message ids and high reasoning m assert.equal(request.get(30).length >= 2, true); assert.equal(request.get(49)[0].value, 2); }); + +test("buildChatRequest normalizes mixed assistant tool payloads without duplicating matching tool-result messages", () => { + const requestFrame = buildChatRequest( + [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "mcp__repo__read_file", + arguments: '{"path":"/tmp/a"}', + }, + }, + ], + tool_results: [ + { + tool_call_id: "call_1\nmc_model_1", + name: "mcp__repo__read_file", + index: 1, + raw_args: '{"path":"/tmp/a"}', + result: "file contents", + }, + ], + }, + { + role: "assistant", + content: "", + tool_results: [ + { + tool_call_id: "call_1\nmc_model_1", + name: "mcp__repo__read_file", + index: 1, + raw_args: '{"path":"/tmp/a"}', + result: "file contents", + }, + ], + }, + ], + "cursor-small", + [ + { + name: "read_file", + description: "Read a file", + }, + ], + "medium" + ); + const topLevel = decodeMessage(requestFrame); + const request = decodeMessage(topLevel.get(1)[0].value); + + assert.equal(request.get(1).length, 3); + assert.equal(request.get(27)[0].value, 1); + assert.equal(request.get(48)[0].value, 0); + assert.equal(request.get(49)[0].value, 1); + assert.equal(request.has(29), true); + assert.equal(request.has(34), true); +}); + +test("encodeMcpTool keeps the custom server field even when name and schema are missing", () => { + const encoded = encodeMcpTool({ description: "" }); + const decoded = decodeMessage(encoded); + + assert.equal(decoded.has(1), false); + assert.equal(decoded.has(2), false); + assert.equal(decoded.has(3), false); + assert.equal(textDecoder.decode(decoded.get(4)[0].value), "custom"); +}); diff --git a/tests/unit/model-sync-route.test.mjs b/tests/unit/model-sync-route.test.mjs index 511bc2a1..978d9157 100644 --- a/tests/unit/model-sync-route.test.mjs +++ b/tests/unit/model-sync-route.test.mjs @@ -8,23 +8,33 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-syn process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const modelsDb = await import("../../src/lib/db/models.ts"); const callLogs = await import("../../src/lib/usage/callLogs.ts"); const modelSyncRoute = await import("../../src/app/api/providers/[id]/sync-models/route.ts"); const scheduler = await import("../../src/shared/services/modelSyncScheduler.ts"); +const originalFetch = globalThis.fetch; async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + globalThis.fetch = originalFetch; core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } test.after(() => { + globalThis.fetch = originalFetch; core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); +async function enableAuth() { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + test("model sync route skips success log when fetched models do not change stored models", async () => { await resetStorage(); @@ -116,3 +126,178 @@ test("model sync route stores the real provider while keeping the account label" globalThis.fetch = originalFetch; } }); + +test("model sync route requires authentication for external requests when auth is enabled", async () => { + await resetStorage(); + await enableAuth(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "Protected Connection", + apiKey: "test-key", + }); + + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + }), + { params: { id: connection.id } } + ); + const body = await response.json(); + + assert.equal(response.status, 401); + assert.equal(body.error.message, "Authentication required"); + assert.equal(body.error.type, "invalid_api_key"); +}); + +test("model sync route returns 404 for unknown connections after internal auth passes", async () => { + await resetStorage(); + + const response = await modelSyncRoute.POST( + new Request("http://localhost/api/providers/missing/sync-models", { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: "missing" } } + ); + + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "Connection not found" }); +}); + +test("model sync route propagates upstream failures and records an error log entry", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "Error Branch", + apiKey: "test-key", + }); + + globalThis.fetch = async (url) => { + assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + return Response.json({ error: "Provider upstream unavailable" }, { status: 502 }); + }; + + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: connection.id } } + ); + const body = await response.json(); + const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + + assert.equal(response.status, 502); + assert.equal(body.error, "Provider upstream unavailable"); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, 502); + assert.equal(logs[0].provider, "openrouter"); + assert.equal(logs[0].path, `/api/providers/${connection.id}/models`); +}); + +test("model sync route writes synced available models for Gemini connections", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "gemini", + authType: "apikey", + name: "Gemini Sync", + apiKey: "gm-key", + }); + + globalThis.fetch = async (url) => { + assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + return Response.json({ + models: [ + { + id: "gemini-custom-preview", + name: "Gemini Custom Preview", + supportedEndpoints: ["chat", "embeddings"], + inputTokenLimit: 32768, + outputTokenLimit: 8192, + description: "Custom Gemini preview model", + supportsThinking: true, + }, + ], + }); + }; + + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: connection.id } } + ); + const body = await response.json(); + const synced = await modelsDb.getSyncedAvailableModels("gemini"); + const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + + assert.equal(response.status, 200); + assert.equal(body.provider, "gemini"); + assert.equal(body.syncedModels, 1); + assert.equal(body.logged, true); + assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 }); + assert.deepEqual(body.models, [ + { + id: "gemini-custom-preview", + name: "Gemini Custom Preview", + source: "auto-sync", + apiFormat: "chat-completions", + supportedEndpoints: ["chat", "embeddings"], + inputTokenLimit: 32768, + outputTokenLimit: 8192, + description: "Custom Gemini preview model", + supportsThinking: true, + }, + ]); + assert.deepEqual(synced, [ + { + id: "gemini-custom-preview", + name: "Gemini Custom Preview", + source: "api-sync", + supportedEndpoints: ["chat", "embeddings"], + inputTokenLimit: 32768, + outputTokenLimit: 8192, + description: "Custom Gemini preview model", + supportsThinking: true, + }, + ]); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, 200); +}); + +test("model sync route returns 500 and records a failure when the internal models fetch throws", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "Exploding Sync", + apiKey: "test-key", + }); + + globalThis.fetch = async () => { + throw new Error("network exploded"); + }; + + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: connection.id } } + ); + const body = await response.json(); + const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + + assert.equal(response.status, 500); + assert.equal(body.error, "network exploded"); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, 500); + assert.equal(logs[0].provider, "openrouter"); +}); diff --git a/tests/unit/provider-nodes-route.test.mjs b/tests/unit/provider-nodes-route.test.mjs new file mode 100644 index 00000000..06d35991 --- /dev/null +++ b/tests/unit/provider-nodes-route.test.mjs @@ -0,0 +1,174 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provider-nodes-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); +const { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, CLAUDE_CODE_COMPATIBLE_PREFIX } = + await import("../../src/shared/constants/providers.ts"); + +async function resetStorage() { + delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeRequest(body) { + return new Request("http://localhost/api/provider-nodes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("provider nodes route lists stored nodes and exposes the CC feature flag", async () => { + await providersDb.createProviderNode({ + id: "openai-compatible-chat-seeded", + name: "Seeded Node", + prefix: "seed", + type: "openai-compatible", + apiType: "chat", + baseUrl: "https://seed.example.com/v1", + }); + + process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true"; + const response = await providerNodesRoute.GET(); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.ccCompatibleProviderEnabled, true); + assert.equal(body.nodes.length, 1); + assert.equal(body.nodes[0].name, "Seeded Node"); +}); + +test("provider nodes route rejects malformed JSON and schema validation failures", async () => { + const malformed = await providerNodesRoute.POST( + new Request("http://localhost/api/provider-nodes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }) + ); + const invalid = await providerNodesRoute.POST( + makeRequest({ + name: "Missing API Type", + prefix: "missing-api-type", + }) + ); + + const malformedBody = await malformed.json(); + const invalidBody = await invalid.json(); + + assert.equal(malformed.status, 400); + assert.equal(malformedBody.error.message, "Invalid request"); + assert.deepEqual(malformedBody.error.details, [{ field: "body", message: "Invalid JSON body" }]); + + assert.equal(invalid.status, 400); + assert.equal(invalidBody.error.message, "Invalid request"); + assert.match( + invalidBody.error.details.find((detail) => detail.field === "apiType")?.message || "", + /Invalid OpenAI compatible API type/ + ); +}); + +test("provider nodes route creates OpenAI-compatible nodes with normalized defaults", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + name: " OpenAI Proxy ", + prefix: " openlike ", + apiType: "chat", + baseUrl: " https://proxy.example.com/v1 ", + chatPath: "", + modelsPath: "", + }) + ); + const body = await response.json(); + + assert.equal(response.status, 201); + assert.match(body.node.id, new RegExp(`^${OPENAI_COMPATIBLE_PREFIX}chat-`)); + assert.equal(body.node.type, "openai-compatible"); + assert.equal(body.node.name, "OpenAI Proxy"); + assert.equal(body.node.prefix, "openlike"); + assert.equal(body.node.baseUrl, "https://proxy.example.com/v1"); + assert.equal(body.node.chatPath, null); + assert.equal(body.node.modelsPath, null); +}); + +test("provider nodes route creates Anthropics-compatible nodes and sanitizes messages URLs", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + type: "anthropic-compatible", + name: " Anthropic Gateway ", + prefix: " anthropicx ", + baseUrl: " https://anthropic.example.com/v1/messages?beta=1 ", + modelsPath: "/models", + }) + ); + const body = await response.json(); + + assert.equal(response.status, 201); + assert.match(body.node.id, new RegExp(`^${ANTHROPIC_COMPATIBLE_PREFIX}`)); + assert.equal(body.node.type, "anthropic-compatible"); + assert.equal(body.node.name, "Anthropic Gateway"); + assert.equal(body.node.prefix, "anthropicx"); + assert.equal(body.node.baseUrl, "https://anthropic.example.com/v1"); + assert.equal(body.node.modelsPath, "/models"); +}); + +test("provider nodes route blocks CC-compatible nodes when the feature flag is disabled", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + type: "anthropic-compatible", + compatMode: "cc", + name: "Claude Code Disabled", + prefix: "cc-disabled", + baseUrl: "https://cc.example.com/v1/messages?beta=1", + }) + ); + const body = await response.json(); + + assert.equal(response.status, 403); + assert.equal(body.error, "CC Compatible provider is disabled"); +}); + +test("provider nodes route creates CC-compatible nodes with CC-specific URL normalization", async () => { + process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true"; + + const response = await providerNodesRoute.POST( + makeRequest({ + type: "anthropic-compatible", + compatMode: "cc", + name: " Claude Code Gateway ", + prefix: " cc-gateway ", + baseUrl: " https://cc.example.com/v1/messages?beta=1 ", + chatPath: "/chat", + modelsPath: "/models", + }) + ); + const body = await response.json(); + + assert.equal(response.status, 201); + assert.match(body.node.id, new RegExp(`^${CLAUDE_CODE_COMPATIBLE_PREFIX}`)); + assert.equal(body.node.type, "anthropic-compatible"); + assert.equal(body.node.name, "Claude Code Gateway"); + assert.equal(body.node.prefix, "cc-gateway"); + assert.equal(body.node.baseUrl, "https://cc.example.com"); + assert.equal(body.node.chatPath, "/chat"); + assert.equal(body.node.modelsPath, null); +}); diff --git a/tests/unit/proxy-fetch.test.mjs b/tests/unit/proxy-fetch.test.mjs index 9e1de34c..42232007 100644 --- a/tests/unit/proxy-fetch.test.mjs +++ b/tests/unit/proxy-fetch.test.mjs @@ -7,6 +7,8 @@ import proxyFetch, { runWithTlsTracking, isTlsFingerprintActive, } from "../../open-sse/utils/proxyFetch.ts"; +import { getDefaultDispatcher } from "../../open-sse/utils/proxyDispatcher.ts"; +import tlsClient from "../../open-sse/utils/tlsClient.ts"; async function withEnv(overrides, fn) { const previous = new Map(); @@ -56,6 +58,14 @@ async function withHttpServer(handler, fn) { } } +const originalTlsAvailable = tlsClient.available; +const originalTlsFetch = tlsClient.fetch.bind(tlsClient); + +test.afterEach(() => { + tlsClient.available = originalTlsAvailable; + tlsClient.fetch = originalTlsFetch; +}); + test("proxy fetch bypasses environment proxy when NO_PROXY matches the target host", async () => { await withHttpServer( (_req, res) => { @@ -81,6 +91,33 @@ test("proxy fetch bypasses environment proxy when NO_PROXY matches the target ho ); }); +test("proxy fetch honors suffix-and-port NO_PROXY patterns", async () => { + await withHttpServer( + (_req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("suffix-bypassed"); + }, + async (url) => { + const parsed = new URL(url); + + await withEnv( + { + HTTP_PROXY: "http://127.0.0.1:9", + HTTPS_PROXY: undefined, + ALL_PROXY: undefined, + NO_PROXY: `.0.0.1:${parsed.port}`, + }, + async () => { + const response = await proxyFetch(url); + + assert.equal(response.status, 200); + assert.equal(await response.text(), "suffix-bypassed"); + } + ); + } + ); +}); + test("proxy fetch fails closed when an invalid environment proxy is configured", async () => { await withHttpServer( (_req, res) => { @@ -110,6 +147,28 @@ test("runWithProxyContext requires a callback function", async () => { ); }); +test("proxy fetch respects an explicit dispatcher override", async () => { + await withHttpServer( + (req, res) => { + assert.equal(req.method, "POST"); + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("dispatcher"); + }, + async (url) => { + const response = await proxyFetch( + new Request(url, { + method: "POST", + body: "payload", + }), + { dispatcher: getDefaultDispatcher() } + ); + + assert.equal(response.status, 200); + assert.equal(await response.text(), "dispatcher"); + } + ); +}); + test("runWithTlsTracking reports direct executions without TLS fingerprint usage", async () => { await withEnv({ ENABLE_TLS_FINGERPRINT: undefined }, async () => { const tracked = await runWithTlsTracking(async () => "ok"); @@ -121,3 +180,84 @@ test("runWithTlsTracking reports direct executions without TLS fingerprint usage assert.equal(isTlsFingerprintActive(), false); }); }); + +test("proxy fetch uses TLS fingerprint transport when enabled and available", async () => { + await withEnv( + { + ENABLE_TLS_FINGERPRINT: "true", + HTTP_PROXY: undefined, + HTTPS_PROXY: undefined, + ALL_PROXY: undefined, + NO_PROXY: undefined, + }, + async () => { + tlsClient.available = true; + tlsClient.fetch = async (url, options = {}) => { + assert.equal(url, "https://omniroute.example.test/hello"); + assert.equal(options.method, "POST"); + return Response.json({ via: "tls-client" }); + }; + + const tracked = await runWithTlsTracking(() => + proxyFetch("https://omniroute.example.test/hello", { + method: "POST", + headers: { "x-test": "1" }, + }) + ); + + assert.equal(isTlsFingerprintActive(), true); + assert.equal(tracked.tlsFingerprintUsed, true); + assert.deepEqual(await tracked.result.json(), { via: "tls-client" }); + } + ); +}); + +test("proxy fetch falls back to native fetch when TLS fingerprint transport throws", async () => { + await withHttpServer( + (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ via: "native-fetch" })); + }, + async (url) => { + await withEnv( + { + ENABLE_TLS_FINGERPRINT: "true", + HTTP_PROXY: undefined, + HTTPS_PROXY: undefined, + ALL_PROXY: undefined, + NO_PROXY: undefined, + }, + async () => { + tlsClient.available = true; + tlsClient.fetch = async () => { + throw new Error("tls fingerprint unavailable"); + }; + + const tracked = await runWithTlsTracking(() => proxyFetch(url)); + + assert.equal(tracked.tlsFingerprintUsed, false); + assert.deepEqual(await tracked.result.json(), { via: "native-fetch" }); + } + ); + } + ); +}); + +test("runWithProxyContext accepts reachable HTTP proxy endpoints and returns callback result", async () => { + await withHttpServer( + (_req, res) => res.end("proxy-ok"), + async (url) => { + const parsed = new URL(url); + const result = await runWithProxyContext( + { + type: "http", + host: parsed.hostname, + port: parsed.port, + }, + async () => "ok" + ); + + assert.equal(result, "ok"); + } + ); +}); diff --git a/tests/unit/proxy-management-v1-route.test.mjs b/tests/unit/proxy-management-v1-route.test.mjs index fc52455c..90b309ad 100644 --- a/tests/unit/proxy-management-v1-route.test.mjs +++ b/tests/unit/proxy-management-v1-route.test.mjs @@ -17,6 +17,25 @@ const proxyBulkAssignV1Route = await import("../../src/app/api/v1/management/proxies/bulk-assign/route.ts"); const proxyLogger = await import("../../src/lib/proxyLogger.ts"); +async function withEnv(name, value, fn) { + const previous = process.env[name]; + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + + try { + return await fn(); + } finally { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } + } +} + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -200,3 +219,152 @@ test("v1 bulk assignment updates multiple scope IDs in one request", async () => const checkPayload = await checkRes.json(); assert.equal(checkPayload.items.length >= 2, true); }); + +test("v1 proxy management companion routes require auth when login protection is enabled", async () => { + await resetStorage(); + + await withEnv("INITIAL_PASSWORD", "secret", async () => { + const assignmentsGetRes = await proxyAssignmentsV1Route.GET( + new Request("http://localhost/api/v1/management/proxies/assignments") + ); + assert.equal(assignmentsGetRes.status, 401); + + const assignmentsPutRes = await proxyAssignmentsV1Route.PUT( + new Request("http://localhost/api/v1/management/proxies/assignments", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer invalid-management-token", + }, + body: JSON.stringify({ + scope: "global", + proxyId: null, + }), + }) + ); + assert.equal(assignmentsPutRes.status, 403); + + const healthRes = await proxyHealthV1Route.GET( + new Request("http://localhost/api/v1/management/proxies/health", { + headers: { + Authorization: "Bearer invalid-management-token", + }, + }) + ); + assert.equal(healthRes.status, 403); + + const bulkRes = await proxyBulkAssignV1Route.PUT( + new Request("http://localhost/api/v1/management/proxies/bulk-assign", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "global", + proxyId: null, + }), + }) + ); + assert.equal(bulkRes.status, 401); + }); +}); + +test("v1 assignments route resolves connection proxies and bulk assignment covers validation branches", async () => { + await resetStorage(); + + const providerConn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "v1-resolve", + apiKey: "sk-test-v1-resolve", + }); + + const proxyRes = await proxyV1Route.POST( + new Request("http://localhost/api/v1/management/proxies", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Resolve Proxy", + type: "http", + host: "resolve.local", + port: 9000, + }), + }) + ); + const proxy = await proxyRes.json(); + + const assignRes = await proxyAssignmentsV1Route.PUT( + new Request("http://localhost/api/v1/management/proxies/assignments", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "account", + scopeId: providerConn.id, + proxyId: proxy.id, + }), + }) + ); + assert.equal(assignRes.status, 200); + + const resolveRes = await proxyAssignmentsV1Route.GET( + new Request( + `http://localhost/api/v1/management/proxies/assignments?resolve_connection_id=${providerConn.id}` + ) + ); + assert.equal(resolveRes.status, 200); + const resolvePayload = await resolveRes.json(); + assert.equal(resolvePayload.level, "account"); + assert.equal(resolvePayload.proxy.host, "resolve.local"); + + const invalidJsonRes = await proxyBulkAssignV1Route.PUT( + new Request("http://localhost/api/v1/management/proxies/bulk-assign", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + ); + assert.equal(invalidJsonRes.status, 400); + + const invalidPayloadRes = await proxyBulkAssignV1Route.PUT( + new Request("http://localhost/api/v1/management/proxies/bulk-assign", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "provider", + scopeIds: [], + }), + }) + ); + assert.equal(invalidPayloadRes.status, 400); + + const normalizedRes = await proxyBulkAssignV1Route.PUT( + new Request("http://localhost/api/v1/management/proxies/bulk-assign", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "key", + scopeIds: [providerConn.id, providerConn.id], + proxyId: proxy.id, + }), + }) + ); + assert.equal(normalizedRes.status, 200); + const normalizedPayload = await normalizedRes.json(); + assert.equal(normalizedPayload.scope, "account"); + assert.equal(normalizedPayload.requested, 2); + assert.equal(normalizedPayload.updated, 1); + + const globalRes = await proxyBulkAssignV1Route.PUT( + new Request("http://localhost/api/v1/management/proxies/bulk-assign", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "global", + proxyId: proxy.id, + }), + }) + ); + assert.equal(globalRes.status, 200); + const globalPayload = await globalRes.json(); + assert.equal(globalPayload.scope, "global"); + assert.equal(globalPayload.requested, 1); + assert.equal(globalPayload.updated, 1); +}); diff --git a/tests/unit/rate-limit-manager.test.mjs b/tests/unit/rate-limit-manager.test.mjs index 5fbaee44..cb1aa96b 100644 --- a/tests/unit/rate-limit-manager.test.mjs +++ b/tests/unit/rate-limit-manager.test.mjs @@ -1,6 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-rate-limit-manager-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); const rateLimitManager = await import("../../open-sse/services/rateLimitManager.ts"); const accountFallback = await import("../../open-sse/services/accountFallback.ts"); @@ -8,9 +15,31 @@ function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function flushBackgroundWork() { + await wait(50); + await new Promise((resolve) => setImmediate(resolve)); +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + test.afterEach(async () => { await rateLimitManager.__resetRateLimitManagerForTests(); - await wait(5); + await flushBackgroundWork(); +}); + +test.after(async () => { + await rateLimitManager.__resetRateLimitManagerForTests(); + await flushBackgroundWork(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); test("rate limit manager bypasses disabled connections and exposes inactive status", async () => { diff --git a/tests/unit/stream-handler.test.mjs b/tests/unit/stream-handler.test.mjs new file mode 100644 index 00000000..ba1f19e1 --- /dev/null +++ b/tests/unit/stream-handler.test.mjs @@ -0,0 +1,175 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + createDisconnectAwareStream, + createStreamController, + pipeWithDisconnect, +} from "../../open-sse/utils/streamHandler.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +async function readStreamText(stream) { + const reader = stream.getReader(); + const chunks = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + return decoder.decode( + chunks.length === 1 ? chunks[0] : Uint8Array.from(chunks.flatMap((chunk) => Array.from(chunk))) + ); +} + +test("createDisconnectAwareStream converts upstream errors into SSE error chunks", async () => { + const upstreamError = Object.assign(new Error("provider exploded"), { statusCode: 429 }); + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.error(upstreamError); + }, + }), + writable: { + getWriter() { + return { + abort() {}, + }; + }, + }, + }; + + const stream = createDisconnectAwareStream(transformStream, createStreamController()); + const text = await readStreamText(stream); + + assert.match(text, /"finish_reason":"error"/); + assert.match(text, /"message":"provider exploded"/); + assert.match(text, /"code":429/); + assert.match(text, /\[DONE\]/); +}); + +test("createDisconnectAwareStream cancel propagates disconnect reason and aborts the writer", async () => { + let aborted = false; + let disconnectEvent = null; + + const transformStream = { + readable: new ReadableStream({ + pull() {}, + cancel() {}, + }), + writable: { + getWriter() { + return { + abort() { + aborted = true; + }, + }; + }, + }, + }; + + const controller = createStreamController({ + onDisconnect(event) { + disconnectEvent = event; + }, + }); + const stream = createDisconnectAwareStream(transformStream, controller); + + await stream.cancel("client-gone"); + + assert.equal(aborted, true); + assert.equal(controller.isConnected(), false); + assert.equal(disconnectEvent.reason, "client-gone"); + assert.ok(disconnectEvent.duration >= 0); +}); + +test("createDisconnectAwareStream uses the default cancel reason when none is provided", async () => { + let disconnectEvent = null; + + const transformStream = { + readable: new ReadableStream({ + cancel() {}, + }), + writable: { + getWriter() { + return { + abort() {}, + }; + }, + }, + }; + + const controller = createStreamController({ + onDisconnect(event) { + disconnectEvent = event; + }, + }); + const stream = createDisconnectAwareStream(transformStream, controller); + + await stream.cancel(); + + assert.equal(disconnectEvent.reason, "cancelled"); +}); + +test("createDisconnectAwareStream closes immediately when the controller is already disconnected", async () => { + const controller = createStreamController(); + controller.handleDisconnect("preclosed"); + + const stream = createDisconnectAwareStream( + { + readable: new ReadableStream({ + pull(inner) { + inner.enqueue(encoder.encode("ignored")); + }, + }), + writable: { + getWriter() { + return { + abort() {}, + }; + }, + }, + }, + controller + ); + const reader = stream.getReader(); + const first = await reader.read(); + + assert.equal(first.done, true); +}); + +test("createStreamController aborts after delayed disconnect and tolerates abort/unknown errors", async () => { + const controller = createStreamController(); + const errorOnlyController = createStreamController(); + + controller.handleDisconnect(); + controller.handleDisconnect("ignored-repeat"); + errorOnlyController.handleError(new DOMException("aborted", "AbortError")); + errorOnlyController.handleError({ statusCode: 418 }); + + await new Promise((resolve) => setTimeout(resolve, 550)); + + assert.equal(controller.signal.aborted, true); + assert.equal(controller.isConnected(), false); + assert.equal(errorOnlyController.signal.aborted, false); +}); + +test("pipeWithDisconnect pipes transformed bytes and marks the controller complete", async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("hello")); + controller.close(); + }, + }); + const providerResponse = new Response(source); + const controller = createStreamController(); + const stream = pipeWithDisconnect(providerResponse, new TransformStream(), controller); + + const text = await readStreamText(stream); + + assert.equal(text, "hello"); + assert.equal(controller.isConnected(), false); +}); diff --git a/tests/unit/token-refresh-route-service.test.mjs b/tests/unit/token-refresh-route-service.test.mjs index 1ff8f5ea..ece5333d 100644 --- a/tests/unit/token-refresh-route-service.test.mjs +++ b/tests/unit/token-refresh-route-service.test.mjs @@ -318,3 +318,59 @@ test("refreshGitHubAndCopilotTokens composes GitHub and Copilot refresh response } ); }); + +test("checkAndRefreshToken leaves credentials untouched when nothing is close to expiry", async () => { + const now = 1_700_000_200_000; + const credentials = { + connectionId: "conn-stable", + accessToken: "stable-access", + refreshToken: "stable-refresh", + expiresAt: new Date(now + tokenRefresh.TOKEN_EXPIRY_BUFFER_MS + 60_000).toISOString(), + providerSpecificData: { + copilotToken: "copilot-stable", + copilotTokenExpiresAt: Math.floor( + (now + tokenRefresh.TOKEN_EXPIRY_BUFFER_MS + 60_000) / 1000 + ), + }, + }; + + await withMockedNow(now, async () => { + await withMockedFetch( + async () => { + throw new Error("fetch should not be called"); + }, + async () => { + const refreshed = await tokenRefresh.checkAndRefreshToken("github", credentials); + assert.deepEqual(refreshed, credentials); + } + ); + }); +}); + +test("refreshGitHubAndCopilotTokens returns refreshed GitHub credentials when Copilot refresh fails", async () => { + await withMockedFetch( + async (url) => { + if (String(url) === OAUTH_ENDPOINTS.github.token) { + return jsonResponse({ + access_token: "github-only-access", + refresh_token: "github-only-refresh", + expires_in: 1200, + }); + } + + assert.equal(String(url), "https://api.github.com/copilot_internal/v2/token"); + return new Response("copilot unavailable", { status: 503 }); + }, + async () => { + const refreshed = await tokenRefresh.refreshGitHubAndCopilotTokens({ + refreshToken: "github-refresh-only", + }); + + assert.deepEqual(refreshed, { + accessToken: "github-only-access", + refreshToken: "github-only-refresh", + expiresIn: 1200, + }); + } + ); +});