mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(modules): let the record's own module map close its pages for a delegate too
Six page gates read `inSharedRecord || user?.modules?.X`, with a comment saying the delegate's own module map does not govern the owner's record. That was true when it was written and stopped being true in v1.38.14: `GET /api/auth/me` now publishes the RECORD's map, masked to what the grant opens, so the prefix that protected against reading the wrong map now widens the gate instead. The navigation drops the entry correctly while a typed URL still renders the page for a record whose owner switched the module off, and `/api/vaccinations` carries no module gate of its own, so it renders and works. Deleting the prefix answers both cases from the one map, which is what it is for. A structural guard holds the six expressions: it locates each gate, proves it is a module gate, and then asserts it does not widen on the sharing question. It finds nothing to assert on if the expression moves, and says so, rather than passing on an empty match. All six legs fail against the previous gates.
This commit is contained in:
1 parent
e7a1950f3a
commit
0ee26625be
7 files changed
+72
-20
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user