mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
test(integration): cover quota REST routes + error sanitization (B/F8)
This commit is contained in:
253
tests/integration/quota-plans-crud.test.ts
Normal file
253
tests/integration/quota-plans-crud.test.ts
Normal file
@@ -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<string, unknown>).action === "quota.plan.updated" &&
|
||||
(e as Record<string, unknown>).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);
|
||||
});
|
||||
286
tests/integration/quota-pools-crud.test.ts
Normal file
286
tests/integration/quota-pools-crud.test.ts
Normal file
@@ -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<string, unknown>).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<string, unknown>).action === "quota.pool.updated" &&
|
||||
(e as Record<string, unknown>).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<string, unknown>).action === "quota.pool.deleted" &&
|
||||
(e as Record<string, unknown>).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);
|
||||
});
|
||||
169
tests/integration/quota-pools-usage.test.ts
Normal file
169
tests/integration/quota-pools-usage.test.ts
Normal file
@@ -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<string, unknown> };
|
||||
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");
|
||||
});
|
||||
145
tests/integration/quota-preview.test.ts
Normal file
145
tests/integration/quota-preview.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
255
tests/integration/quota-routes-error-sanitization.test.ts
Normal file
255
tests/integration/quota-routes-error-sanitization.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
202
tests/integration/quota-store-settings.test.ts
Normal file
202
tests/integration/quota-store-settings.test.ts
Normal file
@@ -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<string, unknown>).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");
|
||||
});
|
||||
Reference in New Issue
Block a user