Files
HealthLog/src/__tests__/openapi-route-coverage-guard.test.ts
T
Marc-André Bombeck d396945f44 fix(api): publish the backup upload
POST /api/admin/backups/upload stores a backup file as a new copy, the
copy whose id the published restore takes, and it refuses a file for
reasons an operator has to be able to look up: a key this server does
not hold (backup.key.missing, with the key ids), an owner account that
does not exist here, a newer schema, a copy too large to store
(backup.upload.too_large). It was exempted from the contract as console
plumbing while its two siblings were published for exactly that kind
of refusal.

It is registered with the three bodies it reads (raw JSON, raw gzip, a
small multipart form) and every status it answers; the summary is the
three identity fields plus one integer count per class, so a class the
backup format gains later does not break a decoder. The round-trip
test calls it through the contract now.
2026-10-22 07:08:02 +02:00

576 lines
25 KiB
TypeScript

/**
* Every route is published, declared unpublished, or registered as retired.
*
* `pnpm openapi:check` compares the registry against `docs/api/openapi.yaml`
* and fails on drift between them. It has never compared the ROUTES against
* the registry, so a route that was never registered at all produced no drift
* and no failure: the spec was internally consistent and silently incomplete.
* That is how two hundred and twenty-two paths, forty-three of them called by
* the native client, ended up undocumented while the contract check ran green
* on every commit for months. The first count taken of this gap said
* forty-five, because it compared against the paths the YAML already listed
* rather than against the verbs the routes export.
*
* This file closes the third side of the triangle. It walks `src/app` for
* route modules, reads the HTTP verbs each one actually exports, and requires
* every verb to be either present in the route table or named in
* {@link UNPUBLISHED} with a reason. Adding a route without touching either
* side fails here, which is the only place it can be caught before a client
* goes looking for a contract that was never written.
*
* ## The third state: retired
*
* Published and exempted both describe a route that exists. Removing one used
* to leave exactly one legal move — drop it from the contract too — and that
* move is invisible to every client already calling it. The native app spent
* twelve days rendering a permanent error card over a chart because
* `/api/auth/me/research-mode` went from published to absent with no third
* thing in between, and a bare 404 reads as a broken deployment rather than a
* decision.
*
* So retirement is a state here rather than a deletion. A path in
* `RETIRED_ROUTES` is required to be gone from `src/app` AND present in the
* contract, which is the one combination the fourth case below used to reject
* outright. Removing a route now forces a choice: drop it from the contract, or
* register it as retired and let the proxy answer 410 for it. What is no longer
* available is doing neither and finding out from a client.
*
* Be exact about what that is worth, because the obvious overclaim is easy to
* write and was written here first. This guard reads the tree as it stands. It
* cannot see a deletion, so it cannot insist that a removal SHOULD have been
* registered: a route deleted from `src/app` and from its route module in one
* commit leaves nothing behind to notice, and no check over a single commit's
* state can recover the fact that something used to be there. Proving otherwise
* needs an anchor outside the commit — the previous release tag — and the job
* that runs this suite checks out at depth one with no tags, so a history
* comparison here would be a check that silently cannot fail.
*
* What the third state does buy, and it is not nothing: deleting a route while
* it is still published now has TWO legal answers instead of one, and the
* failure message names both. Before, the only way past the fourth case was to
* erase the path — the move that is invisible to every client. Now the cheaper
* move is to leave the path standing as a tombstone, which is the move that
* reaches them. And once registered, the registration is held in three
* directions by the cases below, so it cannot rot into a lie about a route that
* came back or a retirement the contract never mentions.
*
* ## Why the exemption list carries prose
*
* A bare allowlist of paths decays into a place to put things. Each entry here
* says WHY the route is absent from the public contract, so the next reader can
* disagree with the reason rather than guess at it, and so an entry whose
* reason has stopped being true is visible as text rather than as a path in a
* list. The reasons fall into a small number of kinds — browser-only
* navigation, an OAuth callback the provider drives, a webhook authenticated by
* a shared secret, an internal cron trigger — and a route that does not fit one
* of those kinds probably belongs in the spec.
*
* ## What this guard does NOT prove
*
* - It proves a verb is PRESENT in the table, not that the operation is
* accurate. A published path whose request schema has drifted from the
* handler passes here. That is the limit of a structural check; the accuracy
* comes from the schemas being imported from `src/lib/validations/*` rather
* than retyped.
* - It reads exports with a regex over comment-stripped source. A verb exported
* through an indirection this matcher cannot see would read as absent, which
* fails loudly rather than passing quietly, and is the direction an
* imprecise matcher should fail in.
* - `globSync` skips dot-directories by default, which once hid fourteen guards
* from `src/app/.well-known`. The sweep below passes an explicit pattern list
* that includes it, and asserts a floor on the number of modules found so an
* enumeration that silently matches nothing cannot read as full coverage.
*
* Mutation checks, each run and confirmed red:
*
* 1. Delete any single entry from {@link UNPUBLISHED} → the third case fails
* naming that path.
* 2. Delete a path from a route module → the second case fails naming the
* verbs that lost their contract.
* 3. Delete a live route module that is published — `src/app/api/version` —
* → the fourth case fails naming `/api/version` as a published path no
* route serves. This is the seam the third state opens: the failure now
* has two ways out, erase the path or register the retirement, and the
* message says so.
* 4. Recreate `src/app/api/auth/me/research-mode/route.ts` with a GET export
* → the fifth case fails naming it, because a tombstone over a live route
* is wrong in three places at once (this contract, the capability list,
* and the 410 the proxy answers before the route can ever run).
* 5. Drop `...retiredPaths` from the route-table index → the sixth case fails
* naming every registered path as answered by the server and absent from
* the contract.
*
* Deliberately NOT claimed: deleting an entry from `RETIRED_ROUTES` does not
* fail anything here, because the contract entries are generated from that same
* constant and both sides disappear together. That is the limit stated above,
* written down so the next reader does not mistake this file for a deletion
* detector.
*/
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { relative } from "node:path";
import { describe, expect, it } from "vitest";
import { openApiPaths } from "@/lib/openapi/routes";
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
const ROOT = process.cwd();
/**
* The verbs a route module can export. `HEAD` and `OPTIONS` are deliberately
* out: Next.js answers both from the others, and neither is a contract a client
* writes against.
*/
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
/**
* The kinds of route that are deliberately outside the published contract.
*
* A kind rather than a free-text reason per path, because these fall into a
* small number of genuinely different shapes and writing the same sentence
* seventy-three times would hide the one entry whose sentence is different. A
* route that fits none of these kinds belongs in the spec.
*/
const REASONS = {
/**
* `requireAdmin()` is cookie-only by construction (`src/lib/api-handler.ts`),
* so a Bearer token cannot reach these however wide its scope. The only
* caller that can is the operator's own browser on the admin console, which
* ships with the server and does not read a spec to find out what it talks
* to. Publishing them would describe an API surface that no API client can
* use.
*/
adminConsole:
"cookie-only admin console; requireAdmin() refuses every Bearer caller",
/**
* A browser navigation, not a request a client makes and reads. The response
* is a redirect to the provider's own authorise page; the client that starts
* it is a link, and the thing that follows it is the address bar.
*/
browserHandoff:
"browser navigation that ends at the provider's authorise page",
/**
* Driven by the provider, arriving with the provider's own query string. A
* client never constructs one, and the contract that governs its shape is
* the provider's, not this one.
*/
oauthCallback: "provider-driven redirect back from an OAuth authorise step",
/**
* Called by the provider, authenticated by a shared secret rather than by a
* session or a token. The payload shape is the provider's to define, and
* documenting it here would suggest a caller could construct one.
*/
providerWebhook:
"provider-called, authenticated by a shared secret rather than a session",
/**
* Serves the deployment itself: crash reports, web vitals, the analytics
* proxy, the deploy hook. No user-facing client calls these, and two of them
* accept a body defined by a browser reporting standard rather than by us.
*/
internalOps: "serves the deployment itself, not any user-facing client",
/**
* The Model Context Protocol transport and the OAuth dance in front of it.
* These speak wire formats that MCP and RFC 8414 / RFC 7591 define, including
* error bodies the standard response envelope cannot express, which is why
* they are the documented exception to the `apiHandler` rule as well.
*/
mcpTransport: "speaks the MCP and OAuth wire formats, not this envelope",
/**
* A clinician share link, authenticated by the URL credential itself. The
* audience is a person who was sent a link, and the whole point is that they
* need nothing else — no account, no token, and no contract to read.
*/
shareLink:
"authenticated by the URL credential, for a recipient with no account",
/**
* A discovery document whose format belongs to somebody else: Apple's
* associated-domains file, and the two OAuth metadata documents. Their shape
* is fixed by an external specification, so restating it here would create a
* second source of truth that can only be wrong.
*/
wellKnown: "format fixed by an external specification",
} as const;
type ExemptionKind = keyof typeof REASONS;
interface Exemption {
kind: ExemptionKind;
/** The verbs this exemption covers. Any other verb must be published. */
methods: readonly HttpMethod[];
}
/**
* Routes that are deliberately not in the public contract.
*
* Keyed by path, listing the verbs the exemption covers, so a route that grows
* a new verb has to justify it rather than inherit somebody else's reason.
*/
const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/ai-quality": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/ai-settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
"/api/admin/app-logs": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/audit-log": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/audit-log/actions": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/backup/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] },
// `/api/admin/backups/{id}/download`, `/api/admin/backups/{id}/restore` and
// `/api/admin/backups/upload` are published rather than exempted: the first
// two open the encrypted stored copy, and the refusal they answer when it
// will not open is a promise an operator needs to be able to look up; the
// upload refuses a file for reasons (a missing key, an unknown owner, a
// newer schema) an operator has to be able to look up the same way. See
// `src/lib/openapi/routes/admin-backups.ts`.
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/central-codex": {
kind: "adminConsole",
methods: ["DELETE", "GET"],
},
"/api/admin/central-codex/device-poll": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/central-codex/device-start": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/data": { kind: "adminConsole", methods: ["DELETE"] },
"/api/admin/drain-per-sample-cumulative": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/key-backup": {
kind: "adminConsole",
methods: ["GET"],
},
"/api/admin/encryption/key-backup/confirm": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/key-backup/verify": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/import-apple-health-export": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/maintenance/pulse-day-mean": {
kind: "adminConsole",
methods: ["GET", "POST"],
},
"/api/admin/monitoring/glitchtip-test": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/monitoring/umami-test": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
"/api/admin/settings/assistant-flags": {
kind: "adminConsole",
methods: ["GET", "PUT"],
},
"/api/admin/settings/module-availability": {
kind: "adminConsole",
methods: ["GET", "PATCH"],
},
"/api/admin/settings/web-push-vapid/generate": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/status": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
"/api/admin/users/{id}/force-logout": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/users/{id}/reset-password": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
// now. Nightscout has no OAuth step and no authorise page: the route is an
// authenticated JSON POST that Zod-parses a URL and a token, probes the
// instance and encrypts the pair onto the row. It was the only POST among
// eight `connect` entries and the only one whose siblings — status, test,
// disconnect — were all published, which is what makes it read as an entry
// that inherited a neighbour's reason rather than earned its own. The seven
// GET handoffs below keep it: each of those really does end at a provider's
// authorise page.
"/api/oura/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/polar/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/strava/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/whoop/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/withings/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/internal/deploy-webhook": {
kind: "internalOps",
methods: ["GET", "POST"],
},
"/api/internal/web-vitals": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/csp-report": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] },
"/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] },
"/api/monitoring/umami/{path}": { kind: "internalOps", methods: ["POST"] },
"/api/mcp/oauth/authorize": {
kind: "mcpTransport",
methods: ["GET", "POST"],
},
"/api/mcp/oauth/register": { kind: "mcpTransport", methods: ["POST"] },
"/api/mcp/oauth/token": { kind: "mcpTransport", methods: ["POST"] },
"/mcp": { kind: "mcpTransport", methods: ["DELETE", "GET", "POST"] },
"/api/auth/oidc/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/fitbit/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/google-health/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/oura/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/polar/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/strava/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/whoop/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/withings/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/telegram/webhook": {
kind: "providerWebhook",
methods: ["GET", "POST"],
},
"/api/whoop/webhook/{token}": { kind: "providerWebhook", methods: ["POST"] },
"/api/withings/webhook": {
kind: "providerWebhook",
methods: ["GET", "POST"],
},
"/api/withings/webhook/{token}": {
kind: "providerWebhook",
methods: ["GET", "POST"],
},
// `/c/{token}/report.pdf` and `/c/{token}/fhir` were exempt here as
// `shareLink` and are published now. That reason holds for the page a
// recipient opens; it does not hold for two routes that serve machine
// formats a practice files into another system, which is precisely the
// audience a contract has. `/c/{token}/d/{id}` was published all along, one
// route away in the same tree, which is what makes the exemption read as an
// oversight rather than a decision. The `shareLink` reason stays: the next
// recipient-facing route is likelier to want it than not.
"/.well-known/apple-app-site-association": {
kind: "wellKnown",
methods: ["GET"],
},
"/.well-known/oauth-authorization-server": {
kind: "wellKnown",
methods: ["GET"],
},
"/.well-known/oauth-protected-resource": {
kind: "wellKnown",
methods: ["GET"],
},
};
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
}
/** `src/app/api/foo/[id]/route.ts` → `/api/foo/{id}`. */
function toApiPath(file: string): string {
return (
"/" +
relative("src/app", file)
.replace(/\/route\.ts$/, "")
.replace(/\[\.\.\.([A-Za-z0-9_$]+)\]/g, "{$1}")
.replace(/\[([A-Za-z0-9_$]+)\]/g, "{$1}")
);
}
function exportedMethods(source: string): HttpMethod[] {
return HTTP_METHODS.filter((method) =>
new RegExp(
`export\\s+(?:async\\s+)?(?:function\\s+${method}\\b|const\\s+${method}\\b)`,
).test(source),
);
}
/**
* Every route module under `src/app`, dot-directories included.
*
* The two patterns are not redundant: `globSync("src/app/**")` does not descend
* into `.well-known`, and a sweep that quietly skips it is exactly the failure
* this project has already had once.
*/
function routeModules(): string[] {
const found = new Set<string>([
...globSync("src/app/**/route.ts", { cwd: ROOT }),
...globSync("src/app/**/.*/**/route.ts", { cwd: ROOT }),
]);
return [...found].sort();
}
interface DiskRoute {
path: string;
methods: HttpMethod[];
}
function routesOnDisk(): DiskRoute[] {
const routes: DiskRoute[] = [];
for (const file of routeModules()) {
const methods = exportedMethods(stripComments(readFileSync(file, "utf8")));
if (methods.length === 0) continue;
routes.push({ path: toApiPath(file), methods });
}
return routes;
}
describe("every route is published, unpublished, or retired", () => {
const disk = routesOnDisk();
const retiredPathSet = new Set(RETIRED_ROUTES.map((route) => route.path));
it("finds the route modules at all", () => {
// A floor, not a count: the point is that an enumeration returning nothing
// cannot read as "everything is covered". The number moves with the tree,
// so it sits well below the real one.
expect(
disk.length,
"the route sweep found almost nothing, which means the pattern is wrong rather than the tree empty",
).toBeGreaterThan(300);
});
it("no route verb is missing from the contract without a reason", () => {
const table = openApiPaths as Record<string, Record<string, unknown>>;
const undocumented: string[] = [];
for (const { path, methods } of disk) {
const exempt = UNPUBLISHED[path];
const published = table[path] ?? {};
for (const method of methods) {
if (exempt?.methods.includes(method)) continue;
if (method.toLowerCase() in published) continue;
undocumented.push(`${method} ${path}`);
}
}
expect(
undocumented,
"these routes answer requests and no client can read what they answer with",
).toEqual([]);
});
it("the exemption list names routes that exist", () => {
const byPath = new Map(disk.map((route) => [route.path, route.methods]));
const stale: string[] = [];
for (const [path, exempt] of Object.entries(UNPUBLISHED)) {
const methods = byPath.get(path);
if (!methods) {
stale.push(`${path} (no such route)`);
continue;
}
for (const method of exempt.methods) {
if (!methods.includes(method)) {
stale.push(`${method} ${path} (route does not export it)`);
}
}
}
expect(
stale,
"an exemption for a route that is gone hides the next route that needs one",
).toEqual([]);
});
it("the exemption list names routes that are not published after all", () => {
// The other way an entry goes stale, and the one this list had: a route
// gets published and its exemption stays behind, so the list reads as
// larger than it is and the reason attached to it looks load-bearing when
// nothing rests on it. Two share-link routes sat here in exactly that
// state, beside a published sibling in the same tree.
const table = openApiPaths as Record<string, Record<string, unknown>>;
const redundant: string[] = [];
for (const [path, exempt] of Object.entries(UNPUBLISHED)) {
for (const method of exempt.methods) {
if (method.toLowerCase() in (table[path] ?? {})) {
redundant.push(`${method} ${path}`);
}
}
}
expect(
redundant,
"these operations are published AND exempt, so the exemption states a reason nothing depends on",
).toEqual([]);
});
it("the contract does not publish a path that no route serves, unless it is retired", () => {
const byPath = new Set(disk.map((route) => route.path));
const phantom = Object.keys(openApiPaths).filter(
(path) => !byPath.has(path) && !retiredPathSet.has(path),
);
expect(
phantom,
"a published path with no route behind it is a promise the deployment cannot keep — register it in RETIRED_ROUTES if it is gone on purpose",
).toEqual([]);
});
it("a retired path is really gone from src/app", () => {
// The other direction of the exemption above. A tombstone standing over a
// route that still answers would publish 410 as the only response while
// the proxy short-circuits the live handler — the contract, the capability
// list and the runtime would all be wrong together, and each of them looks
// internally consistent on its own.
const byPath = new Set(disk.map((route) => route.path));
const undead = RETIRED_ROUTES.map((route) => route.path).filter((path) =>
byPath.has(path),
);
expect(
undead,
"these paths are registered as retired and a route module still exports verbs for them",
).toEqual([]);
});
it("every retired path is published as gone", () => {
// Registering a retirement and not publishing it puts a client back where
// it started: the proxy answers 410, and a client generated from the
// contract has never heard of the path. The retirement is only worth
// anything if it reaches the reader.
const table = openApiPaths as Record<string, Record<string, unknown>>;
const unpublished: string[] = [];
for (const route of RETIRED_ROUTES) {
const published = table[route.path];
if (!published) {
unpublished.push(`${route.path} (absent from the contract)`);
continue;
}
for (const method of route.methods) {
const operation = published[method.toLowerCase()] as
{ responses?: Record<string, unknown> } | undefined;
if (!operation) {
unpublished.push(`${method} ${route.path} (verb not published)`);
continue;
}
if (!operation.responses?.["410"]) {
unpublished.push(`${method} ${route.path} (published without a 410)`);
}
}
}
expect(
unpublished,
"these retirements are answered by the server and invisible in the contract",
).toEqual([]);
});
});