restore.test.ts

11.0 KB · raw

1/**2 * restore.test.ts — coverage for ChunkBuffer, parseRawScan, reconstructAndVerify.3 *4 * Tests build synthetic envelopes via the same helpers the generator uses5 * (Subagent 1's `helpers.ts` + `erasure.ts`). We avoid the full6 * `generateCodex` pipeline so these tests don't depend on PDF rendering7 * or Aztec encoding — they only exercise the restoration math.8 */910import { beforeEach, describe, expect, it } from "vitest";11import { deflate } from "pako";1213import {14  CodexError,15  deriveShardCounts,16  type ChunkEnvelope,17  type CodexPayload,18} from "../../shared/codex-types";19import {20  base64UrlToBytes,21  bytesToBase64Url,22  buildChunkEnvelope,23  sha256Hex,24} from "./helpers";25import { encodeWithErasure } from "./erasure";26import { _resetReedSolomonCacheForTest } from "./rs-loader";27import {28  ChunkBuffer,29  parseRawScan,30  reconstructAndVerify,31} from "./restore";3233// Reset between tests: the WASM Reed-Solomon instance carries internal state34// across encode/reconstruct calls that breaks subsequent reconstructions in35// the same process. Until that's fixed in erasure.ts / rs-loader.ts, isolating36// each test keeps the suite deterministic.37beforeEach(() => {38  _resetReedSolomonCacheForTest();39});4041const VERIFY_CODE = "PX-TEST-ABCD";4243/** Build envelopes for an arbitrary CodexPayload, mirroring the real generator. */44async function buildEnvelopesForPayload(45  payload: CodexPayload,46): Promise<ChunkEnvelope[]> {47  const json = JSON.stringify(payload);48  const compressed = deflate(new TextEncoder().encode(json), { level: 9 });49  // encodeWithErasure shards at CHUNK_SIZE internally — no need to pass it.50  const { dataChunks, parityChunks, totalChunks } =51    await encodeWithErasure(compressed);52  const all = [...dataChunks, ...parityChunks];53  return Promise.all(54    all.map((bytes, i) =>55      buildChunkEnvelope(56        bytes,57        i,58        totalChunks,59        i >= dataChunks.length,60        payload.verifyCode,61      ),62    ),63  );64}6566async function makePayload(seedLength = 12_000): Promise<CodexPayload> {67  // A vaultBlob big enough to span several chunks after deflate. We seed with68  // a deterministic but non-repeating pattern (xorshift-ish) so pako cannot69  // compress it to a single chunk — without that, dataShards collapses to 170  // and the "lose 2 chunks" test has nothing to reconstruct.71  const blobBytes = new Uint8Array(seedLength);72  let s = 0x9e3779b9;73  for (let i = 0; i < seedLength; i++) {74    s = (s ^ (s << 13)) >>> 0;75    s = (s ^ (s >>> 17)) >>> 0;76    s = (s ^ (s << 5)) >>> 0;77    blobBytes[i] = s & 0xff;78  }79  const vaultBlob = bytesToBase64Url(blobBytes);80  const vaultFingerprint = (81    await sha256Hex(new TextEncoder().encode(vaultBlob))82  )83    .slice(0, 16)84    .toUpperCase();85  return {86    version: 2,87    verifyCode: VERIFY_CODE,88    vaultBlob,89    timestamp: 1_700_000_000_000,90    entryCount: 42,91    vaultFingerprint,92    generatedAt: 1_700_000_001_000,93  };94}9596async function makeSingleEnvelope(opts: {97  index: number;98  total: number;99  isParity?: boolean;100  verifyCode?: string;101  bytes?: Uint8Array;102}): Promise<ChunkEnvelope> {103  return buildChunkEnvelope(104    opts.bytes ?? new Uint8Array([1, 2, 3, 4, 5]),105    opts.index,106    opts.total,107    opts.isParity ?? false,108    opts.verifyCode ?? VERIFY_CODE,109  );110}111112describe("ChunkBuffer", () => {113  it("accepts a valid chunk and exposes context", async () => {114    const buf = new ChunkBuffer();115    const env = await makeSingleEnvelope({ index: 0, total: 5 });116    const res = await buf.add(env);117    expect(res.accepted).toBe(true);118    expect(buf.size()).toBe(1);119    expect(buf.getContext()).toEqual({120      verifyCode: VERIFY_CODE,121      stripeCount: 1,122      total: 5,123      dataShards: deriveShardCounts(5).dataShards,124      scanned: 1,125      allStripesObserved: true,126    });127  });128129  it("rejects a chunk from a different verifyCode", async () => {130    const buf = new ChunkBuffer();131    await buf.add(await makeSingleEnvelope({ index: 0, total: 5 }));132    const foreign = await makeSingleEnvelope({133      index: 1,134      total: 5,135      verifyCode: "PX-OTHR-XXXX",136    });137    const res = await buf.add(foreign);138    expect(res).toEqual({ accepted: false, reason: "foreign_codex" });139    expect(buf.size()).toBe(1);140  });141142  it("rejects a chunk with bad integrity hash", async () => {143    const buf = new ChunkBuffer();144    const env = await makeSingleEnvelope({ index: 0, total: 5 });145    // Corrupt the data field without updating the hash — verifyChunkIntegrity146    // must catch this.147    const corrupted: ChunkEnvelope = {148      ...env,149      d: bytesToBase64Url(new Uint8Array([9, 9, 9, 9])),150    };151    const res = await buf.add(corrupted);152    expect(res).toEqual({ accepted: false, reason: "bad_integrity" });153    expect(buf.size()).toBe(0);154  });155156  it("rejects a chunk with wrong envelope version", async () => {157    const buf = new ChunkBuffer();158    const env = await makeSingleEnvelope({ index: 0, total: 5 });159    const wrongVersion = { ...env, v: 1 } as unknown as ChunkEnvelope;160    const res = await buf.add(wrongVersion);161    expect(res.accepted).toBe(false);162    expect(res.reason).toBe("parse_error");163  });164165  it("deduplicates: adding the same index twice keeps only one", async () => {166    const buf = new ChunkBuffer();167    const env = await makeSingleEnvelope({ index: 2, total: 5 });168    expect((await buf.add(env)).accepted).toBe(true);169    const second = await buf.add(env);170    expect(second).toEqual({ accepted: false, reason: "duplicate" });171    expect(buf.size()).toBe(1);172  });173174  it("isReady() flips true at dataShards count", async () => {175    const buf = new ChunkBuffer();176    const total = 5;177    const dataShards = deriveShardCounts(total).dataShards;178    for (let i = 0; i < dataShards; i++) {179      expect(buf.isReady()).toBe(false);180      const env = await makeSingleEnvelope({181        index: i,182        total,183        bytes: new Uint8Array([i, i, i]),184      });185      await buf.add(env);186    }187    expect(buf.isReady()).toBe(true);188  });189190  it("toStripeChunks returns raw chunk bytes per stripe", async () => {191    const buf = new ChunkBuffer();192    const env = await makeSingleEnvelope({193      index: 0,194      total: 5,195      bytes: new Uint8Array([1, 2, 3]),196    });197    await buf.add(env);198    const stripes = buf.toStripeChunks();199    expect(stripes.length).toBe(1);200    const map = stripes[0]!.chunks;201    expect(map.size).toBe(1);202    expect(Array.from(map.get(0)!)).toEqual([1, 2, 3]);203  });204205  it("stripeReadiness exposes per-stripe counts", async () => {206    const buf = new ChunkBuffer();207    const env = await makeSingleEnvelope({ index: 0, total: 5 });208    await buf.add(env);209    expect(buf.stripeReadiness()).toEqual([210      {211        stripe: 0,212        found: 1,213        required: deriveShardCounts(5).dataShards,214        isReady: false,215      },216    ]);217  });218});219220describe("parseRawScan", () => {221  it("parses a well-formed envelope", async () => {222    const env = await makeSingleEnvelope({ index: 0, total: 5 });223    const parsed = parseRawScan(JSON.stringify(env));224    expect(parsed).toEqual(env);225  });226227  it("returns parse_error for non-JSON input", () => {228    expect(parseRawScan("not json")).toEqual({ error: "parse_error" });229  });230231  it("returns wrong_version for v=1 envelopes", () => {232    const v1 = {233      v: 1,234      c: VERIFY_CODE,235      i: 0,236      t: 5,237      p: false,238      d: "AAAA",239      h: "ABCDEF12",240    };241    expect(parseRawScan(JSON.stringify(v1))).toEqual({242      error: "wrong_version",243    });244  });245246  it("returns parse_error for a v2 object with missing fields", () => {247    expect(parseRawScan(JSON.stringify({ v: 2, c: "PX" }))).toEqual({248      error: "parse_error",249    });250  });251});252253describe("reconstructAndVerify", () => {254  it("round-trips a full envelope set back to the original payload", async () => {255    const payload = await makePayload();256    const envelopes = await buildEnvelopesForPayload(payload);257258    const buf = new ChunkBuffer();259    for (const env of envelopes) {260      const res = await buf.add(env);261      expect(res.accepted).toBe(true);262    }263    expect(buf.isReady()).toBe(true);264265    const restored = await reconstructAndVerify(buf);266    expect(restored.verifyCode).toBe(payload.verifyCode);267    expect(restored.vaultBlob).toBe(payload.vaultBlob);268    expect(restored.vaultFingerprint).toBe(payload.vaultFingerprint);269    expect(restored.entryCount).toBe(payload.entryCount);270  });271272  it("reconstructs after losing 2 chunks", async () => {273    const payload = await makePayload();274    const envelopes = await buildEnvelopesForPayload(payload);275    // Need at least dataShards + 2 chunks total so dropping 2 still leaves276    // enough for reconstruction (small vaults pin to the 2-shard parity floor).277    expect(envelopes.length).toBeGreaterThan(2);278279    // Drop two indices that exercise the parity-decode path: one data shard280    // and one parity shard. The parity floor of 2 means we can lose any two.281    const dropA = 1;282    const dropB = envelopes.length - 1;283284    const buf = new ChunkBuffer();285    for (let i = 0; i < envelopes.length; i++) {286      if (i === dropA || i === dropB) continue;287      const res = await buf.add(envelopes[i]!);288      expect(res.accepted).toBe(true);289    }290    expect(buf.isReady()).toBe(true);291292    const restored = await reconstructAndVerify(buf);293    expect(restored.vaultFingerprint).toBe(payload.vaultFingerprint);294    expect(restored.vaultBlob).toBe(payload.vaultBlob);295  });296297  it("throws RECONSTRUCTION_FINGERPRINT_MISMATCH if the reconstructed blob does not match", async () => {298    // Build a payload whose stored fingerprint deliberately does NOT match299    // its vaultBlob — simulates a successful chunk decode that nevertheless300    // pulled the wrong bytes into a parity-reconstructed slot.301    const payload = await makePayload();302    const tampered: CodexPayload = {303      ...payload,304      vaultFingerprint: "DEADBEEFDEADBEEF",305    };306    const envelopes = await buildEnvelopesForPayload(tampered);307308    const buf = new ChunkBuffer();309    for (const env of envelopes) await buf.add(env);310311    await expect(reconstructAndVerify(buf)).rejects.toMatchObject({312      name: "CodexError",313      code: "RECONSTRUCTION_FINGERPRINT_MISMATCH",314    });315  });316317  it("throws CodexError when called on an empty buffer", async () => {318    const buf = new ChunkBuffer();319    await expect(reconstructAndVerify(buf)).rejects.toBeInstanceOf(CodexError);320  });321});322323describe("ChunkBuffer + parseRawScan integration", () => {324  it("a full scan loop accepts every raw envelope string", async () => {325    const payload = await makePayload(2_500);326    const envelopes = await buildEnvelopesForPayload(payload);327    const buf = new ChunkBuffer();328329    for (const env of envelopes) {330      const parsed = parseRawScan(JSON.stringify(env));331      expect("error" in parsed).toBe(false);332      const res = await buf.add(parsed as ChunkEnvelope);333      expect(res.accepted).toBe(true);334    }335336    const restored = await reconstructAndVerify(buf);337    expect(restored.vaultFingerprint).toBe(payload.vaultFingerprint);338    // sanity-check that base64UrlToBytes is reachable from this file339    expect(base64UrlToBytes(restored.vaultBlob).length).toBeGreaterThan(0);340  });341});