Merge ios-completion: device builds unblocked, access token on both clients, P2 wave

Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 deletions

View File

@@ -3,6 +3,7 @@ package wang.yaojia.webterm.transport
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import wang.yaojia.webterm.wire.AuthCookie
import java.util.concurrent.ConcurrentHashMap
/**
@@ -15,11 +16,29 @@ import java.util.concurrent.ConcurrentHashMap
* `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:
* The cookie is a **shell credential**. Three independent layers keep it on its own host, and keep
* everything else out:
* 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.
* applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext;
* 3. **only [AuthCookie.NAME] is ever kept or returned** — see below.
*
* ── Why the name filter is load-bearing, not tidiness (F2) ───────────────────────────────────────
* The app carries TWO mechanisms for the same cookie: this jar, and the hand-written
* `Cookie: webterm_auth=<t>` that `:api-client` stamps on every REST route and [OkHttpTermTransport]
* stamps on the WS upgrade (the FROZEN §1.1 contract). OkHttp's `BridgeInterceptor` (4.12.0) reconciles
* them with:
* ```
* val cookies = cookieJar.loadForRequest(userRequest.url)
* if (cookies.isNotEmpty()) requestBuilder.header("Cookie", cookieHeader(cookies))
* ```
* — the guard is "the jar returned ANYTHING", not "the jar has a `webterm_auth` for this host", and
* `header(...)` REPLACES the whole header. So storing a foreign cookie meant any TLS-terminating
* intermediary that sets one (Cloudflare Access, a captive portal, a future auth edge) silently
* stripped the token off every REST call **and off the WS upgrade**, where a 401 is terminal. Keeping
* the jar to exactly one name makes that impossible instead of merely unlikely, and stops a chatty
* host from evicting the credential through [MAX_COOKIES_PER_HOST].
*
* 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
@@ -48,28 +67,32 @@ public class AuthCookieJar(
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) }
// Second layer: RFC domain/path match + the Secure-over-cleartext refusal. Third layer: our
// name only — a non-empty return here REPLACES the hand-written `Cookie` header (see the
// BridgeInterceptor note above), so nothing foreign may ever be what it replaces it with.
return live.filter { it.isOurs() && it.matches(url) }
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
if (cookies.isEmpty()) return
val ours = cookies.filter { it.isOurs() }
if (ours.isEmpty()) return
val hostKey = authCookieHostKey(url)
val now = clock()
synchronized(writeLock) {
store(hostKey, byHostKey[hostKey].orEmpty().merged(cookies, now))
store(hostKey, byHostKey[hostKey].orEmpty().merged(ours, 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
* Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots,
* structurally invalid ones and anything not named [AuthCookie.NAME] are dropped — records at rest
* are untrusted, and may predate the name filter. 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 }
.filter { it.name == AuthCookie.NAME && it.expiresAtEpochMillis > now }
.mapNotNull { it.toCookieOrNull() }
.capped()
synchronized(writeLock) {
@@ -138,6 +161,13 @@ private fun List<Cookie>.upserting(cookie: Cookie): List<Cookie> =
private fun Cookie.sameIdentity(other: Cookie): Boolean =
name == other.name && domain == other.domain && path == other.path
/**
* The name filter (F2). [AuthCookie.NAME] is the single point of derivation in `:wire-protocol` — the
* same constant the hand-written header is built from — so the two mechanisms can never drift apart on
* what "our cookie" means.
*/
private fun Cookie.isOurs(): Boolean = name == AuthCookie.NAME
private fun Cookie.toSnapshot(): AuthCookieSnapshot =
AuthCookieSnapshot(
name = name,

View File

@@ -2,18 +2,21 @@ package wang.yaojia.webterm.transport
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import wang.yaojia.webterm.wire.AuthCookie
/**
* 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).
*
* An ALIAS of [AuthCookie.NAME], never a second literal: `:wire-protocol` owns the one derivation the
* hand-written `Cookie` header is also built from, and [AuthCookieJar] now keeps/returns this name and
* nothing else (F2), so a drift between the two spellings would be a security bug.
*/
public const val AUTH_COOKIE_NAME: String = "webterm_auth"
public const val AUTH_COOKIE_NAME: String = AuthCookie.NAME
/**
* 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.
* ([AUTH_COOKIE_NAME]) and the jar keeps no other name, so this is a residual boundary guard against a
* host that varies domain/path to mint many entries under that one name. Oldest evicted first.
*/
internal const val MAX_COOKIES_PER_HOST: Int = 16

View File

@@ -2,6 +2,7 @@ package wang.yaojia.webterm.transport
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import wang.yaojia.webterm.wire.AccessTokenSource
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@@ -107,13 +108,25 @@ public class OkHttpTransports private constructor(
public val client: OkHttpClient,
) {
public companion object {
/**
* @param identityProvider optional mTLS material (see [OkHttpClientFactory.create]).
* @param cookieJar the shared session-cookie store (see [OkHttpClientFactory.create]). A
* token-gated host's `Set-Cookie: webterm_auth=…` lands here and is replayed on REST calls
* AND on the WS upgrade, because both transports share this one client.
* @param tokens the access-token seam for the WS upgrade's `Cookie` header (ios-completion §1.1).
* REST requests get their cookie from `:api-client`'s single stamping point instead, so this
* only wires the WS half. Orthogonal to [cookieJar]: the hand-written header covers a token
* the app already holds (Keystore-restored, never `Set-Cookie`-observed), the jar covers the
* cookie a live pairing handed back.
*/
public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
cookieJar: CookieJar = CookieJar.NO_COOKIES,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): OkHttpTransports {
val client = OkHttpClientFactory.create(identityProvider, cookieJar)
return OkHttpTransports(
term = OkHttpTermTransport(client),
term = OkHttpTermTransport(client, tokens),
http = OkHttpHttpTransport(client),
client = client,
)

View File

@@ -2,6 +2,8 @@ package wang.yaojia.webterm.transport
import okhttp3.OkHttpClient
import okhttp3.Request
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.PingableConnection
import wang.yaojia.webterm.wire.PingableTermTransport
@@ -12,6 +14,9 @@ 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"
/** HTTP status the server answers an upgrade with when it rejects Origin OR the access-token cookie. */
internal const val HTTP_UNAUTHORIZED: Int = 401
/**
* The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`)
* over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double.
@@ -29,12 +34,25 @@ internal const val HEADER_ORIGIN: String = "Origin"
* 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.
*
* ### Access token (ios-completion §1.1)
* When [tokens] has a token for the host, the upgrade ALSO carries a hand-written
* `Cookie: webterm_auth=<t>` — no OkHttp `CookieJar`, no `Set-Cookie` parsing (a jar's behaviour on a
* WS upgrade is stack-specific and hard to test; the hand-written header is the same pattern as
* `Origin` and is asserted byte-equal by a MockWebServer test). The two headers are orthogonal: the
* server checks Origin first, then the cookie. No token ⇒ no header at all.
*
* An upgrade answered **401** (rejected Origin OR rejected/missing token — the server writes the same
* bare 401 for both) is surfaced as the typed `UnauthorizedUpgradeException`, which `SessionEngine`
* treats as TERMINAL. The original error is kept as its `cause` so `PairingError.classify`'s cause-walk
* is unaffected.
*
* 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,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) : PingableTermTransport {
private val wsClient: OkHttpClient = sharedClient.newBuilder()
@@ -51,10 +69,14 @@ public class OkHttpTermTransport(
}
private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection {
val request = Request.Builder()
val builder = Request.Builder()
.url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally
.header(HEADER_ORIGIN, endpoint.originHeader)
.build()
// Additive and orthogonal to Origin; absent entirely when the host has no token.
tokens.tokenFor(endpoint)?.let { token ->
builder.header(AuthCookie.HEADER_NAME, AuthCookie.headerValue(token))
}
val request = builder.build()
val connection = OkHttpWebSocketConnection(wsClient, request)
try {
connection.awaitOpen()

View File

@@ -15,6 +15,7 @@ import okhttp3.WebSocketListener
import okio.ByteString
import wang.yaojia.webterm.wire.ConnectionPinger
import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
import java.io.IOException
/** RFC 6455 normal-closure status code, used for a client-initiated detach. */
@@ -145,9 +146,15 @@ internal class OkHttpWebSocketConnection(
}
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)
// A 401 handshake answer is the server's ONE upgrade rejection (foreign Origin OR a
// missing/invalid access-token cookie) and is permanent until reconfigured, so it is TYPED
// for SessionEngine to end terminally on. `t` is kept as the cause, so the verbatim
// transport error is still available to PairingError.classify's cause-walk. Every other
// failure propagates untouched (frozen "rethrow verbatim" contract).
val error = if (response?.code == HTTP_UNAUTHORIZED) UnauthorizedUpgradeException(t) else t
finish(error)
// If we never opened, unblock connect() with the cause; no-op once opened.
opened.completeExceptionally(error)
}
}
}

