feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled

Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated):
:wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec),
:session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client,
:client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework:
:app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux
terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless),
:host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink).

Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading
X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed
rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver,
Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10);
config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers.

Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all
framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35,
S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no
emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial
cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
Yaojia Wang
2026-07-10 16:41:21 +02:00
parent 542fde9580
commit e254918b1c
159 changed files with 20114 additions and 73 deletions

View File

@@ -0,0 +1,98 @@
package wang.yaojia.webterm.transport
import okhttp3.OkHttpClient
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
/**
* The optional mTLS material a [ClientIdentityProvider] hands to the shared client (plan §2 mTLS
* row, §8). Both fields are required together — OkHttp binds an `(SSLSocketFactory, X509TrustManager)`
* pair, never one without the other.
*
* The [sslSocketFactory] SHOULD be backed by a **re-reading `X509KeyManager`** (built in
* `:client-tls-android`, wired in `:app`) so a mid-run cert rotation is presented on the next
* handshake without rebuilding the client (plan §8 / R4). This module treats the pair as opaque —
* it never parses PKCS#12, never touches `AndroidKeyStore`, and keeps depending ONLY on
* `:wire-protocol`.
*/
public class ClientIdentity(
public val sslSocketFactory: SSLSocketFactory,
public val trustManager: X509TrustManager,
)
/**
* The mTLS injection seam, defined LOCALLY in `:transport-okhttp` on purpose (plan §5 A7): it lets
* `:app` supply a `:client-tls-android`-backed client identity WITHOUT this module gaining a
* `:client-tls` dependency edge — the module stays pure `:wire-protocol` + OkHttp.
*
* A SAM ([fun interface]) so the common "no device cert installed" case is just
* `ClientIdentityProvider.NONE`, and a test/`:app` can supply `ClientIdentityProvider { currentId }`.
*
* [currentIdentity] is read ONCE, when the shared [OkHttpClient] is built ([OkHttpClientFactory]).
* Live cert rotation is the injected `SSLSocketFactory`'s concern (its `X509KeyManager` re-reads the
* `AndroidKeyStore` per handshake), not this seam's — so returning a fresh value later has no effect,
* by design.
*/
public fun interface ClientIdentityProvider {
/** The current client identity, or null for no mTLS (default system trust only). */
public fun currentIdentity(): ClientIdentity?
public companion object {
/** No client certificate — the default for unpaired / bare-LAN hosts. */
public val NONE: ClientIdentityProvider = ClientIdentityProvider { null }
}
}
/**
* Builds the SINGLE shared [OkHttpClient] both transports use (plan §2 "one shared OkHttpClient"):
* one client → one `SSLSocketFactory` → mTLS applies uniformly to WS and REST, and the connection
* pool is shared.
*
* Two invariants baked in here:
* - **`.cache(null)`** (plan §8): no ephemeral/disk HTTP cache — preview/diff bodies can hold
* terminal secrets, so nothing is persisted.
* - **Server-cert trust = default system trust.** Tunnel (`*.terminal.yaojia.wang`) and Tailscale
* MagicDNS present real LE certs, so NO custom `X509TrustManager` is installed for the common
* case (plan §6.9). Bare-LAN `ws://` cleartext is an `:app` `network_security_config` allowlist
* concern, NOT this module's — a plain `ws://` upgrade needs no TLS here at all.
*/
public object OkHttpClientFactory {
/**
* @param identityProvider optional mTLS material; [ClientIdentityProvider.NONE] → default trust,
* no client cert.
*/
public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
): OkHttpClient {
val builder = OkHttpClient.Builder().cache(null)
identityProvider.currentIdentity()?.let { identity ->
builder.sslSocketFactory(identity.sslSocketFactory, identity.trustManager)
}
return builder.build()
}
}
/**
* Convenience composition root proving the "one client for BOTH transports" contract: builds the
* shared [OkHttpClient] once via [OkHttpClientFactory] and hands the SAME instance to the WS and
* REST transports (the WS one derives a streaming-tuned variant via `newBuilder()`, which shares the
* connection pool + dispatcher + mTLS). `:app`'s Hilt module will typically call this.
*/
public class OkHttpTransports private constructor(
public val term: OkHttpTermTransport,
public val http: OkHttpHttpTransport,
public val client: OkHttpClient,
) {
public companion object {
public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
): OkHttpTransports {
val client = OkHttpClientFactory.create(identityProvider)
return OkHttpTransports(
term = OkHttpTermTransport(client),
http = OkHttpHttpTransport(client),
client = client,
)
}
}
}

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.transport
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* The only concrete REST transport (A7): implements [HttpTransport] over the SAME shared
* [OkHttpClient] as the WS transport (one client → mTLS + `cache(null)` apply uniformly).
*
* Contract (frozen [HttpTransport] doc + plan §4.3):
* - Headers are copied VERBATIM. This module NEVER adds `Origin` itself — `:api-client` stamps it
* only on the guarded routes; the read-only GETs must stay Origin-free. Getting this wrong breaks
* the CSWSH split.
* - A **non-2xx** status is RETURNED, not thrown (classification is the caller's job). Only a
* **transport-level** failure (connection refused, TLS, reset) throws — as OkHttp's [IOException].
*
* Uses the async `enqueue` path wrapped in [suspendCancellableCoroutine] so coroutine cancellation
* cancels the in-flight [Call] (no thread parked on a blocking `execute`).
*/
public class OkHttpHttpTransport(
private val client: OkHttpClient,
) : HttpTransport {
override suspend fun send(request: HttpRequest): HttpResponse =
suspendCancellableCoroutine { continuation ->
val call = client.newCall(request.toOkHttpRequest())
continuation.invokeOnCancellation { runCatching { call.cancel() } }
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
continuation.resumeWithException(e) // transport-level failure → thrown
}
override fun onResponse(call: Call, response: Response) {
val mapped = try {
response.use { it.toHttpResponse() }
} catch (e: IOException) {
continuation.resumeWithException(e)
return
}
continuation.resume(mapped) // any status (incl. non-2xx) is returned
}
})
}
}
private val EMPTY_REQUEST_BODY: RequestBody = ByteArray(0).toRequestBody(null)
private fun HttpRequest.toOkHttpRequest(): Request {
val builder = Request.Builder().url(url)
// Verbatim header copy — single-valued map, so replace (not append). NO Origin injected here.
headers.forEach { (name, value) -> builder.header(name, value) }
// Null media type → OkHttp adds no Content-Type, so a caller-supplied one is preserved verbatim.
val requestBody = body?.toRequestBody(null)
builder.method(method.name, bodyFor(method, requestBody))
return builder.build()
}
/** OkHttp requires GET to have no body and POST/PUT to have one; DELETE accepts either. */
private fun bodyFor(method: HttpMethod, body: RequestBody?): RequestBody? = when (method) {
HttpMethod.GET -> null
HttpMethod.DELETE -> body
HttpMethod.POST, HttpMethod.PUT -> body ?: EMPTY_REQUEST_BODY
}
private fun Response.toHttpResponse(): HttpResponse =
HttpResponse(
status = code,
body = body?.bytes() ?: ByteArray(0),
headers = headers.toSingleValueMap(),
)
/** Flatten OkHttp's multimap headers to last-value-wins (matches the pure `Map<String,String>` DTO). */
private fun Headers.toSingleValueMap(): Map<String, String> =
buildMap { for (i in 0 until size) put(name(i), value(i)) }

