diff --git a/UPDATING.md b/UPDATING.md index 64f69d56..c54b0c90 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -124,6 +124,189 @@ 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. + +### 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') +``` + +### 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. + +### `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}) +``` + +### 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. + +### `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. + +### 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 +``` + +### `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 aa71d517..b718f6cc 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 @@ -47,7 +45,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: @@ -67,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: @@ -109,21 +118,26 @@ 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 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 @@ -159,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 @@ -232,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 @@ -336,9 +353,14 @@ 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 == '*': + # 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 @@ -364,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}', @@ -386,11 +409,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/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/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/ably/pubsub/server/__init__.py b/ably/pubsub/server/__init__.py index 15c20641..1915935c 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. @@ -168,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/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..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,15 +11,16 @@ 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 - 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 +48,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/authorizeparams_test.py b/test/ably/http/authorizeparams_test.py new file mode 100644 index 00000000..1c27c289 --- /dev/null +++ b/test/ably/http/authorizeparams_test.py @@ -0,0 +1,71 @@ +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() + + +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/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/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/ably/realtime/realtimeauth_test.py b/test/ably/realtime/realtimeauth_test.py index da073117..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,24 +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") - assert realtime.auth.client_id is None - - 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 @@ -655,7 +652,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 +672,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 28535f9f..b349e6c6 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**: 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,17 @@ 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 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 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 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. 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 **72 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. @@ -679,11 +677,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 +1693,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. @@ -1774,22 +1732,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. @@ -1869,66 +1811,14 @@ 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 | |---|---| -| 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 | -| 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 | | 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 @@ -2386,7 +2276,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 | @@ -2395,7 +2284,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 @@ -3031,14 +2919,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. @@ -3083,11 +2964,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. +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. 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 @@ -3106,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 @@ -3129,17 +3001,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 @@ -3731,8 +3592,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 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/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/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 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 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 diff --git a/test/uts/rest/unit/auth/authorize_test.py b/test/uts/rest/unit/auth/authorize_test.py index 5d1f50d3..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,12 +98,6 @@ 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. 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 58a2990e..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,33 +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: 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 async def test_rsa12b_clientid_sent_to_authurl(): captured_requests = [] @@ -206,19 +186,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 '' @@ -226,7 +205,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( @@ -237,7 +215,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( @@ -262,19 +239,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 diff --git a/test/uts/rest/unit/auth/token_details_test.py b/test/uts/rest/unit/auth/token_details_test.py index d4031743..65db8c68 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 @@ -105,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, @@ -127,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' @@ -428,13 +420,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/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"]}' 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'