generate.test.ts

12.2 KB · raw

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";1920// ── chrome.storage shim ─────────────────────────────────────────────────────2122function 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    // Deliberately omit `runtime.getURL` so rs-loader.ts falls through to the45    // Node WASM-from-disk path. With `chrome.runtime.getURL` defined the46    // loader treats us as an extension popup and tries to `fetch()` the WASM,47    // which fails in vitest's node environment.48    runtime: {},49  };50  // @ts-expect-error — test-only injection of the chrome global51  globalThis.chrome = chromeMock;52  return { storage };53}5455// ── Mock CodexDeps factory ──────────────────────────────────────────────────5657function makeDeps(overrides: Partial<CodexDeps> = {}): CodexDeps {58  // A realistic-sized encrypted-looking blob, encoded as deterministic-but-59  // high-entropy base64-ish bytes. The pipeline compresses the JSON wrapper,60  // so for the reconstruction simulation to exercise multiple data shards we61  // need post-compression size >= ~2KB. Real encrypted vault data is dense62  // and incompressible — we model that with a pseudo-random byte stream63  // expressed in a base64 alphabet so it survives JSON stringification.64  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";65  // Linear congruential generator — deterministic per test run, but the66  // output histogram is flat enough that deflate cannot compress it well.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}9293// ── Tests ───────────────────────────────────────────────────────────────────9495describe("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); // AUD-004: 128-bit hash prefix123    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    // Corrupt the data payload after-the-fact — the recorded hash no longer matches.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    // Three full data shards plus tail142    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    // Drop two distinct data shards172    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    // Drop three shards — exceeds parity budget189    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    // PDF magic header211    expect(pdfBytes[0]).toBe(0x25); // '%'212    expect(pdfBytes[1]).toBe(0x50); // 'P'213    expect(pdfBytes[2]).toBe(0x44); // 'D'214    expect(pdfBytes[3]).toBe(0x46); // 'F'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}$/); // AUD-004222  }, 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    // Smoke check the constant import — catches a rename / re-export regression243    // before tests that depend on chrome.storage start failing in confusing ways.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); // 56 * 0.05 = 2.8 → ceil = 3263    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    // Drop exactly parityShards codes — the maximum survivable loss.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});