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 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 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 126 expect(127 isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),128 ).toBe(false);129 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});