Files
HealthLog/src/__tests__/openapi-sharing-denial.test.ts
T
Marc-André Bombeck f4c0eb0ab9 feat(api): publish retirement in the contract, the capability read, and the coverage guard
Three surfaces, one source. The registry the proxy answers 410 from now
also emits the tombstones into `docs/api/openapi.yaml` and rides on
`GET /api/meta/capabilities`, so a retirement cannot be honoured at
runtime and invisible everywhere a client could have read it first.

The contract publishes each retired path with 410 as its only response,
the removing version, and the replacement. `deprecated: true` is
imprecise — the word means "still works, stop using it", and these do not
work — but it is what makes a generator warn at the call site, and the
description carries the exact truth the flag approximates. `security: []`
because the answer is produced before any handler and reads no
credential; publishing it as authenticated would tell a client to sign in
to be told a path is gone.

The capability read carries the same list. That surface is the one a
client already polls on launch, so a retirement can reach it before the
failing call rather than through it.

The coverage guard gains a third state. Published and exempted both
describe a route that exists, so removing one used to leave exactly one
legal move — erase it from the contract as well — and that move is
invisible to every client already calling it. A registered path is now
required to be absent from `src/app` AND present in the contract, which
is the one combination the phantom-path case rejected outright. Both ways
out are named in the failure message.

Its docblock says what the state cannot do, because the first draft
overclaimed: a check over one commit's state cannot see a deletion, so it
cannot insist that a removal should have been registered. Proving that
needs the previous release tag, and the job running this suite checks out
at depth one with no tags, which would make the check one that silently
cannot fail. What is held instead is the registration's integrity, in
three directions and with the mutation for each written down.

The sharing-denial guard learns the same state: a retired path resolves
no record fence because it reaches no handler.
2026-08-23 14:06:52 +02:00

347 lines
15 KiB
TypeScript

