"""E2E — Push Protocol Step 3: POST /{owner}/{slug}/push/unpack-mpack. Exercises the entire Step 3 pseudocode flow end-to-end against the real ASGI app with a real DB session and a mocked MinIO backend. Every pseudocode step is logged so failures are easy to locate. Pseudocode under test --------------------- Client sends: { mpack_key, branch, head, commits_count, objects_count, } Server: authenticate(request) # MSign → claims.handle validate declared counts: commits_count ≤ mpack_max_commits → 422 if exceeded objects_count ≤ mpack_max_objects → 422 if exceeded repo_id = resolve(owner, slug) → 404 if not found wire_bytes = MinIO.get("mpacks/" + mpack_key) → 422 if not found if sha256(wire_bytes) != mpack_key[7:] → 422 [inline content_cache for small mpacks] [advance branch pointer] [enqueue mpack.index job] respond: { job_id, head, branch, objects_in_mpack, commits_in_mpack } Tests ----- S3E2E-1 Happy path: valid payload → 200, all five response fields present. S3E2E-2 Missing mpack_key → 422. S3E2E-3 commits_count exceeds mpack_max_commits → 422. S3E2E-4 objects_count exceeds mpack_max_objects → 422. S3E2E-5 MinIO returns nothing for mpack_key → 422. S3E2E-6 MinIO bytes sha256 mismatch vs mpack_key → 422. S3E2E-7 Unauthenticated request → 401/403. """ from __future__ import annotations import hashlib import logging import msgpack import pytest import pytest_asyncio from httpx import AsyncClient, ASGITransport from sqlalchemy.ext.asyncio import AsyncSession from muse.core.mpack import build_wire_mpack from muse.core.types import blob_id, fake_id from musehub.auth.dependencies import require_valid_token from musehub.auth.request_signing import MSignContext from musehub.config import get_settings from musehub.core.genesis import compute_identity_id import typing from collections.abc import AsyncGenerator from unittest.mock import MagicMock from musehub.db.database import get_db from musehub.db.musehub_repo_models import MusehubRepo from musehub.main import app from musehub.services.musehub_repository import create_repo logger = logging.getLogger(__name__) _OWNER = "gabriel" _IDENTITY_ID = compute_identity_id(b"gabriel") _REPO_NAME = "step3-e2e-test" _MPACK_BYTES = build_wire_mpack({"objects": [], "commits": [], "snapshots": []}) _MPACK_KEY = blob_id(_MPACK_BYTES) _HEAD = fake_id("step3-tip-commit") _AUTH_CTX = MSignContext( handle=_OWNER, identity_id=_IDENTITY_ID, is_agent=False, is_admin=False, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture() async def client(db_session: AsyncSession) -> None: async def _override_db() -> None: yield db_session app.dependency_overrides[get_db] = _override_db app.dependency_overrides[require_valid_token] = lambda: _AUTH_CTX async with AsyncClient( transport=ASGITransport(app=app), base_url="https://localhost:1337", ) as c: yield c app.dependency_overrides.clear() @pytest_asyncio.fixture() async def repo(db_session: AsyncSession) -> MusehubRepo: r = await create_repo( db_session, name=_REPO_NAME, owner=_OWNER, owner_user_id=_IDENTITY_ID, visibility="public", initialize=False, ) await db_session.commit() return r @pytest_asyncio.fixture(autouse=True) async def mock_get_mpack() -> AsyncGenerator[MagicMock, None]: """Stub MinIO get_mpack so tests don't need a live object store.""" from unittest.mock import AsyncMock, MagicMock, patch mock_backend = MagicMock() mock_backend.get_mpack = AsyncMock(return_value=_MPACK_BYTES) with patch("musehub.services.musehub_wire.get_backend", return_value=mock_backend), \ patch("musehub.services.musehub_wire_push.get_backend", return_value=mock_backend), \ patch("musehub.storage.backends.get_backend", return_value=mock_backend), \ patch("musehub.storage.get_backend", return_value=mock_backend): yield mock_backend def _unpack_body( mpack_key: str = _MPACK_KEY, branch: str = "main", head: str = "", commits_count: int = 2, objects_count: int = 5, ) -> bytes: payload = { "mpack_key": mpack_key, "branch": branch, "head": head, "commits_count": commits_count, "blobs_count": objects_count, } logger.info( "[step3] CLIENT: payload mpack_key=%s branch=%s commits=%d objects=%d", mpack_key[:27], branch, commits_count, objects_count, ) return msgpack.packb(payload, use_bin_type=True) # --------------------------------------------------------------------------- # S3E2E-1 — Happy path # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_s3e2e1_happy_path_returns_all_fields( client: AsyncClient, repo: MusehubRepo, ) -> None: """Full Step 3 happy path: valid payload → 200, all five response fields.""" logger.info("[step3] CLIENT: POST /%s/%s/push/unpack-mpack", _OWNER, _REPO_NAME) resp = await client.post( f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) logger.info("[step3] SERVER: responded HTTP %d", resp.status_code) assert resp.status_code == 200, f"expected 200, got {resp.status_code}: {resp.text}" data = resp.json() logger.info("[step3] SERVER: response keys: %s", list(data.keys())) logger.info("[step3] ASSERT: head echoed back") assert "head" in data logger.info("[step3] ASSERT: branch echoed back") assert data.get("branch") == "main" logger.info("[step3] ASSERT: objects_in_mpack echoed back") assert data.get("blobs_in_mpack") == 5 logger.info("[step3] ASSERT: commits_in_mpack echoed back") assert data.get("commits_in_mpack") == 2 # --------------------------------------------------------------------------- # S3E2E-2 — Missing mpack_key → 422 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_s3e2e2_missing_mpack_key_returns_422( client: AsyncClient, repo: MusehubRepo, ) -> None: """Server validates mpack_key present → 422 when absent.""" body = msgpack.packb({"branch": "main", "commits_count": 1}, use_bin_type=True) logger.info("[step3] CLIENT: sending body WITHOUT mpack_key") resp = await client.post( f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack", content=body, headers={"Content-Type": "application/x-msgpack"}, ) logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code) assert resp.status_code == 422, f"expected 422, got {resp.status_code}" # --------------------------------------------------------------------------- # S3E2E-3 — commits_count exceeds limit → 422 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_s3e2e3_excessive_commits_count_returns_422( client: AsyncClient, repo: MusehubRepo, ) -> None: """Server rejects commits_count > mpack_max_commits with 422.""" settings = get_settings() over_limit = settings.mpack_max_commits + 1 logger.info( "[step3] CLIENT: commits_count=%d (limit=%d)", over_limit, settings.mpack_max_commits, ) resp = await client.post( f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack", content=_unpack_body(commits_count=over_limit), headers={"Content-Type": "application/x-msgpack"}, ) logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code) assert resp.status_code == 422, f"expected 422, got {resp.status_code}" # --------------------------------------------------------------------------- # S3E2E-4 — objects_count exceeds limit → 422 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_s3e2e4_excessive_objects_count_returns_422( client: AsyncClient, repo: MusehubRepo, ) -> None: """Server rejects objects_count > mpack_max_objects with 422.""" settings = get_settings() over_limit = settings.mpack_max_objects + 1 logger.info( "[step3] CLIENT: objects_count=%d (limit=%d)", over_limit, settings.mpack_max_objects, ) resp = await client.post( f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack", content=_unpack_body(objects_count=over_limit), headers={"Content-Type": "application/x-msgpack"}, ) logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code) assert resp.status_code == 422, f"expected 422, got {resp.status_code}" # --------------------------------------------------------------------------- # S3E2E-5 — MinIO returns nothing → 422 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_s3e2e5_mpack_not_in_minio_returns_422( client: AsyncClient, repo: MusehubRepo, mock_get_mpack: MagicMock, ) -> None: """When MinIO returns None for mpack_key, server returns 422.""" from unittest.mock import AsyncMock mock_get_mpack.get_mpack = AsyncMock(return_value=None) logger.info("[step3] SETUP: MinIO stub returns None (mpack not found)") resp = await client.post( f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code) assert resp.status_code == 422, f"expected 422, got {resp.status_code}" # --------------------------------------------------------------------------- # S3E2E-6 — sha256 mismatch → 422 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_s3e2e6_sha256_mismatch_returns_422( client: AsyncClient, repo: MusehubRepo, mock_get_mpack: MagicMock, ) -> None: """When MinIO bytes don't match mpack_key sha256, server returns 422.""" # Valid key but MinIO returns different bytes — integrity check fails tampered_bytes = b"tampered-mpack-bytes-that-do-not-match" from unittest.mock import AsyncMock mock_get_mpack.get_mpack = AsyncMock(return_value=tampered_bytes) logger.info("[step3] SETUP: MinIO returns tampered bytes (sha256 mismatch)") resp = await client.post( f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) logger.info("[step3] SERVER: responded HTTP %d (expected 422)", resp.status_code) assert resp.status_code == 422, f"expected 422, got {resp.status_code}" # --------------------------------------------------------------------------- # S3E2E-7 — Unauthenticated request → 401/403 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_s3e2e7_unauthenticated_returns_401_or_403( db_session: AsyncSession, repo: MusehubRepo, ) -> None: """Without auth override, missing credentials → 401 or 403.""" async def _override_db() -> None: yield db_session app.dependency_overrides[get_db] = _override_db # do NOT override require_valid_token — real auth enforcement logger.info("[step3] CLIENT: POST unpack-mpack with NO auth header") async with AsyncClient( transport=ASGITransport(app=app), base_url="https://localhost:1337", ) as c: resp = await c.post( f"/{_OWNER}/{_REPO_NAME}/push/unpack-mpack", content=_unpack_body(), headers={"Content-Type": "application/x-msgpack"}, ) logger.info("[step3] SERVER: responded HTTP %d (expected 401 or 403)", resp.status_code) assert resp.status_code in (401, 403), ( f"expected 401 or 403 for unauthenticated request, got {resp.status_code}" ) app.dependency_overrides.clear()