feat(sharing): open the medication reads to a delegate

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 17:06:23 +02:00
1 parent fe765a7ae2
commit 7a39a649be
14 files changed
+401 -71

No files matched your search

+41 -16
View File
@@ -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<string, string> = {
"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<string, string> = {
* 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.
@@ -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.
@@ -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");
});
+12 -16
View File
@@ -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);
}
@@ -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);
@@ -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.
+2 -2
View File
@@ -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({
@@ -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);
@@ -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);
+8 -3
View File
@@ -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,
);
@@ -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();
+12 -13
View File
@@ -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);
}
+2 -2
View File
@@ -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
@@ -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<string>("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<Response> {
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"),
);
});
});