test_orca_cloud.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. """Tests for the Orca Cloud device-pairing service — device-code request,
  2. token poll (the four RFC 8628 outcomes + success), single-use refresh
  3. rotation, external-API headers (bearer, no apikey), and profile pull."""
  4. from __future__ import annotations
  5. import json
  6. from datetime import datetime, timedelta, timezone
  7. from unittest.mock import AsyncMock, MagicMock
  8. import httpx
  9. import pytest
  10. from backend.app.services.orca_cloud import (
  11. ORCA_CLIENT_ID,
  12. DevicePoll,
  13. OrcaCloudAuthError,
  14. OrcaCloudError,
  15. OrcaCloudService,
  16. )
  17. def _mock_response(
  18. *,
  19. status_code: int = 200,
  20. json_data: dict | list | None = None,
  21. text_body: str = "",
  22. ) -> MagicMock:
  23. """Build an httpx-like response mock with the only attributes the
  24. service touches: ``status_code``, ``.json()``, ``.text``."""
  25. resp = MagicMock(spec=["status_code", "json", "text"])
  26. resp.status_code = status_code
  27. if json_data is not None:
  28. resp.json.return_value = json_data
  29. resp.text = json.dumps(json_data)
  30. else:
  31. resp.json.side_effect = ValueError("not json")
  32. resp.text = text_body
  33. return resp
  34. @pytest.fixture
  35. def svc() -> OrcaCloudService:
  36. return OrcaCloudService(client=MagicMock(spec=httpx.AsyncClient))
  37. # ---------------------------------------------------------------------------
  38. # Device-code request
  39. # ---------------------------------------------------------------------------
  40. class TestRequestDeviceCode:
  41. @pytest.mark.asyncio
  42. async def test_success_returns_device_code_payload(self, svc):
  43. resp = _mock_response(
  44. json_data={
  45. "device_code": "DEV-1",
  46. "user_code": "ABCD-EF12",
  47. "verification_uri": "https://cloud.orcaslicer.com/app/settings",
  48. "verification_uri_complete": "https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12",
  49. "expires_in": 600,
  50. "interval": 5,
  51. }
  52. )
  53. svc._client.post = AsyncMock(return_value=resp)
  54. data = await svc.request_device_code()
  55. assert data["user_code"] == "ABCD-EF12"
  56. assert data["device_code"] == "DEV-1"
  57. @pytest.mark.asyncio
  58. async def test_sends_client_id_scope_and_user_agent(self, svc):
  59. """The device-code request is form-encoded (NOT JSON) with our public
  60. client_id and requested scope, and carries the Cloudflare-clearing
  61. User-Agent. No ``apikey`` header (that was the old Supabase flow)."""
  62. resp = _mock_response(json_data={"device_code": "D", "user_code": "U", "interval": 5, "expires_in": 600})
  63. svc._client.post = AsyncMock(return_value=resp)
  64. await svc.request_device_code()
  65. _args, kwargs = svc._client.post.call_args
  66. assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID
  67. assert kwargs["data"]["scope"] # a scope is always sent
  68. assert kwargs["headers"]["User-Agent"].startswith("Bambuddy/")
  69. assert "apikey" not in kwargs["headers"]
  70. @pytest.mark.asyncio
  71. async def test_forwards_instance_fields_when_given(self, svc):
  72. resp = _mock_response(json_data={"device_code": "D", "user_code": "U", "interval": 5, "expires_in": 600})
  73. svc._client.post = AsyncMock(return_value=resp)
  74. await svc.request_device_code(instance_url="http://192.168.1.50:8080", instance_label="Garage")
  75. _args, kwargs = svc._client.post.call_args
  76. assert kwargs["data"]["instance_url"] == "http://192.168.1.50:8080"
  77. assert kwargs["data"]["instance_label"] == "Garage"
  78. @pytest.mark.asyncio
  79. async def test_invalid_client_raises_auth_error(self, svc):
  80. """A wrong/unregistered client_id returns ``invalid_client`` — an
  81. operator misconfiguration surfaced as an auth error so the route can
  82. map it distinctly from a transient outage."""
  83. resp = _mock_response(status_code=400, json_data={"error": "invalid_client"})
  84. svc._client.post = AsyncMock(return_value=resp)
  85. with pytest.raises(OrcaCloudAuthError, match="invalid_client"):
  86. await svc.request_device_code()
  87. @pytest.mark.asyncio
  88. async def test_network_error_wraps(self, svc):
  89. svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
  90. with pytest.raises(OrcaCloudError):
  91. await svc.request_device_code()
  92. # ---------------------------------------------------------------------------
  93. # Token poll (RFC 8628 device_code grant)
  94. # ---------------------------------------------------------------------------
  95. class TestPollToken:
  96. @pytest.mark.asyncio
  97. async def test_success_applies_tokens_and_returns_complete(self, svc):
  98. resp = _mock_response(
  99. json_data={
  100. "access_token": "oc_ext_A",
  101. "refresh_token": "oc_ext_rt_R",
  102. "expires_in": 86400,
  103. "token_type": "Bearer",
  104. }
  105. )
  106. svc._client.post = AsyncMock(return_value=resp)
  107. status, data = await svc.poll_token("DEV-1")
  108. assert status == DevicePoll.COMPLETE
  109. assert data["access_token"] == "oc_ext_A"
  110. assert svc.access_token == "oc_ext_A"
  111. assert svc.refresh_token == "oc_ext_rt_R"
  112. assert svc.token_expiry is not None
  113. @pytest.mark.asyncio
  114. async def test_sends_device_code_grant_and_client_id(self, svc):
  115. resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 86400})
  116. svc._client.post = AsyncMock(return_value=resp)
  117. await svc.poll_token("DEV-1")
  118. _args, kwargs = svc._client.post.call_args
  119. assert kwargs["data"]["grant_type"] == "urn:ietf:params:oauth:grant-type:device_code"
  120. assert kwargs["data"]["device_code"] == "DEV-1"
  121. assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID
  122. @pytest.mark.asyncio
  123. @pytest.mark.parametrize(
  124. "error_code,expected",
  125. [
  126. ("authorization_pending", DevicePoll.PENDING),
  127. ("slow_down", DevicePoll.SLOW_DOWN),
  128. ("access_denied", DevicePoll.DENIED),
  129. ("expired_token", DevicePoll.EXPIRED),
  130. ("invalid_grant", DevicePoll.EXPIRED), # collapsed to EXPIRED
  131. ],
  132. )
  133. async def test_rfc_error_codes_map_to_statuses(self, svc, error_code, expected):
  134. """The four RFC error codes (plus invalid_grant) are normal polling
  135. control flow — returned as statuses, never raised."""
  136. resp = _mock_response(status_code=400, json_data={"error": error_code})
  137. svc._client.post = AsyncMock(return_value=resp)
  138. status, data = await svc.poll_token("DEV-1")
  139. assert status == expected
  140. assert data is None
  141. @pytest.mark.asyncio
  142. async def test_unknown_error_raises(self, svc):
  143. """An unrecognized error body is a real problem, not a poll state —
  144. raise so it doesn't silently masquerade as 'still pending' forever."""
  145. resp = _mock_response(status_code=400, json_data={"error": "teapot"})
  146. svc._client.post = AsyncMock(return_value=resp)
  147. with pytest.raises(OrcaCloudError):
  148. await svc.poll_token("DEV-1")
  149. @pytest.mark.asyncio
  150. async def test_network_error_wraps(self, svc):
  151. svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
  152. with pytest.raises(OrcaCloudError):
  153. await svc.poll_token("DEV-1")
  154. # ---------------------------------------------------------------------------
  155. # Refresh
  156. # ---------------------------------------------------------------------------
  157. class TestRefresh:
  158. @pytest.mark.asyncio
  159. async def test_rotates_refresh_token(self, svc):
  160. """Refresh tokens are single-use — every successful refresh returns a
  161. NEW pair. Keeping the old refresh token would 400 the next refresh."""
  162. svc.refresh_token = "oc_ext_rt_1"
  163. resp = _mock_response(
  164. json_data={"access_token": "oc_ext_2", "refresh_token": "oc_ext_rt_2", "expires_in": 86400}
  165. )
  166. svc._client.post = AsyncMock(return_value=resp)
  167. await svc.refresh()
  168. assert svc.access_token == "oc_ext_2"
  169. assert svc.refresh_token == "oc_ext_rt_2"
  170. @pytest.mark.asyncio
  171. async def test_sends_refresh_grant_and_client_id(self, svc):
  172. svc.refresh_token = "oc_ext_rt_1"
  173. resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 86400})
  174. svc._client.post = AsyncMock(return_value=resp)
  175. await svc.refresh()
  176. _args, kwargs = svc._client.post.call_args
  177. assert kwargs["data"]["grant_type"] == "refresh_token"
  178. assert kwargs["data"]["refresh_token"] == "oc_ext_rt_1"
  179. assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID
  180. @pytest.mark.asyncio
  181. async def test_no_refresh_token_raises_auth_error(self, svc):
  182. svc.refresh_token = None
  183. with pytest.raises(OrcaCloudAuthError):
  184. await svc.refresh()
  185. @pytest.mark.asyncio
  186. async def test_rejected_refresh_clears_tokens(self, svc):
  187. """A rejected refresh (revoked / already-used / disconnected) is
  188. unrecoverable — clear the stale credentials so the UI flips to
  189. disconnected rather than retrying forever."""
  190. svc.access_token = "OLD"
  191. svc.refresh_token = "oc_ext_rt_old"
  192. svc.token_expiry = datetime.now(timezone.utc)
  193. resp = _mock_response(status_code=400, json_data={"error": "invalid_grant"})
  194. svc._client.post = AsyncMock(return_value=resp)
  195. with pytest.raises(OrcaCloudAuthError):
  196. await svc.refresh()
  197. assert svc.access_token is None
  198. assert svc.refresh_token is None
  199. assert svc.token_expiry is None
  200. # ---------------------------------------------------------------------------
  201. # is_authenticated
  202. # ---------------------------------------------------------------------------
  203. class TestIsAuthenticated:
  204. def test_no_token_means_not_authenticated(self, svc):
  205. assert svc.is_authenticated is False
  206. def test_no_expiry_means_not_authenticated(self, svc):
  207. svc.access_token = "A"
  208. svc.token_expiry = None
  209. assert svc.is_authenticated is False
  210. def test_within_refresh_leeway_is_not_authenticated(self, svc):
  211. svc.access_token = "A"
  212. svc.token_expiry = datetime.now(timezone.utc) + timedelta(minutes=2)
  213. assert svc.is_authenticated is False
  214. def test_with_comfortable_expiry_is_authenticated(self, svc):
  215. svc.access_token = "A"
  216. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
  217. assert svc.is_authenticated is True
  218. # ---------------------------------------------------------------------------
  219. # External-API headers
  220. # ---------------------------------------------------------------------------
  221. class TestApiHeaders:
  222. def test_api_headers_include_bearer_and_ua_no_apikey(self, svc):
  223. """External API auth is a plain bearer token — the old Supabase
  224. ``apikey`` header must NOT be sent (the ``oc_ext_`` token is the whole
  225. credential)."""
  226. svc.access_token = "oc_ext_123"
  227. headers = svc._api_headers()
  228. assert headers["Authorization"] == "Bearer oc_ext_123"
  229. assert headers["User-Agent"].startswith("Bambuddy/")
  230. assert "apikey" not in headers
  231. def test_api_headers_without_token_raises(self, svc):
  232. svc.access_token = None
  233. with pytest.raises(OrcaCloudAuthError):
  234. svc._api_headers()
  235. # ---------------------------------------------------------------------------
  236. # Introspection
  237. # ---------------------------------------------------------------------------
  238. class TestIntrospect:
  239. @pytest.mark.asyncio
  240. async def test_returns_record(self, svc):
  241. svc.access_token = "oc_ext_A"
  242. svc._client.get = AsyncMock(
  243. return_value=_mock_response(
  244. json_data={"user_id": "u-1", "client_id": ORCA_CLIENT_ID, "connection_id": "c-1"}
  245. )
  246. )
  247. info = await svc.introspect()
  248. assert info["user_id"] == "u-1"
  249. @pytest.mark.asyncio
  250. async def test_401_raises_auth_error(self, svc):
  251. svc.access_token = "oc_ext_A"
  252. svc._client.get = AsyncMock(return_value=_mock_response(status_code=401, text_body="unauthorized"))
  253. with pytest.raises(OrcaCloudAuthError):
  254. await svc.introspect()
  255. # ---------------------------------------------------------------------------
  256. # Profile pull
  257. # ---------------------------------------------------------------------------
  258. class TestListProfiles:
  259. @pytest.mark.asyncio
  260. async def test_pull_response_upserts_extracted(self, svc):
  261. svc.access_token = "oc_ext_A"
  262. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
  263. svc._client.get = AsyncMock(
  264. return_value=_mock_response(
  265. json_data={
  266. "next_cursor": 12345,
  267. "upserts": [
  268. {"id": "a", "name": "A", "content": {"x": 1}},
  269. {"id": "b", "name": "B", "content": {"x": 2}},
  270. ],
  271. "deletes": ["zzz"],
  272. }
  273. )
  274. )
  275. result = await svc.list_profiles()
  276. assert [p["id"] for p in result] == ["a", "b"]
  277. @pytest.mark.asyncio
  278. async def test_pull_hits_external_path_without_cursor(self, svc):
  279. """Regression guard: the list must hit the EXTERNAL sync path
  280. (``/api/v1/external/sync/pull``, not the first-party ``/api/v1/sync``)
  281. with no ``?cursor=`` — ``cursor=0`` trips ``410 cursor_too_old``."""
  282. svc.access_token = "oc_ext_A"
  283. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
  284. svc._client.get = AsyncMock(return_value=_mock_response(json_data={"upserts": [], "deletes": []}))
  285. await svc.list_profiles()
  286. called_url = svc._client.get.call_args.args[0]
  287. assert called_url.endswith("/api/v1/external/sync/pull")
  288. assert "cursor" not in called_url
  289. assert "params" not in svc._client.get.call_args.kwargs
  290. @pytest.mark.asyncio
  291. async def test_401_raises_auth_error(self, svc):
  292. svc.access_token = "oc_ext_A"
  293. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
  294. svc._client.get = AsyncMock(return_value=_mock_response(status_code=401, text_body="nope"))
  295. with pytest.raises(OrcaCloudAuthError):
  296. await svc.list_profiles()
  297. @pytest.mark.asyncio
  298. async def test_410_cursor_too_old_raises(self, svc):
  299. svc.access_token = "oc_ext_A"
  300. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
  301. svc._client.get = AsyncMock(return_value=_mock_response(status_code=410, json_data={"error": "cursor_too_old"}))
  302. with pytest.raises(OrcaCloudError, match="cursor too old"):
  303. await svc.list_profiles()
  304. @pytest.mark.asyncio
  305. async def test_bare_list_response_tolerated(self, svc):
  306. svc.access_token = "oc_ext_A"
  307. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
  308. svc._client.get = AsyncMock(return_value=_mock_response(json_data=[{"id": "a", "name": "A"}]))
  309. assert [p["id"] for p in await svc.list_profiles()] == ["a"]
  310. class TestGetProfile:
  311. @pytest.mark.asyncio
  312. async def test_returns_matching_profile_with_content(self, svc):
  313. svc.access_token = "oc_ext_A"
  314. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
  315. svc._client.get = AsyncMock(
  316. return_value=_mock_response(
  317. json_data={
  318. "upserts": [
  319. {"id": "a", "name": "A", "content": {"foo": 1}},
  320. {"id": "target", "name": "Target", "content": {"hit": True}},
  321. ],
  322. "deletes": [],
  323. }
  324. )
  325. )
  326. profile = await svc.get_profile("target")
  327. assert profile["id"] == "target"
  328. assert profile["content"] == {"hit": True}
  329. @pytest.mark.asyncio
  330. async def test_not_found_raises(self, svc):
  331. svc.access_token = "oc_ext_A"
  332. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
  333. svc._client.get = AsyncMock(
  334. return_value=_mock_response(json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []})
  335. )
  336. with pytest.raises(OrcaCloudError, match="not found"):
  337. await svc.get_profile("missing")