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