feat(android): pure-Kotlin foundation — AW0 scaffold + AW1 modules (218 tests green)

Implements the verifiable pure-Kotlin core of the Android client per
docs/ANDROID_CLIENT_PLAN.md, mirroring the iOS SPM package set as Gradle modules.
Built + reviewed via multi-agent workflow (explore→implement→verify→review),
then review findings fixed with regression tests.

Modules (all pure JVM, kotlin("jvm"); Android-framework modules scaffolded but
gated off in settings — no Android SDK in this env):
- :wire-protocol  — frozen wire contract (sealed Client/ServerMessage, enums,
  HostEndpoint CSWSH origin derivation, transport interfaces), hand-rolled codec
  byte-identical to the server's JSON.stringify + tolerant kotlinx decode.
- :session-core   — ReconnectMachine (1→2→4→8→16→30 backoff), PingScheduler,
  GateTracker (two-line epoch guard), AwayDigest, UnreadLedger, TitleSanitizer,
  KeyByteMap (byte-matches public/keybar.ts).
- :api-client     — all REST routes, tolerant decode, Origin-iff-guarded, prefs
  unknown-key preservation, pairing probe + host-tier classifier.
- :client-tls     — pure half: PKCS#12 parse, X509KeyManager alias logic,
  CertificateSummary, provider-agnostic wrong-passphrase classification.
- :test-support   — FakeTransport / FakeHttpTransport / virtual-clock fakes.

Verify: ./gradlew clean test → 218 tests, 0 failures (wire-protocol 47,
session-core 60, api-client 69, client-tls 27, test-support 15).

Review (3 lenses, 8/10 each) findings fixed: sessionId JSON-injection escape,
CancellationException propagation in PingScheduler, PairingProbe close-on-cancel
leak, HostEndpoint trim, HostClassifier hoisted to :wire-protocol (type unified),
BouncyCastle-portable wrong-passphrase detection, lone-surrogate escaping.

Known gap (documented, deferred): /push/fcm-token has no server route yet —
plan task A33 (src/push/fcm.ts) delivers it.
This commit is contained in:
Yaojia Wang
2026-07-08 13:45:07 +02:00
parent cf88e7c588
commit 4ea8f7862a
106 changed files with 7502 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
// :api-client — pure REST client logic (12 routes, tolerant decode, Origin-iff-
// guarded, strict query encoding, prefs unknown-key preservation, pairing probe /
// PairingError / HostClassifier tiers). Consumes HttpTransport by interface only.
// Depends only on :wire-protocol.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(project(":wire-protocol"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(project(":test-support"))
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,10 @@
package wang.yaojia.webterm.api.models
/**
* The two verdicts `POST /hook/decision` accepts — anything else is a 400 server-side. The [wire]
* value is what goes into the request body (`{ sessionId, decision, token }`).
*/
public enum class HookDecision(public val wire: String) {
ALLOW("allow"),
DENY("deny"),
}

View File

@@ -0,0 +1,41 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.StatusTelemetry
import java.util.UUID
/**
* One running (or just-exited) server session from `GET /live-sessions` (read-only discovery — NO
* Origin header). Mirrors `src/types.ts` `LiveSessionInfo` and iOS `APIClient.LiveSessionInfo`
* field-for-field.
*
* Tolerant decode at the untrusted boundary (plan §4):
* - identity/geometry ([id]/[createdAt]/[clientCount]/[exited]/[cols]/[rows]) are REQUIRED — an
* entry missing them is dropped by `LossyDecode.listOrNull`;
* - an unrecognized `status` string maps to [ClaudeStatus.UNKNOWN] (a future server status must
* not make a running session invisible);
* - absent `cwd`/`telemetry`/`lastOutputAt` degrade to `null`.
*
* NOTE: ms timestamps ([createdAt]/[lastOutputAt]) are `Long` — epoch-ms overflows a 32-bit `Int`
* (Swift's `Int` is 64-bit, so iOS used `Int`).
*/
@Serializable
public data class LiveSessionInfo(
@Serializable(with = UuidSerializer::class) val id: UUID,
/** Server `Date.now()` at spawn (ms since epoch). */
val createdAt: Long,
/** Devices currently attached (JOIN/mirror semantics). */
val clientCount: Int,
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
val exited: Boolean,
val cwd: String? = null,
/** Current PTY size (latest-writer-wins on the server). */
val cols: Int,
val rows: Int,
/** Latest statusLine telemetry, if any (B2). */
val telemetry: StatusTelemetry? = null,
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
val lastOutputAt: Long? = null,
)

View File

@@ -0,0 +1,48 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
/**
* Tolerant decode config for the UNTRUSTED server boundary (plan §4 / A8). The server is an
* untrusted input source here: unknown keys are ignored, malformed list elements are dropped one
* by one, and nothing crashes on bad input. The Android analogue of iOS's per-field `try?`
* tolerance in `APIClient/Models.swift`.
*
* ENCODE is deterministic (`ignoreUnknownKeys`/`isLenient` only affect parsing) so the same
* instance also encodes request bodies (`/hook/decision`, `/push/fcm-token`, `PUT /prefs`).
*/
internal val ModelJson: Json = Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Per-element / per-body tolerant decoders (mirror iOS `LiveSessionInfo.decodeList` /
* `LossyBox` / `LossyList`).
*
* - [listOrNull]: a non-array top level yields `null` (the caller raises
* `ApiClientError.InvalidResponseBody` — the pairing "port speaks HTTP but isn't web-terminal"
* signal); malformed elements are dropped, good ones kept.
* - [listOrEmpty]: a non-array top level yields `[]` (matches iOS `TimelineEvent.decodeList`, which
* the server itself returns when timeline capture is disabled).
* - [objectOrNull]: a single object that fails to decode yields `null` (caller raises
* `InvalidResponseBody`).
*/
internal object LossyDecode {
fun <T> listOrNull(bytes: ByteArray, element: KSerializer<T>): List<T>? {
val array = parseArray(bytes) ?: return null
return array.mapNotNull { el -> runCatching { ModelJson.decodeFromJsonElement(element, el) }.getOrNull() }
}
fun <T> listOrEmpty(bytes: ByteArray, element: KSerializer<T>): List<T> =
listOrNull(bytes, element) ?: emptyList()
fun <T> objectOrNull(bytes: ByteArray, deserializer: KSerializer<T>): T? =
runCatching { ModelJson.decodeFromString(deserializer, bytes.decodeToString()) }.getOrNull()
private fun parseArray(bytes: ByteArray): JsonArray? =
runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) as? JsonArray }.getOrNull()
}

View File

@@ -0,0 +1,69 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* One running session belonging to a project (`src/types.ts` `ProjectSessionRef`). The server
* mirrors [LiveSessionInfo] fields into this ref. Tolerant: unknown status → [ClaudeStatus.UNKNOWN],
* absent title → `null`; missing id/clientCount/createdAt/exited drops the ref (nested lossy list).
*/
@Serializable
public data class ProjectSessionRef(
@Serializable(with = UuidSerializer::class) val id: UUID,
val title: String? = null,
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
val clientCount: Int,
val createdAt: Long,
val exited: Boolean,
)
/**
* A discovered project (git repo or recently-used cwd) — `GET /projects` (`src/types.ts`
* `ProjectInfo`). There is NO `namespace` field on the wire; namespace grouping is a client concept
* surfaced only via `UiPrefs.collapsed` group-keys.
*/
@Serializable
public data class ProjectInfo(
val name: String,
val path: String,
val isGit: Boolean,
val branch: String? = null,
/** Uncommitted changes; only present when the server runs the dirty check. */
val dirty: Boolean? = null,
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
val lastActiveMs: Long? = null,
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
)
/** One entry from `git worktree list --porcelain` (`src/types.ts` `WorktreeInfo`). */
@Serializable
public data class WorktreeInfo(
val path: String,
/** Branch name; `null` on detached HEAD. */
val branch: String? = null,
val head: String? = null,
val isMain: Boolean = false,
val isCurrent: Boolean = false,
val locked: Boolean? = null,
val prunable: Boolean? = null,
)
/** Detailed view of one project — `GET /projects/detail?path=` (`src/types.ts` `ProjectDetail`). */
@Serializable
public data class ProjectDetail(
val name: String,
val path: String,
val isGit: Boolean,
val branch: String? = null,
val dirty: Boolean? = null,
@Serializable(with = WorktreeInfoListSerializer::class)
val worktrees: List<WorktreeInfo> = emptyList(),
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
val hasClaudeMd: Boolean = false,
/** CLAUDE.md content (server-truncated for display) when present. */
val claudeMd: String? = null,
)

View File

