mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge managed record settings
This commit is contained in:
commit
201a24e404
36 files changed
+3712
-14
No files matched your search
@@ -0,0 +1,127 @@
|
||||
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"],
|
||||
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
||||
thresholds: ["overrides"],
|
||||
coach: ["disableCoach", "preferences"],
|
||||
insights: ["layout"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["profile", { email: "not-allowed@example.test" }],
|
||||
["modules", { role: "ADMIN" }],
|
||||
[
|
||||
"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 } }],
|
||||
[
|
||||
"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: [],
|
||||
showEvidenceByDefault: false,
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertRecordSettingsAccess,
|
||||
type RecordSettingsAccess,
|
||||
} from "@/lib/record-settings/access";
|
||||
import {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
|
||||
import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations";
|
||||
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
|
||||
import { recordSettingsKeys } from "@/lib/query-keys";
|
||||
|
||||
describe("record settings contract", () => {
|
||||
it("names the target record in both the DTO and cache key", () => {
|
||||
const dto = toRecordSettingsDto({
|
||||
id: "record-1",
|
||||
name: "Managed profile",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin",
|
||||
recordKind: "managed",
|
||||
});
|
||||
|
||||
expect(dto).toEqual({
|
||||
record: {
|
||||
id: "record-1",
|
||||
displayName: "Managed profile",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin",
|
||||
kind: "managed",
|
||||
},
|
||||
});
|
||||
expect(recordSettingsKeys.detail("record-1")).toEqual([
|
||||
"record-settings",
|
||||
"record-1",
|
||||
"detail",
|
||||
]);
|
||||
expect(recordSettingsKeys.recordSettingsIntegrations("record-1")).toEqual([
|
||||
"record-settings",
|
||||
"record-1",
|
||||
"integrations",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let an adult MANAGE grant use Guardian-only configuration", () => {
|
||||
const access: RecordSettingsAccess = {
|
||||
actorId: "adult-manager",
|
||||
recordId: "managed-record",
|
||||
recordKind: "managed",
|
||||
relationship: "adult-manager",
|
||||
};
|
||||
|
||||
expect(() => assertRecordSettingsAccess(access, "guardian")).toThrow(
|
||||
"Guardian configuration is unavailable for this record",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies every Settings destination and limits Guardian writes", () => {
|
||||
expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0);
|
||||
expect(classifySettingsDestination("integrations")).toMatchObject({
|
||||
kind: "managed-guardian",
|
||||
guardianWritable: false,
|
||||
});
|
||||
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
|
||||
expect(classifySettingsDestination("dashboard").kind).toBe(
|
||||
"adult-shared-unavailable",
|
||||
);
|
||||
expect(isGuardianSettingsWriteAllowed("account")).toBe(true);
|
||||
expect(isGuardianSettingsWriteAllowed("notifications")).toBe(true);
|
||||
expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false);
|
||||
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report a synthetic ledger row as a managed connection", () => {
|
||||
const connected = {
|
||||
withings: false,
|
||||
whoop: false,
|
||||
fitbit: false,
|
||||
nightscout: false,
|
||||
polar: false,
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
resolveManagedIntegrationState("connected", connected, "withings"),
|
||||
).toBe("disconnected");
|
||||
expect(
|
||||
resolveManagedIntegrationState(
|
||||
"error_reauth",
|
||||
{ ...connected, oura: true },
|
||||
"oura",
|
||||
),
|
||||
).toBe("error_reauth");
|
||||
});
|
||||
|
||||
it("rejects a late response for another record in a second tab", () => {
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
{ recordId: "managed-record-b" },
|
||||
"managed-record-a",
|
||||
),
|
||||
).toThrow("did not match its key");
|
||||
expect(() =>
|
||||
assertRecordSettingsResponseForRecord(
|
||||
{ recordId: "managed-record-a" },
|
||||
"managed-record-a",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST } from "@/lib/record-settings";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
const RECORD_SETTINGS_ROUTE_ROOT = fromRoot("src/app/api/record-settings");
|
||||
|
||||
describe("record settings route guard", () => {
|
||||
it("freezes the non-empty guardian-only route inventory", async () => {
|
||||
const discovered = (
|
||||
await readdir(RECORD_SETTINGS_ROUTE_ROOT, {
|
||||
recursive: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.endsWith("route.ts"))
|
||||
.map((entry) => `app/api/record-settings/${entry}`)
|
||||
.sort();
|
||||
|
||||
expect(discovered).toHaveLength(3);
|
||||
expect(discovered).toEqual([
|
||||
"app/api/record-settings/[family]/route.ts",
|
||||
"app/api/record-settings/integrations/route.ts",
|
||||
"app/api/record-settings/route.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves every record-settings route through the Guardian fence", async () => {
|
||||
const routes = [
|
||||
"route.ts",
|
||||
"integrations/route.ts",
|
||||
"[family]/route.ts",
|
||||
] as const;
|
||||
const sources = await Promise.all(
|
||||
routes.map((route) =>
|
||||
readFile(resolve(RECORD_SETTINGS_ROUTE_ROOT, route), "utf8"),
|
||||
),
|
||||
);
|
||||
|
||||
for (const source of sources) {
|
||||
expect(source).toContain("requireGuardianAuth");
|
||||
expect(source).toContain("resolveGuardianRecordSettingsAccess");
|
||||
}
|
||||
});
|
||||
|
||||
it("makes the field-specific route exhaustive over the approved DTO families", async () => {
|
||||
const source = await readFile(
|
||||
resolve(RECORD_SETTINGS_ROUTE_ROOT, "[family]/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const families = Object.keys(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST);
|
||||
expect(families.length).toBeGreaterThan(0);
|
||||
for (const family of families) {
|
||||
expect(source).toContain(`case \"${family}\"`);
|
||||
}
|
||||
expect(source).toContain("if (!isManagedRecordSettingsFamily(rawFamily))");
|
||||
expect(source).toContain('apiError("Settings family not found", 404)');
|
||||
expect(
|
||||
source.indexOf("isManagedRecordSettingsFamily(rawFamily)"),
|
||||
).toBeLessThan(source.indexOf("const context = await requireGuardianAuth"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
|
||||
if (
|
||||
expected === null ||
|
||||
typeof expected !== "object" ||
|
||||
actual === null ||
|
||||
typeof actual !== "object"
|
||||
) {
|
||||
expect(typeof actual).toBe(typeof expected);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedRecord = expected as Record<string, unknown>;
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
expect(Object.keys(actualRecord).sort()).toEqual(
|
||||
Object.keys(expectedRecord).sort(),
|
||||
);
|
||||
for (const key of Object.keys(expectedRecord)) {
|
||||
expectMatchingMessageKeys(expectedRecord[key], actualRecord[key]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("managed record settings UI", () => {
|
||||
it("gates shared deep links and renders integration status without controls", async () => {
|
||||
const [
|
||||
statusView,
|
||||
managedSettingsView,
|
||||
settingsPage,
|
||||
layoutModulePage,
|
||||
sectionGate,
|
||||
...localeBundles
|
||||
] = await Promise.all([
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-integration-status.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-record-settings-section.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
|
||||
readFile(fromRoot("src/app/settings/layout/[module]/page.tsx"), "utf8"),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
|
||||
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(statusView).toContain("recordSettingsIntegrations");
|
||||
expect(statusView).toContain("/api/record-settings/integrations");
|
||||
expect(statusView).toContain("assertRecordSettingsResponseForRecord");
|
||||
expect(statusView).not.toContain("<Button");
|
||||
expect(statusView).not.toContain("<Link");
|
||||
expect(settingsPage).toContain("RecordSettingsSectionGate");
|
||||
expect(sectionGate).toContain("if (!inSharedRecord) return children");
|
||||
expect(sectionGate).toContain("ManagedIntegrationStatus");
|
||||
expect(sectionGate).toContain("ManagedRecordSettingsSection");
|
||||
expect(sectionGate).toContain("if (isLoading)");
|
||||
expect(layoutModulePage).toContain(
|
||||
'RecordSettingsSectionGate section="layout"',
|
||||
);
|
||||
expect(managedSettingsView).toContain(
|
||||
"safeParseManagedRecordSettingsPatch",
|
||||
);
|
||||
expect(managedSettingsView).toContain("/api/record-settings/${family}");
|
||||
expect(managedSettingsView).not.toContain("/api/auth/");
|
||||
expect(managedSettingsView).not.toContain("/api/user/");
|
||||
expect(managedSettingsView).not.toContain("<textarea");
|
||||
expect(managedSettingsView).not.toContain("Display name");
|
||||
expect(managedSettingsView).not.toContain("Enable mood reminder");
|
||||
const referenceMessages = JSON.parse(localeBundles[0]).settings.sharedRecord
|
||||
.managedSettings;
|
||||
for (const bundle of localeBundles) {
|
||||
const sharedRecord = JSON.parse(bundle).settings.sharedRecord;
|
||||
expect(sharedRecord).toMatchObject({
|
||||
unavailableTitle: expect.any(String),
|
||||
integrationStatusDescription: expect.any(String),
|
||||
managedSettings: {
|
||||
profile: {
|
||||
title: expect.any(String),
|
||||
displayName: expect.any(String),
|
||||
},
|
||||
modules: { title: expect.any(String) },
|
||||
notifications: { title: expect.any(String) },
|
||||
thresholds: { title: expect.any(String) },
|
||||
coach: { title: expect.any(String) },
|
||||
insights: { title: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expectMatchingMessageKeys(
|
||||
referenceMessages,
|
||||
sharedRecord.managedSettings,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1745,7 +1745,14 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
* the auth layer, or "no module reaches it" would be true because the symbol
|
||||
* had been renamed rather than because no module reaches it.
|
||||
*/
|
||||
const GUARDIAN_ROUTES: Record<string, string> = {};
|
||||
const GUARDIAN_ROUTES: Record<string, string> = {
|
||||
"app/api/record-settings/route.ts":
|
||||
"The managed record's settings descriptor; it is refused for an adult record even with MANAGE.",
|
||||
"app/api/record-settings/integrations/route.ts":
|
||||
"Verified integration connection status for the managed record, without any provider control surface.",
|
||||
"app/api/record-settings/[family]/route.ts":
|
||||
"The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Route modules that deliberately resolve the ACTOR and keep working under a
|
||||
@@ -1784,9 +1791,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* number rather than one per list on purpose: filling either of the new
|
||||
* literals collides here by construction, so two people filling them at once
|
||||
* find out from the merge rather than from production. Import-status polling
|
||||
* adds two record entries and two MANAGE entries: 190 → 194.
|
||||
* adds two record entries and two MANAGE entries: 190 → 194. Managed-record
|
||||
* settings add two guardian-only entries: 194 → 196. Field-specific
|
||||
* managed-record configuration adds one more: 196 → 197.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 194;
|
||||
const FROZEN_ENTRY_COUNT = 197;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
Reference in new issue
Block a user