cgcardona / muse public
test_cli_plugin_dispatch.py python
461 lines 17.3 KB
8d5137ed fix(security): full surface hardening — validation, path containment, p… Gabriel Cardona <cgcardona@gmail.com> 8h ago
1 """Integration tests verifying CLI commands dispatch through the domain plugin.
2
3 Each test confirms that the relevant plugin method is called when the CLI
4 command runs, and that the command's output matches the plugin's semantics.
5 These tests use unittest.mock.patch to intercept plugin calls and also
6 perform end-to-end output assertions.
7 """
8
9 import pathlib
10 from unittest.mock import MagicMock, patch
11
12 import pytest
13 from typer.testing import CliRunner
14
15 from muse.cli.app import cli
16 from muse.domain import (
17 DeleteOp,
18 DriftReport,
19 InsertOp,
20 LiveState,
21 MergeResult,
22 MuseDomainPlugin,
23 SnapshotManifest,
24 StateSnapshot,
25 StructuredDelta,
26 )
27 from muse.plugins.midi.plugin import MidiPlugin
28
29 runner = CliRunner()
30
31
32 @pytest.fixture
33 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
34 """Initialise a fresh Muse repo in tmp_path and set it as cwd."""
35 monkeypatch.chdir(tmp_path)
36 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
37 result = runner.invoke(cli, ["init"])
38 assert result.exit_code == 0, result.output
39 return tmp_path
40
41
42 def _write(repo: pathlib.Path, filename: str, content: str = "data") -> None:
43 (repo / "muse-work" / filename).write_text(content)
44
45
46 def _commit(msg: str = "initial") -> None:
47 result = runner.invoke(cli, ["commit", "-m", msg])
48 assert result.exit_code == 0, result.output
49
50
51 # ---------------------------------------------------------------------------
52 # commit
53 # ---------------------------------------------------------------------------
54
55
56 class TestCommitDispatch:
57 def test_commit_calls_plugin_snapshot(self, repo: pathlib.Path) -> None:
58 _write(repo, "beat.mid", "drums")
59 with patch("muse.cli.commands.commit.resolve_plugin") as mock_resolve:
60 real_plugin = MidiPlugin()
61 mock_plugin = MagicMock(spec=MuseDomainPlugin)
62 mock_plugin.snapshot.side_effect = real_plugin.snapshot
63 mock_resolve.return_value = mock_plugin
64
65 result = runner.invoke(cli, ["commit", "-m", "test"])
66 assert result.exit_code == 0, result.output
67 mock_plugin.snapshot.assert_called_once()
68
69 def test_commit_snapshot_argument_is_workdir_path(self, repo: pathlib.Path) -> None:
70 _write(repo, "beat.mid", "drums")
71 captured_args: list[LiveState] = []
72
73 with patch("muse.cli.commands.commit.resolve_plugin") as mock_resolve:
74 real_plugin = MidiPlugin()
75 mock_plugin = MagicMock(spec=MuseDomainPlugin)
76
77 def capture_snapshot(live_state: LiveState) -> SnapshotManifest:
78 captured_args.append(live_state)
79 return real_plugin.snapshot(live_state)
80
81 mock_plugin.snapshot.side_effect = capture_snapshot
82 mock_resolve.return_value = mock_plugin
83
84 runner.invoke(cli, ["commit", "-m", "test"])
85 assert len(captured_args) == 1
86 assert isinstance(captured_args[0], pathlib.Path)
87 assert captured_args[0].name == "muse-work"
88
89 def test_commit_uses_snapshot_files_for_manifest(self, repo: pathlib.Path) -> None:
90 _write(repo, "track.mid", "content")
91 result = runner.invoke(cli, ["commit", "-m", "via plugin"])
92 assert result.exit_code == 0
93 assert "via plugin" in result.output
94
95
96 # ---------------------------------------------------------------------------
97 # status
98 # ---------------------------------------------------------------------------
99
100
101 class TestStatusDispatch:
102 def test_status_calls_plugin_drift(self, repo: pathlib.Path) -> None:
103 _write(repo, "beat.mid")
104 _commit()
105 _write(repo, "new.mid", "extra")
106
107 with patch("muse.cli.commands.status.resolve_plugin") as mock_resolve:
108 real_plugin = MidiPlugin()
109 mock_plugin = MagicMock(spec=MuseDomainPlugin)
110 mock_plugin.drift.side_effect = real_plugin.drift
111 mock_resolve.return_value = mock_plugin
112
113 result = runner.invoke(cli, ["status"])
114 assert result.exit_code == 0
115 mock_plugin.drift.assert_called_once()
116
117 def test_status_clean_tree_via_drift(self, repo: pathlib.Path) -> None:
118 _write(repo, "beat.mid")
119 _commit()
120 result = runner.invoke(cli, ["status"])
121 assert result.exit_code == 0
122 assert "clean" in result.output
123
124 def test_status_shows_new_file(self, repo: pathlib.Path) -> None:
125 _write(repo, "beat.mid")
126 _commit()
127 _write(repo, "new.mid", "extra")
128 result = runner.invoke(cli, ["status"])
129 assert result.exit_code == 0
130 assert "new.mid" in result.output
131
132 def test_status_shows_deleted_file(self, repo: pathlib.Path) -> None:
133 _write(repo, "beat.mid")
134 _commit()
135 (repo / "muse-work" / "beat.mid").unlink()
136 result = runner.invoke(cli, ["status"])
137 assert result.exit_code == 0
138 assert "beat.mid" in result.output
139
140 def test_status_drift_report_drives_output(self, repo: pathlib.Path) -> None:
141 """Patch drift() to return a controlled DriftReport and verify CLI echoes it."""
142 _write(repo, "beat.mid")
143 _commit()
144
145 fake_delta = StructuredDelta(
146 domain="midi",
147 ops=[InsertOp(op="insert", address="injected.mid", position=None,
148 content_id="abc123", content_summary="new file: injected.mid")],
149 summary="1 file added",
150 )
151 fake_report = DriftReport(has_drift=True, summary="1 added", delta=fake_delta)
152
153 with patch("muse.cli.commands.status.resolve_plugin") as mock_resolve:
154 mock_plugin = MagicMock(spec=MuseDomainPlugin)
155 mock_plugin.drift.return_value = fake_report
156 mock_resolve.return_value = mock_plugin
157
158 result = runner.invoke(cli, ["status"])
159 assert "injected.mid" in result.output
160
161
162 # ---------------------------------------------------------------------------
163 # diff
164 # ---------------------------------------------------------------------------
165
166
167 class TestDiffDispatch:
168 def test_diff_calls_plugin_diff(self, repo: pathlib.Path) -> None:
169 _write(repo, "beat.mid")
170 _commit()
171 _write(repo, "lead.mid", "solo")
172
173 with patch("muse.cli.commands.diff.resolve_plugin") as mock_resolve:
174 real_plugin = MidiPlugin()
175 mock_plugin = MagicMock(spec=MuseDomainPlugin)
176 mock_plugin.snapshot.side_effect = real_plugin.snapshot
177 mock_plugin.diff.side_effect = real_plugin.diff
178 mock_resolve.return_value = mock_plugin
179
180 result = runner.invoke(cli, ["diff"])
181 assert result.exit_code == 0
182 mock_plugin.snapshot.assert_called_once()
183 mock_plugin.diff.assert_called_once()
184
185 def test_diff_calls_plugin_snapshot_for_workdir(self, repo: pathlib.Path) -> None:
186 _write(repo, "beat.mid")
187 _commit()
188 _write(repo, "extra.mid", "new")
189
190 captured: list[LiveState] = []
191 with patch("muse.cli.commands.diff.resolve_plugin") as mock_resolve:
192 real_plugin = MidiPlugin()
193 mock_plugin = MagicMock(spec=MuseDomainPlugin)
194
195 def cap_snapshot(ls: LiveState) -> SnapshotManifest:
196 captured.append(ls)
197 return real_plugin.snapshot(ls)
198
199 mock_plugin.snapshot.side_effect = cap_snapshot
200 mock_plugin.diff.side_effect = real_plugin.diff
201 mock_resolve.return_value = mock_plugin
202
203 runner.invoke(cli, ["diff"])
204 assert any(isinstance(a, pathlib.Path) for a in captured)
205
206 def test_diff_shows_added_file(self, repo: pathlib.Path) -> None:
207 _write(repo, "beat.mid")
208 _commit()
209 _write(repo, "new.mid", "extra")
210 result = runner.invoke(cli, ["diff"])
211 assert result.exit_code == 0
212 assert "new.mid" in result.output
213
214 def test_diff_no_differences(self, repo: pathlib.Path) -> None:
215 _write(repo, "beat.mid")
216 _commit()
217 result = runner.invoke(cli, ["diff"])
218 assert result.exit_code == 0
219 assert "No differences" in result.output
220
221 def test_diff_delta_drives_output(self, repo: pathlib.Path) -> None:
222 """Patch plugin.diff() to return a controlled delta and verify CLI output."""
223 _write(repo, "beat.mid")
224 _commit()
225
226 fake_delta = StructuredDelta(
227 domain="midi",
228 ops=[
229 InsertOp(op="insert", address="injected.mid", position=None,
230 content_id="abc123", content_summary="new file: injected.mid"),
231 DeleteOp(op="delete", address="gone.mid", position=None,
232 content_id="def456", content_summary="deleted: gone.mid"),
233 ],
234 summary="1 file added, 1 file removed",
235 )
236 with patch("muse.cli.commands.diff.resolve_plugin") as mock_resolve:
237 real_plugin = MidiPlugin()
238 mock_plugin = MagicMock(spec=MuseDomainPlugin)
239 mock_plugin.snapshot.side_effect = real_plugin.snapshot
240 mock_plugin.diff.return_value = fake_delta
241 mock_resolve.return_value = mock_plugin
242
243 result = runner.invoke(cli, ["diff"])
244 assert "injected.mid" in result.output
245 assert "gone.mid" in result.output
246
247
248 # ---------------------------------------------------------------------------
249 # merge
250 # ---------------------------------------------------------------------------
251
252
253 class TestMergeDispatch:
254 def test_merge_calls_plugin_merge(self, repo: pathlib.Path) -> None:
255 _write(repo, "beat.mid", "v1")
256 _commit("base")
257
258 runner.invoke(cli, ["branch", "feature"])
259 runner.invoke(cli, ["checkout", "feature"])
260 _write(repo, "lead.mid", "solo")
261 _commit("add lead")
262
263 runner.invoke(cli, ["checkout", "main"])
264 # Add a commit on main so both branches have diverged — forces a real merge.
265 _write(repo, "bass.mid", "bass line")
266 _commit("add bass on main")
267
268 with patch("muse.cli.commands.merge.resolve_plugin") as mock_resolve:
269 real_plugin = MidiPlugin()
270 mock_plugin = MagicMock(spec=MuseDomainPlugin)
271 mock_plugin.merge.side_effect = real_plugin.merge
272 mock_resolve.return_value = mock_plugin
273
274 result = runner.invoke(cli, ["merge", "feature"])
275 assert result.exit_code == 0
276 mock_plugin.merge.assert_called_once()
277
278 def test_merge_plugin_merge_result_drives_outcome(self, repo: pathlib.Path) -> None:
279 _write(repo, "beat.mid", "v1")
280 _commit("base")
281
282 runner.invoke(cli, ["branch", "feature"])
283 runner.invoke(cli, ["checkout", "feature"])
284 _write(repo, "lead.mid", "solo")
285 _commit("add lead")
286
287 runner.invoke(cli, ["checkout", "main"])
288 _write(repo, "bass.mid", "bass line")
289 _commit("add bass on main")
290
291 fake_result = MergeResult(
292 merged=SnapshotManifest(files={"injected.mid": "a" * 64}, domain="midi"),
293 conflicts=[],
294 )
295 with patch("muse.cli.commands.merge.resolve_plugin") as mock_resolve:
296 mock_plugin = MagicMock(spec=MuseDomainPlugin)
297 mock_plugin.merge.return_value = fake_result
298 mock_resolve.return_value = mock_plugin
299
300 result = runner.invoke(cli, ["merge", "feature"])
301 assert result.exit_code == 0
302 assert "Merged" in result.output
303
304 def test_merge_conflict_uses_plugin_conflict_paths(self, repo: pathlib.Path) -> None:
305 _write(repo, "beat.mid", "original")
306 _commit("base")
307
308 runner.invoke(cli, ["branch", "feature"])
309 runner.invoke(cli, ["checkout", "feature"])
310 _write(repo, "beat.mid", "feature-version")
311 _commit("feature changes beat")
312
313 runner.invoke(cli, ["checkout", "main"])
314 _write(repo, "beat.mid", "main-version")
315 _commit("main changes beat")
316
317 result = runner.invoke(cli, ["merge", "feature"])
318 assert result.exit_code != 0
319 assert "beat.mid" in result.output
320
321 def test_merge_conflict_paths_come_from_plugin(self, repo: pathlib.Path) -> None:
322 _write(repo, "beat.mid", "original")
323 _commit("base")
324 runner.invoke(cli, ["branch", "feature"])
325 runner.invoke(cli, ["checkout", "feature"])
326 _write(repo, "beat.mid", "feature-version")
327 _commit("feature")
328 runner.invoke(cli, ["checkout", "main"])
329 _write(repo, "beat.mid", "main-version")
330 _commit("main")
331
332 fake_result = MergeResult(
333 merged=SnapshotManifest(files={}, domain="midi"),
334 conflicts=["plugin-conflict.mid"],
335 )
336 with patch("muse.cli.commands.merge.resolve_plugin") as mock_resolve:
337 mock_plugin = MagicMock(spec=MuseDomainPlugin)
338 mock_plugin.merge.return_value = fake_result
339 mock_resolve.return_value = mock_plugin
340
341 result = runner.invoke(cli, ["merge", "feature"])
342 assert result.exit_code != 0
343 assert "plugin-conflict.mid" in result.output
344
345
346 # ---------------------------------------------------------------------------
347 # cherry-pick
348 # ---------------------------------------------------------------------------
349
350
351 class TestCherryPickDispatch:
352 def test_cherry_pick_calls_plugin_merge(self, repo: pathlib.Path) -> None:
353 _write(repo, "beat.mid", "v1")
354 _commit("initial")
355
356 runner.invoke(cli, ["branch", "feature"])
357 runner.invoke(cli, ["checkout", "feature"])
358 _write(repo, "lead.mid", "solo")
359 _commit("add lead on feature")
360
361 from muse.core.store import get_head_commit_id
362 from muse.core.repo import require_repo
363 import os
364 os.chdir(repo)
365 feature_tip = get_head_commit_id(repo, "feature")
366 assert feature_tip is not None
367
368 runner.invoke(cli, ["checkout", "main"])
369
370 with patch("muse.cli.commands.cherry_pick.resolve_plugin") as mock_resolve:
371 real_plugin = MidiPlugin()
372 mock_plugin = MagicMock(spec=MuseDomainPlugin)
373 mock_plugin.merge.side_effect = real_plugin.merge
374 mock_resolve.return_value = mock_plugin
375
376 result = runner.invoke(cli, ["cherry-pick", feature_tip])
377 assert result.exit_code == 0, result.output
378 mock_plugin.merge.assert_called_once()
379
380 def test_cherry_pick_three_way_args_are_snapshot_manifests(
381 self, repo: pathlib.Path
382 ) -> None:
383 _write(repo, "beat.mid", "v1")
384 _commit("initial")
385
386 runner.invoke(cli, ["branch", "feature"])
387 runner.invoke(cli, ["checkout", "feature"])
388 _write(repo, "lead.mid", "solo")
389 _commit("add lead")
390
391 import os
392 os.chdir(repo)
393 from muse.core.store import get_head_commit_id
394 feature_tip = get_head_commit_id(repo, "feature")
395 assert feature_tip is not None
396
397 runner.invoke(cli, ["checkout", "main"])
398
399 captured_args: list[tuple[StateSnapshot, StateSnapshot, StateSnapshot]] = []
400 with patch("muse.cli.commands.cherry_pick.resolve_plugin") as mock_resolve:
401 real_plugin = MidiPlugin()
402 mock_plugin = MagicMock(spec=MuseDomainPlugin)
403
404 def cap_merge(
405 base: StateSnapshot, left: StateSnapshot, right: StateSnapshot
406 ) -> MergeResult:
407 captured_args.append((base, left, right))
408 return real_plugin.merge(base, left, right)
409
410 mock_plugin.merge.side_effect = cap_merge
411 mock_resolve.return_value = mock_plugin
412
413 runner.invoke(cli, ["cherry-pick", feature_tip])
414 assert len(captured_args) == 1
415 base, left, right = captured_args[0]
416 assert isinstance(base, dict) and "files" in base
417 assert isinstance(left, dict) and "files" in left
418 assert isinstance(right, dict) and "files" in right
419
420
421 # ---------------------------------------------------------------------------
422 # stash
423 # ---------------------------------------------------------------------------
424
425
426 class TestStashDispatch:
427 def test_stash_calls_plugin_snapshot(self, repo: pathlib.Path) -> None:
428 _write(repo, "beat.mid")
429 _commit()
430 _write(repo, "unsaved.mid", "wip")
431
432 with patch("muse.cli.commands.stash.resolve_plugin") as mock_resolve:
433 real_plugin = MidiPlugin()
434 mock_plugin = MagicMock(spec=MuseDomainPlugin)
435 mock_plugin.snapshot.side_effect = real_plugin.snapshot
436 mock_resolve.return_value = mock_plugin
437
438 result = runner.invoke(cli, ["stash"])
439 assert result.exit_code == 0
440 mock_plugin.snapshot.assert_called_once()
441
442 def test_stash_snapshot_argument_is_workdir_path(self, repo: pathlib.Path) -> None:
443 _write(repo, "beat.mid")
444 _commit()
445 _write(repo, "unsaved.mid", "wip")
446
447 captured: list[LiveState] = []
448 with patch("muse.cli.commands.stash.resolve_plugin") as mock_resolve:
449 real_plugin = MidiPlugin()
450 mock_plugin = MagicMock(spec=MuseDomainPlugin)
451
452 def cap_snapshot(ls: LiveState) -> SnapshotManifest:
453 captured.append(ls)
454 return real_plugin.snapshot(ls)
455
456 mock_plugin.snapshot.side_effect = cap_snapshot
457 mock_resolve.return_value = mock_plugin
458
459 runner.invoke(cli, ["stash"])
460 assert len(captured) == 1
461 assert isinstance(captured[0], pathlib.Path)