1package com.peachpasswords.sync23import kotlinx.coroutines.CoroutineScope4import kotlinx.coroutines.Dispatchers5import kotlinx.coroutines.SupervisorJob6import kotlinx.coroutines.launch7import kotlinx.coroutines.runBlocking8import okhttp3.OkHttpClient9import okhttp3.Request10import okhttp3.Response11import okhttp3.WebSocket12import okhttp3.WebSocketListener13import okio.ByteString.Companion.toByteString14import org.junit.Assert.assertEquals15import org.junit.Assert.assertTrue16import org.junit.Test17import java.util.concurrent.CompletableFuture18import java.util.concurrent.TimeUnit192021222324252627class CodePairingServerTest {2829 private val identity = LocalChirpIdentity(30 deviceId = "test-device",31 deviceName = "Test Phone",32 deviceType = DeviceType.ANDROID,33 longTermPublicKeyHex = "00".repeat(32),34 signingPublicKeyHex = "00".repeat(32),35 )3637 38 private class FakeRegistry : PairedDeviceRegistry {39 val devices = mutableListOf<PairedDevice>()40 override fun getAll(): List<PairedDevice> = devices41 override fun get(id: String): PairedDevice? = devices.firstOrNull { it.deviceId == id }42 override fun upsert(device: PairedDevice) { devices.add(device) }43 }4445 private fun server() = CodePairingServer(FakeRegistry())4647 private class Client {48 val opened = CompletableFuture<Boolean>()49 val closed = CompletableFuture<Boolean>()50 val frames = java.util.concurrent.LinkedBlockingQueue<String>()51 val binaryFrames = java.util.concurrent.LinkedBlockingQueue<ByteArray>()52 lateinit var ws: WebSocket5354 fun connect(port: Int) {55 ws = OkHttpClient.Builder().readTimeout(10, TimeUnit.SECONDS).build().newWebSocket(56 Request.Builder().url("ws://127.0.0.1:$port").build(),57 object : WebSocketListener() {58 override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }59 override fun onMessage(webSocket: WebSocket, text: String) { frames.add(text) }60 override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { binaryFrames.add(bytes.toByteArray()) }61 override fun onFailure(webSocket: WebSocket, t: Throwable, r: Response?) { closed.complete(true) }62 override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { closed.complete(true) }63 },64 )65 opened.get(5, TimeUnit.SECONDS)66 }67 }6869 7071727374 private class Harness(val scope: CoroutineScope) {75 val events = java.util.concurrent.LinkedBlockingQueue<PairingEvent>()76 inline fun <reified T : PairingEvent> await(timeoutMs: Long = 10_000): T {77 val deadline = System.currentTimeMillis() + timeoutMs78 val seen = mutableListOf<PairingEvent>()79 while (System.currentTimeMillis() < deadline) {80 val e = events.poll(200, TimeUnit.MILLISECONDS) ?: continue81 seen.add(e)82 if (e is T) return e83 }84 throw AssertionError("never saw ${T::class.simpleName}; saw $seen")85 }86 }8788 private fun CoroutineScope.run(srv: CodePairingServer, windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS): Harness {89 val h = Harness(this)90 launch { srv.start(this@run, ByteArray(32), ByteArray(32), identity, windowMs).collect { h.events.add(it) } }91 return h92 }9394 @Test fun `a wrong token gets no reply and the socket closes`() = runBlocking {95 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)96 val srv = server()97 try {98 val listening = scope.run(srv).await<PairingEvent.Listening>()99100 val c = Client()101 c.connect(listening.port)102 c.ws.send("""{"type":"PEACH_HELLO","token":"00000000"}""")103104 assertTrue("socket should be closed on a bad token", c.closed.get(5, TimeUnit.SECONDS))105 assertEquals("nothing should be sent back — silence is the signal", 0, c.frames.size)106 } finally {107 srv.stop()108 }109 }110111 112113114115116117118119 @Test fun `a binary hello is accepted and answered with a binary ack`() = runBlocking {120 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)121 val srv = server()122 try {123 val listening = scope.run(srv).await<PairingEvent.Listening>()124 val token = PairingCode.parse(listening.code)!!.tokenHex125126 val c = Client()127 c.connect(listening.port)128 c.ws.send("""{"type":"PEACH_HELLO","token":"$token"}""".toByteArray().toByteString())129130 val ack = c.binaryFrames.poll(10, TimeUnit.SECONDS)131 assertTrue("the phone must answer a binary hello, not close on it", ack != null)132 assertTrue(133 "the ack must be a BINARY frame — a text one decodes to zero bytes in the browser",134 c.frames.isEmpty(),135 )136 assertTrue(String(ack!!), String(ack).contains("PEACH_HELLO_ACK"))137 } finally {138 srv.stop()139 }140 }141142 @Test fun `the pairing window expires and stops accepting`() = runBlocking {143 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)144 val srv = server()145 try {146 147 148 val h = scope.run(srv, windowMs = 300)149 val listening = h.await<PairingEvent.Listening>()150 h.await<PairingEvent.Expired>(timeoutMs = 5_000)151152 var refused = false153 runCatching { java.net.Socket("127.0.0.1", listening.port).close() }154 .onFailure { refused = true }155 assertTrue("the port should be closed after the window", refused)156 } finally {157 srv.stop()158 }159 }160161 @Test fun `the advertised address is never a tunnel interface`() {162 163 164 val ip = server().localIpv4()165 if (ip != null) {166 assertTrue("expected a site-local address, got $ip", java.net.InetAddress.getByName(ip).isSiteLocalAddress)167 }168 }169}