From 255cbf5fe0ac5f711e40a1c2aec195425a51fd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 16:30:46 +0200 Subject: [PATCH] feat(sharing): open the measurement reads to a delegate Six routes move from `requireAuth()` to `requireRecordAuth("read")`: the measurement list, the single reading, the two series reads, and the two reminder reads. Each is admitted in the delegable classification with its five answers written; each enters the frozen list with the argument for why a delegate reading through it cannot extend their own reach. Only the GET arms move. The POST, PUT and DELETE arms in the same files keep `requireAuth()`, which refuses outright while a switch is active, so a file entering the list admits a read and nothing else. The two validation breadcrumbs on the list and series reads now write through `auditLog()` rather than a bare `prisma.auditLog.create`. That helper is the only writer that stamps `actorUserId`, and without the stamp a delegate's malformed query lands on the owner's record looking like the owner's own. Integration coverage drives the shipped GET exports against real Postgres: the owner's rows reach a delegate holding a live grant, the delegate's own rows do not, a caller without a grant is refused `sharing.access.denied`, the request after a revocation is refused with no re-login, and a caller who never switched sees exactly what they saw before. --- src/__tests__/delegable-surface-guard.test.ts | 29 +- .../api/measurement-reminders/[id]/route.ts | 4 +- src/app/api/measurement-reminders/route.ts | 4 +- src/app/api/measurements/[id]/route.ts | 4 +- .../__tests__/group-by-day.test.ts | 4 + .../__tests__/zod-multi-issue.test.ts | 28 +- src/app/api/measurements/route.ts | 25 +- .../api/measurements/series-batch/route.ts | 4 +- src/app/api/measurements/series/route.ts | 26 +- .../sharing-delegable-routes.test.ts | 474 ++++++++++++++++++ 10 files changed, 550 insertions(+), 52 deletions(-) create mode 100644 tests/integration/sharing-delegable-routes.test.ts diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 526090ab1..06bf3017f 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -234,16 +234,33 @@ function exportedFunctionNames(rel: string): string[] { /** * Route modules permitted to act on a record that may not be the caller's. * - * EMPTY, and correct empty. The resolver landed with the auth layer and - * deliberately touched no route file; the first members arrive when routes - * migrate, each one carrying a line in the classification record that says why - * it may. A route absent from that record cannot enter this list. + * Every member carries a line in the classification record that says why it + * may. A route absent from that record cannot enter this list. * * The value is the reason, in one line, in the maintainer's words. Not the * route's description — the argument for why a delegate reading or writing * through it cannot extend their own reach. + * + * Note what a member means: the MODULE reaches the resolver, and in every case + * below that is the GET arm alone. The write arms in these same files still + * resolve through `requireAuth()`, which refuses outright while a switch is + * active, so admitting a file here admits a read and nothing more. A delegable + * write is a separate decision that would have to be made at each of them. */ -const DELEGABLE_ROUTES: Record = {}; +const DELEGABLE_ROUTES: Record = { + "app/api/measurements/route.ts": + "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id` and offers no control affordance; the POST arms in the same file keep `requireAuth()` and so refuse under a switch.", + "app/api/measurements/[id]/route.ts": + "One reading of the record, fetched by id and guarded against the resolved user before it is serialised. The PUT and DELETE beside it keep `requireAuth()`.", + "app/api/measurements/series/route.ts": + "A chart series over the record's own readings. Every `where` and every raw-SQL predicate binds the resolved user id; the route holds no write arm at all.", + "app/api/measurements/series-batch/route.ts": + "The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.", + "app/api/measurement-reminders/route.ts": + "The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.", + "app/api/measurement-reminders/[id]/route.ts": + "One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.", +}; /** * Route modules that deliberately resolve the ACTOR and keep working under a @@ -266,7 +283,7 @@ const ACTOR_ROUTES: Record = { * a formality by accident: every addition has to be counted here as well as * listed above, which is one more place a careless admission has to pass. */ -const FROZEN_ENTRY_COUNT = 1; +const FROZEN_ENTRY_COUNT = 7; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index e591c7481..76202e373 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -10,7 +10,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, @@ -41,7 +41,7 @@ async function resolveTimezone(userId: string): Promise { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const reminder = await prisma.measurementReminder.findFirst({ diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 7a05da314..71a813bb4 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -8,7 +8,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, getClientIp, @@ -36,7 +36,7 @@ async function resolveTimezone(userId: string): Promise { } export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const reminders = await prisma.measurementReminder.findMany({ where: { userId: user.id, deletedAt: null }, diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 2537dc58a..5862b3957 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -27,7 +27,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts index 9255e6719..4f26f7c23 100644 --- a/src/app/api/measurements/__tests__/group-by-day.test.ts +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -54,6 +54,7 @@ vi.mock("next/headers", () => ({ })); import { GET } from "../route"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; @@ -80,6 +81,9 @@ beforeEach(() => { // write; the route swallows rejections so the test only needs a // resolved mock to keep the catch-block silent. vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.36.0 — the 422 breadcrumb writes through the `auditLog` helper, whose + // returned promise the route attaches a `.catch` to. + vi.mocked(auditLog).mockResolvedValue(undefined); // v1.29.6 — null source-priority blob → default rank ladders for the // groupBy=day canonical-source collapse. vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); diff --git a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts index e086b323f..41c4a589a 100644 --- a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts +++ b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts @@ -30,9 +30,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -53,6 +51,7 @@ vi.mock("next/headers", () => ({ })); import { GET, POST } from "../route"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { Prisma } from "@/generated/prisma/client"; @@ -71,6 +70,11 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.36.0 — the GET's validation breadcrumb goes through the `auditLog` + // helper now, because that helper is the only writer that stamps + // `actorUserId`. `resetAllMocks` above clears the factory's implementation, + // so the resolved promise the route awaits has to be restored here. + vi.mocked(auditLog).mockResolvedValue(undefined); }); function getReq(qs: string): NextRequest { @@ -121,18 +125,18 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => { const res = await GET(getReq("limit=999999&offset=-1")); expect(res.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { userId: string; action: string; details: string }; - }; - expect(call.data.userId).toBe("user-1"); - expect(call.data.action).toBe("measurements.list.validation-failed"); + expect(auditLog).toHaveBeenCalledTimes(1); + // v1.36.0 — through the helper, which files the row under the resolved + // record and stamps the delegate as actor when there is one. The bare + // `prisma.auditLog.create` this replaced could not do the second half. + expect(auditLog).toHaveBeenCalledWith( + "measurements.list.validation-failed", + expect.objectContaining({ userId: "user-1" }), + ); }); it("does not block the 422 when the audit-row write rejects", async () => { - vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( - new Error("db down"), - ); + vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down")); const res = await GET(getReq("limit=999999&offset=-1")); expect(res.status).toBe(422); }); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6c97ba60b..24eda8253 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { auditLog } from "@/lib/auth/audit"; @@ -58,7 +58,7 @@ import type { import { Prisma } from "@/generated/prisma/client"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listMeasurementsSchema.safeParse(params); @@ -78,17 +78,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "measurements.list.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only thing that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a delegate's malformed query would read as the owner's own. + void auditLog("measurements.list.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } diff --git a/src/app/api/measurements/series-batch/route.ts b/src/app/api/measurements/series-batch/route.ts index 15b57ae96..f447b24b6 100644 --- a/src/app/api/measurements/series-batch/route.ts +++ b/src/app/api/measurements/series-batch/route.ts @@ -22,7 +22,7 @@ * client keeps sleep on the dedicated route. */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { returnAllZodIssues } from "@/lib/api-response"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -33,7 +33,7 @@ import { } from "@/lib/validations/series-batch"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = seriesBatchQuerySchema.safeParse(params); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index bd79aa425..9233530a3 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -11,7 +11,8 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, buildPayloadDiagnostic, @@ -159,7 +160,7 @@ function stdDev(values: number[]): number { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const rawQuery = Object.fromEntries(request.nextUrl.searchParams); const parsed = querySchema.safeParse(rawQuery); @@ -193,17 +194,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "measurements.series.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only thing that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a delegate's malformed query would read as the owner's own. + void auditLog("measurements.series.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts new file mode 100644 index 000000000..c8d437e45 --- /dev/null +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -0,0 +1,474 @@ +/** + * The migrated routes, driven as routes, against real Postgres. + * + * Every case below imports the shipped `GET` export and calls it. Nothing is + * reimplemented: the real `apiHandler`, the real `requireRecordAuth`, the real + * grant table, the real Prisma queries. That matters more here than anywhere + * else in this feature, because the thing being asserted is a substitution + * that happens ABOVE the handler body — a test that rebuilt the handler would + * be testing its own copy of the substitution and would keep passing after the + * route stopped performing it. + * + * Four questions per domain, and the fourth is the one that decides whether + * this release is safe to ship: + * + * 1. a delegate with a live READ grant reads the OWNER's rows; + * 2. the same delegate without a grant is refused `sharing.access.denied`; + * 3. after revocation the very NEXT request is refused — no re-login; + * 4. a caller who never switched gets exactly what they got before. + * + * (4) is the regression. Forty-four routes changed; a person who shares + * nothing must not be able to tell. + * + * Owner and delegate are BOTH seeded with data in every case, and the + * assertions name the owner's values and the delegate's separately. An empty + * result satisfies "the delegate's rows are absent" while hiding the exact + * failure this feature exists to avoid. + */ +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"; + +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), +})); + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `deleg-${suffix}`, + email: `deleg-${suffix}@example.test`, + displayName: `Deleg ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + }, + }); +} + +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; +} + +/** + * A live grant and a session already inside the owner's record. + * + * Written straight to the tables rather than driven through invite / accept / + * switch, because `sharing-lifecycle.test.ts` already proves those three + * routes produce exactly these rows and repeating the handshake once per + * domain would buy nothing but wall-clock. The rows are the contract; this + * file is about what the migrated routes do once they exist. + */ +async function switchInto(ownerId: string, delegateId: string) { + const grant = await getPrismaClient().accountGrant.create({ + data: { + grantorId: ownerId, + granteeId: delegateId, + access: "READ", + acceptedAt: new Date(), + }, + }); + const session = await signIn(delegateId); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: ownerId }, + }); + return { grant, session }; +} + +/** The delegate is inside the record, but the grant is gone. */ +async function revokeGrant(grantId: string) { + await getPrismaClient().accountGrant.update({ + where: { id: grantId }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); +} + +/** + * A route's `GET` export, seen from outside. + * + * Each shipped handler declares its own params shape (`{ id: string }` and so + * on), which is exactly the sort of per-route difference a driver like this one + * has no business knowing. The cast at each call site narrows to this, and the + * `params` object handed in is checked by the route's own Zod / lookup logic + * one line later — a wrong id produces a 404 from the handler, not a type + * error here. + */ +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +function get(url: string): NextRequest { + return new NextRequest(`http://localhost${url}`, { method: "GET" }); +} + +async function call( + handler: Handler, + url: string, + params: Record = {}, +): Promise { + return handler(get(url), { params: Promise.resolve(params) }); +} + +async function body(response: Response): Promise<{ + data: unknown; + error: unknown; + meta?: { errorCode?: string }; +}> { + return response.json(); +} + +/** The stable refusal, asserted by code rather than by status alone. */ +async function expectDenied(response: Response) { + expect(response.status).toBe(403); + expect((await body(response)).meta?.errorCode).toBe("sharing.access.denied"); +} + +/** + * The four-part contract, run against one route. + * + * `seed` writes one row for whichever user it is handed and returns the value + * that identifies it in the response; `read` pulls those identifying values + * back out of the response body. The domain suites below supply both, so each + * one is four assertions of substance rather than four of shape. + */ +function contract( + name: string, + route: { + call: () => Promise; + seed: (userId: string, marker: T) => Promise; + read: (payload: never) => T[]; + ownerMarker: T; + delegateMarker: T; + }, +) { + describe(name, () => { + it("serves the owner's rows to a delegate holding a live grant", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await route.seed(owner.id, route.ownerMarker); + await route.seed(delegate.id, route.delegateMarker); + + await switchInto(owner.id, delegate.id); + const response = await route.call(); + + expect(response.status).toBe(200); + const rows = route.read((await body(response)).data as never); + expect(rows).toContainEqual(route.ownerMarker); + // The delegate's own row is seeded and must be absent. Asserting the + // owner's presence alone would pass on a handler that returned + // everybody's rows. + expect(rows).not.toContainEqual(route.delegateMarker); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + await route.seed(owner.id, route.ownerMarker); + + const session = await signIn(stranger.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + await expectDenied(await route.call()); + }); + + it("refuses the next request after the grant is revoked", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await route.seed(owner.id, route.ownerMarker); + + const { grant } = await switchInto(owner.id, delegate.id); + expect((await route.call()).status).toBe(200); + + // No sign-out, no new session, no cache flush. The same browser, the + // next request. + await revokeGrant(grant.id); + await expectDenied(await route.call()); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await route.seed(plain.id, route.ownerMarker); + await signIn(plain.id); + + const response = await route.call(); + expect(response.status).toBe(200); + expect(route.read((await body(response)).data as never)).toContainEqual( + route.ownerMarker, + ); + }); + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +/* -------------------------------------------------------------------------- */ +/* Measurements */ +/* -------------------------------------------------------------------------- */ + +async function seedWeight(userId: string, value: number) { + await getPrismaClient().measurement.create({ + data: { + userId, + type: "WEIGHT", + value, + unit: "kg", + measuredAt: new Date("2026-07-15T08:00:00Z"), + source: "MANUAL", + }, + }); +} + +contract("GET /api/measurements", { + call: async () => { + const { GET } = await import("@/app/api/measurements/route"); + return call(GET as Handler, "/api/measurements"); + }, + seed: seedWeight, + read: (payload: { measurements: { value: number }[] }) => + payload.measurements.map((m) => m.value), + ownerMarker: 81, + delegateMarker: 64, +}); + +contract("GET /api/measurements/series", { + call: async () => { + const { GET } = await import("@/app/api/measurements/series/route"); + return call(GET as Handler, "/api/measurements/series?kind=weight&days=90"); + }, + seed: seedWeight, + read: (payload: { points: { value: number }[] }) => + payload.points.map((p) => p.value), + ownerMarker: 81, + delegateMarker: 64, +}); + +contract("GET /api/measurements/series-batch", { + call: async () => { + const { GET } = await import("@/app/api/measurements/series-batch/route"); + return call( + GET as Handler, + "/api/measurements/series-batch?types=WEIGHT&from=2026-01-01T00:00:00Z&to=2026-12-31T00:00:00Z", + ); + }, + seed: seedWeight, + read: (payload: { series: Record }) => + (payload.series.WEIGHT ?? []).map((row) => row.value), + ownerMarker: 81, + delegateMarker: 64, +}); + +describe("GET /api/measurements/[id]", () => { + it("serves one of the owner's readings and refuses the delegate's reach past it", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedWeight(owner.id, 81); + await seedWeight(delegate.id, 64); + const ownerRow = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateRow = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const { GET } = await import("@/app/api/measurements/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + GET as Handler, + `/api/measurements/${ownerRow.id}`, + { id: ownerRow.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ value: 81 }); + + // The fetch-then-guard arm now guards against the OWNER. The delegate's + // own reading is out of scope while they are inside somebody else's + // record — which is the correct answer and a surprising one, so it is + // asserted rather than assumed. + const theirs = await call( + GET as Handler, + `/api/measurements/${delegateRow.id}`, + { id: delegateRow.id }, + ); + expect(theirs.status).toBe(404); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, `/api/measurements/${ownerRow.id}`, { + id: ownerRow.id, + }), + ); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedWeight(plain.id, 77); + const row = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/measurements/[id]/route"); + const response = await call(GET as Handler, `/api/measurements/${row.id}`, { + id: row.id, + }); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ value: 77 }); + }); +}); + +describe("the 422 breadcrumb a delegate leaves on the owner's record", () => { + it("files under the owner and names the delegate as the actor", async () => { + // The one write a delegable measurements GET performs. It was a bare + // `prisma.auditLog.create` before the migration, which files the row with + // `actorUserId` NULL — indistinguishable from the owner having typed the + // bad query themselves. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await switchInto(owner.id, delegate.id); + + const { GET } = await import("@/app/api/measurements/route"); + const refused = await call( + GET as Handler, + "/api/measurements?limit=not-a-number", + ); + expect(refused.status).toBe(422); + + await vi.waitFor(async () => { + const row = await getPrismaClient().auditLog.findFirst({ + where: { action: "measurements.list.validation-failed" }, + }); + expect(row?.userId).toBe(owner.id); + expect(row?.actorUserId).toBe(delegate.id); + }); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Measurement reminders */ +/* -------------------------------------------------------------------------- */ + +async function seedReminder(userId: string, label: string) { + await getPrismaClient().measurementReminder.create({ + data: { + userId, + label, + measurementType: "WEIGHT", + intervalDays: 7, + enabled: true, + }, + }); +} + +contract("GET /api/measurement-reminders", { + call: async () => { + const { GET } = await import("@/app/api/measurement-reminders/route"); + return call(GET as Handler, "/api/measurement-reminders"); + }, + seed: seedReminder, + read: (payload: { label: string }[]) => payload.map((r) => r.label), + ownerMarker: "owner weigh-in", + delegateMarker: "delegate weigh-in", +}); + +describe("GET /api/measurement-reminders/[id]", () => { + it("serves the owner's reminder and refuses once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedReminder(owner.id, "owner weigh-in"); + await seedReminder(delegate.id, "delegate weigh-in"); + const ownerRow = + await getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateRow = + await getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const { GET } = await import("@/app/api/measurement-reminders/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + GET as Handler, + `/api/measurement-reminders/${ownerRow.id}`, + { id: ownerRow.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ label: "owner weigh-in" }); + + const theirs = await call( + GET as Handler, + `/api/measurement-reminders/${delegateRow.id}`, + { id: delegateRow.id }, + ); + expect(theirs.status).toBe(404); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, `/api/measurement-reminders/${ownerRow.id}`, { + id: ownerRow.id, + }), + ); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedReminder(plain.id, "plain weigh-in"); + const row = await getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/measurement-reminders/[id]/route"); + const response = await call( + GET as Handler, + `/api/measurement-reminders/${row.id}`, + { id: row.id }, + ); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + label: "plain weigh-in", + }); + }); +});