diff --git a/src/__tests__/module-gate-record-scoped.test.ts b/src/__tests__/module-gate-record-scoped.test.ts new file mode 100644 index 000000000..f2b7fff99 --- /dev/null +++ b/src/__tests__/module-gate-record-scoped.test.ts @@ -0,0 +1,62 @@ +/** + * A module gate on a page decides from the RECORD's map, never from "am I a + * delegate". + * + * The six surfaces below each resolve one module before they render, and each + * of them used to read `inSharedRecord || user?.modules?.X`. The prefix was + * right when it was written: `GET /api/auth/me` published the ACTOR's module + * map, so a delegate's own preferences would have closed a page in somebody + * else's record. Since the map became the record's, masked to what the grant + * opens, the same prefix fails open in the other direction — the navigation + * correctly drops the entry while a typed URL still renders the page for a + * record whose owner switched the module off. + * + * A structural check rather than a render, because the property is about the + * EXPRESSION: there is no way to reach these pages in a unit test without + * standing up the auth query, the router and the shell, and the thing that + * regressed is one `||`. The matcher asserts it found each gate before it + * asserts anything about it, so an expression that moves or is renamed fails + * loudly instead of passing on an empty match. + * + * Its limit, stated: it holds these six files and knows nothing about a + * seventh. A new module page reads none of this. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); + +/** file → the gate assignment's left-hand side. */ +const GATES: ReadonlyArray<[string, string]> = [ + ["src/app/vaccinations/page.tsx", "enabled"], + ["src/app/cycle/page.tsx", "enabled"], + ["src/app/mental-wellbeing/page.tsx", "enabled"], + ["src/app/illness/page.tsx", "enabled"], + ["src/app/illness/[id]/page.tsx", "enabled"], + ["src/components/documents/documents-view.tsx", "moduleEnabled"], +]; + +describe("a page's module gate reads the record's map alone", () => { + it.each(GATES)("%s", (file, binding) => { + const source = readFileSync(join(ROOT, file), "utf8"); + const match = source.match( + new RegExp(`const ${binding}\\s*=\\s*([^;]+);`, "s"), + ); + expect(match, `no \`const ${binding} = …\` gate found in ${file}`).not.toBe( + null, + ); + const expression = match![1]; + // It has to be a module gate at all — otherwise the assertion below would + // pass on any expression that simply does not mention sharing. + expect( + expression, + `${binding} in ${file} is not the module gate`, + ).toContain("modules"); + expect( + expression, + `${file} widens its module gate for a delegate; the record's masked map already answers for both cases`, + ).not.toContain("inSharedRecord"); + }); +}); diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx index ab7a29e57..a4ab8c9aa 100644 --- a/src/app/cycle/page.tsx +++ b/src/app/cycle/page.tsx @@ -4,7 +4,6 @@ import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { CycleView } from "@/components/cycle/cycle-view"; @@ -23,10 +22,9 @@ import { CycleView } from "@/components/cycle/cycle-view"; */ export default function CyclePage() { const { user, isLoading, isAuthenticated } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); - const enabled = inSharedRecord || user?.modules?.cycle !== false; + const enabled = user?.modules?.cycle !== false; useEffect(() => { if (isLoading) return; diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx index c0275553a..98e4c174c 100644 --- a/src/app/illness/[id]/page.tsx +++ b/src/app/illness/[id]/page.tsx @@ -14,7 +14,6 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail"; import { BackLink } from "@/components/ui/back-link"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; @@ -25,12 +24,11 @@ export default function IllnessEpisodePage({ }) { const { id } = use(params); const { user, isAuthenticated, isLoading } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const mounted = useMounted(); const router = useRouter(); const { t } = useTranslations(); - const enabled = inSharedRecord || user?.modules?.illness === true; + const enabled = user?.modules?.illness === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx index 9511acf3c..6fef680cc 100644 --- a/src/app/illness/page.tsx +++ b/src/app/illness/page.tsx @@ -4,7 +4,6 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { IllnessView } from "@/components/illness/illness-view"; @@ -21,11 +20,10 @@ import { IllnessView } from "@/components/illness/illness-view"; */ export default function IllnessPage() { const { user, isLoading, isAuthenticated } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); // Born-gated: enabled ONLY when the resolved map says `true`. - const enabled = inSharedRecord || user?.modules?.illness === true; + const enabled = user?.modules?.illness === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/mental-wellbeing/page.tsx b/src/app/mental-wellbeing/page.tsx index c5ccc99f2..970860c68 100644 --- a/src/app/mental-wellbeing/page.tsx +++ b/src/app/mental-wellbeing/page.tsx @@ -4,7 +4,6 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; @@ -27,10 +26,9 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; */ export default function MentalWellbeingPage() { const { user, isLoading, isAuthenticated } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); - const enabled = inSharedRecord || user?.modules?.mentalHealth === true; + const enabled = user?.modules?.mentalHealth === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/vaccinations/page.tsx b/src/app/vaccinations/page.tsx index 9fb2ae6a4..13687ce7b 100644 --- a/src/app/vaccinations/page.tsx +++ b/src/app/vaccinations/page.tsx @@ -4,7 +4,6 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; @@ -22,13 +21,13 @@ import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; */ export default function VaccinationsPage() { const { user, isLoading, isAuthenticated } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); - // Default-on: hidden only when the resolved map says `false` explicitly. A - // shared-record session always sees it — the delegate's own module map does - // not govern the owner's record. - const enabled = inSharedRecord || user?.modules?.vaccinations !== false; + // Default-on: hidden only when the resolved map says `false` explicitly. + // The map is the RECORD's, masked to what the grant opens, so a delegate is + // answered about the record they are in rather than about their own — and a + // module the owner has switched off closes this page for them too. + const enabled = user?.modules?.vaccinations !== false; useEffect(() => { if (isLoading) return; diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 6b3f5c60d..67e5ac4aa 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -251,8 +251,7 @@ export function DocumentsView() { const queryClient = useQueryClient(); const coachLaunch = useCoachLaunch(); - const moduleEnabled = - inSharedRecord || user?.modules?.inboundDocuments === true; + const moduleEnabled = user?.modules?.inboundDocuments === true; // UX redirect only — the API routes enforce the gate server-side. useEffect(() => {