mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
/**
|
|
* `randomId()` under both context shapes a browser can present.
|
|
*
|
|
* The insecure shape is not invented for the test. Measured in Chromium on
|
|
* `http://<lan-ip>` — a plain-HTTP LAN self-host, the reporter's setup:
|
|
*
|
|
* isSecureContext false
|
|
* crypto object
|
|
* crypto.randomUUID undefined → "TypeError: … is not a function"
|
|
* crypto.subtle undefined
|
|
* crypto.getRandomValues function
|
|
*
|
|
* `insecureCrypto()` reproduces exactly that: `getRandomValues` present,
|
|
* `randomUUID` and `subtle` absent.
|
|
*
|
|
* Watched red: with the fallback branch removed — `randomId` reduced to a
|
|
* bare `return crypto.randomUUID()` — every insecure-context case below
|
|
* fails with the same TypeError the plain-HTTP host raises.
|
|
*/
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const UUID_V4 =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
|
|
/**
|
|
* Bound BEFORE any stubbing: `vi.stubGlobal("crypto", …)` replaces
|
|
* `globalThis.crypto`, so a stub that delegated through the global would
|
|
* call itself.
|
|
*/
|
|
const realGetRandomValues = globalThis.crypto.getRandomValues.bind(
|
|
globalThis.crypto,
|
|
);
|
|
|
|
/**
|
|
* The `crypto` a plain-HTTP origin actually hands the page: real
|
|
* `getRandomValues`, no `randomUUID`, no `subtle`.
|
|
*/
|
|
function insecureCrypto(): Crypto {
|
|
return { getRandomValues: realGetRandomValues } as Crypto;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.resetModules();
|
|
});
|
|
|
|
/** Fresh import per case — the module must not cache a context decision. */
|
|
async function loadRandomId() {
|
|
vi.resetModules();
|
|
return (await import("../random-id")).randomId;
|
|
}
|
|
|
|
describe("randomId in a secure context", () => {
|
|
it("delegates to the platform randomUUID", async () => {
|
|
const randomUUID = vi.fn(
|
|
() => "11111111-2222-4333-8444-555555555555" as const,
|
|
);
|
|
vi.stubGlobal("crypto", { ...globalThis.crypto, randomUUID });
|
|
|
|
const randomId = await loadRandomId();
|
|
|
|
expect(randomId()).toBe("11111111-2222-4333-8444-555555555555");
|
|
expect(randomUUID).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("randomId on a plain-HTTP origin", () => {
|
|
it("returns a v4 UUID without randomUUID present", async () => {
|
|
vi.stubGlobal("crypto", insecureCrypto());
|
|
const randomId = await loadRandomId();
|
|
|
|
expect(randomId()).toMatch(UUID_V4);
|
|
});
|
|
|
|
it("does not throw where crypto.randomUUID would", async () => {
|
|
vi.stubGlobal("crypto", insecureCrypto());
|
|
const randomId = await loadRandomId();
|
|
|
|
// The control: the call the app used to make is genuinely unavailable.
|
|
expect(() => (crypto as Crypto).randomUUID()).toThrow(TypeError);
|
|
expect(() => randomId()).not.toThrow();
|
|
});
|
|
|
|
it("draws from getRandomValues, not a clock or Math.random", async () => {
|
|
const getRandomValues = vi.fn(realGetRandomValues);
|
|
vi.stubGlobal("crypto", { getRandomValues } as unknown as Crypto);
|
|
const randomId = await loadRandomId();
|
|
|
|
randomId();
|
|
|
|
expect(getRandomValues).toHaveBeenCalledTimes(1);
|
|
expect(getRandomValues.mock.calls[0]![0]).toBeInstanceOf(Uint8Array);
|
|
});
|
|
|
|
it("does not collide across a batch the size of a bulk upload", async () => {
|
|
vi.stubGlobal("crypto", insecureCrypto());
|
|
const randomId = await loadRandomId();
|
|
|
|
const ids = new Set(Array.from({ length: 5_000 }, () => randomId()));
|
|
|
|
expect(ids.size).toBe(5_000);
|
|
});
|
|
});
|