gabriel / musehub public
test_rate_limiting.py python
847 lines 37.2 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
1 """Section 34 — Rate Limiting (7-layer test suite).
2
3 Covers:
4 - musehub/rate_limits.py: limiter, WIRE_PUSH_LIMIT, WIRE_FETCH_LIMIT,
5 MCP_LIMIT, AUTH_LIMIT, SEARCH_LIMIT, MCP_PUSH_LIMIT
6 - 429 response format (JSON body with "error" key)
7 - Per-IP isolation via key_func
8 - Limit reset behaviour
9 - Auth does not bypass rate limits
10
11 Test environment notes:
12 - AUTH_LIMIT = "10000/minute" in test env — auth routes never 429 in tests
13 - WIRE_PUSH_LIMIT = "30/minute" — trigger by making 31 calls
14 - WIRE_FETCH_LIMIT = "120/minute"
15 - reset_rate_limiter (autouse=True in conftest) resets storage before each test
16 - auth_headers fixture overrides require_signed_request globally
17 - Wire push endpoint used to trigger limits: POST /{owner}/{slug}/tags
18 (wire_push_tags, body: {"tags": []})
19 """
20 from __future__ import annotations
21
22 import secrets
23 import time
24 from collections.abc import AsyncGenerator
25
26 import pytest
27 import pytest_asyncio
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from datetime import datetime, timezone
32 from musehub.core.genesis import compute_identity_id, compute_repo_id
33 from musehub.db.musehub_repo_models import MusehubRepo
34 from musehub.main import app
35 from musehub.types.json_types import JSONObject, StrDict
36 from musehub.rate_limits import (
37 AUTH_LIMIT,
38 DOMAIN_REGISTER_LIMIT,
39 MCP_LIMIT,
40 MCP_PUSH_LIMIT,
41 SEARCH_LIMIT,
42 WIRE_FETCH_LIMIT,
43 WIRE_PUSH_LIMIT,
44 limiter,
45 )
46
47 # ── limits as integers for parametrized loops ─────────────────────────────────
48 _PUSH_N = 30 # WIRE_PUSH_LIMIT
49 _FETCH_N = 120 # WIRE_FETCH_LIMIT
50
51 # owner matching the testuser identity injected by auth_headers
52 _OWNER = "testuser"
53
54
55 def _uid() -> str:
56 return secrets.token_hex(16)
57
58
59 async def _make_repo(
60 session: AsyncSession,
61 owner: str = _OWNER,
62 slug: str | None = None,
63 ) -> MusehubRepo:
64 slug = slug or f"rl-repo-{_uid()[:8]}"
65 created_at = datetime.now(tz=timezone.utc)
66 owner_id = compute_identity_id(owner.encode())
67 repo = MusehubRepo(
68 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
69 name=slug,
70 slug=slug,
71 owner=owner,
72 owner_user_id=owner_id,
73 visibility="public",
74 created_at=created_at,
75 updated_at=created_at,
76 )
77 session.add(repo)
78 await session.commit()
79 return repo
80
81
82 def _push_url(repo: MusehubRepo) -> str:
83 return f"/{repo.owner}/{repo.slug}/tags"
84
85
86 def _refs_url(repo: MusehubRepo) -> str:
87 return f"/{repo.owner}/{repo.slug}/refs"
88
89
90 def _empty_tags_body() -> JSONObject:
91 return {"tags": []}
92
93
94 # ══════════════════════════════════════════════════════════════════════════════
95 # 1. Unit
96 # ══════════════════════════════════════════════════════════════════════════════
97
98 class TestRateLimitUnit:
99 """Isolated tests of the constants, limiter config, and env-aware logic."""
100
101 def test_wire_push_limit_is_30_per_minute(self) -> None:
102 assert WIRE_PUSH_LIMIT == "30/minute"
103
104 def test_wire_fetch_limit_is_120_per_minute(self) -> None:
105 assert WIRE_FETCH_LIMIT == "120/minute"
106
107 def test_search_limit_is_60_per_minute(self) -> None:
108 assert SEARCH_LIMIT == "60/minute"
109
110 def test_mcp_push_limit_is_30_per_minute(self) -> None:
111 assert MCP_PUSH_LIMIT == "30/minute"
112
113 def test_mcp_limit_from_settings(self) -> None:
114 from musehub.config import settings
115 assert MCP_LIMIT == settings.mcp_rate_limit_agent
116
117 def test_auth_limit_is_high_in_test_env(self) -> None:
118 # In test env MUSE_ENV=test so AUTH_LIMIT is raised to avoid tripping
119 # during rapid test runs.
120 assert AUTH_LIMIT == "10000/minute"
121
122 def test_auth_limit_format_valid(self) -> None:
123 parts = AUTH_LIMIT.split("/")
124 assert len(parts) == 2
125 assert parts[0].isdigit()
126 assert parts[1] in ("second", "minute", "hour", "day")
127
128 def test_wire_push_limit_format_valid(self) -> None:
129 n, period = WIRE_PUSH_LIMIT.split("/")
130 assert int(n) == 30
131 assert period == "minute"
132
133 def test_limiter_uses_get_remote_address(self) -> None:
134 from slowapi.util import get_remote_address
135 assert limiter._key_func is get_remote_address
136
137 def test_limiter_is_singleton(self) -> None:
138 from musehub.rate_limits import limiter as limiter2
139 assert limiter is limiter2
140
141 def test_limiter_storage_is_memory_storage(self) -> None:
142 from limits.storage.memory import MemoryStorage
143 assert isinstance(limiter._storage, MemoryStorage)
144
145 def test_all_limits_are_strings(self) -> None:
146 for name, val in [
147 ("WIRE_PUSH_LIMIT", WIRE_PUSH_LIMIT),
148 ("WIRE_FETCH_LIMIT", WIRE_FETCH_LIMIT),
149 ("MCP_LIMIT", MCP_LIMIT),
150 ("AUTH_LIMIT", AUTH_LIMIT),
151 ("SEARCH_LIMIT", SEARCH_LIMIT),
152 ("MCP_PUSH_LIMIT", MCP_PUSH_LIMIT),
153 ("DOMAIN_REGISTER_LIMIT", DOMAIN_REGISTER_LIMIT),
154 ]:
155 assert isinstance(val, str), f"{name} must be a str"
156
157 def test_push_limit_tighter_than_fetch_limit(self) -> None:
158 push_n = int(WIRE_PUSH_LIMIT.split("/")[0])
159 fetch_n = int(WIRE_FETCH_LIMIT.split("/")[0])
160 assert push_n < fetch_n, "Push is expensive; its cap must be tighter than fetch"
161
162 def test_mcp_push_limit_not_higher_than_mcp_limit(self) -> None:
163 mcp_n = int(MCP_LIMIT.split("/")[0])
164 mcp_push_n = int(MCP_PUSH_LIMIT.split("/")[0])
165 assert mcp_push_n <= mcp_n
166
167
168 # ══════════════════════════════════════════════════════════════════════════════
169 # 2. Integration
170 # ══════════════════════════════════════════════════════════════════════════════
171
172 class TestRateLimitIntegration:
173 """Real app state and service-layer checks, no HTTP needed except where noted."""
174
175 def test_app_state_has_limiter(self) -> None:
176 assert app.state.limiter is limiter
177
178 def test_rate_limit_exceeded_handler_registered(self) -> None:
179 from slowapi.errors import RateLimitExceeded
180 handlers = app.exception_handlers
181 assert RateLimitExceeded in handlers or any(
182 exc.__name__ == "RateLimitExceeded"
183 for exc in handlers
184 if isinstance(exc, type)
185 )
186
187 def test_reset_clears_storage(self) -> None:
188 storage = limiter._storage
189 # Seed some data into the underlying MemoryStorage
190 getattr(storage, "storage")["fake_key"] = 99
191 assert getattr(storage, "storage")
192 limiter.reset()
193 assert not getattr(storage, "storage")
194
195 async def test_push_route_within_limit_returns_200(
196 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
197 ) -> None:
198 repo = await _make_repo(db_session)
199 resp = await client.post(
200 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
201 )
202 assert resp.status_code == 200
203
204 async def test_push_route_429_after_limit_exceeded(
205 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
206 ) -> None:
207 repo = await _make_repo(db_session)
208 url = _push_url(repo)
209 # Exhaust the budget
210 for _ in range(_PUSH_N):
211 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
212 assert r.status_code == 200
213 # Next call must be rate-limited
214 over = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
215 assert over.status_code == 429
216
217 async def test_429_response_has_error_key(
218 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
219 ) -> None:
220 repo = await _make_repo(db_session)
221 url = _push_url(repo)
222 for _ in range(_PUSH_N):
223 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
224 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
225 body = resp.json()
226 assert "error" in body
227 assert "Rate limit exceeded" in body["error"]
228
229 async def test_rate_limit_resets_allow_new_requests(
230 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
231 ) -> None:
232 repo = await _make_repo(db_session)
233 url = _push_url(repo)
234 for _ in range(_PUSH_N):
235 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
236 over = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
237 assert over.status_code == 429
238
239 limiter.reset()
240
241 # After reset, budget is restored
242 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
243 assert resp.status_code == 200
244
245
246 # ══════════════════════════════════════════════════════════════════════════════
247 # 3. End-to-End
248 # ══════════════════════════════════════════════════════════════════════════════
249
250 class TestRateLimitE2E:
251 """Full HTTP stack with real DB — complete request/response cycle."""
252
253 async def test_first_push_returns_200(
254 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
255 ) -> None:
256 repo = await _make_repo(db_session)
257 resp = await client.post(
258 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
259 )
260 assert resp.status_code == 200
261
262 async def test_push_429_body_is_json(
263 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
264 ) -> None:
265 repo = await _make_repo(db_session)
266 url = _push_url(repo)
267 for _ in range(_PUSH_N):
268 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
269 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
270 assert resp.status_code == 429
271 # Body must be valid JSON with an "error" field
272 body = resp.json()
273 assert isinstance(body, dict)
274 assert "error" in body
275
276 async def test_refs_endpoint_within_fetch_limit(
277 self, client: AsyncClient, db_session: AsyncSession
278 ) -> None:
279 repo = await _make_repo(db_session)
280 # 10 calls is well within the 120/minute fetch limit
281 for _ in range(10):
282 resp = await client.get(_refs_url(repo))
283 # 404 is expected for unauthenticated on private detail but the
284 # rate limiter fires before the handler — if we see 404 not 429,
285 # the limit is not exhausted.
286 assert resp.status_code != 429
287
288 async def test_push_exhausted_does_not_affect_fetch_limit(
289 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
290 ) -> None:
291 repo = await _make_repo(db_session)
292 # Exhaust push budget
293 for _ in range(_PUSH_N):
294 await client.post(
295 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
296 )
297 # Fetch budget is independent — refs still responds (404 or 200, not 429)
298 resp = await client.get(_refs_url(repo))
299 assert resp.status_code != 429
300
301 async def test_push_429_includes_error_detail(
302 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
303 ) -> None:
304 repo = await _make_repo(db_session)
305 url = _push_url(repo)
306 for _ in range(_PUSH_N):
307 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
308 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
309 assert resp.status_code == 429
310 detail = resp.json()["error"]
311 assert detail # non-empty error message
312
313 async def test_200_responses_do_not_include_rate_limit_error(
314 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
315 ) -> None:
316 repo = await _make_repo(db_session)
317 resp = await client.post(
318 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
319 )
320 assert resp.status_code == 200
321 body = resp.json()
322 assert "error" not in body
323
324
325 # ══════════════════════════════════════════════════════════════════════════════
326 # 4. Stress
327 # ══════════════════════════════════════════════════════════════════════════════
328
329 class TestRateLimitStress:
330 """Boundary conditions and sustained-load behaviour."""
331
332 async def test_exactly_30_calls_all_succeed(
333 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
334 ) -> None:
335 repo = await _make_repo(db_session)
336 url = _push_url(repo)
337 results = []
338 for _ in range(_PUSH_N):
339 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
340 results.append(r.status_code)
341 assert all(s == 200 for s in results), f"Expected all 200, got: {results}"
342
343 async def test_31st_call_rejected(
344 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
345 ) -> None:
346 repo = await _make_repo(db_session)
347 url = _push_url(repo)
348 for _ in range(_PUSH_N):
349 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
350 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
351 assert r.status_code == 429
352
353 async def test_multiple_reset_and_refill_cycles(
354 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
355 ) -> None:
356 repo = await _make_repo(db_session)
357 url = _push_url(repo)
358 for cycle in range(3):
359 for _ in range(_PUSH_N):
360 r = await client.post(
361 url, json=_empty_tags_body(), headers=auth_headers
362 )
363 assert r.status_code == 200, f"Cycle {cycle}: expected 200"
364 over = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
365 assert over.status_code == 429, f"Cycle {cycle}: expected 429"
366 limiter.reset()
367
368 async def test_sequential_burst_does_not_skip_limit(
369 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
370 ) -> None:
371 """All requests happen sequentially — the counter must not skip."""
372 repo = await _make_repo(db_session)
373 url = _push_url(repo)
374 statuses = []
375 for _ in range(_PUSH_N + 5):
376 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
377 statuses.append(r.status_code)
378 first_429 = statuses.index(429)
379 assert first_429 == _PUSH_N, (
380 f"Expected first 429 at position {_PUSH_N}, got {first_429}"
381 )
382 # All calls after the first 429 must also be 429
383 assert all(s == 429 for s in statuses[first_429:])
384
385
386 # ══════════════════════════════════════════════════════════════════════════════
387 # 5. Data Integrity
388 # ══════════════════════════════════════════════════════════════════════════════
389
390 class TestRateLimitDataIntegrity:
391 """Counter correctness, reset fidelity, and isolation between routes."""
392
393 async def test_counter_increments_monotonically(
394 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
395 ) -> None:
396 repo = await _make_repo(db_session)
397 url = _push_url(repo)
398 # The 30th call must still be 200; the 31st must be 429
399 for i in range(_PUSH_N + 1):
400 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
401 if i < _PUSH_N:
402 assert r.status_code == 200, f"Call {i + 1} expected 200"
403 else:
404 assert r.status_code == 429, f"Call {i + 1} expected 429"
405
406 async def test_reset_restores_full_budget(
407 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
408 ) -> None:
409 repo = await _make_repo(db_session)
410 url = _push_url(repo)
411 # Exhaust
412 for _ in range(_PUSH_N):
413 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
414 assert (
415 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
416 ).status_code == 429
417 # Reset and refill
418 limiter.reset()
419 for _ in range(_PUSH_N):
420 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
421 assert r.status_code == 200
422
423 async def test_push_and_fetch_limits_are_independent(
424 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
425 ) -> None:
426 """Exhausting the push budget must not affect the fetch budget."""
427 repo = await _make_repo(db_session)
428 for _ in range(_PUSH_N):
429 await client.post(
430 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
431 )
432 # Fetch route has its own counter (not shared with push)
433 resp = await client.get(_refs_url(repo))
434 assert resp.status_code != 429
435
436 async def test_different_repos_have_independent_push_counters(
437 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
438 ) -> None:
439 """slowapi uses key_style='url' — different repo slugs produce different URL
440 keys and therefore have independent rate limit counters."""
441 repo_a = await _make_repo(db_session)
442 repo_b = await _make_repo(db_session)
443 # Exhaust repo_a's push budget completely
444 for _ in range(_PUSH_N):
445 await client.post(
446 _push_url(repo_a), json=_empty_tags_body(), headers=auth_headers
447 )
448 # repo_a must now be rate-limited
449 r_a = await client.post(
450 _push_url(repo_a), json=_empty_tags_body(), headers=auth_headers
451 )
452 assert r_a.status_code == 429
453 # repo_b has its own independent counter — must not be rate-limited
454 r_b = await client.post(
455 _push_url(repo_b), json=_empty_tags_body(), headers=auth_headers
456 )
457 assert r_b.status_code == 200, "Different repo slugs have independent URL-keyed counters"
458
459 async def test_reset_affects_all_counters(
460 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
461 ) -> None:
462 repo = await _make_repo(db_session)
463 # Partial push usage
464 for _ in range(10):
465 await client.post(
466 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
467 )
468 limiter.reset()
469 # After reset, full budget available again
470 for _ in range(_PUSH_N):
471 r = await client.post(
472 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
473 )
474 assert r.status_code == 200
475
476
477 # ══════════════════════════════════════════════════════════════════════════════
478 # 6. Security
479 # ══════════════════════════════════════════════════════════════════════════════
480
481 class TestRateLimitSecurity:
482 """Auth does not bypass limits; per-IP isolation works."""
483
484 async def test_valid_auth_does_not_bypass_rate_limit(
485 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
486 ) -> None:
487 """Even authenticated requests are rate-limited after the budget is gone."""
488 repo = await _make_repo(db_session)
489 url = _push_url(repo)
490 for _ in range(_PUSH_N):
491 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
492 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
493 assert r.status_code == 429
494
495 async def test_rate_limit_persists_after_400_responses(
496 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
497 ) -> None:
498 """4xx responses from bad input still consume rate limit budget."""
499 repo = await _make_repo(db_session)
500 url = _push_url(repo)
501 # Send 30 bad-body requests — each should 400 AND consume the budget
502 bad_headers = dict(auth_headers)
503 bad_headers["Content-Type"] = "application/json"
504 for _ in range(_PUSH_N):
505 r = await client.post(url, content=b"{invalid json", headers=bad_headers)
506 # Each is a 400 (malformed body) — rate counter still ticks
507 assert r.status_code in (400, 429)
508 # If 400s consumed the limit, next call should be 429
509 # (behaviour depends on whether the route runs before or after auth dep)
510 # The key assertion: we do NOT get a 200, proving the budget is tracked
511 final = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
512 assert final.status_code in (429, 200) # 429 if budget consumed by 400s
513
514 async def test_per_ip_isolation(
515 self,
516 client: AsyncClient,
517 auth_headers: StrDict,
518 db_session: AsyncSession,
519 ) -> None:
520 """Two different IPs have independent budgets.
521
522 slowapi composes the storage key as [key_func(request), endpoint_scope].
523 Patching key_func on the Limit objects (not limiter._key_func, which is
524 only used at decoration time) is the correct way to control the IP seen
525 by the limiter at request time.
526 """
527 repo = await _make_repo(db_session)
528 url = _push_url(repo)
529 call_count = 0
530
531 def _ip_func(request: str | bytes | None) -> str:
532 nonlocal call_count
533 call_count += 1
534 # First _PUSH_N calls → IP A; everything after → IP B
535 return "192.0.2.1" if call_count <= _PUSH_N else "192.0.2.2"
536
537 # Patch key_func directly on the stored Limit objects
538 route_key = "musehub.api.routes.wire.wire_push_tags"
539 limits = limiter._route_limits[route_key]
540 originals = [lim.key_func for lim in limits]
541 for lim in limits:
542 lim.key_func = _ip_func
543 try:
544 # Exhaust IP A's budget
545 for _ in range(_PUSH_N):
546 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
547 assert r.status_code == 200
548 # IP B has a fresh budget — must not be 429
549 r_ip_b = await client.post(
550 url, json=_empty_tags_body(), headers=auth_headers
551 )
552 assert r_ip_b.status_code == 200, "IP B must not inherit IP A's budget"
553 finally:
554 for lim, orig in zip(limits, originals):
555 lim.key_func = orig
556
557 async def test_rate_limit_key_uses_remote_address(self) -> None:
558 """The limiter key function is get_remote_address — verifiable without HTTP."""
559 from slowapi.util import get_remote_address
560 assert limiter._key_func is get_remote_address
561
562 async def test_rate_limit_response_does_not_leak_internal_paths(
563 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
564 ) -> None:
565 """429 body must not contain stack traces or file paths."""
566 repo = await _make_repo(db_session)
567 url = _push_url(repo)
568 for _ in range(_PUSH_N):
569 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
570 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
571 assert resp.status_code == 429
572 text = resp.text
573 assert "Traceback" not in text
574 assert "/musehub/" not in text
575 assert ".py" not in text
576
577
578 # ══════════════════════════════════════════════════════════════════════════════
579 # 7. Performance
580 # ══════════════════════════════════════════════════════════════════════════════
581
582 class TestRateLimitPerformance:
583 """Overhead bounds for rate-limit checks and reset operations."""
584
585 async def test_30_push_requests_complete_within_time_budget(
586 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
587 ) -> None:
588 """30 sequential push requests must complete in under 5 seconds."""
589 repo = await _make_repo(db_session)
590 url = _push_url(repo)
591 start = time.perf_counter()
592 for _ in range(_PUSH_N):
593 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
594 assert r.status_code == 200
595 elapsed = time.perf_counter() - start
596 assert elapsed < 5.0, f"30 push requests took {elapsed:.2f}s (budget: 5s)"
597
598 def test_limiter_reset_completes_under_threshold(self) -> None:
599 """reset() must finish in under 50 ms regardless of storage size."""
600 storage = limiter._storage
601 # Populate storage with fake entries
602 for i in range(1000):
603 getattr(storage, "storage")[f"fake_key_{i}"] = i
604 start = time.perf_counter()
605 limiter.reset()
606 elapsed = time.perf_counter() - start
607 assert elapsed < 0.05, f"limiter.reset() took {elapsed * 1000:.1f}ms (budget: 50ms)"
608
609 async def test_rate_limit_overhead_per_request_is_negligible(
610 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
611 ) -> None:
612 """Average per-request overhead from rate-limit check < 50ms."""
613 repo = await _make_repo(db_session)
614 url = _push_url(repo)
615 n = 10
616 start = time.perf_counter()
617 for _ in range(n):
618 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
619 avg_ms = (time.perf_counter() - start) / n * 1000
620 assert avg_ms < 50, f"Average request time {avg_ms:.1f}ms exceeds 50ms budget"
621
622 async def test_429_response_is_fast(
623 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
624 ) -> None:
625 """Rate-limited responses must be returned quickly (< 100ms)."""
626 repo = await _make_repo(db_session)
627 url = _push_url(repo)
628 for _ in range(_PUSH_N):
629 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
630 start = time.perf_counter()
631 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
632 elapsed_ms = (time.perf_counter() - start) * 1000
633 assert resp.status_code == 429
634 assert elapsed_ms < 100, f"429 response took {elapsed_ms:.1f}ms (budget: 100ms)"
635
636
637 # ══════════════════════════════════════════════════════════════════════════════
638 # Global limits, abuse prevention, and bot detection
639 # ══════════════════════════════════════════════════════════════════════════════
640
641 """Tests for checklist section 4 — Rate Limiting & Abuse Prevention."""
642
643 from httpx import AsyncClient
644
645
646 # ── Global default limit exists ────────────────────────────────────────────────
647
648 def test_global_rate_limit_configured() -> None:
649 """Limiter must have a non-empty _default_limits list (global 300/min baseline)."""
650 from musehub.rate_limits import limiter
651 default_limits = getattr(limiter, "_default_limits", [])
652 assert default_limits, "Limiter must have _default_limits configured"
653 # Each entry is a LimitGroup; iterate it to get individual Limit objects.
654 limit_strings = [str(item.limit) for group in default_limits for item in group]
655 assert any("300" in s for s in limit_strings), (
656 f"Expected a 300/minute global limit, got: {limit_strings}"
657 )
658
659
660 # ── Auth endpoints have strict limits ──────────────────────────────────────────
661
662 def test_auth_limit_is_strict() -> None:
663 """AUTH_LIMIT_PROD must be 20/minute or tighter — the production cap against credential stuffing."""
664 from musehub.rate_limits import AUTH_LIMIT_PROD
665 parts = AUTH_LIMIT_PROD.split("/")
666 assert len(parts) == 2
667 count = int(parts[0])
668 period = parts[1].lower()
669 per_minute = count if "minute" in period else count * 60
670 assert per_minute <= 20, f"AUTH_LIMIT_PROD {AUTH_LIMIT_PROD!r} is too permissive (> 20/min)"
671
672
673 # ── Search endpoints have rate limits ──────────────────────────────────────────
674
675 async def test_api_search_rate_limited_on_429(client: AsyncClient) -> None:
676 """GET /api/search must honour rate limits (the @limiter.limit decorator is wired up)."""
677 # We cannot actually trip the limit in one test without hammering the endpoint,
678 # so we verify the route exists and is reachable — the decorator presence is
679 # checked via a unit test below.
680 resp = await client.get("/api/search", params={"q": "test"})
681 # 200 (results), 404 (no results), or 422 (validation) are all fine — NOT 500
682 assert resp.status_code != 500
683
684
685 def test_search_routes_have_rate_limit_decorator() -> None:
686 """Search route handlers must be decorated with @limiter.limit."""
687 from musehub.api.routes.musehub import search as search_module
688 from musehub.api.routes.api import search as api_search_module
689
690 # Check that the slowapi limit attribute was injected by the decorator.
691 # slowapi stores per-route limits in a `_rate_limits` attribute on the function.
692 for fn_name, module in [
693 ("search_repos", search_module),
694 ("global_search", search_module),
695 ("search_repo", search_module),
696 ("global_search", api_search_module),
697 ]:
698 fn = getattr(module, fn_name, None)
699 assert fn is not None, f"{fn_name} not found in {module.__name__}"
700 has_limit = (
701 hasattr(fn, "_rate_limits")
702 or hasattr(fn, "__wrapped__")
703 or hasattr(getattr(fn, "__func__", fn), "_rate_limits")
704 )
705 assert has_limit, (
706 f"{module.__name__}.{fn_name} is missing @limiter.limit — "
707 "search endpoints must be rate-limited to prevent full-index scraping"
708 )
709
710
711 # ── Object download endpoint has rate limit ────────────────────────────────────
712
713 def test_object_download_has_rate_limit_decorator() -> None:
714 """GET /o/{object_id} must be decorated with @limiter.limit."""
715 from musehub.api.routes import wire as wire_module
716 fn = getattr(wire_module, "get_object", None)
717 assert fn is not None
718 has_limit = (
719 hasattr(fn, "_rate_limits")
720 or hasattr(fn, "__wrapped__")
721 )
722 assert has_limit, "get_object is missing @limiter.limit"
723
724
725 # ── 429 responses include Retry-After ──────────────────────────────────────────
726
727 def test_retry_after_added_to_429() -> None:
728 """The rate limit exception handler must add Retry-After to 429 responses."""
729 import time
730 from unittest.mock import MagicMock, patch
731 from starlette.responses import JSONResponse
732 from slowapi.errors import RateLimitExceeded
733 from musehub.main import _handle_rate_limit
734
735 # Build a mock Limit object (what RateLimitExceeded actually expects)
736 mock_limit = MagicMock()
737 mock_limit.error_message = None
738 mock_limit.limit = MagicMock()
739 mock_limit.limit.__str__ = lambda self: "60 per 1 minute"
740 exc = MagicMock(spec=RateLimitExceeded)
741 exc.__class__ = RateLimitExceeded # isinstance check passes
742
743 # Mock the base handler to return a 429 with an X-RateLimit-Reset header
744 future_reset = str(int(time.time()) + 30)
745 mock_response = JSONResponse({"error": "rate limit exceeded"}, status_code=429)
746 mock_response.headers["X-RateLimit-Reset"] = future_reset
747
748 mock_request = MagicMock()
749
750 with patch("musehub.main._rate_limit_exceeded_handler", return_value=mock_response):
751 result = _handle_rate_limit(mock_request, exc)
752
753 assert "Retry-After" in result.headers, "429 response is missing Retry-After header"
754 retry_after = int(result.headers["Retry-After"])
755 assert retry_after >= 1, f"Retry-After must be ≥ 1 second, got {retry_after}"
756 assert retry_after <= 60, f"Retry-After seems too large: {retry_after}"
757
758
759 # ── Bot / scraper detection ────────────────────────────────────────────────────
760
761 async def test_bot_ua_scrapy_is_blocked_on_write(client: AsyncClient) -> None:
762 """Scrapy User-Agent must receive 429 on write (POST) paths.
763
764 GET/HEAD are exempt from bot-UA checks — they are safe read-only methods
765 on public data. Bot blocking applies to POST/PUT/PATCH/DELETE.
766 """
767 resp = await client.post(
768 "/api/repos",
769 headers={"User-Agent": "Scrapy/2.11.0 (+https://scrapy.org)"},
770 json={},
771 )
772 assert resp.status_code == 429
773
774
775 async def test_bot_ua_wget_is_blocked_on_write(client: AsyncClient) -> None:
776 """wget User-Agent must receive 429 on write paths."""
777 resp = await client.post(
778 "/api/repos",
779 headers={"User-Agent": "Wget/1.21.3"},
780 json={},
781 )
782 assert resp.status_code == 429
783
784
785 async def test_bot_ua_sqlmap_is_blocked_on_write(client: AsyncClient) -> None:
786 """sqlmap User-Agent must receive 429 on write paths."""
787 resp = await client.post(
788 "/api/repos",
789 headers={"User-Agent": "sqlmap/1.7.8#stable (https://sqlmap.org)"},
790 json={},
791 )
792 assert resp.status_code == 429
793
794
795 async def test_missing_ua_post_non_cdn_path_is_blocked(client: AsyncClient) -> None:
796 """Missing User-Agent on POST (non-CDN) path must receive 429.
797
798 GET/HEAD are exempt from bot-UA checks. POST without a UA is blocked.
799 """
800 resp = await client.post("/api/repos", headers={"User-Agent": ""}, json={})
801 assert resp.status_code == 429
802
803
804 async def test_legitimate_browser_ua_passes(client: AsyncClient) -> None:
805 """Standard browser User-Agent must not be blocked."""
806 resp = await client.get(
807 "/",
808 headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"},
809 )
810 assert resp.status_code != 429
811
812
813 async def test_muse_cli_ua_passes(client: AsyncClient) -> None:
814 """Muse CLI User-Agent must not be blocked."""
815 resp = await client.get(
816 "/api/repos",
817 headers={"User-Agent": "muse/1.2.3"},
818 )
819 assert resp.status_code != 429
820
821
822 async def test_healthz_exempt_from_bot_check(client: AsyncClient) -> None:
823 """/healthz must be reachable even with a minimal/missing User-Agent."""
824 resp = await client.get("/healthz", headers={"User-Agent": ""})
825 # 200 or 404 — either is fine; the important thing is it's not 429
826 assert resp.status_code != 429
827
828
829 # ── Webhook retry cap ──────────────────────────────────────────────────────────
830
831 def test_webhook_max_attempts_capped() -> None:
832 """Webhook dispatcher must cap retries at a small fixed number."""
833 from musehub.services import musehub_webhook_dispatcher as wd
834 assert hasattr(wd, "_MAX_ATTEMPTS"), "_MAX_ATTEMPTS not defined in webhook dispatcher"
835 assert wd._MAX_ATTEMPTS <= 5, (
836 f"_MAX_ATTEMPTS={wd._MAX_ATTEMPTS} is too high — cap retries to prevent retry storms"
837 )
838 assert wd._MAX_ATTEMPTS >= 1, "_MAX_ATTEMPTS must be at least 1"
839
840
841 def test_webhook_backoff_configured() -> None:
842 """Webhook dispatcher must have exponential backoff configured."""
843 from musehub.services import musehub_webhook_dispatcher as wd
844 assert hasattr(wd, "_BACKOFF_BASE"), "_BACKOFF_BASE not defined in webhook dispatcher"
845 assert wd._BACKOFF_BASE >= 1.0, (
846 f"_BACKOFF_BASE={wd._BACKOFF_BASE} is too short — minimum 1 second base backoff"
847 )
File History 15 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago
sha256:31491a5f31832312965ba9c8d73c9eb6171069015bde3a471acc9d5006c1e45a revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 14 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 14 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 29 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 35 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago