1package com.peachpasswords.sync23import kotlinx.coroutines.channels.Channel4import kotlinx.coroutines.runBlocking5import org.junit.Assert.assertEquals6import org.junit.Assert.assertTrue7import org.junit.Test891011121314class VaultAdoptSnapshotTest {1516 private fun memoryPair(): Pair<PairingTransport, PairingTransport> {17 val a2b = Channel<ByteArray>(Channel.UNLIMITED)18 val b2a = Channel<ByteArray>(Channel.UNLIMITED)19 val a = object : PairingTransport {20 override suspend fun send(msg: ByteArray) { a2b.send(msg) }21 override suspend fun receive(): ByteArray = b2a.receive()22 override suspend fun close() {}23 }24 val b = object : PairingTransport {25 override suspend fun send(msg: ByteArray) { b2a.send(msg) }26 override suspend fun receive(): ByteArray = a2b.receive()27 override suspend fun close() {}28 }29 return a to b30 }3132 @Test33 fun `round-trips an empty vault`() = runBlocking {34 val (source, receiver) = memoryPair()35 sendVaultSnapshot(source, emptyMap(), emptyMap())36 val (entries, tombstones) = receiveVaultSnapshot(receiver)37 assertTrue(entries.isEmpty())38 assertTrue(tombstones.isEmpty())39 }4041 @Test42 fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking {43 val (source, receiver) = memoryPair()44 val entries = (0 until 50).associate { "id-$it" to "cipher-$it" }45 val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) }46 sendVaultSnapshot(source, entries, tombstones)47 val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)48 assertEquals(entries, receivedEntries)49 assertEquals(tombstones, receivedTombstones)50 }5152 @Test53 fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking {54 val (source, receiver) = memoryPair()55 var frames = 056 val countingSource = object : PairingTransport {57 override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }58 override suspend fun receive(): ByteArray = source.receive()59 override suspend fun close() {}60 }61 val big = "x".repeat(200 * 1024)62 val entries = (0 until 5).associate { "id-$it" to big }63 val tombstones = mapOf("dead-0" to 42L)64 sendVaultSnapshot(countingSource, entries, tombstones)65 val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)66 assertTrue("expected 3+ chunks, got $frames", frames >= 3)67 assertEquals(entries, receivedEntries)68 assertEquals(tombstones, receivedTombstones)69 }7071 @Test72 fun `rejects out-of-order chunks`() = runBlocking {73 val (source, receiver) = memoryPair()74 sendFrame(source, AdoptionFrame.Snapshot(75 chunkIndex = 1,76 chunkCount = 2,77 entries = mapOf("a" to "x"),78 tombstones = emptyMap(),79 ))80 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()81 assertTrue(error is IllegalStateException)82 assertTrue(error!!.message!!.contains("OUT_OF_ORDER"))83 }8485 @Test86 fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {87 val (source, receiver) = memoryPair()88 sendFrame(source, AdoptionFrame.Snapshot(89 chunkIndex = 0,90 chunkCount = MAX_SNAPSHOT_CHUNKS + 1,91 entries = emptyMap(),92 tombstones = emptyMap(),93 ))94 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()95 assertTrue(error is IllegalStateException)96 assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS"))97 }9899 @Test100 fun `validates ADOPT_SNAPSHOT frames`() {101 val valid = AdoptionFrame.Snapshot(102 chunkIndex = 0, chunkCount = 1,103 entries = mapOf("a" to "x"), tombstones = emptyMap(),104 )105 assertTrue(isAdoptSnapshot(valid))106 assertTrue(!isAdoptSnapshot(null))107 assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete))108 }109110 @Test111 fun `snapshot_capable serializes in ADOPT_REQUEST`() {112 val request = AdoptionFrame.Request(113 sessionRequestId = "req-1",114 deviceName = "test",115 deviceType = "extension",116 snapshotCapable = true,117 )118 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request)119 assertTrue(json.contains("\"snapshot_capable\":true"))120 121 val noFlag = AdoptionFrame.Request(122 sessionRequestId = "req-2",123 deviceName = "test",124 deviceType = "extension",125 )126 val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag)127 assertTrue(!jsonNoFlag.contains("snapshot_capable"))128 }129130 @Test131 fun `rejects raw frame exceeding wire ceiling`() = runBlocking {132 133 134 135 136 val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024137 val oversized = ByteArray(ceiling + 1)138 val channel = object : PairingTransport {139 override suspend fun send(msg: ByteArray) {}140 override suspend fun receive(): ByteArray = oversized141 override suspend fun close() {}142 }143 val error = runCatching { receiveVaultSnapshot(channel) }.exceptionOrNull()144 assertTrue(error is IllegalStateException)145 assertTrue(error!!.message!!.contains("CHUNK_TOO_LARGE"))146 }147148 @Test149 fun `rejects chunk_count mismatch on subsequent frame`() = runBlocking {150 val (source, receiver) = memoryPair()151 sendFrame(source, AdoptionFrame.Snapshot(152 chunkIndex = 0,153 chunkCount = 2,154 entries = mapOf("a" to "x"),155 tombstones = emptyMap(),156 ))157 sendFrame(source, AdoptionFrame.Snapshot(158 chunkIndex = 1,159 chunkCount = 3,160 entries = mapOf("b" to "y"),161 tombstones = emptyMap(),162 ))163 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()164 assertTrue(error is IllegalStateException)165 assertTrue(error!!.message!!.contains("CHUNK_COUNT_MISMATCH"))166 }167168}