gabriel / musehub public
test_mpack_rate_limiting_phase4.py python
398 lines 14.2 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 58 days ago
1 """TDD — Phase 4: per-user daily byte limits, anomaly detection, /api/caps (issue #49).
2
3 Phase 4 invariants:
4 4a. Per-user daily byte limit — tracked in musehub_daily_push_bytes; presign
5 endpoint rejects with 429 when the caller's daily total would exceed
6 settings.mpack_daily_upload_limit_bytes.
7 4b. Anomaly detection — after a successful mpack.index, the per-user 30-day
8 rolling average is computed; if today's upload is >10× the average a
9 musehub_push_anomaly row is inserted and a structured WARNING is logged.
10 The push is NOT rejected.
11 4c. GET /api/caps — public endpoint returning server limits as JSON.
12 """
13 from __future__ import annotations
14
15 import datetime
16
17 import pytest
18 import pytest_asyncio
19 from httpx import AsyncClient, ASGITransport
20 from sqlalchemy import select
21 from sqlalchemy.ext.asyncio import AsyncSession
22
23 from musehub.auth.request_signing import MSignContext, require_signed_request, optional_signed_request
24 from musehub.db.musehub_abuse_models import MusehubDailyPushBytes, MusehubPushAnomaly
25 from musehub.db.database import get_db
26 from musehub.main import app
27
28
29 _AUTH_CTX = MSignContext(
30 handle="gabriel",
31 identity_id="sha256:" + "a" * 64,
32 is_agent=False,
33 is_admin=False,
34 )
35
36 _AUTH_CTX2 = MSignContext(
37 handle="carol",
38 identity_id="sha256:" + "b" * 64,
39 is_agent=False,
40 is_admin=False,
41 )
42
43
44 # ── fixtures ─────────────────────────────────────────────────────────────────
45
46 @pytest_asyncio.fixture()
47 async def client(db_session: AsyncSession):
48 async def _override_get_db():
49 yield db_session
50
51 app.dependency_overrides[get_db] = _override_get_db
52 app.dependency_overrides[require_signed_request] = lambda: _AUTH_CTX
53 app.dependency_overrides[optional_signed_request] = lambda: _AUTH_CTX
54
55 async with AsyncClient(
56 transport=ASGITransport(app=app),
57 base_url="https://localhost:1337",
58 ) as c:
59 yield c
60
61 app.dependency_overrides.clear()
62
63
64 @pytest_asyncio.fixture()
65 async def client2(db_session: AsyncSession):
66 """Second user client — verifies limits are per-user, not global."""
67 async def _override_get_db():
68 yield db_session
69
70 app.dependency_overrides[get_db] = _override_get_db
71 app.dependency_overrides[require_signed_request] = lambda: _AUTH_CTX2
72 app.dependency_overrides[optional_signed_request] = lambda: _AUTH_CTX2
73
74 async with AsyncClient(
75 transport=ASGITransport(app=app),
76 base_url="https://localhost:1337",
77 ) as c:
78 yield c
79
80 app.dependency_overrides.clear()
81
82
83 @pytest_asyncio.fixture()
84 async def repo(client: AsyncClient):
85 resp = await client.post(
86 "/api/repos",
87 json={"owner": "gabriel", "name": "phase4-limits-test", "visibility": "public", "initialize": False},
88 )
89 assert resp.status_code in (200, 201), resp.text
90 data = resp.json()
91 yield data
92 await client.delete(f"/api/repos/{data['repoId']}")
93
94
95 # ── helper ────────────────────────────────────────────────────────────────────
96
97 def _today() -> datetime.date:
98 return datetime.date.today()
99
100
101 # ══════════════════════════════════════════════════════════════════════════════
102 # 4a — per-user daily byte limit
103 # ══════════════════════════════════════════════════════════════════════════════
104
105 @pytest.mark.asyncio
106 async def test_daily_push_bytes_table_exists(db_session: AsyncSession):
107 """musehub_daily_push_bytes table is present and queryable."""
108 rows = (
109 await db_session.execute(select(MusehubDailyPushBytes))
110 ).scalars().all()
111 assert isinstance(rows, list)
112
113
114 @pytest.mark.asyncio
115 async def test_record_mpack_bytes_increments_daily_total(db_session: AsyncSession):
116 """record_mpack_bytes_uploaded upserts the per-user daily row."""
117 from musehub.services.musehub_wire import record_mpack_bytes_uploaded
118
119 identity_id = _AUTH_CTX.identity_id
120 today = _today()
121
122 await record_mpack_bytes_uploaded(db_session, identity_id, 1024)
123 await db_session.commit()
124
125 row = (
126 await db_session.execute(
127 select(MusehubDailyPushBytes).where(
128 MusehubDailyPushBytes.identity_id == identity_id,
129 MusehubDailyPushBytes.date == today,
130 )
131 )
132 ).scalar_one_or_none()
133
134 assert row is not None
135 assert row.bytes_uploaded == 1024
136
137
138 @pytest.mark.asyncio
139 async def test_record_mpack_bytes_accumulates_across_calls(db_session: AsyncSession):
140 """Two calls on the same day accumulate, not overwrite."""
141 from musehub.services.musehub_wire import record_mpack_bytes_uploaded
142
143 identity_id = _AUTH_CTX.identity_id
144
145 await record_mpack_bytes_uploaded(db_session, identity_id, 500)
146 await db_session.commit()
147 await record_mpack_bytes_uploaded(db_session, identity_id, 300)
148 await db_session.commit()
149
150 row = (
151 await db_session.execute(
152 select(MusehubDailyPushBytes).where(
153 MusehubDailyPushBytes.identity_id == identity_id,
154 MusehubDailyPushBytes.date == _today(),
155 )
156 )
157 ).scalar_one_or_none()
158 assert row is not None
159 assert row.bytes_uploaded == 800
160
161
162 @pytest.mark.skip(reason="muse wire protocol in flux")
163 @pytest.mark.asyncio
164 async def test_daily_limit_allows_push_under_quota(
165 client: AsyncClient,
166 repo,
167 ):
168 """mpack-presign succeeds when user is under the daily limit."""
169 import msgpack
170 body = msgpack.packb({"mpack_key": "sha256:" + "c" * 64, "size_bytes": 1024})
171 resp = await client.post(
172 f"/{repo['owner']}/{repo['name']}/push/mpack-presign",
173 content=body,
174 headers={"Content-Type": "application/x-msgpack"},
175 )
176 # 200 or 422 (no backend presign in test) — definitely NOT 429
177 assert resp.status_code != 429
178
179
180 @pytest.mark.asyncio
181 async def test_daily_limit_blocks_push_over_quota(
182 client: AsyncClient,
183 repo,
184 db_session: AsyncSession,
185 ):
186 """mpack-presign returns 429 when daily limit already exceeded."""
187 from musehub.config import get_settings
188 from musehub.services.musehub_wire import record_mpack_bytes_uploaded
189
190 limit = get_settings().mpack_daily_upload_limit_bytes
191
192 # Pre-fill the user's daily counter just over the limit
193 await record_mpack_bytes_uploaded(db_session, _AUTH_CTX.identity_id, limit + 1)
194 await db_session.commit()
195
196 import msgpack
197 body = msgpack.packb({"mpack_key": "sha256:" + "d" * 64, "size_bytes": 1024})
198 resp = await client.post(
199 f"/{repo['owner']}/{repo['name']}/push/mpack-presign",
200 content=body,
201 headers={"Content-Type": "application/x-msgpack"},
202 )
203 assert resp.status_code == 429
204 assert "daily" in resp.text.lower()
205
206
207 @pytest.mark.skip(reason="muse wire protocol in flux")
208 @pytest.mark.asyncio
209 async def test_daily_limit_is_per_user_not_global(
210 client: AsyncClient,
211 repo,
212 db_session: AsyncSession,
213 ):
214 """User carol's quota being exhausted does NOT block gabriel."""
215 from musehub.config import get_settings
216 from musehub.services.musehub_wire import record_mpack_bytes_uploaded
217
218 limit = get_settings().mpack_daily_upload_limit_bytes
219
220 # Exhaust carol's quota
221 await record_mpack_bytes_uploaded(db_session, _AUTH_CTX2.identity_id, limit + 1)
222 await db_session.commit()
223
224 # Gabriel's presign should still work (not 429)
225 import msgpack
226 body = msgpack.packb({"mpack_key": "sha256:" + "e" * 64, "size_bytes": 512})
227 resp = await client.post(
228 f"/{repo['owner']}/{repo['name']}/push/mpack-presign",
229 content=body,
230 headers={"Content-Type": "application/x-msgpack"},
231 )
232 assert resp.status_code != 429
233
234
235 # ══════════════════════════════════════════════════════════════════════════════
236 # 4b — anomaly detection
237 # ══════════════════════════════════════════════════════════════════════════════
238
239 @pytest.mark.asyncio
240 async def test_push_anomaly_table_exists(db_session: AsyncSession):
241 """musehub_push_anomaly table is present and queryable."""
242 rows = (
243 await db_session.execute(select(MusehubPushAnomaly))
244 ).scalars().all()
245 assert isinstance(rows, list)
246
247
248 @pytest.mark.asyncio
249 async def test_check_push_anomaly_does_not_flag_normal_volume(db_session: AsyncSession):
250 """No anomaly row created when today's upload ≤ 10× 30-day average."""
251 from musehub.services.musehub_wire import check_push_anomaly
252
253 identity_id = _AUTH_CTX.identity_id
254 today = _today()
255
256 # Seed 7 days of 1 MB pushes for a 1 MB average
257 for i in range(7):
258 day = today - datetime.timedelta(days=i + 1)
259 db_session.add(MusehubDailyPushBytes(
260 identity_id=identity_id,
261 date=day,
262 bytes_uploaded=1024 * 1024,
263 ))
264 await db_session.commit()
265
266 # Today's upload is 5 MB — 5× the average, under the 10× threshold
267 flagged = await check_push_anomaly(db_session, identity_id, 5 * 1024 * 1024)
268 assert flagged is False
269
270 rows = (
271 await db_session.execute(
272 select(MusehubPushAnomaly).where(
273 MusehubPushAnomaly.identity_id == identity_id
274 )
275 )
276 ).scalars().all()
277 assert len(rows) == 0
278
279
280 @pytest.mark.asyncio
281 async def test_check_push_anomaly_flags_spike(db_session: AsyncSession):
282 """Anomaly row inserted when today's upload is >10× 30-day average."""
283 from musehub.services.musehub_wire import check_push_anomaly
284
285 identity_id = _AUTH_CTX.identity_id
286 today = _today()
287
288 # 30-day average of 1 MB/day
289 for i in range(30):
290 day = today - datetime.timedelta(days=i + 1)
291 db_session.add(MusehubDailyPushBytes(
292 identity_id=identity_id,
293 date=day,
294 bytes_uploaded=1024 * 1024,
295 ))
296 await db_session.commit()
297
298 # Today's upload is 11 MB — 11× the average → should flag
299 flagged = await check_push_anomaly(db_session, identity_id, 11 * 1024 * 1024)
300 assert flagged is True
301 await db_session.commit()
302
303 row = (
304 await db_session.execute(
305 select(MusehubPushAnomaly).where(
306 MusehubPushAnomaly.identity_id == identity_id
307 )
308 )
309 ).scalar_one_or_none()
310 assert row is not None
311 assert row.bytes_today >= 11 * 1024 * 1024
312 assert row.rolling_avg_bytes > 0
313 assert row.ratio >= 10.0
314
315
316 @pytest.mark.asyncio
317 async def test_check_push_anomaly_no_history_does_not_flag(db_session: AsyncSession):
318 """First-ever push for a user (no history) is never flagged as anomalous."""
319 from musehub.services.musehub_wire import check_push_anomaly
320
321 identity_id = "sha256:" + "f" * 64
322 flagged = await check_push_anomaly(db_session, identity_id, 100 * 1024 * 1024)
323 assert flagged is False
324
325
326 @pytest.mark.asyncio
327 async def test_anomaly_does_not_block_push(db_session: AsyncSession):
328 """check_push_anomaly returns True but raises no exception."""
329 from musehub.services.musehub_wire import check_push_anomaly
330
331 identity_id = _AUTH_CTX.identity_id
332 today = _today()
333
334 for i in range(10):
335 day = today - datetime.timedelta(days=i + 1)
336 db_session.add(MusehubDailyPushBytes(
337 identity_id=identity_id,
338 date=day,
339 bytes_uploaded=100,
340 ))
341 await db_session.commit()
342
343 # 100 MB vs 100 bytes average — far above 10× threshold
344 # Should return True but NOT raise
345 try:
346 result = await check_push_anomaly(db_session, identity_id, 100 * 1024 * 1024)
347 assert result is True
348 except Exception as exc:
349 pytest.fail(f"check_push_anomaly raised unexpectedly: {exc}")
350
351
352 # ══════════════════════════════════════════════════════════════════════════════
353 # 4c — GET /api/caps
354 # ══════════════════════════════════════════════════════════════════════════════
355
356 @pytest.mark.asyncio
357 async def test_caps_endpoint_exists(client: AsyncClient):
358 """GET /api/caps returns 200."""
359 resp = await client.get("/api/caps")
360 assert resp.status_code == 200
361
362
363 @pytest.mark.asyncio
364 async def test_caps_returns_required_fields(client: AsyncClient):
365 """GET /api/caps body contains all required server limit fields."""
366 resp = await client.get("/api/caps")
367 assert resp.status_code == 200
368 data = resp.json()
369 assert "max_mpack_bytes" in data
370 assert "daily_upload_limit_bytes" in data
371 assert "max_commits_per_push" in data
372 assert "max_objects_per_push" in data
373
374
375 @pytest.mark.asyncio
376 async def test_caps_values_match_settings(client: AsyncClient):
377 """GET /api/caps values match the active settings object."""
378 from musehub.config import get_settings
379 s = get_settings()
380
381 resp = await client.get("/api/caps")
382 assert resp.status_code == 200
383 data = resp.json()
384 assert data["max_mpack_bytes"] == s.mpack_max_bytes
385 assert data["daily_upload_limit_bytes"] == s.mpack_daily_upload_limit_bytes
386 assert data["max_commits_per_push"] == s.mpack_max_commits
387 assert data["max_objects_per_push"] == s.mpack_max_objects
388
389
390 @pytest.mark.asyncio
391 async def test_caps_is_public_no_auth_required():
392 """GET /api/caps works without any auth header."""
393 async with AsyncClient(
394 transport=ASGITransport(app=app),
395 base_url="https://localhost:1337",
396 ) as c:
397 resp = await c.get("/api/caps")
398 assert resp.status_code == 200
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 58 days ago