Skip to content

Commit e520853

Browse files
owenpearsonclaude
andcommitted
test: derive the auth unit specs
Covers auth/auth_scheme.md, client_id.md, authorize.md, auth_callback.md, token_renewal.md, token_request_params.md, token_details.md and revoke_tokens.md. Auth is where this SDK departs from the specifications most: thirty-one tests carry the deviation mark, seventeen of them for Auth#revokeTokens and the token revocation types, which are not implemented. The rest record how the auth scheme is resolved when a key is present, when a clientId is treated as immutable, and that TokenParams reach an auth_url under the SDK's internal snake_case names. Four specifications demand behaviour features.md makes optional or contradicts, and carry the spec_error mark. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d01d09f commit e520853

10 files changed

Lines changed: 2554 additions & 2 deletions

‎test/uts/deviations.md‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ The tests gated this way:
3535
|---|---|
3636
| `test_rsl1a_publish_message_array` | RSL1c - an object payload asserted to travel unstringified |
3737
| `test_rsl1k_mixed_ids_in_batch` | RSL1k - an absent id in a mixed batch asserted to be generated |
38-
38+
| `test_rsa4a2_expired_token_no_renewal` | RSA4a2 - local expiry detection demanded |
39+
| `test_rsa4b1_preemptive_renewal` | RSA4b1 - local expiry detection demanded |
40+
| `test_rsa4b_renewal_msgpack_response` | RSA4b - renewal driven through the unauthenticated `/time` |
41+
| `test_rsa10i_authorize_preserves_key` | RSA10i - empty assertions, and a premise RSA8e contradicts |
3942
| `test_rsp4_history_pagination` | RSP4 - wire action 4 asserted to be LEAVE |
4043
| `test_tp3_presence_to_json` | TP3 - an outgoing action asserted as the string `"enter"` |
4144
| `test_tp3_null_attributes_omitted` | TP3 - the same outgoing string assertion |
@@ -95,6 +98,26 @@ to let the tests accept both.
9598
- `auth_scheme.md` asserts a raw `Bearer <token>`. RSA3b makes Base64 optional, and
9699
ably-python and ably-js both encode.
97100

101+
### Token expiry tests demand optional behaviour
102+
103+
`RSA4a2/expired-token-no-renewal-0` and `RSA4b1/preemptive-renewal-0` require local
104+
expiry detection. RSA4b1 makes it optional *and* conditional on having persisted a
105+
clock offset per RSA10k and judging expiry against Ably service time rather than the
106+
local clock. Neither setup establishes that precondition.
107+
108+
`RSA4b/renewal-msgpack-response-4` drives a renewal flow through `client.time()`.
109+
`/time` is unauthenticated — the same suite's `RSC16/no-auth-required-2` asserts it
110+
carries no `Authorization` header — so it cannot return a token error or trigger
111+
renewal in any SDK.
112+
113+
### `RSA10i` asserts that an API key survives `authorize()`
114+
115+
RSA8e says provided `AuthOptions` "are used instead of the stored values (even when
116+
null)", and RSA10j repeats it. No features point requires key preservation, and
117+
ably-js's own error text reads "A passed authOptions replaces the stored options
118+
rather than merging." `RSA10k`'s setup depends on the same premise and cannot reach
119+
`/time` without it. `RSA10i` also has an empty assertions block.
120+
98121
### Fixtures that cannot hold their stated values
99122

100123
- `RSP5/decode-cipher-channel-7`: the ciphertext is 32 bytes, an IV plus one AES-CBC
@@ -165,6 +188,22 @@ the mark is the only change needed once the SDK behaviour lands.
165188
| TI4, TI1/TI5 | `href` anywhere in the SDK, and `cause` when deserialising. `AblyException.from_dict` and `raise_for_response` read only `message`, `statusCode` and `code`, so both fields are dropped from server errors | 2 |
166189
| TP3a, TP3d, TP3g | Presence attributes defaulted from the encapsulating ProtocolMessage. There is no ProtocolMessage type; `ably/realtime/channel.py:751-761` passes the presence array through without context. Matters for synthesized-leave detection and `memberKey` | 3 |
167190

