generate.test.ts

17.1 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 ChunkEnvelope,8  type CodexDeps,9} from "../../shared/codex-types";10import type { VaultEntry } from "../../shared/types";11import {12  base64UrlToBytes,13  buildChunkEnvelope,14  bytesToBase64Url,15  sha256Hex,16  verifyChunkIntegrity,17} from "./helpers";18import {19  decodeWithErasure,20  encodeIntoStripes,21  encodeWithErasure,22} from "./erasure";23import { buildAztecMatrix, generateVerifiedAztec, verifyMatrixRoundTrip } from "./aztec";24import { generateCodex } from "./generate";2526// ── chrome.storage shim ─────────────────────────────────────────────────────2728function installChromeStorageMock(): { storage: Record<string, unknown> } {29  const storage: Record<string, unknown> = {};30  const chromeMock = {31    storage: {32      local: {33        get: vi.fn(async (key?: string | string[]) => {34          if (!key) return { ...storage };35          if (typeof key === "string") return { [key]: storage[key] };36          return key.reduce<Record<string, unknown>>((acc, k) => {37            acc[k] = storage[k];38            return acc;39          }, {});40        }),41        set: vi.fn(async (entries: Record<string, unknown>) => {42          Object.assign(storage, entries);43        }),44        remove: vi.fn(async (k: string | string[]) => {45          const keys = Array.isArray(k) ? k : [k];46          for (const key of keys) delete storage[key];47        }),48      },49    },50    // Deliberately omit `runtime.getURL` so rs-loader.ts falls through to the51    // Node WASM-from-disk path. With `chrome.runtime.getURL` defined the52    // loader treats us as an extension popup and tries to `fetch()` the WASM,53    // which fails in vitest's node environment.54    runtime: {},55  };56  // @ts-expect-error — test-only injection of the chrome global57  globalThis.chrome = chromeMock;58  return { storage };59}6061// ── Mock CodexDeps factory ──────────────────────────────────────────────────6263function makeDeps(overrides: Partial<CodexDeps> = {}): CodexDeps {64  // A realistic-sized encrypted-looking blob, encoded as deterministic-but-65  // high-entropy base64-ish bytes. The pipeline compresses the JSON wrapper,66  // so for the reconstruction simulation to exercise multiple data shards we67  // need post-compression size >= ~2KB. Real encrypted vault data is dense68  // and incompressible — we model that with a pseudo-random byte stream69  // expressed in a base64 alphabet so it survives JSON stringification.70  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";71  // Linear congruential generator — deterministic per test run, but the72  // output histogram is flat enough that deflate cannot compress it well.73  let seed = 0x1234abcd;74  const slotALen = 8192;75  const chars: string[] = new Array(slotALen);76  for (let i = 0; i < slotALen; i++) {77    seed = (seed * 1103515245 + 12345) >>> 0;78    chars[i] = alphabet[(seed >>> 16) & 0x3f]!;79  }80  const slotA = "ENCRYPTED::" + chars.join("");81  const slotB: string | null = null;82  const entries: VaultEntry[] = [83    { id: "1", title: "GitHub", username: "alice", password: "secret", createdAt: 1, modifiedAt: 2 },84    { id: "2", title: "Email", username: "bob", password: "hunter2", createdAt: 1, modifiedAt: 2 },85    { id: "3", title: "Bank", username: "carol", password: "money", createdAt: 1, modifiedAt: 2 },86  ];8788  return {89    getSlotA: async () => slotA,90    getSlotB: async () => slotB,91    getLastModified: async () => "1700000000000",92    getAllEntries: async () => entries,93    getVerifyCode: async () => "PX-TEST-CODE",94    isVaultUnlocked: () => true,95    ...overrides,96  };97}9899// ── Tests ───────────────────────────────────────────────────────────────────100101describe("helpers", () => {102  beforeEach(() => {103    installChromeStorageMock();104  });105106  it("base64url round-trips arbitrary binary", () => {107    const original = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255, 0x3f, 0x2b, 0x2f]);108    const encoded = bytesToBase64Url(original);109    expect(encoded).not.toMatch(/[+/=]/);110    const decoded = base64UrlToBytes(encoded);111    expect(Array.from(decoded)).toEqual(Array.from(original));112  });113114  it("sha256Hex returns uppercase 64-char hex for known input", async () => {115    const empty = new Uint8Array();116    const hex = await sha256Hex(empty);117    expect(hex).toBe("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855");118  });119120  it("buildChunkEnvelope + verifyChunkIntegrity agree", async () => {121    const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);122    const env = await buildChunkEnvelope(chunk, 3, 7, false, "PX-AAAA-BBBB");123    expect(env.v).toBe(3);124    expect(env.i).toBe(3);125    expect(env.t).toBe(7);126    expect(env.p).toBe(false);127    expect(env.c).toBe("PX-AAAA-BBBB");128    expect(env.h).toHaveLength(32); // AUD-004: 128-bit hash prefix129    expect(await verifyChunkIntegrity(env)).toBe(true);130  });131132  it("verifyChunkIntegrity rejects a corrupted envelope", async () => {133    const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);134    const env = await buildChunkEnvelope(chunk, 0, 4, false, "PX-AAAA-BBBB");135    // Corrupt the data payload after-the-fact — the recorded hash no longer matches.136    const tampered = { ...env, d: bytesToBase64Url(new Uint8Array(CHUNK_SIZE).fill(0x43)) };137    expect(await verifyChunkIntegrity(tampered)).toBe(false);138  });139});140141describe("erasure", () => {142  beforeEach(() => {143    installChromeStorageMock();144  });145146  it("round-trips with the full chunk set", async () => {147    // Three full data shards plus tail148    const original = new Uint8Array(CHUNK_SIZE * 3 + 17);149    for (let i = 0; i < original.length; i++) original[i] = (i * 31) & 0xff;150151    const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =152      await encodeWithErasure(original);153    expect(dataShards).toBe(4);154    expect(parityShards).toBe(computeParityShards(dataShards));155    expect(parityChunks.length).toBe(parityShards);156    expect(totalChunks).toBe(dataShards + parityShards);157158    const present = new Map<number, Uint8Array>();159    dataChunks.forEach((c, i) => present.set(i, c));160    parityChunks.forEach((c, i) => present.set(dataShards + i, c));161162    const recovered = await decodeWithErasure(present, dataShards, totalChunks);163    for (let i = 0; i < original.length; i++) {164      expect(recovered[i]).toBe(original[i]);165    }166  });167168  it("reconstructs after losing two random data chunks", async () => {169    const original = new Uint8Array(CHUNK_SIZE * 4);170    for (let i = 0; i < original.length; i++) original[i] = (i * 7 + 5) & 0xff;171172    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);173    const present = new Map<number, Uint8Array>();174    dataChunks.forEach((c, i) => present.set(i, c));175    parityChunks.forEach((c, i) => present.set(dataShards + i, c));176177    // Drop two distinct data shards178    present.delete(0);179    present.delete(2);180181    const recovered = await decodeWithErasure(present, dataShards, totalChunks);182    for (let i = 0; i < original.length; i++) {183      expect(recovered[i]).toBe(original[i]);184    }185  });186187  it("throws INSUFFICIENT_CHUNKS when too many are missing", async () => {188    const original = new Uint8Array(CHUNK_SIZE * 4);189    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);190    const present = new Map<number, Uint8Array>();191    dataChunks.forEach((c, i) => present.set(i, c));192    parityChunks.forEach((c, i) => present.set(dataShards + i, c));193194    // Drop three shards — exceeds parity budget195    present.delete(0);196    present.delete(1);197    present.delete(2);198199    await expect(decodeWithErasure(present, dataShards, totalChunks)).rejects.toMatchObject({200      name: "CodexError",201      code: "INSUFFICIENT_CHUNKS",202    });203  });204});205206describe("generateCodex", () => {207  beforeEach(() => {208    installChromeStorageMock();209  });210211  it("produces a non-trivial PDF and a coherent manifest", async () => {212    const deps = makeDeps();213    const { pdfBytes, manifest } = await generateCodex(deps);214215    expect(pdfBytes.byteLength).toBeGreaterThan(1000);216    // PDF magic header217    expect(pdfBytes[0]).toBe(0x25); // '%'218    expect(pdfBytes[1]).toBe(0x50); // 'P'219    expect(pdfBytes[2]).toBe(0x44); // 'D'220    expect(pdfBytes[3]).toBe(0x46); // 'F'221222    expect(manifest.verifyCode).toBe("PX-TEST-CODE");223    expect(manifest.entryCount).toBe(3);224    expect(manifest.parityChunks).toBe(computeParityShards(manifest.dataChunks));225    expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);226    expect(manifest.pageCount).toBeGreaterThanOrEqual(1);227    expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{32}$/); // AUD-004228  }, 60_000);229230  it("rejects generation when the vault is locked", async () => {231    const deps = makeDeps({ isVaultUnlocked: () => false });232    await expect(generateCodex(deps)).rejects.toMatchObject({233      name: "CodexError",234      code: "VAULT_LOCKED",235    });236  });237238  it("reports progress at named milestones", async () => {239    const deps = makeDeps();240    const steps: string[] = [];241    await generateCodex(deps, (step) => {242      if (!steps.includes(step)) steps.push(step);243    });244    expect(steps).toEqual(["compress", "erasure", "aztec", "verify", "pdf"]);245  }, 60_000);246247  it("persists the storage_key_verify_code constant correctly", () => {248    // Smoke check the constant import — catches a rename / re-export regression249    // before tests that depend on chrome.storage start failing in confusing ways.250    expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");251    expect(CodexError).toBeTypeOf("function");252  });253});254255describe("parity scaling", () => {256  beforeEach(() => {257    installChromeStorageMock();258  });259260  it("computeParityShards returns minimum 2 for very small vaults", () => {261    expect(computeParityShards(1)).toBe(2);262    expect(computeParityShards(10)).toBe(2);263    expect(computeParityShards(13)).toBe(2);264  });265266  it("computeParityShards scales at 15% for medium vaults", () => {267    expect(computeParityShards(56)).toBe(9); // 56 * 0.15 = 8.4 → ceil = 9268    expect(computeParityShards(100)).toBe(15);269    expect(computeParityShards(213)).toBe(32);270  });271272  it("computeParityShards caps at 35 for large vaults", () => {273    expect(computeParityShards(600)).toBe(35);274    expect(computeParityShards(660)).toBe(35);275    expect(computeParityShards(1000)).toBe(35);276  });277278  it("encodeWithErasure returns scaled parityShards", async () => {279    const data = new Uint8Array(50_000);280    for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;281    const { dataShards, parityShards } = await encodeWithErasure(data);282    expect(parityShards).toBe(computeParityShards(dataShards));283  });284285  it("large vault: each stripe survives loss of up to its parityShards codes", async () => {286    // 200 KB / 800-byte chunks = 250 data shards → split into 2 stripes of287    // 125 each (well under the GF(2⁸) 256-shard cap). Each stripe gets its288    // own parity budget; we drop exactly parityShards from every stripe.289    const data = new Uint8Array(200_000);290    for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;291292    const { stripes } = await encodeIntoStripes(data);293    expect(stripes.length).toBeGreaterThan(1);294295    let byteCursor = 0;296    for (const stripe of stripes) {297      const present = new Map<number, Uint8Array>();298      stripe.dataChunks.forEach((c, i) => present.set(i, c));299      stripe.parityChunks.forEach((c, i) =>300        present.set(stripe.dataShards + i, c),301      );302      // Drop exactly parityShards codes from this stripe — maximum survivable.303      for (let i = 0; i < stripe.parityShards; i++) present.delete(i);304305      const recovered = await decodeWithErasure(306        present,307        stripe.dataShards,308        stripe.totalChunks,309      );310      const stripeRealBytes = Math.min(311        stripe.dataShards * CHUNK_SIZE,312        Math.max(0, data.length - byteCursor),313      );314      for (let i = 0; i < stripeRealBytes; i++) {315        expect(recovered[i]).toBe(data[byteCursor + i]);316      }317      byteCursor += stripe.dataShards * CHUNK_SIZE;318    }319  }, 60_000);320321  it("large vault: fails gracefully when stripe loss exceeds parityShards", async () => {322    const data = new Uint8Array(200_000);323    for (let i = 0; i < data.length; i++) data[i] = (i * 41) & 0xff;324    const { stripes } = await encodeIntoStripes(data);325    expect(stripes.length).toBeGreaterThan(1);326    const stripe = stripes[0]!;327    const present = new Map<number, Uint8Array>();328    stripe.dataChunks.forEach((c, i) => present.set(i, c));329    stripe.parityChunks.forEach((c, i) =>330      present.set(stripe.dataShards + i, c),331    );332    for (let i = 0; i < stripe.parityShards + 1; i++) present.delete(i);333334    await expect(335      decodeWithErasure(present, stripe.dataShards, stripe.totalChunks),336    ).rejects.toThrow(CodexError);337  }, 60_000);338});339340// ── Aztec round-trip verification ─────────────────────────────────────────────341342describe("aztec round-trip", () => {343  beforeEach(() => {344    installChromeStorageMock();345  });346347  it("buildAztecMatrix + verifyMatrixRoundTrip: 200 varied payloads all pass", () => {348    // Exercise the core encode→decode path for a wide range of envelope-like349    // JSON strings. This is fast (synchronous ZXing in-memory, no canvas, no350    // bwip-js) and covers the payload patterns that previously caused351    // AZTEC_CONTENT_MISMATCH with HybridBinarizer.352    for (let i = 0; i < 200; i++) {353      // Simulate an envelope JSON payload. Use CHUNK_SIZE-sized chunk data with354      // a per-chunk bit pattern so each payload is unique.355      const data = new Uint8Array(CHUNK_SIZE);356      for (let j = 0; j < CHUNK_SIZE; j++) data[j] = ((i * 37 + j * 7) ^ (i << 3)) & 0xff;357358      // Build a short envelope-like JSON string resembling the real envelope format.359      // Base64url encode the chunk data to keep it printable ASCII (matching the360      // real pipeline which uses bytesToBase64Url).361      const b64 = btoa(String.fromCharCode(...data.slice(0, 60))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");362      const payload = JSON.stringify({363        v: 3, i, t: 200, p: i >= 188, c: "PX-TEST-CODE",364        d: b64,365        h: "abcdef1234567890abcdef1234567890",366        s: 0, st: 1,367      });368369      const matrix = buildAztecMatrix(payload);370      const ok = verifyMatrixRoundTrip(matrix, payload);371      if (!ok) {372        throw new Error(`verifyMatrixRoundTrip failed for chunk ${i} (payload len=${payload.length})`);373      }374    }375  });376377  it("generateVerifiedAztec: 20 real envelopes survive round-trip", async () => {378    // End-to-end test of generateVerifiedAztec using real chunk envelopes built379    // by the same helpers used in the live pipeline. Any AZTEC_ROUNDTRIP_FAILED380    // or AZTEC_CONTENT_MISMATCH throw will fail the test immediately.381    const envelopes: ChunkEnvelope[] = [];382    for (let i = 0; i < 20; i++) {383      const data = new Uint8Array(CHUNK_SIZE);384      for (let j = 0; j < CHUNK_SIZE; j++) data[j] = ((i * 53 + j * 11) ^ (i * 19)) & 0xff;385      envelopes.push(await buildChunkEnvelope(data, i, 20, i >= 17, "PX-RTRP-CODE", 0, 1));386    }387388    for (const env of envelopes) {389      const img = await generateVerifiedAztec(env);390      expect(img.pngBytes.length).toBeGreaterThan(0);391    }392  }, 120_000);393394  it("generateCodex: large vault (100 KB) completes without AZTEC errors", async () => {395    // A realistic-sized vault that exercises multi-stripe encoding and produces396    // O(100) Aztec codes. All codes must pass in-memory round-trip verification.397    installChromeStorageMock();398399    // 80 KB of pseudo-random incompressible base64 data → ~100 data shards400    const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";401    let seed = 0xdeadbeef;402    const chars: string[] = new Array(80_000);403    for (let i = 0; i < 80_000; i++) {404      seed = (seed * 1664525 + 1013904223) >>> 0;405      chars[i] = alphabet[(seed >>> 16) & 0x3f]!;406    }407    const slotA = "ENCRYPTED::" + chars.join("");408409    const deps: CodexDeps = {410      getSlotA: async () => slotA,411      getSlotB: async () => null,412      getLastModified: async () => "1700000000000",413      getAllEntries: async () => [414        { id: "1", title: "GitHub", username: "alice", password: "secret", createdAt: 1, modifiedAt: 2 },415      ],416      getVerifyCode: async () => "PX-LARG-TEST",417      isVaultUnlocked: () => true,418    };419420    const steps: string[] = [];421    const { pdfBytes, manifest } = await generateCodex(deps, (step) => {422      if (!steps.includes(step)) steps.push(step);423    });424425    expect(pdfBytes[0]).toBe(0x25); // %PDF426    expect(manifest.totalChunks).toBeGreaterThan(50);427    expect(steps).toEqual(["compress", "erasure", "aztec", "verify", "pdf"]);428  }, 300_000); // 5 min — bwip-js PNG for ~100 chunks dominates in node429});