View File

@@ -271,9 +271,13 @@ class AuthCookieJarTest {
@Test
fun capsTheNumberOfCookiesStoredPerHost() {
// Arrange: a hostile/broken server tries to make the client an unbounded cookie sink.
// Arrange: a hostile/broken server tries to make the client an unbounded cookie sink. Since the
// jar keeps only AUTH_COOKIE_NAME (F2), the sole remaining way to mint many entries is to vary
// (domain, path) under that one name — so that is what the cap has to survive.
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") }
repeat(MAX_COOKIES_PER_HOST * 2) { i ->
response.addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=v$i; Path=/p$i; Max-Age=$MAX_AGE_SEC")
}
server.enqueue(response)
// Act
@@ -283,6 +287,25 @@ class AuthCookieJarTest {
assertEquals(MAX_COOKIES_PER_HOST, persister.latestFor(hostKey())?.size)
}
@Test
fun neverStoresACookieThatIsNotOurs() {
// Arrange: a TLS-terminating intermediary (Cloudflare Access, a captive portal) sets its own.
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)
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
server.get("/")
server.get("/live-sessions")
// Assert: nothing was kept, so nothing rides the next request — a non-empty jar would REPLACE
// the hand-written `Cookie: webterm_auth=…` header outright (OkHttp BridgeInterceptor).
assertNull(persister.latestFor(hostKey()))
server.takeRequest()
assertNull(server.takeRequest().getHeader("Cookie"))
}
// ── 4. the credential never reaches a log/toString path ──────────────────────
@Test

View File

@@ -0,0 +1,203 @@
package wang.yaojia.webterm.transport
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.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
/**
* F2 · the TWO mechanisms that carry `webterm_auth`, wired the way production wires them
* (`di/NetworkModule` line 80 installs the jar on the one shared client; line 96 hands the same
* client the [AccessTokenSource]) — which is the combination NOTHING covered before: the jar's own
* suite builds it with `AccessTokenSource.NONE`, and the token suite builds the client with
* `CookieJar.NO_COOKIES`.
*
* ### Why both live at once cannot be left to luck
* OkHttp's `BridgeInterceptor` (4.12.0) does:
* ```
* val cookies = cookieJar.loadForRequest(userRequest.url)
* if (cookies.isNotEmpty()) requestBuilder.header("Cookie", cookieHeader(cookies))
* ```
* The guard is **"the jar returned anything at all"**, NOT "the jar has a `webterm_auth` for this
* host", and `header(...)` REPLACES the whole header rather than merging. So an unrelated cookie from
* any TLS-terminating intermediary (Cloudflare Access, a captive portal, a future auth edge) used to
* be enough to strip the hand-written token off every REST call **and off the WS upgrade** — where a
* 401 is terminal with no reconnect.
*
* The fix is in [AuthCookieJar]: it now keeps and returns [AuthCookie.NAME] and nothing else, so a
* foreign cookie can neither displace the token nor consume the per-host cap.
*/
class AuthCookieTokenCoexistenceTest {
private lateinit var server: MockWebServer
private val persister = RecordingCoexistencePersister()
private val jar = AuthCookieJar(persister = persister)
private lateinit var client: OkHttpClient
@BeforeEach
fun setUp() {
server = MockWebServer().apply { start() }
// EXACTLY the production graph: one client, the jar installed on it, tokens on the WS transport.
client = OkHttpClientFactory.create(cookieJar = jar)
}
@AfterEach
fun tearDown() {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { server.shutdown() }
client.dispatcher.executorService.shutdown()
}
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}"))
/** The REST half exactly as `:api-client` stamps it (`ApiRoute.toHttpRequest`). */
private fun getWithToken(path: String, token: String = TOKEN) = runBlocking {
OkHttpHttpTransport(client).send(
HttpRequest(
method = HttpMethod.GET,
url = server.url(path).toString(),
headers = mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token)),
),
)
}
/** Seed the live jar from a real `Set-Cookie`, i.e. the way a proxy would do it. */
private fun seedJarWith(vararg setCookie: String) {
val response = MockResponse().setResponseCode(200).setBody("{}")
setCookie.forEach { response.addHeader("Set-Cookie", it) }
server.enqueue(response)
runBlocking {
OkHttpHttpTransport(client).send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString()))
}
server.takeRequest()
}
// ── 1. an unrelated host cookie must not strip the token ─────────────────────────
@Test
fun `a foreign cookie in the jar never strips the hand-written token from a REST call`() {
// Arrange: an intermediary sets its own session cookie on this host.
seedJarWith("proxy_session=abc123; Path=/; Max-Age=$MAX_AGE_SEC")
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
getWithToken("/live-sessions")
// Assert: the shell credential is still on the wire.
val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME)
assertTrue(
sent.orEmpty().contains("${AuthCookie.NAME}=$TOKEN"),
"a proxy's cookie must never displace the access token, got: $sent",
)
}
@Test
fun `a foreign cookie in the jar never strips the token from the WS upgrade`() = runBlocking {
// Arrange: the upgrade is the worst place to lose it — a 401 there is terminal, no reconnect.
seedJarWith("proxy_session=abc123; Path=/; Max-Age=$MAX_AGE_SEC")
server.enqueue(MockResponse().withWebSocketUpgrade(SilentCoexistenceWebSocket()))
// Act
val endpoint = endpoint()
val connection = withTimeout(TIMEOUT_MS) {
OkHttpTermTransport(client, AccessTokenSource { TOKEN }).connect(endpoint)
}
// Assert: cookie intact AND the CSWSH defence untouched.
val upgrade = server.takeRequest()
assertTrue(
upgrade.getHeader(AuthCookie.HEADER_NAME).orEmpty().contains("${AuthCookie.NAME}=$TOKEN"),
"the WS upgrade lost the token to a foreign cookie, got: ${upgrade.getHeader(AuthCookie.HEADER_NAME)}",
)
assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"), "Origin must be untouched")
connection.close()
}
@Test
fun `a chatty host cannot evict the token by flooding foreign cookies`() {
// Arrange: more foreign cookies than the per-host cap (the eviction path).
val flood = (0 until MAX_COOKIES_PER_HOST * 2)
.map { "junk$it=v$it; Path=/; Max-Age=$MAX_AGE_SEC" }
.toTypedArray()
seedJarWith(*flood)
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
getWithToken("/live-sessions")
// Assert: nothing foreign was ever kept, so nothing could be evicted OR sent.
val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME)
assertEquals("${AuthCookie.NAME}=$TOKEN", sent, "only our own cookie may ever reach the wire")
assertTrue(
persister.latestFor(hostKey()).orEmpty().none { it.name != AuthCookie.NAME },
"the jar must never become a sink for another service's cookies",
)
}
// ── 2. the stale-jar-value case ──────────────────────────────────────────────────
/**
* The jar and the token store hold the SAME cookie name, so when both are populated OkHttp's
* bridge decides: `loadForRequest` is consulted last and `header(...)` replaces, therefore **the
* jar's live cookie wins over the hand-written one**. Pinned rather than left implicit.
*
* Against this server the two cannot actually diverge: `POST /auth` is the ONLY writer of either
* side, it runs on this same shared client, and its `Set-Cookie` refreshes the jar in the very
* exchange whose success gates the token-store write (`PairingViewModel` RULE 5). The value that
* arrives is therefore always a single, well-formed `webterm_auth` — never a merge of two.
*/
@Test
fun `with both mechanisms populated exactly one webterm_auth value is sent - the jar's`() {
// Arrange: the jar was refreshed by POST /auth; the store still holds an older typed token.
seedJarWith("${AuthCookie.NAME}=$JAR_TOKEN; Path=/; Max-Age=$MAX_AGE_SEC")
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
// Act
getWithToken("/live-sessions", token = TOKEN)
// Assert: ONE value, deterministic, not a concatenation of both.
val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME)
assertEquals("${AuthCookie.NAME}=$JAR_TOKEN", sent)
assertFalse(sent.orEmpty().contains(TOKEN), "the two must never be merged into one header")
}
private fun hostKey(): String = requireNotNull(authCookieHostKey(server.url("/").toString()))
private companion object {
const val TOKEN = "0123456789abcdefTOKEN"
const val JAR_TOKEN = "0123456789abcdefJARVAL"
const val MAX_AGE_SEC = 60L
const val TIMEOUT_MS = 5_000L
}
}
private class RecordingCoexistencePersister : 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]
}
private class SilentCoexistenceWebSocket : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) = Unit
}

