gabriel / musehub public
test_repair_commit_body_size_limit.py python
146 lines 5.4 KB
Raw
sha256:12b077920a78fb8440555add7bdea5509ff80049f1e4cba9cdcebb4ea69c51fc fix: repair-commit/repair-snapshot/repair-object get right-… Sonnet 5 minor ⚠ breaking 2 days ago
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
File History 1 commit
sha256:12b077920a78fb8440555add7bdea5509ff80049f1e4cba9cdcebb4ea69c51fc fix: repair-commit/repair-snapshot/repair-object get right-… Sonnet 5 minor ⚠ 2 days ago