@@ -0,0 +1,59 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.decodeFromJsonElement
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* Decode a server session id as [UUID]. Server ids are lowercase `crypto.randomUUID()` strings;
* a non-UUID string throws, so `LossyDecode` drops that list element (matches iOS decoding
* `UUID.self` — a malformed id must not surface). Serializes back lowercase (`UUID.toString()`).
*/
internal object UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
}
/**
* Decode [ClaudeStatus] by its `wire` value; an unknown/future value maps to
* [ClaudeStatus.UNKNOWN] rather than dropping the entry — a new server status must never make a
* running session invisible (iOS `rawStatus.flatMap(...) ?? .unknown`).
*/
internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ClaudeStatus", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ClaudeStatus =
ClaudeStatus.fromWire(decoder.decodeString()) ?: ClaudeStatus.UNKNOWN
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
}
/**
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
* `ProjectDetail.worktrees`). A bad nested element must not fail the whole parent object.
*/
internal class LossyListSerializer<T>(private val element: KSerializer<T>) : KSerializer<List<T>> {
private val delegate = ListSerializer(element)
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: List<T>) = delegate.serialize(encoder, value)
override fun deserialize(decoder: Decoder): List<T> {
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
val array = json.decodeJsonElement() as? JsonArray ?: return emptyList()
return array.mapNotNull { runCatching { json.json.decodeFromJsonElement(element, it) }.getOrNull() }
}
}
internal object ProjectSessionRefListSerializer :
KSerializer<List<ProjectSessionRef>> by LossyListSerializer(ProjectSessionRef.serializer())
internal object WorktreeInfoListSerializer :
KSerializer<List<WorktreeInfo>> by LossyListSerializer(WorktreeInfo.serializer())

View File

@@ -0,0 +1,17 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* `GET /live-sessions/:id/preview` response: the tail of the session's ring buffer for a
* read-only thumbnail (no attach, no client registered). [data] is opaque ANSI/UTF-8 — feed it to
* a terminal, never parse it. All fields required (a malformed body → `InvalidResponseBody`).
*/
@Serializable
public data class SessionPreview(
@Serializable(with = UuidSerializer::class) val id: UUID,
val cols: Int,
val rows: Int,
val data: String,
)

View File

@@ -0,0 +1,12 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* `GET /config/ui` response (`{ allowAutoMode }`). Reserved for a future permission-mode switcher
* (filters the high-risk raw `auto` mode); the plan-gate three-way UI does not consume it.
*/
@Serializable
public data class UiConfig(
val allowAutoMode: Boolean,
)

View File

@@ -0,0 +1,106 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
/**
* The cross-device Projects-UI prefs blob (`GET /prefs` RO · `PUT /prefs` G) — an
* OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web client
* (`favourites: string[]`, `collapsed: { group-key: true }`); ALL OTHER top-level keys are
* preserved verbatim across decode → mutate → encode, so an Android `PUT` can never clobber prefs
* written by the web/iOS client or a future server (`PUT` replaces the WHOLE blob server-side, so
* key preservation is correctness, not politeness).
*
* Immutable snapshot: [withFavourites]/[withCollapsed] return NEW copies that replace exactly one
* known key and carry every other key through untouched. Numbers round-trip verbatim because a
* parsed [JsonPrimitive] keeps its source token (`42` never re-encodes as `42.0`).
*/
public class UiPrefs private constructor(private val storage: JsonObject) {
/**
* Favourited project paths (★): non-empty JSON strings, de-duplicated, original order kept.
* Wrong-typed entries are dropped, not fatal (mirrors `public/prefs.ts sanitizePrefs`).
*/
public val favourites: List<String>
get() {
val array = storage[KEY_FAVOURITES] as? JsonArray ?: return emptyList()
val out = LinkedHashSet<String>()
for (element in array) {
val primitive = element as? JsonPrimitive ?: continue
if (!primitive.isString) continue
val path = primitive.content
if (path.isNotEmpty()) out.add(path)
}
return out.toList()
}
/**
* Namespace group-key → collapsed. Only literal `true` values count (expanded is the default;
* both web and server sanitizers agree). A JSON string `"true"` does NOT count.
*/
public val collapsed: Map<String, Boolean>
get() {
val obj = storage[KEY_COLLAPSED] as? JsonObject ?: return emptyMap()
val out = LinkedHashMap<String, Boolean>()
for ((key, value) in obj) {
if (key.isEmpty()) continue
if (value is JsonPrimitive && !value.isString && value.booleanOrNull == true) {
out[key] = true
}
}
return out
}
/** New snapshot with `favourites` replaced; every other key untouched (position preserved). */
public fun withFavourites(favourites: List<String>): UiPrefs {
val next = LinkedHashMap<String, JsonElement>(storage)
next[KEY_FAVOURITES] = JsonArray(favourites.map { JsonPrimitive(it) })
return UiPrefs(JsonObject(next))
}
/** New snapshot with `collapsed` replaced; every other key untouched (position preserved). */
public fun withCollapsed(collapsed: Map<String, Boolean>): UiPrefs {
val next = LinkedHashMap<String, JsonElement>(storage)
next[KEY_COLLAPSED] = JsonObject(collapsed.mapValues { JsonPrimitive(it.value) })
return UiPrefs(JsonObject(next))
}
/** Encode the FULL blob (known + unknown keys) for `PUT /prefs`. */
public fun encodeBody(): ByteArray =
ModelJson.encodeToString(JsonObject.serializer(), storage).encodeToByteArray()
override fun equals(other: Any?): Boolean = other is UiPrefs && other.storage == storage
override fun hashCode(): Int = storage.hashCode()
override fun toString(): String = "UiPrefs(storage=$storage)"
public companion object {
private const val KEY_FAVOURITES = "favourites"
private const val KEY_COLLAPSED = "collapsed"
/** Fresh prefs (e.g. first write from a device that never fetched). */
public fun create(
favourites: List<String> = emptyList(),
collapsed: Map<String, Boolean> = emptyMap(),
): UiPrefs = UiPrefs(
JsonObject(
mapOf(
KEY_FAVOURITES to JsonArray(favourites.map { JsonPrimitive(it) }),
KEY_COLLAPSED to JsonObject(collapsed.mapValues { JsonPrimitive(it.value) }),
),
),
)
/**
* Decode a `/prefs` body. `null` = top level is not a JSON object — callers surface
* `InvalidResponseBody` LOUDLY instead of degrading to empty prefs (an empty-based `PUT`
* would wipe the server blob).
*/
public fun decode(bytes: ByteArray): UiPrefs? {
val element = runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) }.getOrNull()
return (element as? JsonObject)?.let { UiPrefs(it) }
}
}
}

View File

