From 66eb6ef44efd022fb0db23793b9f8d15700109ad Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Sun, 19 Jul 2026 12:34:40 +0200 Subject: [PATCH] UTS: fix spec-contradicting presence setups, corrupt RSP5g fixture, RTN15h1 code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three classes of UTS spec error, each surfaced by SDKs deriving tests (ably-js's test/uts audit and the ably-rust UTS suite): 1. Wildcard clientId in ClientOptions (realtime_presence_enter.md, realtime_presence_reentry.md). RSA7c reserves "*" as a ClientOptions#clientId value — construction must reject it — so every `clientId: "*"` setup was unrunnable on a compliant SDK, and both ably-js and ably-rust had silently adapted. enterClient-only tests now use an unidentified (key-auth) client; the RTP8j wildcard test obtains the wildcard state the way it actually arises, via a wildcard token; RTP15c now uses two clients, since per RTP8j + RTP15f no single clientId permits both a plain enter() and an enterClient() for another user. The reentry setup's identified "admin" + enterClient(other) also contradicted RTP15f and is now unidentified. 2. RSP5g cipher fixture (rest_presence.md) was a 32-byte truncation of the canonical 48-byte ably-common fixture (test-resources/ crypto-data-128.json), cutting off mid-ciphertext, with a wrong plaintext comment. Any SDK implementing the test decrypts garbage. Restored the canonical string and added a value-level assertion on the decrypted JSON. 3. RTN15h1 errorReason code (connection_failures_test.md) asserted pass-through of the server's 40142. Per RSA4a2 the SDK substitutes 40171 ("no means to renew the token") and transitions to FAILED. This aligns the unit spec with the proxy integration spec (connection_resume.md) and with error_reason_test.md (RTN25), which was already fixed to 40171. Co-Authored-By: Claude Fable 5 --- .../connection/connection_failures_test.md | 7 +- .../unit/presence/realtime_presence_enter.md | 106 +++++++++++------- .../presence/realtime_presence_reentry.md | 9 +- uts/rest/unit/presence/rest_presence.md | 9 +- 4 files changed, 79 insertions(+), 52 deletions(-) diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md index ab4bb1932..78243169c 100644 --- a/uts/realtime/unit/connection/connection_failures_test.md +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -77,9 +77,12 @@ AWAIT_STATE client.connection.state == ConnectionState.failed # Connection transitioned to FAILED ASSERT client.connection.state == ConnectionState.failed -# Error reason is set +# Error reason is set. Per RSA4a2, the SDK substitutes 40171 ("no means to +# renew the token") for the server's 40142, having detected it has no way to +# renew (no key, authCallback or authUrl). Matches the proxy integration spec +# (connection_resume.md, RTN15h1 note) and error_reason_test.md (RTN25). ASSERT client.connection.errorReason IS NOT null -ASSERT client.connection.errorReason.code == 40142 +ASSERT client.connection.errorReason.code == 40171 ASSERT client.connection.errorReason.statusCode == 401 CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/presence/realtime_presence_enter.md b/uts/realtime/unit/presence/realtime_presence_enter.md index 23fadf70e..4545e578f 100644 --- a/uts/realtime/unit/presence/realtime_presence_enter.md +++ b/uts/realtime/unit/presence/realtime_presence_enter.md @@ -12,12 +12,15 @@ and `leaveClient` functions. These methods send PRESENCE ProtocolMessages to the and handle ACK/NACK responses. Tests cover protocol message format, implicit channel attach, connection state conditions, and error cases. -**Note on wildcard clientId:** Several tests use `clientId: "*"` (wildcard) which is -the Ably convention for clients permitted to act on behalf of any clientId via -`enterClient`/`updateClient`/`leaveClient`. Some SDKs may reject `"*"` at the -`ClientOptions` construction level. In such cases, adapt these tests to use a -concrete clientId (e.g., `"admin"`) and skip the client-side `enterClient` clientId -mismatch check (RTP15f), or configure the mock to accept any clientId. +**Note on acting on behalf of other clients:** Tests that use +`enterClient`/`updateClient`/`leaveClient` for arbitrary clientIds use an +*unidentified* client (key auth with no `clientId`), which is permitted to act +on behalf of any clientId. Per RSA7c, `"*"` is a reserved value for +`ClientOptions#clientId` and must be rejected at construction time — the +wildcard-clientId state referenced by RTP8j arises only from token auth with a +wildcard (`clientId: "*"`) token. Per RTP15f, an *identified* client calling +`enterClient` with a different clientId results in an error (client-side or via +server NACK). --- @@ -280,9 +283,6 @@ CLOSE_CLIENT(client) ### Setup -Note: Some SDKs may reject wildcard clientId `"*"` at the `ClientOptions` -construction level rather than at `enter()` time. In that case, this test -validates that the error occurs at `ClientOptions` creation instead. ```pseudo channel_name = "test-RTP8j-wild-${random_id()}" @@ -295,10 +295,13 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Wildcard clientId -# Note: clientId "*" may not be accepted by all SDKs at construction time. -# See top-level note for alternative auth patterns (e.g., key auth without clientId). -client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +# Wildcard clientId via a wildcard token. RSA7c reserves "*" as a +# ClientOptions#clientId value (construction must reject it), so the RTP8j +# wildcard state arises from token auth with a token whose clientId is "*". +client = Realtime(options: ClientOptions( + tokenDetails: TokenDetails(token: "wildcard-token", expires: now() + 3600000, clientId: "*"), + autoConnect: false +)) channel = client.channels.get(channel_name) ``` @@ -542,9 +545,10 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Note: clientId "*" may not be accepted by all SDKs at construction time. -# See top-level note for alternative auth patterns (e.g., key auth without clientId). -client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +# Unidentified client (key auth, no clientId): permitted to act on behalf of +# any clientId via enterClient/updateClient/leaveClient. (RSA7c reserves "*" +# as a ClientOptions#clientId value, so it must not be used here.) +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -601,9 +605,10 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Note: clientId "*" may not be accepted by all SDKs at construction time. -# See top-level note for alternative auth patterns (e.g., key auth without clientId). -client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +# Unidentified client (key auth, no clientId): permitted to act on behalf of +# any clientId via enterClient/updateClient/leaveClient. (RSA7c reserves "*" +# as a ClientOptions#clientId value, so it must not be used here.) +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -661,9 +666,10 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Note: clientId "*" may not be accepted by all SDKs at construction time. -# See top-level note for alternative auth patterns (e.g., key auth without clientId). -client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +# Unidentified client (key auth, no clientId): permitted to act on behalf of +# any clientId via enterClient/updateClient/leaveClient. (RSA7c reserves "*" +# as a ClientOptions#clientId value, so it must not be used here.) +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -931,33 +937,44 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Wildcard clientId to allow both enter() and enterClient() on the same connection. -# See note in Purpose section about SDK-level wildcard validation. -# Note: clientId "*" may not be accepted by all SDKs at construction time. -# See top-level note for alternative auth patterns (e.g., key auth without clientId). -client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) -channel = client.channels.get(channel_name) +# Two clients: no single clientId permits both operations on a compliant SDK. +# Per RTP8j, a wildcard (or null) clientId means plain enter() errors +# immediately; per RTP15f, an identified client's enterClient() with a +# different clientId indicates an error. So the normally-entered member is an +# identified client, and the enterClient/leaveClient operations come from a +# second, unidentified (key-auth) client — mirroring the real-world shape of +# an end user plus a server-side process acting on behalf of other users. +client_main = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "main-user", autoConnect: false)) +channel_main = client_main.channels.get(channel_name) + +client_ops = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel_ops = client_ops.channels.get(channel_name) ``` ### Test Steps ```pseudo -client.connect() -AWAIT_STATE client.connection.state == ConnectionState.connected -AWAIT channel.attach() +client_main.connect() +AWAIT_STATE client_main.connection.state == ConnectionState.connected +AWAIT channel_main.attach() + +client_ops.connect() +AWAIT_STATE client_ops.connection.state == ConnectionState.connected +AWAIT channel_ops.attach() -# Normal enter for the wildcard client -AWAIT channel.presence.enter(data: "main-client") +# Normal enter for the identified main client +AWAIT channel_main.presence.enter(data: "main-client") -# enterClient for a different user -AWAIT channel.presence.enterClient("other-user", data: "other-data") +# enterClient for a different user, from the unidentified client +AWAIT channel_ops.presence.enterClient("other-user", data: "other-data") # leaveClient for the other user -AWAIT channel.presence.leaveClient("other-user") +AWAIT channel_ops.presence.leaveClient("other-user") ``` ### Assertions ```pseudo # Three presence messages sent: enter, enterClient, leaveClient +# (each operation is awaited, so the capture order is deterministic) ASSERT captured_presence.length == 3 # The main client's enter is unaffected by the enterClient/leaveClient calls @@ -971,7 +988,8 @@ ASSERT captured_presence[1].presence[0].clientId == "other-user" ASSERT captured_presence[2].presence[0].action == LEAVE ASSERT captured_presence[2].presence[0].clientId == "other-user" -CLOSE_CLIENT(client) +CLOSE_CLIENT(client_main) +CLOSE_CLIENT(client_ops) ``` --- @@ -1033,9 +1051,10 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Note: clientId "*" may not be accepted by all SDKs at construction time. -# See top-level note for alternative auth patterns (e.g., key auth without clientId). -client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +# Unidentified client (key auth, no clientId): permitted to act on behalf of +# any clientId via enterClient/updateClient/leaveClient. (RSA7c reserves "*" +# as a ClientOptions#clientId value, so it must not be used here.) +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -1151,9 +1170,10 @@ mock_ws_b = MockWebSocket( install_mock(mock_ws_a, client: "A") install_mock(mock_ws_b, client: "B") -# Note: clientId "*" may not be accepted by all SDKs at construction time. -# See top-level note for alternative auth patterns (e.g., key auth without clientId). -client_a = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +# Two unidentified clients (key auth, no clientId): permitted to act on behalf +# of any clientId via enterClient. (RSA7c reserves "*" as a +# ClientOptions#clientId value, so it must not be used here.) +client_a = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) client_b = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) channel_a = client_a.channels.get(channel_name) channel_b = client_b.channels.get(channel_name) diff --git a/uts/realtime/unit/presence/realtime_presence_reentry.md b/uts/realtime/unit/presence/realtime_presence_reentry.md index dc5238baf..5ff62a07c 100644 --- a/uts/realtime/unit/presence/realtime_presence_reentry.md +++ b/uts/realtime/unit/presence/realtime_presence_reentry.md @@ -160,10 +160,11 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Use a non-wildcard clientId that has enterClient permission. -# Note: Some SDKs reject wildcard clientId "*" at the ClientOptions level. -# Use a concrete clientId and rely on server-side permission for enterClient. -client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "admin", autoConnect: false)) +# Unidentified client (key auth, no clientId): permitted to act on behalf of +# any clientId via enterClient. (RSA7c reserves "*" as a ClientOptions#clientId +# value, and per RTP15f an identified client cannot enterClient a different +# clientId.) +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) channel = client.channels.get(channel_name) ``` diff --git a/uts/rest/unit/presence/rest_presence.md b/uts/rest/unit/presence/rest_presence.md index 979e07a50..6c06475f7 100644 --- a/uts/rest/unit/presence/rest_presence.md +++ b/uts/rest/unit/presence/rest_presence.md @@ -1221,8 +1221,10 @@ channel_name = "test-RSP5g-${random_id()}" captured_requests = [] cipher_key = base64_decode("WUP6u0K7MXI5Zeo0VppPwg==") -# Encrypted data for {"secret":"data"} -encrypted_data = "HO4cYSP8LybPYBPZPHQOtuD53yrD3YV3NBoTEYBh4U0=" +# Canonical ably-common fixture (test-resources/crypto-data-128.json): +# the encryption of the JSON object {"example":{"json":"Object"}} under the +# key above. 48 bytes: 16-byte IV followed by two ciphertext blocks. +encrypted_data = "HO4cYSP8LybPYBPZPHQOtuD53yrD3YV3NBoTEYBh4U0N1QXHbtkfsDfTspKeLQFt" mock_http = MockHttpClient( onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -1254,7 +1256,8 @@ result = AWAIT channel.presence.get() ### Assertions ```pseudo ASSERT result.items[0].data IS Object/Map -# Decryption applied based on cipher+aes-128-cbc encoding +# Decryption applied based on cipher+aes-128-cbc encoding, then JSON decoding +ASSERT result.items[0].data["example"]["json"] == "Object" ``` ---