test_wire_mpack_presign_step1_e2e.py
python
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠ breaking
20 days ago
| 1 | """E2E — Push Protocol Step 1: POST /{owner}/{slug}/push/mpack-presign. |
| 2 | |
| 3 | Exercises the entire Step 1 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 computes mpack_id = "sha256:" + sha256(mpack_bytes).hexdigest() |
| 10 | Client sends: { mpack_key: mpack_id, size_bytes: len(mpack_bytes) } |
| 11 | |
| 12 | Server: |
| 13 | authenticate(request) # MSign → claims.identity_id, claims.handle |
| 14 | validate: |
| 15 | mpack_key present → 422 if missing |
| 16 | size_bytes ≤ mpack_max_bytes → 413 if exceeded |
| 17 | if mpack_daily_upload_limit_bytes > 0: |
| 18 | today_bytes = SUM(musehub_daily_push_bytes WHERE identity_id=me AND date=today) |
| 19 | if today_bytes >= daily_limit → 429 |
| 20 | INSERT INTO musehub_daily_push_bytes (...) |
| 21 | COMMIT |
| 22 | upload_url = MinIO.presign_put("mpacks/" + mpack_key, ttl=3600s) |
| 23 | respond: { upload_url, mpack_key } |
| 24 | |
| 25 | Tests |
| 26 | ----- |
| 27 | S1E2E-1 Happy path: valid payload → 200, upload_url and mpack_key in response. |
| 28 | S1E2E-2 Missing mpack_key → 422. |
| 29 | S1E2E-3 size_bytes exceeds mpack_max_bytes → 413. |
| 30 | S1E2E-4 Daily quota exhausted → 429. |
| 31 | S1E2E-5 Quota row written to musehub_daily_push_bytes after successful presign. |
| 32 | S1E2E-6 Quota is cumulative: second presign adds to existing daily row. |
| 33 | S1E2E-7 Unauthenticated request → 401/403. |
| 34 | """ |
| 35 | from __future__ import annotations |
| 36 | |
| 37 | import datetime |
| 38 | import logging |
| 39 | |
| 40 | import msgpack |
| 41 | import pytest |
| 42 | import pytest_asyncio |
| 43 | from httpx import AsyncClient, ASGITransport |
| 44 | from sqlalchemy import select, func |
| 45 | from sqlalchemy.ext.asyncio import AsyncSession |
| 46 | |
| 47 | from muse.core.mpack import build_presign_payload |
| 48 | from muse.core.types import blob_id |
| 49 | from musehub.auth.dependencies import require_valid_token |
| 50 | from musehub.auth.request_signing import MSignContext |
| 51 | from musehub.config import get_settings |
| 52 | from musehub.db.database import get_db |
| 53 | from musehub.db.musehub_abuse_models import MusehubDailyPushBytes |
| 54 | from musehub.main import app |
| 55 | from musehub.services.musehub_repository import create_repo |
| 56 | from musehub.core.genesis import compute_identity_id |
| 57 | |
| 58 | logger = logging.getLogger(__name__) |
| 59 | |
| 60 | _FAKE_UPLOAD_URL = "https://minio.example.com/mpacks/sha256:fake?sig=presigned" |
| 61 | _OWNER = "gabriel" |
| 62 | _IDENTITY_ID = compute_identity_id(b"gabriel") |
| 63 | _REPO_NAME = "step1-e2e-test" |
| 64 | |
| 65 | _AUTH_CTX = MSignContext( |
| 66 | handle=_OWNER, |
| 67 | identity_id=_IDENTITY_ID, |
| 68 | is_agent=False, |
| 69 | is_admin=False, |
| 70 | ) |
| 71 | |
| 72 | |
| 73 | # --------------------------------------------------------------------------- |
| 74 | # Fixtures |
| 75 | # --------------------------------------------------------------------------- |
| 76 | |
| 77 | @pytest_asyncio.fixture() |
| 78 | async def client(db_session: AsyncSession) -> None: |
| 79 | async def _override_db() -> None: |
| 80 | yield db_session |
| 81 | |
| 82 | app.dependency_overrides[get_db] = _override_db |
| 83 | app.dependency_overrides[require_valid_token] = lambda: _AUTH_CTX |
| 84 | |
| 85 | async with AsyncClient( |
| 86 | transport=ASGITransport(app=app), |
| 87 | base_url="https://localhost:1337", |
| 88 | ) as c: |
| 89 | yield c |
| 90 | |
| 91 | app.dependency_overrides.clear() |
| 92 | |
| 93 | |
| 94 | @pytest_asyncio.fixture() |
| 95 | async def repo(db_session: AsyncSession) -> MusehubRepo: |
| 96 | r = await create_repo( |
| 97 | db_session, |
| 98 | name=_REPO_NAME, |
| 99 | owner=_OWNER, |
| 100 | owner_user_id=_IDENTITY_ID, |
| 101 | visibility="public", |
| 102 | initialize=False, |
| 103 | ) |
| 104 | await db_session.commit() |
| 105 | return r |
| 106 | |
| 107 | |
| 108 | @pytest_asyncio.fixture(autouse=True) |
| 109 | async def mock_presign_put() -> None: |
| 110 | """Stub MinIO presign_put so tests don't need a live object store.""" |
| 111 | from unittest.mock import AsyncMock, MagicMock, patch |
| 112 | |
| 113 | mock_backend = MagicMock() |
| 114 | mock_backend.presign_mpack_put = AsyncMock(return_value=_FAKE_UPLOAD_URL) |
| 115 | with patch("musehub.services.musehub_wire.get_backend", return_value=mock_backend), \ |
| 116 | patch("musehub.services.musehub_wire_push.get_backend", return_value=mock_backend): |
| 117 | yield mock_backend |
| 118 | |
| 119 | |
| 120 | def _presign_body(mpack_bytes: bytes) -> bytes: |
| 121 | payload = build_presign_payload(mpack_bytes) |
| 122 | logger.info( |
| 123 | "[step1] CLIENT: computed mpack_key=%s size_bytes=%d", |
| 124 | payload["mpack_key"][:27], payload["size_bytes"], |
| 125 | ) |
| 126 | return msgpack.packb(payload, use_bin_type=True) |
| 127 | |
| 128 | |
| 129 | # --------------------------------------------------------------------------- |
| 130 | # S1E2E-1 — Happy path |
| 131 | # --------------------------------------------------------------------------- |
| 132 | |
| 133 | @pytest.mark.asyncio |
| 134 | async def test_s1e2e1_happy_path(client: AsyncClient, repo: MusehubRepo) -> None: |
| 135 | """Full Step 1 happy path: valid payload → 200, upload_url + mpack_key returned.""" |
| 136 | mpack_bytes = b"commits+snapshots+objects" * 100 |
| 137 | expected_key = blob_id(mpack_bytes) |
| 138 | |
| 139 | logger.info("[step1] CLIENT: building presign payload") |
| 140 | body = _presign_body(mpack_bytes) |
| 141 | |
| 142 | logger.info("[step1] CLIENT: POST /%s/%s/push/mpack-presign", _OWNER, _REPO_NAME) |
| 143 | resp = await client.post( |
| 144 | f"/{_OWNER}/{_REPO_NAME}/push/mpack-presign", |
| 145 | content=body, |
| 146 | headers={"Content-Type": "application/x-msgpack"}, |
| 147 | ) |
| 148 | logger.info("[step1] SERVER: responded HTTP %d", resp.status_code) |
| 149 | assert resp.status_code == 200, f"expected 200, got {resp.status_code}: {resp.text}" |
| 150 | |
| 151 | data = resp.json() |
| 152 | logger.info("[step1] SERVER: upload_url=%s mpack_key=%s", str(data.get("upload_url", ""))[:40], str(data.get("mpack_key", ""))[:27]) |
| 153 | |
| 154 | logger.info("[step1] ASSERT: upload_url is present and non-empty") |
| 155 | assert data.get("upload_url"), "upload_url missing from response" |
| 156 | |
| 157 | logger.info("[step1] ASSERT: mpack_key echoed back matches client-computed key") |
| 158 | assert data["mpack_key"] == expected_key |
| 159 | |
| 160 | |
| 161 | # --------------------------------------------------------------------------- |
| 162 | # S1E2E-2 — Missing mpack_key → 422 |
| 163 | # --------------------------------------------------------------------------- |
| 164 | |
| 165 | @pytest.mark.asyncio |
| 166 | async def test_s1e2e2_missing_mpack_key_returns_422(client: AsyncClient, repo: MusehubRepo) -> None: |
| 167 | """Server validates mpack_key present → 422 when absent.""" |
| 168 | body = msgpack.packb({"size_bytes": 1024}, use_bin_type=True) |
| 169 | |
| 170 | logger.info("[step1] CLIENT: sending body WITHOUT mpack_key") |
| 171 | resp = await client.post( |
| 172 | f"/{_OWNER}/{_REPO_NAME}/push/mpack-presign", |
| 173 | content=body, |
| 174 | headers={"Content-Type": "application/x-msgpack"}, |
| 175 | ) |
| 176 | logger.info("[step1] SERVER: responded HTTP %d (expected 422)", resp.status_code) |
| 177 | assert resp.status_code == 422, f"expected 422, got {resp.status_code}" |
| 178 | |
| 179 | |
| 180 | # --------------------------------------------------------------------------- |
| 181 | # S1E2E-3 — size_bytes exceeds limit → 413 |
| 182 | # --------------------------------------------------------------------------- |
| 183 | |
| 184 | @pytest.mark.asyncio |
| 185 | async def test_s1e2e3_oversized_payload_returns_413(client: AsyncClient, repo: MusehubRepo) -> None: |
| 186 | """Server rejects size_bytes > mpack_max_bytes with 413.""" |
| 187 | settings = get_settings() |
| 188 | over_limit = settings.mpack_max_bytes + 1 |
| 189 | |
| 190 | mpack_bytes = b"x" |
| 191 | payload = {"mpack_key": blob_id(mpack_bytes), "size_bytes": over_limit} |
| 192 | body = msgpack.packb(payload, use_bin_type=True) |
| 193 | |
| 194 | logger.info( |
| 195 | "[step1] CLIENT: sending size_bytes=%d (limit=%d)", |
| 196 | over_limit, settings.mpack_max_bytes, |
| 197 | ) |
| 198 | resp = await client.post( |
| 199 | f"/{_OWNER}/{_REPO_NAME}/push/mpack-presign", |
| 200 | content=body, |
| 201 | headers={"Content-Type": "application/x-msgpack"}, |
| 202 | ) |
| 203 | logger.info("[step1] SERVER: responded HTTP %d (expected 413)", resp.status_code) |
| 204 | assert resp.status_code == 413, f"expected 413, got {resp.status_code}" |
| 205 | |
| 206 | |
| 207 | # --------------------------------------------------------------------------- |
| 208 | # S1E2E-4 — Daily quota exhausted → 429 |
| 209 | # --------------------------------------------------------------------------- |
| 210 | |
| 211 | @pytest.mark.asyncio |
| 212 | async def test_s1e2e4_daily_quota_exhausted_returns_429( |
| 213 | client: AsyncClient, repo: MusehubRepo, db_session: AsyncSession |
| 214 | ) -> None: |
| 215 | """When today_bytes >= daily_limit, server returns 429 before presigning.""" |
| 216 | settings = get_settings() |
| 217 | if settings.mpack_daily_upload_limit_bytes <= 0: |
| 218 | pytest.skip("daily quota disabled in this environment") |
| 219 | |
| 220 | today = datetime.date.today() |
| 221 | logger.info( |
| 222 | "[step1] SETUP: seeding daily_push_bytes row at limit (%d bytes)", |
| 223 | settings.mpack_daily_upload_limit_bytes, |
| 224 | ) |
| 225 | from sqlalchemy.dialects.postgresql import insert as pg_insert |
| 226 | await db_session.execute( |
| 227 | pg_insert(MusehubDailyPushBytes).values( |
| 228 | identity_id=_IDENTITY_ID, |
| 229 | date=today, |
| 230 | bytes_uploaded=settings.mpack_daily_upload_limit_bytes, |
| 231 | updated_at=datetime.datetime.now(datetime.timezone.utc), |
| 232 | ).on_conflict_do_update( |
| 233 | index_elements=["identity_id", "date"], |
| 234 | set_={"bytes_uploaded": settings.mpack_daily_upload_limit_bytes}, |
| 235 | ) |
| 236 | ) |
| 237 | await db_session.commit() |
| 238 | |
| 239 | mpack_bytes = b"small mpack" |
| 240 | body = _presign_body(mpack_bytes) |
| 241 | |
| 242 | logger.info("[step1] CLIENT: POST presign after quota exhausted") |
| 243 | resp = await client.post( |
| 244 | f"/{_OWNER}/{_REPO_NAME}/push/mpack-presign", |
| 245 | content=body, |
| 246 | headers={"Content-Type": "application/x-msgpack"}, |
| 247 | ) |
| 248 | logger.info("[step1] SERVER: responded HTTP %d (expected 429)", resp.status_code) |
| 249 | assert resp.status_code == 429, f"expected 429, got {resp.status_code}" |
| 250 | |
| 251 | |
| 252 | # --------------------------------------------------------------------------- |
| 253 | # S1E2E-5 — Quota row written after successful presign |
| 254 | # --------------------------------------------------------------------------- |
| 255 | |
| 256 | @pytest.mark.asyncio |
| 257 | async def test_s1e2e5_quota_row_written_after_presign( |
| 258 | client: AsyncClient, repo: MusehubRepo, db_session: AsyncSession |
| 259 | ) -> None: |
| 260 | """After a successful presign, musehub_daily_push_bytes has a row for today.""" |
| 261 | settings = get_settings() |
| 262 | if settings.mpack_daily_upload_limit_bytes <= 0: |
| 263 | pytest.skip("daily quota disabled in this environment") |
| 264 | |
| 265 | mpack_bytes = b"x" * 2048 |
| 266 | body = _presign_body(mpack_bytes) |
| 267 | |
| 268 | logger.info("[step1] CLIENT: POST presign (quota tracking enabled)") |
| 269 | resp = await client.post( |
| 270 | f"/{_OWNER}/{_REPO_NAME}/push/mpack-presign", |
| 271 | content=body, |
| 272 | headers={"Content-Type": "application/x-msgpack"}, |
| 273 | ) |
| 274 | logger.info("[step1] SERVER: responded HTTP %d", resp.status_code) |
| 275 | assert resp.status_code == 200 |
| 276 | |
| 277 | logger.info("[step1] DB: querying musehub_daily_push_bytes for identity_id=%s", _IDENTITY_ID[:20]) |
| 278 | today = datetime.date.today() |
| 279 | result = await db_session.execute( |
| 280 | select(func.coalesce(func.sum(MusehubDailyPushBytes.bytes_uploaded), 0)).where( |
| 281 | MusehubDailyPushBytes.identity_id == _IDENTITY_ID, |
| 282 | MusehubDailyPushBytes.date == today, |
| 283 | ) |
| 284 | ) |
| 285 | recorded = int(result.scalar()) |
| 286 | logger.info("[step1] DB: bytes_uploaded=%d (expected=%d)", recorded, len(mpack_bytes)) |
| 287 | assert recorded == len(mpack_bytes), ( |
| 288 | f"quota row not written correctly: got {recorded}, expected {len(mpack_bytes)}" |
| 289 | ) |
| 290 | |
| 291 | |
| 292 | # --------------------------------------------------------------------------- |
| 293 | # S1E2E-6 — Quota is cumulative across calls |
| 294 | # --------------------------------------------------------------------------- |
| 295 | |
| 296 | @pytest.mark.asyncio |
| 297 | async def test_s1e2e6_quota_is_cumulative( |
| 298 | client: AsyncClient, repo: MusehubRepo, db_session: AsyncSession |
| 299 | ) -> None: |
| 300 | """Second presign call adds to the existing daily row — does not reset it.""" |
| 301 | settings = get_settings() |
| 302 | if settings.mpack_daily_upload_limit_bytes <= 0: |
| 303 | pytest.skip("daily quota disabled in this environment") |
| 304 | |
| 305 | mpack_a = b"a" * 1000 |
| 306 | mpack_b = b"b" * 500 |
| 307 | |
| 308 | logger.info("[step1] CLIENT: first presign (%d bytes)", len(mpack_a)) |
| 309 | resp_a = await client.post( |
| 310 | f"/{_OWNER}/{_REPO_NAME}/push/mpack-presign", |
| 311 | content=_presign_body(mpack_a), |
| 312 | headers={"Content-Type": "application/x-msgpack"}, |
| 313 | ) |
| 314 | logger.info("[step1] SERVER: first presign → HTTP %d", resp_a.status_code) |
| 315 | assert resp_a.status_code == 200 |
| 316 | |
| 317 | logger.info("[step1] CLIENT: second presign (%d bytes)", len(mpack_b)) |
| 318 | resp_b = await client.post( |
| 319 | f"/{_OWNER}/{_REPO_NAME}/push/mpack-presign", |
| 320 | content=_presign_body(mpack_b), |
| 321 | headers={"Content-Type": "application/x-msgpack"}, |
| 322 | ) |
| 323 | logger.info("[step1] SERVER: second presign → HTTP %d", resp_b.status_code) |
| 324 | assert resp_b.status_code == 200 |
| 325 | |
| 326 | today = datetime.date.today() |
| 327 | result = await db_session.execute( |
| 328 | select(func.coalesce(func.sum(MusehubDailyPushBytes.bytes_uploaded), 0)).where( |
| 329 | MusehubDailyPushBytes.identity_id == _IDENTITY_ID, |
| 330 | MusehubDailyPushBytes.date == today, |
| 331 | ) |
| 332 | ) |
| 333 | recorded = int(result.scalar()) |
| 334 | expected = len(mpack_a) + len(mpack_b) |
| 335 | logger.info("[step1] DB: cumulative bytes_uploaded=%d (expected=%d)", recorded, expected) |
| 336 | assert recorded == expected, f"cumulative quota wrong: got {recorded}, expected {expected}" |
| 337 | |
| 338 | |
| 339 | # --------------------------------------------------------------------------- |
| 340 | # S1E2E-7 — Unauthenticated request → 401/403 |
| 341 | # --------------------------------------------------------------------------- |
| 342 | |
| 343 | @pytest.mark.asyncio |
| 344 | async def test_s1e2e7_unauthenticated_returns_401_or_403( |
| 345 | db_session: AsyncSession, repo: MusehubRepo |
| 346 | ) -> None: |
| 347 | """Without auth override, missing credentials → 401 or 403.""" |
| 348 | async def _override_db() -> None: |
| 349 | yield db_session |
| 350 | |
| 351 | app.dependency_overrides[get_db] = _override_db |
| 352 | # do NOT override require_valid_token — real auth enforcement |
| 353 | |
| 354 | mpack_bytes = b"should be rejected" |
| 355 | body = _presign_body(mpack_bytes) |
| 356 | |
| 357 | logger.info("[step1] CLIENT: POST presign with NO auth header") |
| 358 | async with AsyncClient( |
| 359 | transport=ASGITransport(app=app), |
| 360 | base_url="https://localhost:1337", |
| 361 | ) as c: |
| 362 | resp = await c.post( |
| 363 | f"/{_OWNER}/{_REPO_NAME}/push/mpack-presign", |
| 364 | content=body, |
| 365 | headers={"Content-Type": "application/x-msgpack"}, |
| 366 | ) |
| 367 | |
| 368 | logger.info("[step1] SERVER: responded HTTP %d (expected 401 or 403)", resp.status_code) |
| 369 | assert resp.status_code in (401, 403), ( |
| 370 | f"expected 401 or 403 for unauthenticated request, got {resp.status_code}" |
| 371 | ) |
| 372 | app.dependency_overrides.clear() |
File History
1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
20 days ago