/**
* v1.37.0 — one refusal, on every path a delegate can reach.
*
* A route that resolves through `requireRecordAuth` / `requireGuardianAuth` can
* be asked to act on somebody else's record, and can therefore answer 403
* `sharing.access.denied`. Before this file that fact was published once, on the
* per-request selector parameter, and on none of the hundred and twenty
* operations that produce it: a client reading the spec for `/api/measurements`
* learned that the header existed somewhere and nothing about what the read
* does when the grant behind it ends. This guard closes the pair — the set of
* fenced operations is recomputed from the route sources on every run, and
* every member has to publish the same sentence, byte for byte.
*
* ## Byte-identical, and why it is the point
*
* The refusal carries no reason on the wire. A selector naming an account that
* does not exist, one naming an account that granted nothing, a grant whose
* sections do not reach the surface, and a read grant on a write — all four are
* the same status, the same code and the same bytes, because a distinguishable
* refusal is an account-enumeration oracle for anyone with a login. A contract
* that described them differently per path would invite a client to tell them
* apart, and the first client that tried would be reading tea leaves. So the
* description is ONE string, spliced into every fenced operation, and a
* paraphrase on one path fails here.
*
* ## What this guard deliberately does not do
*
* - It classifies per VERB, not per file, because the two disagree on
* seventeen route modules: `GET /api/dashboard/widgets` is delegable and its
* `PUT` is not. The matcher takes each handler's own top-level declaration
* and follows every declaration in the same module it names, transitively —
* which is how `POST /api/custom-metrics` (body in `postCustomMetric`) and
* `POST /api/cycle/day-logs` (body in `postDayLog`, wrapped in
* `withIdempotency` and declared BELOW the `GET`) are classified correctly. A
* first draft that segmented on export boundaries and matched call sites got
* both of those wrong, in both directions.
* - It resolves within the module only. A handler whose fence lived in an
* imported helper would read as unfenced; `sharing-surface-guard.test.ts` is
* what holds the declaration to the route module itself.
* - It says nothing about the verbs that are NOT fenced. Those refuse a
* switched caller with `sharing.not_permitted` — a different fact, published
* today only where the sharing family itself documents it. Naming it on every
* authenticated path in the application is a larger contract change than a
* freeze should make.
* - Comments are stripped before matching. `.../guardians/route.ts` names both
* fence helpers in a docblock explaining why it uses neither, and counting
* prose would classify an actor surface as delegable.
* - It covers the PUBLISHED surface, which is not the whole delegable surface.
* 116 route modules resolve the fence; 90 of them are in the OpenAPI document
* and 26 have never been (mood entries, the record-settings tree, the
* medication intake tree, several insight reads). That gap predates this
* release and is not closed here — "frozen" means the published contract is
* complete and consistent, not that every delegable route is described. What
* this guard does guarantee is that one of those 26 cannot be published
* without its refusal, because publishing it puts it in scope on the next run.
*/
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { openApiPaths } from "@/lib/openapi/routes";
import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared";
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
/** Published paths that deliberately have no handler behind them. */
const retiredPaths = new Set(RETIRED_ROUTES.map((route) => route.path));
const ROOT = process.cwd();
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
/** A published operation, resolved to the module that serves it. */
interface PublishedOperation {
path: string;
method: HttpMethod;
/** The operation object as the route table declares it. */
operation: Record<string, unknown>;
/** The handler's own declaration plus every module-local one it names. */
handlerSource: string;
}
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
}
function routeModulePath(apiPath: string): string {
return join(
ROOT,
"src/app",
apiPath.replace(/\{([^}]+)\}/g, "[$1]"),
"route.ts",
);
}
/**
* Every top-level declaration in a module, by name.
*
* The unit is the declaration and not the line range between two exports,
* because a route module puts helpers wherever it likes: `cycle/day-logs`
* declares `postDayLog` BELOW its `GET`, so a naive "from this export to the
* next" slice hands GET a body that belongs to POST.
*/
function topLevelChunks(source: string): Map<string, string> {
const declaration =
/^(?:export\s+)?(?:async\s+function|function|const|let|class)\s+([A-Za-z0-9_$]+)/gm;
const found = [...source.matchAll(declaration)];
const chunks = new Map<string, string>();
found.forEach((match, index) => {
const from = match.index ?? 0;
const to = found[index + 1]?.index ?? source.length;
chunks.set(match[1], source.slice(from, to));
});
return chunks;
}
/**
* The text a handler can reach: its own declaration plus every top-level
* declaration in the same module it names, transitively.
*
* Both indirections in the tree need this. `apiHandler(postCustomMetric)`
* names its body as a bare reference, and
* `apiHandler(withIdempotency<[NextRequest]>(postDayLog))` names it as an
* argument to a wrapper — neither is a call site, so a matcher keyed on
* `name(` finds nothing and the handler reads as unfenced. It resolves within
* the module only: a fence resolved in an imported helper would be missed, and
* `sharing-surface-guard.test.ts` is what holds the declaration to the route
* module.
*/
function handlerText(source: string, method: HttpMethod): string {
const chunks = topLevelChunks(source);
const own = chunks.get(method.toUpperCase());
if (!own) return "";
const seen = new Set([method.toUpperCase()]);
let text = own;
for (let depth = 0; depth < 5; depth += 1) {
const named = [...text.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)].map(
(match) => match[1],
);
const next = named.filter((name) => chunks.has(name) && !seen.has(name));
if (next.length === 0) break;
for (const name of next) {
seen.add(name);
text += chunks.get(name);
}
}
return text;
}
function publishedOperations(): PublishedOperation[] {
const operations: PublishedOperation[] = [];
const missing: string[] = [];
for (const [path, item] of Object.entries(openApiPaths)) {
// A retired path is published deliberately and has no route module by
// definition — that is the whole state (`src/lib/http/retired-routes.ts`).
// It resolves no fence because it reaches no handler: the proxy answers 410
// before routing, for every caller and every verb, so there is no record to
// refuse access to. Skipped by the registry rather than by a path pattern,
// so a live route can never fall through this hole.
if (retiredPaths.has(path)) continue;
const modulePath = routeModulePath(path);
if (!existsSync(modulePath)) {
missing.push(path);
continue;
}
const source = stripComments(readFileSync(modulePath, "utf8"));
for (const method of HTTP_METHODS) {
const operation = (item as Record<string, unknown>)[method];
if (!operation || typeof operation !== "object") continue;
operations.push({
path,
method,
operation: operation as Record<string, unknown>,
handlerSource: handlerText(source, method),
});
}
}
// A published path with no route module would silently drop out of every
// assertion below, so it fails here instead.
expect(missing, "published paths with no route module").toEqual([]);
return operations;
}
/** Does this handler resolve the record fence? */
function isFenced(operation: PublishedOperation): boolean {
return /require(?:Record|Guardian)Auth\s*\(/.test(operation.handlerSource);
}
function refusal(
operation: PublishedOperation,
): { description?: string; content?: unknown } | null {
const responses = operation.operation.responses as
Record<string, { description?: string; content?: unknown }> | undefined;
return responses?.["403"] ?? null;
}
const operations = publishedOperations();
const fenced = operations.filter(isFenced);
const unfenced = operations.filter((operation) => !isFenced(operation));
const name = (operation: PublishedOperation) =>
`${operation.method.toUpperCase()} ${operation.path}`;
describe("the sharing refusal every delegable path publishes", () => {
it("discovers a real set of fenced operations", () => {
// Non-zero, and anchored at both ends: a matcher that stopped matching
// would leave every assertion below vacuously true.
expect(operations.length).toBeGreaterThan(200);
expect(fenced.length).toBeGreaterThan(100);
const fencedNames = fenced.map(name);
expect(fencedNames).toContain("GET /api/measurements");
expect(fencedNames).toContain("POST /api/labs");
expect(fencedNames).toContain("GET /api/dashboard/widgets");
const unfencedNames = unfenced.map(name);
// The other half of the same module: `PUT` and `DELETE` on the widget
// layout are the caller's own settings and refuse a switch outright.
expect(unfencedNames).toContain("PUT /api/dashboard/widgets");
expect(unfencedNames).toContain("DELETE /api/dashboard/widgets");
// An actor surface, and a route whose docblock names both fence helpers
// while calling neither.
expect(unfencedNames).toContain("GET /api/auth/me");
expect(unfencedNames).toContain("GET /api/managed-profiles/{id}/guardians");
});
it("publishes the refusal on every fenced operation", () => {
const silent = fenced.filter((operation) => {
const response = refusal(operation);
return (
!response ||
!response.description?.includes(SHARING_ACCESS_DENIED_DESCRIPTION)
);
});
if (silent.length > 0) {
throw new Error(
`${silent.length} delegable operation(s) publish no sharing refusal:\n` +
silent.map((operation) => ` ❌ ${name(operation)}`).join("\n") +
"\n\nSpread `...recordRefusal()` into the operation's responses — or " +
"`...recordRefusal(<the other reason>)` when it already answers 403 " +
"for a module gate or a consent gate.",
);
}
});
it("says it the same way every time", () => {
// One string, not ninety paraphrases. Collected from the published objects
// rather than asserted against the constant, so a hand-written copy that
// differs by a comma is what this catches.
const sentences = new Set(
fenced.map((operation) => {
const description = refusal(operation)?.description ?? "";
const at = description.indexOf(SHARING_ACCESS_DENIED_DESCRIPTION);
return description.slice(at);
}),
);
expect(sentences.size).toBe(1);
expect([...sentences][0]).toBe(SHARING_ACCESS_DENIED_DESCRIPTION);
// What the sentence has to carry: the code a client branches on, and the
// indistinguishability that keeps it from being an enumeration oracle.
expect(SHARING_ACCESS_DENIED_DESCRIPTION).toContain(
"sharing.access.denied",
);
expect(SHARING_ACCESS_DENIED_DESCRIPTION.length).toBeGreaterThan(200);
});
it("keeps it off the paths that cannot answer with it", () => {
const overreaching = unfenced.filter((operation) =>
refusal(operation)?.description?.includes(
SHARING_ACCESS_DENIED_DESCRIPTION,
),
);
expect(overreaching.map(name)).toEqual([]);
});
it("draws only from the reviewed delegable allowlists", () => {
// Containment, not equality, and per FILE rather than per verb — which is
// how `sharing-surface-guard.test.ts` keys its lists, because the
// declaration a human reviewed is "this module may act on another record",
// with the verb-level detail in the entry's prose beside it.
//
// What this closes: the guard above recomputes the delegable set from the
// route sources, so a route that started resolving the fence would be
// discovered and dressed with a refusal automatically — published as
// delegable without anybody deciding it should be. Held to the reviewed
// lists, that becomes two failures instead of a silent contract change.
const guard = readFileSync(
join(ROOT, "src/__tests__/sharing-surface-guard.test.ts"),
"utf8",
);
const allowlist = (name: string): string[] => {
const start = guard.indexOf(`const ${name}`);
expect(
start,
`${name} is missing from the surface guard`,
).toBeGreaterThan(-1);
const block = guard.slice(start, guard.indexOf("\n};", start));
const keys = [...block.matchAll(/^ {2}"(app\/[^"]+)":/gm)].map(
(match) => match[1],
);
// A block that matched nothing would make the union vacuous and the
// containment below unfalsifiable.
expect(keys.length, name).toBeGreaterThan(0);
return keys;
};
const reviewed = new Set([
...allowlist("DELEGABLE_ROUTES"),
...allowlist("DELEGABLE_MANAGE_ROUTES"),
...allowlist("GUARDIAN_ROUTES"),
]);
expect(reviewed.size).toBeGreaterThan(50);
const contributing = new Set(
fenced.map((operation) =>
routeModulePath(operation.path)
.slice(join(ROOT, "src/").length)
.replaceAll("\\", "/"),
),
);
expect(contributing.size).toBeGreaterThan(50);
const unreviewed = [...contributing].filter((file) => !reviewed.has(file));
if (unreviewed.length > 0) {
throw new Error(
`${unreviewed.length} published operation(s) resolve the record fence from a module the surface guard has not reviewed:\n` +
unreviewed.map((file) => ` ❌ src/${file}`).join("\n") +
"\n\nA module becomes delegable by decision, not by discovery: add it " +
"to the matching list in sharing-surface-guard.test.ts with the " +
"reason, or take the fence back out.",
);
}
});
it("carries the refusal into the generated document", () => {
const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8");
expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60));
// The emitter aliases identical response objects, so the sentence appears
// once per DISTINCT composition rather than once per path. Both ends are
// checked: the text is there, and the paths that reference it outnumber it.
const aliases = spec.match(/"403": \*/g)?.length ?? 0;
expect(aliases).toBeGreaterThan(fenced.length - 10);
});
});