@@ -0,0 +1,131 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.PortUnreachableException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import java.security.cert.CertPathBuilderException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
/**
* Error taxonomy for the pairing probe (Android port of iOS `APIClient.PairingError`). Each case
* maps one probe failure mode to actionable UI copy (`PairingViewModel`, A19, renders these).
*
* DEVIATION from iOS (plan R12): the iOS `localNetworkDenied` case is DROPPED — Android has no
* iOS-style Local-Network permission prompt (a plain WS to a LAN IP needs no runtime permission),
* so the ENETDOWN→localNetworkDenied diagnosis is dead weight here. iOS `atsBlocked` is kept as
* [CleartextBlocked]: the genuine Android analogue is a `network_security_config` cleartext block
* surfaced as [java.net.UnknownServiceException] (plan §6.9/§8 cleartext posture).
*/
public sealed interface PairingError {
/** Nothing answered — connection refused / no route / DNS failure (`ConnectException`, `UnknownHostException`, …). */
public data class HostUnreachable(val underlying: String) : PairingError
/**
* The port speaks HTTP but `GET /live-sessions` did not return the web-terminal shape (a JSON
* array) — "端口对吗?". Also used when probe ② connects but the socket never speaks our protocol.
*/
public data object HttpOkButNotWebTerminal : PairingError
/**
* The WS upgrade (or the guarded kill round-trip) was rejected — the host's Origin whitelist
* does not contain our dialed origin. [hint] carries the exact `ALLOWED_ORIGINS=<origin>` line,
* always derived from [HostEndpoint.originHeader] (never hand-assembled; default ports omitted).
*/
public data class OriginRejected(val hint: String) : PairingError
/**
* Cleartext (`ws://`/`http://`) to a host outside the app's `network_security_config` allowlist
* was blocked by the platform ([java.net.UnknownServiceException]). Android analogue of iOS
* `atsBlocked`. [host] is the dialed host for the actionable copy.
*/
public data class CleartextBlocked(val host: String) : PairingError
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError
public companion object {
/**
* Actionable copy for [OriginRejected] — always derived from the SINGLE origin source
* ([HostEndpoint.originHeader]), never hand-assembled. Ported verbatim from iOS.
*/
public fun originRejectedHint(endpoint: HostEndpoint): String =
"服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=${endpoint.originHeader}" +
"(与 App 连接的 URL 完全一致)后重启 web-terminal再重试配对。"
/**
* Classify a transport-level [Throwable] thrown by [wang.yaojia.webterm.wire.HttpTransport]
* or [wang.yaojia.webterm.wire.TermTransport] into the probe taxonomy. Walks the bounded
* `cause` chain (OkHttp wraps causes; never trust an error graph not to cycle) so wrapped
* roots (e.g. an `IOException` wrapping a `ConnectException`) are seen.
*
* @param unrecognizedFallback used by probe step ② — after step ① proved the host reachable
* AND web-terminal-shaped, an upgrade failure with no recognizable network cause is, by
* elimination, the server's Origin 401 (its only upgrade-reject path).
*/
public fun classify(
error: Throwable,
endpoint: HostEndpoint,
unrecognizedFallback: PairingError? = null,
): PairingError {
val chain = causeChain(error)
if (chain.any { it is UnknownServiceException }) {
return CleartextBlocked(host = HostClassifier.hostOf(endpoint))
}
if (chain.any { isTimeout(it) }) return Timeout
if (chain.any { isTls(it) }) return TlsFailure
if (chain.any { isConnectivity(it) }) {
return HostUnreachable(underlying = describe(error))
}
return unrecognizedFallback ?: HostUnreachable(underlying = describe(error))
}
private const val MAX_CAUSE_DEPTH = 8
/** Bounded walk of [Throwable.cause] with an identity cycle-guard (defensive). */
private fun causeChain(error: Throwable): List<Throwable> {
val chain = mutableListOf<Throwable>()
var current: Throwable? = error
while (current != null && chain.size < MAX_CAUSE_DEPTH) {
if (chain.any { it === current }) break
chain.add(current)
current = current.cause
}
return chain
}
// `SocketTimeoutException` extends `InterruptedIOException`; OkHttp's overall call-timeout
// also surfaces as a bare `InterruptedIOException` — both mean "timed out".
private fun isTimeout(e: Throwable): Boolean = e is InterruptedIOException
private fun isTls(e: Throwable): Boolean =
e is SSLException ||
e is CertificateException ||
e is CertPathValidatorException ||
e is CertPathBuilderException
// `ConnectException` / `NoRouteToHostException` / `PortUnreachableException` all extend
// `SocketException`; listed explicitly for readability. `UnknownHostException` is a DNS
// failure (extends `IOException`, not `SocketException`).
private fun isConnectivity(e: Throwable): Boolean =
e is ConnectException ||
e is NoRouteToHostException ||
e is PortUnreachableException ||
e is SocketException ||
e is UnknownHostException
private fun describe(error: Throwable): String =
error.message ?: error::class.simpleName ?: "unknown"
}
}

View File

@@ -0,0 +1,227 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.MessageCodec
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.Tunables
import java.net.URI
import kotlin.time.Duration
/**
* Result of a pairing probe — the validated [HostEndpoint] on success, a [PairingError] on failure.
* Android analogue of iOS `Result<HostEndpoint, PairingError>`. The pairing UI (A19) constructs the
* persisted `Host{id,name}` from the returned endpoint (id/name are not the probe's to know).
*/
public sealed interface PairingProbeResult {
public data class Success(val endpoint: HostEndpoint) : PairingProbeResult
public data class Failure(val error: PairingError) : PairingProbeResult
}
/**
* Public probe entry (Android port of iOS `runPairingProbe`). Two-step probe:
* 1. `GET /live-sessions` (NO Origin) — reachability + web-terminal shape.
* 2. WS `attach(null)` → adopt the server-issued `attached` id → close → **immediately
* `DELETE /live-sessions/:id` WITH Origin** (verifies the Origin guard on both the upgrade and
* the guarded-HTTP side, and never leaks the probe's orphan session).
*
* **Confirm-before-network contract:** callers MUST run this only after the user confirmed a
* scanned/typed host (A19). Step ① already talks to the network and step ② spawns a PTY on the
* target machine — nothing here is speculative, so the UI gate is the caller's responsibility.
*
* The wall-clock deadline is [Tunables.PAIRING_PROBE_TIMEOUT]; tests drive [runPairingProbeCore]
* with an explicit (or `null`) timeout for deterministic virtual-time coverage.
*/
public suspend fun runPairingProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
/**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
* still apply — fast, race-free tests); otherwise the whole probe is cancelled past the deadline and
* resolves [PairingError.Timeout]. Cancellation is the coroutine analogue of iOS's `group.cancelAll`.
*/
internal suspend fun runPairingProbeCore(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
timeout: Duration?,
): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
?: PairingProbeResult.Failure(PairingError.Timeout)
}
// ── Probe body ────────────────────────────────────────────────────────────────────────────────
private suspend fun performProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
): PairingProbeResult {
// ① 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 }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected.
val connection: TransportConnection = try {
ws.connect(endpoint)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(
PairingError.classify(
error,
endpoint,
unrecognizedFallback = PairingError.OriginRejected(
PairingError.originRejectedHint(endpoint),
),
),
)
}
// Once connected, the connection MUST be closed on every exit path — including the timeout/cancel
// path, where withTimeoutOrNull cancels us mid-`firstOrNull`. Without this finally, a cancel skips
// close() and leaks the WS + its orphan PTY session on the host. NonCancellable so the close still
// runs while we are already being cancelled.
return try {
when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
}
} finally {
withContext(NonCancellable) { runCatching { connection.close() } }
}
}
/**
* Probe step ①. Returns a [PairingProbeResult.Failure] to short-circuit, or `null` to proceed.
* Reachable + web-terminal-shaped = HTTP 200 with a JSON-array body (an HTML admin page, a 404, or
* a non-array body are all "not web-terminal").
*/
private suspend fun probeReachability(
endpoint: HostEndpoint,
http: HttpTransport,
): PairingProbeResult.Failure? {
val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
return null
}
/**
* Send `attach(null)` (explicit JSON `"sessionId":null` via [MessageCodec]) and wait for the
* server-issued `attached` id, skipping any other or undecodable frame (untrusted server; only
* `attached` matters here). A stream that ends or errors before speaking our protocol is NOT an
* Origin problem — the upgrade already succeeded — so it maps to [PairingError.HttpOkButNotWebTerminal].
*/
private suspend fun adoptAttachedSession(connection: TransportConnection): Adoption =
try {
connection.send(MessageCodec.encode(ClientMessage.Attach(sessionId = null)))
val sessionId = connection.frames
.mapNotNull { frame -> (MessageCodec.decodeServer(frame) as? ServerMessage.Attached)?.sessionId }
.firstOrNull()
if (sessionId != null) Adoption.Success(sessionId) else Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
}
/**
* The guarded kill round-trip is part of pairing verification itself (`DELETE` exercises the
* HTTP-side Origin guard the later `hookDecision` will need) AND guarantees the probe leaves no
* orphan session. 204 = killed, 404 = already gone (both success), 403 = Origin guard rejected us.
*/
private suspend fun killProbeSession(
sessionId: String,
endpoint: HostEndpoint,
http: HttpTransport,
): PairingProbeResult {
val response = try {
http.send(
HttpRequest(
method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
),
)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
)
else -> PairingProbeResult.Failure(
PairingError.HostUnreachable(underlying = "HTTP ${response.status}"),
)
}
}
private sealed interface Adoption {
data class Success(val sessionId: String) : Adoption
data class Failure(val error: PairingError) : Adoption
}
// ── URL derivation + shape check ────────────────────────────────────────────────────────────────
private val PROBE_JSON = Json { ignoreUnknownKeys = true; isLenient = true }
/** Web-terminal shape = the body parses as a JSON array (the server replies `[]` when idle). */
private fun isJsonArray(body: ByteArray): Boolean =
runCatching { PROBE_JSON.parseToJsonElement(body.decodeToString()) is JsonArray }.getOrDefault(false)
private fun liveSessionsUrl(endpoint: HostEndpoint): String = httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH
private fun killUrl(endpoint: HostEndpoint, sessionId: String): String =
httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + "/" + sessionId
/**
* `scheme://host[:port]` from the endpoint's dialed URL — path/query/fragment/credentials dropped
* (same derivation philosophy as [HostEndpoint.wsUrl]). The dialed port is kept verbatim; the host
* is left as [java.net.URI] returns it (IPv6 literals are already bracketed).
*/
private fun httpBaseUrl(endpoint: HostEndpoint): String {
val uri = URI(endpoint.baseUrl)
val scheme = (uri.scheme ?: "http").lowercase()
val host = uri.host ?: ""
val portPart = if (uri.port != -1) ":${uri.port}" else ""
return "$scheme://$host$portPart"
}
private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404

