Files
HealthLog/src/__tests__/openapi-security-declaration-guard.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

185 lines
7.8 KiB
TypeScript

/**
* Every published operation says how a caller authenticates, or says it does
* not have to.
*
* The document defined `bearerAuth` and `cookieAuth` under
* `components.securitySchemes` and then referenced neither. There was no
* document-level `security` and no operation-level one, so the schemes were
* decoration: a client generated from the contract wires no `Authorization`
* header and no cookie, and reads all of it as an open API. The schemes being
* present is what made it invisible, because the obvious check ("are the
* schemes declared?") passed.
*
* So this guard checks the reference, not the declaration. Each operation must
* resolve to exactly one of two states:
*
* - it inherits the document-level `security`, which offers the two schemes
* as alternatives, or
* - it carries `security: []`, the explicit way OpenAPI says "no credential
* required", and its path appears in {@link UNAUTHENTICATED} below.
*
* ## Why the allowlist is derived from handlers, not from the proxy
*
* `PUBLIC_PATHS` in `src/proxy.ts` is a different question. It says which paths
* the edge lets through without a session; it does not say the handler behind
* one accepts an anonymous caller. `/api/ingest/medication` is on that list and
* resolves a Bearer token itself, so it is authenticated in the contract sense
* while being public at the edge. Reading the proxy list as the answer would
* publish that route as open.
*
* ## What this does NOT prove
*
* It proves the operation names a scheme, not that the server enforces the one
* it names. A route documented as `cookieAuth` that in fact admits a Bearer
* would pass here; `bearer-scope-enforcement-guard.test.ts` and
* `session-surface-guard.test.ts` are what hold that end.
*
* Mutation check: drop the document-level `security` from the registry and
* every authenticated operation fails here; add `security: []` to an operation
* that is not on the list below and it fails naming that operation.
*/
import { describe, expect, it } from "vitest";
import { openApiPaths } from "@/lib/openapi/routes";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
/**
* Operations that genuinely take no credential, with what makes each one so.
*
* Every entry is a route whose handler serves an anonymous caller. A route that
* merely sits on the proxy's public-path list does not belong here.
*/
const UNAUTHENTICATED: Readonly<Record<string, string>> = {
// `/api/auth/oidc/callback` is deliberately absent. It is anonymous, but it
// is not published at all: the provider drives it with its own query string
// and no client constructs one, so it is exempted from the contract by the
// coverage guard rather than published with an opt-out. This test is what
// caught the entry when it was written from the proxy's public-path list
// instead of from the published set.
"/api/health":
"Container healthcheck. Answers before any user exists and outside the response envelope.",
"/api/version":
"Build identity. Read by operators and by the update check; carries nothing account-scoped.",
"/api/auth/login":
"Mints the session. There is no credential to present yet.",
"/api/auth/register":
"Creates the first credential. Refused outright when registration is closed.",
"/api/auth/registration-status":
"Says whether registration is open, so the sign-in screen can decide what to offer.",
"/api/auth/passkey/login-options":
"The WebAuthn challenge that precedes a passkey sign-in.",
"/api/auth/passkey/login-verify":
"Completes the passkey sign-in; the assertion is the credential.",
"/api/auth/oidc/login":
"Starts the identity-provider handoff, for the browser and for the native client.",
"/api/auth/oidc/status":
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
"/api/notifications/vapid":
"The instance's public Web Push key. Public by definition.",
// The tombstones, sourced from the registry rather than restated one by one.
// A retired path answers 410 from the proxy before any handler runs, and that
// answer never reads the request's cookies or Authorization header — so it is
// genuinely credential-free, and publishing it as authenticated would tell a
// client to sign in to be told the path is gone. Sourced rather than listed
// because a hand-written entry per retirement is a step someone forgets, and
// forgetting it here would fail the opt-out check rather than the retirement.
...Object.fromEntries(
RETIRED_ROUTES.map((route) => [
route.path,
`Removed in v${route.removedIn}. The 410 is produced at the edge and reads no credential.`,
]),
),
};
describe("the contract says how each operation is authenticated", () => {
const doc = buildOpenApiDocument() as unknown as {
security?: unknown[];
components?: { securitySchemes?: Record<string, unknown> };
};
const paths = openApiPaths as Record<string, Record<string, unknown>>;
it("declares a document-level default that references the schemes", () => {
const schemes = Object.keys(doc.components?.securitySchemes ?? {});
expect(
schemes.length,
"the schemes themselves went missing, which is a different failure",
).toBeGreaterThan(0);
const declared = JSON.stringify(doc.security ?? []);
const unreferenced = schemes.filter(
(scheme) => !declared.includes(`"${scheme}"`),
);
expect(
unreferenced,
"these schemes are defined and referenced by nothing, so a generated client ignores them",
).toEqual([]);
});
it("every operation either inherits that default or opts out on purpose", () => {
const undeclaredOptOut: string[] = [];
for (const [path, item] of Object.entries(paths)) {
for (const method of HTTP_METHODS) {
const operation = item[method] as { security?: unknown[] } | undefined;
if (!operation) continue;
// No `security` key at all means the document-level default applies,
// which is the authenticated case and needs nothing here.
if (operation.security === undefined) continue;
if (
Array.isArray(operation.security) &&
operation.security.length === 0 &&
!(path in UNAUTHENTICATED)
) {
undeclaredOptOut.push(`${method.toUpperCase()} ${path}`);
}
}
}
expect(
undeclaredOptOut,
"these operations publish themselves as needing no credential without a reason on record",
).toEqual([]);
});
it("the opt-out list names operations that are published", () => {
const stale = Object.keys(UNAUTHENTICATED).filter(
(path) => !(path in paths),
);
expect(
stale,
"an opt-out for a path the document does not publish hides the next one that needs a look",
).toEqual([]);
});
it("each opted-out operation actually carries the empty array", () => {
// The counterpart to the check above. A path listed as unauthenticated but
// silently inheriting the authenticated default publishes the opposite of
// what this file records, and the list would read as if it had been applied.
const missing: string[] = [];
for (const path of Object.keys(UNAUTHENTICATED)) {
const item = paths[path];
if (!item) continue;
const declared = HTTP_METHODS.filter((method) => item[method]).filter(
(method) => {
const operation = item[method] as { security?: unknown[] };
return (
Array.isArray(operation.security) && operation.security.length === 0
);
},
);
if (declared.length === 0) missing.push(path);
}
expect(
missing,
"these paths are recorded as needing no credential and do not say so in the document",
).toEqual([]);
});
});