gabriel / musehub public
test_wire_mpack_unpack_step3_e2e.py python
324 lines 11.5 KB
Raw
sha256:009b5a222314f47640a58d75ce5a1f428f1624cf0b51384dfcdfbdfab3cc42a4 feat: migration idempotency, file attribution DAG walk, mpa… Sonnet 4.6 minor ⚠ breaking 53 days ago
1 """E2E — Push Protocol Step 3: POST /{owner}/{slug}/push/unpack-mpack.
2
3 Exercises the entire Step 3 pseudocode flow end-to-end against the real ASGI
4 app with a real DB session and a mocked MinIO backend. Every pseudocode step
5 is logged so failures are easy to locate.
6
7 Pseudocode under test
8 ---------------------
9 Client sends: {
10 mpack_key, branch, head,
11 commits_count, objects_count,
12 }
13
14 Server:
15 authenticate(request) # MSign → claims.handle
16 validate declared counts:
17 commits_count ≤ mpack_max_commits → 422 if exceeded
18 objects_count ≤ mpack_max_objects → 422 if exceeded
19 repo_id = resolve(owner, slug) → 404 if not found
20 wire_bytes = MinIO.get("mpacks/" + mpack_key) → 422 if not found
21 if sha256(wire_bytes) != mpack_key[7:] → 422
22 [inline content_cache for small mpacks]
23 [advance branch pointer]
24 [enqueue mpack.index job]
25 respond: { job_id, head, branch, objects_in_mpack, commits_in_mpack }
26
27 Tests
28 -----
29 S3E2E-1 Happy path: valid payload → 200, all five response fields present.
30 S3E2E-2 Missing mpack_key → 422.
31 S3E2E-3 commits_count exceeds mpack_max_commits → 422.
32 S3E2E-4 objects_count exceeds mpack_max_objects → 422.
33 S3E2E-5 MinIO returns nothing for mpack_key → 422.
34 S3E2E-6 MinIO bytes sha256 mismatch vs mpack_key → 422.
35 S3E2E-7 Unauthenticated request → 401/403.
36 """
37 from __future__ import annotations
38
39 import hashlib
40 import logging
41
42 import msgpack
43 import pytest
44 import pytest_asyncio
45 from httpx import AsyncClient, ASGITransport
46 from sqlalchemy.ext.asyncio import AsyncSession
47
48 from muse.core.mpack import build_wire_mpack
49 from muse.core.types import blob_id, fake_id
50 from musehub.auth.dependencies import require_valid_token
51 from musehub.auth.request_signing import MSignContext
52 from musehub.config import get_settings
53 from musehub.core.genesis import compute_identity_id
54 from musehub.db.database import get_db
55 from musehub.main import app
56 from musehub.services.musehub_repository import create_repo
57
58 logger = logging.getLogger(__name__)
59
60 _OWNER = "gabriel"
61 _IDENTITY_ID = compute_identity_id(b"gabriel")
62 _REPO_NAME = "step3-e2e-test"
63 _MPACK_BYTES = build_wire_mpack({"objects": [], "commits": [], "snapshots": []})
64 _MPACK_KEY = blob_id(_MPACK_BYTES)
65 _HEAD = fake_id("step3-tip-commit")
66
67 _AUTH_CTX = MSignContext(
68 handle=_OWNER,
69 identity_id=_IDENTITY_ID,
70 is_agent=False,
71 is_admin=False,
72 )
73
74
75 # ---------------------------------------------------------------------------
76 # Fixtures
77 # ---------------------------------------------------------------------------
78
79 @pytest_asyncio.fixture()
80 async def client(db_session: AsyncSession):
81 async def _override_db():
82 yield db_session
83
84 app.dependency_overrides[get_db] = _override_db
85 app.dependency_overrides[require_valid_token] = lambda: _AUTH_CTX
86
87 async with AsyncClient(
88 transport=ASGITransport(app=app),
89 base_url="https://localhost:1337",
90 ) as c:
91 yield c
92
93 app.dependency_overrides.clear()
94
95
96 @pytest_asyncio.fixture()
97 async def repo(db_session: AsyncSession):
98 r = await create_repo(
99 db_session,
100 name=_REPO_NAME,
101 owner=_OWNER,
102 owner_user_id=_IDENTITY_ID,
103 visibility="public",
104 initialize=False,
105 )
106 await db_session.commit()
107 return r
108
109
110 @pytest_asyncio.fixture(autouse=True)
111 async def mock_get_mpack():
112 """Stub MinIO get_mpack so tests don't need a live object store."""
113 from unittest.mock import AsyncMock, MagicMock, patch
114
115 mock_backend = MagicMock()
116 mock_backend.get_mpack = AsyncMock(return_value=_MPACK_BYTES)
117 with patch("musehub.services.musehub_wire.get_backend", return_value=mock_backend), \
118 patch("musehub.services.musehub_wire_push.get_backend", return_value=mock_backend):
119 yield mock_backend
120
121
122 def _unpack_body(
123 mpack_key: str = _MPACK_KEY,
124 branch: str = "main",
125 head: str = "",
126 commits_count: int = 2,
127 objects_count: int = 5,
128 ) -> bytes:
129 payload = {
130 "mpack_key": mpack_key,
131 "branch": branch,
132 "head": head,
133 "commits_count": commits_count,
134 "objects_count": objects_count,
135 }
136 logger.info(
137 "[step3] CLIENT: payload mpack_key=%s branch=%s commits=%d objects=%d",
138 mpack_key[:27], branch, commits_count, objects_count,
139 )
140 return msgpack.packb(payload, use_bin_type=True)
141
142
143 # ---------------------------------------------------------------------------
144 # S3E2E-1 — Happy path
145 # ---------------------------------------------------------------------------
146
147 @pytest.mark.asyncio
148 async def test_s3e2e1_happy_path_returns_all_fields(
149 client: AsyncClient, repo,
150 ) -> None:
151 """Full Step 3 happy path: valid payload → 200, all five response fields."""
152 logger.info("[step3] CLIENT: POST /%s/%s/push/unpack-mpack", _OWNER, _REPO_NAME)
153 resp = await client.post(
154 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
155 content=_unpack_body(),
156 headers={"Content-Type": "application/x-msgpack"},
157 )
158 logger.info("[step3] SERVER: responded HTTP %d", resp.status_code)
159 assert resp.status_code == 200, f"expected 200, got {resp.status_code}: {resp.text}"
160
161 data = resp.json()
162 logger.info("[step3] SERVER: response keys: %s", list(data.keys()))
163
164 logger.info("[step3] ASSERT: head echoed back")
165 assert "head" in data
166
167 logger.info("[step3] ASSERT: branch echoed back")
168 assert data.get("branch") == "main"
169
170 logger.info("[step3] ASSERT: objects_in_mpack echoed back")
171 assert data.get("objects_in_mpack") == 5
172
173 logger.info("[step3] ASSERT: commits_in_mpack echoed back")
174 assert data.get("commits_in_mpack") == 2
175
176
177 # ---------------------------------------------------------------------------
178 # S3E2E-2 — Missing mpack_key → 422
179 # ---------------------------------------------------------------------------
180
181 @pytest.mark.asyncio
182 async def test_s3e2e2_missing_mpack_key_returns_422(
183 client: AsyncClient, repo,
184 ) -> None:
185 """Server validates mpack_key present → 422 when absent."""
186 body = msgpack.packb({"branch": "main", "commits_count": 1}, use_bin_type=True)
187
188 logger.info("[step3] CLIENT: sending body WITHOUT mpack_key")
189 resp = await client.post(
190 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
191 content=body,
192 headers={"Content-Type": "application/x-msgpack"},
193 )
194 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
195 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
196
197
198 # ---------------------------------------------------------------------------
199 # S3E2E-3 — commits_count exceeds limit → 422
200 # ---------------------------------------------------------------------------
201
202 @pytest.mark.asyncio
203 async def test_s3e2e3_excessive_commits_count_returns_422(
204 client: AsyncClient, repo,
205 ) -> None:
206 """Server rejects commits_count > mpack_max_commits with 422."""
207 settings = get_settings()
208 over_limit = settings.mpack_max_commits + 1
209
210 logger.info(
211 "[step3] CLIENT: commits_count=%d (limit=%d)",
212 over_limit, settings.mpack_max_commits,
213 )
214 resp = await client.post(
215 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
216 content=_unpack_body(commits_count=over_limit),
217 headers={"Content-Type": "application/x-msgpack"},
218 )
219 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
220 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
221
222
223 # ---------------------------------------------------------------------------
224 # S3E2E-4 — objects_count exceeds limit → 422
225 # ---------------------------------------------------------------------------
226
227 @pytest.mark.asyncio
228 async def test_s3e2e4_excessive_objects_count_returns_422(
229 client: AsyncClient, repo,
230 ) -> None:
231 """Server rejects objects_count > mpack_max_objects with 422."""
232 settings = get_settings()
233 over_limit = settings.mpack_max_objects + 1
234
235 logger.info(
236 "[step3] CLIENT: objects_count=%d (limit=%d)",
237 over_limit, settings.mpack_max_objects,
238 )
239 resp = await client.post(
240 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
241 content=_unpack_body(objects_count=over_limit),
242 headers={"Content-Type": "application/x-msgpack"},
243 )
244 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
245 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
246
247
248 # ---------------------------------------------------------------------------
249 # S3E2E-5 — MinIO returns nothing → 422
250 # ---------------------------------------------------------------------------
251
252 @pytest.mark.asyncio
253 async def test_s3e2e5_mpack_not_in_minio_returns_422(
254 client: AsyncClient, repo, mock_get_mpack,
255 ) -> None:
256 """When MinIO returns None for mpack_key, server returns 422."""
257 from unittest.mock import AsyncMock
258 mock_get_mpack.get_mpack = AsyncMock(return_value=None)
259
260 logger.info("[step3] SETUP: MinIO stub returns None (mpack not found)")
261 resp = await client.post(
262 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
263 content=_unpack_body(),
264 headers={"Content-Type": "application/x-msgpack"},
265 )
266 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
267 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
268
269
270 # ---------------------------------------------------------------------------
271 # S3E2E-6 — sha256 mismatch → 422
272 # ---------------------------------------------------------------------------
273
274 @pytest.mark.asyncio
275 async def test_s3e2e6_sha256_mismatch_returns_422(
276 client: AsyncClient, repo, mock_get_mpack,
277 ) -> None:
278 """When MinIO bytes don't match mpack_key sha256, server returns 422."""
279 # Valid key but MinIO returns different bytes — integrity check fails
280 tampered_bytes = b"tampered-mpack-bytes-that-do-not-match"
281 from unittest.mock import AsyncMock
282 mock_get_mpack.get_mpack = AsyncMock(return_value=tampered_bytes)
283
284 logger.info("[step3] SETUP: MinIO returns tampered bytes (sha256 mismatch)")
285 resp = await client.post(
286 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
287 content=_unpack_body(),
288 headers={"Content-Type": "application/x-msgpack"},
289 )
290 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
291 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
292
293
294 # ---------------------------------------------------------------------------
295 # S3E2E-7 — Unauthenticated request → 401/403
296 # ---------------------------------------------------------------------------
297
298 @pytest.mark.asyncio
299 async def test_s3e2e7_unauthenticated_returns_401_or_403(
300 db_session: AsyncSession, repo,
301 ) -> None:
302 """Without auth override, missing credentials → 401 or 403."""
303 async def _override_db():
304 yield db_session
305
306 app.dependency_overrides[get_db] = _override_db
307 # do NOT override require_valid_token — real auth enforcement
308
309 logger.info("[step3] CLIENT: POST unpack-mpack with NO auth header")
310 async with AsyncClient(
311 transport=ASGITransport(app=app),
312 base_url="https://localhost:1337",
313 ) as c:
314 resp = await c.post(
315 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
316 content=_unpack_body(),
317 headers={"Content-Type": "application/x-msgpack"},
318 )
319
320 logger.info("[step3] SERVER: responded HTTP %d (expected 401 or 403)", resp.status_code)
321 assert resp.status_code in (401, 403), (
322 f"expected 401 or 403 for unauthenticated request, got {resp.status_code}"
323 )
324 app.dependency_overrides.clear()
File History 2 commits
sha256:009b5a222314f47640a58d75ce5a1f428f1624cf0b51384dfcdfbdfab3cc42a4 feat: migration idempotency, file attribution DAG walk, mpa… Sonnet 4.6 minor 53 days ago
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 59 days ago