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 type { PairingTransport } from "./pairing-handshake";1314function memoryTransportPair(): [PairingTransport, PairingTransport] {15 const a: Uint8Array[] = [];16 const b: Uint8Array[] = [];17 const aReaders: Array<(message: Uint8Array) => void> = [];18 const bReaders: Array<(message: Uint8Array) => void> = [];1920 const transport = (21 inbox: Uint8Array[],22 outbox: Uint8Array[],23 inboxReaders: Array<(message: Uint8Array) => void>,24 outboxReaders: Array<(message: Uint8Array) => void>,25 ): PairingTransport => ({26 async send(message) {27 const reader = outboxReaders.shift();28 if (reader) reader(message);29 else outbox.push(message);30 },31 receive() {32 const message = inbox.shift();33 return message34 ? Promise.resolve(message)35 : new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));36 },37 });3839 return [transport(a, b, aReaders, bReaders), transport(b, a, bReaders, aReaders)];40}4142describe("inline vault snapshot", () => {43 it("round-trips an empty vault (0 entries)", async () => {44 const [source, receiver] = memoryTransportPair();45 const [result] = await Promise.all([46 receiveVaultSnapshot(receiver),47 sendVaultSnapshot(source, {}, {}),48 ]);49 expect(result.entries).toEqual({});50 expect(result.tombstones).toEqual({});51 });5253 it("round-trips 50 entries + 5 tombstones in a single chunk", async () => {54 const [source, receiver] = memoryTransportPair();55 const entries: Record<string, string> = {};56 for (let i = 0; i < 50; i++) entries[`id-${i}`] = `cipher-${i}`;57 const tombstones: Record<string, number> = {};58 for (let i = 0; i < 5; i++) tombstones[`dead-${i}`] = 1000 + i;59 const [result] = await Promise.all([60 receiveVaultSnapshot(receiver),61 sendVaultSnapshot(source, entries, tombstones),62 ]);63 expect(result.entries).toEqual(entries);64 expect(result.tombstones).toEqual(tombstones);65 });6667 it("splits a large vault into 3+ chunks and reassembles it", async () => {68 const [source, receiver] = memoryTransportPair();69 let frames = 0;70 const countingSource: PairingTransport = {71 send: (msg) => {72 frames++;73 return source.send(msg);74 },75 receive: () => source.receive(),76 };77 const big = "x".repeat(200 * 1024); 78 const entries: Record<string, string> = {};79 for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;80 const tombstones: Record<string, number> = { "dead-0": 42 };81 const [result] = await Promise.all([82 receiveVaultSnapshot(receiver),83 sendVaultSnapshot(countingSource, entries, tombstones),84 ]);85 expect(frames).toBeGreaterThanOrEqual(3);86 expect(result.entries).toEqual(entries);87 expect(result.tombstones).toEqual(tombstones);88 });8990 it("rejects oversized duress before sending any snapshot frame", async () => {91 const [source] = memoryTransportPair();92 let frames = 0;93 const countingSource: PairingTransport = {94 send: (msg) => {95 frames++;96 return source.send(msg);97 },98 receive: () => source.receive(),99 };100101 await expect(sendVaultSnapshot(102 countingSource,103 { e1: "cipher1" },104 {},105 "x".repeat(768 * 1024),106 )).rejects.toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");107 expect(frames).toBe(0);108 });109110 it("rejects out-of-order chunks", async () => {111 const [source, receiver] = memoryTransportPair();112 const send = sendFrame(source, {113 type: "ADOPT_SNAPSHOT",114 chunk_index: 1,115 chunk_count: 2,116 entries: { a: "x" },117 tombstones: {},118 });119 await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/);120 await send;121 });122123 it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {124 const [source, receiver] = memoryTransportPair();125 const send = sendFrame(source, {126 type: "ADOPT_SNAPSHOT",127 chunk_index: 0,128 chunk_count: MAX_SNAPSHOT_CHUNKS + 1,129 entries: {},130 tombstones: {},131 });132 await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/);133 await send;134 });135136 it("validates ADOPT_SNAPSHOT frames", () => {137 expect(138 isAdoptSnapshot({139 type: "ADOPT_SNAPSHOT",140 chunk_index: 0,141 chunk_count: 1,142 entries: { a: "x" },143 tombstones: {},144 }),145 ).toBe(true);146 147 expect(148 isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }),149 ).toBe(false);150 expect(151 isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }),152 ).toBe(false);153 154 expect(155 isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),156 ).toBe(false);157 158 expect(159 isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }),160 ).toBe(false);161 expect(162 isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }),163 ).toBe(false);164 expect(165 isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }),166 ).toBe(false);167 expect(isAdoptSnapshot(null)).toBe(false);168 });169170 it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => {171 const [receiverChannel, sourceChannel] = memoryTransportPair();172 const deps: AdoptionReceiverDeps = {173 deviceName: "recv",174 generateDeviceId: () => "recv-id",175 async confirmSas() {176 return true;177 },178 async createVaultFromRootKey() {179 return { vaultId: "v" };180 },181 async deriveLocalIdentity() {182 return {183 deviceId: "recv-id",184 deviceName: "recv",185 deviceType: "extension",186 longTermPublicKeyHex: "ab".repeat(32),187 };188 },189 async upsertPairedDevice() {},190 onError() {},191 };192 void requestAdoption(receiverChannel, deps);193 const first = await receiveFrame(sourceChannel);194 expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });195 });196197 it("round-trips a duress slot on the final chunk (single-chunk vault)", async () => {198 const [source, receiver] = memoryTransportPair();199 const duressJson = JSON.stringify({200 version: 1,201 salt: "aa".repeat(16),202 nonce: "bb".repeat(12),203 ciphertext: "cc".repeat(32),204 tag: "dd".repeat(16),205 });206 const [result] = await Promise.all([207 receiveVaultSnapshot(receiver),208 sendVaultSnapshot(source, { e1: "cipher1" }, { t1: 100 }, duressJson),209 ]);210 expect(result.entries).toEqual({ e1: "cipher1" });211 expect(result.tombstones).toEqual({ t1: 100 });212 expect(result.duressSlot).toBe(duressJson);213 });214215 it("round-trips a duress slot across multiple chunks (final chunk only)", async () => {216 const [source, receiver] = memoryTransportPair();217 const big = "x".repeat(200 * 1024);218 const entries: Record<string, string> = {};219 for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;220 const duressJson = JSON.stringify({221 version: 1,222 salt: "11".repeat(16),223 nonce: "22".repeat(12),224 ciphertext: "33".repeat(32),225 tag: "44".repeat(16),226 });227 const [result] = await Promise.all([228 receiveVaultSnapshot(receiver),229 sendVaultSnapshot(source, entries, { dead: 1 }, duressJson),230 ]);231 expect(result.entries).toEqual(entries);232 expect(result.duressSlot).toBe(duressJson);233 });234235 it("omits duress_slot when no duress is configured (null)", async () => {236 const [source, receiver] = memoryTransportPair();237 const [result] = await Promise.all([238 receiveVaultSnapshot(receiver),239 sendVaultSnapshot(source, { e1: "v1" }, {}),240 ]);241 expect(result.duressSlot).toBeNull();242 });243244 it("isAdoptSnapshot accepts frames with valid duress_slot string", () => {245 expect(246 isAdoptSnapshot({247 type: "ADOPT_SNAPSHOT",248 chunk_index: 0,249 chunk_count: 1,250 entries: {},251 tombstones: {},252 duress_slot: '{"version":1}',253 }),254 ).toBe(true);255 });256257 it("isAdoptSnapshot accepts frames without duress_slot", () => {258 expect(259 isAdoptSnapshot({260 type: "ADOPT_SNAPSHOT",261 chunk_index: 0,262 chunk_count: 1,263 entries: {},264 tombstones: {},265 }),266 ).toBe(true);267 });268269 it("isAdoptSnapshot rejects frames with non-string duress_slot", () => {270 expect(271 isAdoptSnapshot({272 type: "ADOPT_SNAPSHOT",273 chunk_index: 0,274 chunk_count: 1,275 entries: {},276 tombstones: {},277 duress_slot: 42,278 }),279 ).toBe(false);280 expect(281 isAdoptSnapshot({282 type: "ADOPT_SNAPSHOT",283 chunk_index: 0,284 chunk_count: 1,285 entries: {},286 tombstones: {},287 duress_slot: { version: 1 },288 }),289 ).toBe(false);290 });291});