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, + }, + }); +}