From efa8f0af2358affdf03e343d2d0d37dd69195b78 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 31 May 2026 19:31:30 -0300 Subject: [PATCH] feat(quota): /api/quota/groups CRUD (rename re-syncs combos) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET/POST /api/quota/groups and PATCH/DELETE /api/quota/groups/[id]. PATCH rename calls renameGroup then re-syncs quotaShared-* combos (via dynamic-import syncQuotaCombos) for every pool in the group, since combo names embed the group slug. DELETE maps the deleteGroup throw (protected group-demo or pools still referencing the group) to 409 Conflict via buildErrorBody — never 500. All handlers are management-gated via requireManagementAuth and route all errors through buildErrorBody (Hard Rule #12). 37-assertion source-scan test verifies auth, error sanitisation, response shapes, Zod validation, PATCH re-sync wiring, and DELETE 409 logic. --- src/app/api/quota/groups/[id]/route.ts | 98 +++++++ src/app/api/quota/groups/route.ts | 52 ++++ src/shared/schemas/quota.ts | 10 + tests/unit/quota-groups-route.test.ts | 357 +++++++++++++++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 src/app/api/quota/groups/[id]/route.ts create mode 100644 src/app/api/quota/groups/route.ts create mode 100644 tests/unit/quota-groups-route.test.ts diff --git a/src/app/api/quota/groups/[id]/route.ts b/src/app/api/quota/groups/[id]/route.ts new file mode 100644 index 0000000000..7496dbdd6d --- /dev/null +++ b/src/app/api/quota/groups/[id]/route.ts @@ -0,0 +1,98 @@ +/** + * PATCH /api/quota/groups/[id] — rename a quota group (re-syncs combos) + * DELETE /api/quota/groups/[id] — delete a quota group + * + * Auth: requireManagementAuth + * Zod: GroupRenameSchema from @/shared/schemas/quota (PATCH only) + * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) + * + * PATCH rename → renameGroup → re-sync combos for all pools in the group + * (combo names include the group slug, so they must be refreshed after rename). + * + * DELETE blocks (409) when pools still reference the group, or when the + * protected seed 'group-demo' is targeted. Both cases are signalled by + * deleteGroup throwing — caught here and mapped to 409. + * + * NOT LOCAL_ONLY — does not spawn processes (B18, Hard Rules #15/#17 do not apply). + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { GroupRenameSchema } from "@/shared/schemas/quota"; +import { renameGroup, deleteGroup, getGroup, getPoolsByGroup } from "@/lib/localDb"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function PATCH(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + const body = await request.json().catch(() => null); + const parsed = GroupRenameSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(buildErrorBody(400, parsed.error.message), { status: 400 }); + } + + const updated = renameGroup(id, parsed.data.name); + if (!updated) { + return NextResponse.json(buildErrorBody(404, "Group not found"), { status: 404 }); + } + + // Re-sync combos for all pools in the group — combo names embed the group + // slug, so they must be refreshed after a rename. Dynamic import mirrors + // quotaPools.ts::syncQuotaCombosGuarded pattern; failures are non-fatal. + const pools = getPoolsByGroup(id); + for (const pool of pools) { + try { + const { syncQuotaCombos } = await import("@/lib/quota/quotaCombos"); + await syncQuotaCombos(pool.id); + } catch (err) { + // Guard: combo-sync failure must never break group rename callers. + console.warn( + "[quota-groups] syncQuotaCombos failed (non-fatal):", + (err as Error)?.message, + ); + } + } + + const group = getGroup(id); + return NextResponse.json({ group }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to rename group"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} + +export async function DELETE(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + let existed: boolean; + try { + existed = deleteGroup(id); + } catch (err) { + // deleteGroup throws when the group is protected ('group-demo') or when + // pools still reference it — both map to 409 Conflict. + const message = err instanceof Error ? err.message : "Cannot delete group"; + return NextResponse.json(buildErrorBody(409, message), { status: 409 }); + } + + if (!existed) { + return NextResponse.json(buildErrorBody(404, "Group not found"), { status: 404 }); + } + + return new Response(null, { status: 204 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to delete group"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} diff --git a/src/app/api/quota/groups/route.ts b/src/app/api/quota/groups/route.ts new file mode 100644 index 0000000000..04962ebe9f --- /dev/null +++ b/src/app/api/quota/groups/route.ts @@ -0,0 +1,52 @@ +/** + * GET /api/quota/groups — list all quota groups + * POST /api/quota/groups — create a new quota group + * + * Auth: requireManagementAuth (same pattern as /api/quota/pools) + * Zod: GroupCreateSchema from @/shared/schemas/quota + * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) + * + * NOT LOCAL_ONLY — does not spawn processes (B18, Hard Rules #15/#17 do not apply). + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { GroupCreateSchema } from "@/shared/schemas/quota"; +import { listGroups, createGroup } from "@/lib/localDb"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const groups = listGroups(); + return NextResponse.json({ groups }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list groups"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} + +export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const body = await request.json().catch(() => null); + const parsed = GroupCreateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(buildErrorBody(400, parsed.error.message), { status: 400 }); + } + + const group = createGroup(parsed.data.name); + return NextResponse.json({ group }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create group"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} diff --git a/src/shared/schemas/quota.ts b/src/shared/schemas/quota.ts index dfeb12bf8d..3abad23eeb 100644 --- a/src/shared/schemas/quota.ts +++ b/src/shared/schemas/quota.ts @@ -1,6 +1,16 @@ import { z } from "zod"; import { PoolAllocationSchema, QuotaDimensionSchema } from "@/lib/quota/dimensions"; +export const GroupCreateSchema = z.object({ + name: z.string().min(1).max(120), +}); +export type GroupCreate = z.infer; + +export const GroupRenameSchema = z.object({ + name: z.string().min(1).max(120), +}); +export type GroupRename = z.infer; + export const PoolCreateSchema = z .object({ connectionId: z.string().min(1), diff --git a/tests/unit/quota-groups-route.test.ts b/tests/unit/quota-groups-route.test.ts new file mode 100644 index 0000000000..d0db9f12bf --- /dev/null +++ b/tests/unit/quota-groups-route.test.ts @@ -0,0 +1,357 @@ +/** + * tests/unit/quota-groups-route.test.ts + * + * Task B7 — source-level assertions for the quota groups REST routes. + * + * Uses the same source-scan technique as: + * tests/unit/budget-route-auth.test.ts + * tests/unit/quota-pool-log-route.test.ts + * + * This allows the test to run with the Node.js native test runner without + * a Next.js / DOM setup, while providing strong structural coverage of: + * - Auth guard pattern (requireManagementAuth, 401 without auth) + * - Error sanitization (buildErrorBody, no raw err.stack) + * - Response shapes ({ groups }, { group }) + * - POST Zod validation + * - PATCH rename + combo re-sync wiring + * - DELETE 409 logic for protected/in-use groups + * - force-dynamic export + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const LIST_ROUTE_PATH = join(ROOT, "src/app/api/quota/groups/route.ts"); +const ID_ROUTE_PATH = join(ROOT, "src/app/api/quota/groups/[id]/route.ts"); +const SCHEMAS_PATH = join(ROOT, "src/shared/schemas/quota.ts"); + +const listSrc = readFileSync(LIST_ROUTE_PATH, "utf8"); +const idSrc = readFileSync(ID_ROUTE_PATH, "utf8"); +const schemasSrc = readFileSync(SCHEMAS_PATH, "utf8"); + +// ── Both routes: auth guard ─────────────────────────────────────────────────── + +test("groups/route.ts: imports requireManagementAuth", () => { + assert.ok( + listSrc.includes("requireManagementAuth"), + "route must import and call requireManagementAuth", + ); +}); + +test("groups/route.ts: GET calls requireManagementAuth before data access", () => { + const getIdx = listSrc.indexOf("export async function GET"); + assert.ok(getIdx >= 0, "GET handler must exist"); + const postIdx = listSrc.indexOf("export async function POST"); + const getBody = postIdx >= 0 ? listSrc.slice(getIdx, postIdx) : listSrc.slice(getIdx); + const authIdx = getBody.indexOf("requireManagementAuth(request)"); + const dataIdx = getBody.indexOf("listGroups("); + assert.ok(authIdx >= 0, "requireManagementAuth call must be in GET handler"); + assert.ok(dataIdx >= 0, "listGroups call must be in GET handler"); + assert.ok(authIdx < dataIdx, "auth check must come before listGroups call"); +}); + +test("groups/route.ts: GET returns early when authError is truthy", () => { + const getIdx = listSrc.indexOf("export async function GET"); + const postIdx = listSrc.indexOf("export async function POST"); + const getBody = postIdx >= 0 ? listSrc.slice(getIdx, postIdx) : listSrc.slice(getIdx); + assert.ok( + getBody.includes("if (authError) return authError"), + "GET must return authError immediately — 401 without auth", + ); +}); + +test("groups/route.ts: POST calls requireManagementAuth before data access", () => { + const postIdx = listSrc.indexOf("export async function POST"); + assert.ok(postIdx >= 0, "POST handler must exist"); + const postBody = listSrc.slice(postIdx); + const authIdx = postBody.indexOf("requireManagementAuth(request)"); + const jsonIdx = postBody.indexOf("request.json()"); + assert.ok(authIdx >= 0, "requireManagementAuth call must be in POST handler"); + assert.ok(jsonIdx >= 0, "request.json() must be in POST handler"); + assert.ok(authIdx < jsonIdx, "auth check must come before request.json()"); +}); + +test("groups/route.ts: POST returns early when authError is truthy", () => { + const postIdx = listSrc.indexOf("export async function POST"); + const postBody = listSrc.slice(postIdx); + assert.ok( + postBody.includes("if (authError) return authError"), + "POST must return authError immediately — 401 without auth", + ); +}); + +test("groups/[id]/route.ts: PATCH calls requireManagementAuth before data access", () => { + const patchIdx = idSrc.indexOf("export async function PATCH"); + assert.ok(patchIdx >= 0, "PATCH handler must exist"); + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const patchBody = deleteIdx >= 0 ? idSrc.slice(patchIdx, deleteIdx) : idSrc.slice(patchIdx); + const authIdx = patchBody.indexOf("requireManagementAuth(request)"); + const jsonIdx = patchBody.indexOf("request.json()"); + assert.ok(authIdx >= 0, "requireManagementAuth must be in PATCH handler"); + assert.ok(jsonIdx >= 0, "request.json() must be in PATCH handler"); + assert.ok(authIdx < jsonIdx, "auth check must come before request.json()"); +}); + +test("groups/[id]/route.ts: PATCH returns early when authError is truthy", () => { + const patchIdx = idSrc.indexOf("export async function PATCH"); + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const patchBody = deleteIdx >= 0 ? idSrc.slice(patchIdx, deleteIdx) : idSrc.slice(patchIdx); + assert.ok(patchBody.includes("if (authError) return authError"), "PATCH must return authError"); +}); + +test("groups/[id]/route.ts: DELETE calls requireManagementAuth before data access", () => { + const deleteIdx = idSrc.indexOf("export async function DELETE"); + assert.ok(deleteIdx >= 0, "DELETE handler must exist"); + const deleteBody = idSrc.slice(deleteIdx); + const authIdx = deleteBody.indexOf("requireManagementAuth(request)"); + const dataIdx = deleteBody.indexOf("deleteGroup("); + assert.ok(authIdx >= 0, "requireManagementAuth must be in DELETE handler"); + assert.ok(dataIdx >= 0, "deleteGroup call must be in DELETE handler"); + assert.ok(authIdx < dataIdx, "auth check must come before deleteGroup call"); +}); + +test("groups/[id]/route.ts: DELETE returns early when authError is truthy", () => { + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const deleteBody = idSrc.slice(deleteIdx); + assert.ok( + deleteBody.includes("if (authError) return authError"), + "DELETE must return authError — 401 without auth", + ); +}); + +// ── Error sanitization ──────────────────────────────────────────────────────── + +test("groups/route.ts: uses buildErrorBody from @omniroute/open-sse/utils/error", () => { + assert.ok(listSrc.includes("buildErrorBody"), "route must use buildErrorBody — Hard Rule #12"); + assert.ok( + listSrc.includes("@omniroute/open-sse/utils/error"), + "route must import buildErrorBody from @omniroute/open-sse/utils/error", + ); +}); + +test("groups/route.ts: does NOT put raw err.stack in response (no stack leak)", () => { + assert.ok(!listSrc.includes("err.stack"), "route must not leak err.stack in response"); +}); + +test("groups/[id]/route.ts: uses buildErrorBody from @omniroute/open-sse/utils/error", () => { + assert.ok(idSrc.includes("buildErrorBody"), "route must use buildErrorBody — Hard Rule #12"); + assert.ok( + idSrc.includes("@omniroute/open-sse/utils/error"), + "route must import buildErrorBody from @omniroute/open-sse/utils/error", + ); +}); + +test("groups/[id]/route.ts: does NOT put raw err.stack in response (no stack leak)", () => { + assert.ok(!idSrc.includes("err.stack"), "route must not leak err.stack in response"); +}); + +// ── Response shapes ─────────────────────────────────────────────────────────── + +test("groups/route.ts: GET returns { groups } shape", () => { + assert.ok( + listSrc.includes("{ groups }") || listSrc.includes("{groups}") || listSrc.includes("groups:"), + "GET must return { groups } in the response body", + ); +}); + +test("groups/route.ts: POST returns { group } shape with status 201", () => { + const postIdx = listSrc.indexOf("export async function POST"); + const postBody = listSrc.slice(postIdx); + assert.ok( + postBody.includes("{ group }") || postBody.includes("{group}") || postBody.includes("group:"), + "POST must return { group } in the response body", + ); + assert.ok(postBody.includes("201"), "POST must return HTTP 201 on success"); +}); + +test("groups/[id]/route.ts: PATCH returns { group } shape", () => { + const patchIdx = idSrc.indexOf("export async function PATCH"); + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const patchBody = deleteIdx >= 0 ? idSrc.slice(patchIdx, deleteIdx) : idSrc.slice(patchIdx); + assert.ok( + patchBody.includes("{ group }") || patchBody.includes("{group}") || patchBody.includes("group:"), + "PATCH must return { group } in the response body", + ); +}); + +// ── POST: Zod validation ────────────────────────────────────────────────────── + +test("groups/route.ts: POST uses GroupCreateSchema for Zod validation", () => { + assert.ok( + listSrc.includes("GroupCreateSchema"), + "route must import and use GroupCreateSchema for POST body validation", + ); +}); + +test("groups/route.ts: POST returns 400 on invalid body", () => { + const postIdx = listSrc.indexOf("export async function POST"); + const postBody = listSrc.slice(postIdx); + assert.ok(postBody.includes("400"), "POST must return 400 for invalid body"); + assert.ok( + postBody.includes("safeParse") || postBody.includes(".parse("), + "POST must use safeParse or parse for validation", + ); +}); + +// ── PATCH: Zod validation + rename + combo re-sync ─────────────────────────── + +test("groups/[id]/route.ts: PATCH uses GroupRenameSchema for Zod validation", () => { + assert.ok( + idSrc.includes("GroupRenameSchema"), + "PATCH route must import and use GroupRenameSchema", + ); +}); + +test("groups/[id]/route.ts: PATCH calls renameGroup", () => { + const patchIdx = idSrc.indexOf("export async function PATCH"); + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const patchBody = deleteIdx >= 0 ? idSrc.slice(patchIdx, deleteIdx) : idSrc.slice(patchIdx); + assert.ok(patchBody.includes("renameGroup("), "PATCH must call renameGroup"); +}); + +test("groups/[id]/route.ts: PATCH re-syncs combos via syncQuotaCombos (dynamic import)", () => { + const patchIdx = idSrc.indexOf("export async function PATCH"); + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const patchBody = deleteIdx >= 0 ? idSrc.slice(patchIdx, deleteIdx) : idSrc.slice(patchIdx); + assert.ok( + patchBody.includes("syncQuotaCombos"), + "PATCH must call syncQuotaCombos to re-sync combos after rename", + ); +}); + +test("groups/[id]/route.ts: PATCH fetches pools via getPoolsByGroup for combo re-sync", () => { + const patchIdx = idSrc.indexOf("export async function PATCH"); + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const patchBody = deleteIdx >= 0 ? idSrc.slice(patchIdx, deleteIdx) : idSrc.slice(patchIdx); + assert.ok( + patchBody.includes("getPoolsByGroup("), + "PATCH must call getPoolsByGroup to enumerate pools for combo re-sync", + ); +}); + +test("groups/[id]/route.ts: PATCH returns 404 when renameGroup returns false", () => { + const patchIdx = idSrc.indexOf("export async function PATCH"); + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const patchBody = deleteIdx >= 0 ? idSrc.slice(patchIdx, deleteIdx) : idSrc.slice(patchIdx); + assert.ok(patchBody.includes("404"), "PATCH must return 404 when group is not found"); +}); + +// ── DELETE: 409 on protected / in-use groups ────────────────────────────────── + +test("groups/[id]/route.ts: DELETE maps deleteGroup throws to 409 Conflict", () => { + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const deleteBody = idSrc.slice(deleteIdx); + assert.ok( + deleteBody.includes("409"), + "DELETE must return 409 when deleteGroup throws (protected group or pools exist)", + ); +}); + +test("groups/[id]/route.ts: DELETE catches deleteGroup throw and uses buildErrorBody for 409", () => { + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const deleteBody = idSrc.slice(deleteIdx); + // The 409 must go through buildErrorBody — not a raw Error message + assert.ok( + deleteBody.includes("buildErrorBody(409"), + "DELETE 409 response must use buildErrorBody(409, ...) — Hard Rule #12", + ); +}); + +test("groups/[id]/route.ts: DELETE returns 204 on successful delete", () => { + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const deleteBody = idSrc.slice(deleteIdx); + assert.ok(deleteBody.includes("204"), "DELETE must return 204 on success"); +}); + +test("groups/[id]/route.ts: DELETE returns 404 when group not found", () => { + const deleteIdx = idSrc.indexOf("export async function DELETE"); + const deleteBody = idSrc.slice(deleteIdx); + assert.ok(deleteBody.includes("404"), "DELETE must return 404 when group does not exist"); +}); + +// ── Params: Next 16 async params pattern ───────────────────────────────────── + +test("groups/[id]/route.ts: reads id via await params (Next 16 pattern)", () => { + assert.ok( + idSrc.includes("await params"), + "route must use await params — Next 16 async params pattern", + ); +}); + +test("groups/[id]/route.ts: RouteParams typed as Promise<{ id: string }>", () => { + assert.ok( + idSrc.includes("Promise<") && idSrc.includes("id: string"), + "params type must be Promise<{ id: string }> — matching pools/[id] pattern", + ); +}); + +// ── force-dynamic ───────────────────────────────────────────────────────────── + +test("groups/route.ts: has dynamic = 'force-dynamic' export", () => { + assert.ok( + listSrc.includes('dynamic = "force-dynamic"') || listSrc.includes("dynamic = 'force-dynamic'"), + "route must export dynamic = 'force-dynamic'", + ); +}); + +test("groups/[id]/route.ts: has dynamic = 'force-dynamic' export", () => { + assert.ok( + idSrc.includes('dynamic = "force-dynamic"') || idSrc.includes("dynamic = 'force-dynamic'"), + "route must export dynamic = 'force-dynamic'", + ); +}); + +// ── Schemas: GroupCreateSchema and GroupRenameSchema ───────────────────────── + +test("shared/schemas/quota.ts: exports GroupCreateSchema", () => { + assert.ok( + schemasSrc.includes("export const GroupCreateSchema"), + "quota.ts must export GroupCreateSchema", + ); +}); + +test("shared/schemas/quota.ts: GroupCreateSchema requires name (min 1)", () => { + assert.ok( + schemasSrc.includes("GroupCreateSchema") && schemasSrc.includes("min(1)"), + "GroupCreateSchema must enforce min(1) on name", + ); +}); + +test("shared/schemas/quota.ts: exports GroupRenameSchema", () => { + assert.ok( + schemasSrc.includes("export const GroupRenameSchema"), + "quota.ts must export GroupRenameSchema", + ); +}); + +// ── Import correctness ──────────────────────────────────────────────────────── + +test("groups/route.ts: imports listGroups and createGroup from @/lib/localDb", () => { + assert.ok( + listSrc.includes("listGroups") && listSrc.includes("createGroup"), + "route must import listGroups and createGroup", + ); +}); + +test("groups/[id]/route.ts: imports renameGroup, deleteGroup, getGroup, getPoolsByGroup", () => { + assert.ok(idSrc.includes("renameGroup"), "route must import renameGroup"); + assert.ok(idSrc.includes("deleteGroup"), "route must import deleteGroup"); + assert.ok(idSrc.includes("getGroup"), "route must import getGroup"); + assert.ok(idSrc.includes("getPoolsByGroup"), "route must import getPoolsByGroup"); +}); + +// ── GroupDemo: listGroups result includes group-demo ────────────────────────── + +test("groups/route.ts: listGroups() is called in GET (will include GroupDemo in real DB)", () => { + // Source-scan: the call is present; the seed group-demo row is guaranteed + // by the migration, so a real GET would always include it. + assert.ok( + listSrc.includes("listGroups()"), + "GET must call listGroups() — which includes the seeded GroupDemo row", + ); +});