gabriel / musehub public
test_raw_endpoint.py python
330 lines 13.0 KB
Raw
sha256:73f24973678ceefd56628ee17c6d0535d86e33533828af6c63348f50941515ad Merge branch 'fix/smoke-test-tag-label' into dev Human 15 days ago
1 """Tests for GET /{owner}/{repo_slug}/raw/{ref}/{path} endpoint.
2
3 Covers:
4 raw_file_semantic (ui_tree.py):
5 - 200: file exists in snapshot manifest and object exists in storage
6 - 404: file exists in manifest but object missing from storage
7 - 404: file not in snapshot manifest at ref
8 - 404: ref does not exist
9 - correct Content-Type for text files (.py, .toml, .md)
10 - correct Content-Type for binary files (.png)
11 - Content-Disposition: inline for text, attachment for binary
12
13 storage.exists() interface:
14 - BlobBackend.exists(object_id) — single argument, no repo_id
15 - BlobBackend satisfies the StorageBackend protocol
16 """
17 from __future__ import annotations
18
19 import secrets
20 from datetime import datetime, timezone
21 from unittest.mock import MagicMock, patch
22
23 import msgpack
24 import pytest
25 from httpx import AsyncClient
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from muse.core.types import fake_id
29 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_repo_id
30 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubObject, MusehubRepo, MusehubSnapshot, MusehubSnapshotRef
31 from musehub.storage.backends import BlobBackend
32
33
34 MINIO_ENDPOINT = "http://localhost:9000"
35 MINIO_BUCKET = "muse-objects"
36 MINIO_ACCESS_KEY = "minioadmin"
37 MINIO_SECRET_KEY = "minioadmin"
38
39
40 def _uid() -> str:
41 return secrets.token_hex(16)
42
43
44 def _minio_backend() -> BlobBackend:
45 return BlobBackend(
46 bucket=MINIO_BUCKET,
47 endpoint_url=MINIO_ENDPOINT,
48 access_key_id=MINIO_ACCESS_KEY,
49 secret_access_key=MINIO_SECRET_KEY,
50 region="us-east-1",
51 )
52
53
54 # ── DB fixtures ───────────────────────────────────────────────────────────────
55
56 async def _make_repo(
57 db: AsyncSession,
58 owner: str = "gabriel",
59 slug: str = "muse",
60 ) -> MusehubRepo:
61 created_at = datetime.now(tz=timezone.utc)
62 owner_id = compute_identity_id(owner.encode())
63 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
64 repo = MusehubRepo(
65 repo_id=repo_id,
66 name=slug,
67 owner=owner,
68 slug=slug,
69 visibility="public",
70 owner_user_id=owner_id,
71 created_at=created_at,
72 updated_at=created_at,
73 )
74 db.add(repo)
75 await db.flush()
76 return repo
77
78
79 async def _make_snapshot(
80 db: AsyncSession,
81 repo_id: str,
82 manifest: dict[str, str],
83 ) -> MusehubSnapshot:
84 snap = MusehubSnapshot(
85 snapshot_id=fake_id(_uid()),
86 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
87 entry_count=len(manifest),
88 created_at=datetime.now(tz=timezone.utc),
89 )
90 db.add(snap)
91 db.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap.snapshot_id))
92 await db.flush()
93 return snap
94
95
96 async def _make_branch_at_commit(
97 db: AsyncSession,
98 repo_id: str,
99 branch_name: str,
100 manifest: dict[str, str],
101 ) -> tuple[MusehubCommit, MusehubSnapshot]:
102 snap = await _make_snapshot(db, repo_id, manifest)
103 now = datetime.now(tz=timezone.utc)
104 commit = MusehubCommit(
105 commit_id=fake_id(_uid()),
106 snapshot_id=snap.snapshot_id,
107 message="test commit",
108 author="gabriel",
109 branch=branch_name,
110 parent_ids=[],
111 timestamp=now,
112 created_at=now,
113 )
114 db.add(commit)
115 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit.commit_id))
116 await db.flush()
117 branch = MusehubBranch(
118 branch_id=compute_branch_id(repo_id, branch_name),
119 repo_id=repo_id,
120 name=branch_name,
121 head_commit_id=commit.commit_id,
122 )
123 db.add(branch)
124 await db.flush()
125 return commit, snap
126
127
128 async def _make_object_in_db(
129 db: AsyncSession,
130 object_id: str,
131 content: bytes,
132 path: str = "file",
133 ) -> MusehubObject:
134 """Insert a MusehubObject row with storage_uri pointing to MinIO."""
135 obj = MusehubObject(
136 object_id=object_id,
137 path=path,
138 size_bytes=len(content),
139 storage_uri=f"s3://{MINIO_BUCKET}/objects/{object_id}",
140 )
141 db.add(obj)
142 await db.flush()
143 return obj
144
145
146 # ═══════════════════════════════════════════════════════════════════════════════
147 # StorageBackend interface — exists() takes exactly one argument (object_id)
148 # ═══════════════════════════════════════════════════════════════════════════════
149
150 class TestStorageBackendExistsInterface:
151 """Regression: exists() must accept a single object_id, never (repo_id, object_id)."""
152
153 async def test_blob_backend_exists_single_arg(self) -> None:
154 mock_client = MagicMock()
155 mock_client.head_object.return_value = {}
156 backend = BlobBackend(bucket="test-bucket", region="us-east-1")
157 backend._client = mock_client
158 oid = fake_id("test-object")
159 result = await backend.exists(oid)
160 assert result is True
161 mock_client.head_object.assert_called_once_with(
162 Bucket="test-bucket", Key=f"objects/{oid}"
163 )
164
165 async def test_blob_backend_exists_error_returns_false(self) -> None:
166 """BlobBackend.exists() returns False when head_object fails."""
167 mock_client = MagicMock()
168 mock_client.head_object.side_effect = Exception("not found")
169 backend = BlobBackend(bucket="test-bucket", region="us-east-1")
170 backend._client = mock_client
171 result = await backend.exists(fake_id("test-object"))
172 assert result is False
173
174
175 # ═══════════════════════════════════════════════════════════════════════════════
176 # GET /{owner}/{repo_slug}/raw/{ref}/{path} — endpoint tests
177 # ═══════════════════════════════════════════════════════════════════════════════
178
179 class TestRawEndpoint:
180
181 async def test_returns_200_for_file_in_manifest_and_storage(
182 self, client: AsyncClient, db_session: AsyncSession
183 ) -> None:
184 repo = await _make_repo(db_session)
185 file_content = b"[tool.poetry]\nname = 'muse'\n"
186 oid = fake_id("pyproject-oid-" + _uid())
187 _, _ = await _make_branch_at_commit(
188 db_session, repo.repo_id, "main", {"pyproject.toml": oid}
189 )
190 await _make_object_in_db(db_session, oid, file_content, path="pyproject.toml")
191 await db_session.commit()
192
193 backend = _minio_backend()
194 await backend.put(oid, file_content)
195
196 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
197 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
198
199 assert resp.status_code == 200
200 assert resp.content == file_content
201
202 async def test_returns_404_when_file_not_in_manifest(
203 self, client: AsyncClient, db_session: AsyncSession
204 ) -> None:
205 repo = await _make_repo(db_session, slug="muse-raw2-" + _uid())
206 _, _ = await _make_branch_at_commit(
207 db_session, repo.repo_id, "main", {"README.md": fake_id("readme-oid")}
208 )
209 await db_session.commit()
210
211 backend = _minio_backend()
212 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
213 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
214
215 assert resp.status_code == 404
216 assert "pyproject.toml" in resp.json()["detail"]
217
218 async def test_returns_404_when_object_missing_from_storage(
219 self, client: AsyncClient, db_session: AsyncSession
220 ) -> None:
221 repo = await _make_repo(db_session, slug="muse-raw3-" + _uid())
222 oid = fake_id("missing-oid-" + _uid())
223 _, _ = await _make_branch_at_commit(
224 db_session, repo.repo_id, "main", {"pyproject.toml": oid}
225 )
226 await db_session.commit()
227
228 # Backend has no object written for this OID — exists() returns False
229 backend = _minio_backend()
230 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
231 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
232
233 assert resp.status_code == 404
234 # Must be distinct from the manifest-miss message so we can tell the two
235 # failure cases apart from logs/responses (critical for staging diagnosis).
236 assert "storage" in resp.json()["detail"].lower()
237
238 async def test_404_manifest_miss_and_storage_miss_have_distinct_messages(
239 self, client: AsyncClient, db_session: AsyncSession
240 ) -> None:
241 """Regression: the two 404 paths must produce different detail strings.
242
243 Without this the staging 404 is undiagnosable — we can't tell whether
244 the snapshot manifest has the file or whether the object is missing from R2.
245 """
246 backend = _minio_backend()
247
248 # Case A: file not in manifest at all
249 slug_a = "muse-raw3b-" + _uid()
250 repo_a = await _make_repo(db_session, slug=slug_a)
251 _, _ = await _make_branch_at_commit(
252 db_session, repo_a.repo_id, "main", {"README.md": fake_id("readme-oid")}
253 )
254 await db_session.commit()
255 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
256 resp_a = await client.get(f"/{repo_a.owner}/{repo_a.slug}/raw/main/pyproject.toml")
257
258 # Case B: file in manifest, object missing from storage
259 slug_b = "muse-raw3c-" + _uid()
260 repo_b = await _make_repo(db_session, slug=slug_b)
261 _, _ = await _make_branch_at_commit(
262 db_session, repo_b.repo_id, "main", {"pyproject.toml": fake_id("missing-oid-" + _uid())}
263 )
264 await db_session.commit()
265 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
266 resp_b = await client.get(f"/{repo_b.owner}/{repo_b.slug}/raw/main/pyproject.toml")
267
268 assert resp_a.status_code == 404
269 assert resp_b.status_code == 404
270 assert resp_a.json()["detail"] != resp_b.json()["detail"], (
271 "manifest-miss and storage-miss must produce different detail strings"
272 )
273
274 async def test_returns_404_for_unknown_ref(
275 self, client: AsyncClient, db_session: AsyncSession
276 ) -> None:
277 repo = await _make_repo(db_session, slug="muse-raw4-" + _uid())
278 _, _ = await _make_branch_at_commit(
279 db_session, repo.repo_id, "main", {"pyproject.toml": fake_id("oid")}
280 )
281 await db_session.commit()
282
283 backend = _minio_backend()
284 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
285 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/nonexistent-branch/pyproject.toml")
286
287 assert resp.status_code == 404
288
289 async def test_text_file_served_as_text_plain(
290 self, client: AsyncClient, db_session: AsyncSession
291 ) -> None:
292 repo = await _make_repo(db_session, slug="muse-raw5-" + _uid())
293 oid = fake_id("py-oid-" + _uid())
294 content = b"def main(): pass\n"
295 _, _ = await _make_branch_at_commit(
296 db_session, repo.repo_id, "main", {"musehub/main.py": oid}
297 )
298 await _make_object_in_db(db_session, oid, content, path="musehub/main.py")
299 await db_session.commit()
300
301 backend = _minio_backend()
302 await backend.put(oid, content)
303
304 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
305 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/musehub/main.py")
306
307 assert resp.status_code == 200
308 assert "text/plain" in resp.headers["content-type"]
309
310 async def test_binary_file_served_as_attachment(
311 self, client: AsyncClient, db_session: AsyncSession
312 ) -> None:
313 repo = await _make_repo(db_session, slug="muse-raw6-" + _uid())
314 oid = fake_id("png-oid-" + _uid())
315 png_bytes = b"\x89PNG\r\n\x1a\n"
316 _, _ = await _make_branch_at_commit(
317 db_session, repo.repo_id, "main", {"logo.png": oid}
318 )
319 await _make_object_in_db(db_session, oid, png_bytes, path="logo.png")
320 await db_session.commit()
321
322 backend = _minio_backend()
323 await backend.put(oid, png_bytes)
324
325 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
326 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/logo.png")
327
328 assert resp.status_code == 200
329 assert resp.headers["content-type"] == "image/png"
330 assert "attachment" in resp.headers["content-disposition"]
File History 13 commits
sha256:cfefc25a166c3c3eed8ea3529aee19ea350bc05f2954d007420e924133b7d8ce chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 13 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 51 days ago