RelayAdoptionReceiverTest.kt

14.5 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.Test11import kotlin.system.measureTimeMillis1213/**14 * Item 2 (REVIEW_DEFERRED_ITEMS_BRIEF.md): receiver inline-snapshot happy-path.15 *16 * The `sourceCanSendSnapshot` branch in `runRelayAdoptionReceiverSession` was17 * already reached by `RelayAdoptionHostTest` but only down the18 * catch-and-fallback path (source did not send a snapshot). What had no19 * coverage was the success path: a snapshot-capable source that actually20 * sends entries / tombstones / duress, the receiver applying them inline,21 * sending `ADOPT_SNAPSHOT_APPLIED`, and `markSynced` firing.22 *23 * These three tests cover the three sub-branches:24 * 1. snapshot_capable=true AND source sends a real snapshot -> applySnapshot25 *    called, SnapshotApplied sent, markSynced fired.26 * 2. snapshot_capable=true AND source sends nothing -> applySnapshot NOT27 *    called, session still returns paired device (sync fallback).28 * 3. snapshot_capable=false -> 15s wait is skipped, applySnapshot NOT called,29 *    session returns paired device quickly.30 */31class RelayAdoptionReceiverTest {32    private class MemoryRelaySocket(33        private val inbound: Channel<ByteArray>,34        private val outbound: Channel<ByteArray>,35    ) : RelayBinarySocket {36        override fun send(frame: ByteArray) {37            check(outbound.trySend(frame.copyOf()).isSuccess)38        }3940        override suspend fun receive(): ByteArray = inbound.receive()41        override fun close(code: Int, reason: String) {42            outbound.close()43        }4445        override fun closeAfterQueuedFrames(code: Int, reason: String) {46            outbound.close()47        }48    }4950    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {51        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)52        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)53        return MemoryRelaySocket(rightToLeft, leftToRight) to54            MemoryRelaySocket(leftToRight, rightToLeft)55    }5657    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(58        deviceId = "$name-id",59        deviceName = name,60        deviceType = DeviceType.ANDROID,61        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),62        signingPublicKeyHex = "cd".repeat(32),63        wsPort = 49142,64        relayUrl = "wss://relay.example.test",65    )6667    private fun smallSnapshot(): Pair<Map<String, String>, Map<String, Long>> {68        val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1")69        val tombstones = mapOf("dead-0" to 1L)70        return entries to tombstones71    }7273    @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking {74        val (sourceSocket, receiverSocket) = socketPair()75        val token = "AAECAwQFBgcICQoLDA0ODw"76        val relay = "wss://relay.example.test"77        val root = ByteArray(32) { (it + 3).toByte() }78        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")79        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")80        val sourceIdentity = identity("source", sourceKeys.publicKey)81        val receiverIdentity = identity("receiver", receiverKeys.publicKey)82        val (entries, tombstones) = smallSnapshot()83        val duressJson = "{\"duress\":true}"84        var sourceStored: PairedDevice? = null85        var receiverStored: PairedDevice? = null86        var sourceMarkedSynced = false87        var receiverAppliedEntries: Map<String, String>? = null88        var receiverAppliedTombstones: Map<String, Long>? = null89        var receiverAppliedDuress: String? = null90        var receiverMarkedSynced = false9192        val sourceJob = async {93            runRelayAdoptionSourceSession(94                sourceSocket,95                token,96                relay,97                sourceKeys.secretKey,98                sourceKeys.publicKey,99                sourceIdentity,100                object : AdoptionSourceDeps {101                    override val sourceIdentity = sourceIdentity102                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)103                    override suspend fun vaultId() = computeVaultId(root)104                    override suspend fun license() = "PPK-test"105                    override suspend fun onConsent(request: AdoptionFrame.Request) = true106                    override suspend fun upsertPairedDevice(device: PairedDevice) {107                        sourceStored = device108                    }109                    override suspend fun vaultSnapshot() = smallSnapshot()110                    override suspend fun duressSlotJson() = duressJson111                    override suspend fun markSynced(deviceId: String) {112                        sourceMarkedSynced = true113                    }114                },115                confirm = { _, _ -> true },116            )117        }118119        val received = runRelayAdoptionReceiverSession(120            receiverSocket,121            PairingInvitation(relay, token),122            object : AdoptionReceiverDeps {123                override val deviceName = "receiver"124                override val deviceType = DeviceType.ANDROID125                override fun generateDeviceId() = "session-only-request"126                override suspend fun confirmSas() = true127                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =128                    computeVaultId(hexToPubkey(rootKeyHex))129                override suspend fun deriveLocalIdentity() = receiverIdentity130                override suspend fun upsertPairedDevice(device: PairedDevice) {131                    receiverStored = device132                }133                override suspend fun applySnapshot(134                    entries: Map<String, String>,135                    tombstones: Map<String, Long>,136                    duressSlot: String?,137                ) {138                    receiverAppliedEntries = entries139                    receiverAppliedTombstones = tombstones140                    receiverAppliedDuress = duressSlot141                }142                override suspend fun markSynced(deviceId: String) {143                    receiverMarkedSynced = true144                }145                override fun onError(error: String) = throw AssertionError(error)146            },147            confirm = { true },148        )149150        assertNotNull(received)151        assertNotNull(sourceJob.await())152        assertEquals(entries, receiverAppliedEntries)153        assertEquals(tombstones, receiverAppliedTombstones)154        assertEquals(duressJson, receiverAppliedDuress)155        assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)156        assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)157        assertNotNull(receiverStored)158        assertNotNull(sourceStored)159    }160161    @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {162        val (sourceSocket, receiverSocket) = socketPair()163        val token = "AAECAwQFBgcICQoLDA0ODw"164        val relay = "wss://relay.example.test"165        val root = ByteArray(32) { (it + 5).toByte() }166        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")167        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")168        val sourceIdentity = identity("source", sourceKeys.publicKey)169        val receiverIdentity = identity("receiver", receiverKeys.publicKey)170        var sourceStored: PairedDevice? = null171        var receiverStored: PairedDevice? = null172        var receiverApplied = false173        var receiverMarkedSynced = false174175        val sourceJob = async {176            runRelayAdoptionSourceSession(177                sourceSocket,178                token,179                relay,180                sourceKeys.secretKey,181                sourceKeys.publicKey,182                sourceIdentity,183                object : AdoptionSourceDeps {184                    override val sourceIdentity = sourceIdentity185                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)186                    override suspend fun vaultId() = computeVaultId(root)187                    override suspend fun license() = "PPK-test"188                    override suspend fun onConsent(request: AdoptionFrame.Request) = true189                    override suspend fun upsertPairedDevice(device: PairedDevice) {190                        sourceStored = device191                    }192                    // vaultSnapshot() returns null -> source does not send a snapshot.193                    override suspend fun vaultSnapshot() = null194                },195                confirm = { _, _ -> true },196            )197        }198199        val received = runRelayAdoptionReceiverSession(200            receiverSocket,201            PairingInvitation(relay, token),202            object : AdoptionReceiverDeps {203                override val deviceName = "receiver"204                override val deviceType = DeviceType.ANDROID205                override fun generateDeviceId() = "session-only-request"206                override suspend fun confirmSas() = true207                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =208                    computeVaultId(hexToPubkey(rootKeyHex))209                override suspend fun deriveLocalIdentity() = receiverIdentity210                override suspend fun upsertPairedDevice(device: PairedDevice) {211                    receiverStored = device212                }213                override suspend fun applySnapshot(214                    entries: Map<String, String>,215                    tombstones: Map<String, Long>,216                    duressSlot: String?,217                ) {218                    receiverApplied = true219                }220                override suspend fun markSynced(deviceId: String) {221                    receiverMarkedSynced = true222                }223                override fun onError(error: String) = throw AssertionError(error)224            },225            confirm = { true },226        )227228        assertNotNull(received)229        assertNotNull(sourceJob.await())230        assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)231        assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)232        assertNotNull(receiverStored)233        assertNotNull(sourceStored)234    }235236    @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {237        val (sourceSocket, receiverSocket) = socketPair()238        val token = "AAECAwQFBgcICQoLDA0ODw"239        val relay = "wss://relay.example.test"240        val root = ByteArray(32) { (it + 7).toByte() }241        val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")242        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")243        val sourceIdentity = identity("old-source", sourceKeys.publicKey)244        val receiverIdentity = identity("receiver", receiverKeys.publicKey)245        var receiverApplied = false246247        val sourceJob = async {248            sourceSocket.receive()249            sourceSocket.send(250                org.json.JSONObject().put("type", "PEACH_HELLO_ACK")251                    .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))252                    .toString().toByteArray(),253            )254            val noise = noiseNKResponder(255                sourceKeys.secretKey,256                sourceKeys.publicKey,257                sourceSocket::send,258                sourceSocket::receive,259            )260            try {261                val channel = object : PairingTransport {262                    override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))263                    override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())264                    override suspend fun close() = sourceSocket.close()265                }266                assertTrue(receiveFrame(channel) is AdoptionFrame.Request)267                assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)268                sendFrame(channel, AdoptionFrame.Grant(269                    rootKeyHex = Rendezvous.bytesToHex(root),270                    vaultId = computeVaultId(root),271                    license = "PPK-test",272                    source = AdoptionFrame.Source(273                        deviceId = sourceIdentity.deviceId,274                        deviceName = sourceIdentity.deviceName,275                        deviceType = sourceIdentity.deviceType.wireValue,276                        longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,277                        signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,278                    ),279                    snapshotCapable = false,280                ))281                assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)282                sendFrame(channel, AdoptionFrame.Complete)283                assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)284            } finally {285                noise.close()286            }287        }288289        var received: PairedDevice? = null290        val elapsedMs = measureTimeMillis {291            received = runRelayAdoptionReceiverSession(292                receiverSocket,293                PairingInvitation(relay, token),294                object : AdoptionReceiverDeps {295                    override val deviceName = "receiver"296                    override val deviceType = DeviceType.ANDROID297                    override fun generateDeviceId() = "session-only-request"298                    override suspend fun confirmSas() = true299                    override suspend fun createVaultFromRootKey(rootKeyHex: String) =300                        computeVaultId(hexToPubkey(rootKeyHex))301                    override suspend fun deriveLocalIdentity() = receiverIdentity302                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit303                    override suspend fun applySnapshot(304                        entries: Map<String, String>,305                        tombstones: Map<String, Long>,306                        duressSlot: String?,307                    ) {308                        receiverApplied = true309                    }310                    override fun onError(error: String) = throw AssertionError(error)311                },312                confirm = { true },313            )314        }315316        assertNotNull(received)317        sourceJob.await()318        assertFalse(receiverApplied)319        assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)320    }321}