generate.test.ts

13.0 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 {18  decodeWithErasure,19  encodeIntoStripes,20  encodeWithErasure,21} from "./erasure";22import { generateCodex } from "./generate";2324// ── chrome.storage shim ─────────────────────────────────────────────────────2526function 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    // Deliberately omit `runtime.getURL` so rs-loader.ts falls through to the49    // Node WASM-from-disk path. With `chrome.runtime.getURL` defined the50    // loader treats us as an extension popup and tries to `fetch()` the WASM,51    // which fails in vitest's node environment.52    runtime: {},53  };54  // @ts-expect-error — test-only injection of the chrome global55  globalThis.chrome = chromeMock;56  return { storage };57}5859// ── Mock CodexDeps factory ──────────────────────────────────────────────────6061function makeDeps(overrides: Partial<CodexDeps> = {}): CodexDeps {62  // A realistic-sized encrypted-looking blob, encoded as deterministic-but-63  // high-entropy base64-ish bytes. The pipeline compresses the JSON wrapper,64  // so for the reconstruction simulation to exercise multiple data shards we65  // need post-compression size >= ~2KB. Real encrypted vault data is dense66  // and incompressible — we model that with a pseudo-random byte stream67  // expressed in a base64 alphabet so it survives JSON stringification.68  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";69  // Linear congruential generator — deterministic per test run, but the70  // output histogram is flat enough that deflate cannot compress it well.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}9697// ── Tests ───────────────────────────────────────────────────────────────────9899describe("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); // AUD-004: 128-bit hash prefix127    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    // Corrupt the data payload after-the-fact — the recorded hash no longer matches.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    // Three full data shards plus tail146    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    // Drop two distinct data shards176    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    // Drop three shards — exceeds parity budget193    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    // PDF magic header215    expect(pdfBytes[0]).toBe(0x25); // '%'216    expect(pdfBytes[1]).toBe(0x50); // 'P'217    expect(pdfBytes[2]).toBe(0x44); // 'D'218    expect(pdfBytes[3]).toBe(0x46); // 'F'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}$/); // AUD-004226  }, 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    // Smoke check the constant import — catches a rename / re-export regression247    // before tests that depend on chrome.storage start failing in confusing ways.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); // 56 * 0.05 = 2.8 → ceil = 3267    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    // 200 KB / 800-byte chunks = 250 data shards → split into 2 stripes of286    // 125 each (well under the GF(2⁸) 256-shard cap). Each stripe gets its287    // own parity budget; we drop exactly parityShards from every stripe.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      // Drop exactly parityShards codes from this stripe — maximum survivable.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});