gabriel / musehub public
test_bot_throttle.py python
263 lines 10.0 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for BotThrottleMiddleware — agent-first principal model.
2
3 Core invariants:
4 - MSign-authenticated requests bypass UA checks entirely.
5 - GET / HEAD requests bypass UA checks — public reads are always allowed.
6 - UA-based blocking applies only to unauthenticated non-GET/HEAD requests.
7 """
8 from __future__ import annotations
9
10 import pytest
11 from httpx import AsyncClient
12
13
14 # ---------------------------------------------------------------------------
15 # Authenticated requests — always pass through regardless of UA
16 # ---------------------------------------------------------------------------
17
18 async def test_msign_with_curl_ua_passes(client: AsyncClient) -> None:
19 """MSign-authenticated curl calls must not be blocked.
20
21 Developers and agents routinely test with curl. Once they sign the
22 request, the UA is irrelevant — identity is proven cryptographically.
23 """
24 resp = await client.get(
25 "/healthz",
26 headers={
27 "User-Agent": "curl/8.4.0",
28 "Authorization": "MSign handle=\"gabriel\" alg=\"ed25519\" ts=1234567890 sig=\"fakesig\"",
29 },
30 )
31 # Must not be 429 — may be 401/403 (invalid sig) but never bot-blocked
32 assert resp.status_code != 429
33
34
35 async def test_msign_with_python_requests_ua_passes(client: AsyncClient) -> None:
36 """MSign-authenticated python-requests calls must not be blocked."""
37 resp = await client.get(
38 "/healthz",
39 headers={
40 "User-Agent": "python-requests/2.31.0",
41 "Authorization": "MSign handle=\"agentception-abc123\" alg=\"ed25519\" ts=1234567890 sig=\"fakesig\"",
42 },
43 )
44 assert resp.status_code != 429
45
46
47 async def test_msign_with_go_ua_passes(client: AsyncClient) -> None:
48 """MSign-authenticated Go HTTP client calls must not be blocked."""
49 resp = await client.get(
50 "/healthz",
51 headers={
52 "User-Agent": "Go-http-client/1.1",
53 "Authorization": "MSign handle=\"some-agent\" alg=\"ed25519\" ts=1234567890 sig=\"fakesig\"",
54 },
55 )
56 assert resp.status_code != 429
57
58
59 async def test_msign_with_no_ua_passes(client: AsyncClient) -> None:
60 """MSign-authenticated requests with no UA must not be blocked.
61
62 An agent that omits the UA header entirely is still authenticated.
63 """
64 resp = await client.get(
65 "/healthz",
66 headers={
67 "Authorization": "MSign handle=\"agent-42\" alg=\"ed25519\" ts=1234567890 sig=\"fakesig\"",
68 },
69 )
70 assert resp.status_code != 429
71
72
73 # ---------------------------------------------------------------------------
74 # GET requests — always pass through regardless of UA (public reads are open)
75 # ---------------------------------------------------------------------------
76
77 async def test_get_with_curl_ua_passes(client: AsyncClient) -> None:
78 """GET with curl UA must not be blocked.
79
80 curl is the canonical tool for reading public pages and testing APIs.
81 GET is a safe read-only method — UA checks add no meaningful protection
82 here since slowapi already caps request rates.
83 """
84 resp = await client.get(
85 "/api/identities",
86 headers={"User-Agent": "curl/8.4.0"},
87 )
88 assert resp.status_code != 429
89
90
91 async def test_get_with_no_ua_passes(client: AsyncClient) -> None:
92 """GET with no UA must not be blocked — public reads are unconditionally allowed."""
93 resp = await client.get("/api/identities", headers={"User-Agent": ""})
94 assert resp.status_code != 429
95
96
97 async def test_get_with_scanner_ua_passes(client: AsyncClient) -> None:
98 """GET with a scanner UA is not blocked — read-only methods bypass UA checks.
99
100 Vulnerability scanners doing GETs are irritating but not dangerous;
101 slowapi handles their rate. Blocking GETs would break legitimate tooling.
102 """
103 resp = await client.get(
104 "/api/identities",
105 headers={"User-Agent": "sqlmap/1.7"},
106 )
107 assert resp.status_code != 429
108
109
110 # ---------------------------------------------------------------------------
111 # Non-GET unauthenticated requests with bad UAs — must be blocked
112 # ---------------------------------------------------------------------------
113
114 async def test_post_unauthenticated_curl_ua_blocked(client: AsyncClient) -> None:
115 """Unauthenticated POST with curl UA is blocked — write paths require known clients."""
116 resp = await client.post(
117 "/api/v1/repos",
118 headers={"User-Agent": "curl/8.4.0"},
119 content=b"{}",
120 )
121 assert resp.status_code == 429
122
123
124 async def test_post_unauthenticated_python_requests_ua_blocked(client: AsyncClient) -> None:
125 """Unauthenticated POST with python-requests UA is blocked."""
126 resp = await client.post(
127 "/api/v1/repos",
128 headers={"User-Agent": "python-requests/2.31.0"},
129 content=b"{}",
130 )
131 assert resp.status_code == 429
132
133
134 async def test_post_unauthenticated_missing_ua_blocked(client: AsyncClient) -> None:
135 """Unauthenticated POST with no UA is blocked — write paths require a UA."""
136 resp = await client.post(
137 "/api/v1/repos",
138 headers={"User-Agent": ""},
139 content=b"{}",
140 )
141 assert resp.status_code == 429
142
143
144 async def test_post_unauthenticated_scanner_ua_blocked(client: AsyncClient) -> None:
145 """Vulnerability scanners are blocked on write paths."""
146 for ua in ["sqlmap/1.7", "nikto/2.1.6", "nuclei/3.0", "masscan/1.3"]:
147 resp = await client.post(
148 "/api/v1/repos",
149 headers={"User-Agent": ua},
150 content=b"{}",
151 )
152 assert resp.status_code == 429, f"Expected 429 for UA: {ua}"
153
154
155 # ---------------------------------------------------------------------------
156 # Exempt paths — always pass through
157 # ---------------------------------------------------------------------------
158
159 async def test_healthz_passes_with_no_ua(client: AsyncClient) -> None:
160 """/healthz must never be blocked — monitoring probes have minimal UAs."""
161 resp = await client.get("/healthz")
162 assert resp.status_code != 429
163
164
165 async def test_healthz_passes_with_curl_ua(client: AsyncClient) -> None:
166 """/healthz must pass even with a normally-blocked UA."""
167 resp = await client.get("/healthz", headers={"User-Agent": "curl/8.4.0"})
168 assert resp.status_code != 429
169
170
171 # ---------------------------------------------------------------------------
172 # Error message — must be informative, not accusatory
173 # ---------------------------------------------------------------------------
174
175 async def test_blocked_response_body_is_informative(client: AsyncClient) -> None:
176 """Blocked response on a write path must guide the client toward authentication."""
177 resp = await client.post(
178 "/api/v1/repos",
179 headers={"User-Agent": "curl/8.4.0"},
180 content=b"{}",
181 )
182 assert resp.status_code == 429
183 body = resp.json()
184 assert "MSign" in body["detail"] or "authenticate" in body["detail"].lower()
185
186
187 # ---------------------------------------------------------------------------
188 # Muse CLI UA — always passes unauthenticated (it's a known good client)
189 # ---------------------------------------------------------------------------
190
191 async def test_muse_cli_ua_passes_unauthenticated(client: AsyncClient) -> None:
192 """The muse CLI UA must not be blocked even without auth.
193
194 muse CLI sends 'muse/<version>' for unauthenticated pre-flight calls
195 like listing remotes before signing in.
196 """
197 resp = await client.get(
198 "/api/identities",
199 headers={"User-Agent": "muse/1.0.0"},
200 )
201 assert resp.status_code != 429
202
203
204 async def test_browser_ua_passes_unauthenticated(client: AsyncClient) -> None:
205 """Standard browser UAs must pass without auth."""
206 resp = await client.get(
207 "/api/identities",
208 headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"},
209 )
210 assert resp.status_code != 429
211
212
213 # ---------------------------------------------------------------------------
214 # Install / uninstall scripts — must be curl-fetchable before muse is installed
215 # ---------------------------------------------------------------------------
216
217 async def test_install_script_passes_with_curl_ua(client: AsyncClient) -> None:
218 """/install.sh must be fetchable with plain curl — that's its entire purpose.
219
220 ``curl -fsSL staging.musehub.ai/install.sh | sh`` is the documented
221 install command. Blocking it with 429 breaks the install flow.
222 """
223 resp = await client.get("/install.sh", headers={"User-Agent": "curl/8.6.0"})
224 assert resp.status_code != 429
225
226
227 async def test_uninstall_script_passes_with_curl_ua(client: AsyncClient) -> None:
228 """/uninstall.sh must also be fetchable with plain curl."""
229 resp = await client.get("/uninstall.sh", headers={"User-Agent": "curl/8.6.0"})
230 assert resp.status_code != 429
231
232
233 async def test_install_script_passes_with_no_ua(client: AsyncClient) -> None:
234 """/install.sh passes even without a UA — some minimal shell environments omit it."""
235 resp = await client.get("/install.sh", headers={"User-Agent": ""})
236 assert resp.status_code != 429
237
238
239 # ---------------------------------------------------------------------------
240 # Release binary downloads — fetched by install.sh via curl
241 # ---------------------------------------------------------------------------
242
243 async def test_release_tarball_passes_with_curl_ua(client: AsyncClient) -> None:
244 """/releases/* must be fetchable with plain curl.
245
246 install.sh runs ``curl -fsSL .../releases/muse-<ver>-<os>-<arch>.tar.gz``
247 as its second step. If /releases/ is blocked, the install command succeeds
248 on the script fetch but silently fails on the binary download.
249 """
250 resp = await client.get(
251 "/releases/muse-0.2.0-linux-arm64.tar.gz",
252 headers={"User-Agent": "curl/8.6.0"},
253 )
254 assert resp.status_code != 429
255
256
257 async def test_release_tarball_passes_with_no_ua(client: AsyncClient) -> None:
258 """/releases/* passes even without a UA."""
259 resp = await client.get(
260 "/releases/muse-0.2.0-linux-arm64.tar.gz",
261 headers={"User-Agent": ""},
262 )
263 assert resp.status_code != 429
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago