gabriel / musehub public
test_raw_endpoint.py python
314 lines 12.8 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 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 - LocalBackend.exists(object_id) — single argument, no repo_id
15 - S3Backend.exists(object_id) — single argument, no repo_id
16 - Both satisfy the StorageBackend protocol
17 """
18 from __future__ import annotations
19
20 import secrets
21 from datetime import datetime, timezone
22 from pathlib import Path
23 from unittest.mock import MagicMock, patch
24
25 import msgpack
26 import pytest
27 from httpx import AsyncClient
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from muse.core.types import fake_id
31 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_repo_id
32 from musehub.db.musehub_models import (
33 MusehubBranch,
34 MusehubCommit,
35 MusehubRepo,
36 MusehubSnapshot,
37 )
38 from musehub.storage.backends import LocalBackend, S3Backend
39
40
41 def _uid() -> str:
42 return secrets.token_hex(16)
43
44
45 # ── DB fixtures ───────────────────────────────────────────────────────────────
46
47 async def _make_repo(
48 db: AsyncSession,
49 owner: str = "gabriel",
50 slug: str = "muse",
51 ) -> MusehubRepo:
52 created_at = datetime.now(tz=timezone.utc)
53 owner_id = compute_identity_id(owner.encode())
54 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
55 repo = MusehubRepo(
56 repo_id=repo_id,
57 name=slug,
58 owner=owner,
59 slug=slug,
60 visibility="public",
61 owner_user_id=owner_id,
62 created_at=created_at,
63 updated_at=created_at,
64 )
65 db.add(repo)
66 await db.flush()
67 return repo
68
69
70 async def _make_snapshot(
71 db: AsyncSession,
72 repo_id: str,
73 manifest: dict[str, str],
74 ) -> MusehubSnapshot:
75 snap = MusehubSnapshot(
76 snapshot_id=fake_id(_uid()),
77 repo_id=repo_id,
78 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
79 entry_count=len(manifest),
80 created_at=datetime.now(tz=timezone.utc),
81 )
82 db.add(snap)
83 await db.flush()
84 return snap
85
86
87 async def _make_branch_at_commit(
88 db: AsyncSession,
89 repo_id: str,
90 branch_name: str,
91 manifest: dict[str, str],
92 ) -> tuple[MusehubCommit, MusehubSnapshot]:
93 snap = await _make_snapshot(db, repo_id, manifest)
94 now = datetime.now(tz=timezone.utc)
95 commit = MusehubCommit(
96 commit_id=fake_id(_uid()),
97 repo_id=repo_id,
98 snapshot_id=snap.snapshot_id,
99 message="test commit",
100 author="gabriel",
101 branch=branch_name,
102 parent_ids=[],
103 timestamp=now,
104 created_at=now,
105 )
106 db.add(commit)
107 await db.flush()
108 branch = MusehubBranch(
109 branch_id=compute_branch_id(repo_id, branch_name),
110 repo_id=repo_id,
111 name=branch_name,
112 head_commit_id=commit.commit_id,
113 )
114 db.add(branch)
115 await db.flush()
116 return commit, snap
117
118
119 # ═══════════════════════════════════════════════════════════════════════════════
120 # StorageBackend interface — exists() takes exactly one argument (object_id)
121 # ═══════════════════════════════════════════════════════════════════════════════
122
123 class TestStorageBackendExistsInterface:
124 """Regression: exists() must accept a single object_id, never (repo_id, object_id)."""
125
126 async def test_local_backend_exists_single_arg(self, tmp_path: Path) -> None:
127 backend = LocalBackend(repo_root=tmp_path / "objects")
128 oid = fake_id("test-object")
129 result = await backend.exists(oid)
130 assert result is False
131
132 async def test_local_backend_exists_returns_true_after_put(self, tmp_path: Path) -> None:
133 backend = LocalBackend(repo_root=tmp_path / "objects")
134 oid = fake_id("test-object")
135 await backend.put(oid, b"hello world")
136 assert await backend.exists(oid) is True
137
138 async def test_local_backend_exists_two_args_raises(self, tmp_path: Path) -> None:
139 """Unbound LocalBackend.exists() must raise ValueError — no repo_root, no data."""
140 backend = LocalBackend()
141 with pytest.raises(ValueError):
142 await backend.exists(fake_id("test-object"))
143
144 async def test_s3_backend_exists_single_arg(self) -> None:
145 mock_client = MagicMock()
146 mock_client.head_object.return_value = {}
147 backend = S3Backend(bucket="test-bucket", region="us-east-1")
148 backend._client = mock_client
149 oid = fake_id("test-object")
150 result = await backend.exists(oid)
151 assert result is True
152 mock_client.head_object.assert_called_once_with(
153 Bucket="test-bucket", Key=f"objects/{oid.replace(':', '_')}"
154 )
155
156 async def test_s3_backend_exists_two_args_raises(self) -> None:
157 """S3Backend.exists() raises when head_object fails — standard error path."""
158 mock_client = MagicMock()
159 mock_client.head_object.side_effect = Exception("not found")
160 backend = S3Backend(bucket="test-bucket", region="us-east-1")
161 backend._client = mock_client
162 result = await backend.exists(fake_id("test-object"))
163 assert result is False
164
165
166 # ═══════════════════════════════════════════════════════════════════════════════
167 # GET /{owner}/{repo_slug}/raw/{ref}/{path} — endpoint tests
168 # ═══════════════════════════════════════════════════════════════════════════════
169
170 class TestRawEndpoint:
171
172 async def test_returns_200_for_file_in_manifest_and_storage(
173 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
174 ) -> None:
175 repo = await _make_repo(db_session)
176 file_content = b"[tool.poetry]\nname = 'muse'\n"
177 oid = fake_id("pyproject-oid")
178 _, _ = await _make_branch_at_commit(
179 db_session, repo.repo_id, "main", {"pyproject.toml": oid}
180 )
181 await db_session.commit()
182
183 backend = LocalBackend(repo_root=tmp_path / "objects")
184 await backend.put(oid, file_content)
185
186 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
187 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
188
189 assert resp.status_code == 200
190 assert resp.content == file_content
191
192 async def test_returns_404_when_file_not_in_manifest(
193 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
194 ) -> None:
195 repo = await _make_repo(db_session, slug="muse2")
196 _, _ = await _make_branch_at_commit(
197 db_session, repo.repo_id, "main", {"README.md": fake_id("readme-oid")}
198 )
199 await db_session.commit()
200
201 backend = LocalBackend(repo_root=tmp_path / "objects")
202 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
203 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
204
205 assert resp.status_code == 404
206 assert "pyproject.toml" in resp.json()["detail"]
207
208 async def test_returns_404_when_object_missing_from_storage(
209 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
210 ) -> None:
211 repo = await _make_repo(db_session, slug="muse3")
212 oid = fake_id("missing-oid")
213 _, _ = await _make_branch_at_commit(
214 db_session, repo.repo_id, "main", {"pyproject.toml": oid}
215 )
216 await db_session.commit()
217
218 # Backend has no objects written — exists() returns False
219 backend = LocalBackend(repo_root=tmp_path / "objects")
220 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
221 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/pyproject.toml")
222
223 assert resp.status_code == 404
224 # Must be distinct from the manifest-miss message so we can tell the two
225 # failure cases apart from logs/responses (critical for staging diagnosis).
226 assert "storage" in resp.json()["detail"].lower()
227
228 async def test_404_manifest_miss_and_storage_miss_have_distinct_messages(
229 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
230 ) -> None:
231 """Regression: the two 404 paths must produce different detail strings.
232
233 Without this the staging 404 is undiagnosable — we can't tell whether
234 the snapshot manifest has the file or whether the object is missing from R2.
235 """
236 backend = LocalBackend(repo_root=tmp_path / "objects")
237
238 # Case A: file not in manifest at all
239 repo_a = await _make_repo(db_session, slug="muse3b")
240 _, _ = await _make_branch_at_commit(
241 db_session, repo_a.repo_id, "main", {"README.md": fake_id("readme-oid")}
242 )
243 await db_session.commit()
244 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
245 resp_a = await client.get(f"/{repo_a.owner}/{repo_a.slug}/raw/main/pyproject.toml")
246
247 # Case B: file in manifest, object missing from storage
248 repo_b = await _make_repo(db_session, slug="muse3c")
249 _, _ = await _make_branch_at_commit(
250 db_session, repo_b.repo_id, "main", {"pyproject.toml": fake_id("missing-oid")}
251 )
252 await db_session.commit()
253 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
254 resp_b = await client.get(f"/{repo_b.owner}/{repo_b.slug}/raw/main/pyproject.toml")
255
256 assert resp_a.status_code == 404
257 assert resp_b.status_code == 404
258 assert resp_a.json()["detail"] != resp_b.json()["detail"], (
259 "manifest-miss and storage-miss must produce different detail strings"
260 )
261
262 async def test_returns_404_for_unknown_ref(
263 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
264 ) -> None:
265 repo = await _make_repo(db_session, slug="muse4")
266 _, _ = await _make_branch_at_commit(
267 db_session, repo.repo_id, "main", {"pyproject.toml": fake_id("oid")}
268 )
269 await db_session.commit()
270
271 backend = LocalBackend(repo_root=tmp_path / "objects")
272 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
273 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/nonexistent-branch/pyproject.toml")
274
275 assert resp.status_code == 404
276
277 async def test_text_file_served_as_text_plain(
278 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
279 ) -> None:
280 repo = await _make_repo(db_session, slug="muse5")
281 oid = fake_id("py-oid")
282 _, _ = await _make_branch_at_commit(
283 db_session, repo.repo_id, "main", {"musehub/main.py": oid}
284 )
285 await db_session.commit()
286
287 backend = LocalBackend(repo_root=tmp_path / "objects")
288 await backend.put(oid, b"def main(): pass\n")
289
290 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
291 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/musehub/main.py")
292
293 assert resp.status_code == 200
294 assert "text/plain" in resp.headers["content-type"]
295
296 async def test_binary_file_served_as_attachment(
297 self, client: AsyncClient, db_session: AsyncSession, tmp_path: Path
298 ) -> None:
299 repo = await _make_repo(db_session, slug="muse6")
300 oid = fake_id("png-oid")
301 _, _ = await _make_branch_at_commit(
302 db_session, repo.repo_id, "main", {"logo.png": oid}
303 )
304 await db_session.commit()
305
306 backend = LocalBackend(repo_root=tmp_path / "objects")
307 await backend.put(oid, b"\x89PNG\r\n\x1a\n") # PNG magic bytes
308
309 with patch("musehub.api.routes.musehub.ui_tree._get_storage_backend", return_value=backend):
310 resp = await client.get(f"/{repo.owner}/{repo.slug}/raw/main/logo.png")
311
312 assert resp.status_code == 200
313 assert resp.headers["content-type"] == "image/png"
314 assert "attachment" in resp.headers["content-disposition"]
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago