feat(android): access-token support — same frozen contract as iOS

Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:27 +02:00
parent a5fa843f00
commit 9114630c3a
42 changed files with 2419 additions and 51 deletions

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.api.routes.Endpoints
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
*/
public sealed interface AuthProbeResult {
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
public data object Accepted : AuthProbeResult
/**
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
*/
public data object AuthDisabled : AuthProbeResult
/** **401** — wrong token. UI: "访问令牌不正确". */
public data object InvalidToken : AuthProbeResult
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
public data object RateLimited : AuthProbeResult
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
public data object Malformed : AuthProbeResult
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
public data class Unexpected(val status: Int) : AuthProbeResult
}
/**
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
*
* Transport-level failures propagate unwrapped so the caller can classify them with
* [PairingError.classify] (same convention as [runPairingProbe]).
*
* Never logs the token; the token appears only in the JSON body, never in the URL.
*/
public suspend fun probeAccessToken(
endpoint: HostEndpoint,
http: HttpTransport,
token: String,
): AuthProbeResult {
// Boundary validation first: a token outside the server's charset/length can never be correct, and
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
val response = http.send(request)
return classifyAuthResponse(response)
}
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
HTTP_NO_CONTENT ->
// The ONE discriminator between "token correct" and "this host has no auth at all".
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
else -> AuthProbeResult.Unexpected(response.status)
}
/**
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
* nothing about our token.
*/
private fun HttpResponse.carriesAuthCookie(): Boolean =
headers.entries.any { (name, value) ->
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
}
/**
* Sentinel status for "the request could not even be built" — unreachable for a validated
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
*/
private const val UNBUILDABLE_REQUEST = 0
private const val SET_COOKIE_HEADER = "Set-Cookie"
private const val HTTP_NO_CONTENT = 204
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_TOO_MANY_REQUESTS = 429

View File

@@ -52,6 +52,17 @@ public sealed interface PairingError {
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError
/**
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
*
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
*/
public data object AccessTokenRequired : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError

View File

@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
/**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
http: HttpTransport,
ws: TermTransport,
timeout: Duration?,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
?: PairingProbeResult.Failure(PairingError.Timeout)
}
@@ -75,10 +79,16 @@ private suspend fun performProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource,
): PairingProbeResult {
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
probeReachability(endpoint, http)?.let { return it }
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
// wants an access token we do not have.
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected.
@@ -105,7 +115,7 @@ private suspend fun performProbe(
return try {
when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
}
} finally {
withContext(NonCancellable) { runCatching { connection.close() } }
@@ -120,14 +130,20 @@ private suspend fun performProbe(
private suspend fun probeReachability(
endpoint: HostEndpoint,
http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult.Failure? {
val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
if (response.status == HTTP_UNAUTHORIZED) {
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
sessionId: String,
endpoint: HostEndpoint,
http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult {
val response = try {
http.send(
HttpRequest(
method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
),
)
} catch (cancel: CancellationException) {
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
}
return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
)
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
return "$scheme://$host$portPart"
}
/**
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
* derived from the single point ([AuthCookie]), never hand-assembled.
*/
private fun authHeaders(token: String?): Map<String, String> =
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404

View File

@@ -19,6 +19,7 @@ import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
@@ -36,10 +37,17 @@ import java.util.UUID
*
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
*
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
* an unauthenticated LAN host behaves exactly as before. A **401** from any route becomes the typed
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token.
*/
public class ApiClient(
public val endpoint: HostEndpoint,
private val http: HttpTransport,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) {
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
@@ -249,9 +257,18 @@ public class ApiClient(
// ── Internals ──────────────────────────────────────────────────────────────────────────
/**
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
* status mapping — otherwise a guarded git write would degrade a 401 into a generic `Rejected`
* outcome and the UI would never learn to ask for the token (ios-completion §1.1).
*/
private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
return http.send(request)
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
?: throw ApiClientError.InvalidRequest
val response = http.send(request)
if (response.status == HttpStatus.UNAUTHORIZED) throw ApiClientError.Unauthorized
return response
}
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */

View File

@@ -20,6 +20,13 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/**
* **401** from ANY route (RO or guarded): this host has `WEBTERM_TOKEN` set and our request carried
* no cookie / a wrong one (ios-completion §1.1). Distinct from [Forbidden] (that is the Origin
* guard) — the UI must send the user to enter or fix the host's access token.
*/
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
/** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
@@ -10,6 +11,7 @@ internal object HttpStatus {
const val OK = 200
const val NO_CONTENT = 204
const val BAD_REQUEST = 400
const val UNAUTHORIZED = 401
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429
@@ -24,6 +26,12 @@ internal object HttpStatus {
internal object HeaderName {
const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type"
/**
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
*/
const val ACCEPT = "Accept"
}
internal object ContentType {
@@ -55,19 +63,35 @@ internal class ApiRoute(
val originPolicy: OriginPolicy,
val body: ByteArray? = null,
val percentEncodedQuery: String? = null,
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
val accept: String? = null,
) {
/**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
*
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
* ones included, because the server's access-token gate sits in front of the whole app. A null
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
* The token is secret material: it is only ever placed in this header — never in [path], never in
* [percentEncodedQuery], never logged.
*/
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
val headers = LinkedHashMap<String, String>()
if (originPolicy == OriginPolicy.GUARDED) {
headers[HeaderName.ORIGIN] = endpoint.originHeader
}
if (accessToken != null) {
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
}
if (accept != null) {
headers[HeaderName.ACCEPT] = accept
}
if (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
}

View File

@@ -114,6 +114,33 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
/**
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
*
* Two non-obvious requirements, both server-driven (`src/server.ts`):
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
* auth cookie (the token is in the body — this IS the login).
*
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
*/
fun auth(token: String): ApiRoute {
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
return ApiRoute(
HttpMethod.POST,
AUTH_PATH,
OriginPolicy.GUARDED,
body = body,
accept = ContentType.JSON,
)
}
private const val AUTH_PATH = "/auth"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
@@ -199,6 +226,10 @@ internal object Endpoints {
@Serializable
private data class FcmTokenBody(val token: String)
/** `POST /auth` body. Holds secret material — never log a request built from it. */
@Serializable
private data class AuthBody(val token: String)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
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.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.io.IOException
/**
* B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1).
*
* The four distinct server outcomes must stay distinguishable:
* | 204 **with** `Set-Cookie` | token correct → persist it |
* | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated |
* | 401 | the token is wrong → UI says "令牌不正确" |
* | 429 | rate-limited (10/min/IP) → UI says "尝试过多" |
*
* Plus the request shape the server needs: JSON body `{"token":"<t>"}` and an `Accept` that does NOT
* contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302
* instead of 204/401).
*/
class AuthProbeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun queueAuth(status: Int, headers: Map<String, String> = emptyMap()) {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers)
}
private val setCookieHeader = mapOf(
"Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict",
)
// ── the four frozen outcomes ────────────────────────────────────────────────────────────────
@Test
fun `204 with Set-Cookie means the token is correct`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `204 without Set-Cookie means the host has no auth configured`() = runTest {
queueAuth(204)
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)",
)
}
@Test
fun `401 means the token is wrong`() = runTest {
queueAuth(401)
assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `429 means rate-limited`() = runTest {
queueAuth(429)
assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `any other status is surfaced as Unexpected with the code`() = runTest {
queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success
assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN))
}
// ── request shape ───────────────────────────────────────────────────────────────────────────
@Test
fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest {
queueAuth(204, setCookieHeader)
probeAccessToken(endpoint, transport, TOKEN)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth", request.url)
assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString())
assertEquals("application/json", request.headers["Content-Type"])
val accept = request.headers["Accept"].orEmpty()
assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack")
assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401")
}
@Test
fun `the token never appears in the URL`() = runTest {
queueAuth(401)
probeAccessToken(endpoint, transport, TOKEN)
assertFalse(
transport.recordedRequests.single().url.contains(TOKEN),
"a secret must never travel in a URL (query strings land in logs/history)",
)
}
// ── boundary validation + transport failure ─────────────────────────────────────────────────
@Test
fun `a malformed token is rejected before any network IO`() = runTest {
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short"))
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here"))
assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network")
}
@Test
fun `a whitespace-padded token is trimmed once and accepted`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n"))
assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString())
}
@Test
fun `a transport failure propagates so the caller can classify it`() = runTest {
transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused"))
val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull()
assertTrue(thrown is IOException, "network classification stays with PairingError.classify")
}
@Test
fun `a Set-Cookie header is recognised case-insensitively`() = runTest {
queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/"))
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest {
queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/"))
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"only a webterm_auth cookie proves the token was accepted",
)
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* B5 · the two-step pairing probe under an access-token-protected host:
* - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the
* hand-written `Cookie: webterm_auth=<t>`;
* - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT
* "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask
* for the token instead of sending the user on a wild goose chase;
* - with no token configured nothing changes (no `Cookie` header at all).
*/
class PairingProbeTokenTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
const val SERVER_ID = "11111111-1111-4111-8111-111111111111"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val http = FakeHttpTransport()
private val ws = FakeTermTransport()
private fun scriptHappyPath() {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
}
private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens)
@Test
fun `both HTTP legs of the probe carry the auth cookie`() = runTest {
scriptHappyPath()
val result = probe(AccessTokenSource { TOKEN })
assertEquals(PairingProbeResult.Success(endpoint), result)
assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE")
http.recordedRequests.forEach { request ->
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
request.headers[AuthCookie.HEADER_NAME],
"every probe request must present the token",
)
}
}
@Test
fun `with no token the probe requests are byte-identical to before the feature`() = runTest {
scriptHappyPath()
probe(AccessTokenSource.NONE)
http.recordedRequests.forEach { request ->
assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME))
}
}
@Test
fun `a 401 on the reachability leg asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
@Test
fun `a 401 on the guarded kill leg also asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
}