View File

@@ -0,0 +1,173 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TimelineEvent
import java.util.UUID
/**
* Typed client for the server's HTTP surface (12 frozen routes). Pure — consumes [HttpTransport]
* by interface (no OkHttp/Android); `:transport-okhttp` (A7) provides the real impl, the
* `:test-support` fake queues canned responses.
*
* **Origin 铁律 (CSWSH, plan §4.3):** only the guarded (state-changing) routes stamp
* `Origin: endpoint.originHeader`; the read-only GETs never do. Stamping lives in ONE place
* ([ApiRoute.toHttpRequest]) and the value is single-point-derived by [HostEndpoint].
*
* 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.
*/
public class ApiClient(
public val endpoint: HostEndpoint,
private val http: HttpTransport,
) {
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
/** `GET /live-sessions` — the discovery list every device polls. */
public suspend fun liveSessions(): List<LiveSessionInfo> {
val response = perform(Endpoints.liveSessions())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return LossyDecode.listOrNull(response.body, LiveSessionInfo.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /live-sessions/:id/preview` — ring-buffer tail for a read-only thumbnail (no attach). */
public suspend fun preview(id: UUID): SessionPreview {
val response = perform(Endpoints.preview(id))
requireOk(response)
return LossyDecode.objectOrNull(response.body, SessionPreview.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /live-sessions/:id/events` — the activity timeline. A non-array body (timeline capture
* disabled) → `[]`; unknown-class entries survive shape-decode and are filtered downstream. */
public suspend fun events(id: UUID): List<TimelineEvent> {
val response = perform(Endpoints.events(id))
requireOk(response)
return LossyDecode.listOrEmpty(response.body, TimelineEvent.serializer())
}
/** `GET /config/ui` — `{ allowAutoMode }`. */
public suspend fun uiConfig(): UiConfig {
val response = perform(Endpoints.uiConfig())
requireOk(response)
return LossyDecode.objectOrNull(response.body, UiConfig.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /projects` — discovered projects with their running sessions merged in. */
public suspend fun projects(): List<ProjectInfo> {
val response = perform(Endpoints.projects())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return LossyDecode.listOrNull(response.body, ProjectInfo.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one project. An empty path is
* rejected client-side (mirror of the server's 400) before any network I/O. */
public suspend fun projectDetail(path: String): ProjectDetail {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectDetail(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, ProjectDetail.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.ProjectDetailUnavailable
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs {
val response = perform(Endpoints.getPrefs())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
}
// ── G (state-changing — Origin required, byte-equal) ───────────────────────────────────
/** `DELETE /live-sessions/:id`. 204 = success; 404 = already gone (also success on the server,
* but iOS surfaces it as `SessionNotFound` — matched here). */
public suspend fun killSession(id: UUID) {
val response = perform(Endpoints.killSession(id))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /hook/decision` — resolve a held remote approval with a single-use `token` (push
* payload only; NEVER persist it). 403 → stale/mismatched token; 429 → rate-limited. */
public suspend fun hookDecision(sessionId: UUID, decision: HookDecision, token: String) {
val response = perform(Endpoints.hookDecision(sessionId, decision, token))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.FORBIDDEN -> throw ApiClientError.DecisionRejected
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `PUT /prefs` — replace the whole blob. Returns the server's sanitized echo — treat IT as the
* new source of truth, not the input. 403 = Origin guard. */
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
val response = perform(Endpoints.putPrefs(prefs))
return when (response.status) {
HttpStatus.OK -> UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
public suspend fun registerFcmToken(token: String) {
sendFcmToken(token, Endpoints::registerFcmToken)
}
/** `DELETE /push/fcm-token` — unregister (idempotent → 204 even for an unknown token). */
public suspend fun unregisterFcmToken(token: String) {
sendFcmToken(token, Endpoints::unregisterFcmToken)
}
private suspend fun sendFcmToken(token: String, build: (String) -> ApiRoute) {
val normalized = FcmTokenRule.normalize(token) ?: throw ApiClientError.InvalidFcmToken
val response = perform(build(normalized))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.BAD_REQUEST -> throw ApiClientError.InvalidFcmToken
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
// ── Internals ──────────────────────────────────────────────────────────────────────────
private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
return http.send(request)
}
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
private fun requireOk(response: HttpResponse) {
when (response.status) {
HttpStatus.OK -> Unit
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
}

View File

@@ -0,0 +1,49 @@
package wang.yaojia.webterm.api.routes
/**
* Typed failures for [ApiClient] calls (explicit error handling, plan §4). Transport-level errors
* thrown by `HttpTransport.send` propagate UNWRAPPED (the pairing classifier reads their shape).
*
* [userMessage] is UI-ready copy (matches the iOS `APIClientError.message` 话术). The no-arg cases
* are singletons (`object`) so tests can assert by identity/equality; [UnexpectedStatus] carries the
* offending code.
*/
public sealed class ApiClientError(public val userMessage: String) : Exception(userMessage) {
/** The request could not be built from the endpoint (malformed base URL — should not happen for
* a validated `HostEndpoint`; surfaced instead of crashing). */
public data object InvalidRequest : ApiClientError("无法构造请求(主机地址异常)。")
/** A 2xx arrived but the body is not the endpoint's shape. For `/live-sessions` this is the
* pairing probe's "the port speaks HTTP but is not web-terminal" signal. */
public data object InvalidResponseBody : ApiClientError("服务器响应不是预期格式——端口对吗?")
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")
/** 403 from `POST /hook/decision`: the capability token is missing / mismatched / STALE —
* tokens are single-use and expiring by design. */
public data object DecisionRejected : ApiClientError("审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。")
/** 429: the endpoint is rate-limited per IP by fixed server policy. */
public data object RateLimited : ApiClientError("操作过于频繁,服务器已限流,请稍后再试。")
/** The FCM registration token failed the client-side charset/length check, or the server echoed
* a 400. */
public data object InvalidFcmToken : ApiClientError("推送注册令牌格式异常,请重启 App 重新注册推送。")
/** 400 from `GET /projects/detail` — the `path` query parameter is missing/empty. Also raised
* client-side for an empty path, before any network I/O. */
public data object ProjectPathInvalid : ApiClientError("项目路径为空或不合法。")
/** 404 from `GET /projects/detail` — no project at that path (moved/deleted/not a directory). */
public data object ProjectNotFound : ApiClientError("项目不存在(路径可能已移动或删除)。")
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
}

View File

@@ -0,0 +1,91 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.net.URI
/** Named HTTP status codes used by the client (no magic numbers, plan §4). */
internal object HttpStatus {
const val OK = 200
const val NO_CONTENT = 204
const val BAD_REQUEST = 400
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
}
/** Header / content-type names (no magic strings inline). */
internal object HeaderName {
const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type"
}
internal object ContentType {
const val JSON = "application/json"
}
/**
* Whether a route mutates server state — THE security split (plan §4.3): `Origin` is stamped
* **iff** [GUARDED]. If the server ever reclassifies a RO route as guarded, tests go red instead of
* passing by coincidence.
*/
internal enum class OriginPolicy {
/** Read-only GET — MUST NOT carry Origin. */
READ_ONLY,
/** State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal; server 403s a
* missing/foreign Origin (CSWSH defence). */
GUARDED,
}
/**
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
* sites). Origin stamping happens HERE and only here (single point).
*/
internal class ApiRoute(
val method: HttpMethod,
val path: String,
val originPolicy: OriginPolicy,
val body: ByteArray? = null,
val percentEncodedQuery: 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`).
*/
fun toHttpRequest(endpoint: HostEndpoint): 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 (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
}
return HttpRequest(method = method, url = url, headers = headers, body = body)
}
private companion object {
/**
* Rebuild `<scheme>://<host>[:<port>]<path>[?<query>]` from the dialed base URL, keeping the
* dialed port verbatim (like `wsUrl`, unlike the default-port-dropping Origin). The path and
* pre-encoded query are appended verbatim; any path/query/fragment/credentials the base URL
* carried are dropped.
*/
fun buildUrl(baseUrl: String, path: String, query: String?): String? {
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase() ?: return null
val host = uri.host ?: return null
if (host.isEmpty()) return null
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
val portPart = if (uri.port != -1) ":${uri.port}" else ""
val queryPart = if (query != null) "?$query" else ""
return "$scheme://$serializedHost$portPart$path$queryPart"
}
}
}

View File

@@ -0,0 +1,127 @@
package wang.yaojia.webterm.api.routes
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.ModelJson
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* Builders for the frozen endpoints (12 routes, verified against `src/http/…` + iOS `Endpoints` /
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
* pair (this client uses FCM, not APNs/VAPID).
*
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
* · `POST|DELETE /push/fcm-token`
*
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
* `/push/fcm-token`), which is still PENDING — so FCM push is non-functional against the current
* server until A33 lands. The client builders exist now so the token lifecycle is ready the moment
* the server route ships; do not "fix" this as a mismatch.
*/
internal object Endpoints {
// ── RO ───────────────────────────────────────────────────────────────────────────────
fun liveSessions(): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions", OriginPolicy.READ_ONLY)
fun preview(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/preview", OriginPolicy.READ_ONLY)
fun events(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/events", OriginPolicy.READ_ONLY)
fun uiConfig(): ApiRoute =
ApiRoute(HttpMethod.GET, "/config/ui", OriginPolicy.READ_ONLY)
fun projects(): ApiRoute =
ApiRoute(HttpMethod.GET, "/projects", OriginPolicy.READ_ONLY)
/**
* `GET /projects/detail?path=` — RO. The ONE place `path` gets percent-encoded, with a strict
* RFC 3986 unreserved-only set (deliberately stricter than URL-query-allowed: a bare `+` decodes
* to a SPACE in Express's qs parser, and `&`/`=` would split the parameter).
*/
fun projectDetail(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/detail",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
// ── G ────────────────────────────────────────────────────────────────────────────────
fun killSession(id: UUID): ApiRoute =
ApiRoute(HttpMethod.DELETE, "/live-sessions/${pathId(id)}", OriginPolicy.GUARDED)
/** Body is exactly `{ sessionId, decision, token }`. `token` is single-use (push payload only) —
* callers must never persist/log it. */
fun hookDecision(sessionId: UUID, decision: HookDecision, token: String): ApiRoute {
val body = ModelJson.encodeToString(
HookDecisionBody.serializer(),
HookDecisionBody(pathId(sessionId), decision.wire, token),
).encodeToByteArray()
return ApiRoute(HttpMethod.POST, "/hook/decision", OriginPolicy.GUARDED, body = body)
}
/** `PUT /prefs` — G. Replaces the whole blob (server echoes a sanitized 200). */
fun putPrefs(prefs: UiPrefs): ApiRoute =
ApiRoute(HttpMethod.PUT, "/prefs", OriginPolicy.GUARDED, body = prefs.encodeBody())
fun registerFcmToken(normalized: String): ApiRoute =
fcmTokenRoute(HttpMethod.POST, normalized)
fun unregisterFcmToken(normalized: String): ApiRoute =
fcmTokenRoute(HttpMethod.DELETE, normalized)
private fun fcmTokenRoute(method: HttpMethod, normalized: String): ApiRoute {
val body = ModelJson.encodeToString(
FcmTokenBody.serializer(),
FcmTokenBody(normalized),
).encodeToByteArray()
return ApiRoute(method, FCM_TOKEN_PATH, OriginPolicy.GUARDED, body = body)
}
private const val FCM_TOKEN_PATH = "/push/fcm-token"
/**
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
* on the JVM (unlike Swift's uppercase `uuidString`).
*/
private fun pathId(id: UUID): String = id.toString()
/** Strict RFC 3986 unreserved set — everything else is percent-encoded over UTF-8 bytes. */
private val UNRESERVED: Set<Char> =
("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~").toSet()
private fun percentEncode(value: String): String {
val sb = StringBuilder()
for (byte in value.encodeToByteArray()) {
val code = byte.toInt() and 0xFF
val ch = code.toChar()
if (ch in UNRESERVED) {
sb.append(ch)
} else {
sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
}
}
return sb.toString()
}
private val HEX = "0123456789ABCDEF".toCharArray()
@Serializable
private data class HookDecisionBody(val sessionId: String, val decision: String, val token: String)
@Serializable
private data class FcmTokenBody(val token: String)
}

View File

@@ -0,0 +1,24 @@
package wang.yaojia.webterm.api.routes
/**
* Client-side FCM registration-token validator (validate at the boundary, plan §4). Deliberately
* LOOSE, per plan §4.5: non-empty, bounded length, `base64url` charset plus `:` — the FCM token
* format/length is undocumented and changes, so a strict length regex risks rejecting valid tokens.
*
* Unlike iOS's APNs hex rule, FCM tokens are case-sensitive → NOT lowercased. [normalize] returns
* the token unchanged when valid, or `null` (→ `ApiClientError.InvalidFcmToken` before any I/O).
*/
internal object FcmTokenRule {
/** Generous headroom bound; real tokens are ~150250 chars but the ceiling is undocumented. */
private const val MAX_LENGTH = 4096
/** base64url alphabet (`AZ az 09 - _`) plus the `:` that appears in FCM tokens. */
private val ALLOWED: Set<Char> =
(('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', ':')).toSet()
fun normalize(raw: String): String? {
if (raw.isEmpty() || raw.length > MAX_LENGTH) return null
if (!raw.all { it in ALLOWED }) return null
return raw
}
}

View File

@@ -0,0 +1,72 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The prefs round-trip correctness trap: `PUT /prefs` replaces the WHOLE blob, so decode → mutate
* one known key → encode MUST carry every unknown top-level key through verbatim (including exact
* integer formatting), or an Android write clobbers web/iOS/future-server prefs.
*/
class UiPrefsTest {
@Test
fun sanitizesFavouritesAndCollapsedLikeTheWebClient() {
val prefs = UiPrefs.decode(
"""
{"favourites":["/a","/b","/a","",123],"collapsed":{"g1":true,"g2":false,"g3":"true","":true}}
""".trimIndent().toByteArray(),
)!!
// favourites: non-empty strings only, de-duplicated, order preserved; the number 123 dropped.
assertEquals(listOf("/a", "/b"), prefs.favourites)
// collapsed: only literal `true`; false, string "true", and the empty key are all dropped.
assertEquals(mapOf("g1" to true), prefs.collapsed)
}
@Test
fun mutatingOneKeyPreservesUnknownKeysAndIntegerFormatting() {
val original = UiPrefs.decode(
"""
{"favourites":["/old"],"collapsed":{"g":true},"schemaVersion":7,"vendor":{"nested":42,"ratio":1.5}}
""".trimIndent().toByteArray(),
)!!
val mutated = original.withFavourites(listOf("/new"))
val encoded = mutated.encodeBody().decodeToString()
// Known key was replaced...
assertEquals(listOf("/new"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
// ...collapsed (untouched known key) survived...
assertEquals(mapOf("g" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
// ...and every unknown key survived verbatim, integers still integers (42, not 42.0).
assertTrue(encoded.contains("\"schemaVersion\":7"), "unknown scalar key must round-trip: $encoded")
assertTrue(encoded.contains("\"nested\":42"), "nested integer must not become 42.0: $encoded")
assertTrue(encoded.contains("\"ratio\":1.5"), "nested double must round-trip: $encoded")
}
@Test
fun withCollapsedReplacesOnlyThatKey() {
val original = UiPrefs.decode("""{"favourites":["/keep"],"extra":"x"}""".toByteArray())!!
val encoded = original.withCollapsed(mapOf("ns" to true)).encodeBody().decodeToString()
assertEquals(listOf("/keep"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
assertEquals(mapOf("ns" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
assertTrue(encoded.contains("\"extra\":\"x\""))
}
@Test
fun createBuildsAFreshBlobWithBothKnownKeys() {
val encoded = UiPrefs.create(favourites = listOf("/a"), collapsed = mapOf("g" to true))
.encodeBody().decodeToString()
assertEquals("""{"favourites":["/a"],"collapsed":{"g":true}}""", encoded)
}
@Test
fun nonObjectBodyDecodesToNull() {
assertNull(UiPrefs.decode("[]".toByteArray()))
assertNull(UiPrefs.decode("\"hi\"".toByteArray()))
assertNull(UiPrefs.decode("not json".toByteArray()))
}
}

View File

@@ -0,0 +1,65 @@
package wang.yaojia.webterm.api.pairing
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
/** Ports the tier table of iOS `HostClassificationTests` (plan §5.4, fail-safe unknown→public). */
class HostClassifierTest {
@Test
fun loopbackHostsClassifyAsLoopback() {
val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.5.9.200", "::1", "[::1]")
hosts.forEach { assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(it), it) }
}
@Test
fun rfc1918AndLinkLocalAndMdnsClassifyAsPrivateLan() {
val hosts = listOf(
"10.0.0.5", "10.255.255.255",
"172.16.0.1", "172.20.10.1", "172.31.255.255",
"192.168.0.9", "192.168.1.1",
"169.254.1.1",
"mac-mini.local", "MAC-MINI.LOCAL", "printer.local",
)
hosts.forEach { assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(it), it) }
}
@Test
fun cgnatAndMagicDnsClassifyAsTailscale() {
val hosts = listOf(
"100.64.0.1", "100.100.1.1", "100.127.255.255",
"mac.tailnet.ts.net", "MAC.TAILNET.TS.NET", "host.ts.net",
)
hosts.forEach { assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(it), it) }
}
@Test
fun everythingElseFailsSafeToPublic() {
val hosts = listOf(
// routable public IPs
"8.8.8.8", "203.0.113.7",
// hostnames
"example.com", "claude.ai",
// boundary-miss IPv4 (just outside the private/tailscale ranges)
"172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1",
// malformed / out-of-range / wrong arity → fail-safe public
"256.1.1.1", "1.2.3", "999.999.999.999", "not a host", "",
// non-loopback IPv6 (ULA / documentation) → fail-safe public (iOS v1 scope)
"fd00::1", "2001:db8::1",
)
hosts.forEach { assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(it), it) }
}
@Test
fun classifyEndpointDelegatesToHost() {
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
val tailscale = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
val public = requireNotNull(HostEndpoint.fromBaseUrl("https://example.com"))
assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(lan))
assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(tailscale))
assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(public))
}
}

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.api.pairing
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.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
/** Re-derives iOS `PairingError.classify` for JVM exceptions (plan R12: drop localNetworkDenied). */
class PairingErrorTest {
private val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
@Test
fun connectionRefusedMapsToHostUnreachable() {
val result = PairingError.classify(ConnectException("Connection refused"), lan)
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
}
@Test
fun dnsFailureMapsToHostUnreachable() {
val result = PairingError.classify(UnknownHostException("no such host"), lan)
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
}
@Test
fun socketTimeoutMapsToTimeout() {
assertEquals(PairingError.Timeout, PairingError.classify(SocketTimeoutException("read timed out"), lan))
}
@Test
fun interruptedIoMapsToTimeout() {
// OkHttp's overall call-timeout surfaces as a bare InterruptedIOException.
assertEquals(PairingError.Timeout, PairingError.classify(InterruptedIOException("timeout"), lan))
}
@Test
fun sslHandshakeFailureMapsToTlsFailure() {
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLHandshakeException("handshake_failure"), lan))
}
@Test
fun genericSslAndCertFailuresMapToTlsFailure() {
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLException("tls"), lan))
assertEquals(PairingError.TlsFailure, PairingError.classify(CertificateException("bad cert"), lan))
}
@Test
fun cleartextBlockMapsToCleartextBlockedWithHost() {
val err = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted")
val result = PairingError.classify(err, lan)
assertInstanceOf(PairingError.CleartextBlocked::class.java, result)
assertEquals("192.168.1.5", (result as PairingError.CleartextBlocked).host)
}
@Test
fun wrappedCauseIsWalked() {
// OkHttp frequently wraps the real cause; the chain walk must see it.
val wrappedConnect = IOException("unexpected end of stream", ConnectException("refused"))
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(wrappedConnect, lan))
val wrappedTls = IOException("io", SSLHandshakeException("handshake"))
assertEquals(PairingError.TlsFailure, PairingError.classify(wrappedTls, lan))
}
@Test
fun unrecognizedErrorUsesFallbackWhenProvided() {
// Probe step ②: after ① passed, an unclassifiable connect failure is the Origin 401.
val fallback = PairingError.OriginRejected("hint")
assertEquals(fallback, PairingError.classify(IllegalStateException("weird"), lan, unrecognizedFallback = fallback))
}
@Test
fun unrecognizedErrorWithoutFallbackIsHostUnreachable() {
assertInstanceOf(
PairingError.HostUnreachable::class.java,
PairingError.classify(IllegalStateException("weird"), lan),
)
}
@Test
fun causeCycleTerminates() {
// A→B→A cycle must not loop forever; B is a ConnectException so it classifies as reachable-failure.
val a = IOException("a")
val b = ConnectException("b")
a.initCause(b)
b.initCause(a)
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(a, lan))
}
@Test
fun originRejectedHintDerivesFromOriginHeaderWithoutDefaultPort() {
val lanHint = PairingError.originRejectedHint(lan)
assertTrue(lanHint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
val tls = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
val tlsHint = PairingError.originRejectedHint(tls)
assertTrue(tlsHint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
assertFalse(tlsHint.contains(":443"), "default https port must be omitted (no :443 迷信)")
}
}

View File

@@ -0,0 +1,284 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
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.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import javax.net.ssl.SSLHandshakeException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Ports iOS `PairingProbeTests`: the two-step probe (① GET reachability/shape, ② WS attach→adopt→
* kill-with-Origin), each failure mode mapped to a [PairingError], full-success leaves no orphan
* session, and the injected-deadline timeout — all under `runTest` virtual time (zero real waits).
*/
class PairingProbeTest {
private data class Fixture(
val endpoint: HostEndpoint,
val http: FakeHttpTransport,
val ws: FakeTermTransport,
val liveSessionsUrl: String,
val killUrl: String,
)
private fun fixture(base: String = BASE): Fixture {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(base))
return Fixture(
endpoint = endpoint,
http = FakeHttpTransport(),
ws = FakeTermTransport(),
liveSessionsUrl = "$base/live-sessions",
killUrl = "$base/live-sessions/$SESSION_ID",
)
}
/** Non-timeout cases use `timeout = null` so the probe never enters the race — deterministic. */
private suspend fun runProbe(f: Fixture, timeout: Duration? = null): PairingProbeResult =
runPairingProbeCore(f.endpoint, f.http, f.ws, timeout)
private fun failureError(result: PairingProbeResult): PairingError {
assertInstanceOf(PairingProbeResult.Failure::class.java, result)
return (result as PairingProbeResult.Failure).error
}
// ── Probe ① failure branches ────────────────────────────────────────────────────────────────
@Test
fun stepOneConnectionRefusedMapsToHostUnreachableAndNeverTouchesWs() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = ConnectException("refused"))
val result = runProbe(f)
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(result))
assertTrue(f.ws.connectAttempts.isEmpty(), "probe must not touch WS when ① fails")
}
@Test
fun stepOneHtmlBodyMapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "<html><body>router admin</body></html>".toByteArray())
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOne404MapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, status = 404)
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOneNonArrayJsonMapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "{\"ok\":true}".toByteArray())
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOneTlsFailureMapsToTlsFailure() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = SSLHandshakeException("handshake_failure"))
assertEquals(PairingError.TlsFailure, failureError(runProbe(f)))
}
@Test
fun stepOneTransportTimeoutMapsToTimeout() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = SocketTimeoutException("read timed out"))
assertEquals(PairingError.Timeout, failureError(runProbe(f)))
}
@Test
fun stepOneCleartextBlockMapsToCleartextBlockedWithHost() = runTest {
val f = fixture()
f.http.queueFailure(
url = f.liveSessionsUrl,
error = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted"),
)
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.CleartextBlocked::class.java, error)
assertEquals("192.168.1.5", (error as PairingError.CleartextBlocked).host)
}
@Test
fun stepOneDnsFailureMapsToHostUnreachable() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = UnknownHostException("nxdomain"))
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(runProbe(f)))
}
// ── Probe ② failure branches ────────────────────────────────────────────────────────────────
@Test
fun stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() = runTest {
// ① passes; ② upgrade fails (a 401 is a shapeless connect error at the transport layer).
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.scriptConnectFailure()
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.OriginRejected::class.java, error)
val hint = (error as PairingError.OriginRejected).hint
assertTrue(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"), hint)
assertFalse(hint.contains(":443"), hint)
}
@Test
fun originRejectedHintOmitsDefaultPortForHttps() = runTest {
val f = fixture(base = "https://mac.tailnet.ts.net")
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.scriptConnectFailure()
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.OriginRejected::class.java, error)
val hint = (error as PairingError.OriginRejected).hint
assertTrue(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"), hint)
assertFalse(hint.contains(":443"), hint)
}
@Test
fun stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() = runTest {
// A queued finish is flushed into the connection at connect → stream closes before `attached`.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.finishFrames()
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
// ── Full pass + kill round-trip ──────────────────────────────────────────────────────────────
@Test
fun fullProbeSuccessAttachesWithNullSessionIdThenKillsWithOrigin() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit(ATTACHED_FRAME)
val result = runProbe(f)
// Success payload is the probed endpoint.
assertEquals(PairingProbeResult.Success(f.endpoint), result)
// attach(null) is the only WS frame, with an explicit JSON null sessionId key.
assertEquals(1, f.ws.sentFrames.size)
val attach = Json.parseToJsonElement(f.ws.sentFrames.single()).jsonObject
assertEquals("attach", attach["type"]?.jsonPrimitive?.content)
assertTrue(attach["sessionId"] is JsonNull, "sessionId key must be present and JSON null")
// Exactly two HTTP requests: RO GET (NO Origin) then guarded DELETE (byte-equal Origin).
val requests = f.http.recordedRequests
assertEquals(2, requests.size)
assertFalse(requests.first().headers.containsKey("Origin"), "RO GET must not carry Origin")
val kill = requests.last()
assertEquals(HttpMethod.DELETE, kill.method)
assertEquals(f.killUrl, kill.url)
assertEquals(f.endpoint.originHeader, kill.headers["Origin"])
// The probe never holds the connection.
assertEquals(1, f.ws.closeCallCount)
}
@Test
fun publicWrapperHappyPathReturnsEndpoint() = runTest {
// The public entry uses the default Tunables deadline; immediate fakes never trip it.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit(ATTACHED_FRAME)
val result = runPairingProbe(f.endpoint, f.http, f.ws)
assertEquals(PairingProbeResult.Success(f.endpoint), result)
assertEquals(1, f.ws.closeCallCount)
}
@Test
fun outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit("""{"type":"output","data":"replay"}""")
f.ws.emit(ATTACHED_FRAME)
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
}
@Test
fun killRejectedByOriginGuardMapsToOriginRejected() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 403)
f.ws.emit(ATTACHED_FRAME)
assertInstanceOf(PairingError.OriginRejected::class.java, failureError(runProbe(f)))
}
@Test
fun killReturning404StillCountsAsSuccess() = runTest {
// Session exited between attach and kill — the goal state (no orphan) is already reached.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 404)
f.ws.emit(ATTACHED_FRAME)
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
}
// ── Timeout (virtual time, zero real waits) ──────────────────────────────────────────────────
@Test
fun probeTimesOutWhenServerNeverSendsAttached() = runTest {
// ① passes; ② connects but the server never replies `attached` → the probe hangs on the
// frame stream until the injected deadline cancels it.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
val result = runProbe(f, timeout = 10.seconds)
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
}
@Test
fun timeoutPathAlwaysClosesTheConnectionExactlyOnce() = runTest {
// ① passes; ② connects but the server never sends `attached` → the probe hangs on the frame
// stream until the injected deadline cancels it MID-adopt. The connection must still close
// (try/finally + NonCancellable), or the probe leaks the WS + its orphan session.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
val result = runProbe(f, timeout = 10.seconds)
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
assertEquals(1, f.ws.closeCallCount, "the probe must close the WS even on the timeout path")
}
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
const val ATTACHED_FRAME = """{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"""
}
}

