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
↓ peach-browser-4c7b8f11.tar.gzatlas at this save
files changed1 file
src/popup/views/CodexRestoreView.test.tsx+160 −0src/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+});