VaultAdoptSnapshotTest.kt

5.0 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.channels.Channel4import kotlinx.coroutines.runBlocking5import org.junit.Assert.assertEquals6import org.junit.Assert.assertTrue7import org.junit.Test89/**10 * Inline vault snapshot protocol parity with the browser11 * `vault-adopt-snapshot.test.ts`. Round-trip, chunking, and validation12 * tests for the ADOPT_SNAPSHOT / ADOPT_SNAPSHOT_APPLIED frames.13 */14class 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        // Absent when null (encodeDefaults = false)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    }129}