generate.test.ts

9.6 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import {3  CHUNK_SIZE,4  CodexError,5  PARITY_SHARDS,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(8);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    // 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, totalChunks } = await encodeWithErasure(original);146    expect(dataShards).toBe(4);147    expect(parityChunks.length).toBe(PARITY_SHARDS);148    expect(totalChunks).toBe(dataShards + PARITY_SHARDS);149150    const present = new Map<number, Uint8Array>();151    dataChunks.forEach((c, i) => present.set(i, c));152    parityChunks.forEach((c, i) => present.set(dataShards + i, c));153154    const recovered = await decodeWithErasure(present, dataShards, totalChunks);155    for (let i = 0; i < original.length; i++) {156      expect(recovered[i]).toBe(original[i]);157    }158  });159160  it("reconstructs after losing two random data chunks", async () => {161    const original = new Uint8Array(CHUNK_SIZE * 4);162    for (let i = 0; i < original.length; i++) original[i] = (i * 7 + 5) & 0xff;163164    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);165    const present = new Map<number, Uint8Array>();166    dataChunks.forEach((c, i) => present.set(i, c));167    parityChunks.forEach((c, i) => present.set(dataShards + i, c));168169    // Drop two distinct data shards170    present.delete(0);171    present.delete(2);172173    const recovered = await decodeWithErasure(present, dataShards, totalChunks);174    for (let i = 0; i < original.length; i++) {175      expect(recovered[i]).toBe(original[i]);176    }177  });178179  it("throws INSUFFICIENT_CHUNKS when too many are missing", async () => {180    const original = new Uint8Array(CHUNK_SIZE * 4);181    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);182    const present = new Map<number, Uint8Array>();183    dataChunks.forEach((c, i) => present.set(i, c));184    parityChunks.forEach((c, i) => present.set(dataShards + i, c));185186    // Drop three shards — exceeds parity budget187    present.delete(0);188    present.delete(1);189    present.delete(2);190191    await expect(decodeWithErasure(present, dataShards, totalChunks)).rejects.toMatchObject({192      name: "CodexError",193      code: "INSUFFICIENT_CHUNKS",194    });195  });196});197198describe("generateCodex", () => {199  beforeEach(() => {200    installChromeStorageMock();201  });202203  it("produces a non-trivial PDF and a coherent manifest", async () => {204    const deps = makeDeps();205    const { pdfBytes, manifest } = await generateCodex(deps);206207    expect(pdfBytes.byteLength).toBeGreaterThan(1000);208    // PDF magic header209    expect(pdfBytes[0]).toBe(0x25); // '%'210    expect(pdfBytes[1]).toBe(0x50); // 'P'211    expect(pdfBytes[2]).toBe(0x44); // 'D'212    expect(pdfBytes[3]).toBe(0x46); // 'F'213214    expect(manifest.verifyCode).toBe("PX-TEST-CODE");215    expect(manifest.entryCount).toBe(3);216    expect(manifest.parityChunks).toBe(PARITY_SHARDS);217    expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);218    expect(manifest.pageCount).toBeGreaterThanOrEqual(1);219    expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{16}$/);220  }, 60_000);221222  it("rejects generation when the vault is locked", async () => {223    const deps = makeDeps({ isVaultUnlocked: () => false });224    await expect(generateCodex(deps)).rejects.toMatchObject({225      name: "CodexError",226      code: "VAULT_LOCKED",227    });228  });229230  it("reports progress at named milestones", async () => {231    const deps = makeDeps();232    const steps: string[] = [];233    await generateCodex(deps, (step) => {234      if (!steps.includes(step)) steps.push(step);235    });236    expect(steps).toEqual(["compress", "erasure", "aztec", "verify", "pdf"]);237  }, 60_000);238239  it("persists the storage_key_verify_code constant correctly", () => {240    // Smoke check the constant import — catches a rename / re-export regression241    // before tests that depend on chrome.storage start failing in confusing ways.242    expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");243    expect(CodexError).toBeTypeOf("function");244  });245});