View File

@@ -0,0 +1,69 @@
package wang.yaojia.webterm.transport
import okhttp3.OkHttpClient
import okhttp3.Request
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.PingableConnection
import wang.yaojia.webterm.wire.PingableTermTransport
import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.Tunables
import java.util.concurrent.TimeUnit
/** The `Origin` header name — THE CSWSH defence; stamped byte-equal from [HostEndpoint.originHeader]. */
internal const val HEADER_ORIGIN: String = "Origin"
/**
* The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`)
* over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double.
*
* The shared [OkHttpClient] (built by [OkHttpClientFactory], mTLS + `cache(null)` applied) is reused,
* but WS needs streaming tuning, so a variant is derived via `newBuilder()` — which SHARES the
* connection pool, dispatcher and `SSLSocketFactory`:
* - **`readTimeout(0)`** — a long-lived stream may sit idle between frames; a read timeout would
* wrongly kill it. Keep-alive is the ping's job, not the read timeout's.
* - **`pingInterval(PING_INTERVAL)`** — OkHttp sends real WS control pings and fails a
* pong-less (half-dead) connection, surfacing it as an `onFailure` → flow error → reconnect.
*
* `connect` stamps `Origin: endpoint.originHeader` on the upgrade [Request] and suspends until the
* handshake opens (or rethrows the connect-time failure verbatim, per the frozen contract). The dial
* is cancellation-safe: if the caller's coroutine is cancelled (or the handshake fails) while it is
* in flight, the just-created WebSocket is torn down before rethrowing, so no socket is leaked.
*
* Inbound frames flow up UNMODIFIED — there is deliberately NO transport-level frame-size cap here.
* `SessionEngine` (A14) self-measures each inbound frame's UTF-8 size and is the single authoritative
* classifier of an oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure).
*/
public class OkHttpTermTransport(
sharedClient: OkHttpClient,
) : PingableTermTransport {
private val wsClient: OkHttpClient = sharedClient.newBuilder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(Tunables.PING_INTERVAL.inWholeMilliseconds, TimeUnit.MILLISECONDS)
.build()
override suspend fun connect(endpoint: HostEndpoint): TransportConnection =
openConnection(endpoint)
override suspend fun connectPingable(endpoint: HostEndpoint): PingableConnection {
val connection = openConnection(endpoint)
return PingableConnection(connection = connection, pinger = connection.pinger)
}
private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection {
val request = Request.Builder()
.url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally
.header(HEADER_ORIGIN, endpoint.originHeader)
.build()
val connection = OkHttpWebSocketConnection(wsClient, request)
try {
connection.awaitOpen()
} catch (e: Throwable) {
// Cancelled or failed mid-handshake → abort the socket so it is never leaked, then
// rethrow verbatim (a connect-time onFailure stays byte-identical for the A9 probe).
connection.cancel()
throw e
}
return connection
}
}

