gabriel / muse public
test_local_file_transport.py python
400 lines 15.0 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Comprehensive tests for LocalFileTransport — unit, integration, security, stress.
2
3 Coverage matrix
4 ---------------
5 Unit
6 _repo_root : valid URL → resolved path; bad scheme; missing .muse/
7 fetch_remote_info : reads repo.json + branch heads
8 make_transport : file:// → LocalFileTransport; https:// → HttpTransport
9
10 Integration (two real repos on disk)
11 push from A → B via file:// using push_stream
12 pull-equivalent: fetch_remote_info + fetch_stream from B after push
13 round-trip: push A→B, verify B branch heads, then fetch B→A-mirror
14
15 Stress
16 push bundle with 50 commits and 200 objects via push_stream
17 fetch 20 commits via fetch_stream
18 """
19
20 from __future__ import annotations
21
22 import base64
23 import datetime
24 import hashlib
25 import json
26 import os
27 import pathlib
28
29 import pytest
30
31 from muse._version import __version__
32 from muse.core.object_store import write_object
33 from muse.core.pack import build_mpack
34 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
35 from muse.core.store import (
36 CommitRecord,
37 SnapshotRecord,
38 get_all_branch_heads,
39 get_head_commit_id,
40 read_commit,
41 write_commit,
42 write_snapshot,
43 )
44
45 from muse.core._types import Manifest, blob_id
46 from muse.core.transport import (
47 HttpTransport,
48 LocalFileTransport,
49 TransportError,
50 make_transport,
51 )
52
53
54 # ---------------------------------------------------------------------------
55 # Helpers
56 # ---------------------------------------------------------------------------
57
58
59 def _sha(b: bytes) -> str:
60 return blob_id(b)
61
62
63 def _make_repo(path: pathlib.Path, branch: str = "main") -> pathlib.Path:
64 """Create a minimal initialised Muse repo at *path*."""
65 muse = path / ".muse"
66 (muse / "refs" / "heads").mkdir(parents=True)
67 (muse / "objects").mkdir()
68 (muse / "commits").mkdir()
69 (muse / "snapshots").mkdir()
70 (muse / "repo.json").write_text(
71 json.dumps({"repo_id": f"repo-{path.name}", "schema_version": __version__, "domain": "midi", "default_branch": branch})
72 )
73 (muse / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
74 return path
75
76
77 def _add_commit(
78 root: pathlib.Path,
79 label: str,
80 branch: str = "main",
81 parent: str | None = None,
82 content: bytes = b"hello",
83 ) -> str:
84 """Write a commit with a real content-addressed ID and return it.
85
86 *label* is used only to derive a unique message so that different calls
87 with different labels produce different commit IDs even when all other
88 inputs are the same.
89 """
90 oid = _sha(content)
91 write_object(root, oid, content)
92 manifest: Manifest = {"file.txt": oid}
93 snap_id = compute_snapshot_id(manifest)
94 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
95 write_snapshot(root, snap)
96 message = f"commit {label[:8]}"
97 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
98 parent_ids = [parent] if parent else []
99 real_cid = compute_commit_id(parent_ids, snap_id, message, committed_at.isoformat())
100 commit = CommitRecord(
101 commit_id=real_cid,
102 repo_id=f"repo-{root.name}",
103 branch=branch,
104 snapshot_id=snap_id,
105 message=message,
106 committed_at=committed_at,
107 parent_commit_id=parent,
108 )
109 write_commit(root, commit)
110 (root / ".muse" / "refs" / "heads" / branch).write_text(real_cid)
111 return real_cid
112
113
114 # ---------------------------------------------------------------------------
115 # Unit — _repo_root
116 # ---------------------------------------------------------------------------
117
118
119 class TestRepoRoot:
120 def test_valid_url_returns_resolved_path(self, tmp_path: pathlib.Path) -> None:
121 repo = _make_repo(tmp_path / "myrepo")
122 url = f"file://{repo}"
123 result = LocalFileTransport._repo_root(url)
124 assert result == repo.resolve()
125
126 def test_invalid_scheme_raises_transport_error(self, tmp_path: pathlib.Path) -> None:
127 with pytest.raises(TransportError, match="file://"):
128 LocalFileTransport._repo_root("https://hub.example.com/repos/r1")
129
130 def test_missing_muse_dir_raises_404(self, tmp_path: pathlib.Path) -> None:
131 with pytest.raises(TransportError) as exc_info:
132 LocalFileTransport._repo_root(f"file://{tmp_path}")
133 assert exc_info.value.status_code == 404
134 assert ".muse/" in str(exc_info.value)
135
136 def test_path_with_double_dots_normalized(self, tmp_path: pathlib.Path) -> None:
137 """resolve() must collapse .. so the check is on the canonical path."""
138 repo = _make_repo(tmp_path / "repo")
139 # Construct a URL with a harmless .. that stays inside the repo.
140 url = f"file://{repo}/subdir/../"
141 # The path resolves to the repo root — .muse/ exists there.
142 result = LocalFileTransport._repo_root(url)
143 assert result == repo.resolve()
144
145 def test_symlink_target_with_no_muse_is_rejected(self, tmp_path: pathlib.Path) -> None:
146 """A symlink that resolves to a dir without .muse/ must raise TransportError."""
147 target = tmp_path / "innocent"
148 target.mkdir()
149 link = tmp_path / "evil_link"
150 link.symlink_to(target)
151 with pytest.raises(TransportError) as exc_info:
152 LocalFileTransport._repo_root(f"file://{link}")
153 assert exc_info.value.status_code == 404
154
155 def test_symlink_to_valid_repo_is_accepted(self, tmp_path: pathlib.Path) -> None:
156 """A symlink that resolves to a valid repo is accepted after resolve()."""
157 repo = _make_repo(tmp_path / "real_repo")
158 link = tmp_path / "alias"
159 link.symlink_to(repo)
160 result = LocalFileTransport._repo_root(f"file://{link}")
161 # Should return the canonical (resolved) path, not the symlink.
162 assert result == repo.resolve()
163
164
165 # ---------------------------------------------------------------------------
166 # Unit — fetch_remote_info
167 # ---------------------------------------------------------------------------
168
169
170 class TestFetchRemoteInfo:
171 def test_reads_repo_json_and_branch_heads(self, tmp_path: pathlib.Path) -> None:
172 repo = _make_repo(tmp_path / "remote")
173 cid = _add_commit(repo, "a" * 64)
174 t = LocalFileTransport()
175 info = t.fetch_remote_info(f"file://{repo}", signing=None)
176 assert info["repo_id"] == f"repo-{repo.name}"
177 assert info["domain"] == "midi"
178 assert info["default_branch"] == "main"
179 assert info["branch_heads"]["main"] == cid
180
181 def test_multiple_branches_returned(self, tmp_path: pathlib.Path) -> None:
182 repo = _make_repo(tmp_path / "remote")
183 cid_main = _add_commit(repo, "a" * 64, branch="main")
184 cid_dev = _add_commit(repo, "b" * 64, branch="dev")
185 t = LocalFileTransport()
186 info = t.fetch_remote_info(f"file://{repo}", signing=None)
187 assert info["branch_heads"]["main"] == cid_main
188 assert info["branch_heads"]["dev"] == cid_dev
189
190 def test_token_is_ignored(self, tmp_path: pathlib.Path) -> None:
191 """LocalFileTransport ignores the token arg — no auth for local repos."""
192 repo = _make_repo(tmp_path / "remote")
193 _add_commit(repo, "c" * 64)
194 t = LocalFileTransport()
195 info = t.fetch_remote_info(f"file://{repo}", signing="should-be-ignored")
196 assert info["repo_id"] == f"repo-{repo.name}"
197
198 def test_corrupted_repo_json_raises_transport_error(self, tmp_path: pathlib.Path) -> None:
199 repo = _make_repo(tmp_path / "bad")
200 (repo / ".muse" / "repo.json").write_text("NOT JSON")
201 t = LocalFileTransport()
202 with pytest.raises(TransportError, match="repo.json"):
203 t.fetch_remote_info(f"file://{repo}", signing=None)
204
205
206 # ---------------------------------------------------------------------------
207 # Unit — fetch_pack
208 # ---------------------------------------------------------------------------
209
210
211 # ---------------------------------------------------------------------------
212 # Unit — push_pack
213 # ---------------------------------------------------------------------------
214
215
216 # ---------------------------------------------------------------------------
217 # Security — branch name and path traversal
218 # ---------------------------------------------------------------------------
219
220
221 # ---------------------------------------------------------------------------
222 # make_transport factory
223 # ---------------------------------------------------------------------------
224
225
226 class TestMakeTransport:
227 def test_file_url_returns_local_transport(self) -> None:
228 assert isinstance(make_transport("file:///some/path"), LocalFileTransport)
229
230 def test_https_url_returns_http_transport(self) -> None:
231 assert isinstance(make_transport("https://hub.example.com/repos/r1"), HttpTransport)
232
233 def test_http_url_returns_http_transport(self) -> None:
234 assert isinstance(make_transport("http://hub.example.com/repos/r1"), HttpTransport)
235
236 def test_empty_url_returns_http_transport(self) -> None:
237 assert isinstance(make_transport(""), HttpTransport)
238
239
240 # ---------------------------------------------------------------------------
241 # Integration — full round-trip between two real repos
242 # ---------------------------------------------------------------------------
243
244
245 def _push(
246 t: LocalFileTransport,
247 url: str,
248 local: pathlib.Path,
249 commit_ids: list[str],
250 branch: str,
251 *,
252 have: list[str] | None = None,
253 force: bool = False,
254 ) -> dict:
255 """Helper: build_mpack → push_stream. Returns the PushResult dict."""
256 bundle = build_mpack(local, commit_ids=commit_ids, have=have or [])
257 local_head = commit_ids[-1] if commit_ids else None
258 return t.push_stream(
259 url,
260 None,
261 objects=list(bundle.get("objects") or []),
262 commits=list(bundle.get("commits") or []),
263 snapshots=list(bundle.get("snapshots") or []),
264 branch=branch,
265 force=force,
266 have=have or [],
267 local_head=local_head,
268 )
269
270
271 class TestIntegrationRoundTrip:
272 def test_push_then_fetch_info(self, tmp_path: pathlib.Path) -> None:
273 """Push from local → remote via push_stream; branch heads must reflect the push."""
274 local = _make_repo(tmp_path / "local")
275 remote = _make_repo(tmp_path / "remote")
276 cid = _add_commit(local, _sha(b"initial"), branch="main")
277
278 t = LocalFileTransport()
279 result = _push(t, f"file://{remote}", local, [cid], "main")
280 assert result["ok"] is True
281
282 info = t.fetch_remote_info(f"file://{remote}", None)
283 assert info["branch_heads"]["main"] == cid
284
285 def test_fetch_stream_after_push(self, tmp_path: pathlib.Path) -> None:
286 """After pushing A→B, fetch_stream from B must return the same commit."""
287 src = _make_repo(tmp_path / "src")
288 dst = _make_repo(tmp_path / "dst")
289 cid = _add_commit(src, _sha(b"content"), branch="main")
290
291 t = LocalFileTransport()
292 _push(t, f"file://{dst}", src, [cid], "main")
293
294 fetched = t.fetch_stream(f"file://{dst}", None, want=[cid], have=[])
295 fetched_ids = {c["commit_id"] for c in (fetched.get("commits") or [])}
296 assert cid in fetched_ids
297
298 def test_multi_branch_round_trip(self, tmp_path: pathlib.Path) -> None:
299 """Push two branches via push_stream; remote must have both."""
300 local = _make_repo(tmp_path / "local")
301 remote = _make_repo(tmp_path / "remote")
302
303 cid_main = _add_commit(local, _sha(b"main-commit"), branch="main")
304 cid_dev = _add_commit(local, _sha(b"dev-commit"), branch="dev")
305
306 t = LocalFileTransport()
307 url = f"file://{remote}"
308
309 _push(t, url, local, [cid_main], "main")
310 _push(t, url, local, [cid_dev], "dev")
311
312 info = t.fetch_remote_info(url, None)
313 assert info["branch_heads"]["main"] == cid_main
314 assert info["branch_heads"]["dev"] == cid_dev
315
316 def test_incremental_push_is_fast_forward(self, tmp_path: pathlib.Path) -> None:
317 """Second push_stream whose parent is the remote tip must be accepted."""
318 local = _make_repo(tmp_path / "local")
319 remote = _make_repo(tmp_path / "remote")
320
321 cid1 = _add_commit(local, "commit-1", branch="main")
322
323 t = LocalFileTransport()
324 url = f"file://{remote}"
325
326 _push(t, url, local, [cid1], "main")
327
328 # Second commit with cid1 as parent.
329 cid2 = _add_commit(local, "commit-2", branch="main", parent=cid1)
330 result = _push(t, url, local, [cid2], "main", have=[cid1])
331
332 assert result["ok"] is True
333 assert get_head_commit_id(remote, "main") == cid2
334
335
336 # ---------------------------------------------------------------------------
337 # Stress — large bundle
338 # ---------------------------------------------------------------------------
339
340
341 class TestStress:
342 def test_push_large_bundle(self, tmp_path: pathlib.Path) -> None:
343 """Push a bundle with 50 commits and 200 distinct objects via push_stream."""
344 remote = _make_repo(tmp_path / "remote")
345 local = _make_repo(tmp_path / "local")
346
347 prev_cid: str | None = None
348 last_cid = ""
349 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
350 for i in range(50):
351 # Write 4 objects per commit (200 total).
352 manifest: Manifest = {}
353 for j in range(4):
354 blob = f"blob-{i}-{j}".encode()
355 oid = _sha(blob)
356 write_object(local, oid, blob)
357 manifest[f"file_{i}_{j}.txt"] = oid
358 snap_id = compute_snapshot_id(manifest)
359 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
360 write_snapshot(local, snap)
361 message = f"commit {i}"
362 parent_ids = [prev_cid] if prev_cid else []
363 cid = compute_commit_id(parent_ids, snap_id, message, committed_at.isoformat())
364 commit = CommitRecord(
365 commit_id=cid,
366 repo_id=f"repo-{local.name}",
367 branch="main",
368 snapshot_id=snap_id,
369 message=message,
370 committed_at=committed_at,
371 parent_commit_id=prev_cid,
372 )
373 write_commit(local, commit)
374 prev_cid = cid
375 last_cid = cid
376
377 (local / ".muse" / "refs" / "heads" / "main").write_text(last_cid)
378
379 t = LocalFileTransport()
380 result = _push(t, f"file://{remote}", local, [last_cid], "main")
381 assert result["ok"] is True
382 assert get_head_commit_id(remote, "main") == last_cid
383
384 def test_fetch_stream_large_bundle(self, tmp_path: pathlib.Path) -> None:
385 """Fetch from a remote with 20 commits via fetch_stream; verify all are returned."""
386 remote = _make_repo(tmp_path / "remote")
387 all_cids: list[str] = []
388 prev: str | None = None
389
390 for i in range(20):
391 cid = _add_commit(remote, f"remote-commit-{i}", parent=prev)
392 all_cids.append(cid)
393 prev = cid
394
395 last = all_cids[-1]
396 result = LocalFileTransport().fetch_stream(
397 f"file://{remote}", None, want=[last], have=[]
398 )
399 fetched_ids = {c["commit_id"] for c in (result.get("commits") or [])}
400 assert last in fetched_ids
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago