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