| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- """Tests for the Orca Cloud service — PKCE generation, authorize URL shape,
- token exchange / refresh round-trip, single-use refresh token rotation,
- and Cloudflare-cleaning User-Agent header."""
- from __future__ import annotations
- import base64
- import hashlib
- import json
- from datetime import datetime, timedelta, timezone
- from unittest.mock import AsyncMock, MagicMock, patch
- from urllib.parse import parse_qs, urlparse
- import httpx
- import pytest
- from backend.app.services import orca_cloud
- from backend.app.services.orca_cloud import (
- ORCA_ANON_KEY,
- ORCA_AUTH_BASE,
- ORCA_REDIRECT_URI,
- OrcaCloudAuthError,
- OrcaCloudError,
- OrcaCloudService,
- build_authorize_url,
- generate_pkce,
- parse_callback_url,
- )
- # ---------------------------------------------------------------------------
- # PKCE primitives
- # ---------------------------------------------------------------------------
- class TestPkce:
- def test_challenge_is_sha256_of_verifier(self):
- """The challenge must be base64url(sha256(verifier)) — this is the
- RFC 7636 invariant Supabase will check on the exchange step. A bug
- here means the exchange always fails with code_verifier mismatch."""
- verifier, challenge, _state = generate_pkce()
- expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
- assert challenge == expected
- def test_verifier_length_in_rfc_range(self):
- verifier, _challenge, _state = generate_pkce()
- # 32 random bytes -> 43 chars after base64url-no-pad; RFC 7636
- # requires 43-128.
- assert 43 <= len(verifier) <= 128
- def test_state_is_unique_per_call(self):
- """Two consecutive calls must not share state — otherwise a stolen
- state from one flow could be replayed against another in-flight one."""
- _, _, s1 = generate_pkce()
- _, _, s2 = generate_pkce()
- assert s1 != s2
- def test_characters_are_url_safe(self):
- """Both verifier and challenge must be URL-safe base64 (no padding,
- no + or /) so they can be sent as query-string values without
- re-encoding."""
- verifier, challenge, state = generate_pkce()
- for value in (verifier, challenge, state):
- assert all(c.isalnum() or c in ("-", "_") for c in value), value
- class TestAuthorizeUrl:
- def test_url_targets_authorize_endpoint(self):
- url = build_authorize_url("CHALLENGE")
- assert url.startswith(f"{ORCA_AUTH_BASE}/auth/v1/authorize?")
- def test_url_contains_required_pkce_params(self):
- """The four PKCE params Supabase needs at authorize time. Missing any
- of these = Supabase 400s the request before redirecting to Google."""
- url = build_authorize_url("CHALLENGE")
- params = parse_qs(urlparse(url).query)
- assert params["provider"] == ["google"]
- assert params["redirect_to"] == [ORCA_REDIRECT_URI]
- assert params["code_challenge"] == ["CHALLENGE"]
- assert params["code_challenge_method"] == ["S256"]
- def test_url_does_not_pass_state(self):
- """Regression guard against re-introducing the bug we hit in the
- first deployed integration: passing ``state`` to GoTrue's authorize
- endpoint silently overrides its internal redirect_to tracking, so
- the user lands at the project Site URL instead of our localhost
- callback. CSRF is protected by PKCE alone — verifier is server-side
- and single-use."""
- url = build_authorize_url("CHALLENGE")
- params = parse_qs(urlparse(url).query)
- assert "state" not in params
- class TestParseCallback:
- def test_extracts_code_and_state_from_query(self):
- code, state = parse_callback_url("http://localhost:41172/callback?code=ABC&state=XYZ")
- assert code == "ABC"
- assert state == "XYZ"
- def test_falls_back_to_fragment(self):
- """Some Supabase configurations put PKCE codes in the URL fragment
- rather than the query (depends on response_mode setting). Both must
- be handled or some users get a confusing 'no code in URL' error."""
- code, state = parse_callback_url("http://localhost:41172/callback#code=ABC&state=XYZ")
- assert code == "ABC"
- assert state == "XYZ"
- def test_returns_none_when_no_code(self):
- code, state = parse_callback_url("http://localhost:41172/callback?error=denied")
- assert code is None
- assert state is None
- def test_handles_whitespace_padding(self):
- """Users paste from address bars and sometimes accidentally include
- a leading/trailing space — the parser must be forgiving."""
- code, _state = parse_callback_url(" http://localhost:41172/callback?code=ABC&state=XYZ ")
- assert code == "ABC"
- # ---------------------------------------------------------------------------
- # Token exchange + refresh
- # ---------------------------------------------------------------------------
- def _mock_response(
- *,
- status_code: int = 200,
- json_data: dict | None = None,
- text_body: str = "",
- ) -> MagicMock:
- """Build an httpx-like response mock with the only attributes the
- service touches: ``status_code``, ``.json()``, ``.text``."""
- resp = MagicMock(spec=["status_code", "json", "text"])
- resp.status_code = status_code
- if json_data is not None:
- resp.json.return_value = json_data
- resp.text = json.dumps(json_data)
- else:
- resp.json.side_effect = ValueError("not json")
- resp.text = text_body
- return resp
- @pytest.fixture
- def svc() -> OrcaCloudService:
- return OrcaCloudService(client=MagicMock(spec=httpx.AsyncClient))
- class TestExchangeCode:
- @pytest.mark.asyncio
- async def test_success_populates_tokens_and_expiry(self, svc):
- token_resp = _mock_response(
- json_data={
- "access_token": "ACCESS-1",
- "refresh_token": "REFRESH-1",
- "expires_in": 3600,
- "token_type": "bearer",
- }
- )
- svc._client.post = AsyncMock(return_value=token_resp)
- await svc.exchange_code("CODE", "VERIFIER")
- assert svc.access_token == "ACCESS-1"
- assert svc.refresh_token == "REFRESH-1"
- assert svc.token_expiry is not None
- # Expiry should be approximately now + 3600s (within a 60s window).
- delta = svc.token_expiry - datetime.now(timezone.utc)
- assert timedelta(seconds=3540) <= delta <= timedelta(seconds=3660)
- @pytest.mark.asyncio
- async def test_sends_apikey_and_user_agent_headers(self, svc):
- """Two load-bearing headers: the publishable apikey (Supabase
- requires it) and a non-default User-Agent (Cloudflare 1010s
- ``Python-urllib/X.Y`` so an honest ``Bambuddy/<v>`` UA is needed)."""
- token_resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 3600})
- svc._client.post = AsyncMock(return_value=token_resp)
- await svc.exchange_code("CODE", "VERIFIER")
- _args, kwargs = svc._client.post.call_args
- headers = kwargs["headers"]
- assert headers["apikey"] == ORCA_ANON_KEY
- assert headers["User-Agent"].startswith("Bambuddy/")
- assert headers["Content-Type"] == "application/json"
- @pytest.mark.asyncio
- async def test_400_raises_auth_error_not_generic(self, svc):
- """400 from Supabase usually means a bad verifier or stale code —
- the user has to restart sign-in. Raising auth-specific exception
- lets the route map to a sensible 400 with a 'click Connect again'
- message rather than a generic 502."""
- err_resp = _mock_response(
- status_code=400,
- json_data={"error": "invalid_grant", "error_description": "code expired"},
- )
- svc._client.post = AsyncMock(return_value=err_resp)
- with pytest.raises(OrcaCloudAuthError) as exc:
- await svc.exchange_code("CODE", "VERIFIER")
- assert "code expired" in str(exc.value)
- @pytest.mark.asyncio
- async def test_network_error_wraps_as_orca_error(self, svc):
- svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
- with pytest.raises(OrcaCloudError):
- await svc.exchange_code("CODE", "VERIFIER")
- class TestPasswordLogin:
- @pytest.mark.asyncio
- async def test_success_populates_tokens(self, svc):
- resp = _mock_response(
- json_data={
- "access_token": "PWD-A",
- "refresh_token": "PWD-R",
- "expires_in": 3600,
- }
- )
- svc._client.post = AsyncMock(return_value=resp)
- await svc.password_login("user@example.com", "secret")
- assert svc.access_token == "PWD-A"
- assert svc.refresh_token == "PWD-R"
- @pytest.mark.asyncio
- async def test_disabled_provider_raises_auth_error_not_generic(self, svc):
- """Whether Orca's Supabase project accepts password grant is config-
- dependent. When it doesn't (their desktop SDK refuses passwords by
- design, the backend may follow suit), the failure mode is a 400 /
- 422 with an error like ``email_provider_disabled``. The caller maps
- ``OrcaCloudAuthError`` to a 400 with a "use OAuth instead" hint —
- a 502 would imply Orca is down, which would be wrong UX."""
- err = _mock_response(
- status_code=422,
- json_data={"error": "email_provider_disabled", "error_description": "Email logins are disabled"},
- )
- svc._client.post = AsyncMock(return_value=err)
- with pytest.raises(OrcaCloudAuthError, match="Email logins are disabled"):
- await svc.password_login("user@example.com", "secret")
- @pytest.mark.asyncio
- async def test_invalid_credentials_raises_auth_error(self, svc):
- err = _mock_response(
- status_code=400,
- json_data={"error": "invalid_grant", "error_description": "Invalid login credentials"},
- )
- svc._client.post = AsyncMock(return_value=err)
- with pytest.raises(OrcaCloudAuthError, match="Invalid login credentials"):
- await svc.password_login("user@example.com", "wrong")
- class TestRefresh:
- @pytest.mark.asyncio
- async def test_rotates_refresh_token(self, svc):
- """Supabase refresh tokens are single-use — every successful refresh
- returns a NEW refresh token and invalidates the old. If the service
- kept the old one, the next refresh would 400 and the user would be
- force-logged-out."""
- svc.refresh_token = "REFRESH-1"
- resp = _mock_response(
- json_data={
- "access_token": "ACCESS-2",
- "refresh_token": "REFRESH-2",
- "expires_in": 3600,
- }
- )
- svc._client.post = AsyncMock(return_value=resp)
- await svc.refresh()
- assert svc.access_token == "ACCESS-2"
- assert svc.refresh_token == "REFRESH-2"
- @pytest.mark.asyncio
- async def test_no_refresh_token_raises_auth_error(self, svc):
- svc.refresh_token = None
- with pytest.raises(OrcaCloudAuthError):
- await svc.refresh()
- @pytest.mark.asyncio
- async def test_rejected_refresh_clears_tokens(self, svc):
- """If Supabase rejects the refresh token (revoked / rotated out from
- under us / hit by a token-replay defense), the service must clear
- the now-useless stored credentials so the UI can flip to the
- disconnected state rather than retrying forever."""
- svc.access_token = "OLD-ACCESS"
- svc.refresh_token = "OLD-REFRESH"
- svc.token_expiry = datetime.now(timezone.utc)
- err = _mock_response(
- status_code=401,
- json_data={"error": "invalid_grant", "error_description": "refresh token rotated"},
- )
- svc._client.post = AsyncMock(return_value=err)
- with pytest.raises(OrcaCloudAuthError):
- await svc.refresh()
- assert svc.access_token is None
- assert svc.refresh_token is None
- assert svc.token_expiry is None
- class TestIsAuthenticated:
- def test_no_token_means_not_authenticated(self, svc):
- assert svc.is_authenticated is False
- def test_no_expiry_means_not_authenticated(self, svc):
- """Pessimistic default: if we don't know when the token expires,
- treat it as expired so the next API call triggers a refresh
- rather than fails halfway through."""
- svc.access_token = "ACCESS"
- svc.token_expiry = None
- assert svc.is_authenticated is False
- def test_within_refresh_leeway_is_not_authenticated(self, svc):
- """The 5-minute leeway prevents a long-running API call from timing
- out mid-flight on a token that was technically still valid when the
- call started."""
- svc.access_token = "ACCESS"
- svc.token_expiry = datetime.now(timezone.utc) + timedelta(minutes=2)
- assert svc.is_authenticated is False
- def test_with_comfortable_expiry_is_authenticated(self, svc):
- svc.access_token = "ACCESS"
- svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
- assert svc.is_authenticated is True
- class TestApiHeaders:
- def test_api_headers_include_apikey_and_bearer(self, svc):
- svc.access_token = "ACCESS-123"
- headers = svc._api_headers()
- assert headers["apikey"] == ORCA_ANON_KEY
- assert headers["Authorization"] == "Bearer ACCESS-123"
- assert headers["User-Agent"].startswith("Bambuddy/")
- def test_api_headers_without_token_raises(self, svc):
- svc.access_token = None
- with pytest.raises(OrcaCloudAuthError):
- svc._api_headers()
- class TestListProfiles:
- @pytest.mark.asyncio
- async def test_pull_response_upserts_extracted(self, svc):
- """The bare-cursor /sync/pull returns a ``SyncPullResponse`` shape;
- we extract the ``upserts`` list and ignore ``next_cursor`` / ``deletes``
- (no prior client state to invalidate)."""
- svc.access_token = "ACCESS"
- svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
- svc._client.get = AsyncMock(
- return_value=_mock_response(
- json_data={
- "next_cursor": 12345,
- "upserts": [
- {"id": "a", "name": "A", "content": {"x": 1}},
- {"id": "b", "name": "B", "content": {"x": 2}},
- ],
- "deletes": ["zzz"],
- },
- )
- )
- result = await svc.list_profiles()
- assert [p["id"] for p in result] == ["a", "b"]
- @pytest.mark.asyncio
- async def test_pull_hits_path_without_cursor(self, svc):
- """Regression guard: ``cursor=0`` trips ``410 cursor_too_old`` on
- the production endpoint. The first-sync bootstrap must hit
- ``/api/v1/sync/pull`` with no ``?cursor=`` parameter — same behaviour
- as OrcaSlicer's own client."""
- svc.access_token = "ACCESS"
- svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
- svc._client.get = AsyncMock(
- return_value=_mock_response(json_data={"upserts": [], "deletes": []}),
- )
- await svc.list_profiles()
- called_url = svc._client.get.call_args.args[0]
- assert called_url.endswith("/api/v1/sync/pull")
- assert "cursor" not in called_url
- # And no ``params`` kwarg either, which would be a second way to
- # smuggle the cursor in.
- assert "params" not in svc._client.get.call_args.kwargs
- @pytest.mark.asyncio
- async def test_bare_list_response_tolerated(self, svc):
- """If the server ever rolls out a flat-list response shape, we
- forward it verbatim rather than logging-and-empty."""
- svc.access_token = "ACCESS"
- svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
- svc._client.get = AsyncMock(
- return_value=_mock_response(json_data=[{"id": "a", "name": "A"}]),
- )
- assert [p["id"] for p in await svc.list_profiles()] == ["a"]
- class TestGetProfile:
- @pytest.mark.asyncio
- async def test_returns_matching_profile_with_content(self, svc):
- """``get_profile`` lists then filters since Orca has no dedicated
- per-profile GET — verify the matched entry returns with full
- content, not stripped to metadata."""
- svc.access_token = "ACCESS"
- svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
- svc._client.get = AsyncMock(
- return_value=_mock_response(
- json_data={
- "upserts": [
- {"id": "a", "name": "A", "content": {"foo": 1}},
- {"id": "target", "name": "Target", "content": {"hit": True}},
- ],
- "deletes": [],
- },
- )
- )
- profile = await svc.get_profile("target")
- assert profile["id"] == "target"
- assert profile["content"] == {"hit": True}
- @pytest.mark.asyncio
- async def test_not_found_raises(self, svc):
- svc.access_token = "ACCESS"
- svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
- svc._client.get = AsyncMock(
- return_value=_mock_response(
- json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []},
- ),
- )
- with pytest.raises(OrcaCloudError, match="not found"):
- await svc.get_profile("missing")
|