|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Regression tests: successful tool responses must never carry the access token. |
| 4 | +
|
| 5 | +Graph API list responses include `paging.next` / `paging.previous` URLs with |
| 6 | +`access_token=<live token>` (and `appsecret_proof`) embedded in the query |
| 7 | +string. Error responses were already scrubbed via _redact_url; successful |
| 8 | +responses were passed straight through, so any tool that returned a paged |
| 9 | +Graph response (real case: get_pixel_events) echoed the operator's |
| 10 | +never-expiring system-user token into the caller's transcript. |
| 11 | +
|
| 12 | +Two layers are covered: |
| 13 | + 1. make_api_request drops paging.next / paging.previous at any depth |
| 14 | + (cursors stay, so callers can still paginate with `after`). |
| 15 | + 2. meta_api_tool redacts any leftover access_token= / appsecret_proof= |
| 16 | + query values in the final output, whatever field they sit in. |
| 17 | +""" |
| 18 | + |
| 19 | +import json |
| 20 | + |
| 21 | +import pytest |
| 22 | +from unittest.mock import AsyncMock, MagicMock, patch |
| 23 | + |
| 24 | +from meta_ads_mcp.core.api import ( |
| 25 | + _redact_secret_params, |
| 26 | + _strip_paging_links, |
| 27 | + make_api_request, |
| 28 | + meta_api_tool, |
| 29 | +) |
| 30 | + |
| 31 | +TOKEN = "EAAFAKETOKEN0123456789abcdefXYZ" |
| 32 | +PROOF = "deadbeefcafe0123456789" |
| 33 | +LEAKY_NEXT = f"https://graph.facebook.com/v25.0/123/stats?access_token={TOKEN}&limit=25&after=abc" |
| 34 | +LEAKY_PREV = f"https://graph.facebook.com/v25.0/123/stats?access_token={TOKEN}&appsecret_proof={PROOF}&before=xyz" |
| 35 | + |
| 36 | + |
| 37 | +def _paged_payload(): |
| 38 | + return { |
| 39 | + "data": [{"value": "PageView", "count": 14}], |
| 40 | + "paging": { |
| 41 | + "cursors": {"before": "B", "after": "A"}, |
| 42 | + "next": LEAKY_NEXT, |
| 43 | + "previous": LEAKY_PREV, |
| 44 | + }, |
| 45 | + } |
| 46 | + |
| 47 | + |
| 48 | +# --- Layer 1: _strip_paging_links ------------------------------------------ |
| 49 | + |
| 50 | +def test_strip_removes_next_and_previous_keeps_cursors(): |
| 51 | + cleaned = _strip_paging_links(_paged_payload()) |
| 52 | + assert cleaned["paging"] == {"cursors": {"before": "B", "after": "A"}} |
| 53 | + assert cleaned["data"] == [{"value": "PageView", "count": 14}] |
| 54 | + |
| 55 | + |
| 56 | +def test_strip_works_at_any_depth(): |
| 57 | + """Field-expansion responses nest paging inside objects and lists.""" |
| 58 | + payload = { |
| 59 | + "id": "act_1", |
| 60 | + "adspixels": _paged_payload(), |
| 61 | + "list": [{"inner": _paged_payload()}], |
| 62 | + } |
| 63 | + text = json.dumps(_strip_paging_links(payload)) |
| 64 | + assert TOKEN not in text |
| 65 | + assert PROOF not in text |
| 66 | + assert text.count('"after": "A"') == 2 # cursors survive in both nested spots |
| 67 | + |
| 68 | + |
| 69 | +def test_strip_leaves_non_paging_next_keys_alone(): |
| 70 | + """Only `next`/`previous` inside a `paging` object are links; a field that |
| 71 | + merely happens to be called `next` elsewhere is real data.""" |
| 72 | + payload = {"next": "keep-me", "data": [{"previous": "keep-me-too"}]} |
| 73 | + assert _strip_paging_links(payload) == payload |
| 74 | + |
| 75 | + |
| 76 | +def test_strip_handles_scalars_and_empty_paging(): |
| 77 | + assert _strip_paging_links("text") == "text" |
| 78 | + assert _strip_paging_links(None) is None |
| 79 | + assert _strip_paging_links({"paging": {}}) == {"paging": {}} |
| 80 | + assert _strip_paging_links({"paging": "not-a-dict"}) == {"paging": "not-a-dict"} |
| 81 | + |
| 82 | + |
| 83 | +# --- Layer 1 end-to-end: make_api_request ---------------------------------- |
| 84 | + |
| 85 | +@pytest.fixture |
| 86 | +def mock_httpx_client(): |
| 87 | + with patch("meta_ads_mcp.core.api.httpx.AsyncClient") as mock_client_cls: |
| 88 | + client = MagicMock() |
| 89 | + response = MagicMock() |
| 90 | + response.status_code = 200 |
| 91 | + response.headers = {} |
| 92 | + response.json.return_value = _paged_payload() |
| 93 | + response.raise_for_status.return_value = None |
| 94 | + client.get = AsyncMock(return_value=response) |
| 95 | + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=client) |
| 96 | + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) |
| 97 | + yield client |
| 98 | + |
| 99 | + |
| 100 | +@pytest.mark.asyncio |
| 101 | +async def test_make_api_request_strips_paging_links(mock_httpx_client): |
| 102 | + result = await make_api_request("123/stats", "SECRET_TOKEN", {}) |
| 103 | + assert "next" not in result["paging"] |
| 104 | + assert "previous" not in result["paging"] |
| 105 | + assert result["paging"]["cursors"]["after"] == "A" |
| 106 | + assert TOKEN not in json.dumps(result) |
| 107 | + |
| 108 | + |
| 109 | +# --- Layer 2: _redact_secret_params ---------------------------------------- |
| 110 | + |
| 111 | +def test_redact_masks_token_and_proof_values(): |
| 112 | + text = f'{{"u": "https://x/y?access_token={TOKEN}&limit=5&appsecret_proof={PROOF}"}}' |
| 113 | + out = _redact_secret_params(text) |
| 114 | + assert TOKEN not in out and PROOF not in out |
| 115 | + assert "access_token=REDACTED" in out |
| 116 | + assert "appsecret_proof=REDACTED" in out |
| 117 | + assert "limit=5" in out # unrelated params untouched |
| 118 | + |
| 119 | + |
| 120 | +def test_redact_handles_value_at_end_and_escaped_json(): |
| 121 | + assert _redact_secret_params(f"?access_token={TOKEN}") == "?access_token=REDACTED" |
| 122 | + escaped = json.dumps(f"https://x/y?a=1&access_token={TOKEN}") |
| 123 | + assert TOKEN not in _redact_secret_params(escaped) |
| 124 | + |
| 125 | + |
| 126 | +def test_redact_leaves_clean_text_unchanged(): |
| 127 | + clean = '{"data": [{"name": "access_token_rotation_policy"}], "limit": 25}' |
| 128 | + assert _redact_secret_params(clean) == clean |
| 129 | + |
| 130 | + |
| 131 | +# --- Layer 2 end-to-end: meta_api_tool wrapper ----------------------------- |
| 132 | + |
| 133 | +@pytest.mark.asyncio |
| 134 | +async def test_wrapper_redacts_token_in_any_string_field(): |
| 135 | + @meta_api_tool |
| 136 | + async def fake_tool(access_token=None): |
| 137 | + # A leak in a field the paging-strip layer knows nothing about. |
| 138 | + return json.dumps({"preview_url": f"https://x/y?access_token={TOKEN}"}) |
| 139 | + |
| 140 | + out = await fake_tool(access_token="SECRET_TOKEN") |
| 141 | + assert TOKEN not in out |
| 142 | + assert "access_token=REDACTED" in out |
| 143 | + |
| 144 | + |
| 145 | +@pytest.mark.asyncio |
| 146 | +async def test_wrapper_redacts_token_in_dict_results(): |
| 147 | + @meta_api_tool |
| 148 | + async def fake_tool(access_token=None): |
| 149 | + return {"link": f"https://x/y?access_token={TOKEN}"} |
| 150 | + |
| 151 | + out = await fake_tool(access_token="SECRET_TOKEN") |
| 152 | + assert TOKEN not in out |
| 153 | + |
| 154 | + |
| 155 | +@pytest.mark.asyncio |
| 156 | +async def test_wrapper_passes_clean_output_through_unchanged(): |
| 157 | + payload = json.dumps({"data": [1, 2, 3]}, indent=2) |
| 158 | + |
| 159 | + @meta_api_tool |
| 160 | + async def fake_tool(access_token=None): |
| 161 | + return payload |
| 162 | + |
| 163 | + assert await fake_tool(access_token="SECRET_TOKEN") == payload |
| 164 | + |
| 165 | + |
| 166 | +# --- get_login_link must not show any part of the cached token -------------- |
| 167 | + |
| 168 | +@pytest.mark.asyncio |
| 169 | +async def test_login_link_never_shows_token_prefix(monkeypatch): |
| 170 | + """It used to return `Token preview: <first 10 chars>...` — a partial |
| 171 | + disclosure of the live token. Now it only says that a token is cached.""" |
| 172 | + from meta_ads_mcp.core import authentication |
| 173 | + |
| 174 | + monkeypatch.delenv("META_ADS_DISABLE_CALLBACK_SERVER", raising=False) |
| 175 | + token_info = MagicMock(created_at=1, expires_in=2) |
| 176 | + with patch.object(authentication.auth_manager, "get_access_token", return_value=TOKEN), \ |
| 177 | + patch.object(authentication.auth_manager, "token_info", token_info, create=True): |
| 178 | + out = await authentication.get_login_link() |
| 179 | + |
| 180 | + assert "Already Authenticated" in out |
| 181 | + assert TOKEN[:10] not in out |
| 182 | + assert "Token preview" not in out |
| 183 | + |
| 184 | + |
| 185 | +# --- Removed tool ----------------------------------------------------------- |
| 186 | + |
| 187 | +def test_get_capi_diagnostics_is_gone(): |
| 188 | + """Meta no longer serves /server_events_diagnostic (Graph v25 returns |
| 189 | + 400 code 2500 'Unknown path components'), so the tool was removed.""" |
| 190 | + from meta_ads_mcp.core import conversions |
| 191 | + |
| 192 | + assert not hasattr(conversions, "get_capi_diagnostics") |
| 193 | + assert hasattr(conversions, "send_conversion_events") # sibling untouched |
0 commit comments