View File

@@ -0,0 +1,86 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
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.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.io.IOException
import java.util.UUID
/** Status-code → typed [ApiClientError] mapping per route, plus transport errors propagating raw. */
class ApiClientErrorMappingTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
@Test
fun killSessionMapsNotFoundAndForbidden() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.killSession(ID) })
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 403)
assertEquals(ApiClientError.Forbidden, errorOf { client.killSession(ID) })
}
@Test
fun hookDecisionMapsForbiddenToRejectedAnd429ToRateLimited() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 403)
assertEquals(ApiClientError.DecisionRejected, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 429)
assertEquals(ApiClientError.RateLimited, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
}
@Test
fun projectDetailMapsEmptyPathAndAllServerErrorStatuses() = runTest {
// Empty path is rejected BEFORE any I/O.
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("") })
assertTrue(transport.recordedRequests.isEmpty(), "empty path must not hit the network")
val url = "$BASE/projects/detail?path=%2Fp"
transport.queueSuccess(url = url, status = 400)
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("/p") })
transport.queueSuccess(url = url, status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectDetail("/p") })
transport.queueSuccess(url = url, status = 500)
assertEquals(ApiClientError.ProjectDetailUnavailable, errorOf { client.projectDetail("/p") })
}
@Test
fun putPrefsMapsForbiddenAndUnexpected() = runTest {
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 403)
assertEquals(ApiClientError.Forbidden, errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 418)
assertEquals(ApiClientError.UnexpectedStatus(418), errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
}
@Test
fun readOnlyRoutesMapNonOkToUnexpectedOrSessionNotFound() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", status = 500, body = "boom".toByteArray())
assertEquals(ApiClientError.UnexpectedStatus(500), errorOf { client.liveSessions() })
// preview/events/uiConfig go through requireOk: 404 → SessionNotFound.
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/preview", status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.preview(ID) })
}
@Test
fun transportLevelErrorsPropagateUnwrapped() = runTest {
transport.queueFailure(url = "$BASE/live-sessions", error = IOException("connection refused"))
val error = errorOf { client.liveSessions() }
assertTrue(error is IOException)
assertEquals("connection refused", error?.message)
}
}

