test_orca_cloud_refresh.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. """What a rejected Orca Cloud refresh is allowed to do to stored credentials.
  2. The refresh token is single-use and rotating, and Orca reports every rejection
  3. with one composite reason (``unknown, expired, revoked, or already used``), so
  4. Bambuddy cannot tell a genuine revocation from a lost rotation race. Routes may
  5. still clear on that signal — a person is looking at the page and can pair again
  6. — but a background job must not, or an unattended run can destroy a working
  7. pairing (#2717).
  8. """
  9. import asyncio
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from fastapi import HTTPException
  13. from sqlalchemy import select
  14. from backend.app.api.routes.orca_cloud import _SETTINGS_KEYS, _build_authenticated_service
  15. from backend.app.models.settings import Settings
  16. from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
  17. async def _store_global_credentials(db):
  18. """An auth-disabled install's Orca credentials, expired so the helper
  19. refreshes rather than returning straight away."""
  20. db.add_all(
  21. [
  22. Settings(key=_SETTINGS_KEYS["token"], value="oc_ext_old"),
  23. Settings(key=_SETTINGS_KEYS["refresh_token"], value="oc_ext_rt_old"),
  24. Settings(key=_SETTINGS_KEYS["expires_at"], value="2000-01-01T00:00:00+00:00"),
  25. Settings(key=_SETTINGS_KEYS["email"], value="a@b.c"),
  26. ]
  27. )
  28. await db.commit()
  29. async def _stored_keys(db) -> set[str]:
  30. result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
  31. return {s.key for s in result.scalars().all()}
  32. def _expired_service(refresh_side_effect=None):
  33. """A service that reports its access token as expired, so the helper takes
  34. the refresh branch."""
  35. svc = MagicMock()
  36. svc.is_authenticated = False
  37. svc.refresh_token = "oc_ext_rt_old"
  38. svc.set_tokens = MagicMock()
  39. svc.refresh = AsyncMock(side_effect=refresh_side_effect)
  40. svc.access_token = "oc_ext_new"
  41. svc.token_expiry = None
  42. svc.close = AsyncMock()
  43. return svc
  44. class TestRejectedRefresh:
  45. @pytest.mark.asyncio
  46. async def test_routes_clear_the_dead_pairing_by_default(self, db_session):
  47. """Unchanged behaviour for interactive callers: the page flips to
  48. disconnected while the user is there to pair again."""
  49. await _store_global_credentials(db_session)
  50. svc = _expired_service(OrcaCloudAuthError("grant already used"))
  51. with (
  52. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  53. pytest.raises(HTTPException) as exc,
  54. ):
  55. await _build_authenticated_service(db_session, None)
  56. assert exc.value.status_code == 401
  57. assert await _stored_keys(db_session) == set()
  58. @pytest.mark.asyncio
  59. async def test_background_callers_leave_the_credentials_alone(self, db_session):
  60. """The whole point of the flag. A scheduled backup that guesses wrong
  61. here destroys a pairing nobody asked it to touch, and the user finds
  62. out when their profiles stop being backed up."""
  63. await _store_global_credentials(db_session)
  64. svc = _expired_service(OrcaCloudAuthError("grant already used"))
  65. with (
  66. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  67. pytest.raises(HTTPException) as exc,
  68. ):
  69. await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
  70. # Still reported as a hard auth failure — the caller has to skip the
  71. # account — but nothing was destroyed on the way out.
  72. assert exc.value.status_code == 401
  73. assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
  74. assert _SETTINGS_KEYS["refresh_token"] in await _stored_keys(db_session)
  75. @pytest.mark.asyncio
  76. async def test_an_unreachable_orca_never_clears_either_way(self, db_session):
  77. """A transport failure says nothing about the credentials' validity."""
  78. await _store_global_credentials(db_session)
  79. svc = _expired_service(OrcaCloudError("connection reset"))
  80. with (
  81. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  82. pytest.raises(HTTPException) as exc,
  83. ):
  84. await _build_authenticated_service(db_session, None)
  85. assert exc.value.status_code == 502
  86. assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
  87. class TestSuccessfulRefresh:
  88. @pytest.mark.asyncio
  89. async def test_the_rotated_pair_is_persisted_even_for_background_callers(self, db_session):
  90. """Not optional: by the time the refresh succeeds the old token is
  91. consumed, so failing to store the new pair would break a live pairing
  92. for real. The flag suppresses destruction, never persistence.
  93. """
  94. await _store_global_credentials(db_session)
  95. svc = _expired_service()
  96. svc.refresh_token = "oc_ext_rt_new"
  97. with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc):
  98. returned = await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
  99. assert returned is svc
  100. result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["token"]))
  101. assert result.scalar_one().value == "oc_ext_new"
  102. result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"]))
  103. assert result.scalar_one().value == "oc_ext_rt_new"
  104. class TestTheClientIsNotLeakedOnFailure:
  105. """A built service owns an httpx client from construction.
  106. On success the caller closes it. On failure nobody is ever handed it, so
  107. the builder has to close it itself -- otherwise every failed build leaks a
  108. client into the connection pool. Harmless enough while the only callers
  109. were routes, where a person retries a broken sign-in a handful of times;
  110. it stopped being harmless once spool assignment started building one per
  111. Orca-referenced spool, which fails on every assignment for as long as the
  112. stored credentials cannot be refreshed.
  113. """
  114. @pytest.mark.asyncio
  115. async def test_a_rejected_refresh_closes_it(self, db_session):
  116. await _store_global_credentials(db_session)
  117. svc = _expired_service(OrcaCloudAuthError("grant already used"))
  118. with (
  119. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  120. pytest.raises(HTTPException),
  121. ):
  122. await _build_authenticated_service(db_session, None)
  123. svc.close.assert_awaited_once()
  124. @pytest.mark.asyncio
  125. async def test_an_unreachable_orca_closes_it(self, db_session):
  126. await _store_global_credentials(db_session)
  127. svc = _expired_service(OrcaCloudError("connection reset"))
  128. with (
  129. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  130. pytest.raises(HTTPException),
  131. ):
  132. await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
  133. svc.close.assert_awaited_once()
  134. @pytest.mark.asyncio
  135. async def test_an_expired_token_with_nothing_to_refresh_closes_it(self, db_session):
  136. """The earliest raise, before any network call -- and the one easiest
  137. to miss, since it is a bare `raise` rather than an except block."""
  138. await _store_global_credentials(db_session)
  139. svc = _expired_service()
  140. svc.refresh_token = ""
  141. with (
  142. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  143. pytest.raises(HTTPException) as exc,
  144. ):
  145. await _build_authenticated_service(db_session, None)
  146. assert exc.value.status_code == 401
  147. svc.refresh.assert_not_awaited()
  148. svc.close.assert_awaited_once()
  149. @pytest.mark.asyncio
  150. async def test_a_cancelled_build_closes_it_and_stays_cancelled(self, db_session):
  151. """CancelledError is a BaseException, so an `except Exception` guard
  152. would let the client leak on shutdown -- and swallowing it here would
  153. break cancellation itself, which is the worse of the two bugs."""
  154. await _store_global_credentials(db_session)
  155. svc = _expired_service(asyncio.CancelledError())
  156. with (
  157. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  158. pytest.raises(asyncio.CancelledError),
  159. ):
  160. await _build_authenticated_service(db_session, None)
  161. svc.close.assert_awaited_once()
  162. @pytest.mark.asyncio
  163. async def test_a_failing_close_does_not_mask_the_real_error(self, db_session):
  164. """Cleanup is best-effort. The caller needs the auth failure, not
  165. whatever went wrong tidying up after it."""
  166. await _store_global_credentials(db_session)
  167. svc = _expired_service(OrcaCloudAuthError("grant already used"))
  168. svc.close = AsyncMock(side_effect=RuntimeError("pool already shut down"))
  169. with (
  170. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  171. pytest.raises(HTTPException) as exc,
  172. ):
  173. await _build_authenticated_service(db_session, None)
  174. assert exc.value.status_code == 401
  175. @pytest.mark.asyncio
  176. async def test_a_successful_build_leaves_it_open_for_the_caller(self, db_session):
  177. """The other half of the contract: closing here would hand back a dead
  178. client and break every route that uses one."""
  179. await _store_global_credentials(db_session)
  180. svc = _expired_service()
  181. svc.refresh_token = "oc_ext_rt_new"
  182. with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc):
  183. returned = await _build_authenticated_service(db_session, None)
  184. assert returned is svc
  185. svc.close.assert_not_awaited()