Skip to content

Commit abcd022

Browse files
authored
Merge pull request #1210 from ably/AIT-767/proxy-support
uts: add `ProxyManager` and `ProxySession` for integration tests
2 parents 0e5e684 + 18f4a68 commit abcd022

7 files changed

Lines changed: 1115 additions & 0 deletions

File tree

.claude/skills/uts-to-kotlin/SKILL.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,12 @@ Map the spec path to a test path:
6565
|---|---|
6666
| `.../uts/test/rest/unit/<name>.md` | `uts/src/test/kotlin/io/ably/lib/rest/unit/<Name>Test.kt` |
6767
| `.../uts/test/realtime/unit/<sub>/<name>.md` | `uts/src/test/kotlin/io/ably/lib/realtime/unit/<sub>/<Name>Test.kt` |
68+
| `.../uts/test/realtime/integration/<sub>/<name>.md` | `uts/src/test/kotlin/io/ably/lib/realtime/integration/<sub>/<Name>Test.kt` |
6869

6970
Class name: take the file name, strip `_test` suffix, convert `snake_case``PascalCase`, append `Test`.
7071

72+
**Integration specs that drive traffic through the programmable proxy** (they reference `create_proxy_session()`, proxy rules, or `uts/test/realtime/integration/helpers/proxy.md`) follow a different translation flow — see the **Proxy integration tests** section at the end of this skill instead of the unit-test rules below.
73+
7174
Example: `connection_state_machine_test.md``ConnectionStateMachineTest`
7275

