Files
HealthLog/tests/integration/managed-record-modules.test.ts
T
Marc-André Bombeck fb1cb4e3ed 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
2026-09-10 00:02:03 +02:00

323 lines
12 KiB
TypeScript

/**
* 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,
);
});
});