mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge commit '38214e472' into work/v137-record-session-fence
This commit is contained in:
commit
a08e82d0be
81 files changed
+3505
-386
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
|
||||
const pathnameRef = { value: "/settings/security" };
|
||||
const activeRecordRef = { value: true };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => pathnameRef.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: activeRecordRef.value
|
||||
? {
|
||||
id: "guardian",
|
||||
modules: undefined,
|
||||
accountAccess: {
|
||||
active: {
|
||||
accountId: "managed-record",
|
||||
username: "managed-record",
|
||||
displayName: "Managed record",
|
||||
access: "write",
|
||||
level: "manage",
|
||||
recordKind: "managed",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
},
|
||||
accounts: [],
|
||||
canSwitch: true,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
SETTINGS_SECTIONS,
|
||||
SettingsShell,
|
||||
} from "@/components/settings/settings-shell";
|
||||
import { SharedRecordBanner } from "@/components/layout/shared-record-banner";
|
||||
|
||||
function renderSettings(pathname = "/settings/security") {
|
||||
pathnameRef.value = pathname;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SettingsShell>
|
||||
<div>managed settings content</div>
|
||||
</SettingsShell>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("managed record navigation", () => {
|
||||
it("shows only managed settings destinations while retaining an unavailable deep-link heading", () => {
|
||||
const html = renderSettings();
|
||||
const managedDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind === "managed-guardian",
|
||||
);
|
||||
const unavailableDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind !== "managed-guardian",
|
||||
);
|
||||
|
||||
expect(html).toContain('data-active-record-id="managed-record"');
|
||||
expect(html).toContain('id="settings-section-security-title"');
|
||||
expect(html).not.toMatch(/href="[^"]*managed-record/);
|
||||
|
||||
for (const section of managedDestinations) {
|
||||
expect(
|
||||
html.match(new RegExp(`href="/settings/${section.slug}"`, "g"))
|
||||
?.length ?? 0,
|
||||
).toBe(2);
|
||||
}
|
||||
|
||||
for (const section of unavailableDestinations) {
|
||||
expect(html).not.toContain(`href="/settings/${section.slug}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the exit control present while administering a managed record", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SharedRecordBanner />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-account-id="managed-record"');
|
||||
expect(html).toContain('data-slot="shared-record-banner-exit"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES,
|
||||
resolveSharedRecordNavigation,
|
||||
} from "@/lib/navigation/shared-record";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import {
|
||||
isDestinationInSharedRecord,
|
||||
visibleNavDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
|
||||
|
||||
describe("shared-record navigation", () => {
|
||||
it("keeps every granted route family in one closed eight-domain inventory", () => {
|
||||
expect(Object.keys(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES)).toEqual(
|
||||
SHARE_DOMAINS,
|
||||
);
|
||||
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(
|
||||
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].length,
|
||||
`${domain} needs at least one route family`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a scoped grant only its matching doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["labs"]);
|
||||
|
||||
expect(navigation.destinationHrefs).toEqual(["/labs"]);
|
||||
expect(navigation.allowsPath("/labs/panel/7")).toBe(true);
|
||||
expect(navigation.allowsPath("/medications")).toBe(false);
|
||||
|
||||
expect(
|
||||
visibleNavDestinations(undefined, true, true, ["labs"]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/labs"]);
|
||||
expect(isDestinationInSharedRecord("/labs/panel/7", ["labs"])).toBe(true);
|
||||
expect(isDestinationInSharedRecord("/medications", ["labs"])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["profile"]);
|
||||
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile"]);
|
||||
expect(navigation.allowsPath("/profile")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the active record scope instead of the actor's module flags", () => {
|
||||
const actorModules = {
|
||||
medications: false,
|
||||
labs: false,
|
||||
mood: false,
|
||||
mentalHealth: false,
|
||||
cycle: false,
|
||||
illness: false,
|
||||
inboundDocuments: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
visibleNavDestinations(actorModules, true, true, ["medications"]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/medications"]);
|
||||
});
|
||||
|
||||
it("keeps whole-record navigation distinct from an all-sections scope", () => {
|
||||
const wholeRecord = resolveSharedRecordNavigation(null);
|
||||
const allSections = resolveSharedRecordNavigation([...SHARE_DOMAINS]);
|
||||
|
||||
expect(wholeRecord.allowsPath("/")).toBe(true);
|
||||
expect(allSections.allowsPath("/")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses unclassified direct URLs in presentation before pages start reads", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["documents"]);
|
||||
|
||||
expect(navigation.allowsPath("/documents/record-1")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/account")).toBe(false);
|
||||
expect(navigation.allowsPath("/new-unclassified-route")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps server-resolved access and record kind distinct in chrome", () => {
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "read",
|
||||
canWrite: false,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "view", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "view-and-add", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
}),
|
||||
).toEqual({ access: "manage", recordKind: "shared" });
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
recordKind: "managed",
|
||||
}),
|
||||
).toEqual({ access: "manage", recordKind: "managed" });
|
||||
});
|
||||
|
||||
it("fails closed when a malformed entry reaches presentation", () => {
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "manage",
|
||||
canWrite: false,
|
||||
recordKind: "managed",
|
||||
} as never),
|
||||
).toEqual({ access: "view", recordKind: "self" });
|
||||
|
||||
expect(
|
||||
resolveRecordPresentation({
|
||||
level: "unknown",
|
||||
canWrite: true,
|
||||
recordKind: "shared",
|
||||
} as never),
|
||||
).toEqual({ access: "view", recordKind: "self" });
|
||||
});
|
||||
});
|
||||
@@ -879,6 +879,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
|
||||
},
|
||||
"app/api/profile/summary/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.",
|
||||
},
|
||||
"app/api/mood-entries/route.ts": {
|
||||
domain: "mind",
|
||||
why: "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.",
|
||||
@@ -939,6 +943,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "cycle",
|
||||
why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.",
|
||||
},
|
||||
"app/api/cycle/calendar/route.ts": {
|
||||
domain: "cycle",
|
||||
why: "The initial cycle calendar read. Its gate resolves tracking eligibility from the substituted record id, and the handler only reads that record's bounded calendar inputs.",
|
||||
},
|
||||
"app/api/cycle/insights/route.ts": {
|
||||
domain: "cycle",
|
||||
why: "The initial cycle insights read. Its rate-limit key and every phase-correlation input use the resolved record id; it creates no provider egress or mutation.",
|
||||
},
|
||||
"app/api/cycle/day-logs/route.ts": {
|
||||
domain: "cycle",
|
||||
why: 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.',
|
||||
@@ -1793,9 +1805,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* find out from the merge rather than from production. Import-status polling
|
||||
* 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.
|
||||
* managed-record configuration adds one more: 196 → 197. The bounded
|
||||
* read-only profile summary makes the next explicit admission: 197 → 198.
|
||||
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 197;
|
||||
const FROZEN_ENTRY_COUNT = 200;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -13,10 +10,6 @@ import {
|
||||
NOTIFICATION_CHANNELS,
|
||||
NOTIFICATION_DELIVERY_MATRIX,
|
||||
} from "../../tests/fixtures/v137/notification-matrix";
|
||||
import {
|
||||
ACCESSIBILITY_STATES,
|
||||
RELEASE_JOURNEYS,
|
||||
} from "../../tests/fixtures/v137/e2e-journeys";
|
||||
|
||||
describe("sharing and handler inventories", () => {
|
||||
it("freezes the exact sharing-domain vocabulary", () => {
|
||||
@@ -104,28 +97,3 @@ describe("security principals and compatibility", () => {
|
||||
expect(LEGACY_ACCOUNT_PAYLOADS[3]?.legacyDecoder).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("journey and accessibility inventory", () => {
|
||||
it("names every release journey and accessibility state exactly once", () => {
|
||||
expect(RELEASE_JOURNEYS).toHaveLength(8);
|
||||
expect(ACCESSIBILITY_STATES).toHaveLength(5);
|
||||
expect(new Set(RELEASE_JOURNEYS.map(({ name }) => name)).size).toBe(
|
||||
RELEASE_JOURNEYS.length,
|
||||
);
|
||||
expect(new Set(ACCESSIBILITY_STATES.map(({ name }) => name)).size).toBe(
|
||||
ACCESSIBILITY_STATES.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the browser anchors discoverable without focused or skipped cases", () => {
|
||||
const anchorFiles = [
|
||||
"v137-sharing-managed-profiles.spec.ts",
|
||||
"v137-sharing-managed-profiles-a11y.spec.ts",
|
||||
];
|
||||
|
||||
for (const file of anchorFiles) {
|
||||
const source = readFileSync(join(process.cwd(), "e2e", file), "utf8");
|
||||
expect(source).not.toMatch(/\b(?:test|describe)\.(?:only|skip)\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user