test_orca_cloud_device.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. """Integration tests for the Orca Cloud device-pairing routes.
  2. Covers the /device/start -> /device/poll pairing loop, its terminal outcomes,
  3. token persistence, and status/logout — all in auth-disabled mode (the global
  4. Settings-table fallback), with the service's network calls patched out.
  5. """
  6. from __future__ import annotations
  7. from datetime import datetime, timedelta, timezone
  8. from unittest.mock import patch
  9. import pytest
  10. from httpx import AsyncClient
  11. from backend.app.services import orca_cloud as orca_service
  12. from backend.app.services.orca_cloud import DevicePoll, OrcaCloudService
  13. AUTH_DISABLED = "backend.app.core.auth.is_auth_enabled"
  14. @pytest.fixture(autouse=True)
  15. def _dummy_shared_client():
  16. """Register a throwaway shared HTTP client so per-request
  17. OrcaCloudService() instances don't spin up (and leak) a real one — the
  18. network methods are patched anyway."""
  19. from unittest.mock import MagicMock
  20. orca_service.set_shared_http_client(MagicMock())
  21. yield
  22. orca_service.set_shared_http_client(None)
  23. _DEVICE_CODE_RESPONSE = {
  24. "device_code": "DEV-SECRET-1",
  25. "user_code": "ABCD-EF12",
  26. "verification_uri": "https://cloud.orcaslicer.com/app/settings",
  27. "verification_uri_complete": "https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12",
  28. "expires_in": 600,
  29. "interval": 5,
  30. }
  31. async def _start(async_client: AsyncClient):
  32. with (
  33. patch(AUTH_DISABLED, return_value=False),
  34. patch.object(OrcaCloudService, "request_device_code", return_value=dict(_DEVICE_CODE_RESPONSE)),
  35. ):
  36. return await async_client.post("/api/v1/orca-cloud/device/start")
  37. class TestDeviceStart:
  38. @pytest.mark.asyncio
  39. async def test_start_returns_user_code_and_hides_device_code(self, async_client: AsyncClient):
  40. resp = await _start(async_client)
  41. assert resp.status_code == 200
  42. body = resp.json()
  43. assert body["user_code"] == "ABCD-EF12"
  44. assert body["interval"] == 5
  45. assert body["verification_uri_complete"].endswith("user_code=ABCD-EF12")
  46. # The device_code is a secret and must NOT be echoed to the client.
  47. assert "device_code" not in body
  48. class TestDevicePoll:
  49. @pytest.mark.asyncio
  50. async def test_poll_without_pending_is_400(self, async_client: AsyncClient):
  51. with patch(AUTH_DISABLED, return_value=False):
  52. resp = await async_client.post("/api/v1/orca-cloud/device/poll")
  53. assert resp.status_code == 400
  54. @pytest.mark.asyncio
  55. async def test_poll_pending_reports_in_progress(self, async_client: AsyncClient):
  56. await _start(async_client)
  57. with (
  58. patch(AUTH_DISABLED, return_value=False),
  59. patch.object(OrcaCloudService, "poll_token", return_value=(DevicePoll.PENDING, None)),
  60. ):
  61. resp = await async_client.post("/api/v1/orca-cloud/device/poll")
  62. assert resp.status_code == 200
  63. body = resp.json()
  64. assert body["status"] == DevicePoll.PENDING
  65. assert body["connected"] is False
  66. @pytest.mark.asyncio
  67. async def test_poll_complete_persists_tokens_and_connects(self, async_client: AsyncClient):
  68. await _start(async_client)
  69. async def fake_complete(self, device_code):
  70. assert device_code == "DEV-SECRET-1" # the stored secret is used
  71. self.access_token = "oc_ext_new"
  72. self.refresh_token = "oc_ext_rt_new"
  73. self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=86400)
  74. return DevicePoll.COMPLETE, {"access_token": "oc_ext_new"}
  75. with (
  76. patch(AUTH_DISABLED, return_value=False),
  77. patch.object(OrcaCloudService, "poll_token", new=fake_complete),
  78. patch.object(OrcaCloudService, "introspect", return_value={"user_id": "user-123"}),
  79. ):
  80. resp = await async_client.post("/api/v1/orca-cloud/device/poll")
  81. assert resp.status_code == 200
  82. body = resp.json()
  83. assert body["status"] == DevicePoll.COMPLETE
  84. assert body["connected"] is True
  85. assert body["user_id"] == "user-123"
  86. # Status now reflects the connection, and the pending state is
  87. # cleared (a fresh poll finds nothing pending -> 400).
  88. status = await async_client.get("/api/v1/orca-cloud/status")
  89. assert status.json()["connected"] is True
  90. again = await async_client.post("/api/v1/orca-cloud/device/poll")
  91. assert again.status_code == 400
  92. @pytest.mark.asyncio
  93. async def test_poll_denied_clears_pending(self, async_client: AsyncClient):
  94. await _start(async_client)
  95. with (
  96. patch(AUTH_DISABLED, return_value=False),
  97. patch.object(OrcaCloudService, "poll_token", return_value=(DevicePoll.DENIED, None)),
  98. ):
  99. resp = await async_client.post("/api/v1/orca-cloud/device/poll")
  100. assert resp.json()["status"] == DevicePoll.DENIED
  101. # Pending cleared -> next poll has nothing to poll.
  102. with patch(AUTH_DISABLED, return_value=False):
  103. again = await async_client.post("/api/v1/orca-cloud/device/poll")
  104. assert again.status_code == 400
  105. @pytest.mark.asyncio
  106. async def test_poll_expires_by_ttl_without_network(self, async_client: AsyncClient):
  107. """A pending code older than DEVICE_CODE_TTL is reported expired
  108. without even calling the token endpoint. Shrinking the TTL to a
  109. negative window makes any just-created pending state 'stale'."""
  110. await _start(async_client)
  111. # poll_token must NOT be called; if it were, this would blow up.
  112. def _boom(*a, **k):
  113. raise AssertionError("poll_token should not be called for an expired code")
  114. with (
  115. patch(AUTH_DISABLED, return_value=False),
  116. patch("backend.app.api.routes.orca_cloud.DEVICE_CODE_TTL", timedelta(seconds=-1)),
  117. patch.object(OrcaCloudService, "poll_token", new=_boom),
  118. ):
  119. resp = await async_client.post("/api/v1/orca-cloud/device/poll")
  120. assert resp.status_code == 200
  121. assert resp.json()["status"] == DevicePoll.EXPIRED
  122. # And the expired pending state is cleared.
  123. with patch(AUTH_DISABLED, return_value=False):
  124. again = await async_client.post("/api/v1/orca-cloud/device/poll")
  125. assert again.status_code == 400
  126. class TestLogout:
  127. @pytest.mark.asyncio
  128. async def test_logout_clears_connection(self, async_client: AsyncClient):
  129. await _start(async_client)
  130. async def fake_complete(self, device_code):
  131. self.access_token = "oc_ext_new"
  132. self.refresh_token = "oc_ext_rt_new"
  133. self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=86400)
  134. return DevicePoll.COMPLETE, {"access_token": "oc_ext_new"}
  135. with (
  136. patch(AUTH_DISABLED, return_value=False),
  137. patch.object(OrcaCloudService, "poll_token", new=fake_complete),
  138. patch.object(OrcaCloudService, "introspect", return_value={"user_id": "u"}),
  139. ):
  140. await async_client.post("/api/v1/orca-cloud/device/poll")
  141. with patch(AUTH_DISABLED, return_value=False):
  142. out = await async_client.post("/api/v1/orca-cloud/logout")
  143. assert out.status_code == 200
  144. status = await async_client.get("/api/v1/orca-cloud/status")
  145. assert status.json()["connected"] is False