View File

@@ -0,0 +1,145 @@
package wang.yaojia.webterm.transport
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.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
import java.io.IOException
/**
* B5 · the access token on the **WS upgrade** (FROZEN contract, ios-completion §1.1), over
* MockWebServer:
* - the upgrade request carries the hand-written `Cookie: webterm_auth=<t>` ALONGSIDE `Origin`
* (the two are orthogonal — the server checks Origin first, then the cookie);
* - no token ⇒ no `Cookie` header at all (an unauthenticated LAN host is unaffected);
* - an upgrade answered with **401** throws the typed [UnauthorizedUpgradeException] so
* `SessionEngine` can go terminal instead of back-off-looping against a permanent rejection;
* - any OTHER upgrade failure still surfaces verbatim (untyped) — only 401 is special.
*/
class OkHttpAccessTokenTest {
private companion object {
const val TIMEOUT_MS = 5_000L
const val TOKEN = "0123456789abcdefTOKEN"
}
private lateinit var server: MockWebServer
private val client: OkHttpClient = OkHttpClientFactory.create()
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
}
@AfterEach
fun tearDown() {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { server.shutdown() }
client.dispatcher.executorService.shutdown()
}
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}"))
private fun transport(tokens: AccessTokenSource): OkHttpTermTransport =
OkHttpTermTransport(client, tokens)
@Test
fun stampsTheAuthCookieAlongsideOriginOnTheWsUpgrade() = runBlocking {
// Arrange
server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket()))
val endpoint = endpoint()
// Act
val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource { TOKEN }).connect(endpoint) }
// Assert
val upgrade = server.takeRequest()
assertEquals("${AuthCookie.NAME}=$TOKEN", upgrade.getHeader(AuthCookie.HEADER_NAME))
assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"), "Origin must be untouched")
connection.close()
}
@Test
fun sendsNoCookieHeaderWhenTheHostHasNoToken() = runBlocking {
server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket()))
val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) }
assertNull(server.takeRequest().getHeader(AuthCookie.HEADER_NAME))
connection.close()
}
@Test
fun theTokenNeverAppearsInTheUpgradeUrl() = runBlocking {
server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket()))
val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource { TOKEN }).connect(endpoint()) }
assertFalse(server.takeRequest().path.orEmpty().contains(TOKEN), "a secret must never be in a URL")
connection.close()
}
@Test
fun aPingableDialAlsoCarriesTheAuthCookie() = runBlocking {
server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket()))
val pingable = withTimeout(TIMEOUT_MS) {
transport(AccessTokenSource { TOKEN }).connectPingable(endpoint())
}
assertEquals("${AuthCookie.NAME}=$TOKEN", server.takeRequest().getHeader(AuthCookie.HEADER_NAME))
pingable.connection.close()
}
@Test
fun a401UpgradeThrowsTheTypedUnauthorizedError() = runBlocking {
// Arrange: the server's bare `HTTP/1.1 401 Unauthorized` upgrade rejection.
server.enqueue(MockResponse().setResponseCode(401))
// Act
val thrown = runCatching {
withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) }
}.exceptionOrNull()
// Assert: typed, and the verbatim transport cause is retained for PairingError.classify.
assertTrue(
thrown is UnauthorizedUpgradeException,
"a 401 upgrade must be typed so the engine can end terminally, got $thrown",
)
assertTrue((thrown as UnauthorizedUpgradeException).cause is Throwable)
}
@Test
fun aNon401UpgradeFailureStillSurfacesVerbatim() = runBlocking {
server.enqueue(MockResponse().setResponseCode(500))
val thrown = runCatching {
withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) }
}.exceptionOrNull()
assertFalse(thrown is UnauthorizedUpgradeException, "only 401 is the auth rejection")
assertTrue(thrown is IOException || thrown is IllegalStateException, "verbatim transport error, got $thrown")
}
}
/** Server side that accepts the upgrade and says nothing. */
private class SilentServerWebSocket : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) = Unit
}