gabriel / musehub public
test_mpack_size_gates.py python
283 lines 9.4 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 20 days ago
1 """TDD — Phase 1 mpack push security: size gates.
2
3 Three gates, zero trust:
4
5 1. presign rejects size_bytes above _MAX_BUNDLE_BYTES → HTTP 413
6 Fires before a presigned URL is issued — client never gets a URL to abuse.
7
8 2. unpack-mpack rejects wire_bytes above _MAX_BUNDLE_BYTES → HTTP 422
9 Defends against a client that bypassed presign and PUT directly to MinIO.
10
11 3. unpack-mpack rejects commits_count / objects_count above their caps → HTTP 422
12 These are logging inputs, not trusted counts, but bounding them prevents
13 absurd allocations and log lines in the background worker.
14
15 All caps live in musehub.config.Settings so they can be tuned per environment.
16 """
17 from __future__ import annotations
18
19 import hashlib
20 from unittest.mock import AsyncMock, patch
21
22 import msgpack
23 import pytest
24 import pytest_asyncio
25 from httpx import AsyncClient, ASGITransport
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.auth.request_signing import MSignContext, require_signed_request, optional_signed_request
29 from musehub.db.database import get_db
30 from musehub.main import app
31
32
33 _AUTH_CTX = MSignContext(
34 handle="gabriel",
35 identity_id="sha256:" + "0" * 64,
36 is_agent=False,
37 is_admin=True,
38 )
39
40
41 @pytest_asyncio.fixture()
42 async def client(db_session: AsyncSession) -> None:
43 async def _override_get_db() -> None:
44 yield db_session
45
46 app.dependency_overrides[get_db] = _override_get_db
47 app.dependency_overrides[require_signed_request] = lambda: _AUTH_CTX
48 app.dependency_overrides[optional_signed_request] = lambda: _AUTH_CTX
49
50 async with AsyncClient(
51 transport=ASGITransport(app=app),
52 base_url="https://localhost:1337",
53 ) as c:
54 yield c
55
56 app.dependency_overrides.clear()
57
58
59 @pytest_asyncio.fixture()
60 async def repo(client: AsyncClient) -> None:
61 resp = await client.post(
62 "/api/repos",
63 json={"owner": "gabriel", "name": "size-gates-test", "visibility": "public", "initialize": False},
64 )
65 assert resp.status_code in (200, 201), resp.text
66 data = resp.json()
67 yield data["slug"]
68 await client.delete(f"/api/repos/{data['repoId']}")
69
70
71 # ── Gate 1: presign rejects oversized mpacks ──────────────────────────────
72
73 @pytest.mark.asyncio
74 async def test_presign_rejects_oversized_mpack(
75 client: AsyncClient, repo: str,
76 ) -> None:
77 """POST /push/mpack-presign with size_bytes above cap → 413.
78
79 The cap is enforced before the presigned URL is issued so the client
80 never receives a URL they can abuse.
81 """
82 from musehub.config import get_settings
83 settings = get_settings()
84 oversized = settings.mpack_max_bytes + 1
85
86 resp = await client.post(
87 f"/gabriel/{repo}/push/mpack-presign",
88 content=msgpack.packb(
89 {
90 "mpack_key": "sha256:" + "a" * 64,
91 "size_bytes": oversized,
92 },
93 use_bin_type=True,
94 ),
95 headers={"Content-Type": "application/x-msgpack"},
96 )
97
98 assert resp.status_code == 413, (
99 f"Expected 413 for mpack size {oversized:,} bytes, got {resp.status_code}: {resp.text}"
100 )
101
102
103 @pytest.mark.skip(reason="muse wire protocol in flux")
104 @pytest.mark.asyncio
105 async def test_presign_accepts_mpack_at_limit(
106 client: AsyncClient, repo: str,
107 ) -> None:
108 """POST /push/mpack-presign with size_bytes == cap → 200 (not rejected).
109
110 The limit is exclusive: exactly at the cap is still allowed.
111 """
112 from musehub.config import get_settings
113 settings = get_settings()
114 at_limit = settings.mpack_max_bytes
115
116 resp = await client.post(
117 f"/gabriel/{repo}/push/mpack-presign",
118 content=msgpack.packb(
119 {
120 "mpack_key": "sha256:" + "b" * 64,
121 "size_bytes": at_limit,
122 },
123 use_bin_type=True,
124 ),
125 headers={"Content-Type": "application/x-msgpack"},
126 )
127
128 assert resp.status_code == 200, (
129 f"Expected 200 for mpack size == cap ({at_limit:,}), got {resp.status_code}: {resp.text}"
130 )
131
132
133 # ── Gate 2: unpack-mpack rejects oversized wire bytes ─────────────────────
134
135 @pytest.mark.skip(reason="muse wire protocol in flux")
136 @pytest.mark.asyncio
137 async def test_unpack_mpack_rejects_oversized_wire_bytes(
138 client: AsyncClient, repo: str,
139 ) -> None:
140 """POST /push/unpack-mpack where MinIO returns bytes above cap → 422.
141
142 Defends against a client that bypassed the presign check and PUT a
143 giant blob directly to MinIO. The gate fires after the MinIO GET,
144 before the background job is enqueued.
145 """
146 from musehub.config import get_settings
147 settings = get_settings()
148
149 # Fabricate oversized wire bytes (random content, doesn't need to be a
150 # real mpack — the size check fires before any parsing).
151 oversized_bytes = b"x" * (settings.mpack_max_bytes + 1)
152 mpack_key = "sha256:" + hashlib.sha256(oversized_bytes).hexdigest()
153
154 with patch(
155 "musehub.services.musehub_wire.get_backend",
156 ) as mock_get_backend:
157 mock_backend = AsyncMock()
158 mock_backend.get_mpack = AsyncMock(return_value=oversized_bytes)
159 mock_get_backend.return_value = mock_backend
160
161 resp = await client.post(
162 f"/gabriel/{repo}/push/unpack-mpack",
163 content=msgpack.packb(
164 {
165 "mpack_key": mpack_key,
166 "branch": "main",
167 "head": "sha256:" + "c" * 64,
168 "commits_count": 1,
169 "objects_count": 1,
170 },
171 use_bin_type=True,
172 ),
173 headers={"Content-Type": "application/x-msgpack"},
174 )
175
176 assert resp.status_code == 422, (
177 f"Expected 422 for oversized wire bytes ({len(oversized_bytes):,}), "
178 f"got {resp.status_code}: {resp.text}"
179 )
180
181
182 # ── Gate 3: unpack-mpack rejects absurd count values ─────────────────────
183
184 @pytest.mark.asyncio
185 async def test_unpack_mpack_rejects_absurd_commits_count(
186 client: AsyncClient, repo: str,
187 ) -> None:
188 """commits_count above cap → 422 at the route layer, before MinIO is touched."""
189 from musehub.config import get_settings
190 settings = get_settings()
191
192 resp = await client.post(
193 f"/gabriel/{repo}/push/unpack-mpack",
194 content=msgpack.packb(
195 {
196 "mpack_key": "sha256:" + "d" * 64,
197 "branch": "main",
198 "head": "sha256:" + "e" * 64,
199 "commits_count": settings.mpack_max_commits + 1,
200 "objects_count": 1,
201 },
202 use_bin_type=True,
203 ),
204 headers={"Content-Type": "application/x-msgpack"},
205 )
206
207 assert resp.status_code == 422, (
208 f"Expected 422 for commits_count above cap, got {resp.status_code}: {resp.text}"
209 )
210
211
212 @pytest.mark.asyncio
213 async def test_unpack_mpack_rejects_absurd_objects_count(
214 client: AsyncClient, repo: str,
215 ) -> None:
216 """objects_count above cap → 422 at the route layer, before MinIO is touched."""
217 from musehub.config import get_settings
218 settings = get_settings()
219
220 resp = await client.post(
221 f"/gabriel/{repo}/push/unpack-mpack",
222 content=msgpack.packb(
223 {
224 "mpack_key": "sha256:" + "f" * 64,
225 "branch": "main",
226 "head": "sha256:" + "a" * 64,
227 "commits_count": 1,
228 "objects_count": settings.mpack_max_objects + 1,
229 },
230 use_bin_type=True,
231 ),
232 headers={"Content-Type": "application/x-msgpack"},
233 )
234
235 assert resp.status_code == 422, (
236 f"Expected 422 for objects_count above cap, got {resp.status_code}: {resp.text}"
237 )
238
239
240 @pytest.mark.skip(reason="muse wire protocol in flux")
241 @pytest.mark.asyncio
242 async def test_unpack_mpack_accepts_counts_at_limit(
243 client: AsyncClient, repo: str,
244 ) -> None:
245 """commits_count and objects_count exactly at cap → not rejected by the count gate.
246
247 The subsequent MinIO GET will fail (fake mpack_key), but the count
248 gate itself must not fire — that is what this test asserts.
249 """
250 from musehub.config import get_settings
251 settings = get_settings()
252
253 fake_bytes = b"fake"
254 mpack_key = "sha256:" + hashlib.sha256(fake_bytes).hexdigest()
255
256 with patch(
257 "musehub.services.musehub_wire.get_backend",
258 ) as mock_get_backend:
259 mock_backend = AsyncMock()
260 mock_backend.get_mpack = AsyncMock(return_value=None)
261 mock_get_backend.return_value = mock_backend
262
263 resp = await client.post(
264 f"/gabriel/{repo}/push/unpack-mpack",
265 content=msgpack.packb(
266 {
267 "mpack_key": mpack_key,
268 "branch": "main",
269 "head": "sha256:" + "b" * 64,
270 "commits_count": settings.mpack_max_commits,
271 "objects_count": settings.mpack_max_objects,
272 },
273 use_bin_type=True,
274 ),
275 headers={"Content-Type": "application/x-msgpack"},
276 )
277
278 # Count gate did not fire. The 422 here is from the missing mpack — correct.
279 assert resp.status_code == 422, resp.text
280 body = resp.json()
281 assert "commits_count" not in str(body).lower() and "objects_count" not in str(body).lower(), (
282 f"Count gate fired at limit — should only fire above limit. Response: {body}"
283 )
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago