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