feat: gate a switched browser on the record's modules, not the actor's

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
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 00:02:03 +02:00
1 parent f521b03c35
commit fb1cb4e3ed
9 files changed
+556 -46

No files matched your search

@@ -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",
{
+77 -33
View File
@@ -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. `{ <moduleKey>: 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
+50 -7
View File
@@ -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": {
@@ -20,9 +20,27 @@ vi.mock("next/navigation", () => ({
const mockModulesRef: { value: Record<string, boolean> | 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<string, boolean>;
activeRecord?: { recordKind: "managed" | "shared"; level: string } | null;
}) {
mockPathnameRef.value = props.pathname ?? "/settings/account";
mockModulesRef.value = props.modules;
mockActiveRecordRef.value = props.activeRecord ?? null;
return renderToStaticMarkup(
<I18nProvider initialLocale={props.locale ?? "en"}>
<SettingsShell active={props.active}>
@@ -327,6 +347,39 @@ describe("<SettingsShell>", () => {
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 =
@@ -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. */}
<ManagedSwitchRow
name="cycleTrackingEnabled"
label={t(MODULE_REGISTRY.cycle.labelKey)}
defaultChecked={settings.cycleTrackingEnabled === true}
disabled={disabled}
/>
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+26 -2
View File
@@ -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(),
@@ -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<string, boolean>;
cycleTrackingEnabled: boolean;
accountAccess: { active: { accountId: string } | null };
}
async function readMe(): Promise<MePayload> {
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,
);
});
});