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
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user