CodePairingServerTest.kt

5.6 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 org.junit.Assert.assertEquals14import org.junit.Assert.assertTrue15import org.junit.Test16import java.util.concurrent.CompletableFuture17import java.util.concurrent.TimeUnit1819/**20 * Covers the two behaviours that are easy to get quietly wrong: a bad token21 * must be refused with no reply at all, and an abandoned code must stop working.22 * Both are invisible when broken — a leaky token check still pairs happily with23 * the right code, and a window that never closes looks identical to one that24 * does until months later.25 */26class CodePairingServerTest {2728    private val identity = LocalChirpIdentity(29        deviceId = "test-device",30        deviceName = "Test Phone",31        deviceType = DeviceType.ANDROID,32        longTermPublicKeyHex = "00".repeat(32),33        signingPublicKeyHex = "00".repeat(32),34    )3536    /** Enough of the registry for pairing; the real store needs Android crypto. */37    private class FakeRegistry : PairedDeviceRegistry {38        val devices = mutableListOf<PairedDevice>()39        override fun getAll(): List<PairedDevice> = devices40        override fun get(id: String): PairedDevice? = devices.firstOrNull { it.deviceId == id }41        override fun upsert(device: PairedDevice) { devices.add(device) }42    }4344    private fun server() = CodePairingServer(FakeRegistry())4546    private class Client {47        val opened = CompletableFuture<Boolean>()48        val closed = CompletableFuture<Boolean>()49        val frames = java.util.concurrent.LinkedBlockingQueue<String>()50        lateinit var ws: WebSocket5152        fun connect(port: Int) {53            ws = OkHttpClient.Builder().readTimeout(10, TimeUnit.SECONDS).build().newWebSocket(54                Request.Builder().url("ws://127.0.0.1:$port").build(),55                object : WebSocketListener() {56                    override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }57                    override fun onMessage(webSocket: WebSocket, text: String) { frames.add(text) }58                    override fun onFailure(webSocket: WebSocket, t: Throwable, r: Response?) { closed.complete(true) }59                    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { closed.complete(true) }60                },61            )62            opened.get(5, TimeUnit.SECONDS)63        }64    }6566    /**67     * Collects into a queue on a job that stays alive. Terminating the flow with68     * `first {}` would trip awaitClose and shut the listener down before the69     * test could connect to it.70     */71    private class Harness(val scope: CoroutineScope) {72        val events = java.util.concurrent.LinkedBlockingQueue<PairingEvent>()73        inline fun <reified T : PairingEvent> await(timeoutMs: Long = 10_000): T {74            val deadline = System.currentTimeMillis() + timeoutMs75            val seen = mutableListOf<PairingEvent>()76            while (System.currentTimeMillis() < deadline) {77                val e = events.poll(200, TimeUnit.MILLISECONDS) ?: continue78                seen.add(e)79                if (e is T) return e80            }81            throw AssertionError("never saw ${T::class.simpleName}; saw $seen")82        }83    }8485    private fun CoroutineScope.run(srv: CodePairingServer, windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS): Harness {86        val h = Harness(this)87        launch { srv.start(this@run, ByteArray(32), ByteArray(32), identity, windowMs).collect { h.events.add(it) } }88        return h89    }9091    @Test fun `a wrong token gets no reply and the socket closes`() = runBlocking {92        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)93        val srv = server()94        try {95            val listening = scope.run(srv).await<PairingEvent.Listening>()9697            val c = Client()98            c.connect(listening.port)99            c.ws.send("""{"type":"PEACH_HELLO","token":"00000000"}""")100101            assertTrue("socket should be closed on a bad token", c.closed.get(5, TimeUnit.SECONDS))102            assertEquals("nothing should be sent back — silence is the signal", 0, c.frames.size)103        } finally {104            srv.stop()105        }106    }107108    @Test fun `the pairing window expires and stops accepting`() = runBlocking {109        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)110        val srv = server()111        try {112            // 300ms rather than the real five minutes; the window is a parameter113            // precisely so this is testable at all.114            val h = scope.run(srv, windowMs = 300)115            val listening = h.await<PairingEvent.Listening>()116            h.await<PairingEvent.Expired>(timeoutMs = 5_000)117118            var refused = false119            runCatching { java.net.Socket("127.0.0.1", listening.port).close() }120                .onFailure { refused = true }121            assertTrue("the port should be closed after the window", refused)122        } finally {123            srv.stop()124        }125    }126127    @Test fun `the advertised address is never a tunnel interface`() {128        // Not asserting a specific address — CI and dev machines differ — only129        // that whatever is chosen is one a LAN peer could route back to.130        val ip = server().localIpv4()131        if (ip != null) {132            assertTrue("expected a site-local address, got $ip", java.net.InetAddress.getByName(ip).isSiteLocalAddress)133        }134    }135}