gabriel / musehub public
test_fetch_mpack_route.py python
260 lines 10.8 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 20 days ago
1 """TDD — HTTP route POST /{owner}/{slug}/fetch/mpack (issue #47 Phase 2).
2
3 Tests the new route that calls wire_fetch_mpack and returns a msgpack response.
4
5 Tests:
6 RB0 404 for a repo that does not exist.
7 RB1 Small public repo → 200, presign=False, mpack_id present, mpack bytes non-empty.
8 RB2 mpack_id == sha256(mpack bytes) — the route preserves the content-addressing proof.
9 RB3 Private repo → 404 without auth (don't leak existence).
10 RB4 Empty want list → 200, commit_count=0, object_count=0.
11 """
12 from __future__ import annotations
13
14 import hashlib
15
16 import msgpack
17 import pytest
18 from httpx import AsyncClient
19 from sqlalchemy.dialects.postgresql import insert as pg_insert
20 from sqlalchemy.ext.asyncio import AsyncSession
21 from sqlalchemy import select
22
23 from datetime import datetime, timezone
24
25 import msgpack as _msgpack_mod
26 from muse.core.types import blob_id, fake_id
27 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitGraph, MusehubCommitRef, MusehubMPackIndex, MusehubObject, MusehubObjectRef, MusehubSnapshot, MusehubSnapshotRef
28 from tests.factories import create_repo
29
30
31 # ── helpers ───────────────────────────────────────────────────────────────────
32
33 def _now() -> datetime:
34 return datetime.now(tz=timezone.utc)
35
36
37 async def _store_object(
38 session: AsyncSession,
39 repo_id: str,
40 oid: str,
41 content: bytes,
42 ) -> None:
43 from musehub.services.musehub_wire import get_backend
44 backend = get_backend()
45 uri = await backend.put(oid, content)
46 # Build a minimal mpack blob and register it so the mpack index gate passes.
47 mpack_blob = _msgpack_mod.packb(
48 {"objects": [{"object_id": oid, "content": content}]},
49 use_bin_type=True,
50 )
51 mpack_id = blob_id(mpack_blob)
52 await backend.put_mpack(mpack_id, mpack_blob)
53 await session.execute(
54 pg_insert(MusehubObject)
55 .values(
56 object_id=oid,
57 path="file.dat",
58 size_bytes=len(content),
59 storage_uri=uri,
60 )
61 .on_conflict_do_nothing(index_elements=["object_id"])
62 )
63 await session.execute(
64 pg_insert(MusehubObjectRef)
65 .values(repo_id=repo_id, object_id=oid)
66 .on_conflict_do_nothing()
67 )
68 await session.execute(
69 pg_insert(MusehubMPackIndex)
70 .values(entity_id=oid, mpack_id=mpack_id, entity_type="object")
71 .on_conflict_do_nothing()
72 )
73 await session.commit()
74
75
76 async def _make_commit(
77 session: AsyncSession,
78 repo_id: str,
79 *,
80 manifest: dict[str, str],
81 seed: str = "c1",
82 parent_ids: list[str] | None = None,
83 ) -> tuple[MusehubCommit, MusehubSnapshot]:
84 snap_id = fake_id(f"snap-route-{seed}")
85 snap = MusehubSnapshot(
86 snapshot_id=snap_id,
87 directories=[],
88 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
89 entry_count=len(manifest),
90 created_at=_now(),
91 )
92 session.add(snap)
93 await session.execute(
94 pg_insert(MusehubSnapshotRef)
95 .values(repo_id=repo_id, snapshot_id=snap_id)
96 .on_conflict_do_nothing()
97 )
98 commit_id = fake_id(f"commit-route-{seed}")
99 commit = MusehubCommit(
100 commit_id=commit_id,
101 branch="main",
102 parent_ids=parent_ids or [],
103 message=f"commit {seed}",
104 author="gabriel",
105 timestamp=_now(),
106 snapshot_id=snap_id,
107 )
108 session.add(commit)
109 await session.execute(
110 pg_insert(MusehubCommitRef)
111 .values(repo_id=repo_id, commit_id=commit_id)
112 .on_conflict_do_nothing()
113 )
114 await session.execute(
115 pg_insert(MusehubCommitGraph)
116 .values(
117 commit_id=commit_id,
118 parent_ids=parent_ids or [],
119 generation=0,
120 snapshot_id=snap_id,
121 )
122 .on_conflict_do_nothing()
123 )
124 branch_q = await session.execute(
125 select(MusehubBranch).where(
126 MusehubBranch.repo_id == repo_id,
127 MusehubBranch.name == "main",
128 )
129 )
130 branch = branch_q.scalar_one_or_none()
131 if branch:
132 branch.head_commit_id = commit_id
133 await session.commit()
134 return commit, snap
135
136
137 # ══════════════════════════════════════════════════════════════════════════════
138 # RB0 — 404 for missing repo
139 # ══════════════════════════════════════════════════════════════════════════════
140
141 @pytest.mark.asyncio
142 async def test_rb0_route_404_missing_repo(client: AsyncClient) -> None:
143 """POST /nobody/no-such-repo/fetch/mpack → 404."""
144 resp = await client.post(
145 "/nobody/no-such-repo/fetch/mpack",
146 content=msgpack.packb({"want": [], "have": []}, use_bin_type=True),
147 headers={"Content-Type": "application/x-msgpack"},
148 )
149 assert resp.status_code == 404
150
151
152 # ══════════════════════════════════════════════════════════════════════════════
153 # RB1 — small public repo → 200, presign=False, mpack_id and mpack_bytes
154 # ══════════════════════════════════════════════════════════════════════════════
155
156 @pytest.mark.asyncio
157 async def test_rb1_small_public_repo_returns_inline_mpack(
158 client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str]
159 ) -> None:
160 """Small public repo → 200, presign=False, mpack_id non-empty, mpack bytes present."""
161 repo = await create_repo(db_session, owner="gabriel", visibility="public")
162 raw = b"route test content"
163 oid = blob_id(raw)
164 await _store_object(db_session, repo.repo_id, oid, raw)
165 commit, _ = await _make_commit(
166 db_session, repo.repo_id, manifest={"route.bin": oid}, seed="rb1"
167 )
168
169 resp = await client.post(
170 f"/gabriel/{repo.slug}/fetch/mpack",
171 content=msgpack.packb({"want": [commit.commit_id], "have": []}, use_bin_type=True),
172 headers={**wire_headers, "Content-Type": "application/x-msgpack"},
173 )
174
175 assert resp.status_code == 200
176 data = msgpack.unpackb(resp.content, raw=False)
177
178 assert data["mpack_url"], "mpack_url must be non-empty"
179 assert data["mpack_id"].startswith("sha256:")
180 assert data["commit_count"] == 1
181 assert data["blob_count"] == 1
182
183
184 # ══════════════════════════════════════════════════════════════════════════════
185 # RB2 — mpack_id == sha256(mpack_bytes) — proof preserved end-to-end
186 # ══════════════════════════════════════════════════════════════════════════════
187
188 @pytest.mark.asyncio
189 async def test_rb2_route_preserves_content_addressing_proof(
190 client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str]
191 ) -> None:
192 """mpack_id must equal sha256(mpack_bytes) in the HTTP response."""
193 repo = await create_repo(db_session, owner="gabriel", visibility="public")
194 raw = b"proof content through the route"
195 oid = blob_id(raw)
196 await _store_object(db_session, repo.repo_id, oid, raw)
197 commit, _ = await _make_commit(
198 db_session, repo.repo_id, manifest={"proof.bin": oid}, seed="rb2"
199 )
200
201 resp = await client.post(
202 f"/gabriel/{repo.slug}/fetch/mpack",
203 content=msgpack.packb({"want": [commit.commit_id], "have": []}, use_bin_type=True),
204 headers={**wire_headers, "Content-Type": "application/x-msgpack"},
205 )
206
207 assert resp.status_code == 200
208 data = msgpack.unpackb(resp.content, raw=False)
209
210 assert data["mpack_id"].startswith("sha256:")
211 assert data["mpack_url"], "mpack_url must be non-empty"
212 # mpack_url encodes the mpack_id as its object key
213 mpack_hex = data["mpack_id"].removeprefix("sha256:")
214 assert mpack_hex in data["mpack_url"], (
215 f"mpack_url {data['mpack_url']!r} must reference mpack_id {data['mpack_id']!r}"
216 )
217
218
219 # ══════════════════════════════════════════════════════════════════════════════
220 # RB3 — private repo → 404 without auth
221 # ══════════════════════════════════════════════════════════════════════════════
222
223 @pytest.mark.asyncio
224 async def test_rb3_private_repo_returns_404_without_auth(
225 client: AsyncClient, db_session: AsyncSession
226 ) -> None:
227 """Private repo must return 404 to unauthenticated request (don't leak existence)."""
228 repo = await create_repo(db_session, owner="gabriel", visibility="private")
229
230 resp = await client.post(
231 f"/gabriel/{repo.slug}/fetch/mpack",
232 content=msgpack.packb({"want": [], "have": []}, use_bin_type=True),
233 headers={"Content-Type": "application/x-msgpack"},
234 )
235 assert resp.status_code == 404
236
237
238 # ══════════════════════════════════════════════════════════════════════════════
239 # RB4 — empty want → 200, zero counts
240 # ══════════════════════════════════════════════════════════════════════════════
241
242 @pytest.mark.asyncio
243 async def test_rb4_empty_want_returns_zero_counts(
244 client: AsyncClient, db_session: AsyncSession, wire_headers: dict[str, str]
245 ) -> None:
246 """Empty want list → 200, commit_count=0, object_count=0."""
247 repo = await create_repo(db_session, owner="gabriel", visibility="public")
248
249 resp = await client.post(
250 f"/gabriel/{repo.slug}/fetch/mpack",
251 content=msgpack.packb({"want": [], "have": []}, use_bin_type=True),
252 headers={**wire_headers, "Content-Type": "application/x-msgpack"},
253 )
254
255 assert resp.status_code == 200
256 data = msgpack.unpackb(resp.content, raw=False)
257
258 assert data["commit_count"] == 0
259 assert data["blob_count"] == 0
260 assert data["mpack_url"] is None
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago