"""Tests for checklist section 4 — Rate Limiting & Abuse Prevention.""" from __future__ import annotations import pytest from httpx import AsyncClient # ── Global default limit exists ──────────────────────────────────────────────── def test_global_rate_limit_configured() -> None: """Limiter must have a non-empty _default_limits list (global 300/min baseline).""" from musehub.rate_limits import limiter default_limits = getattr(limiter, "_default_limits", []) assert default_limits, "Limiter must have _default_limits configured" # Each entry is a LimitGroup; iterate it to get individual Limit objects. limit_strings = [str(item.limit) for group in default_limits for item in group] assert any("300" in s for s in limit_strings), ( f"Expected a 300/minute global limit, got: {limit_strings}" ) # ── Auth endpoints have strict limits ────────────────────────────────────────── def test_auth_limit_is_strict() -> None: """AUTH_LIMIT_PROD must be 20/minute or tighter — the production cap against credential stuffing.""" from musehub.rate_limits import AUTH_LIMIT_PROD parts = AUTH_LIMIT_PROD.split("/") assert len(parts) == 2 count = int(parts[0]) period = parts[1].lower() per_minute = count if "minute" in period else count * 60 assert per_minute <= 20, f"AUTH_LIMIT_PROD {AUTH_LIMIT_PROD!r} is too permissive (> 20/min)" # ── Search endpoints have rate limits ────────────────────────────────────────── @pytest.mark.anyio async def test_api_search_rate_limited_on_429(client: AsyncClient) -> None: """GET /api/search must honour rate limits (the @limiter.limit decorator is wired up).""" # We cannot actually trip the limit in one test without hammering the endpoint, # so we verify the route exists and is reachable — the decorator presence is # checked via a unit test below. resp = await client.get("/api/search", params={"q": "test"}) # 200 (results), 404 (no results), or 422 (validation) are all fine — NOT 500 assert resp.status_code != 500 def test_search_routes_have_rate_limit_decorator() -> None: """Search route handlers must be decorated with @limiter.limit.""" from musehub.api.routes.musehub import search as search_module from musehub.api.routes.api import search as api_search_module # Check that the slowapi limit attribute was injected by the decorator. # slowapi stores per-route limits in a `_rate_limits` attribute on the function. for fn_name, module in [ ("search_repos", search_module), ("global_search", search_module), ("search_repo", search_module), ("global_search", api_search_module), ]: fn = getattr(module, fn_name, None) assert fn is not None, f"{fn_name} not found in {module.__name__}" has_limit = ( hasattr(fn, "_rate_limits") or hasattr(fn, "__wrapped__") or hasattr(getattr(fn, "__func__", fn), "_rate_limits") ) assert has_limit, ( f"{module.__name__}.{fn_name} is missing @limiter.limit — " "search endpoints must be rate-limited to prevent full-index scraping" ) # ── Object download endpoint has rate limit ──────────────────────────────────── def test_object_download_has_rate_limit_decorator() -> None: """GET /o/{object_id} must be decorated with @limiter.limit.""" from musehub.api.routes import wire as wire_module fn = getattr(wire_module, "get_object", None) assert fn is not None has_limit = ( hasattr(fn, "_rate_limits") or hasattr(fn, "__wrapped__") ) assert has_limit, "get_object is missing @limiter.limit" # ── 429 responses include Retry-After ────────────────────────────────────────── def test_retry_after_added_to_429() -> None: """The rate limit exception handler must add Retry-After to 429 responses.""" import time from unittest.mock import MagicMock, patch from starlette.responses import JSONResponse from slowapi.errors import RateLimitExceeded from musehub.main import _handle_rate_limit # Build a mock Limit object (what RateLimitExceeded actually expects) mock_limit = MagicMock() mock_limit.error_message = None mock_limit.limit = MagicMock() mock_limit.limit.__str__ = lambda self: "60 per 1 minute" exc = MagicMock(spec=RateLimitExceeded) exc.__class__ = RateLimitExceeded # isinstance check passes # Mock the base handler to return a 429 with an X-RateLimit-Reset header future_reset = str(int(time.time()) + 30) mock_response = JSONResponse({"error": "rate limit exceeded"}, status_code=429) mock_response.headers["X-RateLimit-Reset"] = future_reset mock_request = MagicMock() with patch("musehub.main._rate_limit_exceeded_handler", return_value=mock_response): result = _handle_rate_limit(mock_request, exc) assert "Retry-After" in result.headers, "429 response is missing Retry-After header" retry_after = int(result.headers["Retry-After"]) assert retry_after >= 1, f"Retry-After must be ≥ 1 second, got {retry_after}" assert retry_after <= 60, f"Retry-After seems too large: {retry_after}" # ── Bot / scraper detection ──────────────────────────────────────────────────── @pytest.mark.anyio async def test_bot_ua_scrapy_is_blocked(client: AsyncClient) -> None: """Scrapy User-Agent must receive 429.""" resp = await client.get("/", headers={"User-Agent": "Scrapy/2.11.0 (+https://scrapy.org)"}) assert resp.status_code == 429 @pytest.mark.anyio async def test_bot_ua_wget_is_blocked(client: AsyncClient) -> None: """wget User-Agent must receive 429.""" resp = await client.get("/", headers={"User-Agent": "Wget/1.21.3"}) assert resp.status_code == 429 @pytest.mark.anyio async def test_bot_ua_sqlmap_is_blocked(client: AsyncClient) -> None: """sqlmap User-Agent must receive 429.""" resp = await client.get("/", headers={"User-Agent": "sqlmap/1.7.8#stable (https://sqlmap.org)"}) assert resp.status_code == 429 @pytest.mark.anyio async def test_missing_ua_non_cdn_path_is_blocked(client: AsyncClient) -> None: """Missing User-Agent on non-CDN path must receive 429.""" resp = await client.get("/api/repos", headers={"User-Agent": ""}) assert resp.status_code == 429 @pytest.mark.anyio async def test_legitimate_browser_ua_passes(client: AsyncClient) -> None: """Standard browser User-Agent must not be blocked.""" resp = await client.get( "/", headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}, ) assert resp.status_code != 429 @pytest.mark.anyio async def test_muse_cli_ua_passes(client: AsyncClient) -> None: """Muse CLI User-Agent must not be blocked.""" resp = await client.get( "/api/repos", headers={"User-Agent": "muse/1.2.3"}, ) assert resp.status_code != 429 @pytest.mark.anyio async def test_healthz_exempt_from_bot_check(client: AsyncClient) -> None: """/healthz must be reachable even with a minimal/missing User-Agent.""" resp = await client.get("/healthz", headers={"User-Agent": ""}) # 200 or 404 — either is fine; the important thing is it's not 429 assert resp.status_code != 429 # ── Webhook retry cap ────────────────────────────────────────────────────────── def test_webhook_max_attempts_capped() -> None: """Webhook dispatcher must cap retries at a small fixed number.""" from musehub.services import musehub_webhook_dispatcher as wd assert hasattr(wd, "_MAX_ATTEMPTS"), "_MAX_ATTEMPTS not defined in webhook dispatcher" assert wd._MAX_ATTEMPTS <= 5, ( f"_MAX_ATTEMPTS={wd._MAX_ATTEMPTS} is too high — cap retries to prevent retry storms" ) assert wd._MAX_ATTEMPTS >= 1, "_MAX_ATTEMPTS must be at least 1" def test_webhook_backoff_configured() -> None: """Webhook dispatcher must have exponential backoff configured.""" from musehub.services import musehub_webhook_dispatcher as wd assert hasattr(wd, "_BACKOFF_BASE"), "_BACKOFF_BASE not defined in webhook dispatcher" assert wd._BACKOFF_BASE >= 1.0, ( f"_BACKOFF_BASE={wd._BACKOFF_BASE} is too short — minimum 1 second base backoff" )