From 0d927c88aeb65307d7af2728ff4cf902df54bfec Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 14:44:16 +0100 Subject: [PATCH 1/5] Run the UTS through the server door's builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors ably-js#2294: the UTS constructs every client through a single seam (TestRealtimeClient/TestRestClient in ClientFactories.kt), now selected by the uts.side system property — `core` (default) keeps the core constructors; `server` routes both client kinds through PubSubServer's side-stamping builders. The builders only stamp the side-declaring agent flag and pass everything else through (DebugOptions included, via its copy() override), so conformance must be identical on both legs; CI runs both for the UTS unit and integration tiers. A harness self-test (SideModesTest, mirroring ably-js's side_modes.test.ts) asserts each mode's stamp on the wire via the mock HTTP engine — bare versionless flag in server mode, none in core mode — so a broken seam cannot silently degrade the server leg into a duplicate core run. Unlike ably-js there is no device leg: io.ably.pubsub:device is an Android artifact and cannot run on the JVM this suite uses; its stamping contract is covered by the device module's instrumentation tests. Also completes DebugOptions.copy() with the same four fields the base ClientOptions.copy() was missing (headers, fallbackHosts, transportParams, agents) — the door stamping relies on the polymorphic copy() to carry the suite's mock hooks. Co-Authored-By: Claude Fable 5 --- .github/workflows/check.yml | 7 ++ .github/workflows/integration-test.yml | 4 + .../java/io/ably/lib/debug/DebugOptions.java | 4 + uts/README.md | 21 ++++ uts/build.gradle.kts | 13 +++ .../lib/uts/infra/unit/ClientFactories.kt | 40 +++++++- .../io/ably/lib/uts/unit/SideModesTest.kt | 98 +++++++++++++++++++ 7 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4f8396ea9..f2207af4b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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. diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index d8da65d6c..84548e7fd 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -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 diff --git a/lib/src/main/java/io/ably/lib/debug/DebugOptions.java b/lib/src/main/java/io/ably/lib/debug/DebugOptions.java index 43a212009..b3172d83c 100644 --- a/lib/src/main/java/io/ably/lib/debug/DebugOptions.java +++ b/lib/src/main/java/io/ably/lib/debug/DebugOptions.java @@ -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; } } diff --git a/uts/README.md b/uts/README.md index d2867a7eb..de1ed6ec0 100644 --- a/uts/README.md +++ b/uts/README.md @@ -792,6 +792,27 @@ 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`). + 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 diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts index 5b15d625e..3f9f89a02 100644 --- a/uts/build.gradle.kts +++ b/uts/build.gradle.kts @@ -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. @@ -39,6 +42,16 @@ tasks.withType().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("runUtsUnitTests") { diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt index 94a055cdd..7239362e0 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt @@ -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 { @@ -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'") + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt new file mode 100644 index 000000000..a3d348dfc --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt @@ -0,0 +1,98 @@ +package io.ably.lib.uts.unit + +/* + * Harness self-test for the uts.side modes — not a UTS spec translation. + * + * The suite can construct its clients through the core constructors or through the server + * door's builders (see the uts.side handling in ClientFactories.kt). The builders' one + * observable behavior is the side-declaring Ably-Agent entry they stamp, so this file + * asserts that the stamp matches the selected mode. It exists to fail loudly if the seam + * silently degrades — for example if a refactor bypasses the factories and a "server" run + * quietly constructs plain core clients, turning the server CI leg into a duplicate of the + * core leg. + * + * The side entry is a versionless flag — a bare token, per ably/ably-common#361 — so the + * assertions also fail if a `/version` form regresses. Mirrors ably-js's side_modes.test.ts. + */ + +import io.ably.lib.rest.AblyBase +import io.ably.lib.uts.infra.unit.MockHttpClient +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import io.ably.lib.uts.infra.unit.TestRestClient +import io.ably.lib.uts.infra.unit.utsSide +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.Timeout + +@Timeout(30) +class SideModesTest { + + /** + * Builds a client, drives one HTTP request through the mock engine, and returns the + * Ably-Agent header it carried. The response body is irrelevant — only the captured + * request headers matter — so any parse failure in the SDK is swallowed. + */ + private fun agentHeaderFrom(makeClient: (MockHttpClient) -> AblyBase): String { + val captured = mutableListOf>>() + val mock = MockHttpClient { + onConnectionAttempt = { it.respondWithSuccess() } + onRequest = { request -> + captured += request.headers + request.respondWith(200, "[1704067200000]") + } + } + val client = makeClient(mock) + try { + runCatching { client.time() } + } finally { + runCatching { client.close() } + } + + assertTrue(captured.isNotEmpty(), "expected the mock engine to observe a request") + val agent = captured.first().entries + .firstOrNull { it.key.equals("Ably-Agent", ignoreCase = true) } + ?.value?.firstOrNull() + assertNotNull(agent, "expected an Ably-Agent header, got headers: ${captured.first().keys}") + return agent + } + + /** + * What the selected mode must stamp: nothing for `core`; the bare (versionless) server + * flag for `server` — never the device flag, and never any `ably-pubsub-server/...` form. + */ + private fun assertStamp(agent: String) { + val tokens = agent.split(" ") + when (utsSide) { + "core" -> assertFalse( + tokens.any { it.startsWith("ably-pubsub-") }, + "core mode must not stamp a side entry, got: $agent", + ) + "server" -> { + assertTrue(tokens.contains("ably-pubsub-server"), "expected the bare server side flag in: $agent") + assertFalse(agent.contains("ably-pubsub-server/"), "the side flag must be versionless in: $agent") + assertFalse(tokens.any { it.startsWith("ably-pubsub-device") }, "a server client must not carry the device entry: $agent") + } + else -> throw IllegalArgumentException("Unknown uts.side '$utsSide'") + } + assertTrue(agent.contains("ably-java/"), "the core base identifier must always be present in: $agent") + } + + @Test + fun `REST clients carry the agent stamp of the selected entry point`() { + assertStamp(agentHeaderFrom { mock -> TestRestClient { install(mock) } }) + } + + @Test + fun `realtime clients carry the agent stamp of the selected entry point`() { + assertStamp( + agentHeaderFrom { mock -> + TestRealtimeClient { + autoConnect = false + install(mock) + } + }, + ) + } +} From 7bca4207e9d0ac54a68eab945ff33371ffab6ddd Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 15:00:02 +0100 Subject: [PATCH 2/5] Skip token-auth conformance on the server UTS leg (realtime 40167) The server-mode UTS leg surfaced a real platform behavior: realtime rejects a token-authenticated connection that declares the server side through the agent entry alone, with 40167 "a connection or request may only declare itself as a server via a signed x-ably-clientType token claim". The signed-claim mechanism is PDR-091 deferred decision D2 and does not exist yet, so nothing the test infrastructure can mint will authenticate a token-auth server client. Key-auth server clients are unaffected (verified against sandbox), so the two token-auth test classes (TokenRequestTest, AuthReauthTest) now call assumeSideSupportsTokenAuth() and are reported skipped, not failed, on the server leg; the core leg still runs them. When D2 lands, the test infra can mint the claim and the assumption gets deleted. Co-Authored-By: Claude Fable 5 --- uts/README.md | 7 +++++++ .../ably/lib/uts/infra/unit/ClientFactories.kt | 18 ++++++++++++++++++ .../proxy/realtime/AuthReauthTest.kt | 2 ++ .../standard/realtime/TokenRequestTest.kt | 3 +++ 4 files changed, 30 insertions(+) diff --git a/uts/README.md b/uts/README.md index de1ed6ec0..5a4d3e78b 100644 --- a/uts/README.md +++ b/uts/README.md @@ -813,6 +813,13 @@ Unlike ably-js's UTS there is no `device` mode: `io.ably.pubsub:device` is an An 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 tests are skipped on the server leg.** Realtime rejects a token-authenticated +connection that declares the server side through the agent entry alone (error 40167: the side +must come from a signed `x-ably-clientType` token claim — PDR-091 deferred decision D2, not yet +implemented anywhere the test infra can reach). Tests whose clients use token auth call +`assumeSideSupportsTokenAuth()` and are reported as skipped, not failed, in server mode. When D2 +lands, mint the claim in the test infra and delete the assumption. + 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 diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt index 7239362e0..f7f68320d 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt @@ -37,6 +37,24 @@ class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") { */ val utsSide: String = System.getProperty("uts.side").let { if (it.isNullOrEmpty()) "core" else it } +/** + * Call at the top of any test whose client authenticates with a token. + * + * Realtime rejects a token-authenticated connection that declares the server side through the + * agent entry alone: error 40167, "a connection or request may only declare itself as a server + * via a signed x-ably-clientType token claim". The signed-claim mechanism is PDR-091's deferred + * decision D2 and nothing in the test infrastructure can mint such a claim yet, so until D2 + * lands, token-auth conformance runs on the core leg only and is skipped (not failed) on the + * server leg. Key-auth tests are unaffected — on API-key auth the agent entry is the accepted + * declaration. + */ +fun assumeSideSupportsTokenAuth() = org.junit.jupiter.api.Assumptions.assumeTrue( + utsSide == "core", + "token-auth conformance is skipped on the '$utsSide' leg: realtime requires a signed " + + "x-ably-clientType token claim (40167) to declare the server side on token auth, " + + "which the test infrastructure cannot mint until PDR-091 D2 lands", +) + fun TestRealtimeClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime { val options = ClientOptionsBuilder().apply(block) return when (utsSide) { diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt index 613bd3b5a..9009ce02a 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt @@ -9,6 +9,7 @@ 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.assumeSideSupportsTokenAuth import io.ably.lib.uts.infra.unit.TestRealtimeClient import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -55,6 +56,7 @@ class AuthReauthTest { */ @Test fun `RTN22, RTC8a - server-initiated re-authentication`() = runTest { + assumeSideSupportsTokenAuth() // No proxy rules: the AUTH injection is triggered imperatively after the SDK connects. val session = ProxySession.create(rules = emptyList()) diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt index f91d8da07..efd79b219 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt @@ -4,6 +4,7 @@ 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.assumeSideSupportsTokenAuth import io.ably.lib.uts.infra.unit.TestRealtimeClient import io.ably.lib.uts.infra.unit.TestRestClient import kotlinx.coroutines.runBlocking @@ -52,6 +53,7 @@ class TokenRequestTest { */ @Test fun `RSA9a, RSA9g - createTokenRequest produces server-accepted token`() = runTest { + assumeSideSupportsTokenAuth() // Client A signs TokenRequests locally with the API key (no network). val creator = TestRestClient { key = app.defaultKey @@ -85,6 +87,7 @@ class TokenRequestTest { */ @Test fun `RSA9 - createTokenRequest with clientId`() = runTest { + assumeSideSupportsTokenAuth() val testClientId = "token-request-client-" + UUID.randomUUID() val creator = TestRestClient { From e1ba4f393f6e7303f07bb5d143222ec061db5a39 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 15:10:24 +0100 Subject: [PATCH 3/5] Run AuthReauthTest on the server UTS leg via a claim-bearing JWT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UTS spec authenticates this test with a JWT; the Java port had substituted a native TokenRequest for convenience. Restoring the JWT (AblyJwt: HS256 via JDK crypto, no external library) lets the test carry the signed x-ably-clientType=server claim on the server leg — the only server-side declaration realtime accepts on token auth — so it now runs on every leg instead of being skipped. Verified against sandbox: the claim-bearing JWT connects and re-authenticates where the bare agent flag was rejected with 40167. assumeSideSupportsTokenAuth stays for the native-token tests (TokenRequestTest): the native token format cannot carry the claim yet. Also drops DR/ticket numbers from code comments in this PR's files; the behavior is described in place instead. Co-Authored-By: Claude Fable 5 --- .../ably/lib/uts/infra/integration/AblyJwt.kt | 48 +++++++++++++++++++ .../lib/uts/infra/unit/ClientFactories.kt | 16 +++---- .../proxy/realtime/AuthReauthTest.kt | 23 +++++---- .../io/ably/lib/uts/unit/SideModesTest.kt | 5 +- 4 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt new file mode 100644 index 000000000..60d3ed5d7 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt @@ -0,0 +1,48 @@ +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, and JWT-based + * tests can run on the server UTS leg while native-token tests remain skipped (see + * assumeSideSupportsTokenAuth). + */ +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" + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt index f7f68320d..f2a95d4f9 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt @@ -38,21 +38,21 @@ class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") { val utsSide: String = System.getProperty("uts.side").let { if (it.isNullOrEmpty()) "core" else it } /** - * Call at the top of any test whose client authenticates with a token. + * Call at the top of any test whose client authenticates with a native Ably token. * * Realtime rejects a token-authenticated connection that declares the server side through the * agent entry alone: error 40167, "a connection or request may only declare itself as a server - * via a signed x-ably-clientType token claim". The signed-claim mechanism is PDR-091's deferred - * decision D2 and nothing in the test infrastructure can mint such a claim yet, so until D2 - * lands, token-auth conformance runs on the core leg only and is skipped (not failed) on the - * server leg. Key-auth tests are unaffected — on API-key auth the agent entry is the accepted - * declaration. + * via a signed x-ably-clientType token claim". The native Ably token format cannot carry that + * claim yet, so until the platform supports it, native-token conformance runs on the core leg + * only and is skipped (not failed) on the server leg. Key-auth tests are unaffected — on + * API-key auth the agent entry is the accepted declaration — and JWT-based tests can carry the + * claim already (see AblyJwt), so they run on every leg instead of calling this. */ fun assumeSideSupportsTokenAuth() = org.junit.jupiter.api.Assumptions.assumeTrue( utsSide == "core", - "token-auth conformance is skipped on the '$utsSide' leg: realtime requires a signed " + + "native-token conformance is skipped on the '$utsSide' leg: realtime requires a signed " + "x-ably-clientType token claim (40167) to declare the server side on token auth, " + - "which the test infrastructure cannot mint until PDR-091 D2 lands", + "and the native token format cannot carry that claim yet", ) fun TestRealtimeClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime { diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt index 9009ce02a..9d767309b 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt @@ -1,16 +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.assumeSideSupportsTokenAuth 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 @@ -56,19 +56,23 @@ class AuthReauthTest { */ @Test fun `RTN22, RTC8a - server-initiated re-authentication`() = runTest { - assumeSideSupportsTokenAuth() // No proxy rules: the AUTH injection is triggered imperatively after the SDK connects. 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 @@ -130,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() } } } } diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt index a3d348dfc..81a2b0936 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt @@ -11,8 +11,9 @@ package io.ably.lib.uts.unit * quietly constructs plain core clients, turning the server CI leg into a duplicate of the * core leg. * - * The side entry is a versionless flag — a bare token, per ably/ably-common#361 — so the - * assertions also fail if a `/version` form regresses. Mirrors ably-js's side_modes.test.ts. + * The side entry is registered in the ably-common agents registry as a versionless flag — a + * bare token — so the assertions also fail if a `/version` form regresses. Mirrors ably-js's + * side_modes.test.ts. */ import io.ably.lib.rest.AblyBase From 00d1b637b3bf7a17a0a28b8bd41c34b87e9f8aaa Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 15:14:43 +0100 Subject: [PATCH 4/5] Adapt the UTS side-mode assertions to the ably-pubsub-java family identifier The family identifier shares the ably-pubsub- prefix with the side flags, so the harness self-test now matches the side identifiers exactly (via the Side constants) rather than by prefix, and asserts the renamed family entry is always present. Co-Authored-By: Claude Fable 5 --- .../io/ably/lib/uts/unit/SideModesTest.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt index 81a2b0936..409b39c11 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt @@ -21,6 +21,7 @@ import io.ably.lib.uts.infra.unit.MockHttpClient import io.ably.lib.uts.infra.unit.TestRealtimeClient import io.ably.lib.uts.infra.unit.TestRestClient import io.ably.lib.uts.infra.unit.utsSide +import io.ably.pubsub.internal.Side import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -65,19 +66,25 @@ class SideModesTest { */ private fun assertStamp(agent: String) { val tokens = agent.split(" ") + // The family identifier (ably-pubsub-java/) shares the ably-pubsub- prefix + // with the side flags, so the side checks match the exact identifiers, never the prefix. + val sideTokens = tokens.filter { + it == Side.DEVICE_AGENT_IDENTIFIER || it == Side.SERVER_AGENT_IDENTIFIER || + it.startsWith(Side.DEVICE_AGENT_IDENTIFIER + "/") || it.startsWith(Side.SERVER_AGENT_IDENTIFIER + "/") + } when (utsSide) { - "core" -> assertFalse( - tokens.any { it.startsWith("ably-pubsub-") }, + "core" -> assertTrue( + sideTokens.isEmpty(), "core mode must not stamp a side entry, got: $agent", ) "server" -> { - assertTrue(tokens.contains("ably-pubsub-server"), "expected the bare server side flag in: $agent") - assertFalse(agent.contains("ably-pubsub-server/"), "the side flag must be versionless in: $agent") - assertFalse(tokens.any { it.startsWith("ably-pubsub-device") }, "a server client must not carry the device entry: $agent") + assertTrue(tokens.contains(Side.SERVER_AGENT_IDENTIFIER), "expected the bare server side flag in: $agent") + assertFalse(agent.contains(Side.SERVER_AGENT_IDENTIFIER + "/"), "the side flag must be versionless in: $agent") + assertFalse(tokens.any { it.startsWith(Side.DEVICE_AGENT_IDENTIFIER) }, "a server client must not carry the device entry: $agent") } else -> throw IllegalArgumentException("Unknown uts.side '$utsSide'") } - assertTrue(agent.contains("ably-java/"), "the core base identifier must always be present in: $agent") + assertTrue(agent.contains("ably-pubsub-java/"), "the family identifier must always be present in: $agent") } @Test From 91d6930dc11c840494293543a490d3d5e7a841be Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 15:23:26 +0100 Subject: [PATCH 5/5] Run native-token conformance on every UTS leg by splitting the clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TokenRequestTest's two clients now sit on opposite sides of the seam, matching how native tokens are really used: the minting client — the createTokenRequest surface under test — goes through the door on every leg, so the server leg exercises token minting through the server package; the consuming client models the device the token was minted for and is always a plain core client, since a client may not authenticate itself with a native token while declaring the server side (realtime rejects that with 40167, and the native token format cannot carry the required signed claim). Nothing is skipped on any leg any more, so assumeSideSupportsTokenAuth is deleted. Co-Authored-By: Claude Fable 5 --- uts/README.md | 20 ++++++++++++----- .../ably/lib/uts/infra/integration/AblyJwt.kt | 7 +++--- .../lib/uts/infra/unit/ClientFactories.kt | 18 --------------- .../standard/realtime/TokenRequestTest.kt | 22 ++++++++++++++----- 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/uts/README.md b/uts/README.md index 5a4d3e78b..befd4afef 100644 --- a/uts/README.md +++ b/uts/README.md @@ -813,12 +813,20 @@ Unlike ably-js's UTS there is no `device` mode: `io.ably.pubsub:device` is an An 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 tests are skipped on the server leg.** Realtime rejects a token-authenticated -connection that declares the server side through the agent entry alone (error 40167: the side -must come from a signed `x-ably-clientType` token claim — PDR-091 deferred decision D2, not yet -implemented anywhere the test infra can reach). Tests whose clients use token auth call -`assumeSideSupportsTokenAuth()` and are reported as skipped, not failed, in server mode. When D2 -lands, mint the claim in the test infra and delete the assumption. +**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 diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt index 60d3ed5d7..3372e3b53 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt @@ -11,9 +11,10 @@ import javax.crypto.spec.SecretKeySpec * 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, and JWT-based - * tests can run on the server UTS leg while native-token tests remain skipped (see - * assumeSideSupportsTokenAuth). + * 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 { /** diff --git a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt index f2a95d4f9..7239362e0 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt @@ -37,24 +37,6 @@ class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") { */ val utsSide: String = System.getProperty("uts.side").let { if (it.isNullOrEmpty()) "core" else it } -/** - * Call at the top of any test whose client authenticates with a native Ably token. - * - * Realtime rejects a token-authenticated connection that declares the server side through the - * agent entry alone: error 40167, "a connection or request may only declare itself as a server - * via a signed x-ably-clientType token claim". The native Ably token format cannot carry that - * claim yet, so until the platform supports it, native-token conformance runs on the core leg - * only and is skipped (not failed) on the server leg. Key-auth tests are unaffected — on - * API-key auth the agent entry is the accepted declaration — and JWT-based tests can carry the - * claim already (see AblyJwt), so they run on every leg instead of calling this. - */ -fun assumeSideSupportsTokenAuth() = org.junit.jupiter.api.Assumptions.assumeTrue( - utsSide == "core", - "native-token conformance is skipped on the '$utsSide' leg: realtime requires a signed " + - "x-ably-clientType token claim (40167) to declare the server side on token auth, " + - "and the native token format cannot carry that claim yet", -) - fun TestRealtimeClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime { val options = ClientOptionsBuilder().apply(block) return when (utsSide) { diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt index efd79b219..26fbde6b2 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt @@ -1,11 +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.assumeSideSupportsTokenAuth -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 @@ -30,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) @@ -47,13 +55,16 @@ 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 */ @Test fun `RSA9a, RSA9g - createTokenRequest produces server-accepted token`() = runTest { - assumeSideSupportsTokenAuth() // Client A signs TokenRequests locally with the API key (no network). val creator = TestRestClient { key = app.defaultKey @@ -61,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 @@ -87,7 +98,6 @@ class TokenRequestTest { */ @Test fun `RSA9 - createTokenRequest with clientId`() = runTest { - assumeSideSupportsTokenAuth() val testClientId = "token-request-client-" + UUID.randomUUID() val creator = TestRestClient { @@ -97,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)