import { describe, expect, it, vi } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: {} })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/monitoring/glitchtip", () => ({ sendGlitchtipEvent: vi.fn().mockResolvedValue(undefined), })); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {}, })), })); import { NextRequest, NextResponse } from "next/server"; import { apiHandler, HttpError } from "@/lib/api-handler"; import { apiError, apiSuccess, withErrorEnvelopeDefaults, } from "@/lib/api-response"; import { defaultErrorCodeForStatus } from "@/lib/api/default-error-codes"; const INCOMING = "3f9a2c1b-0000-4000-8000-000000000001"; function call( handler: () => Promise, headers: Record = {}, ) { const route = apiHandler(async (_request: NextRequest) => handler()); return route(new NextRequest("http://localhost/api/probe", { headers })); } describe("meta.requestId on every error envelope", () => { it("adds the request id to an apiError envelope, equal to the header", async () => { const res = await call(async () => apiError("Nope", 409, { errorCode: "probe.conflict" }), ); const body = await res.json(); expect(res.status).toBe(409); expect(body.meta.errorCode).toBe("probe.conflict"); expect(body.meta.requestId).toBe(res.headers.get("x-request-id")); expect(body.meta.requestId).toMatch(/^[A-Za-z0-9-]{8,64}$/); }); it("adds it to an envelope without meta and to a hand-built one", async () => { const plain = await call(async () => apiError("Not found", 404)); expect((await plain.json()).meta).toEqual({ errorCode: "http.not_found", requestId: plain.headers.get("x-request-id"), }); const hand = await call(async () => NextResponse.json({ data: null, error: "Gone" }, { status: 410 }), ); expect((await hand.json()).meta.requestId).toBe( hand.headers.get("x-request-id"), ); }); it("adds it to a thrown HttpError and to an unexpected throw (500)", async () => { const thrown = await call(async () => { throw new HttpError(403, "Forbidden"); }); expect((await thrown.json()).meta.requestId).toBe( thrown.headers.get("x-request-id"), ); const crash = await call(async () => { throw new Error("boom"); }); expect(crash.status).toBe(500); expect((await crash.json()).meta.requestId).toBe( crash.headers.get("x-request-id"), ); }); it("adopts a well-formed incoming x-request-id and replaces a malformed one", async () => { const adopted = await call(async () => apiError("Nope", 400), { "x-request-id": INCOMING, }); expect((await adopted.json()).meta.requestId).toBe(INCOMING); expect(adopted.headers.get("x-request-id")).toBe(INCOMING); const forged = ''; const replaced = await call(async () => apiError("Nope", 400), { "x-request-id": forged, }); const id = (await replaced.json()).meta.requestId; expect(id).not.toBe(forged); expect(id).toMatch(/^[A-Za-z0-9-]{8,64}$/); expect(replaced.headers.get("x-request-id")).toBe(id); }); it("keeps status, headers and set-cookie on the rewritten response", async () => { const res = await call(async () => { const r = apiError("Slow down", 429, { headers: { "Retry-After": "30" }, }); r.cookies.set("probe", "1"); return r; }); expect(res.status).toBe(429); expect(res.headers.get("Retry-After")).toBe("30"); expect(res.headers.get("set-cookie")).toContain("probe=1"); }); it("leaves a success, a non-envelope error and a non-JSON error untouched", async () => { const ok = await call(async () => apiSuccess({ fine: true })); expect(await ok.json()).toEqual({ data: { fine: true }, error: null }); const rpc = await call(async () => NextResponse.json( { jsonrpc: "2.0", error: { code: -32600, message: "bad" }, id: 1 }, { status: 400 }, ), ); expect(await rpc.json()).toEqual({ jsonrpc: "2.0", error: { code: -32600, message: "bad" }, id: 1, }); const text = await call( async () => new Response("plain failure", { status: 502 }), ); expect(await text.text()).toBe("plain failure"); }); it("never overwrites an id the handler set itself", async () => { const res = await withErrorEnvelopeDefaults( NextResponse.json( { data: null, error: "x", meta: { requestId: "kept-by-handler" } }, { status: 500 }, ), "ignored-0000", ); expect((await res.json()).meta.requestId).toBe("kept-by-handler"); }); }); describe("meta.errorCode on every error envelope", () => { it("defaults the code from the status when the route named none", async () => { const thrown = await call(async () => { throw new HttpError(403, "Forbidden"); }); expect((await thrown.json()).meta.errorCode).toBe("http.forbidden"); const crash = await call(async () => { throw new Error("boom"); }); expect((await crash.json()).meta.errorCode).toBe("http.internal"); const hand = await call(async () => NextResponse.json({ data: null, error: "Gone" }, { status: 410 }), ); expect((await hand.json()).meta.errorCode).toBe("http.gone"); const bad = await call(async () => { throw new SyntaxError("Unexpected token"); }); expect((await bad.json()).meta.errorCode).toBe("http.bad_request"); }); it("keeps a code the route named, and its other meta", async () => { const res = await call(async () => apiError("Nope", 409, { errorCode: "probe.conflict", extra: 1 }), ); const meta = (await res.json()).meta; expect(meta.errorCode).toBe("probe.conflict"); expect(meta.extra).toBe(1); }); it("falls back to the class code for an unlisted status", () => { expect(defaultErrorCodeForStatus(418)).toBe("http.client_error"); expect(defaultErrorCodeForStatus(507)).toBe("http.server_error"); expect(defaultErrorCodeForStatus(422)).toBe("http.invalid"); }); it("adds the code even when the handler already set the id", async () => { const res = await withErrorEnvelopeDefaults( NextResponse.json( { data: null, error: "x", meta: { requestId: "kept-by-handler" } }, { status: 404 }, ), "ignored-0000", ); expect(await res.json()).toEqual({ data: null, error: "x", meta: { requestId: "kept-by-handler", errorCode: "http.not_found" }, }); }); it("leaves a success untouched", async () => { const ok = await call(async () => apiSuccess({ fine: true })); expect((await ok.json()).meta).toBeUndefined(); }); });