From 4587a4bec920a224fc4638bfcf9ae302685350b3 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 18:52:24 +0000 Subject: [PATCH 01/10] pubsub: use token auth when a key comes with a means of getting a token A client given a key alongside an auth_callback, auth_url, token or token_details authenticated with basic auth: Auth.__init__ chose the scheme from use_token_auth and the presence of a key secret alone, so the callback and auth URL were never consulted, and the HTTP client's credential chain discarded a token or token_details given with a key. RSA4 selects token auth whenever use_token_auth is unspecified and any of those four is given, and RSA4a/RSA4b then treat the key as a means of renewing the token. The client now does that. The key is kept, so a token given with it can be renewed. use_token_auth=False still forces basic auth when a key is present, which RSA4 allows, since it only applies when use_token_auth is unspecified. This is a breaking change. A client configured with both now calls its auth_callback or auth_url, which reaches the customer's auth server, or presents the token it was given. Callers who relied on the key winning pass use_token_auth=False, or drop the unused option. The RSA1 token-precedence UTS test now asserts the specification's Bearer header. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 23 +++++++++++++++++++++ ably/pubsub/http/auth.py | 7 ++++++- ably/pubsub/http/http.py | 11 +++++----- ably/pubsub/server/__init__.py | 6 ++++-- test/uts/deviations.md | 1 - test/uts/rest/unit/auth/auth_scheme_test.py | 9 ++------ 6 files changed, 41 insertions(+), 16 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 64f69d56..10f90d46 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -124,6 +124,29 @@ await pubsub_realtime_client.channels.get('my-channel').detach() pubsub_realtime_client.channels.release('my-channel') ``` +### A key no longer overrides `auth_callback`, `auth_url` or a token + +A client given a `key` together with an `auth_callback`, `auth_url`, `token` or `token_details` +now uses token auth, and keeps the key only as a means of renewing the token. 3.x used basic +auth with the key whenever one was present, never called the `auth_callback` or `auth_url`, +and discarded a `token` or `token_details` given alongside the key. So a client configured this +way now makes requests to your auth server, or presents the token you gave it, where 3.x did +neither. + +To keep using basic auth with the key, pass `use_token_auth=False`: + +Example 3.x code: +```python +rest = AblyRest(key='xxx', auth_callback=callback) +``` + +Example 4.0.0 code: +```python +pubsub_http_client = create_http_client(key='xxx', auth_callback=callback, use_token_auth=False) +``` + +Or drop the `auth_callback`, `auth_url` or token, which 3.x never used. + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index aa71d517..03b90914 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -47,7 +47,12 @@ def __init__(self, ably: DefaultPubSubHttpClient | DefaultPubSubRealtimeClient, self.__token_details: TokenDetails | None = None self.__time_offset: int | None = None - must_use_token_auth = options.use_token_auth is True + # RSA4: token auth unless use_token_auth says otherwise, or nothing but a key is given + if options.use_token_auth is None: + must_use_token_auth = bool(options.auth_callback or options.auth_url + or options.auth_token or options.token_details) + else: + must_use_token_auth = options.use_token_auth must_not_use_token_auth = options.use_token_auth is False can_use_basic_auth = options.key_secret is not None if not must_use_token_auth and can_use_basic_auth: diff --git a/ably/pubsub/http/http.py b/ably/pubsub/http/http.py index 7d912d0b..693526cf 100644 --- a/ably/pubsub/http/http.py +++ b/ably/pubsub/http/http.py @@ -69,14 +69,15 @@ def __init__(self, key: Optional[str] = None, token: Optional[str] = None, if key is not None and ('key_name' in kwargs or 'key_secret' in kwargs): raise ValueError("key and key_name or key_secret are mutually exclusive. " "Provider either a key or key_name & key_secret") - if key is not None: - options = Options(key=key, **kwargs) - elif token is not None: - options = Options(auth_token=token, **kwargs) + # RSA4: a key given alongside a token is kept, to renew the token with + if token is not None: + options = Options(key=key, auth_token=token, **kwargs) elif token_details is not None: if not isinstance(token_details, TokenDetails): raise ValueError("token_details must be an instance of TokenDetails") - options = Options(token_details=token_details, **kwargs) + options = Options(key=key, token_details=token_details, **kwargs) + elif key is not None: + options = Options(key=key, **kwargs) elif not ('auth_callback' in kwargs or 'auth_url' in kwargs or # and don't have both key_name and key_secret ('key_name' in kwargs and 'key_secret' in kwargs)): diff --git a/ably/pubsub/server/__init__.py b/ably/pubsub/server/__init__.py index 15c20641..84fb2779 100644 --- a/ably/pubsub/server/__init__.py +++ b/ably/pubsub/server/__init__.py @@ -93,8 +93,10 @@ def create_http_client(**kwargs) -> PubSubHttpClient: auth_params: dict Query parameters to include in the `auth_url` request. use_token_auth: bool - Force token authentication even when an API key is available. By default the - client uses basic auth where a key is present. + Whether to use token authentication. By default the client uses token auth when + any of `auth_callback`, `auth_url`, `token` or `token_details` is given, and + basic auth when only a key is. Set it to False to authenticate with the key + regardless, or to True to request tokens with the key. query_time: bool Query the Ably service for the current time when signing token requests, rather than trusting the local clock. Defaults to False. diff --git a/test/uts/deviations.md b/test/uts/deviations.md index 28535f9f..42a19e62 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -1918,7 +1918,6 @@ which is about a client that *does* configure `clientId: "*"`. | Spec points | Behaviour | |---|---| -| RSA4 | With a `key` present, `auth_callback` and `auth_url` are ignored when choosing the auth scheme, so Basic is selected and the callback is never called. `Auth.__init__` considers only `use_token_auth` and `key_secret`. `AblyRest.__init__`'s credential `elif` chain compounds it by discarding `token`/`token_details` when a key is given | | RSA15a, RSA15c | A mismatch between `ClientOptions.clientId` and a statically supplied `TokenDetails.clientId` is never detected. `Auth.__init__` only falls back to the token's clientId; `_configure_client_id`, which would raise, is reached only after a *fetched* token | | RSA12a | A token with a **null** clientId is rejected when `ClientOptions.clientId` is set, with 40102 "Client ID cannot be changed to 'None'". RSA15a constrains only non-wildcard token clientIds. Needs a `new_client_id is not None` guard | | RSA7, RSA16c | A clientId learned from a token is treated as immutable, so `authorize()` to a token with a different clientId raises 40102. RSA15 scopes immutability to a clientId set in `ClientOptions`. `_configure_client_id` uses `self.client_id or self.auth_options.client_id`, conflating the two. Possibly deliberate — worth a maintainer's call | diff --git a/test/uts/rest/unit/auth/auth_scheme_test.py b/test/uts/rest/unit/auth/auth_scheme_test.py index e69b732a..b9ade5ba 100644 --- a/test/uts/rest/unit/auth/auth_scheme_test.py +++ b/test/uts/rest/unit/auth/auth_scheme_test.py @@ -241,14 +241,9 @@ async def auth_callback(params): await client.request('GET', '/channels/test', version=api_version) - # DEVIATION: RSA4 selects token auth whenever use_token_auth is unspecified - # and any of auth_url, auth_callback, token or token_details is given, so - # the spec expects `bearer('callback-token')`. ably-python prefers basic auth - # whenever a key secret is present and use_token_auth is not True, and never - # calls the auth_callback. + # authCallback takes precedence, so Bearer auth is used request = captured_requests[0] - expected_basic = 'Basic ' + base64.b64encode(b'appId.keyId:keySecret').decode('ascii') - assert request.headers['Authorization'] == expected_basic + assert request.headers['Authorization'] == bearer('callback-token') # UTS: rest/unit/RSA2/basic-auth-header-format-0 From 6d7dbd28aae81545ae79a423c06d5676db32c330 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 18:55:33 +0000 Subject: [PATCH 02/10] pubsub: send token params to an auth_url under their wire names token_request_from_auth_url passed the SDK's internal token params dict straight to the auth URL, as the query string for GET and the form body for POST. An auth server therefore received client_id where RSA8c1a and every other SDK send clientId, a capability dict as its Python repr, a timedelta ttl as "1:00:00", and an unset param as an empty value. Issue #736 reports the client_id half: an auth server written against the docs or another SDK never sees the client ID and issues a token without one. The params are now sent under their wire names and in their wire forms: client_id becomes clientId, a capability that is not already a JSON string is serialised as canonical JSON, a timedelta ttl becomes integer milliseconds, and None values are left out. timestamp and nonce already travel under their wire names, and auth_params and any other key pass through unchanged. Token params still take precedence over auth_params on a name conflict (RSA8c2), now under the wire name. This is a breaking change for auth servers: one that reads client_id from a 3.x client must read clientId instead. There is no transition period that sends both. The names callers use in token_params are unchanged. A mocked test covers GET and POST, and the RSA12b UTS test's deviation note drops the half this fixes; it stays gated on the RSA12a clientId check. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 25 ++++++++++++ ably/pubsub/http/auth.py | 18 +++++++++ test/ably/http/authurlparams_test.py | 47 +++++++++++++++++++++++ test/uts/deviations.md | 1 - test/uts/rest/unit/auth/client_id_test.py | 13 ++----- 5 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 test/ably/http/authurlparams_test.py diff --git a/UPDATING.md b/UPDATING.md index 10f90d46..0abb0a9c 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -147,6 +147,31 @@ pubsub_http_client = create_http_client(key='xxx', auth_callback=callback, use_t Or drop the `auth_callback`, `auth_url` or token, which 3.x never used. +### Token params reach an `auth_url` under their wire names + +A client with an `auth_url` now sends the token params to it under the names and in the forms +the other Ably SDKs use, in the query string for `GET` and in the form body for `POST`: + +- the client ID as `clientId`, where 3.x sent `client_id` +- a `capability` given as a dict or `Capability` as JSON, where 3.x sent its Python repr +- a `ttl` given as a `timedelta` as an integer count of milliseconds, where 3.x sent `1:00:00` + +Unset params are left out rather than sent empty. This changes what your auth server receives. +An auth server written for 3.x clients that reads `client_id` must read `clientId` instead, as it +already does for clients built with the other SDKs. There is no transition period in which both +are sent, so update the auth server before, or together with, the clients. The token params +passed to `create_token_request`, `request_token` and `authorize` keep their snake_case names. + +Example 3.x auth server code: +```python +client_id = request.args.get('client_id') +``` + +Example 4.0.0 auth server code: +```python +client_id = request.args.get('clientId') +``` + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index 03b90914..3399f199 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -391,11 +391,29 @@ def _timestamp(self): def _random_nonce(self): return uuid.uuid4().hex[:16] + @staticmethod + def _auth_url_token_params(token_params): + """Returns the token params under their wire names and in their wire forms (RSA8c1a)""" + params = {} + for name, value in token_params.items(): + if value is None: + continue + if name == 'client_id': + name = 'clientId' + elif name == 'ttl' and isinstance(value, timedelta): + value = int(value.total_seconds() * 1000) + elif name == 'capability' and not isinstance(value, str): + value = str(Capability(value)) + params[name] = value + return params + async def token_request_from_auth_url(self, method: str, url: str, token_params, headers, auth_params): # Extract URL parameters using utility function clean_url, url_params = extract_url_params(url) + token_params = self._auth_url_token_params(token_params) + body = None params = None if method == 'GET': diff --git a/test/ably/http/authurlparams_test.py b/test/ably/http/authurlparams_test.py new file mode 100644 index 00000000..b1f6321d --- /dev/null +++ b/test/ably/http/authurlparams_test.py @@ -0,0 +1,47 @@ +from datetime import timedelta +from urllib.parse import parse_qs + +import pytest +import respx +from httpx import Response + +from ably.pubsub.server import create_http_client + + +@pytest.fixture(scope='session', autouse=True) +def test_app_setup(): + """Avoid creating a sandbox application for these mocked requests.""" + yield + + +@pytest.mark.parametrize('method', ['GET', 'POST']) +@respx.mock +async def test_token_params_reach_auth_url_under_wire_names(method): + """RSA8c1a: send TokenParams to the auth URL as another SDK would.""" + auth_url = 'https://auth.example.test/token' + client = create_http_client(auth_url=auth_url, auth_method=method, client_id='alice', + auth_params={'clientId': 'overridden', 'extra': 'kept'}) + auth_route = respx.request(method, auth_url).mock(return_value=Response( + 200, json={'token': 'a-token', 'clientId': 'alice'})) + try: + await client.auth.authorize({ + 'ttl': timedelta(hours=1), + 'capability': {'channel': ['subscribe', 'publish']}, + 'nonce': 'a-nonce', + 'timestamp': 1234567890000, + }) + request = auth_route.calls.last.request + if method == 'GET': + sent = parse_qs(request.url.query.decode('utf-8')) + else: + sent = parse_qs(request.content.decode('utf-8')) + assert sent == { + 'clientId': ['alice'], + 'ttl': ['3600000'], + 'capability': ['{"channel": ["publish", "subscribe"]}'], + 'nonce': ['a-nonce'], + 'timestamp': ['1234567890000'], + 'extra': ['kept'], + } + finally: + await client.close() diff --git a/test/uts/deviations.md b/test/uts/deviations.md index 42a19e62..b5a2eb55 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -1927,7 +1927,6 @@ which is about a client that *does* configure `clientId: "*"`. | RSA16c | No local expiry detection without a server time offset, which an authCallback client never obtains. See the RSA4b1 spec error above; here the specification asserts renewal *does* happen, so there is no green reading | | RSA16d | A failed renewal leaves the invalidated token in place — `_ensure_valid_auth_credentials` assigns only on success | | RSA16d | `authorize()` cannot switch a client back to basic auth: `_ensure_valid_auth_credentials` sets `Method.TOKEN` unconditionally, and `AuthOptions.replace` drops `use_token_auth`, which is stored outside the options dict | -| RSA8c1a, RSA12b | `TokenParams` reach the `auth_url` under the SDK's internal snake_case names: `Auth._ensure_valid_auth_credentials` sets `token_params['client_id']` and `token_request_from_auth_url` passes the dict straight to the query string, so an auth server sees `client_id`, not `clientId` | ### Options diff --git a/test/uts/rest/unit/auth/client_id_test.py b/test/uts/rest/unit/auth/client_id_test.py index 58a2990e..7c293d48 100644 --- a/test/uts/rest/unit/auth/client_id_test.py +++ b/test/uts/rest/unit/auth/client_id_test.py @@ -125,15 +125,10 @@ async def mock_auth_callback(params): # UTS: rest/unit/RSA12b/clientid-sent-to-authurl-0 -# DEVIATION: two departures, either of which alone fails this test. -# - The specification's auth_url response carries no clientId, and -# Auth._configure_client_id reads the absent value as an attempt to change the -# configured clientId, raising 40102 before `status()` returns. This is the -# RSA12a mechanism above. -# - RSA8c1a sends the TokenParams as query params under their wire names. -# ably-python passes its internal snake_case dict straight through -# (`token_params['client_id']` in Auth._ensure_valid_auth_credentials), so the -# auth_url receives `client_id`, not `clientId`. +# DEVIATION: the specification's auth_url response carries no clientId, and +# Auth._configure_client_id reads the absent value as an attempt to change the +# configured clientId, raising 40102 before `status()` returns. This is the RSA12a +# mechanism above. @deviation async def test_rsa12b_clientid_sent_to_authurl(): captured_requests = [] From 993f2aed435ec150a959d70a35ac0315f17e9156 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 19:00:12 +0000 Subject: [PATCH 03/10] pubsub: hold a realtime client's configured clientId before CONNECTED Auth.__init__ set auth.client_id to None on a realtime client, deferring it to the clientId a CONNECTED message confirms. So client.client_id and client.auth.client_id disagreed until the connection was established (RTC17), and _ensure_valid_auth_credentials, which only puts a non-null client_id into the token params, called the first auth_callback or auth_url without the configured clientId (RTN2e, RSA7d, RSA12a). The REST client did neither. A realtime client now initialises its clientId as the REST client does: the configured clientId, or failing that the clientId of a supplied TokenDetails (RSA7b1, RSA7b2). CONNECTED still validates it through _configure_client_id, which keeps a configured clientId against a wildcard and rejects a conflicting one with 40102 (RSA15a). This is a breaking change. auth.client_id is no longer None before CONNECTED on a client with a configured clientId, and the first token request now carries that clientId, so an auth_callback receives client_id and an auth server receives clientId where 3.x sent neither. An auth server that issues tokens from the params it receives now issues them for that clientId. The RTN2e UTS test is un-gated, the RTC17 one asserts the specification's equality, and three legacy realtime auth tests that pinned None before CONNECTED now expect the clientId. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 16 +++++++ ably/pubsub/http/auth.py | 10 ++--- ably/pubsub/realtime/presence.py | 2 +- test/ably/realtime/realtimeauth_test.py | 9 ++-- test/uts/deviations.md | 44 +++++-------------- .../unit/auth/connection_auth_test.py | 5 +-- .../unit/client/realtime_client_test.py | 6 +-- 7 files changed, 39 insertions(+), 53 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 0abb0a9c..7ed99141 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -172,6 +172,22 @@ Example 4.0.0 auth server code: client_id = request.args.get('clientId') ``` +### A realtime client knows its configured `client_id` before it connects + +A realtime client built with a `client_id` now reports it from `auth.client_id` as soon as it is +created, as an HTTP client always has. 3.x held `auth.client_id` at `None` until the connection +reached `CONNECTED`. A realtime client built with a `token_details` that carries a client ID +likewise reports that ID straight away. + +Because the client ID is known when the first token is requested, a realtime client now passes +it to its `auth_callback` as `client_id` in the token params, and to its `auth_url` as +`clientId`, which 3.x did not do. An auth server or callback that issues tokens from those params +now issues them for that client ID. Check that it does not reject or override a client ID it was +not expecting. + +Code that read `auth.client_id` before connecting to tell whether the client was connected +should check `connection.state` instead. + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index 3399f199..3496c8bb 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -34,12 +34,10 @@ def __init__(self, ably: DefaultPubSubHttpClient | DefaultPubSubRealtimeClient, self.__auth_options = options self.__clock = select_clock(options) - if not self.ably._is_realtime: - self.__client_id = options.client_id - if not self.__client_id and options.token_details: - self.__client_id = options.token_details.client_id - else: - self.__client_id = None + # RSA7b1, RTC17: a configured clientId holds from the start, on realtime clients too + self.__client_id = options.client_id + if not self.__client_id and options.token_details: + self.__client_id = options.token_details.client_id self.__client_id_validated: bool = False self.__basic_credentials: str | None = None diff --git a/ably/pubsub/realtime/presence.py b/ably/pubsub/realtime/presence.py index 60b3b211..70e21774 100644 --- a/ably/pubsub/realtime/presence.py +++ b/ably/pubsub/realtime/presence.py @@ -27,7 +27,7 @@ def _get_client_id(presence: RealtimePresence) -> str | None: """Get the clientId for the current connection.""" - # Use auth.client_id if available (set after CONNECTED), + # Use auth.client_id if available, # otherwise fall back to auth_options.client_id return presence.channel.ably.auth.client_id or presence.channel.ably.auth.auth_options.client_id diff --git a/test/ably/realtime/realtimeauth_test.py b/test/ably/realtime/realtimeauth_test.py index da073117..2d5399fb 100644 --- a/test/ably/realtime/realtimeauth_test.py +++ b/test/ably/realtime/realtimeauth_test.py @@ -636,7 +636,8 @@ async def test_auth_client_id_mismatch(self): realtime = await TestApp.get_ably_realtime(token_details=token_details, client_id="WRONG") - assert realtime.auth.client_id is None + # RTC17: the configured clientId holds before CONNECTED + assert realtime.auth.client_id == "WRONG" state_change = await realtime.connection.once_async(ConnectionState.FAILED) @@ -655,7 +656,8 @@ async def test_auth_client_id_wildcard_token(self): realtime = await TestApp.get_ably_realtime(token_details=token_details, client_id=client_id) - assert realtime.auth.client_id is None + # RTC17: the configured clientId holds before CONNECTED + assert realtime.auth.client_id == client_id await realtime.connection.once_async(ConnectionState.CONNECTED) @@ -674,7 +676,8 @@ async def test_auth_client_id_inheritance_token(self): realtime = await TestApp.get_ably_realtime(token_details=token_details) - assert realtime.auth.client_id is None + # RSA7b2: the token's clientId holds before CONNECTED confirms it + assert realtime.auth.client_id == client_id await realtime.connection.once_async(ConnectionState.CONNECTED) diff --git a/test/uts/deviations.md b/test/uts/deviations.md index b5a2eb55..44b6cb40 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -33,8 +33,8 @@ parametrized over a table of fixtures the specification gives inline; and five `objects/unit` tests are parametrized, three over a table and two over the scenarios the specification loops through. That turns 1481 derived tests into 1608 pytest cases. -Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1289 Test IDs -(1298 tests, 1423 cases) pass, 168 (168 tests, 170 cases) are gated behind +Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1290 Test IDs +(1299 tests, 1424 cases) pass, 167 (167 tests, 169 cases) are gated behind `RUN_DEVIATIONS`, and 15 (15 tests, 15 cases) cannot be run at all. The three groups are disjoint: two Test IDs have a gated part and a passing part — `TI1`, as two functions, and `TO/endpoint-affects-host`, as one parametrized test — and are counted with the gated, @@ -45,19 +45,19 @@ the Test IDs come from `uts/rest/unit` (503 tests, 536 cases), 482 from `uts/rest/integration` (84, 122), 73 from `uts/realtime/integration` (73, 95) and 17 from `uts/objects/integration` (17, 29); 8 of the REST integration ids (8, 8), 30 of the realtime ones (30, 30) and 5 of the objects ones (5, 5) come from the `proxy` package -within each. Of the gated Test IDs 79 are REST and 89 realtime, which is 80 REST cases -and 90 realtime. None of the 339 objects Test IDs is gated or unrunnable. +within each. Of the gated Test IDs 79 are REST and 88 realtime, which is 80 REST cases +and 89 realtime. None of the 339 objects Test IDs is gated or unrunnable. A further 148 pytest cases cover the harness itself and are not derived from a specification: 130 under `helpers/`, for the mocks, and 18 under `objects/helpers/`, for the objects builders and the synced-channel mock. -The 157 gated Test IDs that record SDK non-compliance — 157 tests, 159 cases — reduce to -**69 distinct root causes**, 26 on the REST side and 43 on the realtime side. Three further +The 156 gated Test IDs that record SDK non-compliance — 156 tests, 158 cases — reduce to +**68 distinct root causes**, 26 on the REST side and 42 on the realtime side. Three further defects are recorded below with no test of their own, because the specification's test cannot discriminate (RTP18a), has nothing to assert against (the timezone split on synthesized LEAVE timestamps), or is worked around in the setup of every test that would otherwise trip over it (`enterClient` on an anonymous connection), so the file -carries **72 SDK root causes** in all. The remaining 11 gated Test IDs are +carries **71 SDK root causes** in all. The remaining 11 gated Test IDs are specification faults, and reduce to 8. The objects tier records no SDK non-compliance; its specification faults are all corrected or worked around in tests that pass, and reduce to the seven candidate issues O.1–O.7, none of them filed. @@ -1774,22 +1774,6 @@ as it was. ably-python's behaviour the correct one, in which case this entry closes as a spec change rather than a fix. -#### TokenParams passed to an authCallback carry no clientId on a realtime client — 1 test - -**Spec points:** RSA12a, RTN2e. - -`Auth.__init__` (`ably/rest/auth.py:36-41`) sets `self.__client_id = None` when -`ably._is_realtime`, deferring the clientId to the CONNECTED `connectionDetails`. -`_ensure_valid_auth_credentials` only adds `token_params['client_id']` when -`self.client_id is not None`, so an authCallback on a realtime client is called with the -clientId missing entirely, even though `ClientOptions.clientId` was set. The REST client does -pass it, so the SDK is inconsistent with itself. The snake_case key is idiomatic translation, -not the deviation; the absent member is. - -**Tests affected:** `test_rtn2e_callback_params_include_clientid` — `KeyError: 'client_id'`. - -**Status:** open bug. - #### RSA4f invalid-format validation is not implemented — 1 test **Spec points:** RSA4f, RSA4c2. @@ -2393,7 +2377,6 @@ pseudocode is not mistaken for non-compliance. | RTN15, RTN23 | A DISCONNECTED `ErrorInfo` needs no `statusCode` | `ConnectionManager.on_disconnected` evaluates `exception.status_code >= 500` unguarded, so a DISCONNECTED whose error omits `statusCode` raises `TypeError` in a task whose exception is only logged, and the connection silently stays CONNECTED. `DISCONNECTED_MESSAGE` supplies 400 | Open bug | | TO3l1, TO3l5 | `httpRequestTimeout` and `httpMaxRetryCount` carry their defaults on the options object | Left unset; the effective defaults are applied downstream by `Http` and by `Options.__get_hosts`. The spec's `httpRequestTimeout` is milliseconds and ably-python's `http_request_timeout` is seconds, on the value a caller passes as much as on the default — gated, with the measurement, under *`httpRequestTimeout` is seconds where the specification counts milliseconds* in Failing Tests | Intentional for where the defaults are applied; the unit is an open bug, recorded there | | RTC7 (TO3l3, TO3l4) | `client.options.httpOpenTimeout == 4000` and `httpRequestTimeout == 10000` | Both `None` on `Options`; `Http.http_open_timeout` / `http_request_timeout` fall back to `CONNECTION_RETRY_DEFAULTS`, which holds 4 and 10 — seconds, where TO3l3 and TO3l4 count milliseconds. The three realtime timeouts the same test checks are defaulted on `Options` and match | Two faults in one row: the defaults are unreadable from `options`, and the unit reaches the wire — `rest/proxy/RSC15l2/timeout-triggers-fallback-0` measures a request outliving its configured timeout by a factor of a thousand. Both open; the unit is gated under *`httpRequestTimeout` is seconds where the specification counts milliseconds* in Failing Tests | -| RTC17 (RSA7b1) | `client.clientId == client.auth.clientId` | `AblyRealtime.client_id` reads `options.client_id` and returns the configured value, while `Auth.__init__` sets `self.__client_id = None` whenever `ably._is_realtime` (`rest/auth.py:34-41`), deferring it to whatever a CONNECTED confirms. The two disagree on a client that has not connected | Open bug. RSA12b only allows the realtime clientId to be unknown while it has not been *configured* | | RTC1f | a `transportParams` boolean appears as `"true"` / `"false"` | `True` / `False`, because `WebSocketTransport.connect` builds the query string with `urllib.parse.urlencode`, which renders each value through `str()` (`websockettransport.py:89`). Integers are unaffected | Open bug. A caller can pass the strings directly, but a bool is what the spec's Stringifiable type admits | ### LiveObjects: the internal shapes the unit specifications assume — S-1 to S-6 @@ -3029,14 +3012,7 @@ was. **Hold this one**: specification#466 would make the current behaviour corre two UTS specs disagree about it today. `test/uts/realtime/unit/auth/connection_auth_test.py -k rsa4c3_callback_error_stays_connected` -**3.16 TokenParams passed to an authCallback carry no clientId on a realtime client.** -RSA12a, RTN2e. `Auth.__init__` nulls `client_id` for a realtime client (`rest/auth.py:36-41`) -and `_ensure_valid_auth_credentials` only adds it when non-null, so an auth server never -learns the configured clientId. The REST client does pass it. The same nulling is why -`client.client_id` and `client.auth.client_id` disagree before CONNECTED (RTC17). -`test/uts/realtime/unit/auth/connection_auth_test.py -k rtn2e` - -**3.17 No 40171 log at instantiation with a non-renewable token.** RSA4a1. Requires an +**3.16 No 40171 log at instantiation with a non-renewable token.** RSA4a1. Requires an info-level record carrying 40171 and the TI5 help URL; the SDK logs at debug with no code, and `grep -rn href ably/` finds no help URLs at all. Small, and the TI5 half is a gap of its own. @@ -3729,8 +3705,8 @@ The header states how many derived tests there are, how many pass, how many are gated and how many cannot run. Those numbers are the check that the file is still true: in pytest cases, the gated count must equal the number of failures under `RUN_DEVIATIONS=1`, and gated plus unrunnable must equal the number of skips without -it. As of this writing that is 170 failures, 1571 passes and 15 skips with the variable -set, and 185 skips and 1571 passes without it, the 1571 being 1423 derived cases and 148 +it. As of this writing that is 169 failures, 1572 passes and 15 skips with the variable +set, and 184 skips and 1572 passes without it, the 1572 being 1424 derived cases and 148 harness ones — 130 under `helpers/` and 18 under `objects/helpers/`. Those figures need the `submodules` checkout CI makes: without it, three `rest/unit/encoding` tests that read the ably-common fixtures skip as well. The tiers are told apart by the junit report's diff --git a/test/uts/realtime/unit/auth/connection_auth_test.py b/test/uts/realtime/unit/auth/connection_auth_test.py index a1c32b77..6522b3b3 100644 --- a/test/uts/realtime/unit/auth/connection_auth_test.py +++ b/test/uts/realtime/unit/auth/connection_auth_test.py @@ -89,7 +89,6 @@ def on_connection_attempt(conn): # UTS: realtime/unit/RTN2e/callback-params-include-clientid-2 -@deviation async def test_rtn2e_callback_params_include_clientid(): received_params = None @@ -107,9 +106,7 @@ async def auth_callback(params): assert received_params is not None # `TokenParams` is a plain dict here, and ably-python spells its members # snake_case throughout, so `client_id` is the idiomatic rendering of the - # specification's `clientId`. The deviation is that the member is absent: - # `Auth.__init__` leaves `client_id` unset on a realtime client, so - # `_ensure_valid_auth_credentials` never puts it in the token params. + # specification's `clientId`. assert received_params['client_id'] == 'my-client-id' diff --git a/test/uts/realtime/unit/client/realtime_client_test.py b/test/uts/realtime/unit/client/realtime_client_test.py index b680edd5..28d47938 100644 --- a/test/uts/realtime/unit/client/realtime_client_test.py +++ b/test/uts/realtime/unit/client/realtime_client_test.py @@ -143,11 +143,7 @@ async def test_rtc17_client_id_attribute(): assert client.client_id == 'explicit-client-id' - # DEVIATION: the spec asserts `client.clientId == client.auth.clientId`. - # `DefaultPubSubRealtimeClient.client_id` reads the client options, while `Auth.client_id` is - # held at None for a realtime client until the server confirms one in a CONNECTED - # message, so the two disagree before the connection is established. - assert client.auth.client_id is None + assert client.client_id == client.auth.client_id # UTS: realtime/unit/RTC1a/echo-messages-option-0 From 5f53e863c55b537d2d0a180af21270f366c4d3a2 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 19:03:23 +0000 Subject: [PATCH 04/10] pubsub: keep an explicit clientId in authorize() and copy the params _ensure_valid_auth_credentials wrote the client's clientId into the token params whenever it had one, over any client_id the caller passed, and did so in the caller's own dict. So authorize({'client_id': 'b'}) on a client configured as 'a' silently requested a token for 'a', and the caller's dict came back changed. RSA10b and RSA10j have explicit TokenParams supersede the configured ones, and RSA10h makes Auth#clientId the value used only by default. authorize() now copies the params it is given and fills in the client's clientId only when they name none. Params taken from default_token_params still have the configured clientId written over theirs, which RSA7a4 requires. A conflicting explicit clientId now yields a token for that clientId, which _configure_client_id rejects with 40102 as for any token whose clientId conflicts with the configured one (RSA15a). This is a breaking change for a caller that passed a clientId other than the configured one: authorize() now fails with 40102 where it used to succeed with a token for the configured clientId. Such callers drop the explicit clientId or pass the configured one. The RSA10b UTS test's deviation note now names only the RSA12a check that still gates it, and two mocked tests cover the copy and the explicit clientId. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 22 +++++++++++ ably/pubsub/http/auth.py | 7 +++- test/ably/http/authorizeparams_test.py | 45 +++++++++++++++++++++++ test/uts/deviations.md | 1 - test/uts/rest/unit/auth/authorize_test.py | 10 ++--- 5 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 test/ably/http/authorizeparams_test.py diff --git a/UPDATING.md b/UPDATING.md index 7ed99141..a779b422 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -188,6 +188,28 @@ not expecting. Code that read `auth.client_id` before connecting to tell whether the client was connected should check `connection.state` instead. +### `authorize()` keeps an explicit `client_id` + +A `client_id` passed in the token params to `auth.authorize()` now reaches the token request, the +`auth_callback` or the `auth_url` as given. The client's own client ID is only filled in when the +params name none. 3.x replaced an explicit `client_id` with the client's own whenever the client +had one, and wrote it into the dict the caller passed. The caller's dict is no longer modified. + +So a client configured with one client ID that authorizes for another now receives a token for +the one it asked for, and then fails with an `IncompatibleClientIdException` (code 40102), as the +client ID of a client cannot change once configured. 3.x silently issued a token for the +configured client ID instead. Drop the explicit `client_id`, or pass the configured one: + +Example 3.x code: +```python +await rest.auth.authorize({'client_id': 'someone-else', 'ttl': 3600000}) +``` + +Example 4.0.0 code: +```python +await pubsub_http_client.auth.authorize({'ttl': 3600000}) +``` + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index 3496c8bb..f19a1a68 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -112,16 +112,21 @@ async def __authorize_when_necessary(self, token_params=None, auth_options=None, async def _ensure_valid_auth_credentials(self, token_params=None, auth_options=None, force=False): self.__auth_mechanism = Auth.Method.TOKEN + explicit_client_id = None if token_params is None: token_params = dict(self.auth_options.default_token_params) else: + token_params = dict(token_params) + explicit_client_id = token_params.get('client_id') self.auth_options.default_token_params = dict(token_params) self.auth_options.default_token_params.pop('timestamp', None) if auth_options is not None: self.auth_options.replace(auth_options) auth_options = dict(self.auth_options.auth_options) - if self.client_id is not None: + # RSA10h: Auth#clientId is only the default for an explicit clientId (RSA10b, RSA10j), + # and takes precedence over one from default_token_params (RSA7a4) + if self.client_id is not None and explicit_client_id is None: token_params['client_id'] = self.client_id token_details = self.__token_details diff --git a/test/ably/http/authorizeparams_test.py b/test/ably/http/authorizeparams_test.py new file mode 100644 index 00000000..79bbb9bf --- /dev/null +++ b/test/ably/http/authorizeparams_test.py @@ -0,0 +1,45 @@ +import pytest + +from ably.pubsub.server import IncompatibleClientIdException, TokenDetails, create_http_client + + +@pytest.fixture(scope='session', autouse=True) +def test_app_setup(): + """Avoid creating a sandbox application for these offline calls.""" + yield + + +def callback_client(received, token_client_id): + async def auth_callback(token_params): + received.append(token_params) + return TokenDetails(token='a-token', client_id=token_client_id) + + return create_http_client(auth_callback=auth_callback, client_id='configured') + + +async def test_authorize_fills_the_configured_client_id_without_mutating_params(): + """RSA10h: Auth#clientId is the default clientId, applied to a copy of the caller's params.""" + received = [] + client = callback_client(received, 'configured') + token_params = {'ttl': 60000} + try: + await client.auth.authorize(token_params) + assert received[0] == {'ttl': 60000, 'client_id': 'configured'} + assert token_params == {'ttl': 60000} + finally: + await client.close() + + +async def test_authorize_keeps_an_explicit_client_id(): + """RSA10b, RSA10j: an explicit clientId reaches the callback, and a conflicting token fails 40102.""" + received = [] + client = callback_client(received, 'explicit') + token_params = {'client_id': 'explicit'} + try: + with pytest.raises(IncompatibleClientIdException) as excinfo: + await client.auth.authorize(token_params) + assert excinfo.value.code == 40102 + assert received[0]['client_id'] == 'explicit' + assert token_params == {'client_id': 'explicit'} + finally: + await client.close() diff --git a/test/uts/deviations.md b/test/uts/deviations.md index 44b6cb40..0fb04148 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -1905,7 +1905,6 @@ which is about a client that *does* configure `clientId: "*"`. | RSA15a, RSA15c | A mismatch between `ClientOptions.clientId` and a statically supplied `TokenDetails.clientId` is never detected. `Auth.__init__` only falls back to the token's clientId; `_configure_client_id`, which would raise, is reached only after a *fetched* token | | RSA12a | A token with a **null** clientId is rejected when `ClientOptions.clientId` is set, with 40102 "Client ID cannot be changed to 'None'". RSA15a constrains only non-wildcard token clientIds. Needs a `new_client_id is not None` guard | | RSA7, RSA16c | A clientId learned from a token is treated as immutable, so `authorize()` to a token with a different clientId raises 40102. RSA15 scopes immutability to a clientId set in `ClientOptions`. `_configure_client_id` uses `self.client_id or self.auth_options.client_id`, conflating the two. Possibly deliberate — worth a maintainer's call | -| RSA10b, RSA10h, RSA10j | `authorize()` overwrites an explicit `tokenParams.clientId`. `Auth._ensure_valid_auth_credentials` assigns `self.client_id` over whatever the caller passed, whenever the client has one. RSA10h makes it the default "if not null" | | RSA5c, RSA6c | `create_token_request()` ignores `default_token_params`. The merge lives in `Auth.request_token` only, so a direct call yields `ttl=None` and `capability=None`. RSA5, RSA5b, RSA5d, RSA6, RSA6b and RSA6d all pass, so the nullability requirement itself is met | | RSA16b | `TokenDetails` built from a bare token string fabricates `expires` (now plus an hour), `issued` (0) and `capability`. RSA16b requires only `token` to be populated. The invented expiry can drive spurious renewal | | RSA16c | No local expiry detection without a server time offset, which an authCallback client never obtains. See the RSA4b1 spec error above; here the specification asserts renewal *does* happen, so there is no green reading | diff --git a/test/uts/rest/unit/auth/authorize_test.py b/test/uts/rest/unit/auth/authorize_test.py index 5d1f50d3..e7aff225 100644 --- a/test/uts/rest/unit/auth/authorize_test.py +++ b/test/uts/rest/unit/auth/authorize_test.py @@ -99,12 +99,10 @@ async def mock_auth_callback(params): ) client = rest_client(mock_http, auth_callback=mock_auth_callback, client_id='default-client') - # DEVIATION: `Auth._ensure_valid_auth_credentials` overwrites the caller's - # `client_id` with `Auth.client_id` unconditionally, so the callback is handed - # 'default-client'. RSA10h has `Auth#clientId` supply a default only, and RSA10j - # has explicit `TokenParams` supersede configured ones. The token that comes - # back then carries no clientId, which `Auth._configure_client_id` rejects with - # IncompatibleClientIdException 40102 before the assertions are reached. + # DEVIATION: the token that comes back carries no clientId, which + # `Auth._configure_client_id` reads as an attempt to change the configured + # clientId and rejects with IncompatibleClientIdException 40102 before the + # assertions are reached. This is the RSA12a mechanism in client_id_test.py. await client.auth.authorize({'client_id': 'override-client', 'ttl': 7200000}) params = callback_params[0] From 5f2a64ad3fa5aaf386e3faedc933ab18c1b08e81 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 19:07:25 +0000 Subject: [PATCH 05/10] pubsub: reject a token_details whose clientId conflicts with client_id A client built with a client_id and a token_details naming a different, non-wildcard clientId was never checked: Auth.__init__ only fell back to the token's clientId when none was configured, and _configure_client_id, which raises on a conflict, runs only after a fetched token. An HTTP client then made requests under a token for the other clientId. RSA15a requires the configured clientId to match any non-wildcard clientId in the TokenDetails, and RSA15c requires an error response. Auth.__init__ now raises IncompatibleClientIdException with 40102, the code _configure_client_id already uses for the same conflict, when token auth is selected and the token's clientId is known and conflicts. The check runs at construction, which the RSA7 UTS test explicitly permits. A wildcard or absent token clientId passes (RSA15b). A bare token string carries no clientId the client can read, so it is checked where RSA15a says: against the CONNECTED connectionDetails on a realtime client, which already fails the connection with 40102. A REST client has nothing to check it against. This is a breaking change: such a client now fails to construct, and a realtime one no longer gets as far as connecting. Callers make the two clientIds agree or drop client_id. The RSA15a UTS test is un-gated, the RSA7 mismatch and RSA15c ones expect the error from the constructor, and the legacy realtime mismatch test expects it there too. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 22 ++++++++++++++ ably/pubsub/http/auth.py | 6 ++++ ably/pubsub/server/__init__.py | 2 ++ test/ably/realtime/realtimeauth_test.py | 18 +++++------ test/uts/deviations.md | 17 +++++------ test/uts/rest/unit/auth/client_id_test.py | 37 ++++++++++------------- 6 files changed, 61 insertions(+), 41 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index a779b422..0cd5b7f5 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -210,6 +210,28 @@ Example 4.0.0 code: await pubsub_http_client.auth.authorize({'ttl': 3600000}) ``` +### A `token_details` whose client ID conflicts with `client_id` is rejected + +Creating a client with both a `client_id` and a `token_details` whose own client ID is a different +one, and is not the wildcard `*`, now raises an `IncompatibleClientIdException` (code 40102) from +the factory. 3.x built the client: an HTTP client then sent requests under a token for the other +client ID, and a realtime client failed only once it connected. Make the two agree, or leave +`client_id` out and let the client take the token's. + +Example 3.x code: +```python +rest = AblyRest(token_details=token_details_for_bob, client_id='alice') +``` + +Example 4.0.0 code: +```python +pubsub_http_client = create_http_client(token_details=token_details_for_bob) +``` + +A bare `token` string carries no client ID the client can read, so it cannot be checked when the +client is created. A realtime client still checks it against the client ID the server reports +when the connection opens, and fails the connection with 40102 if they conflict. + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index f19a1a68..f049343c 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -70,6 +70,12 @@ def __init__(self, ably: DefaultPubSubHttpClient | DefaultPubSubRealtimeClient, if options.token_details: self.__token_details = options.token_details + # RSA15a, RSA15c: the token's clientId is known before any request, so check it now + token_client_id = options.token_details.client_id + if options.client_id is not None and token_client_id not in (None, '*', options.client_id): + raise IncompatibleClientIdException( + f"clientId '{options.client_id}' in the client options does not match the " + f"token's clientId '{token_client_id}'", 400, 40102) elif options.auth_token: self.__token_details = TokenDetails(token=options.auth_token) else: diff --git a/ably/pubsub/server/__init__.py b/ably/pubsub/server/__init__.py index 84fb2779..1915935c 100644 --- a/ably/pubsub/server/__init__.py +++ b/ably/pubsub/server/__init__.py @@ -170,6 +170,8 @@ def create_http_client(**kwargs) -> PubSubHttpClient: AblyException If no means of authenticating is given, or if `endpoint` is combined with `environment`, `rest_host` or `realtime_host`. + IncompatibleClientIdException + If `client_id` conflicts with the client ID of `token_details`. """ with factory_construction(): return _DefaultPubSubHttpClient(**kwargs) diff --git a/test/ably/realtime/realtimeauth_test.py b/test/ably/realtime/realtimeauth_test.py index 2d5399fb..781e379b 100644 --- a/test/ably/realtime/realtimeauth_test.py +++ b/test/ably/realtime/realtimeauth_test.py @@ -10,6 +10,7 @@ from ably.pubsub.types.channelstate import ChannelState from ably.pubsub.types.connectionstate import ConnectionEvent from ably.pubsub.types.tokendetails import TokenDetails +from ably.pubsub.util.exceptions import IncompatibleClientIdException from test.ably.testapp import TestApp from test.ably.utils import BaseAsyncTestCase, random_string @@ -625,25 +626,20 @@ async def auth_callback(_): await realtime.close() await rest.close() - # Rest token generation with client_id, then connecting with a - # different client_id, should fail with a library-generated message - # (RSA15a, RSA15c) + # Rest token generation with client_id, then constructing a client with a + # different client_id, should fail with a library-generated error before + # connecting (RSA15a, RSA15c) async def test_auth_client_id_mismatch(self): rest = await TestApp.get_ably_rest() client_id = 'test_client_id' token_details = await rest.auth.request_token({"client_id": client_id}) - realtime = await TestApp.get_ably_realtime(token_details=token_details, client_id="WRONG") + with pytest.raises(IncompatibleClientIdException) as excinfo: + await TestApp.get_ably_realtime(token_details=token_details, client_id="WRONG") - # RTC17: the configured clientId holds before CONNECTED - assert realtime.auth.client_id == "WRONG" - - state_change = await realtime.connection.once_async(ConnectionState.FAILED) + assert excinfo.value.code == 40102 - assert state_change.reason.code == 40102 - - await realtime.close() await rest.close() # Rest token generation with clientId '*', then connecting with just the diff --git a/test/uts/deviations.md b/test/uts/deviations.md index 0fb04148..d91c85f7 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -33,8 +33,8 @@ parametrized over a table of fixtures the specification gives inline; and five `objects/unit` tests are parametrized, three over a table and two over the scenarios the specification loops through. That turns 1481 derived tests into 1608 pytest cases. -Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1290 Test IDs -(1299 tests, 1424 cases) pass, 167 (167 tests, 169 cases) are gated behind +Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1293 Test IDs +(1302 tests, 1427 cases) pass, 164 (164 tests, 166 cases) are gated behind `RUN_DEVIATIONS`, and 15 (15 tests, 15 cases) cannot be run at all. The three groups are disjoint: two Test IDs have a gated part and a passing part — `TI1`, as two functions, and `TO/endpoint-affects-host`, as one parametrized test — and are counted with the gated, @@ -45,19 +45,19 @@ the Test IDs come from `uts/rest/unit` (503 tests, 536 cases), 482 from `uts/rest/integration` (84, 122), 73 from `uts/realtime/integration` (73, 95) and 17 from `uts/objects/integration` (17, 29); 8 of the REST integration ids (8, 8), 30 of the realtime ones (30, 30) and 5 of the objects ones (5, 5) come from the `proxy` package -within each. Of the gated Test IDs 79 are REST and 88 realtime, which is 80 REST cases +within each. Of the gated Test IDs 76 are REST and 88 realtime, which is 77 REST cases and 89 realtime. None of the 339 objects Test IDs is gated or unrunnable. A further 148 pytest cases cover the harness itself and are not derived from a specification: 130 under `helpers/`, for the mocks, and 18 under `objects/helpers/`, for the objects builders and the synced-channel mock. -The 156 gated Test IDs that record SDK non-compliance — 156 tests, 158 cases — reduce to -**68 distinct root causes**, 26 on the REST side and 42 on the realtime side. Three further +The 153 gated Test IDs that record SDK non-compliance — 153 tests, 155 cases — reduce to +**67 distinct root causes**, 25 on the REST side and 42 on the realtime side. Three further defects are recorded below with no test of their own, because the specification's test cannot discriminate (RTP18a), has nothing to assert against (the timezone split on synthesized LEAVE timestamps), or is worked around in the setup of every test that would otherwise trip over it (`enterClient` on an anonymous connection), so the file -carries **71 SDK root causes** in all. The remaining 11 gated Test IDs are +carries **70 SDK root causes** in all. The remaining 11 gated Test IDs are specification faults, and reduce to 8. The objects tier records no SDK non-compliance; its specification faults are all corrected or worked around in tests that pass, and reduce to the seven candidate issues O.1–O.7, none of them filed. @@ -1902,7 +1902,6 @@ which is about a client that *does* configure `clientId: "*"`. | Spec points | Behaviour | |---|---| -| RSA15a, RSA15c | A mismatch between `ClientOptions.clientId` and a statically supplied `TokenDetails.clientId` is never detected. `Auth.__init__` only falls back to the token's clientId; `_configure_client_id`, which would raise, is reached only after a *fetched* token | | RSA12a | A token with a **null** clientId is rejected when `ClientOptions.clientId` is set, with 40102 "Client ID cannot be changed to 'None'". RSA15a constrains only non-wildcard token clientIds. Needs a `new_client_id is not None` guard | | RSA7, RSA16c | A clientId learned from a token is treated as immutable, so `authorize()` to a token with a different clientId raises 40102. RSA15 scopes immutability to a clientId set in `ClientOptions`. `_configure_client_id` uses `self.client_id or self.auth_options.client_id`, conflating the two. Possibly deliberate — worth a maintainer's call | | RSA5c, RSA6c | `create_token_request()` ignores `default_token_params`. The merge lives in `Auth.request_token` only, so a direct call yields `ttl=None` and `capability=None`. RSA5, RSA5b, RSA5d, RSA6, RSA6b and RSA6d all pass, so the nullability requirement itself is met | @@ -3704,8 +3703,8 @@ The header states how many derived tests there are, how many pass, how many are gated and how many cannot run. Those numbers are the check that the file is still true: in pytest cases, the gated count must equal the number of failures under `RUN_DEVIATIONS=1`, and gated plus unrunnable must equal the number of skips without -it. As of this writing that is 169 failures, 1572 passes and 15 skips with the variable -set, and 184 skips and 1572 passes without it, the 1572 being 1424 derived cases and 148 +it. As of this writing that is 166 failures, 1575 passes and 15 skips with the variable +set, and 181 skips and 1575 passes without it, the 1575 being 1427 derived cases and 148 harness ones — 130 under `helpers/` and 18 under `objects/helpers/`. Those figures need the `submodules` checkout CI makes: without it, three `rest/unit/encoding` tests that read the ably-common fixtures skip as well. The tiers are told apart by the junit report's diff --git a/test/uts/rest/unit/auth/client_id_test.py b/test/uts/rest/unit/auth/client_id_test.py index 7c293d48..2a9a072c 100644 --- a/test/uts/rest/unit/auth/client_id_test.py +++ b/test/uts/rest/unit/auth/client_id_test.py @@ -201,19 +201,18 @@ async def test_rsa12_wildcard_clientid(): # UTS: rest/unit/RSA7/clientid-mismatch-error-1 -@deviation async def test_rsa7_clientid_mismatch_error(): # Case 2 of the spec's table: ClientOptions 'client-a' against a token - # naming 'client-b'. ably-python never compares the two for a statically - # supplied TokenDetails, so no error is reported at construction or at use. - client = rest_client(channel_details_mock(), client_id='client-a', - token_details=TokenDetails( - token='mismatched-token', - expires=now() + 3600000, - client_id='client-b', - )) - + # naming 'client-b'. The specification leaves the timing of the detection to + # the implementation, constructor or first use; ably-python detects it at + # construction, so the constructor is what fails. with pytest.raises(AblyException) as excinfo: + client = rest_client(channel_details_mock(), client_id='client-a', + token_details=TokenDetails( + token='mismatched-token', + expires=now() + 3600000, + client_id='client-b', + )) await client.channels.get('test').status() message = excinfo.value.message or '' @@ -221,7 +220,6 @@ async def test_rsa7_clientid_mismatch_error(): # UTS: rest/unit/RSA15a/token-clientid-must-match-0 -@deviation async def test_rsa15a_token_clientid_must_match(): client_match = rest_client(channel_details_mock(), client_id='my-client', token_details=TokenDetails( @@ -232,7 +230,6 @@ async def test_rsa15a_token_clientid_must_match(): assert client_match.auth.client_id == 'my-client' - # ably-python constructs this client without complaint. with pytest.raises(AblyException) as excinfo: rest_client(channel_details_mock(), client_id='my-client', token_details=TokenDetails( @@ -257,19 +254,17 @@ async def test_rsa15b_wildcard_token_permits_any(): # UTS: rest/unit/RSA15c/incompatible-clientid-error-0 -@deviation async def test_rsa15c_incompatible_clientid_error(): # The spec defers to the RSA15a mismatch case: a REST operation under a # TokenDetails whose clientId is incompatible must fail with code 40102. - # ably-python performs the operation instead. - client = rest_client(channel_details_mock(), client_id='client-a', - token_details=TokenDetails( - token='incompatible-token', - expires=now() + 3600000, - client_id='client-b', - )) - + # ably-python detects the mismatch at construction, as in RSA7 above. with pytest.raises(AblyException) as excinfo: + client = rest_client(channel_details_mock(), client_id='client-a', + token_details=TokenDetails( + token='incompatible-token', + expires=now() + 3600000, + client_id='client-b', + )) await client.channels.get('test').status() assert excinfo.value.code == 40102 From 94b2bdef8aa65bc95d44025fcd9ce816a2ab0384 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 19:10:12 +0000 Subject: [PATCH 06/10] pubsub: make TokenDetails.capability the server's JSON string TokenDetails parsed its capability into a Capability mapping, so a token's capability could not be read as the JSON string TD5 and RSA16a describe, and a missing one became an empty Capability rather than None. Separately, Capability.add_operation_to_resource always raised AttributeError: Capability stores each resource's operations as a set, and the method called .append on it. TokenDetails.capability is now a str holding the capability exactly as the server sent it, or None. A dict or Capability passed to the constructor is serialised as canonical JSON, and to_dict() returns the string. Capability stays the builder for the capability token param and is still canonicalised before a TokenRequest is signed (RSA9f). add_operation_to_resource now adds the operation to the set. Why as received rather than canonicalised: TD5 says the attribute "contains the capability JSON stringified" and TC2 that "a string representation of the JSON will suffice", and nothing in features.md asks a client to canonicalise what the server returns; c14n appears only for TokenRequest signing. The UTS specs assert the literal server string (rest/unit/RSA16a/reflects-capability-1, rest/unit/TD/ token-details-from-json-0), which json.dumps canonicalisation would fail on its separators alone. ably-js (TokenDetails.capability: string, the response passed through JSON.parse), ably-java (a String deserialised by gson, Capability.c14n applied only to outgoing params), ably-cocoa (NSString via artString) and ably-go (a string struct field) all store it as received. Only ably-dotnet parses and re-serialises it. This is a breaking change: code that indexed or compared token_details.capability as a Capability wraps it in Capability(...), and to_dict() now gives a string, so an auth server returning to_dict() from an auth_url sends the capability in the wire form rather than as a JSON object. The TD and RSA16a UTS tests now assert the specification's strings, and the legacy capability and token tests wrap the attribute. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 27 +++++++++++++++++++ ably/pubsub/types/capability.py | 2 +- ably/pubsub/types/tokendetails.py | 7 +++-- test/ably/http/httpcapability_test.py | 18 ++++++------- test/ably/http/httptoken_test.py | 8 +++--- test/uts/deviations.md | 1 - test/uts/rest/unit/auth/token_details_test.py | 13 ++------- test/uts/rest/unit/types/token_types_test.py | 6 ++--- 8 files changed, 50 insertions(+), 32 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 0cd5b7f5..5acc14ef 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -232,6 +232,33 @@ A bare `token` string carries no client ID the client can read, so it cannot be client is created. A realtime client still checks it against the client ID the server reports when the connection opens, and fails the connection with 40102 if they conflict. +### `TokenDetails.capability` is a JSON string + +`TokenDetails.capability` now holds the capability as the JSON string the Ably service returned, +exactly as it was sent, or `None` where none is known. 3.x parsed it into a `Capability`. Wrap +the string in `Capability` where you indexed or compared it: + +Example 3.x code: +```python +token_details = await rest.auth.request_token() +operations = token_details.capability['my-channel'] +``` + +Example 4.0.0 code: +```python +from ably.pubsub.server import Capability + +token_details = await pubsub_http_client.auth.request_token() +operations = Capability(token_details.capability)['my-channel'] +``` + +`Capability` is unchanged as the way to build a `capability` token param, and its +`add_operation_to_resource` method now works where 3.x raised `AttributeError`. A `TokenDetails` +you construct yourself with a dict or `Capability` stores it as canonical JSON, and +`TokenDetails.to_dict()` now gives the capability as that string rather than as a dict. So an +auth server that returns `to_dict()` to its clients from an `auth_url` now sends the capability +as a JSON string, the form the Ably service itself returns, rather than as a JSON object. + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/types/capability.py b/ably/pubsub/types/capability.py index 4f931466..1a7afacc 100644 --- a/ably/pubsub/types/capability.py +++ b/ably/pubsub/types/capability.py @@ -76,7 +76,7 @@ def add_resource(self, resource, operations=None): self[resource] = list(operations) def add_operation_to_resource(self, operation, resource): - self.setdefault(resource, []).append(operation) + self[resource] = self.get(resource, set()) | {operation} def __str__(self): return Capability.c14n(self) diff --git a/ably/pubsub/types/tokendetails.py b/ably/pubsub/types/tokendetails.py index 29c7dd24..18ea8cea 100644 --- a/ably/pubsub/types/tokendetails.py +++ b/ably/pubsub/types/tokendetails.py @@ -20,7 +20,10 @@ def __init__(self, token=None, expires=None, issued=0, self.__expires = expires self.__token = token self.__issued = issued - self.__capability = Capability(capability or {}) + # TD5, RSA16a: the capability JSON as the server sent it, not a canonical form + if capability is not None and not isinstance(capability, str): + capability = str(Capability(capability)) + self.__capability = capability self.__client_id = client_id @property @@ -48,7 +51,7 @@ def to_dict(self): 'expires': self.expires, 'token': self.token, 'issued': self.issued, - 'capability': self.capability.to_dict(), + 'capability': self.capability, 'clientId': self.client_id, } diff --git a/test/ably/http/httpcapability_test.py b/test/ably/http/httpcapability_test.py index 7e96a57d..c40b7ecc 100644 --- a/test/ably/http/httpcapability_test.py +++ b/test/ably/http/httpcapability_test.py @@ -23,7 +23,7 @@ async def test_blanket_intersection_with_key(self): token_details = await self.ably.auth.request_token(key_name=key['key_name'], key_secret=key['key_secret']) expected_capability = Capability(key["capability"]) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability." + assert expected_capability == Capability(token_details.capability), "Unexpected capability." async def test_equal_intersection_with_key(self): key = self.test_vars['keys'][1] @@ -36,7 +36,7 @@ async def test_equal_intersection_with_key(self): expected_capability = Capability(key["capability"]) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability" + assert expected_capability == Capability(token_details.capability), "Unexpected capability" @dont_vary_protocol async def test_empty_ops_intersection(self): @@ -74,7 +74,7 @@ async def test_non_empty_ops_intersection(self): token_details = await self.ably.auth.request_token(token_params, **kwargs) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability" + assert expected_capability == Capability(token_details.capability), "Unexpected capability" async def test_non_empty_paths_intersection(self): key = self.test_vars['keys'][4] @@ -97,7 +97,7 @@ async def test_non_empty_paths_intersection(self): token_details = await self.ably.auth.request_token(token_params, **kwargs) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability" + assert expected_capability == Capability(token_details.capability), "Unexpected capability" async def test_wildcard_ops_intersection(self): key = self.test_vars['keys'][4] @@ -119,7 +119,7 @@ async def test_wildcard_ops_intersection(self): token_details = await self.ably.auth.request_token(token_params, **kwargs) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability" + assert expected_capability == Capability(token_details.capability), "Unexpected capability" async def test_wildcard_ops_intersection_2(self): key = self.test_vars['keys'][4] @@ -141,7 +141,7 @@ async def test_wildcard_ops_intersection_2(self): token_details = await self.ably.auth.request_token(token_params, **kwargs) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability" + assert expected_capability == Capability(token_details.capability), "Unexpected capability" async def test_wildcard_resources_intersection(self): key = self.test_vars['keys'][2] @@ -163,7 +163,7 @@ async def test_wildcard_resources_intersection(self): token_details = await self.ably.auth.request_token(token_params, **kwargs) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability" + assert expected_capability == Capability(token_details.capability), "Unexpected capability" async def test_wildcard_resources_intersection_2(self): key = self.test_vars['keys'][2] @@ -185,7 +185,7 @@ async def test_wildcard_resources_intersection_2(self): token_details = await self.ably.auth.request_token(token_params, **kwargs) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability" + assert expected_capability == Capability(token_details.capability), "Unexpected capability" async def test_wildcard_resources_intersection_3(self): key = self.test_vars['keys'][2] @@ -208,7 +208,7 @@ async def test_wildcard_resources_intersection_3(self): token_details = await self.ably.auth.request_token(token_params, **kwargs) assert token_details.token is not None, "Expected token" - assert expected_capability == token_details.capability, "Unexpected capability" + assert expected_capability == Capability(token_details.capability), "Unexpected capability" @dont_vary_protocol async def test_invalid_capabilities(self): diff --git a/test/ably/http/httptoken_test.py b/test/ably/http/httptoken_test.py index 2406f120..1e9f0440 100644 --- a/test/ably/http/httptoken_test.py +++ b/test/ably/http/httptoken_test.py @@ -38,7 +38,7 @@ async def test_request_token_null_params(self): assert token_details.token is not None, "Expected token" assert token_details.issued + 300 >= pre_time, "Unexpected issued time" assert token_details.issued <= post_time + 500, "Unexpected issued time" - assert self.permit_all == str(token_details.capability), "Unexpected capability" + assert self.permit_all == str(Capability(token_details.capability)), "Unexpected capability" async def test_request_token_explicit_timestamp(self): pre_time = await self.server_time() @@ -87,7 +87,7 @@ async def test_request_token_with_capability_that_subsets_key_capability(self): assert token_details is not None assert token_details.token is not None - assert capability == token_details.capability, "Unexpected capability" + assert capability == Capability(token_details.capability), "Unexpected capability" async def test_request_token_with_specified_key(self): test_vars = await TestApp.get_test_vars() @@ -95,7 +95,7 @@ async def test_request_token_with_specified_key(self): token_details = await self.ably.auth.request_token( key_name=key["key_name"], key_secret=key["key_secret"]) assert token_details.token is not None, "Expected token" - assert key.get("capability") == token_details.capability, "Unexpected capability" + assert key.get("capability") == Capability(token_details.capability), "Unexpected capability" @dont_vary_protocol async def test_request_token_with_invalid_mac(self): @@ -309,7 +309,7 @@ async def auth_callback(token_params): token = await ably.auth.authorize() - assert str(token.capability) == str(capability) + assert str(Capability(token.capability)) == str(capability) await ably.close() @dont_vary_protocol diff --git a/test/uts/deviations.md b/test/uts/deviations.md index d91c85f7..c2d72810 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -2366,7 +2366,6 @@ pseudocode is not mistaken for non-compliance. | RSC19e | An error indicated idiomatically | `httpx.ConnectError` / `ReadTimeout` reach the caller unwrapped, because `AblyRest.request` carries no `@catch_all` unlike `time()` and `stats()`. The messages do name the failure | Borderline; defensible under RSC19e | | RSC15a | Six hosts tried | Three. `Options.__get_hosts` truncates to `http_max_retry_count`, which TO3l5 sanctions | Intentional | | TI | `ErrorInfo` equality by attributes | No `__eq__`, so errors compare by identity. Python exceptions conventionally do, and the requirement appears nowhere in `features.md`. Adding `__eq__` without `__hash__` would make `AblyException` unhashable and break any caller that puts one in a set | Intentional | -| TD5, RSA16a | `capability` is stringified JSON | A `Capability` object, a public convenience type used throughout `auth`. Narrowing the return type to `str` would break every caller that indexes or mutates it, so it is reserved for a future major | Intentional; a breaking change to align | | RSA6b, RSA6d | The capability literal as passed | Canonicalised by `Capability.c14n`, which RSA9f requires | Compliant; rendering only | | RSA8d | `error.message` contains the callback's text | Wrapped as 40170 with the original in `cause`; `__str__` renders both | Rendering | | CHM2 | Missing metrics default to 0 | `ChannelMetrics.from_dict` uses a bare `obj.get(name)`, so any omitted metric parses as `None` | Open bug, broader than CHM2g/h | diff --git a/test/uts/rest/unit/auth/token_details_test.py b/test/uts/rest/unit/auth/token_details_test.py index d4031743..a37cb852 100644 --- a/test/uts/rest/unit/auth/token_details_test.py +++ b/test/uts/rest/unit/auth/token_details_test.py @@ -8,7 +8,6 @@ import pytest from ably.pubsub.types.authoptions import AuthOptions -from ably.pubsub.types.capability import Capability from ably.pubsub.types.tokendetails import TokenDetails from ably.pubsub.util.exceptions import AblyException from test.uts.helpers.client import rest_client @@ -428,13 +427,5 @@ async def auth_callback(params): assert client.auth.token_details is not None - # DEVIATION: the spec asserts the stringified JSON - # '{"channel1":["publish","subscribe"],"channel2":["subscribe"]}'. - # `TokenDetails.capability` is a parsed `Capability` throughout ably-python, an - # established SDK-wide choice, so the same capability is asserted in that form. - capability = client.auth.token_details.capability - assert isinstance(capability, Capability) - assert capability.to_dict() == { - 'channel1': ['publish', 'subscribe'], - 'channel2': ['subscribe'], - } + assert client.auth.token_details.capability == ( + '{"channel1":["publish","subscribe"],"channel2":["subscribe"]}') diff --git a/test/uts/rest/unit/types/token_types_test.py b/test/uts/rest/unit/types/token_types_test.py index 243291b3..0cbb5e3a 100644 --- a/test/uts/rest/unit/types/token_types_test.py +++ b/test/uts/rest/unit/types/token_types_test.py @@ -50,9 +50,7 @@ def test_td1_token_details_attributes(): expires=1234567890000, capability='{"*":["*"]}', ) - # NOTE: the spec asserts capability IS the JSON string. ably-python parses it into a - # Capability mapping, which stringifies back to the same JSON. - assert json.loads(str(token_with_capability.capability)) == {'*': ['*']} + assert token_with_capability.capability == '{"*":["*"]}' # TD5 - clientId attribute token_with_client = TokenDetails( @@ -79,7 +77,7 @@ def test_td_token_details_from_json(): assert token_details.token == 'deserialized-token' assert token_details.expires == 1234567890000 assert token_details.issued == 1234567800000 - assert json.loads(str(token_details.capability)) == {'channel-1': ['publish']} + assert token_details.capability == '{"channel-1":["publish"]}' assert token_details.client_id == 'json-client' From 539b4d13b99ae74497f74f69b1745011eb98c232 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 19:12:26 +0000 Subject: [PATCH 07/10] pubsub: stop inventing expires and issued for a bare token string TokenDetails defaulted expires to the local time plus an hour, read from time.time() rather than the clock, and issued to 0. So a TokenDetails known only by its token string, whether passed as token, returned as a bare string or JWT by an auth_callback or auth_url, or constructed directly, reported an expiry nobody had stated. RSA16b requires only the token attribute to be populated in that case. expires and issued now default to None, as capability already does since the previous commit. Every reader of expires handles None: token_details_has_expired returns False for it, as it does with no server time offset, which is the only check RSA4b1 permits and the one reauth_if_expired relies on; and the cached-token debug log now formats it with %s rather than %d, which would have raised inside logging. A bare token with a known time offset is therefore no longer renewed an hour after it was wrapped; the server's token error drives its renewal (RSA4b). TokenDetails no longer reads the time at all. This is a breaking change for code that read expires or issued from such a TokenDetails, which must handle None. Nothing on the wire changes. The two RSA16b UTS tests are un-gated. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 20 +++++++++++++++++++ ably/pubsub/http/auth.py | 2 +- ably/pubsub/types/tokendetails.py | 9 +++------ test/uts/deviations.md | 17 ++++++++-------- test/uts/rest/unit/auth/token_details_test.py | 7 ------- 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 5acc14ef..22571096 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -259,6 +259,26 @@ you construct yourself with a dict or `Capability` stores it as canonical JSON, auth server that returns `to_dict()` to its clients from an `auth_url` now sends the capability as a JSON string, the form the Ably service itself returns, rather than as a JSON object. +### A `TokenDetails` known only by its token string no longer invents its details + +A `TokenDetails` built from a token string alone, whether you construct it yourself, pass a +`token`, or have an `auth_callback` or `auth_url` return a bare token string or JWT, now has +`expires`, `issued` and `capability` set to `None`. 3.x invented an expiry an hour after the +`TokenDetails` was created, an `issued` of `0` and an empty capability. Code that read those +values must handle `None`; the client itself does, and leaves it to the Ably service to report +when such a token has expired. + +Example 3.x code: +```python +seconds_left = (rest.auth.token_details.expires - now_ms) / 1000 +``` + +Example 4.0.0 code: +```python +expires = pubsub_http_client.auth.token_details.expires +seconds_left = None if expires is None else (expires - now_ms) / 1000 +``` + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index f049343c..bf910ba1 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -137,7 +137,7 @@ async def _ensure_valid_auth_credentials(self, token_params=None, auth_options=N token_details = self.__token_details if not force and not self.token_details_has_expired(): - log.debug("using cached token; expires = %d", + log.debug("using cached token; expires = %s", token_details.expires) return token_details diff --git a/ably/pubsub/types/tokendetails.py b/ably/pubsub/types/tokendetails.py index 18ea8cea..1baea8be 100644 --- a/ably/pubsub/types/tokendetails.py +++ b/ably/pubsub/types/tokendetails.py @@ -1,5 +1,4 @@ import json -import time from ably.pubsub.types.capability import Capability @@ -12,12 +11,10 @@ class TokenDetails: # new requests 9000ms before it expires TOKEN_EXPIRY_BUFFER = 15 * 1000 - def __init__(self, token=None, expires=None, issued=0, + def __init__(self, token=None, expires=None, issued=None, capability=None, client_id=None): - if expires is None: - self.__expires = time.time() * 1000 + TokenDetails.DEFAULTS['ttl'] - else: - self.__expires = expires + # RSA16b: nothing is invented for a token whose details are unknown + self.__expires = expires self.__token = token self.__issued = issued # TD5, RSA16a: the capability JSON as the server sent it, not a canonical form diff --git a/test/uts/deviations.md b/test/uts/deviations.md index c2d72810..8f5411c0 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -33,8 +33,8 @@ parametrized over a table of fixtures the specification gives inline; and five `objects/unit` tests are parametrized, three over a table and two over the scenarios the specification loops through. That turns 1481 derived tests into 1608 pytest cases. -Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1293 Test IDs -(1302 tests, 1427 cases) pass, 164 (164 tests, 166 cases) are gated behind +Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1295 Test IDs +(1304 tests, 1429 cases) pass, 162 (162 tests, 164 cases) are gated behind `RUN_DEVIATIONS`, and 15 (15 tests, 15 cases) cannot be run at all. The three groups are disjoint: two Test IDs have a gated part and a passing part — `TI1`, as two functions, and `TO/endpoint-affects-host`, as one parametrized test — and are counted with the gated, @@ -45,19 +45,19 @@ the Test IDs come from `uts/rest/unit` (503 tests, 536 cases), 482 from `uts/rest/integration` (84, 122), 73 from `uts/realtime/integration` (73, 95) and 17 from `uts/objects/integration` (17, 29); 8 of the REST integration ids (8, 8), 30 of the realtime ones (30, 30) and 5 of the objects ones (5, 5) come from the `proxy` package -within each. Of the gated Test IDs 76 are REST and 88 realtime, which is 77 REST cases +within each. Of the gated Test IDs 74 are REST and 88 realtime, which is 75 REST cases and 89 realtime. None of the 339 objects Test IDs is gated or unrunnable. A further 148 pytest cases cover the harness itself and are not derived from a specification: 130 under `helpers/`, for the mocks, and 18 under `objects/helpers/`, for the objects builders and the synced-channel mock. -The 153 gated Test IDs that record SDK non-compliance — 153 tests, 155 cases — reduce to -**67 distinct root causes**, 25 on the REST side and 42 on the realtime side. Three further +The 151 gated Test IDs that record SDK non-compliance — 151 tests, 153 cases — reduce to +**66 distinct root causes**, 24 on the REST side and 42 on the realtime side. Three further defects are recorded below with no test of their own, because the specification's test cannot discriminate (RTP18a), has nothing to assert against (the timezone split on synthesized LEAVE timestamps), or is worked around in the setup of every test that would otherwise trip over it (`enterClient` on an anonymous connection), so the file -carries **70 SDK root causes** in all. The remaining 11 gated Test IDs are +carries **69 SDK root causes** in all. The remaining 11 gated Test IDs are specification faults, and reduce to 8. The objects tier records no SDK non-compliance; its specification faults are all corrected or worked around in tests that pass, and reduce to the seven candidate issues O.1–O.7, none of them filed. @@ -1905,7 +1905,6 @@ which is about a client that *does* configure `clientId: "*"`. | RSA12a | A token with a **null** clientId is rejected when `ClientOptions.clientId` is set, with 40102 "Client ID cannot be changed to 'None'". RSA15a constrains only non-wildcard token clientIds. Needs a `new_client_id is not None` guard | | RSA7, RSA16c | A clientId learned from a token is treated as immutable, so `authorize()` to a token with a different clientId raises 40102. RSA15 scopes immutability to a clientId set in `ClientOptions`. `_configure_client_id` uses `self.client_id or self.auth_options.client_id`, conflating the two. Possibly deliberate — worth a maintainer's call | | RSA5c, RSA6c | `create_token_request()` ignores `default_token_params`. The merge lives in `Auth.request_token` only, so a direct call yields `ttl=None` and `capability=None`. RSA5, RSA5b, RSA5d, RSA6, RSA6b and RSA6d all pass, so the nullability requirement itself is met | -| RSA16b | `TokenDetails` built from a bare token string fabricates `expires` (now plus an hour), `issued` (0) and `capability`. RSA16b requires only `token` to be populated. The invented expiry can drive spurious renewal | | RSA16c | No local expiry detection without a server time offset, which an authCallback client never obtains. See the RSA4b1 spec error above; here the specification asserts renewal *does* happen, so there is no green reading | | RSA16d | A failed renewal leaves the invalidated token in place — `_ensure_valid_auth_credentials` assigns only on success | | RSA16d | `authorize()` cannot switch a client back to basic auth: `_ensure_valid_auth_credentials` sets `Method.TOKEN` unconditionally, and `AuthOptions.replace` drops `use_token_auth`, which is stored outside the options dict | @@ -3702,8 +3701,8 @@ The header states how many derived tests there are, how many pass, how many are gated and how many cannot run. Those numbers are the check that the file is still true: in pytest cases, the gated count must equal the number of failures under `RUN_DEVIATIONS=1`, and gated plus unrunnable must equal the number of skips without -it. As of this writing that is 166 failures, 1575 passes and 15 skips with the variable -set, and 181 skips and 1575 passes without it, the 1575 being 1427 derived cases and 148 +it. As of this writing that is 164 failures, 1577 passes and 15 skips with the variable +set, and 179 skips and 1577 passes without it, the 1577 being 1429 derived cases and 148 harness ones — 130 under `helpers/` and 18 under `objects/helpers/`. Those figures need the `submodules` checkout CI makes: without it, three `rest/unit/encoding` tests that read the ably-common fixtures skip as well. The tiers are told apart by the junit report's diff --git a/test/uts/rest/unit/auth/token_details_test.py b/test/uts/rest/unit/auth/token_details_test.py index a37cb852..65db8c68 100644 --- a/test/uts/rest/unit/auth/token_details_test.py +++ b/test/uts/rest/unit/auth/token_details_test.py @@ -104,11 +104,7 @@ def on_request(request): # UTS: rest/unit/RSA16b/token-string-in-options-0 -@deviation async def test_rsa16b_token_string_in_options(): - # DEVIATION: RSA16b requires that only `token` is populated. `TokenDetails.__init__` - # fabricates `expires` from a default one-hour TTL, defaults `issued` to 0 and turns - # a missing capability into an empty `Capability`. mock_http = MockHttpClient( on_connection_attempt=lambda conn: conn.respond_with_success(), on_request=respond_with_channel_details, @@ -126,10 +122,7 @@ async def test_rsa16b_token_string_in_options(): # UTS: rest/unit/RSA16b/token-string-from-callback-1 -@deviation async def test_rsa16b_token_string_from_callback(): - # DEVIATION: as above. `Auth.request_token` wraps a token string in a bare - # `TokenDetails`, which fabricates `expires` and defaults `issued` to 0. async def auth_callback(params): return 'just-a-token-string' From 421dd2f6db5a49497ea62cf1724b06f658890708 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 19:15:02 +0000 Subject: [PATCH 08/10] pubsub: apply default_token_params in create_token_request() create_token_request() read only the token params handed to it, so a direct call ignored default_token_params entirely and signed a request with no ttl or capability (RSA5c, RSA6c). request_token(), meanwhile, merged explicit params over the defaults key by key. Both now take the same rule: with no token params the defaults apply, and given params, even an empty dict, replace the defaults wholesale. That is what RSA8e and RSA9h say ("the values of each attribute are not merged with the configured client library defaults, but rather are used instead of the stored values (even when null)"), what RSA10j says for authorize(), and what authorize() here already did. Replace rather than merge on the evidence of the other SDKs too: ably-js (`tokenParams || Utils.copy(this.tokenParams)` in both _requestTokenImpl and _createTokenRequestImpl), ably-java (`params == null ? this.tokenParams : params.copy()` in requestToken and createTokenRequest), ably-cocoa (`tokenParams ? : [_tokenParams copy]`, commented "they're used instead of stored, don't merge them") and ably-dotnet (`tokenParams ?? CurrentTokenParams ?? ...`) all replace. ably-go replaces in requestToken and never reads DefaultTokenParams in createTokenRequest. None merges key by key. request_token() also copies the params it passes to an auth_callback, as the merge used to. This is a breaking change in both directions. An auth server whose client sets default_token_params and signs with create_token_request() now issues tokens carrying those defaults, typically a shorter ttl or a narrower capability. A request_token() call that names some params no longer inherits the rest from the defaults, so a default capability that restricted its tokens no longer applies. Callers pass every param they want. The RSA5c and RSA6c UTS tests are un-gated, and a mocked test covers both methods. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 28 +++++++++++++++++++ ably/pubsub/http/auth.py | 11 +++++--- test/ably/http/authorizeparams_test.py | 26 +++++++++++++++++ test/uts/deviations.md | 17 ++++++----- .../unit/auth/token_request_params_test.py | 9 +----- 5 files changed, 70 insertions(+), 21 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 22571096..c54b0c90 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -279,6 +279,34 @@ expires = pubsub_http_client.auth.token_details.expires seconds_left = None if expires is None else (expires - now_ms) / 1000 ``` +### `default_token_params` apply to `create_token_request()`, and given params replace them + +`auth.create_token_request()` called without token params now applies the client's +`default_token_params`, as `request_token()` and `authorize()` already did. 3.x ignored them. So +an auth server whose client is configured with `default_token_params` and which signs token +requests with `create_token_request()` now issues tokens with those defaults, typically a shorter +`ttl` or a narrower `capability` than before. Check that the defaults are what you want your +clients' tokens to carry, or remove them. + +Token params passed to `create_token_request()` or `request_token()` now replace the defaults +entirely, as they already did for `authorize()` and as every other Ably SDK does. 3.x merged them +over the defaults key by key in `request_token()`. Take care where a default `capability` or +`ttl` restricted the tokens: a `request_token()` call that names other params no longer carries +them, so its token gets the key's full capability and the service's default `ttl`. Pass every +param you want, including the defaults you still want: + +Example 3.x code: +```python +rest = AblyRest(key='xxx', default_token_params={'capability': capability}) +await rest.auth.request_token({'ttl': 600000}) # carried the default capability +``` + +Example 4.0.0 code: +```python +pubsub_http_client = create_http_client(key='xxx', default_token_params={'capability': capability}) +await pubsub_http_client.auth.request_token({'ttl': 600000, 'capability': capability}) +``` + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index bf910ba1..f9de2ced 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -173,9 +173,10 @@ async def request_token(self, token_params: dict | None = None, auth_url: str | None = None, auth_method: str | None = None, auth_headers: dict | None = None, auth_params: dict | None = None, query_time=None): - token_params = token_params or {} - token_params = dict(self.auth_options.default_token_params, - **token_params) + # RSA8e: given params replace the defaults rather than merging with them + if token_params is None: + token_params = self.auth_options.default_token_params + token_params = dict(token_params) key_name = key_name or self.auth_options.key_name key_secret = key_secret or self.auth_options.key_secret @@ -246,7 +247,9 @@ async def request_token(self, token_params: dict | None = None, async def create_token_request(self, token_params: dict | str | None = None, key_name: str | None = None, key_secret: str | None = None, query_time=None): - token_params = token_params or {} + # RSA5c, RSA6c, RSA9h: the defaults apply when no params are given, and given params replace them + if token_params is None: + token_params = dict(self.auth_options.default_token_params) token_request = {} key_name = key_name or self.auth_options.key_name diff --git a/test/ably/http/authorizeparams_test.py b/test/ably/http/authorizeparams_test.py index 79bbb9bf..1c27c289 100644 --- a/test/ably/http/authorizeparams_test.py +++ b/test/ably/http/authorizeparams_test.py @@ -43,3 +43,29 @@ async def test_authorize_keeps_an_explicit_client_id(): assert token_params == {'client_id': 'explicit'} finally: await client.close() + + +async def test_given_token_params_replace_the_defaults(): + """RSA8e, RSA9h: default_token_params apply only when no params are given.""" + received = [] + + async def auth_callback(token_params): + received.append(token_params) + return 'a-token' + + defaults = {'ttl': 1800000, 'capability': '{"*":["subscribe"]}'} + key_client = create_http_client(key='app.key:secret', default_token_params=dict(defaults)) + callback_client = create_http_client(auth_callback=auth_callback, default_token_params=dict(defaults)) + try: + token_request = await key_client.auth.create_token_request() + assert (token_request.ttl, token_request.capability) == (1800000, '{"*": ["subscribe"]}') + + token_request = await key_client.auth.create_token_request({'ttl': 600000}) + assert (token_request.ttl, token_request.capability) == (600000, None) + + await callback_client.auth.request_token() + await callback_client.auth.request_token({'ttl': 600000}) + assert received == [defaults, {'ttl': 600000}] + finally: + await key_client.close() + await callback_client.close() diff --git a/test/uts/deviations.md b/test/uts/deviations.md index 8f5411c0..00db93e1 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -33,8 +33,8 @@ parametrized over a table of fixtures the specification gives inline; and five `objects/unit` tests are parametrized, three over a table and two over the scenarios the specification loops through. That turns 1481 derived tests into 1608 pytest cases. -Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1295 Test IDs -(1304 tests, 1429 cases) pass, 162 (162 tests, 164 cases) are gated behind +Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1297 Test IDs +(1306 tests, 1431 cases) pass, 160 (160 tests, 162 cases) are gated behind `RUN_DEVIATIONS`, and 15 (15 tests, 15 cases) cannot be run at all. The three groups are disjoint: two Test IDs have a gated part and a passing part — `TI1`, as two functions, and `TO/endpoint-affects-host`, as one parametrized test — and are counted with the gated, @@ -45,19 +45,19 @@ the Test IDs come from `uts/rest/unit` (503 tests, 536 cases), 482 from `uts/rest/integration` (84, 122), 73 from `uts/realtime/integration` (73, 95) and 17 from `uts/objects/integration` (17, 29); 8 of the REST integration ids (8, 8), 30 of the realtime ones (30, 30) and 5 of the objects ones (5, 5) come from the `proxy` package -within each. Of the gated Test IDs 74 are REST and 88 realtime, which is 75 REST cases +within each. Of the gated Test IDs 72 are REST and 88 realtime, which is 73 REST cases and 89 realtime. None of the 339 objects Test IDs is gated or unrunnable. A further 148 pytest cases cover the harness itself and are not derived from a specification: 130 under `helpers/`, for the mocks, and 18 under `objects/helpers/`, for the objects builders and the synced-channel mock. -The 151 gated Test IDs that record SDK non-compliance — 151 tests, 153 cases — reduce to -**66 distinct root causes**, 24 on the REST side and 42 on the realtime side. Three further +The 149 gated Test IDs that record SDK non-compliance — 149 tests, 151 cases — reduce to +**65 distinct root causes**, 23 on the REST side and 42 on the realtime side. Three further defects are recorded below with no test of their own, because the specification's test cannot discriminate (RTP18a), has nothing to assert against (the timezone split on synthesized LEAVE timestamps), or is worked around in the setup of every test that would otherwise trip over it (`enterClient` on an anonymous connection), so the file -carries **69 SDK root causes** in all. The remaining 11 gated Test IDs are +carries **68 SDK root causes** in all. The remaining 11 gated Test IDs are specification faults, and reduce to 8. The objects tier records no SDK non-compliance; its specification faults are all corrected or worked around in tests that pass, and reduce to the seven candidate issues O.1–O.7, none of them filed. @@ -1904,7 +1904,6 @@ which is about a client that *does* configure `clientId: "*"`. |---|---| | RSA12a | A token with a **null** clientId is rejected when `ClientOptions.clientId` is set, with 40102 "Client ID cannot be changed to 'None'". RSA15a constrains only non-wildcard token clientIds. Needs a `new_client_id is not None` guard | | RSA7, RSA16c | A clientId learned from a token is treated as immutable, so `authorize()` to a token with a different clientId raises 40102. RSA15 scopes immutability to a clientId set in `ClientOptions`. `_configure_client_id` uses `self.client_id or self.auth_options.client_id`, conflating the two. Possibly deliberate — worth a maintainer's call | -| RSA5c, RSA6c | `create_token_request()` ignores `default_token_params`. The merge lives in `Auth.request_token` only, so a direct call yields `ttl=None` and `capability=None`. RSA5, RSA5b, RSA5d, RSA6, RSA6b and RSA6d all pass, so the nullability requirement itself is met | | RSA16c | No local expiry detection without a server time offset, which an authCallback client never obtains. See the RSA4b1 spec error above; here the specification asserts renewal *does* happen, so there is no green reading | | RSA16d | A failed renewal leaves the invalidated token in place — `_ensure_valid_auth_credentials` assigns only on success | | RSA16d | `authorize()` cannot switch a client back to basic auth: `_ensure_valid_auth_credentials` sets `Method.TOKEN` unconditionally, and `AuthOptions.replace` drops `use_token_auth`, which is stored outside the options dict | @@ -3701,8 +3700,8 @@ The header states how many derived tests there are, how many pass, how many are gated and how many cannot run. Those numbers are the check that the file is still true: in pytest cases, the gated count must equal the number of failures under `RUN_DEVIATIONS=1`, and gated plus unrunnable must equal the number of skips without -it. As of this writing that is 164 failures, 1577 passes and 15 skips with the variable -set, and 179 skips and 1577 passes without it, the 1577 being 1429 derived cases and 148 +it. As of this writing that is 162 failures, 1579 passes and 15 skips with the variable +set, and 177 skips and 1579 passes without it, the 1579 being 1431 derived cases and 148 harness ones — 130 under `helpers/` and 18 under `objects/helpers/`. Those figures need the `submodules` checkout CI makes: without it, three `rest/unit/encoding` tests that read the ably-common fixtures skip as well. The tiers are told apart by the junit report's diff --git a/test/uts/rest/unit/auth/token_request_params_test.py b/test/uts/rest/unit/auth/token_request_params_test.py index 1a341833..cf5e7577 100644 --- a/test/uts/rest/unit/auth/token_request_params_test.py +++ b/test/uts/rest/unit/auth/token_request_params_test.py @@ -4,7 +4,6 @@ """ from test.uts.helpers.client import rest_client -from test.uts.helpers.deviations import deviation from test.uts.helpers.mock_http import MockHttpClient KEY = 'appId.keyId:keySecret' @@ -34,15 +33,11 @@ async def test_rsa5b_explicit_ttl_preserved(): # UTS: rest/unit/RSA5c/ttl-from-default-params-0 -@deviation async def test_rsa5c_ttl_from_default_params(): client = rest_client(unreachable_mock(), key=KEY, default_token_params={'ttl': 1800000}) token_request = await client.auth.create_token_request() - # DEVIATION: `Auth.create_token_request` reads only the token params handed - # to it (`token_params = token_params or {}`); the defaults are merged in - # `Auth.request_token`, one level up, so a direct call sees none of them. assert token_request.ttl == 1800000 @@ -79,15 +74,13 @@ async def test_rsa6b_explicit_capability_preserved(): # UTS: rest/unit/RSA6c/capability-from-default-params-0 -@deviation async def test_rsa6c_capability_from_default_params(): client = rest_client(unreachable_mock(), key=KEY, default_token_params={'capability': '{"*":["subscribe"]}'}) token_request = await client.auth.create_token_request() - # DEVIATION: as for RSA5c, `Auth.create_token_request` does not merge - # `default_token_params`. + # NOTE: canonical rendering, as in RSA6b. assert token_request.capability == '{"*": ["subscribe"]}' From c31280978bbda7290a61c7ca83e5fc7b9f5edec5 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 19:19:43 +0000 Subject: [PATCH 09/10] pubsub: accept a token that names no clientId on a configured client _configure_client_id ran after every token fetch and read a token with no clientId as an attempt to change the configured one, raising 40102. A bare token string or JWT from an auth_callback or auth_url is wrapped without being parsed, so its clientId is always None: a client configured with clientId whose auth server returns a JWT for that very clientId failed its first attempt, and a realtime client then waited out a full disconnected_retry_timeout, fifteen seconds, before the retry took the cached-token branch and connected (deviations.md I.4, RSA7). A REST client failed the request outright. RSA15a constrains only a non-wildcard token clientId, and the RSA7 UTS case table has a configured clientId with a null token clientId succeed. _configure_client_id now leaves a configured clientId as it is when the token names none. The server still validates the token against the clientId: a realtime client sends it on connect (RSA7e1) and fails on a CONNECTED clientId that conflicts (RSA15a), and a mismatched JWT now reaches that failure on its first attempt. This is not a breaking change. The RSA12b, RSA10b and realtime integration RSA7 matching-clientId UTS tests are un-gated, the RSA12a test asserts the specification's success, and the mismatched-clientId integration test no longer needs a twenty-second wait. The header's REST root-cause count also drops the RSA10b cause, which closed with the explicit-clientId fix but whose test stayed gated on this one. Co-Authored-By: Claude Opus 5.5 --- ably/pubsub/http/auth.py | 5 ++ test/uts/deviations.md | 84 +++++----------------- test/uts/realtime/integration/auth_test.py | 9 +-- test/uts/rest/unit/auth/authorize_test.py | 7 +- test/uts/rest/unit/auth/client_id_test.py | 17 +---- 5 files changed, 24 insertions(+), 98 deletions(-) diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index f9de2ced..dc492ece 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -353,6 +353,11 @@ def _configure_client_id(self, new_client_id): log.debug("Auth._configure_client_id(): new client_id = %s", new_client_id) original_client_id = self.client_id or self.auth_options.client_id + # RSA12a, RSA15a: a token that names no clientId, such as a bare token string or a JWT + # the library does not parse, leaves a configured clientId as it is + if new_client_id is None and self.auth_options.client_id is not None: + return + # If new client ID from Ably is a wildcard, but preconfigured clientId is set, # then keep the existing clientId if original_client_id != '*' and new_client_id == '*': diff --git a/test/uts/deviations.md b/test/uts/deviations.md index 00db93e1..b7f307a0 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -33,8 +33,8 @@ parametrized over a table of fixtures the specification gives inline; and five `objects/unit` tests are parametrized, three over a table and two over the scenarios the specification loops through. That turns 1481 derived tests into 1608 pytest cases. -Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1297 Test IDs -(1306 tests, 1431 cases) pass, 160 (160 tests, 162 cases) are gated behind +Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1300 Test IDs +(1309 tests, 1434 cases) pass, 157 (157 tests, 159 cases) are gated behind `RUN_DEVIATIONS`, and 15 (15 tests, 15 cases) cannot be run at all. The three groups are disjoint: two Test IDs have a gated part and a passing part — `TI1`, as two functions, and `TO/endpoint-affects-host`, as one parametrized test — and are counted with the gated, @@ -45,19 +45,19 @@ the Test IDs come from `uts/rest/unit` (503 tests, 536 cases), 482 from `uts/rest/integration` (84, 122), 73 from `uts/realtime/integration` (73, 95) and 17 from `uts/objects/integration` (17, 29); 8 of the REST integration ids (8, 8), 30 of the realtime ones (30, 30) and 5 of the objects ones (5, 5) come from the `proxy` package -within each. Of the gated Test IDs 72 are REST and 88 realtime, which is 73 REST cases -and 89 realtime. None of the 339 objects Test IDs is gated or unrunnable. +within each. Of the gated Test IDs 70 are REST and 87 realtime, which is 71 REST cases +and 88 realtime. None of the 339 objects Test IDs is gated or unrunnable. A further 148 pytest cases cover the harness itself and are not derived from a specification: 130 under `helpers/`, for the mocks, and 18 under `objects/helpers/`, for the objects builders and the synced-channel mock. -The 149 gated Test IDs that record SDK non-compliance — 149 tests, 151 cases — reduce to -**65 distinct root causes**, 23 on the REST side and 42 on the realtime side. Three further +The 146 gated Test IDs that record SDK non-compliance — 146 tests, 148 cases — reduce to +**62 distinct root causes**, 21 on the REST side and 41 on the realtime side. Three further defects are recorded below with no test of their own, because the specification's test cannot discriminate (RTP18a), has nothing to assert against (the timezone split on synthesized LEAVE timestamps), or is worked around in the setup of every test that would otherwise trip over it (`enterClient` on an anonymous connection), so the file -carries **68 SDK root causes** in all. The remaining 11 gated Test IDs are +carries **65 SDK root causes** in all. The remaining 11 gated Test IDs are specification faults, and reduce to 8. The objects tier records no SDK non-compliance; its specification faults are all corrected or worked around in tests that pass, and reduce to the seven candidate issues O.1–O.7, none of them filed. @@ -679,11 +679,8 @@ immediately below it says "the key assertion is that the connection enters FAILE with error code 40102". Both cannot hold: a constructor has no token to compare a clientId against, so nothing is knowable until the server answers. Measured: construction does not raise — the client comes back INITIALIZED — and the connection reaches FAILED with -40102/401 "invalid clientId for credentials" about fifteen seconds later. The derived test -drops the throw and makes the specification's own named assertion. The fifteen seconds are -not the server's: they are the spurious `disconnected_retry_timeout` recorded under -`#### A JWT string with a matching clientId is rejected 40102` below, which is why the -test waits twenty seconds rather than the default ten. +40102/401 "invalid clientId for credentials" on its first attempt. The derived test drops +the throw and makes the specification's own named assertion. Filed as [#551](https://github.com/ably/specification/issues/551). @@ -1698,43 +1695,6 @@ two aware `datetime.now(timezone.utc)` readings and passes. ### Auth -#### A JWT string with a matching clientId is rejected 40102 — 1 test - -**Spec point:** RSA7, `realtime/integration/RSA7/matching-clientid-succeeds-0`. - -RSA7 requires that a token whose `clientId` equals the client's configured `clientId` is -accepted. `Auth._ensure_valid_auth_credentials` calls -`self._configure_client_id(self.__token_details.client_id)` after every token fetch -(`ably/rest/auth.py:126`). Where an `auth_callback` returns a **JWT string**, -`request_token` wraps it as `TokenDetails(token=)` (`auth.py:213`) without parsing the -JWT, so `token_details.client_id` is `None`. `_configure_client_id(None)` then finds -`None != 'test-client-…'` and raises `IncompatibleClientIdException(…, 400, 40102)` -(`auth.py:345`), although the JWT's own `x-ably-clientId` claim is exactly the configured -id. Measured against the sandbox, one auth-callback invocation: - -``` -initialized→connecting None -connecting→disconnected AblyAuthException 80019/401 'Client configured authentication provider request failed' -disconnected→connecting None -connecting→connected None -``` - -The connection does arrive in the end, because `__token_details` is assigned before the -raise: the retry takes the cached-token branch and skips `_configure_client_id` altogether. -The observable cost is a spurious failed attempt and a full `disconnected_retry_timeout` — -fifteen seconds — on a clientId that matched. - -**Tests affected:** -`realtime/integration/auth_test.py::test_rsa7_matching_clientid_succeeds` — -`AssertionError: Timed out waiting for connection state connected; it was disconnected`. -The sibling `test_rsa7_mismatched_clientid_fails` is not gated and pays the same fifteen -seconds before the server's genuine rejection arrives, which is why it waits twenty -seconds rather than the default ten. - -**Status:** open bug. A fix either reads the `x-ably-clientId` claim when wrapping a bare -token string, or leaves `_configure_client_id` alone where the fetched token carries no -clientId of its own. - #### An authCallback error is always rewritten as 401/40170, so RSA4d is unreachable — 4 tests **Spec points:** RSA4d, RSA4d1. @@ -1902,7 +1862,6 @@ which is about a client that *does* configure `clientId: "*"`. | Spec points | Behaviour | |---|---| -| RSA12a | A token with a **null** clientId is rejected when `ClientOptions.clientId` is set, with 40102 "Client ID cannot be changed to 'None'". RSA15a constrains only non-wildcard token clientIds. Needs a `new_client_id is not None` guard | | RSA7, RSA16c | A clientId learned from a token is treated as immutable, so `authorize()` to a token with a different clientId raises 40102. RSA15 scopes immutability to a clientId set in `ClientOptions`. `_configure_client_id` uses `self.client_id or self.auth_options.client_id`, conflating the two. Possibly deliberate — worth a maintainer's call | | RSA16c | No local expiry detection without a server time offset, which an authCallback client never obtains. See the RSA4b1 spec error above; here the specification asserts renewal *does* happen, so there is no green reading | | RSA16d | A failed renewal leaves the invalidated token in place — `_ensure_valid_auth_credentials` assigns only on success | @@ -3052,11 +3011,11 @@ none of these shows up as a failure — which is why they are easy to lose. The five tiers above classify the realtime unit derivation; the REST unit derivation's candidates went upstream as [#709](https://github.com/ably/ably-python/issues/709)–[#712](https://github.com/ably/ably-python/issues/712). -Six further defects came out of the two integration tiers, and none of them is filed. I.1 -and I.2 are tier 2 by the ranking above — an error where there should be none — and I.3 is -tier 1 for a client that configures the option it concerns, since the call does not come -back when the caller asked for it to. I.4 and I.6 are tier 2 as well; I.5 is tier 3, a -presence member silently leaving the set with nothing to report it. +Five further defects came out of the two integration tiers, and none of them is filed; each +keeps the label it was first given. I.1 and I.2 are tier 2 by the ranking above — an error +where there should be none — and I.3 is tier 1 for a client that configures the option it +concerns, since the call does not come back when the caller asked for it to. I.6 is tier 2 +as well; I.5 is tier 3, a presence member silently leaving the set with nothing to report it. The realtime integration tier also put four defects the unit tier had already recorded in front of the real server, and those extend the entries above rather than opening issues of @@ -3098,17 +3057,6 @@ no timeout and no fallback at all. Distinct from bounding a single socket read rather than the request; a fix wants to settle both. `test/uts/rest/integration/proxy/rest_fallback_test.py -k rsc15l2` -**I.4 A JWT string whose clientId matches the configured one is rejected 40102.** RSA7. -An `auth_callback` returning an Ably JWT gets wrapped as `TokenDetails(token=)` -without the JWT being parsed (`ably/rest/auth.py:213`), so `token_details.client_id` is -`None`; `_configure_client_id(None)` (`:126`, raising at `:345`) then reads that as a -clientId change and raises 40102 although the JWT's `x-ably-clientId` claim is the -configured id exactly. The connection recovers on the retry, which takes the cached-token -branch, so the cost is a spurious failed attempt and a full `disconnected_retry_timeout` — -fifteen seconds — on every such client. Affects anyone whose auth provider returns a JWT -and who also configures `clientId`, which is the ordinary shape of a JWT deployment. -`test/uts/realtime/integration/auth_test.py -k rsa7_matching` - **I.5 RTP17i re-entry never runs on a channel that is already ATTACHED.** RTP17i, RTP17g. `RealtimePresence.on_attached()` is reached only from `RealtimeChannel._notify_state()`, and an ATTACHED arriving on an already-attached channel takes the RTL12 branch @@ -3700,8 +3648,8 @@ The header states how many derived tests there are, how many pass, how many are gated and how many cannot run. Those numbers are the check that the file is still true: in pytest cases, the gated count must equal the number of failures under `RUN_DEVIATIONS=1`, and gated plus unrunnable must equal the number of skips without -it. As of this writing that is 162 failures, 1579 passes and 15 skips with the variable -set, and 177 skips and 1579 passes without it, the 1579 being 1431 derived cases and 148 +it. As of this writing that is 159 failures, 1582 passes and 15 skips with the variable +set, and 174 skips and 1582 passes without it, the 1582 being 1434 derived cases and 148 harness ones — 130 under `helpers/` and 18 under `objects/helpers/`. Those figures need the `submodules` checkout CI makes: without it, three `rest/unit/encoding` tests that read the ably-common fixtures skip as well. The tiers are told apart by the junit report's diff --git a/test/uts/realtime/integration/auth_test.py b/test/uts/realtime/integration/auth_test.py index 3bb60ae2..f69ce40d 100644 --- a/test/uts/realtime/integration/auth_test.py +++ b/test/uts/realtime/integration/auth_test.py @@ -17,17 +17,11 @@ from ably.pubsub.realtime.connection import ConnectionState from test.uts.helpers.client import await_connection_state, sandbox_realtime_client -from test.uts.helpers.deviations import deviation from test.uts.helpers.sandbox import extract_key_name, extract_key_secret, generate_jwt, random_id # The lifetime every JWT here is issued with, as the specification's `ttl: 3600000`. TOKEN_TTL = 3600000 -# The wait for the mismatched-clientId connection to be failed by the server. The SDK spends -# its `disconnected_retry_timeout` — 15 seconds by default — between the first attempt and -# the one the server rejects, so the specification's unstated wait has to clear that. -FAIL_TIMEOUT = 20.0 - def connection_id(client): """The specification's `connection.id`, held on the connection manager.""" @@ -105,7 +99,6 @@ async def test_rsa8_token_auth_connect(realtime_sandbox): # UTS: realtime/integration/RSA7/matching-clientid-succeeds-0 -@deviation async def test_rsa7_matching_clientid_succeeds(realtime_sandbox): test_client_id = 'test-client-' + random_id() @@ -132,7 +125,7 @@ async def test_rsa7_mismatched_clientid_fails(realtime_sandbox): client_id='wrong-client-id', auto_connect=False) client.connect() - await await_connection_state(client, ConnectionState.FAILED, FAIL_TIMEOUT) + await await_connection_state(client, ConnectionState.FAILED) assert client.connection.state is ConnectionState.FAILED assert client.connection.error_reason.code == 40102 diff --git a/test/uts/rest/unit/auth/authorize_test.py b/test/uts/rest/unit/auth/authorize_test.py index e7aff225..c1c0a3dd 100644 --- a/test/uts/rest/unit/auth/authorize_test.py +++ b/test/uts/rest/unit/auth/authorize_test.py @@ -13,7 +13,7 @@ from ably.pubsub.types.tokendetails import TokenDetails from ably.pubsub.util.exceptions import AblyException from test.uts.helpers.client import rest_client -from test.uts.helpers.deviations import deviation, spec_error +from test.uts.helpers.deviations import spec_error from test.uts.helpers.mock_http import MockHttpClient KEY = 'appId.keyId:keySecret' @@ -85,7 +85,6 @@ def on_request(request): # UTS: rest/unit/RSA10b/authorize-explicit-params-0 -@deviation async def test_rsa10b_authorize_explicit_params(): callback_params = [] @@ -99,10 +98,6 @@ async def mock_auth_callback(params): ) client = rest_client(mock_http, auth_callback=mock_auth_callback, client_id='default-client') - # DEVIATION: the token that comes back carries no clientId, which - # `Auth._configure_client_id` reads as an attempt to change the configured - # clientId and rejects with IncompatibleClientIdException 40102 before the - # assertions are reached. This is the RSA12a mechanism in client_id_test.py. await client.auth.authorize({'client_id': 'override-client', 'ttl': 7200000}) params = callback_params[0] diff --git a/test/uts/rest/unit/auth/client_id_test.py b/test/uts/rest/unit/auth/client_id_test.py index 2a9a072c..7feea69d 100644 --- a/test/uts/rest/unit/auth/client_id_test.py +++ b/test/uts/rest/unit/auth/client_id_test.py @@ -11,7 +11,6 @@ from ably.pubsub.types.tokendetails import TokenDetails from ably.pubsub.util.exceptions import AblyException, IncompatibleClientIdException from test.uts.helpers.client import rest_client -from test.uts.helpers.deviations import deviation from test.uts.helpers.mock_http import MockHttpClient # `channels.get(name).status()` walks status.occupancy.metrics unguarded, so the @@ -108,28 +107,14 @@ async def mock_auth_callback(params): client = rest_client(channel_details_mock(), auth_callback=mock_auth_callback, client_id='library-client-id') - # DEVIATION: the spec expects the operation to succeed. The returned token - # carries no clientId, which RSA15a leaves unconstrained (only a non-wildcard - # token clientId has to match ClientOptions), but Auth._configure_client_id - # reads the absent value as an attempt to change the configured clientId and - # raises 40102. - with pytest.raises(IncompatibleClientIdException) as excinfo: - await client.channels.get('test').status() - - assert excinfo.value.code == 40102 + await client.channels.get('test').status() - # The spec's own assertions, which hold: the clientId reached the callback. # ably-python passes TokenParams as a dict with snake_case keys. assert len(received_params) >= 1 assert received_params[0]['client_id'] == 'library-client-id' # UTS: rest/unit/RSA12b/clientid-sent-to-authurl-0 -# DEVIATION: the specification's auth_url response carries no clientId, and -# Auth._configure_client_id reads the absent value as an attempt to change the -# configured clientId, raising 40102 before `status()` returns. This is the RSA12a -# mechanism above. -@deviation async def test_rsa12b_clientid_sent_to_authurl(): captured_requests = [] From 538ae75eb939e5bd90dc1a8faf73f6c50317ccfe Mon Sep 17 00:00:00 2001 From: owenpearson Date: Mon, 26 Oct 2026 19:24:54 +0000 Subject: [PATCH 10/10] pubsub: let a basic-auth realtime connection enter presence for others A realtime client built from a key alone is told clientId "*" in its CONNECTED message. _configure_client_id opens with a branch that keeps a configured clientId against a server wildcard, and with no clientId configured that branch still fired, recording the clientId as validated and None. can_assume_client_id then refused every clientId, so presence.enter_client could never succeed on such a connection (deviations.md I.2; RSA7b4, RTP14, RTP15). The UTS integration presence tests and the repository's own presence suite worked around it by configuring client_id='*'. The branch now applies only when a clientId is configured, so with none the client takes the wildcard as its clientId, which RSA7b4 asks for. Because auth.client_id can now be "*", the Basic auth X-Ably-ClientId header is taken from the configured clientId, as RSA7e2 specifies, rather than from auth.client_id, so a wildcard the server reported is never sent back to it as a clientId. A later authorize() on such a client requests a wildcard token, which RSA10h asks for and the key already permits. This is not a breaking change. The three batch presence tests and the two realtime integration presence tests that configured client_id='*' as a workaround now build their clients as the specifications do. Co-Authored-By: Claude Opus 5.5 --- ably/pubsub/http/auth.py | 11 +-- test/uts/deviations.md | 68 ++----------------- .../presence/presence_sync_test.py | 6 +- .../integration/presence_lifecycle_test.py | 15 ++-- .../rest/integration/batch_presence_test.py | 12 +--- 5 files changed, 21 insertions(+), 91 deletions(-) diff --git a/ably/pubsub/http/auth.py b/ably/pubsub/http/auth.py index dc492ece..b718f6cc 100644 --- a/ably/pubsub/http/auth.py +++ b/ably/pubsub/http/auth.py @@ -359,8 +359,8 @@ def _configure_client_id(self, new_client_id): return # If new client ID from Ably is a wildcard, but preconfigured clientId is set, - # then keep the existing clientId - if original_client_id != '*' and new_client_id == '*': + # then keep the existing clientId. With none set, the wildcard is the clientId (RSA7b4) + if original_client_id is not None and original_client_id != '*' and new_client_id == '*': self.__client_id_validated = True self.__client_id = original_client_id return @@ -386,11 +386,12 @@ def can_assume_client_id(self, assumed_client_id): async def _get_auth_headers(self): if self.__auth_mechanism == Auth.Method.BASIC: - # RSA7e2 - if self.client_id: + # RSA7e2: the configured clientId, not a wildcard the server reported + client_id = self.auth_options.client_id + if client_id: return { 'Authorization': f'Basic {self.basic_credentials}', - 'X-Ably-ClientId': base64.b64encode(self.client_id.encode('utf-8')) + 'X-Ably-ClientId': base64.b64encode(client_id.encode('utf-8')) } return { 'Authorization': f'Basic {self.basic_credentials}', diff --git a/test/uts/deviations.md b/test/uts/deviations.md index b7f307a0..b349e6c6 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -52,12 +52,10 @@ specification: 130 under `helpers/`, for the mocks, and 18 under `objects/helper the objects builders and the synced-channel mock. The 146 gated Test IDs that record SDK non-compliance — 146 tests, 148 cases — reduce to -**62 distinct root causes**, 21 on the REST side and 41 on the realtime side. Three further +**62 distinct root causes**, 21 on the REST side and 41 on the realtime side. Two further defects are recorded below with no test of their own, because the specification's test -cannot discriminate (RTP18a), has nothing to assert against (the timezone split on -synthesized LEAVE timestamps), or is worked around in the setup of every test that -would otherwise trip over it (`enterClient` on an anonymous connection), so the file -carries **65 SDK root causes** in all. The remaining 11 gated Test IDs are +cannot discriminate (RTP18a) or has nothing to assert against (the timezone split on +synthesized LEAVE timestamps), so the file carries **64 SDK root causes** in all. The remaining 11 gated Test IDs are specification faults, and reduce to 8. The objects tier records no SDK non-compliance; its specification faults are all corrected or worked around in tests that pass, and reduce to the seven candidate issues O.1–O.7, none of them filed. @@ -1813,51 +1811,6 @@ JWT, fall with it. renew and retry on a token error whatever the flag says, and only then decide whether the final response is raised or returned. -#### A basic-auth connection records its clientId as validated and `None`, so `enterClient` can never succeed — no test - -**Spec points:** RSA7b4, RTP14, RTP15. - -A client built from a full-access key alone and asked to `enter_client("user-1", …)` -raises `AblyException: 40012 400 Unable to enter presence channel with clientId user-1 as -it does not match the current clientId None`. - -The server tells such a connection `clientId: "*"` in CONNECTED, and -`Auth._configure_client_id` (`ably/rest/auth.py:335-353`) opens with a branch that exists -to stop a server wildcard overwriting a clientId the caller configured: - -```python -if original_client_id != '*' and new_client_id == '*': - self.__client_id_validated = True - self.__client_id = original_client_id - return -``` - -With no configured clientId, `original_client_id` is `None`, `None != '*'` holds, and the -client id is recorded as **validated and `None`**. `can_assume_client_id` -(`auth.py:356-363`) then takes the validated path and answers `None == '*' or None == -assumed`, which is `False` for every clientId, so `enter_client` cannot succeed for any of -them. The `original_client_id is None` escape in the unvalidated branch is unreachable once -CONNECTED has arrived. The branch should apply only where a clientId was configured, and -`__client_id` should become `'*'` otherwise, which is what RSA7b4 asks for. - -**Tests affected:** none, which is why this is recorded here rather than gated. The three -`batch_presence.md` tests build their realtime client with `client_id='*'` instead, in -`entering_client()`, with the diagnosis in its docstring — the repository's own presence -suite carries the same workaround for the same reason, commented "Use wildcard auth for -enterClient" (`test/ably/realtime/realtimepresence_test.py:394-396`). It is setup rather -than subject: those tests are about `batchPresence`, and nothing they assert depends on how -the members got there. A fix would let the setup halves be written exactly as the -specification writes them. Both `realtime/integration` presence specifications carry the -same workaround for the same reason — `presence_lifecycle_test.py::test_rtp4_bulk_enter_observed` -and `presence/presence_sync_test.py::test_rtp2_sync_multiple_members` each build the client -that enters members on behalf of others with `client_id='*'` — which makes five tests -across two tiers that would otherwise be written as their specifications write them. - -This is distinct from the wildcard-clientId contradiction recorded under UTS Spec Errors, -which is about a client that *does* configure `clientId: "*"`. - -**Status:** open bug, one line from a fix. - #### Auth behaviour on the REST client | Spec points | Behaviour | @@ -3011,9 +2964,9 @@ none of these shows up as a failure — which is why they are easy to lose. The five tiers above classify the realtime unit derivation; the REST unit derivation's candidates went upstream as [#709](https://github.com/ably/ably-python/issues/709)–[#712](https://github.com/ably/ably-python/issues/712). -Five further defects came out of the two integration tiers, and none of them is filed; each -keeps the label it was first given. I.1 and I.2 are tier 2 by the ranking above — an error -where there should be none — and I.3 is tier 1 for a client that configures the option it +Four further defects came out of the two integration tiers, and none of them is filed; each +keeps the label it was first given. I.1 is tier 2 by the ranking above — an error where +there should be none — and I.3 is tier 1 for a client that configures the option it concerns, since the call does not come back when the caller asked for it to. I.6 is tier 2 as well; I.5 is tier 3, a presence member silently leaving the set with nothing to report it. @@ -3034,15 +2987,6 @@ offset. The same expired token renews correctly through `publish()`. A fix has t the two meanings `raise_on_error` carries. `test/uts/rest/integration/auth_test.py -k rsc10` -**I.2 `enterClient` cannot succeed on a basic-auth connection.** RSA7b4, RTP14, RTP15. A -connection built from a key alone is told `clientId: "*"`, and the branch of -`Auth._configure_client_id` (`ably/rest/auth.py:335-353`) that guards a configured clientId -against a server wildcard fires when there is no configured clientId, recording it as -validated and `None`. `can_assume_client_id` then refuses every clientId. One line from a -fix, and `enter_client` is unusable without one. No test gates on it — the three tests that -would hit it pass `client_id='*'` in setup instead, as the repository's own presence suite -does — so it will not show up as a failure. - **I.3 `httpRequestTimeout` is applied as seconds where the specification counts milliseconds.** TO3l4, TO3l3, RSC15l2. `ably/http/http.py:193` hands `(http_open_timeout, http_request_timeout)` to `httpx`, which reads seconds, so a client diff --git a/test/uts/realtime/integration/presence/presence_sync_test.py b/test/uts/realtime/integration/presence/presence_sync_test.py index 20a46ed6..c8101e78 100644 --- a/test/uts/realtime/integration/presence/presence_sync_test.py +++ b/test/uts/realtime/integration/presence/presence_sync_test.py @@ -11,10 +11,6 @@ The specification is json only — it has no `## Protocol Variants` section — so these two take the JSON default `sandbox_realtime_client` applies rather than the `use_binary_protocol` fixture. - -`enter_client` on a connection authenticated by key alone is refused 40012, a root cause -recorded in [deviations.md](../../../deviations.md), so the client entering members on -behalf of others is built with `client_id='*'`. """ from ably.pubsub.realtime.connection import ConnectionState @@ -61,7 +57,7 @@ async def test_rtp2_sync_multiple_members(realtime_sandbox): api_key = realtime_sandbox.key_str channel_name = 'presence-sync-multi-' + random_id() - client_a = sandbox_realtime_client(api_key, auto_connect=False, client_id='*') + client_a = sandbox_realtime_client(api_key, auto_connect=False) client_b = sandbox_realtime_client(api_key, auto_connect=False) client_a.connect() diff --git a/test/uts/realtime/integration/presence_lifecycle_test.py b/test/uts/realtime/integration/presence_lifecycle_test.py index 0a8a7c70..1883bb79 100644 --- a/test/uts/realtime/integration/presence_lifecycle_test.py +++ b/test/uts/realtime/integration/presence_lifecycle_test.py @@ -10,14 +10,10 @@ attach, and a presence action is named by its lowercase wire name — `'enter'`, `'present'`, `'update'`, `'leave'` — which is what `set_presence` emits. -Two adaptations, both of them root causes already recorded in -[deviations.md](../../deviations.md): - -- Subscribing to several actions at once (RTP6b) raises `TypeError: unhashable type: - 'list'`, because the list reaches pyee as a dict key. The one listener is registered - once per action instead, which is what the array form means. -- `enter_client` on a connection authenticated by key alone is refused 40012, so the - client entering members on behalf of others is built with `client_id='*'`. +One adaptation, a root cause already recorded in [deviations.md](../../deviations.md): +subscribing to several actions at once (RTP6b) raises `TypeError: unhashable type: +'list'`, because the list reaches pyee as a dict key. The one listener is registered once +per action instead, which is what the array form means. """ import asyncio @@ -45,8 +41,7 @@ async def test_rtp4_bulk_enter_observed(realtime_sandbox, use_binary_protocol): api_key = realtime_sandbox.key_str channel_name = 'presence-bulk-' + random_id() - client_a = sandbox_realtime_client( - api_key, use_binary_protocol=use_binary_protocol, client_id='*') + client_a = sandbox_realtime_client(api_key, use_binary_protocol=use_binary_protocol) client_b = sandbox_realtime_client(api_key, use_binary_protocol=use_binary_protocol) client_a.connect() diff --git a/test/uts/rest/integration/batch_presence_test.py b/test/uts/rest/integration/batch_presence_test.py index ecf2d93c..9feb49fb 100644 --- a/test/uts/rest/integration/batch_presence_test.py +++ b/test/uts/rest/integration/batch_presence_test.py @@ -44,19 +44,13 @@ def member_for(entry, client_id): async def entering_client(key, use_binary_protocol): """A CONNECTED realtime client that may enter presence for any clientId. - The specifications build this with the full-access key alone and call - `enterClient` straight away. ably-python needs `client_id='*'` on top: a basic-auth - connection is told `clientId: "*"` by the server, and `Auth._configure_client_id` - answers a wildcard from the server by marking the client id validated while leaving - it `None` (`ably/rest/auth.py:335`), after which `can_assume_client_id` refuses every - id and `enter_client` raises 40012. The repository's own presence suite carries the - same `client_id='*'` for the same reason - (`test/ably/realtime/realtimepresence_test.py:396`). + The specifications build this with the full-access key alone, which the server + answers with the wildcard clientId `"*"` (RSA7b4). The connection is awaited to CONNECTED before any enter, since a presence enter on a connection that is still CONNECTING is queued rather than sent. """ - client = sandbox_realtime_client(key, client_id='*', use_binary_protocol=use_binary_protocol) + client = sandbox_realtime_client(key, use_binary_protocol=use_binary_protocol) await await_connection_state(client, ConnectionState.CONNECTED) return client