chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* Regression: issue #6424 — unknown paths under /api/* (outside /api/v1/*)
|
||||
* returned the Next.js dashboard HTML 404 shell instead of JSON. This made
|
||||
* management-auth failures indistinguishable from missing routes, and forced
|
||||
* CLI/SDK callers to parse ~463 KB of HTML.
|
||||
*
|
||||
* Complements the /v1/* catch-all from #6405; asserts the app-router catch-all
|
||||
* under `src/app/api/[...omnirouteApiCatchAll]/route.ts` returns JSON for
|
||||
* every HTTP method.
|
||||
*/
|
||||
|
||||
const catchAll = await import(
|
||||
"../../../src/app/api/[...omnirouteApiCatchAll]/route.ts"
|
||||
);
|
||||
|
||||
function makeReq(pathname: string, method = "GET"): Request {
|
||||
return new Request(`http://localhost:20128${pathname}`, { method });
|
||||
}
|
||||
|
||||
test("/api catchall returns application/json 404 with not_found type on GET", async () => {
|
||||
const res = await catchAll.GET(makeReq("/api/context/rtk/does-not-exist"));
|
||||
assert.equal(res.status, 404);
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
assert.ok(ct.includes("application/json"), `expected JSON content-type, got: ${ct}`);
|
||||
const body = (await res.json()) as {
|
||||
error: { type: string; message: string; code?: string; path?: string };
|
||||
};
|
||||
assert.equal(body.error.type, "not_found");
|
||||
assert.equal(body.error.path, "/api/context/rtk/does-not-exist");
|
||||
assert.match(body.error.message, /Unknown API route/);
|
||||
});
|
||||
|
||||
test("/api catchall returns JSON 404 on POST / PUT / PATCH / DELETE / HEAD", async () => {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE", "HEAD"] as const) {
|
||||
const res = await (catchAll as Record<string, (r: Request) => Promise<Response>>)[
|
||||
method
|
||||
](makeReq(`/api/settings/nope/${method.toLowerCase()}`, method));
|
||||
assert.equal(res.status, 404, `${method} status`);
|
||||
assert.ok(
|
||||
(res.headers.get("content-type") || "").includes("application/json"),
|
||||
`${method} content-type`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("/api catchall OPTIONS preflight returns CORS headers", async () => {
|
||||
const res = await catchAll.OPTIONS();
|
||||
assert.ok(
|
||||
res.headers.get("access-control-allow-methods"),
|
||||
"OPTIONS must expose CORS methods header",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* T-013 — GET /api/settings/authz-inventory.
|
||||
*
|
||||
* Covers spec AC-1, AC-2, AC-12 (and AC-13 PATCH coverage continues to live
|
||||
* in `tests/unit/settings/authz-bypass.test.ts`; this file only asserts the
|
||||
* inventory endpoint itself).
|
||||
*
|
||||
* - AC-1 response shape: 5 tiers, each with prefixes; bypassEnabled / bypassPrefixes / spawnCapablePrefixes present.
|
||||
* - AC-2 bypass-state flags match getSettings() and update after PATCH.
|
||||
* - AC-12 anonymous request → 401/403 (no inventory leak).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupSettingsFixture } from "../_mocks/settings.ts";
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
const fixture = setupSettingsFixture("authz-inventory");
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
const ORIGINAL_CORS_ALLOW_ALL = process.env.CORS_ALLOW_ALL;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
const inventoryRoute = await import("../../../src/app/api/settings/authz-inventory/route.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await fixture.resetStorage();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fixture.cleanup();
|
||||
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
if (ORIGINAL_CORS_ALLOW_ALL === undefined) delete process.env.CORS_ALLOW_ALL;
|
||||
else process.env.CORS_ALLOW_ALL = ORIGINAL_CORS_ALLOW_ALL;
|
||||
});
|
||||
|
||||
// ─── AC-1 — shape ─────────────────────────────────────────────────────────
|
||||
|
||||
test("AC-1: GET returns 5 tiers with prefixes + bypass state envelope", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac1";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const request = await makeManagementSessionRequest(
|
||||
"http://localhost/api/settings/authz-inventory",
|
||||
{ method: "GET" }
|
||||
);
|
||||
const response = await inventoryRoute.GET(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
tiers: Array<{ name: string; prefixes: string[]; description: string; bypassable: boolean }>;
|
||||
bypassEnabled: boolean;
|
||||
bypassPrefixes: string[];
|
||||
spawnCapablePrefixes: string[];
|
||||
};
|
||||
|
||||
assert.equal(body.tiers.length, 5);
|
||||
const names = body.tiers.map((t) => t.name).sort();
|
||||
assert.deepEqual(names, ["ALWAYS_PROTECTED", "CLIENT_API", "LOCAL_ONLY", "MANAGEMENT", "PUBLIC"]);
|
||||
|
||||
const localOnly = body.tiers.find((t) => t.name === "LOCAL_ONLY");
|
||||
assert.ok(localOnly);
|
||||
assert.ok(localOnly!.prefixes.includes("/api/mcp/"));
|
||||
assert.ok(localOnly!.prefixes.includes("/api/cli-tools/runtime/"));
|
||||
assert.equal(localOnly!.bypassable, true);
|
||||
|
||||
const alwaysProtected = body.tiers.find((t) => t.name === "ALWAYS_PROTECTED");
|
||||
assert.ok(alwaysProtected);
|
||||
assert.ok(alwaysProtected!.prefixes.includes("/api/shutdown"));
|
||||
assert.equal(alwaysProtected!.bypassable, false);
|
||||
|
||||
const clientApi = body.tiers.find((t) => t.name === "CLIENT_API");
|
||||
assert.ok(clientApi);
|
||||
assert.ok(clientApi!.prefixes.includes("/v1/"));
|
||||
assert.ok(clientApi!.prefixes.includes("/api/v1/"));
|
||||
assert.ok(clientApi!.prefixes.includes("/v1beta/"));
|
||||
assert.ok(clientApi!.prefixes.includes("/api/v1beta/"));
|
||||
|
||||
// Every tier carries a non-empty description.
|
||||
for (const tier of body.tiers) {
|
||||
assert.ok(tier.description.length > 0, `tier ${tier.name} missing description`);
|
||||
}
|
||||
|
||||
assert.ok(body.spawnCapablePrefixes.includes("/api/cli-tools/runtime/"));
|
||||
});
|
||||
|
||||
// ─── AC-2 — flags match getSettings() pre- and post-mutation ──────────────
|
||||
|
||||
test("AC-2: bypassEnabled + bypassPrefixes reflect getSettings() (defaults)", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac2a";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as {
|
||||
bypassEnabled: boolean;
|
||||
bypassPrefixes: string[];
|
||||
};
|
||||
// Default snapshot: kill-switch ON, single prefix /api/mcp/.
|
||||
assert.equal(body.bypassEnabled, true);
|
||||
assert.deepEqual(body.bypassPrefixes, ["/api/mcp/"]);
|
||||
});
|
||||
|
||||
test("AC-2: bypassEnabled flips after settings mutation", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac2b";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
// Mutate directly through the settings DB (bypasses the password gate —
|
||||
// we are not testing the gate here, only the inventory's reflection of
|
||||
// the persisted state).
|
||||
await settingsDb.updateSettings({ localOnlyManageScopeBypassEnabled: false });
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { bypassEnabled: boolean };
|
||||
assert.equal(body.bypassEnabled, false);
|
||||
});
|
||||
|
||||
test("AC-2: bypassPrefixes additions land in the inventory", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac2c";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
await settingsDb.updateSettings({
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/mcp/v2/"],
|
||||
});
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { bypassPrefixes: string[] };
|
||||
assert.deepEqual(body.bypassPrefixes, ["/api/mcp/", "/api/mcp/v2/"]);
|
||||
});
|
||||
|
||||
// ─── #5602 — CORS status surfaced for the dashboard wildcard warning ──────
|
||||
|
||||
test("#5602: cors.allowAll is false by default (no CORS_ALLOW_ALL)", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-cors-a";
|
||||
delete process.env.CORS_ALLOW_ALL;
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as {
|
||||
cors: { allowAll: boolean; allowedOrigins: string[] };
|
||||
};
|
||||
assert.ok(body.cors, "response should carry a cors envelope");
|
||||
assert.equal(body.cors.allowAll, false);
|
||||
assert.deepEqual(body.cors.allowedOrigins, []);
|
||||
});
|
||||
|
||||
test("#5602: cors.allowAll reflects CORS_ALLOW_ALL=true", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-cors-b";
|
||||
process.env.CORS_ALLOW_ALL = "true";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { cors: { allowAll: boolean } };
|
||||
assert.equal(body.cors.allowAll, true);
|
||||
});
|
||||
|
||||
// ─── AC-12 — anonymous request rejected (no inventory leak) ───────────────
|
||||
|
||||
test("AC-12: anonymous request (no cookie, no Bearer) → 401", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac12";
|
||||
// Bootstrap a password so isAuthRequired() returns true even on loopback.
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
const anonRequest = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
});
|
||||
const response = await inventoryRoute.GET(anonRequest);
|
||||
assert.ok(
|
||||
response.status === 401 || response.status === 403,
|
||||
`expected 401/403, got ${response.status}`
|
||||
);
|
||||
const body = (await response.json()) as { error?: { message?: string } };
|
||||
// Should NOT leak the inventory shape.
|
||||
assert.ok(!("tiers" in body));
|
||||
});
|
||||
|
||||
test("AC-12: anonymous request with bogus Bearer → 403", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac12b";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
const bogus = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
headers: new Headers({ authorization: "Bearer not-a-real-key" }),
|
||||
});
|
||||
const response = await inventoryRoute.GET(bogus);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
// ─── OQ-5 — any valid API key (no manage scope required) → 200 ────────────
|
||||
|
||||
test("OQ-5: any valid API key (read-only scope) → 200 inventory", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-oq5";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
// Key with NO manage scope — would be rejected by /api/settings PATCH,
|
||||
// but the inventory read endpoint is intentionally one rung lower (OQ-5).
|
||||
const created = await apiKeysDb.createApiKey("oq5-read", "machine-oq5", []);
|
||||
const request = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
headers: new Headers({ authorization: `Bearer ${created.key}` }),
|
||||
});
|
||||
const response = await inventoryRoute.GET(request);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { tiers: unknown[] };
|
||||
assert.equal(body.tiers.length, 5);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Hermetic auth context (6A re-wire fix): without a configured password,
|
||||
// isAuthRequired() is false on a fresh DB (CI) and the route answers 200 —
|
||||
// these 401/403 assertions only held locally because the dev DATA_DIR had a
|
||||
// real password. Create an isolated DATA_DIR with login protection enabled.
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-detect-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../../src/lib/db/settings.ts");
|
||||
const { GET } = await import("../../../../src/app/api/cli-tools/detect/route.ts");
|
||||
|
||||
describe("GET /api/cli-tools/detect", () => {
|
||||
before(async () => {
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "test-password-hash",
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
it("returns 401 without authorization", async () => {
|
||||
// @ts-ignore - we can call the handler directly
|
||||
const req = new NextRequest("http://localhost:3000/api/cli-tools/detect");
|
||||
const res = await GET(req);
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it("returns 403 with wrong authorization (invalid API key)", async () => {
|
||||
// @ts-ignore
|
||||
const req = new NextRequest("http://localhost:3000/api/cli-tools/detect", {
|
||||
headers: { authorization: "Bearer wrong-key" },
|
||||
});
|
||||
const res = await GET(req);
|
||||
assert.strictEqual(res.status, 403);
|
||||
});
|
||||
|
||||
it("returns 200 with valid auth and returns tools array", async () => {
|
||||
// Mock the auth - check that requireCliToolsAuth is called
|
||||
// Since requireCliToolsAuth uses DB, we need a more involved mock.
|
||||
// For quick coverage, we'll test that the handler structure is right.
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("returns single tool when tool query param provided", async () => {
|
||||
// Verify route reads searchParams correctly
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* TDD regression guard for #5083 — Bug 3:
|
||||
* COMBO_002 validation errors only surface the generic
|
||||
* "One or more combo fields are invalid" message; the field-level reason
|
||||
* is buried inside error.details.issues.details[*].
|
||||
*
|
||||
* Fix: extract the first issue from error.details and add
|
||||
* error.details.firstField / error.details.firstMessage to the response body.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildComboErrorBody } from "@/lib/api/comboErrorResponse";
|
||||
|
||||
/**
|
||||
* Simulate what the PUT /api/combos/[id] route passes as `details`
|
||||
* for a COMBO_002 response after the fix.
|
||||
* The fix extracts the first Zod issue from validation.error.details
|
||||
* and adds firstField / firstMessage to the payload.
|
||||
*/
|
||||
describe("COMBO_002 response — firstField / firstMessage surfacing (#5083 Bug 3)", () => {
|
||||
it("details payload exposes firstField when provided", () => {
|
||||
// Simulate the fixed route calling comboErrorResponse with firstField/firstMessage
|
||||
const details = {
|
||||
issues: { message: "Invalid request", details: [{ field: "name", message: "Required" }] },
|
||||
firstField: "name",
|
||||
firstMessage: "Required",
|
||||
};
|
||||
const body = buildComboErrorBody("COMBO_002", details);
|
||||
assert.equal(body.error.code, "COMBO_002");
|
||||
assert.equal(body.error.details.firstField, "name");
|
||||
assert.equal(body.error.details.firstMessage, "Required");
|
||||
});
|
||||
|
||||
it("details payload exposes firstField for nested path (e.g. models.0.id)", () => {
|
||||
const details = {
|
||||
issues: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "models.0.id", message: "String must contain at least 1 character(s)" }],
|
||||
},
|
||||
firstField: "models.0.id",
|
||||
firstMessage: "String must contain at least 1 character(s)",
|
||||
};
|
||||
const body = buildComboErrorBody("COMBO_002", details);
|
||||
assert.equal(body.error.details.firstField, "models.0.id");
|
||||
assert.equal(body.error.details.firstMessage, "String must contain at least 1 character(s)");
|
||||
});
|
||||
|
||||
it("details payload has firstField=null when no issues are present", () => {
|
||||
// Edge case: empty issues list
|
||||
const details = {
|
||||
issues: { message: "Invalid request", details: [] },
|
||||
firstField: null,
|
||||
firstMessage: null,
|
||||
};
|
||||
const body = buildComboErrorBody("COMBO_002", details);
|
||||
assert.equal(body.error.details.firstField, null);
|
||||
assert.equal(body.error.details.firstMessage, null);
|
||||
});
|
||||
|
||||
it("the generic message key is still present (backward compat)", () => {
|
||||
const details = {
|
||||
issues: { message: "Invalid request", details: [{ field: "strategy", message: "Invalid enum value" }] },
|
||||
firstField: "strategy",
|
||||
firstMessage: "Invalid enum value",
|
||||
};
|
||||
const body = buildComboErrorBody("COMBO_002", details);
|
||||
// The top-level error.message is from the error code catalog, not the issue message
|
||||
assert.equal(body.error.message, "One or more combo fields are invalid");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This helper simulates the logic that the fixed route.ts uses to extract
|
||||
* firstField/firstMessage from validateBody's error payload.
|
||||
* It exercises the extraction logic independently of the heavy route harness.
|
||||
*/
|
||||
describe("COMBO_002 firstField extraction logic", () => {
|
||||
function extractFirstField(validationError: {
|
||||
message: string;
|
||||
details: Array<{ field: string; message: string }>;
|
||||
}): { firstField: string | null; firstMessage: string | null } {
|
||||
const first = validationError.details?.[0] ?? null;
|
||||
return {
|
||||
firstField: first?.field ?? null,
|
||||
firstMessage: first?.message ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
it("extracts the first field from a non-empty details array", () => {
|
||||
const error = {
|
||||
message: "Invalid request",
|
||||
details: [
|
||||
{ field: "name", message: "Required" },
|
||||
{ field: "strategy", message: "Invalid enum value" },
|
||||
],
|
||||
};
|
||||
const { firstField, firstMessage } = extractFirstField(error);
|
||||
assert.equal(firstField, "name");
|
||||
assert.equal(firstMessage, "Required");
|
||||
});
|
||||
|
||||
it("returns null/null for an empty details array", () => {
|
||||
const error = { message: "Invalid request", details: [] };
|
||||
const { firstField, firstMessage } = extractFirstField(error);
|
||||
assert.equal(firstField, null);
|
||||
assert.equal(firstMessage, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Unit tests for the standardized combo error response helper.
|
||||
*
|
||||
* Bug #3 from `plans/2026-06-23-omniroute-v3.8.34-deep-audit.md`:
|
||||
* every 4xx response from `/api/combos/{id}` must include a stable
|
||||
* machine-readable `code` token, an `error.message`, optional
|
||||
* `error.details`, and `requestId` correlation. These tests assert the
|
||||
* shape and HTTP status for every branch the route uses.
|
||||
*
|
||||
* Runner: node:test (the runner that collects tests/unit/api/**), not vitest —
|
||||
* the original vitest import crashed under the node:test runner that the
|
||||
* package.json glob also feeds it to (check:test-discovery).
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildComboErrorBody, comboErrorResponse } from "@/lib/api/comboErrorResponse";
|
||||
import { ERROR_CODES } from "@/shared/constants/errorCodes";
|
||||
|
||||
describe("buildComboErrorBody", () => {
|
||||
it("emits the canonical { error: { code, message, category } } envelope", () => {
|
||||
const body = buildComboErrorBody("COMBO_001");
|
||||
assert.equal(body.error.code, "COMBO_001");
|
||||
assert.equal(body.error.message, "Request body is not valid JSON");
|
||||
assert.equal(body.error.category, "COMBO");
|
||||
});
|
||||
|
||||
it("includes details when provided", () => {
|
||||
const body = buildComboErrorBody("COMBO_002", {
|
||||
issues: [{ path: ["name"], message: "Required" }],
|
||||
});
|
||||
assert.equal(body.error.code, "COMBO_002");
|
||||
assert.deepEqual(body.error.details, {
|
||||
issues: [{ path: ["name"], message: "Required" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits details when undefined", () => {
|
||||
const body = buildComboErrorBody("COMBO_007");
|
||||
assert.ok(!("details" in body.error));
|
||||
});
|
||||
|
||||
it("falls back to INTERNAL_001 for an unknown code", () => {
|
||||
const body = buildComboErrorBody(
|
||||
"COMBO_999" as unknown as Parameters<typeof buildComboErrorBody>[0]
|
||||
);
|
||||
// An unknown code falls through to INTERNAL_001 from the catalog.
|
||||
assert.equal(body.error.code, "INTERNAL_001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("comboErrorResponse", () => {
|
||||
it("returns the catalog httpStatus when no override is given", async () => {
|
||||
const res = comboErrorResponse("COMBO_001");
|
||||
assert.equal(res.status, ERROR_CODES.COMBO_001.httpStatus);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error.code, "COMBO_001");
|
||||
});
|
||||
|
||||
it("respects an explicit status override", async () => {
|
||||
const res = comboErrorResponse("COMBO_006", 409, { name: "qs:foo" });
|
||||
assert.equal(res.status, 409);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error.code, "COMBO_006");
|
||||
assert.deepEqual(body.error.details, { name: "qs:foo" });
|
||||
});
|
||||
|
||||
it("attaches x-request-id when a Request is supplied", async () => {
|
||||
const req = new Request("https://example.com/api/combos/abc", {
|
||||
headers: { "x-request-id": "test-corr-id-1234" },
|
||||
});
|
||||
const res = comboErrorResponse("COMBO_004", 400, undefined, req);
|
||||
assert.ok(res.headers.get("x-request-id"));
|
||||
});
|
||||
|
||||
it("does NOT leak internal combo names in DAG errors (sanitized reason tag)", async () => {
|
||||
// The route should translate a thrown `Error("cycle detected: combo-A")`
|
||||
// into `{ reason: "cycle-detected" }` — never the raw message.
|
||||
const reason = /cycle/i.test("cycle detected: combo-A") ? "cycle-detected" : "invalid-graph";
|
||||
const res = comboErrorResponse("COMBO_005", 400, {
|
||||
comboName: "user-facing-only",
|
||||
reason,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error.code, "COMBO_005");
|
||||
assert.equal(body.error.details.reason, "cycle-detected");
|
||||
// Crucial: the raw "combo-A" string must NOT appear in the response body.
|
||||
const text = JSON.stringify(body);
|
||||
assert.ok(!text.includes("combo-A"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("all five route 4xx branches have a defined COMBO_* code", () => {
|
||||
// The route uses these codes; this is a regression test against accidental
|
||||
// removal of a code from the catalog (would break clients parsing `code`).
|
||||
const expectedCodes = [
|
||||
"COMBO_001", // JSON parse failure (route.ts L49-58)
|
||||
"COMBO_002", // zod schema failure (route.ts L65)
|
||||
"COMBO_003", // composite tier config (route.ts L117)
|
||||
"COMBO_004", // name collision (route.ts L124)
|
||||
"COMBO_005", // DAG validation (route.ts L139)
|
||||
"COMBO_006", // quota-share conflict 409 (route.ts L71-78)
|
||||
"COMBO_007", // not found 404 (route.ts L31)
|
||||
] as const;
|
||||
|
||||
for (const code of expectedCodes) {
|
||||
it(`${code} is registered with httpStatus 400 or 409 or 404`, () => {
|
||||
const def = ERROR_CODES[code];
|
||||
assert.notEqual(def, undefined);
|
||||
assert.ok([400, 404, 409].includes(def.httpStatus));
|
||||
assert.equal(def.category, "COMBO");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* GET /api/compression/engines
|
||||
*
|
||||
* Auth + isolation pattern mirrors tests/unit/api/context-combos-default-route.test.ts:
|
||||
* - makeManagementSessionRequest() for JWT cookie auth.
|
||||
* - Temp DATA_DIR, resetDbInstance() before each test, cleanup in test.after().
|
||||
*/
|
||||
import test, { describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
// ─── isolated temp DB ─────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-compression-engines-route-")
|
||||
);
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const enginesRoute = await import("../../../src/app/api/compression/engines/route.ts");
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function setupAuth(): Promise<void> {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "test-password-hash",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async () => {
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
await setupAuth();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/compression/engines", () => {
|
||||
test("returns 401 without auth", async () => {
|
||||
const req = new Request("http://localhost/api/compression/engines");
|
||||
const res = await enginesRoute.GET(req);
|
||||
assert.equal(res.status, 401, `Expected 401 without auth, got ${res.status}`);
|
||||
});
|
||||
|
||||
test("returns an engines array", async () => {
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/compression/engines");
|
||||
const res = await enginesRoute.GET(req);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = (await res.json()) as { engines: unknown[] };
|
||||
assert.ok(Array.isArray(body.engines), "response should have an engines array");
|
||||
assert.ok(body.engines.length > 0, "engines array should be non-empty");
|
||||
});
|
||||
|
||||
test("includes headroom and caveman engines", async () => {
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/compression/engines");
|
||||
const res = await enginesRoute.GET(req);
|
||||
const body = (await res.json()) as { engines: Array<{ id: string }> };
|
||||
const ids = body.engines.map((e) => e.id);
|
||||
assert.ok(ids.includes("headroom"), `engines should include headroom, got: ${ids.join(", ")}`);
|
||||
assert.ok(ids.includes("caveman"), `engines should include caveman, got: ${ids.join(", ")}`);
|
||||
});
|
||||
|
||||
test("headroom entry has non-empty configSchema and numeric stackPriority", async () => {
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/compression/engines");
|
||||
const res = await enginesRoute.GET(req);
|
||||
const body = (await res.json()) as {
|
||||
engines: Array<{
|
||||
id: string;
|
||||
configSchema: Array<{ key: string }>;
|
||||
stackPriority: unknown;
|
||||
}>;
|
||||
};
|
||||
const headroom = body.engines.find((e) => e.id === "headroom");
|
||||
assert.ok(headroom, "headroom engine should be present");
|
||||
assert.ok(
|
||||
Array.isArray(headroom.configSchema) && headroom.configSchema.length > 0,
|
||||
"headroom configSchema should be a non-empty array"
|
||||
);
|
||||
assert.strictEqual(
|
||||
typeof headroom.stackPriority,
|
||||
"number",
|
||||
"headroom stackPriority should be a number"
|
||||
);
|
||||
});
|
||||
|
||||
test("headroom configSchema includes the 'minRows' field key", async () => {
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/compression/engines");
|
||||
const res = await enginesRoute.GET(req);
|
||||
const body = (await res.json()) as {
|
||||
engines: Array<{
|
||||
id: string;
|
||||
configSchema: Array<{ key: string }>;
|
||||
}>;
|
||||
};
|
||||
const headroom = body.engines.find((e) => e.id === "headroom");
|
||||
assert.ok(headroom, "headroom engine should be present");
|
||||
const hasMinRows = headroom.configSchema.some((f) => f.key === "minRows");
|
||||
assert.ok(
|
||||
hasMinRows,
|
||||
`headroom configSchema should contain a field with key 'minRows', got keys: ${headroom.configSchema.map((f) => f.key).join(", ")}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Regression #6425 — /api/compression/preview rejects mode:"caveman" and stacked yields 0%.
|
||||
*
|
||||
* Two independent defects, one test file (both surface via the same endpoint):
|
||||
*
|
||||
* 1. `mode: "caveman"` was rejected by the Zod enum (only "off"/"lite"/"standard"/
|
||||
* "aggressive"/"ultra"/"rtk"/"stacked" allowed). The `caveman` engine exists in the
|
||||
* registry (see cavemanAdapter.ts) so the mode alias should be accepted and mapped
|
||||
* to a single-engine stacked run.
|
||||
*
|
||||
* 2. `mode: "stacked"` on prose containing well-known caveman-rule triggers ("Basically",
|
||||
* "I think", "probably", "just", "comprehensive") returned savingsPct: 0. Root cause:
|
||||
* the caveman engine's stacked-adapter did not default `enabled: true` when invoked
|
||||
* with no explicit stepConfig — DEFAULT_CAVEMAN_CONFIG.enabled=false made
|
||||
* cavemanCompress() a no-op. Mirrors the rtkAdapter fix pattern (rtk defaults enabled).
|
||||
*
|
||||
* Auth pattern mirrors compression-preview-engine.test.ts (JWT session, temp DATA_DIR).
|
||||
*/
|
||||
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";
|
||||
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
// ─── temp DB isolation ────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-compression-preview-6425-")
|
||||
);
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const previewRoute = await import("../../../src/app/api/compression/preview/route.ts");
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CAVEMAN_TRIGGER =
|
||||
"Basically, I think we should probably just use a comprehensive analysis approach to " +
|
||||
"actually really understand the very important issue at hand.";
|
||||
|
||||
async function setupAuth(): Promise<void> {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "test-password-hash",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async () => {
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
await setupAuth();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("#6425 (a): POST /api/compression/preview accepts mode:'caveman' and produces >0% savings", async () => {
|
||||
const request = await makeManagementSessionRequest(
|
||||
"http://localhost/api/compression/preview",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
messages: [{ role: "user", content: CAVEMAN_TRIGGER }],
|
||||
mode: "caveman",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const response = await previewRoute.POST(request);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`mode:"caveman" should be accepted (was rejected before #6425 fix), got ${response.status}`
|
||||
);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
savingsPct: number;
|
||||
techniquesUsed: string[];
|
||||
mode: string;
|
||||
};
|
||||
|
||||
assert.ok(body.originalTokens > 0, "originalTokens should be > 0");
|
||||
assert.ok(
|
||||
body.compressedTokens < body.originalTokens,
|
||||
`caveman rules should shrink well-known filler prose (got ${body.compressedTokens} vs ${body.originalTokens})`
|
||||
);
|
||||
assert.ok(body.savingsPct > 0, `savingsPct should be > 0, got ${body.savingsPct}`);
|
||||
});
|
||||
|
||||
test("#6425 (b): POST /api/compression/preview mode:'stacked' returns >0% on caveman-trigger prose", async () => {
|
||||
const request = await makeManagementSessionRequest(
|
||||
"http://localhost/api/compression/preview",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
messages: [{ role: "user", content: CAVEMAN_TRIGGER }],
|
||||
mode: "stacked",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const response = await previewRoute.POST(request);
|
||||
assert.equal(response.status, 200, `Expected 200, got ${response.status}`);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
savingsPct: number;
|
||||
engineBreakdown: Array<{ engine: string; savingsPercent: number }>;
|
||||
};
|
||||
|
||||
assert.ok(body.originalTokens > 0, "originalTokens should be > 0");
|
||||
assert.ok(
|
||||
body.savingsPct > 0,
|
||||
`stacked pipeline (default [rtk, caveman]) should produce >0% savings on filler-heavy prose, got ${body.savingsPct}% (regression: caveman step was silently disabled by DEFAULT_CAVEMAN_CONFIG.enabled=false)`
|
||||
);
|
||||
|
||||
// Assert the caveman step in the breakdown actually did work — this is the tightest guard
|
||||
// against the specific fix in cavemanAdapter.ts (default enabled when no explicit config).
|
||||
const cavemanStep = body.engineBreakdown.find((s) => s.engine === "caveman");
|
||||
assert.ok(cavemanStep, "engineBreakdown should include a caveman step");
|
||||
assert.ok(
|
||||
cavemanStep.savingsPercent > 0,
|
||||
`caveman step should report >0% savings on trigger prose, got ${cavemanStep.savingsPercent}%`
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* TDD: per-engine compression preview via engineId param.
|
||||
*
|
||||
* Auth pattern mirrors tests/unit/compression/compression-preview-auth.test.ts:
|
||||
* - Use makeManagementSessionRequest to create a JWT-auth'd Request.
|
||||
* - Set DATA_DIR to a temp dir and run the DB setup before importing the route.
|
||||
*/
|
||||
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";
|
||||
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
// ─── temp DB isolation ────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-compression-preview-engine-")
|
||||
);
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const previewRoute = await import("../../../src/app/api/compression/preview/route.ts");
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function setupAuth(): Promise<void> {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "test-password-hash",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a message array whose content is a pretty-printed JSON string of a homogeneous
|
||||
* array of ≥8 rows. headroom's crushText() tries the whole string as a JSON array when
|
||||
* it starts with "[", so passing JSON.stringify(rows, null, 2) as content triggers
|
||||
* compaction. Pretty-printed JSON has whitespace so countTokens() also gives a
|
||||
* meaningful baseline to compare against the compact tabular output.
|
||||
*/
|
||||
function buildHeadroomMessages(): Array<{ role: string; content: string }> {
|
||||
const rows = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `item-${i + 1}`,
|
||||
value: (i + 1) * 100,
|
||||
active: true,
|
||||
}));
|
||||
// Pretty-print so word-split countTokens() produces a non-trivial baseline count
|
||||
return [{ role: "user", content: JSON.stringify(rows, null, 2) }];
|
||||
}
|
||||
|
||||
// ─── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async () => {
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
await setupAuth();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("POST /api/compression/preview with engineId=headroom returns 200 with token savings", async () => {
|
||||
const messages = buildHeadroomMessages();
|
||||
|
||||
const request = await makeManagementSessionRequest("http://localhost/api/compression/preview", {
|
||||
method: "POST",
|
||||
body: {
|
||||
engineId: "headroom",
|
||||
messages,
|
||||
mode: "stacked",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await previewRoute.POST(request);
|
||||
assert.equal(response.status, 200, `Expected 200, got ${response.status}`);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
tokensSaved: number;
|
||||
savingsPct: number;
|
||||
techniquesUsed: string[];
|
||||
original: string;
|
||||
compressed: string;
|
||||
mode: string;
|
||||
};
|
||||
|
||||
assert.ok(
|
||||
typeof body.originalTokens === "number" && body.originalTokens > 0,
|
||||
`originalTokens should be > 0, got: ${body.originalTokens}`
|
||||
);
|
||||
assert.ok(
|
||||
typeof body.compressedTokens === "number",
|
||||
`compressedTokens should be a number, got: ${body.compressedTokens}`
|
||||
);
|
||||
assert.ok(
|
||||
body.compressedTokens < body.originalTokens,
|
||||
`compressedTokens (${body.compressedTokens}) should be < originalTokens (${body.originalTokens}) — headroom should compact a 10-row homogeneous JSON array`
|
||||
);
|
||||
assert.ok(body.tokensSaved > 0, `tokensSaved should be > 0, got: ${body.tokensSaved}`);
|
||||
assert.ok(body.savingsPct > 0, `savingsPct should be > 0, got: ${body.savingsPct}`);
|
||||
|
||||
// Response shape completeness checks
|
||||
assert.ok(typeof body.original === "string", "should have original field");
|
||||
assert.ok(typeof body.compressed === "string", "should have compressed field");
|
||||
assert.ok(Array.isArray(body.techniquesUsed), "techniquesUsed should be an array");
|
||||
});
|
||||
|
||||
test("POST /api/compression/preview with engineId but no mode still works (mode defaults to stacked)", async () => {
|
||||
const messages = buildHeadroomMessages();
|
||||
|
||||
const request = await makeManagementSessionRequest("http://localhost/api/compression/preview", {
|
||||
method: "POST",
|
||||
body: {
|
||||
engineId: "headroom",
|
||||
messages,
|
||||
// mode intentionally omitted — should default to "stacked" when engineId present
|
||||
},
|
||||
});
|
||||
|
||||
const response = await previewRoute.POST(request);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`Expected 200 when mode omitted with engineId, got ${response.status}`
|
||||
);
|
||||
|
||||
const body = (await response.json()) as { tokensSaved: number };
|
||||
assert.ok(body.tokensSaved > 0, `tokensSaved should be > 0 even without explicit mode`);
|
||||
});
|
||||
|
||||
test("POST /api/compression/preview without engineId still works (existing path untouched)", async () => {
|
||||
const request = await makeManagementSessionRequest("http://localhost/api/compression/preview", {
|
||||
method: "POST",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "Hello world" }],
|
||||
mode: "standard",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await previewRoute.POST(request);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`Existing path should still return 200, got ${response.status}`
|
||||
);
|
||||
|
||||
const body = (await response.json()) as { original: string; mode: string };
|
||||
assert.ok(typeof body.original === "string", "should have original field");
|
||||
assert.equal(body.mode, "standard", "mode field should reflect the requested mode");
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, beforeEach, afterEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
describe("Compression Settings API Schema Validation", () => {
|
||||
const compressionModeValues = [
|
||||
"off",
|
||||
"lite",
|
||||
"standard",
|
||||
"aggressive",
|
||||
"ultra",
|
||||
"rtk",
|
||||
"stacked",
|
||||
];
|
||||
|
||||
it("should validate all compression mode values", () => {
|
||||
assert.deepStrictEqual(compressionModeValues, [
|
||||
"off",
|
||||
"lite",
|
||||
"standard",
|
||||
"aggressive",
|
||||
"ultra",
|
||||
"rtk",
|
||||
"stacked",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should validate caveman config structure", () => {
|
||||
const defaultCavemanConfig = {
|
||||
enabled: true,
|
||||
compressRoles: ["user"],
|
||||
skipRules: [],
|
||||
minMessageLength: 50,
|
||||
preservePatterns: [],
|
||||
};
|
||||
|
||||
assert.equal(defaultCavemanConfig.enabled, true);
|
||||
assert.deepStrictEqual(defaultCavemanConfig.compressRoles, ["user"]);
|
||||
assert.equal(Array.isArray(defaultCavemanConfig.skipRules), true);
|
||||
assert.equal(defaultCavemanConfig.minMessageLength, 50);
|
||||
assert.equal(Array.isArray(defaultCavemanConfig.preservePatterns), true);
|
||||
});
|
||||
|
||||
it("should validate full compression config structure", () => {
|
||||
const defaultConfig = {
|
||||
enabled: false,
|
||||
defaultMode: "off",
|
||||
autoTriggerTokens: 0,
|
||||
cacheMinutes: 5,
|
||||
preserveSystemPrompt: true,
|
||||
comboOverrides: {},
|
||||
cavemanConfig: {
|
||||
enabled: true,
|
||||
compressRoles: ["user"],
|
||||
skipRules: [],
|
||||
minMessageLength: 50,
|
||||
preservePatterns: [],
|
||||
},
|
||||
ultra: {
|
||||
enabled: false,
|
||||
compressionRate: 0.5,
|
||||
minScoreThreshold: 0.3,
|
||||
slmFallbackToAggressive: true,
|
||||
maxTokensPerMessage: 0,
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(defaultConfig.enabled, false);
|
||||
assert.ok(compressionModeValues.includes(defaultConfig.defaultMode));
|
||||
assert.equal(typeof defaultConfig.autoTriggerTokens, "number");
|
||||
assert.equal(typeof defaultConfig.cacheMinutes, "number");
|
||||
assert.equal(typeof defaultConfig.preserveSystemPrompt, "boolean");
|
||||
assert.equal(typeof defaultConfig.comboOverrides, "object");
|
||||
assert.equal(typeof defaultConfig.cavemanConfig, "object");
|
||||
assert.equal(typeof defaultConfig.ultra, "object");
|
||||
assert.equal(defaultConfig.ultra.compressionRate, 0.5);
|
||||
});
|
||||
|
||||
it("should validate all caveman compression rules are defined", async () => {
|
||||
const { CAVEMAN_RULES } =
|
||||
await import("../../../../open-sse/services/compression/cavemanRules.ts");
|
||||
assert.ok(Array.isArray(CAVEMAN_RULES));
|
||||
assert.ok(CAVEMAN_RULES.length >= 29, `Expected >= 29 rules, got ${CAVEMAN_RULES.length}`);
|
||||
for (const rule of CAVEMAN_RULES) {
|
||||
assert.ok(rule.name && typeof rule.name === "string", `Rule must have a name`);
|
||||
assert.ok(rule.pattern instanceof RegExp, `Rule ${rule.name} must have a RegExp pattern`);
|
||||
assert.ok(
|
||||
typeof rule.replacement === "string" || typeof rule.replacement === "function",
|
||||
`Rule ${rule.name} must have string or function replacement`
|
||||
);
|
||||
assert.ok(
|
||||
rule.pattern.source !== "^$" || rule.replacement !== "",
|
||||
`Rule ${rule.name} must not be a no-op (empty pattern + empty replacement)`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate compression modes cover all CavemanConfig roles", () => {
|
||||
const validRoles = ["user", "assistant", "system"];
|
||||
for (const role of validRoles) {
|
||||
assert.ok(validRoles.includes(role), `Role ${role} should be valid`);
|
||||
}
|
||||
assert.equal(validRoles.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Route round-trip: engines map + activeComboId ─────────────────────────
|
||||
// Mirrors the mcp-accessibility-config test harness: allocate a temp DATA_DIR,
|
||||
// import route + DB modules, tear down in after().
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-compression-route-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const route = await import("../../../../src/app/api/settings/compression/route.ts");
|
||||
|
||||
function makeRequest(method: string, body?: unknown): Request {
|
||||
return new Request("http://localhost/api/settings/compression", {
|
||||
method,
|
||||
headers: body !== undefined ? { "content-type": "application/json" } : {},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
}
|
||||
|
||||
describe("settings/compression route — engines + activeComboId", () => {
|
||||
beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
});
|
||||
|
||||
it("PUT engines map persists and GET returns engines + activeComboId", async () => {
|
||||
const putRes = await route.PUT(
|
||||
makeRequest("PUT", { engines: { rtk: { enabled: true, level: "standard" } } })
|
||||
);
|
||||
assert.equal(putRes.status, 200);
|
||||
|
||||
// Fresh DB handle so we read from storage, not from the write-path return value.
|
||||
core.resetDbInstance();
|
||||
|
||||
const getRes = await route.GET(makeRequest("GET"));
|
||||
assert.equal(getRes.status, 200);
|
||||
const body = await getRes.json();
|
||||
|
||||
assert.equal(body.engines?.rtk?.enabled, true, "engines.rtk.enabled should be true after PUT");
|
||||
assert.equal(
|
||||
body.engines?.rtk?.level,
|
||||
"standard",
|
||||
"engines.rtk.level should be 'standard' after PUT"
|
||||
);
|
||||
// activeComboId is always present (null by default)
|
||||
assert.ok("activeComboId" in body, "GET response must include activeComboId");
|
||||
});
|
||||
|
||||
it("PUT activeComboId persists and is returned by GET", async () => {
|
||||
const putRes = await route.PUT(makeRequest("PUT", { activeComboId: "combo-abc" }));
|
||||
assert.equal(putRes.status, 200);
|
||||
|
||||
core.resetDbInstance();
|
||||
|
||||
const getRes = await route.GET(makeRequest("GET"));
|
||||
assert.equal(getRes.status, 200);
|
||||
const body = await getRes.json();
|
||||
assert.equal(body.activeComboId, "combo-abc");
|
||||
});
|
||||
|
||||
it("PUT activeComboId:null clears the active combo", async () => {
|
||||
// First set it, then clear.
|
||||
await route.PUT(makeRequest("PUT", { activeComboId: "combo-to-clear" }));
|
||||
core.resetDbInstance();
|
||||
await route.PUT(makeRequest("PUT", { activeComboId: null }));
|
||||
core.resetDbInstance();
|
||||
|
||||
const getRes = await route.GET(makeRequest("GET"));
|
||||
assert.equal(getRes.status, 200);
|
||||
const body = await getRes.json();
|
||||
assert.equal(body.activeComboId, null);
|
||||
});
|
||||
|
||||
it("PUT with invalid engines shape is rejected by schema validation (400)", async () => {
|
||||
// engines values must have an `enabled` boolean — passing a string should fail the schema.
|
||||
const putRes = await route.PUT(makeRequest("PUT", { engines: { rtk: { enabled: "yes" } } }));
|
||||
assert.equal(putRes.status, 400);
|
||||
const body = await putRes.json();
|
||||
// Validation failures use { error: { message, details } } via validateBody helper.
|
||||
assert.ok(body.error !== null && typeof body.error === "object", "error should be an object");
|
||||
const errorMessage: string =
|
||||
typeof body.error === "string"
|
||||
? body.error
|
||||
: (body.error?.message ?? JSON.stringify(body.error));
|
||||
assert.ok(!errorMessage.includes("at /"), "error must not contain a stack trace");
|
||||
});
|
||||
|
||||
it("PUT accepts enginesExplicit (round-tripped from GET response)", async () => {
|
||||
// Regression: the GET handler injects `enginesExplicit` (compression.ts:632) so the
|
||||
// hub/panel can round-trip the full settings object. The previous .strict() PUT schema
|
||||
// rejected it with 400 ("Unrecognized key: enginesExplicit"), causing every toggle on
|
||||
// the Compression Hub / Panel to revert. Allow it through.
|
||||
const putRes = await route.PUT(makeRequest("PUT", { enabled: true, enginesExplicit: true }));
|
||||
assert.equal(putRes.status, 200);
|
||||
|
||||
core.resetDbInstance();
|
||||
|
||||
const putRes2 = await route.PUT(makeRequest("PUT", { enabled: false, enginesExplicit: false }));
|
||||
assert.equal(putRes2.status, 200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* TDD for the RTK learn/discover API routes (F2.1 / Item 2).
|
||||
*
|
||||
* GET /api/context/rtk/discover → ranked noise candidates mined from the
|
||||
* opt-in rtk raw-output sample store.
|
||||
* GET /api/context/rtk/learn?command=X → a suggested RTK filter draft for command X.
|
||||
*
|
||||
* Auth: requireManagementAuth — not required in the unconfigured/first-run state
|
||||
* (fresh temp DATA_DIR, no INITIAL_PASSWORD), so these exercise the 200 happy path.
|
||||
* The pure miners (discoverRepeatedNoise/suggestFilter) and the sample adapter
|
||||
* (listRtkCommandSamples) are unit-tested separately; this asserts the wiring.
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/api/compression/rtk-learn-discover-routes.test.ts
|
||||
*/
|
||||
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-rtk-ld-routes-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
|
||||
const { maybePersistRtkRawOutput } = await import(
|
||||
"../../../../open-sse/services/compression/engines/rtk/index.ts"
|
||||
);
|
||||
const discoverRoute = await import("../../../../src/app/api/context/rtk/discover/route.ts");
|
||||
const learnRoute = await import("../../../../src/app/api/context/rtk/learn/route.ts");
|
||||
|
||||
function seedSamples() {
|
||||
// Three runs of the same command with a shared noise line + a unique line each.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
maybePersistRtkRawOutput(
|
||||
`Resolving dependencies...\nnpm warn deprecated foo@1.0.0\nadded ${i} packages in ${i}s\n`,
|
||||
{ retention: "always", command: "npm install" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function get(url: string): Request {
|
||||
return new Request(url, { method: "GET" });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
if (ORIGINAL_INITIAL_PASSWORD !== undefined)
|
||||
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
});
|
||||
|
||||
test("GET /discover — returns ranked noise candidates + sampleCount", async () => {
|
||||
seedSamples();
|
||||
const res = await discoverRoute.GET(get("http://localhost/api/context/rtk/discover"));
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { sampleCount: number; candidates: Array<{ hits: number }> };
|
||||
assert.equal(body.sampleCount, 3);
|
||||
assert.ok(Array.isArray(body.candidates));
|
||||
assert.ok(body.candidates.length > 0, "the shared 'Resolving dependencies' line is a candidate");
|
||||
});
|
||||
|
||||
test("GET /discover — empty store → 200 with no candidates", async () => {
|
||||
const res = await discoverRoute.GET(get("http://localhost/api/context/rtk/discover"));
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { sampleCount: number; candidates: unknown[] };
|
||||
assert.equal(body.sampleCount, 0);
|
||||
assert.equal(body.candidates.length, 0);
|
||||
});
|
||||
|
||||
test("GET /learn?command=npm install — returns a suggested filter learned from matching samples", async () => {
|
||||
seedSamples();
|
||||
const res = await learnRoute.GET(
|
||||
get("http://localhost/api/context/rtk/learn?command=" + encodeURIComponent("npm install"))
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as {
|
||||
command: string;
|
||||
sampleCount: number;
|
||||
filter: { id: string; match: { commands: string[] } };
|
||||
};
|
||||
assert.equal(body.command, "npm install");
|
||||
assert.equal(body.sampleCount, 3, "only the matching command's samples are learned from");
|
||||
assert.ok(body.filter.id.length > 0);
|
||||
assert.ok(body.filter.match.commands.length > 0);
|
||||
});
|
||||
|
||||
test("GET /learn without a command → 400", async () => {
|
||||
const res = await learnRoute.GET(get("http://localhost/api/context/rtk/learn"));
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error: { message?: string } | string };
|
||||
// Error must not leak a stack trace (Hard Rule #12).
|
||||
const msg = typeof body.error === "string" ? body.error : (body.error?.message ?? "");
|
||||
assert.ok(!msg.includes("at /"), "no stack trace in error body");
|
||||
});
|
||||
|
||||
test("GET /learn filters out other commands' samples", async () => {
|
||||
seedSamples(); // 3× "npm install"
|
||||
maybePersistRtkRawOutput("Compiling cargo...\nFinished in 2s\n", {
|
||||
retention: "always",
|
||||
command: "cargo build",
|
||||
});
|
||||
const res = await learnRoute.GET(
|
||||
get("http://localhost/api/context/rtk/learn?command=" + encodeURIComponent("npm install"))
|
||||
);
|
||||
const body = (await res.json()) as { sampleCount: number };
|
||||
assert.equal(body.sampleCount, 3, "the cargo sample is excluded");
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* TDD: GET /api/context/analytics/engine?engineId=&days=
|
||||
*
|
||||
* Auth + isolation pattern mirrors tests/unit/api/compression-preview-engine.test.ts:
|
||||
* - makeManagementSessionRequest() for JWT cookie auth.
|
||||
* - Temp DATA_DIR, resetDbInstance() before each test, cleanup in test.after().
|
||||
*/
|
||||
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";
|
||||
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
// ─── isolated temp DB ─────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ctx-analytics-engine-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const { insertCompressionAnalyticsRow } =
|
||||
await import("../../../src/lib/db/compressionAnalytics.ts");
|
||||
const engineRoute = await import("../../../src/app/api/context/analytics/engine/route.ts");
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function setupAuth(): Promise<void> {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "test-password-hash",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async () => {
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
await setupAuth();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("GET /api/context/analytics/engine returns 400 when engineId is missing", async () => {
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/context/analytics/engine");
|
||||
const res = await engineRoute.GET(req);
|
||||
assert.equal(res.status, 400, `Expected 400, got ${res.status}`);
|
||||
const body = (await res.json()) as { error: string };
|
||||
assert.ok(typeof body.error === "string", "response should have an error string");
|
||||
});
|
||||
|
||||
test("GET /api/context/analytics/engine returns 200 with correct aggregation for headroom", async () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "headroom",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 800,
|
||||
tokens_saved: 200,
|
||||
});
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "headroom",
|
||||
original_tokens: 500,
|
||||
compressed_tokens: 350,
|
||||
tokens_saved: 150,
|
||||
});
|
||||
// caveman row — must NOT appear in headroom result
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "caveman",
|
||||
original_tokens: 800,
|
||||
compressed_tokens: 600,
|
||||
tokens_saved: 200,
|
||||
});
|
||||
|
||||
const req = await makeManagementSessionRequest(
|
||||
"http://localhost/api/context/analytics/engine?engineId=headroom"
|
||||
);
|
||||
const res = await engineRoute.GET(req);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
engineId: string;
|
||||
runs: number;
|
||||
tokensSaved: number;
|
||||
avgSavingsPercent: number;
|
||||
days: number;
|
||||
};
|
||||
|
||||
assert.equal(body.engineId, "headroom");
|
||||
assert.equal(body.runs, 2, "should count only the 2 headroom rows");
|
||||
assert.equal(body.tokensSaved, 350, "should sum tokens_saved for headroom rows");
|
||||
assert.equal(typeof body.avgSavingsPercent, "number", "avgSavingsPercent should be a number");
|
||||
assert.equal(body.days, 7, "default days should be 7");
|
||||
});
|
||||
|
||||
test("GET /api/context/analytics/engine respects ?days= parameter", async () => {
|
||||
const req = await makeManagementSessionRequest(
|
||||
"http://localhost/api/context/analytics/engine?engineId=headroom&days=30"
|
||||
);
|
||||
const res = await engineRoute.GET(req);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = (await res.json()) as { days: number };
|
||||
assert.equal(body.days, 30, "should pass days=30 through to the result");
|
||||
});
|
||||
|
||||
test("GET /api/context/analytics/engine returns 200 with zero metrics for unknown engine", async () => {
|
||||
const req = await makeManagementSessionRequest(
|
||||
"http://localhost/api/context/analytics/engine?engineId=nonexistent"
|
||||
);
|
||||
const res = await engineRoute.GET(req);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
const body = (await res.json()) as { runs: number; tokensSaved: number };
|
||||
assert.equal(body.runs, 0);
|
||||
assert.equal(body.tokensSaved, 0);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* TDD: GET /api/context/combos/default is a read-only shim.
|
||||
*
|
||||
* The default compression pipeline is now DERIVED from the engines map
|
||||
* (open-sse deriveDefaultPlan) rather than editable here:
|
||||
* - GET → returns the derived default plan for the live config.
|
||||
* - PUT → rejected with a deprecation error (not 200); body carries no stack trace.
|
||||
*
|
||||
* Auth + isolation pattern mirrors tests/unit/api/context-analytics-engine-route.test.ts:
|
||||
* - makeManagementSessionRequest() for JWT cookie auth.
|
||||
* - Temp DATA_DIR, resetDbInstance() before each test, cleanup in test.after().
|
||||
*/
|
||||
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";
|
||||
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
import { deriveDefaultPlan } from "@omniroute/open-sse/services/compression/deriveDefaultPlan.ts";
|
||||
|
||||
// ─── isolated temp DB ─────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ctx-combos-default-route-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const compressionDb = await import("../../../src/lib/db/compression.ts");
|
||||
const defaultRoute = await import("../../../src/app/api/context/combos/default/route.ts");
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function setupAuth(): Promise<void> {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "test-password-hash",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async () => {
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
await setupAuth();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("GET /api/context/combos/default returns the derived default plan (single-mode caveman)", async () => {
|
||||
await compressionDb.updateCompressionSettings({
|
||||
enabled: true,
|
||||
engines: { caveman: { enabled: true, level: "full" } },
|
||||
});
|
||||
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default");
|
||||
const res = await defaultRoute.GET(req);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
mode: string;
|
||||
pipeline: Array<{ engine: string; intensity?: string }>;
|
||||
};
|
||||
|
||||
// caveman alone is single-mode → effective mode "standard", empty stacked pipeline.
|
||||
const expected = deriveDefaultPlan({ caveman: { enabled: true, level: "full" } }, true);
|
||||
assert.equal(body.mode, expected.mode, `expected mode ${expected.mode}`);
|
||||
assert.equal(body.mode, "standard");
|
||||
assert.deepEqual(body.pipeline, expected.stackedPipeline);
|
||||
});
|
||||
|
||||
test("GET /api/context/combos/default returns the derived stacked pipeline (reflects enabled engines)", async () => {
|
||||
await compressionDb.updateCompressionSettings({
|
||||
enabled: true,
|
||||
engines: {
|
||||
caveman: { enabled: true, level: "full" },
|
||||
headroom: { enabled: true },
|
||||
},
|
||||
});
|
||||
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default");
|
||||
const res = await defaultRoute.GET(req);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
mode: string;
|
||||
pipeline: Array<{ engine: string; intensity?: string }>;
|
||||
};
|
||||
|
||||
const expected = deriveDefaultPlan(
|
||||
{ caveman: { enabled: true, level: "full" }, headroom: { enabled: true } },
|
||||
true
|
||||
);
|
||||
assert.equal(body.mode, "stacked");
|
||||
assert.deepEqual(body.pipeline, expected.stackedPipeline);
|
||||
const engineIds = body.pipeline.map((s) => s.engine);
|
||||
assert.ok(engineIds.includes("caveman"), `expected caveman in derived pipeline, got: ${engineIds}`);
|
||||
});
|
||||
|
||||
test("GET /api/context/combos/default returns off when master switch is disabled", async () => {
|
||||
await compressionDb.updateCompressionSettings({
|
||||
enabled: false,
|
||||
engines: { caveman: { enabled: true, level: "full" } },
|
||||
});
|
||||
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default");
|
||||
const res = await defaultRoute.GET(req);
|
||||
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as { mode: string; pipeline: unknown[] };
|
||||
assert.equal(body.mode, "off");
|
||||
assert.deepEqual(body.pipeline, []);
|
||||
});
|
||||
|
||||
test("PUT /api/context/combos/default is deprecated and rejects writes (not 200)", async () => {
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ engineId: "headroom", enabled: true }),
|
||||
});
|
||||
const res = await defaultRoute.PUT(req);
|
||||
assert.notEqual(res.status, 200, "PUT must no longer succeed — the route is read-only");
|
||||
assert.ok(res.status >= 400, `expected a 4xx deprecation status, got ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as { error?: { message?: string } | string };
|
||||
// The deprecation message points editors at the engines settings.
|
||||
const message =
|
||||
typeof body.error === "string" ? body.error : (body.error?.message ?? JSON.stringify(body));
|
||||
assert.match(message, /derived|engines|deprecat/i, `unexpected deprecation message: ${message}`);
|
||||
|
||||
// Hard Rule #12: no raw stack trace leaks into the response body. Match the V8
|
||||
// stack-frame shape (" at fn (file:line:col)") rather than the bare "at " token,
|
||||
// which legitimately appears in the URL inside the deprecation message.
|
||||
assert.ok(
|
||||
!/\bat\s+\S+\s+\(?\/?\S+:\d+:\d+/.test(JSON.stringify(body)),
|
||||
"error body must not contain a stack trace"
|
||||
);
|
||||
assert.ok(!/\n\s+at\s/.test(JSON.stringify(body)), "error body must not contain a stack trace");
|
||||
});
|
||||
|
||||
test("POST /api/context/combos/default is deprecated and rejects writes (not 200)", async () => {
|
||||
const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ engineId: "headroom", enabled: true }),
|
||||
});
|
||||
const res = await defaultRoute.POST(req);
|
||||
assert.notEqual(res.status, 200, "POST must no longer succeed — the route is read-only");
|
||||
assert.ok(res.status >= 400, `expected a 4xx deprecation status, got ${res.status}`);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Isolated DB + auth disabled so requireManagementAuth passes (no key configured).
|
||||
let tmpDataDir: string;
|
||||
let core: typeof import("@/lib/db/core");
|
||||
let db: typeof import("@/lib/db/discoveryResults");
|
||||
let resultsRoute: typeof import("@/app/api/discovery/results/route");
|
||||
let resultByIdRoute: typeof import("@/app/api/discovery/results/[id]/route");
|
||||
let scanRoute: typeof import("@/app/api/discovery/scan/route");
|
||||
let verifyRoute: typeof import("@/app/api/discovery/verify/[id]/route");
|
||||
|
||||
function req(method: string, url: string, body?: unknown): Request {
|
||||
return new Request(`http://localhost${url}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
tmpDataDir = mkdtempSync(join(tmpdir(), "omniroute-discovery-routes-"));
|
||||
process.env.DATA_DIR = tmpDataDir;
|
||||
delete process.env.REQUIRE_API_KEY;
|
||||
process.env.OMNIROUTE_DISABLE_AUTH = "1";
|
||||
core = await import("@/lib/db/core");
|
||||
core.resetDbInstance();
|
||||
core.getDbInstance();
|
||||
db = await import("@/lib/db/discoveryResults");
|
||||
resultsRoute = await import("@/app/api/discovery/results/route");
|
||||
resultByIdRoute = await import("@/app/api/discovery/results/[id]/route");
|
||||
scanRoute = await import("@/app/api/discovery/scan/route");
|
||||
verifyRoute = await import("@/app/api/discovery/verify/[id]/route");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
if (tmpDataDir) rmSync(tmpDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("discovery API routes", () => {
|
||||
test("GET /results lists persisted findings (and filters by providerId)", async () => {
|
||||
db.upsertDiscoveryResult({
|
||||
providerId: "acme",
|
||||
method: "free_tier",
|
||||
authType: "none",
|
||||
feasibility: 3,
|
||||
riskLevel: "none",
|
||||
status: "pending",
|
||||
});
|
||||
const res = await resultsRoute.GET(req("GET", "/api/discovery/results?providerId=acme"));
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(Array.isArray(body.results));
|
||||
assert.ok(body.results.some((r: { providerId: string }) => r.providerId === "acme"));
|
||||
});
|
||||
|
||||
test("GET /results/:id returns the row, 404 when missing, 400 on bad id", async () => {
|
||||
const created = db.upsertDiscoveryResult({
|
||||
providerId: "beta",
|
||||
method: "trial",
|
||||
authType: "api_key",
|
||||
feasibility: 2,
|
||||
riskLevel: "low",
|
||||
status: "pending",
|
||||
});
|
||||
const ok = await resultByIdRoute.GET(req("GET", `/api/discovery/results/${created.id}`), {
|
||||
params: Promise.resolve({ id: String(created.id) }),
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
|
||||
const missing = await resultByIdRoute.GET(req("GET", "/api/discovery/results/999999"), {
|
||||
params: Promise.resolve({ id: "999999" }),
|
||||
});
|
||||
assert.equal(missing.status, 404);
|
||||
|
||||
const bad = await resultByIdRoute.GET(req("GET", "/api/discovery/results/abc"), {
|
||||
params: Promise.resolve({ id: "abc" }),
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
});
|
||||
|
||||
test("POST /scan persists findings; rejects an empty providerId with 400", async () => {
|
||||
const res = await scanRoute.POST(req("POST", "/api/discovery/scan", { providerId: "gamma" }));
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(Array.isArray(body.results) && body.results.length > 0);
|
||||
assert.ok(body.results[0].id > 0);
|
||||
// the persisted row is now queryable
|
||||
assert.ok(db.getDiscoveryResults("gamma").length > 0);
|
||||
|
||||
const invalid = await scanRoute.POST(req("POST", "/api/discovery/scan", { providerId: "" }));
|
||||
assert.equal(invalid.status, 400);
|
||||
|
||||
const malformed = new Request("http://localhost/api/discovery/scan", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{not json",
|
||||
});
|
||||
const malformedRes = await scanRoute.POST(malformed);
|
||||
assert.equal(malformedRes.status, 400);
|
||||
});
|
||||
|
||||
test("POST /verify/:id marks verified, 404 when missing", async () => {
|
||||
const created = db.upsertDiscoveryResult({
|
||||
providerId: "delta",
|
||||
method: "public_api",
|
||||
authType: "api_key",
|
||||
feasibility: 5,
|
||||
riskLevel: "none",
|
||||
status: "pending",
|
||||
});
|
||||
const res = await verifyRoute.POST(req("POST", `/api/discovery/verify/${created.id}`), {
|
||||
params: Promise.resolve({ id: String(created.id) }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.result.status, "verified");
|
||||
|
||||
const missing = await verifyRoute.POST(req("POST", "/api/discovery/verify/999999"), {
|
||||
params: Promise.resolve({ id: "999999" }),
|
||||
});
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
|
||||
test("DELETE /results/:id removes the row, 404 on second delete", async () => {
|
||||
const created = db.upsertDiscoveryResult({
|
||||
providerId: "epsilon",
|
||||
method: "free_tier",
|
||||
authType: "none",
|
||||
feasibility: 1,
|
||||
riskLevel: "none",
|
||||
status: "pending",
|
||||
});
|
||||
const first = await resultByIdRoute.DELETE(req("DELETE", `/api/discovery/results/${created.id}`), {
|
||||
params: Promise.resolve({ id: String(created.id) }),
|
||||
});
|
||||
assert.equal(first.status, 200);
|
||||
const second = await resultByIdRoute.DELETE(req("DELETE", `/api/discovery/results/${created.id}`), {
|
||||
params: Promise.resolve({ id: String(created.id) }),
|
||||
});
|
||||
assert.equal(second.status, 404);
|
||||
});
|
||||
|
||||
test("error responses do not leak stack traces", async () => {
|
||||
const missing = await resultByIdRoute.GET(req("GET", "/api/discovery/results/424242"), {
|
||||
params: Promise.resolve({ id: "424242" }),
|
||||
});
|
||||
const body = await missing.json();
|
||||
assert.ok(!String(body.error?.message ?? "").includes("at /"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveServerErrorMessage } from "../../../src/lib/api/serverErrorMessage.ts";
|
||||
|
||||
const FALLBACK = "Failed to toggle combo";
|
||||
|
||||
test("prefers error.details[0].message (field-level / COMBO_002 shape)", () => {
|
||||
const body = {
|
||||
error: {
|
||||
message: "One or more combo fields are invalid",
|
||||
details: [{ field: "config.compressionMode", message: "Invalid compression mode" }],
|
||||
},
|
||||
};
|
||||
assert.equal(resolveServerErrorMessage(body, FALLBACK), "Invalid compression mode");
|
||||
});
|
||||
|
||||
test("falls back to error.message when there are no details", () => {
|
||||
const body = { error: { message: "Combo not found" } };
|
||||
assert.equal(resolveServerErrorMessage(body, FALLBACK), "Combo not found");
|
||||
});
|
||||
|
||||
test("falls back to error.message when details[0] has no message", () => {
|
||||
const body = { error: { message: "Bad request", details: [{ field: "body" }] } };
|
||||
assert.equal(resolveServerErrorMessage(body, FALLBACK), "Bad request");
|
||||
});
|
||||
|
||||
test("returns fallback for null body (failed res.json())", () => {
|
||||
assert.equal(resolveServerErrorMessage(null, FALLBACK), FALLBACK);
|
||||
});
|
||||
|
||||
test("returns fallback for a body without an error object", () => {
|
||||
assert.equal(resolveServerErrorMessage({ ok: false }, FALLBACK), FALLBACK);
|
||||
assert.equal(resolveServerErrorMessage("nope", FALLBACK), FALLBACK);
|
||||
assert.equal(resolveServerErrorMessage({ error: "string-not-object" }, FALLBACK), FALLBACK);
|
||||
});
|
||||
|
||||
test("returns fallback when message fields are empty strings", () => {
|
||||
const body = { error: { message: "", details: [{ message: "" }] } };
|
||||
assert.equal(resolveServerErrorMessage(body, FALLBACK), FALLBACK);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests for GET /api/services/9router/models
|
||||
*
|
||||
* Uses an isolated in-memory DB and mocks fetch + service registry.
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } 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-9router-models-api-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
process.env.NINEROUTER_PORT = "20130";
|
||||
|
||||
// Must import db core first so the DB is initialised at the test path.
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const { saveServiceModels, getServiceModels } =
|
||||
await import("../../../../src/lib/db/serviceModels.ts");
|
||||
|
||||
// Import the route handler under test (after env is set).
|
||||
const { GET } = await import("../../../../src/app/api/services/9router/models/route.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url);
|
||||
}
|
||||
|
||||
function makeFetch(status: number, body: unknown): typeof fetch {
|
||||
return async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetDb();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("GET /api/services/9router/models", () => {
|
||||
it("returns { data: [] } when no models are stored", async () => {
|
||||
const req = makeRequest("http://localhost/api/services/9router/models");
|
||||
const res = await GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok("data" in body, "response should have a data property");
|
||||
assert.deepEqual(body.data, []);
|
||||
});
|
||||
|
||||
it("returns stored models as { data: [...] }", async () => {
|
||||
saveServiceModels("9router", [
|
||||
{ id: "9router/cx/gpt-5-mini", name: "GPT-5 mini", available: true },
|
||||
{ id: "9router/auto/sonnet", name: "Sonnet", available: true },
|
||||
]);
|
||||
|
||||
const req = makeRequest("http://localhost/api/services/9router/models");
|
||||
const res = await GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.data.length, 2);
|
||||
assert.equal(body.data[0].id, "9router/cx/gpt-5-mini");
|
||||
assert.equal(body.data[1].id, "9router/auto/sonnet");
|
||||
});
|
||||
|
||||
it("?refresh=true triggers a sync before returning", async () => {
|
||||
// Pre-seed with one model so we can tell that the sync ran.
|
||||
saveServiceModels("9router", [{ id: "9router/old-model", available: true }]);
|
||||
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = async () => {
|
||||
fetchCalled = true;
|
||||
return new Response(
|
||||
// Upstream returns an unprefixed id — sync should prefix it as "9router/new-model".
|
||||
JSON.stringify({ data: [{ id: "new-model", object: "model" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const req = makeRequest("http://localhost/api/services/9router/models?refresh=true");
|
||||
const res = await GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(fetchCalled, "?refresh=true should have triggered a fetch to the service");
|
||||
|
||||
const body = await res.json();
|
||||
// After sync, new model should be present prefixed (old model pruned to unavailable).
|
||||
const ids = body.data.map((m: { id: string }) => m.id);
|
||||
assert.ok(ids.includes("9router/new-model"), "synced model should appear in response");
|
||||
});
|
||||
|
||||
it("?refresh=false (default) does NOT trigger a sync", async () => {
|
||||
saveServiceModels("9router", [{ id: "9router/cached-model", available: true }]);
|
||||
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = async () => {
|
||||
fetchCalled = true;
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
};
|
||||
|
||||
const req = makeRequest("http://localhost/api/services/9router/models");
|
||||
const res = await GET(req);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(fetchCalled, false, "GET without ?refresh=true should NOT call fetch");
|
||||
});
|
||||
|
||||
it("response shape always has a data array", async () => {
|
||||
const req = makeRequest("http://localhost/api/services/9router/models");
|
||||
const res = await GET(req);
|
||||
const body = await res.json();
|
||||
|
||||
assert.ok(Array.isArray(body.data), "data must always be an array");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for POST /api/services/9router/provider-expose
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach } 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-9router-provider-expose-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
// Initialise DB first.
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const { upsertVersionManagerTool, getVersionManagerTool } =
|
||||
await import("../../../../src/lib/db/versionManager.ts");
|
||||
|
||||
// Import route under test.
|
||||
const { POST } = await import("../../../../src/app/api/services/9router/provider-expose/route.ts");
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/services/9router/provider-expose", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
describe("POST /api/services/9router/provider-expose", () => {
|
||||
it("returns 400 for invalid JSON body", async () => {
|
||||
const req = new Request("http://localhost/api/services/9router/provider-expose", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it("returns 400 when enabled is missing", async () => {
|
||||
const req = makeRequest({});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.ok(body?.error?.message ?? body?.message, "should have an error message");
|
||||
});
|
||||
|
||||
it("returns 400 when enabled is not a boolean", async () => {
|
||||
const req = makeRequest({ enabled: "yes" });
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it("sets providerExpose to true and returns 204", async () => {
|
||||
await upsertVersionManagerTool({ tool: "9router", status: "stopped" });
|
||||
|
||||
const req = makeRequest({ enabled: true });
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
const row = await getVersionManagerTool("9router");
|
||||
assert.equal(row?.providerExpose, true, "providerExpose should be true in DB");
|
||||
});
|
||||
|
||||
it("sets providerExpose to false and returns 204", async () => {
|
||||
await upsertVersionManagerTool({ tool: "9router", status: "stopped" });
|
||||
|
||||
// First enable it.
|
||||
await POST(makeRequest({ enabled: true }));
|
||||
|
||||
// Then disable it.
|
||||
const res = await POST(makeRequest({ enabled: false }));
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
const row = await getVersionManagerTool("9router");
|
||||
assert.equal(row?.providerExpose, false, "providerExpose should be false in DB");
|
||||
});
|
||||
|
||||
it("error response does not leak stack traces", async () => {
|
||||
// Missing body triggers 400 — verify the message doesn't contain stack frames.
|
||||
const req = makeRequest({});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 400);
|
||||
const text = await res.text();
|
||||
assert.ok(!text.includes("at /"), "error body must not expose stack trace");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for GET /api/services/9router/status?reveal=key (R-01)
|
||||
*
|
||||
* - Without ?reveal: returns only masked key
|
||||
* - With ?reveal=key but missing X-Reveal-Confirm: returns 403
|
||||
* - With ?reveal=key and X-Reveal-Confirm: yes: returns apiKeyPlain + logs audit
|
||||
*/
|
||||
|
||||
import { describe, it, before, after, beforeEach } 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-9router-reveal-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
// Bootstrap DB and seed service row
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('9router', 'stopped', 20130, 0, 1, 1)`
|
||||
).run();
|
||||
|
||||
const { updateVersionManagerTool } = await import("../../../../src/lib/db/versionManager.ts");
|
||||
await updateVersionManagerTool("9router", {
|
||||
installedVersion: "0.4.59",
|
||||
status: "stopped",
|
||||
});
|
||||
|
||||
// Ensure audit_log table exists (compliance init)
|
||||
const { initAuditLog, countAuditLog } = await import("../../../../src/lib/compliance/index.ts");
|
||||
initAuditLog();
|
||||
|
||||
// Import GET after env is set
|
||||
const { GET } =
|
||||
await import("../../../../src/app/api/services/9router/status/route.ts?t=reveal-suite");
|
||||
|
||||
function makeRequest(url: string, headers?: Record<string, string>): Request {
|
||||
return new Request(url, { headers });
|
||||
}
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /api/services/9router/status", () => {
|
||||
it("without ?reveal returns only masked key (no plain)", async () => {
|
||||
const req = makeRequest("http://localhost/api/services/9router/status");
|
||||
const res = await GET(req);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok("apiKeyMasked" in body, "should have apiKeyMasked");
|
||||
assert.ok(!("apiKeyPlain" in body), "should NOT have apiKeyPlain");
|
||||
});
|
||||
|
||||
it("?reveal=key without X-Reveal-Confirm returns 403", async () => {
|
||||
const req = makeRequest("http://localhost/api/services/9router/status?reveal=key");
|
||||
const res = await GET(req);
|
||||
assert.equal(res.status, 403);
|
||||
const body = await res.json();
|
||||
assert.ok(
|
||||
body.error?.message?.toLowerCase().includes("confirm") ||
|
||||
body.error?.message?.toLowerCase().includes("header") ||
|
||||
body.message?.toLowerCase().includes("confirm") ||
|
||||
String(body).toLowerCase().includes("confirm"),
|
||||
`body should mention confirmation: ${JSON.stringify(body)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("?reveal=key with wrong X-Reveal-Confirm value returns 403", async () => {
|
||||
const req = makeRequest("http://localhost/api/services/9router/status?reveal=key", {
|
||||
"X-Reveal-Confirm": "no",
|
||||
});
|
||||
const res = await GET(req);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it("?reveal=key with X-Reveal-Confirm: yes returns apiKeyPlain", async () => {
|
||||
const req = makeRequest("http://localhost/api/services/9router/status?reveal=key", {
|
||||
"X-Reveal-Confirm": "yes",
|
||||
});
|
||||
const res = await GET(req);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(typeof body.apiKeyPlain === "string", "should have apiKeyPlain string");
|
||||
assert.ok(body.apiKeyPlain.startsWith("nr_"), "plain key should start with nr_");
|
||||
// masked should also be present
|
||||
assert.ok("apiKeyMasked" in body, "should still have apiKeyMasked");
|
||||
});
|
||||
|
||||
it("reveal logs an audit entry with action=service.reveal_api_key", async () => {
|
||||
const countBefore = countAuditLog({ action: "service.reveal_api_key" });
|
||||
|
||||
const req = makeRequest("http://localhost/api/services/9router/status?reveal=key", {
|
||||
"X-Reveal-Confirm": "yes",
|
||||
});
|
||||
const res = await GET(req);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const countAfter = countAuditLog({ action: "service.reveal_api_key" });
|
||||
assert.equal(
|
||||
countAfter,
|
||||
countBefore + 1,
|
||||
`audit entries should increase by 1 (was ${countBefore}, now ${countAfter})`
|
||||
);
|
||||
});
|
||||
|
||||
it("plain key is different from masked key (not trivially masked)", async () => {
|
||||
const req = makeRequest("http://localhost/api/services/9router/status?reveal=key", {
|
||||
"X-Reveal-Confirm": "yes",
|
||||
});
|
||||
const res = await GET(req);
|
||||
const body = await res.json();
|
||||
assert.notEqual(body.apiKeyPlain, body.apiKeyMasked, "plain and masked should differ");
|
||||
assert.ok(
|
||||
body.apiKeyPlain.length > body.apiKeyMasked.length ||
|
||||
!body.apiKeyMasked.includes(body.apiKeyPlain),
|
||||
"masked should not contain the full plain key"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* T-012 — Settings PATCH audit log.
|
||||
*
|
||||
* Covers spec AC-9 / AC-10 / AC-11 (plus idempotent no-op case):
|
||||
* - AC-9 success diff row carries `action=settings.update`, target,
|
||||
* actor, ip, and per-key {before, after} diff for every changed key.
|
||||
* - AC-10 each rejection path (PASSWORD_REQUIRED, PASSWORD_MISMATCH,
|
||||
* BYPASS_PREFIX_NOT_ALLOWED, zod validation failure) writes a
|
||||
* `settings.update_failed` row with the matching `reason` code and
|
||||
* NEVER persists settings.
|
||||
* - AC-11 every changed key shows up in the success diff — not only
|
||||
* security-impacting keys.
|
||||
* - Idempotent PATCH (body matches stored state) writes NO row.
|
||||
*
|
||||
* Runs through the real PATCH handler + `setupSettingsFixture` mock so the
|
||||
* production `updateSettings → applyRuntimeSettings → logAuditEvent` pipeline
|
||||
* fires exactly as it does in deployment.
|
||||
*
|
||||
* INSUFFICIENT_SCOPE is intentionally NOT exercised here — per spec AC-13 it
|
||||
* is rejected by `requireManagementAuth` before the audit-aware handler body
|
||||
* runs, so no row is written.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupSettingsFixture } from "../_mocks/settings.ts";
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
// Allocate fixture FIRST so DATA_DIR is set before any DB import resolves.
|
||||
const fixture = setupSettingsFixture("settings-audit");
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
const settingsRoute = await import("../../../src/app/api/settings/route.ts");
|
||||
const compliance = await import("../../../src/lib/compliance/index.ts");
|
||||
const managementPassword = await import("../../../src/lib/auth/managementPassword.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await fixture.resetStorage();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fixture.cleanup();
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
});
|
||||
|
||||
async function bootstrapWithPassword(password: string): Promise<void> {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-settings-audit";
|
||||
process.env.INITIAL_PASSWORD = password;
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
await managementPassword.ensurePersistentManagementPasswordHash({
|
||||
source: "test.bootstrap",
|
||||
});
|
||||
}
|
||||
|
||||
function settingsRows() {
|
||||
// `getAuditLog`'s `AuditLogEntry[]` return type now exposes `action`,
|
||||
// `actor`, `target`, `status`, `details`, etc. directly — no local cast
|
||||
// needed. See src/lib/compliance/index.ts.
|
||||
return compliance.getAuditLog({ target: "settings", limit: 50 });
|
||||
}
|
||||
|
||||
// ─── AC-9 — success diff row written ──────────────────────────────────────
|
||||
|
||||
test("AC-9: successful PATCH writes settings.update with diff of changed keys", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac9");
|
||||
const before = await settingsDb.getSettings();
|
||||
assert.equal(before.localOnlyManageScopeBypassEnabled, true);
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "initial-pass-ac9",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const rows = settingsRows();
|
||||
const successRows = rows.filter((r) => r.action === "settings.update");
|
||||
assert.equal(successRows.length, 1, `expected 1 success row, got: ${JSON.stringify(rows)}`);
|
||||
const row = successRows[0];
|
||||
assert.equal(row.target, "settings");
|
||||
assert.equal(row.status, "success");
|
||||
assert.equal(row.resource_type, "settings");
|
||||
// Cookie session ⇒ actor=dashboard.
|
||||
assert.equal(row.actor, "dashboard");
|
||||
const details = row.details as { diff: Record<string, { before: unknown; after: unknown }> };
|
||||
assert.ok(details && typeof details === "object", "details must be parsed JSON");
|
||||
assert.ok(details.diff, "diff present");
|
||||
assert.deepEqual(details.diff.localOnlyManageScopeBypassEnabled, {
|
||||
before: true,
|
||||
after: false,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AC-10 — failure rows for each rejection path ────────────────────────
|
||||
|
||||
test("AC-10a: PASSWORD_REQUIRED failure writes settings.update_failed", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac10a");
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: { localOnlyManageScopeBypassEnabled: false },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { reason: string; attempted_keys: string[] };
|
||||
assert.equal(details.reason, "PASSWORD_REQUIRED");
|
||||
assert.ok(details.attempted_keys.includes("localOnlyManageScopeBypassEnabled"));
|
||||
// No raw payload values — only the key NAMES are recorded under
|
||||
// `attempted_keys`. There must be no `before`/`after` or `diff` block on a
|
||||
// failure row, and no other fields beyond reason+attempted_keys in details.
|
||||
assert.deepEqual(
|
||||
Object.keys(details).sort(),
|
||||
["attempted_keys", "reason"],
|
||||
"failure details must only contain reason + attempted_keys (no payload echo)"
|
||||
);
|
||||
|
||||
// Persisted state unchanged.
|
||||
const after = await settingsDb.getSettings();
|
||||
assert.equal(after.localOnlyManageScopeBypassEnabled, true);
|
||||
});
|
||||
|
||||
test("AC-10b: PASSWORD_MISMATCH failure writes settings.update_failed", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac10b");
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "definitely-wrong",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { reason: string; attempted_keys: string[] };
|
||||
assert.equal(details.reason, "PASSWORD_MISMATCH");
|
||||
// Password attempt MUST NOT leak — only key names.
|
||||
const serialized = JSON.stringify(rows[0]);
|
||||
assert.equal(
|
||||
serialized.includes("definitely-wrong"),
|
||||
false,
|
||||
"rejected currentPassword must not appear in audit row"
|
||||
);
|
||||
|
||||
const after = await settingsDb.getSettings();
|
||||
assert.equal(after.localOnlyManageScopeBypassEnabled, true);
|
||||
});
|
||||
|
||||
test("AC-10c: BYPASS_PREFIX_NOT_ALLOWED failure writes settings.update_failed", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac10c");
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/cli-tools/runtime/"],
|
||||
currentPassword: "initial-pass-ac10c",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { reason: string };
|
||||
assert.equal(details.reason, "BYPASS_PREFIX_NOT_ALLOWED");
|
||||
|
||||
// Snapshot untouched.
|
||||
const after = await settingsDb.getSettings();
|
||||
assert.deepEqual(after.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
|
||||
});
|
||||
|
||||
test("AC-10d: zod validation failure (wrong type) writes settings.update_failed", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac10d");
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: "definitely-not-a-boolean",
|
||||
currentPassword: "initial-pass-ac10d",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { reason: string };
|
||||
assert.equal(details.reason, "VALIDATION_FAILED");
|
||||
});
|
||||
|
||||
// AC-13 sanity: INSUFFICIENT_SCOPE rejection happens upstream in
|
||||
// requireManagementAuth and never reaches the handler body, so no audit row.
|
||||
// We cover it implicitly by NOT having an INSUFFICIENT_SCOPE failure test —
|
||||
// the route-level rejection is already covered by api-auth.test.ts.
|
||||
|
||||
// ─── AC-11 — diff covers every changed key (not only security keys) ──────
|
||||
|
||||
test("AC-11: diff records every changed key, including non-security keys", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac11");
|
||||
|
||||
// Seed an initial value for a non-security key so the diff is meaningful.
|
||||
await settingsDb.updateSettings({ theme: "light", instanceName: "before" });
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
theme: "dark",
|
||||
instanceName: "after",
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "initial-pass-ac11",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { diff: Record<string, { before: unknown; after: unknown }> };
|
||||
// Security key AND multiple non-security keys must all be in diff.
|
||||
assert.ok(details.diff.localOnlyManageScopeBypassEnabled, "security key in diff");
|
||||
assert.ok(details.diff.theme, "theme (non-security) in diff");
|
||||
assert.ok(details.diff.instanceName, "instanceName (non-security) in diff");
|
||||
assert.deepEqual(details.diff.theme, { before: "light", after: "dark" });
|
||||
assert.deepEqual(details.diff.instanceName, { before: "before", after: "after" });
|
||||
});
|
||||
|
||||
// ─── Idempotent no-op writes NO row ──────────────────────────────────────
|
||||
|
||||
test("idempotent PATCH (body matches current state) writes NO audit row", async () => {
|
||||
await bootstrapWithPassword("initial-pass-noop");
|
||||
|
||||
// Settings already at default — patch the same value back.
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: true, // same as default
|
||||
currentPassword: "initial-pass-noop",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const rows = settingsRows();
|
||||
assert.equal(
|
||||
rows.length,
|
||||
0,
|
||||
`idempotent PATCH must not emit an audit row, got: ${JSON.stringify(rows)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Multi-row sanity: success + failure sequence ─────────────────────────
|
||||
|
||||
test("sequence: failure then success produces exactly 1 failure row + 1 success row", async () => {
|
||||
await bootstrapWithPassword("initial-pass-seq");
|
||||
|
||||
// 1) failure
|
||||
await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: { localOnlyManageScopeBypassEnabled: false, currentPassword: "wrong" },
|
||||
})
|
||||
);
|
||||
// 2) success
|
||||
await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "initial-pass-seq",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const rows = settingsRows();
|
||||
const failures = rows.filter((r) => r.action === "settings.update_failed");
|
||||
const successes = rows.filter((r) => r.action === "settings.update");
|
||||
assert.equal(failures.length, 1);
|
||||
assert.equal(successes.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { test, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
selfFetchWithRetry,
|
||||
ensureLoopbackServerReady,
|
||||
__resetLoopbackReadinessForTests,
|
||||
} from "../../../src/app/api/providers/[id]/sync-models/route.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: retry succeeds on attempt 3
|
||||
// ---------------------------------------------------------------------------
|
||||
test("self-fetch retries with backoff and succeeds on attempt 3", async () => {
|
||||
let attempts = 0;
|
||||
const fetchMock: typeof fetch = async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error("fetch failed");
|
||||
}
|
||||
return new Response(JSON.stringify({ models: [{ id: "model-1" }] }), { status: 200 });
|
||||
};
|
||||
|
||||
let inProcCalls = 0;
|
||||
const inProcMock = async () => {
|
||||
inProcCalls++;
|
||||
return new Response(JSON.stringify({ models: [] }), { status: 200 });
|
||||
};
|
||||
|
||||
const result = await selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-1/models", {
|
||||
fetch: fetchMock,
|
||||
maxRetries: 5,
|
||||
backoffMs: 5,
|
||||
inProcessFallback: inProcMock,
|
||||
skipReadinessGate: true,
|
||||
});
|
||||
|
||||
assert.equal(attempts, 3, "should have retried twice before succeeding on attempt 3");
|
||||
assert.equal(inProcCalls, 0, "should not have called in-process route");
|
||||
assert.equal(result.ok, true, "response should be ok");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: falls back to in-process after maxRetries failures
|
||||
// ---------------------------------------------------------------------------
|
||||
test("self-fetch falls back to in-process route after maxRetries failures", async () => {
|
||||
let attempts = 0;
|
||||
const fetchMock: typeof fetch = async () => {
|
||||
attempts++;
|
||||
throw new Error("fetch failed");
|
||||
};
|
||||
|
||||
let inProcCalls = 0;
|
||||
const inProcMock = async () => {
|
||||
inProcCalls++;
|
||||
return new Response(JSON.stringify({ models: [{ id: "in-proc-model" }] }), { status: 200 });
|
||||
};
|
||||
|
||||
const result = await selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-2/models", {
|
||||
fetch: fetchMock,
|
||||
maxRetries: 3,
|
||||
backoffMs: 5,
|
||||
connectionId: "conn-2",
|
||||
inProcessFallback: inProcMock,
|
||||
skipReadinessGate: true,
|
||||
});
|
||||
|
||||
assert.equal(attempts, 3, "should retry exactly maxRetries times");
|
||||
assert.equal(inProcCalls, 1, "should fall back to in-process exactly once");
|
||||
const body = await result.json();
|
||||
assert.equal(body.models[0].id, "in-proc-model");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: HTTP error responses are returned as-is (no retry on HTTP errors)
|
||||
//
|
||||
// Retry contract: only network-level failures (ECONNREFUSED, "fetch failed")
|
||||
// are retried. HTTP responses (even 4xx/5xx) mean the server IS up and
|
||||
// returned an error that should be propagated as-is to the caller.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("self-fetch returns HTTP error responses immediately without retrying", async () => {
|
||||
// 5xx HTTP response: server is up but returned error
|
||||
{
|
||||
let attempts = 0;
|
||||
const fetchMock: typeof fetch = async () => {
|
||||
attempts++;
|
||||
return new Response("server error", { status: 503 });
|
||||
};
|
||||
const inProcMock = async () => new Response(JSON.stringify({ models: [] }), { status: 200 });
|
||||
|
||||
const res = await selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-3/models", {
|
||||
fetch: fetchMock,
|
||||
maxRetries: 5,
|
||||
backoffMs: 5,
|
||||
inProcessFallback: inProcMock,
|
||||
skipReadinessGate: true,
|
||||
});
|
||||
|
||||
assert.equal(attempts, 1, "5xx HTTP response should NOT retry (got " + attempts + ")");
|
||||
assert.equal(res.status, 503, "should propagate the 503 response as-is");
|
||||
}
|
||||
|
||||
// 4xx HTTP response: also returned immediately without retry
|
||||
{
|
||||
let attempts = 0;
|
||||
const fetchMock: typeof fetch = async () => {
|
||||
attempts++;
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
const inProcMock = async () => new Response(JSON.stringify({ models: [] }), { status: 200 });
|
||||
|
||||
const res = await selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-4/models", {
|
||||
fetch: fetchMock,
|
||||
maxRetries: 5,
|
||||
backoffMs: 5,
|
||||
inProcessFallback: inProcMock,
|
||||
skipReadinessGate: true,
|
||||
});
|
||||
|
||||
assert.equal(attempts, 1, "4xx HTTP response should NOT retry (got " + attempts + ")");
|
||||
assert.equal(res.status, 404, "should propagate the 404 response as-is");
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Readiness gate tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("ensureLoopbackServerReady: 17 concurrent callers trigger exactly ONE probe sequence", async () => {
|
||||
__resetLoopbackReadinessForTests();
|
||||
let probeCalls = 0;
|
||||
let serverIsUp = false;
|
||||
// Server becomes ready after 30ms
|
||||
setTimeout(() => {
|
||||
serverIsUp = true;
|
||||
}, 30);
|
||||
const mockFetch = async (_url) => {
|
||||
probeCalls++;
|
||||
if (!serverIsUp) throw new Error("fetch failed");
|
||||
return new Response("", { status: 200 });
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 17 }, () =>
|
||||
ensureLoopbackServerReady({ fetch: mockFetch, pollMs: 5, maxWaitMs: 1000 })
|
||||
)
|
||||
);
|
||||
|
||||
// Probe may have polled multiple times before server came up -- that is fine.
|
||||
// What matters: 17 concurrent callers share ONE probe sequence.
|
||||
// Expected: roughly (30ms / 5ms) = ~6 attempts, not 17 x 6 = 102.
|
||||
assert.ok(probeCalls <= 15, "single shared probe expected <=15 attempts, got " + probeCalls);
|
||||
});
|
||||
|
||||
test("ensureLoopbackServerReady: rejects after maxWaitMs with consistent network errors", async () => {
|
||||
__resetLoopbackReadinessForTests();
|
||||
const mockFetch = async (_url) => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
};
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLoopbackServerReady({
|
||||
fetch: mockFetch,
|
||||
maxWaitMs: 50,
|
||||
pollMs: 10,
|
||||
}),
|
||||
/loopback server not ready/
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureLoopbackServerReady: resolves on 4xx (any HTTP status confirms server is up)", async () => {
|
||||
__resetLoopbackReadinessForTests();
|
||||
let calls = 0;
|
||||
const mockFetch = async (_url) => {
|
||||
calls++;
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
// Should not throw: 404 means the server is dispatching
|
||||
await ensureLoopbackServerReady({ fetch: mockFetch, maxWaitMs: 500, pollMs: 10 });
|
||||
assert.equal(calls, 1, "resolved after exactly 1 probe (server immediately responded 404)");
|
||||
});
|
||||
|
||||
test("selfFetchWithRetry with gate: 17 concurrent callers produce one probe + one fetch each", async () => {
|
||||
__resetLoopbackReadinessForTests();
|
||||
let probeCalls = 0;
|
||||
let modelFetchCalls = 0;
|
||||
let serverIsUp = false;
|
||||
setTimeout(() => {
|
||||
serverIsUp = true;
|
||||
}, 30);
|
||||
|
||||
const mockFetch = async (url) => {
|
||||
const isReadinessProbe = url.includes("__readiness_probe__");
|
||||
if (isReadinessProbe) {
|
||||
probeCalls++;
|
||||
if (!serverIsUp) throw new Error("fetch failed");
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
modelFetchCalls++;
|
||||
if (!serverIsUp) throw new Error("fetch failed");
|
||||
return new Response(JSON.stringify({ models: [{ id: "model-x" }] }), { status: 200 });
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 17 }, (_, i) =>
|
||||
selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-" + i + "/models", {
|
||||
fetch: mockFetch,
|
||||
maxRetries: 3,
|
||||
backoffMs: 5,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// After readiness gate succeeds, each connection makes EXACTLY one model fetch
|
||||
assert.equal(modelFetchCalls, 17, "each connection fetches its own models exactly once");
|
||||
// Probe may have polled several times but NOT 17 x poll-count
|
||||
assert.ok(probeCalls <= 15, "probe should be shared, got " + probeCalls + " attempts");
|
||||
});
|
||||
|
||||
// Sanity check: disabling the gate shows amplification (verifies the gate is doing work)
|
||||
test("sanity: without readiness gate, 17 callers retry independently (amplification confirmed)", async () => {
|
||||
__resetLoopbackReadinessForTests();
|
||||
let modelFetchCalls = 0;
|
||||
let serverIsUp = false;
|
||||
setTimeout(() => {
|
||||
serverIsUp = true;
|
||||
}, 30);
|
||||
|
||||
const mockFetch = async (_url) => {
|
||||
modelFetchCalls++;
|
||||
if (!serverIsUp) throw new Error("fetch failed");
|
||||
return new Response(JSON.stringify({ models: [{ id: "model-x" }] }), { status: 200 });
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 17 }, (_, i) =>
|
||||
selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-" + i + "/models", {
|
||||
fetch: mockFetch,
|
||||
maxRetries: 5,
|
||||
backoffMs: 5,
|
||||
skipReadinessGate: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Without gate: each of the 17 callers retries independently during boot race.
|
||||
// Expect well above 17 total fetch attempts.
|
||||
assert.ok(
|
||||
modelFetchCalls > 17,
|
||||
"without gate, callers retry independently, got " + modelFetchCalls + " (expected >17)"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* Regression: issue #6405 — unknown /v1/* routes returned HTML dashboard 404
|
||||
* instead of a JSON error. Ensure the app-router catch-all under
|
||||
* `src/app/api/v1/[...omnirouteCatchAll]/route.ts` responds with a proper
|
||||
* OpenAI-compatible JSON not-found body for every HTTP method.
|
||||
*/
|
||||
|
||||
const catchAll = await import(
|
||||
"../../../src/app/api/v1/[...omnirouteCatchAll]/route.ts"
|
||||
);
|
||||
|
||||
function makeReq(pathname: string, method = "GET"): Request {
|
||||
return new Request(`http://localhost:20128${pathname}`, { method });
|
||||
}
|
||||
|
||||
test("v1 catchall returns application/json 404 with not_found error type on GET", async () => {
|
||||
const res = await catchAll.GET(makeReq("/v1/does-not-exist"));
|
||||
assert.equal(res.status, 404);
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
assert.ok(ct.includes("application/json"), `expected JSON content-type, got: ${ct}`);
|
||||
const body = (await res.json()) as {
|
||||
error: { type: string; message: string; code?: string; path?: string };
|
||||
};
|
||||
assert.equal(body.error.type, "not_found");
|
||||
assert.equal(body.error.path, "/v1/does-not-exist");
|
||||
assert.match(body.error.message, /Unknown API route/);
|
||||
});
|
||||
|
||||
test("v1 catchall returns JSON 404 on POST / PUT / PATCH / DELETE / HEAD", async () => {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE", "HEAD"] as const) {
|
||||
const res = await (catchAll as Record<string, (r: Request) => Promise<Response>>)[
|
||||
method
|
||||
](makeReq(`/v1/nope/${method.toLowerCase()}`, method));
|
||||
assert.equal(res.status, 404, `${method} status`);
|
||||
assert.ok(
|
||||
(res.headers.get("content-type") || "").includes("application/json"),
|
||||
`${method} content-type`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("v1 catchall OPTIONS preflight returns CORS headers", async () => {
|
||||
// Note: `Access-Control-Allow-Origin` is applied by the global middleware
|
||||
// (`src/middleware.ts`), not by handlers directly. The handler is only
|
||||
// responsible for the static methods/allowed-headers piece.
|
||||
const res = await catchAll.OPTIONS();
|
||||
assert.ok(
|
||||
res.headers.get("access-control-allow-methods"),
|
||||
"OPTIONS must expose CORS methods header",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const routePath = path.join(repoRoot, "src/app/api/v1/me/status/route.ts");
|
||||
|
||||
test("GET /api/v1/me/status rejects missing Bearer token in the handler", async () => {
|
||||
const route = await import("../../../src/app/api/v1/me/status/route.ts");
|
||||
|
||||
const response = await route.GET(new Request("http://localhost/api/v1/me/status"));
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("GET /api/v1/me/status derives identity from Bearer metadata and ignores query apiKeyId", () => {
|
||||
const source = fs.readFileSync(routePath, "utf8");
|
||||
|
||||
assert.match(source, /Authorization/);
|
||||
assert.match(source, /Bearer/);
|
||||
assert.match(source, /validateApiKey/);
|
||||
assert.match(source, /getApiKeyMetadata/);
|
||||
assert.match(source, /metadata\.id === "env-key"/);
|
||||
assert.doesNotMatch(source, /searchParams\.get\(["']apiKeyId["']\)/);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clearBifrostFailure,
|
||||
getActiveBifrostCooldown,
|
||||
getBifrostFailureCooldownMs,
|
||||
recordBifrostFailure,
|
||||
resetBifrostCooldowns,
|
||||
} from "../../../../src/app/api/v1/relay/chat/completions/bifrostCooldown.ts";
|
||||
|
||||
afterEach(() => {
|
||||
resetBifrostCooldowns();
|
||||
});
|
||||
|
||||
test("bifrost cooldown defaults to a short retry suppression window", () => {
|
||||
assert.equal(getBifrostFailureCooldownMs({}), 5000);
|
||||
assert.equal(
|
||||
getBifrostFailureCooldownMs({ OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS: "250" }),
|
||||
250
|
||||
);
|
||||
assert.equal(
|
||||
getBifrostFailureCooldownMs({ OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS: "bad" }),
|
||||
5000
|
||||
);
|
||||
});
|
||||
|
||||
test("bifrost cooldown reports remaining time and expires", () => {
|
||||
recordBifrostFailure("http://bifrost.local", "timeout", 1000, 500);
|
||||
|
||||
assert.deepEqual(getActiveBifrostCooldown("http://bifrost.local", 1100), {
|
||||
remainingMs: 400,
|
||||
reason: "timeout",
|
||||
});
|
||||
assert.equal(getActiveBifrostCooldown("http://bifrost.local", 1501), null);
|
||||
});
|
||||
|
||||
test("bifrost cooldown can be disabled or cleared", () => {
|
||||
recordBifrostFailure("http://bifrost.local", "timeout", 1000, 0);
|
||||
assert.equal(getActiveBifrostCooldown("http://bifrost.local", 1001), null);
|
||||
|
||||
recordBifrostFailure("http://bifrost.local", "timeout", 1000, 500);
|
||||
clearBifrostFailure("http://bifrost.local");
|
||||
|
||||
assert.equal(getActiveBifrostCooldown("http://bifrost.local", 1001), null);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "../../../../src/app/api/v1/provider-plugin-manifest/route.ts";
|
||||
|
||||
test("provider plugin manifest route returns JSON-safe manifest", async () => {
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Content-Type"), "application/json");
|
||||
assert.equal(body.schemaVersion, 1);
|
||||
assert.equal(body.generatedFrom, "open-sse/config/providers");
|
||||
assert.ok(body.providers.length > 100);
|
||||
assert.ok(body.providers.some((provider: { id: string }) => provider.id === "openai"));
|
||||
|
||||
const serialized = JSON.stringify(body);
|
||||
assert.equal(serialized.includes("clientSecret"), false);
|
||||
});
|
||||
|
||||
test("provider plugin manifest route handles CORS preflight", async () => {
|
||||
const response = await OPTIONS();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Methods"), "GET, OPTIONS");
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Headers"), "*");
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getBifrostRoutingConfig,
|
||||
getRoutingFallbackHeader,
|
||||
resolveRelayRoutingBackend,
|
||||
shouldTryBifrost,
|
||||
shouldTryBifrostForRequest,
|
||||
} from "../../../../src/app/api/v1/relay/chat/completions/routingBackend.ts";
|
||||
|
||||
test("relay routing backend defaults to TypeScript without bifrost", () => {
|
||||
const env = {};
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(getBifrostRoutingConfig(env), null);
|
||||
});
|
||||
|
||||
test("relay routing backend auto-enables bifrost when base URL is configured", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080/",
|
||||
OMNIROUTE_BIFROST_KEY: "sidecar-key",
|
||||
BIFROST_TIMEOUT_MS: "250",
|
||||
};
|
||||
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "auto");
|
||||
assert.equal(config?.baseUrl, "http://127.0.0.1:8080");
|
||||
assert.equal(config?.apiKey, "sidecar-key");
|
||||
assert.equal(config?.timeoutMs, 250);
|
||||
assert.equal(config?.streamingEnabled, true);
|
||||
assert.equal(config?.enabled, true);
|
||||
assert.equal(shouldTryBifrost("auto", config), true);
|
||||
});
|
||||
|
||||
test("relay routing backend honors explicit TS and strict bifrost modes", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
OMNIROUTE_RELAY_BACKEND: "ts",
|
||||
};
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(shouldTryBifrost("ts", config), false);
|
||||
|
||||
env.OMNIROUTE_RELAY_BACKEND = "bifrost";
|
||||
assert.equal(resolveRelayRoutingBackend(env), "bifrost");
|
||||
assert.equal(shouldTryBifrost("bifrost", config), true);
|
||||
});
|
||||
|
||||
test("relay routing backend respects bifrost killswitch", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
BIFROST_ENABLED: "0",
|
||||
};
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(config?.enabled, false);
|
||||
assert.equal(shouldTryBifrost("auto", config), false);
|
||||
});
|
||||
|
||||
test("relay routing backend falls back on invalid timeout values", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
BIFROST_TIMEOUT_MS: "not-a-number",
|
||||
};
|
||||
|
||||
assert.equal(getBifrostRoutingConfig(env)?.timeoutMs, 30000);
|
||||
});
|
||||
|
||||
test("relay routing backend exposes TS fallback header only for enabled auto bifrost fallback", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.equal(getRoutingFallbackHeader("auto", config), "bifrost");
|
||||
assert.equal(getRoutingFallbackHeader("ts", config), undefined);
|
||||
assert.equal(getRoutingFallbackHeader("bifrost", config), undefined);
|
||||
assert.equal(
|
||||
getRoutingFallbackHeader(
|
||||
"auto",
|
||||
getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
BIFROST_ENABLED: "0",
|
||||
})
|
||||
),
|
||||
undefined
|
||||
);
|
||||
assert.equal(getRoutingFallbackHeader("auto", null), undefined);
|
||||
});
|
||||
|
||||
test("relay routing backend keeps strict bifrost failures out of auto fallback accounting", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.equal(shouldTryBifrost("bifrost", config), true);
|
||||
assert.equal(getRoutingFallbackHeader("bifrost", config), undefined);
|
||||
assert.equal(resolveRelayRoutingBackend({ OMNIROUTE_RELAY_BACKEND: "bifrost" }), "bifrost");
|
||||
});
|
||||
|
||||
test("relay routing backend auto mode tries bifrost for manifest-eligible providers", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
shouldTryBifrostForRequest("auto", config, { model: "openai/gpt-4.1" }, () => ({
|
||||
eligible: true,
|
||||
reasons: [],
|
||||
})),
|
||||
{ tryBifrost: true }
|
||||
);
|
||||
});
|
||||
|
||||
test("relay routing backend auto mode skips bifrost for manifest-ineligible providers", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
shouldTryBifrostForRequest("auto", config, { model: "cw/claude-sonnet-4.6" }, () => ({
|
||||
eligible: false,
|
||||
reasons: ["custom executor: claude-web"],
|
||||
})),
|
||||
{ tryBifrost: false, fallbackReason: "bifrost-ineligible" }
|
||||
);
|
||||
});
|
||||
|
||||
test("relay routing backend auto mode keeps unknown providers on TS fallback", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
shouldTryBifrostForRequest("auto", config, { model: "unknown/model" }, () => null),
|
||||
{ tryBifrost: false, fallbackReason: "bifrost-provider-unknown" }
|
||||
);
|
||||
});
|
||||
|
||||
test("relay routing backend strict bifrost bypasses manifest eligibility", () => {
|
||||
const config = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
shouldTryBifrostForRequest("bifrost", config, { model: "cw/claude-sonnet-4.6" }, () => ({
|
||||
eligible: false,
|
||||
reasons: ["custom executor: claude-web"],
|
||||
})),
|
||||
{ tryBifrost: true }
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { finalizeReadableStream } from "../../../../src/app/api/v1/relay/chat/completions/streamFinalizer.ts";
|
||||
|
||||
test("finalizeReadableStream finalizes once after the wrapped stream completes", async () => {
|
||||
const finalized: unknown[] = [];
|
||||
const stream = finalizeReadableStream(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("hello"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
(error) => finalized.push(error)
|
||||
);
|
||||
|
||||
assert.equal(await new Response(stream).text(), "hello");
|
||||
assert.deepEqual(finalized, [undefined]);
|
||||
});
|
||||
|
||||
test("finalizeReadableStream finalizes once when the consumer cancels", async () => {
|
||||
const finalized: unknown[] = [];
|
||||
let cancelReason: unknown;
|
||||
const stream = finalizeReadableStream(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("chunk"));
|
||||
},
|
||||
cancel(reason) {
|
||||
cancelReason = reason;
|
||||
},
|
||||
}),
|
||||
(error) => finalized.push(error)
|
||||
);
|
||||
|
||||
const reader = stream.getReader();
|
||||
const first = await reader.read();
|
||||
assert.equal(new TextDecoder().decode(first.value), "chunk");
|
||||
|
||||
await reader.cancel("client disconnected");
|
||||
await reader.cancel("second cancel");
|
||||
|
||||
assert.equal(cancelReason, "client disconnected");
|
||||
assert.deepEqual(finalized, ["client disconnected"]);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { z } from "zod";
|
||||
import { validatedJsonBody } from "@/shared/validation/helpers";
|
||||
|
||||
function makeRequest(body: string, contentType = "application/json"): Request {
|
||||
return new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("validatedJsonBody", () => {
|
||||
const schema = z.object({
|
||||
name: z.string().min(1),
|
||||
count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
test("returns the parsed and validated data on success", async () => {
|
||||
const result = await validatedJsonBody(makeRequest('{"name":"hello","count":3}'), schema);
|
||||
assert.equal(result.success, true);
|
||||
if (result.success) {
|
||||
assert.deepEqual(result.data, { name: "hello", count: 3 });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a 400 with structured details when the body fails Zod validation", async () => {
|
||||
const result = await validatedJsonBody(makeRequest('{"name":"","count":-1}'), schema);
|
||||
assert.equal(result.success, false);
|
||||
if (!result.success) {
|
||||
assert.equal(result.response.status, 400);
|
||||
const body = await result.response.json();
|
||||
assert.equal(body.error.message, "Invalid request");
|
||||
assert.ok(Array.isArray(body.error.details));
|
||||
const fields = body.error.details.map((d: { field: string }) => d.field);
|
||||
assert.ok(fields.includes("name"));
|
||||
assert.ok(fields.includes("count"));
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a 400 with a body-parse failure for malformed JSON", async () => {
|
||||
const result = await validatedJsonBody(makeRequest("not json at all"), schema);
|
||||
assert.equal(result.success, false);
|
||||
if (!result.success) {
|
||||
assert.equal(result.response.status, 400);
|
||||
const body = await result.response.json();
|
||||
assert.deepEqual(body, {
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a 400 for an empty body", async () => {
|
||||
const result = await validatedJsonBody(makeRequest(""), schema);
|
||||
assert.equal(result.success, false);
|
||||
if (!result.success) {
|
||||
assert.equal(result.response.status, 400);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a 400 when required fields are missing entirely", async () => {
|
||||
const result = await validatedJsonBody(makeRequest("{}"), schema);
|
||||
assert.equal(result.success, false);
|
||||
if (!result.success) {
|
||||
assert.equal(result.response.status, 400);
|
||||
const body = await result.response.json();
|
||||
const fields = body.error.details.map((d: { field: string }) => d.field);
|
||||
assert.ok(fields.includes("name"));
|
||||
assert.ok(fields.includes("count"));
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves the same envelope shape between parse and validate failure", async () => {
|
||||
const parseFailure = await validatedJsonBody(makeRequest("nope"), schema);
|
||||
const validateFailure = await validatedJsonBody(makeRequest("{}"), schema);
|
||||
assert.equal(parseFailure.success, false);
|
||||
assert.equal(validateFailure.success, false);
|
||||
if (!parseFailure.success && !validateFailure.success) {
|
||||
const parseBody = await parseFailure.response.json();
|
||||
const validateBody = await validateFailure.response.json();
|
||||
assert.equal(typeof parseBody.error.message, "string");
|
||||
assert.equal(typeof validateBody.error.message, "string");
|
||||
assert.ok(Array.isArray(parseBody.error.details));
|
||||
assert.ok(Array.isArray(validateBody.error.details));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Tests for SSRF guard on the webhook URL surface:
|
||||
*
|
||||
* - POST /api/webhooks rejects custom/slack/discord webhooks targeting
|
||||
* loopback / RFC1918 / link-local hosts.
|
||||
* - PUT /api/webhooks/[id] rejects updates that would point a non-telegram
|
||||
* webhook at a blocked host.
|
||||
* - POST /api/webhooks/[id]/test refuses to perform the diagnostic fetch
|
||||
* when the persisted URL is blocked (defense in depth, in case a row
|
||||
* bypassed schema validation via direct DB access or older data).
|
||||
* - kind === "telegram" is exempt — `url` there is a Telegram chat_id, not
|
||||
* an HTTP URL — so the guard must not block it.
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } 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-webhook-ssrf-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const { updateSettings } = await import("../../../../src/lib/db/settings.ts");
|
||||
await updateSettings({ requireLogin: false });
|
||||
|
||||
const webhooksRoute = await import("../../../../src/app/api/webhooks/route.ts");
|
||||
const webhookByIdRoute = await import("../../../../src/app/api/webhooks/[id]/route.ts");
|
||||
const webhookTestRoute =
|
||||
await import("../../../../src/app/api/webhooks/[id]/test/route.ts?suite=ssrf-guard");
|
||||
const { createWebhook } = await import("../../../../src/lib/db/webhooks.ts");
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function jsonRequest(url: string, method: string, body: unknown): Request {
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/webhooks — SSRF guard on create", () => {
|
||||
it("rejects custom webhook pointing to 127.0.0.1", async () => {
|
||||
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
|
||||
url: "http://127.0.0.1:20128/api/v1/management/proxies",
|
||||
kind: "custom",
|
||||
});
|
||||
const res = await webhooksRoute.POST(req);
|
||||
assert.equal(res.status, 400, "loopback URL must be rejected");
|
||||
const body = await res.json();
|
||||
assert.ok(body.error, "error body must be present");
|
||||
});
|
||||
|
||||
it("rejects slack webhook pointing to link-local metadata host", async () => {
|
||||
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
|
||||
url: "http://169.254.169.254/latest/meta-data/iam",
|
||||
kind: "slack",
|
||||
});
|
||||
const res = await webhooksRoute.POST(req);
|
||||
assert.equal(res.status, 400, "169.254/16 must be rejected");
|
||||
});
|
||||
|
||||
it("rejects discord webhook pointing to RFC1918 (192.168/16)", async () => {
|
||||
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
|
||||
url: "http://192.168.0.15:8080/hook",
|
||||
kind: "discord",
|
||||
});
|
||||
const res = await webhooksRoute.POST(req);
|
||||
assert.equal(res.status, 400, "RFC1918 must be rejected");
|
||||
});
|
||||
|
||||
it("rejects discord webhook with embedded credentials", async () => {
|
||||
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
|
||||
url: "https://user:pass@example.com/hook",
|
||||
kind: "discord",
|
||||
});
|
||||
const res = await webhooksRoute.POST(req);
|
||||
assert.equal(res.status, 400, "embedded credentials must be rejected");
|
||||
});
|
||||
|
||||
it("rejects discord webhook with non-http(s) protocol", async () => {
|
||||
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
|
||||
url: "file:///etc/passwd",
|
||||
kind: "discord",
|
||||
});
|
||||
const res = await webhooksRoute.POST(req);
|
||||
assert.equal(res.status, 400, "non-http protocol must be rejected");
|
||||
});
|
||||
|
||||
it("accepts public https webhook", async () => {
|
||||
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
|
||||
url: "https://example.com/hook",
|
||||
kind: "custom",
|
||||
});
|
||||
const res = await webhooksRoute.POST(req);
|
||||
assert.equal(res.status, 201, "public URL must be accepted");
|
||||
});
|
||||
|
||||
it("does not trip the SSRF guard for telegram kind (url is a chat_id, not HTTP)", async () => {
|
||||
const req = jsonRequest("http://localhost/api/webhooks", "POST", {
|
||||
url: "@my-telegram-channel",
|
||||
kind: "telegram",
|
||||
});
|
||||
const res = await webhooksRoute.POST(req);
|
||||
// Telegram may still be rejected for unrelated reasons (e.g., storage
|
||||
// encryption not configured). What matters is that the rejection is NOT
|
||||
// caused by the SSRF/URL guard refinement.
|
||||
if (res.status === 400) {
|
||||
const body = await res.json();
|
||||
const msg = JSON.stringify(body).toLowerCase();
|
||||
assert.ok(
|
||||
!/block|private|invalid outbound url|outbound url/.test(msg),
|
||||
`telegram chat_id must not trip the URL guard: ${msg}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/webhooks/[id] — SSRF guard on update", () => {
|
||||
it("rejects an update that flips a webhook URL to loopback", async () => {
|
||||
const created = createWebhook({
|
||||
url: "https://example.com/initial",
|
||||
events: ["*"],
|
||||
kind: "custom",
|
||||
});
|
||||
|
||||
const req = jsonRequest(`http://localhost/api/webhooks/${created.id}`, "PUT", {
|
||||
url: "http://localhost/internal",
|
||||
});
|
||||
const res = await webhookByIdRoute.PUT(req, {
|
||||
params: Promise.resolve({ id: created.id }),
|
||||
});
|
||||
assert.equal(res.status, 400, "PUT must reject loopback host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/webhooks/[id]/test — defense in depth on dispatch", () => {
|
||||
it("does not exfiltrate response body when persisted URL is loopback", async () => {
|
||||
// Bypass schema by inserting directly via the DB layer.
|
||||
const stale = createWebhook({
|
||||
url: "http://127.0.0.1:1/should-be-blocked",
|
||||
events: ["*"],
|
||||
kind: "custom",
|
||||
});
|
||||
|
||||
const req = new Request(`http://localhost/api/webhooks/${stale.id}/test`, { method: "POST" });
|
||||
const res = await webhookTestRoute.POST(req, {
|
||||
params: Promise.resolve({ id: stale.id }),
|
||||
});
|
||||
assert.equal(res.status, 200, "test endpoint still returns a structured diagnostic envelope");
|
||||
const body = await res.json();
|
||||
assert.equal(body.delivered, false, "blocked URL must not report a successful delivery");
|
||||
assert.equal(body.status, 0, "no upstream status from a guarded call");
|
||||
assert.equal(body.responseBody, "", "no upstream body must be exfiltrated");
|
||||
assert.ok(
|
||||
typeof body.error === "string" && /block|private|invalid/i.test(body.error),
|
||||
`error message must signal the block: ${body.error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user