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