gabriel / musehub public
test_rate_limiting_section4.py python
198 lines 8.8 KB
Raw
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