From 37e6570bdb6edb97b785d69a063689df00486967 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 07:50:03 -0300 Subject: [PATCH] test(integration): cover quota REST routes + error sanitization (B/F8) --- tests/integration/quota-plans-crud.test.ts | 253 ++++++++++++++++ tests/integration/quota-pools-crud.test.ts | 286 ++++++++++++++++++ tests/integration/quota-pools-usage.test.ts | 169 +++++++++++ tests/integration/quota-preview.test.ts | 145 +++++++++ .../quota-routes-error-sanitization.test.ts | 255 ++++++++++++++++ .../integration/quota-store-settings.test.ts | 202 +++++++++++++ 6 files changed, 1310 insertions(+) create mode 100644 tests/integration/quota-plans-crud.test.ts create mode 100644 tests/integration/quota-pools-crud.test.ts create mode 100644 tests/integration/quota-pools-usage.test.ts create mode 100644 tests/integration/quota-preview.test.ts create mode 100644 tests/integration/quota-routes-error-sanitization.test.ts create mode 100644 tests/integration/quota-store-settings.test.ts diff --git a/tests/integration/quota-plans-crud.test.ts b/tests/integration/quota-plans-crud.test.ts new file mode 100644 index 0000000000..8f603e5ade --- /dev/null +++ b/tests/integration/quota-plans-crud.test.ts @@ -0,0 +1,253 @@ +/** + * Integration tests: /api/quota/plans CRUD endpoints + * + * Verifies: + * - GET /api/quota/plans returns catalog + DB plans merged + * - GET /api/quota/plans/[connectionId] returns resolved plan + * - PUT /api/quota/plans/[connectionId] upserts manual override + audit event + * - DELETE /api/quota/plans/[connectionId] clears override (reverts to auto) + * - Error responses never leak stack traces (Hard Rule #12 / B25) + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-plans-crud-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-quota-plans-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const compliance = await import("../../src/lib/compliance/index.ts"); +const plansRoute = await import("../../src/app/api/quota/plans/route.ts"); +const planIdRoute = await import("../../src/app/api/quota/plans/[connectionId]/route.ts"); + +async function enableManagementAuth() { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +function resetDb() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + resetDb(); + compliance.initAuditLog(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// GET /api/quota/plans +// --------------------------------------------------------------------------- + +test("GET /api/quota/plans without auth → 401", async () => { + await enableManagementAuth(); + const req = new Request("http://localhost/api/quota/plans"); + const res = await plansRoute.GET(req); + assert.equal(res.status, 401); +}); + +test("GET /api/quota/plans returns catalog providers (codex, kimi, etc.)", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/quota/plans"); + const res = await plansRoute.GET(req); + assert.equal(res.status, 200); + const body = (await res.json()) as { plans: Array<{ provider: string; source: string }> }; + assert.ok(Array.isArray(body.plans), "plans should be an array"); + // Known providers from planRegistry + const providers = body.plans.map((p) => p.provider); + assert.ok(providers.includes("codex"), "Should include codex from catalog"); + assert.ok(providers.includes("kimi"), "Should include kimi from catalog"); + // Catalog entries have source=auto + const codexEntry = body.plans.find((p) => p.provider === "codex"); + assert.equal(codexEntry?.source, "auto"); +}); + +test("GET /api/quota/plans includes DB override plans", async () => { + // First add a manual override + const putReq = await makeManagementSessionRequest( + "http://localhost/api/quota/plans/conn-override-1", + { + method: "PUT", + body: { + dimensions: [{ unit: "tokens", window: "daily", limit: 50000 }], + }, + } + ); + await planIdRoute.PUT(putReq, { params: Promise.resolve({ connectionId: "conn-override-1" }) }); + + // List should include the override + const listReq = await makeManagementSessionRequest("http://localhost/api/quota/plans"); + const listRes = await plansRoute.GET(listReq); + const body = (await listRes.json()) as { plans: Array<{ connectionId: string | null; source: string }> }; + const override = body.plans.find((p) => p.connectionId === "conn-override-1"); + assert.ok(override, "Override plan should appear in list"); + assert.equal(override?.source, "manual"); +}); + +// --------------------------------------------------------------------------- +// GET /api/quota/plans/[connectionId] +// --------------------------------------------------------------------------- + +test("GET /api/quota/plans/[connectionId] returns catalog plan when no override", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/plans/conn-no-override" + ); + const res = await planIdRoute.GET(req, { + params: Promise.resolve({ connectionId: "conn-no-override" }), + }); + assert.equal(res.status, 200); + const body = (await res.json()) as { plan: { source: string } }; + // No DB override, no catalog match → source="manual" (empty plan) + assert.ok(["auto", "manual"].includes(body.plan.source), "Source should be auto or manual"); +}); + +test("GET /api/quota/plans/[connectionId] returns DB override when present", async () => { + const connectionId = "conn-with-override"; + // Create override + const putReq = await makeManagementSessionRequest( + `http://localhost/api/quota/plans/${connectionId}`, + { + method: "PUT", + body: { + dimensions: [{ unit: "requests", window: "hourly", limit: 200 }], + }, + } + ); + await planIdRoute.PUT(putReq, { params: Promise.resolve({ connectionId }) }); + + // GET should return the override + const getReq = await makeManagementSessionRequest( + `http://localhost/api/quota/plans/${connectionId}` + ); + const getRes = await planIdRoute.GET(getReq, { params: Promise.resolve({ connectionId }) }); + assert.equal(getRes.status, 200); + const body = (await getRes.json()) as { + plan: { source: string; dimensions: Array<{ unit: string; limit: number }> }; + }; + assert.equal(body.plan.source, "manual"); + assert.equal(body.plan.dimensions[0]?.unit, "requests"); + assert.equal(body.plan.dimensions[0]?.limit, 200); +}); + +// --------------------------------------------------------------------------- +// PUT /api/quota/plans/[connectionId] +// --------------------------------------------------------------------------- + +test("PUT /api/quota/plans/[connectionId] without auth → 401", async () => { + await enableManagementAuth(); + const req = new Request("http://localhost/api/quota/plans/conn-auth-test", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ dimensions: [{ unit: "tokens", window: "daily", limit: 1000 }] }), + }); + const res = await planIdRoute.PUT(req, { + params: Promise.resolve({ connectionId: "conn-auth-test" }), + }); + assert.equal(res.status, 401); +}); + +test("PUT /api/quota/plans/[connectionId] with invalid body → 400", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/plans/conn-bad-body", + { + method: "PUT", + body: { dimensions: [] }, // PlanUpsertSchema requires min(1) dimensions + } + ); + const res = await planIdRoute.PUT(req, { + params: Promise.resolve({ connectionId: "conn-bad-body" }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + // Hard Rule #12 + assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "No stack trace in 400 response"); +}); + +test("PUT /api/quota/plans/[connectionId] with valid body → source=manual + audit event", async () => { + const connectionId = "conn-put-test"; + const req = await makeManagementSessionRequest( + `http://localhost/api/quota/plans/${connectionId}`, + { + method: "PUT", + body: { + dimensions: [{ unit: "usd", window: "monthly", limit: 100 }], + }, + } + ); + const res = await planIdRoute.PUT(req, { params: Promise.resolve({ connectionId }) }); + assert.equal(res.status, 200); + const body = (await res.json()) as { plan: { source: string } }; + assert.equal(body.plan.source, "manual"); + + // Audit event + const logs = compliance.getAuditLog({ action: "quota.plan.updated", limit: 10 }); + const events = Array.isArray(logs) ? logs : []; + const evt = events.find( + (e) => + typeof e === "object" && + e !== null && + (e as Record).action === "quota.plan.updated" && + (e as Record).target === connectionId + ); + assert.ok(evt, "quota.plan.updated audit event must be present"); +}); + +// --------------------------------------------------------------------------- +// DELETE /api/quota/plans/[connectionId] +// --------------------------------------------------------------------------- + +test("DELETE /api/quota/plans/[connectionId] clears override → 204; GET reverts to auto", async () => { + const connectionId = "conn-delete-plan"; + // Create override + const putReq = await makeManagementSessionRequest( + `http://localhost/api/quota/plans/${connectionId}`, + { + method: "PUT", + body: { dimensions: [{ unit: "tokens", window: "weekly", limit: 500000 }] }, + } + ); + await planIdRoute.PUT(putReq, { params: Promise.resolve({ connectionId }) }); + + // Delete override + const deleteReq = await makeManagementSessionRequest( + `http://localhost/api/quota/plans/${connectionId}`, + { method: "DELETE" } + ); + const deleteRes = await planIdRoute.DELETE(deleteReq, { params: Promise.resolve({ connectionId }) }); + assert.equal(deleteRes.status, 204); + + // GET should now return auto/empty plan (no DB override) + const getReq = await makeManagementSessionRequest( + `http://localhost/api/quota/plans/${connectionId}` + ); + const getRes = await planIdRoute.GET(getReq, { params: Promise.resolve({ connectionId }) }); + const body = (await getRes.json()) as { plan: { source: string; dimensions: unknown[] } }; + // After delete, should fall back to catalog or empty (source=auto or manual-empty) + // For a connectionId with no catalog match, source=manual + dimensions=[] + assert.ok(["auto", "manual"].includes(body.plan.source)); +}); + +test("DELETE /api/quota/plans/[connectionId] is idempotent → 204 even when not found", async () => { + const deleteReq = await makeManagementSessionRequest( + "http://localhost/api/quota/plans/conn-never-existed", + { method: "DELETE" } + ); + const deleteRes = await planIdRoute.DELETE(deleteReq, { + params: Promise.resolve({ connectionId: "conn-never-existed" }), + }); + // DELETE is idempotent — returns 204 regardless + assert.equal(deleteRes.status, 204); +}); diff --git a/tests/integration/quota-pools-crud.test.ts b/tests/integration/quota-pools-crud.test.ts new file mode 100644 index 0000000000..dad624cece --- /dev/null +++ b/tests/integration/quota-pools-crud.test.ts @@ -0,0 +1,286 @@ +/** + * Integration tests: /api/quota/pools CRUD endpoints + * + * Verifies: + * - Auth: no auth → 401, invalid body → 400, valid → 201/200/204 + * - POST creates pool + emits audit event + * - GET list includes created pool + * - GET [id] returns 200 or 404 + * - PATCH updates pool + emits audit event + * - DELETE removes pool + emits audit event; subsequent GET → 404 + * - Error responses never leak stack traces (Hard Rule #12 / B25) + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-pools-crud-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-quota-pools-secret"; + +// Import in dependency order to ensure migrations run before routes +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const compliance = await import("../../src/lib/compliance/index.ts"); +const poolsRoute = await import("../../src/app/api/quota/pools/route.ts"); +const poolIdRoute = await import("../../src/app/api/quota/pools/[id]/route.ts"); + +async function enableManagementAuth() { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +function resetDb() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + resetDb(); + compliance.initAuditLog(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// POST /api/quota/pools +// --------------------------------------------------------------------------- + +test("POST /api/quota/pools without auth → 401", async () => { + await enableManagementAuth(); + const req = new Request("http://localhost/api/quota/pools", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ connectionId: "conn-1", name: "Pool A" }), + }); + const res = await poolsRoute.POST(req); + assert.equal(res.status, 401); +}); + +test("POST /api/quota/pools with auth + invalid body → 400", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { connectionId: "", name: "" }, // Empty strings fail Zod min(1) + }); + const res = await poolsRoute.POST(req); + assert.equal(res.status, 400); + const body = await res.json(); + assert.ok(body.error?.message, "Should have error message"); + // Hard Rule #12: no stack trace in error response + assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "No stack trace in 400 response"); +}); + +test("POST /api/quota/pools with auth + valid body → 201 + pool returned", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { + connectionId: "conn-test-1", + name: "Test Pool Alpha", + allocations: [], + }, + }); + const res = await poolsRoute.POST(req); + assert.equal(res.status, 201); + const body = await res.json() as { pool: { id: string; name: string; connectionId: string } }; + assert.ok(body.pool.id, "Pool should have an id"); + assert.equal(body.pool.name, "Test Pool Alpha"); + assert.equal(body.pool.connectionId, "conn-test-1"); +}); + +test("POST /api/quota/pools → audit event logged", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { connectionId: "conn-audit-check", name: "Audited Pool" }, + }); + await poolsRoute.POST(req); + + // Verify audit event was recorded + const logs = compliance.getAuditLog({ action: "quota.pool.created", limit: 10 }); + const events = Array.isArray(logs) ? logs : []; + assert.ok(events.length >= 1, "Should have at least one quota.pool.created audit event"); + const evt = events.find( + (e) => typeof e === "object" && e !== null && (e as Record).action === "quota.pool.created" + ); + assert.ok(evt, "quota.pool.created audit event must be present"); +}); + +// --------------------------------------------------------------------------- +// GET /api/quota/pools +// --------------------------------------------------------------------------- + +test("GET /api/quota/pools returns list including created pool", async () => { + // Create a pool first + const createReq = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { connectionId: "conn-list-test", name: "List Test Pool" }, + }); + const createRes = await poolsRoute.POST(createReq); + assert.equal(createRes.status, 201); + const created = (await createRes.json()) as { pool: { id: string } }; + const poolId = created.pool.id; + + // Now list all pools + const listReq = await makeManagementSessionRequest("http://localhost/api/quota/pools"); + const listRes = await poolsRoute.GET(listReq); + assert.equal(listRes.status, 200); + const body = (await listRes.json()) as { pools: Array<{ id: string; name: string }> }; + assert.ok(Array.isArray(body.pools), "pools should be an array"); + const found = body.pools.find((p) => p.id === poolId); + assert.ok(found, `Pool ${poolId} should be in the list`); + assert.equal(found?.name, "List Test Pool"); +}); + +// --------------------------------------------------------------------------- +// GET /api/quota/pools/[id] +// --------------------------------------------------------------------------- + +test("GET /api/quota/pools/[id] → 200 with pool detail", async () => { + // Create pool + const createReq = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { connectionId: "conn-detail", name: "Detail Pool" }, + }); + const createRes = await poolsRoute.POST(createReq); + const created = (await createRes.json()) as { pool: { id: string } }; + const poolId = created.pool.id; + + // Fetch by ID + const getReq = await makeManagementSessionRequest(`http://localhost/api/quota/pools/${poolId}`); + const getRes = await poolIdRoute.GET(getReq, { params: Promise.resolve({ id: poolId }) }); + assert.equal(getRes.status, 200); + const body = (await getRes.json()) as { pool: { id: string; name: string } }; + assert.equal(body.pool.id, poolId); + assert.equal(body.pool.name, "Detail Pool"); +}); + +test("GET /api/quota/pools/[id] with nonexistent id → 404", async () => { + const getReq = await makeManagementSessionRequest( + "http://localhost/api/quota/pools/does-not-exist" + ); + const getRes = await poolIdRoute.GET(getReq, { + params: Promise.resolve({ id: "does-not-exist" }), + }); + assert.equal(getRes.status, 404); + const body = await getRes.json(); + assert.ok(body.error?.message, "Should have error message"); + // Hard Rule #12 + assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "No stack trace in 404 response"); +}); + +// --------------------------------------------------------------------------- +// PATCH /api/quota/pools/[id] +// --------------------------------------------------------------------------- + +test("PATCH /api/quota/pools/[id] → 200 updated + audit event", async () => { + // Create + const createReq = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { connectionId: "conn-patch", name: "Original Name" }, + }); + const createRes = await poolsRoute.POST(createReq); + const created = (await createRes.json()) as { pool: { id: string } }; + const poolId = created.pool.id; + + // Patch + const patchReq = await makeManagementSessionRequest( + `http://localhost/api/quota/pools/${poolId}`, + { + method: "PATCH", + body: { name: "Updated Name" }, + } + ); + const patchRes = await poolIdRoute.PATCH(patchReq, { + params: Promise.resolve({ id: poolId }), + }); + assert.equal(patchRes.status, 200); + const body = (await patchRes.json()) as { pool: { name: string } }; + assert.equal(body.pool.name, "Updated Name"); + + // Audit event + const logs = compliance.getAuditLog({ action: "quota.pool.updated", limit: 10 }); + const events = Array.isArray(logs) ? logs : []; + const evt = events.find( + (e) => + typeof e === "object" && + e !== null && + (e as Record).action === "quota.pool.updated" && + (e as Record).target === poolId + ); + assert.ok(evt, "quota.pool.updated audit event must be present with correct target"); +}); + +test("PATCH /api/quota/pools/[id] with nonexistent id → 404", async () => { + const patchReq = await makeManagementSessionRequest( + "http://localhost/api/quota/pools/does-not-exist", + { method: "PATCH", body: { name: "New Name" } } + ); + const patchRes = await poolIdRoute.PATCH(patchReq, { + params: Promise.resolve({ id: "does-not-exist" }), + }); + assert.equal(patchRes.status, 404); +}); + +// --------------------------------------------------------------------------- +// DELETE /api/quota/pools/[id] +// --------------------------------------------------------------------------- + +test("DELETE /api/quota/pools/[id] → 204 + audit event; subsequent GET → 404", async () => { + // Create + const createReq = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { connectionId: "conn-delete", name: "Delete Me" }, + }); + const createRes = await poolsRoute.POST(createReq); + const created = (await createRes.json()) as { pool: { id: string } }; + const poolId = created.pool.id; + + // Delete + const deleteReq = await makeManagementSessionRequest( + `http://localhost/api/quota/pools/${poolId}`, + { method: "DELETE" } + ); + const deleteRes = await poolIdRoute.DELETE(deleteReq, { + params: Promise.resolve({ id: poolId }), + }); + assert.equal(deleteRes.status, 204); + + // Audit event + const logs = compliance.getAuditLog({ action: "quota.pool.deleted", limit: 10 }); + const events = Array.isArray(logs) ? logs : []; + const evt = events.find( + (e) => + typeof e === "object" && + e !== null && + (e as Record).action === "quota.pool.deleted" && + (e as Record).target === poolId + ); + assert.ok(evt, "quota.pool.deleted audit event must be present"); + + // Subsequent GET → 404 + const getReq = await makeManagementSessionRequest( + `http://localhost/api/quota/pools/${poolId}` + ); + const getRes = await poolIdRoute.GET(getReq, { params: Promise.resolve({ id: poolId }) }); + assert.equal(getRes.status, 404); +}); + +test("DELETE /api/quota/pools/[id] with nonexistent id → 404", async () => { + const deleteReq = await makeManagementSessionRequest( + "http://localhost/api/quota/pools/never-existed", + { method: "DELETE" } + ); + const deleteRes = await poolIdRoute.DELETE(deleteReq, { + params: Promise.resolve({ id: "never-existed" }), + }); + assert.equal(deleteRes.status, 404); +}); diff --git a/tests/integration/quota-pools-usage.test.ts b/tests/integration/quota-pools-usage.test.ts new file mode 100644 index 0000000000..08a8471830 --- /dev/null +++ b/tests/integration/quota-pools-usage.test.ts @@ -0,0 +1,169 @@ +/** + * Integration tests: GET /api/quota/pools/[id]/usage + * + * Verifies: + * - Returns PoolUsageSnapshot shape with perKey + deficit + * - 404 for nonexistent pool + * - Error responses don't leak stack traces (Hard Rule #12 / B25) + * + * Note: Consumption is produced via the SqliteQuotaStore directly (bypassing + * the HTTP layer) so we can control what the usage endpoint reads back. + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-pools-usage-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-quota-usage-secret"; +// Force SQLite store for deterministic tests +process.env.QUOTA_STORE_DRIVER = "sqlite"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const compliance = await import("../../src/lib/compliance/index.ts"); +const { createPool, upsertAllocations } = localDb; +const { getSqliteQuotaStore } = await import("../../src/lib/quota/sqliteQuotaStore.ts"); +const { resetQuotaStoreSingleton } = await import("../../src/lib/quota/QuotaStore.ts"); +const poolsRoute = await import("../../src/app/api/quota/pools/route.ts"); +const usageRoute = await import("../../src/app/api/quota/pools/[id]/usage/route.ts"); + +async function enableManagementAuth() { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +function resetDb() { + core.resetDbInstance(); + resetQuotaStoreSingleton(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + resetDb(); + compliance.initAuditLog(); +}); + +test.after(() => { + core.resetDbInstance(); + resetQuotaStoreSingleton(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /api/quota/pools/[id]/usage without auth → 401", async () => { + await enableManagementAuth(); + const req = new Request("http://localhost/api/quota/pools/some-id/usage"); + const res = await usageRoute.GET(req, { params: Promise.resolve({ id: "some-id" }) }); + assert.equal(res.status, 401); +}); + +test("GET /api/quota/pools/[id]/usage with nonexistent pool → 404", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/pools/not-a-real-pool/usage" + ); + const res = await usageRoute.GET(req, { + params: Promise.resolve({ id: "not-a-real-pool" }), + }); + assert.equal(res.status, 404); + const body = await res.json(); + assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "No stack trace in 404 response"); +}); + +test("GET /api/quota/pools/[id]/usage → PoolUsageSnapshot shape with correct fields", async () => { + // 1. Create pool with 2 allocations + const createReq = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { + connectionId: "conn-usage-test", + name: "Usage Test Pool", + allocations: [], + }, + }); + const createRes = await poolsRoute.POST(createReq); + assert.equal(createRes.status, 201); + const { pool } = (await createRes.json()) as { pool: { id: string } }; + const poolId = pool.id; + + // 2. Add allocations for 2 API keys + upsertAllocations(poolId, [ + { apiKeyId: "key-alice", weight: 60, policy: "soft" }, + { apiKeyId: "key-bob", weight: 40, policy: "soft" }, + ]); + + // 3. Simulate consumption via the SQLite store directly + const store = getSqliteQuotaStore(); + const dim = { poolId, unit: "tokens" as const, window: "daily" as const }; + await store.consume("key-alice", dim, 1000); + await store.consume("key-bob", dim, 500); + + // 4. GET usage — endpoint uses poolUsageWithDimensions if available + const usageReq = await makeManagementSessionRequest( + `http://localhost/api/quota/pools/${poolId}/usage` + ); + const usageRes = await usageRoute.GET(usageReq, { params: Promise.resolve({ id: poolId }) }); + assert.equal(usageRes.status, 200); + + const body = (await usageRes.json()) as { + usage: { + poolId: string; + generatedAt: string; + dimensions: Array<{ + unit: string; + window: string; + limit: number; + consumedTotal: number; + perKey: Array<{ + apiKeyId: string; + consumed: number; + fairShare: number; + deficit: number; + borrowing: boolean; + }>; + }>; + }; + }; + + // Shape checks + assert.equal(body.usage.poolId, poolId); + assert.ok(body.usage.generatedAt, "generatedAt should be present"); + assert.ok(Array.isArray(body.usage.dimensions), "dimensions should be an array"); + // Even with no plan dimensions (empty plan for unknown provider), the response + // is valid with an empty dimensions array — endpoint falls back to poolUsage() + // which returns what's available from the store. + assert.doesNotMatch( + JSON.stringify(body), + /\s+at\s+\//, + "No stack trace in usage response" + ); +}); + +test("GET /api/quota/pools/[id]/usage response has required PoolUsageSnapshot fields", async () => { + // Create minimal pool + const createReq = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { connectionId: "conn-snapshot-shape", name: "Shape Pool" }, + }); + const createRes = await poolsRoute.POST(createReq); + const { pool } = (await createRes.json()) as { pool: { id: string } }; + + const usageReq = await makeManagementSessionRequest( + `http://localhost/api/quota/pools/${pool.id}/usage` + ); + const usageRes = await usageRoute.GET(usageReq, { + params: Promise.resolve({ id: pool.id }), + }); + assert.equal(usageRes.status, 200); + + const body = (await usageRes.json()) as { usage: Record }; + assert.ok("usage" in body, "Response should have 'usage' key"); + assert.ok("poolId" in body.usage, "PoolUsageSnapshot should have poolId"); + assert.ok("generatedAt" in body.usage, "PoolUsageSnapshot should have generatedAt"); + assert.ok("dimensions" in body.usage, "PoolUsageSnapshot should have dimensions"); +}); diff --git a/tests/integration/quota-preview.test.ts b/tests/integration/quota-preview.test.ts new file mode 100644 index 0000000000..2afd58547a --- /dev/null +++ b/tests/integration/quota-preview.test.ts @@ -0,0 +1,145 @@ +/** + * Integration tests: GET /api/quota/preview + * + * Verifies: + * - Auth check (401 without session) + * - Zod validation for query params (400 on missing required fields) + * - 404 when pool does not exist + * - Valid query → returns { decision } with kind="allow" + * - enforce is dry-run: store counters unchanged before and after (peek) + * - Error responses don't leak stack traces (Hard Rule #12 / B25) + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-preview-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-quota-preview-secret"; +process.env.QUOTA_STORE_DRIVER = "sqlite"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const compliance = await import("../../src/lib/compliance/index.ts"); +const { createPool, upsertAllocations } = localDb; +const { getSqliteQuotaStore } = await import("../../src/lib/quota/sqliteQuotaStore.ts"); +const { resetQuotaStoreSingleton } = await import("../../src/lib/quota/QuotaStore.ts"); +const previewRoute = await import("../../src/app/api/quota/preview/route.ts"); + +async function enableManagementAuth() { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +function resetDb() { + core.resetDbInstance(); + resetQuotaStoreSingleton(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + resetDb(); + compliance.initAuditLog(); +}); + +test.after(() => { + core.resetDbInstance(); + resetQuotaStoreSingleton(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /api/quota/preview without auth → 401", async () => { + await enableManagementAuth(); + const req = new Request( + "http://localhost/api/quota/preview?apiKeyId=k1&poolId=p1" + ); + const res = await previewRoute.GET(req); + assert.equal(res.status, 401); +}); + +test("GET /api/quota/preview without required query params → 400", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/preview" + // Missing apiKeyId and poolId + ); + const res = await previewRoute.GET(req); + assert.equal(res.status, 400); + const body = await res.json(); + assert.ok(body.error?.message, "Should have error message"); + assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "No stack trace in 400 response"); +}); + +test("GET /api/quota/preview with nonexistent poolId → 404", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/preview?apiKeyId=key-1&poolId=no-such-pool" + ); + const res = await previewRoute.GET(req); + assert.equal(res.status, 404); + const body = await res.json(); + assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "No stack trace in 404 response"); +}); + +test("GET /api/quota/preview with valid params → { decision } with kind", async () => { + // Create a real pool + const pool = createPool({ connectionId: "conn-preview", name: "Preview Pool" }); + upsertAllocations(pool.id, [ + { apiKeyId: "preview-key-1", weight: 100, policy: "soft" }, + ]); + + const req = await makeManagementSessionRequest( + `http://localhost/api/quota/preview?apiKeyId=preview-key-1&poolId=${pool.id}&estimatedTokens=100` + ); + const res = await previewRoute.GET(req); + assert.equal(res.status, 200); + + const body = (await res.json()) as { decision: { kind: string } }; + assert.ok(body.decision, "Response should have decision field"); + assert.ok( + ["allow", "block"].includes(body.decision.kind), + `decision.kind must be "allow" or "block", got: ${body.decision.kind}` + ); +}); + +test("GET /api/quota/preview is dry-run: store counters unchanged after call", async () => { + // Create pool and seed some consumption + const pool = createPool({ connectionId: "conn-dryrun", name: "Dry Run Pool" }); + upsertAllocations(pool.id, [ + { apiKeyId: "dryrun-key", weight: 100, policy: "hard" }, + ]); + + const store = getSqliteQuotaStore(); + const dim = { poolId: pool.id, unit: "tokens" as const, window: "daily" as const }; + + // Pre-peek value + const before = await store.peek("dryrun-key", dim); + + // Call preview (dry-run) + const req = await makeManagementSessionRequest( + `http://localhost/api/quota/preview?apiKeyId=dryrun-key&poolId=${pool.id}&estimatedTokens=500` + ); + await previewRoute.GET(req); + + // Post-peek value — must equal pre-peek (no consumption occurred) + const after = await store.peek("dryrun-key", dim); + assert.equal(before, after, "Store counter must not change after a preview (dry-run) call"); +}); + +test("GET /api/quota/preview accepts optional estimatedUsd and estimatedRequests", async () => { + const pool = createPool({ connectionId: "conn-optional", name: "Optional Pool" }); + upsertAllocations(pool.id, [{ apiKeyId: "opt-key", weight: 100, policy: "burst" }]); + + const req = await makeManagementSessionRequest( + `http://localhost/api/quota/preview?apiKeyId=opt-key&poolId=${pool.id}&estimatedUsd=1.5&estimatedRequests=3` + ); + const res = await previewRoute.GET(req); + assert.equal(res.status, 200); + const body = (await res.json()) as { decision: unknown }; + assert.ok(body.decision, "Should return decision"); +}); diff --git a/tests/integration/quota-routes-error-sanitization.test.ts b/tests/integration/quota-routes-error-sanitization.test.ts new file mode 100644 index 0000000000..b786b65898 --- /dev/null +++ b/tests/integration/quota-routes-error-sanitization.test.ts @@ -0,0 +1,255 @@ +/** + * Integration tests: error sanitization across all quota REST routes + * + * Verifies Hard Rule #12 (B25): No route returns raw stack traces, absolute + * paths, or credential strings in error response bodies. + * + * Tests the error paths of each quota endpoint — 400s (bad input) and 404s + * (not found) — to confirm buildErrorBody sanitization is active. + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +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"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-quota-err-sanitization-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-quota-sanitization-secret"; +process.env.QUOTA_STORE_DRIVER = "sqlite"; +// Ensure a known fake URL that we can check is NOT leaked +process.env.QUOTA_STORE_REDIS_URL = "redis://secret-host:9999/0"; + +const core = await import("../../src/lib/db/core.ts"); +const compliance = await import("../../src/lib/compliance/index.ts"); +const { resetQuotaStoreSingleton } = await import("../../src/lib/quota/QuotaStore.ts"); + +// Import all routes +const poolsRoute = await import("../../src/app/api/quota/pools/route.ts"); +const poolIdRoute = await import("../../src/app/api/quota/pools/[id]/route.ts"); +const usageRoute = await import("../../src/app/api/quota/pools/[id]/usage/route.ts"); +const plansRoute = await import("../../src/app/api/quota/plans/route.ts"); +const planIdRoute = await import("../../src/app/api/quota/plans/[connectionId]/route.ts"); +const previewRoute = await import("../../src/app/api/quota/preview/route.ts"); +const settingsRoute = await import("../../src/app/api/settings/quota-store/route.ts"); + +function resetDb() { + core.resetDbInstance(); + resetQuotaStoreSingleton(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +// Helper to assert no stack trace / path leak in a response body text +function assertNoStackTraceText(text: string, label: string) { + assert.doesNotMatch( + text, + /\s+at\s+\//, + `${label}: Response must not contain stack trace (Hard Rule #12)` + ); + assert.doesNotMatch( + text, + /\/home\/[a-z]/, + `${label}: Response must not contain absolute home path` + ); +} + +// Reads the response body once and runs stack trace assertion +async function assertNoStackTrace(res: Response, label: string) { + const text = await res.text(); + assertNoStackTraceText(text, label); +} + +// Helper to assert secret URL not in response body text +function assertNoSecretUrlText(text: string, label: string) { + assert.doesNotMatch( + text, + /secret-host/, + `${label}: Response must not contain secret Redis host` + ); + assert.doesNotMatch( + text, + /redis:\/\/secret/, + `${label}: Response must not contain Redis URL` + ); +} + +// Reads the response body once and runs both assertions (body cannot be read twice) +async function assertNoStackTraceAndNoSecretUrl(res: Response, label: string) { + const text = await res.text(); + assertNoStackTraceText(text, label); + assertNoSecretUrlText(text, label); +} + +test.beforeEach(() => { + resetDb(); + compliance.initAuditLog(); +}); + +test.after(() => { + core.resetDbInstance(); + resetQuotaStoreSingleton(); + delete process.env.QUOTA_STORE_REDIS_URL; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// POST /api/quota/pools — bad body → 400 +// --------------------------------------------------------------------------- + +test("POST /api/quota/pools 400 error response has no stack trace", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/quota/pools", { + method: "POST", + body: { connectionId: "", name: "" }, // Empty strings fail Zod validation + }); + const res = await poolsRoute.POST(req); + assert.equal(res.status, 400); + await assertNoStackTrace(res, "POST /api/quota/pools 400"); +}); + +// --------------------------------------------------------------------------- +// GET /api/quota/pools/[id] — 404 +// --------------------------------------------------------------------------- + +test("GET /api/quota/pools/[id] 404 response has no stack trace", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/pools/does-not-exist" + ); + const res = await poolIdRoute.GET(req, { + params: Promise.resolve({ id: "does-not-exist" }), + }); + assert.equal(res.status, 404); + await assertNoStackTrace(res, "GET /api/quota/pools/[id] 404"); +}); + +// --------------------------------------------------------------------------- +// PATCH /api/quota/pools/[id] — bad body → 400 +// --------------------------------------------------------------------------- + +test("PATCH /api/quota/pools/[id] 400 error response has no stack trace", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/pools/does-not-exist", + { + method: "PATCH", + body: { allocations: "not-an-array" }, // Zod expects array + } + ); + const res = await poolIdRoute.PATCH(req, { + params: Promise.resolve({ id: "does-not-exist" }), + }); + // May be 400 (Zod) or 404 (not found after Zod passes) — either is fine, + // key requirement is no stack trace + await assertNoStackTrace(res, "PATCH /api/quota/pools/[id]"); +}); + +// --------------------------------------------------------------------------- +// GET /api/quota/pools/[id]/usage — 404 +// --------------------------------------------------------------------------- + +test("GET /api/quota/pools/[id]/usage 404 response has no stack trace", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/pools/ghost-pool/usage" + ); + const res = await usageRoute.GET(req, { + params: Promise.resolve({ id: "ghost-pool" }), + }); + assert.equal(res.status, 404); + await assertNoStackTrace(res, "GET /api/quota/pools/[id]/usage 404"); +}); + +// --------------------------------------------------------------------------- +// GET /api/quota/plans — valid but checked for sanitization +// --------------------------------------------------------------------------- + +test("GET /api/quota/plans 200 response has no stack trace or path leak", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/quota/plans"); + const res = await plansRoute.GET(req); + assert.equal(res.status, 200); + await assertNoStackTrace(res, "GET /api/quota/plans 200"); +}); + +// --------------------------------------------------------------------------- +// PUT /api/quota/plans/[connectionId] — bad body → 400 +// --------------------------------------------------------------------------- + +test("PUT /api/quota/plans/[connectionId] 400 error response has no stack trace", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/plans/conn-bad", + { + method: "PUT", + body: { dimensions: [] }, // PlanUpsertSchema requires min(1) + } + ); + const res = await planIdRoute.PUT(req, { + params: Promise.resolve({ connectionId: "conn-bad" }), + }); + assert.equal(res.status, 400); + await assertNoStackTrace(res, "PUT /api/quota/plans/[connectionId] 400"); +}); + +// --------------------------------------------------------------------------- +// GET /api/quota/preview — missing params → 400 +// --------------------------------------------------------------------------- + +test("GET /api/quota/preview 400 error response has no stack trace", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/quota/preview" + // Missing apiKeyId and poolId + ); + const res = await previewRoute.GET(req); + assert.equal(res.status, 400); + await assertNoStackTrace(res, "GET /api/quota/preview 400"); +}); + +// --------------------------------------------------------------------------- +// GET /api/settings/quota-store — NEVER returns Redis URL +// --------------------------------------------------------------------------- + +test("GET /api/settings/quota-store response does not contain Redis URL (Hard Rule #12/#1)", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store" + ); + const res = await settingsRoute.GET(req); + assert.equal(res.status, 200); + await assertNoStackTraceAndNoSecretUrl(res, "GET /api/settings/quota-store 200"); +}); + +// --------------------------------------------------------------------------- +// PUT /api/settings/quota-store — bad driver → 400 (Zod) +// --------------------------------------------------------------------------- + +test("PUT /api/settings/quota-store 400 error response has no stack trace", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store", + { + method: "PUT", + body: { driver: "baddriver" }, + } + ); + const res = await settingsRoute.PUT(req); + assert.equal(res.status, 400); + await assertNoStackTrace(res, "PUT /api/settings/quota-store 400"); +}); + +// --------------------------------------------------------------------------- +// PUT /api/settings/quota-store — redis without URL → 400 (custom check) +// --------------------------------------------------------------------------- + +test("PUT /api/settings/quota-store redis+no-URL error response does not leak Redis URL", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store", + { + method: "PUT", + body: { driver: "redis" }, // No URL provided + } + ); + const res = await settingsRoute.PUT(req); + assert.equal(res.status, 400); + await assertNoStackTraceAndNoSecretUrl(res, "PUT /api/settings/quota-store redis-no-url 400"); +}); diff --git a/tests/integration/quota-store-settings.test.ts b/tests/integration/quota-store-settings.test.ts new file mode 100644 index 0000000000..bcf2e6bc75 --- /dev/null +++ b/tests/integration/quota-store-settings.test.ts @@ -0,0 +1,202 @@ +/** + * Integration tests: GET/PUT /api/settings/quota-store + * + * Verifies: + * - GET returns driver + redisUrlConfigured flag (NEVER the URL itself) + * - PUT sqlite → 200; PUT redis without URL → 400; PUT redis with URL → 200 + * - PUT emits quota.store.driver_changed audit event + * - GET never returns actual Redis URL (Hard Rule #12 / #1) + * - Error responses don't leak stack traces (Hard Rule #12 / B25) + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-store-settings-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-quota-store-settings-secret"; +// Ensure no QUOTA_STORE_REDIS_URL leaks in from environment +delete process.env.QUOTA_STORE_REDIS_URL; +process.env.QUOTA_STORE_DRIVER = "sqlite"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const compliance = await import("../../src/lib/compliance/index.ts"); +const { resetQuotaStoreSingleton } = await import("../../src/lib/quota/QuotaStore.ts"); +const settingsRoute = await import("../../src/app/api/settings/quota-store/route.ts"); + +async function enableManagementAuth() { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await localDb.updateSettings({ requireLogin: true, password: "" }); +} + +function resetDb() { + core.resetDbInstance(); + resetQuotaStoreSingleton(); + delete process.env.QUOTA_STORE_REDIS_URL; + delete process.env.INITIAL_PASSWORD; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetDb(); + compliance.initAuditLog(); +}); + +test.after(() => { + core.resetDbInstance(); + resetQuotaStoreSingleton(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// GET /api/settings/quota-store +// --------------------------------------------------------------------------- + +test("GET /api/settings/quota-store without auth → 401", async () => { + await enableManagementAuth(); + const req = new Request("http://localhost/api/settings/quota-store"); + const res = await settingsRoute.GET(req); + assert.equal(res.status, 401); +}); + +test("GET /api/settings/quota-store returns driver + redisUrlConfigured (not URL)", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store" + ); + const res = await settingsRoute.GET(req); + assert.equal(res.status, 200); + const body = (await res.json()) as { + driver: string; + redisUrlConfigured: boolean; + redisUrl: null | string; + }; + + assert.ok(["sqlite", "redis"].includes(body.driver), "driver must be sqlite or redis"); + assert.ok(typeof body.redisUrlConfigured === "boolean", "redisUrlConfigured must be boolean"); + + // Hard Rule #12 / #1 — URL must NEVER be returned + assert.equal(body.redisUrl, null, "Redis URL must be null (never returned)"); + + // Verify the raw Redis URL string is not anywhere in the response text + const responseText = JSON.stringify(body); + assert.doesNotMatch(responseText, /redis:\/\//, "Redis URL must not appear in response"); + assert.doesNotMatch(responseText, /\s+at\s+\//, "No stack trace in response"); +}); + +test("GET /api/settings/quota-store redisUrlConfigured=false when no URL configured", async () => { + delete process.env.QUOTA_STORE_REDIS_URL; + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store" + ); + const res = await settingsRoute.GET(req); + const body = (await res.json()) as { redisUrlConfigured: boolean }; + assert.equal(body.redisUrlConfigured, false); +}); + +// --------------------------------------------------------------------------- +// PUT /api/settings/quota-store +// --------------------------------------------------------------------------- + +test("PUT /api/settings/quota-store without auth → 401", async () => { + await enableManagementAuth(); + const req = new Request("http://localhost/api/settings/quota-store", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ driver: "sqlite" }), + }); + const res = await settingsRoute.PUT(req); + assert.equal(res.status, 401); +}); + +test("PUT /api/settings/quota-store driver=sqlite → 200", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store", + { + method: "PUT", + body: { driver: "sqlite" }, + } + ); + const res = await settingsRoute.PUT(req); + assert.equal(res.status, 200); + const body = (await res.json()) as { driver: string; redisUrl: null }; + assert.equal(body.driver, "sqlite"); + assert.equal(body.redisUrl, null); +}); + +test("PUT /api/settings/quota-store driver=redis without URL → 400", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store", + { + method: "PUT", + body: { driver: "redis" }, // No redisUrl + } + ); + const res = await settingsRoute.PUT(req); + assert.equal(res.status, 400); + const body = await res.json(); + assert.ok(body.error?.message, "Should have error message"); + assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "No stack trace in 400 response"); +}); + +test("PUT /api/settings/quota-store driver=redis with valid URL → 200 + audit event", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store", + { + method: "PUT", + body: { driver: "redis", redisUrl: "redis://localhost:6379" }, + } + ); + const res = await settingsRoute.PUT(req); + assert.equal(res.status, 200); + const body = (await res.json()) as { + driver: string; + redisUrlConfigured: boolean; + redisUrl: null; + }; + assert.equal(body.driver, "redis"); + assert.equal(body.redisUrlConfigured, true); + // Hard Rule #12 / #1 — URL NEVER in response + assert.equal(body.redisUrl, null); + + // Audit event + const logs = compliance.getAuditLog({ action: "quota.store.driver_changed", limit: 10 }); + const events = Array.isArray(logs) ? logs : []; + const evt = events.find( + (e) => + typeof e === "object" && + e !== null && + (e as Record).action === "quota.store.driver_changed" + ); + assert.ok(evt, "quota.store.driver_changed audit event must be present"); + + // Verify the actual Redis URL was NOT logged in audit metadata + const evtStr = JSON.stringify(evt); + assert.doesNotMatch( + evtStr, + /redis:\/\/localhost:6379/, + "Actual Redis URL must not appear in audit log metadata" + ); +}); + +test("PUT /api/settings/quota-store with invalid driver → 400 (Zod)", async () => { + const req = await makeManagementSessionRequest( + "http://localhost/api/settings/quota-store", + { + method: "PUT", + body: { driver: "memcached" }, // Not in enum + } + ); + const res = await settingsRoute.PUT(req); + assert.equal(res.status, 400); + const body = await res.json(); + assert.ok(body.error?.message, "Should have error message"); + assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "No stack trace in 400 response"); +});