CodePairingServerTest.kt

7.3 KB · raw

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.TimeUnit1920/**21 * Covers the two behaviours that are easy to get quietly wrong: a bad token22 * must be refused with no reply at all, and an abandoned code must stop working.23 * Both are invisible when broken — a leaky token check still pairs happily with24 * the right code, and a window that never closes looks identical to one that25 * does until months later.26 */27class 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    /** Enough of the registry for pairing; the real store needs Android crypto. */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    /**70     * Collects into a queue on a job that stays alive. Terminating the flow with71     * `first {}` would trip awaitClose and shut the listener down before the72     * test could connect to it.73     */74    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    /**112     * The browser's pairing socket is binary end to end (`openPairingSocket` in113     * peach-browser's pairing-dial.ts sets `binaryType = "arraybuffer"` and114     * sends `Uint8Array`). This listener required a TEXT hello and answered with115     * a TEXT ack, so every correct code was silently closed on and the browser116     * reported "invalid code" — pairing could not succeed at all. Neither half117     * fails loudly, which is exactly why both are pinned here.118     */119    @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            // 300ms rather than the real five minutes; the window is a parameter147            // precisely so this is testable at all.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        // Not asserting a specific address — CI and dev machines differ — only163        // that whatever is chosen is one a LAN peer could route back to.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}