From af0f7497402d9656130876bcdb79e2be4b35c4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 16:38:39 +0200 Subject: [PATCH] feat(sharing): open the lab and clinical-record reads to a delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten routes: lab results and their detail, the biomarker catalogue and its detail, allergies, family history, the mental-health screener history, and the health-profile facts. All GET arms; every write arm in the same files keeps `requireAuth()` and refuses under a switch. The family-history payload is the one admitted read that carries third-party health information by design — it describes the owner's relatives — and it is admitted because a caregiver reading a family history is what the feature is for and the rows are the owner's own. The mental-health module gate resolves against the record, not the caller. That is the recorded decision: the module map says which parts of a record exist, so a delegate who enabled the screener on their own account gains no screener view inside somebody else's. The integration test asserts both directions. --- src/__tests__/delegable-surface-guard.test.ts | 22 +- src/app/api/allergies/[id]/route.ts | 4 +- src/app/api/allergies/route.ts | 4 +- .../anamnesis/facts/__tests__/route.test.ts | 7 + src/app/api/anamnesis/facts/route.ts | 4 +- src/app/api/biomarkers/[id]/route.ts | 4 +- src/app/api/biomarkers/route.ts | 4 +- src/app/api/family-history/[id]/route.ts | 4 +- src/app/api/family-history/route.ts | 4 +- src/app/api/labs/[id]/route.ts | 4 +- src/app/api/labs/route.ts | 4 +- .../api/mental-health/assessments/route.ts | 4 +- .../sharing-delegable-routes.test.ts | 368 ++++++++++++++++++ 13 files changed, 416 insertions(+), 21 deletions(-) diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 06bf3017f..f3ad80b47 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -260,6 +260,26 @@ const DELEGABLE_ROUTES: Record = { "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()`.", + "app/api/labs/route.ts": + "The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result stays on `requireAuth()`.", + "app/api/labs/[id]/route.ts": + "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.", + "app/api/biomarkers/route.ts": + "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. No writes on the read arm.", + "app/api/biomarkers/[id]/route.ts": + "One biomarker of the record, fetch-then-guard against the resolved user.", + "app/api/allergies/route.ts": + "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows.", + "app/api/allergies/[id]/route.ts": + "One allergy of the record, fetch-then-guard against the resolved user.", + "app/api/family-history/route.ts": + "The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's.", + "app/api/family-history/[id]/route.ts": + "One family-history entry of the record, fetch-then-guard against the resolved user.", + "app/api/mental-health/assessments/route.ts": + "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", + "app/api/anamnesis/facts/route.ts": + "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", }; /** @@ -283,7 +303,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 = 7; +const FROZEN_ENTRY_COUNT = 17; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts index a2591bf40..621da46b0 100644 --- a/src/app/api/allergies/[id]/route.ts +++ b/src/app/api/allergies/[id]/route.ts @@ -12,7 +12,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 { @@ -31,7 +31,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; const row = await prisma.allergy.findUnique({ where: { id } }); diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index 385d87dce..8ae18805b 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -11,7 +11,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 { @@ -29,7 +29,7 @@ import { import { toAllergyDTO } from "@/lib/records/dto"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = new URL(request.url).searchParams; const parsed = allergyListQuerySchema.safeParse({ diff --git a/src/app/api/anamnesis/facts/__tests__/route.test.ts b/src/app/api/anamnesis/facts/__tests__/route.test.ts index a5278b2c4..f9fa02399 100644 --- a/src/app/api/anamnesis/facts/__tests__/route.test.ts +++ b/src/app/api/anamnesis/facts/__tests__/route.test.ts @@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/lib/api-handler", () => ({ apiHandler: (handler: unknown) => handler, requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })), + // v1.36.0 — the GET resolves a record rather than a caller. Same shape when + // nobody has switched, which is the case this file exercises. + requireRecordAuth: vi.fn(async () => ({ + user: { id: "user-1" }, + actor: { id: "user-1" }, + grantId: null, + })), })); vi.mock("@/lib/idempotency", () => ({ withIdempotency: (handler: unknown) => handler, diff --git a/src/app/api/anamnesis/facts/route.ts b/src/app/api/anamnesis/facts/route.ts index 08f419d84..228a7d860 100644 --- a/src/app/api/anamnesis/facts/route.ts +++ b/src/app/api/anamnesis/facts/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -19,7 +19,7 @@ import { import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const payload = await readHealthProfileFacts(user.id); annotate({ action: { name: "anamnesis.facts.get" }, diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 3c0fb11ae..06aa4e5a5 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -64,7 +64,7 @@ function serialiseBiomarker(row: { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const row = await prisma.biomarker.findFirst({ where: { id } }); diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index f08a10153..256f17ec5 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -71,7 +71,7 @@ function serialiseBiomarker(row: { } export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); // Visible markers first, then hidden — the manager renders them in two // sections and the lab-entry pickers drop the hidden tail client-side. diff --git a/src/app/api/family-history/[id]/route.ts b/src/app/api/family-history/[id]/route.ts index 80d02ea8a..47da56ff4 100644 --- a/src/app/api/family-history/[id]/route.ts +++ b/src/app/api/family-history/[id]/route.ts @@ -10,7 +10,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 { @@ -29,7 +29,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; const row = await prisma.familyHistoryEntry.findUnique({ where: { id } }); diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index e005d523e..d58cb44d4 100644 --- a/src/app/api/family-history/route.ts +++ b/src/app/api/family-history/route.ts @@ -12,7 +12,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 { @@ -30,7 +30,7 @@ import { import { toFamilyHistoryEntryDTO } from "@/lib/records/dto"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = new URL(request.url).searchParams; const parsed = familyHistoryListQuerySchema.safeParse({ diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index c1a5fc1ef..d83c7de99 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -65,7 +65,7 @@ function toResolved( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const row = await prisma.labResult.findFirst({ diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 9c60a96ad..43b099036 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -70,7 +70,7 @@ function toResolved( } 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 = listLabResultsSchema.safeParse(params); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index e7a014910..b2777ab47 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -18,7 +18,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -99,7 +99,7 @@ function respondExisting(row: Parameters[0]): Response { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); // Opt-in module (default OFF): the screener history is unreachable until the // account turns the mental-health module on. Enforced server-side even for a // valid Bearer token (the nav entry / page redirect are UX-only). diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index c8d437e45..38d4fbce3 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -472,3 +472,371 @@ describe("GET /api/measurement-reminders/[id]", () => { }); }); }); + +/* -------------------------------------------------------------------------- */ +/* Labs and biomarkers */ +/* -------------------------------------------------------------------------- */ + +async function seedLab(userId: string, analyte: string) { + await getPrismaClient().labResult.create({ + data: { + userId, + analyte, + value: 5.2, + unit: "mmol/L", + takenAt: new Date("2026-07-10T09:00:00Z"), + }, + }); +} + +contract("GET /api/labs", { + call: async () => { + const { GET } = await import("@/app/api/labs/route"); + return call(GET as Handler, "/api/labs"); + }, + seed: seedLab, + read: (payload: { results: { analyte: string }[] }) => + payload.results.map((r) => r.analyte), + ownerMarker: "owner-hba1c", + delegateMarker: "delegate-hba1c", +}); + +async function seedBiomarker(userId: string, name: string) { + await getPrismaClient().biomarker.create({ + data: { userId, name, unit: "mmol/L" }, + }); +} + +contract("GET /api/biomarkers", { + call: async () => { + const { GET } = await import("@/app/api/biomarkers/route"); + return call(GET as Handler, "/api/biomarkers"); + }, + seed: seedBiomarker, + read: (payload: { biomarkers: { name: string }[] }) => + payload.biomarkers.map((b) => b.name), + ownerMarker: "owner-ferritin", + delegateMarker: "delegate-ferritin", +}); + +describe("GET /api/labs/[id] and /api/biomarkers/[id]", () => { + it("resolve by id against the owner, not the caller", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedLab(owner.id, "owner-hba1c"); + await seedLab(delegate.id, "delegate-hba1c"); + await seedBiomarker(owner.id, "owner-ferritin"); + + const ownerLab = await getPrismaClient().labResult.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateLab = await getPrismaClient().labResult.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + const ownerMarker = await getPrismaClient().biomarker.findFirstOrThrow({ + where: { userId: owner.id }, + }); + + const labs = await import("@/app/api/labs/[id]/route"); + const markers = await import("@/app/api/biomarkers/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const lab = await call(labs.GET as Handler, `/api/labs/${ownerLab.id}`, { + id: ownerLab.id, + }); + expect(lab.status).toBe(200); + expect((await body(lab)).data).toMatchObject({ analyte: "owner-hba1c" }); + + // The delegate's own result is not reachable from inside the owner's + // record. The fetch-then-guard arm now guards against the owner. + const theirs = await call( + labs.GET as Handler, + `/api/labs/${delegateLab.id}`, + { id: delegateLab.id }, + ); + expect(theirs.status).toBe(404); + + const marker = await call( + markers.GET as Handler, + `/api/biomarkers/${ownerMarker.id}`, + { id: ownerMarker.id }, + ); + expect(marker.status).toBe(200); + expect((await body(marker)).data).toMatchObject({ name: "owner-ferritin" }); + + await revokeGrant(grant.id); + await expectDenied( + await call(labs.GET as Handler, `/api/labs/${ownerLab.id}`, { + id: ownerLab.id, + }), + ); + await expectDenied( + await call(markers.GET as Handler, `/api/biomarkers/${ownerMarker.id}`, { + id: ownerMarker.id, + }), + ); + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedLab(plain.id, "plain-hba1c"); + const row = await getPrismaClient().labResult.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/labs/[id]/route"); + const response = await call(GET as Handler, `/api/labs/${row.id}`, { + id: row.id, + }); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + analyte: "plain-hba1c", + }); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Clinical record */ +/* -------------------------------------------------------------------------- */ + +async function seedAllergy(userId: string, substance: string) { + await getPrismaClient().allergy.create({ data: { userId, substance } }); +} + +contract("GET /api/allergies", { + call: async () => { + const { GET } = await import("@/app/api/allergies/route"); + return call(GET as Handler, "/api/allergies"); + }, + seed: seedAllergy, + read: (payload: { substance: string }[]) => payload.map((a) => a.substance), + ownerMarker: "owner-penicillin", + delegateMarker: "delegate-penicillin", +}); + +async function seedFamilyHistory(userId: string, condition: string) { + await getPrismaClient().familyHistoryEntry.create({ + data: { userId, relationship: "MOTHER", condition }, + }); +} + +contract("GET /api/family-history", { + call: async () => { + const { GET } = await import("@/app/api/family-history/route"); + return call(GET as Handler, "/api/family-history"); + }, + seed: seedFamilyHistory, + read: (payload: { condition: string }[]) => payload.map((e) => e.condition), + ownerMarker: "owner-condition", + delegateMarker: "delegate-condition", +}); + +describe("GET /api/allergies/[id] and /api/family-history/[id]", () => { + it("resolve by id against the owner and refuse once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedAllergy(owner.id, "owner-penicillin"); + await seedAllergy(delegate.id, "delegate-penicillin"); + await seedFamilyHistory(owner.id, "owner-condition"); + + const ownerAllergy = await getPrismaClient().allergy.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateAllergy = await getPrismaClient().allergy.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + const ownerEntry = + await getPrismaClient().familyHistoryEntry.findFirstOrThrow({ + where: { userId: owner.id }, + }); + + const allergies = await import("@/app/api/allergies/[id]/route"); + const history = await import("@/app/api/family-history/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + allergies.GET as Handler, + `/api/allergies/${ownerAllergy.id}`, + { id: ownerAllergy.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ + substance: "owner-penicillin", + }); + + const theirs = await call( + allergies.GET as Handler, + `/api/allergies/${delegateAllergy.id}`, + { id: delegateAllergy.id }, + ); + expect(theirs.status).toBe(404); + + const entry = await call( + history.GET as Handler, + `/api/family-history/${ownerEntry.id}`, + { id: ownerEntry.id }, + ); + expect(entry.status).toBe(200); + expect((await body(entry)).data).toMatchObject({ + condition: "owner-condition", + }); + + await revokeGrant(grant.id); + await expectDenied( + await call( + allergies.GET as Handler, + `/api/allergies/${ownerAllergy.id}`, + { id: ownerAllergy.id }, + ), + ); + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedAllergy(plain.id, "plain-penicillin"); + const row = await getPrismaClient().allergy.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/allergies/[id]/route"); + const response = await call(GET as Handler, `/api/allergies/${row.id}`, { + id: row.id, + }); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + substance: "plain-penicillin", + }); + }); +}); + +async function seedFact(userId: string, value: string) { + // Through the real writer, because the revision's value is an encrypted + // column and a hand-built row would read back as `unreadable`. + const { createHealthProfileFact } = + await import("@/lib/profile/health-facts"); + await createHealthProfileFact(userId, "SMOKING_STATUS", value as never); +} + +contract("GET /api/anamnesis/facts", { + call: async () => { + const { GET } = await import("@/app/api/anamnesis/facts/route"); + return call(GET as Handler, "/api/anamnesis/facts"); + }, + seed: seedFact, + read: (payload: { + current: Record; + }) => + Object.values(payload.current) + .map((row) => row?.value ?? null) + .filter((v): v is string => v !== null), + ownerMarker: "CURRENT", + delegateMarker: "NEVER", +}); + +/* -------------------------------------------------------------------------- */ +/* Mental health — the module gate follows the record */ +/* -------------------------------------------------------------------------- */ + +async function seedAssessment(userId: string, totalScore: number) { + await getPrismaClient().mentalHealthAssessment.create({ + data: { + userId, + instrument: "PHQ9", + locale: "en", + responsesEncrypted: Buffer.from("sealed"), + totalScore, + severityBand: "mild", + takenAt: new Date("2026-07-12T10:00:00Z"), + }, + }); +} + +async function setModulePreference(userId: string, enabled: boolean) { + await getPrismaClient().user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { mentalHealth: enabled } }, + }); +} + +describe("GET /api/mental-health/assessments", () => { + it("reads the owner's screener history when the OWNER enabled the module", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await setModulePreference(owner.id, true); + await seedAssessment(owner.id, 11); + await seedAssessment(delegate.id, 3); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/mental-health/assessments/route"); + const response = await call( + GET as Handler, + "/api/mental-health/assessments", + ); + + expect(response.status).toBe(200); + const payload = (await body(response)).data as { + assessments: { totalScore: number }[]; + }; + expect(payload.assessments.map((a) => a.totalScore)).toEqual([11]); + }); + + it("refuses when the OWNER switched the module off, whatever the delegate set", async () => { + // The decision recorded in the classification: the module map governs + // which parts of a RECORD exist, so it is read off the record. A delegate + // who enabled the screener on their own account does not thereby gain a + // screener view inside somebody else's. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await setModulePreference(owner.id, false); + await setModulePreference(delegate.id, true); + await seedAssessment(owner.id, 11); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/mental-health/assessments/route"); + const response = await call( + GET as Handler, + "/api/mental-health/assessments", + ); + + expect(response.status).toBe(403); + expect((await body(response)).meta?.errorCode).toBe("module.disabled"); + }); + + it("refuses a caller with no grant, and refuses the request after revocation", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await setModulePreference(owner.id, true); + await seedAssessment(owner.id, 11); + + const { GET } = await import("@/app/api/mental-health/assessments/route"); + const { grant } = await switchInto(owner.id, delegate.id); + expect( + (await call(GET as Handler, "/api/mental-health/assessments")).status, + ).toBe(200); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, "/api/mental-health/assessments"), + ); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await setModulePreference(plain.id, true); + await seedAssessment(plain.id, 7); + await signIn(plain.id); + + const { GET } = await import("@/app/api/mental-health/assessments/route"); + const response = await call( + GET as Handler, + "/api/mental-health/assessments", + ); + expect(response.status).toBe(200); + const payload = (await body(response)).data as { + assessments: { totalScore: number }[]; + }; + expect(payload.assessments.map((a) => a.totalScore)).toEqual([7]); + }); +});