Files
HealthLog/src/lib/__tests__/api-handler-error-request-id.test.ts
T
Marc-André Bombeck c300574a8e fix(api): read the error code before the status
A read-failure primitive decided from the status alone, so a 401 from a
step-up or re-proof gate would have read as an ended session and sent
the reader to sign-in. The code now decides first and the status is the
fallback for codes it does not know.

The two invitation routes answer an unknown name or e-mail with
sharing.invite.no_account. Both have a second 404 with a different next
step, and since every error now carries a code the clients match on it
instead of on the status. The status defaults move into a module the
client can import, and the contract text no longer says these arms
carry no code.
2026-10-20 09:48:15 +02:00

205 lines
6.9 KiB
TypeScript

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<Response>,
headers: Record<string, string> = {},
) {
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 = '<script>"x"</script>';
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();
});
});