fix(android): make the terminal actually usable on a device

The client built to an APK and 617 JVM tests passed, but nothing had ever
run on hardware, and three defects made it unusable the moment it did. All
three were invisible to the JVM suite because none of those tests
instantiate an Android View.

1. Every key press crashed. `RemoteTerminalView` bound the forked emulator
   but never installed a `TerminalViewClient`, and the stock Termux view
   dereferences `mClient` with no null guard on the first line of the paths
   a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
   offset 0, `onKeyUp`, `onKeyPreIme`). Installing any client is not enough:
   returning false continues into `mTermSession.write()` at offset 150, and
   `mTermSession` is null forever because `TerminalSession` is final and
   forking a process is what this app must not do. So the client consumes
   the key itself and `KeyRouting` has no defer-to-stock branch at all —
   the crash is unrepresentable, not merely avoided. Termux's `KeyHandler`
   stays the authority for the key tables, so DECCKM still emits `ESC O A`.

2. The PTY never learned the real size. Nothing called
   `RemoteTerminalSession.updateSize`, and the stock fallback is dead
   (`TerminalView.updateSize` early-returns without a session), so every
   full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives
   it from real cell metrics — read through the public `getFontWidth()` /
   `getFontLineSpacing()` accessors rather than reflection, which would
   break silently under R8, or a re-measured Paint, which would drift from
   what the renderer actually draws (plan risk R5).

3. Bare-LAN connections were impossible. `network_security_config.xml` was
   a self-labelled STUB denying all cleartext while `HostEndpoint` accepts
   http and derives ws://. The format has no CIDR syntax, so the allowlist
   its own comment promised cannot be written; cleartext is permitted in
   base-config, the tunnel domain keeps a TLS block, trust anchors are
   pinned to system-only, and the guard moves to the §5.4 pairing tiers.

Adversarial review then found three more, and one it got wrong:

  - Swipe-scrolling inside an alternate-screen TUI still crashed. The touch
    path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll
    into `handleKeyCode` whenever the alternate buffer is active, and that
    dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so
    this was a crash during normal use on the app's main screen.
    `TerminalScrollGesture` claims the vertical drag first and reproduces
    all three `doScroll` branches, closing the fling runnable and
    `onGenericMotionEvent` with it.
  - `autofill()` dereferences `mTermSession` unguarded while the view
    advertises itself autofillable, so any password manager crashed it.
    The subtree is excluded from the autofill structure.
  - The review asked for stock text selection to be restored. It cannot be:
    the ActionMode's own Copy and Paste handlers dereference the absent
    session, so a reachable selection UI has an NPE on its Copy button.
    Long press stays consumed and copy-out is recorded as a feature gap.

Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and
sealed with Tink AEAD under an AndroidKeyStore key (fail-closed — a seal
failure persists nothing rather than falling back to plaintext), and
`allowBackup=false` keeps a 30-day shell credential off Google Drive.

Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures.
Nothing here is device-verified yet; that is the next step.
This commit is contained in:
Yaojia Wang
2026-07-30 08:59:01 +02:00
parent c3613c2fae
commit 3e5cfdc1cf
48 changed files with 5719 additions and 85 deletions

View File

