Files
HealthLog/src/__tests__/customize-shortcut-guard.test.ts
T
Marc-André Bombeck 25e86b36b3 Close the delegated-access gaps the release audit left open
Seven findings from the adversarial pass before v1.36.1 was tagged. One
blocking defect shipped in that release; these did not, and they are all places
where somebody looking after another person's health record meets a wall with
no explanation, or where a guard cannot fail.

The document vault was the largest. `/documents` is marked as a shared
destination, so the shell offers it under a switch, and every read behind it
resolved the caller instead of the record: the vault painted a query-error card
and the illness view painted another beside it. That is the same failure the
release describes as fixed on the dashboard. Five GET arms move to the record
resolver now, and only the GET arms: the list, one document, its file, its
thumbnail and the vault's usage payload, which the filter bar is built from.
Uploading, retyping, deleting, linking a document to an illness and every AI
verb stay with the owner, and the two reads that spend decryption CPU key
their quota on the person calling rather than the record, the rule
`medications/compliance` already follows.

Logging a value on a metric you track yourself leaves the admitted set. It was
admitted, named on the consent screen in six languages, and unreachable: the
only form that posts one sits on a page a shared record does not open, and the
component that renders it says so in its own comment while returning null. It
is the third route the rule that withdrew allergies and family history fitted
exactly, and it was applied to two of the three. Reading those values stays.

Two guards could not fail, and both were proved by mutation before and after.
The prefetch guard checked rule three with two regexes: one was satisfied by
the doc comment every page carries, the other missed an aliased import, and
the pair stayed green with the dashboard genuinely prefetching off the
switch-blind session. It reads the AST now, resolves aliases through the
module's own import table, and pins the five prefetching pages by name so a
page that stops being detected is loud. The activity verb map decided
membership on a literal `requireRecordAuth("write")`, so putting the argument
on its own line, which is what the formatter does the moment the call grows,
dropped a route out of the scan and turned a genuinely-red run green. Both
counts are of matches now rather than of files walked.

The rest are single instances of rules applied everywhere else. "Take all due"
was withheld on a note saying it rides the bulk intake route; it does not, and
its own docblock says why not, so a helper with five morning tablets tapped
five times for nothing. Marking a dose skipped is offered wherever marking it
taken is. A form could be opened in the frame before the app knew whose record
was on screen and then stood there once the answer arrived, on the capture
sheet and on the dashboard's quick-entry sheets. Two more copies of the intake
toast were still building their own, one of them with an Undo pointing at a
route no delegate may call. The customise link on the mood page led to a page
that exists to say it is not part of what was shared. And the access-level
radios on the invitation screen are hidden by design with no focus ring on
their label, so keyboard focus moved invisibly across the one screen where
somebody grants write access to their record.

Three new structural guards, each watched fail first: the customise link sits
inside a capability branch on every surface a delegate can reach, every intake
surface confirms through the one shared decision, and the batch sweep is
offered at the level that admits it.
2026-08-04 14:55:32 +02:00

219 lines
7.9 KiB
TypeScript