View File

@@ -0,0 +1,153 @@
package wang.yaojia.webterm.transport
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import wang.yaojia.webterm.wire.ConnectionPinger
import wang.yaojia.webterm.wire.TransportConnection
import java.io.IOException
/** RFC 6455 normal-closure status code, used for a client-initiated detach. */
internal const val NORMAL_CLOSURE_CODE: Int = 1000
/**
* One live OkHttp WebSocket, adapted to the frozen [TransportConnection] contract (A7).
*
* The connection is opened eagerly in the constructor (`newWebSocket`), so [send] and [close] act on
* a real socket and [awaitOpen] can rethrow a connect-time failure verbatim (the pairing probe, A9,
* classifies POSIX/TLS codes off it). Inbound frames land in an UNLIMITED [inbox] mailbox from the
* listener (`onMessage → trySend`, never dropped even before a collector attaches); [frames] is a
* `channelFlow` that drains the mailbox with backpressure and turns the mailbox's terminal state into
* the two distinguishable flow outcomes the engine relies on:
* - a clean `onClosing`/`onClosed` → mailbox closed normally → the flow **completes normally**;
* - an `onFailure` → mailbox closed with the cause → the flow **throws** it.
*
* Inbound frames are forwarded up UNMODIFIED — there is NO transport-level size cap. `SessionEngine`
* (A14) self-measures each frame's UTF-8 byte size and is the single authoritative classifier of an
* oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure); the transport just
* shuttles bytes.
*
* `close()` is a client DETACH — it sends a 1000 close; the server-side PTY keeps running (invariant
* #2). It is NEVER a kill.
*
* Single-collection by contract (one live WS, consumed once), mirroring the `FakeTermTransport`
* double.
*/
internal class OkHttpWebSocketConnection(
client: OkHttpClient,
request: Request,
) : TransportConnection {
private val inbox = Channel<String>(Channel.UNLIMITED)
private val opened = CompletableDeferred<Unit>()
@Volatile
private var terminated = false
private val webSocket: WebSocket = client.newWebSocket(request, WsListener())
/**
* Liveness-based pinger. OkHttp (unlike iOS `URLSessionWebSocketTask.sendPing`) exposes NO manual
* ping with an observable pong via [WebSocketListener], so a true per-ping round-trip is not
* available. Real keep-alive is delegated to OkHttp's `pingInterval` (set on the streaming WS
* client in [OkHttpTermTransport]); this hook reports whether the connection is still live so the
* pure `PingScheduler` (A5) can count a dead connection as a miss.
*/
val pinger: ConnectionPinger = object : ConnectionPinger {
override suspend fun ping(): Boolean = !terminated
}
/** Suspends until the WS handshake completes; rethrows the connect-time failure verbatim. */
suspend fun awaitOpen() {
opened.await()
}
override val frames: Flow<String> = channelFlow {
val scope = this
val pump = launch {
try {
// Suspending send → lossless, in-order forwarding (the mailbox holds the backlog).
for (frame in inbox) {
scope.send(frame)
}
scope.close() // mailbox drained after a clean close → complete normally
} catch (e: CancellationException) {
throw e // cooperative cancellation must propagate, never be swallowed into a close
} catch (cause: Throwable) {
scope.close(cause) // mailbox closed with a cause → propagate the error
}
}
awaitClose {
pump.cancel()
// Downstream cancelled its collection → tear the socket down (harmless if already closed).
webSocket.cancel()
}
}
override suspend fun send(frame: String) {
if (!webSocket.send(frame)) {
throw IOException("WS send failed: the connection is closing or closed")
}
}
override suspend fun close() {
webSocket.close(NORMAL_CLOSURE_CODE, null)
finish(null)
}
/**
* Abort the underlying socket, tearing down an IN-FLIGHT handshake so a dial cancelled (or
* failed) before the connection opens never leaks the just-created WebSocket. Idempotent and
* harmless once the socket is already opened, closing or closed.
*/
fun cancel() {
webSocket.cancel()
finish(null)
}
/** Idempotently terminate: mark dead and close the mailbox (null = clean, non-null = error). */
private fun finish(cause: Throwable?) {
terminated = true
inbox.close(cause)
}
private inner class WsListener : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
opened.complete(Unit)
}
// Forwarded verbatim — the engine (A14) is the authoritative oversized-replay classifier.
override fun onMessage(webSocket: WebSocket, text: String) {
inbox.trySend(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
inbox.trySend(bytes.utf8())
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(NORMAL_CLOSURE_CODE, null) // complete the closing handshake
finish(null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
finish(null)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
finish(t)
// If we never opened, unblock connect() with the verbatim cause; no-op once opened.
opened.completeExceptionally(t)
}
}
}