7376
Package: derived from the output path under `kotlin/`.
@@ -417,3 +420,173 @@ For any place where the generated test diverges from the spec pseudocode (adapte
417420
- [ ] The deviation is recorded in `uts/src/test/kotlin/io/ably/lib/deviations.md`
418421

419422
If you find gaps during this review, fix them and re-run Steps 5–6 before finishing.
423+
424+
---
425+
426+
## Proxy integration tests
427+
428+
Some specs are **integration tests** that exercise fault-handling behaviour against the **real Ably sandbox** instead of a mocked transport. They route the SDK through the [`ably/uts-proxy`](https://github.com/ably/uts-proxy) — a programmable HTTP/WebSocket proxy that forwards traffic transparently by default but can inject faults (dropped connections, modified/injected/delayed frames, error responses) via rules.
429+
430+
Recognise them by: a reference to `create_proxy_session()`, proxy `rules`, `trigger_action`, `get_log`, or a pointer to `uts/test/realtime/integration/helpers/proxy.md`.
431+
432+
### When proxy tests are the right tool
433+
434+
| Test type | When the spec uses it |
435+
|---|---|
436+
| **Unit test** (mock HTTP/WS — the rest of this skill) | Client-side logic, state machines, request formation, error parsing. Fast, deterministic. |
437+
| **Direct sandbox integration** | Happy-path behaviour (connect, publish, subscribe, presence). No fault injection. |
438+
| **Proxy integration test** | Fault behaviour against the real backend: connection failures, resume, heartbeat starvation, token renewal under network errors, channel error injection. |
439+
440+
### Infrastructure
441+
442+
Three helpers live in `uts/src/test/kotlin/io/ably/lib/test/helper/`. **Read them before translating a proxy spec** — they hold the exact method signatures.
443+
444+
- **`ProxyManager`** — downloads/starts the shared `uts-proxy` process and exposes the sandbox host. Call `ProxyManager.ensureProxy()` once per suite in setup. `ProxyManager.sandboxRealtimeHost` / `sandboxRestHost` are the upstream sandbox hosts (the default target of every session).
445+
- **`ProxySession`** — one programmable session wrapping the proxy control API.
446+
- **`SandboxApp`** — provisions/deletes a sandbox test app from the shared `test-app-setup.json` in ably-common. `SandboxApp.create()` returns a `SandboxApp` with `appId`, `defaultKey`, and `keys` (`defaultKey` is a full-capability `appId.keyId:keySecret`); `app.delete()` tears it down. Provision in suite setup, delete in teardown.
447+
448+
`ensureProxy()`, the `ProxySession` methods, and the `SandboxApp` methods are all **`suspend`** functions. Per-test bodies use `runTest { }`; JUnit5 `@BeforeAll`/`@AfterAll` (with `@TestInstance(Lifecycle.PER_CLASS)`) wrap their suspend calls in `runBlocking { }`.
449+
450+
### Test class docstring
451+
452+
Give every proxy integration test class this KDoc:
453+
454+
```kotlin
455+
/**
456+
* Proxy integration test against Ably Sandbox endpoint.
457+
*
458+
* Uses the programmable proxy (`uts/test/proxy/`) to inject transport-level faults while the
459+
* SDK communicates with the real Ably backend. See
460+
* `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure details.
461+
*/
462+
```
463+
464+
### Session lifecycle
465+
466+
`create_proxy_session(endpoint: "nonprod:sandbox", rules: [...])``ProxySession.create(...)`. The sandbox is already the default target, so an empty rule set is just:
467+
468+
```kotlin
469+
val session = ProxySession.create(rules = emptyList())
470+
```
471+
472+
Always close the session (and the client) in a `finally` block:
473+
474+
```kotlin
475+
ProxyManager.ensureProxy()
476+
val session = ProxySession.create(rules = listOf(
477+
wsConnectRule(action = mapOf("type" to "refuse_connection"), count = 2),
478+
))
479+
try {
480+
val client = TestRealtimeClient {
481+
key = sandboxKey
482+
connectThroughProxy(session) // routes realtime + REST through the proxy
483+
autoConnect = false
484+
}
485+
client.connect()
486+
awaitState(client, ConnectionState.connected)
487+
// … scenario …
488+
client.close()
489+
} finally {
490+
session.close()
491+
}
492+
```
493+
494+
| Pseudocode | Kotlin |
495+
|---|---|
496+
| `create_proxy_session(endpoint: "nonprod:sandbox", rules: [...])` | `ProxySession.create(rules = listOf(...))` |
497+
| `session.add_rules(rules, position: "prepend")` | `session.addRules(rules, position = "prepend")` |
498+
| `session.trigger_action({ type: "disconnect" })` | `session.triggerAction(mapOf("type" to "disconnect"))` |
499+
| `session.get_log()` | `session.getLog()` |
500+
| `session.close()` | `session.close()` |
501+
| `session.proxy_port` / `session.proxy_host` | `session.proxyPort` / `session.proxyHost` |
502+
503+
### Connecting through the proxy
504+
505+
Call `connectThroughProxy(session)` inside the client builder block. It is a `ClientOptionsBuilder` extension (in `ProxySession.kt`) that wires the SDK through the proxy:
506+
507+
```kotlin
508+
val client = TestRealtimeClient {
509+
key = sandboxKey
510+
connectThroughProxy(session)
511+
autoConnect = false
512+
}
513+
```
514+
515+
ably-java has **no `endpoint` ClientOptions field**; `connectThroughProxy` sets the discrete host fields for you:
516+
517+
| Proxy-def option | What `connectThroughProxy` sets |
518+
|---|---|
519+
| `endpoint: "localhost"` | `realtimeHost` **and** `restHost` = `session.proxyHost` (`"localhost"`) |
520+
| `port: proxy_port` | `port = session.proxyPort` |
521+
| `tls: false` | `tls = false` |
522+
| `useBinaryProtocol: false` | already the `ClientOptionsBuilder` default — left untouched |
523+
524+
It does **not** touch `autoConnect`, so set that yourself. Setting explicit hosts disables fallback hosts automatically, so don't add `fallbackHosts`.
525+
526+
### Auth in proxy tests
527+
528+
A spec that needs to observe (re-)authentication uses an `authCallback`. Where the pseudocode "generates a JWT from the key parts", the idiomatic ably-java equivalent is a **locally-signed `TokenRequest`** from the same sandbox key — no JWT library required. The realtime client exchanges it for a token through the proxy:
529+
530+
```kotlin
531+
val tokenSigner = AblyRest(app.defaultKey) // local signing only; no network
532+
val authCallbackCount = AtomicInteger(0)
533+
val authCallback = Auth.TokenCallback { params ->
534+
authCallbackCount.incrementAndGet()
535+
tokenSigner.auth.createTokenRequest(params, null)
536+
}
537+
val client = TestRealtimeClient {
538+
this.authCallback = authCallback
539+
connectThroughProxy(session)
540+
autoConnect = false
541+
}
542+
```
543+
544+
`TokenParams`/`TokenRequest` are nested in `io.ably.lib.rest.Auth`; `AuthDetails` is nested in `io.ably.lib.types.ProtocolMessage`.
545+
546+
### Rule factory helpers
547+
548+
Build rules with the factory helpers in `ProxySession.kt` rather than raw map literals. Rules are evaluated in order, first match wins, unmatched traffic passes through, and `times` auto-removes a rule after N firings.
549+
550+
| Match condition | Kotlin |
551+
|---|---|
552+
| `{ "type": "ws_connect", "count": 2 }` | `wsConnectRule(action = ..., count = 2)` |
553+
| `{ "type": "ws_connect", "queryContains": { "resume": "*" } }` | `wsConnectRule(action = ..., queryContains = mapOf("resume" to "*"))` |
554+
| `{ "type": "ws_frame_to_client", "action": "ATTACHED", "channel": "c" }` | `wsFrameToClientRule(action = ..., messageAction = 11, channel = "c")` |
555+
| `{ "type": "ws_frame_to_server", "action": "ATTACH", "channel": "c" }` | `wsFrameToServerRule(action = ..., messageAction = 10, channel = "c")` |
556+
| `{ "type": "http_request", "method": "POST", "pathContains": "/keys/" }` | `httpRequestRule(action = ..., method = "POST", pathContains = "/keys/")` |
557+
558+
`messageAction` is the protocol action **number** (e.g. `4` CONNECTED, `6` DISCONNECTED, `9` ERROR, `10` ATTACH, `11` ATTACHED) — see the action-number table in `proxy.md`.
559+
560+
Actions are passed as `mapOf(...)`, e.g.:
561+
562+
```kotlin
563+
mapOf("type" to "refuse_connection")
564+
mapOf("type" to "disconnect")
565+
mapOf("type" to "suppress")
566+
mapOf("type" to "delay", "delayMs" to 2000)
567+
mapOf("type" to "inject_to_client", "message" to mapOf("action" to 6))
568+
mapOf("type" to "http_respond", "status" to 401, "body" to mapOf(...))
569+
```
570+
571+
### Verifying the event log
572+
573+
`getLog()` returns a typed `List<Event>`. Access fields via dot notation (`it.type`, `it.direction`, `it.queryParams`, `it.status`); numeric fields (`status`, `closeCode`) are already `Int?`. The raw protocol message is exposed as `Event.message` (a Gson `JsonObject?`) — introspect it with `it.message?.get("action")?.asInt`.
574+
575+
```kotlin
576+
val log = session.getLog()
577+
val wsConnects = log.filter { it.type == "ws_connect" }
578+
assertTrue(wsConnects.size >= 2)
579+
val queryParams = wsConnects.first().queryParams
580+
assertNotNull(queryParams["resume"])
581+
```
582+
583+
### Conventions
584+
585+
1. Each test references the spec point and (where it exists) the corresponding unit test.
586+
2. `ProxyManager.ensureProxy()` and sandbox-app provisioning go in `@BeforeAll` / suite setup; clean up in `@AfterAll`.
587+
3. Each test creates its own `ProxySession` and closes it (and the client) in `finally`.
588+
4. Use `awaitState` / `awaitChannelState` for state assertions; verify via SDK state **and** the proxy log where useful.
589+
5. Use generous timeouts (10–30s) — real network is involved: `awaitState(client, ConnectionState.connected, 15.seconds)`.
590+
6. Don't set `fallbackHosts`; explicit hosts already disable fallbacks.
591+
592+
Steps 5 (compile) and 6 (run) still apply. Note that proxy tests hit the live sandbox and download the proxy binary on first run, so they are slower and require network access.

uts/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ dependencies {
1111
testImplementation(libs.mockk)
1212
testImplementation(libs.coroutine.core)
1313
testImplementation(libs.coroutine.test)
14+
testImplementation(libs.ktor.client.core)
15+
testImplementation(libs.ktor.client.cio)
1416
}
1517

1618
tasks.withType<Test>().configureEach {
@@ -22,4 +24,14 @@ tasks.withType<Test>().configureEach {
2224
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
2325
beforeTest(closureOf<TestDescriptor> { logger.lifecycle("-> $this") })
2426
outputs.upToDateWhen { false }
27+
28+
// Gradle does not forward -D system properties to the forked test JVM, so propagate the
29+
// local uts-proxy override explicitly. Accepts either `-Duts.proxy.localPath=...` on the
30+
// Gradle invocation or the `UTS_PROXY_LOCAL_PATH` environment variable. See ProxyManager.
31+
systemProperty(
32+
"uts.proxy.localPath",
33+
providers.systemProperty("uts.proxy.localPath")
34+
.orElse(providers.environmentVariable("UTS_PROXY_LOCAL_PATH"))
35+
.getOrElse(""),
36+
)
2537
}

uts/src/test/kotlin/io/ably/lib/Utils.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import io.ably.lib.realtime.ChannelStateListener
77
import io.ably.lib.realtime.ConnectionState
88
import io.ably.lib.realtime.ConnectionStateListener
99
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.delay
1011
import kotlinx.coroutines.suspendCancellableCoroutine
1112
import kotlinx.coroutines.withContext
1213
import kotlinx.coroutines.withTimeout
1314
import kotlin.coroutines.resume
1415
import kotlin.time.Duration
16+
import kotlin.time.Duration.Companion.milliseconds
1517
import kotlin.time.Duration.Companion.seconds
1618

1719
suspend fun awaitState(
@@ -37,6 +39,26 @@ suspend fun awaitState(
3739
}
3840
}
3941

42+
/**
43+
* Suspends until [condition] returns `true`, polling every [interval], or fails with a
44+
* [kotlinx.coroutines.TimeoutCancellationException] once [timeout] elapses.
45+
*
46+
* Runs on a real-thread dispatcher so [timeout] measures wall-clock time (not virtual
47+
* `kotlinx.coroutines.test` time) — use this for integration tests that wait on real network or
48+
* proxy state, e.g. `pollUntil { authCallbackCount.get() > original }`.
49+
*/
50+
suspend fun pollUntil(
51+
timeout: Duration = 15.seconds,
52+
interval: Duration = 100.milliseconds,
53+
condition: suspend () -> Boolean,
54+
) {
55+
withContext(Dispatchers.Default.limitedParallelism(1)) {
56+
withTimeout(timeout) {
57+
while (!condition()) delay(interval)
58+
}
59+
}
60+
}
61+
4062
suspend fun awaitChannelState(
4163
channel: Channel,
4264
target: ChannelState,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package io.ably.lib.realtime.integration.proxy
2+
3+
import io.ably.lib.awaitState
4+
import io.ably.lib.pollUntil
5+
import io.ably.lib.realtime.ConnectionState
6+
import io.ably.lib.rest.AblyRest
7+
import io.ably.lib.rest.Auth
8+
import io.ably.lib.test.helper.ProxyManager
9+
import io.ably.lib.test.helper.ProxySession
10+
import io.ably.lib.test.helper.SandboxApp
11+
import io.ably.lib.test.helper.connectThroughProxy
12+
import io.ably.lib.uts.infra.TestRealtimeClient
13+
import kotlinx.coroutines.runBlocking
14+
import kotlinx.coroutines.test.runTest
15+
import org.junit.jupiter.api.AfterAll
16+
import org.junit.jupiter.api.BeforeAll
17+
import org.junit.jupiter.api.Test
18+
import org.junit.jupiter.api.TestInstance
19+
import java.util.Collections
20+
import java.util.concurrent.atomic.AtomicInteger
21+
import kotlin.test.assertEquals
22+
import kotlin.test.assertNotNull
23+
import kotlin.test.assertTrue
24+
import kotlin.time.Duration.Companion.seconds
25+
26+
/**
27+
* Proxy integration test against Ably Sandbox endpoint.
28+
*
29+
* Uses the programmable proxy (`uts/test/proxy/`) to inject transport-level faults while the
30+
* SDK communicates with the real Ably backend. See
31+
* `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure details.
32+
*
33+
* Spec points: RTN22, RTC8a.
34+
* Unit-test counterparts: `server_initiated_reauth_test.md` (RTN22), `realtime_authorize.md` (RTC8a).
35+
*/
36+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
37+
class AuthReauthTest {
38+
39+
private lateinit var app: SandboxApp
40+
41+
@BeforeAll
42+
fun setUpAll() = runBlocking {
43+
ProxyManager.ensureProxy()
44+
app = SandboxApp.create()
45+
}
46+
47+
@AfterAll
48+
fun tearDownAll() = runBlocking {
49+
if (::app.isInitialized) app.delete()
50+
}
51+
52+
/**
53+
* @UTS realtime/proxy/RTN22/server-initiated-reauth-0
54+
* @UTS realtime/proxy/RTC8a/server-initiated-reauth-0
55+
*/
56+
@Test
57+
fun `RTN22, RTC8a - server-initiated re-authentication`() = runTest {
58+
// No proxy rules: the AUTH injection is triggered imperatively after the SDK connects.
59+
val session = ProxySession.create(rules = emptyList())
60+
61+
// Re-authentication is observed via an authCallback. The spec generates a JWT from the
62+
// sandbox key parts; the idiomatic ably-java equivalent is a locally-signed TokenRequest
63+
// produced from the same key — no external JWT library required. The realtime client then
64+
// exchanges it for a token (through the proxy), satisfying RTC8a.
65+
val tokenSigner = AblyRest(app.defaultKey)
66+
val authCallbackCount = AtomicInteger(0)
67+
val authCallback = Auth.TokenCallback { params ->
68+
authCallbackCount.incrementAndGet()
69+
tokenSigner.auth.createTokenRequest(params, null)
70+
}
71+
72+
// Keep the JSON protocol (ClientOptionsBuilder default): the proxy injects/inspects frames
73+
// as JSON, so the assertions below read `message.get("action")` from the proxy log.
74+
val client = TestRealtimeClient {
75+
this.authCallback = authCallback
76+
connectThroughProxy(session)
77+
autoConnect = false
78+
}
79+
80+
try {
81+
// Connect through proxy
82+
client.connect()
83+
awaitState(client, ConnectionState.connected, 15.seconds)
84+
85+
// Record identity and auth state before injection
86+
val originalConnectionId = client.connection.id
87+
val originalAuthCallbackCount = authCallbackCount.get()
88+
assertNotNull(originalConnectionId)
89+
assertTrue(originalAuthCallbackCount >= 1)
90+
91+
// Record state changes from this point
92+
val stateChanges = Collections.synchronizedList(mutableListOf<ConnectionState>())
93+
client.connection.on { change -> stateChanges.add(change.current) }
94+
95+
// Inject a server-initiated AUTH ProtocolMessage (action 17), simulating Ably
96+
// requesting re-authentication.
97+
session.triggerAction(
98+
mapOf("type" to "inject_to_client", "message" to mapOf("action" to 17)),
99+
)
100+
101+
// Wait for the SDK to invoke authCallback again and send its AUTH response.
102+
// Allow time for the token request round-trip to the sandbox.
103+
pollUntil { stateChanges.size > 1 }
104+
105+
// authCallback was called again (re-authentication triggered)
106+
assertEquals(originalAuthCallbackCount + 1, authCallbackCount.get())
107+
108+
// Connection remains CONNECTED (re-auth does not disrupt the connection)
109+
assertEquals(ConnectionState.connected, client.connection.state)
110+
111+
// Connection ID is unchanged (no reconnection occurred)
112+
assertEquals(originalConnectionId, client.connection.id)
113+
114+
// No state transitions away from CONNECTED occurred
115+
val nonConnectedChanges = stateChanges.filter { it != ConnectionState.connected }
116+
assertEquals(0, nonConnectedChanges.size)
117+
118+
// RTC8a: the client sends an AUTH (action 17) frame carrying the renewed auth details.
119+
val clientAuthFrames = session.getLog().filter {
120+
it.type == "ws_frame" &&
121+
it.direction == "client_to_server" &&
122+
it.message?.get("action")?.asInt == 17 &&
123+
it.message.get("auth")?.isJsonNull == false
124+
}
125+
126+
assertTrue(
127+
clientAuthFrames.isNotEmpty(),
128+
"Expected at least one client-to-server AUTH frame carrying auth details",
129+
)
130+
} finally {
131+
// Nest teardown so session/tokenSigner are always cleaned up even if close-wait times out.
132+
try {
133+
client.close()
134+
awaitState(client, ConnectionState.closed, 10.seconds)
135+
} finally {
136+
session.close()
137+
runCatching { tokenSigner.close() }
138+
}
139+
}
140+
}
141+
}

0 commit comments

Comments
 (0)