Files
HealthLog/src/__tests__/record-settings-configuration.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

157 lines
4.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isManagedRecordSettingsFamily,
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
managedModulePreferencesFrom,
parseManagedRecordSettingsPatch,
} from "@/lib/record-settings/configuration";
import { WRITABLE_MODULE_KEYS } from "@/lib/validations/modules";
describe("managed record settings configuration contract", () => {
it("enumerates the only target-record fields each settings DTO may write", () => {
expect(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST).toEqual({
profile: [
"displayName",
"heightCm",
"dateOfBirth",
"gender",
"locale",
"timezone",
"unitPreference",
"timeFormat",
"dateFormat",
],
modules: ["modulePreferences", "cycleTrackingEnabled"],
notifications: ["moodReminderEnabled", "notificationPreferences"],
thresholds: ["overrides"],
coach: ["disableCoach", "preferences"],
insights: ["layout"],
});
});
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",
{
notificationPreferences: { medication: { deliveryDefault: "client" } },
},
],
["thresholds", { glucoseUnit: "imperial" }],
["coach", { memories: [] }],
["insights", { provider: "external" }],
] as const)("rejects a disallowed %s field", (family, patch) => {
expect(() => parseManagedRecordSettingsPatch(family, patch)).toThrow();
});
it.each([
[
"profile",
{
displayName: "Managed profile",
heightCm: 120,
dateOfBirth: "2016-04-03",
gender: "OTHER",
locale: "en",
timezone: "Europe/Berlin",
unitPreference: "metric",
timeFormat: "H24",
dateFormat: "DMY",
},
],
["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",
{
moodReminderEnabled: true,
notificationPreferences: {
medication: { lowStockRunwayDays: 7, reorderLeadDays: 10 },
mood: { reminderHour: 22 },
},
},
],
["thresholds", { overrides: { WEIGHT: { min: 55, max: 80 } } }],
[
"coach",
{
disableCoach: false,
preferences: {
tone: "warm",
verbosity: "default",
excludeMetrics: [],
defaultWindow: "allTime",
},
},
],
[
"insights",
{
layout: {
version: 2,
tiles: [{ id: "weight", visible: true, order: 0 }],
},
},
],
] as const)("accepts a typed %s patch", (family, patch) => {
expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow();
});
// Watched red: re-adding `showEvidenceByDefault: true` to the managed
// coach pick makes the strict schema accept the key again and this
// fails. The evidence disclosure stopped honouring the flag in v1.4.27
// (F14); a guardian toggle over a pref nothing reads is a dead control.
it("refuses the retired showEvidenceByDefault pref", () => {
expect(() =>
parseManagedRecordSettingsPatch("coach", {
preferences: {
tone: "warm",
verbosity: "default",
excludeMetrics: [],
defaultWindow: "allTime",
showEvidenceByDefault: true,
},
}),
).toThrow();
});
it("uses the complete canonical module inventory for a fresh record", () => {
expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual(
WRITABLE_MODULE_KEYS,
);
expect(managedModulePreferencesFrom(null)).toEqual(
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
);
});
it("accepts only the canonical profile gender enum", () => {
expect(() =>
parseManagedRecordSettingsPatch("profile", { gender: "MALE" }),
).not.toThrow();
expect(() =>
parseManagedRecordSettingsPatch("profile", { gender: "UNKNOWN" }),
).toThrow();
});
it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
"rejects inherited family name %s",
(family) => {
expect(isManagedRecordSettingsFamily(family)).toBe(false);
},
);
});