@@ -0,0 +1,173 @@
package wang.yaojia.webterm.transport
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import java.util.concurrent.ConcurrentHashMap
/**
* The client's cookie jar for the optional `WEBTERM_TOKEN` gate (server `src/http/auth.ts`).
*
* When a host is token-gated, `GET /?token=…` (or `POST /auth`) answers with
* `Set-Cookie: webterm_auth=…; HttpOnly; SameSite=Strict[; Secure]`, and from then on EVERY remote
* HTTP route **and the WS handshake** require that cookie. The upgrade is a plain HTTP request, so
* installing this jar on the one shared [okhttp3.OkHttpClient] covers both paths at once — OkHttp's
* `BridgeInterceptor` runs for WebSocket calls too.
*
* ── Isolation (security-load-bearing) ────────────────────────────────────────────────────────────
* The cookie is a **shell credential**. Two independent layers keep it on its own host:
* 1. storage is partitioned by [authCookieHostKey] (`scheme://host:port`), so another host's key
* simply has no entry to read;
* 2. what survives that lookup is still filtered through OkHttp's own `Cookie.matches(url)`, which
* applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext.
*
* Expiry is enforced on BOTH sides of the boundary: expired entries are pruned when read (and the
* pruning is pushed to storage) and dropped when restored, so a stale credential is never
* resurrected after a long background or a clock jump.
*
* Everything is in-memory and synchronous — OkHttp calls a `CookieJar` on the call thread.
* Durability is the [AuthCookiePersister]'s job (`:host-registry` DataStore, adapted in `:app`);
* [restore] rehydrates at cold start. A cookie jar is NOT a cache: the shared client keeps
* `.cache(null)` (plan §8) untouched.
*
* @param persister where a host's changed cookie set is handed for durable storage.
* @param clock wall-clock source (injected for tests); must be the same epoch as `expiresAt`.
*/
public class AuthCookieJar(
private val persister: AuthCookiePersister = AuthCookiePersister.NONE,
private val clock: () -> Long = System::currentTimeMillis,
) : CookieJar {
/** hostKey → that host's cookies. Values are immutable lists, replaced wholesale, never mutated. */
private val byHostKey = ConcurrentHashMap<String, List<Cookie>>()
/** Serialises the read-modify-write of a host's list (reads stay lock-free through the map). */
private val writeLock = Any()
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val hostKey = authCookieHostKey(url)
val stored = byHostKey[hostKey] ?: return emptyList()
val live = pruneExpired(hostKey, stored)
// Second layer: RFC domain/path match + the Secure-over-cleartext refusal.
return live.filter { it.matches(url) }
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
if (cookies.isEmpty()) return
val hostKey = authCookieHostKey(url)
val now = clock()
synchronized(writeLock) {
store(hostKey, byHostKey[hostKey].orEmpty().merged(cookies, now))
}
}
/**
* Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots
* and structurally invalid ones are dropped. This is a hydration path, not a change, so the
* [AuthCookiePersister] is deliberately NOT notified.
*/
public fun restore(hostKey: String, cookies: List<AuthCookieSnapshot>) {
val now = clock()
val live = cookies
.filter { it.expiresAtEpochMillis > now }
.mapNotNull { it.toCookieOrNull() }
.capped()
synchronized(writeLock) {
if (live.isEmpty()) byHostKey.remove(hostKey) else byHostKey[hostKey] = live
}
}
/**
* Forget one host's cookies (unpair / "sign out of this host") and tell storage to do the same.
* Other hosts are untouched.
*/
public fun clear(hostKey: String) {
synchronized(writeLock) { byHostKey.remove(hostKey) }
persister.persist(hostKey, emptyList())
}
/** Redacted by construction — a cookie jar's contents are credentials. */
override fun toString(): String = "AuthCookieJar(hosts=${byHostKey.size})"
// ── internals ───────────────────────────────────────────────────────────────────────────────
/** Drops expired entries, publishing the pruned set (so storage forgets them too) iff it changed. */
private fun pruneExpired(hostKey: String, stored: List<Cookie>): List<Cookie> {
val now = clock()
val live = stored.filter { it.expiresAt > now }
if (live.size == stored.size) return stored
synchronized(writeLock) {
// Re-read under the lock: a concurrent save may have replaced the list meanwhile.
val current = byHostKey[hostKey].orEmpty()
store(hostKey, current.filter { it.expiresAt > now })
}
return live
}
/** The single write path: cap, publish in memory, then hand the persistent subset to storage. */
private fun store(hostKey: String, cookies: List<Cookie>) {
val capped = cookies.capped()
if (capped.isEmpty()) byHostKey.remove(hostKey) else byHostKey[hostKey] = capped
// Session cookies (no Max-Age/Expires) must not outlive the process — memory only.
persister.persist(hostKey, capped.filter { it.persistent }.map { it.toSnapshot() })
}
}
/** Keep the most recent [MAX_COOKIES_PER_HOST] entries (oldest evicted first). */
private fun List<Cookie>.capped(): List<Cookie> =
if (size <= MAX_COOKIES_PER_HOST) this else takeLast(MAX_COOKIES_PER_HOST)
/**
* Fold [incoming] into the stored set: same-identity cookies are replaced IN PLACE (position
* preserved), an already-expired incoming cookie deletes its stored twin (the `Max-Age=0` logout
* shape), and anything stored that has expired is dropped on the way through. Returns a NEW list.
*/
private fun List<Cookie>.merged(incoming: List<Cookie>, now: Long): List<Cookie> =
incoming.fold(filter { it.expiresAt > now }) { acc, cookie ->
if (cookie.expiresAt > now) acc.upserting(cookie) else acc.filterNot { it.sameIdentity(cookie) }
}
private fun List<Cookie>.upserting(cookie: Cookie): List<Cookie> =
if (any { it.sameIdentity(cookie) }) {
map { if (it.sameIdentity(cookie)) cookie else it }
} else {
this + cookie
}
/** RFC 6265 cookie identity: (name, domain, path). The host partition is applied one level up. */
private fun Cookie.sameIdentity(other: Cookie): Boolean =
name == other.name && domain == other.domain && path == other.path
private fun Cookie.toSnapshot(): AuthCookieSnapshot =
AuthCookieSnapshot(
name = name,
value = value,
domain = domain,
path = path,
expiresAtEpochMillis = expiresAt,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
)
/**
* Rebuild an OkHttp [Cookie] from storage. Values at rest are untrusted (a tampered or
* format-drifted blob) — an unparsable record yields null and is dropped rather than crashing the
* jar. `expiresAt` is set explicitly, so a restored cookie is always persistent.
*/
private fun AuthCookieSnapshot.toCookieOrNull(): Cookie? =
try {
Cookie.Builder()
.name(name)
.value(value)
.expiresAt(expiresAtEpochMillis)
.path(path)
.let { if (hostOnly) it.hostOnlyDomain(domain) else it.domain(domain) }
.let { if (secure) it.secure() else it }
.let { if (httpOnly) it.httpOnly() else it }
.build()
} catch (_: IllegalArgumentException) {
null // never log the exception payload — it can carry the cookie value
} catch (_: IllegalStateException) {
null
}

View File

