/ tests / test_meta_oauth_helpers.py
test_meta_oauth_helpers.py
  1  """Tests for auth_setup Meta OAuth public helpers.
  2  
  3  These helpers are the shared building blocks between the interactive
  4  CLI (``setup_meta_ads``) and the web-based wizard. Extracting them so
  5  both paths build the same auth URL and run the same short→long token
  6  exchange prevents drift.
  7  """
  8  
  9  from __future__ import annotations
 10  
 11  from unittest.mock import AsyncMock, patch
 12  
 13  import pytest
 14  
 15  # Module imports under test — some of these don't exist yet.
 16  from mureo.auth_setup import (  # noqa: I001
 17      build_meta_auth_url,
 18      exchange_meta_code,
 19  )
 20  
 21  
 22  class TestBuildMetaAuthUrl:
 23      def test_returns_facebook_oauth_dialog_url(self) -> None:
 24          url = build_meta_auth_url(
 25              app_id="1234567890",
 26              redirect_uri="http://127.0.0.1:8080/meta-ads/callback",
 27              state="state-xyz",
 28          )
 29          assert url.startswith("https://www.facebook.com/")
 30          assert "dialog/oauth" in url
 31          assert "client_id=1234567890" in url
 32          # redirect_uri appears URL-encoded
 33          assert (
 34              "redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fmeta-ads%2Fcallback"
 35              in url
 36          )
 37          assert "state=state-xyz" in url
 38          assert "response_type=code" in url
 39  
 40      def test_includes_required_scopes(self) -> None:
 41          url = build_meta_auth_url(
 42              app_id="app",
 43              redirect_uri="http://127.0.0.1:1/cb",
 44              state="s",
 45          )
 46          assert "ads_management" in url
 47          assert "ads_read" in url
 48          assert "business_management" in url
 49  
 50      def test_rejects_non_localhost_redirect_uri(self) -> None:
 51          """Same localhost-only guard as the Google flow so a caller
 52          cannot redirect Facebook's OAuth grant to a remote host."""
 53          with pytest.raises(ValueError):
 54              build_meta_auth_url(
 55                  app_id="app",
 56                  redirect_uri="https://evil.example.com/cb",
 57                  state="s",
 58              )
 59  
 60      def test_rejects_non_http_scheme(self) -> None:
 61          with pytest.raises(ValueError):
 62              build_meta_auth_url(
 63                  app_id="app",
 64                  redirect_uri="javascript:alert(1)",
 65                  state="s",
 66              )
 67  
 68      def test_state_required(self) -> None:
 69          """``state`` is required for the web wizard (CSRF protection)
 70          — callers must supply it, not rely on a default."""
 71          with pytest.raises(TypeError):
 72              build_meta_auth_url(  # type: ignore[call-arg]
 73                  app_id="app",
 74                  redirect_uri="http://127.0.0.1:1/cb",
 75              )
 76  
 77  
 78  class TestExchangeMetaCode:
 79      @pytest.mark.asyncio
 80      async def test_returns_long_lived_token(self) -> None:
 81          """Happy path: composes short-token + upgrade-to-long-token in
 82          one public call, returning a MetaOAuthResult."""
 83          from mureo.auth_setup import MetaOAuthResult
 84  
 85          with (
 86              patch(
 87                  "mureo.auth_setup._exchange_code_for_short_token",
 88                  new=AsyncMock(return_value="SHORT_TOKEN"),
 89              ) as mock_short,
 90              patch(
 91                  "mureo.auth_setup._exchange_short_for_long_token",
 92                  new=AsyncMock(
 93                      return_value=MetaOAuthResult(
 94                          access_token="LONG_TOKEN", expires_in=5184000
 95                      )
 96                  ),
 97              ) as mock_long,
 98          ):
 99              result = await exchange_meta_code(
100                  code="AUTH_CODE",
101                  app_id="app",
102                  app_secret="secret",
103                  redirect_uri="http://127.0.0.1:8080/meta-ads/callback",
104              )
105  
106          assert result.access_token == "LONG_TOKEN"
107          assert result.expires_in == 5184000
108  
109          mock_short.assert_awaited_once_with(
110              code="AUTH_CODE",
111              app_id="app",
112              app_secret="secret",
113              redirect_uri="http://127.0.0.1:8080/meta-ads/callback",
114          )
115          mock_long.assert_awaited_once_with(
116              short_token="SHORT_TOKEN",
117              app_id="app",
118              app_secret="secret",
119          )
120  
121      @pytest.mark.asyncio
122      async def test_short_token_failure_propagates(self) -> None:
123          with patch(
124              "mureo.auth_setup._exchange_code_for_short_token",
125              new=AsyncMock(side_effect=RuntimeError("facebook said no")),
126          ):
127              with pytest.raises(RuntimeError, match="facebook said no"):
128                  await exchange_meta_code(
129                      code="AUTH_CODE",
130                      app_id="app",
131                      app_secret="secret",
132                      redirect_uri="http://127.0.0.1:1/cb",
133                  )
134  
135      @pytest.mark.asyncio
136      async def test_long_upgrade_failure_propagates(self) -> None:
137          with (
138              patch(
139                  "mureo.auth_setup._exchange_code_for_short_token",
140                  new=AsyncMock(return_value="SHORT"),
141              ),
142              patch(
143                  "mureo.auth_setup._exchange_short_for_long_token",
144                  new=AsyncMock(side_effect=RuntimeError("upgrade failed")),
145              ),
146          ):
147              with pytest.raises(RuntimeError, match="upgrade failed"):
148                  await exchange_meta_code(
149                      code="AUTH_CODE",
150                      app_id="app",
151                      app_secret="secret",
152                      redirect_uri="http://127.0.0.1:1/cb",
153                  )