View File

@@ -0,0 +1,58 @@
package wang.yaojia.webterm.transport
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import java.security.KeyStore
/**
* The shared-client invariants (plan §8): NO HTTP cache (preview/diff bodies can hold terminal
* secrets), and injected mTLS material is actually wired onto the client.
*/
class OkHttpClientFactoryTest {
@Test
fun sharedClientHasNoCache() {
val client = OkHttpClientFactory.create()
assertNull(client.cache, "OkHttpClient.cache must be null — no terminal secrets on disk")
}
@Test
fun defaultProviderInstallsNoClientIdentityButStillBuilds() {
val client = OkHttpClientFactory.create(ClientIdentityProvider.NONE)
assertNotNull(client.sslSocketFactory) // default system factory, not a caller-supplied one
}
@Test
fun injectedIdentityIsAppliedToTheClient() {
// Arrange: a system-default trust manager + matching socket factory as stand-in mTLS material.
val trustManager = systemDefaultTrustManager()
val sslSocketFactory = SSLContext.getInstance("TLS")
.apply { init(null, arrayOf(trustManager), null) }
.socketFactory
// Act
val client = OkHttpClientFactory.create { ClientIdentity(sslSocketFactory, trustManager) }
// Assert: the shared client uses exactly the injected factory (mTLS applies to WS + REST).
assertSame(sslSocketFactory, client.sslSocketFactory)
}
@Test
fun composesBothTransportsFromOneClientWithNoCache() {
val transports = OkHttpTransports.create()
assertNotNull(transports.term)
assertNotNull(transports.http)
assertNull(transports.client.cache)
}
private fun systemDefaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
}

View File

