4c7b8f1172f4

test: PCH-01-015 CodexRestoreView passes untrimmed passphrase to RESTORE_FROM_CODEX — integration test drives the real PDF→ChunkBuffer→ready flow + whitespace-block case (v3.9.0)

time
2026-06-10 20:43 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
pch-01-015 test + pch-01-002 blake2s
parent
df9ee6501da9
1 file src/popup/views/CodexRestoreView.test.tsx+160 −0
src/popup/views/CodexRestoreView.test.tsx+160 −0
@@ -0,0 +1,160 @@1+// @vitest-environment jsdom2+3+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";4+import { deflate } from "pako";5+6+import { flushView, renderView } from "./view-test-utils";7+import { buildChunkEnvelope, bytesToBase64Url, sha256Hex } from "../codex/helpers";8+import { encodeWithErasure } from "../codex/erasure";9+import { _resetReedSolomonCacheForTest } from "../codex/rs-loader";10+import type { ChunkEnvelope, CodexPayload } from "../../shared/codex-types";11+12+// 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.15+16+const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();17+18+vi.mock("../../shared/bridge", () => ({19+  sendMessage: (msg: unknown) => sendMessageMock(msg),20+}));21+22+// Camera-only dependency; stub so importing the view never pulls in zxing.23+vi.mock("../codex/scanner", () => ({24+  startScanner: vi.fn(() => ({ stop: vi.fn() })),25+}));26+27+// The view dynamically imports pdf-import; return our pre-built envelopes so a28+// real ChunkBuffer reaches isReady() and reconstructAndVerify succeeds.29+let preparedEnvelopes: ChunkEnvelope[] = [];30+vi.mock("../codex/pdf-import", () => ({31+  importCodexPDF: vi.fn(async () => ({32+    chunksFound: preparedEnvelopes.length,33+    totalExpected: preparedEnvelopes.length,34+    envelopes: preparedEnvelopes,35+    errors: [],36+  })),37+}));38+39+const VERIFY_CODE = "PX-TEST-CODE";40+41+async 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+}64+65+async 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+}75+76+describe("CodexRestoreView (PCH-01-015 untrimmed passphrase)", () => {77+  let dispose: (() => void) | undefined;78+79+  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+  });87+88+  afterEach(() => {89+    dispose?.();90+    dispose = undefined;91+    vi.unstubAllGlobals();92+  });93+94+  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();102+103+    // 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+  }116+117+  it("forwards the untrimmed passphrase to RESTORE_FROM_CODEX", async () => {118+    const view = await mountAndScan();119+120+    const pwInput = view.container.querySelector<HTMLInputElement>('input[type="password"]');121+    expect(pwInput, "passphrase prompt should be visible after a complete scan").not.toBeNull();122+123+    pwInput!.value = "  hunter2  ";124+    pwInput!.dispatchEvent(new Event("input", { bubbles: true }));125+    await flushView();126+127+    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();133+134+    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+  });141+142+  it("blocks an all-whitespace passphrase without dispatching RESTORE_FROM_CODEX", async () => {143+    const view = await mountAndScan();144+145+    const pwInput = view.container.querySelector<HTMLInputElement>('input[type="password"]')!;146+    pwInput.value = "   ";147+    pwInput.dispatchEvent(new Event("input", { bubbles: true }));148+    await flushView();149+150+    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();155+156+    expect(157+      sendMessageMock.mock.calls.some(([m]) => (m as { type?: string }).type === "RESTORE_FROM_CODEX"),158+    ).toBe(false);159+  });160+});

view file