Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ jobs:
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3
- run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:runUtsUnitTests

# A second UTS leg through the server door's builders (see the uts.side handling in
# uts/.../ClientFactories.kt): the builders stamp a side-declaring agent entry and pass
# everything else through, so conformance must be identical on both legs; SideModesTest
# fails a leg whose stamp does not match. There is no device leg on the JVM — the device
# door is an Android artifact, covered by the instrumentation tests in emulate.yml.
- run: ./gradlew :uts:runUtsUnitTests -Duts.side=server

# Continuously proves the release pre-flight and that every published module
# builds a publishable artifact set, so version/coordinate regressions surface
# on PRs rather than on release day.
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,7 @@ jobs:
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3

- run: ./gradlew :uts:runUtsIntegrationTests

# A second leg through the server door's builders — see the uts.side handling in
# uts/.../ClientFactories.kt and the matching leg in check.yml.
- run: ./gradlew :uts:runUtsIntegrationTests -Duts.side=server
4 changes: 4 additions & 0 deletions lib/src/main/java/io/ably/lib/debug/DebugOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ public DebugOptions copy() {
copied.authParams = authParams;
copied.queryTime = queryTime;
copied.useTokenAuth = useTokenAuth;
copied.headers = headers;
copied.fallbackHosts = fallbackHosts;
copied.transportParams = transportParams;
copied.agents = agents;
return copied;
}
}
36 changes: 36 additions & 0 deletions uts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,42 @@ RUN_DEVIATIONS=1 ./gradlew :uts:runUtsUnitTests --tests "*ConnectionRecoveryTest
`runLiveObjectsUnitTests`); `runUtsIntegrationTests` runs in the `check-uts` job of
`integration-test.yml` (alongside `check-liveobjects`).

### Per-side package modes

The suite constructs its clients through a single seam (`TestRealtimeClient` / `TestRestClient`
in `infra/unit/ClientFactories.kt`), selected by the `uts.side` system property (or the
`UTS_SIDE` environment variable):

```bash
./gradlew :uts:runUtsUnitTests # core (default): the core constructors
./gradlew :uts:runUtsUnitTests -Duts.side=server # io.ably.pubsub:server — the PubSubServer builders
```

