import { webcrypto } from "node:crypto"; import { stubGlobal, unstubAllGlobals } from "@intentic/testing/bun"; import { DEFAULT_ACCENT, EmbedError, embedFailure, embedUrl, escapeHtml, fetchEmbedJson, onAccent, parseHex, solveProofOfWork } from "./embed.js"; /* The wire every embed speaks before it speaks its own, against a fake fetch and the real WebCrypto. */ // jsdom ships no SubtleCrypto; node's is the same WebCrypto the browser exposes, so the solver is exercised // against the real digest rather than a stub. beforeAll(() => { Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true }); }); // The daemon verifies exactly this: hash the whole answer string and count the leading zero bits. const leadingZeroBits = async (answer: string): Promise => { const digest = new Uint8Array(await webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(answer))); let bits = 0; for (const byte of digest) { if (byte !== 0) { return bits + Math.clz32(byte) - 24; } bits += 8; } return bits; }; test("a door's route is spelled the daemon's way, with the automation id encoded", () => { expect(embedUrl({ base: "https://sandbox.example", automationId: "a b" }, "webchat", "config")).toBe( "https://sandbox.example/webchat/a%20b/config", ); }); test("a refusal carries the server's own sentence and status, and a bodyless one says what it can", async () => { const said = await embedFailure(new Response(JSON.stringify({ error: "origin not allowed" }), { status: 403 })); expect(said).toBeInstanceOf(EmbedError); expect(said).toMatchObject({ message: "origin not allowed", status: 403 }); expect(await embedFailure(new Response("nope", { status: 502 }))).toMatchObject({ message: "request failed (502)", status: 502 }); }); test("a JSON fetch answers the body or throws the refusal", async () => { stubGlobal( "fetch", jest.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })), ); expect(await fetchEmbedJson<{ ok: boolean }>("https://x/y")).toEqual({ ok: true }); stubGlobal( "fetch", jest.fn(async () => new Response(JSON.stringify({ error: "rate limited" }), { status: 429 })), ); await expect(fetchEmbedJson("https://x/y")).rejects.toMatchObject({ message: "rate limited", status: 429 }); stubGlobal( "fetch", jest.fn(async () => new Response("bad gateway", { status: 502 })), ); await expect(fetchEmbedJson("https://x/y")).rejects.toMatchObject({ message: "request failed (502)", status: 502 }); unstubAllGlobals(); }); test("a door that cannot be reached says so in words a visitor can read, without the URL", async () => { stubGlobal( "fetch", jest.fn(async () => { throw new TypeError("Failed to fetch"); }), ); await expect(fetchEmbedJson("https://x/y")).rejects.toThrow("The server could not be reached: Failed to fetch"); unstubAllGlobals(); }); /* The one test that pays for real work: 12 bits is ~900 awaited digests. */ test("the answer really clears the difficulty, and carries the salt back for the daemon to re-derive", async () => { const answer = await solveProofOfWork({ salt: "abc123", difficulty: 12 }, "needs https"); expect(answer.startsWith("abc123:")).toBe(true); expect(await leadingZeroBits(answer)).toBeGreaterThanOrEqual(12); }, 20_000); test("a different salt yields a different answer: a solution cannot be replayed across callers", async () => { const [one, two] = await Promise.all([ solveProofOfWork({ salt: "salt-one", difficulty: 6 }, "needs https"), solveProofOfWork({ salt: "salt-two", difficulty: 6 }, "needs https"), ]); expect(one).not.toBe(two); }); test("an http:// page is told the truth, in the embed's own words, instead of hanging on a missing SubtleCrypto", async () => { Object.defineProperty(globalThis, "crypto", { value: { randomUUID: webcrypto.randomUUID }, configurable: true }); await expect(solveProofOfWork({ salt: "abc", difficulty: 8 }, "This page must be served over HTTPS to start a chat.")).rejects.toThrow( /HTTPS to start a chat/, ); Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true }); }); test("an accent is read as three channels from either hex spelling, and anything else is unreadable", () => { expect(parseHex(DEFAULT_ACCENT)).toEqual([228, 113, 0]); expect(parseHex(" #FA0 ")).toEqual([255, 170, 0]); expect(parseHex("rebeccapurple")).toBeUndefined(); expect(parseHex("#12345")).toBeUndefined(); }); // The default orange is where a brightness threshold gets it wrong: it read as dark and put white on it, at 3.15:1. test("the label on an accent is whichever of the dark ink and white contrasts more, so the default orange gets dark text", () => { expect(onAccent(DEFAULT_ACCENT, "#111827")).toBe("#111827"); expect(onAccent(DEFAULT_ACCENT, "#201c19")).toBe("#201c19"); expect(onAccent("#1a3d8f", "#111827")).toBe("#ffffff"); expect(onAccent("#fde047", "#111827")).toBe("#111827"); }); test("escaped text stays text both between tags and inside a quoted attribute", () => { expect(escapeHtml(` & more`)).toBe("<img src=x onerror="alert('hi')"> & more"); expect(escapeHtml("Report a problem")).toBe("Report a problem"); });