Files
HealthLog/tests/integration/managed-record-modules.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

530 lines
20 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 type { ShareDomain } from "@/lib/sharing/scope";
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);
},
})),
};
});
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>;
moduleAccess: Record<string, string>;
cycleTrackingEnabled: boolean;
accountAccess: {
active: { accountId: string; sections: string[] | null } | 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;
}
/**
* The one thing that must hold between the two module maps, asserted on every
* payload this file reads.
*
* `moduleAccess` is additive: it says WHY, and a client that only gates a
* surface keeps reading the boolean. The moment the two disagree, one of the
* two answers is wrong and there is no way to tell which from the wire — so
* the pairing is checked here against the real route rather than only against
* the pure builder, where a wiring mistake in the route would not show.
*/
function expectAccessMatchesBooleans(payload: MePayload) {
const keys = Object.keys(payload.modules);
expect(keys.length).toBeGreaterThan(10);
for (const key of keys) {
expect(payload.moduleAccess[key], `a reason for ${key}`).toBeDefined();
expect(payload.modules[key], `modules.${key}`).toBe(
payload.moduleAccess[key] === "enabled",
);
}
}
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,
);
});
});
describe("what a SCOPED grant is told about the record's modules", () => {
/** An owner who tracks everything, and a delegate holding one section. */
async function delegateInside(scope: ShareDomain[] | null) {
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: "READ",
scope,
});
await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
return { owner, delegate };
}
it("answers false for every module outside the granted sections", async () => {
// The disclosure the record scoping opened: `active` is set for ANY live
// grant, so a delegate holding `measurements` would otherwise read the
// owner's cycle, screener, illness and lab configuration — and the cycle
// flag derives from the owner's recorded sex.
const { owner } = await delegateInside(["measurements"]);
const payload = await readMe();
expect(payload.accountAccess.active?.accountId).toBe(owner.id);
expect(payload.accountAccess.active?.sections).toEqual(["measurements"]);
// The owner really does track these — masked, not merely absent.
const { resolveModuleMap } = await import("@/lib/modules/gate");
const truth = await resolveModuleMap(owner.id);
for (const key of [
"cycle",
"mood",
"mentalHealth",
"illness",
"labs",
"medications",
"inboundDocuments",
] as const) {
expect(truth[key], `the record's own ${key}`).toBe(true);
expect(payload.modules[key], `the delegate's ${key}`).toBe(false);
}
expect(payload.cycleTrackingEnabled).toBe(false);
// The granted section still answers honestly, or the mask would be a
// blackout rather than a scope.
for (const key of ["sleep", "glucose", "workouts", "recovery"] as const) {
expect(payload.modules[key], `the delegate's ${key}`).toBe(true);
}
});
it("says WHY the modules outside the grant are not there", async () => {
// The boolean above cannot tell "the owner switched it off" from "your
// grant does not reach it", so a delegate's empty state either says
// nothing or offers them a switch that is not theirs. This is the field
// that separates the two.
await delegateInside(["measurements"]);
const payload = await readMe();
expectAccessMatchesBooleans(payload);
for (const key of [
"cycle",
"mood",
"mentalHealth",
"illness",
"labs",
"medications",
"inboundDocuments",
] as const) {
expect(payload.moduleAccess[key], `the delegate's ${key}`).toBe(
"not_granted",
);
}
// Record-spanning modules have no single section to open, so no scoped
// grant reaches them — the same answer the boolean mask gives.
for (const key of [
"achievements",
"coach",
"insights",
"doctorReport",
] as const) {
expect(payload.moduleAccess[key], `the delegate's ${key}`).toBe(
"not_granted",
);
}
for (const key of ["sleep", "glucose", "workouts", "recovery"] as const) {
expect(payload.moduleAccess[key], `the delegate's ${key}`).toBe(
"enabled",
);
}
});
it("leaves an unscoped grant reading the record's true map", async () => {
// `scope: null` is the whole record, which every grant written before
// scoping carries. Masking it would break the case the record scoping was
// built for.
const { owner } = await delegateInside(null);
const payload = await readMe();
expect(payload.accountAccess.active?.sections).toBeNull();
const { resolveModuleMap } = await import("@/lib/modules/gate");
expect(payload.modules).toEqual(await resolveModuleMap(owner.id));
expect(payload.cycleTrackingEnabled).toBe(true);
});
it("never tells an unscoped delegate a module is out of their grant", async () => {
// `not_granted` is the mask speaking, and there is no mask here. An
// unscoped grant that reported one would be describing a narrowing the
// owner never made.
await delegateInside(null);
const payload = await readMe();
expectAccessMatchesBooleans(payload);
// `mcp` ships opt-in, so "disabled" is expected here; what must never
// appear is a reason that only a narrowing could produce.
for (const [key, state] of Object.entries(payload.moduleAccess)) {
expect(["enabled", "disabled"], `${key} reads ${state}`).toContain(state);
}
});
});
describe("a module the operator switched off server-wide", () => {
/** The operator's instance-wide blob. Disabled allowlist: only `false` bites. */
async function operatorDisables(keys: string[]) {
await getPrismaClient().appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
moduleAvailabilityJson: Object.fromEntries(keys.map((k) => [k, false])),
},
update: {
moduleAvailabilityJson: Object.fromEntries(keys.map((k) => [k, false])),
},
});
}
it("reads unavailable rather than as the record's own choice", async () => {
// The boolean is `false` either way, which is exactly the confusion: the
// account holder is told to go and turn on a switch the operator removed,
// and the Modules hub they are sent to has no live control to offer.
const user = await makeUser("own");
await signIn(user.id);
await operatorDisables(["labs"]);
const payload = await readMe();
expectAccessMatchesBooleans(payload);
expect(payload.modules.labs).toBe(false);
expect(payload.moduleAccess.labs).toBe("unavailable");
// Untouched keys stay the record's own answer.
expect(payload.moduleAccess.sleep).toBe("enabled");
});
it("outranks the grant mask for a scoped delegate", async () => {
// Precedence, outside-in: the switch that would have to move is the
// operator's whether or not the grant reaches the section.
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: "READ",
scope: ["measurements"],
});
await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
await operatorDisables(["labs", "sleep"]);
const payload = await readMe();
expectAccessMatchesBooleans(payload);
// Outside the grant AND off server-wide.
expect(payload.moduleAccess.labs).toBe("unavailable");
// Inside the grant, off server-wide.
expect(payload.moduleAccess.sleep).toBe("unavailable");
// Outside the grant, available server-wide.
expect(payload.moduleAccess.cycle).toBe("not_granted");
expect(payload.moduleAccess.glucose).toBe("enabled");
});
});