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,34 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* [HostEndpoint.fromBaseUrl] is the SINGLE derivation point for the stored `baseUrl` and the
|
||||
* Origin/WS derivations. These vectors lock the trim fix: the stored `baseUrl` must carry no
|
||||
* surrounding whitespace, so every downstream `URI(endpoint.baseUrl)` re-parse succeeds.
|
||||
*/
|
||||
class HostEndpointTest {
|
||||
@Test
|
||||
fun `fromBaseUrl stores a trimmed baseUrl so downstream URI re-parsing never sees whitespace`() {
|
||||
val padded = requireNotNull(HostEndpoint.fromBaseUrl(" http://192.168.1.5:3000 "))
|
||||
val clean = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
|
||||
|
||||
assertEquals("http://192.168.1.5:3000", padded.baseUrl)
|
||||
assertEquals(padded.baseUrl, padded.baseUrl.trim(), "stored baseUrl has no surrounding whitespace")
|
||||
// The padded input yields an endpoint identical to the trimmed-input variant.
|
||||
assertEquals(clean, padded)
|
||||
// And a downstream re-parse (PairingProbe.httpBaseUrl / HostClassifier.hostOf) now succeeds.
|
||||
assertEquals("192.168.1.5", URI(padded.baseUrl).host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trimming does not disturb the Origin and WS derivations`() {
|
||||
val padded = requireNotNull(HostEndpoint.fromBaseUrl(" https://mac.tailnet.ts.net "))
|
||||
|
||||
assertEquals("https://mac.tailnet.ts.net", padded.baseUrl)
|
||||
assertEquals("https://mac.tailnet.ts.net", padded.originHeader)
|
||||
assertEquals("wss://mac.tailnet.ts.net/term", padded.wsUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A4 boundary-validation vectors: [MessageCodec.decodeClient] is the Android mirror of the
|
||||
* server's `parseClientMessage` accept/reject decision, and the exact inverse of
|
||||
* [MessageCodec.encode]. Accept/reject vectors transcribed from `test/protocol.test.ts` /
|
||||
* the iOS `ServerVectorTests` server-mirror; the round-trip block proves encode/decode symmetry.
|
||||
*/
|
||||
class MessageCodecDecodeClientTest {
|
||||
private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
|
||||
// ─── accept: valid client frames decode to the right message ─────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes attach with explicit null and with a uuid`() {
|
||||
assertEquals(
|
||||
ClientMessage.Attach(null),
|
||||
MessageCodec.decodeClient("""{"type":"attach","sessionId":null}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Attach(validUuid),
|
||||
MessageCodec.decodeClient("""{"type":"attach","sessionId":"$validUuid"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes attach with an absolute cwd and normalizes an uppercase sessionId`() {
|
||||
assertEquals(
|
||||
ClientMessage.Attach(null, "/Users/dev/proj"),
|
||||
MessageCodec.decodeClient("""{"type":"attach","sessionId":null,"cwd":"/Users/dev/proj"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Attach(validUuid),
|
||||
MessageCodec.decodeClient("""{"type":"attach","sessionId":"F47AC10B-58CC-4372-A567-0E02B2C3D479"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes input resize approve and reject, ignoring unknown keys`() {
|
||||
assertEquals(ClientMessage.Input("ls -la"), MessageCodec.decodeClient("""{"type":"input","data":"ls -la"}"""))
|
||||
assertEquals(ClientMessage.Resize(1, 1), MessageCodec.decodeClient("""{"type":"resize","cols":1,"rows":1}"""))
|
||||
assertEquals(
|
||||
ClientMessage.Resize(1000, 1000),
|
||||
MessageCodec.decodeClient("""{"type":"resize","cols":1000,"rows":1000}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Resize(80, 40),
|
||||
MessageCodec.decodeClient("""{"type":"resize","cols":80,"rows":40,"extra":"ignored"}"""),
|
||||
)
|
||||
assertEquals(ClientMessage.Approve(), MessageCodec.decodeClient("""{"type":"approve"}"""))
|
||||
assertEquals(ClientMessage.Reject, MessageCodec.decodeClient("""{"type":"reject"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recovers a known approve mode and tolerates an unknown one as absent`() {
|
||||
assertEquals(
|
||||
ClientMessage.Approve(ApproveMode.ACCEPT_EDITS),
|
||||
MessageCodec.decodeClient("""{"type":"approve","mode":"acceptEdits"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Approve(ApproveMode.DEFAULT),
|
||||
MessageCodec.decodeClient("""{"type":"approve","mode":"default"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Approve(null),
|
||||
MessageCodec.decodeClient("""{"type":"approve","mode":"alien"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── reject: invalid client frames -> null ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `rejects every invalid client frame as null`() {
|
||||
val invalid = listOf(
|
||||
"not json at all", "", "null", "[]", "42",
|
||||
"""{"type":"ping"}""", """{"data":"hello"}""", """{"type":42}""",
|
||||
"""{"type":"resize","cols":0,"rows":40}""",
|
||||
"""{"type":"resize","cols":1001,"rows":40}""",
|
||||
"""{"type":"resize","cols":80,"rows":0}""",
|
||||
"""{"type":"resize","cols":80,"rows":1001}""",
|
||||
"""{"type":"resize","cols":80.5,"rows":40}""",
|
||||
"""{"type":"resize","cols":80,"rows":24.9}""",
|
||||
"""{"type":"resize","cols":"80","rows":40}""",
|
||||
"""{"type":"resize","rows":40}""",
|
||||
"""{"type":"resize","cols":80}""",
|
||||
"""{"type":"input","data":42}""",
|
||||
"""{"type":"input","data":null}""",
|
||||
"""{"type":"input"}""",
|
||||
"""{"type":"input","data":{}}""",
|
||||
"""{"type":"attach","sessionId":"abc123"}""",
|
||||
"""{"type":"attach","sessionId":""}""",
|
||||
"""{"type":"attach","sessionId":42}""",
|
||||
"""{"type":"attach"}""",
|
||||
"""{"type":"attach","sessionId":null,"cwd":"relative"}""",
|
||||
"""{"type":"attach","sessionId":null,"cwd":42}""",
|
||||
// the removed 'blur' type must be rejected
|
||||
"""{"type":"blur"}""",
|
||||
)
|
||||
for (frame in invalid) {
|
||||
assertNull(MessageCodec.decodeClient(frame), "should reject: $frame")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── round-trip: decodeClient(encode(m)) == m ────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `encode then decodeClient is the identity for canonical messages`() {
|
||||
val messages = listOf(
|
||||
ClientMessage.Attach(null),
|
||||
ClientMessage.Attach(validUuid),
|
||||
ClientMessage.Attach(null, "/Users/dev/proj"),
|
||||
ClientMessage.Attach(validUuid, "/srv/app"),
|
||||
ClientMessage.Input("ls -la"),
|
||||
// control bytes + quote/backslash survive the round trip
|
||||
ClientMessage.Input("hi" + Char(0x0D) + Char(0x1B) + "[A"),
|
||||
ClientMessage.Input("a\"b\\c"),
|
||||
ClientMessage.Resize(80, 24),
|
||||
ClientMessage.Resize(1, 1),
|
||||
ClientMessage.Resize(1000, 1000),
|
||||
ClientMessage.Approve(null),
|
||||
ClientMessage.Approve(ApproveMode.DEFAULT),
|
||||
ClientMessage.Approve(ApproveMode.ACCEPT_EDITS),
|
||||
ClientMessage.Approve(ApproveMode.PLAN),
|
||||
ClientMessage.Approve(ApproveMode.AUTO),
|
||||
ClientMessage.Reject,
|
||||
)
|
||||
for (m in messages) {
|
||||
assertEquals(m, MessageCodec.decodeClient(MessageCodec.encode(m)), "round-trip: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* A4 decode vectors: [MessageCodec.decodeServer] maps an UNTRUSTED server text frame to a
|
||||
* [ServerMessage] or null (drop). Transcribed from the iOS `ServerVectorTests` + the server's
|
||||
* own `test/protocol.test.ts`. It must never throw, tolerate unknown keys, treat wrong-typed
|
||||
* OPTIONAL fields as absent, and drop on any missing/wrong-typed REQUIRED field.
|
||||
*
|
||||
* Control characters in expected VALUES are built with `Char(code)` (never raw source bytes);
|
||||
* JSON escapes inside frame literals use `\\uXXXX`.
|
||||
*/
|
||||
class MessageCodecDecodeServerTest {
|
||||
private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
private val esc = Char(0x1B).toString()
|
||||
|
||||
// ─── attached ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes attached and normalizes an uppercase uuid to lowercase`() {
|
||||
// Arrange / Act / Assert
|
||||
assertEquals(
|
||||
ServerMessage.Attached(validUuid),
|
||||
MessageCodec.decodeServer("""{"type":"attached","sessionId":"$validUuid"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ServerMessage.Attached(validUuid),
|
||||
MessageCodec.decodeServer("""{"type":"attached","sessionId":"F47AC10B-58CC-4372-A567-0E02B2C3D479"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── output ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes output with ansi bytes verbatim into data`() {
|
||||
// Arrange / Act
|
||||
val decoded = MessageCodec.decodeServer("""{"type":"output","data":"\u001b[1;32mHello\u001b[0m"}""")
|
||||
|
||||
// Assert
|
||||
assertEquals(ServerMessage.Output(esc + "[1;32mHello" + esc + "[0m"), decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores unknown top-level keys on a server frame`() {
|
||||
// Arrange / Act / Assert — ignoreUnknownKeys tolerance
|
||||
assertEquals(
|
||||
ServerMessage.Output("hi"),
|
||||
MessageCodec.decodeServer("""{"type":"output","data":"hi","extra":"ignored"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── exit ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes exit with code only and with code plus reason`() {
|
||||
// Arrange / Act / Assert
|
||||
assertEquals(ServerMessage.Exit(0, null), MessageCodec.decodeServer("""{"type":"exit","code":0}"""))
|
||||
|
||||
val spawnFail = MessageCodec.decodeServer("""{"type":"exit","code":-1,"reason":"spawn failed: /bin/badshell"}""")
|
||||
assertEquals(ServerMessage.Exit(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn failed: /bin/badshell"), spawnFail)
|
||||
}
|
||||
|
||||
// ─── status ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes every ClaudeStatus with safe defaults for detail pending gate`() {
|
||||
for (raw in listOf("working", "waiting", "idle", "unknown", "stuck")) {
|
||||
// Act
|
||||
val decoded = MessageCodec.decodeServer("""{"type":"status","status":"$raw"}""")
|
||||
|
||||
// Assert
|
||||
val expected = ServerMessage.Status(ClaudeStatus.fromWire(raw)!!, null, false, null)
|
||||
assertEquals(expected, decoded, "status=$raw")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes status carrying detail pending and a tool or plan gate`() {
|
||||
assertEquals(
|
||||
ServerMessage.Status(ClaudeStatus.WAITING, "Bash", true, GateKind.TOOL),
|
||||
MessageCodec.decodeServer(
|
||||
"""{"type":"status","status":"waiting","detail":"Bash","pending":true,"gate":"tool"}""",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
ServerMessage.Status(ClaudeStatus.WAITING, null, true, GateKind.PLAN),
|
||||
MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true,"gate":"plan"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tolerates an unknown gate value and a non-boolean pending`() {
|
||||
// Unknown gate -> null but the pending signal is kept (frame not dropped).
|
||||
assertEquals(
|
||||
ServerMessage.Status(ClaudeStatus.WAITING, null, true, null),
|
||||
MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true,"gate":"alien"}"""),
|
||||
)
|
||||
// Non-boolean pending -> safe default false.
|
||||
assertEquals(
|
||||
ServerMessage.Status(ClaudeStatus.WORKING, null, false, null),
|
||||
MessageCodec.decodeServer("""{"type":"status","status":"working","pending":"yes"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes a full telemetry sample field by field`() {
|
||||
// Arrange
|
||||
val frame =
|
||||
"{\"type\":\"telemetry\",\"telemetry\":{" +
|
||||
"\"contextUsedPct\":42.5,\"costUsd\":1.23," +
|
||||
"\"linesAdded\":10,\"linesRemoved\":2," +
|
||||
"\"model\":\"Fable 5\",\"effort\":\"high\"," +
|
||||
"\"pr\":{\"number\":7,\"url\":\"https://github.com/x/y/pull/7\",\"reviewState\":\"APPROVED\"}," +
|
||||
"\"rate\":{\"fiveHourPct\":12.5,\"sevenDayPct\":33},\"at\":1751600000000}}"
|
||||
|
||||
// Act
|
||||
val decoded = MessageCodec.decodeServer(frame)
|
||||
|
||||
// Assert
|
||||
val expected = ServerMessage.Telemetry(
|
||||
StatusTelemetry(
|
||||
contextUsedPct = 42.5,
|
||||
costUsd = 1.23,
|
||||
linesAdded = 10,
|
||||
linesRemoved = 2,
|
||||
model = "Fable 5",
|
||||
effort = "high",
|
||||
pr = PrInfo(7, "https://github.com/x/y/pull/7", "APPROVED"),
|
||||
rate = RateInfo(12.5, 33.0),
|
||||
at = 1_751_600_000_000L,
|
||||
),
|
||||
)
|
||||
assertEquals(expected, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes telemetry with only the required at field`() {
|
||||
assertEquals(
|
||||
ServerMessage.Telemetry(StatusTelemetry(at = 123L)),
|
||||
MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":123}}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tolerates wrong-typed and unknown optional telemetry fields`() {
|
||||
// costUsd string + pr non-object are treated as absent; the frame is kept.
|
||||
assertEquals(
|
||||
ServerMessage.Telemetry(StatusTelemetry(at = 5L)),
|
||||
MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":5,"costUsd":"lots","pr":42}}"""),
|
||||
)
|
||||
// Unknown nested key ignored.
|
||||
assertEquals(
|
||||
ServerMessage.Telemetry(StatusTelemetry(at = 9L)),
|
||||
MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":9,"mystery":true}}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── invalid frames -> null (never throws) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `drops every malformed server frame as null`() {
|
||||
val invalid = listOf(
|
||||
"not json at all", "", "null", "[]", "42", "true",
|
||||
"""{"type":"ping"}""", """{"data":"hello"}""", """{"type":42}""",
|
||||
// attached: non-uuid / v1 / missing / wrong-typed (M7)
|
||||
"""{"type":"attached","sessionId":"abc123"}""",
|
||||
"""{"type":"attached","sessionId":"550e8400-e29b-11d4-a716-446655440000"}""",
|
||||
"""{"type":"attached"}""",
|
||||
"""{"type":"attached","sessionId":42}""",
|
||||
// output: data missing / wrong-typed
|
||||
"""{"type":"output"}""",
|
||||
"""{"type":"output","data":42}""",
|
||||
// exit: code missing / string / non-integer
|
||||
"""{"type":"exit"}""",
|
||||
"""{"type":"exit","code":"0"}""",
|
||||
"""{"type":"exit","code":1.5}""",
|
||||
// status: status missing / unknown value / wrong-typed
|
||||
"""{"type":"status"}""",
|
||||
"""{"type":"status","status":"sleeping"}""",
|
||||
"""{"type":"status","status":42}""",
|
||||
// telemetry: key missing / non-object / at missing / at wrong-typed
|
||||
"""{"type":"telemetry"}""",
|
||||
"""{"type":"telemetry","telemetry":"x"}""",
|
||||
"""{"type":"telemetry","telemetry":{"costUsd":1}}""",
|
||||
"""{"type":"telemetry","telemetry":{"at":"now"}}""",
|
||||
)
|
||||
|
||||
for (frame in invalid) {
|
||||
assertNull(MessageCodec.decodeServer(frame), "should drop: $frame")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `treats client frame types as non-server frames`() {
|
||||
val clientFrames = listOf(
|
||||
ClientMessage.Attach(null),
|
||||
ClientMessage.Input("x"),
|
||||
ClientMessage.Resize(80, 24),
|
||||
ClientMessage.Approve(ApproveMode.PLAN),
|
||||
ClientMessage.Reject,
|
||||
)
|
||||
for (m in clientFrames) {
|
||||
assertNull(MessageCodec.decodeServer(MessageCodec.encode(m)), "client frame not a server frame: $m")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── fuzz: never throws on hostile input ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `survives random byte fuzz without throwing`() {
|
||||
val rng = Random(0xDEADBEEF)
|
||||
repeat(300) {
|
||||
val len = rng.nextInt(0, 81)
|
||||
val bytes = ByteArray(len) { rng.nextInt(0, 256).toByte() }
|
||||
val text = String(bytes, Charsets.UTF_8)
|
||||
assertDoesNotThrow { MessageCodec.decodeServer(text) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `survives single-byte mutations of valid frames without throwing`() {
|
||||
val rng = Random(0xFEEDFACE)
|
||||
val seeds = listOf(
|
||||
"""{"type":"attached","sessionId":"$validUuid"}""",
|
||||
"""{"type":"output","data":"\u001b[1;32mHello\u001b[0m"}""",
|
||||
"""{"type":"exit","code":-1,"reason":"spawn failed"}""",
|
||||
"""{"type":"status","status":"waiting","pending":true,"gate":"plan"}""",
|
||||
"""{"type":"telemetry","telemetry":{"at":1751600000000,"costUsd":1.5}}""",
|
||||
)
|
||||
for (seed in seeds) {
|
||||
repeat(40) {
|
||||
val bytes = seed.toByteArray(Charsets.UTF_8)
|
||||
bytes[rng.nextInt(0, bytes.size)] = rng.nextInt(0, 256).toByte()
|
||||
assertDoesNotThrow { MessageCodec.decodeServer(String(bytes, Charsets.UTF_8)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A4 golden vectors: [MessageCodec.encode] must be BYTE-IDENTICAL to the web client /
|
||||
* server `JSON.stringify` for every [ClientMessage]. Expected strings transcribed from
|
||||
* `src/protocol.ts` / `public/terminal-session.ts` and cross-checked against the iOS
|
||||
* `CodecRoundtripTests` (same wire). Any drift here is a cross-implementation break.
|
||||
*
|
||||
* Control bytes are written as explicit `\u` escapes (never raw bytes) so the vectors stay
|
||||
* copy-safe.
|
||||
*/
|
||||
class MessageCodecEncodeTest {
|
||||
private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
|
||||
@Test
|
||||
fun `attach without sessionId still carries explicit sessionId null`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = null))
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"attach","sessionId":null}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach with uuid serializes lowercased uuid string`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = validUuid))
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"attach","sessionId":"$validUuid"}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach lowercases an uppercase uuid so the wire is canonical`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(
|
||||
ClientMessage.Attach(sessionId = "F47AC10B-58CC-4372-A567-0E02B2C3D479"),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"attach","sessionId":"$validUuid"}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach cwd key appears only when present, after sessionId`() {
|
||||
// Arrange / Act
|
||||
val withCwd = MessageCodec.encode(ClientMessage.Attach(sessionId = null, cwd = "/Users/dev/proj"))
|
||||
val withoutCwd = MessageCodec.encode(ClientMessage.Attach(sessionId = null))
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"attach","sessionId":null,"cwd":"/Users/dev/proj"}""", withCwd)
|
||||
assertFalse(withoutCwd.contains("cwd"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input passes raw keyboard bytes through, escaping control bytes like JSON stringify`() {
|
||||
// Arrange — Esc / Up / ^C / CR / Tab / Shift+Tab verbatim vector (test/protocol.test.ts)
|
||||
val raw = "\u001B[A\u0003\r\t\u001B[Z"
|
||||
|
||||
// Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Input(raw))
|
||||
|
||||
// Assert — ESC/^C -> \u001b / \u0003, CR -> \r, Tab -> \t
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\u001b[A\\u0003\\r\\t\\u001b[Z\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input escapes quote and backslash and control bytes exactly like JSON stringify`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("hi\r\u001B[A\"\\"))
|
||||
|
||||
// Assert — byte-identical to the JS client's JSON.stringify output
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"hi\\r\\u001b[A\\\"\\\\\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input uses the JSON short escapes for backspace tab newline formfeed carriage-return`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("\u0008\u0009\u000A\u000C\u000D"))
|
||||
|
||||
// Assert
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\b\\t\\n\\f\\r\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input long-form-escapes the other C0 control bytes as lowercase hex`() {
|
||||
// Arrange — NUL / BEL / unit-separator have no short escape
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("\u0000\u0007\u001F"))
|
||||
|
||||
// Assert
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\u0000\\u0007\\u001f\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input leaves ordinary and non-ascii characters unescaped`() {
|
||||
// Arrange / Act — '/' is NOT escaped by JSON.stringify; unicode >= 0x20 passes through
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("a/b 日本語 é"))
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"input","data":"a/b 日本語 é"}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach escapes a sessionId containing a quote like JSON stringify`() {
|
||||
// A canonical id is a UUID, but encode must stay TOTAL + byte-identical to JSON.stringify
|
||||
// for ANY string — defence against JSON injection via an unescaped id (the encodeAttach fix).
|
||||
val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = "x\"y"))
|
||||
|
||||
assertEquals("""{"type":"attach","sessionId":"x\"y"}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach escapes a sessionId containing a backslash like JSON stringify`() {
|
||||
val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = "a\\b"))
|
||||
|
||||
// JSON output is {"type":"attach","sessionId":"a\\b"}
|
||||
assertEquals("{\"type\":\"attach\",\"sessionId\":\"a\\\\b\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input escapes a lone high surrogate as lowercase u-escape like JSON stringify`() {
|
||||
// node: JSON.stringify("\uD800x") === "\"\\ud800x\"" (ES2019 well-formed JSON.stringify)
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("\uD800x"))
|
||||
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\ud800x\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input escapes a lone low surrogate as lowercase u-escape`() {
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("\uDC00"))
|
||||
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\udc00\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input keeps a valid surrogate pair verbatim like JSON stringify`() {
|
||||
// 😀 = U+1F600 = high D83D + low DE00; JSON.stringify emits the astral char unescaped.
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("😀"))
|
||||
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"😀\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resize emits bare integer cols and rows in order`() {
|
||||
// Arrange / Act / Assert
|
||||
assertEquals("""{"type":"resize","cols":120,"rows":40}""", MessageCodec.encode(ClientMessage.Resize(120, 40)))
|
||||
assertEquals("""{"type":"resize","cols":1,"rows":1}""", MessageCodec.encode(ClientMessage.Resize(1, 1)))
|
||||
assertEquals(
|
||||
"""{"type":"resize","cols":1000,"rows":1000}""",
|
||||
MessageCodec.encode(ClientMessage.Resize(1000, 1000)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approve without mode is a bare frame, reject is a bare frame`() {
|
||||
// Arrange / Act / Assert
|
||||
assertEquals("""{"type":"approve"}""", MessageCodec.encode(ClientMessage.Approve()))
|
||||
assertEquals("""{"type":"reject"}""", MessageCodec.encode(ClientMessage.Reject))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approve mode is a top-level key with the server whitelist spelling`() {
|
||||
// Arrange / Act / Assert — every ApproveMode wire value, mode at the top level
|
||||
assertEquals(
|
||||
"""{"type":"approve","mode":"default"}""",
|
||||
MessageCodec.encode(ClientMessage.Approve(ApproveMode.DEFAULT)),
|
||||
)
|
||||
assertEquals(
|
||||
"""{"type":"approve","mode":"acceptEdits"}""",
|
||||
MessageCodec.encode(ClientMessage.Approve(ApproveMode.ACCEPT_EDITS)),
|
||||
)
|
||||
assertEquals(
|
||||
"""{"type":"approve","mode":"plan"}""",
|
||||
MessageCodec.encode(ClientMessage.Approve(ApproveMode.PLAN)),
|
||||
)
|
||||
assertEquals(
|
||||
"""{"type":"approve","mode":"auto"}""",
|
||||
MessageCodec.encode(ClientMessage.Approve(ApproveMode.AUTO)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A4 boundary-validation primitives. Session-id vectors are the M7 UUID-v4 set from
|
||||
* `test/protocol.test.ts` / iOS `ServerVectorTests`; resize/cwd/type-whitelist mirror
|
||||
* `src/protocol.ts`.
|
||||
*/
|
||||
class ValidationTest {
|
||||
// ─── isValidSessionId (SESSION_ID_RE, M7) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `accepts valid uuid v4 including uppercase hex and variant 8`() {
|
||||
assertTrue(Validation.isValidSessionId("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||
assertTrue(Validation.isValidSessionId("F47AC10B-58CC-4372-A567-0E02B2C3D479"))
|
||||
assertTrue(Validation.isValidSessionId("550e8400-e29b-41d4-8716-446655440000"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects non-uuid empty wrong-version wrong-variant and malformed ids`() {
|
||||
val invalid = listOf(
|
||||
"abc123",
|
||||
"",
|
||||
"550e8400-e29b-11d4-a716-446655440000", // version nibble = 1 (v1)
|
||||
"f47ac10b-58cc-4372-c567-0e02b2c3d479", // variant 'c' (not 8/9/a/b)
|
||||
"f47ac10b58cc4372a5670e02b2c3d479", // no separators
|
||||
"f47ac10b-58cc-4372-a567-0e02b2c3d47", // one digit short
|
||||
"g47ac10b-58cc-4372-a567-0e02b2c3d479", // illegal char 'g'
|
||||
)
|
||||
for (candidate in invalid) {
|
||||
assertFalse(Validation.isValidSessionId(candidate), "should reject: $candidate")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exposes the session-id regex for downstream reuse`() {
|
||||
assertTrue(Validation.SESSION_ID_RE.matches("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||
assertFalse(Validation.SESSION_ID_RE.matches("nope"))
|
||||
}
|
||||
|
||||
// ─── isValidResize ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `accepts resize dimensions on the inclusive 1 to 1000 boundary`() {
|
||||
assertTrue(Validation.isValidResize(1, 1))
|
||||
assertTrue(Validation.isValidResize(1000, 1000))
|
||||
assertTrue(Validation.isValidResize(120, 40))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects resize dimensions outside 1 to 1000`() {
|
||||
assertFalse(Validation.isValidResize(0, 40))
|
||||
assertFalse(Validation.isValidResize(1001, 40))
|
||||
assertFalse(Validation.isValidResize(80, 0))
|
||||
assertFalse(Validation.isValidResize(80, 1001))
|
||||
}
|
||||
|
||||
// ─── isAbsoluteCwd ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `accepts absolute cwd and rejects relative or empty`() {
|
||||
assertTrue(Validation.isAbsoluteCwd("/a"))
|
||||
assertFalse(Validation.isAbsoluteCwd("a"))
|
||||
assertFalse(Validation.isAbsoluteCwd(""))
|
||||
}
|
||||
|
||||
// ─── type whitelists ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `whitelists exactly the client frame types`() {
|
||||
for (t in listOf("attach", "input", "resize", "approve", "reject")) {
|
||||
assertTrue(Validation.isAllowedClientType(t), "client type: $t")
|
||||
}
|
||||
for (t in listOf("blur", "ping", "attached", "")) {
|
||||
assertFalse(Validation.isAllowedClientType(t), "not a client type: $t")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whitelists exactly the server frame types`() {
|
||||
for (t in listOf("attached", "output", "exit", "status", "telemetry")) {
|
||||
assertTrue(Validation.isAllowedServerType(t), "server type: $t")
|
||||
}
|
||||
for (t in listOf("input", "blur", "approve", "")) {
|
||||
assertFalse(Validation.isAllowedServerType(t), "not a server type: $t")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user