test_orca_cloud_refresh.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  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. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from fastapi import HTTPException
  12. from sqlalchemy import select
  13. from backend.app.api.routes.orca_cloud import _SETTINGS_KEYS, _build_authenticated_service
  14. from backend.app.models.settings import Settings
  15. from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
  16. async def _store_global_credentials(db):
  17. """An auth-disabled install's Orca credentials, expired so the helper
  18. refreshes rather than returning straight away."""
  19. db.add_all(
  20. [
  21. Settings(key=_SETTINGS_KEYS["token"], value="oc_ext_old"),
  22. Settings(key=_SETTINGS_KEYS["refresh_token"], value="oc_ext_rt_old"),
  23. Settings(key=_SETTINGS_KEYS["expires_at"], value="2000-01-01T00:00:00+00:00"),
  24. Settings(key=_SETTINGS_KEYS["email"], value="a@b.c"),
  25. ]
  26. )
  27. await db.commit()
  28. async def _stored_keys(db) -> set[str]:
  29. result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
  30. return {s.key for s in result.scalars().all()}
  31. def _expired_service(refresh_side_effect=None):
  32. """A service that reports its access token as expired, so the helper takes
  33. the refresh branch."""
  34. svc = MagicMock()
  35. svc.is_authenticated = False
  36. svc.refresh_token = "oc_ext_rt_old"
  37. svc.set_tokens = MagicMock()
  38. svc.refresh = AsyncMock(side_effect=refresh_side_effect)
  39. svc.access_token = "oc_ext_new"
  40. svc.token_expiry = None
  41. return svc
  42. class TestRejectedRefresh:
  43. @pytest.mark.asyncio
  44. async def test_routes_clear_the_dead_pairing_by_default(self, db_session):
  45. """Unchanged behaviour for interactive callers: the page flips to
  46. disconnected while the user is there to pair again."""
  47. await _store_global_credentials(db_session)
  48. svc = _expired_service(OrcaCloudAuthError("grant already used"))
  49. with (
  50. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  51. pytest.raises(HTTPException) as exc,
  52. ):
  53. await _build_authenticated_service(db_session, None)
  54. assert exc.value.status_code == 401
  55. assert await _stored_keys(db_session) == set()
  56. @pytest.mark.asyncio
  57. async def test_background_callers_leave_the_credentials_alone(self, db_session):
  58. """The whole point of the flag. A scheduled backup that guesses wrong
  59. here destroys a pairing nobody asked it to touch, and the user finds
  60. out when their profiles stop being backed up."""
  61. await _store_global_credentials(db_session)
  62. svc = _expired_service(OrcaCloudAuthError("grant already used"))
  63. with (
  64. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  65. pytest.raises(HTTPException) as exc,
  66. ):
  67. await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
  68. # Still reported as a hard auth failure — the caller has to skip the
  69. # account — but nothing was destroyed on the way out.
  70. assert exc.value.status_code == 401
  71. assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
  72. assert _SETTINGS_KEYS["refresh_token"] in await _stored_keys(db_session)
  73. @pytest.mark.asyncio
  74. async def test_an_unreachable_orca_never_clears_either_way(self, db_session):
  75. """A transport failure says nothing about the credentials' validity."""
  76. await _store_global_credentials(db_session)
  77. svc = _expired_service(OrcaCloudError("connection reset"))
  78. with (
  79. patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
  80. pytest.raises(HTTPException) as exc,
  81. ):
  82. await _build_authenticated_service(db_session, None)
  83. assert exc.value.status_code == 502
  84. assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
  85. class TestSuccessfulRefresh:
  86. @pytest.mark.asyncio
  87. async def test_the_rotated_pair_is_persisted_even_for_background_callers(self, db_session):
  88. """Not optional: by the time the refresh succeeds the old token is
  89. consumed, so failing to store the new pair would break a live pairing
  90. for real. The flag suppresses destruction, never persistence.
  91. """
  92. await _store_global_credentials(db_session)
  93. svc = _expired_service()
  94. svc.refresh_token = "oc_ext_rt_new"
  95. with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc):
  96. returned = await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
  97. assert returned is svc
  98. result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["token"]))
  99. assert result.scalar_one().value == "oc_ext_new"
  100. result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"]))
  101. assert result.scalar_one().value == "oc_ext_rt_new"