test_orca_cloud.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. """Tests for the Orca Cloud service — PKCE generation, authorize URL shape,
  2. token exchange / refresh round-trip, single-use refresh token rotation,
  3. and Cloudflare-cleaning User-Agent header."""
  4. from __future__ import annotations
  5. import base64
  6. import hashlib
  7. import json
  8. from datetime import datetime, timedelta, timezone
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. from urllib.parse import parse_qs, urlparse
  11. import httpx
  12. import pytest
  13. from backend.app.services import orca_cloud
  14. from backend.app.services.orca_cloud import (
  15. ORCA_ANON_KEY,
  16. ORCA_AUTH_BASE,
  17. ORCA_REDIRECT_URI,
  18. OrcaCloudAuthError,
  19. OrcaCloudError,
  20. OrcaCloudService,
  21. build_authorize_url,
  22. generate_pkce,
  23. parse_callback_url,
  24. )
  25. # ---------------------------------------------------------------------------
  26. # PKCE primitives
  27. # ---------------------------------------------------------------------------
  28. class TestPkce:
  29. def test_challenge_is_sha256_of_verifier(self):
  30. """The challenge must be base64url(sha256(verifier)) — this is the
  31. RFC 7636 invariant Supabase will check on the exchange step. A bug
  32. here means the exchange always fails with code_verifier mismatch."""
  33. verifier, challenge, _state = generate_pkce()
  34. expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
  35. assert challenge == expected
  36. def test_verifier_length_in_rfc_range(self):
  37. verifier, _challenge, _state = generate_pkce()
  38. # 32 random bytes -> 43 chars after base64url-no-pad; RFC 7636
  39. # requires 43-128.
  40. assert 43 <= len(verifier) <= 128
  41. def test_state_is_unique_per_call(self):
  42. """Two consecutive calls must not share state — otherwise a stolen
  43. state from one flow could be replayed against another in-flight one."""
  44. _, _, s1 = generate_pkce()
  45. _, _, s2 = generate_pkce()
  46. assert s1 != s2
  47. def test_characters_are_url_safe(self):
  48. """Both verifier and challenge must be URL-safe base64 (no padding,
  49. no + or /) so they can be sent as query-string values without
  50. re-encoding."""
  51. verifier, challenge, state = generate_pkce()
  52. for value in (verifier, challenge, state):
  53. assert all(c.isalnum() or c in ("-", "_") for c in value), value
  54. class TestAuthorizeUrl:
  55. def test_url_targets_authorize_endpoint(self):
  56. url = build_authorize_url("CHALLENGE")
  57. assert url.startswith(f"{ORCA_AUTH_BASE}/auth/v1/authorize?")
  58. def test_url_contains_required_pkce_params(self):
  59. """The four PKCE params Supabase needs at authorize time. Missing any
  60. of these = Supabase 400s the request before redirecting to Google."""
  61. url = build_authorize_url("CHALLENGE")
  62. params = parse_qs(urlparse(url).query)
  63. assert params["provider"] == ["google"]
  64. assert params["redirect_to"] == [ORCA_REDIRECT_URI]
  65. assert params["code_challenge"] == ["CHALLENGE"]
  66. assert params["code_challenge_method"] == ["S256"]
  67. def test_url_does_not_pass_state(self):
  68. """Regression guard against re-introducing the bug we hit in the
  69. first deployed integration: passing ``state`` to GoTrue's authorize
  70. endpoint silently overrides its internal redirect_to tracking, so
  71. the user lands at the project Site URL instead of our localhost
  72. callback. CSRF is protected by PKCE alone — verifier is server-side
  73. and single-use."""
  74. url = build_authorize_url("CHALLENGE")
  75. params = parse_qs(urlparse(url).query)
  76. assert "state" not in params
  77. class TestParseCallback:
  78. def test_extracts_code_and_state_from_query(self):
  79. code, state = parse_callback_url("http://localhost:41172/callback?code=ABC&state=XYZ")
  80. assert code == "ABC"
  81. assert state == "XYZ"
  82. def test_falls_back_to_fragment(self):
  83. """Some Supabase configurations put PKCE codes in the URL fragment
  84. rather than the query (depends on response_mode setting). Both must
  85. be handled or some users get a confusing 'no code in URL' error."""
  86. code, state = parse_callback_url("http://localhost:41172/callback#code=ABC&state=XYZ")
  87. assert code == "ABC"
  88. assert state == "XYZ"
  89. def test_returns_none_when_no_code(self):
  90. code, state = parse_callback_url("http://localhost:41172/callback?error=denied")
  91. assert code is None
  92. assert state is None
  93. def test_handles_whitespace_padding(self):
  94. """Users paste from address bars and sometimes accidentally include
  95. a leading/trailing space — the parser must be forgiving."""
  96. code, _state = parse_callback_url(" http://localhost:41172/callback?code=ABC&state=XYZ ")
  97. assert code == "ABC"
  98. # ---------------------------------------------------------------------------
  99. # Token exchange + refresh
  100. # ---------------------------------------------------------------------------
  101. def _mock_response(
  102. *,
  103. status_code: int = 200,
  104. json_data: dict | None = None,
  105. text_body: str = "",
  106. ) -> MagicMock:
  107. """Build an httpx-like response mock with the only attributes the
  108. service touches: ``status_code``, ``.json()``, ``.text``."""
  109. resp = MagicMock(spec=["status_code", "json", "text"])
  110. resp.status_code = status_code
  111. if json_data is not None:
  112. resp.json.return_value = json_data
  113. resp.text = json.dumps(json_data)
  114. else:
  115. resp.json.side_effect = ValueError("not json")
  116. resp.text = text_body
  117. return resp
  118. @pytest.fixture
  119. def svc() -> OrcaCloudService:
  120. return OrcaCloudService(client=MagicMock(spec=httpx.AsyncClient))
  121. class TestExchangeCode:
  122. @pytest.mark.asyncio
  123. async def test_success_populates_tokens_and_expiry(self, svc):
  124. token_resp = _mock_response(
  125. json_data={
  126. "access_token": "ACCESS-1",
  127. "refresh_token": "REFRESH-1",
  128. "expires_in": 3600,
  129. "token_type": "bearer",
  130. }
  131. )
  132. svc._client.post = AsyncMock(return_value=token_resp)
  133. await svc.exchange_code("CODE", "VERIFIER")
  134. assert svc.access_token == "ACCESS-1"
  135. assert svc.refresh_token == "REFRESH-1"
  136. assert svc.token_expiry is not None
  137. # Expiry should be approximately now + 3600s (within a 60s window).
  138. delta = svc.token_expiry - datetime.now(timezone.utc)
  139. assert timedelta(seconds=3540) <= delta <= timedelta(seconds=3660)
  140. @pytest.mark.asyncio
  141. async def test_sends_apikey_and_user_agent_headers(self, svc):
  142. """Two load-bearing headers: the publishable apikey (Supabase
  143. requires it) and a non-default User-Agent (Cloudflare 1010s
  144. ``Python-urllib/X.Y`` so an honest ``Bambuddy/<v>`` UA is needed)."""
  145. token_resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 3600})
  146. svc._client.post = AsyncMock(return_value=token_resp)
  147. await svc.exchange_code("CODE", "VERIFIER")
  148. _args, kwargs = svc._client.post.call_args
  149. headers = kwargs["headers"]
  150. assert headers["apikey"] == ORCA_ANON_KEY
  151. assert headers["User-Agent"].startswith("Bambuddy/")
  152. assert headers["Content-Type"] == "application/json"
  153. @pytest.mark.asyncio
  154. async def test_400_raises_auth_error_not_generic(self, svc):
  155. """400 from Supabase usually means a bad verifier or stale code —
  156. the user has to restart sign-in. Raising auth-specific exception
  157. lets the route map to a sensible 400 with a 'click Connect again'
  158. message rather than a generic 502."""
  159. err_resp = _mock_response(
  160. status_code=400,
  161. json_data={"error": "invalid_grant", "error_description": "code expired"},
  162. )
  163. svc._client.post = AsyncMock(return_value=err_resp)
  164. with pytest.raises(OrcaCloudAuthError) as exc:
  165. await svc.exchange_code("CODE", "VERIFIER")
  166. assert "code expired" in str(exc.value)
  167. @pytest.mark.asyncio
  168. async def test_network_error_wraps_as_orca_error(self, svc):
  169. svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
  170. with pytest.raises(OrcaCloudError):
  171. await svc.exchange_code("CODE", "VERIFIER")
  172. class TestPasswordLogin:
  173. @pytest.mark.asyncio
  174. async def test_success_populates_tokens(self, svc):
  175. resp = _mock_response(
  176. json_data={
  177. "access_token": "PWD-A",
  178. "refresh_token": "PWD-R",
  179. "expires_in": 3600,
  180. }
  181. )
  182. svc._client.post = AsyncMock(return_value=resp)
  183. await svc.password_login("user@example.com", "secret")
  184. assert svc.access_token == "PWD-A"
  185. assert svc.refresh_token == "PWD-R"
  186. @pytest.mark.asyncio
  187. async def test_disabled_provider_raises_auth_error_not_generic(self, svc):
  188. """Whether Orca's Supabase project accepts password grant is config-
  189. dependent. When it doesn't (their desktop SDK refuses passwords by
  190. design, the backend may follow suit), the failure mode is a 400 /
  191. 422 with an error like ``email_provider_disabled``. The caller maps
  192. ``OrcaCloudAuthError`` to a 400 with a "use OAuth instead" hint —
  193. a 502 would imply Orca is down, which would be wrong UX."""
  194. err = _mock_response(
  195. status_code=422,
  196. json_data={"error": "email_provider_disabled", "error_description": "Email logins are disabled"},
  197. )
  198. svc._client.post = AsyncMock(return_value=err)
  199. with pytest.raises(OrcaCloudAuthError, match="Email logins are disabled"):
  200. await svc.password_login("user@example.com", "secret")
  201. @pytest.mark.asyncio
  202. async def test_invalid_credentials_raises_auth_error(self, svc):
  203. err = _mock_response(
  204. status_code=400,
  205. json_data={"error": "invalid_grant", "error_description": "Invalid login credentials"},
  206. )
  207. svc._client.post = AsyncMock(return_value=err)
  208. with pytest.raises(OrcaCloudAuthError, match="Invalid login credentials"):
  209. await svc.password_login("user@example.com", "wrong")
  210. class TestRefresh:
  211. @pytest.mark.asyncio
  212. async def test_rotates_refresh_token(self, svc):
  213. """Supabase refresh tokens are single-use — every successful refresh
  214. returns a NEW refresh token and invalidates the old. If the service
  215. kept the old one, the next refresh would 400 and the user would be
  216. force-logged-out."""
  217. svc.refresh_token = "REFRESH-1"
  218. resp = _mock_response(
  219. json_data={
  220. "access_token": "ACCESS-2",
  221. "refresh_token": "REFRESH-2",
  222. "expires_in": 3600,
  223. }
  224. )
  225. svc._client.post = AsyncMock(return_value=resp)
  226. await svc.refresh()
  227. assert svc.access_token == "ACCESS-2"
  228. assert svc.refresh_token == "REFRESH-2"
  229. @pytest.mark.asyncio
  230. async def test_no_refresh_token_raises_auth_error(self, svc):
  231. svc.refresh_token = None
  232. with pytest.raises(OrcaCloudAuthError):
  233. await svc.refresh()
  234. @pytest.mark.asyncio
  235. async def test_rejected_refresh_clears_tokens(self, svc):
  236. """If Supabase rejects the refresh token (revoked / rotated out from
  237. under us / hit by a token-replay defense), the service must clear
  238. the now-useless stored credentials so the UI can flip to the
  239. disconnected state rather than retrying forever."""
  240. svc.access_token = "OLD-ACCESS"
  241. svc.refresh_token = "OLD-REFRESH"
  242. svc.token_expiry = datetime.now(timezone.utc)
  243. err = _mock_response(
  244. status_code=401,
  245. json_data={"error": "invalid_grant", "error_description": "refresh token rotated"},
  246. )
  247. svc._client.post = AsyncMock(return_value=err)
  248. with pytest.raises(OrcaCloudAuthError):
  249. await svc.refresh()
  250. assert svc.access_token is None
  251. assert svc.refresh_token is None
  252. assert svc.token_expiry is None
  253. class TestIsAuthenticated:
  254. def test_no_token_means_not_authenticated(self, svc):
  255. assert svc.is_authenticated is False
  256. def test_no_expiry_means_not_authenticated(self, svc):
  257. """Pessimistic default: if we don't know when the token expires,
  258. treat it as expired so the next API call triggers a refresh
  259. rather than fails halfway through."""
  260. svc.access_token = "ACCESS"
  261. svc.token_expiry = None
  262. assert svc.is_authenticated is False
  263. def test_within_refresh_leeway_is_not_authenticated(self, svc):
  264. """The 5-minute leeway prevents a long-running API call from timing
  265. out mid-flight on a token that was technically still valid when the
  266. call started."""
  267. svc.access_token = "ACCESS"
  268. svc.token_expiry = datetime.now(timezone.utc) + timedelta(minutes=2)
  269. assert svc.is_authenticated is False
  270. def test_with_comfortable_expiry_is_authenticated(self, svc):
  271. svc.access_token = "ACCESS"
  272. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
  273. assert svc.is_authenticated is True
  274. class TestApiHeaders:
  275. def test_api_headers_include_apikey_and_bearer(self, svc):
  276. svc.access_token = "ACCESS-123"
  277. headers = svc._api_headers()
  278. assert headers["apikey"] == ORCA_ANON_KEY
  279. assert headers["Authorization"] == "Bearer ACCESS-123"
  280. assert headers["User-Agent"].startswith("Bambuddy/")
  281. def test_api_headers_without_token_raises(self, svc):
  282. svc.access_token = None
  283. with pytest.raises(OrcaCloudAuthError):
  284. svc._api_headers()
  285. class TestListProfiles:
  286. @pytest.mark.asyncio
  287. async def test_pull_response_upserts_extracted(self, svc):
  288. """The bare-cursor /sync/pull returns a ``SyncPullResponse`` shape;
  289. we extract the ``upserts`` list and ignore ``next_cursor`` / ``deletes``
  290. (no prior client state to invalidate)."""
  291. svc.access_token = "ACCESS"
  292. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
  293. svc._client.get = AsyncMock(
  294. return_value=_mock_response(
  295. json_data={
  296. "next_cursor": 12345,
  297. "upserts": [
  298. {"id": "a", "name": "A", "content": {"x": 1}},
  299. {"id": "b", "name": "B", "content": {"x": 2}},
  300. ],
  301. "deletes": ["zzz"],
  302. },
  303. )
  304. )
  305. result = await svc.list_profiles()
  306. assert [p["id"] for p in result] == ["a", "b"]
  307. @pytest.mark.asyncio
  308. async def test_pull_hits_path_without_cursor(self, svc):
  309. """Regression guard: ``cursor=0`` trips ``410 cursor_too_old`` on
  310. the production endpoint. The first-sync bootstrap must hit
  311. ``/api/v1/sync/pull`` with no ``?cursor=`` parameter — same behaviour
  312. as OrcaSlicer's own client."""
  313. svc.access_token = "ACCESS"
  314. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
  315. svc._client.get = AsyncMock(
  316. return_value=_mock_response(json_data={"upserts": [], "deletes": []}),
  317. )
  318. await svc.list_profiles()
  319. called_url = svc._client.get.call_args.args[0]
  320. assert called_url.endswith("/api/v1/sync/pull")
  321. assert "cursor" not in called_url
  322. # And no ``params`` kwarg either, which would be a second way to
  323. # smuggle the cursor in.
  324. assert "params" not in svc._client.get.call_args.kwargs
  325. @pytest.mark.asyncio
  326. async def test_bare_list_response_tolerated(self, svc):
  327. """If the server ever rolls out a flat-list response shape, we
  328. forward it verbatim rather than logging-and-empty."""
  329. svc.access_token = "ACCESS"
  330. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
  331. svc._client.get = AsyncMock(
  332. return_value=_mock_response(json_data=[{"id": "a", "name": "A"}]),
  333. )
  334. assert [p["id"] for p in await svc.list_profiles()] == ["a"]
  335. class TestGetProfile:
  336. @pytest.mark.asyncio
  337. async def test_returns_matching_profile_with_content(self, svc):
  338. """``get_profile`` lists then filters since Orca has no dedicated
  339. per-profile GET — verify the matched entry returns with full
  340. content, not stripped to metadata."""
  341. svc.access_token = "ACCESS"
  342. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
  343. svc._client.get = AsyncMock(
  344. return_value=_mock_response(
  345. json_data={
  346. "upserts": [
  347. {"id": "a", "name": "A", "content": {"foo": 1}},
  348. {"id": "target", "name": "Target", "content": {"hit": True}},
  349. ],
  350. "deletes": [],
  351. },
  352. )
  353. )
  354. profile = await svc.get_profile("target")
  355. assert profile["id"] == "target"
  356. assert profile["content"] == {"hit": True}
  357. @pytest.mark.asyncio
  358. async def test_not_found_raises(self, svc):
  359. svc.access_token = "ACCESS"
  360. svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
  361. svc._client.get = AsyncMock(
  362. return_value=_mock_response(
  363. json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []},
  364. ),
  365. )
  366. with pytest.raises(OrcaCloudError, match="not found"):
  367. await svc.get_profile("missing")