/** * Behavioural suite for the fail-closed Bearer-scope default, against a real * Postgres and the real resolver — no mocked Prisma, no mocked `requireAuth`. * * The gap this pins: `requireAuth()` enforced a token's scope only when the * route passed one, and 324 of 330 route files pass none. A token minted for * medication intake therefore reached the full-backup export, the labs surface, * the coach, and the bulk deletes. B1 is the direct regression test — it fails * on the pre-fix tree. * * Seven cases against 300-odd routes prove nothing about the other routes by * enumeration. The guarantee for those is structural (one resolution path, one * authorisation arm) and is held by * `src/__tests__/bearer-scope-enforcement-guard.test.ts`. What these cases do * is prove the arm itself behaves, end to end, for every credential shape the * app actually mints. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; process.env.API_TOKEN_HMAC_KEY ??= "test-hmac-key-bearer-scope-enforcement-32-bytes-min-0987654321"; const { hashToken } = await import("@/lib/auth/hmac"); const USER_ID = "user-bearer-scope-test"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { headers: vi.fn(async () => ({ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, })), cookies: vi.fn(async () => ({ get: (name: string) => { const value = cookieJar.get(name); return value ? { name, value } : undefined; }, set: (name: string, value: string) => { cookieJar.set(name, value); }, delete: (name: string) => { cookieJar.delete(name); }, })), }; }); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); await getPrismaClient().user.create({ data: { id: USER_ID, username: "bearer-scope", email: "bearer-scope@example.test", timezone: "UTC", }, }); }); /** Mint a real `ApiToken` row with the given scopes and arm the Bearer header. */ async function useToken(permissions: string[], label = "t"): Promise { const raw = `hlk_${label}_${"0".repeat(48)}`; await getPrismaClient().apiToken.create({ data: { userId: USER_ID, name: label, tokenHash: hashToken(raw), permissions, }, }); headerJar.set("authorization", `Bearer ${raw}`); return raw; } /** Arm a real cookie session instead of a Bearer token. */ async function useCookieSession(): Promise { const session = await getPrismaClient().session.create({ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); } function req(path: string, method = "GET"): NextRequest { const init: RequestInit = { method }; const raw = headerJar.get("authorization"); if (raw) init.headers = { authorization: raw }; return new NextRequest(`https://health.example${path}`, init as never); } /** The most recent `auth.bearer.failure` audit row's machine reason. */ async function lastBearerFailureReason(): Promise { const row = await getPrismaClient().auditLog.findFirst({ where: { action: "auth.bearer.failure" }, orderBy: { createdAt: "desc" }, }); if (!row?.details) return undefined; return (JSON.parse(row.details) as { reason?: string }).reason; } describe("B1 — a narrow token cannot read the full-account export", () => { it("refuses ['medication:ingest'] on GET /api/export/full-backup with 403", async () => { // The vulnerability in one request: this is the token the settings card // told users to hand to third-party automations, reading every row the // account holds. await useToken(["medication:ingest"], "narrow"); const { GET } = await import("@/app/api/export/full-backup/route"); const res = await GET(req("/api/export/full-backup")); expect(res.status).toBe(403); const json = (await res.json()) as { data: null; error: string }; expect(json.data).toBeNull(); expect(json.error).toBe("Insufficient permissions"); // The break is visible AND attributable: an operator can name the token. // Audit writes are fire-and-forget; give the microtask a beat to land. await new Promise((r) => setTimeout(r, 100)); expect(await lastBearerFailureReason()).toBe("undeclared_scope"); }); it("refuses the same token on GET /api/labs with 403", async () => { await useToken(["medication:ingest"], "narrow2"); const { GET } = await import("@/app/api/labs/route"); const res = await GET(req("/api/labs")); expect(res.status).toBe(403); }); }); describe("B2/B3 — the medication-ingest surface is unchanged", () => { async function seedMedication(): Promise { const med = await getPrismaClient().medication.create({ data: { userId: USER_ID, name: "Test Med", dose: "1 mg" }, }); return med.id; } it("B2 — the per-medication token still ingests", async () => { // The credential that actually performs medication intake. It never // touches `requireAuth` — `/api/ingest/medication` hand-rolls the whole // resolution — so the fail-closed default cannot reach it. This is the // one flow a fail-closed default could plausibly have broken. const medId = await seedMedication(); await useToken(["medication:ingest", `medication:${medId}:ingest`], "pair"); const { POST } = await import("@/app/api/ingest/medication/route"); const request = new NextRequest( "https://health.example/api/ingest/medication", { method: "POST", headers: { authorization: headerJar.get("authorization")!, "content-type": "application/json", }, body: JSON.stringify({ medicationName: "Test Med", idempotencyKey: "bearer-scope-b2", }), } as never, ); const res = await POST(request); expect([200, 201]).toContain(res.status); const events = await getPrismaClient().medicationIntakeEvent.count({ where: { userId: USER_ID }, }); expect(events).toBe(1); }); it("B3 — a token without the per-medication grant is still refused", async () => { // The pre-existing second gate. This is why the retired `/api/tokens` // mint never worked for its advertised purpose. await seedMedication(); await useToken(["medication:ingest"], "familyonly"); const { POST } = await import("@/app/api/ingest/medication/route"); const request = new NextRequest( "https://health.example/api/ingest/medication", { method: "POST", headers: { authorization: headerJar.get("authorization")!, "content-type": "application/json", }, body: JSON.stringify({ medicationName: "Test Med", idempotencyKey: "bearer-scope-b3", }), } as never, ); const res = await POST(request); expect(res.status).toBe(403); }); }); describe("B4 — the native client is not broken", () => { it("admits a ['*'] token on GET /api/export/full-backup", async () => { // Login, passkey login-verify and refresh rotation all mint `["*"]`, so // this is exactly the credential the iOS app holds. If this case ever // goes red, the native client is down. await useToken(["*"], "wildcard"); const { GET } = await import("@/app/api/export/full-backup/route"); const res = await GET(req("/api/export/full-backup")); expect(res.status).toBe(200); }); it("admits a ['*'] token on a batch ingest route", async () => { await useToken(["*"], "wildcardbatch"); const { POST } = await import("@/app/api/measurements/batch/route"); const request = new NextRequest( "https://health.example/api/measurements/batch", { method: "POST", headers: { authorization: headerJar.get("authorization")!, "content-type": "application/json", }, body: JSON.stringify({ entries: [ { hkIdentifier: "HKQuantityTypeIdentifierBodyMass", value: 70, unit: "kg", startDate: new Date().toISOString(), endDate: new Date().toISOString(), externalId: "uuid-bearer-scope-1", }, ], }), } as never, ); const res = await POST(request); expect(res.status).toBe(200); }); }); describe("B5 — an MCP token is audience-bound to /mcp", () => { it("refuses ['health:read'] on a REST read", async () => { // Narrowing, not breakage: the token's REST read leg was never a feature // an MCP client used. Its audience is now /mcp alone. await useToken(["health:read"], "mcpread"); const { GET } = await import("@/app/api/export/full-backup/route"); const res = await GET(req("/api/export/full-backup")); expect(res.status).toBe(403); }); it("still resolves the same token on the /mcp wire", async () => { const raw = await useToken(["health:read"], "mcpread2"); const { resolveMcpAuthContext } = await import("@/lib/mcp/auth"); const ctx = await resolveMcpAuthContext(raw); expect(ctx.userId).toBe(USER_ID); expect(ctx.canRead).toBe(true); // Read-only token: the write tools stay shut. expect(ctx.canWrite).toBe(false); }); it("grants write on /mcp only for a consented health:write token", async () => { const raw = await useToken(["health:read", "health:write"], "mcprw"); const { resolveMcpAuthContext } = await import("@/lib/mcp/auth"); const ctx = await resolveMcpAuthContext(raw); expect(ctx.canWrite).toBe(true); }); }); describe("B6 — a declared scope grants only what it names", () => { it("admits ['fhir:read'] on the FHIR face", async () => { await useToken(["fhir:read"], "fhir"); const { GET } = await import("@/app/api/fhir/Observation/route"); const res = await GET(req("/api/fhir/Observation")); expect(res.status).toBe(200); }); it("refuses the same token on /api/labs, which declares no scope", async () => { // The whole point of the inversion: holding a scope buys the routes that // name it, and nothing else. await useToken(["fhir:read"], "fhir2"); const { GET } = await import("@/app/api/labs/route"); const res = await GET(req("/api/labs")); expect(res.status).toBe(403); }); }); describe("B7 — cookie sessions are untouched", () => { it("reaches every route the narrow token was refused on", async () => { await useCookieSession(); const backup = await import("@/app/api/export/full-backup/route"); expect((await backup.GET(req("/api/export/full-backup"))).status).toBe(200); const labs = await import("@/app/api/labs/route"); expect((await labs.GET(req("/api/labs"))).status).toBe(200); const fhir = await import("@/app/api/fhir/Observation/route"); expect((await fhir.GET(req("/api/fhir/Observation"))).status).toBe(200); }); it("requireAdmin still refuses a ['*'] Bearer token", async () => { // The cookie-only property of `requireAdmin()` is orthogonal to this // change and must stay orthogonal. A wildcard token is the strongest // Bearer credential the app mints and it still cannot elevate. await getPrismaClient().user.update({ where: { id: USER_ID }, data: { role: "ADMIN" }, }); await useToken(["*"], "adminwild"); const { requireAdmin, HttpError } = await import("@/lib/api-handler"); await expect(requireAdmin()).rejects.toBeInstanceOf(HttpError); }); });