123import { 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";111213141516const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();1718vi.mock("../../shared/bridge", () => ({19 sendMessage: (msg: unknown) => sendMessageMock(msg),20}));212223vi.mock("../codex/scanner", () => ({24 startScanner: vi.fn(() => ({ stop: vi.fn() })),25}));26272829let 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 95 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 118 119 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 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 146 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 195 196 await flushView();197 await flushView();198199 expect(restoreFromCodexDispatched()).toBe(false);200 });201});