CodexRestoreView.test.tsx

5.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, 16)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  async function mountAndScan() {95    const { CodexRestoreView } = await import("./CodexRestoreView");96    const onRestoreSuccess = vi.fn();97    const view = renderView(98      CodexRestoreView({ onBack: () => {}, onRestoreSuccess }) as unknown as Parameters<typeof renderView>[0],99    );100    dispose = view.dispose;101    await flushView();102103    // Drive the PDF import path: set a fake PDF file on the (always-rendered)104    // file input and fire change → handlePDFSelected → mocked importCodexPDF →105    // real ChunkBuffer.add → phase becomes "ready".106    const fileInput = view.container.querySelector<HTMLInputElement>('input[type="file"]')!;107    Object.defineProperty(fileInput, "files", {108      configurable: true,109      value: [{ type: "application/pdf", name: "codex.pdf" }],110    });111    fileInput.dispatchEvent(new Event("change", { bubbles: true }));112    await flushView();113    await flushView();114    return view;115  }116117  it("forwards the untrimmed passphrase to RESTORE_FROM_CODEX", async () => {118    const view = await mountAndScan();119120    const pwInput = view.container.querySelector<HTMLInputElement>('input[type="password"]');121    expect(pwInput, "passphrase prompt should be visible after a complete scan").not.toBeNull();122123    pwInput!.value = "  hunter2  ";124    pwInput!.dispatchEvent(new Event("input", { bubbles: true }));125    await flushView();126127    const restoreBtn = Array.from(view.container.querySelectorAll("button")).find((b) =>128      b.textContent?.includes("restore vault"),129    )!;130    restoreBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));131    await flushView();132    await flushView();133134    const restoreCall = sendMessageMock.mock.calls.find(135      ([m]) => (m as { type?: string }).type === "RESTORE_FROM_CODEX",136    );137    expect(restoreCall, "RESTORE_FROM_CODEX should have been dispatched").toBeTruthy();138    const payload = (restoreCall![0] as { payload: { masterPassphrase: string } }).payload;139    expect(payload.masterPassphrase).toBe("  hunter2  ");140  });141142  it("blocks an all-whitespace passphrase without dispatching RESTORE_FROM_CODEX", async () => {143    const view = await mountAndScan();144145    const pwInput = view.container.querySelector<HTMLInputElement>('input[type="password"]')!;146    pwInput.value = "   ";147    pwInput.dispatchEvent(new Event("input", { bubbles: true }));148    await flushView();149150    const restoreBtn = Array.from(view.container.querySelectorAll("button")).find((b) =>151      b.textContent?.includes("restore vault"),152    )!;153    restoreBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));154    await flushView();155156    expect(157      sendMessageMock.mock.calls.some(([m]) => (m as { type?: string }).type === "RESTORE_FROM_CODEX"),158    ).toBe(false);159  });160});