1package com.peachpasswords.sync23import kotlinx.coroutines.channels.Channel4import kotlinx.coroutines.runBlocking5import org.junit.Assert.assertEquals6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Test9101112131415class VaultAdoptSnapshotTest {1617 private fun memoryPair(): Pair<PairingTransport, PairingTransport> {18 val a2b = Channel<ByteArray>(Channel.UNLIMITED)19 val b2a = Channel<ByteArray>(Channel.UNLIMITED)20 val a = object : PairingTransport {21 override suspend fun send(msg: ByteArray) { a2b.send(msg) }22 override suspend fun receive(): ByteArray = b2a.receive()23 override suspend fun close() {}24 }25 val b = object : PairingTransport {26 override suspend fun send(msg: ByteArray) { b2a.send(msg) }27 override suspend fun receive(): ByteArray = a2b.receive()28 override suspend fun close() {}29 }30 return a to b31 }3233 @Test34 fun `round-trips an empty vault`() = runBlocking {35 val (source, receiver) = memoryPair()36 sendVaultSnapshot(source, emptyMap(), emptyMap())37 val (entries, tombstones) = receiveVaultSnapshot(receiver)38 assertTrue(entries.isEmpty())39 assertTrue(tombstones.isEmpty())40 }4142 @Test43 fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking {44 val (source, receiver) = memoryPair()45 val entries = (0 until 50).associate { "id-$it" to "cipher-$it" }46 val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) }47 sendVaultSnapshot(source, entries, tombstones)48 val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)49 assertEquals(entries, receivedEntries)50 assertEquals(tombstones, receivedTombstones)51 }5253 @Test54 fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking {55 val (source, receiver) = memoryPair()56 var frames = 057 val countingSource = object : PairingTransport {58 override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }59 override suspend fun receive(): ByteArray = source.receive()60 override suspend fun close() {}61 }62 val big = "x".repeat(200 * 1024)63 val entries = (0 until 5).associate { "id-$it" to big }64 val tombstones = mapOf("dead-0" to 42L)65 sendVaultSnapshot(countingSource, entries, tombstones)66 val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)67 assertTrue("expected 3+ chunks, got $frames", frames >= 3)68 assertEquals(entries, receivedEntries)69 assertEquals(tombstones, receivedTombstones)70 }7172 @Test73 fun `rejects out-of-order chunks`() = runBlocking {74 val (source, receiver) = memoryPair()75 sendFrame(source, AdoptionFrame.Snapshot(76 chunkIndex = 1,77 chunkCount = 2,78 entries = mapOf("a" to "x"),79 tombstones = emptyMap(),80 ))81 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()82 assertTrue(error is IllegalStateException)83 assertTrue(error!!.message!!.contains("OUT_OF_ORDER"))84 }8586 @Test87 fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {88 val (source, receiver) = memoryPair()89 sendFrame(source, AdoptionFrame.Snapshot(90 chunkIndex = 0,91 chunkCount = MAX_SNAPSHOT_CHUNKS + 1,92 entries = emptyMap(),93 tombstones = emptyMap(),94 ))95 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()96 assertTrue(error is IllegalStateException)97 assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS"))98 }99100 @Test101 fun `validates ADOPT_SNAPSHOT frames`() {102 val valid = AdoptionFrame.Snapshot(103 chunkIndex = 0, chunkCount = 1,104 entries = mapOf("a" to "x"), tombstones = emptyMap(),105 )106 assertTrue(isAdoptSnapshot(valid))107 assertTrue(!isAdoptSnapshot(null))108 assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete))109 }110111 @Test112 fun `snapshot_capable serializes in ADOPT_REQUEST`() {113 val request = AdoptionFrame.Request(114 sessionRequestId = "req-1",115 deviceName = "test",116 deviceType = "extension",117 snapshotCapable = true,118 )119 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request)120 assertTrue(json.contains("\"snapshot_capable\":true"))121 122 val noFlag = AdoptionFrame.Request(123 sessionRequestId = "req-2",124 deviceName = "test",125 deviceType = "extension",126 )127 val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag)128 assertTrue(!jsonNoFlag.contains("snapshot_capable"))129 }130131 @Test132 fun `rejects raw frame exceeding wire ceiling`() = runBlocking {133 134 135 136 137 val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024138 val oversized = ByteArray(ceiling + 1)139 val channel = object : PairingTransport {140 override suspend fun send(msg: ByteArray) {}141 override suspend fun receive(): ByteArray = oversized142 override suspend fun close() {}143 }144 val error = runCatching { receiveVaultSnapshot(channel) }.exceptionOrNull()145 assertTrue(error is IllegalStateException)146 assertTrue(error!!.message!!.contains("CHUNK_TOO_LARGE"))147 }148149 @Test150 fun `rejects chunk_count mismatch on subsequent frame`() = runBlocking {151 val (source, receiver) = memoryPair()152 sendFrame(source, AdoptionFrame.Snapshot(153 chunkIndex = 0,154 chunkCount = 2,155 entries = mapOf("a" to "x"),156 tombstones = emptyMap(),157 ))158 sendFrame(source, AdoptionFrame.Snapshot(159 chunkIndex = 1,160 chunkCount = 3,161 entries = mapOf("b" to "y"),162 tombstones = emptyMap(),163 ))164 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()165 assertTrue(error is IllegalStateException)166 assertTrue(error!!.message!!.contains("CHUNK_COUNT_MISMATCH"))167 }168169 @Test170 fun `round-trips duress_slot on final chunk`() = runBlocking {171 val (source, receiver) = memoryPair()172 val entries = mapOf("id-0" to "cipher-0")173 val tombstones = mapOf("dead-0" to 42L)174 val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""175 sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)176 val snapshot = receiveVaultSnapshot(receiver)177 assertEquals(entries, snapshot.entries)178 assertEquals(tombstones, snapshot.tombstones)179 assertEquals(duressJson, snapshot.duressSlot)180 }181182 @Test183 fun `duress_slot absent returns null`() = runBlocking {184 val (source, receiver) = memoryPair()185 sendVaultSnapshot(source, mapOf("id-0" to "cipher-0"), emptyMap())186 val snapshot = receiveVaultSnapshot(receiver)187 assertNull(snapshot.duressSlot)188 }189190 @Test191 fun `duress_slot rides final chunk only in multi-chunk transfer`() = runBlocking {192 val (source, receiver) = memoryPair()193 val big = "x".repeat(200 * 1024)194 val entries = (0 until 5).associate { "id-$it" to big }195 val tombstones = mapOf("dead-0" to 42L)196 val duressJson = """{"version":1,"salt":"ff","nonce":"ee","ciphertext":"dd","tag":"cc"}"""197 sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)198 val snapshot = receiveVaultSnapshot(receiver)199 assertEquals(entries, snapshot.entries)200 assertEquals(tombstones, snapshot.tombstones)201 assertEquals(duressJson, snapshot.duressSlot)202 }203204 @Test205 fun `old frame without duress_slot deserializes`() {206 val oldJson = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{"a":"x"},"tombstones":{}}"""207 val frame = runCatching {208 AdoptJson.decodeFromString(AdoptionFrame.serializer(), oldJson)209 }.getOrNull()210 assertTrue(isAdoptSnapshot(frame))211 assertTrue((frame as AdoptionFrame.Snapshot).duressSlot == null)212 }213214}