diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts new file mode 100644 index 000000000..53ca21e44 --- /dev/null +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -0,0 +1,135 @@ +/** + * Structural guards on the acting-account boundary. + * + * The behavioural tests in `src/lib/__tests__/acting-account-resolver.test.ts` + * prove what today's code does. These prove what tomorrow's may not do, and + * they exist because the boundary has one shape that keeps it safe: the acting + * account is a value ONE function is allowed to act on, and the helpers that + * decide role and step-up never see it. + * + * Tripwires, not proofs. They cannot show the resolver is correct — only that + * the boundary has not moved without somebody editing this file. Every leg + * below asserts a non-zero match count first, because a guard whose matcher + * finds nothing reports success, and this repository has shipped several of + * those. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { globSync } from "node:fs"; +import { join, sep } from "node:path"; + +const SRC = join(process.cwd(), "src"); + +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .filter( + (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function filesMatching(re: RegExp): string[] { + return sourceFiles().filter((rel) => re.test(read(rel))); +} + +/** The source text of a top-level exported function, braces included. */ +function functionBody(src: string, name: string): string { + const start = src.indexOf(`export async function ${name}(`); + if (start === -1) return ""; + const open = src.indexOf("{", src.indexOf(")", start)); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}") { + depth -= 1; + if (depth === 0) return src.slice(open, i + 1); + } + } + return ""; +} + +describe("the acting-account carrier is read in one place", () => { + /** + * Files allowed to touch `Session.actingAsUserId`. + * + * The column is a selector — it names an account, it does not authorise one. + * Anything that reads it and acts on the value without going through the + * resolver has invented a second answer to "whose record is this", and the + * copy that drifts is always the one nobody reads. + * + * Later items in this feature will extend this list, and that is the point: + * the switch endpoint (which writes it) and revocation cleanup (which clears + * it) arrive as a visible diff here rather than as a quiet new reader. + * + * The matcher reads source text and does not exempt comments, so a file that + * merely NAMES the column in prose trips it. Left that way deliberately: a + * guard over an authorization carrier that shouts at a mention is the right + * direction to be wrong in. + */ + const CARRIER_ALLOWLIST = [ + // Projects it off the session row it already loaded. + "lib/auth/session.ts", + // The resolver. The only thing that acts on the value. + "lib/api-handler.ts", + ].sort(); + + it("no other file reads or writes the carrier column", () => { + const readers = filesMatching(/actingAsUserId|acting_as_user_id/); + // Non-zero proof: an emptied allowlist must fail rather than agree with an + // emptied match set. + expect(readers.length).toBeGreaterThan(0); + expect(readers).toEqual(CARRIER_ALLOWLIST); + }); + + it("the selector header is named in one place", () => { + // A route that read the header itself would be deciding an authorisation + // question the resolver exists to answer once. + const namers = filesMatching(/x-healthlog-account/i); + expect(namers.length).toBeGreaterThan(0); + expect(namers).toEqual(["lib/api-handler.ts"]); + }); +}); + +describe("the cookie-only helpers cannot see the acting account", () => { + /** + * `requireAdmin`, `requireCookieAuth` and `requireFreshMfa` are safe under a + * switch for one reason: they resolve through `getSession()`, which answers + * who is CALLING, and they never consult the carrier. That is the property + * that keeps role checks and step-up gates structurally out of a delegate's + * reach, and it is one careless line away from being untrue. + */ + const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"]; + + it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => { + const body = functionBody(read("lib/api-handler.ts"), name); + + // Non-zero proof, twice over: the slice found a function, and the slice is + // the function it claims to be. A renamed helper must fail here rather + // than silently assert against an empty string. + expect(body.length).toBeGreaterThan(0); + expect(body).toContain("getSession()"); + + expect(body).not.toContain("actingAsUserId"); + expect(body).not.toContain("requireRecordAuth"); + expect(body).not.toContain("readActingCarrier"); + }); + + it("substitutes a data-scope user in exactly one function", () => { + const src = read("lib/api-handler.ts"); + const record = functionBody(src, "requireRecordAuth"); + expect(record).toContain("readActingCarrier"); + + // `readActingCarrier` is what turns a request into "acting as somebody + // else". Two callers: the bare path, which refuses on it, and the record + // path, which acts on it. A third would be a mode nobody declared. + const callers = (src.match(/await readActingCarrier\(/g) ?? []).length; + expect(callers).toBe(2); + }); +}); diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts new file mode 100644 index 000000000..fe8e624dc --- /dev/null +++ b/src/lib/__tests__/acting-account-resolver.test.ts @@ -0,0 +1,616 @@ +/** + * v1.36.0 — the acting-account resolver, arm by arm. + * + * Three things are worth more here than anywhere else in the feature, because + * everything downstream trusts them: + * + * 1. **It fails closed.** A bare `requireAuth()` reached under an active + * switch refuses. It must never fall back to the caller's own account — + * a delegate who believes they are reading the owner's record being handed + * their own is the same data-mixing failure, pointed the other way. + * 2. **Refusals do not enumerate.** A selector naming an account that does + * not exist and one naming an account that granted nothing produce the + * same bytes. Asserted as bytes, not as "both were 403". + * 3. **Nothing is cached.** The grant is loaded on the request that uses it. + * Proven against a real database in + * `tests/integration/acting-account-resolver.test.ts`, where revoking + * between two requests is a thing that can actually happen. + * + * The grant module is deliberately NOT mocked. The resolver consuming S1's + * `isGrantActive` rather than its own idea of "active" is the property that + * keeps the panel and the resolver from disagreeing, and a mocked predicate + * would report that property working while it was broken. The Prisma fake below + * honours its `where` clause for the same reason: a fake that ignores `where` + * turns every scoping assertion into a check that cannot fail. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --- Mocks must be hoisted before importing the module under test. --- + +interface GrantRow { + id: string; + grantorId: string; + granteeId: string; + access: "READ" | "WRITE"; + acceptedAt: Date | null; + revokedAt: Date | null; + expiresAt: Date | null; +} + +const grantRows: GrantRow[] = []; +const userRows = new Map(); +const touched: string[] = []; + +vi.mock("@/lib/db", () => ({ + prisma: { + accountGrant: { + // Honours the narrowing the real query performs. `findActiveGrant` + // narrows on `revokedAt: null` in SQL and leaves accepted/expired to the + // state machine; a fake that returned any row for any `where` would make + // the cross-user arms below pass no matter what the resolver did. + findFirst: vi.fn( + async ({ + where, + }: { + where: { + grantorId: string; + granteeId: string; + revokedAt: null; + }; + }) => + grantRows.find( + (g) => + g.grantorId === where.grantorId && + g.granteeId === where.granteeId && + (where.revokedAt === null ? g.revokedAt === null : true), + ) ?? null, + ), + update: vi.fn(async ({ where }: { where: { id: string } }) => { + touched.push(where.id); + return {}; + }), + }, + user: { + findUnique: vi.fn( + async ({ where }: { where: { id: string } }) => + userRows.get(where.id) ?? null, + ), + }, + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, + session: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +const headerJar = new Map(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// --- Imports use the mocked modules above. --- + +import { NextRequest, NextResponse } from "next/server"; +import { + ACCOUNT_SELECTOR_HEADER, + apiHandler, + requireActorAuth, + requireAdmin, + requireAuth, + requireCookieAuth, + requireFreshMfa, + requireRecordAuth, + type RecordAuthContext, +} from "../api-handler"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { emitIfSampled } from "@/lib/logging/transports"; +import { prisma } from "@/lib/db"; +import type { WideEvent } from "@/lib/logging/types"; + +const OWNER = "owner-1"; +const DELEGATE = "delegate-1"; + +function user(id: string, role: "USER" | "ADMIN" = "USER") { + return { id, role, username: id, email: `${id}@example.test` }; +} + +/** Sign the delegate in over the cookie transport. */ +function signedInCookie( + actingAsUserId: string | null = null, + role: "USER" | "ADMIN" = "USER", +): void { + vi.mocked(getSession).mockResolvedValue({ + session: { + id: "session-1", + expiresAt: new Date(Date.now() + 3_600_000), + actingAsUserId, + }, + user: user(DELEGATE, role) as never, + }); +} + +/** Sign the delegate in over the Bearer transport. */ +function signedInBearer(): void { + vi.mocked(getSession).mockResolvedValue(null); + headerJar.set("authorization", "Bearer hlk_" + "a".repeat(64)); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-1", + userId: DELEGATE, + permissions: ["*"], + revoked: false, + expiresAt: new Date(Date.now() + 3_600_000), + } as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + userRows.set(DELEGATE, user(DELEGATE)); +} + +function selector(value: string): void { + headerJar.set(ACCOUNT_SELECTOR_HEADER, value); +} + +function liveGrant(overrides: Partial = {}): GrantRow { + const row: GrantRow = { + id: "grant-1", + grantorId: OWNER, + granteeId: DELEGATE, + access: "READ", + acceptedAt: new Date(Date.now() - 60_000), + revokedAt: null, + expiresAt: null, + ...overrides, + }; + grantRows.push(row); + return row; +} + +/** + * Run a resolver call the way a route runs it: inside `apiHandler`, against a + * real `NextRequest`, so the HTTP method the resolver reads is the one the + * request actually carried and the refusal is a real HTTP response. + */ +function route( + resolve: () => Promise, +): (method?: string) => Promise { + // A zero-argument handler is assignable to the one-argument shape Next.js + // calls; naming the type here keeps the call site honest without an unused + // parameter. + const handler: (request: NextRequest) => Promise = apiHandler( + async () => { + await resolve(); + return NextResponse.json({ data: null, error: null }); + }, + ); + return (method = "GET") => + handler(new NextRequest("http://localhost/api/test", { method })); +} + +/** + * Resolve inside a route and hand back what the handler body would have seen. + * Every substitution assertion runs through here rather than calling the + * resolver bare, because the HTTP method is part of the decision and a bare + * call has none. + */ +async function resolveInRoute( + resolve: () => Promise, + method = "GET", +): Promise { + let value: T | undefined; + const response = await route(async () => { + value = await resolve(); + })(method); + expect(response.status).toBe(200); + return value as T; +} + +/** The wide event the last run emitted. */ +function lastEvent(): WideEvent { + const calls = vi.mocked(emitIfSampled).mock.calls; + return calls[calls.length - 1][0] as WideEvent; +} + +function sharingAuditCalls() { + return vi + .mocked(auditLog) + .mock.calls.filter(([action]) => action === "sharing.access.denied"); +} + +beforeEach(() => { + vi.clearAllMocks(); + grantRows.length = 0; + userRows.clear(); + touched.length = 0; + headerJar.clear(); + userRows.set(OWNER, user(OWNER)); + userRows.set(DELEGATE, user(DELEGATE)); +}); + +describe("do no harm — nothing is acting as anyone", () => { + it("resolves every mode to the caller, and never asks about a grant", async () => { + signedInCookie(null); + + const bare = await resolveInRoute(() => requireAuth()); + expect(bare.user.id).toBe(DELEGATE); + + const actor = await resolveInRoute(() => requireActorAuth()); + expect(actor.user.id).toBe(DELEGATE); + + const record = await resolveInRoute(() => requireRecordAuth("read")); + expect(record.user.id).toBe(DELEGATE); + expect(record.actor.id).toBe(DELEGATE); + expect(record.grantId).toBeNull(); + + // No carrier means no authorization question was ever asked. If this + // fires, the resolver is doing work on the un-switched path — which is + // where 100% of today's traffic lives. + expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled(); + expect(sharingAuditCalls()).toHaveLength(0); + }); + + it("leaves the wide event's actor identity alone", async () => { + signedInCookie(null); + const call = route(() => requireRecordAuth("read")); + await call(); + + expect(lastEvent().auth?.user_id).toBe(DELEGATE); + expect(lastEvent().auth?.acting_as).toBeUndefined(); + }); +}); + +describe("bare requireAuth refuses to run under a switch", () => { + it("refuses the session carrier without falling back to the caller", async () => { + signedInCookie(OWNER); + liveGrant(); + + const response = await route(() => requireAuth())(); + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.meta.errorCode).toBe("sharing.not_permitted"); + expect(body.data).toBeNull(); + }); + + it("refuses even when the grant behind the switch is perfectly valid", async () => { + // The point of the fail-closed arm: the grant is not the question. The + // route never declared that it can be used on someone else's record. + signedInCookie(OWNER); + liveGrant(); + + await expect(requireAuth()).rejects.toMatchObject({ + errorCode: "sharing.not_permitted", + statusCode: 403, + }); + }); + + it("refuses a Bearer selector, and records it", async () => { + signedInBearer(); + selector(OWNER); + liveGrant(); + + await expect(requireAuth()).rejects.toMatchObject({ + errorCode: "sharing.not_permitted", + }); + expect(sharingAuditCalls()).toHaveLength(1); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + userId: DELEGATE, + details: expect.objectContaining({ + reason: "undeclared_mode", + carrier: "header", + target: OWNER, + }), + }); + }); + + it("refuses a selector sent over the cookie transport", async () => { + // The header is a Bearer-only carrier. Ignoring it here would serve the + // caller's own record to a client that believes it asked for another's. + signedInCookie(null); + selector(OWNER); + + await expect(requireAuth()).rejects.toMatchObject({ + errorCode: "sharing.not_permitted", + }); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ carrier: "misplaced-header" }), + }); + }); + + it("keeps the ambient web refusal out of the audit table", async () => { + // Every navigation and poll that touches a non-delegable route lands here + // while a switch is on. A row per request would bury the header refusals, + // which are the ones that mean something. + signedInCookie(OWNER); + await expect(requireAuth()).rejects.toThrow(); + expect(sharingAuditCalls()).toHaveLength(0); + }); +}); + +describe("requireActorAuth — always the caller's own rows", () => { + it("works under a session switch and answers as the caller", async () => { + signedInCookie(OWNER); + liveGrant(); + + const ctx = await requireActorAuth(); + expect(ctx.user.id).toBe(DELEGATE); + // The switcher, the banner and the "whose records may I open" list all run + // while switched, and every one of them is about the delegate. + expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled(); + }); + + it("refuses a Bearer selector loudly", async () => { + signedInBearer(); + selector(OWNER); + liveGrant(); + + await expect(requireActorAuth()).rejects.toMatchObject({ + errorCode: "sharing.not_permitted", + statusCode: 403, + }); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ reason: "actor_surface" }), + }); + }); + + it("refuses a selector on the cookie transport too", async () => { + signedInCookie(null); + selector(OWNER); + await expect(requireActorAuth()).rejects.toMatchObject({ + errorCode: "sharing.not_permitted", + }); + }); +}); + +describe("requireRecordAuth — substitution", () => { + it("substitutes the owner as the data scope and keeps the delegate as actor", async () => { + signedInCookie(OWNER); + const grant = liveGrant(); + + const resolved: RecordAuthContext = await resolveInRoute(() => + requireRecordAuth("read"), + ); + expect(resolved.user.id).toBe(OWNER); + expect(resolved.actor.id).toBe(DELEGATE); + expect(resolved.grantId).toBe(grant.id); + }); + + it("substitutes over the Bearer selector as well", async () => { + signedInBearer(); + selector(OWNER); + liveGrant(); + + const resolved = await resolveInRoute(() => requireRecordAuth("read")); + expect(resolved.user.id).toBe(OWNER); + expect(resolved.actor.id).toBe(DELEGATE); + }); + + it("names the acting account on the wide event without moving the actor", async () => { + signedInCookie(OWNER); + liveGrant(); + + await route(() => requireRecordAuth("read"))(); + expect(lastEvent().auth?.user_id).toBe(DELEGATE); + expect(lastEvent().auth?.acting_as).toBe(OWNER); + }); + + it("stamps the grant as used without making the read wait on it", async () => { + signedInCookie(OWNER); + const grant = liveGrant(); + + await resolveInRoute(() => requireRecordAuth("read")); + await new Promise((r) => setTimeout(r, 0)); + expect(touched).toEqual([grant.id]); + }); +}); + +describe("requireRecordAuth — every way a grant can fail", () => { + const refused = async (): Promise => { + const response = await route(() => requireRecordAuth("read"))(); + expect(response.status).toBe(403); + expect((await response.clone().json()).meta.errorCode).toBe( + "sharing.access.denied", + ); + return response; + }; + + it("refuses a pending invitation", async () => { + signedInCookie(OWNER); + liveGrant({ acceptedAt: null }); + await refused(); + }); + + it("refuses a revoked grant", async () => { + signedInCookie(OWNER); + liveGrant({ revokedAt: new Date(Date.now() - 1000) }); + await refused(); + }); + + it("refuses an expired grant", async () => { + signedInCookie(OWNER); + liveGrant({ expiresAt: new Date(Date.now() - 1000) }); + await refused(); + }); + + it("refuses a grant made to somebody else", async () => { + signedInCookie(OWNER); + liveGrant({ granteeId: "other-delegate" }); + await refused(); + }); + + it("refuses a write method under a read grant", async () => { + signedInCookie(OWNER); + liveGrant(); + + const response = await route(() => requireRecordAuth("read"))("POST"); + expect(response.status).toBe(403); + expect((await response.json()).meta.errorCode).toBe( + "sharing.access.denied", + ); + }); + + it("refuses a declared write under a read grant", async () => { + signedInCookie(OWNER); + liveGrant(); + + const response = await route(() => requireRecordAuth("write"))(); + expect(response.status).toBe(403); + }); + + it("admits a write method once the grant says WRITE", async () => { + // v1 never writes a WRITE row, so this arm is reached by the data rather + // than by a special case — which is what makes v2 enforcement work instead + // of a rewrite. + signedInCookie(OWNER); + liveGrant({ access: "WRITE" }); + + const response = await route(() => requireRecordAuth("write"))("POST"); + expect(response.status).toBe(200); + }); + + it("refuses when the method cannot be known", async () => { + // Outside an event context there is no method to check, so we cannot prove + // the request is a read. Same fail-closed posture as the MCP audience + // guard. + signedInCookie(OWNER); + liveGrant(); + await expect(requireRecordAuth("read")).rejects.toMatchObject({ + errorCode: "sharing.access.denied", + }); + }); + + it("refuses a selector too long to name an account, without querying", async () => { + signedInBearer(); + selector("x".repeat(65)); + await expect(requireRecordAuth("read")).rejects.toMatchObject({ + errorCode: "sharing.access.denied", + }); + expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled(); + }); + + it("refuses a selector sent over the cookie transport", async () => { + signedInCookie(null); + selector(OWNER); + liveGrant(); + await expect(requireRecordAuth("read")).rejects.toMatchObject({ + errorCode: "sharing.not_permitted", + }); + }); + + it("refuses when the owner row is gone under a surviving grant", async () => { + signedInCookie(OWNER); + liveGrant(); + userRows.delete(OWNER); + await refused(); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ reason: "owner_missing" }), + }); + }); +}); + +describe("refusals do not enumerate", () => { + it("answers identically for an account that does not exist and one that granted nothing", async () => { + signedInBearer(); + selector("no-such-account-at-all"); + const missing = await route(() => requireRecordAuth("read"))(); + const missingBody = await missing.text(); + + vi.clearAllMocks(); + headerJar.clear(); + signedInBearer(); + // An account that exists, has a session, has data, and simply never + // granted this caller anything. + userRows.set("real-stranger", user("real-stranger")); + selector("real-stranger"); + const present = await route(() => requireRecordAuth("read"))(); + const presentBody = await present.text(); + + expect(missing.status).toBe(present.status); + // Bytes, not statuses. A difference of one word here is an account + // enumeration oracle for anyone holding a login. + expect(missingBody).toBe(presentBody); + }); + + it("answers identically for a revoked grant and a grant that never existed", async () => { + signedInBearer(); + selector(OWNER); + liveGrant({ revokedAt: new Date(Date.now() - 1000) }); + const revoked = await route(() => requireRecordAuth("read"))(); + const revokedBody = await revoked.text(); + + vi.clearAllMocks(); + headerJar.clear(); + grantRows.length = 0; + signedInBearer(); + selector(OWNER); + const never = await route(() => requireRecordAuth("read"))(); + + expect(revokedBody).toBe(await never.text()); + }); + + it("keeps the reason off the wire and on the audit row", async () => { + signedInBearer(); + selector(OWNER); + liveGrant({ expiresAt: new Date(Date.now() - 1000) }); + + const body = await (await route(() => requireRecordAuth("read"))()).text(); + expect(body).not.toContain("expire"); + expect(body).not.toContain("grant"); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ reason: "no_active_grant" }), + }); + }); +}); + +describe("the cookie-only helpers stay out of reach of a switch", () => { + it("resolves requireAdmin against the actor, never the account being acted on", async () => { + signedInCookie(OWNER, "ADMIN"); + liveGrant(); + userRows.set(OWNER, user(OWNER, "USER")); + + const ctx = await requireAdmin(); + // The role came off the caller's row. Switching into somebody's record + // must confer nothing, and switching out of an admin's must take nothing + // away. + expect(ctx.user.id).toBe(DELEGATE); + }); + + it("resolves requireCookieAuth against the actor", async () => { + signedInCookie(OWNER); + liveGrant(); + const ctx = await requireCookieAuth(); + expect(ctx.user.id).toBe(DELEGATE); + }); + + it("resolves requireFreshMfa against the actor", async () => { + signedInCookie(OWNER); + liveGrant(); + vi.mocked(getSession).mockResolvedValue({ + session: { + id: "session-1", + expiresAt: new Date(Date.now() + 3_600_000), + actingAsUserId: OWNER, + }, + user: { + ...user(DELEGATE), + totpConfirmedAt: new Date(Date.now() - 86_400_000), + } as never, + }); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(), + } as never); + + const ctx = await requireFreshMfa(300); + expect(ctx.user.id).toBe(DELEGATE); + }); +}); diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts new file mode 100644 index 000000000..a718a7bdd --- /dev/null +++ b/tests/integration/acting-account-resolver.test.ts @@ -0,0 +1,382 @@ +/** + * The acting-account resolver against real Postgres. + * + * The unit suite proves the arms. This file proves the two things a unit suite + * cannot, because both are about which ROWS come back: + * + * * **Substitution.** Under a valid switch the handler's `where` clause is + * built from the owner's id and returns the owner's measurements — not a + * mock's idea of them, and demonstrably not the delegate's own, which are + * seeded alongside so a resolver that quietly served the caller their own + * record would show up as the wrong ids rather than as an empty list. + * * **Nothing is cached.** A revocation lands between two requests inside one + * test and the second one 403s. Anything memoised — on the session, in the + * process, on the token — would let the first request's verdict survive + * into the second, and the owner's revoke button would mean "at some point + * later" instead of "now". + * + * The do-no-harm arm runs against a REAL shipped route (`GET /api/measurements`, + * a bare-`requireAuth` handler nobody has migrated). A mock cannot fake that, + * and the arm is worth nothing unless the route it exercises is one somebody + * actually ships. + * + * The delegable handler is assembled here rather than imported because no route + * declares `requireRecordAuth` yet — S3 lands the frozen list empty on purpose, + * and S6 migrates the first route into it. Everything under it is real: the + * real `apiHandler`, the real resolver, the real grant module, the real + * database. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest, NextResponse } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +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), +})); + +import { + ACCOUNT_SELECTOR_HEADER, + apiHandler, + requireRecordAuth, +} from "@/lib/api-handler"; +import { hashToken } from "@/lib/auth/hmac"; +import { prisma } from "@/lib/db"; +import { acceptGrant, inviteGrant, revokeGrant } from "@/lib/sharing/grants"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `acting-${suffix}`, + email: `acting-${suffix}@example.test`, + role: "USER", + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function mintToken(userId: string): Promise { + const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId, + name: "acting-account-test", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + return raw; +} + +async function seedWeight(userId: string, value: number) { + return getPrismaClient().measurement.create({ + data: { + userId, + type: "WEIGHT", + value, + unit: "kg", + measuredAt: new Date(), + source: "MANUAL", + }, + }); +} + +/** + * A delegable read handler: the real wrapper, the real resolver, and a `where` + * clause built from the resolved data-scope user exactly as a migrated route + * builds one. + */ +const delegableRead: (request: NextRequest) => Promise = apiHandler( + async () => { + const { user, actor, grantId } = await requireRecordAuth("read"); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id }, + orderBy: { value: "asc" }, + }); + return NextResponse.json({ + data: { + scopeUserId: user.id, + actorUserId: actor.id, + grantId, + values: rows.map((r) => r.value), + }, + error: null, + }); + }, +); + +function call(method = "GET"): Promise { + return delegableRead( + new NextRequest("http://localhost/api/test/delegable", { method }), + ); +} + +/** Owner and delegate, an accepted grant, and one measurement each. */ +async function household() { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedWeight(owner.id, 81); + await seedWeight(delegate.id, 64); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + }); + const grant = await acceptGrant({ + grantId: invited.id, + granteeId: delegate.id, + ip: "203.0.113.7", + }); + return { owner, delegate, grant }; +} + +/** Point the delegate's browser session at the owner's record. */ +async function switchTo(sessionId: string, ownerId: string | null) { + await getPrismaClient().session.update({ + where: { id: sessionId }, + data: { actingAsUserId: ownerId }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("acting-account resolver — substitution", () => { + it("returns the OWNER's rows to a switched delegate, not the delegate's own", async () => { + const { owner, delegate, grant } = await household(); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + const body = await (await call()).json(); + expect(body.data.scopeUserId).toBe(owner.id); + expect(body.data.actorUserId).toBe(delegate.id); + expect(body.data.grantId).toBe(grant.id); + // The delegate's own 64 kg row exists and must not be in this answer. An + // empty list would have passed a weaker assertion while hiding the exact + // failure this feature has to avoid. + expect(body.data.values).toEqual([81]); + }); + + it("substitutes over the Bearer selector too", async () => { + const { owner, delegate } = await household(); + const raw = await mintToken(delegate.id); + headerJar.set("authorization", `Bearer ${raw}`); + headerJar.set(ACCOUNT_SELECTOR_HEADER, owner.id); + + const body = await (await call()).json(); + expect(body.data.scopeUserId).toBe(owner.id); + expect(body.data.values).toEqual([81]); + }); + + it("stamps the grant as used", async () => { + const { owner, delegate, grant } = await household(); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + await call(); + // Fire-and-forget, so give the stamp a tick to land before reading it. + await new Promise((resolve) => setTimeout(resolve, 50)); + const row = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: grant.id }, + }); + expect(row.lastUsedAt).not.toBeNull(); + }); + + it("serves the caller their own rows when nothing is switched", async () => { + const { delegate } = await household(); + await signIn(delegate.id); + + const body = await (await call()).json(); + expect(body.data.scopeUserId).toBe(delegate.id); + expect(body.data.grantId).toBeNull(); + expect(body.data.values).toEqual([64]); + }); +}); + +describe("acting-account resolver — the decision is never cached", () => { + it("refuses the request after the one that came before it succeeded", async () => { + const { owner, delegate, grant } = await household(); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + const first = await call(); + expect(first.status).toBe(200); + expect((await first.json()).data.values).toEqual([81]); + + await revokeGrant({ grantId: grant.id, grantorId: owner.id }); + + const second = await call(); + expect(second.status).toBe(403); + expect((await second.json()).meta.errorCode).toBe("sharing.access.denied"); + }); + + it("stops on expiry with nothing having swept anything", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedWeight(owner.id, 81); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + expiresAt: new Date(Date.now() + 400), + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + expect((await call()).status).toBe(200); + await new Promise((resolve) => setTimeout(resolve, 500)); + // The row did not change. Only the clock the resolver reads moved. + expect((await call()).status).toBe(403); + }); + + it("refuses the moment the grant is only pending", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + expect((await call()).status).toBe(403); + }); +}); + +describe("acting-account resolver — do no harm", () => { + it("leaves an un-switched request on a real shipped route exactly as it was", async () => { + const user = await makeUser("solo"); + await seedWeight(user.id, 72); + await signIn(user.id); + + const { GET } = await import("@/app/api/measurements/route"); + const response = await GET( + new NextRequest("http://localhost/api/measurements?type=WEIGHT"), + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect( + body.data.measurements.map((m: { value: number }) => m.value), + ).toEqual([72]); + }); + + it("refuses that same route while a switch is active, and serves nobody's rows", async () => { + const { owner, delegate } = await household(); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + const { GET } = await import("@/app/api/measurements/route"); + const response = await GET( + new NextRequest("http://localhost/api/measurements?type=WEIGHT"), + ); + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.meta.errorCode).toBe("sharing.not_permitted"); + expect(body.data).toBeNull(); + // Not the owner's rows, and — the failure that matters — not the + // delegate's own either. + expect(JSON.stringify(body)).not.toContain("64"); + }); +}); + +describe("acting-account resolver — refusals do not enumerate", () => { + it("answers a nonexistent account and a stranger's account with the same bytes", async () => { + const { delegate } = await household(); + const stranger = await makeUser("stranger"); + await seedWeight(stranger.id, 99); + const raw = await mintToken(delegate.id); + headerJar.set("authorization", `Bearer ${raw}`); + + headerJar.set(ACCOUNT_SELECTOR_HEADER, "cmnosuchaccount000000000x"); + const missing = await call(); + const missingBody = await missing.text(); + + headerJar.set(ACCOUNT_SELECTOR_HEADER, stranger.id); + const present = await call(); + const presentBody = await present.text(); + + expect(missing.status).toBe(403); + expect(present.status).toBe(403); + expect(missingBody).toBe(presentBody); + }); +}); + +describe("acting-account resolver — the cookie-only helpers", () => { + it("resolves the actor under a live switch, against the real session row", async () => { + // The unit suite asserts this with `getSession` mocked, which cannot catch + // the one edit that would break it: `getSession` itself substituting the + // owner. Here the row is real, the switch is real, and the grant behind it + // is valid — so the only reason these helpers answer with the delegate is + // that `getSession` still answers "who is calling". + const { owner, delegate } = await household(); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + const { requireAdmin, requireCookieAuth } = + await import("@/lib/api-handler"); + const cookieCtx = await requireCookieAuth(); + expect(cookieCtx.user.id).toBe(delegate.id); + + // The delegate is not an admin; the refusal proves the role was read off + // the caller's row and not the record owner's. + await getPrismaClient().user.update({ + where: { id: owner.id }, + data: { role: "ADMIN" }, + }); + await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 403 }); + }); +}); + +describe("acting-account resolver — the carrier column", () => { + it("puts the delegate's browser back in its own account when the owner is deleted", async () => { + const { owner, delegate } = await household(); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + await getPrismaClient().user.delete({ where: { id: owner.id } }); + + const row = await getPrismaClient().session.findUnique({ + where: { id: session.id }, + }); + // The session survives (it belongs to the delegate) with the switch + // cleared. A CASCADE here would have signed the delegate out of their own + // account because somebody else deleted theirs. + expect(row).not.toBeNull(); + expect(row!.actingAsUserId).toBeNull(); + + const body = await (await call()).json(); + expect(body.data.scopeUserId).toBe(delegate.id); + }); +});