"""musehub#228 — repair-commit (and repair-object/repair-snapshot) need their own size-limit tier, distinct from the generic 10 MB API cap and the 500 MB push cap. Background: repair-commit's payload is a single WireCommit record (commit metadata + structured_delta), not a full mpack. It was reasonably assumed to be small when ContentSizeLimitMiddleware was written, but a commit with an unusually large structured diff (observed: 15.6 MB on a real bulk-refactor commit, gabriel/muse sha256:2f17e90958a3a39216322972274314b4c495ed995ea0edf4d24a777d171226f7) breaks that assumption and gets rejected with 413 before the handler ever runs. Phase 1 audit found repair-object and repair-snapshot share the exact same exposure — repair-object carries arbitrary raw object bytes (fundamentally unbounded, same risk profile as a push), repair-snapshot carries a manifest that can exceed 10 MB for a repo with enough tracked files. Design (confirmed with gabriel): three tiers. PUSH_MAX_BYTES (500 MB) — push/*, repair-object (raw bytes, push-scale) REPAIR_MAX_BYTES (100 MB) — repair-commit, repair-snapshot (metadata, ~6x headroom over the observed 15.6 MB worst case) API_MAX_BYTES ( 10 MB) — everything else, unchanged """ from __future__ import annotations from httpx import AsyncClient from musehub.middleware.content_size import API_MAX_BYTES, PUSH_MAX_BYTES, REPAIR_MAX_BYTES def test_limits_are_correct_values() -> None: assert API_MAX_BYTES == 10 * 1024 * 1024 assert REPAIR_MAX_BYTES == 100 * 1024 * 1024 assert PUSH_MAX_BYTES == 500 * 1024 * 1024 assert API_MAX_BYTES < REPAIR_MAX_BYTES < PUSH_MAX_BYTES # --------------------------------------------------------------------------- # repair-commit — the ticket's reproduction case # --------------------------------------------------------------------------- async def test_repair_commit_15_6mb_structured_delta_not_rejected_by_middleware( client: AsyncClient, ) -> None: """The exact real-world case: a commit whose structured_delta is ~15.6 MB — well over the old 10 MB API cap, comfortably under the new 100 MB repair tier — must not be rejected at the middleware layer. """ observed_size = int(15.6 * 1024 * 1024) resp = await client.post( "/gabriel/muse/repair-commit", headers={"Content-Length": str(observed_size)}, content=b"", ) # No auth / no real body → 401 or 422 from the handler, but must get PAST # the middleware — never 413. assert resp.status_code != 413 async def test_repair_commit_over_api_limit_but_under_repair_limit_allowed( client: AsyncClient, ) -> None: """20 MB on repair-commit (over the old 10 MB API cap) must not 413.""" over_api_limit = API_MAX_BYTES * 2 resp = await client.post( "/gabriel/muse/repair-commit", headers={"Content-Length": str(over_api_limit)}, content=b"", ) assert resp.status_code != 413 async def test_repair_commit_over_repair_limit_still_rejected( client: AsyncClient, ) -> None: """The ceiling isn't removed entirely — a payload genuinely over 100 MB on repair-commit must still get 413.""" over_repair_limit = REPAIR_MAX_BYTES + 1 resp = await client.post( "/gabriel/muse/repair-commit", headers={"Content-Length": str(over_repair_limit)}, content=b"", ) assert resp.status_code == 413 assert "too large" in resp.json()["detail"].lower() # --------------------------------------------------------------------------- # repair-snapshot — same tier, found during the Phase 1 audit # --------------------------------------------------------------------------- async def test_repair_snapshot_over_api_limit_but_under_repair_limit_allowed( client: AsyncClient, ) -> None: over_api_limit = API_MAX_BYTES * 2 resp = await client.post( "/gabriel/muse/repair-snapshot", headers={"Content-Length": str(over_api_limit)}, content=b"", ) assert resp.status_code != 413 async def test_repair_snapshot_over_repair_limit_still_rejected( client: AsyncClient, ) -> None: over_repair_limit = REPAIR_MAX_BYTES + 1 resp = await client.post( "/gabriel/muse/repair-snapshot", headers={"Content-Length": str(over_repair_limit)}, content=b"", ) assert resp.status_code == 413 # --------------------------------------------------------------------------- # repair-object — joins the PUSH tier (raw bytes, push-scale), not REPAIR # --------------------------------------------------------------------------- async def test_repair_object_over_repair_limit_but_under_push_limit_allowed( client: AsyncClient, ) -> None: """repair-object carries raw object bytes — it must use the 500 MB push tier, not the 100 MB repair-metadata tier. A 150 MB payload (over REPAIR_MAX_BYTES, under PUSH_MAX_BYTES) must not 413.""" over_repair_limit = REPAIR_MAX_BYTES + (50 * 1024 * 1024) resp = await client.post( "/gabriel/muse/repair-object", headers={"Content-Length": str(over_repair_limit)}, content=b"", ) assert resp.status_code != 413 async def test_repair_object_over_push_limit_still_rejected( client: AsyncClient, ) -> None: over_push_limit = PUSH_MAX_BYTES + 1 resp = await client.post( "/gabriel/muse/repair-object", headers={"Content-Length": str(over_push_limit)}, content=b"", ) assert resp.status_code == 413