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