From 3d837cdd77f689dff7ccc5e7721253cf7fe10ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:30:31 +0200 Subject: [PATCH] test(kit): call route handlers through the real API kit against Postgres tests/kit/ holds typed fixture factories and callRoute(handler, options). The factories write through the application's Prisma client, so a fixture has the shape production stores and the compiler checks it; sessions and Bearer tokens are minted by createSession and issueApiToken, so a fixture cannot drift into a shape authentication no longer accepts. callRoute builds a real NextRequest, fills the next/headers stand-in from the auth fixture and runs the exported handler, apiHandler included. Every call is also a contract check: the status must be declared for that path and method in the OpenAPI registry, a JSON body must parse with the registered schema, and a key the schema does not declare fails the call. The scope gate's generic 403, documented on the shared 401, is the one answer accepted without its own entry. kit-contract.test.ts proves each refusal can fire. The token list and revoke move onto the kit. The mocked unit test asserted the list's where clause and proved the API switch is not consulted by reading api-handler.ts for a string; the kit test authenticates a Bearer token while the switch is off, revokes it, and shows it refused on the next request. --- src/app/api/tokens/__tests__/route.test.ts | 180 --------- tests/integration/routes/kit-contract.test.ts | 111 ++++++ tests/integration/routes/tokens.test.ts | 187 +++++++++ tests/kit/call-route.ts | 363 ++++++++++++++++++ tests/kit/factories.ts | 219 +++++++++++ 5 files changed, 880 insertions(+), 180 deletions(-) delete mode 100644 src/app/api/tokens/__tests__/route.test.ts create mode 100644 tests/integration/routes/kit-contract.test.ts create mode 100644 tests/integration/routes/tokens.test.ts create mode 100644 tests/kit/call-route.ts create mode 100644 tests/kit/factories.ts diff --git a/src/app/api/tokens/__tests__/route.test.ts b/src/app/api/tokens/__tests__/route.test.ts deleted file mode 100644 index 2c757c67d..000000000 --- a/src/app/api/tokens/__tests__/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * `/api/tokens` — the instance-wide API switch must not hide live credentials. - * - * `AppSettings.apiGlobal` gates the surfaces a token is FOR — external - * medication ingest, the MCP bridge — and not a token's ability to - * authenticate: nothing on the `requireAuth` path consults it, which the last - * case here asserts directly rather than assuming. So a token minted before an - * operator flipped the switch stays a live credential while it is off, and the - * LIST used to answer 403 — hiding exactly the credentials their owner most - * needed to see, including the wildcard access token their own phone signs in - * with. - * - * The read is ungated now. The revoke is not, and that asymmetry is pinned - * below rather than left to drift: it is a decision about what the switch - * means, and a change to it should be a visible diff here. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: (fn: unknown) => fn, - requireAuth: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - apiToken: { - findMany: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); - -vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() })); - -import { GET } from "../route"; -import { DELETE } from "../[id]/route"; -import { requireAuth } from "@/lib/api-handler"; -import { prisma } from "@/lib/db"; -import { isApiGloballyEnabled } from "@/lib/app-settings"; - -const USER = { id: "user-1", username: "u" }; - -const ROW = { - id: "tok-1", - name: "iPhone", - permissions: ["*"], - lastUsedAt: new Date("2026-08-01T10:00:00Z"), - expiresAt: null, - createdAt: new Date("2026-01-01T00:00:00Z"), - revoked: false, -}; - -type ListEnvelope = { - data: Array<{ id: string }> | null; - error: string | null; -}; - -const params = Promise.resolve({ id: "tok-1" }); - -type DeleteHandler = ( - r: Request, - ctx: { params: Promise<{ id: string }> }, -) => Promise; - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); - vi.mocked(prisma.apiToken.findMany).mockResolvedValue([ROW] as never); -}); - -describe("GET /api/tokens", () => { - it("lists the caller's tokens while the operator has the API switched off", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); - - const res = await (GET as unknown as () => Promise)(); - - expect(res.status).toBe(200); - const env = (await res.json()) as ListEnvelope; - expect(env.error).toBeNull(); - expect(env.data?.[0]?.id).toBe("tok-1"); - expect(prisma.apiToken.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { userId: USER.id } }), - ); - }); - - it("does not consult the switch at all", async () => { - // Stronger than the status assertion above and the reason it is separate: - // a future re-gating that happened to answer 200 anyway would still be a - // read whose behaviour depends on an operator toggle it has no business - // reading. - vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); - - await (GET as unknown as () => Promise)(); - - expect(isApiGloballyEnabled).not.toHaveBeenCalled(); - }); - - it("still scopes the list to the caller", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); - - await (GET as unknown as () => Promise)(); - - const args = vi.mocked(prisma.apiToken.findMany).mock.calls[0][0]; - expect(args?.where).toEqual({ userId: USER.id }); - // No `revoked: false` filter — revoked rows stay in the list, which is - // what the published contract says and what the UI relies on. - expect(JSON.stringify(args?.where)).not.toContain("revoked"); - }); -}); - -describe("DELETE /api/tokens/{id}", () => { - it("keeps refusing while the operator has the API switched off", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); - - const res = await (DELETE as unknown as DeleteHandler)( - new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), - { params }, - ); - - expect(res.status).toBe(403); - expect(prisma.apiToken.update).not.toHaveBeenCalled(); - }); - - it("revokes when the switch is on", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); - vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ - ...ROW, - userId: USER.id, - } as never); - vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); - - const res = await (DELETE as unknown as DeleteHandler)( - new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), - { params }, - ); - - expect(res.status).toBe(200); - expect(prisma.apiToken.update).toHaveBeenCalledWith({ - where: { id: "tok-1" }, - data: { revoked: true }, - }); - }); - - it("does not reach another account's token", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); - vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ - ...ROW, - userId: "someone-else", - } as never); - - const res = await (DELETE as unknown as DeleteHandler)( - new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), - { params }, - ); - - expect(res.status).toBe(404); - expect(prisma.apiToken.update).not.toHaveBeenCalled(); - }); -}); - -describe("the switch and the authentication path", () => { - it("is not consulted by requireAuth, which is why the read must not gate on it", async () => { - // The premise the fix rests on, asserted rather than assumed: if the auth - // kit ever started refusing Bearer callers while `apiGlobal` is off, then - // a token would NOT be live during the outage and hiding the list would - // stop being wrong. This reads the source because the behaviour is an - // absence, and an absence has no call to spy on. - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const source = readFileSync( - join(process.cwd(), "src/lib/api-handler.ts"), - "utf8", - ); - expect(source).not.toContain("isApiGloballyEnabled"); - expect(source).not.toContain("apiGlobal"); - }); -}); diff --git a/tests/integration/routes/kit-contract.test.ts b/tests/integration/routes/kit-contract.test.ts new file mode 100644 index 000000000..99e0106e1 --- /dev/null +++ b/tests/integration/routes/kit-contract.test.ts @@ -0,0 +1,111 @@ +/** + * The route kit's contract check, proven able to fail. + * + * Every kit test leans on `callRoute` refusing a response the published + * contract does not describe. A check that passes whatever it is handed would + * turn every one of them into a weaker test than it claims to be, so each + * refusal is exercised here against a handler that answers exactly the wrong + * thing, wrapped in the real `apiHandler`. + */ +import { NextResponse } from "next/server"; +import { describe, expect, it } from "vitest"; + +import { apiHandler } from "@/lib/api-handler"; + +import { ContractViolation, anonymous, callRoute } from "../../kit/call-route"; + +const token = { + id: "tok-1", + name: "phone", + permissions: ["*"], + lastUsedAt: null, + expiresAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + revoked: false, +}; + +function answering(status: number, body: unknown) { + return apiHandler(async () => NextResponse.json(body, { status })); +} + +describe("callRoute's contract check", () => { + it("accepts a body the registered schema describes", async () => { + const res = await callRoute( + answering(200, { data: [token], error: null }), + { + method: "GET", + route: "/api/tokens", + auth: anonymous, + }, + ); + expect(res.status).toBe(200); + }); + + it("refuses a key the registered schema does not declare", async () => { + await expect( + callRoute( + answering(200, { + data: [{ ...token, tokenHash: "abc" }], + error: null, + }), + { method: "GET", route: "/api/tokens", auth: anonymous }, + ), + ).rejects.toThrow(/does not declare: data\[0\]\.tokenHash/); + }); + + it("refuses a body of the wrong shape", async () => { + await expect( + callRoute( + answering(200, { data: [{ ...token, revoked: "no" }], error: null }), + { + method: "GET", + route: "/api/tokens", + auth: anonymous, + }, + ), + ).rejects.toBeInstanceOf(ContractViolation); + }); + + it("refuses a status the operation does not declare", async () => { + await expect( + callRoute(answering(409, { data: null, error: "conflict" }), { + method: "GET", + route: "/api/tokens", + auth: anonymous, + }), + ).rejects.toThrow(/answered 409, which the contract does not declare/); + }); + + it("refuses a 403 that is not the generic scope refusal", async () => { + await expect( + callRoute( + answering(403, { + data: null, + error: "nope", + meta: { errorCode: "http.forbidden" }, + }), + { method: "GET", route: "/api/tokens", auth: anonymous }, + ), + ).rejects.toThrow(/answered 403/); + }); + + it("refuses a route the registry does not know", async () => { + await expect( + callRoute(answering(200, { data: null, error: null }), { + method: "GET", + route: "/api/not-a-route", + auth: anonymous, + }), + ).rejects.toThrow(/is not in the OpenAPI registry/); + }); + + it("skips the check only when told why", async () => { + const res = await callRoute(answering(200, { anything: true }), { + method: "GET", + route: "/api/not-a-route", + auth: anonymous, + contract: { skip: "exercising the escape hatch itself" }, + }); + expect(res.body).toEqual({ anything: true }); + }); +}); diff --git a/tests/integration/routes/tokens.test.ts b/tests/integration/routes/tokens.test.ts new file mode 100644 index 000000000..a8bdc6877 --- /dev/null +++ b/tests/integration/routes/tokens.test.ts @@ -0,0 +1,187 @@ +/** + * `/api/tokens` and `/api/tokens/{id}` — list and revoke the caller's API + * tokens, against the real database and through the real API kit. + * + * The decisions pinned here are the ones a person relies on when they go + * looking for a credential to kill: + * - the list carries every token the account holds, revoked ones included, + * newest first, and never anyone else's; + * - the list is not gated on the operator's instance-wide API switch, + * because that switch does not stop a token authenticating, which the + * Bearer case below proves by authenticating with one while it is off; + * - the revoke is gated on the switch, reaches only the caller's own rows, + * and a revoked token stops authenticating on the very next request. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET } from "@/app/api/tokens/route"; +import { DELETE } from "@/app/api/tokens/[id]/route"; + +import { callRoute } from "../../kit/call-route"; +import { makeSession, makeToken, makeUser } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +type TokenRow = { + id: string; + name: string; + permissions: string[]; + revoked: boolean; + createdAt: string; +}; + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +async function switchApi(enabled: boolean): Promise { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", apiGlobal: enabled }, + update: { apiGlobal: enabled }, + }); +} + +describe("GET /api/tokens", () => { + it("lists the caller's tokens newest first, revoked ones included, and nobody else's", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const older = await makeToken(owner, { name: "older" }); + const newer = await makeToken(owner, { name: "newer" }); + await prisma.apiToken.update({ + where: { id: older.tokenId }, + data: { revoked: true, createdAt: new Date("2026-01-01T00:00:00Z") }, + }); + await makeToken(other, { name: "not yours" }); + + const res = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data.map((t) => [t.id, t.name, t.revoked])).toEqual([ + [newer.tokenId, "newer", false], + [older.tokenId, "older", true], + ]); + }); + + it("answers a Bearer caller while the operator has the API switched off", async () => { + // The premise of the ungated list: the switch does not stop a token from + // authenticating. Proven by authenticating with one while it is off. + const owner = await makeUser(); + const phone = await makeToken(owner, { name: "phone" }); + await switchApi(false); + + const res = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: phone, + }); + + expect(res.status).toBe(200); + expect(res.data.map((t) => t.id)).toEqual([phone.tokenId]); + }); + + it("refuses a narrow token that does not name this route's scope", async () => { + const owner = await makeUser(); + const ingest = await makeToken(owner, { + permissions: ["measurements:write"], + }); + + const res = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: ingest, + }); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("auth.scope.insufficient"); + expect(res.data).toBeNull(); + }); +}); + +describe("DELETE /api/tokens/{id}", () => { + it("revokes the caller's token, keeps the row listed, and the token stops authenticating", async () => { + const owner = await makeUser(); + const session = await makeSession(owner); + const phone = await makeToken(owner, { name: "phone" }); + + const before = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: phone, + }); + expect(before.status).toBe(200); + + const res = await callRoute<{ revoked: boolean }>(DELETE, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: phone.tokenId }, + auth: session, + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ revoked: true }); + const row = await prisma.apiToken.findUniqueOrThrow({ + where: { id: phone.tokenId }, + }); + expect(row.revoked).toBe(true); + + const listed = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: session, + }); + expect(listed.data).toEqual([ + expect.objectContaining({ id: phone.tokenId, revoked: true }), + ]); + + const after = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: phone, + }); + expect(after.status).toBe(401); + }); + + it("answers 404 for another account's token and leaves it live", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const theirs = await makeToken(other); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: theirs.tokenId }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(404); + const row = await prisma.apiToken.findUniqueOrThrow({ + where: { id: theirs.tokenId }, + }); + expect(row.revoked).toBe(false); + }); + + it("refuses while the operator has the API switched off and revokes nothing", async () => { + const owner = await makeUser(); + const phone = await makeToken(owner); + await switchApi(false); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: phone.tokenId }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(403); + const row = await prisma.apiToken.findUniqueOrThrow({ + where: { id: phone.tokenId }, + }); + expect(row.revoked).toBe(false); + }); +}); diff --git a/tests/kit/call-route.ts b/tests/kit/call-route.ts new file mode 100644 index 000000000..1634c3e87 --- /dev/null +++ b/tests/kit/call-route.ts @@ -0,0 +1,363 @@ +/** + * Call a route handler the way Next.js would, through the real `apiHandler`, + * against the worker's real database, and check the answer against the + * published contract. + * + * What runs: the exported handler (`GET`, `POST`, …) of a `route.ts`, which + * every API route wraps in `apiHandler`. Authentication (cookie session or + * Bearer token, scope checks, the acting-account resolver), the error + * envelope, Zod validation, idempotency, rate limits and the handler body are + * all the production code. What does not run is the edge in `src/proxy.ts` + * (security headers, the public-path allowlist, demo mode), which has its own + * tests. + * + * The contract check: the response status must be one the OpenAPI registry + * (`src/lib/openapi/routes`) declares for that path and method, and a JSON + * body must parse with the schema registered for that status. Keys the + * schema does not declare fail too, because a client generated from the + * contract cannot see them and a field nobody declared is a field nobody + * promised to keep. Every kit test is therefore also a consumer contract + * check for the native client, at no extra cost to whoever writes it. A test + * that deliberately calls an unregistered route, or reaches a body the + * registry cannot describe, says so with `contract: { skip: "" }`. + */ +import { NextRequest } from "next/server"; +import type { z } from "zod"; + +import { openApiComponents, openApiPaths } from "@/lib/openapi/routes"; +import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; + +import { cookieJar, headerJar } from "../integration/mock-next-headers"; +import type { BearerFixture, SessionFixture } from "./factories"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export type RouteAuth = SessionFixture | BearerFixture | { kind: "anonymous" }; + +export const anonymous: RouteAuth = { kind: "anonymous" }; + +type RouteParams = Record; + +/** + * The shape Next.js hands a route handler. Written as a method so a handler + * whose context names its own params (`{ params: Promise<{ id: string }> }`) + * is accepted without a cast; static handlers that take only the request, or + * nothing at all, fit as they are. + */ +type RouteHandler = { + handle( + request: NextRequest, + context: { params: Promise }, + ): Promise; +}["handle"]; + +export interface CallRouteOptions { + method: HttpMethod; + /** The path as the contract names it, e.g. `/api/tokens/{id}`. */ + route: string; + /** Values for the `{…}` segments of `route`. */ + params?: RouteParams; + query?: Record; + /** + * Serialised as JSON with `content-type: application/json`. A `FormData` + * goes out as a multipart body instead, with the boundary the platform + * picks. + */ + body?: unknown; + auth: RouteAuth; + headers?: Record; + contract?: { skip: string }; +} + +export interface RouteResult { + status: number; + headers: Headers; + /** The parsed JSON body, or the text of a non-JSON one. */ + body: unknown; + /** `body.data` of the envelope; typed by the caller, checked by the contract. */ + data: T; + error: string | null; + meta: Record | undefined; +} + +export class ContractViolation extends Error { + constructor(message: string) { + super(message); + this.name = "ContractViolation"; + } +} + +export async function callRoute( + handler: RouteHandler, + options: CallRouteOptions, +): Promise> { + const path = concretePath(options.route, options.params ?? {}); + const url = new URL(path, "http://localhost:3000"); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + + const headers = new Headers(options.headers); + const multipart = options.body instanceof FormData; + if ( + options.body !== undefined && + !multipart && + !headers.has("content-type") + ) { + headers.set("content-type", "application/json"); + } + if (options.auth.kind === "bearer") { + headers.set("authorization", `Bearer ${options.auth.token}`); + } + if (options.auth.kind === "session") { + headers.set("cookie", `${SESSION_COOKIE_NAME}=${options.auth.cookie}`); + } + + // `next/headers` is served from the shared jars; fill them for this call + // and put them back afterwards, so a record-context assertion a test set + // with `switchSessionTo` survives and nothing of this call leaks into the + // next one. + const savedCookies = new Map(cookieJar); + const savedHeaders = new Map(headerJar); + cookieJar.clear(); + if (options.auth.kind === "session") { + cookieJar.set(SESSION_COOKIE_NAME, options.auth.cookie); + } + headerJar.delete("authorization"); + headers.forEach((value, name) => headerJar.set(name.toLowerCase(), value)); + + let response: Response; + try { + const request = new NextRequest(url, { + method: options.method, + headers, + body: + options.body === undefined + ? undefined + : options.body instanceof FormData + ? options.body + : JSON.stringify(options.body), + }); + response = await handler(request, { + params: Promise.resolve(options.params ?? {}), + }); + } finally { + cookieJar.clear(); + for (const [name, value] of savedCookies) cookieJar.set(name, value); + headerJar.clear(); + for (const [name, value] of savedHeaders) headerJar.set(name, value); + } + + const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text(); + const isJson = contentType.includes("json") && text.length > 0; + const body: unknown = isJson ? JSON.parse(text) : text; + + if (!options.contract?.skip) { + checkContract( + options.method, + options.route, + response.status, + contentType, + body, + isJson, + ); + } + + const envelope = isEnvelope(body) ? body : null; + return { + status: response.status, + headers: response.headers, + body, + // The one cast in the kit: the contract check above has already parsed + // the body against the registered schema, and the caller names the type + // it expects. + data: (envelope?.data ?? null) as T, + error: + envelope && typeof envelope.error === "string" ? envelope.error : null, + meta: + envelope && isRecord(envelope.meta) + ? (envelope.meta as Record) + : undefined, + }; +} + +function concretePath(route: string, params: RouteParams): string { + const used = new Set(); + const path = route.replace(/\{([^}]+)\}/g, (_, name: string) => { + const value = params[name]; + if (value === undefined) { + throw new Error(`callRoute: no value for {${name}} in ${route}`); + } + used.add(name); + return Array.isArray(value) + ? value.map(encodeURIComponent).join("/") + : encodeURIComponent(value); + }); + for (const name of Object.keys(params)) { + if (!used.has(name)) { + throw new Error(`callRoute: ${route} has no {${name}} segment`); + } + } + return path; +} + +interface ResponseObject { + content?: Record; + $ref?: string; +} + +/** + * The generic scope gate answers 403 `auth.scope.insufficient` on any route a + * narrow Bearer token does not reach. The registry documents that refusal in + * the prose of the shared 401 (`stdResponses`) rather than as a 403 entry on + * every operation, so an operation that declares the shared 401 is taken to + * declare this 403 as well, and nothing else is. + */ +const SCOPE_REFUSAL_CODE = "auth.scope.insufficient"; + +function lookupResponse( + method: HttpMethod, + route: string, + status: number, + body: unknown, +): ResponseObject { + const pathItem = (openApiPaths as Record)[route]; + if (!isRecord(pathItem)) { + throw new ContractViolation( + `${method} ${route} is not in the OpenAPI registry. Register the route in src/lib/openapi/routes, or pass contract: { skip: "" }.`, + ); + } + const operation = pathItem[method.toLowerCase()]; + if (!isRecord(operation)) { + throw new ContractViolation( + `${route} is registered, but not for ${method}.`, + ); + } + const responses = isRecord(operation.responses) ? operation.responses : {}; + let declared = responses[String(status)] ?? responses.default; + if ( + !isRecord(declared) && + status === 403 && + isRecord(responses["401"]) && + isRecord(body) && + isRecord(body.meta) && + body.meta.errorCode === SCOPE_REFUSAL_CODE + ) { + declared = responses["401"]; + } + if (!isRecord(declared)) { + throw new ContractViolation( + `${method} ${route} answered ${status}, which the contract does not declare (declared: ${Object.keys(responses).join(", ")}).`, + ); + } + if (typeof declared.$ref === "string") { + const name = declared.$ref.split("/").pop() ?? ""; + const resolved = ( + openApiComponents.responses as Record | undefined + )?.[name]; + if (!isRecord(resolved)) { + throw new ContractViolation( + `unresolvable response reference ${declared.$ref}`, + ); + } + return resolved as ResponseObject; + } + return declared as ResponseObject; +} + +function checkContract( + method: HttpMethod, + route: string, + status: number, + contentType: string, + body: unknown, + isJson: boolean, +): void { + const declared = lookupResponse(method, route, status, body); + const content = declared.content ?? {}; + const mediaTypes = Object.keys(content); + if (!isJson) { + if (status === 204 || mediaTypes.length === 0) return; + const mediaType = contentType.split(";")[0]?.trim() ?? ""; + if (!mediaTypes.some((m) => m === mediaType || m === "*/*")) { + throw new ContractViolation( + `${method} ${route} ${status} answered ${mediaType || "no content type"}; the contract declares ${mediaTypes.join(", ")}.`, + ); + } + return; + } + const mediaType = contentType.split(";")[0]?.trim() ?? ""; + const media = content[mediaType] ?? content["application/json"]; + if (!media) { + throw new ContractViolation( + `${method} ${route} ${status} answered JSON; the contract declares ${mediaTypes.join(", ") || "no body"}.`, + ); + } + if (!isZodSchema(media.schema)) return; + const parsed = media.schema.safeParse(body); + // A file download (a FHIR bundle, an export) is often declared as the + // string it is served as; check it as that string before calling it a + // mismatch. + if (!parsed.success && typeof body !== "string") { + const asText = media.schema.safeParse(JSON.stringify(body)); + if (asText.success) return; + } + if (!parsed.success) { + const issues = parsed.error.issues + .slice(0, 8) + .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`) + .join("\n"); + throw new ContractViolation( + `${method} ${route} ${status} does not match its registered schema:\n${issues}`, + ); + } + const undeclared = undeclaredKeys(body, parsed.data, ""); + if (undeclared.length > 0) { + throw new ContractViolation( + `${method} ${route} ${status} carries keys its registered schema does not declare: ${undeclared.slice(0, 12).join(", ")}`, + ); + } +} + +/** + * Keys present in what the handler sent and absent from what the schema + * parsed out of it. A closed `z.object` drops keys it does not declare, a + * loose object or a record keeps them, so the difference is exactly the set + * of fields the contract never mentions. + */ +function undeclaredKeys(input: unknown, output: unknown, at: string): string[] { + if (Array.isArray(input) && Array.isArray(output)) { + return input.flatMap((item, i) => + undeclaredKeys(item, output[i], `${at}[${i}]`), + ); + } + if (!isRecord(input) || !isRecord(output)) return []; + const found: string[] = []; + for (const key of Object.keys(input)) { + const path = at ? `${at}.${key}` : key; + if (!(key in output)) { + found.push(path); + continue; + } + found.push(...undeclaredKeys(input[key], output[key], path)); + } + return found; +} + +function isZodSchema(value: unknown): value is z.ZodType { + return ( + isRecord(value) && + typeof (value as { safeParse?: unknown }).safeParse === "function" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isEnvelope( + value: unknown, +): value is { data?: unknown; error?: unknown; meta?: unknown } { + return isRecord(value) && ("data" in value || "error" in value); +} diff --git a/tests/kit/factories.ts b/tests/kit/factories.ts new file mode 100644 index 000000000..1510314f6 --- /dev/null +++ b/tests/kit/factories.ts @@ -0,0 +1,219 @@ +/** + * Typed fixture factories for tests that run against the real database. + * + * Every factory writes through the application's Prisma client and returns + * the stored row, so a fixture has the shape production stores, checked by + * the compiler: a renamed or retyped column is a type error here, once, and + * not a silent mismatch in a hundred hand-written row literals cast past the + * type checker. Overrides are the Prisma create input for that model, so a + * test names only what it is about and takes a valid default for the rest. + * + * Credentials are minted the way the server mints them: a session through + * `createSession` and a Bearer token through `issueApiToken`, so a fixture + * cannot drift into a shape the authentication path no longer accepts. + * + * Only for files under `tests/integration/`; importing this anywhere else + * opens a connection to whatever `DATABASE_URL` points at. + */ +import { randomBytes } from "node:crypto"; + +import type { + AccountGrant, + Medication, + MedicationSchedule, + Measurement, + Prisma, + User, + Workout, +} from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { createSession, SESSION_COOKIE_NAME } from "@/lib/auth/session"; +import { issueApiToken } from "@/lib/auth/issue-token"; + +import { cookieJar } from "../integration/mock-next-headers"; + +let counter = 0; + +/** A short unique suffix, so fixtures never collide on a unique column. */ +export function uniqueSuffix(): string { + counter += 1; + return `${counter.toString(36)}${randomBytes(3).toString("hex")}`; +} + +type UserOverrides = Partial; + +/** + * An onboarded account with a password-less sign-in identity. Onboarding is + * marked complete because an account that has not finished it is redirected + * by the proxy and refused by a handful of write routes; a test about + * onboarding sets `onboardingCompletedAt: null` itself. + */ +export async function makeUser(overrides: UserOverrides = {}): Promise { + const suffix = uniqueSuffix(); + return prisma.user.create({ + data: { + username: `user-${suffix}`, + email: `user-${suffix}@example.test`, + onboardingCompletedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }, + }); +} + +/** An administrator account; admin routes also need a cookie session. */ +export async function makeAdmin(overrides: UserOverrides = {}): Promise { + return makeUser({ role: "ADMIN", ...overrides }); +} + +export interface SessionFixture { + kind: "session"; + userId: string; + sessionId: string; + /** The cookie value: the secret, never stored, exactly as a browser holds it. */ + cookie: string; +} + +/** + * A browser session minted by the real `createSession`. The secret is read + * off the cookie the function sets, and the jar is left as it was found, so + * minting a second session for another account does not sign the first one + * out of a request in flight. + */ +export async function makeSession( + user: Pick, + options: { mfaVerifiedAt?: Date | null } = {}, +): Promise { + const before = new Map(cookieJar); + const sessionId = await createSession( + user.id, + false, + "203.0.113.10", + "healthlog-kit", + options.mfaVerifiedAt ?? null, + ); + const cookie = cookieJar.get(SESSION_COOKIE_NAME); + cookieJar.clear(); + for (const [name, value] of before) cookieJar.set(name, value); + if (!cookie) { + throw new Error("createSession did not set the session cookie"); + } + return { kind: "session", userId: user.id, sessionId, cookie }; +} + +export interface BearerFixture { + kind: "bearer"; + userId: string; + tokenId: string; + /** The raw `hlk_…` value, which the server stores only as an HMAC. */ + token: string; + permissions: string[]; +} + +/** A Bearer token minted by the real `issueApiToken`. Wildcard by default. */ +export async function makeToken( + user: Pick, + options: { + permissions?: string[]; + name?: string; + expiresInDays?: number; + } = {}, +): Promise { + const permissions = options.permissions ?? ["*"]; + const issued = await issueApiToken({ + userId: user.id, + name: options.name ?? `kit-${uniqueSuffix()}`, + permissions, + expiresInDays: options.expiresInDays, + }); + return { + kind: "bearer", + userId: user.id, + tokenId: issued.tokenId, + token: issued.token, + permissions, + }; +} + +export async function makeMeasurement( + user: Pick, + overrides: Partial< + Omit + > = {}, +): Promise { + return prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 72.5, + unit: "kg", + measuredAt: new Date("2026-03-02T07:30:00Z"), + ...overrides, + }, + }); +} + +export async function makeMedication( + user: Pick, + overrides: Partial< + Omit + > = {}, + schedules: ReadonlyArray< + Partial> + > = [{}], +): Promise { + return prisma.medication.create({ + data: { + userId: user.id, + name: `Medication ${uniqueSuffix()}`, + dose: "10mg", + ...overrides, + schedules: { + create: schedules.map((schedule) => ({ + windowStart: "08:00", + windowEnd: "10:00", + ...schedule, + })), + }, + }, + include: { schedules: true }, + }); +} + +export async function makeWorkout( + user: Pick, + overrides: Partial> = {}, +): Promise { + const startedAt = new Date("2026-03-02T17:00:00Z"); + return prisma.workout.create({ + data: { + userId: user.id, + sportType: "running", + startedAt, + endedAt: new Date(startedAt.getTime() + 30 * 60 * 1000), + durationSec: 30 * 60, + ...overrides, + }, + }); +} + +/** + * A grant from `grantor` to `grantee`, accepted unless the override says + * otherwise. Read access by default; `access: "WRITE"` for a delegate who + * edits. + */ +export async function makeGrant( + grantor: Pick, + grantee: Pick, + overrides: Partial< + Omit + > = {}, +): Promise { + return prisma.accountGrant.create({ + data: { + grantorId: grantor.id, + granteeId: grantee.id, + acceptedAt: new Date(), + ...overrides, + }, + }); +}