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