478f372e5910
test: Phase C receiver inline-snapshot happy-path coverage (v2.9.9 receiver test)
- time
- 2026-07-30 22:49 UTC · 2 months ago
- machine
- koh2
- parent
- 943c244c435c
↓ peach-android-478f372e.tar.gzatlas at this save
files changed1 file
app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+234 −0app/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+}