@@ -0,0 +1,126 @@
package wang.yaojia.webterm.transport
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.io.IOException
/**
* A7 REST contract over MockWebServer: 2xx returns status+body; non-2xx is returned (not thrown);
* a transport failure throws; the Origin split (guarded POST carries the caller's Origin verbatim,
* a read-only GET carries none) is preserved byte-for-byte.
*/
class OkHttpHttpTransportTest {
private lateinit var server: MockWebServer
private lateinit var transport: OkHttpHttpTransport
private val client = OkHttpClientFactory.create()
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
transport = OkHttpHttpTransport(client)
}
@AfterEach
fun tearDown() {
server.shutdown()
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
private fun url(path: String): String = server.url(path).toString()
@Test
fun returnsStatusAndBodyForA2xxResponse() = runBlocking {
// Arrange
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
val response = transport.send(HttpRequest(HttpMethod.GET, url("/live-sessions")))
// Assert
assertEquals(200, response.status)
assertEquals("[]", String(response.body, Charsets.UTF_8))
}
@Test
fun returnsNon2xxStatusWithoutThrowing() = runBlocking {
// Arrange: a 403 is meaningful to the caller (Origin guard), never an exception.
server.enqueue(MockResponse().setResponseCode(403).setBody("forbidden"))
// Act
val response = transport.send(
HttpRequest(
method = HttpMethod.POST,
url = url("/hook/decision"),
headers = mapOf("Origin" to "http://host:3000", "Content-Type" to "application/json"),
body = """{"x":1}""".toByteArray(Charsets.UTF_8),
),
)
// Assert
assertEquals(403, response.status)
assertEquals("forbidden", String(response.body, Charsets.UTF_8))
}
@Test
fun guardedPostCarriesCallerOriginAndBodyVerbatim() = runBlocking {
// Arrange
server.enqueue(MockResponse().setResponseCode(204))
val origin = "http://192.168.1.5:3000"
val payload = """{"sessionId":"s","decision":"allow","token":"t"}"""
// Act
transport.send(
HttpRequest(
method = HttpMethod.POST,
url = url("/hook/decision"),
headers = mapOf("Origin" to origin, "Content-Type" to "application/json"),
body = payload.toByteArray(Charsets.UTF_8),
),
)
// Assert: the server saw the exact Origin, Content-Type, method and body.
val recorded = server.takeRequest()
assertEquals("POST", recorded.method)
assertEquals(origin, recorded.getHeader("Origin"))
assertEquals("application/json", recorded.getHeader("Content-Type"))
assertEquals(payload, recorded.body.readUtf8())
}
@Test
fun readOnlyGetCarriesNoOriginHeader() = runBlocking {
// Arrange
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
// Act
transport.send(HttpRequest(HttpMethod.GET, url("/config/ui")))
// Assert: the CSWSH split — read-only GET must NOT carry Origin.
val recorded = server.takeRequest()
assertEquals("GET", recorded.method)
assertNull(recorded.getHeader("Origin"))
}
@Test
fun throwsOnTransportLevelFailure() {
// Arrange: the server drops the socket at connect → an IOException, not a returned status.
server.enqueue(MockResponse().apply { socketPolicy = SocketPolicy.DISCONNECT_AT_START })
// Act + Assert
assertThrows<IOException> {
runBlocking { transport.send(HttpRequest(HttpMethod.GET, url("/live-sessions"))) }
}
}
}

View File