191+
### Auth
192+
193+
| Spec points | Behaviour |
194+
|---|---|
195+
| 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 |
196+
| 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 |
197+
| 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 |
198+
| 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 |
199+
| RSA10b, RSA10h, RSA10j | `authorize()` overwrites an explicit `tokenParams.clientId`. `ably/rest/auth.py:126-127` assigns `self.client_id` unconditionally. RSA10h makes it the default "if not null" |
200+
| 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 |
201+
| 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 |
202+
| 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 |
203+
| RSA16d | A failed renewal leaves the invalidated token in place — `_ensure_valid_auth_credentials` assigns only on success |
204+
| 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 |
205+
| 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` |
206+
168207
### Options
169208

170209
| Spec points | Behaviour |
@@ -198,7 +237,8 @@ comment above. These run, so they guard against regression.
198237
| RSC15a | Six hosts tried | Three. `Options.__get_hosts` truncates to `http_max_retry_count`, which TO3l5 sanctions | Intentional |
199238
| 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 |
200239
| 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 |
201-
240+
| RSA6b, RSA6d | The capability literal as passed | Canonicalised by `Capability.c14n`, which RSA9f requires | Compliant; rendering only |
241+
| RSA8d | `error.message` contains the callback's text | Wrapped as 40170 with the original in `cause`; `__str__` renders both | Rendering |
202242
| 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 |
203243
| CHM2g, CHM2h | `objectPublishers` and `objectSubscribers` on `ChannelMetrics` | Neither is modelled, so both are dropped on parsing. The test asserts their absence, and turns red once they are added | Open bug |
204244
| TO3l8 | `maxMessageSize` is a client option, default 65536 | Rejected by `Options.__init__`. `ably/realtime/channel.py:422` reads it with `getattr(..., 65536)`, so the default holds but cannot be configured, nor overridden by `connectionDetails` (CD2c) | Open bug |

‎test/uts/rest/unit/auth/__init__.py‎

Whitespace-only changes.
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
"""Derived from uts/rest/unit/auth/auth_callback.md in ably/specification.
2+
3+
Spec points: RSA8c, RSA8d
4+
"""
5+
6+
import base64
7+
import re
8+
import time
9+
10+
import pytest
11+
12+
from ably import api_version
13+
from ably.types.tokendetails import TokenDetails
14+
from ably.types.tokenrequest import TokenRequest
15+
from ably.util.exceptions import AblyException
16+
from test.uts.helpers.client import rest_client
17+
from test.uts.helpers.mock_http import MockHttpClient
18+
19+
CHANNEL_BODY = {'channelId': 'test'}
20+
21+
JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-jwt-payload'
22+
23+
REQUEST_TOKEN_PATH = re.compile(r'^/keys/.*/requestToken$')
24+
25+
AUTH_HOST = 'auth.example.com'
26+
27+
28+
def now():
29+
return int(time.time() * 1000)
30+
31+
32+
def bearer(token):
33+
"""The Bearer credential ably-python sends for `token`.
34+
35+
NOTE: the specification asserts the raw token string. RSA3b makes the
36+
Base64 encoding optional ("the token string is optionally Base64-encoded
37+
and used in the `Authorization: Bearer` header"), and ably-python encodes,
38+
so the specification's literal is one permitted rendering of two.
39+
"""
40+
return 'Bearer ' + base64.b64encode(token.encode('utf-8')).decode('ascii')
41+
42+
43+
def capture_and_respond(captured_requests, body=None):
44+
def on_request(request):
45+
captured_requests.append(request)
46+
request.respond_with(200, body if body is not None else CHANNEL_BODY)
47+
48+
return on_request
49+
50+
51+
# UTS: rest/unit/RSA8d/callback-invoked-for-auth-0
52+
async def test_rsa8d_callback_invoked_for_auth():
53+
callback_invoked = False
54+
callback_params = None
55+
captured_requests = []
56+
57+
async def auth_callback(params):
58+
nonlocal callback_invoked, callback_params
59+
callback_invoked = True
60+
callback_params = params
61+
return TokenDetails(token='callback-token', expires=now() + 3600000)
62+
63+
mock_http = MockHttpClient(
64+
on_connection_attempt=lambda conn: conn.respond_with_success(),
65+
on_request=capture_and_respond(captured_requests),
66+
)
67+
client = rest_client(mock_http, auth_callback=auth_callback)
68+
69+
await client.request('GET', '/channels/test', version=api_version)
70+
71+
assert callback_invoked is True
72+
assert callback_params is not None
73+
74+
assert len(captured_requests) == 1
75+
assert captured_requests[0].headers['Authorization'] == bearer('callback-token')
76+
77+
78+
# UTS: rest/unit/RSA8d/callback-returns-jwt-1
79+
async def test_rsa8d_callback_returns_jwt():
80+
captured_requests = []
81+
82+
async def auth_callback(params):
83+
return JWT
84+
85+
mock_http = MockHttpClient(
86+
on_connection_attempt=lambda conn: conn.respond_with_success(),
87+
on_request=capture_and_respond(captured_requests),
88+
)
89+
client = rest_client(mock_http, auth_callback=auth_callback)
90+
91+
await client.request('GET', '/channels/test', version=api_version)
92+
93+
assert captured_requests[0].headers['Authorization'] == bearer(JWT)
94+
95+
96+
# UTS: rest/unit/RSA8d/callback-returns-token-request-2
97+
async def test_rsa8d_callback_returns_token_request():
98+
captured_requests = []
99+
100+
async def auth_callback(params):
101+
return TokenRequest(
102+
key_name='app.key',
103+
ttl=3600000,
104+
timestamp=now(),
105+
nonce='unique-nonce',
106+
mac='computed-mac',
107+
)
108+
109+
def on_request(request):
110+
captured_requests.append(request)
111+
if REQUEST_TOKEN_PATH.match(request.path):
112+
request.respond_with(200, {'token': 'exchanged-token', 'expires': now() + 3600000})
113+
else:
114+
request.respond_with(200, CHANNEL_BODY)
115+
116+
mock_http = MockHttpClient(
117+
on_connection_attempt=lambda conn: conn.respond_with_success(),
118+
on_request=on_request,
119+
)
120+
client = rest_client(mock_http, auth_callback=auth_callback)
121+
122+
await client.request('GET', '/channels/test', version=api_version)
123+
124+
assert len(captured_requests) == 2
125+
126+
first_request = captured_requests[0]
127+
assert first_request.method == 'POST'
128+
assert REQUEST_TOKEN_PATH.match(first_request.path)
129+
130+
second_request = captured_requests[1]
131+
assert second_request.headers['Authorization'] == bearer('exchanged-token')
132+
133+
134+
# UTS: rest/unit/RSA8d/callback-receives-token-params-3
135+
async def test_rsa8d_callback_receives_token_params():
136+
received_params = None
137+
138+
async def auth_callback(params):
139+
nonlocal received_params
140+
received_params = params
141+
return TokenDetails(token='test-token', expires=now() + 3600000)
142+
143+
mock_http = MockHttpClient(
144+
on_connection_attempt=lambda conn: conn.respond_with_success(),
145+
on_request=lambda request: request.respond_with(200, CHANNEL_BODY),
146+
)
147+
client = rest_client(mock_http, auth_callback=auth_callback)
148+
149+
await client.auth.authorize(token_params={
150+
'client_id': 'requested-client-id',
151+
'ttl': 7200000,
152+
'capability': {'channel1': ['publish']},
153+
})
154+
155+
assert received_params['client_id'] == 'requested-client-id'
156+
assert received_params['ttl'] == 7200000
157+
assert received_params['capability'] == {'channel1': ['publish']}
158+
159+
160+
def auth_url_mock(captured_requests, token_body=None, token_headers=None):
161+
"""A mock that answers the auth_url with a token and everything else with a channel."""
162+
body = token_body if token_body is not None else {
163+
'token': 'authurl-token',
164+
'expires': now() + 3600000,
165+
}
166+
167+
def on_request(request):
168+
captured_requests.append(request)
169+
if request.url.host == AUTH_HOST:
170+
request.respond_with(200, body, token_headers)
171+
else:
172+
request.respond_with(200, CHANNEL_BODY)
173+
174+
return MockHttpClient(
175+
on_connection_attempt=lambda conn: conn.respond_with_success(),
176+
on_request=on_request,
177+
)
178+
179+
180+
# UTS: rest/unit/RSA8c/authurl-invoked-for-auth-0
181+
async def test_rsa8c_authurl_invoked_for_auth():
182+
captured_requests = []
183+
client = rest_client(auth_url_mock(captured_requests),
184+
auth_url=f'https://{AUTH_HOST}/token')
185+
186+
await client.request('GET', '/channels/test', version=api_version)
187+
188+
auth_request = captured_requests[0]
189+
assert auth_request.url.host == AUTH_HOST
190+
assert auth_request.url.path == '/token'
191+
assert auth_request.method == 'GET'
192+
193+
api_request = captured_requests[1]
194+
assert api_request.headers['Authorization'] == bearer('authurl-token')
195+
196+
197+
# UTS: rest/unit/RSA8c/authurl-post-method-1
198+
async def test_rsa8c_authurl_post_method():
199+
captured_requests = []
200+
client = rest_client(auth_url_mock(captured_requests),
201+
auth_url=f'https://{AUTH_HOST}/token',
202+
auth_method='POST')
203+
204+
await client.request('GET', '/channels/test', version=api_version)
205+
206+
auth_request = captured_requests[0]
207+
assert auth_request.method == 'POST'
208+
209+
210+
# UTS: rest/unit/RSA8c/authurl-custom-headers-2
211+
async def test_rsa8c_authurl_custom_headers():
212+
captured_requests = []
213+
client = rest_client(auth_url_mock(captured_requests),
214+
auth_url=f'https://{AUTH_HOST}/token',
215+
auth_headers={
216+
'X-Custom-Header': 'custom-value',
217+
'X-API-Key': 'my-api-key',
218+
})
219+
220+
await client.request('GET', '/channels/test', version=api_version)
221+
222+
auth_request = captured_requests[0]
223+
assert auth_request.headers['X-Custom-Header'] == 'custom-value'
224+
assert auth_request.headers['X-API-Key'] == 'my-api-key'
225+
226+
227+
# UTS: rest/unit/RSA8c/authurl-query-params-3
228+
async def test_rsa8c_authurl_query_params():
229+
captured_requests = []
230+
client = rest_client(auth_url_mock(captured_requests),
231+
auth_url=f'https://{AUTH_HOST}/token',
232+
auth_params={
233+
'client_id': 'my-client',
234+
'scope': 'publish:*',
235+
})
236+
237+
await client.request('GET', '/channels/test', version=api_version)
238+
239+
auth_request = captured_requests[0]
240+
assert auth_request.url.query_params['client_id'] == 'my-client'
241+
assert auth_request.url.query_params['scope'] == 'publish:*'
242+
243+
244+
# UTS: rest/unit/RSA8c/authurl-returns-jwt-4
245+
async def test_rsa8c_authurl_returns_jwt():
246+
captured_requests = []
247+
jwt = 'eyJhbGciOiJIUzI1NiJ9.jwt-body.signature'
248+
client = rest_client(
249+
auth_url_mock(captured_requests, token_body=jwt,
250+
token_headers={'Content-Type': 'text/plain'}),
251+
auth_url=f'https://{AUTH_HOST}/jwt')
252+
253+
await client.request('GET', '/channels/test', version=api_version)
254+
255+
api_request = captured_requests[1]
256+
assert api_request.headers['Authorization'] == bearer(jwt)
257+
258+
259+
# UTS: rest/unit/RSA8d/callback-error-propagated-4
260+
async def test_rsa8d_callback_error_propagated():
261+
captured_requests = []
262+
263+
async def auth_callback(params):
264+
raise Exception('Authentication server unavailable')
265+
266+
mock_http = MockHttpClient(
267+
on_connection_attempt=lambda conn: conn.respond_with_success(),
268+
on_request=capture_and_respond(captured_requests),
269+
)
270+
client = rest_client(mock_http, auth_callback=auth_callback)
271+
272+
with pytest.raises(AblyException) as excinfo:
273+
await client.request('GET', '/channels/test', version=api_version)
274+
275+
# NOTE: the spec asserts the error message contains the callback's own text. ably-python
276+
# wraps the callback failure in an AblyException whose `message` names the callback and
277+
# whose `cause` is the original exception; the rendered error carries both.
278+
assert excinfo.value.code == 40170
279+
assert 'auth_callback raised an exception' in excinfo.value.message
280+
assert 'Authentication server unavailable' in str(excinfo.value.cause)
281+
assert 'Authentication server unavailable' in str(excinfo.value)
282+
283+
assert len(captured_requests) == 0
284+
285+
286+
# UTS: rest/unit/RSA8c/authurl-error-propagated-5
287+
async def test_rsa8c_authurl_error_propagated():
288+
captured_requests = []
289+
290+
def on_request(request):
291+
captured_requests.append(request)
292+
if request.url.host == AUTH_HOST:
293+
request.respond_with(500, {'error': 'Internal server error'})
294+
else:
295+
request.respond_with(200, CHANNEL_BODY)
296+
297+
mock_http = MockHttpClient(
298+
on_connection_attempt=lambda conn: conn.respond_with_success(),
299+
on_request=on_request,
300+
)
301+
client = rest_client(mock_http, auth_url=f'https://{AUTH_HOST}/token')
302+
303+
with pytest.raises(AblyException) as excinfo:
304+
await client.request('GET', '/channels/test', version=api_version)
305+
306+
assert excinfo.value.status_code == 500
307+
308+
assert len(captured_requests) == 1
309+
assert captured_requests[0].url.host == AUTH_HOST

0 commit comments

Comments
 (0)