Files
HealthLog/src/__tests__/destructive-control-guard.test.ts
T
Marc-André Bombeck a1b1617d0f settings: move the About-me note and free-text allergies into the Anamnese
The free-text self-context note and the free-text allergy line are
personal medical context, so they join conditions, allergies and family
history in Settings -> Anamnese. The note is its own card (same AI-surface
gate as the inclusion card); the allergy line sits directly under the
structured AllergyManager in the same card, labelled as a free-text
supplement so the two inputs never read as duplicates. The account
section drops the old panel. Store, API and query keys are unchanged;
the scoped Clear (note only, allergies untouched) carries over with its
tests. Orphaned strings removed, new labels in all six languages.
Refs #159
2026-08-13 06:16:45 +02:00

866 lines
35 KiB
TypeScript

/**
* Structural guard on destructive controls: nothing that can take a person's
* data away ships without someone having written down what it takes and what
* gates it.
*
* WHY A REGISTRY RATHER THAN A PATTERN MATCH
*
* The obvious guard — "every `Trash2` trigger must sit next to an
* `AlertDialog`" — fails in both directions, and it fails quietly. It misses
* the control that destroys the most: Settings → AI cleared a person's
* free-text note AND their allergy line from one untargeted tap, through a
* `PUT` carrying two empty strings, with no trash icon involved in the write
* and nothing in the payload spelling "delete". It cries wolf on the reverse:
* a `Trash2` that removes a row from an unsaved draft, a "reset" that only
* touches local state until a separate Save, an undo affordance inside a
* success toast. Both classes are live in this tree. A guard that has to be
* suppressed on half its hits teaches people to suppress it.
*
* So the honest shape is: the REGISTRY below is the source of truth for what
* is destructive, because that judgement is not mechanical. Three detectors
* then make FORGETTING TO REGISTER the failure mode — each is complete for
* its own syntactic shape, and together they cover every way this codebase
* currently issues a destroying request:
*
* D1 a DELETE goes out `apiDelete(…)` / `method: "DELETE"`
* D2 a route whose own name is the act `/api/…/{disconnect,revoke,reset,
* purge,clear,wipe,bulk-delete}`
* D3 a write blanks a stored field a payload literal assigning `""`
* or `null` to a named key
*
* WHAT THIS CANNOT DO — stated plainly, because a guard that oversells itself
* is worse than none:
*
* - It cannot prove the registry is COMPLETE. A destructive control that
* issues no DELETE, hits no act-named route, and blanks no literal field —
* a PUT that overwrites a list with a shorter one, say — is invisible to
* all three detectors and will not be caught by anything here.
* - It cannot prove a registered claim is TRUE. `confirm: ["ConfirmButton"]`
* is checked only to the extent that the named mechanism is present in one
* of the entry's files. A reviewer who waves through a wrong entry defeats
* it, and no test substitutes for that review.
* - `unconfirmed` entries are the escape hatch, and they are deliberately
* expensive: each needs a written reason long enough to be an argument
* rather than a shrug. There is no silent skip.
*
* What it does hold: a new destructive control cannot land unnoticed, a
* removed one cannot leave a lie behind, and the specific regression that
* started this — a destroying payload fired straight from an `onClick` — goes
* red at the call site.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
type ConfirmKind =
| "ConfirmButton"
| "ConfirmDialog"
| "DeleteButton"
| "SelectionActionBar"
| "AlertDialog"
| "inline-two-step"
| "typed-phrase";
/**
* How bounded the loss is. This is the axis that decides whether a control
* wants a confirmation, an undo, or both, so the registry makes the next
* person answer it rather than leaving it implied.
*
* permanent a column overwritten, or a row hard-deleted.
* Nothing in the product brings it back.
* tombstoned-no-restore soft-deleted in the database, but no restore route
* and no surface a person could reach it from. Gone,
* as far as the person is concerned.
* restorable a restore route exists AND a control reaches it.
*/
type Recovery = "permanent" | "tombstoned-no-restore" | "restorable";
interface DestructiveEntry {
/** Path under `src/`, forward slashes. Where the destroying request lives. */
file: string;
/** Plain words: what a person loses. */
destroys: string;
/** Files carrying the trigger, when the mutation lives in a hook. */
triggers?: string[];
recovery: Recovery;
/** Mechanisms gating the triggers. Empty only alongside `unconfirmed`. */
confirm: ConfirmKind[];
/**
* Controls in this entry that deliberately do NOT confirm. Every one needs
* an argument, not a note — the length floor is enforced below.
*/
unconfirmed?: { control: string; reason: string }[];
/**
* Set when the entry is deliberately kept although no detector sees it —
* the concrete form of "this guard cannot prove the registry is complete".
* The value says which shape slips past, so the blind spot is on the record
* instead of being inferred from an absent row.
*/
undetected?: string;
}
const REGISTRY: DestructiveEntry[] = [
// ── Account, credentials, sessions ──────────────────────────────────────
{
file: "components/settings/advanced-section.tsx",
destroys:
"every health record on the account, or the whole account including passkeys and audit history",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/settings/security-sessions-card.tsx",
destroys: "one login session, or every session other than this one",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/settings/trusted-devices-card.tsx",
destroys: "the second-factor memory for one device, or for all of them",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/settings/security-section/passkey-list-section.tsx",
destroys: "a passkey — hard-deleted, the credential cannot be re-derived",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/settings/security-section/security-keys-card.tsx",
destroys: "a WebAuthn second factor",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/settings/api-section.tsx",
destroys: "an API token — revoked, and the secret is never shown again",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/settings/mcp-section.tsx",
destroys: "an MCP client grant or an MCP token",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/settings/sharing-section.tsx",
destroys: "a clinician share link — the URL stays dead once revoked",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/settings/account-section/avatar-section.tsx",
destroys: "the uploaded profile photo, deleted from the server",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
// ── AI credentials ──────────────────────────────────────────────────────
{
file: "components/settings/ai/codex-provider-form.tsx",
destroys: "the stored Codex sign-in and the cached insight text with it",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/admin/central-codex-section.tsx",
destroys:
"the instance-wide shared AI sign-in — every user relying on it loses AI features",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/admin/ai-server-key-section.tsx",
destroys:
"the operator-wide AI provider key, which is never displayed again once stored",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/settings/ai/ai-consent-card.tsx",
destroys: "the AI consent receipt that gates every AI surface",
recovery: "restorable",
confirm: ["inline-two-step"],
},
{
file: "components/records/about-me-note-manager.tsx",
destroys: "the free-text self-context note the Coach and briefing read",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/settings/coach-memory-section.tsx",
destroys: "one remembered Coach fact, or every one of them",
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton", "AlertDialog"],
},
{
file: "hooks/use-coach-reminders.ts",
destroys: "a Coach reminder",
triggers: ["components/settings/coach-reminders-section.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
},
{
file: "hooks/use-coach-plans.ts",
destroys: "a Coach plan and the record of how it went",
triggers: [
"app/coach/plans/page.tsx",
"components/insights/coach-panel/plan-proposal-card.tsx",
],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
unconfirmed: [
{
control:
"decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx",
reason:
"Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.",
},
],
},
{
file: "components/insights/coach-panel/use-coach.ts",
destroys: "a whole Coach conversation and every message in it",
triggers: [
"components/insights/coach-panel/history-rail.tsx",
"app/coach/conversations/page.tsx",
],
recovery: "permanent",
confirm: [],
unconfirmed: [
{
control:
"delete conversation — history-rail.tsx and conversations page",
reason:
"This surface deliberately chose undo over confirmation: the delete is held for a few seconds behind an undo toast, and the earlier arm-then-confirm double tap was removed on purpose. Adding a dialog back would give the same act two gates. The pending-delete flush on unmount is a real hole in that promise and is tracked separately; it is a defect in the undo, not an argument for a confirmation.",
},
],
},
{
file: "components/insights/coach-panel/coach-conversation.tsx",
destroys:
"the stored list of pending clarifying questions, and document attachments on a conversation",
recovery: "permanent",
confirm: [],
unconfirmed: [
{
control: "dismiss questions; detach a document",
reason:
"Neither touches something the person wrote. The questions are assistant-generated suggestions that the extractor regenerates on later turns, and dismissing them IS the affordance. Detaching removes a join row only — the document itself stays in the vault and is two taps from being re-attached.",
},
],
},
// ── Preferences and layout a person arranged by hand ─────────────────────
{
file: "components/settings/thresholds-editor-section.tsx",
destroys: "the custom range set for one metric, or for every metric",
recovery: "permanent",
confirm: ["ConfirmButton", "ConfirmDialog"],
},
{
file: "components/targets/target-edit-sheet.tsx",
destroys:
"the custom target range for one metric — both halves at once for blood pressure",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/settings/dashboard-layout-section.tsx",
destroys:
"the dashboard arrangement: tile order, hidden tiles, ring choice",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/insights/insights-edit-mode.tsx",
destroys: "the Insights overview arrangement: section order and visibility",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/settings/environment-section.tsx",
destroys: "a saved travel location",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
{
file: "components/settings/web-push-card.tsx",
destroys: "the browser push subscription row",
recovery: "restorable",
confirm: [],
unconfirmed: [
{
control: "unsubscribe from push notifications",
reason:
"This is the off half of a notification channel toggle. Re-subscribing from the same control recreates the row, nothing the person authored is stored in it, and the browser permission survives. Gating it would put a dialog on a switch.",
},
],
},
// ── Measurements, mood, labs, illness — the undo-and-restore surfaces ────
{
file: "components/measurements/measurement-list.tsx",
destroys: "one measurement, or every selected measurement",
recovery: "restorable",
confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"],
},
{
file: "components/mood/mood-list.tsx",
destroys: "one mood entry, or every selected mood entry",
recovery: "restorable",
confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"],
},
{
file: "components/labs/lab-history-list.tsx",
destroys: "one lab reading",
recovery: "restorable",
confirm: ["DeleteButton"],
},
{
file: "components/illness/use-illness.ts",
destroys: "an illness episode with its day logs and notes",
triggers: ["components/illness/episode-menu.tsx"],
recovery: "restorable",
confirm: ["AlertDialog"],
},
{
file: "components/labs/biomarker-manager.tsx",
destroys: "a biomarker AND every lab reading recorded against it",
recovery: "permanent",
confirm: ["DeleteButton"],
},
{
file: "components/labs/lab-biomarker-detail.tsx",
destroys: "a biomarker AND every lab reading recorded against it",
recovery: "permanent",
confirm: ["DeleteButton"],
},
{
file: "components/custom-metrics/custom-metric-detail.tsx",
destroys: "a custom metric definition",
recovery: "tombstoned-no-restore",
confirm: ["DeleteButton"],
},
{
file: "components/custom-metrics/custom-metric-history-list.tsx",
destroys: "one custom-metric reading",
recovery: "permanent",
confirm: ["DeleteButton"],
},
{
file: "components/records/allergy-manager.tsx",
destroys: "one allergy record",
recovery: "permanent",
confirm: ["DeleteButton"],
},
{
file: "components/records/family-history-manager.tsx",
destroys: "one family-history entry",
recovery: "tombstoned-no-restore",
confirm: ["DeleteButton"],
},
{
file: "components/records/health-profile-facts-manager.tsx",
destroys: "the current value of one effective-dated health-profile fact",
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
},
{
file: "hooks/use-measurement-reminders.ts",
destroys: "a checkup or measurement reminder with its schedule",
triggers: ["components/measurement-reminders/vorsorge-section.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
// ── Visits and the address book ─────────────────────────────────────────
{
file: "hooks/use-encounters.ts",
destroys:
"a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
triggers: ["components/encounters/encounter-sheet.tsx"],
// The route soft-deletes and a restore route exists, but nothing in the
// product reaches it yet, so from a person's side the visit is gone. This
// becomes "restorable" the day a surface offers the undo, not before.
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "hooks/use-practitioners.ts",
destroys: "one address-book entry",
triggers: ["components/practitioners/practitioner-list.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/vaccinations/use-vaccinations.ts",
destroys:
"one logged dose with its lot, site and the document links it collected — the booster reminder it once satisfied is deliberately not rewound",
triggers: ["components/vaccinations/vaccination-sheet.tsx"],
// The route soft-deletes and a restore route exists, but no surface offers
// the undo yet, so from a person's side the dose is gone.
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
// ── Cycle ───────────────────────────────────────────────────────────────
{
file: "components/cycle/use-cycle.ts",
destroys:
"a whole logged cycle day — flow, temperature, symptoms, note — or a custom symptom from the catalogue",
triggers: ["components/cycle/log-day-sheet.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton", "ConfirmDialog"],
},
// ── Medications ─────────────────────────────────────────────────────────
{
file: "components/medications/sections/destructive-zone-section.tsx",
destroys:
"every intake event for a medication, or the medication with its schedules and inventory",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/medications/intake-history-editable.tsx",
destroys: "one intake event, or every selected intake event",
triggers: ["components/medications/intake-history-list-v2.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/medications/dose-history-ledger.tsx",
destroys: "one intake event",
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/medications/side-effects-section.tsx",
destroys: "one side-effect log entry",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/medications/sections/inventory-section.tsx",
destroys: "one inventory pack record",
recovery: "permanent",
confirm: ["DeleteButton"],
},
{
file: "components/medications/scheduling/schedule-history-timeline.tsx",
destroys: "one schedule revision, re-segmenting the dosing history",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/medications/use-medication-intake.ts",
destroys: "an intake event recorded seconds ago",
recovery: "tombstoned-no-restore",
confirm: [],
unconfirmed: [
{
control: "Undo inside the intake success toast",
reason:
"This IS the undo for a create the person made a moment earlier, reached only from that create's own toast. Confirming an undo would ask someone to confirm a correction, which inverts what the affordance is for.",
},
],
},
// ── Mood tags ───────────────────────────────────────────────────────────
{
file: "components/mood/manage/archived-tags-card.tsx",
destroys:
"an archived custom mood tag AND every mood entry's link to it, by cascade",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/mood/manage/tag-groups-card.tsx",
destroys: "a custom mood tag group (its member tags are rehomed, not lost)",
recovery: "permanent",
confirm: ["AlertDialog"],
},
// ── Documents ───────────────────────────────────────────────────────────
{
file: "components/documents/document-detail-sheet.tsx",
destroys: "one document in the vault",
recovery: "restorable",
confirm: [],
unconfirmed: [
{
control: "Delete in the document detail sheet",
reason:
"Documents chose undo over confirmation across every one of its delete paths, and it is the one domain where that choice is fully backed: the row is soft-deleted, a restore route exists, and the success toast carries the undo that calls it. Layering a dialog on top would gate a reversible act on the surface that already handles reversibility best.",
},
],
},
{
file: "components/documents/documents-view.tsx",
destroys: "every selected document, or one document from a focused card",
triggers: [
"components/documents/document-bulk-bar.tsx",
"components/documents/document-card.tsx",
],
recovery: "restorable",
confirm: [],
undetected:
'The bulk route is POST /api/documents/inbound/bulk with the verb in the body ({ action: "delete" }), so no detector sees it: no DELETE goes out, the path names no act, and nothing is blanked. Registered by hand.',
unconfirmed: [
{
control: "bulk delete, and the Delete/Backspace key on a focused card",
reason:
"Same undo-not-confirmation choice as the detail sheet, and the bulk path raises one aggregate undo for the whole batch. The keyboard path is the weakest link in that argument, since a keystroke needs no aim and the undo lives only in a toast with no trash view behind it; it is recorded here rather than silently accepted, and closing it means giving documents a recovery surface, not a dialog.",
},
],
},
// ── Account sharing ─────────────────────────────────────────────────────
{
file: "components/settings/access/grants-given-card.tsx",
destroys:
"another account's standing access to this health record, and every browser session of theirs that was inside it",
recovery: "permanent",
confirm: ["ConfirmButton"],
undetected:
"No detector sees it. The DELETE goes out from `useRevokeGrant` in `lib/queries/use-account-grants.ts`, and `lib/` is an excluded root; the card itself only calls `.mutate(grant.id)` with a bare string, which no payload pattern matches, and the route path lives in the hook rather than here. Registered by hand.",
},
{
file: "components/settings/access/grants-received-card.tsx",
destroys:
"this account's own access to somebody else's record, handed back rather than withdrawn",
recovery: "permanent",
confirm: ["ConfirmButton"],
undetected:
"Same shape as the card above, and one step further out of reach: renouncing is a POST, so not even a DELETE goes out. The path (`/api/account/grants/{id}/renounce`) names an act the D2 vocabulary does not carry, and it lives in the hook regardless. Registered by hand.",
},
// ── Admin ───────────────────────────────────────────────────────────────
{
file: "components/admin/danger-zone-section.tsx",
destroys: "every user's records on the instance",
recovery: "permanent",
confirm: ["typed-phrase"],
},
{
file: "components/admin/invite-tokens-section.tsx",
destroys: "an unredeemed invite token — the link stays dead",
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/admin/user-management-section.tsx",
destroys: "another user's sessions, or their stored password hash",
recovery: "permanent",
confirm: ["AlertDialog"],
},
// ── Integrations ────────────────────────────────────────────────────────
...(
[
["withings-card.tsx", "Withings"],
["fitbit-card.tsx", "Fitbit"],
["whoop-card.tsx", "WHOOP"],
["google-health-card.tsx", "Google Health"],
["nightscout-card.tsx", "Nightscout"],
["oauth-provider-card.tsx", "the generic OAuth providers"],
] as const
).map(([f, name]): DestructiveEntry => ({
file: `components/settings/integrations/${f}`,
destroys: `the stored ${name} credentials — reconnecting means authorising again`,
recovery: "permanent",
confirm: ["AlertDialog"],
})),
];
// ── Detectors ─────────────────────────────────────────────────────────────
/** D1 — a DELETE request goes out. */
const D1 = /apiDelete[<(]|method:\s*"DELETE"/;
/** D2 — a route path whose own name is the destructive act. */
const D2 =
/["`]\/api\/[^"`?\s]*(?:disconnect|revoke|reset|purge|clear|wipe|bulk-delete)[^"`?\s]*/;
/** D3 — a write payload that blanks a named field. */
const D3 =
/(?:\.mutate|\.mutateAsync|apiPut|apiPatch|apiPost)[<(]?\(\s*(?:"[^"]*",\s*)?\{[^{}]*?\b\w+:\s*(?:""|null)/;
/**
* Where a destructive CONTROL can live. Route handlers are the other end of
* the wire, not a control, and `src/lib` holds no JSX — its three matches are
* the `apiDelete` helper itself, an audit-path string constant and an OpenAPI
* path literal. Both exclusions are asserted below so neither can be widened
* into a hiding place without this test noticing.
*/
const CONTROL_ROOTS = ["app/", "components/", "hooks/"];
const EXCLUDED_PREFIXES = ["app/api/", "generated/", "lib/"];
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function isControlFile(rel: string): boolean {
if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) return false;
return CONTROL_ROOTS.some((p) => rel.startsWith(p));
}
function detect(rel: string): string[] {
const src = read(rel);
const tags: string[] = [];
if (D1.test(src)) tags.push("D1");
if (D2.test(src)) tags.push("D2");
if (D3.test(src)) tags.push("D3");
return tags;
}
let detectedCache: string[] | null = null;
function detectedFiles(): string[] {
detectedCache ??= sourceFiles().filter(
(rel) => isControlFile(rel) && detect(rel).length,
);
return detectedCache;
}
const CONFIRM_MARKERS: Record<ConfirmKind, RegExp> = {
ConfirmButton: /<ConfirmButton\b/,
ConfirmDialog: /<ConfirmDialog\b/,
DeleteButton: /<DeleteButton\b/,
SelectionActionBar: /<SelectionActionBar\b/,
AlertDialog: /<AlertDialogAction\b/,
// A confirm step rendered in place rather than in a dialog: the first tap
// swaps the control for an explanation plus a separate, differently-labelled
// confirm button. Marked by the `-confirm` slot that second button carries.
"inline-two-step": /data-slot="[\w-]+-confirm"/,
// An admin-grade gate: the exact word has to be typed before the action arms.
"typed-phrase": /CONFIRM_TOKEN|confirmText|typedConfirm/,
};
const byFile = new Map(REGISTRY.map((e) => [e.file, e]));
describe("T1 — every destructive control is registered", () => {
it("the registry names every file the detectors flag", () => {
const missing = detectedFiles().filter((rel) => !byFile.has(rel));
expect(
missing,
"A destructive request is issued from these files and nothing in the " +
"registry accounts for it. Add an entry naming what it destroys, how " +
"recoverable that is, and what gates it — or, if it genuinely does " +
"not destroy anything, say so under `unconfirmed` with the argument.",
).toEqual([]);
});
it("carries no stale entry", () => {
const stale = REGISTRY.filter(
(e) => !e.undetected && !detectedFiles().includes(e.file),
).map((e) => e.file);
expect(
stale,
"These entries no longer match any detector. If the control was removed, " +
"delete the entry; if it was rewritten into a shape the detectors " +
"cannot see, that is worth knowing before the entry is dropped.",
).toEqual([]);
});
it("declares each file once", () => {
expect(byFile.size).toBe(REGISTRY.length);
});
it("pins the detector exclusions so they cannot quietly widen", () => {
// `src/lib` and the route handlers are excluded because a control cannot
// live there. If that stops being true the exclusion is a hiding place.
expect(EXCLUDED_PREFIXES).toEqual(["app/api/", "generated/", "lib/"]);
expect(CONTROL_ROOTS).toEqual(["app/", "components/", "hooks/"]);
});
});
describe("T2 — every registered control names a real gate", () => {
for (const entry of REGISTRY) {
describe(entry.file, () => {
const files = [entry.file, ...(entry.triggers ?? [])];
it("has a confirmation mechanism, or an argument for having none", () => {
expect(
entry.confirm.length > 0 || (entry.unconfirmed?.length ?? 0) > 0,
`${entry.file}: neither a confirmation nor a reason for going without one`,
).toBe(true);
});
for (const kind of entry.confirm) {
it(`the claimed ${kind} is actually present`, () => {
const found = files.some((f) => CONFIRM_MARKERS[kind].test(read(f)));
expect(
found,
`${entry.file} claims ${kind}, but no such control renders in ${files.join(" / ")}`,
).toBe(true);
});
}
for (const skip of entry.unconfirmed ?? []) {
it(`the case for leaving "${skip.control}" unconfirmed is written out`, () => {
// A one-liner is a shrug. The floor is set where a sentence that
// actually argues something has to be written.
expect(
skip.reason.trim().length,
`${entry.file} → ${skip.control}: give the reason, not a note`,
).toBeGreaterThanOrEqual(120);
});
}
it("every named trigger file exists", () => {
for (const f of files) expect(() => read(f)).not.toThrow();
});
});
}
});
describe("T3 — a destroying payload never fires straight from a handler", () => {
/**
* The regression that started this: `onClick={() => save.mutate({ aboutMe:
* "", allergies: "" })}`. One tap, two fields blanked, no gate. This walks
* every event-handler attribute in every registered file and fails if a
* destroying request sits inline inside one — which is the shape that skips
* the dialog by construction rather than by oversight.
*
* Indirection through a named handler (`onClick={handleDisconnect}`) is out
* of reach here and is covered instead by T2's requirement that the file
* render a confirmation at all. Named, so nobody reads more into this.
*/
/**
* Elements whose handlers ARE the confirmed path — the dialog's own action
* button, and the primitives that own their dialog. A destroying call inside
* one of these is the point, not the bug. Getting this list wrong in the
* other direction is how a guard starts crying wolf, so it is short and
* every member is a confirmation surface by construction.
*/
const CONFIRMED_ELEMENTS = new Set([
"AlertDialogAction",
"ConfirmButton",
"ConfirmDialog",
"DeleteButton",
"SelectionActionBar",
]);
/** `onConfirm` is a confirmation callback wherever it appears. */
const CONFIRMED_ATTRS = /^onConfirm$/;
/**
* Walk the JSX opening tags and hand back `[element, handlerAttr, body]` for
* every event handler, so the check can tell a raw `onClick` on a `Button`
* from the `onClick` the dialog's action button carries.
*/
function handlers(src: string): { el: string; attr: string; body: string }[] {
const out: { el: string; attr: string; body: string }[] = [];
const TAG = /<([A-Z][\w.]*|[a-z][\w-]*)(?=[\s/>])/g;
for (const tag of src.matchAll(TAG)) {
const el = tag[1];
// Scan to the end of THIS opening tag, tracking brace depth and strings
// so an attribute expression containing `>` does not end it early.
let i = tag.index + tag[0].length;
let depth = 0;
let quote: string | null = null;
const attrs: { attr: string; body: string }[] = [];
let pendingAttr: string | null = null;
let bodyStart = 0;
for (; i < src.length; i++) {
const c = src[i];
if (quote) {
if (c === quote) quote = null;
continue;
}
if (depth === 0 && (c === '"' || c === "'" || c === "`")) {
quote = c;
continue;
}
if (c === "{") {
if (depth === 0) {
const before = src.slice(Math.max(0, i - 60), i);
const m = /\b(on[A-Z]\w*)=$/.exec(before);
pendingAttr = m ? m[1] : null;
bodyStart = i;
}
depth++;
continue;
}
if (c === "}") {
depth--;
if (depth === 0 && pendingAttr) {
attrs.push({
attr: pendingAttr,
body: src.slice(bodyStart, i + 1),
});
pendingAttr = null;
}
continue;
}
if (depth === 0 && c === ">") break;
}
for (const a of attrs) out.push({ el, ...a });
}
return out;
}
// Unconditional, including the entries that argue for having no dialog. The
// whole registry is clean of this shape today, so there is no exemption to
// carve out and no reason to leave a door open for one. A control that
// genuinely should fire without a gate still has somewhere to go: the
// confirmed elements above, or a named handler the file can be read at.
for (const entry of REGISTRY) {
const files = [entry.file, ...(entry.triggers ?? [])].filter((f) =>
f.endsWith(".tsx"),
);
for (const file of files) {
it(`${file} — no inline destroying handler`, () => {
const offenders = handlers(read(file))
.filter(
(h) =>
!CONFIRMED_ELEMENTS.has(h.el) && !CONFIRMED_ATTRS.test(h.attr),
)
.filter((h) => D1.test(h.body) || D3.test(h.body))
.map((h) => `<${h.el} ${h.attr}=${h.body.slice(0, 80)}`);
expect(
offenders,
`${file}: a destroying request fires directly from an event handler. ` +
`Route it through ConfirmButton / ConfirmDialog / DeleteButton, or ` +
`move it behind a named handler the reviewer can read.`,
).toEqual([]);
});
}
}
});
describe("T4 — the registry answers the recoverability question", () => {
it("every entry classifies what a person can get back", () => {
for (const e of REGISTRY) {
expect(
["permanent", "tombstoned-no-restore", "restorable"],
e.file,
).toContain(e.recovery);
expect(e.destroys.trim().length, e.file).toBeGreaterThan(10);
}
});
it("nothing permanent is left unconfirmed without an argument", () => {
const bare = REGISTRY.filter(
(e) =>
e.recovery === "permanent" &&
e.confirm.length === 0 &&
(e.unconfirmed?.length ?? 0) === 0,
).map((e) => e.file);
expect(bare).toEqual([]);
});
});