/**
* The customise wrench never leads a delegate into a wall.
*
* Every module surface carries the same glyph in the same slot, linking to its
* section under `/settings/layout/…`. `/settings` is not a shared-record
* destination: the shell renders "not part of what was shared" there before
* the page does. So the link has to be withheld inside somebody else's record,
* and five of the six that a delegate can reach withheld it. `/mood` did not,
* which made the wrench a control that existed only to explain itself.
*
* One missed instance out of six is what a rule looks like when nothing states
* it, so this states it. It is structural rather than rendered because these
* pages early-return a loading gate under SSR — the markup a delegate would
* see is not reachable without a browser, and the property is about the source
* either way: the link is inside a `canManage` conditional, or it is not.
*
* ## What decides
*
* The AST. The `href` is found as a string literal on a JSX attribute and the
* ancestor chain above it is walked for a `&&` whose left-hand side names
* `canManage`. A comment about gating cannot satisfy that, and a `canManage`
* used elsewhere in the same file cannot either — which a "does this file
* mention the symbol" matcher would have accepted, and which is exactly what
* `/mood` looked like the day it shipped ungated.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join, sep } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const SRC = join(process.cwd(), "src");
/** The prefix every customise shortcut points at. */
const LAYOUT_PREFIX = "/settings/layout/";
/** The capability a shared-record surface withholds these on. */
const GUARD = "canManage";
/**
* The surfaces that carry a layout shortcut and need no gate.
*
* All three are `/insights`, which has no `sharedRecord` flag in the nav model
* — Insights and the Coach drop out of a switch entirely, being AI surfaces —
* so a delegate never renders any of them. They are exempt by construction
* rather than by permission, and the exemption is written here so that "every
* one is guarded" does not have to be softened into something weaker for them.
* A surface that gains the flag has to leave this list in the same diff.
*/
const NOT_A_SHARED_DESTINATION: Record<string, string> = {
"components/insights/insights-tab-strip.tsx":
"The Insights tab strip. `/insights` carries no `sharedRecord` flag, so a switched session never reaches the surface this sits on.",
"app/insights/page-client.tsx":
"The Insights page itself. Same flag, same reason.",
"components/insights/insights-edit-mode.tsx":
"The Insights arrange mode, reachable only from the page above.",
};
function allSourceFiles(): string[] {
return readdirSync(SRC, { recursive: true })
.map((p) => String(p).split(sep).join("/"))
.filter((p) => p.endsWith(".tsx"))
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.tsx"))
.sort();
}
/** Does any ancestor of `node` gate it behind `<GUARD> && …`? */
function guardedByCanManage(node: ts.Node): boolean {
for (let cur: ts.Node | undefined = node; cur; cur = cur.parent) {
const parent: ts.Node | undefined = cur.parent;
if (
parent &&
ts.isBinaryExpression(parent) &&
parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
// Only the RIGHT side is the guarded branch. `canManage && x` guards `x`;
// `x && canManage` is `canManage` being the guarded thing.
parent.right === cur
) {
const names = new Set<string>();
const collect = (n: ts.Node): void => {
if (ts.isIdentifier(n)) names.add(n.text);
n.forEachChild(collect);
};
collect(parent.left);
if (names.has(GUARD)) return true;
}
// A conditional whose whenTrue branch holds the link, guarded on the
// capability: `canManage ? <Link/> : null`, the shape `vorsorge-section`
// uses.
if (
parent &&
ts.isConditionalExpression(parent) &&
parent.whenTrue === cur
) {
const names = new Set<string>();
const collect = (n: ts.Node): void => {
if (ts.isIdentifier(n)) names.add(n.text);
n.forEachChild(collect);
};
collect(parent.condition);
if (names.has(GUARD)) return true;
}
}
return false;
}
/** Every unguarded layout-shortcut href in one module, as its literal text. */
function unguardedShortcuts(source: string, fileName: string): string[] {
const parsed = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const found: string[] = [];
const visit = (node: ts.Node): void => {
if (
ts.isStringLiteralLike(node) &&
node.text.startsWith(LAYOUT_PREFIX) &&
!guardedByCanManage(node)
) {
found.push(node.text);
}
node.forEachChild(visit);
};
parsed.forEachChild(visit);
return found;
}
describe("the customise shortcut is withheld inside somebody else's record", () => {
const carrying = allSourceFiles().filter((rel) =>
readFileSync(join(SRC, rel), "utf8").includes(LAYOUT_PREFIX),
);
it("finds the surfaces that carry one", () => {
// The count of MATCHES, not of files walked. An empty result set and a
// clean tree are the same sentence otherwise, and this is a leg whose
// verdict is an empty list.
expect(carrying.length).toBeGreaterThan(4);
expect(carrying).toContain("app/mood/page.tsx");
expect(carrying).toContain("app/medications/page-client.tsx");
for (const rel of Object.keys(NOT_A_SHARED_DESTINATION)) {
expect(carrying, rel).toContain(rel);
}
});
it("gates every one a delegate can reach", () => {
const offenders: string[] = [];
for (const rel of carrying) {
if (rel in NOT_A_SHARED_DESTINATION) continue;
for (const href of unguardedShortcuts(
readFileSync(join(SRC, rel), "utf8"),
rel,
)) {
offenders.push(`${rel} → ${href}`);
}
}
expect(
offenders,
`a customise link outside a \`${GUARD}\` branch sends a delegate to /settings, which a switch closes`,
).toEqual([]);
});
it("the matcher reads the branch, not the file", () => {
// The shape that shipped: the capability is resolved and used elsewhere in
// the module while the link itself sits outside any branch. A matcher
// asking "does this file name canManage" calls this clean.
const UNGATED = `
export function Page() {
const { canManage } = useRecordCapabilities();
return (
<>
{canManage && <Button data-slot="add" />}
<Link href="/settings/layout/mood">Customise</Link>
</>
);
}
`;
expect(UNGATED.includes(GUARD)).toBe(true);
expect(unguardedShortcuts(UNGATED, "u.tsx")).toEqual([
"/settings/layout/mood",
]);
const GATED = `
export function Page() {
const { canManage } = useRecordCapabilities();
return canManage && <Link href="/settings/layout/mood">Customise</Link>;
}
`;
expect(unguardedShortcuts(GATED, "g.tsx")).toEqual([]);
const TERNARY = `
export function Page() {
const { canManage } = useRecordCapabilities();
const wrench = canManage ? (
<Link href="/settings/layout/vorsorge">Customise</Link>
) : null;
return wrench;
}
`;
expect(unguardedShortcuts(TERNARY, "t.tsx")).toEqual([]);
// And the inverted shape is not a gate: `href && canManage` puts the
// capability in the guarded position, not the link.
const INVERTED = `
export function Page() {
const { canManage } = useRecordCapabilities();
return <Link href={"/settings/layout/labs"}>{something && canManage}</Link>;
}
`;
expect(unguardedShortcuts(INVERTED, "i.tsx")).toEqual([
"/settings/layout/labs",
]);
});
});