gabriel / muse public
test_dev_guard.py python
158 lines 7.3 KB
Raw
sha256:2a8dfcf895b528326eedf00a21babac1a18d90176b856ea1e0fb3027b39be0da feat(dev-safety): Phase 3 of #185 — guard rail blocks mutat… Sonnet 5 patch 2 days ago
1 """Phase 3 of #185 (musehub staging): guard rail against mutating an editable
2 build's own canonical repo.
3
4 Unit tests exercise the classifier functions directly. The integration test
5 runs the real `muse-dev` binary end-to-end but points `MUSE_DEV_PROTECTED_ROOTS`
6 at a disposable temp repo, never the real ~/ecosystem/muse — this guard rail
7 must never be integration-tested against the actual canonical repo it exists
8 to protect.
9 """
10 import argparse
11 import os
12 import subprocess
13 import sys
14 from pathlib import Path
15
16 import pytest
17
18 sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "muse" / "cli"))
19 from dev_guard import ( # noqa: E402
20 GuardBlocked,
21 check_guard,
22 classify_is_mutating,
23 is_protected_root,
24 )
25
26
27 def _ns(**kwargs) -> argparse.Namespace:
28 return argparse.Namespace(**kwargs)
29
30
31 class TestIsProtectedRoot:
32 def test_exact_match_is_protected(self, tmp_path: Path) -> None:
33 assert is_protected_root(str(tmp_path), protected_roots=[str(tmp_path)])
34
35 def test_subdirectory_of_protected_root_is_protected(self, tmp_path: Path) -> None:
36 sub = tmp_path / "nested" / "deep"
37 sub.mkdir(parents=True)
38 assert is_protected_root(str(sub), protected_roots=[str(tmp_path)])
39
40 def test_sibling_path_is_not_protected(self, tmp_path: Path) -> None:
41 sibling = tmp_path.parent / "not-protected"
42 assert not is_protected_root(str(sibling), protected_roots=[str(tmp_path)])
43
44 def test_unrelated_third_party_repo_is_not_protected(self, tmp_path: Path) -> None:
45 assert not is_protected_root("/some/other/repo", protected_roots=[str(tmp_path)])
46
47
48 class TestClassifyIsMutating:
49 @pytest.mark.parametrize("command", ["commit", "merge", "push", "pull", "reset", "rm", "revert", "rebase", "gc", "prune", "clean"])
50 def test_known_mutating_top_level_commands(self, command: str) -> None:
51 assert classify_is_mutating(_ns(command=command))
52
53 @pytest.mark.parametrize("command", ["status", "log", "diff", "branch", "rev-parse"])
54 def test_known_readonly_top_level_commands(self, command: str) -> None:
55 assert not classify_is_mutating(_ns(command=command))
56
57 def test_code_grep_is_readonly(self) -> None:
58 assert not classify_is_mutating(_ns(command="code", code_command="grep"))
59
60 def test_code_add_is_mutating(self) -> None:
61 assert classify_is_mutating(_ns(command="code", code_command="add"))
62
63 def test_code_impact_is_readonly(self) -> None:
64 assert not classify_is_mutating(_ns(command="code", code_command="impact"))
65
66 def test_code_patch_is_mutating(self) -> None:
67 assert classify_is_mutating(_ns(command="code", code_command="patch"))
68
69
70 class TestCheckGuard:
71 def test_mutating_command_against_protected_root_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
72 monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False)
73 with pytest.raises(GuardBlocked):
74 check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True)
75
76 def test_mutating_command_with_override_env_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
77 monkeypatch.setenv("MUSE_DEV_ALLOW_CANONICAL", "1")
78 check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True) # must not raise
79
80 def test_readonly_command_against_protected_root_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
81 monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False)
82 check_guard(_ns(command="status"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True) # must not raise
83
84 def test_mutating_command_against_unprotected_root_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
85 monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False)
86 other = tmp_path / "sandbox"
87 other.mkdir()
88 check_guard(_ns(command="commit"), cwd=str(other), protected_roots=[str(tmp_path / "protected")], is_editable=True) # must not raise
89
90 def test_stable_non_editable_build_is_never_blocked(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
91 monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False)
92 # A stable (non-editable) `muse` build has no live-source hazard —
93 # the guard only ever applies to editable/dev builds.
94 check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=False) # must not raise
95
96
97 class TestRealMuseDevBinaryIntegration:
98 """Exercises the real `muse-dev` binary — but only against a disposable
99 temp repo pointed to via MUSE_DEV_PROTECTED_ROOTS, never the real checkout."""
100
101 def _muse_dev_path(self) -> str | None:
102 proc = subprocess.run(["zsh", "-lc", "command -v muse-dev"], capture_output=True, text=True)
103 return proc.stdout.strip() or None
104
105 def test_commit_blocked_against_fake_protected_root(self, tmp_path: Path) -> None:
106 muse_dev = self._muse_dev_path()
107 if muse_dev is None:
108 pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)")
109
110 repo = tmp_path / "fake-canonical-repo"
111 repo.mkdir()
112 subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True)
113 (repo / "file.txt").write_text("hello\n")
114 subprocess.run([muse_dev, "code", "add", "."], cwd=repo, capture_output=True, text=True, check=True)
115
116 env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo))
117 env.pop("MUSE_DEV_ALLOW_CANONICAL", None)
118 proc = subprocess.run(
119 [muse_dev, "commit", "-m", "should be blocked"],
120 cwd=repo, capture_output=True, text=True, env=env,
121 )
122
123 assert proc.returncode != 0
124 assert "MUSE_DEV_ALLOW_CANONICAL" in (proc.stdout + proc.stderr)
125
126 def test_commit_allowed_with_override(self, tmp_path: Path) -> None:
127 muse_dev = self._muse_dev_path()
128 if muse_dev is None:
129 pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)")
130
131 repo = tmp_path / "fake-canonical-repo-override"
132 repo.mkdir()
133 subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True)
134 (repo / "file.txt").write_text("hello\n")
135 subprocess.run([muse_dev, "code", "add", "."], cwd=repo, capture_output=True, text=True, check=True)
136
137 env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo), MUSE_DEV_ALLOW_CANONICAL="1")
138 proc = subprocess.run(
139 [muse_dev, "commit", "-m", "should be allowed"],
140 cwd=repo, capture_output=True, text=True, env=env,
141 )
142
143 assert proc.returncode == 0, proc.stderr
144
145 def test_status_never_blocked_against_fake_protected_root(self, tmp_path: Path) -> None:
146 muse_dev = self._muse_dev_path()
147 if muse_dev is None:
148 pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)")
149
150 repo = tmp_path / "fake-canonical-repo-readonly"
151 repo.mkdir()
152 subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True)
153
154 env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo))
155 env.pop("MUSE_DEV_ALLOW_CANONICAL", None)
156 proc = subprocess.run([muse_dev, "status"], cwd=repo, capture_output=True, text=True, env=env)
157
158 assert proc.returncode == 0, proc.stderr
File History 1 commit
sha256:2a8dfcf895b528326eedf00a21babac1a18d90176b856ea1e0fb3027b39be0da feat(dev-safety): Phase 3 of #185 — guard rail blocks mutat… Sonnet 5 patch 2 days ago