generate.test.ts

10.2 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    getRecoveryWords: async () => null,88    getVerifyCode: async () => "PX-TEST-CODE",89    isVaultUnlocked: () => true,90    ...overrides,91  };92}9394// ── Tests ───────────────────────────────────────────────────────────────────9596describe("helpers", () => {97  beforeEach(() => {98    installChromeStorageMock();99  });100101  it("base64url round-trips arbitrary binary", () => {102    const original = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255, 0x3f, 0x2b, 0x2f]);103    const encoded = bytesToBase64Url(original);104    expect(encoded).not.toMatch(/[+/=]/);105    const decoded = base64UrlToBytes(encoded);106    expect(Array.from(decoded)).toEqual(Array.from(original));107  });108109  it("sha256Hex returns uppercase 64-char hex for known input", async () => {110    const empty = new Uint8Array();111    const hex = await sha256Hex(empty);112    expect(hex).toBe("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855");113  });114115  it("buildChunkEnvelope + verifyChunkIntegrity agree", async () => {116    const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);117    const env = await buildChunkEnvelope(chunk, 3, 7, false, "PX-AAAA-BBBB");118    expect(env.v).toBe(2);119    expect(env.i).toBe(3);120    expect(env.t).toBe(7);121    expect(env.p).toBe(false);122    expect(env.c).toBe("PX-AAAA-BBBB");123    expect(env.h).toHaveLength(8);124    expect(await verifyChunkIntegrity(env)).toBe(true);125  });126127  it("verifyChunkIntegrity rejects a corrupted envelope", async () => {128    const chunk = new Uint8Array(CHUNK_SIZE).fill(0x42);129    const env = await buildChunkEnvelope(chunk, 0, 4, false, "PX-AAAA-BBBB");130    // Corrupt the data payload after-the-fact — the recorded hash no longer matches.131    const tampered = { ...env, d: bytesToBase64Url(new Uint8Array(CHUNK_SIZE).fill(0x43)) };132    expect(await verifyChunkIntegrity(tampered)).toBe(false);133  });134});135136describe("erasure", () => {137  beforeEach(() => {138    installChromeStorageMock();139  });140141  it("round-trips with the full chunk set", async () => {142    // Three full data shards plus tail143    const original = new Uint8Array(CHUNK_SIZE * 3 + 17);144    for (let i = 0; i < original.length; i++) original[i] = (i * 31) & 0xff;145146    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);147    expect(dataShards).toBe(4);148    expect(parityChunks.length).toBe(PARITY_SHARDS);149    expect(totalChunks).toBe(dataShards + PARITY_SHARDS);150151    const present = new Map<number, Uint8Array>();152    dataChunks.forEach((c, i) => present.set(i, c));153    parityChunks.forEach((c, i) => present.set(dataShards + i, c));154155    const recovered = await decodeWithErasure(present, dataShards, totalChunks);156    for (let i = 0; i < original.length; i++) {157      expect(recovered[i]).toBe(original[i]);158    }159  });160161  it("reconstructs after losing two random data chunks", async () => {162    const original = new Uint8Array(CHUNK_SIZE * 4);163    for (let i = 0; i < original.length; i++) original[i] = (i * 7 + 5) & 0xff;164165    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);166    const present = new Map<number, Uint8Array>();167    dataChunks.forEach((c, i) => present.set(i, c));168    parityChunks.forEach((c, i) => present.set(dataShards + i, c));169170    // Drop two distinct data shards171    present.delete(0);172    present.delete(2);173174    const recovered = await decodeWithErasure(present, dataShards, totalChunks);175    for (let i = 0; i < original.length; i++) {176      expect(recovered[i]).toBe(original[i]);177    }178  });179180  it("throws INSUFFICIENT_CHUNKS when too many are missing", async () => {181    const original = new Uint8Array(CHUNK_SIZE * 4);182    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);183    const present = new Map<number, Uint8Array>();184    dataChunks.forEach((c, i) => present.set(i, c));185    parityChunks.forEach((c, i) => present.set(dataShards + i, c));186187    // Drop three shards — exceeds parity budget188    present.delete(0);189    present.delete(1);190    present.delete(2);191192    await expect(decodeWithErasure(present, dataShards, totalChunks)).rejects.toMatchObject({193      name: "CodexError",194      code: "INSUFFICIENT_CHUNKS",195    });196  });197});198199describe("generateCodex", () => {200  beforeEach(() => {201    installChromeStorageMock();202  });203204  it("produces a non-trivial PDF and a coherent manifest", async () => {205    const deps = makeDeps();206    const { pdfBytes, manifest } = await generateCodex(deps, { includeRecoveryWords: false });207208    expect(pdfBytes.byteLength).toBeGreaterThan(1000);209    // PDF magic header210    expect(pdfBytes[0]).toBe(0x25); // '%'211    expect(pdfBytes[1]).toBe(0x50); // 'P'212    expect(pdfBytes[2]).toBe(0x44); // 'D'213    expect(pdfBytes[3]).toBe(0x46); // 'F'214215    expect(manifest.verifyCode).toBe("PX-TEST-CODE");216    expect(manifest.entryCount).toBe(3);217    expect(manifest.parityChunks).toBe(PARITY_SHARDS);218    expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);219    expect(manifest.includesRecoveryWords).toBe(false);220    expect(manifest.pageCount).toBeGreaterThanOrEqual(1);221    expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{16}$/);222  }, 60_000);223224  it("rejects generation when the vault is locked", async () => {225    const deps = makeDeps({ isVaultUnlocked: () => false });226    await expect(generateCodex(deps, { includeRecoveryWords: false })).rejects.toMatchObject({227      name: "CodexError",228      code: "VAULT_LOCKED",229    });230  });231232  it("rejects generation when recovery words are missing", async () => {233    const deps = makeDeps({ getRecoveryWords: async () => null });234    await expect(generateCodex(deps, { includeRecoveryWords: true })).rejects.toMatchObject({235      name: "CodexError",236      code: "RECOVERY_WORDS_MISSING",237    });238  });239240  it("rejects generation when recovery words are invalid", async () => {241    const deps = makeDeps({242      getRecoveryWords: async () => [243        "abandon", "ability", "able", "about", "above", "absent",244        "absorb", "abstract", "absurd", "abuse", "access", "notaword",245      ],246    });247    await expect(generateCodex(deps, { includeRecoveryWords: true })).rejects.toMatchObject({248      name: "CodexError",249      code: "RECOVERY_WORDS_INVALID",250    });251  });252253  it("persists the storage_key_verify_code constant correctly", () => {254    // Smoke check the constant import — catches a rename / re-export regression255    // before tests that depend on chrome.storage start failing in confusing ways.256    expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");257    expect(CodexError).toBeTypeOf("function");258  });259});