test_wire_mpack_unpack_step3_e2e.py
python
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