// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { deflate } from "pako"; import { flushView, renderView } from "./view-test-utils"; import { buildChunkEnvelope, bytesToBase64Url, sha256Hex } from "../codex/helpers"; import { encodeWithErasure } from "../codex/erasure"; import { _resetReedSolomonCacheForTest } from "../codex/rs-loader"; import type { ChunkEnvelope, CodexPayload } from "../../shared/codex-types"; // PCH-01-015: the Codex restore view must forward the RAW (untrimmed) master // passphrase to RESTORE_FROM_CODEX, trimming only for the emptiness check — // mirroring Lock.tsx so a master password with surrounding whitespace restores. const sendMessageMock = vi.fn<(msg: unknown) => Promise>(); vi.mock("../../shared/bridge", () => ({ sendMessage: (msg: unknown) => sendMessageMock(msg), })); // Camera-only dependency; stub so importing the view never pulls in zxing. vi.mock("../codex/scanner", () => ({ startScanner: vi.fn(() => ({ stop: vi.fn() })), })); // The view dynamically imports pdf-import; return our pre-built envelopes so a // real ChunkBuffer reaches isReady() and reconstructAndVerify succeeds. let preparedEnvelopes: ChunkEnvelope[] = []; vi.mock("../codex/pdf-import", () => ({ importCodexPDF: vi.fn(async () => ({ chunksFound: preparedEnvelopes.length, totalExpected: preparedEnvelopes.length, envelopes: preparedEnvelopes, errors: [], })), })); const VERIFY_CODE = "PX-TEST-CODE"; async function makePayload(): Promise { const blobBytes = new Uint8Array(2000); let s = 0x9e3779b9; for (let i = 0; i < blobBytes.length; i++) { s = (s ^ (s << 13)) >>> 0; s = (s ^ (s >>> 17)) >>> 0; s = (s ^ (s << 5)) >>> 0; blobBytes[i] = s & 0xff; } const vaultBlob = bytesToBase64Url(blobBytes); const vaultFingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))) .slice(0, 16) .toUpperCase(); return { version: 2, verifyCode: VERIFY_CODE, vaultBlob, timestamp: 1_700_000_000_000, entryCount: 7, vaultFingerprint, generatedAt: 1_700_000_001_000, }; } async function buildEnvelopes(payload: CodexPayload): Promise { const compressed = deflate(new TextEncoder().encode(JSON.stringify(payload)), { level: 9 }); const { dataChunks, parityChunks, totalChunks } = await encodeWithErasure(compressed); const all = [...dataChunks, ...parityChunks]; return Promise.all( all.map((bytes, i) => buildChunkEnvelope(bytes, i, totalChunks, i >= dataChunks.length, payload.verifyCode), ), ); } describe("CodexRestoreView (PCH-01-015 untrimmed passphrase)", () => { let dispose: (() => void) | undefined; beforeEach(async () => { document.body.innerHTML = ""; sendMessageMock.mockReset(); sendMessageMock.mockResolvedValue({ ok: true, data: { entryCount: 7, verifyCode: VERIFY_CODE } }); _resetReedSolomonCacheForTest(); const payload = await makePayload(); preparedEnvelopes = await buildEnvelopes(payload); }); afterEach(() => { dispose?.(); dispose = undefined; vi.unstubAllGlobals(); }); // Poll the microtask/timer queue until `cond` holds (the PDF scan and the // restore both run real async crypto, so a fixed flush count is racy). async function waitFor(cond: () => boolean, max = 100): Promise { for (let i = 0; i < max; i++) { if (cond()) return; await flushView(); } throw new Error("waitFor: condition never became true"); } function restoreFromCodexDispatched(): boolean { return sendMessageMock.mock.calls.some(([m]) => (m as { type?: string }).type === "RESTORE_FROM_CODEX"); } async function mountAndScan() { const { CodexRestoreView } = await import("./CodexRestoreView"); const onRestoreSuccess = vi.fn(); const view = renderView( CodexRestoreView({ onBack: () => {}, onRestoreSuccess }) as unknown as Parameters[0], ); dispose = view.dispose; await flushView(); // Drive the PDF import path: set a fake PDF file on the (always-rendered) // file input and fire change → handlePDFSelected → mocked importCodexPDF → // real ChunkBuffer.add → phase becomes "ready". const fileInput = view.container.querySelector('input[type="file"]')!; Object.defineProperty(fileInput, "files", { configurable: true, value: [{ type: "application/pdf", name: "codex.pdf" }], }); fileInput.dispatchEvent(new Event("change", { bubbles: true })); // Wait for the scan to finish and the passphrase prompt to render. await waitFor(() => view.container.querySelector('input[type="password"]') !== null); return view; } it("forwards the untrimmed passphrase to RESTORE_FROM_CODEX", async () => { const view = await mountAndScan(); const pwInput = view.container.querySelector('input[type="password"]'); expect(pwInput, "passphrase prompt should be visible after a complete scan").not.toBeNull(); pwInput!.value = " hunter2 "; pwInput!.dispatchEvent(new Event("input", { bubbles: true })); await flushView(); const restoreBtn = Array.from(view.container.querySelectorAll("button")).find((b) => b.textContent?.includes("restore vault"), )!; restoreBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); // reconstructAndVerify runs real WASM; wait until the dispatch lands so this // test's async fully drains before the next test resets the mock. await waitFor(restoreFromCodexDispatched); const restoreCall = sendMessageMock.mock.calls.find( ([m]) => (m as { type?: string }).type === "RESTORE_FROM_CODEX", ); const payload = (restoreCall![0] as { payload: { masterPassphrase: string } }).payload; expect(payload.masterPassphrase).toBe(" hunter2 "); }); it("blocks an all-whitespace passphrase without dispatching RESTORE_FROM_CODEX", async () => { const view = await mountAndScan(); const pwInput = view.container.querySelector('input[type="password"]')!; pwInput.value = " "; pwInput.dispatchEvent(new Event("input", { bubbles: true })); await flushView(); const restoreBtn = Array.from(view.container.querySelectorAll("button")).find((b) => b.textContent?.includes("restore vault"), )!; restoreBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); // The whitespace guard rejects synchronously (no await), but flush a few // times anyway to prove no dispatch is queued. await flushView(); await flushView(); expect(restoreFromCodexDispatched()).toBe(false); }); });