Skip to content

Commit d3db579

Browse files
test(core,adms): cover aclose idempotency, exception-path cleanup, OBO/CC cache isolation
- AsyncHttpClient: assert __aexit__ runs aclose when the body raises; assert real httpx.AsyncClient.aclose is safe to call twice (the protocol can result in double-cleanup in caller code). - AsyncAdmsHttp: same exception-path coverage; assert aclose is idempotent on the owned client. - IasTokenFetcher: assert interleaving get_token (cached) and exchange_token (not cached) keeps the two grant types isolated — no cache-key collision and exactly one client_credentials IAS hit across the sequence. Guards the OBO privilege boundary against a future regression where someone adds caching to exchange_token.
1 parent 9d7b5ca commit d3db579

3 files changed

Lines changed: 78 additions & 0 deletions

File tree

tests/adms/unit/test_client.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,30 @@ async def test_context_manager_closes_client(self, config):
252252

253253
mock_client.aclose.assert_called_once()
254254

255+
@pytest.mark.asyncio
256+
async def test_context_manager_closes_client_on_exception(self, config):
257+
fetcher = _make_token_fetcher(config)
258+
mock_client = AsyncMock(spec=httpx.AsyncClient)
259+
260+
with pytest.raises(RuntimeError, match="boom"):
261+
async with AsyncAdmsHttp(config=config, token_fetcher=fetcher, client=mock_client):
262+
raise RuntimeError("boom")
263+
264+
mock_client.aclose.assert_called_once()
265+
266+
@pytest.mark.asyncio
267+
async def test_aclose_idempotent_on_owned_client(self, config):
268+
fetcher = _make_token_fetcher(config)
269+
mock_client = AsyncMock(spec=httpx.AsyncClient)
270+
271+
http = AsyncAdmsHttp(config=config, token_fetcher=fetcher, client=mock_client)
272+
await http.aclose()
273+
await http.aclose() # second call must not raise; httpx tolerates double aclose
274+
275+
# The owned client may be closed once or twice — both are valid.
276+
# What matters is no exception is propagated.
277+
assert mock_client.aclose.await_count >= 1
278+
255279
@pytest.mark.asyncio
256280
async def test_with_user_jwt_shares_underlying_client(self, config):
257281
fetcher = _make_token_fetcher(config)

tests/core/unit/auth/test_ias_fetcher.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,37 @@ def test_grant_type_is_client_credentials(self, fetcher, mock_session):
146146
assert payload["grant_type"] == "client_credentials"
147147
assert payload["client_id"] == "client-id"
148148
assert payload["client_secret"] == "client-secret"
149+
150+
def test_obo_and_cc_caches_are_isolated(self, fetcher, mock_session):
151+
"""Interleaving ``get_token`` (cached) with ``exchange_token`` (not cached)
152+
must not collide on a shared cache key.
153+
154+
Why: OBO tokens are scoped to a specific end-user JWT; sharing them
155+
across users would be a privilege boundary violation. CC tokens are
156+
the application's own credential and should be cached for reuse.
157+
A naive single-key cache would either leak OBO tokens to CC callers
158+
or cache-bust CC on every OBO call.
159+
"""
160+
mock_session.post.side_effect = [
161+
_make_token_response("cc-token"), # first get_token → IAS hit
162+
_make_token_response("obo-token-a"), # exchange_token(jwt_a) → IAS hit
163+
_make_token_response("obo-token-b"), # exchange_token(jwt_b) → IAS hit
164+
]
165+
166+
cc1 = fetcher.get_token()
167+
obo_a = fetcher.exchange_token("jwt-a")
168+
cc2 = fetcher.get_token() # must hit cache → no extra IAS call
169+
obo_b = fetcher.exchange_token("jwt-b")
170+
171+
assert cc1 == cc2 == "cc-token"
172+
assert obo_a == "obo-token-a"
173+
assert obo_b == "obo-token-b"
174+
# 1 CC fetch (cached on second call) + 2 OBO fetches (never cached) = 3.
175+
assert mock_session.post.call_count == 3
176+
177+
cc_grant_calls = [
178+
call for call in mock_session.post.call_args_list
179+
if call[1]["data"]["grant_type"] == "client_credentials"
180+
]
181+
assert len(cc_grant_calls) == 1
182+

tests/core/unit/http/test_async_client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,26 @@ async def test_aexit_closes_client(self, mock_httpx_client):
5050
pass
5151
mock_httpx_client.aclose.assert_awaited_once()
5252

53+
@pytest.mark.asyncio
54+
async def test_aexit_closes_client_on_exception(self, mock_httpx_client):
55+
c = AsyncHttpClient(base_url="https://api.example.com", client=mock_httpx_client)
56+
with pytest.raises(RuntimeError, match="boom"):
57+
async with c:
58+
raise RuntimeError("boom")
59+
mock_httpx_client.aclose.assert_awaited_once()
60+
61+
@pytest.mark.asyncio
62+
async def test_aclose_is_idempotent(self):
63+
# Use a real httpx.AsyncClient — its aclose() must tolerate repeated calls
64+
# because the context-manager protocol can result in double-cleanup
65+
# (explicit aclose + __aexit__) in caller code.
66+
real_client = httpx.AsyncClient()
67+
c = AsyncHttpClient(base_url="https://api.example.com", client=real_client)
68+
async with c:
69+
pass
70+
# Second close after __aexit__ already ran — must not raise.
71+
await real_client.aclose()
72+
5373

5474
class TestAsyncHttpClientGet:
5575
@pytest.mark.asyncio

0 commit comments

Comments
 (0)