"""Tests for BotThrottleMiddleware — agent-first principal model. The core invariant: MSign-authenticated requests bypass UA checks entirely. UA-based blocking applies only to unauthenticated requests. """ from __future__ import annotations import pytest from httpx import AsyncClient # --------------------------------------------------------------------------- # Authenticated requests — always pass through regardless of UA # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_msign_with_curl_ua_passes(client: AsyncClient) -> None: """MSign-authenticated curl calls must not be blocked. Developers and agents routinely test with curl. Once they sign the request, the UA is irrelevant — identity is proven cryptographically. """ resp = await client.get( "/healthz", headers={ "User-Agent": "curl/8.4.0", "Authorization": "MSign handle=\"gabriel\" ts=1234567890 sig=\"fakesig\"", }, ) # Must not be 429 — may be 401/403 (invalid sig) but never bot-blocked assert resp.status_code != 429 @pytest.mark.anyio async def test_msign_with_python_requests_ua_passes(client: AsyncClient) -> None: """MSign-authenticated python-requests calls must not be blocked.""" resp = await client.get( "/healthz", headers={ "User-Agent": "python-requests/2.31.0", "Authorization": "MSign handle=\"agentception-abc123\" ts=1234567890 sig=\"fakesig\"", }, ) assert resp.status_code != 429 @pytest.mark.anyio async def test_msign_with_go_ua_passes(client: AsyncClient) -> None: """MSign-authenticated Go HTTP client calls must not be blocked.""" resp = await client.get( "/healthz", headers={ "User-Agent": "Go-http-client/1.1", "Authorization": "MSign handle=\"some-agent\" ts=1234567890 sig=\"fakesig\"", }, ) assert resp.status_code != 429 @pytest.mark.anyio async def test_msign_with_no_ua_passes(client: AsyncClient) -> None: """MSign-authenticated requests with no UA must not be blocked. An agent that omits the UA header entirely is still authenticated. """ resp = await client.get( "/healthz", headers={ "Authorization": "MSign handle=\"agent-42\" ts=1234567890 sig=\"fakesig\"", }, ) assert resp.status_code != 429 # --------------------------------------------------------------------------- # Unauthenticated requests with bad UAs — must be blocked # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_unauthenticated_curl_ua_blocked(client: AsyncClient) -> None: """Unauthenticated curl requests are blocked — commodity scraper signal.""" resp = await client.get( "/api/identities", headers={"User-Agent": "curl/8.4.0"}, ) assert resp.status_code == 429 @pytest.mark.anyio async def test_unauthenticated_python_requests_ua_blocked(client: AsyncClient) -> None: """Unauthenticated python-requests are blocked.""" resp = await client.get( "/api/identities", headers={"User-Agent": "python-requests/2.31.0"}, ) assert resp.status_code == 429 @pytest.mark.anyio async def test_unauthenticated_go_ua_blocked(client: AsyncClient) -> None: """Unauthenticated Go HTTP client requests are blocked.""" resp = await client.get( "/api/identities", headers={"User-Agent": "Go-http-client/1.1"}, ) assert resp.status_code == 429 @pytest.mark.anyio async def test_unauthenticated_missing_ua_blocked(client: AsyncClient) -> None: """Unauthenticated requests with an empty UA are blocked. httpx always sends a UA, so we explicitly blank it to simulate a client that omits the header. """ resp = await client.get("/api/identities", headers={"User-Agent": ""}) assert resp.status_code == 429 @pytest.mark.anyio async def test_unauthenticated_scanner_ua_blocked(client: AsyncClient) -> None: """Vulnerability scanners are blocked regardless of auth header absence.""" for ua in ["sqlmap/1.7", "nikto/2.1.6", "nuclei/3.0", "masscan/1.3"]: resp = await client.get( "/api/identities", headers={"User-Agent": ua}, ) assert resp.status_code == 429, f"Expected 429 for UA: {ua}" # --------------------------------------------------------------------------- # Exempt paths — always pass through # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_healthz_passes_with_no_ua(client: AsyncClient) -> None: """/healthz must never be blocked — monitoring probes have minimal UAs.""" resp = await client.get("/healthz") assert resp.status_code != 429 @pytest.mark.anyio async def test_healthz_passes_with_curl_ua(client: AsyncClient) -> None: """/healthz must pass even with a normally-blocked UA.""" resp = await client.get("/healthz", headers={"User-Agent": "curl/8.4.0"}) assert resp.status_code != 429 # --------------------------------------------------------------------------- # Error message — must be informative, not accusatory # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_blocked_response_body_is_informative(client: AsyncClient) -> None: """Blocked response must guide the client toward authentication.""" resp = await client.get( "/api/identities", headers={"User-Agent": "curl/8.4.0"}, ) assert resp.status_code == 429 body = resp.json() assert "MSign" in body["detail"] or "authenticate" in body["detail"].lower() # --------------------------------------------------------------------------- # Muse CLI UA — always passes unauthenticated (it's a known good client) # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_muse_cli_ua_passes_unauthenticated(client: AsyncClient) -> None: """The muse CLI UA must not be blocked even without auth. muse CLI sends 'muse/' for unauthenticated pre-flight calls like listing remotes before signing in. """ resp = await client.get( "/api/identities", headers={"User-Agent": "muse/1.0.0"}, ) assert resp.status_code != 429 @pytest.mark.anyio async def test_browser_ua_passes_unauthenticated(client: AsyncClient) -> None: """Standard browser UAs must pass without auth.""" resp = await client.get( "/api/identities", headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}, ) assert resp.status_code != 429