test_local_file_transport.py
python
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