View File

@@ -0,0 +1,140 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
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.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.testsupport.FakeHttpTransport
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 java.util.UUID
/**
* B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1):
* - `Cookie: webterm_auth=<t>` is stamped on **EVERY** route — read-only GETs included — because the
* server's auth gate runs in front of the whole app, not just the guarded routes;
* - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the
* Origin-iff-guarded 铁律 is untouched);
* - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical);
* - a **401** on any RO/G route is the typed [ApiClientError.Unauthorized], never a generic status
* error, so the UI can route the user to re-enter the token.
*/
class AccessTokenCookieTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun clientWithToken(): ApiClient =
ApiClient(endpoint, transport, AccessTokenSource { TOKEN })
private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
private fun assertCookie(index: Int) {
val headers = transport.recordedRequests[index].headers
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
headers[AuthCookie.HEADER_NAME],
"every request must carry the hand-written auth cookie",
)
}
@Test
fun `a read-only GET carries the cookie and still no Origin`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithToken().liveSessions()
assertCookie(0)
assertFalse(
transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN),
"the cookie is additive — a RO route must still never carry Origin",
)
}
@Test
fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
assertCookie(0)
assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN])
}
@Test
fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/detail?path=%2Frepo",
body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(),
)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray())
val client = clientWithToken()
client.projectDetail("/repo")
client.killSession(ID)
client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create())
assertEquals(3, transport.recordedRequests.size)
transport.recordedRequests.indices.forEach { assertCookie(it) }
}
@Test
fun `no configured token means no Cookie header at all`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithoutToken().liveSessions()
assertFalse(
transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME),
"an unauthenticated host must see a byte-identical request to before the feature",
)
}
@Test
fun `the token is re-read per request so a token stored later takes effect`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
var stored: String? = null
val client = ApiClient(endpoint, transport, AccessTokenSource { stored })
client.liveSessions()
stored = TOKEN
client.liveSessions()
assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME))
assertCookie(1)
}
@Test
fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val error = errorOf { clientWithoutToken().liveSessions() }
assertEquals(ApiClientError.Unauthorized, error)
assertTrue(
ApiClientError.Unauthorized.userMessage.isNotBlank(),
"the UI needs actionable copy for a missing token",
)
}
@Test
fun `a 401 on a guarded git write throws Unauthorized instead of a Rejected outcome`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
assertEquals(ApiClientError.Unauthorized, errorOf { clientWithoutToken().gitPush("/repo") })
}
}