@@ -0,0 +1,82 @@
package wang.yaojia.webterm.transport
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* The name of the server's shared-access-token cookie (`src/http/auth.ts` `AUTH_COOKIE_NAME`).
* Exposed as a constant so nothing has to hard-code the string; the jar itself is name-agnostic
* (it stores whatever the paired host sets, scoped to that host).
*/
public const val AUTH_COOKIE_NAME: String = "webterm_auth"
/**
* Upper bound on how many cookies are retained per host. The paired server sets exactly one
* ([AUTH_COOKIE_NAME]); the cap is a boundary guard so a broken or hostile host cannot turn the
* client into an unbounded credential sink. Oldest entries are evicted first.
*/
internal const val MAX_COOKIES_PER_HOST: Int = 16
/**
* A storable, OkHttp-free view of one cookie — the wire between [AuthCookieJar] and whatever
* persists it (`:host-registry`'s `AuthCookieStore`, adapted in `:app`).
*
* The DTO is declared HERE rather than shared with `:host-registry` on purpose: `:transport-okhttp`
* must keep depending only on `:wire-protocol` (+ OkHttp), exactly like the [ClientIdentityProvider]
* seam avoids a `:client-tls` edge. `:app` maps the two field-for-field.
*
* [expiresAtEpochMillis] is ABSOLUTE (wall-clock ms), never a `Max-Age` duration: persisting the raw
* `Set-Cookie` header would silently restart the lifetime on every restore and extend a shell
* credential past what the server granted.
*
* [toString] is REDACTED. [value] is the credential — it must never reach a log, a crash report or a
* `toString()`.
*/
public data class AuthCookieSnapshot(
val name: String,
val value: String,
val domain: String,
val path: String,
val expiresAtEpochMillis: Long,
val secure: Boolean = false,
val httpOnly: Boolean = false,
val hostOnly: Boolean = true,
) {
override fun toString(): String =
"AuthCookieSnapshot(name=$name, value=<redacted>, domain=$domain, path=$path, " +
"expiresAtEpochMillis=$expiresAtEpochMillis, secure=$secure, httpOnly=$httpOnly, hostOnly=$hostOnly)"
}
/**
* Where a host's cookie set goes when it changes. Deliberately NON-suspending: OkHttp calls the
* `CookieJar` synchronously on the call thread, so the implementation must hand off (e.g.
* `scope.launch { store.replaceHost(...) }`) rather than block on DataStore I/O.
*
* The callback always carries the host's COMPLETE persistent cookie set, so the implementation is a
* wholesale replace — an empty list means "this host has no persisted cookie any more" (server-side
* deletion, expiry, or unpair), never "nothing changed".
*/
public fun interface AuthCookiePersister {
/** @param hostKey the isolation key from [authCookieHostKey] — never a bare hostname. */
public fun persist(hostKey: String, cookies: List<AuthCookieSnapshot>)
public companion object {
/** Memory-only: the cookie lives for the process lifetime and is never written down. */
public val NONE: AuthCookiePersister = AuthCookiePersister { _, _ -> }
}
}
/**
* The per-host isolation key: `scheme://host:port` with the port ALWAYS explicit
* (`HttpUrl.port` fills in the scheme default). Stricter than the cookie RFC on purpose — browsers
* scope cookies by domain alone, which would hand a host's shell credential to a different service
* on the same machine or to the same name over a different scheme.
*
* The WS upgrade lands on the same key as REST: OkHttp normalises `ws(s)://` to `http(s)://`, and a
* `HostEndpoint`'s `wsUrl` and `baseUrl` share scheme/host/port by construction.
*/
public fun authCookieHostKey(url: HttpUrl): String = "${url.scheme}://${url.host}:${url.port}"
/** Same key derived from a `HostEndpoint.baseUrl` (or any absolute http(s) URL); null if unparsable. */
public fun authCookieHostKey(baseUrl: String): String? =
baseUrl.toHttpUrlOrNull()?.let { authCookieHostKey(it) }

View File

