1import { beforeEach, describe, expect, it, vi } from "vitest";2import {3 CHUNK_SIZE,4 CodexError,5 PARITY_SHARDS,6 STORAGE_KEY_VERIFY_CODE,7 type CodexDeps,8} from "../../shared/codex-types";9import type { VaultEntry } from "../../shared/types";10import {11 base64UrlToBytes,12 buildChunkEnvelope,13 bytesToBase64Url,14 sha256Hex,15 verifyChunkIntegrity,16} from "./helpers";17import { decodeWithErasure, encodeWithErasure } from "./erasure";18import { generateCodex } from "./generate";19202122function installChromeStorageMock(): { storage: Record<string, unknown> } {23 const storage: Record<string, unknown> = {};24 const chromeMock = {25 storage: {26 local: {27 get: vi.fn(async (key?: string | string[]) => {28 if (!key) return { ...storage };29 if (typeof key === "string") return { [key]: storage[key] };30 return key.reduce<Record<string, unknown>>((acc, k) => {31 acc[k] = storage[k];32 return acc;33 }, {});34 }),35 set: vi.fn(async (entries: Record<string, unknown>) => {36 Object.assign(storage, entries);37 }),38 remove: vi.fn(async (k: string | string[]) => {39 const keys = Array.isArray(k) ? k : [k];40 for (const key of keys) delete storage[key];41 }),42 },43 },44 45 46 47 48 runtime: {},49 };50 51 globalThis.chrome = chromeMock;52 return { storage };53}54555657function makeDeps(overrides: Partial<CodexDeps> = {}): CodexDeps {58 59 60 61 62 63 64 const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";65 66 67 let seed = 0x1234abcd;68 const slotALen = 8192;69 const chars: string[] = new Array(slotALen);70 for (let i = 0; i < slotALen; i++) {71 seed = (seed * 1103515245 + 12345) >>> 0;72 chars[i] = alphabet[(seed >>> 16) & 0x3f]!;73 }74 const slotA = "ENCRYPTED::" + chars.join("");75 const slotB: string | null = null;76 const entries: VaultEntry[] = [77 { id: "1", title: "GitHub", username: "alice", password: "secret", createdAt: 1, modifiedAt: 2 },78 { id: "2", title: "Email", username: "bob", password: "hunter2", createdAt: 1, modifiedAt: 2 },79 { id: "3", title: "Bank", username: "carol", password: "money", createdAt: 1, modifiedAt: 2 },80 ];8182 return {83 getSlotA: async () => slotA,84 getSlotB: async () => slotB,85 getLastModified: async () => "1700000000000",86 getAllEntries: async () => entries,87 getVerifyCode: async () => "PX-TEST-CODE",88 isVaultUnlocked: () => true,89 ...overrides,90 };91}92939495describe("helpers", () => {96 beforeEach(() => {97 installChromeStorageMock();98 });99100 it("base64url round-trips arbitrary binary", () => {101 const original = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255, 0x3f, 0x2b, 0x2f]);102 const encoded = bytesToBase64Url(original);103 expect(encoded).not.toMatch(/[+/=]/);104 const decoded = base64UrlToBytes(encoded);105 expect(Array.from(decoded)).toEqual(Array.from(original));106 });107108 it("sha256Hex returns uppercase 64-char hex for known input", async () => {109 const empty = new Uint8Array();110 const hex = await sha256Hex(empty);111 expect(hex).toBe("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855");112 });113114 it("buildChunkEnvelope + verifyChunkIntegrity agree", async () => {115 const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);116 const env = await buildChunkEnvelope(chunk, 3, 7, false, "PX-AAAA-BBBB");117 expect(env.v).toBe(2);118 expect(env.i).toBe(3);119 expect(env.t).toBe(7);120 expect(env.p).toBe(false);121 expect(env.c).toBe("PX-AAAA-BBBB");122 expect(env.h).toHaveLength(8);123 expect(await verifyChunkIntegrity(env)).toBe(true);124 });125126 it("verifyChunkIntegrity rejects a corrupted envelope", async () => {127 const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);128 const env = await buildChunkEnvelope(chunk, 0, 4, false, "PX-AAAA-BBBB");129 130 const tampered = { ...env, d: bytesToBase64Url(new Uint8Array(CHUNK_SIZE).fill(0x43)) };131 expect(await verifyChunkIntegrity(tampered)).toBe(false);132 });133});134135describe("erasure", () => {136 beforeEach(() => {137 installChromeStorageMock();138 });139140 it("round-trips with the full chunk set", async () => {141 142 const original = new Uint8Array(CHUNK_SIZE * 3 + 17);143 for (let i = 0; i < original.length; i++) original[i] = (i * 31) & 0xff;144145 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);146 expect(dataShards).toBe(4);147 expect(parityChunks.length).toBe(PARITY_SHARDS);148 expect(totalChunks).toBe(dataShards + PARITY_SHARDS);149150 const present = new Map<number, Uint8Array>();151 dataChunks.forEach((c, i) => present.set(i, c));152 parityChunks.forEach((c, i) => present.set(dataShards + i, c));153154 const recovered = await decodeWithErasure(present, dataShards, totalChunks);155 for (let i = 0; i < original.length; i++) {156 expect(recovered[i]).toBe(original[i]);157 }158 });159160 it("reconstructs after losing two random data chunks", async () => {161 const original = new Uint8Array(CHUNK_SIZE * 4);162 for (let i = 0; i < original.length; i++) original[i] = (i * 7 + 5) & 0xff;163164 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);165 const present = new Map<number, Uint8Array>();166 dataChunks.forEach((c, i) => present.set(i, c));167 parityChunks.forEach((c, i) => present.set(dataShards + i, c));168169 170 present.delete(0);171 present.delete(2);172173 const recovered = await decodeWithErasure(present, dataShards, totalChunks);174 for (let i = 0; i < original.length; i++) {175 expect(recovered[i]).toBe(original[i]);176 }177 });178179 it("throws INSUFFICIENT_CHUNKS when too many are missing", async () => {180 const original = new Uint8Array(CHUNK_SIZE * 4);181 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);182 const present = new Map<number, Uint8Array>();183 dataChunks.forEach((c, i) => present.set(i, c));184 parityChunks.forEach((c, i) => present.set(dataShards + i, c));185186 187 present.delete(0);188 present.delete(1);189 present.delete(2);190191 await expect(decodeWithErasure(present, dataShards, totalChunks)).rejects.toMatchObject({192 name: "CodexError",193 code: "INSUFFICIENT_CHUNKS",194 });195 });196});197198describe("generateCodex", () => {199 beforeEach(() => {200 installChromeStorageMock();201 });202203 it("produces a non-trivial PDF and a coherent manifest", async () => {204 const deps = makeDeps();205 const { pdfBytes, manifest } = await generateCodex(deps);206207 expect(pdfBytes.byteLength).toBeGreaterThan(1000);208 209 expect(pdfBytes[0]).toBe(0x25); 210 expect(pdfBytes[1]).toBe(0x50); 211 expect(pdfBytes[2]).toBe(0x44); 212 expect(pdfBytes[3]).toBe(0x46); 213214 expect(manifest.verifyCode).toBe("PX-TEST-CODE");215 expect(manifest.entryCount).toBe(3);216 expect(manifest.parityChunks).toBe(PARITY_SHARDS);217 expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);218 expect(manifest.pageCount).toBeGreaterThanOrEqual(1);219 expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{16}$/);220 }, 60_000);221222 it("rejects generation when the vault is locked", async () => {223 const deps = makeDeps({ isVaultUnlocked: () => false });224 await expect(generateCodex(deps)).rejects.toMatchObject({225 name: "CodexError",226 code: "VAULT_LOCKED",227 });228 });229230 it("reports progress at named milestones", async () => {231 const deps = makeDeps();232 const steps: string[] = [];233 await generateCodex(deps, (step) => {234 if (!steps.includes(step)) steps.push(step);235 });236 expect(steps).toEqual(["compress", "erasure", "aztec", "verify", "pdf"]);237 }, 60_000);238239 it("persists the storage_key_verify_code constant correctly", () => {240 241 242 expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");243 expect(CodexError).toBeTypeOf("function");244 });245});