RelayAdoptionReceiverTest.kt

10.3 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotNull9import org.junit.Assert.assertTrue10import org.junit.Test1112/**13 * Item 2 (REVIEW_DEFERRED_ITEMS_BRIEF.md): receiver inline-snapshot happy-path.14 *15 * The `sourceCanSendSnapshot` branch in `runRelayAdoptionReceiverSession` was16 * already reached by `RelayAdoptionHostTest` but only down the17 * catch-and-fallback path (source did not send a snapshot). What had no18 * coverage was the success path: a snapshot-capable source that actually19 * sends entries / tombstones / duress, the receiver applying them inline,20 * sending `ADOPT_SNAPSHOT_APPLIED`, and `markSynced` firing.21 *22 * These three tests cover the three sub-branches:23 * 1. snapshot_capable=true AND source sends a real snapshot -> applySnapshot24 *    called, SnapshotApplied sent, markSynced fired.25 * 2. snapshot_capable=true AND source sends nothing -> applySnapshot NOT26 *    called, session still returns paired device (sync fallback).27 * 3. snapshot_capable=false -> 15s wait is skipped, applySnapshot NOT called,28 *    session returns paired device quickly.29 */30class RelayAdoptionReceiverTest {31    private class MemoryRelaySocket(32        private val inbound: Channel<ByteArray>,33        private val outbound: Channel<ByteArray>,34    ) : RelayBinarySocket {35        override fun send(frame: ByteArray) {36            check(outbound.trySend(frame.copyOf()).isSuccess)37        }3839        override suspend fun receive(): ByteArray = inbound.receive()40        override fun close(code: Int, reason: String) {41            outbound.close()42        }4344        override fun closeAfterQueuedFrames(code: Int, reason: String) {45            outbound.close()46        }47    }4849    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {50        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)51        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)52        return MemoryRelaySocket(rightToLeft, leftToRight) to53            MemoryRelaySocket(leftToRight, rightToLeft)54    }5556    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(57        deviceId = "$name-id",58        deviceName = name,59        deviceType = DeviceType.ANDROID,60        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),61        signingPublicKeyHex = "cd".repeat(32),62        wsPort = 49142,63        relayUrl = "wss://relay.example.test",64    )6566    private fun smallSnapshot(): Pair<Map<String, String>, Map<String, Long>> {67        val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1")68        val tombstones = mapOf("dead-0" to 1L)69        return entries to tombstones70    }7172    @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking {73        val (sourceSocket, receiverSocket) = socketPair()74        val token = "AAECAwQFBgcICQoLDA0ODw"75        val relay = "wss://relay.example.test"76        val root = ByteArray(32) { (it + 3).toByte() }77        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")78        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")79        val sourceIdentity = identity("source", sourceKeys.publicKey)80        val receiverIdentity = identity("receiver", receiverKeys.publicKey)81        val (entries, tombstones) = smallSnapshot()82        val duressJson = "{\"duress\":true}"83        var sourceStored: PairedDevice? = null84        var receiverStored: PairedDevice? = null85        var sourceMarkedSynced = false86        var receiverAppliedEntries: Map<String, String>? = null87        var receiverAppliedTombstones: Map<String, Long>? = null88        var receiverAppliedDuress: String? = null89        var receiverMarkedSynced = false9091        val sourceJob = async {92            runRelayAdoptionSourceSession(93                sourceSocket,94                token,95                relay,96                sourceKeys.secretKey,97                sourceKeys.publicKey,98                sourceIdentity,99                object : AdoptionSourceDeps {100                    override val sourceIdentity = sourceIdentity101                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)102                    override suspend fun vaultId() = computeVaultId(root)103                    override suspend fun license() = "PPK-test"104                    override suspend fun onConsent(request: AdoptionFrame.Request) = true105                    override suspend fun upsertPairedDevice(device: PairedDevice) {106                        sourceStored = device107                    }108                    override suspend fun vaultSnapshot() = smallSnapshot()109                    override suspend fun duressSlotJson() = duressJson110                    override suspend fun markSynced(deviceId: String) {111                        sourceMarkedSynced = true112                    }113                },114                confirm = { _, _ -> true },115            )116        }117118        val received = runRelayAdoptionReceiverSession(119            receiverSocket,120            PairingInvitation(relay, token),121            object : AdoptionReceiverDeps {122                override val deviceName = "receiver"123                override val deviceType = DeviceType.ANDROID124                override fun generateDeviceId() = "session-only-request"125                override suspend fun confirmSas() = true126                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =127                    computeVaultId(hexToPubkey(rootKeyHex))128                override suspend fun deriveLocalIdentity() = receiverIdentity129                override suspend fun upsertPairedDevice(device: PairedDevice) {130                    receiverStored = device131                }132                override suspend fun applySnapshot(133                    entries: Map<String, String>,134                    tombstones: Map<String, Long>,135                    duressSlot: String?,136                ) {137                    receiverAppliedEntries = entries138                    receiverAppliedTombstones = tombstones139                    receiverAppliedDuress = duressSlot140                }141                override suspend fun markSynced(deviceId: String) {142                    receiverMarkedSynced = true143                }144                override fun onError(error: String) = throw AssertionError(error)145            },146            confirm = { true },147        )148149        assertNotNull(received)150        assertNotNull(sourceJob.await())151        assertEquals(entries, receiverAppliedEntries)152        assertEquals(tombstones, receiverAppliedTombstones)153        assertEquals(duressJson, receiverAppliedDuress)154        assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)155        assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)156        assertNotNull(receiverStored)157        assertNotNull(sourceStored)158    }159160    @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {161        val (sourceSocket, receiverSocket) = socketPair()162        val token = "AAECAwQFBgcICQoLDA0ODw"163        val relay = "wss://relay.example.test"164        val root = ByteArray(32) { (it + 5).toByte() }165        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")166        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")167        val sourceIdentity = identity("source", sourceKeys.publicKey)168        val receiverIdentity = identity("receiver", receiverKeys.publicKey)169        var sourceStored: PairedDevice? = null170        var receiverStored: PairedDevice? = null171        var receiverApplied = false172        var receiverMarkedSynced = false173174        val sourceJob = async {175            runRelayAdoptionSourceSession(176                sourceSocket,177                token,178                relay,179                sourceKeys.secretKey,180                sourceKeys.publicKey,181                sourceIdentity,182                object : AdoptionSourceDeps {183                    override val sourceIdentity = sourceIdentity184                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)185                    override suspend fun vaultId() = computeVaultId(root)186                    override suspend fun license() = "PPK-test"187                    override suspend fun onConsent(request: AdoptionFrame.Request) = true188                    override suspend fun upsertPairedDevice(device: PairedDevice) {189                        sourceStored = device190                    }191                    // vaultSnapshot() returns null -> source does not send a snapshot.192                    override suspend fun vaultSnapshot() = null193                },194                confirm = { _, _ -> true },195            )196        }197198        val received = runRelayAdoptionReceiverSession(199            receiverSocket,200            PairingInvitation(relay, token),201            object : AdoptionReceiverDeps {202                override val deviceName = "receiver"203                override val deviceType = DeviceType.ANDROID204                override fun generateDeviceId() = "session-only-request"205                override suspend fun confirmSas() = true206                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =207                    computeVaultId(hexToPubkey(rootKeyHex))208                override suspend fun deriveLocalIdentity() = receiverIdentity209                override suspend fun upsertPairedDevice(device: PairedDevice) {210                    receiverStored = device211                }212                override suspend fun applySnapshot(213                    entries: Map<String, String>,214                    tombstones: Map<String, Long>,215                    duressSlot: String?,216                ) {217                    receiverApplied = true218                }219                override suspend fun markSynced(deviceId: String) {220                    receiverMarkedSynced = true221                }222                override fun onError(error: String) = throw AssertionError(error)223            },224            confirm = { true },225        )226227        assertNotNull(received)228        assertNotNull(sourceJob.await())229        assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)230        assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)231        assertNotNull(receiverStored)232        assertNotNull(sourceStored)233    }234}