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); 50 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 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 106 expect(107 isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),108 ).toBe(false);109 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});