test_repair_commit_body_size_limit.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """musehub#228 β repair-commit (and repair-object/repair-snapshot) need their |
| 2 | own size-limit tier, distinct from the generic 10 MB API cap and the 500 MB |
| 3 | push cap. |
| 4 | |
| 5 | Background: repair-commit's payload is a single WireCommit record (commit |
| 6 | metadata + structured_delta), not a full mpack. It was reasonably assumed to |
| 7 | be small when ContentSizeLimitMiddleware was written, but a commit with an |
| 8 | unusually large structured diff (observed: 15.6 MB on a real bulk-refactor |
| 9 | commit, gabriel/muse sha256:2f17e90958a3a39216322972274314b4c495ed995ea0edf4d24a777d171226f7) |
| 10 | breaks that assumption and gets rejected with 413 before the handler ever runs. |
| 11 | |
| 12 | Phase 1 audit found repair-object and repair-snapshot share the exact same |
| 13 | exposure β repair-object carries arbitrary raw object bytes (fundamentally |
| 14 | unbounded, same risk profile as a push), repair-snapshot carries a manifest |
| 15 | that can exceed 10 MB for a repo with enough tracked files. |
| 16 | |
| 17 | Design (confirmed with gabriel): three tiers. |
| 18 | PUSH_MAX_BYTES (500 MB) β push/*, repair-object (raw bytes, push-scale) |
| 19 | REPAIR_MAX_BYTES (100 MB) β repair-commit, repair-snapshot (metadata, ~6x |
| 20 | headroom over the observed 15.6 MB worst case) |
| 21 | API_MAX_BYTES ( 10 MB) β everything else, unchanged |
| 22 | """ |
| 23 | from __future__ import annotations |
| 24 | |
| 25 | from httpx import AsyncClient |
| 26 | |
| 27 | from musehub.middleware.content_size import API_MAX_BYTES, PUSH_MAX_BYTES, REPAIR_MAX_BYTES |
| 28 | |
| 29 | |
| 30 | def test_limits_are_correct_values() -> None: |
| 31 | assert API_MAX_BYTES == 10 * 1024 * 1024 |
| 32 | assert REPAIR_MAX_BYTES == 100 * 1024 * 1024 |
| 33 | assert PUSH_MAX_BYTES == 500 * 1024 * 1024 |
| 34 | assert API_MAX_BYTES < REPAIR_MAX_BYTES < PUSH_MAX_BYTES |
| 35 | |
| 36 | |
| 37 | # --------------------------------------------------------------------------- |
| 38 | # repair-commit β the ticket's reproduction case |
| 39 | # --------------------------------------------------------------------------- |
| 40 | |
| 41 | |
| 42 | async def test_repair_commit_15_6mb_structured_delta_not_rejected_by_middleware( |
| 43 | client: AsyncClient, |
| 44 | ) -> None: |
| 45 | """The exact real-world case: a commit whose structured_delta is ~15.6 MB |
| 46 | β well over the old 10 MB API cap, comfortably under the new 100 MB |
| 47 | repair tier β must not be rejected at the middleware layer. |
| 48 | """ |
| 49 | observed_size = int(15.6 * 1024 * 1024) |
| 50 | resp = await client.post( |
| 51 | "/gabriel/muse/repair-commit", |
| 52 | headers={"Content-Length": str(observed_size)}, |
| 53 | content=b"", |
| 54 | ) |
| 55 | # No auth / no real body β 401 or 422 from the handler, but must get PAST |
| 56 | # the middleware β never 413. |
| 57 | assert resp.status_code != 413 |
| 58 | |
| 59 | |
| 60 | async def test_repair_commit_over_api_limit_but_under_repair_limit_allowed( |
| 61 | client: AsyncClient, |
| 62 | ) -> None: |
| 63 | """20 MB on repair-commit (over the old 10 MB API cap) must not 413.""" |
| 64 | over_api_limit = API_MAX_BYTES * 2 |
| 65 | resp = await client.post( |
| 66 | "/gabriel/muse/repair-commit", |
| 67 | headers={"Content-Length": str(over_api_limit)}, |
| 68 | content=b"", |
| 69 | ) |
| 70 | assert resp.status_code != 413 |
| 71 | |
| 72 | |
| 73 | async def test_repair_commit_over_repair_limit_still_rejected( |
| 74 | client: AsyncClient, |
| 75 | ) -> None: |
| 76 | """The ceiling isn't removed entirely β a payload genuinely over 100 MB |
| 77 | on repair-commit must still get 413.""" |
| 78 | over_repair_limit = REPAIR_MAX_BYTES + 1 |
| 79 | resp = await client.post( |
| 80 | "/gabriel/muse/repair-commit", |
| 81 | headers={"Content-Length": str(over_repair_limit)}, |
| 82 | content=b"", |
| 83 | ) |
| 84 | assert resp.status_code == 413 |
| 85 | assert "too large" in resp.json()["detail"].lower() |
| 86 | |
| 87 | |
| 88 | # --------------------------------------------------------------------------- |
| 89 | # repair-snapshot β same tier, found during the Phase 1 audit |
| 90 | # --------------------------------------------------------------------------- |
| 91 | |
| 92 | |
| 93 | async def test_repair_snapshot_over_api_limit_but_under_repair_limit_allowed( |
| 94 | client: AsyncClient, |
| 95 | ) -> None: |
| 96 | over_api_limit = API_MAX_BYTES * 2 |
| 97 | resp = await client.post( |
| 98 | "/gabriel/muse/repair-snapshot", |
| 99 | headers={"Content-Length": str(over_api_limit)}, |
| 100 | content=b"", |
| 101 | ) |
| 102 | assert resp.status_code != 413 |
| 103 | |
| 104 | |
| 105 | async def test_repair_snapshot_over_repair_limit_still_rejected( |
| 106 | client: AsyncClient, |
| 107 | ) -> None: |
| 108 | over_repair_limit = REPAIR_MAX_BYTES + 1 |
| 109 | resp = await client.post( |
| 110 | "/gabriel/muse/repair-snapshot", |
| 111 | headers={"Content-Length": str(over_repair_limit)}, |
| 112 | content=b"", |
| 113 | ) |
| 114 | assert resp.status_code == 413 |
| 115 | |
| 116 | |
| 117 | # --------------------------------------------------------------------------- |
| 118 | # repair-object β joins the PUSH tier (raw bytes, push-scale), not REPAIR |
| 119 | # --------------------------------------------------------------------------- |
| 120 | |
| 121 | |
| 122 | async def test_repair_object_over_repair_limit_but_under_push_limit_allowed( |
| 123 | client: AsyncClient, |
| 124 | ) -> None: |
| 125 | """repair-object carries raw object bytes β it must use the 500 MB push |
| 126 | tier, not the 100 MB repair-metadata tier. A 150 MB payload (over |
| 127 | REPAIR_MAX_BYTES, under PUSH_MAX_BYTES) must not 413.""" |
| 128 | over_repair_limit = REPAIR_MAX_BYTES + (50 * 1024 * 1024) |
| 129 | resp = await client.post( |
| 130 | "/gabriel/muse/repair-object", |
| 131 | headers={"Content-Length": str(over_repair_limit)}, |
| 132 | content=b"", |
| 133 | ) |
| 134 | assert resp.status_code != 413 |
| 135 | |
| 136 | |
| 137 | async def test_repair_object_over_push_limit_still_rejected( |
| 138 | client: AsyncClient, |
| 139 | ) -> None: |
| 140 | over_push_limit = PUSH_MAX_BYTES + 1 |
| 141 | resp = await client.post( |
| 142 | "/gabriel/muse/repair-object", |
| 143 | headers={"Content-Length": str(over_push_limit)}, |
| 144 | content=b"", |
| 145 | ) |
| 146 | assert resp.status_code == 413 |