Files
HealthLog/src/lib/api/__tests__/api-fetch-insecure-context.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

114 lines
3.6 KiB
TypeScript

/**
* Every client write still works on a plain-HTTP origin.
*
* `apiPost` / `apiPatch` mint a default `Idempotency-Key`. That key came
* from `crypto.randomUUID()`, which the Web Cryptography IDL marks
* `[SecureContext]` — absent on `http://<lan-ip>`, verified in Chromium.
* So on a LAN self-host served over plain HTTP the header line threw
* before `fetch` was ever reached, and all seventy-odd `apiPost` /
* `apiPatch` call sites failed with a raw TypeError.
*
* Watched red: restoring `crypto.randomUUID()` in
* `withDefaultIdempotencyKey` fails every case here.
*/
import { afterEach, beforeEach, 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}$/;
const fetchMock = vi.fn();
/**
* 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;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function idempotencyKeyOf(call: unknown[]): string | null {
return new Headers((call[1] as RequestInit | undefined)?.headers).get(
"Idempotency-Key",
);
}
beforeEach(() => {
vi.resetModules();
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("crypto", insecureCrypto());
});
afterEach(() => {
fetchMock.mockReset();
vi.unstubAllGlobals();
});
describe("client writes on a plain-HTTP origin", () => {
it("has no crypto.randomUUID to call — the control", () => {
expect(typeof (crypto as Crypto).randomUUID).toBe("undefined");
});
it("apiPost still sends, with a v4 Idempotency-Key", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ data: { id: "m1" } }));
const { apiPost } = await import("../api-fetch");
await expect(apiPost("/api/measurements", { value: 80 })).resolves.toEqual({
id: "m1",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(idempotencyKeyOf(fetchMock.mock.calls[0]!)).toMatch(UUID_V4);
});
it("apiPatch still sends, with a v4 Idempotency-Key", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
const { apiPatch } = await import("../api-fetch");
await apiPatch("/api/auth/me", { locale: "de" });
expect(idempotencyKeyOf(fetchMock.mock.calls[0]!)).toMatch(UUID_V4);
});
it("gives each write its own key", async () => {
// A fresh Response per call: a body can only be read once.
fetchMock.mockImplementation(() => jsonResponse({ data: null }));
const { apiPost } = await import("../api-fetch");
await apiPost("/api/measurements", { value: 80 });
await apiPost("/api/measurements", { value: 81 });
const first = idempotencyKeyOf(fetchMock.mock.calls[0]!);
const second = idempotencyKeyOf(fetchMock.mock.calls[1]!);
expect(first).not.toBe(second);
});
it("still lets a caller's own natural key win", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null }));
const { apiPost } = await import("../api-fetch");
await apiPost(
"/api/admin/backups/b1/restore",
{ confirm: "RESTORE" },
{ headers: { "Idempotency-Key": "restore-b1-fixed" } },
);
expect(idempotencyKeyOf(fetchMock.mock.calls[0]!)).toBe("restore-b1-fixed");
});
});