package com.peachpasswords.sync import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test /** * Item 2 (REVIEW_DEFERRED_ITEMS_BRIEF.md): receiver inline-snapshot happy-path. * * The `sourceCanSendSnapshot` branch in `runRelayAdoptionReceiverSession` was * already reached by `RelayAdoptionHostTest` but only down the * catch-and-fallback path (source did not send a snapshot). 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. * * These three tests cover the three sub-branches: * 1. snapshot_capable=true AND source sends a real snapshot -> applySnapshot * called, SnapshotApplied sent, markSynced fired. * 2. snapshot_capable=true AND source sends nothing -> applySnapshot NOT * called, session still returns paired device (sync fallback). * 3. snapshot_capable=false -> 15s wait is skipped, applySnapshot NOT called, * session returns paired device quickly. */ class RelayAdoptionReceiverTest { private class MemoryRelaySocket( private val inbound: Channel, private val outbound: Channel, ) : RelayBinarySocket { override fun send(frame: ByteArray) { check(outbound.trySend(frame.copyOf()).isSuccess) } override suspend fun receive(): ByteArray = inbound.receive() override fun close(code: Int, reason: String) { outbound.close() } override fun closeAfterQueuedFrames(code: Int, reason: String) { outbound.close() } } private fun socketPair(): Pair { val leftToRight = Channel(Channel.UNLIMITED) val rightToLeft = Channel(Channel.UNLIMITED) return MemoryRelaySocket(rightToLeft, leftToRight) to MemoryRelaySocket(leftToRight, rightToLeft) } private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity( deviceId = "$name-id", deviceName = name, deviceType = DeviceType.ANDROID, longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey), signingPublicKeyHex = "cd".repeat(32), wsPort = 49142, relayUrl = "wss://relay.example.test", ) private fun smallSnapshot(): Pair, Map> { val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1") val tombstones = mapOf("dead-0" to 1L) return entries to tombstones } @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking { val (sourceSocket, receiverSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val relay = "wss://relay.example.test" val root = ByteArray(32) { (it + 3).toByte() } val sourceKeys = deriveLocalSyncKeypair(root, "source-id") val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id") val sourceIdentity = identity("source", sourceKeys.publicKey) val receiverIdentity = identity("receiver", receiverKeys.publicKey) val (entries, tombstones) = smallSnapshot() val duressJson = "{\"duress\":true}" var sourceStored: PairedDevice? = null var receiverStored: PairedDevice? = null var sourceMarkedSynced = false var receiverAppliedEntries: Map? = null var receiverAppliedTombstones: Map? = null var receiverAppliedDuress: String? = null var receiverMarkedSynced = false val sourceJob = async { runRelayAdoptionSourceSession( sourceSocket, token, relay, sourceKeys.secretKey, sourceKeys.publicKey, sourceIdentity, object : AdoptionSourceDeps { override val sourceIdentity = sourceIdentity override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root) override suspend fun vaultId() = computeVaultId(root) override suspend fun license() = "PPK-test" override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun upsertPairedDevice(device: PairedDevice) { sourceStored = device } override suspend fun vaultSnapshot() = smallSnapshot() override suspend fun duressSlotJson() = duressJson override suspend fun markSynced(deviceId: String) { sourceMarkedSynced = true } }, confirm = { _, _ -> true }, ) } val received = runRelayAdoptionReceiverSession( receiverSocket, PairingInvitation(relay, token), object : AdoptionReceiverDeps { override val deviceName = "receiver" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = "session-only-request" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String): String = computeVaultId(hexToPubkey(rootKeyHex)) override suspend fun deriveLocalIdentity() = receiverIdentity override suspend fun upsertPairedDevice(device: PairedDevice) { receiverStored = device } override suspend fun applySnapshot( entries: Map, tombstones: Map, duressSlot: String?, ) { receiverAppliedEntries = entries receiverAppliedTombstones = tombstones receiverAppliedDuress = duressSlot } override suspend fun markSynced(deviceId: String) { receiverMarkedSynced = true } override fun onError(error: String) = throw AssertionError(error) }, confirm = { true }, ) assertNotNull(received) assertNotNull(sourceJob.await()) assertEquals(entries, receiverAppliedEntries) assertEquals(tombstones, receiverAppliedTombstones) assertEquals(duressJson, receiverAppliedDuress) assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced) assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced) assertNotNull(receiverStored) assertNotNull(sourceStored) } @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking { val (sourceSocket, receiverSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val relay = "wss://relay.example.test" val root = ByteArray(32) { (it + 5).toByte() } val sourceKeys = deriveLocalSyncKeypair(root, "source-id") val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id") val sourceIdentity = identity("source", sourceKeys.publicKey) val receiverIdentity = identity("receiver", receiverKeys.publicKey) var sourceStored: PairedDevice? = null var receiverStored: PairedDevice? = null var receiverApplied = false var receiverMarkedSynced = false val sourceJob = async { runRelayAdoptionSourceSession( sourceSocket, token, relay, sourceKeys.secretKey, sourceKeys.publicKey, sourceIdentity, object : AdoptionSourceDeps { override val sourceIdentity = sourceIdentity override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root) override suspend fun vaultId() = computeVaultId(root) override suspend fun license() = "PPK-test" override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun upsertPairedDevice(device: PairedDevice) { sourceStored = device } // vaultSnapshot() returns null -> source does not send a snapshot. override suspend fun vaultSnapshot() = null }, confirm = { _, _ -> true }, ) } val received = runRelayAdoptionReceiverSession( receiverSocket, PairingInvitation(relay, token), object : AdoptionReceiverDeps { override val deviceName = "receiver" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = "session-only-request" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String): String = computeVaultId(hexToPubkey(rootKeyHex)) override suspend fun deriveLocalIdentity() = receiverIdentity override suspend fun upsertPairedDevice(device: PairedDevice) { receiverStored = device } override suspend fun applySnapshot( entries: Map, tombstones: Map, duressSlot: String?, ) { receiverApplied = true } override suspend fun markSynced(deviceId: String) { receiverMarkedSynced = true } override fun onError(error: String) = throw AssertionError(error) }, confirm = { true }, ) assertNotNull(received) assertNotNull(sourceJob.await()) assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied) assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced) assertNotNull(receiverStored) assertNotNull(sourceStored) } }