vault-adopt-snapshot.test.ts

8.2 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 oversized duress before sending any snapshot frame", async () => {63    const [source] = memoryTransportPair();64    let frames = 0;65    const countingSource: PairingTransport = {66      send: (msg) => {67        frames++;68        return source.send(msg);69      },70      receive: () => source.receive(),71    };7273    await expect(sendVaultSnapshot(74      countingSource,75      { e1: "cipher1" },76      {},77      "x".repeat(768 * 1024),78    )).rejects.toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");79    expect(frames).toBe(0);80  });8182  it("rejects out-of-order chunks", async () => {83    const [source, receiver] = memoryTransportPair();84    const send = sendFrame(source, {85      type: "ADOPT_SNAPSHOT",86      chunk_index: 1,87      chunk_count: 2,88      entries: { a: "x" },89      tombstones: {},90    });91    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/);92    await send;93  });9495  it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {96    const [source, receiver] = memoryTransportPair();97    const send = sendFrame(source, {98      type: "ADOPT_SNAPSHOT",99      chunk_index: 0,100      chunk_count: MAX_SNAPSHOT_CHUNKS + 1,101      entries: {},102      tombstones: {},103    });104    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/);105    await send;106  });107108  it("validates ADOPT_SNAPSHOT frames", () => {109    expect(110      isAdoptSnapshot({111        type: "ADOPT_SNAPSHOT",112        chunk_index: 0,113        chunk_count: 1,114        entries: { a: "x" },115        tombstones: {},116      }),117    ).toBe(true);118    // Missing tombstones / chunk_index.119    expect(120      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }),121    ).toBe(false);122    expect(123      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }),124    ).toBe(false);125    // Wrong type tag.126    expect(127      isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),128    ).toBe(false);129    // Invalid indices and value types.130    expect(131      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }),132    ).toBe(false);133    expect(134      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }),135    ).toBe(false);136    expect(137      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }),138    ).toBe(false);139    expect(isAdoptSnapshot(null)).toBe(false);140  });141142  it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => {143    const [receiverChannel, sourceChannel] = memoryTransportPair();144    const deps: AdoptionReceiverDeps = {145      deviceName: "recv",146      generateDeviceId: () => "recv-id",147      async confirmSas() {148        return true;149      },150      async createVaultFromRootKey() {151        return { vaultId: "v" };152      },153      async deriveLocalIdentity() {154        return {155          deviceId: "recv-id",156          deviceName: "recv",157          deviceType: "extension",158          longTermPublicKeyHex: "ab".repeat(32),159        };160      },161      async upsertPairedDevice() {},162      onError() {},163    };164    void requestAdoption(receiverChannel, deps);165    const first = await receiveFrame(sourceChannel);166    expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });167  });168169  it("round-trips a duress slot on the final chunk (single-chunk vault)", async () => {170    const [source, receiver] = memoryTransportPair();171    const duressJson = JSON.stringify({172      version: 1,173      salt: "aa".repeat(16),174      nonce: "bb".repeat(12),175      ciphertext: "cc".repeat(32),176      tag: "dd".repeat(16),177    });178    const [result] = await Promise.all([179      receiveVaultSnapshot(receiver),180      sendVaultSnapshot(source, { e1: "cipher1" }, { t1: 100 }, duressJson),181    ]);182    expect(result.entries).toEqual({ e1: "cipher1" });183    expect(result.tombstones).toEqual({ t1: 100 });184    expect(result.duressSlot).toBe(duressJson);185  });186187  it("round-trips a duress slot across multiple chunks (final chunk only)", async () => {188    const [source, receiver] = memoryTransportPair();189    const big = "x".repeat(200 * 1024);190    const entries: Record<string, string> = {};191    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;192    const duressJson = JSON.stringify({193      version: 1,194      salt: "11".repeat(16),195      nonce: "22".repeat(12),196      ciphertext: "33".repeat(32),197      tag: "44".repeat(16),198    });199    const [result] = await Promise.all([200      receiveVaultSnapshot(receiver),201      sendVaultSnapshot(source, entries, { dead: 1 }, duressJson),202    ]);203    expect(result.entries).toEqual(entries);204    expect(result.duressSlot).toBe(duressJson);205  });206207  it("omits duress_slot when no duress is configured (null)", async () => {208    const [source, receiver] = memoryTransportPair();209    const [result] = await Promise.all([210      receiveVaultSnapshot(receiver),211      sendVaultSnapshot(source, { e1: "v1" }, {}),212    ]);213    expect(result.duressSlot).toBeNull();214  });215216  it("isAdoptSnapshot accepts frames with valid duress_slot string", () => {217    expect(218      isAdoptSnapshot({219        type: "ADOPT_SNAPSHOT",220        chunk_index: 0,221        chunk_count: 1,222        entries: {},223        tombstones: {},224        duress_slot: '{"version":1}',225      }),226    ).toBe(true);227  });228229  it("isAdoptSnapshot accepts frames without duress_slot", () => {230    expect(231      isAdoptSnapshot({232        type: "ADOPT_SNAPSHOT",233        chunk_index: 0,234        chunk_count: 1,235        entries: {},236        tombstones: {},237      }),238    ).toBe(true);239  });240241  it("isAdoptSnapshot rejects frames with non-string duress_slot", () => {242    expect(243      isAdoptSnapshot({244        type: "ADOPT_SNAPSHOT",245        chunk_index: 0,246        chunk_count: 1,247        entries: {},248        tombstones: {},249        duress_slot: 42,250      }),251    ).toBe(false);252    expect(253      isAdoptSnapshot({254        type: "ADOPT_SNAPSHOT",255        chunk_index: 0,256        chunk_count: 1,257        entries: {},258        tombstones: {},259        duress_slot: { version: 1 },260      }),261    ).toBe(false);262  });263});