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 )