package com.peachpasswords.sync import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.ByteString.Companion.toByteString import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit /** * Covers the two behaviours that are easy to get quietly wrong: a bad token * must be refused with no reply at all, and an abandoned code must stop working. * Both are invisible when broken — a leaky token check still pairs happily with * the right code, and a window that never closes looks identical to one that * does until months later. */ class CodePairingServerTest { private val identity = LocalChirpIdentity( deviceId = "test-device", deviceName = "Test Phone", deviceType = DeviceType.ANDROID, longTermPublicKeyHex = "00".repeat(32), signingPublicKeyHex = "00".repeat(32), ) /** Enough of the registry for pairing; the real store needs Android crypto. */ private class FakeRegistry : PairedDeviceRegistry { val devices = mutableListOf() override fun getAll(): List = devices override fun get(id: String): PairedDevice? = devices.firstOrNull { it.deviceId == id } override fun upsert(device: PairedDevice) { devices.add(device) } } private fun server() = CodePairingServer(FakeRegistry()) private class Client { val opened = CompletableFuture() val closed = CompletableFuture() val frames = java.util.concurrent.LinkedBlockingQueue() val binaryFrames = java.util.concurrent.LinkedBlockingQueue() lateinit var ws: WebSocket fun connect(port: Int) { ws = OkHttpClient.Builder().readTimeout(10, TimeUnit.SECONDS).build().newWebSocket( Request.Builder().url("ws://127.0.0.1:$port").build(), object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) } override fun onMessage(webSocket: WebSocket, text: String) { frames.add(text) } override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { binaryFrames.add(bytes.toByteArray()) } override fun onFailure(webSocket: WebSocket, t: Throwable, r: Response?) { closed.complete(true) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { closed.complete(true) } }, ) opened.get(5, TimeUnit.SECONDS) } } /** * Collects into a queue on a job that stays alive. Terminating the flow with * `first {}` would trip awaitClose and shut the listener down before the * test could connect to it. */ private class Harness(val scope: CoroutineScope) { val events = java.util.concurrent.LinkedBlockingQueue() inline fun await(timeoutMs: Long = 10_000): T { val deadline = System.currentTimeMillis() + timeoutMs val seen = mutableListOf() while (System.currentTimeMillis() < deadline) { val e = events.poll(200, TimeUnit.MILLISECONDS) ?: continue seen.add(e) if (e is T) return e } throw AssertionError("never saw ${T::class.simpleName}; saw $seen") } } private fun CoroutineScope.run(srv: CodePairingServer, windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS): Harness { val h = Harness(this) launch { srv.start(this@run, ByteArray(32), ByteArray(32), identity, windowMs).collect { h.events.add(it) } } return h } @Test fun `a wrong token gets no reply and the socket closes`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val srv = server() try { val listening = scope.run(srv).await() val c = Client() c.connect(listening.port) c.ws.send("""{"type":"PEACH_HELLO","token":"00000000"}""") assertTrue("socket should be closed on a bad token", c.closed.get(5, TimeUnit.SECONDS)) assertEquals("nothing should be sent back — silence is the signal", 0, c.frames.size) } finally { srv.stop() } } /** * The browser's pairing socket is binary end to end (`openPairingSocket` in * peach-browser's pairing-dial.ts sets `binaryType = "arraybuffer"` and * sends `Uint8Array`). This listener required a TEXT hello and answered with * a TEXT ack, so every correct code was silently closed on and the browser * reported "invalid code" — pairing could not succeed at all. Neither half * fails loudly, which is exactly why both are pinned here. */ @Test fun `a binary hello is accepted and answered with a binary ack`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val srv = server() try { val listening = scope.run(srv).await() val token = PairingCode.parse(listening.code)!!.tokenHex val c = Client() c.connect(listening.port) c.ws.send("""{"type":"PEACH_HELLO","token":"$token"}""".toByteArray().toByteString()) val ack = c.binaryFrames.poll(10, TimeUnit.SECONDS) assertTrue("the phone must answer a binary hello, not close on it", ack != null) assertTrue( "the ack must be a BINARY frame — a text one decodes to zero bytes in the browser", c.frames.isEmpty(), ) assertTrue(String(ack!!), String(ack).contains("PEACH_HELLO_ACK")) } finally { srv.stop() } } @Test fun `the pairing window expires and stops accepting`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val srv = server() try { // 300ms rather than the real five minutes; the window is a parameter // precisely so this is testable at all. val h = scope.run(srv, windowMs = 300) val listening = h.await() h.await(timeoutMs = 5_000) var refused = false runCatching { java.net.Socket("127.0.0.1", listening.port).close() } .onFailure { refused = true } assertTrue("the port should be closed after the window", refused) } finally { srv.stop() } } @Test fun `the advertised address is never a tunnel interface`() { // Not asserting a specific address — CI and dev machines differ — only // that whatever is chosen is one a LAN peer could route back to. val ip = server().localIpv4() if (ip != null) { assertTrue("expected a site-local address, got $ip", java.net.InetAddress.getByName(ip).isSiteLocalAddress) } } }