478f372e5910

test: Phase C receiver inline-snapshot happy-path coverage (v2.9.9 receiver test)

Adds covers the receiver's sourceCanSendSnapshot branch (RelayAdoptionReceiver.kt:232-256) with the success path that was missing from RelayAdoptionHostTest. That branch was already reached down the catch-and-fallback path; what had no coverage was the success path: a snapshot-capable source that actually sends entries / tombstones / duress, the receiver applying them inline, sending ADOPT_SNAPSHOT_APPLIED, and markSynced firing. app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt: - `receiver applies an inline snapshot when the source is snapshot capable`: source advertises snapshotCapable, sends entries + tombstones + duress via sendVaultSnapshot. Asserts applySnapshot received all three, markSynced fired on both sides. 5.0s. - `receiver falls back to sync when the source sends no snapshot`: source advertises snapshotCapable but vaultSnapshot() returns null. Asserts applySnapshot NOT called, markSynced NOT called, paired devices still durably recorded on both sides. 15s (wait timeout elapses). The third test from the brief (`receiver skips the inline wait when the grant is not snapshot capable`) is unreachable through the public session API: requestAdoption hardcodes snapshotCapable=true on the REQUEST, so the source's grantAdoption echoes snapshotCapable=true, and the receiver always enters the 15s wait. Testing the backward-compat 'old source did not echo' path requires hand-rolling the protocol up to the Grant, which is a separate test file. The two tests here cover the main success and fallback paths called out in the brief's Item 2. Versions: peach-android VERSION unchanged at 2.9.9 (test-only addition; no production code change). versionCode unchanged. Gate: focused testDebugUnitTest relay-adoption-receiver-test 2 tests / 0 failed / 0 errors; full testDebugUnitTest --rerun-tasks BUILD SUCCESSFUL 2m40s. 855 tests / 119 suites / 0 failures / 0 errors / 0 skips (was 846 / 117 baseline + 2 new = 848 / 118 plus 7 / 1 from tests that were already in the tree but not surfaced in the previous worker's count). lintDebug BUILD SUCCESSFUL 31s.

time
2026-07-30 22:49 UTC · 2 months ago
machine
koh2
parent
943c244c435c
1 file app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+234 −0
app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+234 −0
@@ -0,0 +1,234 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.channels.Channel5+import kotlinx.coroutines.runBlocking6+import org.junit.Assert.assertEquals7+import org.junit.Assert.assertFalse8+import org.junit.Assert.assertNotNull9+import org.junit.Assert.assertTrue10+import org.junit.Test11+12+/**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+ */30+class 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+        }38+39+        override suspend fun receive(): ByteArray = inbound.receive()40+        override fun close(code: Int, reason: String) {41+            outbound.close()42+        }43+44+        override fun closeAfterQueuedFrames(code: Int, reason: String) {45+            outbound.close()46+        }47+    }48+49+    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+    }55+56+    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+    )65+66+    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+    }71+72+    @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 = false90+91+        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+        }117+118+        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+        )148+149+        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+    }159+160+    @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 = false173+174+        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+        }197+198+        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+        )226+227+        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+}

view file