CodexRestoreView.test.tsx

7.8 KB · raw

1// @vitest-environment jsdom23import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";4import { deflate } from "pako";56import { flushView, renderView } from "./view-test-utils";7import { buildChunkEnvelope, bytesToBase64Url, sha256Hex } from "../codex/helpers";8import { encodeWithErasure } from "../codex/erasure";9import { _resetReedSolomonCacheForTest } from "../codex/rs-loader";10import type { ChunkEnvelope, CodexPayload } from "../../shared/codex-types";1112// PCH-01-015: the Codex restore view must forward the RAW (untrimmed) master13// passphrase to RESTORE_FROM_CODEX, trimming only for the emptiness check —14// mirroring Lock.tsx so a master password with surrounding whitespace restores.1516const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();1718vi.mock("../../shared/bridge", () => ({19  sendMessage: (msg: unknown) => sendMessageMock(msg),20}));2122// Camera-only dependency; stub so importing the view never pulls in zxing.23vi.mock("../codex/scanner", () => ({24  startScanner: vi.fn(() => ({ stop: vi.fn() })),25}));2627// The view dynamically imports pdf-import; return our pre-built envelopes so a28// real ChunkBuffer reaches isReady() and reconstructAndVerify succeeds.29let preparedEnvelopes: ChunkEnvelope[] = [];30vi.mock("../codex/pdf-import", () => ({31  importCodexPDF: vi.fn(async () => ({32    chunksFound: preparedEnvelopes.length,33    totalExpected: preparedEnvelopes.length,34    envelopes: preparedEnvelopes,35    errors: [],36  })),37}));3839const VERIFY_CODE = "PX-TEST-CODE";4041async function makePayload(): Promise<CodexPayload> {42  const blobBytes = new Uint8Array(2000);43  let s = 0x9e3779b9;44  for (let i = 0; i < blobBytes.length; i++) {45    s = (s ^ (s << 13)) >>> 0;46    s = (s ^ (s >>> 17)) >>> 0;47    s = (s ^ (s << 5)) >>> 0;48    blobBytes[i] = s & 0xff;49  }50  const vaultBlob = bytesToBase64Url(blobBytes);51  const vaultFingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob)))52    .slice(0, 32)53    .toUpperCase();54  return {55    version: 2,56    verifyCode: VERIFY_CODE,57    vaultBlob,58    timestamp: 1_700_000_000_000,59    entryCount: 7,60    vaultFingerprint,61    generatedAt: 1_700_000_001_000,62  };63}6465async function buildEnvelopes(payload: CodexPayload): Promise<ChunkEnvelope[]> {66  const compressed = deflate(new TextEncoder().encode(JSON.stringify(payload)), { level: 9 });67  const { dataChunks, parityChunks, totalChunks } = await encodeWithErasure(compressed);68  const all = [...dataChunks, ...parityChunks];69  return Promise.all(70    all.map((bytes, i) =>71      buildChunkEnvelope(bytes, i, totalChunks, i >= dataChunks.length, payload.verifyCode),72    ),73  );74}7576describe("CodexRestoreView (PCH-01-015 untrimmed passphrase)", () => {77  let dispose: (() => void) | undefined;7879  beforeEach(async () => {80    document.body.innerHTML = "";81    sendMessageMock.mockReset();82    sendMessageMock.mockResolvedValue({ ok: true, data: { entryCount: 7, verifyCode: VERIFY_CODE } });83    _resetReedSolomonCacheForTest();84    const payload = await makePayload();85    preparedEnvelopes = await buildEnvelopes(payload);86  });8788  afterEach(() => {89    dispose?.();90    dispose = undefined;91    vi.unstubAllGlobals();92  });9394  // Poll the microtask/timer queue until `cond` holds (the PDF scan and the95  // restore both run real async crypto, so a fixed flush count is racy).96  async function waitFor(cond: () => boolean, max = 100): Promise<void> {97    for (let i = 0; i < max; i++) {98      if (cond()) return;99      await flushView();100    }101    throw new Error("waitFor: condition never became true");102  }103104  function restoreFromCodexDispatched(): boolean {105    return sendMessageMock.mock.calls.some(([m]) => (m as { type?: string }).type === "RESTORE_FROM_CODEX");106  }107108  async function mountAndScan() {109    const { CodexRestoreView } = await import("./CodexRestoreView");110    const onRestoreSuccess = vi.fn();111    const view = renderView(112      CodexRestoreView({ onBack: () => {}, onRestoreSuccess }) as unknown as Parameters<typeof renderView>[0],113    );114    dispose = view.dispose;115    await flushView();116117    // Drive the PDF import path: set a fake PDF file on the (always-rendered)118    // file input and fire change → handlePDFSelected → mocked importCodexPDF →119    // real ChunkBuffer.add → phase becomes "ready".120    const fileInput = view.container.querySelector<HTMLInputElement>('input[type="file"]')!;121    Object.defineProperty(fileInput, "files", {122      configurable: true,123      value: [{ type: "application/pdf", name: "codex.pdf" }],124    });125    fileInput.dispatchEvent(new Event("change", { bubbles: true }));126    // Wait for the scan to finish and the passphrase prompt to render.127    await waitFor(() => view.container.querySelector('input[type="password"]') !== null);128    return view;129  }130131  it("forwards the untrimmed passphrase to RESTORE_FROM_CODEX", async () => {132    const view = await mountAndScan();133134    const pwInput = view.container.querySelector<HTMLInputElement>('input[type="password"]');135    expect(pwInput, "passphrase prompt should be visible after a complete scan").not.toBeNull();136137    pwInput!.value = "  hunter2  ";138    pwInput!.dispatchEvent(new Event("input", { bubbles: true }));139    await flushView();140141    const restoreBtn = Array.from(view.container.querySelectorAll("button")).find((b) =>142      b.textContent?.includes("restore vault"),143    )!;144    restoreBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));145    // reconstructAndVerify runs real WASM; wait until the dispatch lands so this146    // test's async fully drains before the next test resets the mock.147    await waitFor(restoreFromCodexDispatched);148149    const restoreCall = sendMessageMock.mock.calls.find(150      ([m]) => (m as { type?: string }).type === "RESTORE_FROM_CODEX",151    );152    const payload = (restoreCall![0] as { payload: { masterPassphrase: string } }).payload;153    expect(payload.masterPassphrase).toBe("  hunter2  ");154  });155156  it("PB-26-607: never renders a raw background error string", async () => {157    const RAW = "TypeError: argon2id derive failed at unlockVault (vault.ts:1712)";158    sendMessageMock.mockImplementation(async (msg: unknown) => {159      if ((msg as { type?: string }).type === "RESTORE_FROM_CODEX") {160        return { ok: false, error: RAW };161      }162      return { ok: true, data: { entryCount: 7, verifyCode: VERIFY_CODE } };163    });164165    const view = await mountAndScan();166    const pwInput = view.container.querySelector<HTMLInputElement>('input[type="password"]')!;167    pwInput.value = "hunter2";168    pwInput.dispatchEvent(new Event("input", { bubbles: true }));169    await flushView();170171    const restoreBtn = Array.from(view.container.querySelectorAll("button")).find((b) =>172      b.textContent?.includes("restore vault"),173    )!;174    restoreBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));175    await waitFor(() => view.container.textContent?.includes("restoration failed") === true);176177    expect(view.container.textContent).toContain("restoration failed. please try again.");178    expect(view.container.textContent).not.toContain(RAW);179    expect(view.container.textContent).not.toContain("vault.ts");180  });181182  it("blocks an all-whitespace passphrase without dispatching RESTORE_FROM_CODEX", async () => {183    const view = await mountAndScan();184185    const pwInput = view.container.querySelector<HTMLInputElement>('input[type="password"]')!;186    pwInput.value = "   ";187    pwInput.dispatchEvent(new Event("input", { bubbles: true }));188    await flushView();189190    const restoreBtn = Array.from(view.container.querySelectorAll("button")).find((b) =>191      b.textContent?.includes("restore vault"),192    )!;193    restoreBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));194    // The whitespace guard rejects synchronously (no await), but flush a few195    // times anyway to prove no dispatch is queued.196    await flushView();197    await flushView();198199    expect(restoreFromCodexDispatched()).toBe(false);200  });201});