From fb1cb4e3ed854096dc9b320e7e52fc386062631c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 00:02:03 +0200 Subject: [PATCH] feat: gate a switched browser on the record's modules, not the actor's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning a module off for a profile you look after wrote a column nothing on screen was reading. GET /api/auth/me published the ACTOR's module map while switched, and the navigation, the dashboard tiles and the insights sections all gate on it — so a guardian who turned Cycle off for the child kept seeing a Cycle destination inside the child's record, and a guardian who tracks it saw one there whether or not the child did (#939). The map and the resolved cycle flag now answer for the record the session is inside. They are not display preferences: every surface they gate shows the record's data, so the record is what decides. Locale, theme and layout stay the actor's. The record is taken from the grant-validated active entry rather than the session's own selector, so a revoked switch cannot publish somebody's configuration, and with no switch the payload is what it was. Cycle also gets a row on the guardian's Modules surface, which it never had. It is a delegated module, so its state is the record's own cycle profile and modulePreferences.cycle is refused — which left a guardian no way to turn it off for a record whose sex says it should be on. The family names the real column instead of a blob entry the gate ignores. Refs #939 --- .../record-settings-configuration.test.ts | 14 +- src/app/api/auth/me/route.ts | 110 ++++-- src/app/api/record-settings/[family]/route.ts | 57 +++- .../__tests__/settings-shell.test.tsx | 55 ++- .../managed-record-settings-section.tsx | 12 + src/lib/openapi/routes/profile.ts | 2 +- src/lib/openapi/routes/record-settings.ts | 2 +- src/lib/record-settings/configuration.ts | 28 +- .../managed-record-modules.test.ts | 322 ++++++++++++++++++ 9 files changed, 556 insertions(+), 46 deletions(-) create mode 100644 tests/integration/managed-record-modules.test.ts diff --git a/src/__tests__/record-settings-configuration.test.ts b/src/__tests__/record-settings-configuration.test.ts index a881aaabf..2bbf98387 100644 --- a/src/__tests__/record-settings-configuration.test.ts +++ b/src/__tests__/record-settings-configuration.test.ts @@ -23,7 +23,7 @@ describe("managed record settings configuration contract", () => { "timeFormat", "dateFormat", ], - modules: ["modulePreferences"], + modules: ["modulePreferences", "cycleTrackingEnabled"], notifications: ["moodReminderEnabled", "notificationPreferences"], thresholds: ["overrides"], coach: ["disableCoach", "preferences"], @@ -34,6 +34,13 @@ describe("managed record settings configuration contract", () => { it.each([ ["profile", { email: "not-allowed@example.test" }], ["modules", { role: "ADMIN" }], + // The delegated key by its module name, which is the mistake a stale + // client makes. It has to stay a refusal: accepting it would persist an + // inert `false` behind a green "saved" and change nothing. + ["modules", { modulePreferences: { cycle: false } }], + // An empty patch. It parses as a strict object and would audit a change + // nobody made. + ["modules", {}], [ "notifications", { @@ -63,6 +70,11 @@ describe("managed record settings configuration contract", () => { }, ], ["modules", { modulePreferences: { mood: false } }], + // v1.38.14 (#939) — the delegated key, sent on its own. `cycle` is not a + // writable module preference and never will be: its user-layer state is + // the record's own cycle profile, so the family names the real column + // rather than a blob entry the gate ignores. + ["modules", { cycleTrackingEnabled: false }], [ "notifications", { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 425bfa33a..a278da185 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -9,12 +9,27 @@ * shell would 403 the moment a switch was on. * * So every field below is still the CALLER's own — their preferences, their - * modules, their identity — and the one field about the switch, - * `accountAccess`, says so explicitly. Nothing here ever renders the owner's - * data; a delegate reading their own display preferences while inside - * somebody else's record is the correct behaviour and the reason the mode - * exists (design §4: locale, theme and layout belong to the person, not the - * record). + * identity — and the one field about the switch, `accountAccess`, says so + * explicitly. Nothing here ever renders the owner's health data; a delegate + * reading their own display preferences while inside somebody else's record is + * the correct behaviour and the reason the mode exists (design §4: locale, + * theme and layout belong to the person, not the record). + * + * TWO fields are the exception, and the exception is the rule applied rather + * than a hole in it: `modules` and `cycleTrackingEnabled` resolve for the + * RECORD the session is inside, not for the actor (#939). They are not display + * preferences. They say what this record tracks — and every consumer of them + * gates a surface that shows the RECORD's data: the navigation, the dashboard + * tiles, the insights sections, quick-add. Publishing the actor's map while + * switched meant a Guardian who turned Cycle off for the child still saw a + * Cycle destination in the child's record, and a Guardian who tracks it saw + * one there whether or not the child did — the toggle in the record's own + * settings wrote a column nothing on screen was reading. Design §4 is about + * whose eyes are on the screen; this is about whose record is on it. + * + * The own-record case is byte-identical: with no switch the active record IS + * the caller, and every value below is resolved from the same row it always + * was. */ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireActorAuth } from "@/lib/api-handler"; @@ -41,26 +56,49 @@ export const GET = apiHandler(async () => { annotate({ action: { name: "auth.me" } }); - // Two independent awaits, overlapped: the onboarding-cookie resync - // (v1.4.22 W5 Sr-H1 — fall-back for legacy sessions that predate the - // cookie; new sessions anchor it inside `createSession`) touches only - // the cookie store, while the cycle-profile read (v1.15.0 — resolved - // cycle-tracking gate; no row is forced, a NULL toggle derives from - // gender) is a Postgres round-trip. Running them sequentially added - // the cookie hop to every /me — and /me sits on every app boot path. - const [, , cycleProfile, modules, moduleAvailability, accountAccess] = - await Promise.all([ - setOnboardingPendingCookie(user.onboardingCompletedAt == null), - // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app - // boot, mirroring the onboarding-cookie resync. A locally edited cookie is - // corrected here; an account that enrols (or has the policy lifted) loses - // the redirect on the next /me read. - syncMfaEnrollCookie(user.id, { - totpConfirmedAt: user.totpConfirmedAt, - mfaEnforced: user.mfaEnforced, - }), + // Overlapped, not sequential: the two cookie resyncs (v1.4.22 W5 Sr-H1 — + // the onboarding fall-back for sessions that predate the cookie; new ones + // anchor it inside `createSession`) touch only the cookie store, while the + // access resolution is a Postgres round-trip. Running them one after the + // other added the cookie hop to every /me, and /me sits on every app boot. + const [, , accountAccess] = await Promise.all([ + setOnboardingPendingCookie(user.onboardingCompletedAt == null), + // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app + // boot, mirroring the onboarding-cookie resync. A locally edited cookie is + // corrected here; an account that enrols (or has the policy lifted) loses + // the redirect on the next /me read. + syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }), + // v1.36.0 — which records this caller may open, which one they are + // inside, and what they may do there. Every value resolved server-side; + // see the module docblock for why none of it is left to the client. + resolveAccountAccess(auth), + ]); + + // The record the two module fields answer for, and the reason this read is + // sequenced after the block above rather than inside it: the active record + // has to be the GRANT-VALIDATED one. The session row's own selector is not + // that — it is a stamp, and a stamp whose grant was revoked a minute ago + // still names an account. `resolveAccountAccess` is what checks the live + // grant, so its answer is what may pick whose configuration is published. + // The cost is one round-trip on a hot path, paid on every /me; publishing a + // revoked record's configuration would be paid once, by somebody else. + const recordId = accountAccess.active?.accountId ?? user.id; + + const [record, cycleProfile, modules, moduleAvailability] = await Promise.all( + [ + // The actor's own row is already in hand; a switched session needs the + // record's `gender`, which is the column the cycle gate derives from. + recordId === user.id + ? Promise.resolve({ gender: user.gender }) + : prisma.user.findUnique({ + where: { id: recordId }, + select: { gender: true }, + }), prisma.cycleProfile.findUnique({ - where: { userId: user.id }, + where: { userId: recordId }, select: { cycleTrackingEnabled: true }, }), // v1.18.0 — resolved module enable/disable map for every toggleable @@ -68,19 +106,21 @@ export const GET = apiHandler(async () => { // gate / disableCoach + operator assistant flag); the rest read the // disabled-allowlist `modulePreferencesJson`. Default-on. Clients // hide a whole module surface end-to-end when its key is `false`. - resolveModuleMap(user.id), + resolveModuleMap(recordId), // v1.18.0 — operator-layer availability map (server-wide kill-switch). // The resolved `modules` map above already AND-s this in, so it cannot // distinguish operator-off from user-off. The Modules hub needs that // distinction to render an operator-disabled module as a read-only // "disabled server-wide" row instead of a live toggle that no-ops. + // Server-WIDE, so it is the same map for every record and is not + // re-scoped here. getOperatorModuleAvailability(), - // v1.36.0 — which records this caller may open, which one they are - // inside, and what they may do there. Every value resolved server-side; - // see the module docblock for why none of it is left to the client. - resolveAccountAccess(auth), - ]); - const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); + ], + ); + const cycleTrackingEnabled = isCycleEnabled( + record?.gender ?? null, + cycleProfile, + ); // v1.7.0 — patient-identity fields for the health-record export. The // KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on @@ -157,7 +197,10 @@ export const GET = apiHandler(async () => { insurerIkNumber: user.insurerIkNumber ?? null, insuranceNumber, // v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS - // hides the whole cycle tab when this is false. + // hides the whole cycle tab when this is false. v1.38.14 — resolved for + // the ACTIVE RECORD, so a switched browser hides the tab the record does + // not track rather than the one the actor does not (#939). Unchanged on + // the native transport, which carries no switch. cycleTrackingEnabled, // v1.18.0 — module enable/disable map. `{ : boolean }` // for every toggleable module; `false` means the module is OFF and @@ -165,6 +208,7 @@ export const GET = apiHandler(async () => { // …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the // resolved `disableCoach` + operator master flag, so this map is the // single thing a client needs to gate every secondary domain. + // v1.38.14 — resolved for the ACTIVE RECORD; see the module docblock. modules, // v1.18.0 — operator-layer availability per toggleable module. `false` // ⇒ the operator turned the module off server-wide (off for every diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index c18bf3919..089715897 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -30,6 +30,8 @@ import { parseManagedRecordSettingsPatch, resolveGuardianRecordSettingsAccess, } from "@/lib/record-settings"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { resolveInsightsLayout, serializeInsightsLayout, @@ -101,13 +103,23 @@ export const GET = apiHandler( dateFormat: record.dateFormat, }; break; - case "modules": + case "modules": { + // The delegated key needs its real source, which is not the module + // blob. `isCycleEnabled` resolves the record's own opt-in against its + // sex exactly as `/api/auth/me` does for the account itself, so the + // switch a guardian sees is the state the gate will answer with. + const cycleProfile = await prisma.cycleProfile.findUnique({ + where: { userId: access.recordId }, + select: { cycleTrackingEnabled: true }, + }); settings = { modulePreferences: managedModulePreferencesFrom( record.modulePreferencesJson, ), + cycleTrackingEnabled: isCycleEnabled(record.gender, cycleProfile), }; break; + } case "notifications": { const preferences = parseNotificationPrefs(record.notificationPrefs); settings = { @@ -233,19 +245,50 @@ export const PATCH = apiHandler( const patch = parseManagedRecordSettingsPatch("modules", body); const current = await prisma.user.findUnique({ where: { id: access.recordId }, - select: { modulePreferencesJson: true }, + select: { modulePreferencesJson: true, gender: true }, }); const modulePreferences = { ...managedModulePreferencesFrom(current?.modulePreferencesJson), ...patch.modulePreferences, }; - await prisma.user.update({ - where: { id: access.recordId }, - data: { modulePreferencesJson: toJson(modulePreferences) }, + if (patch.modulePreferences !== undefined) { + await prisma.user.update({ + where: { id: access.recordId }, + data: { modulePreferencesJson: toJson(modulePreferences) }, + }); + } + // The delegated key writes its own column. An explicit boolean is + // what the cycle gate honours over the sex-derived default, so a + // guardian turning Cycle off for a record whose sex says otherwise + // is recorded as the deliberate answer it is. + if (patch.cycleTrackingEnabled !== undefined) { + await getOrCreateCycleProfile(access.recordId); + await prisma.cycleProfile.update({ + where: { userId: access.recordId }, + data: { cycleTrackingEnabled: patch.cycleTrackingEnabled }, + }); + } + const cycleProfile = await prisma.cycleProfile.findUnique({ + where: { userId: access.recordId }, + select: { cycleTrackingEnabled: true }, }); invalidateUserHealthScore(access.recordId); - settings = { modulePreferences }; - changed = Object.keys(patch.modulePreferences); + settings = { + modulePreferences, + cycleTrackingEnabled: isCycleEnabled( + current?.gender ?? null, + cycleProfile, + ), + }; + // The module keys themselves, not the wrapper field: the record's + // trail should say which module moved. `cycleTrackingEnabled` is + // named as itself because that is the column it writes. + changed = [ + ...Object.keys(patch.modulePreferences ?? {}), + ...(patch.cycleTrackingEnabled !== undefined + ? ["cycleTrackingEnabled"] + : []), + ]; break; } case "notifications": { diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 8dba31062..01ba0b83d 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -20,9 +20,27 @@ vi.mock("next/navigation", () => ({ const mockModulesRef: { value: Record | undefined } = { value: undefined, }; +// v1.37.0 — the shell also filters by which record the browser is inside. +// Unlike the module gate above, that filter is NOT hydration-deferred: the +// server has already resolved which record is active, so it applies on the SSR +// pass and is assertable from a static render. +const mockActiveRecordRef: { + value: { recordKind: "managed" | "shared"; level: string } | null; +} = { value: null }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ - user: { id: "u1", role: "USER", modules: mockModulesRef.value }, + user: { + id: "u1", + role: "USER", + modules: mockModulesRef.value, + accountAccess: mockActiveRecordRef.value + ? { + accounts: [mockActiveRecordRef.value], + active: mockActiveRecordRef.value, + canSwitch: true, + } + : null, + }, isAuthenticated: true, isLoading: false, refetch: vi.fn(), @@ -42,9 +60,11 @@ function renderShell(props: { pathname?: string; locale?: "en" | "de"; modules?: Record; + activeRecord?: { recordKind: "managed" | "shared"; level: string } | null; }) { mockPathnameRef.value = props.pathname ?? "/settings/account"; mockModulesRef.value = props.modules; + mockActiveRecordRef.value = props.activeRecord ?? null; return renderToStaticMarkup( @@ -327,6 +347,39 @@ describe("", () => { expect(optedOut).toContain('href="/settings/gesundheitsakte"'); }); + describe("inside somebody else's record", () => { + /** + * #939 — a guardian could turn a module off for the profile they look + * after and had to find the page by URL to do it. The section is + * classified `managed-guardian`, which is what the shell offers a managed + * record; asserting it here is what stops a future narrowing of the filter + * from silently taking the destination away again. + */ + it("offers the Modules destination to a guardian on a managed record", () => { + const html = renderShell({ + active: "account", + activeRecord: { recordKind: "managed", level: "manage" }, + }); + expect(html).toContain('href="/settings/modules"'); + // Its own configuration is not the record's, and stays away. + expect(html).not.toContain('href="/settings/security"'); + }); + + it("does not offer it to an adult delegate holding MANAGE", () => { + // A delegate manages somebody's health record, not their account: + // modules, thresholds and notification routing stay with the owner. + // Integrations remains status-only on a managed record, and unreachable + // here — both are `managed-guardian`, which an adult record never opens. + const html = renderShell({ + active: "account", + activeRecord: { recordKind: "shared", level: "manage" }, + }); + expect(html).not.toContain('href="/settings/modules"'); + expect(html).not.toContain('href="/settings/integrations"'); + expect(html).toContain('href="/settings/anamnesis"'); + }); + }); + it("falls back to `account` when the pathname doesn't match a known slug", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); const activeLinkRegex = diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 3cb4c9878..f7970da7e 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -505,6 +505,7 @@ function ModulesSettingsForm({ modulePreferences: Object.fromEntries( entries.map(([key]) => [key, form.get(key) === "on"]), ), + cycleTrackingEnabled: form.get("cycleTrackingEnabled") === "on", }); } @@ -523,6 +524,17 @@ function ModulesSettingsForm({ disabled={disabled} /> ))} + {/* v1.38.14 (#939) — Cycle sits below the rest because it is not one of + them: it is a DELEGATED module, so its state lives in the record's own + cycle profile rather than in the module blob, and it had no row here + at all. A guardian could not turn it off for a record whose sex says + it should be on, which is the case the issue was opened about. */} + ); diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 1e8697cd1..541cccfc5 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -84,7 +84,7 @@ const moduleMapResolved = z .meta({ id: "ModuleMap", description: - "Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag.", + "Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag. On `GET /api/auth/me` it answers for the RECORD the session is inside rather than for the caller, because every surface it gates shows the record's data; with no switch — which is every native request, since the Bearer transport carries none — the two are the same account and the payload is unchanged.", }); const moduleMapEnvelopeInner = z diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index ab891db0e..d8622e60f 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -276,7 +276,7 @@ const recordSettingsPatchRequest = z MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.modules.meta({ id: "RecordModuleSettingsPatch", description: - "Partial module toggle. Merged onto the record's current preferences; omitted keys keep their value.", + "Partial module toggle. `modulePreferences` is merged onto the record's current preferences; omitted keys keep their value. `cycleTrackingEnabled` is separate because `cycle` is a DELEGATED module: its state lives in the record's own cycle profile and `modulePreferences.cycle` is refused, so this is the field that turns cycle tracking on or off for the record — an explicit boolean overrides the default the record's sex would derive. Both fields are optional and at least one is required; an empty body is a 422 rather than a save that changed nothing.", }), MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.notifications.meta({ id: "RecordNotificationSettingsPatch", diff --git a/src/lib/record-settings/configuration.ts b/src/lib/record-settings/configuration.ts index d0fa87ee8..511a5d01f 100644 --- a/src/lib/record-settings/configuration.ts +++ b/src/lib/record-settings/configuration.ts @@ -30,7 +30,7 @@ export const MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST = { "timeFormat", "dateFormat", ], - modules: ["modulePreferences"], + modules: ["modulePreferences", "cycleTrackingEnabled"], notifications: ["moodReminderEnabled", "notificationPreferences"], thresholds: ["overrides"], coach: ["disableCoach", "preferences"], @@ -140,7 +140,31 @@ const managedCoachPreferencesSchema = coachPrefsSchema */ export const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = { profile: profilePatchSchema, - modules: z.object({ modulePreferences: modulePrefsPatchSchema }).strict(), + /** + * Two fields because the module foundation has two kinds of key, and only + * one of them lives in `modulePreferencesJson`. + * + * `cycle` is a DELEGATED module: its user-layer state is owned by + * `CycleProfile.cycleTrackingEnabled` (NULL derives from `User.gender`), and + * `modulePreferencesJson.cycle` is ignored on purpose — which is why + * `modulePrefsPatchSchema` refuses the key. A guardian therefore had no way + * to turn Cycle off for a record whose sex says it should be on, and no row + * for it in the list either: it is simply not a writable key (#939). Naming + * the real column here writes the real source of truth rather than minting a + * second one that the gate would ignore. + * + * Both optional so a client may send either alone; at least one required, so + * an empty body is a refusal rather than a save that changed nothing. + */ + modules: z + .object({ + modulePreferences: modulePrefsPatchSchema.optional(), + cycleTrackingEnabled: z.boolean().optional(), + }) + .strict() + .refine((patch) => Object.values(patch).some((v) => v !== undefined), { + message: "Name at least one field to change", + }), notifications: z .object({ moodReminderEnabled: z.boolean().optional(), diff --git a/tests/integration/managed-record-modules.test.ts b/tests/integration/managed-record-modules.test.ts new file mode 100644 index 000000000..95e7dee0e --- /dev/null +++ b/tests/integration/managed-record-modules.test.ts @@ -0,0 +1,322 @@ +/** + * Which record's modules a switched browser reads and writes (#939). + * + * Two claims, and neither is provable from one end: + * + * 1. a guardian turning a module off for the profile they look after writes + * the PROFILE's state, and their own account is untouched — proved + * through the real `PATCH /api/record-settings/modules` handler and the + * real rows behind it; + * 2. the payload the browser then gates its navigation on answers for the + * RECORD. Before this, `GET /api/auth/me` published the ACTOR's module + * map while switched, so the toggle wrote a column nothing on screen was + * reading: the guardian turned Cycle off for the child and kept seeing a + * Cycle destination in the child's record. + * + * `cycle` carries most of the weight because it is the case the issue was + * opened about and the one the module blob cannot express: it is a DELEGATED + * module whose user-layer state lives in `CycleProfile.cycleTrackingEnabled`, + * so `modulePreferencesJson.cycle` is ignored by the gate and refused by the + * schema. A test that wrote the blob and asserted the blob would have passed + * against a record whose gate still answered "on". + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } 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: `mods-${suffix}`, + email: `mods-${suffix}@example.test`, + displayName: `Mods ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + // The guardian tracks cycles themselves. That is the whole point: their + // own map must not be what the child's record is painted from. + gender: "FEMALE", + onboardingCompletedAt: new Date(), + }, + }); +} + +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; +} + +interface MePayload { + modules: Record; + cycleTrackingEnabled: boolean; + accountAccess: { active: { accountId: string } | null }; +} + +async function readMe(): Promise { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + expect(res.status).toBe(200); + return ((await res.json()) as { data: MePayload }).data; +} + +async function patchModules(body: unknown) { + const { PATCH } = await import("@/app/api/record-settings/[family]/route"); + return PATCH( + new NextRequest("http://localhost/api/record-settings/modules", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ family: "modules" }) }, + ); +} + +async function readModules() { + const { GET } = await import("@/app/api/record-settings/[family]/route"); + return GET( + new NextRequest("http://localhost/api/record-settings/modules", { + method: "GET", + }), + { params: Promise.resolve({ family: "modules" }) }, + ); +} + +/** A guardian signed in and switched into the profile they look after. */ +async function guardianInside(profileGender: string | null = "FEMALE") { + const guardian = await makeUser("guardian"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + gender: profileGender, + }); + const session = await signIn(guardian.id); + await switchSessionTo(session.id, profile.id); + return { guardian, profile, session }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + counter = 0; +}); + +describe("a guardian turning Cycle off for the record they look after", () => { + it("writes the record's own cycle profile and leaves their account alone", async () => { + const { guardian, profile, session } = await guardianInside(); + + const response = await patchModules({ cycleTrackingEnabled: false }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data.recordId).toBe(profile.id); + expect(body.data.settings.cycleTrackingEnabled).toBe(false); + + const prisma = getPrismaClient(); + // The delegated module's REAL source, not the module blob — which the + // gate ignores for this key and the schema refuses. + const written = await prisma.cycleProfile.findUniqueOrThrow({ + where: { userId: profile.id }, + }); + expect(written.cycleTrackingEnabled).toBe(false); + expect( + await prisma.cycleProfile.findUnique({ where: { userId: guardian.id } }), + ).toBeNull(); + + // And the gate agrees, which is the claim that matters. + const { isModuleEnabled } = await import("@/lib/modules/gate"); + await expect(isModuleEnabled(profile.id, "cycle")).resolves.toBe(false); + await expect(isModuleEnabled(guardian.id, "cycle")).resolves.toBe(true); + + // The guardian's own account, read as themselves, is what it was. + await switchSessionTo(session.id, null); + const own = await readMe(); + expect(own.accountAccess.active).toBeNull(); + expect(own.modules.cycle).toBe(true); + expect(own.cycleTrackingEnabled).toBe(true); + }); + + it("hides the destination in the record the browser is inside", async () => { + const { profile } = await guardianInside(); + + // Before: the record tracks cycles, because its sex says so. + const before = await readMe(); + expect(before.accountAccess.active?.accountId).toBe(profile.id); + expect(before.modules.cycle).toBe(true); + + await patchModules({ cycleTrackingEnabled: false }); + + // After: the payload the navigation gates on answers for the RECORD. + // `visibleNavDestinations` drops an entry whose `requiresModule` key is + // false, so this is the value that removes the Cycle destination. + const after = await readMe(); + expect(after.modules.cycle).toBe(false); + expect(after.cycleTrackingEnabled).toBe(false); + + const { visibleNavDestinations } = + await import("@/components/layout/nav-model"); + const hrefs = visibleNavDestinations(after.modules, true, true, null).map( + (destination) => destination.href, + ); + expect(hrefs).not.toContain("/cycle"); + }); + + it("makes the module gate refuse the record's cycle routes", async () => { + const { profile } = await guardianInside(); + await patchModules({ cycleTrackingEnabled: false }); + + const { requireModuleEnabled } = await import("@/lib/modules/gate"); + const gate = await requireModuleEnabled(profile.id, "cycle"); + expect(gate.enabled).toBe(false); + if (gate.enabled) throw new Error("expected the gate to refuse"); + expect(gate.response.status).toBe(403); + const refusal = await gate.response.json(); + expect(refusal.meta?.errorCode).toBe("module.disabled"); + expect(refusal.meta?.module).toBe("cycle"); + }); +}); + +describe("a guardian turning a directly-owned module off", () => { + it("writes the record's preference blob and not their own", async () => { + const { guardian, profile } = await guardianInside(); + + const response = await patchModules({ + modulePreferences: { mood: false }, + }); + expect(response.status).toBe(200); + expect((await response.json()).data.settings.modulePreferences.mood).toBe( + false, + ); + + const { isModuleEnabled } = await import("@/lib/modules/gate"); + await expect(isModuleEnabled(profile.id, "mood")).resolves.toBe(false); + await expect(isModuleEnabled(guardian.id, "mood")).resolves.toBe(true); + + const switched = await readMe(); + expect(switched.modules.mood).toBe(false); + }); +}); + +describe("the module map a switched payload publishes", () => { + it("is byte-identical to the unswitched one when there is no switch", async () => { + // The do-no-harm claim, and it has to be an equality rather than a spot + // check: the record scoping is a change to a field every client gates on. + const user = await makeUser("own"); + await signIn(user.id); + + const payload = await readMe(); + const { resolveModuleMap } = await import("@/lib/modules/gate"); + expect(payload.modules).toEqual(await resolveModuleMap(user.id)); + expect(payload.accountAccess.active).toBeNull(); + }); + + it("reads the record's sex rather than the actor's", async () => { + // No explicit opt-in anywhere: the record simply has a sex the cycle gate + // derives OFF from, which is the reporter's own suggested fix for a + // profile created for a child. + const { profile } = await guardianInside("MALE"); + + const payload = await readMe(); + expect(payload.accountAccess.active?.accountId).toBe(profile.id); + expect(payload.modules.cycle).toBe(false); + expect(payload.cycleTrackingEnabled).toBe(false); + }); +}); + +describe("who may write the record's module map", () => { + it("refuses a delegate whose grant is READ", async () => { + const guardian = await makeUser("guardian"); + const reader = await makeUser("reader"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants"); + const grant = await inviteGrant({ + grantorId: profile.id, + granteeId: reader.id, + access: "READ", + scope: null, + }); + await acceptGrant({ grantId: grant.id, granteeId: reader.id }); + const session = await signIn(reader.id); + await switchSessionTo(session.id, profile.id); + + const write = await patchModules({ cycleTrackingEnabled: false }); + expect(write.status).toBe(403); + + // Not the read either: the record's configuration is guardian-only, and + // the refusal is the same at both ends. + expect((await readModules()).status).toBe(403); + + const prisma = getPrismaClient(); + expect( + await prisma.cycleProfile.findUnique({ where: { userId: profile.id } }), + ).toBeNull(); + }); + + it("refuses an adult MANAGE delegate on an ordinary record", async () => { + // A MANAGE grant on an adult's own record is not a route into their + // account configuration. The guardian fence is about the managed marker, + // not about the level. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants"); + const grant = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "MANAGE", + scope: null, + }); + await acceptGrant({ grantId: grant.id, granteeId: delegate.id }); + const session = await signIn(delegate.id); + await switchSessionTo(session.id, owner.id); + + expect((await patchModules({ cycleTrackingEnabled: false })).status).toBe( + 403, + ); + }); +});