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..befd4afef 100644 --- a/uts/README.md +++ b/uts/README.md @@ -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 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/integration/AblyJwt.kt b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt new file mode 100644 index 000000000..3372e3b53 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/infra/integration/AblyJwt.kt @@ -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" + } +} 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/integration/proxy/realtime/AuthReauthTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt index 613bd3b5a..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,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 @@ -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 @@ -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() } } } } 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..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,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 @@ -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) @@ -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 @@ -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 @@ -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) 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..409b39c11 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/SideModesTest.kt @@ -0,0 +1,106 @@ +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 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 +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 +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(" ") + // 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" -> assertTrue( + sideTokens.isEmpty(), + "core mode must not stamp a side entry, got: $agent", + ) + "server" -> { + 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-pubsub-java/"), "the family 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) + } + }, + ) + } +}