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:
@@ -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"),
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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。")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ~150–250 chars but the ceiling is undocumented. */
|
||||
private const val MAX_LENGTH = 4096
|
||||
|
||||
/** base64url alphabet (`A–Z a–z 0–9 - _`) 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
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 迷信)")
|
||||
}
|
||||
}
|
||||
@@ -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":"[0mreplay"}""")
|
||||
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"}"""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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":"[0mhello"}""".toByteArray(),
|
||||
)
|
||||
val preview = client.preview(ID)
|
||||
assertEquals(ID, preview.id)
|
||||
assertEquals(100, preview.cols)
|
||||
assertEquals("[0mhello", 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user