View File

@@ -0,0 +1,167 @@
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) across all 12 routes. Verifies method, URL,
* the presence/absence of the `Origin` header, and the exact JSON body for the mutating routes.
*/
class ApiRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun lastRequest(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(request: HttpRequest) =
assertEquals(ORIGIN, request.headers[HeaderName.ORIGIN], "guarded route must stamp byte-equal Origin")
private fun assertReadOnly(request: HttpRequest) =
assertFalse(request.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
// ── read-only routes: correct verb+url, NO Origin ───────────────────────────────────────
@Test
fun liveSessionsIsPlainReadOnlyGet() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
client.liveSessions()
val request = lastRequest()
assertEquals(HttpMethod.GET, request.method)
assertEquals("$BASE/live-sessions", request.url)
assertReadOnly(request)
assertNull(request.body)
}
@Test
fun previewEventsUiConfigProjectsPrefsAreReadOnlyGets() = runTest {
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/preview",
body = """{"id":"$ID_STR","cols":80,"rows":24,"data":""}""".toByteArray(),
)
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
transport.queueSuccess(url = "$BASE/projects", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/prefs", body = "{}".toByteArray())
client.preview(ID)
client.events(ID)
client.uiConfig()
client.projects()
client.prefs()
val urls = transport.recordedRequests.map { it.url }
assertEquals(
listOf(
"$BASE/live-sessions/$ID_STR/preview",
"$BASE/live-sessions/$ID_STR/events",
"$BASE/config/ui",
"$BASE/projects",
"$BASE/prefs",
),
urls,
)
transport.recordedRequests.forEach {
assertEquals(HttpMethod.GET, it.method)
assertReadOnly(it)
}
}
@Test
fun projectDetailStrictlyPercentEncodesPathInTheQuery() = runTest {
val path = "/home/me/my repo/a+b&c"
transport.queueSuccess(
url = "$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
body = """{"name":"repo","path":"$path","isGit":true}""".toByteArray(),
)
client.projectDetail(path)
val request = lastRequest()
assertEquals(HttpMethod.GET, request.method)
assertEquals("$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", request.url)
assertReadOnly(request)
}
// ── guarded routes: correct verb+url, Origin stamped, exact body ────────────────────────
@Test
fun killSessionIsGuardedDeleteWithNoBody() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
client.killSession(ID)
val request = lastRequest()
assertEquals(HttpMethod.DELETE, request.method)
assertEquals("$BASE/live-sessions/$ID_STR", request.url)
assertGuarded(request)
assertNull(request.body)
}
@Test
fun hookDecisionIsGuardedPostWithExactBodyAndJsonContentType() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
client.hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
val request = lastRequest()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/hook/decision", request.url)
assertGuarded(request)
assertEquals(ContentType.JSON, request.headers[HeaderName.CONTENT_TYPE])
assertEquals(
"""{"sessionId":"$ID_STR","decision":"allow","token":"cap-tok-123"}""",
request.body?.decodeToString(),
)
}
@Test
fun putPrefsIsGuardedPutEchoingTheFullBlob() = runTest {
val body = """{"favourites":["/a"],"collapsed":{}}"""
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = body.toByteArray())
client.putPrefs(UiPrefs.create(favourites = listOf("/a")))
val request = lastRequest()
assertEquals(HttpMethod.PUT, request.method)
assertEquals("$BASE/prefs", request.url)
assertGuarded(request)
assertEquals(body, request.body?.decodeToString())
}
@Test
fun fcmTokenRegisterAndUnregisterAreGuardedWithTokenBody() = runTest {
val token = "fVoT9x-abc_DEF:api-901"
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/push/fcm-token", status = 204)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/push/fcm-token", status = 204)
client.registerFcmToken(token)
client.unregisterFcmToken(token)
val post = transport.recordedRequests[0]
val delete = transport.recordedRequests[1]
assertEquals(HttpMethod.POST, post.method)
assertEquals(HttpMethod.DELETE, delete.method)
assertTrue(post.url.endsWith("/push/fcm-token"))
assertGuarded(post)
assertGuarded(delete)
assertEquals("""{"token":"$token"}""", post.body?.decodeToString())
assertEquals("""{"token":"$token"}""", delete.body?.decodeToString())
}
}

