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.Test1112131415161718192021222324252627282930class 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 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}