mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
205 lines
6.9 KiB
TypeScript
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();
|
|
});
|
|
});
|