From 7a39a649be2cd0d4e9b6b1a1033ffb26377a0c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 17:06:23 +0200 Subject: [PATCH] feat(sharing): open the medication reads to a delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten routes: the cabinet list, the medication detail, its cadence, schedule revisions, side effects, inventory, phase config and intake history, plus the account-wide intake list and the batched compliance read. `GET /api/medications/compliance` is the one delegable read that spends a quota, and its rate-limit bucket now keys on the ACTOR while everything below it scopes to the record. Both halves matter: a delegate hammering the route must burn their own allowance rather than lock the owner out of their own cabinet, and switching records must not hand the same caller a fresh one. The test reads the `rate_limits` row rather than inferring it from a 429, because a bucket keyed on the wrong person still answers 200. `GET /api/medications/[id]/api-endpoint` stays refused. It returns two scalar fields and no secret, and it tells a delegate whether the owner holds a live ingest credential and for which medication — in the same file as the PUT that mints one that would survive revocation. The two intake validation breadcrumbs move onto `auditLog()` for the actor stamp. The POST arm's breadcrumb stays as it is: that arm cannot be reached under a switch. --- src/__tests__/delegable-surface-guard.test.ts | 57 +++- src/app/api/medications/[id]/cadence/route.ts | 4 +- .../[id]/intake/__tests__/route.test.ts | 17 +- src/app/api/medications/[id]/intake/route.ts | 28 +- .../api/medications/[id]/inventory/route.ts | 4 +- .../medications/[id]/phase-config/route.ts | 4 +- src/app/api/medications/[id]/route.ts | 4 +- .../[id]/schedule-revisions/route.ts | 4 +- .../medications/[id]/side-effects/route.ts | 4 +- src/app/api/medications/compliance/route.ts | 11 +- .../intake/__tests__/route.test.ts | 9 +- src/app/api/medications/intake/route.ts | 25 +- src/app/api/medications/route.ts | 4 +- .../sharing-delegable-routes.test.ts | 297 +++++++++++++++++- 14 files changed, 401 insertions(+), 71 deletions(-) diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 543024fc6..748e4b8bc 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -24,12 +24,15 @@ * 1. **Every leg asserts its own matcher found something.** A matcher that * matches nothing reports success, and this repository has shipped several * of those — a resolver hidden behind a newline, a regex a variable slipped - * past, an absence asserted in a fixture that never held the thing. The - * frozen lists below start EMPTY, so "found nothing" and "the list is - * correct" look identical from the outside. Each leg therefore pins a - * separate, non-empty count: the number of route modules SCANNED, or the - * number of files naming a symbol the leg depends on. Those counts are the - * evidence; the empty result is only the verdict. + * past, an absence asserted in a fixture that never held the thing. Legs + * (c) and (d) still expect an EMPTY offender set, so for them "found + * nothing" and "the list is correct" look identical from the outside. + * Each leg therefore pins a separate, non-empty count: the number of + * route modules SCANNED, or the number of files naming a symbol the leg + * depends on. Those counts are the evidence; the empty result is only the + * verdict. The delegable list itself was empty when this file was written + * and is not any more, which removes the trap from legs (a) and (b) but + * not from the two below them. * * 2. **AST decides, text only skips.** Membership is decided by parsing the * module and reading its identifiers, so a comment explaining why a route @@ -311,11 +314,31 @@ const DELEGABLE_ROUTES: Record = { "app/api/cycle/cycles/route.ts": "The record's cycle history. The gate reads `user.gender` off the resolved record, which is the point: whether cycle tracking applies is a property of the record, not of whoever is looking at it.", "app/api/cycle/day-logs/route.ts": - "One day of the record's cycle log. The `auditLog(\"cycle.day-log.upsert\")` in this file belongs to the PUT, which keeps `requireAuth()`.", + 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.', "app/api/cycle/profile/route.ts": "The record's cycle profile, supplied by the gate itself. No query of its own.", "app/api/cycle/symptoms/custom/route.ts": "The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.", + "app/api/medications/route.ts": + "The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.", + "app/api/medications/[id]/route.ts": + "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.", + "app/api/medications/[id]/cadence/route.ts": + "One medication's cadence, behind `assertMedicationOwnership(id, user.id)` — the shared guard, resolved against the record.", + "app/api/medications/[id]/schedule-revisions/route.ts": + "One medication's archived schedule eras, behind the same ownership guard. The create arm keeps `requireAuth()` and its compliance backfill with it.", + "app/api/medications/[id]/side-effects/route.ts": + "One medication's recorded side effects, behind the same ownership guard. The rate limit in this file belongs to the create arm.", + "app/api/medications/[id]/inventory/route.ts": + "One medication's inventory, behind the same ownership guard. The rate limit in this file belongs to the create arm.", + "app/api/medications/[id]/phase-config/route.ts": + "One medication's reminder phase config, behind the same ownership guard. The upsert arm keeps `requireAuth()`.", + "app/api/medications/[id]/intake/route.ts": + "One medication's intake history, behind the same ownership guard. The taking of a dose is a write and stays on `requireAuth()`.", + "app/api/medications/intake/route.ts": + "The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to.", + "app/api/medications/compliance/route.ts": + "Batched compliance across the record's cabinet. The one delegable read that spends a quota, so its rate-limit bucket keys on the ACTOR: a delegate must burn their own allowance rather than lock the owner out, and must not collect a fresh one by switching records.", }; /** @@ -339,7 +362,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 = 35; +const FROZEN_ENTRY_COUNT = 45; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -453,8 +476,8 @@ describe("the matchers see what they look for", () => { }); it("the co-occurrence rule fires on a module that does both", () => { - // Leg (c) finds nothing on today's tree because nothing calls the record - // resolver yet. This is the shape it exists to catch, proved against the + // Leg (c) finds nothing on today's tree because no delegable module runs a + // role check. This is the shape it exists to catch, proved against the // same predicate the tree scan uses. const OFFENDER = ` import { requireRecordAuth, requireAdmin } from "@/lib/api-handler"; @@ -530,11 +553,13 @@ describe("(a) the delegable route set is frozen", () => { "app/.well-known/oauth-protected-resource/route.ts", ); - // Positive control for this leg's own matcher, and the reason it exists: - // while the frozen list is empty, "no route names the resolver" and "the - // matcher names nothing" are the same sentence. Neuter `identifiersIn` and - // the assertion below still passes; this one does not. `lib/api-handler.ts` - // is the guaranteed positive — it declares the symbol. + // Positive control for this leg's own matcher. It was written when the + // frozen list was empty and "no route names the resolver" and "the matcher + // names nothing" were the same sentence; the list is populated now, so the + // assertion below would catch a neutered matcher on its own. Kept anyway, + // because a future removal could empty the list again and the day that + // happens is not the day to rediscover this. `lib/api-handler.ts` is the + // guaranteed positive — it declares the symbol. expect(modulesNaming(["lib/api-handler.ts"], RECORD_RESOLVER)).toEqual([ "lib/api-handler.ts", ]); @@ -594,7 +619,7 @@ describe("(c) a record resolver never shares a module with a role or step-up che // This leg's own non-zero proof, and the one that matters most here: the // offender set is empty today whether or not the helper names are right, - // because nothing calls the record resolver yet. So each helper has to be + // because no delegable module runs a role check. So each helper has to be // findable in the very tree being scanned. Rename `requireAdmin` and this // fails, rather than the guard quietly checking for a symbol that no // longer exists. diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 13c3967ff..dae2a9dd4 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -18,7 +18,7 @@ import { NextRequest } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { @@ -42,7 +42,7 @@ const querySchema = z.object({ export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 870ca4e7d..b76584056 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -72,9 +72,7 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ 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() })); @@ -100,6 +98,7 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({ import { GET, POST } from "../route"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; @@ -127,6 +126,10 @@ const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) }; beforeEach(() => { vi.resetAllMocks(); + // v1.36.0 — the GET's validation breadcrumb goes through the `auditLog` + // helper, whose promise the route attaches a `.catch` to. `resetAllMocks` + // clears the factory's implementation, so it is restored here. + vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( @@ -292,13 +295,19 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => { await GET(makeRequest("status=junk"), ROUTE_PARAMS); await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS); await new Promise((r) => setTimeout(r, 5)); + // v1.36.0 — the GET's breadcrumb goes through the `auditLog` helper (the + // only writer that stamps `actorUserId`); the POST's still writes the row + // directly, because that arm cannot be reached under a switch. + expect(auditLog).toHaveBeenCalledWith( + "medications.intake.list.validation-failed", + expect.objectContaining({ userId: "user-1" }), + ); expect(prisma.auditLog.create).toHaveBeenCalled(); const actions = vi .mocked(prisma.auditLog.create) .mock.calls.map( (c) => (c[0] as { data: { action: string } }).data.action, ); - expect(actions).toContain("medications.intake.list.validation-failed"); expect(actions).toContain("medications.intake.create.validation-failed"); }); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 99d027529..26ac314e5 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/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 { @@ -538,7 +538,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. @@ -559,20 +559,16 @@ export const GET = apiHandler( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "medications.intake.list.validation-failed", - details: JSON.stringify({ - issues: auditIssues, - medicationId: id, - }), - }, - }) - .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("medications.intake.list.validation-failed", { + userId: user.id, + details: { issues: auditIssues, medicationId: id }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index d22a8572b..ea361cabf 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -16,7 +16,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -44,7 +44,7 @@ const POST_WINDOW_MS = 60_000; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index b8eada6c1..ad5883877 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/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 { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; import { phaseConfigSchema } from "@/lib/validations/phase-config"; @@ -10,7 +10,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; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 8bd2587af..2a9c5b9a6 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[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 { @@ -76,7 +76,7 @@ function reconcileScheduleWindow( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const medication = await prisma.medication.findUnique({ diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index ee5a1ec3c..b91699a4d 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -23,7 +23,7 @@ import { NextRequest } from "next/server"; 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 { @@ -73,7 +73,7 @@ function summariseEntries(payload: unknown): Array<{ export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 5562e4873..db2fafd08 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -19,7 +19,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -45,7 +45,7 @@ const POST_WINDOW_MS = 60_000; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 6c6263e02..f120a7a35 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -1,7 +1,7 @@ import pLimit from "p-limit"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { @@ -26,10 +26,15 @@ import { checkRateLimit } from "@/lib/rate-limit"; * detail page keeps the per-id route for the grid. */ export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user, actor } = await requireRecordAuth("read"); + // v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to + // the record. Two reasons, and both are the same reason from opposite ends: + // a delegate hammering this route must burn their own allowance rather than + // locking the owner out of their own cabinet, and switching records must not + // hand the same caller a fresh allowance. `user.id` would get both wrong. const rl = await checkRateLimit( - `medication-compliance-summary:${user.id}`, + `medication-compliance-summary:${actor.id}`, 30, 60_000, ); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 2bef4d0f4..fd2841b4d 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -44,9 +44,7 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ 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/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), @@ -76,6 +74,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 { @@ -92,6 +91,10 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + // v1.36.0 — the GET's validation breadcrumb goes through the `auditLog` + // helper, whose promise the route attaches a `.catch` to. `resetAllMocks` + // clears the factory's implementation, so it is restored here. + vi.mocked(auditLog).mockResolvedValue(undefined); // v1.4.34 IW-G — reset compliance LRU between tests so each case // observes a cold cache. __resetAllCachesForTests(); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 336ee1a19..deb226425 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -13,7 +13,7 @@ */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -84,7 +84,7 @@ const updateSchema = z.object({ }); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const parsed = querySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams), @@ -102,17 +102,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "medications.intake.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("medications.intake.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/medications/route.ts b/src/app/api/medications/route.ts index b7c7c321e..5f9ecd6ae 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/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, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -33,7 +33,7 @@ import { NextRequest } from "next/server"; const APPLE_HEALTH_MIRROR_CAP = 30; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); // Cache the list shape on userId. The 60 s fresh TTL bounds the // cross-midnight staleness window for `todayEventCount` and the diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 4704225f6..3b42bc7ed 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -1366,7 +1366,9 @@ describe("GET /api/cycle/profile", () => { const response = await call(GET as Handler, "/api/cycle/profile"); expect(response.status).toBe(200); - expect((await body(response)).data).toMatchObject({ cycleTrackingEnabled: true }); + expect((await body(response)).data).toMatchObject({ + cycleTrackingEnabled: true, + }); }); it("refuses when the RECORD does not track a cycle, whatever the caller does", async () => { @@ -1402,7 +1404,9 @@ describe("GET /api/cycle/profile", () => { const { GET } = await import("@/app/api/cycle/profile/route"); const response = await call(GET as Handler, "/api/cycle/profile"); expect(response.status).toBe(200); - expect((await body(response)).data).toMatchObject({ cycleTrackingEnabled: true }); + expect((await body(response)).data).toMatchObject({ + cycleTrackingEnabled: true, + }); }); }); @@ -1463,3 +1467,292 @@ describe("GET /api/cycle/day-logs and /api/cycle/symptoms/custom", () => { }); }); }); + +/* -------------------------------------------------------------------------- */ +/* Medications */ +/* -------------------------------------------------------------------------- */ + +async function seedMedication(userId: string, name: string) { + const medication = await getPrismaClient().medication.create({ + data: { userId, name, dose: "10mg" }, + }); + await getPrismaClient().medicationSchedule.create({ + data: { + medicationId: medication.id, + windowStart: "08:00", + windowEnd: "10:00", + }, + }); + await getPrismaClient().medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: new Date("2026-07-14T08:00:00Z"), + takenAt: new Date("2026-07-14T08:12:00Z"), + }, + }); +} + +contract("GET /api/medications", { + call: async () => { + const { GET } = await import("@/app/api/medications/route"); + return call(GET as Handler, "/api/medications"); + }, + seed: seedMedication, + read: (payload: { name: string }[]) => payload.map((m) => m.name), + ownerMarker: "owner-statin", + delegateMarker: "delegate-statin", +}); + +/** Today's dose, so the `scope=today` window actually contains it. */ +async function seedTodaysDose(userId: string, name: string) { + const medication = await getPrismaClient().medication.create({ + data: { userId, name, dose: "10mg" }, + }); + await getPrismaClient().medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: new Date(), + }, + }); + return medication; +} + +describe("GET /api/medications/intake", () => { + async function today(): Promise { + const { GET } = await import("@/app/api/medications/intake/route"); + return call(GET as Handler, "/api/medications/intake?scope=today"); + } + + it("lists the owner's doses and none of the delegate's", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const ownerMed = await seedTodaysDose(owner.id, "owner-statin"); + const delegateMed = await seedTodaysDose(delegate.id, "delegate-statin"); + + const { grant } = await switchInto(owner.id, delegate.id); + const response = await today(); + expect(response.status).toBe(200); + const ids = ((await body(response)).data as { medicationId: string }[]).map( + (e) => e.medicationId, + ); + expect(ids).toContain(ownerMed.id); + expect(ids).not.toContain(delegateMed.id); + + await revokeGrant(grant.id); + await expectDenied(await today()); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + await seedTodaysDose(owner.id, "owner-statin"); + + const session = await signIn(stranger.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + await expectDenied(await today()); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + const med = await seedTodaysDose(plain.id, "plain-statin"); + await signIn(plain.id); + + const response = await today(); + expect(response.status).toBe(200); + const ids = ((await body(response)).data as { medicationId: string }[]).map( + (e) => e.medicationId, + ); + expect(ids).toContain(med.id); + }); +}); + +describe("the per-medication reads", () => { + it("resolve the ownership guard against the record", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedMedication(owner.id, "owner-statin"); + await seedMedication(delegate.id, "delegate-statin"); + const ownerMed = await getPrismaClient().medication.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateMed = await getPrismaClient().medication.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const detail = await import("@/app/api/medications/[id]/route"); + const cadence = await import("@/app/api/medications/[id]/cadence/route"); + const revisions = + await import("@/app/api/medications/[id]/schedule-revisions/route"); + const sideEffects = + await import("@/app/api/medications/[id]/side-effects/route"); + const inventory = + await import("@/app/api/medications/[id]/inventory/route"); + const phaseConfig = + await import("@/app/api/medications/[id]/phase-config/route"); + const intake = await import("@/app/api/medications/[id]/intake/route"); + + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + detail.GET as Handler, + `/api/medications/${ownerMed.id}`, + { id: ownerMed.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ name: "owner-statin" }); + + // Every sibling reaches the owner's medication... + for (const route of [ + cadence.GET, + revisions.GET, + sideEffects.GET, + inventory.GET, + phaseConfig.GET, + intake.GET, + ]) { + const response = await call( + route as Handler, + `/api/medications/${ownerMed.id}/sub`, + { id: ownerMed.id }, + ); + expect(response.status).toBe(200); + } + + // ...and none of them reaches the delegate's own, because + // `assertMedicationOwnership` now asks about the record. + for (const route of [ + detail.GET, + cadence.GET, + revisions.GET, + sideEffects.GET, + inventory.GET, + phaseConfig.GET, + intake.GET, + ]) { + const response = await call( + route as Handler, + `/api/medications/${delegateMed.id}/sub`, + { id: delegateMed.id }, + ); + expect(response.status).toBe(404); + } + + await revokeGrant(grant.id); + for (const route of [detail.GET, cadence.GET, intake.GET]) { + await expectDenied( + await call(route as Handler, `/api/medications/${ownerMed.id}/sub`, { + id: ownerMed.id, + }), + ); + } + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedMedication(plain.id, "plain-statin"); + const med = await getPrismaClient().medication.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const detail = await import("@/app/api/medications/[id]/route"); + const response = await call( + detail.GET as Handler, + `/api/medications/${med.id}`, + { id: med.id }, + ); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + name: "plain-statin", + }); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + await seedMedication(owner.id, "owner-statin"); + const med = await getPrismaClient().medication.findFirstOrThrow({ + where: { userId: owner.id }, + }); + + const session = await signIn(stranger.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + const detail = await import("@/app/api/medications/[id]/route"); + await expectDenied( + await call(detail.GET as Handler, `/api/medications/${med.id}`, { + id: med.id, + }), + ); + }); +}); + +describe("GET /api/medications/compliance — the bucket follows the actor", () => { + it("spends the delegate's quota, not the owner's", async () => { + // The one delegable read that costs a quota. If the bucket keyed on the + // resolved user, a delegate could lock the owner out of their own + // cabinet, and could collect a fresh allowance by switching records. The + // rate_limits row is read from the table rather than inferred from a 429, + // because a key that is merely wrong still produces a 200. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedMedication(owner.id, "owner-statin"); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/medications/compliance/route"); + const response = await call(GET as Handler, "/api/medications/compliance"); + expect(response.status).toBe(200); + + const keys = ( + await getPrismaClient().rateLimit.findMany({ + where: { key: { startsWith: "medication-compliance-summary:" } }, + }) + ).map((row) => row.key); + expect(keys).toEqual([`medication-compliance-summary:${delegate.id}`]); + expect(keys).not.toContain(`medication-compliance-summary:${owner.id}`); + }); + + it("still keys on the caller when nobody has switched", async () => { + const plain = await makeUser("plain"); + await seedMedication(plain.id, "plain-statin"); + await signIn(plain.id); + + const { GET } = await import("@/app/api/medications/compliance/route"); + expect( + (await call(GET as Handler, "/api/medications/compliance")).status, + ).toBe(200); + + const keys = ( + await getPrismaClient().rateLimit.findMany({ + where: { key: { startsWith: "medication-compliance-summary:" } }, + }) + ).map((row) => row.key); + expect(keys).toEqual([`medication-compliance-summary:${plain.id}`]); + }); + + it("refuses the next request after the grant is revoked", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedMedication(owner.id, "owner-statin"); + + const { GET } = await import("@/app/api/medications/compliance/route"); + const { grant } = await switchInto(owner.id, delegate.id); + expect( + (await call(GET as Handler, "/api/medications/compliance")).status, + ).toBe(200); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, "/api/medications/compliance"), + ); + }); +});