feat(aiturk): reach the IDE through an end-to-end encrypted tunnel

A phone on the same Wi-Fi could not reach the computer at all (client
isolation on the router), and every failure was shown as one 'same
Wi-Fi' message. Contract v3: the phone only dials turkservis.online.

Transport: a relay pairing lives on the virtual host
<relayId>.relay.aiturk.invalid. For that host only, the shared OkHttp
client resolves a placeholder address, opens a WebSocket byte stream to
the relay instead of a TCP connection, and runs TLS on top of it pinned
to the IDE's certificate fingerprint. The relay sees ciphertext only.
DNS, sockets, proxy choice and TLS are unchanged for every other host.

Pairing: tunnel first (hello within 10 s), LAN candidates second, one
claim only. The local-network permission is requested only when a LAN
address may be tried; denying it no longer ends the pairing.

Errors: host offline, unknown relay, busy, no internet, silent computer,
certificate mismatch and the LAN failures each get their own text plus
a short diagnostic code built from a fixed vocabulary (never a token,
pairing code or address). After pairing the screen shows the first 8
characters of the fingerprint as a security code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-05 04:18:52 +03:00
1 parent 9afac6f508
commit cbe716300c
14 files changed
+2469 -138

No files matched your search

@@ -0,0 +1,148 @@
package com.m57.hermescontrol.data.aiturk
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.net.InetAddress
import java.net.Socket
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.concurrent.thread
/**
* A local stand-in for the turkservis.online tunnel relay: a WebSocket endpoint
* (`/t/<relayId>`) that bridges binary frames to a TCP socket of [targetPort] —
* exactly what gateway + IDE relay client do together. It never looks inside
* the bytes, so the TLS handshake with the fake IDE really runs end to end.
*/
internal class FakeRelay(
private val targetPort: () -> Int,
) {
val server = MockWebServer()
val streams = AtomicInteger()
val bytesFromPhone = AtomicLong()
val paths = CopyOnWriteArrayList<String>()
val headerNames = CopyOnWriteArrayList<String>()
val textFrames = AtomicInteger()
/** When set, every new stream is accepted and immediately closed with this code. */
@Volatile
var closeWith: Int? = null
private val sockets = CopyOnWriteArrayList<Socket>()
private val webSockets = CopyOnWriteArrayList<WebSocket>()
/** Cuts every open stream abnormally, like a network change (Wi-Fi ⇄ mobile data). */
fun dropStreams() {
webSockets.forEach { runCatching { it.cancel() } }
sockets.forEach { runCatching { it.close() } }
}
val wsBase: String get() = "ws://$LOOPBACK:${server.port}/t/"
fun connector(): AiturkTunnelConnector = WebSocketTunnelConnector(wsBase, OkHttpClient())
fun start(): FakeRelay {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
paths += request.path.orEmpty()
headerNames += request.headers.names().map { it.lowercase() }
return MockResponse().withWebSocketUpgrade(Bridge())
}
}
server.start(InetAddress.getByName(LOOPBACK), 0)
return this
}
fun stop() {
sockets.forEach { runCatching { it.close() } }
runCatching { server.shutdown() }
}
private inner class Bridge : WebSocketListener() {
@Volatile
private var tcp: Socket? = null
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
streams.incrementAndGet()
webSockets += webSocket
closeWith?.let {
webSocket.close(it, null)
return
}
val socket = Socket(LOOPBACK, targetPort())
sockets += socket
tcp = socket
thread(isDaemon = true, name = "fake-relay-pump") {
val buffer = ByteArray(BUFFER)
try {
val input = socket.getInputStream()
while (true) {
val read = input.read(buffer)
if (read < 0) break
webSocket.send(buffer.toByteString(0, read))
}
webSocket.close(NORMAL, null)
} catch (_: Exception) {
webSocket.close(NORMAL, null)
}
}
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) {
bytesFromPhone.addAndGet(bytes.size.toLong())
val socket = tcp ?: return
runCatching {
socket.getOutputStream().write(bytes.toByteArray())
socket.getOutputStream().flush()
}
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
textFrames.incrementAndGet()
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, null)
runCatching { tcp?.close() }
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
runCatching { tcp?.close() }
}
}
companion object {
const val LOOPBACK = "127.0.0.1"
const val RELAY_ID = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
const val OTHER_RELAY_ID = "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d"
private const val BUFFER = 16 * 1024
private const val NORMAL = 1000
}
}
@@ -0,0 +1,393 @@
package com.m57.hermescontrol.data.aiturk
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.ServerSocket
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertificateException
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException
/** Contract v3 pairing order: tunnel first, LAN second, exactly one `claim`. */
class AiturkRelayPairingTest {
private lateinit var gateway: MockWebServer
private lateinit var relayIde: FakeIde
private lateinit var relay: FakeRelay
private val others = mutableListOf<FakeIde>()
@BeforeEach
fun setUp() {
gateway = MockWebServer()
gateway.start()
relayIde = FakeIde().start()
relay = FakeRelay { relayIde.server.port }.start()
}
@AfterEach
fun tearDown() {
relay.stop()
runCatching { gateway.shutdown() }
(others + relayIde).forEach { runCatching { it.stop() } }
}
private fun client(
helloTimeoutMs: Long = 2_500L,
relayHelloTimeoutMs: Long = 5_000L,
) = AiturkPairingClient(
gatewayBaseUrl = gateway.url("/"),
helloTimeoutMs = helloTimeoutMs,
relayHelloTimeoutMs = relayHelloTimeoutMs,
tunnel = { relay.connector() },
)
private fun closedPort(): Int = ServerSocket(0).use { it.localPort }
private fun <T> AiturkPairResult<T>.failure(): AiturkPairResult.Failure = this as AiturkPairResult.Failure
// ── Order ────────────────────────────────────────────────────────────
@Test
fun relayIsTriedFirstAndLanCandidatesAreNeverContacted() =
runBlocking {
// Same certificate on the LAN listener, as with the real IDE.
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
val device = (client().pair(offer, "ABCDEFGH", "Galaxy S24") as AiturkPairResult.Success).value
assertEquals("https://${FakeRelay.RELAY_ID}.relay.aiturk.invalid/", device.baseUrl)
assertEquals("${FakeRelay.RELAY_ID}.relay.aiturk.invalid:443", device.hostPort)
assertNull(device.endpoint)
assertTrue(device.viaRelay)
assertEquals(relayIde.fingerprint, device.fingerprint)
assertEquals(FakeIde.TOKEN, device.token)
assertEquals(listOf("/aiturk/pair/hello", "/aiturk/pair/claim"), relayIde.paths.toList())
assertEquals("""{"code":"ABCDEFGH","device_name":"Galaxy S24"}""", relayIde.claimBodies.single())
assertTrue(lan.paths.isEmpty(), "LAN must not be contacted when the relay answered: ${lan.paths}")
}
@Test
fun relayOnlyOfferPairsWithoutAnyLanAddress() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
// lanAllowed = false: the local-network permission is irrelevant for the tunnel.
val result = client().pair(offer, "ABCDEFGH", "x", lanAllowed = false)
assertTrue(result is AiturkPairResult.Success)
assertEquals(1, relayIde.claimCount)
}
@Test
fun hostOfflineOnTheRelayFallsBackToTheLan() =
runBlocking {
relay.closeWith = 4503
val lan = FakeIde().start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), lan.fingerprint, FakeRelay.RELAY_ID)
val device = (client().pair(offer, "ABCDEFGH", "x") as AiturkPairResult.Success).value
assertEquals(lan.endpoint, device.endpoint)
assertEquals(lan.endpoint.baseUrl, device.baseUrl)
assertEquals(lan.endpoint.hostPort, device.hostPort)
assertFalse(device.viaRelay)
assertEquals(1, lan.claimCount)
assertEquals(0, relayIde.claimCount)
assertEquals(1, relay.streams.get())
}
@Test
fun hostOfflineThenUnreachableLanKeepsBothCodes() =
runBlocking {
relay.closeWith = 4503
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
val offer = AiturkPairingOffer(listOf(dead), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.HOST_OFFLINE, failure.error)
assertEquals("relay:host_offline,lan:refused", failure.code)
assertEquals(0, relayIde.claimCount)
}
@Test
fun relayFailuresAreToldApart() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val cases =
listOf(
Triple(4503, AiturkPairError.HOST_OFFLINE, "relay:host_offline,lan:no_candidates"),
Triple(4404, AiturkPairError.RELAY_UNKNOWN, "relay:unknown,lan:no_candidates"),
Triple(4429, AiturkPairError.RELAY_BUSY, "relay:busy,lan:no_candidates"),
Triple(4504, AiturkPairError.HOST_TIMEOUT, "relay:timeout,lan:no_candidates"),
)
cases.forEach { (closeCode, error, code) ->
relay.closeWith = closeCode
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(error, failure.error, "close $closeCode")
assertEquals(code, failure.code, "close $closeCode")
}
relay.stop()
val offline = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.NO_INTERNET, offline.error)
assertEquals("relay:network,lan:no_candidates", offline.code)
assertEquals(0, relayIde.claimCount)
}
@Test
fun wrongCertificateInsideTheTunnelIsAPinFailure() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), FakeIde.fingerprintOf(FakeIde.selfSigned()), FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.TLS_PIN, failure.error)
assertEquals("tls:pin,lan:no_candidates", failure.code)
assertTrue(relayIde.paths.isEmpty(), "nothing may be sent to an unverified computer: ${relayIde.paths}")
}
@Test
fun silentComputerBehindAnOpenTunnelTimesOut() =
runBlocking {
relayIde.hello = FakeIde.json(200, "{}").setHeadersDelay(5, TimeUnit.SECONDS)
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client(relayHelloTimeoutMs = 600L).pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.HOST_TIMEOUT, failure.error)
assertEquals("relay:timeout,lan:no_candidates", failure.code)
}
// ── One claim ────────────────────────────────────────────────────────
@Test
fun claimAnsweredOverTheRelayIsNeverRepeatedOnTheLan() =
runBlocking {
relayIde.claim = FakeIde.json(401, """{"error":"wrong_code"}""")
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.WRONG_CODE, failure.error)
assertEquals("claim:wrong_code", failure.code)
assertEquals(1, relayIde.claimCount)
assertTrue(lan.paths.isEmpty(), "a failed claim must not be retried on the LAN: ${lan.paths}")
}
@Test
fun claimCutMidFlightIsNotSentAgain() =
runBlocking {
// The computer receives the claim; the tunnel drops before its answer returns.
relayIde.claim = FakeIde.json(200, FakeIde.claimBody()).setHeadersDelay(5, TimeUnit.SECONDS)
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
Thread {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10)
while (relayIde.claimCount == 0 && System.nanoTime() < deadline) Thread.sleep(10)
relay.dropStreams()
}.apply { isDaemon = true }.start()
val result = client().pair(offer, "ABCDEFGH", "x")
assertTrue(result is AiturkPairResult.Failure)
assertTrue(result.failure().code.startsWith("claim:relay_"), result.failure().code)
assertEquals(1, relayIde.claimCount, "the single-use code must be sent exactly once")
assertEquals(0, lan.claimCount)
}
@Test
fun pairingClosedOverTheRelaySpendsNoClaim() =
runBlocking {
relayIde.hello = FakeIde.json(200, """{"app":"aiturk-ide","version":"1","pairing":false}""")
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PAIRING_CLOSED, failure.error)
assertEquals("relay:pairing_closed", failure.code)
assertEquals(0, relayIde.claimCount)
}
// ── LAN codes ────────────────────────────────────────────────────────
@Test
fun lanFailuresCarryTheirOwnCodes() =
runBlocking {
val refused = AiturkPairingOffer(listOf(AiturkEndpoint(FakeIde.LOOPBACK, closedPort())), relayIde.fingerprint)
val refusedFailure = client().pair(refused, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PC_UNREACHABLE, refusedFailure.error)
assertEquals("lan:refused", refusedFailure.code)
val impostor = FakeIde().start().also { others += it }
val tls = AiturkPairingOffer(listOf(impostor.endpoint), relayIde.fingerprint)
val tlsFailure = client().pair(tls, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PC_UNREACHABLE, tlsFailure.error)
assertEquals("lan:tls", tlsFailure.code)
val silent =
FakeIde().apply { hello = FakeIde.json(200, "{}").setHeadersDelay(5, TimeUnit.SECONDS) }.start().also { others += it }
val timeout = AiturkPairingOffer(listOf(silent.endpoint), silent.fingerprint)
val timeoutFailure = client(helloTimeoutMs = 400L).pair(timeout, "ABCDEFGH", "x").failure()
assertEquals("lan:timeout", timeoutFailure.code)
val denied = client().pair(refused, "ABCDEFGH", "x", lanAllowed = false).failure()
assertEquals(AiturkPairError.LAN_PERMISSION, denied.error)
assertEquals("lan:permission", denied.code)
assertEquals(0, relay.streams.get(), "an offer without a relay id must never open a tunnel")
}
@Test
fun everyExceptionTypeHasItsOwnKind() {
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(SocketTimeoutException("x")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(InterruptedIOException("timeout")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(ConnectException("connect failed: ETIMEDOUT (Connection timed out)")))
assertEquals(AiturkDiag.REFUSED, AiturkDiag.networkKind(ConnectException("connect failed: ECONNREFUSED (Connection refused)")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(NoRouteToHostException("x")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(ConnectException("connect failed: EHOSTUNREACH (No route to host)")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(ConnectException("connect failed: ENETUNREACH (Network is unreachable)")))
assertEquals(AiturkDiag.PERMISSION, AiturkDiag.networkKind(ConnectException("connect failed: EPERM (Operation not permitted)")))
assertEquals(AiturkDiag.PERMISSION, AiturkDiag.networkKind(java.net.SocketException("socket failed: EACCES (Permission denied)")))
assertEquals(AiturkDiag.DNS, AiturkDiag.networkKind(UnknownHostException("host")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(SSLHandshakeException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(SSLException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(IOException("wrapped", CertificateException("pin"))))
assertEquals(AiturkDiag.IO, AiturkDiag.networkKind(IOException("unexpected end of stream")))
assertEquals(AiturkPairError.LAN_PERMISSION, AiturkPairingClient.lanError(AiturkDiag.PERMISSION))
listOf(AiturkDiag.TIMEOUT, AiturkDiag.REFUSED, AiturkDiag.UNREACHABLE, AiturkDiag.TLS, AiturkDiag.IO).forEach {
assertEquals(AiturkPairError.PC_UNREACHABLE, AiturkPairingClient.lanError(it), it)
}
assertEquals(AiturkDiag.TLS, AiturkPairingClient.mostTelling(listOf(AiturkDiag.TIMEOUT, AiturkDiag.TLS, AiturkDiag.REFUSED)))
assertEquals(AiturkDiag.REFUSED, AiturkPairingClient.mostTelling(listOf(AiturkDiag.TIMEOUT, AiturkDiag.REFUSED)))
}
@Test
fun tunnelFailuresHaveTheirOwnKindsAndTexts() {
assertEquals(AiturkTunnelException.HOST_OFFLINE, AiturkDiag.tunnelKind(SSLException("x", AiturkTunnelException("host_offline", 4503))))
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(SSLHandshakeException("x").apply { initCause(CertificateException("pin")) }))
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(SSLPeerUnverifiedException("x")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.tunnelKind(SocketTimeoutException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.tunnelKind(SSLException("closed")))
assertEquals(AiturkDiag.NETWORK, AiturkDiag.tunnelKind(IOException("x")))
assertEquals("tls:pin", AiturkDiag.relayCode(AiturkDiag.PIN))
assertEquals("relay:host_offline", AiturkDiag.relayCode(AiturkTunnelException.HOST_OFFLINE))
assertEquals(AiturkPairError.HOST_OFFLINE, AiturkPairingClient.relayError("host_offline"))
assertEquals(AiturkPairError.RELAY_UNKNOWN, AiturkPairingClient.relayError("unknown"))
assertEquals(AiturkPairError.NO_INTERNET, AiturkPairingClient.relayError("network"))
assertEquals(AiturkPairError.RELAY_BUSY, AiturkPairingClient.relayError("busy"))
assertEquals(AiturkPairError.HOST_TIMEOUT, AiturkPairingClient.relayError("timeout"))
assertEquals(AiturkPairError.TLS_PIN, AiturkPairingClient.relayError("pin"))
assertEquals(AiturkPairError.RELAY_ERROR, AiturkPairingClient.relayError("overflow"))
assertEquals(AiturkPairError.RELAY_ERROR, AiturkPairingClient.relayError("http_500"))
}
@Test
fun diagnosticCodeNeverCarriesATokenACodeOrAnAddress() =
runBlocking {
relay.closeWith = 4503
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
val offer = AiturkPairingOffer(listOf(dead), relayIde.fingerprint, FakeRelay.RELAY_ID)
val code = client().pair(offer, "ABCDEFGH", "x").failure().code
listOf("ABCDEFGH", FakeRelay.RELAY_ID, FakeIde.LOOPBACK, dead.port.toString(), relayIde.fingerprint, "amd_", "https", ".invalid").forEach {
assertFalse(it in code, "'$it' leaked into '$code'")
}
assertEquals(code, AiturkDiag.sanitize(code))
// The exception message (which on Android names the address) is never copied.
val noisy = ConnectException("failed to connect to /192.168.1.20 (port 47321): connect failed: ECONNREFUSED")
assertEquals("refused", AiturkDiag.networkKind(noisy))
// The UI drops anything that is not built from the fixed vocabulary.
listOf(
"relay:" + FakeIde.TOKEN,
"lan:192.168.1.20:47321",
"claim:ABCDEFGH",
"relay:https://turkservis.online/x",
"relay:host_offline lan:timeout",
"x".repeat(200) + ":y",
).forEach { assertEquals("", AiturkDiag.sanitize(it), it) }
assertEquals("relay:host_offline,lan:timeout", AiturkDiag.sanitize("relay:host_offline,lan:timeout"))
assertEquals("tls:pin", AiturkDiag.sanitize("tls:pin"))
assertEquals("resolve:http_500", AiturkDiag.sanitize("resolve:http_500"))
}
// ── resolve ──────────────────────────────────────────────────────────
@Test
fun resolveAcceptsARelayIdWithOrWithoutLanAddresses() =
runBlocking {
val fp = "A".repeat(43)
gateway.enqueue(FakeIde.json(200, """{"urls":[],"fp":"$fp","relay":"${FakeRelay.RELAY_ID}","expiresIn":280}"""))
gateway.enqueue(FakeIde.json(200, """{"fp":"$fp","relay":"${FakeRelay.RELAY_ID}"}"""))
gateway.enqueue(FakeIde.json(200, """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":"${FakeRelay.RELAY_ID}"}"""))
gateway.enqueue(FakeIde.json(200, """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":null}"""))
val relayOnly = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertEquals(AiturkPairingOffer(emptyList(), fp, FakeRelay.RELAY_ID), relayOnly)
assertEquals(relayOnly, (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value)
val both = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertEquals(AiturkPairingOffer(listOf(AiturkEndpoint("192.168.1.20", 47321)), fp, FakeRelay.RELAY_ID), both)
val lanOnly = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertNull(lanOnly.relay)
}
@Test
fun resolveRejectsAnythingThatIsNotARelayId() =
runBlocking {
val fp = "A".repeat(43)
val id = FakeRelay.RELAY_ID
listOf(
"""{"urls":[],"fp":"$fp","relay":"https://turkservis.online/v1/aiturk/mobile-relay/r/$id"}""",
"""{"urls":[],"fp":"$fp","relay":"${id.uppercase()}"}""",
"""{"urls":[],"fp":"$fp","relay":"$id.evil.example"}""",
"""{"urls":[],"fp":"$fp","relay":"$id/"}""",
"""{"urls":[],"fp":"$fp","relay":""}""",
"""{"urls":[],"fp":"$fp","relay":42}""",
"""{"urls":[],"fp":"$fp","relay":["$id"]}""",
// A wrong relay is never silently dropped in favour of the LAN addresses.
"""{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":"evil.example"}""",
"""{"urls":[],"fp":"$fp"}""",
"""{"urls":"https://192.168.1.20:47321","fp":"$fp","relay":"$id"}""",
).forEach { body ->
gateway.enqueue(FakeIde.json(200, body))
val failure = client().resolve("ABCDEFGH").failure()
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, failure.error, body)
assertEquals("resolve:bad_response", failure.code, body)
}
}
@Test
fun resolveFailuresHaveCodes() =
runBlocking {
gateway.enqueue(MockResponse().setResponseCode(404))
gateway.enqueue(MockResponse().setResponseCode(429))
gateway.enqueue(MockResponse().setResponseCode(500))
assertEquals("resolve:not_found", client().resolve("ABCDEFGH").failure().code)
assertEquals("resolve:rate_limited", client().resolve("ABCDEFGH").failure().code)
assertEquals("resolve:http_500", client().resolve("ABCDEFGH").failure().code)
gateway.shutdown()
assertEquals("resolve:network", client().resolve("ABCDEFGH").failure().code)
}
}
@@ -25,6 +25,10 @@ internal class FakeIde(
@Volatile
var claim: MockResponse = json(200, claimBody())
/** Answers for paths other than hello/claim (null → 404). */
@Volatile
var extra: (RecordedRequest) -> MockResponse? = { null }
val endpoint: AiturkEndpoint get() = AiturkEndpoint(LOOPBACK, server.port)
val fingerprint: String get() = fingerprintOf(certificate)
val claimCount: Int get() = paths.count { it == "/aiturk/pair/claim" }
@@ -49,7 +53,7 @@ internal class FakeIde(
}
else -> {
json(404, """{"error":"not_found"}""")
extra(request) ?: json(404, """{"error":"not_found"}""")
}
}
}
@@ -68,6 +68,33 @@ class AiturkTokenRefreshSkipTest {
verify(exactly = 0) { AuthManager.setToken(any()) }
}
@Test
fun tunnelPairedConnectionNeverAsksTheDashboardForAToken() =
runBlocking {
// Contract v3: a relay pairing is the virtual host, pinned on port 443.
val relayId = FakeRelay.RELAY_ID
AiturkLanTrust.pin(AiturkRelay.hostPort(relayId), "A".repeat(43))
every { AuthManager.baseUrl() } returns AiturkRelay.baseUrl(relayId)
every { OkHttpProvider.probe } returns countingClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertNull(DashboardSessionTokenRefresher.refresh())
assertEquals(0, calls.get())
verify(exactly = 0) { AuthManager.setToken(any()) }
}
@Test
fun unpairedVirtualHostIsNotTreatedAsPaired() =
runBlocking {
every { AuthManager.baseUrl() } returns AiturkRelay.baseUrl(FakeRelay.OTHER_RELAY_ID)
every { OkHttpProvider.probe } returns countingClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertEquals(1, calls.get())
}
@Test
fun unpairedConnectionStillRefreshesAsBefore() =
runBlocking {
@@ -0,0 +1,417 @@
package com.m57.hermescontrol.data.aiturk
import okhttp3.Dns
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.net.URI
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.random.Random
/**
* Contract v3: REST and WebSocket traffic to the virtual relay host really
* travels through the tunnel, with TLS terminated by the (fake) IDE and pinned
* to its certificate — and nothing changes for any other host.
*/
class AiturkTunnelTest {
private lateinit var ide: FakeIde
private lateinit var relay: FakeRelay
private val virtualBase = "https://${AiturkRelay.virtualHost(FakeRelay.RELAY_ID)}"
@BeforeEach
fun setUp() {
AiturkLanTrust.resetForTest(MemoryPins())
ide = FakeIde().start()
relay = FakeRelay { ide.server.port }.start()
}
@AfterEach
fun tearDown() {
relay.stop()
runCatching { ide.stop() }
AiturkLanTrust.resetForTest(MemoryPins())
}
private fun pinnedClient(fingerprint: String = ide.fingerprint): OkHttpClient =
AiturkLanTrust.pinnedClient(
OkHttpClient
.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build(),
fingerprint,
) { relay.connector() }
// ── REST ─────────────────────────────────────────────────────────────
@Test
fun restRequestReachesTheIdeThroughTheTunnelWithPinnedTls() {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute().use { response ->
assertEquals(200, response.code)
assertTrue(response.body.string().contains("aiturk-ide"))
assertNotNull(response.handshake, "TLS must be established inside the tunnel")
}
// The IDE saw the request with NO path prefix; the relay saw only its own stream path.
assertEquals(listOf("/aiturk/pair/hello"), ide.paths.toList())
assertEquals(listOf("/t/${FakeRelay.RELAY_ID}"), relay.paths.toList())
assertEquals(0, relay.textFrames.get(), "the stream is binary only")
assertFalse("origin" in relay.headerNames, "headers seen by the relay: ${relay.headerNames}")
assertFalse("cookie" in relay.headerNames)
assertFalse("authorization" in relay.headerNames)
}
@Test
fun largeBodiesSurviveTheTunnelInBothDirections() {
val payload = Random(7).nextBytes(1_500_000)
ide.extra = { request ->
if (request.path == "/api/echo") {
MockResponse().setResponseCode(200).setBody(okio.Buffer().write(request.body.readByteArray()))
} else {
null
}
}
val request =
Request
.Builder()
.url("$virtualBase/api/echo")
.post(payload.toRequestBody("application/octet-stream".toMediaType()))
.build()
val echoed = pinnedClient().newCall(request).execute().use { it.body.bytes() }
assertArrayEquals(payload, echoed)
}
@Test
fun tokenTravelsOnlyInsideTls() {
val token = "amd_" + "s".repeat(43)
val seen = AtomicReference<String>()
ide.extra = { request ->
seen.set(request.getHeader("Authorization"))
FakeIde.json(200, """{"ok":true}""")
}
val sniffed = LinkedBlockingQueue<ByteArray>()
val sniffing =
AiturkTunnelConnector { relayId, timeoutMs ->
val inner = relay.connector().open(relayId, timeoutMs)
object : AiturkTunnelChannel by inner {
override val output =
object : java.io.OutputStream() {
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
override fun write(
b: ByteArray,
off: Int,
len: Int,
) {
sniffed += b.copyOfRange(off, off + len)
inner.output.write(b, off, len)
}
}
}
}
val client = AiturkLanTrust.pinnedClient(OkHttpClient(), ide.fingerprint) { sniffing }
client
.newCall(
Request
.Builder()
.url("$virtualBase/api/status?marker=PLAINTEXT-MARKER")
.header("Authorization", "Bearer $token")
.build(),
).execute()
.close()
assertEquals("Bearer $token", seen.get())
val onTheWire = String(sniffed.flatMap { it.asIterable() }.toByteArray(), Charsets.ISO_8859_1)
assertTrue(onTheWire.isNotEmpty())
assertFalse(token in onTheWire, "the device token must never be readable by the relay")
assertFalse("PLAINTEXT-MARKER" in onTheWire, "paths and queries must never be readable by the relay")
assertFalse("/api/status" in onTheWire)
}
// ── WebSocket ────────────────────────────────────────────────────────
@Test
fun webSocketWorksThroughTheTunnel() {
ide.extra = { request ->
if (request.path?.startsWith("/api/ws") == true) {
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
webSocket.send("echo:$text")
}
},
)
} else {
null
}
}
val opened = CountDownLatch(1)
val messages = LinkedBlockingQueue<String>()
val failure = AtomicReference<Throwable>()
val socket =
pinnedClient().newWebSocket(
Request.Builder().url("$virtualBase/api/ws?token=t").build(),
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
messages += text
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
failure.set(t)
opened.countDown()
}
},
)
try {
assertTrue(opened.await(10, TimeUnit.SECONDS), "WebSocket did not open")
assertNull(failure.get(), "WebSocket failed: ${failure.get()}")
socket.send("hello")
assertEquals("echo:hello", messages.poll(10, TimeUnit.SECONDS))
// The app's WebSocket URL is seen by the IDE as written (no prefix).
assertTrue(ide.paths.any { it == "/api/ws?token=t" }, "paths: ${ide.paths}")
} finally {
socket.cancel()
}
}
// ── Trust ────────────────────────────────────────────────────────────
@Test
fun certificateThatDoesNotMatchThePairingFingerprintIsRejected() {
val other = FakeIde.fingerprintOf(FakeIde.selfSigned())
val error =
assertThrows(IOException::class.java) {
pinnedClient(other).newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(error))
assertTrue(ide.paths.isEmpty(), "no request may be sent before the pin matched: ${ide.paths}")
}
@Test
fun sharedClientHookUsesTheStoredPinForTheVirtualHost() {
// What OkHttpProvider.base does: AiturkLanTrust.applyTo(builder).
val previous = AiturkTunnel.connector
AiturkTunnel.connector = relay.connector()
try {
val client = AiturkLanTrust.applyTo(OkHttpClient.Builder()).build()
val request = Request.Builder().url("$virtualBase/aiturk/pair/hello").build()
// Not paired yet: platform trust rejects the IDE's self-signed certificate.
assertThrows(IOException::class.java) { client.newCall(request).execute() }
assertTrue(ide.paths.isEmpty())
AiturkLanTrust.pin(AiturkRelay.hostPort(FakeRelay.RELAY_ID), ide.fingerprint)
assertTrue(AiturkLanTrust.isPairedUrl("$virtualBase/"))
client.newCall(request).execute().use { assertEquals(200, it.code) }
// A pin for one relay id says nothing about another.
val otherHost = AiturkRelay.virtualHost(FakeRelay.OTHER_RELAY_ID)
assertFalse(AiturkLanTrust.isPairedUrl("https://$otherHost/"))
assertThrows(IOException::class.java) {
client.newCall(Request.Builder().url("https://$otherHost/aiturk/pair/hello").build()).execute()
}
} finally {
AiturkTunnel.connector = previous
}
}
@Test
fun onlyAnIdeAddressCanBePinned() {
val fp = "A".repeat(43)
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("turkservis.online:443", fp) }
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("evil.relay.aiturk.invalid:443", fp) }
assertThrows(IllegalArgumentException::class.java) {
AiturkLanTrust.pin("${AiturkRelay.virtualHost(FakeRelay.RELAY_ID)}:8443", fp)
}
AiturkLanTrust.pin(AiturkRelay.hostPort(FakeRelay.RELAY_ID), fp)
AiturkLanTrust.pin("192.168.1.20:47321", fp)
}
// ── Everything else is untouched ─────────────────────────────────────
@Test
fun otherHostsKeepDefaultDnsSocketsAndProxy() {
val opens = AtomicInteger()
val counting =
AiturkTunnelConnector { relayId, timeoutMs ->
opens.incrementAndGet()
relay.connector().open(relayId, timeoutMs)
}
val plain = MockWebServer()
plain.enqueue(MockResponse().setBody("plain"))
plain.start()
try {
val client = AiturkTunnel.applyTo(OkHttpClient.Builder()) { counting }.build()
val body = client.newCall(Request.Builder().url(plain.url("/x")).build()).execute().use { it.body.string() }
assertEquals("plain", body)
assertEquals(0, opens.get(), "a normal host must never open a tunnel")
assertEquals(0, relay.streams.get())
// DNS: real names go to the system resolver, unchanged.
val recorded = mutableListOf<String>()
val delegate =
Dns { host ->
recorded += host
Dns.SYSTEM.lookup(host)
}
assertEquals(Dns.SYSTEM.lookup("localhost"), AiturkTunnelDns(delegate).lookup("localhost"))
assertEquals(listOf("localhost"), recorded)
// Socket: an ordinary TCP connection made by java.net.Socket itself.
val socket = AiturkTunnelSocketFactory { counting }.createSocket()
assertTrue(socket is Socket)
socket.use {
it.connect(InetSocketAddress(plain.hostName, plain.port), 2_000)
assertTrue(it.isConnected)
assertNull((it as AiturkTunnelSocket).channel)
assertEquals(plain.port, it.port)
}
assertEquals(0, opens.get())
} finally {
plain.shutdown()
}
}
@Test
fun virtualHostResolvesToAPlaceholderWithoutTouchingRealDns() {
val host = AiturkRelay.virtualHost(FakeRelay.RELAY_ID)
val delegate = Dns { throw AssertionError("system DNS must not be asked about $it") }
val address = AiturkTunnelDns(delegate).lookup(host).single()
assertEquals(host, address.hostName)
assertEquals("192.0.2.1", address.hostAddress)
assertEquals(listOf(Proxy.NO_PROXY), AiturkTunnelProxySelector().select(URI("https://$host/")))
// Look-alikes are ordinary names.
listOf("relay.aiturk.invalid", "x.relay.aiturk.invalid", "${FakeRelay.RELAY_ID}.relay.aiturk.invalid.evil.example").forEach {
assertFalse(AiturkRelay.isVirtualHost(it), it)
}
}
// ── Close codes ──────────────────────────────────────────────────────
@Test
fun relayCloseCodesBecomeNamedFailures() {
val expected =
mapOf(
4503 to AiturkTunnelException.HOST_OFFLINE,
4404 to AiturkTunnelException.UNKNOWN,
4429 to AiturkTunnelException.BUSY,
4504 to AiturkTunnelException.TIMEOUT,
4408 to AiturkTunnelException.OVERFLOW,
)
expected.forEach { (code, kind) ->
relay.closeWith = code
val error =
assertThrows(IOException::class.java) {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(kind, AiturkDiag.tunnelKind(error), "close code $code")
assertEquals(code, AiturkTunnelException.find(error)?.closeCode)
}
assertTrue(ide.paths.isEmpty())
assertNull(AiturkTunnelException.kindForCloseCode(1000))
}
@Test
fun unreachableRelayIsANetworkFailure() {
relay.stop()
val error =
assertThrows(IOException::class.java) {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(AiturkTunnelException.NETWORK, AiturkDiag.tunnelKind(error))
}
@Test
fun hostOfflineIsRememberedForTheChatBanner() {
AiturkRelayStatus.onSocketEnded(IOException("x", AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503)))
assertTrue(AiturkRelayStatus.hostOffline.value)
AiturkRelayStatus.onSocketEnded(IOException("timeout"))
assertFalse(AiturkRelayStatus.hostOffline.value)
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
AiturkRelayStatus.onSocketOpened()
assertFalse(AiturkRelayStatus.hostOffline.value)
}
// ── No replay ────────────────────────────────────────────────────────
@Test
fun postCutMidFlightIsNotSentAgain() {
val posts = AtomicInteger()
val arrived = CountDownLatch(1)
ide.extra = { request ->
if (request.method == "POST") posts.incrementAndGet()
arrived.countDown()
// The computer got the request; its answer never makes it back.
FakeIde.json(200, """{"ok":true}""").setHeadersDelay(5, TimeUnit.SECONDS)
}
Thread {
if (arrived.await(10, TimeUnit.SECONDS)) relay.dropStreams()
}.apply { isDaemon = true }.start()
// Same setting the pairing `claim` uses.
val client = pinnedClient().newBuilder().retryOnConnectionFailure(false).build()
val request =
Request
.Builder()
.url("$virtualBase/api/once")
.post("""{"n":1}""".toRequestBody("application/json".toMediaType()))
.build()
assertThrows(IOException::class.java) { client.newCall(request).execute().use { it.body.string() } }
assertEquals(1, posts.get(), "a POST that may have reached the computer must not be replayed")
assertEquals(1, relay.streams.get(), "no second tunnel stream may be opened for a retry")
}
}
@@ -0,0 +1,152 @@
package com.m57.hermescontrol.ui.aiturkpair
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.aiturk.AiturkPairError
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
import com.m57.hermescontrol.data.aiturk.AiturkTunnelConnector
import com.m57.hermescontrol.data.aiturk.AiturkTunnelException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
/**
* Contract v3: the local-network permission is asked for only when a LAN
* address may be tried. The coroutine dispatcher is never advanced, so no
* network call runs: these tests look at the decision made synchronously.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AiturkPairViewModelTest {
private val fp = "A".repeat(43)
private val relayId = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
private val tunnelOpens = AtomicInteger()
@BeforeEach
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(): AiturkPairViewModel {
val tunnel =
AiturkTunnelConnector { _, _ ->
tunnelOpens.incrementAndGet()
throw AiturkTunnelException(AiturkTunnelException.NETWORK)
}
return AiturkPairViewModel(
client = AiturkPairingClient(gatewayBaseUrl = "http://127.0.0.1:9/".toHttpUrl(), tunnel = { tunnel }),
register = { _: AiturkPairedDevice -> },
deviceName = "Test phone",
)
}
@Test
fun relayOnlyQrNeverAsksForTheLocalNetworkPermission() {
val vm = viewModel()
vm.onQrScanned("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId", lanPermissionGranted = false)
val state = vm.uiState.value
assertFalse(state.lanPermissionNeeded, "a relay-only pairing must not ask for ACCESS_LOCAL_NETWORK")
assertEquals(AiturkPairStep.CONNECTING, state.step)
assertNull(state.errorRes)
}
@Test
fun relayOnlyDeepLinkNeverAsksEither() {
val vm = viewModel()
vm.onDeepLink("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId")
assertEquals("turkservis.online", vm.uiState.value.confirmAddress)
vm.confirmDeepLink(lanPermissionGranted = false)
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.CONNECTING, vm.uiState.value.step)
}
@Test
fun qrWithALanAddressStillAsksBeforeConnecting() {
val vm = viewModel()
vm.onQrScanned("aiturk://pair?v=1&u=192.168.1.20:47321&fp=$fp&c=ABCDEFGH&r=$relayId", lanPermissionGranted = false)
assertTrue(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.IDLE, vm.uiState.value.step)
// Denying it no longer ends the pairing: the tunnel is still tried.
vm.onLanPermissionResult(granted = false)
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.CONNECTING, vm.uiState.value.step)
assertNull(vm.uiState.value.errorRes)
}
@Test
fun typedCodeResolvesFirstWithoutAskingForThePermission() {
val vm = viewModel()
vm.onCodeChange("ABCDEFGH")
vm.submitCode(lanPermissionGranted = false)
// Whether a LAN address exists is only known after `resolve`.
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.RESOLVING, vm.uiState.value.step)
}
@Test
fun everyFailureHasItsOwnText() {
val texts = AiturkPairError.entries.associateWith(AiturkPairViewModel::pairErrorRes)
assertEquals(R.string.aiturk_pair_error_host_offline, texts[AiturkPairError.HOST_OFFLINE])
assertEquals(R.string.aiturk_pair_error_relay_unknown, texts[AiturkPairError.RELAY_UNKNOWN])
assertEquals(R.string.aiturk_pair_error_no_internet, texts[AiturkPairError.NO_INTERNET])
assertEquals(R.string.aiturk_pair_error_relay_busy, texts[AiturkPairError.RELAY_BUSY])
assertEquals(R.string.aiturk_pair_error_host_timeout, texts[AiturkPairError.HOST_TIMEOUT])
assertEquals(R.string.aiturk_pair_error_tls_pin, texts[AiturkPairError.TLS_PIN])
assertEquals(R.string.aiturk_pair_error_pc_unreachable, texts[AiturkPairError.PC_UNREACHABLE])
assertEquals(R.string.aiturk_pair_error_lan_permission, texts[AiturkPairError.LAN_PERMISSION])
assertEquals(R.string.aiturk_pair_error_wrong_code, texts[AiturkPairError.WRONG_CODE])
assertEquals(R.string.aiturk_pair_error_pairing_closed, texts[AiturkPairError.PAIRING_CLOSED])
assertEquals(R.string.aiturk_pair_error_pairing_locked, texts[AiturkPairError.PAIRING_LOCKED])
// The named failures never collapse into one message.
val distinct =
listOf(
AiturkPairError.HOST_OFFLINE,
AiturkPairError.RELAY_UNKNOWN,
AiturkPairError.NO_INTERNET,
AiturkPairError.RELAY_BUSY,
AiturkPairError.HOST_TIMEOUT,
AiturkPairError.TLS_PIN,
AiturkPairError.PC_UNREACHABLE,
AiturkPairError.LAN_PERMISSION,
AiturkPairError.WRONG_CODE,
AiturkPairError.PAIRING_CLOSED,
AiturkPairError.PAIRING_LOCKED,
AiturkPairError.CODE_NOT_FOUND,
AiturkPairError.RATE_LIMITED,
).map { texts.getValue(it) }
assertEquals(distinct.size, distinct.toSet().size)
assertEquals(R.string.aiturk_pair_error_qr_invalid, AiturkPairViewModel.payloadErrorRes(AiturkPayloadError.BAD_RELAY))
}
@Test
fun securityCodeIsTheFirstEightCharactersOfTheFingerprint() {
assertEquals("AbCdEfGh", AiturkPairViewModel.securityCode("AbCdEfGh" + "x".repeat(35)))
assertEquals(0, tunnelOpens.get())
}
}