@@ -0,0 +1,227 @@
package wang.yaojia.webterm.transport
import app.cash.turbine.test
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
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.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
/**
* A7 WS contract over MockWebServer's `withWebSocketUpgrade`:
* - the `Origin` header on the upgrade byte-equals `endpoint.originHeader` (THE CSWSH defence);
* - frames arrive in order; a clean server close COMPLETES the flow normally; a server abort
* SURFACES as a flow error (the two must stay distinguishable for A14);
* - `send` writes the exact frame; `close` detaches (server sees a 1000 close).
*/
class OkHttpTermTransportTest {
private lateinit var server: MockWebServer
private val client: OkHttpClient = OkHttpClientFactory.create()
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
}
@AfterEach
fun tearDown() {
// Force-close any live WS connection so MockWebServer isn't left waiting on an open socket.
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
// MockWebServer's WS shutdown occasionally times out draining its own task queue even after
// the socket is closed — a teardown artifact, not a product concern (assertions already ran).
runCatching { server.shutdown() }
client.dispatcher.executorService.shutdown()
}
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}"))
private fun transport(): OkHttpTermTransport = OkHttpTermTransport(client)
@Test
fun stampsOriginHeaderOnTheWsUpgrade() = runBlocking {
// Arrange
val serverWs = RecordingServerWebSocket()
server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
val endpoint = endpoint()
// Act
val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint) }
// Assert: the upgrade request carried the byte-equal Origin.
val upgrade = server.takeRequest()
assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"))
connection.close()
}
@Test
fun deliversFramesInArrivalOrderThenCompletesOnCleanClose() = runBlocking {
// Arrange
val serverWs = RecordingServerWebSocket()
server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) }
val socket = withTimeout(TIMEOUT_MS) { serverWs.opened.await() }
// Act + Assert
connection.frames.test {
socket.send("frame-1")
socket.send("frame-2")
assertEquals("frame-1", awaitItem())
assertEquals("frame-2", awaitItem())
socket.close(NORMAL_CLOSURE_CODE, "done")
awaitComplete() // clean close → NORMAL flow completion
}
}
@Test
fun serverAbortSurfacesAsAFlowError() = runBlocking {
// Arrange
val serverWs = RecordingServerWebSocket()
server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) }
withTimeout(TIMEOUT_MS) { serverWs.opened.await() }
// Act + Assert
connection.frames.test {
server.shutdown() // abrupt TCP teardown (no WS close frame), not a clean close
val error = awaitError()
assertTrue(error is IOException, "expected a transport IOException, got $error")
}
}
@Test
fun sendWritesTheExactFrameToTheServer() = runBlocking {
// Arrange
val serverWs = RecordingServerWebSocket()
server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) }
withTimeout(TIMEOUT_MS) { serverWs.opened.await() }
// Act
connection.send("""{"type":"attach","sessionId":null}""")
// Assert
val received = withTimeout(TIMEOUT_MS) { serverWs.messages.receive() }
assertEquals("""{"type":"attach","sessionId":null}""", received)
connection.close()
}
@Test
fun closeDetachesWithANormalClosure() = runBlocking {
// Arrange
val serverWs = RecordingServerWebSocket()
server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) }
withTimeout(TIMEOUT_MS) { serverWs.opened.await() }
// Act: client detach (server PTY keeps running — this is never a kill).
connection.close()
// Assert: the server observed a graceful 1000 close.
val code = withTimeout(TIMEOUT_MS) { serverWs.closingCode.await() }
assertEquals(NORMAL_CLOSURE_CODE, code)
}
@Test
fun connectPingableReportsLivenessAcrossClose() = runBlocking {
// Arrange
val serverWs = RecordingServerWebSocket()
server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
// Act
val pingable = withTimeout(TIMEOUT_MS) { transport().connectPingable(endpoint()) }
withTimeout(TIMEOUT_MS) { serverWs.opened.await() }
// Assert: alive before close, dead after a client detach.
assertTrue(pingable.pinger.ping())
pingable.connection.close()
assertFalse(pingable.pinger.ping())
}
@Test
fun cancellingAnInFlightDialTearsDownTheSocketWithNoLeak() = runBlocking {
// Arrange: the server accepts the TCP connection but never completes the WS upgrade, so onOpen
// never fires — connect() stays suspended in the handshake (a genuine in-flight dial).
server.enqueue(MockResponse().apply { socketPolicy = SocketPolicy.NO_RESPONSE })
// Act: dial in a child job, wait until OkHttp has the call in flight, then cancel mid-handshake.
val dialing = launch(start = CoroutineStart.UNDISPATCHED) {
transport().connect(endpoint())
}
withTimeout(TIMEOUT_MS) {
while (client.dispatcher.runningCallsCount() == 0) delay(POLL_MS)
}
dialing.cancelAndJoin()
// Assert: the just-created WebSocket was torn down — no leaked running call survives the cancel.
// (Without the fix the call sits blocked on readTimeout(0) forever, so this poll never settles.)
withTimeout(TIMEOUT_MS) {
while (client.dispatcher.runningCallsCount() != 0) delay(POLL_MS)
}
assertEquals(0, client.dispatcher.runningCallsCount())
}
@Test
fun cancellingFrameCollectionPropagatesCancellationCleanly() = runBlocking {
// Arrange
val serverWs = RecordingServerWebSocket()
server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) }
withTimeout(TIMEOUT_MS) { serverWs.opened.await() }
// Act: park a collector inside the flow (no frames sent → the pump suspends draining the mailbox).
val collector = launch(start = CoroutineStart.UNDISPATCHED) {
connection.frames.collect { }
}
// Assert: cancelling the collection lands as a cancellation (the pump must NOT swallow the
// CancellationException into a normal close) and join returns promptly — no deadlock.
withTimeout(TIMEOUT_MS) { collector.cancelAndJoin() }
assertTrue(collector.isCancelled, "collector must end cancelled, not completed normally")
}
private companion object {
const val TIMEOUT_MS = 5_000L
const val POLL_MS = 10L
}
}
/** Server side of the wire: records the upgrade socket, inbound frames, and the client's close code. */
private class RecordingServerWebSocket : WebSocketListener() {
val opened = CompletableDeferred<WebSocket>()
val messages = Channel<String>(Channel.UNLIMITED)
val closingCode = CompletableDeferred<Int>()
override fun onOpen(webSocket: WebSocket, response: Response) {
opened.complete(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) {
messages.trySend(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
closingCode.complete(code)
}
}