1import { beforeEach, describe, expect, it, vi } from "vitest";2import {3 CHUNK_SIZE,4 CodexError,5 computeParityShards,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(32); 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, parityShards, totalChunks } =146 await encodeWithErasure(original);147 expect(dataShards).toBe(4);148 expect(parityShards).toBe(computeParityShards(dataShards));149 expect(parityChunks.length).toBe(parityShards);150 expect(totalChunks).toBe(dataShards + parityShards);151152 const present = new Map<number, Uint8Array>();153 dataChunks.forEach((c, i) => present.set(i, c));154 parityChunks.forEach((c, i) => present.set(dataShards + i, c));155156 const recovered = await decodeWithErasure(present, dataShards, totalChunks);157 for (let i = 0; i < original.length; i++) {158 expect(recovered[i]).toBe(original[i]);159 }160 });161162 it("reconstructs after losing two random data chunks", async () => {163 const original = new Uint8Array(CHUNK_SIZE * 4);164 for (let i = 0; i < original.length; i++) original[i] = (i * 7 + 5) & 0xff;165166 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);167 const present = new Map<number, Uint8Array>();168 dataChunks.forEach((c, i) => present.set(i, c));169 parityChunks.forEach((c, i) => present.set(dataShards + i, c));170171 172 present.delete(0);173 present.delete(2);174175 const recovered = await decodeWithErasure(present, dataShards, totalChunks);176 for (let i = 0; i < original.length; i++) {177 expect(recovered[i]).toBe(original[i]);178 }179 });180181 it("throws INSUFFICIENT_CHUNKS when too many are missing", async () => {182 const original = new Uint8Array(CHUNK_SIZE * 4);183 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);184 const present = new Map<number, Uint8Array>();185 dataChunks.forEach((c, i) => present.set(i, c));186 parityChunks.forEach((c, i) => present.set(dataShards + i, c));187188 189 present.delete(0);190 present.delete(1);191 present.delete(2);192193 await expect(decodeWithErasure(present, dataShards, totalChunks)).rejects.toMatchObject({194 name: "CodexError",195 code: "INSUFFICIENT_CHUNKS",196 });197 });198});199200describe("generateCodex", () => {201 beforeEach(() => {202 installChromeStorageMock();203 });204205 it("produces a non-trivial PDF and a coherent manifest", async () => {206 const deps = makeDeps();207 const { pdfBytes, manifest } = await generateCodex(deps);208209 expect(pdfBytes.byteLength).toBeGreaterThan(1000);210 211 expect(pdfBytes[0]).toBe(0x25); 212 expect(pdfBytes[1]).toBe(0x50); 213 expect(pdfBytes[2]).toBe(0x44); 214 expect(pdfBytes[3]).toBe(0x46); 215216 expect(manifest.verifyCode).toBe("PX-TEST-CODE");217 expect(manifest.entryCount).toBe(3);218 expect(manifest.parityChunks).toBe(computeParityShards(manifest.dataChunks));219 expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);220 expect(manifest.pageCount).toBeGreaterThanOrEqual(1);221 expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{32}$/); 222 }, 60_000);223224 it("rejects generation when the vault is locked", async () => {225 const deps = makeDeps({ isVaultUnlocked: () => false });226 await expect(generateCodex(deps)).rejects.toMatchObject({227 name: "CodexError",228 code: "VAULT_LOCKED",229 });230 });231232 it("reports progress at named milestones", async () => {233 const deps = makeDeps();234 const steps: string[] = [];235 await generateCodex(deps, (step) => {236 if (!steps.includes(step)) steps.push(step);237 });238 expect(steps).toEqual(["compress", "erasure", "aztec", "verify", "pdf"]);239 }, 60_000);240241 it("persists the storage_key_verify_code constant correctly", () => {242 243 244 expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");245 expect(CodexError).toBeTypeOf("function");246 });247});248249describe("parity scaling", () => {250 beforeEach(() => {251 installChromeStorageMock();252 });253254 it("computeParityShards returns minimum 2 for very small vaults", () => {255 expect(computeParityShards(1)).toBe(2);256 expect(computeParityShards(10)).toBe(2);257 expect(computeParityShards(13)).toBe(2);258 expect(computeParityShards(40)).toBe(2);259 });260261 it("computeParityShards scales at 5% for medium vaults", () => {262 expect(computeParityShards(56)).toBe(3); 263 expect(computeParityShards(100)).toBe(5);264 expect(computeParityShards(213)).toBe(11);265 });266267 it("computeParityShards caps at 20 for large vaults", () => {268 expect(computeParityShards(600)).toBe(20);269 expect(computeParityShards(660)).toBe(20);270 expect(computeParityShards(1000)).toBe(20);271 });272273 it("encodeWithErasure returns scaled parityShards", async () => {274 const data = new Uint8Array(50_000);275 for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;276 const { dataShards, parityShards } = await encodeWithErasure(data);277 expect(parityShards).toBe(computeParityShards(dataShards));278 });279280 it("large vault: survives loss of up to parityShards codes", async () => {281 const data = new Uint8Array(200_000);282 for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;283284 const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =285 await encodeWithErasure(data);286287 const allChunks = new Map<number, Uint8Array>(288 [...dataChunks, ...parityChunks].map((c, i) => [i, c]),289 );290 291 for (let i = 0; i < parityShards; i++) allChunks.delete(i);292293 const reconstructed = await decodeWithErasure(294 allChunks,295 dataShards,296 totalChunks,297 );298 for (let i = 0; i < data.length; i++) {299 expect(reconstructed[i]).toBe(data[i]);300 }301 }, 60_000);302303 it("large vault: fails gracefully when loss exceeds parityShards", async () => {304 const data = new Uint8Array(200_000);305 for (let i = 0; i < data.length; i++) data[i] = (i * 41) & 0xff;306 const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =307 await encodeWithErasure(data);308309 const allChunks = new Map<number, Uint8Array>(310 [...dataChunks, ...parityChunks].map((c, i) => [i, c]),311 );312 for (let i = 0; i < parityShards + 1; i++) allChunks.delete(i);313314 await expect(315 decodeWithErasure(allChunks, dataShards, totalChunks),316 ).rejects.toThrow(CodexError);317 }, 60_000);318});