Files
HealthLog/src/__tests__/client-random-id-guard.test.ts
T
Marc-André Bombeck 5556478691 Make uploads and every other write work over plain HTTP
crypto.randomUUID() is [SecureContext] in the Web Cryptography IDL, so a
page served over plain HTTP never gets it. Measured in Chromium against
http://<lan-ip>: isSecureContext false, randomUUID and subtle undefined,
getRandomValues still a function.

The document vault minted its queue id and Idempotency-Key that way, inside
the file-input change handler. On a LAN self-host the call threw before a
queue row existed, so picking a file produced no upload, no error and no
trace — the picker simply looked inert, and the only way through was to
reach the app over HTTPS. The client transport has minted a default
Idempotency-Key the same way since v1.37.18, which put every apiPost and
apiPatch on the same footing: on such a host nothing wrote at all.

Add randomId(), which prefers the platform call and otherwise assembles a
v4 UUID from getRandomValues — the same CSPRNG, the same 122 bits, no
secure context required — and route every browser-side call site through
it. Nothing in an upload needs one: the bytes travel over multipart XHR and
every cryptographic operation happens on the server. Passkeys, web push and
home-screen install still need HTTPS; browsers withhold those outright.

A structural guard fails a direct crypto.randomUUID() anywhere outside the
two server-side files that may keep it, so the class cannot return one call
site at a time.

Also point the CSV and JSON import cards straight at the import guide. They
resolved through a redirect under the integrations tree, which is the wrong
place for a guide that is not an integration runbook. The suite that
covered those links pinned the old spelling rather than the guarantee; it
now asserts an absolute docs-site URL, no internal path, and new-tab
hygiene.
2026-09-05 16:22:39 +02:00

131 lines
4.6 KiB
TypeScript

/**
* Browser-side code does not call `crypto.randomUUID()` directly.
*
* `randomUUID` is `[SecureContext]` in the Web Cryptography IDL, so it is
* absent on a plain-HTTP origin — the LAN self-host case. A direct call
* there throws `TypeError: crypto.randomUUID is not a function`, and the
* two places it was reached from were both silent: the document-vault
* upload threw inside a file-input change handler (a picker that appeared
* to do nothing), and the client transport threw while assembling the
* default `Idempotency-Key` for every `apiPost` / `apiPatch`.
*
* `randomId()` in `src/lib/random-id.ts` covers both contexts. This guard
* keeps the class from coming back one call site at a time.
*
* Server-side files are exempt: Node's global `crypto` carries
* `randomUUID` unconditionally, and `node:crypto` imports are unrelated.
* The exemption is an explicit list, not a pattern, so a new browser file
* cannot join it by accident.
*
* Limit, stated so the next reader does not over-trust it: the matcher is
* textual. `const f = crypto["randomUUID"]` or a destructured
* `const { randomUUID } = crypto` would slip it.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..", "..");
const SRC = join(ROOT, "src");
/**
* Pruned on purpose: the generated Prisma client, vendored dependencies, and
* the suites themselves are not the tree under audit. The walk descends into
* dot-prefixed directories, so `src/app/.well-known` stays inside the sweep —
* `fs.globSync` would drop it.
*/
const SKIP_DIRS = new Set([
"__tests__",
"__mocks__",
"node_modules",
".next",
"generated",
]);
/**
* Files that legitimately call the platform API directly, each because it
* runs on the server where the property always exists.
*/
const SERVER_SIDE_EXEMPT = new Set([
// Next middleware — runs on the edge runtime, never in the page.
"src/proxy.ts",
// The one module allowed to reach for the platform call.
"src/lib/random-id.ts",
]);
/** A direct call, tolerant of whitespace between the parts. */
const DIRECT_CALL = /\bcrypto\s*\.\s*randomUUID\s*\(/;
/**
* Comments are prose, not calls. Without this the guard fires on the very
* doc comments that explain why the call was removed — a guard that its own
* explanation trips is one the next reader deletes.
*/
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
}
/** Every `.ts(x)` under `src/`, as a repo-relative POSIX path. */
function sourceFiles(dir: string = SRC, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
if (statSync(p).isDirectory()) {
sourceFiles(p, out);
} else if (
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
!name.endsWith(".test.ts") &&
!name.endsWith(".test.tsx")
) {
out.push(relative(ROOT, p).split(sep).join("/"));
}
}
return out.sort();
}
describe("client-side randomUUID guard", () => {
it("sweeps a non-trivial number of files", () => {
// Without this the suite would pass by matching nothing at all.
expect(sourceFiles().length).toBeGreaterThan(500);
});
it("finds no direct crypto.randomUUID() outside the exempt set", () => {
const offenders = sourceFiles().filter(
(file) =>
!SERVER_SIDE_EXEMPT.has(file) &&
DIRECT_CALL.test(stripComments(readFileSync(join(ROOT, file), "utf8"))),
);
expect(offenders).toEqual([]);
});
it("still matches the call it is meant to catch", () => {
// The matcher's own positive control — a guard that cannot fire is
// indistinguishable from a clean tree.
expect(DIRECT_CALL.test('headers.set("k", crypto.randomUUID());')).toBe(
true,
);
expect(DIRECT_CALL.test("const id = crypto . randomUUID ()")).toBe(true);
expect(DIRECT_CALL.test("const id = randomId();")).toBe(false);
// …and does not fire on prose about the call.
expect(
DIRECT_CALL.test(
stripComments("/**\n * it did so with crypto.randomUUID().\n */"),
),
).toBe(false);
expect(
DIRECT_CALL.test(stripComments("// const id = crypto.randomUUID();")),
).toBe(false);
});
it("keeps every exempt file real and still calling it", () => {
for (const file of SERVER_SIDE_EXEMPT) {
const source = stripComments(readFileSync(join(ROOT, file), "utf8"));
expect(
DIRECT_CALL.test(source),
`${file} no longer needs the exemption`,
).toBe(true);
}
});
});