View File

@@ -0,0 +1,68 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/** The intentionally-LOOSE FCM token validator (plan §4.5) + register/unregister error mapping. */
class FcmTokenTest {
private companion object {
const val BASE = "http://h:3000"
const val URL = "$BASE/push/fcm-token"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun acceptsBase64UrlPlusColonAndIsCaseSensitive() {
// base64url chars + ':' , mixed case preserved (FCM tokens are case-sensitive → not lowered).
val token = "cRZ7-x_Y9:APA91bH-Ab_CdEf"
assertEquals(token, FcmTokenRule.normalize(token))
}
@Test
fun rejectsEmptyOverlongAndOutOfCharsetTokens() {
assertNull(FcmTokenRule.normalize(""))
assertNull(FcmTokenRule.normalize("a".repeat(4097)))
assertNull(FcmTokenRule.normalize("has space"))
assertNull(FcmTokenRule.normalize("has/slash"))
assertNull(FcmTokenRule.normalize("emoji😀"))
assertNotNull(FcmTokenRule.normalize("a".repeat(4096))) // exactly at the bound is fine
}
@Test
fun invalidTokenIsRejectedBeforeAnyNetworkIO() = runTest {
val error = runCatching { client.registerFcmToken("bad token") }.exceptionOrNull()
assertEquals(ApiClientError.InvalidFcmToken, error)
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an invalid token")
}
@Test
fun registerMaps400To403To429() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 400)
assertEquals(ApiClientError.InvalidFcmToken, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 403)
assertEquals(ApiClientError.Forbidden, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 429)
assertEquals(ApiClientError.RateLimited, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
}
@Test
fun registerAndUnregisterSucceedOn204() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 204)
transport.queueSuccess(method = HttpMethod.DELETE, url = URL, status = 204)
// No exception = success.
client.registerFcmToken("okTok")
client.unregisterFcmToken("okTok")
assertEquals(2, transport.recordedRequests.size)
}
}

