mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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
157 lines
4.9 KiB
TypeScript
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);
|
|
},
|
|
);
|
|
});
|