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