The server builders only stamp the side-declaring agent entry (a versionless flag, per
ably/ably-common#361) and pass everything else through — `DebugOptions` included, whose `copy()`
override keeps the mock hooks — so every mode must pass identically. `SideModesTest` asserts each
mode's stamp so a broken seam cannot silently degrade the server leg into a duplicate core run.
CI runs both modes (`check.yml` and `integration-test.yml`).

Unlike ably-js's UTS there is no `device` mode: `io.ably.pubsub:device` is an Android artifact,
so its door cannot run on the JVM this suite uses. Its stamping contract is covered by the
instrumentation tests in the `device` module (`emulate.yml`).

**Token auth on the server leg.** Realtime rejects a token-authenticated connection that
declares the server side through the agent entry alone (error 40167: on token auth the side
must come from a signed `x-ably-clientType` token claim). The suite handles this per token
format, with nothing skipped:

- **JWTs** can carry the claim already: tests that authenticate the client under test with a
token mint one via `AblyJwt` (HS256, JDK crypto), adding `x-ably-clientType=server` on the
server leg (see `AuthReauthTest`).
- **Native tokens** cannot carry the claim yet, so a client may not authenticate *itself* with
one while declaring the server side. `TokenRequestTest` therefore splits its clients across
the seam, matching how the feature is really used: the **minting** client (the
`createTokenRequest` surface under test) goes through the door on every leg, and the
**consuming** client — modelling the device the token was minted for — is always a plain
core client.

Notes:
- `ProxyManager` **advises** running proxy suites single-fork (`maxParallelForks = 1`) because they
share the control port (10100). This is not currently set in `uts/build.gradle.kts`; it isn't
Expand Down
13 changes: 13 additions & 0 deletions uts/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ plugins {

dependencies {
testImplementation(project(":core"))
// The server door package, so the suite can run through its side-stamping builders
// (`-Duts.side=server`) as well as the core constructors. See ClientFactories.kt.
testImplementation(project(":server"))
testImplementation(project(":network-client-core"))
// Runtime-only so compile-time stays decoupled from the plugin internals; the LiveObjects test
// helpers reach the internal wire/message classes (e.g. for build_public_object_message) by reflection.
Expand Down Expand Up @@ -39,6 +42,16 @@ tasks.withType<Test>().configureEach {
.orElse(providers.environmentVariable("UTS_PROXY_LOCAL_PATH"))
.getOrElse(""),
)

// Which package's entry points the suite constructs clients through: `core` (default) or
// `server` (the io.ably.pubsub:server builders). Forwarded explicitly for the same reason
// as uts.proxy.localPath above. See ClientFactories.kt.
systemProperty(
"uts.side",
providers.systemProperty("uts.side")
.orElse(providers.environmentVariable("UTS_SIDE"))
.getOrElse("core"),
)
}

tasks.register<Test>("runUtsUnitTests") {
Expand Down
49 changes: 49 additions & 0 deletions uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package io.ably.lib.uts.infra.integration

import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

/**
* Minimal HS256 Ably JWT signer, built on JDK crypto only (no external JWT library).
*
* Exists for tests that need token claims the native Ably token format cannot carry —
* notably `x-ably-clientType=server`: on token auth the realtime service accepts a
* server-side declaration only from that signed claim (a `-server` agent entry alone is
* rejected with error 40167), and the native token format cannot carry the claim yet. So a
* JWT is the one way a token-authenticated client can declare the server side, which lets
* JWT-based tests run on the server UTS leg. (Native-token tests instead keep their
* token-consuming client on the core constructors, modelling the device the token was minted
* for — see TokenRequestTest.)
*/
object AblyJwt {
/**
* Signs a JWT with the given Ably API key (`keyName:keySecret`), valid for [ttlSeconds],
* with wildcard capability, and the optional Ably claims.
*/
fun sign(
keyStr: String,
clientId: String? = null,
clientType: String? = null,
ttlSeconds: Long = 3600,
): String {
val keyName = keyStr.substringBefore(':')
val keySecret = keyStr.substringAfter(':')
val now = System.currentTimeMillis() / 1000
val header = """{"typ":"JWT","alg":"HS256","kid":"$keyName"}"""
val claims = buildString {
append("""{"iat":$now,"exp":${now + ttlSeconds},"x-ably-capability":"{\"*\":[\"*\"]}"""")
if (clientId != null) append(""","x-ably-clientId":"$clientId"""")
if (clientType != null) append(""","x-ably-clientType":"$clientType"""")
append("}")
}
val enc = Base64.getUrlEncoder().withoutPadding()
val signingInput = enc.encodeToString(header.toByteArray(Charsets.UTF_8)) + "." +
enc.encodeToString(claims.toByteArray(Charsets.UTF_8))
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(keySecret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
}
val signature = enc.encodeToString(mac.doFinal(signingInput.toByteArray(Charsets.UTF_8)))
return "$signingInput.$signature"
}
}
40 changes: 36 additions & 4 deletions uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.ably.lib.uts.infra.unit
import io.ably.lib.debug.DebugOptions
import io.ably.lib.realtime.AblyRealtime
import io.ably.lib.rest.AblyRest
import io.ably.pubsub.server.PubSubServer

class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") {
init {
Expand All @@ -17,8 +18,39 @@ class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") {
}
}

fun TestRealtimeClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime =
AblyRealtime(ClientOptionsBuilder().apply(block))
/**
* Which package's entry points the suite constructs clients through, selected by the
* `uts.side` system property (uts/build.gradle.kts forwards it to the test JVM):
*
* - `core` (default): the core constructors, the entry shape of today's package.
* - `server`: `io.ably.pubsub:server` — both client kinds via its side-stamping builders.
*
* There is no `device` mode, unlike ably-js's UTS: `io.ably.pubsub:device` is an Android
* artifact, so its door cannot run on the JVM this suite uses; its stamping contract is
* covered by the instrumentation tests in the device module instead.
*
* The builders only stamp the side-declaring agent entry and pass every other option
* through — [DebugOptions] included: its `copy()` override keeps the mock hooks the suite
* installs — so conformance must be identical whichever door constructed the client.
* `SideModesTest` asserts each mode's stamp, so a broken seam cannot silently degrade the
* server CI leg into a duplicate of the core leg.
*/
val utsSide: String = System.getProperty("uts.side").let { if (it.isNullOrEmpty()) "core" else it }

fun TestRestClient(block: ClientOptionsBuilder.() -> Unit): AblyRest =
AblyRest(ClientOptionsBuilder().apply(block))
fun TestRealtimeClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime {
val options = ClientOptionsBuilder().apply(block)
return when (utsSide) {
"core" -> AblyRealtime(options)
"server" -> PubSubServer.realtimeClientBuilder(options).build()
else -> throw IllegalArgumentException("Unknown uts.side '$utsSide': use 'core' or 'server'")
}
}

fun TestRestClient(block: ClientOptionsBuilder.() -> Unit): AblyRest {
val options = ClientOptionsBuilder().apply(block)
return when (utsSide) {
"core" -> AblyRest(options)
"server" -> PubSubServer.httpClientBuilder(options).build()
else -> throw IllegalArgumentException("Unknown uts.side '$utsSide': use 'core' or 'server'")
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package io.ably.lib.uts.integration.proxy.realtime

import io.ably.lib.realtime.ConnectionState
import io.ably.lib.rest.AblyRest
import io.ably.lib.rest.Auth
import io.ably.lib.uts.infra.awaitState
import io.ably.lib.uts.infra.integration.AblyJwt
import io.ably.lib.uts.infra.integration.SandboxApp
import io.ably.lib.uts.infra.integration.proxy.ProxyManager
import io.ably.lib.uts.infra.integration.proxy.ProxySession
import io.ably.lib.uts.infra.integration.proxy.connectThroughProxy
import io.ably.lib.uts.infra.pollUntil
import io.ably.lib.uts.infra.unit.TestRealtimeClient
import io.ably.lib.uts.infra.unit.utsSide
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterAll
Expand Down Expand Up @@ -59,14 +60,19 @@ class AuthReauthTest {
val session = ProxySession.create(rules = emptyList())

// Re-authentication is observed via an authCallback. The spec generates a JWT from the
// sandbox key parts; the idiomatic ably-java equivalent is a locally-signed TokenRequest
// produced from the same key — no external JWT library required. The realtime client then
// exchanges it for a token (through the proxy), satisfying RTC8a.
val tokenSigner = AblyRest(app.defaultKey)
// sandbox key parts, and so does this test (AblyJwt: HS256 via JDK crypto, no external
// library). A JWT rather than a native TokenRequest is load-bearing on the server UTS
// leg: a token-authenticated client may declare the server side only via the signed
// x-ably-clientType claim, which the native token format cannot carry yet — so the JWT
// carries the claim on the server leg, and this test runs on every leg.
val authCallbackCount = AtomicInteger(0)
val authCallback = Auth.TokenCallback { params ->
authCallbackCount.incrementAndGet()
tokenSigner.auth.createTokenRequest(params, null)
AblyJwt.sign(
app.defaultKey,
clientId = params.clientId,
clientType = if (utsSide == "server") "server" else null,
)
}

// Keep the JSON protocol (ClientOptionsBuilder default): the proxy injects/inspects frames
Expand Down Expand Up @@ -128,13 +134,12 @@ class AuthReauthTest {
"Expected at least one client-to-server AUTH frame carrying auth details",
)
} finally {
// Nest teardown so session/tokenSigner are always cleaned up even if close-wait times out.
// Nest teardown so the session is always cleaned up even if close-wait times out.
try {
client.close()
awaitState(client, ConnectionState.closed, 10.seconds)
} finally {
session.close()
runCatching { tokenSigner.close() }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package io.ably.lib.uts.integration.standard.realtime

import io.ably.lib.realtime.AblyRealtime
import io.ably.lib.realtime.ConnectionState
import io.ably.lib.rest.Auth
import io.ably.lib.uts.infra.awaitState
import io.ably.lib.uts.infra.integration.SandboxApp
import io.ably.lib.uts.infra.unit.TestRealtimeClient
import io.ably.lib.uts.infra.unit.ClientOptionsBuilder
import io.ably.lib.uts.infra.unit.TestRestClient
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
Expand All @@ -29,6 +30,14 @@ import kotlin.time.Duration.Companion.seconds
* server. A REST client signs the TokenRequest; a separate realtime client exchanges it (through
* its `authCallback`) for a token and connects, proving the server accepted it.
*
* The two clients deliberately sit on different sides of the seam. The **minting** client — the
* RSA9 surface under test — goes through [TestRestClient], so on the server UTS leg it exercises
* `createTokenRequest` through the server door, the shape a real server has: mint native tokens
* for others. The **consuming** client models the device those tokens are minted for, so it is
* always a plain core client: a client may not authenticate *itself* with a native token while
* declaring the server side (realtime rejects that with 40167 — on token auth the side must come
* from the signed x-ably-clientType claim, which the native token format cannot carry).
*
* Spec points: RSA9, RSA9a, RSA9g. Source spec: `realtime/integration/auth/token_request_test.md`.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
Expand All @@ -46,6 +55,10 @@ class TokenRequestTest {
if (::app.isInitialized) app.delete()
}

/** The token-consuming client — a plain core client on every leg; see the class doc. */
private fun tokenConsumingClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime =
AblyRealtime(ClientOptionsBuilder().apply(block))

/**
* @UTS realtime/integration/RSA9a/token-request-server-accepted-0
* @UTS realtime/integration/RSA9g/token-request-server-accepted-0
Expand All @@ -59,7 +72,7 @@ class TokenRequestTest {
}

// Client B connects using a TokenRequest produced by client A.
val client = TestRealtimeClient {
val client = tokenConsumingClient {
authCallback = Auth.TokenCallback { params -> creator.auth.createTokenRequest(params, null) }
realtimeHost = SandboxApp.sandboxHost
restHost = SandboxApp.sandboxHost
Expand Down Expand Up @@ -94,7 +107,7 @@ class TokenRequestTest {

// The TokenRequest is signed with the specific clientId, producing a token that
// authenticates the client with that identity.
val client = TestRealtimeClient {
val client = tokenConsumingClient {
authCallback = Auth.TokenCallback { params ->
params.clientId = testClientId
creator.auth.createTokenRequest(params, null)
Expand Down
Loading
Loading