@@ -0,0 +1,91 @@
package wang.yaojia.webterm.transport
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostNetworkTier
import java.net.UnknownServiceException
/**
* The app refused to send this request **in plaintext** because the target host is not on a network
* where cleartext is allowed (plan §6.9 / §8 "Cleartext posture").
*
* The supertype is load-bearing, twice over:
* - it is a `java.io.IOException`, so OkHttp reports it through the normal failure path
* (`Callback.onFailure` / `WebSocketListener.onFailure`) and every existing caller already handles
* it — nothing leaves the device before it is thrown;
* - it is specifically [UnknownServiceException], which is EXACTLY what the Android platform throws
* when `network_security_config` blocks cleartext — and what `:api-client`'s
* `PairingError.classify` already maps to `PairingError.CleartextBlocked`. So a refused dial
* surfaces as "this host must use https/wss", not as the misleading "host unreachable"
* (`ConnectException`) — the user can tell "the app refused this" from "the host is down" with no
* changes anywhere else. Do not widen this supertype without re-checking that classifier.
*
* @param host the URL host that was refused (never a credential — safe to show and to log).
* @param tier what [HostClassifier] made of that host (always a non-cleartext tier here).
*/
public class CleartextNotPermittedException(
public val host: String,
public val tier: HostNetworkTier,
) : UnknownServiceException(
"Refused to send an unencrypted request to \"$host\" ($tier): plaintext http/ws is permitted " +
"only for loopback, private-LAN and Tailscale hosts. Use https/wss for this host.",
)
/**
* The transport-time half of the cleartext allowlist.
*
* Android's `network_security_config` is the platform half, and it genuinely **cannot** express a
* CIDR/prefix — the format takes exact domains only. So the shipped config permits cleartext in
* `<base-config>` (plus a TLS-only `<domain-config>` for the tunnel apex), and the CIDR scoping plan
* §6.9 asks for is enforced here, where a real classification is possible:
*
* | tier | cleartext (`http`/`ws`) | why |
* |---|---|---|
* | [HostNetworkTier.LOOPBACK] | permitted | on-device only; the hook side-channel and local dev |
* | [HostNetworkTier.PRIVATE_LAN] | permitted | THE bare-LAN posture (RFC1918 · 169.254/16 · `.local`) |
* | [HostNetworkTier.TAILSCALE] | permitted | CGNAT 100.64/10 · `*.ts.net` — already WireGuard-encrypted |
* | [HostNetworkTier.PUBLIC] | **refused** | includes every unclassifiable host — fail closed |
*
* `https`/`wss` is never touched: TLS to a public host is the tunnel/Tailscale path and stays on
* default system trust.
*/
internal object CleartextPolicy {
/** The tiers whose plaintext dial is allowed. Everything else — including "unknown" — is not. */
private val CLEARTEXT_TIERS: Set<HostNetworkTier> = setOf(
HostNetworkTier.LOOPBACK,
HostNetworkTier.PRIVATE_LAN,
HostNetworkTier.TAILSCALE,
)
/**
* The refusal for [url], or null when the dial is allowed. Pure: OkHttp has already normalised
* `ws(s)://` to `http(s)://` by the time a request exists, so [HttpUrl.isHttps] is the whole
* encrypted/not test.
*/
fun refusalFor(url: HttpUrl): CleartextNotPermittedException? {
if (url.isHttps) return null
val tier = HostClassifier.classify(url.host)
if (tier in CLEARTEXT_TIERS) return null
return CleartextNotPermittedException(host = url.host, tier = tier)
}
}
/**
* Installs [CleartextPolicy] on the one shared client (see [OkHttpClientFactory]). It runs as an
* **application** interceptor — first in the chain, before DNS, before the connection pool, before
* the cookie jar attaches a credential — so a refused request never touches the network.
*
* That placement is safe only because the client also pins `followRedirects(false)` /
* `followSslRedirects(false)`: an application interceptor does not see redirect follow-ups, so a
* `3xx` to `http://` would otherwise slip past this check. The two must stay together.
*/
internal class CleartextGuardInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
CleartextPolicy.refusalFor(request.url)?.let { throw it }
return chain.proceed(request)
}
}

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.transport
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@@ -48,23 +49,45 @@ public fun interface ClientIdentityProvider {
* one client → one `SSLSocketFactory` → mTLS applies uniformly to WS and REST, and the connection
* pool is shared.
*
* Two invariants baked in here:
* Five invariants baked in here:
* - **`.cache(null)`** (plan §8): no ephemeral/disk HTTP cache — preview/diff bodies can hold
* terminal secrets, so nothing is persisted.
* terminal secrets, so nothing is persisted. A [cookieJar] is NOT a cache and never re-enables it.
* - **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.
* case (plan §6.9).
* - **Cleartext is scoped to LAN/Tailscale/loopback** ([CleartextGuardInterceptor]). The `:app`
* `network_security_config` is only half the control: the format has no CIDR/prefix syntax, so it
* can only permit cleartext globally. The CIDR half of plan §6.9 is enforced here — a plaintext
* `http`/`ws` dial to a PUBLIC host fails fast with [CleartextNotPermittedException] instead of
* silently succeeding.
* - **No redirects followed** (`followRedirects` / `followSslRedirects` = false). No route in plan
* §4.2/§4.3 legitimately redirects, and following one would let a hostile or compromised host
* `3xx` an `https` call down to `http` — past the cleartext guard, which (as an application
* interceptor) does not see redirect follow-ups. Callers get the `3xx` verbatim.
* - **One cookie jar for WS + REST.** The optional `WEBTERM_TOKEN` gate answers pairing with a
* `webterm_auth` cookie that every later HTTP route AND the WS upgrade require. Because both
* transports share this client, installing an [AuthCookieJar] here puts the cookie on the upgrade
* request too (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Default is
* [CookieJar.NO_COOKIES] — a host with no token gate behaves exactly as before.
*/
public object OkHttpClientFactory {
/**
* @param identityProvider optional mTLS material; [ClientIdentityProvider.NONE] → default trust,
* no client cert.
* @param cookieJar session-cookie store; [CookieJar.NO_COOKIES] disables cookies entirely.
*/
public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
cookieJar: CookieJar = CookieJar.NO_COOKIES,
): OkHttpClient {
val builder = OkHttpClient.Builder().cache(null)
val builder = OkHttpClient.Builder()
.cache(null)
.cookieJar(cookieJar)
// First in the chain: refuse a public-host plaintext dial before DNS, the connection pool
// or the cookie jar can act on it.
.addInterceptor(CleartextGuardInterceptor())
.followRedirects(false)
.followSslRedirects(false)
identityProvider.currentIdentity()?.let { identity ->
builder.sslSocketFactory(identity.sslSocketFactory, identity.trustManager)
}
@@ -86,8 +109,9 @@ public class OkHttpTransports private constructor(
public companion object {
public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
cookieJar: CookieJar = CookieJar.NO_COOKIES,
): OkHttpTransports {
val client = OkHttpClientFactory.create(identityProvider)
val client = OkHttpClientFactory.create(identityProvider, cookieJar)
return OkHttpTransports(
term = OkHttpTermTransport(client),
http = OkHttpHttpTransport(client),

View File

@@ -0,0 +1,351 @@
package wang.yaojia.webterm.transport
import kotlinx.coroutines.CompletableDeferred
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 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 wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
/**
* The `WEBTERM_TOKEN` session-cookie path (server: `src/http/auth.ts`). The gate sets an
* `HttpOnly; SameSite=Strict; Secure-when-https` cookie named `webterm_auth` at pairing and then
* requires it on EVERY remote HTTP route **and on the WS upgrade** (which is a plain HTTP request —
* without the cookie the socket 401s before it ever opens).
*
* These tests pin four things:
* 1. **replay** — a cookie set by a host rides every later request to that host, REST and WS alike;
* 2. **host isolation** — host A's cookie is NEVER offered to host B (a shell credential; a
* cross-host leak is a security bug, not a cosmetic one);
* 3. **scope/expiry** — expiry and the `Secure` scope are honoured (OkHttp's own `Cookie.matches`),
* and a server deletion (`Max-Age=0`) is propagated to storage;
* 4. **no credential in any log/toString path**.
*
* The `Origin` byte-equality assertion is repeated on the WS upgrade here on purpose: adding the
* `Cookie` header must not disturb THE CSWSH defence.
*/
class AuthCookieJarTest {
private lateinit var server: MockWebServer
private lateinit var otherServer: MockWebServer
private val persister = RecordingPersister()
private var nowOffsetMs: Long = 0L
private val jar = AuthCookieJar(persister = persister, clock = { System.currentTimeMillis() + nowOffsetMs })
private lateinit var client: OkHttpClient
@BeforeEach
fun setUp() {
server = MockWebServer().apply { start() }
otherServer = MockWebServer().apply { start() }
client = OkHttpClientFactory.create(cookieJar = jar)
}
@AfterEach
fun tearDown() {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { server.shutdown() }
runCatching { otherServer.shutdown() }
client.dispatcher.executorService.shutdown()
}
private fun transport() = OkHttpHttpTransport(client)
private fun MockWebServer.get(path: String) = runBlocking {
transport().send(HttpRequest(HttpMethod.GET, url(path).toString()))
}
private fun setCookieResponse(attributes: String = "Path=/; Max-Age=$MAX_AGE_SEC; HttpOnly; SameSite=Strict") =
MockResponse()
.setResponseCode(200)
.addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=$TOKEN; $attributes")
.setBody("{}")
// ── 1. replay ────────────────────────────────────────────────────────────────
@Test
fun replaysTheAuthCookieOnEveryLaterRequestToTheSameHost() {
// Arrange: pairing response carries the Set-Cookie; two later reads follow.
server.enqueue(setCookieResponse())
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
server.get("/?token=$TOKEN")
server.get("/live-sessions")
server.get("/config/ui")
// Assert: the pairing request itself had no cookie; both later ones did.
assertNull(server.takeRequest().getHeader("Cookie"))
assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie"))
assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie"))
}
@Test
fun carriesTheCookieOnTheWsUpgradeWithTheOriginStillByteEqual() = runBlocking {
// Arrange: pair over REST (cookie captured), then dial the WS on the SAME host.
server.enqueue(setCookieResponse())
val serverWs = OpenedServerWebSocket()
server.enqueue(MockResponse().withWebSocketUpgrade(serverWs))
server.get("/?token=$TOKEN")
server.takeRequest() // drop the pairing request
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}"))
// Act
val connection = withTimeout(TIMEOUT_MS) { OkHttpTermTransport(client).connect(endpoint) }
// Assert: the UPGRADE request carries the cookie AND the byte-equal Origin (CSWSH defence).
val upgrade = server.takeRequest()
assertEquals("$AUTH_COOKIE_NAME=$TOKEN", upgrade.getHeader("Cookie"))
assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"))
connection.close()
}
@Test
fun restoredCookiesAreReplayedAfterAColdStart() {
// Arrange: a fresh process — nothing in memory, everything rehydrated from storage.
val coldJar = AuthCookieJar(clock = { System.currentTimeMillis() + nowOffsetMs })
val coldClient = OkHttpClientFactory.create(cookieJar = coldJar)
val hostKey = requireNotNull(authCookieHostKey(server.url("/").toString()))
coldJar.restore(hostKey, listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() + HOUR_MS)))
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
runBlocking {
OkHttpHttpTransport(coldClient)
.send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString()))
}
// Assert
assertEquals("$AUTH_COOKIE_NAME=$TOKEN", server.takeRequest().getHeader("Cookie"))
coldClient.connectionPool.evictAll()
coldClient.dispatcher.executorService.shutdown()
}
// ── 2. host isolation ────────────────────────────────────────────────────────
@Test
fun neverSendsAHostsCookieToADifferentHost() {
// Arrange: host A hands out the credential; host B must never see it.
server.enqueue(setCookieResponse())
otherServer.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
server.get("/?token=$TOKEN")
otherServer.get("/live-sessions")
// Assert
val leaked = otherServer.takeRequest().getHeader("Cookie")
assertNull(leaked, "host A's shell credential must never ride a request to host B")
}
@Test
fun clearForgetsOneHostsCookiesAndLeavesTheOthersAlone() {
// Arrange: both hosts are authed.
server.enqueue(setCookieResponse())
otherServer.enqueue(setCookieResponse())
server.get("/?token=$TOKEN")
otherServer.get("/?token=$TOKEN")
server.takeRequest()
otherServer.takeRequest()
// Act: unpair host A only.
jar.clear(requireNotNull(authCookieHostKey(server.url("/").toString())))
// Assert
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
otherServer.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
server.get("/live-sessions")
otherServer.get("/live-sessions")
assertNull(server.takeRequest().getHeader("Cookie"))
assertEquals("$AUTH_COOKIE_NAME=$TOKEN", otherServer.takeRequest().getHeader("Cookie"))
}
// ── 3. scope + expiry ────────────────────────────────────────────────────────
@Test
fun stopsReplayingAnExpiredCookieAndPersistsTheRemoval() {
// Arrange
server.enqueue(setCookieResponse())
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
server.get("/?token=$TOKEN")
server.takeRequest()
// Act: jump past Max-Age.
nowOffsetMs = (MAX_AGE_SEC + 1) * 1_000L
server.get("/live-sessions")
// Assert: not sent any more, and storage was told to drop it.
assertNull(server.takeRequest().getHeader("Cookie"))
assertEquals(emptyList<AuthCookieSnapshot>(), persister.latestFor(hostKey()))
}
@Test
fun honoursAServerSideDeletionOfTheCookie() {
// Arrange: authed, then the server expires the cookie (Max-Age=0 — the logout shape).
server.enqueue(setCookieResponse())
server.enqueue(setCookieResponse(attributes = "Path=/; Max-Age=0"))
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
server.get("/?token=$TOKEN")
server.get("/live-sessions")
server.get("/live-sessions")
// Assert
server.takeRequest()
server.takeRequest()
assertNull(server.takeRequest().getHeader("Cookie"))
assertEquals(emptyList<AuthCookieSnapshot>(), persister.latestFor(hostKey()))
}
@Test
fun doesNotRestoreACookieThatAlreadyExpiredAtRest() {
// Arrange: storage handed back a stale record (clock skew / long-backgrounded app).
val coldJar = AuthCookieJar(clock = { System.currentTimeMillis() + nowOffsetMs })
val coldClient = OkHttpClientFactory.create(cookieJar = coldJar)
coldJar.restore(hostKey(), listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() - 1)))
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
runBlocking {
OkHttpHttpTransport(coldClient)
.send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString()))
}
// Assert
assertNull(server.takeRequest().getHeader("Cookie"))
coldClient.connectionPool.evictAll()
coldClient.dispatcher.executorService.shutdown()
}
@Test
fun aSecureCookieIsNeverSentOverCleartext() {
// Arrange: a Secure cookie restored for a cleartext LAN host (the bare-LAN ws:// posture).
jar.restore(
hostKey(),
listOf(snapshot(expiresAtEpochMillis = System.currentTimeMillis() + HOUR_MS, secure = true)),
)
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
server.get("/live-sessions")
// Assert
assertNull(server.takeRequest().getHeader("Cookie"))
}
@Test
fun persistsOnlyTheHostThatIssuedTheCookieAndOnlyPersistentCookies() {
// Arrange: one persistent (Max-Age) + one session cookie (no Max-Age/Expires).
server.enqueue(
MockResponse()
.setResponseCode(200)
.addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=$TOKEN; Path=/; Max-Age=$MAX_AGE_SEC")
.addHeader("Set-Cookie", "transient=x; Path=/")
.setBody("{}"),
)
// Act
server.get("/?token=$TOKEN")
// Assert: storage is told about the persistent one only (a session cookie must not outlive
// the process), and it is filed under this host's key.
val persisted = persister.latestFor(hostKey())
assertEquals(listOf(AUTH_COOKIE_NAME), persisted?.map { it.name })
assertEquals(TOKEN, persisted?.single()?.value)
}
@Test
fun capsTheNumberOfCookiesStoredPerHost() {
// Arrange: a hostile/broken server tries to make the client an unbounded cookie sink.
val response = MockResponse().setResponseCode(200).setBody("{}")
repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") }
server.enqueue(response)
// Act
server.get("/")
// Assert
assertEquals(MAX_COOKIES_PER_HOST, persister.latestFor(hostKey())?.size)
}
// ── 4. the credential never reaches a log/toString path ──────────────────────
@Test
fun neverExposesTheCookieValueInAnyToStringPath() {
// Arrange
server.enqueue(setCookieResponse())
server.get("/?token=$TOKEN")
// Act
val rendered = listOf(
jar.toString(),
persister.latestFor(hostKey()).toString(),
persister.latestFor(hostKey())!!.single().toString(),
AuthCookieJar().toString(),
)
// Assert: the token/cookie value appears nowhere; the name/host key may.
rendered.forEach { text ->
assertFalse(text.contains(TOKEN), "credential leaked into a log/toString path: $text")
}
assertTrue(rendered[2].contains(AUTH_COOKIE_NAME), "the redacted rendering should still identify the cookie")
}
private fun hostKey(): String = requireNotNull(authCookieHostKey(server.url("/").toString()))
private fun snapshot(
expiresAtEpochMillis: Long,
secure: Boolean = false,
) = AuthCookieSnapshot(
name = AUTH_COOKIE_NAME,
value = TOKEN,
domain = server.hostName,
path = "/",
expiresAtEpochMillis = expiresAtEpochMillis,
secure = secure,
httpOnly = true,
hostOnly = true,
)
private companion object {
const val TOKEN = "s3cr3t-webterm-token-value"
const val MAX_AGE_SEC = 60L
const val HOUR_MS = 3_600_000L
const val TIMEOUT_MS = 5_000L
}
}
/** Records the last persisted snapshot per host key — the `:app`/DataStore side of the seam. */
private class RecordingPersister : AuthCookiePersister {
private val latest = mutableMapOf<String, List<AuthCookieSnapshot>>()
override fun persist(hostKey: String, cookies: List<AuthCookieSnapshot>) {
latest[hostKey] = cookies
}
fun latestFor(hostKey: String): List<AuthCookieSnapshot>? = latest[hostKey]
}
/** Minimal server-side WS listener: only "the upgrade happened" matters here. */
private class OpenedServerWebSocket : WebSocketListener() {
val opened = CompletableDeferred<WebSocket>()
override fun onOpen(webSocket: WebSocket, response: Response) {
opened.complete(webSocket)
}
}

