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 {18 decodeWithErasure,19 encodeIntoStripes,20 encodeWithErasure,21} from "./erasure";22import { generateCodex } from "./generate";23242526function installChromeStorageMock(): { storage: Record<string, unknown> } {27 const storage: Record<string, unknown> = {};28 const chromeMock = {29 storage: {30 local: {31 get: vi.fn(async (key?: string | string[]) => {32 if (!key) return { ...storage };33 if (typeof key === "string") return { [key]: storage[key] };34 return key.reduce<Record<string, unknown>>((acc, k) => {35 acc[k] = storage[k];36 return acc;37 }, {});38 }),39 set: vi.fn(async (entries: Record<string, unknown>) => {40 Object.assign(storage, entries);41 }),42 remove: vi.fn(async (k: string | string[]) => {43 const keys = Array.isArray(k) ? k : [k];44 for (const key of keys) delete storage[key];45 }),46 },47 },48 49 50 51 52 runtime: {},53 };54 55 globalThis.chrome = chromeMock;56 return { storage };57}58596061function makeDeps(overrides: Partial<CodexDeps> = {}): CodexDeps {62 63 64 65 66 67 68 const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";69 70 71 let seed = 0x1234abcd;72 const slotALen = 8192;73 const chars: string[] = new Array(slotALen);74 for (let i = 0; i < slotALen; i++) {75 seed = (seed * 1103515245 + 12345) >>> 0;76 chars[i] = alphabet[(seed >>> 16) & 0x3f]!;77 }78 const slotA = "ENCRYPTED::" + chars.join("");79 const slotB: string | null = null;80 const entries: VaultEntry[] = [81 { id: "1", title: "GitHub", username: "alice", password: "secret", createdAt: 1, modifiedAt: 2 },82 { id: "2", title: "Email", username: "bob", password: "hunter2", createdAt: 1, modifiedAt: 2 },83 { id: "3", title: "Bank", username: "carol", password: "money", createdAt: 1, modifiedAt: 2 },84 ];8586 return {87 getSlotA: async () => slotA,88 getSlotB: async () => slotB,89 getLastModified: async () => "1700000000000",90 getAllEntries: async () => entries,91 getVerifyCode: async () => "PX-TEST-CODE",92 isVaultUnlocked: () => true,93 ...overrides,94 };95}96979899describe("helpers", () => {100 beforeEach(() => {101 installChromeStorageMock();102 });103104 it("base64url round-trips arbitrary binary", () => {105 const original = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255, 0x3f, 0x2b, 0x2f]);106 const encoded = bytesToBase64Url(original);107 expect(encoded).not.toMatch(/[+/=]/);108 const decoded = base64UrlToBytes(encoded);109 expect(Array.from(decoded)).toEqual(Array.from(original));110 });111112 it("sha256Hex returns uppercase 64-char hex for known input", async () => {113 const empty = new Uint8Array();114 const hex = await sha256Hex(empty);115 expect(hex).toBe("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855");116 });117118 it("buildChunkEnvelope + verifyChunkIntegrity agree", async () => {119 const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);120 const env = await buildChunkEnvelope(chunk, 3, 7, false, "PX-AAAA-BBBB");121 expect(env.v).toBe(3);122 expect(env.i).toBe(3);123 expect(env.t).toBe(7);124 expect(env.p).toBe(false);125 expect(env.c).toBe("PX-AAAA-BBBB");126 expect(env.h).toHaveLength(32); 127 expect(await verifyChunkIntegrity(env)).toBe(true);128 });129130 it("verifyChunkIntegrity rejects a corrupted envelope", async () => {131 const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);132 const env = await buildChunkEnvelope(chunk, 0, 4, false, "PX-AAAA-BBBB");133 134 const tampered = { ...env, d: bytesToBase64Url(new Uint8Array(CHUNK_SIZE).fill(0x43)) };135 expect(await verifyChunkIntegrity(tampered)).toBe(false);136 });137});138139describe("erasure", () => {140 beforeEach(() => {141 installChromeStorageMock();142 });143144 it("round-trips with the full chunk set", async () => {145 146 const original = new Uint8Array(CHUNK_SIZE * 3 + 17);147 for (let i = 0; i < original.length; i++) original[i] = (i * 31) & 0xff;148149 const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =150 await encodeWithErasure(original);151 expect(dataShards).toBe(4);152 expect(parityShards).toBe(computeParityShards(dataShards));153 expect(parityChunks.length).toBe(parityShards);154 expect(totalChunks).toBe(dataShards + parityShards);155156 const present = new Map<number, Uint8Array>();157 dataChunks.forEach((c, i) => present.set(i, c));158 parityChunks.forEach((c, i) => present.set(dataShards + i, c));159160 const recovered = await decodeWithErasure(present, dataShards, totalChunks);161 for (let i = 0; i < original.length; i++) {162 expect(recovered[i]).toBe(original[i]);163 }164 });165166 it("reconstructs after losing two random data chunks", async () => {167 const original = new Uint8Array(CHUNK_SIZE * 4);168 for (let i = 0; i < original.length; i++) original[i] = (i * 7 + 5) & 0xff;169170 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);171 const present = new Map<number, Uint8Array>();172 dataChunks.forEach((c, i) => present.set(i, c));173 parityChunks.forEach((c, i) => present.set(dataShards + i, c));174175 176 present.delete(0);177 present.delete(2);178179 const recovered = await decodeWithErasure(present, dataShards, totalChunks);180 for (let i = 0; i < original.length; i++) {181 expect(recovered[i]).toBe(original[i]);182 }183 });184185 it("throws INSUFFICIENT_CHUNKS when too many are missing", async () => {186 const original = new Uint8Array(CHUNK_SIZE * 4);187 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);188 const present = new Map<number, Uint8Array>();189 dataChunks.forEach((c, i) => present.set(i, c));190 parityChunks.forEach((c, i) => present.set(dataShards + i, c));191192 193 present.delete(0);194 present.delete(1);195 present.delete(2);196197 await expect(decodeWithErasure(present, dataShards, totalChunks)).rejects.toMatchObject({198 name: "CodexError",199 code: "INSUFFICIENT_CHUNKS",200 });201 });202});203204describe("generateCodex", () => {205 beforeEach(() => {206 installChromeStorageMock();207 });208209 it("produces a non-trivial PDF and a coherent manifest", async () => {210 const deps = makeDeps();211 const { pdfBytes, manifest } = await generateCodex(deps);212213 expect(pdfBytes.byteLength).toBeGreaterThan(1000);214 215 expect(pdfBytes[0]).toBe(0x25); 216 expect(pdfBytes[1]).toBe(0x50); 217 expect(pdfBytes[2]).toBe(0x44); 218 expect(pdfBytes[3]).toBe(0x46); 219220 expect(manifest.verifyCode).toBe("PX-TEST-CODE");221 expect(manifest.entryCount).toBe(3);222 expect(manifest.parityChunks).toBe(computeParityShards(manifest.dataChunks));223 expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);224 expect(manifest.pageCount).toBeGreaterThanOrEqual(1);225 expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{32}$/); 226 }, 60_000);227228 it("rejects generation when the vault is locked", async () => {229 const deps = makeDeps({ isVaultUnlocked: () => false });230 await expect(generateCodex(deps)).rejects.toMatchObject({231 name: "CodexError",232 code: "VAULT_LOCKED",233 });234 });235236 it("reports progress at named milestones", async () => {237 const deps = makeDeps();238 const steps: string[] = [];239 await generateCodex(deps, (step) => {240 if (!steps.includes(step)) steps.push(step);241 });242 expect(steps).toEqual(["compress", "erasure", "aztec", "verify", "pdf"]);243 }, 60_000);244245 it("persists the storage_key_verify_code constant correctly", () => {246 247 248 expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");249 expect(CodexError).toBeTypeOf("function");250 });251});252253describe("parity scaling", () => {254 beforeEach(() => {255 installChromeStorageMock();256 });257258 it("computeParityShards returns minimum 2 for very small vaults", () => {259 expect(computeParityShards(1)).toBe(2);260 expect(computeParityShards(10)).toBe(2);261 expect(computeParityShards(13)).toBe(2);262 expect(computeParityShards(40)).toBe(2);263 });264265 it("computeParityShards scales at 5% for medium vaults", () => {266 expect(computeParityShards(56)).toBe(3); 267 expect(computeParityShards(100)).toBe(5);268 expect(computeParityShards(213)).toBe(11);269 });270271 it("computeParityShards caps at 20 for large vaults", () => {272 expect(computeParityShards(600)).toBe(20);273 expect(computeParityShards(660)).toBe(20);274 expect(computeParityShards(1000)).toBe(20);275 });276277 it("encodeWithErasure returns scaled parityShards", async () => {278 const data = new Uint8Array(50_000);279 for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;280 const { dataShards, parityShards } = await encodeWithErasure(data);281 expect(parityShards).toBe(computeParityShards(dataShards));282 });283284 it("large vault: each stripe survives loss of up to its parityShards codes", async () => {285 286 287 288 const data = new Uint8Array(200_000);289 for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;290291 const { stripes } = await encodeIntoStripes(data);292 expect(stripes.length).toBeGreaterThan(1);293294 let byteCursor = 0;295 for (const stripe of stripes) {296 const present = new Map<number, Uint8Array>();297 stripe.dataChunks.forEach((c, i) => present.set(i, c));298 stripe.parityChunks.forEach((c, i) =>299 present.set(stripe.dataShards + i, c),300 );301 302 for (let i = 0; i < stripe.parityShards; i++) present.delete(i);303304 const recovered = await decodeWithErasure(305 present,306 stripe.dataShards,307 stripe.totalChunks,308 );309 const stripeRealBytes = Math.min(310 stripe.dataShards * CHUNK_SIZE,311 Math.max(0, data.length - byteCursor),312 );313 for (let i = 0; i < stripeRealBytes; i++) {314 expect(recovered[i]).toBe(data[byteCursor + i]);315 }316 byteCursor += stripe.dataShards * CHUNK_SIZE;317 }318 }, 60_000);319320 it("large vault: fails gracefully when stripe loss exceeds parityShards", async () => {321 const data = new Uint8Array(200_000);322 for (let i = 0; i < data.length; i++) data[i] = (i * 41) & 0xff;323 const { stripes } = await encodeIntoStripes(data);324 expect(stripes.length).toBeGreaterThan(1);325 const stripe = stripes[0]!;326 const present = new Map<number, Uint8Array>();327 stripe.dataChunks.forEach((c, i) => present.set(i, c));328 stripe.parityChunks.forEach((c, i) =>329 present.set(stripe.dataShards + i, c),330 );331 for (let i = 0; i < stripe.parityShards + 1; i++) present.delete(i);332333 await expect(334 decodeWithErasure(present, stripe.dataShards, stripe.totalChunks),335 ).rejects.toThrow(CodexError);336 }, 60_000);337});