gabriel / musehub public
test_mist_phase7_rate_limits.py python
178 lines 8.0 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Phase 7 TDD: Rate limiting audit for /api/mists/* endpoints.
2
3 Tests are written RED first. Run before touching mists.py and rate_limits.py
4 to confirm failure, then implement.
5
6 Gap:
7 - POST /api/mists and POST /api/mists/{id}/fork already have @limiter.limit.
8 - PATCH /api/mists/{id} and DELETE /api/mists/{id} have NO rate limit decorator.
9 - GET read endpoints (explore, get, forks, owner list, embed) have no per-route
10 limit — they fall through to the global 300/min IP bucket, which is too loose.
11
12 Work:
13 1. Add MIST_UPDATE_LIMIT and MIST_DELETE_LIMIT to rate_limits.py (keyed on
14 MSign handle like create/fork).
15 2. Add MIST_READ_LIMIT to rate_limits.py (keyed on IP, read-only endpoints).
16 3. Decorate update_mist and delete_mist with @limiter.limit(..., key_func=get_msign_handle).
17 4. Decorate all GET handlers with @limiter.limit(MIST_READ_LIMIT).
18 """
19 from __future__ import annotations
20
21 import pytest
22 from httpx import AsyncClient
23
24
25 # ---------------------------------------------------------------------------
26 # 1. Rate limit constants defined in rate_limits.py
27 # ---------------------------------------------------------------------------
28
29 class TestRateLimitConstants:
30 def test_mist_update_limit_defined(self) -> None:
31 """MIST_UPDATE_LIMIT must be defined in rate_limits."""
32 from musehub.rate_limits import MIST_UPDATE_LIMIT
33 assert isinstance(MIST_UPDATE_LIMIT, str)
34 assert "/" in MIST_UPDATE_LIMIT, (
35 f"MIST_UPDATE_LIMIT must be a rate string like '20/minute'; got {MIST_UPDATE_LIMIT!r}"
36 )
37
38 def test_mist_delete_limit_defined(self) -> None:
39 """MIST_DELETE_LIMIT must be defined in rate_limits."""
40 from musehub.rate_limits import MIST_DELETE_LIMIT
41 assert isinstance(MIST_DELETE_LIMIT, str)
42 assert "/" in MIST_DELETE_LIMIT
43
44 def test_mist_read_limit_defined(self) -> None:
45 """MIST_READ_LIMIT must be defined in rate_limits."""
46 from musehub.rate_limits import MIST_READ_LIMIT
47 assert isinstance(MIST_READ_LIMIT, str)
48 assert "/" in MIST_READ_LIMIT
49
50
51 # ---------------------------------------------------------------------------
52 # 2. Mutating endpoints have @limiter.limit decorators (source inspection)
53 # ---------------------------------------------------------------------------
54
55 class TestMutatingEndpointsHaveLimiters:
56 def test_update_mist_has_limiter_decorator(self) -> None:
57 """update_mist must have a @limiter.limit(...) decorator in mists.py."""
58 import inspect
59 from musehub.api.routes.musehub import mists as _mod
60 src = inspect.getsource(_mod.update_mist)
61 # The decorator is applied before the function — check the module source
62 # around the function definition to catch the decorator above it.
63 full_src = inspect.getsource(_mod)
64 # Find the update_mist function and check for limiter.limit above it.
65 update_idx = full_src.find("async def update_mist(")
66 assert update_idx != -1
67 # Look at the 500 chars before the function definition for the decorator.
68 preamble = full_src[max(0, update_idx - 500): update_idx]
69 assert "limiter.limit" in preamble, (
70 "update_mist must be decorated with @limiter.limit(...); "
71 "no limiter.limit found in the 500 chars before 'async def update_mist'"
72 )
73
74 def test_delete_mist_has_limiter_decorator(self) -> None:
75 """delete_mist must have a @limiter.limit(...) decorator in mists.py."""
76 import inspect
77 from musehub.api.routes.musehub import mists as _mod
78 full_src = inspect.getsource(_mod)
79 delete_idx = full_src.find("async def delete_mist(")
80 assert delete_idx != -1
81 preamble = full_src[max(0, delete_idx - 500): delete_idx]
82 assert "limiter.limit" in preamble, (
83 "delete_mist must be decorated with @limiter.limit(...); "
84 "no limiter.limit found in the 500 chars before 'async def delete_mist'"
85 )
86
87 def test_update_mist_uses_msign_key_func(self) -> None:
88 """update_mist rate limiter must key on MSign handle, not IP."""
89 import inspect
90 from musehub.api.routes.musehub import mists as _mod
91 full_src = inspect.getsource(_mod)
92 update_idx = full_src.find("async def update_mist(")
93 preamble = full_src[max(0, update_idx - 500): update_idx]
94 assert "get_msign_handle" in preamble, (
95 "update_mist limiter must use key_func=get_msign_handle"
96 )
97
98 def test_delete_mist_uses_msign_key_func(self) -> None:
99 """delete_mist rate limiter must key on MSign handle, not IP."""
100 import inspect
101 from musehub.api.routes.musehub import mists as _mod
102 full_src = inspect.getsource(_mod)
103 delete_idx = full_src.find("async def delete_mist(")
104 preamble = full_src[max(0, delete_idx - 500): delete_idx]
105 assert "get_msign_handle" in preamble, (
106 "delete_mist limiter must use key_func=get_msign_handle"
107 )
108
109
110 # ---------------------------------------------------------------------------
111 # 3. Read endpoints have @limiter.limit decorators
112 # ---------------------------------------------------------------------------
113
114 class TestReadEndpointsHaveLimiters:
115 def _check_fn_has_limiter(self, fn_name: str) -> None:
116 import inspect
117 from musehub.api.routes.musehub import mists as _mod
118 full_src = inspect.getsource(_mod)
119 fn_idx = full_src.find(f"async def {fn_name}(")
120 assert fn_idx != -1, f"Could not find 'async def {fn_name}(' in mists.py"
121 preamble = full_src[max(0, fn_idx - 500): fn_idx]
122 assert "limiter.limit" in preamble, (
123 f"{fn_name} must be decorated with @limiter.limit(...); "
124 f"none found in the 500 chars before 'async def {fn_name}'"
125 )
126
127 def test_explore_mists_has_limiter(self) -> None:
128 """explore_mists (GET /api/mists/explore) must have @limiter.limit."""
129 self._check_fn_has_limiter("explore_mists")
130
131 def test_get_mist_has_limiter(self) -> None:
132 """get_mist (GET /api/mists/{mist_id}) must have @limiter.limit."""
133 self._check_fn_has_limiter("get_mist")
134
135 def test_list_mist_forks_has_limiter(self) -> None:
136 """list_mist_forks (GET /api/mists/{mist_id}/forks) must have @limiter.limit."""
137 self._check_fn_has_limiter("list_mist_forks")
138
139 def test_list_owner_mists_has_limiter(self) -> None:
140 """list_owner_mists (GET /api/{owner}/mists) must have @limiter.limit."""
141 self._check_fn_has_limiter("list_owner_mists")
142
143 def test_get_mist_embed_has_limiter(self) -> None:
144 """get_mist_embed (GET /api/{owner}/mists/{id}/embed) must have @limiter.limit."""
145 self._check_fn_has_limiter("get_mist_embed")
146
147
148 # ---------------------------------------------------------------------------
149 # 4. Read endpoints still return 200 under threshold (regression)
150 # ---------------------------------------------------------------------------
151
152 class TestReadEndpointsWorkUnderThreshold:
153 @pytest.mark.asyncio
154 async def test_explore_returns_200_under_limit(self, client: AsyncClient) -> None:
155 r = await client.get("/api/mists/explore")
156 assert r.status_code == 200, (
157 f"GET /api/mists/explore returned {r.status_code} under the rate limit"
158 )
159
160 @pytest.mark.asyncio
161 async def test_get_nonexistent_mist_returns_404_not_429(
162 self, client: AsyncClient
163 ) -> None:
164 """A single GET for an unknown mist_id must return 404, not 429."""
165 r = await client.get("/api/mists/nonexistentId12")
166 assert r.status_code in (404, 200), (
167 f"GET /api/mists/nonexistentId12 returned {r.status_code}; "
168 "429 under single request means the limit is misconfigured"
169 )
170
171 @pytest.mark.asyncio
172 async def test_list_owner_mists_returns_200_under_limit(
173 self, client: AsyncClient
174 ) -> None:
175 r = await client.get("/api/gabriel/mists")
176 assert r.status_code == 200, (
177 f"GET /api/gabriel/mists returned {r.status_code} under the rate limit"
178 )
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago