gabriel / muse public
test_cmd_stash.py python
225 lines 9.0 KB
86000da9 fix: replace typer CliRunner with argparse-compatible test helper Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """Comprehensive tests for ``muse stash``.
2
3 Covers:
4 - Unit: _load_stash / _save_stash atomic write, size guard
5 - Integration: stash → pop, list, drop
6 - E2E: full CLI via CliRunner
7 - Security: stash.json size limit, atomic writes, sanitized output
8 - Stress: many stash entries, repeated save/load
9 """
10
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import pathlib
16 import uuid
17
18 import pytest
19 from tests.cli_test_helper import CliRunner
20
21 cli = None # argparse migration — CliRunner ignores this arg
22
23 runner = CliRunner()
24
25
26 # ---------------------------------------------------------------------------
27 # Helpers
28 # ---------------------------------------------------------------------------
29
30 def _env(root: pathlib.Path) -> dict[str, str]:
31 return {"MUSE_REPO_ROOT": str(root)}
32
33
34 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
35 muse_dir = tmp_path / ".muse"
36 muse_dir.mkdir()
37 repo_id = str(uuid.uuid4())
38 (muse_dir / "repo.json").write_text(json.dumps({
39 "repo_id": repo_id,
40 "domain": "midi",
41 "default_branch": "main",
42 "created_at": "2025-01-01T00:00:00+00:00",
43 }), encoding="utf-8")
44 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
45 (muse_dir / "refs" / "heads").mkdir(parents=True)
46 (muse_dir / "snapshots").mkdir()
47 (muse_dir / "commits").mkdir()
48 (muse_dir / "objects").mkdir()
49 return tmp_path, repo_id
50
51
52 def _make_commit(root: pathlib.Path, repo_id: str, message: str = "init") -> str:
53 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
54 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
55
56 ref_file = root / ".muse" / "refs" / "heads" / "main"
57 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
58 manifest: dict[str, str] = {}
59 snap_id = compute_snapshot_id(manifest)
60 committed_at = datetime.datetime.now(datetime.timezone.utc)
61 commit_id = compute_commit_id(
62 parent_ids=[parent_id] if parent_id else [],
63 snapshot_id=snap_id, message=message,
64 committed_at_iso=committed_at.isoformat(),
65 )
66 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
67 write_commit(root, CommitRecord(
68 commit_id=commit_id, repo_id=repo_id, branch="main",
69 snapshot_id=snap_id, message=message, committed_at=committed_at,
70 parent_commit_id=parent_id,
71 ))
72 ref_file.parent.mkdir(parents=True, exist_ok=True)
73 ref_file.write_text(commit_id, encoding="utf-8")
74 return commit_id
75
76
77 # ---------------------------------------------------------------------------
78 # Unit tests
79 # ---------------------------------------------------------------------------
80
81 class TestStashUnit:
82 def test_load_stash_empty(self, tmp_path: pathlib.Path) -> None:
83 root, _ = _init_repo(tmp_path)
84 from muse.cli.commands.stash import _load_stash
85 assert _load_stash(root) == []
86
87 def test_save_and_load_stash_roundtrip(self, tmp_path: pathlib.Path) -> None:
88 root, _ = _init_repo(tmp_path)
89 from muse.cli.commands.stash import _load_stash, _save_stash, StashEntry
90 entry = StashEntry(
91 snapshot_id="a" * 64, manifest={"file.mid": "b" * 64},
92 branch="main", stashed_at="2025-01-01T00:00:00+00:00",
93 )
94 _save_stash(root, [entry])
95 loaded = _load_stash(root)
96 assert len(loaded) == 1
97 assert loaded[0]["snapshot_id"] == "a" * 64
98 assert loaded[0]["branch"] == "main"
99
100 def test_save_stash_is_atomic(self, tmp_path: pathlib.Path) -> None:
101 """After _save_stash, no temp files should remain in .muse/."""
102 root, _ = _init_repo(tmp_path)
103 from muse.cli.commands.stash import _save_stash, StashEntry
104 entry = StashEntry(
105 snapshot_id="c" * 64, manifest={},
106 branch="dev", stashed_at="2025-01-01T00:00:00+00:00",
107 )
108 _save_stash(root, [entry])
109 tmp_files = list((root / ".muse").glob(".stash_tmp_*"))
110 assert tmp_files == []
111 assert (root / ".muse" / "stash.json").exists()
112
113 def test_load_stash_ignores_oversized_file(self, tmp_path: pathlib.Path) -> None:
114 root, _ = _init_repo(tmp_path)
115 stash_path = root / ".muse" / "stash.json"
116 stash_path.write_bytes(b"x" * (65 * 1024 * 1024)) # 65 MiB > 64 MiB limit
117 from muse.cli.commands.stash import _load_stash
118 result = _load_stash(root)
119 assert result == []
120
121
122 # ---------------------------------------------------------------------------
123 # Integration tests
124 # ---------------------------------------------------------------------------
125
126 class TestStashIntegration:
127 def test_stash_with_no_changes_reports_nothing(self, tmp_path: pathlib.Path) -> None:
128 root, repo_id = _init_repo(tmp_path)
129 _make_commit(root, repo_id)
130 result = runner.invoke(cli, ["stash"], env=_env(root), catch_exceptions=False)
131 assert "Nothing to stash" in result.output
132
133 def test_stash_list_empty(self, tmp_path: pathlib.Path) -> None:
134 root, repo_id = _init_repo(tmp_path)
135 _make_commit(root, repo_id)
136 result = runner.invoke(cli, ["stash", "list"], env=_env(root), catch_exceptions=False)
137 assert "No stash entries" in result.output
138
139 def test_stash_pop_empty_fails(self, tmp_path: pathlib.Path) -> None:
140 root, repo_id = _init_repo(tmp_path)
141 _make_commit(root, repo_id)
142 result = runner.invoke(cli, ["stash", "pop"], env=_env(root))
143 assert result.exit_code != 0
144
145 def test_stash_drop_empty_fails(self, tmp_path: pathlib.Path) -> None:
146 root, repo_id = _init_repo(tmp_path)
147 _make_commit(root, repo_id)
148 result = runner.invoke(cli, ["stash", "drop"], env=_env(root))
149 assert result.exit_code != 0
150
151 def test_stash_list_shows_entries(self, tmp_path: pathlib.Path) -> None:
152 root, _ = _init_repo(tmp_path)
153 from muse.cli.commands.stash import _save_stash, StashEntry
154 _save_stash(root, [
155 StashEntry(snapshot_id="a" * 64, manifest={},
156 branch="main", stashed_at="2025-01-01T00:00:00+00:00"),
157 ])
158 result = runner.invoke(cli, ["stash", "list"], env=_env(root), catch_exceptions=False)
159 assert "stash@{0}" in result.output
160 assert "main" in result.output
161
162
163 # ---------------------------------------------------------------------------
164 # Security tests
165 # ---------------------------------------------------------------------------
166
167 class TestStashSecurity:
168 def test_stash_list_sanitizes_branch_name_with_control_chars(
169 self, tmp_path: pathlib.Path
170 ) -> None:
171 root, _ = _init_repo(tmp_path)
172 from muse.cli.commands.stash import _save_stash, StashEntry
173 malicious_branch = "feat/\x1b[31mred\x1b[0m"
174 _save_stash(root, [
175 StashEntry(snapshot_id="a" * 64, manifest={},
176 branch=malicious_branch, stashed_at="2025-01-01T00:00:00+00:00"),
177 ])
178 result = runner.invoke(cli, ["stash", "list"], env=_env(root), catch_exceptions=False)
179 assert result.exit_code == 0
180 assert "\x1b" not in result.output
181
182 def test_stash_pop_sanitizes_branch_name(self, tmp_path: pathlib.Path) -> None:
183 root, repo_id = _init_repo(tmp_path)
184 _make_commit(root, repo_id)
185 from muse.cli.commands.stash import _save_stash, StashEntry
186 _save_stash(root, [
187 StashEntry(snapshot_id="a" * 64, manifest={},
188 branch="feat/\x1b[31mred\x1b[0m",
189 stashed_at="2025-01-01T00:00:00+00:00"),
190 ])
191 result = runner.invoke(cli, ["stash", "pop"], env=_env(root), catch_exceptions=False)
192 assert "\x1b" not in result.output
193
194
195 # ---------------------------------------------------------------------------
196 # Stress tests
197 # ---------------------------------------------------------------------------
198
199 class TestStashStress:
200 def test_many_stash_entries_save_load(self, tmp_path: pathlib.Path) -> None:
201 root, _ = _init_repo(tmp_path)
202 from muse.cli.commands.stash import _save_stash, _load_stash, StashEntry
203 entries = [
204 StashEntry(snapshot_id=f"{'a' * 63}{i % 10}",
205 manifest={"file.mid": "b" * 64}, branch="main",
206 stashed_at=f"2025-01-{i % 28 + 1:02d}T00:00:00+00:00")
207 for i in range(50)
208 ]
209 _save_stash(root, entries)
210 loaded = _load_stash(root)
211 assert len(loaded) == 50
212
213 def test_repeated_save_load_no_corruption(self, tmp_path: pathlib.Path) -> None:
214 root, _ = _init_repo(tmp_path)
215 from muse.cli.commands.stash import _save_stash, _load_stash, StashEntry
216 for i in range(20):
217 entry = StashEntry(snapshot_id=f"{'b' * 63}{i % 10}",
218 manifest={}, branch="main",
219 stashed_at="2025-01-01T00:00:00+00:00")
220 loaded = _load_stash(root)
221 loaded.insert(0, entry)
222 _save_stash(root, loaded)
223
224 final = _load_stash(root)
225 assert len(final) == 20