vault-adopt-snapshot.test.ts

7.7 KB · raw

1import { describe, expect, it } from "vitest";2import {3  MAX_SNAPSHOT_CHUNKS,4  isAdoptSnapshot,5  receiveFrame,6  receiveVaultSnapshot,7  requestAdoption,8  sendFrame,9  sendVaultSnapshot,10  type AdoptionReceiverDeps,11} from "./vault-adopt";12import { memoryTransportPair, type PairingTransport } from "./pairing-handshake";1314describe("inline vault snapshot", () => {15  it("round-trips an empty vault (0 entries)", async () => {16    const [source, receiver] = memoryTransportPair();17    const [result] = await Promise.all([18      receiveVaultSnapshot(receiver),19      sendVaultSnapshot(source, {}, {}),20    ]);21    expect(result.entries).toEqual({});22    expect(result.tombstones).toEqual({});23  });2425  it("round-trips 50 entries + 5 tombstones in a single chunk", async () => {26    const [source, receiver] = memoryTransportPair();27    const entries: Record<string, string> = {};28    for (let i = 0; i < 50; i++) entries[`id-${i}`] = `cipher-${i}`;29    const tombstones: Record<string, number> = {};30    for (let i = 0; i < 5; i++) tombstones[`dead-${i}`] = 1000 + i;31    const [result] = await Promise.all([32      receiveVaultSnapshot(receiver),33      sendVaultSnapshot(source, entries, tombstones),34    ]);35    expect(result.entries).toEqual(entries);36    expect(result.tombstones).toEqual(tombstones);37  });3839  it("splits a large vault into 3+ chunks and reassembles it", async () => {40    const [source, receiver] = memoryTransportPair();41    let frames = 0;42    const countingSource: PairingTransport = {43      send: (msg) => {44        frames++;45        return source.send(msg);46      },47      receive: () => source.receive(),48    };49    const big = "x".repeat(200 * 1024); // 200 KiB per entry forces chunking50    const entries: Record<string, string> = {};51    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;52    const tombstones: Record<string, number> = { "dead-0": 42 };53    const [result] = await Promise.all([54      receiveVaultSnapshot(receiver),55      sendVaultSnapshot(countingSource, entries, tombstones),56    ]);57    expect(frames).toBeGreaterThanOrEqual(3);58    expect(result.entries).toEqual(entries);59    expect(result.tombstones).toEqual(tombstones);60  });6162  it("rejects out-of-order chunks", async () => {63    const [source, receiver] = memoryTransportPair();64    const send = sendFrame(source, {65      type: "ADOPT_SNAPSHOT",66      chunk_index: 1,67      chunk_count: 2,68      entries: { a: "x" },69      tombstones: {},70    });71    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/);72    await send;73  });7475  it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {76    const [source, receiver] = memoryTransportPair();77    const send = sendFrame(source, {78      type: "ADOPT_SNAPSHOT",79      chunk_index: 0,80      chunk_count: MAX_SNAPSHOT_CHUNKS + 1,81      entries: {},82      tombstones: {},83    });84    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/);85    await send;86  });8788  it("validates ADOPT_SNAPSHOT frames", () => {89    expect(90      isAdoptSnapshot({91        type: "ADOPT_SNAPSHOT",92        chunk_index: 0,93        chunk_count: 1,94        entries: { a: "x" },95        tombstones: {},96      }),97    ).toBe(true);98    // Missing tombstones / chunk_index.99    expect(100      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }),101    ).toBe(false);102    expect(103      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }),104    ).toBe(false);105    // Wrong type tag.106    expect(107      isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),108    ).toBe(false);109    // Invalid indices and value types.110    expect(111      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }),112    ).toBe(false);113    expect(114      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }),115    ).toBe(false);116    expect(117      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }),118    ).toBe(false);119    expect(isAdoptSnapshot(null)).toBe(false);120  });121122  it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => {123    const [receiverChannel, sourceChannel] = memoryTransportPair();124    const deps: AdoptionReceiverDeps = {125      deviceName: "recv",126      generateDeviceId: () => "recv-id",127      async confirmSas() {128        return true;129      },130      async createVaultFromRootKey() {131        return { vaultId: "v" };132      },133      async deriveLocalIdentity() {134        return {135          deviceId: "recv-id",136          deviceName: "recv",137          deviceType: "extension",138          longTermPublicKeyHex: "ab".repeat(32),139        };140      },141      async upsertPairedDevice() {},142      onError() {},143    };144    void requestAdoption(receiverChannel, deps);145    const first = await receiveFrame(sourceChannel);146    expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });147  });148149  it("round-trips a duress slot on the final chunk (single-chunk vault)", async () => {150    const [source, receiver] = memoryTransportPair();151    const duressJson = JSON.stringify({152      version: 1,153      salt: "aa".repeat(16),154      nonce: "bb".repeat(12),155      ciphertext: "cc".repeat(32),156      tag: "dd".repeat(16),157    });158    const [result] = await Promise.all([159      receiveVaultSnapshot(receiver),160      sendVaultSnapshot(source, { e1: "cipher1" }, { t1: 100 }, duressJson),161    ]);162    expect(result.entries).toEqual({ e1: "cipher1" });163    expect(result.tombstones).toEqual({ t1: 100 });164    expect(result.duressSlot).toBe(duressJson);165  });166167  it("round-trips a duress slot across multiple chunks (final chunk only)", async () => {168    const [source, receiver] = memoryTransportPair();169    const big = "x".repeat(200 * 1024);170    const entries: Record<string, string> = {};171    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;172    const duressJson = JSON.stringify({173      version: 1,174      salt: "11".repeat(16),175      nonce: "22".repeat(12),176      ciphertext: "33".repeat(32),177      tag: "44".repeat(16),178    });179    const [result] = await Promise.all([180      receiveVaultSnapshot(receiver),181      sendVaultSnapshot(source, entries, { dead: 1 }, duressJson),182    ]);183    expect(result.entries).toEqual(entries);184    expect(result.duressSlot).toBe(duressJson);185  });186187  it("omits duress_slot when no duress is configured (null)", async () => {188    const [source, receiver] = memoryTransportPair();189    const [result] = await Promise.all([190      receiveVaultSnapshot(receiver),191      sendVaultSnapshot(source, { e1: "v1" }, {}),192    ]);193    expect(result.duressSlot).toBeNull();194  });195196  it("isAdoptSnapshot accepts frames with valid duress_slot string", () => {197    expect(198      isAdoptSnapshot({199        type: "ADOPT_SNAPSHOT",200        chunk_index: 0,201        chunk_count: 1,202        entries: {},203        tombstones: {},204        duress_slot: '{"version":1}',205      }),206    ).toBe(true);207  });208209  it("isAdoptSnapshot accepts frames without duress_slot", () => {210    expect(211      isAdoptSnapshot({212        type: "ADOPT_SNAPSHOT",213        chunk_index: 0,214        chunk_count: 1,215        entries: {},216        tombstones: {},217      }),218    ).toBe(true);219  });220221  it("isAdoptSnapshot rejects frames with non-string duress_slot", () => {222    expect(223      isAdoptSnapshot({224        type: "ADOPT_SNAPSHOT",225        chunk_index: 0,226        chunk_count: 1,227        entries: {},228        tombstones: {},229        duress_slot: 42,230      }),231    ).toBe(false);232    expect(233      isAdoptSnapshot({234        type: "ADOPT_SNAPSHOT",235        chunk_index: 0,236        chunk_count: 1,237        entries: {},238        tombstones: {},239        duress_slot: { version: 1 },240      }),241    ).toBe(false);242  });243});