gabriel / musehub public
test_wire_mpack_unpack_step3_e2e.py python
330 lines 12.0 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day 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 patch("musehub.storage.backends.get_backend", return_value=mock_backend), \
124 patch("musehub.storage.get_backend", return_value=mock_backend):
125 yield mock_backend
126
127
128 def _unpack_body(
129 mpack_key: str = _MPACK_KEY,
130 branch: str = "main",
131 head: str = "",
132 commits_count: int = 2,
133 objects_count: int = 5,
134 ) -> bytes:
135 payload = {
136 "mpack_key": mpack_key,
137 "branch": branch,
138 "head": head,
139 "commits_count": commits_count,
140 "blobs_count": objects_count,
141 }
142 logger.info(
143 "[step3] CLIENT: payload mpack_key=%s branch=%s commits=%d objects=%d",
144 mpack_key[:27], branch, commits_count, objects_count,
145 )
146 return msgpack.packb(payload, use_bin_type=True)
147
148
149 # ---------------------------------------------------------------------------
150 # S3E2E-1 — Happy path
151 # ---------------------------------------------------------------------------
152
153 @pytest.mark.asyncio
154 async def test_s3e2e1_happy_path_returns_all_fields(
155 client: AsyncClient, repo: MusehubRepo,
156 ) -> None:
157 """Full Step 3 happy path: valid payload → 200, all five response fields."""
158 logger.info("[step3] CLIENT: POST /%s/%s/push/unpack-mpack", _OWNER, _REPO_NAME)
159 resp = await client.post(
160 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
161 content=_unpack_body(),
162 headers={"Content-Type": "application/x-msgpack"},
163 )
164 logger.info("[step3] SERVER: responded HTTP %d", resp.status_code)
165 assert resp.status_code == 200, f"expected 200, got {resp.status_code}: {resp.text}"
166
167 data = resp.json()
168 logger.info("[step3] SERVER: response keys: %s", list(data.keys()))
169
170 logger.info("[step3] ASSERT: head echoed back")
171 assert "head" in data
172
173 logger.info("[step3] ASSERT: branch echoed back")
174 assert data.get("branch") == "main"
175
176 logger.info("[step3] ASSERT: objects_in_mpack echoed back")
177 assert data.get("blobs_in_mpack") == 5
178
179 logger.info("[step3] ASSERT: commits_in_mpack echoed back")
180 assert data.get("commits_in_mpack") == 2
181
182
183 # ---------------------------------------------------------------------------
184 # S3E2E-2 — Missing mpack_key → 422
185 # ---------------------------------------------------------------------------
186
187 @pytest.mark.asyncio
188 async def test_s3e2e2_missing_mpack_key_returns_422(
189 client: AsyncClient, repo: MusehubRepo,
190 ) -> None:
191 """Server validates mpack_key present → 422 when absent."""
192 body = msgpack.packb({"branch": "main", "commits_count": 1}, use_bin_type=True)
193
194 logger.info("[step3] CLIENT: sending body WITHOUT mpack_key")
195 resp = await client.post(
196 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
197 content=body,
198 headers={"Content-Type": "application/x-msgpack"},
199 )
200 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
201 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
202
203
204 # ---------------------------------------------------------------------------
205 # S3E2E-3 — commits_count exceeds limit → 422
206 # ---------------------------------------------------------------------------
207
208 @pytest.mark.asyncio
209 async def test_s3e2e3_excessive_commits_count_returns_422(
210 client: AsyncClient, repo: MusehubRepo,
211 ) -> None:
212 """Server rejects commits_count > mpack_max_commits with 422."""
213 settings = get_settings()
214 over_limit = settings.mpack_max_commits + 1
215
216 logger.info(
217 "[step3] CLIENT: commits_count=%d (limit=%d)",
218 over_limit, settings.mpack_max_commits,
219 )
220 resp = await client.post(
221 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
222 content=_unpack_body(commits_count=over_limit),
223 headers={"Content-Type": "application/x-msgpack"},
224 )
225 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
226 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
227
228
229 # ---------------------------------------------------------------------------
230 # S3E2E-4 — objects_count exceeds limit → 422
231 # ---------------------------------------------------------------------------
232
233 @pytest.mark.asyncio
234 async def test_s3e2e4_excessive_objects_count_returns_422(
235 client: AsyncClient, repo: MusehubRepo,
236 ) -> None:
237 """Server rejects objects_count > mpack_max_objects with 422."""
238 settings = get_settings()
239 over_limit = settings.mpack_max_objects + 1
240
241 logger.info(
242 "[step3] CLIENT: objects_count=%d (limit=%d)",
243 over_limit, settings.mpack_max_objects,
244 )
245 resp = await client.post(
246 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
247 content=_unpack_body(objects_count=over_limit),
248 headers={"Content-Type": "application/x-msgpack"},
249 )
250 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
251 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
252
253
254 # ---------------------------------------------------------------------------
255 # S3E2E-5 — MinIO returns nothing → 422
256 # ---------------------------------------------------------------------------
257
258 @pytest.mark.asyncio
259 async def test_s3e2e5_mpack_not_in_minio_returns_422(
260 client: AsyncClient, repo: MusehubRepo, mock_get_mpack: MagicMock,
261 ) -> None:
262 """When MinIO returns None for mpack_key, server returns 422."""
263 from unittest.mock import AsyncMock
264 mock_get_mpack.get_mpack = AsyncMock(return_value=None)
265
266 logger.info("[step3] SETUP: MinIO stub returns None (mpack not found)")
267 resp = await client.post(
268 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
269 content=_unpack_body(),
270 headers={"Content-Type": "application/x-msgpack"},
271 )
272 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
273 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
274
275
276 # ---------------------------------------------------------------------------
277 # S3E2E-6 — sha256 mismatch → 422
278 # ---------------------------------------------------------------------------
279
280 @pytest.mark.asyncio
281 async def test_s3e2e6_sha256_mismatch_returns_422(
282 client: AsyncClient, repo: MusehubRepo, mock_get_mpack: MagicMock,
283 ) -> None:
284 """When MinIO bytes don't match mpack_key sha256, server returns 422."""
285 # Valid key but MinIO returns different bytes — integrity check fails
286 tampered_bytes = b"tampered-mpack-bytes-that-do-not-match"
287 from unittest.mock import AsyncMock
288 mock_get_mpack.get_mpack = AsyncMock(return_value=tampered_bytes)
289
290 logger.info("[step3] SETUP: MinIO returns tampered bytes (sha256 mismatch)")
291 resp = await client.post(
292 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
293 content=_unpack_body(),
294 headers={"Content-Type": "application/x-msgpack"},
295 )
296 logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code)
297 assert resp.status_code == 422, f"expected 422, got {resp.status_code}"
298
299
300 # ---------------------------------------------------------------------------
301 # S3E2E-7 — Unauthenticated request → 401/403
302 # ---------------------------------------------------------------------------
303
304 @pytest.mark.asyncio
305 async def test_s3e2e7_unauthenticated_returns_401_or_403(
306 db_session: AsyncSession, repo: MusehubRepo,
307 ) -> None:
308 """Without auth override, missing credentials → 401 or 403."""
309 async def _override_db() -> None:
310 yield db_session
311
312 app.dependency_overrides[get_db] = _override_db
313 # do NOT override require_valid_token — real auth enforcement
314
315 logger.info("[step3] CLIENT: POST unpack-mpack with NO auth header")
316 async with AsyncClient(
317 transport=ASGITransport(app=app),
318 base_url="https://localhost:1337",
319 ) as c:
320 resp = await c.post(
321 f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack",
322 content=_unpack_body(),
323 headers={"Content-Type": "application/x-msgpack"},
324 )
325
326 logger.info("[step3] SERVER: responded HTTP %d (expected 401 or 403)", resp.status_code)
327 assert resp.status_code in (401, 403), (
328 f"expected 401 or 403 for unauthenticated request, got {resp.status_code}"
329 )
330 app.dependency_overrides.clear()
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 10 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago