Files
2026-07-13 13:39:12 +08:00

242 lines
9.3 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
checkIpRateLimit,
getClientIp,
sanitizeForensicHeader,
} from "../../../../src/app/api/v1/relay/chat/completions/relaySecurity.ts";
import { getDbInstance } from "../../../../src/lib/db/core.ts";
import { getRelayLogs } from "../../../../src/lib/db/relayProxies.ts";
// ─── T-12 (#3932 PR-4): bifrost sidecar proxy route ──────────────────────
//
// We test the *contract* of the route by setting env before import and
// calling the exported POST handler. The handler is module-scope configured
// (BIFROST_BASE_URL is read at import time), so env must be set BEFORE the
// dynamic import below.
const ORIGINAL_BIFROST_BASE_URL = process.env.BIFROST_BASE_URL;
const ORIGINAL_BIFROST_API_KEY = process.env.BIFROST_API_KEY;
const ORIGINAL_BIFROST_OMNI_KEY = process.env.OMNIROUTE_BIFROST_KEY;
const ORIGINAL_BIFROST_TIMEOUT = process.env.BIFROST_TIMEOUT_MS;
const ORIGINAL_BIFROST_STREAMING = process.env.BIFROST_STREAMING_ENABLED;
const ORIGINAL_FETCH = globalThis.fetch;
function seedRelayToken(rawToken: string) {
const id = `rl_test_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const now = Math.floor(Date.now() / 1000);
getDbInstance()
.prepare(
`
INSERT INTO relay_tokens (id, name, token_hash, token_prefix, description, combo_id,
allowed_models, max_tokens_per_request, max_requests_per_minute, max_requests_per_day,
max_cost_per_day, enabled, created_at, updated_at, expires_at, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
`
)
.run(
id,
"bifrost-sse-lifecycle",
createHash("sha256").update(rawToken).digest("hex"),
"rl_test",
"",
null,
JSON.stringify(["*"]),
128000,
60,
10000,
0,
now,
now,
null,
"{}"
);
return { id, rawToken };
}
function restoreEnv() {
if (ORIGINAL_BIFROST_BASE_URL === undefined) delete process.env.BIFROST_BASE_URL;
else process.env.BIFROST_BASE_URL = ORIGINAL_BIFROST_BASE_URL;
if (ORIGINAL_BIFROST_API_KEY === undefined) delete process.env.BIFROST_API_KEY;
else process.env.BIFROST_API_KEY = ORIGINAL_BIFROST_API_KEY;
if (ORIGINAL_BIFROST_OMNI_KEY === undefined) delete process.env.OMNIROUTE_BIFROST_KEY;
else process.env.OMNIROUTE_BIFROST_KEY = ORIGINAL_BIFROST_OMNI_KEY;
if (ORIGINAL_BIFROST_TIMEOUT === undefined) delete process.env.BIFROST_TIMEOUT_MS;
else process.env.BIFROST_TIMEOUT_MS = ORIGINAL_BIFROST_TIMEOUT;
if (ORIGINAL_BIFROST_STREAMING === undefined) delete process.env.BIFROST_STREAMING_ENABLED;
else process.env.BIFROST_STREAMING_ENABLED = ORIGINAL_BIFROST_STREAMING;
globalThis.fetch = ORIGINAL_FETCH;
}
// Case 1: BIFROST_BASE_URL unset. We test this first because the route's
// module-scope `BIFROST_BASE_URL` would be empty for the entire test file.
test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unset", async () => {
delete process.env.BIFROST_BASE_URL;
delete process.env.BIFROST_API_KEY;
delete process.env.OMNIROUTE_BIFROST_KEY;
delete process.env.BIFROST_TIMEOUT_MS;
delete process.env.BIFROST_STREAMING_ENABLED;
// Dynamic import after env is set so the module reads the empty value.
const { POST } = await import(
"../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"
);
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-4", messages: [] }),
});
const res = await POST(req);
assert.equal(res.status, 503);
assert.equal(res.headers.get("X-Bifrost-Fallback"), "/api/v1/relay/chat/completions");
const body = await res.json();
assert.match(String(body?.error?.message ?? ""), /Bifrost sidecar not configured/);
restoreEnv();
});
// Case 2: BIFROST_BASE_URL set but no auth token in request. The route should
// return 401 *before* trying to reach the gateway, so we don't need a mock fetch.
test("bifrost route: returns 401 when BIFROST_BASE_URL is set but no auth token is provided", async () => {
process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080";
delete process.env.BIFROST_API_KEY;
delete process.env.OMNIROUTE_BIFROST_KEY;
delete process.env.BIFROST_TIMEOUT_MS;
delete process.env.BIFROST_STREAMING_ENABLED;
// Use a fresh module instance by appending a cache-busting query string.
// (Node's ESM cache is keyed by resolved URL, so a unique query bypasses it.)
const { POST } = await import(
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
);
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }),
});
const res = await POST(req);
assert.equal(res.status, 401);
const body = await res.json();
assert.match(String(body?.error?.message ?? ""), /Missing relay token/);
restoreEnv();
});
// Case 3: hashToken() is used internally. Verify the SHA-256 output shape so
// downstream code that compares hashes doesn't silently break if the impl
// changes. (This is a contract test, not a black-box test of the function.)
test("bifrost route: relay token hashing matches the SHA-256 hex contract", () => {
const token = "test-token-abc-123";
const hash = createHash("sha256").update(token).digest("hex");
assert.equal(hash.length, 64); // SHA-256 hex = 64 chars
assert.match(hash, /^[0-9a-f]{64}$/);
});
// Case 4: Validate the CORS preflight handler exists and responds with 204/200
test("bifrost route: OPTIONS responds with CORS headers", async () => {
const { OPTIONS } = await import(
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
);
const res = await OPTIONS();
// handleCorsOptions() returns 204 No Content with the standard CORS
// methods/headers. Access-Control-Allow-Origin is intentionally NOT set on
// the route's response — src/middleware.ts (applyCorsHeaders) is the single
// source of truth for which origin to echo, based on the allowlist in
// src/server/cors/origins.ts. We assert the route ships its end of the
// contract: status + methods/headers. Origin overlay is exercised by the
// middleware tests.
assert.ok(res.status === 200 || res.status === 204, `expected 200/204, got ${res.status}`);
assert.ok(
res.headers.get("Access-Control-Allow-Methods"),
"missing Access-Control-Allow-Methods header"
);
assert.ok(
res.headers.get("Access-Control-Allow-Headers"),
"missing Access-Control-Allow-Headers header"
);
});
test("bifrost route: shared relay IP limiter blocks a repeated token from one IP", () => {
const tokenId = `bifrost-ip-limit-${Date.now()}-${Math.random()}`;
const ip = "203.0.113.77";
for (let i = 0; i < 30; i++) {
assert.equal(checkIpRateLimit(tokenId, ip).allowed, true);
}
const blocked = checkIpRateLimit(tokenId, ip);
assert.equal(blocked.allowed, false);
assert.ok(blocked.resetIn >= 0 && blocked.resetIn <= 60);
});
test("bifrost route: shared relay security helpers sanitize forwarded client metadata", () => {
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
headers: {
"x-forwarded-for": "198.51.100.8, 10.0.0.2",
},
});
assert.equal(getClientIp(req), "198.51.100.8");
assert.equal(sanitizeForensicHeader("ua\r\nforged"), "ua forged");
});
test("bifrost route: records relay usage after SSE stream completion", async () => {
process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080";
process.env.BIFROST_TIMEOUT_MS = "5000";
delete process.env.BIFROST_API_KEY;
delete process.env.OMNIROUTE_BIFROST_KEY;
delete process.env.BIFROST_STREAMING_ENABLED;
const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`);
globalThis.fetch = async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n"));
controller.close();
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
}
);
const { POST } = await import(
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
);
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
method: "POST",
headers: {
authorization: `Bearer ${relayToken.rawToken}`,
"content-type": "application/json",
"x-request-id": "bifrost-sse-lifecycle-test",
},
body: JSON.stringify({
model: "gpt-4",
stream: true,
messages: [{ role: "user", content: "hi" }],
}),
});
const res = await POST(req);
assert.equal(res.status, 200);
assert.equal(res.headers.get("X-Routed-By"), "bifrost");
assert.equal(getRelayLogs(relayToken.id, 10).length, 0);
assert.match(await res.text(), /delta/);
const logs = getRelayLogs(relayToken.id, 10);
assert.equal(logs.length, 1);
assert.equal(logs[0].status, "success");
assert.equal(logs[0].status_code, 200);
assert.equal(logs[0].request_id, "bifrost-sse-lifecycle-test");
restoreEnv();
});