import { expect, test } from "@playwright/test"; /** * `crypto.randomUUID` only exists on secure origins. The isolated NAS preview * is plain HTTP on a LAN/Tailnet address, where calling it directly threw * `crypto.randomUUID is not a function` at render time and replaced the whole * session-review route with the error boundary. * * These tests run against the Vite dev server so the module can be imported * directly, and pin that idempotency keys stay available without a secure * context. */ 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 SHA256_VECTORS = [ { value: "", digest: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", }, { value: "abc", digest: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", }, { value: "a".repeat(56), digest: "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a", }, { value: "지금 이 이야기를 조금 더 다뤄도 괜찮을까요?", digest: "c06db0f06811c540ea7d985c7d4e3b880e978e33524128538d6f0ab7b23362aa", }, ] as const; test.describe("insecure-context idempotency keys", () => { test("randomUuid keeps working when crypto.randomUUID is unavailable", async ({ page, }) => { await page.addInitScript(() => { // `randomUUID` lives on Crypto.prototype, so shadow it with an own // undefined property instead of deleting it. Object.defineProperty(globalThis.crypto, "randomUUID", { value: undefined, configurable: true, }); }); await page.goto("/"); const probe = await page.evaluate(async () => { const module = await import("/src/lib/uuid.ts"); return { randomUuidPresent: typeof (globalThis.crypto as { randomUUID?: unknown }).randomUUID, getRandomValuesPresent: typeof globalThis.crypto.getRandomValues, values: [module.randomUuid(), module.randomUuid(), module.randomUuid()], }; }); expect(probe.randomUuidPresent).toBe("undefined"); expect(probe.getRandomValuesPresent).toBe("function"); for (const value of probe.values) { expect(value).toMatch(UUID_V4); } expect(new Set(probe.values).size).toBe(3); }); test("randomUuid degrades once more when Web Crypto is missing entirely", async ({ page, }) => { await page.addInitScript(() => { // `randomUUID` lives on Crypto.prototype, so shadow it with an own // undefined property instead of deleting it. Object.defineProperty(globalThis.crypto, "randomUUID", { value: undefined, configurable: true, }); Object.defineProperty(globalThis.crypto, "getRandomValues", { value: undefined, configurable: true, }); }); await page.goto("/"); const probe = await page.evaluate(async () => { const module = await import("/src/lib/uuid.ts"); const api = globalThis.crypto as { randomUUID?: unknown; getRandomValues?: unknown; }; return { randomUuidPresent: typeof api.randomUUID, getRandomValuesPresent: typeof api.getRandomValues, values: [module.randomUuid(), module.randomUuid()], }; }); expect(probe.randomUuidPresent).toBe("undefined"); expect(probe.getRandomValuesPresent).toBe("undefined"); const values = probe.values; for (const value of values) { expect(value).toMatch(UUID_V4); } expect(new Set(values).size).toBe(2); }); test("sha256Hex matches known vectors when crypto.subtle is unavailable", async ({ page, }) => { await page.addInitScript(() => { Object.defineProperty(globalThis.crypto, "subtle", { value: undefined, configurable: true, }); }); await page.goto("/"); const probe = await page.evaluate(async (vectors) => { const module = await import("/src/lib/sha256.ts"); return { subtlePresent: typeof globalThis.crypto.subtle, digests: await Promise.all( vectors.map(({ value }) => module.sha256Hex(value)), ), }; }, SHA256_VECTORS); expect(probe.subtlePresent).toBe("undefined"); expect(probe.digests).toEqual( SHA256_VECTORS.map(({ digest }) => digest), ); }); test("sha256Hex falls back when a partial subtle implementation throws", async ({ page, }) => { await page.addInitScript(() => { let digestCalls = 0; Object.defineProperty(globalThis, "__sha256DigestCalls", { configurable: true, get: () => digestCalls, }); Object.defineProperty(globalThis.crypto, "subtle", { configurable: true, value: { digest: async () => { digestCalls += 1; throw new DOMException( "partial Web Crypto implementation", "NotSupportedError", ); }, }, }); }); await page.goto("/"); const probe = await page.evaluate(async (vectors) => { const module = await import("/src/lib/sha256.ts"); const digests = await Promise.all( vectors.map(({ value }) => module.sha256Hex(value)), ); return { subtlePresent: typeof globalThis.crypto.subtle, digestCalls: ( globalThis as typeof globalThis & { __sha256DigestCalls: number } ).__sha256DigestCalls, digests, }; }, SHA256_VECTORS); expect(probe.subtlePresent).toBe("object"); expect(probe.digestCalls).toBe(SHA256_VECTORS.length); expect(probe.digests).toEqual( SHA256_VECTORS.map(({ digest }) => digest), ); }); test("no product source calls crypto.randomUUID without the fallback", async () => { const { readFileSync, readdirSync, statSync } = await import("node:fs"); const path = await import("node:path"); const root = path.join(process.cwd(), "src"); const offenders: string[] = []; const walk = (dir: string) => { for (const entry of readdirSync(dir)) { const full = path.join(dir, entry); if (statSync(full).isDirectory()) { walk(full); continue; } if (!/\.(ts|tsx)$/.test(entry)) continue; if (full.endsWith(path.join("lib", "uuid.ts"))) continue; if (readFileSync(full, "utf8").includes("crypto.randomUUID")) { offenders.push(path.relative(root, full)); } } }; walk(root); expect(offenders).toEqual([]); }); test("no product source calls subtle.digest outside the sha256 fallback", async () => { const { readFileSync, readdirSync, statSync } = await import("node:fs"); const path = await import("node:path"); const root = path.join(process.cwd(), "src"); const fallbackPath = path.join("lib", "sha256.ts"); const offenders: string[] = []; const walk = (dir: string) => { for (const entry of readdirSync(dir)) { const full = path.join(dir, entry); if (statSync(full).isDirectory()) { walk(full); continue; } if (!/\.(ts|tsx)$/.test(entry)) continue; if (full.endsWith(fallbackPath)) continue; if (/\bsubtle\s*\??\.\s*digest\s*\(/.test(readFileSync(full, "utf8"))) { offenders.push(path.relative(root, full)); } } }; walk(root); expect(offenders).toEqual([]); }); });