View File

@@ -0,0 +1,102 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/** End-to-end decode of each model from a well-formed body (exercises the custom serializers). */
class HappyPathDecodeTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun previewDecodesGeometryAndOpaqueData() = runTest {
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/preview",
body = """{"id":"$ID_STR","cols":100,"rows":30,"data":"hello"}""".toByteArray(),
)
val preview = client.preview(ID)
assertEquals(ID, preview.id)
assertEquals(100, preview.cols)
assertEquals("hello", preview.data)
}
@Test
fun uiConfigDecodesAllowAutoMode() = runTest {
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
assertTrue(client.uiConfig().allowAutoMode)
}
@Test
fun liveSessionsDecodesTelemetry() = runTest {
val body = """
[{"id":"$ID_STR","createdAt":10,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24,
"telemetry":{"at":99,"model":"opus","costUsd":0.42,"linesAdded":10,"pr":{"number":7,"url":"http://x"}}}]
""".trimIndent()
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
val session = client.liveSessions().single()
assertEquals("opus", session.telemetry?.model)
assertEquals(0.42, session.telemetry?.costUsd)
assertEquals(7, session.telemetry?.pr?.number)
}
@Test
fun projectDetailDecodesWorktreesSessionsAndClaudeMd() = runTest {
val body = """
{"name":"repo","path":"/r","isGit":true,"branch":"main","dirty":true,
"worktrees":[{"path":"/r","branch":"main","isMain":true,"isCurrent":true},
{"path":"/r-wt","head":"abc123","isMain":false,"isCurrent":false}],
"sessions":[{"id":"$ID_STR","title":"claude","status":"waiting","clientCount":1,"createdAt":5,"exited":false}],
"hasClaudeMd":true,"claudeMd":"# Repo"}
""".trimIndent()
transport.queueSuccess(url = "$BASE/projects/detail?path=%2Fr", body = body.toByteArray())
val detail = client.projectDetail("/r")
assertEquals("main", detail.branch)
assertEquals(2, detail.worktrees.size)
assertTrue(detail.worktrees[0].isMain)
assertEquals("abc123", detail.worktrees[1].head)
assertEquals("claude", detail.sessions.single().title)
assertEquals("# Repo", detail.claudeMd)
}
@Test
fun prefsRoundTripsThroughGetAndPutEcho() = runTest {
transport.queueSuccess(
url = "$BASE/prefs",
body = """{"favourites":["/a"],"collapsed":{"g":true},"vendor":1}""".toByteArray(),
)
val prefs = client.prefs()
assertEquals(listOf("/a"), prefs.favourites)
// PUT returns the server's sanitized echo — treat IT as truth (server dropped "vendor").
transport.queueSuccess(
method = HttpMethod.PUT,
url = "$BASE/prefs",
body = """{"favourites":["/a","/b"],"collapsed":{}}""".toByteArray(),
)
val echoed = client.putPrefs(prefs.withFavourites(listOf("/a", "/b")))
assertEquals(listOf("/a", "/b"), echoed.favourites)
}
@Test
fun prefsNonObjectBodyThrowsInvalidResponseBody() = runTest {
transport.queueSuccess(url = "$BASE/prefs", body = "[]".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.prefs() }.exceptionOrNull(),
)
}
}

View File

@@ -0,0 +1,101 @@
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.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.HostEndpoint
import java.util.UUID
/** Tolerant decode at the UNTRUSTED server boundary (plan §4): drop bad elements, never crash. */
class TolerantDecodeTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun liveSessionsDropsMalformedEntriesAndMapsUnknownStatusToUnknown() = runTest {
// Entry 1: valid, unknown status string. Entry 2: missing required clientCount → dropped.
// Entry 3: non-UUID id → dropped. Entry 4: valid, big-ms createdAt (Long), no telemetry.
val body = """
[
{"id":"$ID_STR","createdAt":1700000000000,"clientCount":2,"status":"reticulating","exited":false,"cols":80,"rows":24},
{"id":"22222222-2222-4222-8222-222222222222","createdAt":1,"status":"idle","exited":false,"cols":80,"rows":24},
{"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24},
{"id":"33333333-3333-4333-8333-333333333333","createdAt":1700000000001,"clientCount":0,"status":"working","exited":true,"cwd":"/w","cols":120,"rows":40,"lastOutputAt":1700000000005}
]
""".trimIndent()
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
val sessions = client.liveSessions()
assertEquals(2, sessions.size)
assertEquals(ID, sessions[0].id)
assertEquals(ClaudeStatus.UNKNOWN, sessions[0].status) // unknown wire value → UNKNOWN, not dropped
assertEquals(1_700_000_000_000L, sessions[0].createdAt) // ms fits Long, would overflow Int
assertEquals(ClaudeStatus.WORKING, sessions[1].status)
assertEquals(1_700_000_000_005L, sessions[1].lastOutputAt)
}
@Test
fun liveSessionsNonArrayBodyThrowsInvalidResponseBody() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "{}".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.liveSessions() }.exceptionOrNull(),
)
}
@Test
fun projectsKeepsProjectButDropsOnlyItsMalformedNestedSession() = runTest {
val body = """
[
{"name":"repo","path":"/r","isGit":true,"branch":"main","sessions":[
{"id":"$ID_STR","status":"working","clientCount":1,"createdAt":1,"exited":false},
{"id":"bad","status":"working","clientCount":1,"createdAt":1,"exited":false}
]}
]
""".trimIndent()
transport.queueSuccess(url = "$BASE/projects", body = body.toByteArray())
val projects = client.projects()
assertEquals(1, projects.size) // project survives its bad nested session
assertEquals(1, projects[0].sessions.size)
assertEquals(ID, projects[0].sessions[0].id)
}
@Test
fun eventsNonArrayBodyDegradesToEmptyAndUnknownClassSurvives() = runTest {
// A non-array body (timeline capture disabled) → [] rather than an error.
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "{}".toByteArray())
assertTrue(client.events(ID).isEmpty())
// Unknown class decodes fine (shape ok); hasKnownClass=false for downstream filtering.
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/events",
body = """[{"at":5,"class":"weird","label":"did a thing"}]""".toByteArray(),
)
val events = client.events(ID)
assertEquals(1, events.size)
assertFalse(events[0].hasKnownClass)
assertEquals("did a thing", events[0].label)
}
@Test
fun singleObjectRoutesThrowInvalidResponseBodyOnGarbage() = runTest {
transport.queueSuccess(url = "$BASE/config/ui", body = "not json".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.uiConfig() }.exceptionOrNull(),
)
}
}