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 getRecoveryWords: async () => null,88 getVerifyCode: async () => "PX-TEST-CODE",89 isVaultUnlocked: () => true,90 ...overrides,91 };92}93949596describe("helpers", () => {97 beforeEach(() => {98 installChromeStorageMock();99 });100101 it("base64url round-trips arbitrary binary", () => {102 const original = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255, 0x3f, 0x2b, 0x2f]);103 const encoded = bytesToBase64Url(original);104 expect(encoded).not.toMatch(/[+/=]/);105 const decoded = base64UrlToBytes(encoded);106 expect(Array.from(decoded)).toEqual(Array.from(original));107 });108109 it("sha256Hex returns uppercase 64-char hex for known input", async () => {110 const empty = new Uint8Array();111 const hex = await sha256Hex(empty);112 expect(hex).toBe("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855");113 });114115 it("buildChunkEnvelope + verifyChunkIntegrity agree", async () => {116 const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);117 const env = await buildChunkEnvelope(chunk, 3, 7, false, "PX-AAAA-BBBB");118 expect(env.v).toBe(2);119 expect(env.i).toBe(3);120 expect(env.t).toBe(7);121 expect(env.p).toBe(false);122 expect(env.c).toBe("PX-AAAA-BBBB");123 expect(env.h).toHaveLength(8);124 expect(await verifyChunkIntegrity(env)).toBe(true);125 });126127 it("verifyChunkIntegrity rejects a corrupted envelope", async () => {128 const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);129 const env = await buildChunkEnvelope(chunk, 0, 4, false, "PX-AAAA-BBBB");130 131 const tampered = { ...env, d: bytesToBase64Url(new Uint8Array(CHUNK_SIZE).fill(0x43)) };132 expect(await verifyChunkIntegrity(tampered)).toBe(false);133 });134});135136describe("erasure", () => {137 beforeEach(() => {138 installChromeStorageMock();139 });140141 it("round-trips with the full chunk set", async () => {142 143 const original = new Uint8Array(CHUNK_SIZE * 3 + 17);144 for (let i = 0; i < original.length; i++) original[i] = (i * 31) & 0xff;145146 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);147 expect(dataShards).toBe(4);148 expect(parityChunks.length).toBe(PARITY_SHARDS);149 expect(totalChunks).toBe(dataShards + PARITY_SHARDS);150151 const present = new Map<number, Uint8Array>();152 dataChunks.forEach((c, i) => present.set(i, c));153 parityChunks.forEach((c, i) => present.set(dataShards + i, c));154155 const recovered = await decodeWithErasure(present, dataShards, totalChunks);156 for (let i = 0; i < original.length; i++) {157 expect(recovered[i]).toBe(original[i]);158 }159 });160161 it("reconstructs after losing two random data chunks", async () => {162 const original = new Uint8Array(CHUNK_SIZE * 4);163 for (let i = 0; i < original.length; i++) original[i] = (i * 7 + 5) & 0xff;164165 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);166 const present = new Map<number, Uint8Array>();167 dataChunks.forEach((c, i) => present.set(i, c));168 parityChunks.forEach((c, i) => present.set(dataShards + i, c));169170 171 present.delete(0);172 present.delete(2);173174 const recovered = await decodeWithErasure(present, dataShards, totalChunks);175 for (let i = 0; i < original.length; i++) {176 expect(recovered[i]).toBe(original[i]);177 }178 });179180 it("throws INSUFFICIENT_CHUNKS when too many are missing", async () => {181 const original = new Uint8Array(CHUNK_SIZE * 4);182 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);183 const present = new Map<number, Uint8Array>();184 dataChunks.forEach((c, i) => present.set(i, c));185 parityChunks.forEach((c, i) => present.set(dataShards + i, c));186187 188 present.delete(0);189 present.delete(1);190 present.delete(2);191192 await expect(decodeWithErasure(present, dataShards, totalChunks)).rejects.toMatchObject({193 name: "CodexError",194 code: "INSUFFICIENT_CHUNKS",195 });196 });197});198199describe("generateCodex", () => {200 beforeEach(() => {201 installChromeStorageMock();202 });203204 it("produces a non-trivial PDF and a coherent manifest", async () => {205 const deps = makeDeps();206 const { pdfBytes, manifest } = await generateCodex(deps, { includeRecoveryWords: false });207208 expect(pdfBytes.byteLength).toBeGreaterThan(1000);209 210 expect(pdfBytes[0]).toBe(0x25); 211 expect(pdfBytes[1]).toBe(0x50); 212 expect(pdfBytes[2]).toBe(0x44); 213 expect(pdfBytes[3]).toBe(0x46); 214215 expect(manifest.verifyCode).toBe("PX-TEST-CODE");216 expect(manifest.entryCount).toBe(3);217 expect(manifest.parityChunks).toBe(PARITY_SHARDS);218 expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);219 expect(manifest.includesRecoveryWords).toBe(false);220 expect(manifest.pageCount).toBeGreaterThanOrEqual(1);221 expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{16}$/);222 }, 60_000);223224 it("rejects generation when the vault is locked", async () => {225 const deps = makeDeps({ isVaultUnlocked: () => false });226 await expect(generateCodex(deps, { includeRecoveryWords: false })).rejects.toMatchObject({227 name: "CodexError",228 code: "VAULT_LOCKED",229 });230 });231232 it("rejects generation when recovery words are missing", async () => {233 const deps = makeDeps({ getRecoveryWords: async () => null });234 await expect(generateCodex(deps, { includeRecoveryWords: true })).rejects.toMatchObject({235 name: "CodexError",236 code: "RECOVERY_WORDS_MISSING",237 });238 });239240 it("rejects generation when recovery words are invalid", async () => {241 const deps = makeDeps({242 getRecoveryWords: async () => [243 "abandon", "ability", "able", "about", "above", "absent",244 "absorb", "abstract", "absurd", "abuse", "access", "notaword",245 ],246 });247 await expect(generateCodex(deps, { includeRecoveryWords: true })).rejects.toMatchObject({248 name: "CodexError",249 code: "RECOVERY_WORDS_INVALID",250 });251 });252253 it("persists the storage_key_verify_code constant correctly", () => {254 255 256 expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");257 expect(CodexError).toBeTypeOf("function");258 });259});