gabriel / musehub public
test_object_integrity.py python
356 lines 11.8 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Tests for checklist 2.4 — Object / commit integrity.
2
3 Covers:
4 - SHA-256 content-addressed object verification on push and push/objects
5 - Forged parent_id rejection at receive time
6 """
7 from __future__ import annotations
8
9 import hashlib
10 import uuid
11 from datetime import datetime, timezone
12
13 def _uuid4() -> str:
14 return uuid.uuid4().hex[:8]
15
16 import msgpack
17 import pytest
18 from httpx import AsyncClient
19 from sqlalchemy.ext.asyncio import AsyncSession
20
21 from tests.factories import create_repo as factory_create_repo
22 from musehub.types.json_types import JSONObject, StrDict
23
24
25 # ── helpers ────────────────────────────────────────────────────────────────────
26
27 def _utc_now() -> str:
28 return datetime.now(tz=timezone.utc).isoformat()
29
30
31 def _sha256_object_id(content: bytes) -> str:
32 return "sha256:" + hashlib.sha256(content).hexdigest()
33
34
35 def _mp(data: JSONObject) -> bytes:
36 return msgpack.packb(data, use_bin_type=True)
37
38
39 def _sha256_id(seed: str) -> str:
40 return "sha256:" + hashlib.sha256(seed.encode()).hexdigest()
41
42
43 def _make_commit(
44 commit_id: str | None = None,
45 parent: str | None = None,
46 snap_id: str | None = None,
47 ) -> JSONObject:
48 uid = uuid.uuid4().hex
49 return {
50 "commit_id": commit_id or _sha256_id(f"commit-{uid}"),
51 "branch": "main",
52 "snapshot_id": snap_id or _sha256_id(f"snap-{uid}"),
53 "message": "test commit",
54 "committed_at": _utc_now(),
55 "parent_commit_id": parent,
56 "author": "Test User <[email protected]>",
57 }
58
59
60 def _make_valid_object(content: bytes) -> JSONObject:
61 """Object whose object_id is the correct sha256 of content."""
62 return {
63 "object_id": _sha256_object_id(content),
64 "content": content,
65 "path": "file.bin",
66 }
67
68
69 def _make_tampered_object(content: bytes) -> JSONObject:
70 """Object whose object_id claims sha256 of DIFFERENT content."""
71 wrong_content = content + b"\x00" # flip one byte
72 return {
73 "object_id": _sha256_object_id(wrong_content), # hash of wrong_content
74 "content": content, # but we send content
75 "path": "file.bin",
76 }
77
78
79 def _make_non_sha256_object(content: bytes) -> JSONObject:
80 """Object_id without sha256: prefix — should be accepted without hash check."""
81 return {
82 "object_id": "blob:" + uuid.uuid4().hex,
83 "content": content,
84 "path": "file.bin",
85 }
86
87
88 # ── SHA-256 object verification: /push endpoint ────────────────────────────────
89
90 async def test_push_with_valid_sha256_object_succeeds(
91 client: AsyncClient,
92 db_session: AsyncSession,
93 wire_headers: StrDict,
94 ) -> None:
95 """An object whose sha256(content) matches object_id must be accepted."""
96 repo = await factory_create_repo(
97 db_session, slug=f"integrity-valid-sha-{_uuid4()}", owner="test-user-wire"
98 )
99 content = b"hello, content-addressed world"
100 obj = _make_valid_object(content)
101 commit = _make_commit()
102
103 resp = await client.post(
104 f"/{repo.owner}/{repo.slug}/push",
105 content=_mp({
106 "bundle": {"commits": [commit], "snapshots": [], "objects": [obj]},
107 "branch": "main",
108 }),
109 headers=wire_headers,
110 )
111 assert resp.status_code == 200
112
113
114 async def test_push_with_tampered_sha256_object_returns_422(
115 client: AsyncClient,
116 db_session: AsyncSession,
117 wire_headers: StrDict,
118 ) -> None:
119 """An object whose sha256(content) does NOT match object_id must be rejected with 422."""
120 repo = await factory_create_repo(
121 db_session, slug=f"integrity-tampered-sha-{_uuid4()}", owner="test-user-wire"
122 )
123 content = b"legit content"
124 obj = _make_tampered_object(content)
125 commit = _make_commit()
126
127 resp = await client.post(
128 f"/{repo.owner}/{repo.slug}/push",
129 content=_mp({
130 "bundle": {"commits": [commit], "snapshots": [], "objects": [obj]},
131 "branch": "main",
132 }),
133 headers=wire_headers,
134 )
135 assert resp.status_code == 422
136 assert "mismatch" in resp.text.lower()
137
138
139 async def test_push_with_non_sha256_object_id_is_rejected(
140 client: AsyncClient,
141 db_session: AsyncSession,
142 wire_headers: StrDict,
143 ) -> None:
144 """Objects without sha256: prefix are rejected — the wire schema requires it."""
145 repo = await factory_create_repo(
146 db_session, slug=f"integrity-non-sha256-{_uuid4()}", owner="test-user-wire"
147 )
148 obj = _make_non_sha256_object(b"some bytes")
149 commit = _make_commit()
150
151 resp = await client.post(
152 f"/{repo.owner}/{repo.slug}/push",
153 content=_mp({
154 "bundle": {"commits": [commit], "snapshots": [], "objects": [obj]},
155 "branch": "main",
156 }),
157 headers=wire_headers,
158 )
159 assert resp.status_code == 422
160
161
162 # ── SHA-256 object verification: /push/objects endpoint ───────────────────────
163
164 async def test_push_objects_with_valid_sha256_succeeds(
165 client: AsyncClient,
166 db_session: AsyncSession,
167 wire_headers: StrDict,
168 ) -> None:
169 """push/objects with a valid sha256 object_id must return 200."""
170 repo = await factory_create_repo(
171 db_session, slug=f"integrity-objects-valid-{_uuid4()}", owner="test-user-wire"
172 )
173 content = b"chunked upload content"
174 obj = _make_valid_object(content)
175
176 resp = await client.post(
177 f"/{repo.owner}/{repo.slug}/push/objects",
178 content=_mp({"objects": [obj]}),
179 headers=wire_headers,
180 )
181 assert resp.status_code == 200
182
183
184 async def test_push_objects_with_tampered_sha256_returns_422(
185 client: AsyncClient,
186 db_session: AsyncSession,
187 wire_headers: StrDict,
188 ) -> None:
189 """push/objects with a tampered sha256 object_id must return 422."""
190 repo = await factory_create_repo(
191 db_session, slug=f"integrity-objects-tampered-{_uuid4()}", owner="test-user-wire"
192 )
193 content = b"attacker-supplied content"
194 obj = _make_tampered_object(content)
195
196 resp = await client.post(
197 f"/{repo.owner}/{repo.slug}/push/objects",
198 content=_mp({"objects": [obj]}),
199 headers=wire_headers,
200 )
201 assert resp.status_code == 422
202 assert "mismatch" in resp.text.lower()
203
204
205 # ── Parent commit integrity ────────────────────────────────────────────────────
206
207 async def test_push_commit_with_parent_in_bundle_succeeds(
208 client: AsyncClient,
209 db_session: AsyncSession,
210 wire_headers: StrDict,
211 ) -> None:
212 """A commit whose parent_commit_id is in the same push bundle must be accepted."""
213 repo = await factory_create_repo(
214 db_session, slug=f"integrity-parent-in-bundle-{_uuid4()}", owner="test-user-wire"
215 )
216 parent_id = _sha256_id(f"parent-{uuid.uuid4().hex}")
217 child_id = _sha256_id(f"child-{uuid.uuid4().hex}")
218 parent = _make_commit(commit_id=parent_id)
219 child = _make_commit(commit_id=child_id, parent=parent_id)
220
221 resp = await client.post(
222 f"/{repo.owner}/{repo.slug}/push",
223 content=_mp({
224 "bundle": {"commits": [parent, child], "snapshots": [], "objects": []},
225 "branch": "main",
226 }),
227 headers=wire_headers,
228 )
229 assert resp.status_code == 200
230
231
232 async def test_push_commit_with_parent_in_db_succeeds(
233 client: AsyncClient,
234 db_session: AsyncSession,
235 wire_headers: StrDict,
236 ) -> None:
237 """A commit whose parent is already in the DB for this repo must be accepted."""
238 repo = await factory_create_repo(
239 db_session, slug=f"integrity-parent-in-db-{_uuid4()}", owner="test-user-wire"
240 )
241 parent_id = _sha256_id(f"parent-{uuid.uuid4().hex}")
242 child_id = _sha256_id(f"child-{uuid.uuid4().hex}")
243 parent = _make_commit(commit_id=parent_id)
244
245 # Push parent first
246 r1 = await client.post(
247 f"/{repo.owner}/{repo.slug}/push",
248 content=_mp({
249 "bundle": {"commits": [parent], "snapshots": [], "objects": []},
250 "branch": "main",
251 }),
252 headers=wire_headers,
253 )
254 assert r1.status_code == 200
255
256 # Push child referencing parent (already in DB)
257 child = _make_commit(commit_id=child_id, parent=parent_id)
258 r2 = await client.post(
259 f"/{repo.owner}/{repo.slug}/push",
260 content=_mp({
261 "bundle": {"commits": [child], "snapshots": [], "objects": []},
262 "branch": "main",
263 }),
264 headers=wire_headers,
265 )
266 assert r2.status_code == 200
267
268
269 async def test_push_commit_with_forged_parent_id_is_rejected(
270 client: AsyncClient,
271 db_session: AsyncSession,
272 wire_headers: StrDict,
273 ) -> None:
274 """A commit referencing a parent that exists in neither the bundle nor this repo's DB
275 must be rejected (forged history reference)."""
276 repo = await factory_create_repo(
277 db_session, slug=f"integrity-forged-parent-{_uuid4()}", owner="test-user-wire"
278 )
279 forged_parent_id = _sha256_id(f"forged-{uuid.uuid4().hex}") # does not exist anywhere
280 child_id = _sha256_id(f"child-{uuid.uuid4().hex}")
281 child = _make_commit(commit_id=child_id, parent=forged_parent_id)
282
283 resp = await client.post(
284 f"/{repo.owner}/{repo.slug}/push",
285 content=_mp({
286 "bundle": {"commits": [child], "snapshots": [], "objects": []},
287 "branch": "main",
288 }),
289 headers=wire_headers,
290 )
291 # Should be rejected — either 409 (push rejected) or 422
292 assert resp.status_code in (409, 422)
293 assert "parent" in resp.text.lower() or "rejected" in resp.text.lower()
294
295
296 async def test_push_commit_with_parent_from_different_repo_is_rejected(
297 client: AsyncClient,
298 db_session: AsyncSession,
299 wire_headers: StrDict,
300 ) -> None:
301 """A commit whose parent_id exists in a DIFFERENT repo must be rejected (cross-repo forgery)."""
302 repo_a = await factory_create_repo(
303 db_session, slug=f"integrity-repo-a-{_uuid4()}", owner="test-user-wire"
304 )
305 repo_b = await factory_create_repo(
306 db_session, slug=f"integrity-repo-b-{_uuid4()}", owner="test-user-wire"
307 )
308
309 # Push a commit to repo_a
310 commit_in_a_id = _sha256_id(f"commit-a-{uuid.uuid4().hex}")
311 commit_in_a = _make_commit(commit_id=commit_in_a_id)
312 r1 = await client.post(
313 f"/{repo_a.owner}/{repo_a.slug}/push",
314 content=_mp({
315 "bundle": {"commits": [commit_in_a], "snapshots": [], "objects": []},
316 "branch": "main",
317 }),
318 headers=wire_headers,
319 )
320 assert r1.status_code == 200
321
322 # Now push to repo_b claiming a parent that only exists in repo_a
323 child_id = _sha256_id(f"child-b-{uuid.uuid4().hex}")
324 child = _make_commit(commit_id=child_id, parent=commit_in_a_id)
325 r2 = await client.post(
326 f"/{repo_b.owner}/{repo_b.slug}/push",
327 content=_mp({
328 "bundle": {"commits": [child], "snapshots": [], "objects": []},
329 "branch": "main",
330 }),
331 headers=wire_headers,
332 )
333 assert r2.status_code in (409, 422)
334 assert "parent" in r2.text.lower() or "rejected" in r2.text.lower()
335
336
337 async def test_push_root_commit_with_no_parent_succeeds(
338 client: AsyncClient,
339 db_session: AsyncSession,
340 wire_headers: StrDict,
341 ) -> None:
342 """A root commit (no parent_commit_id) must be accepted — this is the initial push."""
343 repo = await factory_create_repo(
344 db_session, slug=f"integrity-root-commit-{_uuid4()}", owner="test-user-wire"
345 )
346 root = _make_commit(parent=None)
347
348 resp = await client.post(
349 f"/{repo.owner}/{repo.slug}/push",
350 content=_mp({
351 "bundle": {"commits": [root], "snapshots": [], "objects": []},
352 "branch": "main",
353 }),
354 headers=wire_headers,
355 )
356 assert resp.status_code == 200
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago