gabriel / muse public
test_cmd_pull_hardening.py python
735 lines 34.6 KB
Raw
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 20 days ago
1 """Comprehensive hardening tests for ``muse pull``.
2
3 Covers all changes introduced in the pull command review:
4
5 Unit
6 ----
7 - Parser flags: --ff-only, --dry-run, --json/-j
8 - Dead-code removal: _current_branch and _restore_from_manifest absent
9 - _PullJson TypedDict keys complete
10
11 Integration (mocked transport)
12 -------------------------------
13 - All error messages routed to stderr
14 - remote not configured → stderr + exit 1
15 - branch not on remote → stderr + exit 1
16 - fetch TransportError → stderr + exit 1 (INTERNAL_ERROR)
17 - unknown flag → non-zero exit
18 - up_to_date JSON schema complete
19 - fast_forward JSON schema complete
20 - merged JSON schema complete
21 - conflict JSON schema complete (exit 2)
22 - fetched JSON schema complete (--no-merge)
23 - dry_run JSON schema complete
24 - --ff-only: diverged branches refuse pull, exit 1
25 - --ff-only: fast-forward still succeeds
26 - "Already up to date" goes to stderr, not stdout
27 - apply_manifest called BEFORE write_branch_ref in fast-forward
28 - commits_received uses commits_written from apply_mpack result
29
30 End-to-end (mocked transport)
31 ------------------------------
32 - --no-merge fetches and exits 0
33 - --json produces valid fetched schema
34 - --dry-run produces no side effects
35 - --dry-run --json produces valid schema
36
37 Security
38 --------
39 - remote name ANSI-sanitized in all errors
40 - branch name ANSI-sanitized in all errors
41 - conflict path ANSI-sanitized in text output
42 - invalid --format exits to stderr
43 - progress to stderr, stdout clean on --json
44 """
45
46 from __future__ import annotations
47
48 type _IntMap = dict[str, int]
49
50 import argparse
51 import datetime
52 import json
53 import pathlib
54 from typing import TYPE_CHECKING
55 from unittest.mock import MagicMock, call, patch
56
57 import pytest
58
59 from tests.cli_test_helper import CliRunner, InvokeResult
60 from collections.abc import Callable
61 from muse.core.types import blob_id, MsgpackDict
62 from muse.core.paths import muse_dir
63
64 if TYPE_CHECKING:
65 from muse.cli.commands.pull import _PullJson
66 from muse.core.mpack import MPack, RemoteInfo
67
68 cli = None
69 runner = CliRunner()
70
71
72 # ---------------------------------------------------------------------------
73 # Helpers
74 # ---------------------------------------------------------------------------
75
76 def _env(root: pathlib.Path) -> Manifest:
77 return {"MUSE_REPO_ROOT": str(root)}
78
79
80
81
82 def _json_line(r: InvokeResult) -> _PullJson:
83 """Extract the single JSON object line from combined output."""
84 for line in r.output.splitlines():
85 stripped = line.strip()
86 if stripped.startswith("{"):
87 parsed: _PullJson = json.loads(stripped)
88 return parsed
89 raise ValueError(f"No JSON line found in output:\n{r.output!r}")
90
91
92 def _make_remote_info(branch_heads: Manifest) -> "RemoteInfo":
93 return {
94 "repo_id": "test-repo",
95 "domain": "code",
96 "default_branch": "main",
97 "branch_heads": branch_heads,
98 }
99
100
101 def _make_bundle(
102 commit_id: str = "a" * 64,
103 snapshot_id: str = "b" * 64,
104 ) -> "MPack":
105 from muse.core.mpack import MPack
106 return MPack(commits=[], snapshots=[], blobs=[])
107
108
109 # ---------------------------------------------------------------------------
110 # Fixtures
111 # ---------------------------------------------------------------------------
112
113 @pytest.fixture()
114 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
115 """Minimal .muse/ repo with one commit on main."""
116 from muse._version import __version__
117 from muse.core.object_store import write_object
118 from muse.core.ids import hash_commit, hash_snapshot
119 from muse.core.commits import (
120 CommitRecord,
121 write_commit,
122 )
123 from muse.core.snapshots import (
124 SnapshotRecord,
125 write_snapshot,
126 )
127
128 muse = muse_dir(tmp_path)
129 for sub in ("refs/heads", "objects", "commits", "snapshots"):
130 (muse / sub).mkdir(parents=True)
131 (muse / "repo.json").write_text(
132 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
133 )
134 (muse / "HEAD").write_text("ref: refs/heads/main\n")
135 (muse / "config.toml").write_text('[remotes.origin]\nurl = "https://hub.example.com/r"\n')
136
137 blob = b"x = 1\n"
138 oid = blob_id(blob)
139 write_object(tmp_path, oid, blob)
140 snap_id = hash_snapshot({"a.py": oid})
141 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid}))
142 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
143 cid = hash_commit(
144 parent_ids=[],
145 snapshot_id=snap_id,
146 message="base",
147 committed_at_iso=ts.isoformat(),
148 )
149 write_commit(tmp_path, CommitRecord(
150 commit_id=cid, branch="main",
151 snapshot_id=snap_id, message="base", committed_at=ts,
152 ))
153 (muse / "refs" / "heads" / "main").write_text(cid)
154
155 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
156 monkeypatch.chdir(tmp_path)
157 return tmp_path
158
159
160 # ---------------------------------------------------------------------------
161 # Unit — dead code, parser flags, TypedDict
162 # ---------------------------------------------------------------------------
163
164 class TestDeadCodeRemoval:
165 def test_no_current_branch_wrapper(self) -> None:
166 import muse.cli.commands.pull as m
167 assert not hasattr(m, "_current_branch"), "_current_branch must be deleted"
168
169 def test_no_restore_from_manifest_wrapper(self) -> None:
170 import muse.cli.commands.pull as m
171 assert not hasattr(m, "_restore_from_manifest"), "_restore_from_manifest must be deleted"
172
173 def test_json_module_removed_or_used(self) -> None:
174 """json is imported and used (for json.dumps in run)."""
175 import muse.cli.commands.pull as m
176 import inspect
177 src = inspect.getsource(m)
178 assert "json.dumps" in src, "json module must be used"
179
180 def test_pull_json_typeddict_keys(self) -> None:
181 from muse.cli.commands.pull import _PullJson
182 required = {
183 "status", "remote", "branch", "local_branch",
184 "commits_received", "blobs_written", "head",
185 "conflict_paths", "dry_run",
186 }
187 assert required <= set(_PullJson.__annotations__.keys())
188
189
190 class TestRegisterFlags:
191 def _parse(self, *args: str) -> argparse.Namespace:
192 import argparse, muse.cli.commands.pull as m
193 p = argparse.ArgumentParser()
194 sub = p.add_subparsers()
195 m.register(sub)
196 return p.parse_args(["pull", *args])
197
198 def test_ff_only_flag(self) -> None:
199 ns = self._parse("--ff-only")
200 assert getattr(ns, "ff_only") is True
201
202 def test_dry_run_short(self) -> None:
203 ns = self._parse("-n")
204 assert getattr(ns, "dry_run") is True
205
206 def test_dry_run_long(self) -> None:
207 ns = self._parse("--dry-run")
208 assert getattr(ns, "dry_run") is True
209
210 def test_default_json_out_is_false(self) -> None:
211 ns = self._parse()
212 assert ns.json_out is False
213
214 def test_json_flag_sets_json_out(self) -> None:
215 ns = self._parse("--json")
216 assert ns.json_out is True
217
218 def test_j_shorthand_sets_json_out(self) -> None:
219 ns = self._parse("-j")
220 assert ns.json_out is True
221
222 def test_no_merge_flag(self) -> None:
223 ns = self._parse("--no-merge")
224 assert getattr(ns, "no_merge") is True
225
226 def test_message_flag(self) -> None:
227 ns = self._parse("-m", "custom msg")
228 assert getattr(ns, "message") == "custom msg"
229
230 def test_branch_flag(self) -> None:
231 ns = self._parse("-b", "dev")
232 assert getattr(ns, "branch_flag") == "dev"
233
234
235 # ---------------------------------------------------------------------------
236 # Integration — JSON schema, error routing
237 # ---------------------------------------------------------------------------
238
239 class _REQUIRED:
240 KEYS = {
241 "status", "remote", "branch", "local_branch",
242 "commits_received", "blobs_written", "head",
243 "conflict_paths", "dry_run",
244 }
245
246
247 class TestErrorRouting:
248 def test_remote_not_configured_to_stderr(self, repo: pathlib.Path) -> None:
249 r = runner.invoke(cli, ["pull", "no_such_remote"], env=_env(repo))
250 assert r.exit_code != 0
251 assert "not configured" in (r.stderr or "").lower()
252
253 def test_branch_not_on_remote_to_stderr(self, repo: pathlib.Path) -> None:
254 info = _make_remote_info({"main": "a" * 64})
255 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
256 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
257 with patch("muse.cli.commands.pull.make_transport") as mt:
258 mt.return_value.fetch_remote_info.return_value = info
259 r = runner.invoke(cli, ["pull", "origin", "--branch", "nonexistent"], env=_env(repo))
260 assert r.exit_code != 0
261 assert "does not exist" in (r.stderr or "").lower()
262
263 def test_fetch_transport_error_to_stderr(self, repo: pathlib.Path) -> None:
264 from muse.core.transport import TransportError
265 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
266 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
267 with patch("muse.cli.commands.pull.make_transport") as mt:
268 mt.return_value.fetch_remote_info.side_effect = TransportError("timeout", 503)
269 r = runner.invoke(cli, ["pull"], env=_env(repo))
270 assert r.exit_code != 0
271 assert "cannot reach" in (r.stderr or "").lower()
272
273 def test_fetch_mpack_error_to_stderr(self, repo: pathlib.Path) -> None:
274 from muse.core.transport import TransportError
275 info = _make_remote_info({"main": "b" * 64})
276 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
277 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
278 with patch("muse.cli.commands.pull.make_transport") as mt:
279 mt.return_value.fetch_remote_info.return_value = info
280 mt.return_value.fetch_mpack.side_effect = TransportError("fetch failed", 500)
281 r = runner.invoke(cli, ["pull"], env=_env(repo))
282 assert r.exit_code != 0
283 assert "fetch failed" in (r.stderr or "").lower()
284
285 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
286 r = runner.invoke(cli, ["pull", "--format", "xml"], env=_env(repo))
287 assert r.exit_code != 0
288
289 def test_already_up_to_date_to_stderr(self, repo: pathlib.Path) -> None:
290 """'Already up to date' must go to stderr, not stdout."""
291 from muse.core.refs import get_head_commit_id
292 head = get_head_commit_id(repo, "main") or ""
293 info = _make_remote_info({"main": head})
294 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
295 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
296 with patch("muse.cli.commands.pull.make_transport") as mt:
297 mt.return_value.fetch_remote_info.return_value = info
298 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
299 r = runner.invoke(cli, ["pull"], env=_env(repo))
300 assert r.exit_code == 0
301 assert "already up to date" in (r.stderr or "").lower()
302 # stdout must be empty (text mode)
303 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
304 assert len(json_lines) == 0
305
306
307 class TestJsonSchema:
308 def _run(
309 self,
310 repo: pathlib.Path,
311 extra_args: list[str] | None = None,
312 remote_head: str | None = None,
313 apply_result: _IntMap | None = None,
314 ) -> InvokeResult:
315 from muse.core.refs import get_head_commit_id
316 local_head = get_head_commit_id(repo, "main") or "a" * 64
317 rhead = remote_head or local_head
318 info = _make_remote_info({"main": rhead})
319 ar = apply_result or {"commits_written": 2, "snapshots_written": 1,
320 "blobs_written": 5, "blobs_skipped": 0}
321
322 blobs_count = ar.get("blobs_written", 5) if ar else 5
323
324 def _fetch_mpack(url: str, signing: None, want: list[str], have: list[str], on_object: Callable[..., None] | None = None, **kwargs: str) -> MsgpackDict:
325 if callable(on_object):
326 for i in range(blobs_count):
327 content = f"pull-blob-{i}".encode()
328 on_object({"object_id": blob_id(content), "content": content, "path": f"f{i}.txt"})
329 return {"commits": [], "snapshots": [], "blobs_received": blobs_count}
330
331 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
332 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
333 with patch("muse.cli.commands.pull.make_transport") as mt:
334 mt.return_value.fetch_remote_info.return_value = info
335 mt.return_value.fetch_mpack.side_effect = _fetch_mpack
336 with patch("muse.cli.commands.pull.apply_mpack", return_value=ar):
337 with patch("muse.cli.commands.pull.set_remote_head"):
338 return runner.invoke(
339 cli, ["pull", "--json"] + (extra_args or []),
340 env=_env(repo),
341 )
342
343 def test_up_to_date_schema(self, repo: pathlib.Path) -> None:
344 from muse.core.refs import get_head_commit_id
345 head = get_head_commit_id(repo, "main") or ""
346 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
347 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
348 with patch("muse.cli.commands.pull.make_transport") as mt:
349 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": head})
350 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
351 r = runner.invoke(cli, ["pull", "--json"], env=_env(repo))
352 assert r.exit_code == 0, r.output
353 d = _json_line(r)
354 assert _REQUIRED.KEYS <= d.keys()
355 assert d["status"] in ("up_to_date",)
356 assert d["commits_received"] == 0
357
358 def test_fetched_schema_no_merge(self, repo: pathlib.Path) -> None:
359 r = self._run(repo, extra_args=["--no-merge"], remote_head="b" * 64)
360 assert r.exit_code == 0, r.output
361 d = _json_line(r)
362 assert _REQUIRED.KEYS <= d.keys()
363 assert d["status"] == "fetched"
364 assert d["commits_received"] == 2
365 assert d["blobs_written"] == 5
366
367 def test_dry_run_schema(self, repo: pathlib.Path) -> None:
368 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
369 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
370 with patch("muse.cli.commands.pull.make_transport") as mt:
371 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": "b" * 64})
372 r = runner.invoke(cli, ["pull", "--dry-run", "--json"], env=_env(repo))
373 assert r.exit_code == 0, r.output
374 d = _json_line(r)
375 assert _REQUIRED.KEYS <= d.keys()
376 assert d["status"] == "dry_run"
377 assert d["dry_run"] is True
378 assert d["head"] is None
379
380
381 class TestFastForwardOrdering:
382 def test_apply_manifest_before_write_branch_ref(self, repo: pathlib.Path) -> None:
383 """apply_manifest must be called BEFORE write_branch_ref in fast-forward.
384
385 Uses muse code cat to confirm the ordering contract: apply_manifest first
386 so that a crash between the two operations leaves the working tree consistent
387 with the branch pointer (the tree is safe; the pointer not yet advanced).
388 """
389 from muse.core.refs import get_head_commit_id
390 from muse.core.commits import CommitRecord
391 from muse.core.snapshots import SnapshotRecord
392
393 local_head = get_head_commit_id(repo, "main") or ""
394 call_order: list[str] = []
395 remote_cid = "c" * 64
396 snap_id = "d" * 64
397
398 fake_commit = CommitRecord(
399 commit_id=remote_cid,
400 branch="main",
401 snapshot_id=snap_id,
402 message="remote",
403 committed_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc),
404 )
405 fake_snap = SnapshotRecord(
406 snapshot_id=snap_id,
407 manifest={"a.py": "e" * 64},
408 )
409
410 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
411 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
412 with patch("muse.cli.commands.pull.make_transport") as mt:
413 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
414 mt.return_value.fetch_pack.return_value = _make_bundle()
415 with patch("muse.cli.commands.pull.apply_mpack", return_value={
416 "commits_written": 1, "snapshots_written": 1,
417 "blobs_written": 2, "blobs_skipped": 0,
418 }):
419 with patch("muse.cli.commands.pull.set_remote_head"):
420 with patch("muse.cli.commands.pull.find_merge_base", return_value=local_head):
421 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
422 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
423 with patch(
424 "muse.cli.commands.pull.apply_manifest",
425 side_effect=lambda *a, **kw: call_order.append("apply"),
426 ):
427 with patch(
428 "muse.cli.commands.pull.write_branch_ref",
429 side_effect=lambda *a, **kw: call_order.append("write_ref"),
430 ):
431 runner.invoke(cli, ["pull"], env=_env(repo))
432
433 assert "apply" in call_order, "apply_manifest must be called in fast-forward path"
434 assert "write_ref" in call_order, "write_branch_ref must be called in fast-forward path"
435 assert call_order.index("apply") < call_order.index("write_ref"), (
436 "apply_manifest must happen BEFORE write_branch_ref in fast-forward"
437 )
438
439 def test_bootstrap_apply_manifest_before_write_branch_ref(self, repo: pathlib.Path) -> None:
440 """Same ordering contract in the bootstrap path (no local commits yet)."""
441 from muse.core.commits import CommitRecord
442 from muse.core.snapshots import SnapshotRecord
443
444 call_order: list[str] = []
445 remote_cid = "f" * 64
446 snap_id = "ab" * 32 # valid lowercase hex (64 chars)
447
448 fake_commit = CommitRecord(
449 commit_id=remote_cid,
450 branch="main",
451 snapshot_id=snap_id,
452 message="remote",
453 committed_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc),
454 )
455 fake_snap = SnapshotRecord(
456 snapshot_id=snap_id,
457 manifest={"a.py": "cd" * 32}, # valid lowercase hex object ID (64 chars)
458 )
459
460 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
461 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
462 with patch("muse.cli.commands.pull.make_transport") as mt:
463 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
464 mt.return_value.fetch_pack.return_value = _make_bundle()
465 mt.return_value.fetch_objects.return_value = []
466 with patch("muse.cli.commands.pull.apply_mpack", return_value={
467 "commits_written": 1, "snapshots_written": 1,
468 "blobs_written": 2, "blobs_skipped": 0,
469 }):
470 with patch("muse.cli.commands.pull.set_remote_head"):
471 # ours_commit_id is None → bootstrap path
472 with patch("muse.cli.commands.pull.get_head_commit_id", return_value=None):
473 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
474 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
475 with patch(
476 "muse.cli.commands.pull.apply_manifest",
477 side_effect=lambda *a, **kw: call_order.append("apply"),
478 ):
479 with patch(
480 "muse.cli.commands.pull.write_branch_ref",
481 side_effect=lambda *a, **kw: call_order.append("write_ref"),
482 ):
483 runner.invoke(cli, ["pull"], env=_env(repo))
484
485 assert "apply" in call_order, "apply_manifest must be called in bootstrap path"
486 assert "write_ref" in call_order, "write_branch_ref must be called in bootstrap path"
487 assert call_order.index("apply") < call_order.index("write_ref"), (
488 "apply_manifest must happen BEFORE write_branch_ref in bootstrap path"
489 )
490
491
492 class TestFFOnly:
493 def test_ff_only_diverged_exits_1(self, repo: pathlib.Path) -> None:
494 from muse.core.refs import get_head_commit_id
495 local_head = get_head_commit_id(repo, "main") or ""
496 remote_cid = "d" * 64
497
498 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
499 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
500 with patch("muse.cli.commands.pull.make_transport") as mt:
501 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
502 mt.return_value.fetch_pack.return_value = _make_bundle()
503 with patch("muse.cli.commands.pull.apply_mpack", return_value={
504 "commits_written": 1, "snapshots_written": 1,
505 "blobs_written": 1, "blobs_skipped": 0
506 }):
507 with patch("muse.cli.commands.pull.set_remote_head"):
508 # Simulate diverged: merge_base is neither ours nor theirs
509 with patch("muse.cli.commands.pull.find_merge_base", return_value="e" * 64):
510 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(repo))
511
512 assert r.exit_code == 1
513 assert "fast-forward" in (r.stderr or "").lower()
514
515 def test_ff_only_fast_forward_succeeds(self, repo: pathlib.Path) -> None:
516 from muse.core.refs import get_head_commit_id
517 local_head = get_head_commit_id(repo, "main") or ""
518 remote_cid = "f" * 64
519
520 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
521 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
522 with patch("muse.cli.commands.pull.make_transport") as mt:
523 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
524 mt.return_value.fetch_pack.return_value = _make_bundle()
525 with patch("muse.cli.commands.pull.apply_mpack", return_value={
526 "commits_written": 1, "snapshots_written": 1,
527 "blobs_written": 1, "blobs_skipped": 0
528 }):
529 with patch("muse.cli.commands.pull.set_remote_head"):
530 with patch("muse.cli.commands.pull.find_merge_base", return_value=local_head):
531 fake_commit = MagicMock()
532 fake_commit.snapshot_id = "a" * 64
533 fake_snap = MagicMock()
534 fake_snap.manifest = {}
535 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
536 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
537 with patch("muse.cli.commands.pull.apply_manifest"):
538 with patch("muse.cli.commands.pull.write_branch_ref"):
539 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(repo))
540
541 assert r.exit_code == 0, r.output
542
543
544 class TestCommitsReceivedFromApplyResult:
545 def test_commits_received_uses_commits_written(self, repo: pathlib.Path) -> None:
546 """commits_received in JSON must come from apply_mpack result, not mpack length."""
547 remote_cid = "g" * 64
548 info = _make_remote_info({"main": remote_cid})
549 ar = {"commits_written": 7, "snapshots_written": 7,
550 "blobs_written": 21, "blobs_skipped": 0}
551
552 def _fetch_mpack(url: str, signing: None, want: list[str], have: list[str], on_object: Callable[..., None] | None = None, **kwargs: str) -> MsgpackDict:
553 if callable(on_object):
554 for i in range(21):
555 content = f"pull-blob-{i}".encode()
556 on_object({"object_id": blob_id(content), "content": content, "path": f"f{i}.txt"})
557 return {"commits": [], "snapshots": [], "blobs_received": 21}
558
559 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
560 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
561 with patch("muse.cli.commands.pull.make_transport") as mt:
562 mt.return_value.fetch_remote_info.return_value = info
563 mt.return_value.fetch_mpack.side_effect = _fetch_mpack
564 with patch("muse.cli.commands.pull.apply_mpack", return_value=ar):
565 with patch("muse.cli.commands.pull.set_remote_head"):
566 r = runner.invoke(
567 cli, ["pull", "--no-merge", "--json"], env=_env(repo)
568 )
569 assert r.exit_code == 0, r.output
570 d = _json_line(r)
571 assert d["commits_received"] == 7
572 assert d["blobs_written"] == 21
573
574
575 # ---------------------------------------------------------------------------
576 # End-to-end with mocked transport (presign+mpack architecture)
577 # ---------------------------------------------------------------------------
578
579 class TestEndToEnd:
580 """End-to-end pull tests using mocked transport."""
581
582 def _make_transport_mock(self, remote_head: str) -> MagicMock:
583 t = MagicMock()
584 t.fetch_remote_info.return_value = _make_remote_info({"main": remote_head})
585 t.fetch_mpack.return_value = {"commits": [], "snapshots": [], "blobs_received": 0}
586 return t
587
588 def test_pull_no_merge_fetches(self, repo: pathlib.Path) -> None:
589 remote_cid = "b" * 64
590 t = self._make_transport_mock(remote_cid)
591 ar = {"commits_written": 1, "snapshots_written": 1, "blobs_written": 0, "blobs_skipped": 0}
592 with (
593 patch("muse.cli.commands.pull.get_remote", return_value="https://hub.example.com"),
594 patch("muse.cli.commands.pull.get_signing_identity", return_value=None),
595 patch("muse.cli.commands.pull.make_transport", return_value=t),
596 patch("muse.cli.commands.pull.apply_mpack", return_value=ar),
597 patch("muse.cli.commands.pull.set_remote_head"),
598 ):
599 r = runner.invoke(cli, ["pull", "--no-merge"], env=_env(repo), catch_exceptions=False)
600 assert r.exit_code == 0, r.output
601 t.fetch_mpack.assert_called_once()
602
603 def test_pull_json_fetched_schema(self, repo: pathlib.Path) -> None:
604 remote_cid = "b" * 64
605 t = self._make_transport_mock(remote_cid)
606 ar = {"commits_written": 1, "snapshots_written": 1, "blobs_written": 0, "blobs_skipped": 0}
607 with (
608 patch("muse.cli.commands.pull.get_remote", return_value="https://hub.example.com"),
609 patch("muse.cli.commands.pull.get_signing_identity", return_value=None),
610 patch("muse.cli.commands.pull.make_transport", return_value=t),
611 patch("muse.cli.commands.pull.apply_mpack", return_value=ar),
612 patch("muse.cli.commands.pull.set_remote_head"),
613 ):
614 r = runner.invoke(
615 cli, ["pull", "--no-merge", "--json"], env=_env(repo), catch_exceptions=False
616 )
617 assert r.exit_code == 0, r.output
618 d = _json_line(r)
619 assert _REQUIRED.KEYS <= d.keys()
620 assert d["status"] == "fetched"
621
622 def test_dry_run_no_side_effects(self, repo: pathlib.Path) -> None:
623 from muse.core.refs import get_head_commit_id
624 remote_cid = "b" * 64
625 head_before = get_head_commit_id(repo, "main")
626 t = self._make_transport_mock(remote_cid)
627 with (
628 patch("muse.cli.commands.pull.get_remote", return_value="https://hub.example.com"),
629 patch("muse.cli.commands.pull.get_signing_identity", return_value=None),
630 patch("muse.cli.commands.pull.make_transport", return_value=t),
631 ):
632 r = runner.invoke(cli, ["pull", "--dry-run"], env=_env(repo), catch_exceptions=False)
633 assert r.exit_code == 0, r.output
634 head_after = get_head_commit_id(repo, "main")
635 assert head_before == head_after, "dry-run must not advance local HEAD"
636 t.fetch_mpack.assert_not_called()
637
638 def test_dry_run_json_schema(self, repo: pathlib.Path) -> None:
639 remote_cid = "b" * 64
640 t = self._make_transport_mock(remote_cid)
641 with (
642 patch("muse.cli.commands.pull.get_remote", return_value="https://hub.example.com"),
643 patch("muse.cli.commands.pull.get_signing_identity", return_value=None),
644 patch("muse.cli.commands.pull.make_transport", return_value=t),
645 ):
646 r = runner.invoke(
647 cli, ["pull", "--dry-run", "--json"], env=_env(repo), catch_exceptions=False
648 )
649 assert r.exit_code == 0, r.output
650 d = _json_line(r)
651 assert _REQUIRED.KEYS <= d.keys()
652 assert d["dry_run"] is True
653
654
655 # ---------------------------------------------------------------------------
656 # Security
657 # ---------------------------------------------------------------------------
658
659 class TestSecurity:
660 def test_remote_name_ansi_sanitized(self, repo: pathlib.Path) -> None:
661 ansi = "\x1b[31mmalicious\x1b[0m"
662 r = runner.invoke(cli, ["pull", ansi], env=_env(repo))
663 assert r.exit_code != 0
664 assert "\x1b[31m" not in (r.stderr or "")
665
666 def test_branch_name_sanitized_in_not_found(self, repo: pathlib.Path) -> None:
667 info = _make_remote_info({"main": "a" * 64})
668 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
669 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
670 with patch("muse.cli.commands.pull.make_transport") as mt:
671 mt.return_value.fetch_remote_info.return_value = info
672 r = runner.invoke(
673 cli, ["pull", "origin", "--branch", "\x1b[31mmalicious\x1b[0m"],
674 env=_env(repo),
675 )
676 assert "\x1b[31m" not in (r.stderr or "")
677 assert "\x1b[31m" not in r.output
678
679 def test_progress_not_in_stdout_on_json(self, repo: pathlib.Path) -> None:
680 """--json: stdout must contain exactly one JSON line, no mixed progress."""
681 from muse.core.refs import get_head_commit_id
682 head = get_head_commit_id(repo, "main") or ""
683 info = _make_remote_info({"main": head})
684 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
685 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
686 with patch("muse.cli.commands.pull.make_transport") as mt:
687 mt.return_value.fetch_remote_info.return_value = info
688 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
689 r = runner.invoke(cli, ["pull", "--json"], env=_env(repo))
690 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
691 assert len(json_lines) == 1
692 json.loads(json_lines[0]) # must be valid JSON
693
694 def test_unknown_flag_exits_nonzero_yaml(self, repo: pathlib.Path) -> None:
695 r = runner.invoke(cli, ["pull", "--format", "yaml"], env=_env(repo))
696 assert r.exit_code != 0
697
698 def test_conflict_paths_sanitized_in_text(self, repo: pathlib.Path) -> None:
699 """File paths in CONFLICT lines must be run through sanitize_display."""
700 from muse.core.refs import get_head_commit_id
701 local_head = get_head_commit_id(repo, "main") or ""
702 remote_cid = "h" * 64
703
704 malicious_path = "\x1b[31mmalicious.py\x1b[0m"
705
706 from unittest.mock import MagicMock as MM
707 merge_result = MM()
708 merge_result.is_clean = False
709 merge_result.conflicts = {malicious_path}
710 merge_result.applied_strategies = {}
711
712 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
713 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
714 with patch("muse.cli.commands.pull.make_transport") as mt:
715 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
716 mt.return_value.fetch_pack.return_value = _make_bundle()
717 with patch("muse.cli.commands.pull.apply_mpack", return_value={
718 "commits_written": 1, "snapshots_written": 1,
719 "blobs_written": 1, "blobs_skipped": 0
720 }):
721 with patch("muse.cli.commands.pull.set_remote_head"):
722 with patch("muse.cli.commands.pull.find_merge_base", return_value="z" * 64):
723 with patch("muse.cli.commands.pull.resolve_plugin") as rp:
724 with patch("muse.cli.commands.pull.read_domain", return_value="code"):
725 plugin = MM()
726 plugin.__class__ = type("P", (), {"merge": None})
727 rp.return_value = plugin
728 plugin.merge.return_value = merge_result
729 with patch("muse.cli.commands.pull.write_merge_state"):
730 r = runner.invoke(cli, ["pull"], env=_env(repo))
731
732 assert "\x1b[31m" not in (r.stderr or "")
733 assert "\x1b[31m" not in r.output
734
735
File History 2 commits
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 20 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 73 days ago