test_rate_limiting_section4.py
python
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a
init: musehub initial commit
Human
170 days ago
| 1 | """Tests for checklist section 4 — Rate Limiting & Abuse Prevention.""" |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | import pytest |
| 5 | from httpx import AsyncClient |
| 6 | |
| 7 | |
| 8 | # ── Global default limit exists ──────────────────────────────────────────────── |
| 9 | |
| 10 | def test_global_rate_limit_configured() -> None: |
| 11 | """Limiter must have a non-empty _default_limits list (global 300/min baseline).""" |
| 12 | from musehub.rate_limits import limiter |
| 13 | default_limits = getattr(limiter, "_default_limits", []) |
| 14 | assert default_limits, "Limiter must have _default_limits configured" |
| 15 | # Each entry is a LimitGroup; iterate it to get individual Limit objects. |
| 16 | limit_strings = [str(item.limit) for group in default_limits for item in group] |
| 17 | assert any("300" in s for s in limit_strings), ( |
| 18 | f"Expected a 300/minute global limit, got: {limit_strings}" |
| 19 | ) |
| 20 | |
| 21 | |
| 22 | # ── Auth endpoints have strict limits ────────────────────────────────────────── |
| 23 | |
| 24 | def test_auth_limit_is_strict() -> None: |
| 25 | """AUTH_LIMIT_PROD must be 20/minute or tighter — the production cap against credential stuffing.""" |
| 26 | from musehub.rate_limits import AUTH_LIMIT_PROD |
| 27 | parts = AUTH_LIMIT_PROD.split("/") |
| 28 | assert len(parts) == 2 |
| 29 | count = int(parts[0]) |
| 30 | period = parts[1].lower() |
| 31 | per_minute = count if "minute" in period else count * 60 |
| 32 | assert per_minute <= 20, f"AUTH_LIMIT_PROD {AUTH_LIMIT_PROD!r} is too permissive (> 20/min)" |
| 33 | |
| 34 | |
| 35 | # ── Search endpoints have rate limits ────────────────────────────────────────── |
| 36 | |
| 37 | @pytest.mark.anyio |
| 38 | async def test_api_search_rate_limited_on_429(client: AsyncClient) -> None: |
| 39 | """GET /api/search must honour rate limits (the @limiter.limit decorator is wired up).""" |
| 40 | # We cannot actually trip the limit in one test without hammering the endpoint, |
| 41 | # so we verify the route exists and is reachable — the decorator presence is |
| 42 | # checked via a unit test below. |
| 43 | resp = await client.get("/api/search", params={"q": "test"}) |
| 44 | # 200 (results), 404 (no results), or 422 (validation) are all fine — NOT 500 |
| 45 | assert resp.status_code != 500 |
| 46 | |
| 47 | |
| 48 | def test_search_routes_have_rate_limit_decorator() -> None: |
| 49 | """Search route handlers must be decorated with @limiter.limit.""" |
| 50 | from musehub.api.routes.musehub import search as search_module |
| 51 | from musehub.api.routes.api import search as api_search_module |
| 52 | |
| 53 | # Check that the slowapi limit attribute was injected by the decorator. |
| 54 | # slowapi stores per-route limits in a `_rate_limits` attribute on the function. |
| 55 | for fn_name, module in [ |
| 56 | ("search_repos", search_module), |
| 57 | ("global_search", search_module), |
| 58 | ("search_repo", search_module), |
| 59 | ("global_search", api_search_module), |
| 60 | ]: |
| 61 | fn = getattr(module, fn_name, None) |
| 62 | assert fn is not None, f"{fn_name} not found in {module.__name__}" |
| 63 | has_limit = ( |
| 64 | hasattr(fn, "_rate_limits") |
| 65 | or hasattr(fn, "__wrapped__") |
| 66 | or hasattr(getattr(fn, "__func__", fn), "_rate_limits") |
| 67 | ) |
| 68 | assert has_limit, ( |
| 69 | f"{module.__name__}.{fn_name} is missing @limiter.limit — " |
| 70 | "search endpoints must be rate-limited to prevent full-index scraping" |
| 71 | ) |
| 72 | |
| 73 | |
| 74 | # ── Object download endpoint has rate limit ──────────────────────────────────── |
| 75 | |
| 76 | def test_object_download_has_rate_limit_decorator() -> None: |
| 77 | """GET /o/{object_id} must be decorated with @limiter.limit.""" |
| 78 | from musehub.api.routes import wire as wire_module |
| 79 | fn = getattr(wire_module, "get_object", None) |
| 80 | assert fn is not None |
| 81 | has_limit = ( |
| 82 | hasattr(fn, "_rate_limits") |
| 83 | or hasattr(fn, "__wrapped__") |
| 84 | ) |
| 85 | assert has_limit, "get_object is missing @limiter.limit" |
| 86 | |
| 87 | |
| 88 | # ── 429 responses include Retry-After ────────────────────────────────────────── |
| 89 | |
| 90 | def test_retry_after_added_to_429() -> None: |
| 91 | """The rate limit exception handler must add Retry-After to 429 responses.""" |
| 92 | import time |
| 93 | from unittest.mock import MagicMock, patch |
| 94 | from starlette.responses import JSONResponse |
| 95 | from slowapi.errors import RateLimitExceeded |
| 96 | from musehub.main import _handle_rate_limit |
| 97 | |
| 98 | # Build a mock Limit object (what RateLimitExceeded actually expects) |
| 99 | mock_limit = MagicMock() |
| 100 | mock_limit.error_message = None |
| 101 | mock_limit.limit = MagicMock() |
| 102 | mock_limit.limit.__str__ = lambda self: "60 per 1 minute" |
| 103 | exc = MagicMock(spec=RateLimitExceeded) |
| 104 | exc.__class__ = RateLimitExceeded # isinstance check passes |
| 105 | |
| 106 | # Mock the base handler to return a 429 with an X-RateLimit-Reset header |
| 107 | future_reset = str(int(time.time()) + 30) |
| 108 | mock_response = JSONResponse({"error": "rate limit exceeded"}, status_code=429) |
| 109 | mock_response.headers["X-RateLimit-Reset"] = future_reset |
| 110 | |
| 111 | mock_request = MagicMock() |
| 112 | |
| 113 | with patch("musehub.main._rate_limit_exceeded_handler", return_value=mock_response): |
| 114 | result = _handle_rate_limit(mock_request, exc) |
| 115 | |
| 116 | assert "Retry-After" in result.headers, "429 response is missing Retry-After header" |
| 117 | retry_after = int(result.headers["Retry-After"]) |
| 118 | assert retry_after >= 1, f"Retry-After must be ≥ 1 second, got {retry_after}" |
| 119 | assert retry_after <= 60, f"Retry-After seems too large: {retry_after}" |
| 120 | |
| 121 | |
| 122 | # ── Bot / scraper detection ──────────────────────────────────────────────────── |
| 123 | |
| 124 | @pytest.mark.anyio |
| 125 | async def test_bot_ua_scrapy_is_blocked(client: AsyncClient) -> None: |
| 126 | """Scrapy User-Agent must receive 429.""" |
| 127 | resp = await client.get("/", headers={"User-Agent": "Scrapy/2.11.0 (+https://scrapy.org)"}) |
| 128 | assert resp.status_code == 429 |
| 129 | |
| 130 | |
| 131 | @pytest.mark.anyio |
| 132 | async def test_bot_ua_wget_is_blocked(client: AsyncClient) -> None: |
| 133 | """wget User-Agent must receive 429.""" |
| 134 | resp = await client.get("/", headers={"User-Agent": "Wget/1.21.3"}) |
| 135 | assert resp.status_code == 429 |
| 136 | |
| 137 | |
| 138 | @pytest.mark.anyio |
| 139 | async def test_bot_ua_sqlmap_is_blocked(client: AsyncClient) -> None: |
| 140 | """sqlmap User-Agent must receive 429.""" |
| 141 | resp = await client.get("/", headers={"User-Agent": "sqlmap/1.7.8#stable (https://sqlmap.org)"}) |
| 142 | assert resp.status_code == 429 |
| 143 | |
| 144 | |
| 145 | @pytest.mark.anyio |
| 146 | async def test_missing_ua_non_cdn_path_is_blocked(client: AsyncClient) -> None: |
| 147 | """Missing User-Agent on non-CDN path must receive 429.""" |
| 148 | resp = await client.get("/api/repos", headers={"User-Agent": ""}) |
| 149 | assert resp.status_code == 429 |
| 150 | |
| 151 | |
| 152 | @pytest.mark.anyio |
| 153 | async def test_legitimate_browser_ua_passes(client: AsyncClient) -> None: |
| 154 | """Standard browser User-Agent must not be blocked.""" |
| 155 | resp = await client.get( |
| 156 | "/", |
| 157 | headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}, |
| 158 | ) |
| 159 | assert resp.status_code != 429 |
| 160 | |
| 161 | |
| 162 | @pytest.mark.anyio |
| 163 | async def test_muse_cli_ua_passes(client: AsyncClient) -> None: |
| 164 | """Muse CLI User-Agent must not be blocked.""" |
| 165 | resp = await client.get( |
| 166 | "/api/repos", |
| 167 | headers={"User-Agent": "muse/1.2.3"}, |
| 168 | ) |
| 169 | assert resp.status_code != 429 |
| 170 | |
| 171 | |
| 172 | @pytest.mark.anyio |
| 173 | async def test_healthz_exempt_from_bot_check(client: AsyncClient) -> None: |
| 174 | """/healthz must be reachable even with a minimal/missing User-Agent.""" |
| 175 | resp = await client.get("/healthz", headers={"User-Agent": ""}) |
| 176 | # 200 or 404 — either is fine; the important thing is it's not 429 |
| 177 | assert resp.status_code != 429 |
| 178 | |
| 179 | |
| 180 | # ── Webhook retry cap ────────────────────────────────────────────────────────── |
| 181 | |
| 182 | def test_webhook_max_attempts_capped() -> None: |
| 183 | """Webhook dispatcher must cap retries at a small fixed number.""" |
| 184 | from musehub.services import musehub_webhook_dispatcher as wd |
| 185 | assert hasattr(wd, "_MAX_ATTEMPTS"), "_MAX_ATTEMPTS not defined in webhook dispatcher" |
| 186 | assert wd._MAX_ATTEMPTS <= 5, ( |
| 187 | f"_MAX_ATTEMPTS={wd._MAX_ATTEMPTS} is too high — cap retries to prevent retry storms" |
| 188 | ) |
| 189 | assert wd._MAX_ATTEMPTS >= 1, "_MAX_ATTEMPTS must be at least 1" |
| 190 | |
| 191 | |
| 192 | def test_webhook_backoff_configured() -> None: |
| 193 | """Webhook dispatcher must have exponential backoff configured.""" |
| 194 | from musehub.services import musehub_webhook_dispatcher as wd |
| 195 | assert hasattr(wd, "_BACKOFF_BASE"), "_BACKOFF_BASE not defined in webhook dispatcher" |
| 196 | assert wd._BACKOFF_BASE >= 1.0, ( |
| 197 | f"_BACKOFF_BASE={wd._BACKOFF_BASE} is too short — minimum 1 second base backoff" |
| 198 | ) |
File History
1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a
init: musehub initial commit
Human
170 days ago