View File

@@ -0,0 +1,304 @@
package wang.yaojia.webterm.transport
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
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.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
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 org.junit.jupiter.api.assertThrows
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.io.IOException
import java.net.InetAddress
import java.net.UnknownServiceException
/**
* Transport-time cleartext scoping (plan §6.9 / §8 "Cleartext posture").
*
* Android's `network_security_config` has no CIDR/prefix syntax, so the platform file can only permit
* cleartext globally (plus a TLS-only `<domain-config>` for the tunnel apex). The CIDR allowlist the
* platform cannot express is enforced HERE, on the one shared client: a plaintext (`http`/`ws`) dial
* is allowed only for loopback / private-LAN / Tailscale hosts
* ([wang.yaojia.webterm.wire.HostClassifier]); a plaintext dial to a PUBLIC host fails fast with the
* typed [CleartextNotPermittedException] before a byte leaves the device.
*
* Two layers of test, on purpose:
* - **wiring** — real [MockWebServer] exchanges through the real client. A non-loopback URL *host*
* reaches the loopback server via a pinned [Dns], so the guard sees the host it would see in the
* field and "refused" is proven by `requestCount == 0`. (Only hostnames can be pinned this way:
* OkHttp resolves an IP literal itself and never consults `Dns`.)
* - **the decision table** — [CleartextPolicy] over every CIDR edge, including the ones no local
* socket can stand in for (10/8, 172.16/12, CGNAT …).
*/
class CleartextGuardTest {
private lateinit var server: MockWebServer
@BeforeEach
fun setUp() {
server = MockWebServer().apply { start() }
}
@AfterEach
fun tearDown() {
runCatching { server.shutdown() }
}
// ── wiring: permitted tiers reach the host ──────────────────────────────────────────────────
@Test
fun permitsCleartextToAPrivateLanHost() {
// Arrange: the bare-LAN posture — http:// to an mDNS LAN name (PRIVATE_LAN).
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
val response = get("http://mybox.local:${server.port}/live-sessions")
// Assert: the dial went through to the host.
assertEquals(200, response.status)
assertEquals(1, server.requestCount)
}
@Test
fun permitsCleartextToATailscaleMagicDnsHost() {
// Arrange: *.ts.net is already WireGuard-encrypted — cleartext there must keep working.
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
val response = get("http://mybox.tailnet-1a2b.ts.net:${server.port}/live-sessions")
// Assert
assertEquals(200, response.status)
assertEquals(1, server.requestCount)
}
@Test
fun permitsCleartextToLoopbackSoLocalFlowsKeepWorking() {
// Arrange
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act: the plain MockWebServer URL (localhost/127.0.0.1) — no DNS pinning, no guard in the way.
val response = runBlocking {
OkHttpHttpTransport(OkHttpClientFactory.create())
.send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString()))
}
// Assert
assertEquals(200, response.status)
assertEquals(1, server.requestCount)
}
// ── wiring: cleartext to a public host is refused ───────────────────────────────────────────
@Test
fun refusesCleartextToAPublicHostBeforeAnyByteLeavesTheDevice() {
// Arrange: a public name that would otherwise resolve straight to the live server.
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act: caught as IOException on purpose — the refusal MUST be one, or OkHttp's async path
// cannot surface it as a call failure.
val failure = assertThrows<IOException> {
get("http://terminal.example.com:${server.port}/live-sessions")
}
// Assert: typed, self-describing, and nothing was sent.
val refusal = assertInstanceOf(CleartextNotPermittedException::class.java, failure)
assertEquals("terminal.example.com", refusal.host)
assertEquals(HostNetworkTier.PUBLIC, refusal.tier)
assertTrue(
refusal.message.orEmpty().contains("terminal.example.com"),
"the refusal must name the host it refused: ${refusal.message}",
)
assertEquals(0, server.requestCount, "a refused cleartext dial must never reach the network")
}
@Test
fun theRefusalIsTheSameTypeThePlatformUsesSoTheExistingPairingCopyApplies() {
// `:api-client`'s PairingError.classify maps java.net.UnknownServiceException — the platform's
// own network_security_config cleartext block — to PairingError.CleartextBlocked ("this host
// must use https/wss"). Our refusal must land in that bucket, NOT in HostUnreachable, or the
// user cannot tell "the app refused this" from "the host is down".
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
val failure = assertThrows<UnknownServiceException> {
get("http://terminal.example.com:${server.port}/live-sessions")
}
assertInstanceOf(CleartextNotPermittedException::class.java, failure)
}
@Test
fun refusesACleartextWebSocketUpgradeToAPublicHost() {
// Arrange: the terminal stream itself — the plaintext path that matters most.
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://terminal.example.com:${server.port}"))
server.enqueue(MockResponse().withWebSocketUpgrade(NeverReachedServerWebSocket()))
// Act
val failure = assertThrows<CleartextNotPermittedException> {
runBlocking { OkHttpTermTransport(pinnedToLoopback()).connect(endpoint) }
}
// Assert
assertEquals("terminal.example.com", failure.host)
assertEquals(0, server.requestCount, "the ws:// upgrade must be refused before the socket opens")
}
@Test
fun refusesCleartextToAnUnclassifiableHostFailSafe() {
// Arrange: an out-of-range dotted quad is not an IP at all — HostClassifier fails safe to
// PUBLIC, so must the guard.
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act + Assert
assertThrows<CleartextNotPermittedException> {
get("http://999.1.2.3:${server.port}/live-sessions")
}
assertEquals(0, server.requestCount)
}
// ── wiring: https is untouched ──────────────────────────────────────────────────────────────
@Test
fun leavesHttpsToAPublicHostAlone() {
// Arrange: the tunnel/Tailscale shape. The MockWebServer speaks cleartext, so the handshake
// will fail — what matters is HOW it fails: at the socket, not at our guard.
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
val failure = assertThrows<IOException> {
get("https://terminal.example.com:${server.port}/live-sessions")
}
// Assert: the guard did NOT veto it — the call got as far as TLS.
assertFalse(
failure is CleartextNotPermittedException,
"https must never be refused by the cleartext guard, got: $failure",
)
}
// ── wiring: no silent scheme downgrade ──────────────────────────────────────────────────────
@Test
fun doesNotFollowRedirectsSoAHostCannotDowngradeTheClient() {
// Arrange: a host answers with a 3xx pointing at a public http:// URL.
server.enqueue(
MockResponse()
.setResponseCode(302)
.addHeader("Location", "http://terminal.example.com:${server.port}/live-sessions"),
)
// Act
val response = get("http://mybox.local:${server.port}/live-sessions")
// Assert: the 3xx is handed to the caller verbatim; the client chased nothing.
assertEquals(302, response.status)
assertEquals(1, server.requestCount)
}
@Test
fun theSharedClientRefusesToFollowRedirectsAtAll() {
val client = OkHttpClientFactory.create()
assertFalse(client.followRedirects, "no plan §4.2/§4.3 route legitimately redirects")
assertFalse(client.followSslRedirects, "an https→http downgrade must never be chased")
}
// ── the decision table (every CIDR edge, incl. ones no local socket can stand in for) ────────
@Test
fun permitsCleartextForEveryLoopbackPrivateAndTailscaleHost() {
val allowed = listOf(
"127.0.0.1", "127.1.2.3", "localhost", // loopback
"10.0.0.5", "10.255.255.255", // RFC1918 10/8
"172.16.0.1", "172.31.255.254", // RFC1918 172.16/12
"192.168.1.50", // RFC1918 192.168/16
"169.254.7.7", // link-local
"mybox.local", "MyBox.Local", // mDNS (case-insensitive)
"100.64.0.1", "100.127.255.254", // CGNAT / Tailscale
"mybox.tailnet-1a2b.ts.net", // MagicDNS
)
allowed.forEach { host ->
assertNull(
CleartextPolicy.refusalFor("http://$host:3000/live-sessions".toHttpUrl()),
"cleartext to $host is a supported path and must not be refused",
)
}
}
@Test
fun refusesCleartextForPublicAndOutOfRangeHosts() {
val refused = mapOf(
"8.8.8.8" to HostNetworkTier.PUBLIC, // plainly public
"172.15.0.1" to HostNetworkTier.PUBLIC, // just below RFC1918 172.16/12
"172.32.0.1" to HostNetworkTier.PUBLIC, // just above it
"100.63.255.255" to HostNetworkTier.PUBLIC, // just below CGNAT
"100.128.0.1" to HostNetworkTier.PUBLIC, // just above it
"192.169.1.1" to HostNetworkTier.PUBLIC, // near-miss on 192.168/16
"169.253.7.7" to HostNetworkTier.PUBLIC, // near-miss on link-local
"terminal.yaojia.wang" to HostNetworkTier.PUBLIC, // the tunnel — TLS-only
"ts.net.attacker.example" to HostNetworkTier.PUBLIC, // suffix spoof
"999.1.2.3" to HostNetworkTier.PUBLIC, // unclassifiable → fail closed
)
refused.forEach { (host, tier) ->
val refusal = CleartextPolicy.refusalFor("http://$host:3000/live-sessions".toHttpUrl())
assertEquals(host, refusal?.host, "cleartext to $host must be refused")
assertEquals(tier, refusal?.tier)
}
}
@Test
fun neverRefusesAnEncryptedDialWhateverTheTier() {
listOf("terminal.yaojia.wang", "8.8.8.8", "192.168.1.50", "mybox.tailnet-1a2b.ts.net").forEach { host ->
assertNull(
CleartextPolicy.refusalFor("https://$host/live-sessions".toHttpUrl()),
"https is out of the guard's scope — $host must pass through",
)
}
}
// ── helpers ─────────────────────────────────────────────────────────────────────────────────
/**
* A client whose DNS always answers loopback, so a non-loopback URL host reaches [server].
* Hostnames only — OkHttp parses an IP literal itself and never calls [Dns].
*/
private fun pinnedToLoopback(): OkHttpClient =
OkHttpClientFactory.create()
.newBuilder()
.dns(LoopbackDns)
.build()
private fun get(url: String) = runBlocking {
OkHttpHttpTransport(pinnedToLoopback()).send(HttpRequest(HttpMethod.GET, url))
}
}
/** Resolves every hostname to loopback (the MockWebServer's bind address). */
private object LoopbackDns : Dns {
override fun lookup(hostname: String): List<InetAddress> = listOf(InetAddress.getLoopbackAddress())
}
/** Minimal server-side WS listener; [opened] must stay incomplete — the upgrade is refused. */
private class NeverReachedServerWebSocket : WebSocketListener() {
val opened = CompletableDeferred<WebSocket>()
override fun onOpen(webSocket: WebSocket, response: Response) {
opened.complete(webSocket)
}
}

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.transport
import okhttp3.CookieJar
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
@@ -36,7 +37,11 @@ class OkHttpClientFactoryTest {
.socketFactory
// Act
val client = OkHttpClientFactory.create { ClientIdentity(sslSocketFactory, trustManager) }
// (named argument: `create` now also takes a trailing `cookieJar`, so a trailing lambda
// would bind to the wrong parameter.)
val client = OkHttpClientFactory.create(
identityProvider = { ClientIdentity(sslSocketFactory, trustManager) },
)
// Assert: the shared client uses exactly the injected factory (mTLS applies to WS + REST).
assertSame(sslSocketFactory, client.sslSocketFactory)
@@ -50,6 +55,27 @@ class OkHttpClientFactoryTest {
assertNull(transports.client.cache)
}
@Test
fun defaultClientCarriesNoCookieJar() {
// The gate is opt-in: an unconfigured client behaves exactly as before (LAN zero-config).
assertSame(CookieJar.NO_COOKIES, OkHttpClientFactory.create().cookieJar)
}
@Test
fun injectedCookieJarIsWiredWithoutEnablingTheHttpCache() {
// Arrange
val jar = AuthCookieJar()
// Act
val client = OkHttpClientFactory.create(cookieJar = jar)
// Assert: the jar reaches BOTH transports through the one shared client, and a cookie jar
// is NOT a cache — `.cache(null)` (plan §8) stays intact.
assertSame(jar, client.cookieJar)
assertNull(client.cache, "a cookie jar must never re-enable the HTTP cache")
assertSame(jar, OkHttpTransports.create(cookieJar = jar).client.cookieJar)
}
private fun systemDefaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)