gabriel / muse public
test_cmd_ls_files.py python
377 lines 13.7 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 16 hours ago
1 """Comprehensive tests for ``muse ls-files``.
2
3 Coverage tiers
4 --------------
5 - Integration: JSON/text format, --commit, --path-prefix, empty manifest
6 - Security: ANSI in file path stripped in text mode, JSON mode safe
7 - Stress: 1 000-file manifest, 200 sequential calls
8 """
9 from __future__ import annotations
10
11 type _FileStore = dict[str, bytes]
12
13 import datetime
14 import json
15 import pathlib
16
17 from muse.core.errors import ExitCode
18 from muse.core.object_store import write_object
19 from muse.core.ids import hash_commit, hash_snapshot
20 from muse.core.commits import (
21 CommitRecord,
22 write_commit,
23 )
24 from muse.core.snapshots import (
25 SnapshotRecord,
26 write_snapshot,
27 )
28 from muse.core.types import Manifest, blob_id, long_id, split_id
29 from muse.core.paths import muse_dir, ref_path
30 from tests.cli_test_helper import CliRunner, InvokeResult
31
32 runner = CliRunner()
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
40 repo = tmp_path / "repo"
41 dot_muse = muse_dir(repo)
42 for sub in ("objects", "commits", "snapshots", "refs/heads"):
43 (dot_muse / sub).mkdir(parents=True)
44 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
45 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test", "domain": "code"}))
46 return repo
47
48
49 def _oid(content: bytes) -> str:
50 return blob_id(content)
51
52
53 def _add_commit(
54 repo: pathlib.Path,
55 manifest: _FileStore,
56 *,
57 commit_suffix: str = "a",
58 branch: str = "main",
59 set_head: bool = True,
60 ) -> str:
61 """Store objects, snapshot, and commit; return commit_id."""
62 stored: Manifest = {}
63 for path, content in manifest.items():
64 oid = _oid(content)
65 write_object(repo, oid, content)
66 stored[path] = oid
67
68 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
69 snap_id = hash_snapshot(stored)
70 write_snapshot(repo, SnapshotRecord(
71 snapshot_id=snap_id,
72 manifest=stored,
73 created_at=committed_at,
74 ))
75 commit_id = hash_commit( parent_ids=[],
76 snapshot_id=snap_id,
77 message="test",
78 committed_at_iso=committed_at.isoformat(),
79 author="tester",
80 )
81 write_commit(repo, CommitRecord(
82 commit_id=commit_id,
83 branch=branch,
84 snapshot_id=snap_id,
85 message="test",
86 committed_at=committed_at,
87 author="tester",
88 parent_commit_id=None,
89 ))
90 if set_head:
91 ref = ref_path(repo, branch)
92 ref.parent.mkdir(parents=True, exist_ok=True)
93 ref.write_text(commit_id)
94 return commit_id
95
96
97 def _ls(repo: pathlib.Path, *args: str) -> InvokeResult:
98 from muse.cli.app import main as cli
99 return runner.invoke(
100 cli,
101 ["ls-files", *args],
102 env={"MUSE_REPO_ROOT": str(repo)},
103 )
104
105
106 def _lsj(repo: pathlib.Path, *args: str) -> InvokeResult:
107 """Like _ls but always passes --json."""
108 return _ls(repo, "--json", *args)
109
110
111 # ---------------------------------------------------------------------------
112 # Integration — JSON format
113 # ---------------------------------------------------------------------------
114
115
116 class TestJsonFormat:
117 def test_lists_files(self, tmp_path: pathlib.Path) -> None:
118 repo = _make_repo(tmp_path)
119 cid = _add_commit(repo, {"src/main.py": b"# main", "README.md": b"# readme"})
120 result = _lsj(repo)
121 assert result.exit_code == 0
122 data = json.loads(result.output)
123 assert data["file_count"] == 2
124 paths = [f["path"] for f in data["files"]]
125 assert "src/main.py" in paths
126 assert "README.md" in paths
127
128 def test_files_sorted_alphabetically(self, tmp_path: pathlib.Path) -> None:
129 repo = _make_repo(tmp_path)
130 _add_commit(repo, {"z.py": b"z", "a.py": b"a", "m.py": b"m"})
131 data = json.loads(_lsj(repo).output)
132 paths = [f["path"] for f in data["files"]]
133 assert paths == sorted(paths)
134
135 def test_json_has_commit_and_snapshot_id(self, tmp_path: pathlib.Path) -> None:
136 repo = _make_repo(tmp_path)
137 cid = _add_commit(repo, {"f.py": b"x"})
138 data = json.loads(_lsj(repo).output)
139 assert data["commit_id"] == cid
140 assert data["snapshot_id"].startswith("sha256:")
141
142 def test_empty_manifest(self, tmp_path: pathlib.Path) -> None:
143 repo = _make_repo(tmp_path)
144 _add_commit(repo, {})
145 data = json.loads(_lsj(repo).output)
146 assert data["file_count"] == 0
147 assert data["files"] == []
148
149 def test_json_flag_shorthand(self, tmp_path: pathlib.Path) -> None:
150 repo = _make_repo(tmp_path)
151 _add_commit(repo, {"f.py": b"x"})
152 result = _lsj(repo)
153 assert result.exit_code == 0
154 data = json.loads(result.output)
155 assert data["file_count"] == 1
156
157 def test_object_ids_are_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
158 repo = _make_repo(tmp_path)
159 _add_commit(repo, {"a.py": b"content"})
160 data = json.loads(_lsj(repo).output)
161 for f in data["files"]:
162 assert f["object_id"].startswith("sha256:")
163 _, hex_part = split_id(f["object_id"])
164 assert len(hex_part) == 64
165 assert all(c in "0123456789abcdef" for c in hex_part)
166
167
168 # ---------------------------------------------------------------------------
169 # Integration — text format
170 # ---------------------------------------------------------------------------
171
172
173 class TestTextFormat:
174 def test_text_tab_separated(self, tmp_path: pathlib.Path) -> None:
175 repo = _make_repo(tmp_path)
176 _add_commit(repo, {"hello.py": b"hi"})
177 # Default (no --json) emits text: <oid>\t<path> per line
178 result = _ls(repo)
179 assert result.exit_code == 0
180 line = result.output.strip()
181 parts = line.split("\t")
182 assert len(parts) == 2
183 assert parts[0].startswith("sha256:") # canonical object_id
184 assert parts[1] == "hello.py"
185
186 def test_text_oid_matches_json_oid(self, tmp_path: pathlib.Path) -> None:
187 repo = _make_repo(tmp_path)
188 _add_commit(repo, {"check.py": b"content"})
189 json_data = json.loads(_lsj(repo).output)
190 text_out = _ls(repo).output.strip()
191 json_oid = json_data["files"][0]["object_id"]
192 text_oid = text_out.split("\t")[0]
193 assert json_oid == text_oid
194
195
196 # ---------------------------------------------------------------------------
197 # Integration — --commit flag
198 # ---------------------------------------------------------------------------
199
200
201 class TestCommitFlag:
202 def test_explicit_commit_resolves(self, tmp_path: pathlib.Path) -> None:
203 repo = _make_repo(tmp_path)
204 cid = _add_commit(repo, {"explicit.py": b"content"})
205 result = _lsj(repo, "--commit", cid)
206 assert result.exit_code == 0
207 data = json.loads(result.output)
208 assert data["commit_id"] == cid
209
210 def test_invalid_commit_id_errors(self, tmp_path: pathlib.Path) -> None:
211 repo = _make_repo(tmp_path)
212 result = _ls(repo, "--commit", "not-a-valid-id")
213 assert result.exit_code == ExitCode.USER_ERROR
214
215 def test_nonexistent_commit_id_errors(self, tmp_path: pathlib.Path) -> None:
216 repo = _make_repo(tmp_path)
217 result = _ls(repo, "--commit", long_id("f" * 64))
218 assert result.exit_code == ExitCode.USER_ERROR
219
220 def test_no_commits_on_branch_errors(self, tmp_path: pathlib.Path) -> None:
221 repo = _make_repo(tmp_path)
222 result = _ls(repo)
223 assert result.exit_code == ExitCode.USER_ERROR
224
225
226 # ---------------------------------------------------------------------------
227 # Integration — --path-prefix filter
228 # ---------------------------------------------------------------------------
229
230
231 class TestPathPrefix:
232 def test_prefix_filters_to_subtree(self, tmp_path: pathlib.Path) -> None:
233 repo = _make_repo(tmp_path)
234 _add_commit(repo, {
235 "src/main.py": b"main",
236 "src/utils.py": b"utils",
237 "tests/test_main.py": b"test",
238 "README.md": b"readme",
239 })
240 data = json.loads(_lsj(repo, "--path-prefix", "src/").output)
241 paths = [f["path"] for f in data["files"]]
242 assert all(p.startswith("src/") for p in paths)
243 assert len(paths) == 2
244
245 def test_prefix_file_count_reflects_filter(self, tmp_path: pathlib.Path) -> None:
246 repo = _make_repo(tmp_path)
247 _add_commit(repo, {
248 "a/x.py": b"x",
249 "a/y.py": b"y",
250 "b/z.py": b"z",
251 })
252 data = json.loads(_lsj(repo, "--path-prefix", "a/").output)
253 assert data["file_count"] == 2
254
255 def test_prefix_no_match_returns_empty(self, tmp_path: pathlib.Path) -> None:
256 repo = _make_repo(tmp_path)
257 _add_commit(repo, {"src/main.py": b"main"})
258 data = json.loads(_lsj(repo, "--path-prefix", "tests/").output)
259 assert data["file_count"] == 0
260 assert data["files"] == []
261
262 def test_prefix_text_format(self, tmp_path: pathlib.Path) -> None:
263 repo = _make_repo(tmp_path)
264 _add_commit(repo, {"src/a.py": b"a", "tests/b.py": b"b"})
265 # Default (no --json) with --path-prefix emits text lines
266 result = _ls(repo, "--path-prefix", "src/")
267 assert result.exit_code == 0
268 lines = [l for l in result.output.strip().splitlines() if l]
269 assert len(lines) == 1
270 assert "src/a.py" in lines[0]
271
272
273 # ---------------------------------------------------------------------------
274 # Security
275 # ---------------------------------------------------------------------------
276
277
278 class TestSecurity:
279 def test_ansi_in_path_stripped_in_text_mode(self, tmp_path: pathlib.Path) -> None:
280 """File path with ANSI escape must be sanitized in text mode."""
281 repo = _make_repo(tmp_path)
282 malicious_path = "src/\x1b[31mmalicious\x1b[0m.py"
283 _add_commit(repo, {malicious_path: b"content"})
284 # Default (no --json) emits sanitized text
285 result = _ls(repo)
286 assert result.exit_code == 0
287 assert "\x1b" not in result.output
288
289 def test_ansi_in_path_preserved_in_json(self, tmp_path: pathlib.Path) -> None:
290 """JSON mode encodes ANSI as \\u001b — never emits raw escape sequences."""
291 repo = _make_repo(tmp_path)
292 malicious_path = "src/\x1b[31mmalicious\x1b[0m.py"
293 _add_commit(repo, {malicious_path: b"content"})
294 result = _lsj(repo)
295 assert result.exit_code == 0
296 # No raw ANSI bytes in stdout — json.dumps encodes \x1b as \u001b
297 assert "\x1b" not in result.output
298 data = json.loads(result.output)
299 # The path is preserved in the JSON payload (as \u001b-encoded)
300 paths = [f["path"] for f in data["files"]]
301 assert any("\x1b" in p or "\u001b" in p for p in paths)
302
303 def test_path_traversal_commit_id_rejected(self, tmp_path: pathlib.Path) -> None:
304 repo = _make_repo(tmp_path)
305 result = _ls(repo, "--commit", "../../../etc/passwd")
306 assert result.exit_code == ExitCode.USER_ERROR
307
308 def test_no_traceback_on_invalid_input(self, tmp_path: pathlib.Path) -> None:
309 repo = _make_repo(tmp_path)
310 result = _ls(repo, "--commit", "bad!")
311 assert "Traceback" not in result.output
312
313
314 # ---------------------------------------------------------------------------
315 # Stress
316 # ---------------------------------------------------------------------------
317
318
319 class TestStress:
320 def test_1000_file_manifest(self, tmp_path: pathlib.Path) -> None:
321 """1 000-file manifest lists and returns in reasonable time."""
322 repo = _make_repo(tmp_path)
323 manifest = {f"src/file_{i:04d}.py": f"content {i}".encode() for i in range(1000)}
324 _add_commit(repo, manifest)
325 result = _lsj(repo)
326 assert result.exit_code == 0
327 data = json.loads(result.output)
328 assert data["file_count"] == 1000
329
330 def test_1000_file_prefix_filter(self, tmp_path: pathlib.Path) -> None:
331 repo = _make_repo(tmp_path)
332 manifest = {f"a/file_{i:04d}.py": b"a" for i in range(500)}
333 manifest.update({f"b/file_{i:04d}.py": b"b" for i in range(500)})
334 _add_commit(repo, manifest)
335 data = json.loads(_lsj(repo, "--path-prefix", "a/").output)
336 assert data["file_count"] == 500
337
338 def test_200_sequential_calls(self, tmp_path: pathlib.Path) -> None:
339 repo = _make_repo(tmp_path)
340 _add_commit(repo, {"stable.py": b"content"})
341 for i in range(200):
342 result = _lsj(repo)
343 assert result.exit_code == 0, f"failed at iteration {i}"
344 assert json.loads(result.output)["file_count"] == 1
345
346
347 class TestRegisterFlags:
348 def test_json_short_flag(self) -> None:
349 import argparse
350 from muse.cli.commands.ls_files import register
351 p = argparse.ArgumentParser()
352 subs = p.add_subparsers()
353 register(subs)
354 args = p.parse_args(["ls-files", "-j"])
355 assert args.json_out is True
356
357 def test_json_long_flag(self) -> None:
358 import argparse
359 from muse.cli.commands.ls_files import register
360 p = argparse.ArgumentParser()
361 subs = p.add_subparsers()
362 register(subs)
363 args = p.parse_args(["ls-files", "--json"])
364 assert args.json_out is True
365
366 def test_default_no_json(self) -> None:
367 import argparse
368 from muse.cli.commands.ls_files import register
369 p = argparse.ArgumentParser()
370 subs = p.add_subparsers()
371 register(subs)
372 # Command-specific required args may differ; just check dest exists when possible
373 try:
374 args = p.parse_args(["ls-files"])
375 assert args.json_out is False
376 except SystemExit:
377 pass # required positional args missing — flag default still correct
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 16 hours ago