|
@@ -8,6 +8,7 @@ still clear on that signal — a person is looking at the page and can pair agai
|
|
|
pairing (#2717).
|
|
pairing (#2717).
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
|
|
+import asyncio
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
|
|
import pytest
|
|
import pytest
|
|
@@ -48,6 +49,7 @@ def _expired_service(refresh_side_effect=None):
|
|
|
svc.refresh = AsyncMock(side_effect=refresh_side_effect)
|
|
svc.refresh = AsyncMock(side_effect=refresh_side_effect)
|
|
|
svc.access_token = "oc_ext_new"
|
|
svc.access_token = "oc_ext_new"
|
|
|
svc.token_expiry = None
|
|
svc.token_expiry = None
|
|
|
|
|
+ svc.close = AsyncMock()
|
|
|
return svc
|
|
return svc
|
|
|
|
|
|
|
|
|
|
|
|
@@ -123,3 +125,106 @@ class TestSuccessfulRefresh:
|
|
|
assert result.scalar_one().value == "oc_ext_new"
|
|
assert result.scalar_one().value == "oc_ext_new"
|
|
|
result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"]))
|
|
result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"]))
|
|
|
assert result.scalar_one().value == "oc_ext_rt_new"
|
|
assert result.scalar_one().value == "oc_ext_rt_new"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestTheClientIsNotLeakedOnFailure:
|
|
|
|
|
+ """A built service owns an httpx client from construction.
|
|
|
|
|
+
|
|
|
|
|
+ On success the caller closes it. On failure nobody is ever handed it, so
|
|
|
|
|
+ the builder has to close it itself -- otherwise every failed build leaks a
|
|
|
|
|
+ client into the connection pool. Harmless enough while the only callers
|
|
|
|
|
+ were routes, where a person retries a broken sign-in a handful of times;
|
|
|
|
|
+ it stopped being harmless once spool assignment started building one per
|
|
|
|
|
+ Orca-referenced spool, which fails on every assignment for as long as the
|
|
|
|
|
+ stored credentials cannot be refreshed.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_a_rejected_refresh_closes_it(self, db_session):
|
|
|
|
|
+ await _store_global_credentials(db_session)
|
|
|
|
|
+ svc = _expired_service(OrcaCloudAuthError("grant already used"))
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
|
|
|
|
|
+ pytest.raises(HTTPException),
|
|
|
|
|
+ ):
|
|
|
|
|
+ await _build_authenticated_service(db_session, None)
|
|
|
|
|
+
|
|
|
|
|
+ svc.close.assert_awaited_once()
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_an_unreachable_orca_closes_it(self, db_session):
|
|
|
|
|
+ await _store_global_credentials(db_session)
|
|
|
|
|
+ svc = _expired_service(OrcaCloudError("connection reset"))
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
|
|
|
|
|
+ pytest.raises(HTTPException),
|
|
|
|
|
+ ):
|
|
|
|
|
+ await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
|
|
|
|
|
+
|
|
|
|
|
+ svc.close.assert_awaited_once()
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_an_expired_token_with_nothing_to_refresh_closes_it(self, db_session):
|
|
|
|
|
+ """The earliest raise, before any network call -- and the one easiest
|
|
|
|
|
+ to miss, since it is a bare `raise` rather than an except block."""
|
|
|
|
|
+ await _store_global_credentials(db_session)
|
|
|
|
|
+ svc = _expired_service()
|
|
|
|
|
+ svc.refresh_token = ""
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
|
|
|
|
|
+ pytest.raises(HTTPException) as exc,
|
|
|
|
|
+ ):
|
|
|
|
|
+ await _build_authenticated_service(db_session, None)
|
|
|
|
|
+
|
|
|
|
|
+ assert exc.value.status_code == 401
|
|
|
|
|
+ svc.refresh.assert_not_awaited()
|
|
|
|
|
+ svc.close.assert_awaited_once()
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_a_cancelled_build_closes_it_and_stays_cancelled(self, db_session):
|
|
|
|
|
+ """CancelledError is a BaseException, so an `except Exception` guard
|
|
|
|
|
+ would let the client leak on shutdown -- and swallowing it here would
|
|
|
|
|
+ break cancellation itself, which is the worse of the two bugs."""
|
|
|
|
|
+ await _store_global_credentials(db_session)
|
|
|
|
|
+ svc = _expired_service(asyncio.CancelledError())
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
|
|
|
|
|
+ pytest.raises(asyncio.CancelledError),
|
|
|
|
|
+ ):
|
|
|
|
|
+ await _build_authenticated_service(db_session, None)
|
|
|
|
|
+
|
|
|
|
|
+ svc.close.assert_awaited_once()
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_a_failing_close_does_not_mask_the_real_error(self, db_session):
|
|
|
|
|
+ """Cleanup is best-effort. The caller needs the auth failure, not
|
|
|
|
|
+ whatever went wrong tidying up after it."""
|
|
|
|
|
+ await _store_global_credentials(db_session)
|
|
|
|
|
+ svc = _expired_service(OrcaCloudAuthError("grant already used"))
|
|
|
|
|
+ svc.close = AsyncMock(side_effect=RuntimeError("pool already shut down"))
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
|
|
|
|
|
+ pytest.raises(HTTPException) as exc,
|
|
|
|
|
+ ):
|
|
|
|
|
+ await _build_authenticated_service(db_session, None)
|
|
|
|
|
+
|
|
|
|
|
+ assert exc.value.status_code == 401
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_a_successful_build_leaves_it_open_for_the_caller(self, db_session):
|
|
|
|
|
+ """The other half of the contract: closing here would hand back a dead
|
|
|
|
|
+ client and break every route that uses one."""
|
|
|
|
|
+ await _store_global_credentials(db_session)
|
|
|
|
|
+ svc = _expired_service()
|
|
|
|
|
+ svc.refresh_token = "oc_ext_rt_new"
|
|
|
|
|
+
|
|
|
|
|
+ with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc):
|
|
|
|
|
+ returned = await _build_authenticated_service(db_session, None)
|
|
|
|
|
+
|
|
|
|
|
+ assert returned is svc
|
|
|
|
|
+ svc.close.assert_not_awaited()
|