diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a2fe3a2bb..f2207af4b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -24,3 +24,30 @@ jobs: - name: Set up Gradle 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. + release-dry-run: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 + with: + persist-credentials: false + - name: Set up the JDK + uses: actions/setup-java@17f84c3641ba7b8f6deff6309fc4c864478f5d62 # v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 + - run: ./gradlew verifyReleaseArtifacts publishToMavenLocal diff --git a/.github/workflows/emulate.yml b/.github/workflows/emulate.yml index 304ec070d..836ab86f2 100644 --- a/.github/workflows/emulate.yml +++ b/.github/workflows/emulate.yml @@ -50,7 +50,7 @@ jobs: arch: ${{ steps.get-avd-arch.outputs.arch }} target: default # Print emulator logs if tests fail - script: ./gradlew :core-android:connectedAndroidTest ${{ matrix.android-api-level == 19 && '-PhttpURLConnection' || '' }} || (adb logcat -d System.out:I && exit 1) + script: ./gradlew :core-android:connectedAndroidTest :device:connectedAndroidTest ${{ matrix.android-api-level == 19 && '-PhttpURLConnection' || '' }} || (adb logcat -d System.out:I && exit 1) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() 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/.github/workflows/release.yaml b/.github/workflows/release.yaml index c55cc27a9..1d81892c7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -46,6 +46,12 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 + # Fails before anything is uploaded if the artifact set, group or lockstep + # version drifts (core, core-android, device and server release + # together on the same version; partial release must be impossible). + - name: Release pre-flight + run: ./gradlew verifyReleaseArtifacts + - name: Publish and release to Maven Central run: ./gradlew publishAndReleaseToMavenCentral env: diff --git a/README.md b/README.md index a16465305..2a0449358 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,23 @@ Find out more: --- +> [!NOTE] +> **This branch carries the in-development 2.0 device/server package split.** The SDK is being +> restructured into new artifacts that declare which side of the network they run on, so that +> traffic classifies correctly on MAU-priced accounts: +> +> | Artifact | For | Entry point | +> |----------|-----|-------------| +> | `io.ably.pubsub:device` (aar) | Devices: Android apps and other end-user runtimes | `PubSubDevice.clientBuilder(...)` | +> | `io.ably.pubsub:server` (jar) | Servers and other trusted backend environments | `PubSubServer.httpClientBuilder(...)` / `PubSubServer.realtimeClientBuilder(...)` | +> | `io.ably.pubsub:core`, `io.ably.pubsub:core-android` | Internal implementation artifacts — do not depend on these directly | — | +> +> Nothing from this branch is published yet. The `io.ably:ably-java` and `io.ably:ably-android` +> 1.x artifacts continue to work and will receive security and critical fixes from a maintenance +> branch for one year after the 2.0 release. The installation instructions below still describe 1.x. + +--- + ## Getting started Everything you need to get started with Ably: diff --git a/build.gradle.kts b/build.gradle.kts index a98b165b6..b904815de 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,56 @@ subprojects { } } +/* + * Release pre-flight: the split ships core, core-android, device and server in lockstep + * (one version, one run), so the set of published artifacts and their + * coordinates are asserted here and the release workflow fails before anything is + * uploaded if they drift. If you add or remove a published module, update this list + * deliberately. + */ +val expectedReleaseArtifacts = sortedSetOf( + "io.ably.pubsub:core:jar", + "io.ably.pubsub:core-android:aar", + "io.ably.pubsub:device:aar", + "io.ably.pubsub:server:jar", + "io.ably.pubsub:liveobjects:jar", + "io.ably.pubsub:pubsub-adapter:jar", + "io.ably.pubsub:network-client-core:jar", + "io.ably.pubsub:network-client-default:jar", + "io.ably.pubsub:network-client-okhttp:jar", +) + +tasks.register("verifyReleaseArtifacts") { + description = "Asserts the published artifact set, group and lockstep version before a release." + doLast { + val rootVersion = project.property("VERSION_NAME") as String + val actual = sortedSetOf() + subprojects.filter { it.pluginManager.hasPlugin("com.vanniktech.maven.publish") }.forEach { p -> + val artifactId = p.findProperty("POM_ARTIFACT_ID") + ?: error("${p.path} applies maven-publish but has no POM_ARTIFACT_ID") + val packaging = p.findProperty("POM_PACKAGING") ?: "jar" + // The version each module publishes at comes from its effective VERSION_NAME + // (a module-local gradle.properties can override the root's — exactly the + // lockstep drift this guards against). + val moduleVersion = p.findProperty("VERSION_NAME") + if (moduleVersion != rootVersion) { + error("Lockstep violation: ${p.path} has VERSION_NAME $moduleVersion, expected $rootVersion") + } + val group = p.findProperty("GROUP") + actual.add("$group:$artifactId:$packaging") + } + if (actual != expectedReleaseArtifacts) { + error( + "Published artifact set does not match the expected release set.\n" + + " expected: $expectedReleaseArtifacts\n" + + " actual: $actual\n" + + "If this change is deliberate, update expectedReleaseArtifacts in build.gradle.kts." + ) + } + logger.lifecycle("Release pre-flight OK: ${actual.size} artifacts at $rootVersion: $actual") + } +} + configure(subprojects) { pluginManager.withPlugin("com.vanniktech.maven.publish") { extensions.configure { diff --git a/device/build.gradle.kts b/device/build.gradle.kts new file mode 100644 index 000000000..5418c02d1 --- /dev/null +++ b/device/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.maven.publish) +} + +android { + namespace = "io.ably.pubsub.device" + defaultConfig { + minSdk = 19 + compileSdk = 34 + testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + lint { + abortOnError = false + } + + testOptions.targetSdk = 34 + + sourceSets { + getByName("main") { + // `../shared` holds the side-agent helper shared with the `server` module; it is + // compiled into each door artifact rather than published as an artifact of its own. + java.srcDirs("src/main/java", "../shared/src/main/java") + } + } +} + +dependencies { + api(project(":core-android")) + androidTestImplementation(libs.bundles.instrumental.android) +} + +configurations { + all { + exclude(group = "org.hamcrest", module = "hamcrest-core") + resolutionStrategy { + force(libs.jetbrains) + } + } +} diff --git a/device/gradle.properties b/device/gradle.properties new file mode 100644 index 000000000..1be8fb312 --- /dev/null +++ b/device/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=device +POM_NAME=Ably Pub/Sub device SDK +POM_DESCRIPTION=Ably Pub/Sub client for devices: Android apps and other end-user runtimes. The recommended entry point is PubSubDevice.clientBuilder(...). +POM_PACKAGING=aar diff --git a/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java new file mode 100644 index 000000000..9b280f19b --- /dev/null +++ b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java @@ -0,0 +1,72 @@ +package io.ably.pubsub.device; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +/** + * The agent entries asserted here are what the platform reads to classify traffic on + * MAU-priced accounts, so these tests are deliberately strict: if one fails, billing + * classification is broken, not just a header. + *

+ * The side entry is a versionless flag — a bare token on the wire, registered as such in the ably-common agents registry + * — so the assertions also fail if a version (or any {@code /suffix}) reappears on it. + */ +public class PubSubDeviceTest { + + private static final String FAKE_KEY = "fakeAppId.fakeKeyId:fakeKeySecret"; + + private static ClientOptions offlineOptions(String key) throws Exception { + ClientOptions options = new ClientOptions(key); + options.autoConnect = false; + return options; + } + + /** The stamped entry is present as a versionless flag, and the other side's is absent. */ + private static void assertDeviceFlag(Map agents) { + assertTrue("expected the device side flag", agents.containsKey(Side.DEVICE_AGENT_IDENTIFIER)); + assertNull("the side flag is versionless", agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + assertFalse("a device client must not carry the server entry", + agents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void client_stampsDeviceAgent() throws Exception { + AblyRealtime client = PubSubDevice.clientBuilder(offlineOptions(FAKE_KEY)).build(); + assertDeviceFlag(client.options.agents); + } + + @Test + public void keyString_isAcceptedAndDisambiguatedAsKey() throws Exception { + ClientOptions builtOptions = PubSubDevice.clientBuilder(FAKE_KEY).build().options; + assertEquals(FAKE_KEY, builtOptions.key); + assertNull(builtOptions.token); + assertDeviceFlag(builtOptions.agents); + } + + @Test + public void callerAgentEntries_arePreserved_andCannotOverrideTheSideEntry() throws Exception { + ClientOptions options = offlineOptions(FAKE_KEY); + Map callerAgents = new HashMap<>(); + callerAgents.put("some-sdk", "1.2.3"); + callerAgents.put(Side.DEVICE_AGENT_IDENTIFIER, "not-the-real-form"); + options.agents = callerAgents; + + AblyRealtime client = PubSubDevice.clientBuilder(options).build(); + assertEquals("1.2.3", client.options.agents.get("some-sdk")); + // The stamp replaces the caller's value: the flag is present and back to versionless. + assertDeviceFlag(client.options.agents); + + // the caller's own map is untouched + assertTrue(options.agents == callerAgents); + assertEquals("not-the-real-form", callerAgents.get(Side.DEVICE_AGENT_IDENTIFIER)); + } +} diff --git a/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java new file mode 100644 index 000000000..c838ee19e --- /dev/null +++ b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java @@ -0,0 +1,75 @@ +package io.ably.pubsub.device; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; + +/** + * The door into Ably Pub/Sub for devices: Android apps and other end-user runtimes. + *

+ * Clients built here declare themselves device-side to Ably: every connection and request + * they make carries the {@code ably-pubsub-device} agent entry, which is how the platform + * classifies the traffic (on MAU-priced accounts, device traffic is what is counted). The + * side is the package's to declare — a caller-supplied agent entry cannot override it. + *

+ * There is one door: a device holds one live client. Connectionless operations (history, + * presence reads, token requests) are all available on it. + *

+ * This builder is the only recommended entry point of this artifact; the classes it + * constructs come from {@code io.ably.pubsub:core-android}, which is an internal + * implementation artifact not intended for direct use. + */ +public final class PubSubDevice { + private PubSubDevice() {} + + /** + * Returns a builder for the device's client. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static ClientBuilder clientBuilder(ClientOptions options) { + return new ClientBuilder(options, null); + } + + /** + * Returns a builder for the device's client. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static ClientBuilder clientBuilder(String keyOrToken) { + return new ClientBuilder(null, keyOrToken); + } + + /** + * Builds the device client. Accepts everything the core constructor accepts. + */ + public static final class ClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private ClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the device side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRealtime build() throws AblyException { + // The side entry is a versionless flag — see Side. + final ClientOptions stamped; + if (keyOrToken != null) { + stamped = Side.optionsWithSideAgent(keyOrToken, Side.DEVICE_AGENT_IDENTIFIER); + } else { + stamped = Side.optionsWithSideAgent(options, Side.DEVICE_AGENT_IDENTIFIER); + } + return new AblyRealtime(stamped); + } + } +} 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/lib/src/main/java/io/ably/lib/transport/Defaults.java b/lib/src/main/java/io/ably/lib/transport/Defaults.java index 66e3e897c..8844bff11 100644 --- a/lib/src/main/java/io/ably/lib/transport/Defaults.java +++ b/lib/src/main/java/io/ably/lib/transport/Defaults.java @@ -14,7 +14,14 @@ public class Defaults { */ public static final String ABLY_PROTOCOL_VERSION = "6"; - public static final String ABLY_AGENT_VERSION = String.format("%s/%s", "ably-java", BuildConfig.VERSION); + /** + * The SDK family identifier. It renamed from {@code ably-java} with the per-side package + * split, so the identifier alone partitions the fleet: {@code ably-java/*} is legacy-package + * traffic, {@code ably-pubsub-java/*} is new-package traffic. It names the family rather than + * any one published artifact; the side a client declares travels as a separate versionless + * agent entry (see io.ably.pubsub.internal.Side and the agents registry in ably-common). + */ + public static final String ABLY_AGENT_VERSION = String.format("%s/%s", "ably-pubsub-java", BuildConfig.VERSION); /* realtime params */ public static final String ABLY_PROTOCOL_VERSION_PARAM = "v"; diff --git a/lib/src/main/java/io/ably/lib/types/ClientOptions.java b/lib/src/main/java/io/ably/lib/types/ClientOptions.java index 3d63be81a..f1f88ded1 100644 --- a/lib/src/main/java/io/ably/lib/types/ClientOptions.java +++ b/lib/src/main/java/io/ably/lib/types/ClientOptions.java @@ -372,6 +372,10 @@ public ClientOptions 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/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java index 258c21368..88d280dc9 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java @@ -88,7 +88,7 @@ public void realtime_websocket_param_test() { * Defaults.ABLY_AGENT_PARAM, as ultimately the request param has been derived from those values. */ assertEquals("Verify correct lib version", requestParameters.get("agent"), - Collections.singletonList("ably-java/2.0.0 jre/" + System.getProperty("java.version"))); + Collections.singletonList("ably-pubsub-java/2.0.0 jre/" + System.getProperty("java.version"))); /* Spec RTN2a */ assertEquals("Verify correct format", requestParameters.get("format"), diff --git a/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java b/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java index 3d482d95d..34f873e48 100644 --- a/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java +++ b/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java @@ -1,7 +1,11 @@ package io.ably.lib.types; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import java.util.HashMap; + import org.junit.Test; public class ClientOptionsTest { @@ -13,4 +17,24 @@ public void should_support_idempotent_rest_publishing() { // Then assertTrue(clientOptions.idempotentRestPublishing); } + + @Test + public void copy_carries_headers_fallbackHosts_transportParams_and_agents() { + // Given + clientOptions.headers = new HashMap<>(); + clientOptions.headers.put("X-Custom", "value"); + clientOptions.fallbackHosts = new String[]{"a.example.com", "b.example.com"}; + clientOptions.transportParams = new Param[]{new Param("remainPresentFor", "1000")}; + clientOptions.agents = new HashMap<>(); + clientOptions.agents.put("some-sdk", "1.2.3"); + + // When + ClientOptions copied = clientOptions.copy(); + + // Then + assertSame(clientOptions.headers, copied.headers); + assertArrayEquals(clientOptions.fallbackHosts, copied.fallbackHosts); + assertSame(clientOptions.transportParams, copied.transportParams); + assertSame(clientOptions.agents, copied.agents); + } } diff --git a/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt index d91359b8c..a9f44104b 100644 --- a/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt +++ b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt @@ -27,7 +27,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -35,7 +35,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { realtimeClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -43,7 +43,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.request("/time") assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -59,7 +59,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -67,7 +67,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { restClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -75,7 +75,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.request("/time") assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -91,7 +91,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -99,7 +99,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { restClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -107,7 +107,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").presence.history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -123,7 +123,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -131,7 +131,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { realtimeClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -139,7 +139,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").presence.history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 000000000..00e35300b --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.maven.publish) + checkstyle + `java-library` +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +dependencies { + api(project(":core")) + testImplementation(libs.bundles.tests) +} + +sourceSets { + named("main") { + java { + // `../shared` holds the side-agent helper shared with the `device` module; it is + // compiled into each door artifact rather than published as an artifact of its own. + srcDirs("src/main/java", "../shared/src/main/java") + } + } +} + +tasks.register("runUnitTests") { + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } +} diff --git a/server/gradle.properties b/server/gradle.properties new file mode 100644 index 000000000..8aa9715ce --- /dev/null +++ b/server/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=server +POM_NAME=Ably Pub/Sub server SDK +POM_DESCRIPTION=Ably Pub/Sub client for servers and other trusted backend environments. The recommended entry points are PubSubServer.httpClientBuilder(...) and PubSubServer.realtimeClientBuilder(...). +POM_PACKAGING=jar diff --git a/server/src/main/java/io/ably/pubsub/server/PubSubServer.java b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java new file mode 100644 index 000000000..ec310161b --- /dev/null +++ b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java @@ -0,0 +1,123 @@ +package io.ably.pubsub.server; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; + +/** + * The door into Ably Pub/Sub for servers and other trusted backend environments. + *

+ * Clients built here declare themselves server-side to Ably: every connection and request + * they make carries the {@code ably-pubsub-server} agent entry, which is how the platform + * classifies the traffic (and, on MAU-priced accounts using API-key auth, how it earns the + * server exemption). The side is the package's to declare — a caller-supplied agent entry + * cannot override it. + *

+ * These builders are the only recommended entry points of this artifact; the classes they + * construct come from {@code io.ably.pubsub:core}, which is an internal implementation + * artifact not intended for direct use. + */ +public final class PubSubServer { + private PubSubServer() {} + + /** + * Returns a builder for a stateless client that interacts with Ably over HTTP. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static HttpClientBuilder httpClientBuilder(ClientOptions options) { + return new HttpClientBuilder(options, null); + } + + /** + * Returns a builder for a stateless client that interacts with Ably over HTTP. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static HttpClientBuilder httpClientBuilder(String keyOrToken) { + return new HttpClientBuilder(null, keyOrToken); + } + + /** + * Returns a builder for a stateful client that maintains a live connection to Ably. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static RealtimeClientBuilder realtimeClientBuilder(ClientOptions options) { + return new RealtimeClientBuilder(options, null); + } + + /** + * Returns a builder for a stateful client that maintains a live connection to Ably. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static RealtimeClientBuilder realtimeClientBuilder(String keyOrToken) { + return new RealtimeClientBuilder(null, keyOrToken); + } + + /** + * Resolves the caller's input exactly as the core constructors would, then stamps the + * server-side agent entry (a versionless flag — see {@link Side}). Resolution happens + * at {@code build()} time so the caller's input is read once, when the client is + * constructed. + */ + private static ClientOptions stampedOptions(ClientOptions options, String keyOrToken) throws AblyException { + if (keyOrToken != null) { + return Side.optionsWithSideAgent(keyOrToken, Side.SERVER_AGENT_IDENTIFIER); + } + return Side.optionsWithSideAgent(options, Side.SERVER_AGENT_IDENTIFIER); + } + + /** + * Builds the HTTP (REST) client. Accepts everything the core constructor accepts. + */ + public static final class HttpClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private HttpClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the server side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRest build() throws AblyException { + return new AblyRest(stampedOptions(options, keyOrToken)); + } + } + + /** + * Builds the realtime client. Accepts everything the core constructor accepts. + */ + public static final class RealtimeClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private RealtimeClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the server side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRealtime build() throws AblyException { + return new AblyRealtime(stampedOptions(options, keyOrToken)); + } + } +} diff --git a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java new file mode 100644 index 000000000..ee4a8d4eb --- /dev/null +++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java @@ -0,0 +1,163 @@ +package io.ably.pubsub.server; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.sun.net.httpserver.HttpServer; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; + +/** + * The agent entries asserted here are what the platform reads to classify traffic (and, on + * MAU-priced accounts, what earns the server exemption), so these tests are deliberately + * strict: if one fails, billing classification is broken, not just a header. + *

+ * The side entry is a versionless flag — a bare token on the wire, registered as such in the ably-common agents registry + * — so the assertions also fail if a version (or any {@code /suffix}) reappears on it. + */ +public class PubSubServerTest { + + private static final String FAKE_KEY = "fakeAppId.fakeKeyId:fakeKeySecret"; + private static final String FAKE_TOKEN = "fakeTokenString"; + + private static ClientOptions offlineOptions(String key) throws AblyException { + ClientOptions options = new ClientOptions(key); + options.autoConnect = false; + return options; + } + + /** The stamped entry is present as a versionless flag, and the other side's is absent. */ + private static void assertServerFlag(Map agents) { + assertTrue("expected the server side flag", agents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + assertNull("the side flag is versionless", agents.get(Side.SERVER_AGENT_IDENTIFIER)); + assertFalse("a server client must not carry the device entry", + agents.containsKey(Side.DEVICE_AGENT_IDENTIFIER)); + } + + @Test + public void httpClient_stampsServerAgent() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(offlineOptions(FAKE_KEY)).build(); + assertServerFlag(client.options.agents); + } + + @Test + public void realtimeClient_stampsServerAgent() throws AblyException { + AblyRealtime client = PubSubServer.realtimeClientBuilder(offlineOptions(FAKE_KEY)).build(); + assertServerFlag(client.options.agents); + } + + @Test + public void keyString_isAcceptedAndDisambiguatedAsKey() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(FAKE_KEY).build(); + assertEquals(FAKE_KEY, client.options.key); + assertNull(client.options.token); + assertServerFlag(client.options.agents); + } + + @Test + public void tokenString_isAcceptedAndDisambiguatedAsToken() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(FAKE_TOKEN).build(); + assertEquals(FAKE_TOKEN, client.options.token); + assertNull(client.options.key); + assertServerFlag(client.options.agents); + } + + @Test + public void callerAgentEntries_arePreserved() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + options.agents = new HashMap<>(); + options.agents.put("some-sdk", "1.2.3"); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + assertEquals("1.2.3", client.options.agents.get("some-sdk")); + assertServerFlag(client.options.agents); + } + + @Test + public void callerCannotOverrideTheSideEntry() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + options.agents = new HashMap<>(); + options.agents.put(Side.SERVER_AGENT_IDENTIFIER, "not-the-real-form"); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + // The stamp replaces the caller's value: the flag is present and back to versionless. + assertServerFlag(client.options.agents); + } + + @Test + public void callersOptionsObject_isNotMutated() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + Map callerAgents = new HashMap<>(); + callerAgents.put("some-sdk", "1.2.3"); + options.agents = callerAgents; + PubSubServer.httpClientBuilder(options).build(); + assertTrue(options.agents == callerAgents); + assertEquals(1, callerAgents.size()); + assertFalse(callerAgents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void nullOptions_getTheCoreConstructorsOwnError() { + try { + PubSubServer.httpClientBuilder((ClientOptions) null).build(); + fail("expected the core's initialization error"); + } catch (AblyException e) { + assertEquals(40000, e.errorInfo.code); + } + } + + /** + * Wire-level assertion: the Ably-Agent header actually sent over HTTP carries the + * side-declaring flag as a bare token alongside the core's base identifier. This is the + * value billing classification reads. + */ + @Test + public void httpRequests_carryTheServerAgentHeaderOnTheWire() throws Exception { + AtomicReference observedAgentHeader = new AtomicReference<>(); + HttpServer httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext("/time", exchange -> { + observedAgentHeader.set(exchange.getRequestHeaders().getFirst("Ably-Agent")); + byte[] body = "[1234567890000]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + httpServer.start(); + try { + ClientOptions options = offlineOptions(FAKE_KEY); + options.tls = false; + options.restHost = "127.0.0.1"; + options.port = httpServer.getAddress().getPort(); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + client.time(); + + String agentHeader = observedAgentHeader.get(); + assertNotNull("no Ably-Agent header observed", agentHeader); + List tokens = Arrays.asList(agentHeader.split(" ")); + // The flag must be present as a bare token: `name/anything` means the + // versionless stamp regressed (the registry entry is versionless). + assertTrue("missing bare side flag in: " + agentHeader, + tokens.contains(Side.SERVER_AGENT_IDENTIFIER)); + assertFalse("side flag must be versionless in: " + agentHeader, + agentHeader.contains(Side.SERVER_AGENT_IDENTIFIER + "/")); + assertTrue("missing core base identifier in: " + agentHeader, + agentHeader.contains("ably-pubsub-java/")); + } finally { + httpServer.stop(0); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ed2fc200d..c11795fed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,8 @@ rootProject.name = "ably-java" include("core") include("core-android") +include("device") +include("server") include("gradle-lint") include("network-client-core") include("network-client-default") diff --git a/shared/src/main/java/io/ably/pubsub/internal/Side.java b/shared/src/main/java/io/ably/pubsub/internal/Side.java new file mode 100644 index 000000000..147f74c27 --- /dev/null +++ b/shared/src/main/java/io/ably/pubsub/internal/Side.java @@ -0,0 +1,103 @@ +package io.ably.pubsub.internal; + +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Internal helper shared by the {@code io.ably.pubsub:device} and {@code io.ably.pubsub:server} + * door artifacts. It is compiled into each artifact's output from a shared source directory + * rather than published, so that the two artifacts can share this code without a third + * artifact existing for it to live in. + *

+ * The package split keeps {@code io.ably.pubsub:core} itself as the shared core, so nothing here may + * grow into a general abstraction over the core: it exists only to stamp the side a package + * declares. + */ +public final class Side { + private Side() {} + + /* + * The `-device` / `-server` suffix on both identifiers below is load-bearing, not + * cosmetic. On API-key auth the realtime system grants the server exemption by matching + * an agent entry ending in `-server`, and an identifier that is not yet in the + * ably-common registry is classified by that suffix alone. Renaming either without + * preserving its suffix silently reclassifies every client the package constructs. + * + * Both live here rather than in the package that uses each, so the naming scheme can be + * changed in one place. + */ + + /** The agent identifier declaring the device side, sent by {@code io.ably.pubsub:device}. */ + public static final String DEVICE_AGENT_IDENTIFIER = "ably-pubsub-device"; + + /** + * The agent identifier declaring the server side, sent by {@code io.ably.pubsub:server}. + *

+ * This is the entry that earns the MAU exemption on API-key auth, so its {@code -server} + * suffix is the one with billing consequences. + */ + public static final String SERVER_AGENT_IDENTIFIER = "ably-pubsub-server"; + + /** + * Returns a copy of the caller's options carrying the agent entry that declares this + * package's side. + *

+ * The side entry is a versionless flag — a bare token on the wire, like the + * platform's own {@code browser} entry — registered as such in the ably-common agents + * registry. Identity, version and support status keep + * travelling on the SDK's own {@code ably-pubsub-java/} entry alongside it; + * {@link io.ably.lib.util.AgentHeaderCreator} emits a map entry with a {@code null} + * value as a bare token. + *

+ * The copy is made with {@link ClientOptions#copy()} and a fresh agents map, so the + * caller's options and their own {@code agents} map are both left untouched. The + * caller's {@code agents} entries are preserved alongside the side stamp, so an SDK + * layered on top of this package keeps its attribution. The side stamp is applied last + * and so wins a collision on its own identifier: which side the package declares is the + * package's to state, not the caller's to redefine. + *

+ * {@code null} passes through unchanged rather than being defaulted, so a caller who + * passes nothing gets the core constructor's own initialization error ("no options + * provided") instead of constructing with only an {@code agents} entry and failing + * later with a vaguer authentication error. + * + * @param options the options the caller passed to the door's builder, or {@code null}. + * @param identifier the side-declaring agent identifier to stamp. + * @return a stamped copy of the options, or {@code null} if {@code options} was {@code null}. + */ + public static ClientOptions optionsWithSideAgent(ClientOptions options, String identifier) { + if (options == null) { + return null; + } + ClientOptions stamped = options.copy(); + Map agents = new LinkedHashMap<>(); + if (options.agents != null) { + agents.putAll(options.agents); + } + agents.put(identifier, null); + stamped.agents = agents; + return stamped; + } + + /** + * As {@link #optionsWithSideAgent(ClientOptions, String)}, for the API key or + * token string form the core constructors also accept. Reuses the core's own + * key-versus-token disambiguation ({@link ClientOptions#ClientOptions(String)}: an Ably + * API key always contains a colon, an Ably token never does). + * + * @param keyOrToken the Ably API key or token string the caller passed to the door's builder. + * @param identifier the side-declaring agent identifier to stamp. + * @return stamped options constructed from the key or token. + * @throws AblyException if the key or token string is rejected by the core. + */ + public static ClientOptions optionsWithSideAgent(String keyOrToken, String identifier) + throws AblyException { + ClientOptions options = new ClientOptions(keyOrToken); + options.agents = new LinkedHashMap<>(); + options.agents.put(identifier, null); + return options; + } +} 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) + } + }, + ) + } +}