gabriel / muse public
test_cmd_annotate_hardening.py python
899 lines 33.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Comprehensive tests for ``muse annotate`` CLI hardening.
2
3 Audit findings addressed
4 ------------------------
5 Security
6 - ANSI injection in reviewer names (from disk and from user input) now
7 blocked by sanitize_display() at output time and sanitize_provenance()
8 at input validation time.
9 - Control characters in reviewer names rejected before storage.
10 - Commit references resolved via resolve_commit_ref (safe glob-prefix scan)
11 — raw user strings no longer passed directly to read_commit.
12 - Error messages routed to stderr; stdout carries only data.
13 - SystemExit(1) replaced with ExitCode enum values.
14
15 Performance
16 - ORSet reconstruction removed from mutation path — replaced with pure
17 set union (O(n) set operations vs. O(n) ORSet token generation for
18 every existing reviewer on every mutation).
19
20 Correctness
21 - Short commit IDs (prefix scan) now work: muse annotate abc1234.
22 - HEAD~N references work through resolve_commit_ref.
23 - Multiple --reviewed-by flags (action='append') work without comma sep.
24 - --remove-reviewer removes from stored list.
25 - --dry-run shows prospective state without writing.
26
27 Agent UX
28 - --json flag emits complete, stable _AnnotateJson schema.
29 - JSON always present and always contains all fields.
30
31 Coverage tiers
32 --------------
33 - Unit: _validate_reviewer, _parse_reviewer_list, _commit_to_json
34 - Integration: run show, add, remove, test-run, dry-run, combination
35 - Security: ANSI in names, control chars, stderr routing, no tracebacks
36 - E2E: full CLI invocation, JSON schema, exit codes
37 - Stress: 100 reviewers, 50 annotations, concurrent isolated repos
38 """
39 from __future__ import annotations
40
41 import datetime
42 import json
43 import pathlib
44 import threading
45 from typing import TYPE_CHECKING
46 from unittest.mock import patch
47
48 import pytest
49
50 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
51 from muse.core.store import CommitRecord, read_commit, write_commit
52 from muse.core.errors import ExitCode
53 from tests.cli_test_helper import CliRunner, InvokeResult
54
55 if TYPE_CHECKING:
56 from muse.cli.commands.annotate import _AnnotateJson
57
58 runner = CliRunner()
59 cli = None # argparse migration — CliRunner ignores this
60
61
62 # ---------------------------------------------------------------------------
63 # Helpers
64 # ---------------------------------------------------------------------------
65
66
67 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
68 """Create a minimal Muse repo layout."""
69 muse = tmp_path / ".muse"
70 for sub in ("commits", "snapshots", "refs/heads", "objects"):
71 (muse / sub).mkdir(parents=True, exist_ok=True)
72 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
73 (muse / "repo.json").write_text(
74 json.dumps({"repo_id": "test-repo"}), encoding="utf-8"
75 )
76 return tmp_path
77
78
79 def _write_commit(
80 root: pathlib.Path,
81 message: str = "test commit",
82 branch: str = "main",
83 ) -> CommitRecord:
84 """Write a content-addressed CommitRecord and update the branch ref."""
85 committed_at = datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc)
86 snap_id = compute_snapshot_id({})
87 cid = compute_commit_id([], snap_id, message, committed_at.isoformat())
88 record = CommitRecord(
89 commit_id=cid,
90 repo_id="test-repo",
91 branch=branch,
92 snapshot_id=snap_id,
93 message=message,
94 committed_at=committed_at,
95 author="test-author",
96 )
97 write_commit(root, record)
98 (root / ".muse" / "refs" / "heads" / branch).write_text(cid, encoding="utf-8")
99 return record
100
101
102 def _invoke(root: pathlib.Path, *args: str) -> InvokeResult:
103 """Run ``muse annotate <args>`` inside *root*."""
104 return runner.invoke(
105 cli,
106 ["annotate", *args],
107 env={"MUSE_REPO_ROOT": str(root)},
108 )
109
110
111 def _parse_json(result: InvokeResult) -> "_AnnotateJson":
112 """Extract and parse the first JSON blob from *result.output*."""
113 from muse.cli.commands.annotate import _AnnotateJson
114
115 start = result.output.index("{")
116 blob = result.output[start:]
117 depth = 0
118 end = 0
119 for i, ch in enumerate(blob):
120 if ch == "{":
121 depth += 1
122 elif ch == "}":
123 depth -= 1
124 if depth == 0:
125 end = i + 1
126 break
127 raw = json.loads(blob[:end])
128 assert isinstance(raw, dict)
129 reviewed_by = raw.get("reviewed_by", [])
130 assert isinstance(reviewed_by, list)
131 str_reviewed: list[str] = [str(r) for r in reviewed_by]
132 return _AnnotateJson(
133 commit_id=str(raw.get("commit_id", "")),
134 message=str(raw.get("message", "")),
135 branch=str(raw.get("branch", "")),
136 author=str(raw.get("author", "")),
137 committed_at=str(raw.get("committed_at", "")),
138 reviewed_by=str_reviewed,
139 test_runs=int(raw.get("test_runs", 0)),
140 changed=bool(raw.get("changed", False)),
141 dry_run=bool(raw.get("dry_run", False)),
142 )
143
144
145 # ---------------------------------------------------------------------------
146 # Unit — _validate_reviewer
147 # ---------------------------------------------------------------------------
148
149
150 class TestValidateReviewer:
151 def test_valid_name_returns_unchanged(self) -> None:
152 from muse.cli.commands.annotate import _validate_reviewer
153
154 assert _validate_reviewer("alice") == "alice"
155 assert _validate_reviewer("agent-x") == "agent-x"
156 assert _validate_reviewer("ci-bot-v2") == "ci-bot-v2"
157
158 def test_empty_name_exits_user_error(self) -> None:
159 from muse.cli.commands.annotate import _validate_reviewer
160
161 with pytest.raises(SystemExit) as exc:
162 _validate_reviewer("")
163 assert exc.value.code == ExitCode.USER_ERROR.value
164
165 def test_name_with_ansi_exits_user_error(self) -> None:
166 from muse.cli.commands.annotate import _validate_reviewer
167
168 with pytest.raises(SystemExit) as exc:
169 _validate_reviewer("\x1b[31mevil\x1b[0m")
170 assert exc.value.code == ExitCode.USER_ERROR.value
171
172 def test_name_with_null_byte_exits_user_error(self) -> None:
173 from muse.cli.commands.annotate import _validate_reviewer
174
175 with pytest.raises(SystemExit) as exc:
176 _validate_reviewer("foo\x00bar")
177 assert exc.value.code == ExitCode.USER_ERROR.value
178
179 def test_name_with_newline_exits_user_error(self) -> None:
180 from muse.cli.commands.annotate import _validate_reviewer
181
182 with pytest.raises(SystemExit) as exc:
183 _validate_reviewer("alice\nbob")
184 assert exc.value.code == ExitCode.USER_ERROR.value
185
186 def test_overlong_name_exits_user_error(self) -> None:
187 from muse.cli.commands.annotate import _validate_reviewer, _MAX_REVIEWER_LEN
188
189 with pytest.raises(SystemExit) as exc:
190 _validate_reviewer("a" * (_MAX_REVIEWER_LEN + 1))
191 assert exc.value.code == ExitCode.USER_ERROR.value
192
193 def test_exact_max_length_accepted(self) -> None:
194 from muse.cli.commands.annotate import _validate_reviewer, _MAX_REVIEWER_LEN
195
196 name = "a" * _MAX_REVIEWER_LEN
197 assert _validate_reviewer(name) == name
198
199
200 # ---------------------------------------------------------------------------
201 # Unit — _parse_reviewer_list
202 # ---------------------------------------------------------------------------
203
204
205 class TestParseReviewerList:
206 def test_single_name(self) -> None:
207 from muse.cli.commands.annotate import _parse_reviewer_list
208
209 assert _parse_reviewer_list(["alice"]) == ["alice"]
210
211 def test_comma_separated(self) -> None:
212 from muse.cli.commands.annotate import _parse_reviewer_list
213
214 result = _parse_reviewer_list(["alice,bob"])
215 assert "alice" in result
216 assert "bob" in result
217
218 def test_multiple_flags(self) -> None:
219 from muse.cli.commands.annotate import _parse_reviewer_list
220
221 result = _parse_reviewer_list(["alice", "bob"])
222 assert "alice" in result
223 assert "bob" in result
224
225 def test_deduplication(self) -> None:
226 from muse.cli.commands.annotate import _parse_reviewer_list
227
228 result = _parse_reviewer_list(["alice", "alice"])
229 assert result.count("alice") == 1
230
231 def test_empty_list(self) -> None:
232 from muse.cli.commands.annotate import _parse_reviewer_list
233
234 assert _parse_reviewer_list([]) == []
235
236 def test_whitespace_stripped(self) -> None:
237 from muse.cli.commands.annotate import _parse_reviewer_list
238
239 result = _parse_reviewer_list([" alice , bob "])
240 assert "alice" in result
241 assert "bob" in result
242
243 def test_empty_segments_skipped(self) -> None:
244 from muse.cli.commands.annotate import _parse_reviewer_list
245
246 result = _parse_reviewer_list(["alice,,bob"])
247 assert "alice" in result
248 assert "bob" in result
249 assert "" not in result
250
251 def test_invalid_name_raises(self) -> None:
252 from muse.cli.commands.annotate import _parse_reviewer_list
253
254 with pytest.raises(SystemExit):
255 _parse_reviewer_list(["\x1b[31mevil\x1b[0m"])
256
257
258 # ---------------------------------------------------------------------------
259 # Unit — _commit_to_json
260 # ---------------------------------------------------------------------------
261
262
263 class TestCommitToJson:
264 def _record(self) -> CommitRecord:
265 committed_at = datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc)
266 snap_id = compute_snapshot_id({})
267 cid = compute_commit_id([], snap_id, "msg", committed_at.isoformat())
268 return CommitRecord(
269 commit_id=cid,
270 repo_id="r1",
271 branch="main",
272 snapshot_id=snap_id,
273 message="msg",
274 committed_at=committed_at,
275 author="alice",
276 reviewed_by=["bob"],
277 test_runs=3,
278 )
279
280 def test_all_fields_present(self) -> None:
281 from muse.cli.commands.annotate import _commit_to_json
282
283 rec = self._record()
284 j = _commit_to_json(rec, changed=True, dry_run=False)
285 for field in (
286 "commit_id", "message", "branch", "author",
287 "committed_at", "reviewed_by", "test_runs", "changed", "dry_run",
288 ):
289 assert field in j, f"Missing field: {field}"
290
291 def test_changed_and_dry_run_flags(self) -> None:
292 from muse.cli.commands.annotate import _commit_to_json
293
294 rec = self._record()
295 j = _commit_to_json(rec, changed=True, dry_run=True)
296 assert j["changed"] is True
297 assert j["dry_run"] is True
298
299 def test_reviewed_by_is_list(self) -> None:
300 from muse.cli.commands.annotate import _commit_to_json
301
302 rec = self._record()
303 j = _commit_to_json(rec, changed=False, dry_run=False)
304 assert isinstance(j["reviewed_by"], list)
305 assert "bob" in j["reviewed_by"]
306
307
308 # ---------------------------------------------------------------------------
309 # Integration — show mode (no mutation flags)
310 # ---------------------------------------------------------------------------
311
312
313 class TestShowMode:
314 def test_show_no_reviewers(self, tmp_path: pathlib.Path) -> None:
315 repo = _make_repo(tmp_path)
316 c = _write_commit(repo)
317 result = _invoke(repo, c.commit_id)
318 assert result.exit_code == 0
319 assert "reviewed-by: (none)" in result.output
320
321 def test_show_existing_reviewers(self, tmp_path: pathlib.Path) -> None:
322 repo = _make_repo(tmp_path)
323 c = _write_commit(repo)
324 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
325 result = _invoke(repo, c.commit_id)
326 assert result.exit_code == 0
327 assert "alice" in result.output
328
329 def test_show_test_runs(self, tmp_path: pathlib.Path) -> None:
330 repo = _make_repo(tmp_path)
331 c = _write_commit(repo)
332 _invoke(repo, "--test-run", c.commit_id)
333 result = _invoke(repo, c.commit_id)
334 assert result.exit_code == 0
335 assert "test-runs: 1" in result.output
336
337 def test_show_head_default(self, tmp_path: pathlib.Path) -> None:
338 repo = _make_repo(tmp_path)
339 _write_commit(repo)
340 result = _invoke(repo)
341 assert result.exit_code == 0
342 assert "reviewed-by" in result.output
343
344 def test_show_json_schema(self, tmp_path: pathlib.Path) -> None:
345 repo = _make_repo(tmp_path)
346 c = _write_commit(repo)
347 result = _invoke(repo, "--json", c.commit_id)
348 assert result.exit_code == 0
349 data = _parse_json(result)
350 assert data["commit_id"] == c.commit_id
351 assert data["changed"] is False
352 assert data["dry_run"] is False
353 assert isinstance(data["reviewed_by"], list)
354 assert isinstance(data["test_runs"], int)
355
356 def test_show_json_has_message_and_branch(self, tmp_path: pathlib.Path) -> None:
357 repo = _make_repo(tmp_path)
358 c = _write_commit(repo, message="feat: some feature")
359 result = _invoke(repo, "--json", c.commit_id)
360 data = _parse_json(result)
361 assert data["message"] == "feat: some feature"
362 assert data["branch"] == "main"
363
364 def test_unknown_commit_exits_not_found(self, tmp_path: pathlib.Path) -> None:
365 repo = _make_repo(tmp_path)
366 result = _invoke(repo, "deadbeef")
367 assert result.exit_code == ExitCode.NOT_FOUND.value
368
369 def test_unknown_commit_no_traceback(self, tmp_path: pathlib.Path) -> None:
370 repo = _make_repo(tmp_path)
371 result = _invoke(repo, "deadbeef")
372 assert "Traceback" not in result.output
373
374
375 # ---------------------------------------------------------------------------
376 # Integration — add reviewer (--reviewed-by)
377 # ---------------------------------------------------------------------------
378
379
380 class TestAddReviewer:
381 def test_single_reviewer_added(self, tmp_path: pathlib.Path) -> None:
382 repo = _make_repo(tmp_path)
383 c = _write_commit(repo)
384 result = _invoke(repo, "--reviewed-by", "alice", c.commit_id)
385 assert result.exit_code == 0
386 rec = read_commit(repo, c.commit_id)
387 assert rec is not None
388 assert "alice" in rec.reviewed_by
389
390 def test_comma_separated_reviewers(self, tmp_path: pathlib.Path) -> None:
391 repo = _make_repo(tmp_path)
392 c = _write_commit(repo)
393 result = _invoke(repo, "--reviewed-by", "alice,bob", c.commit_id)
394 assert result.exit_code == 0
395 rec = read_commit(repo, c.commit_id)
396 assert rec is not None
397 assert "alice" in rec.reviewed_by
398 assert "bob" in rec.reviewed_by
399
400 def test_multiple_reviewed_by_flags(self, tmp_path: pathlib.Path) -> None:
401 repo = _make_repo(tmp_path)
402 c = _write_commit(repo)
403 result = _invoke(
404 repo,
405 "--reviewed-by", "alice",
406 "--reviewed-by", "bob",
407 c.commit_id,
408 )
409 assert result.exit_code == 0
410 rec = read_commit(repo, c.commit_id)
411 assert rec is not None
412 assert "alice" in rec.reviewed_by
413 assert "bob" in rec.reviewed_by
414
415 def test_orset_idempotent(self, tmp_path: pathlib.Path) -> None:
416 repo = _make_repo(tmp_path)
417 c = _write_commit(repo)
418 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
419 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
420 rec = read_commit(repo, c.commit_id)
421 assert rec is not None
422 assert rec.reviewed_by.count("alice") == 1
423
424 def test_reviewer_added_message_shown(self, tmp_path: pathlib.Path) -> None:
425 repo = _make_repo(tmp_path)
426 c = _write_commit(repo)
427 result = _invoke(repo, "--reviewed-by", "alice", c.commit_id)
428 assert "alice" in result.output
429 assert "Added" in result.output
430
431 def test_no_changes_when_reviewer_already_present(
432 self, tmp_path: pathlib.Path
433 ) -> None:
434 repo = _make_repo(tmp_path)
435 c = _write_commit(repo)
436 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
437 result = _invoke(repo, "--reviewed-by", "alice", c.commit_id)
438 assert result.exit_code == 0
439 assert "no changes" in result.output
440
441 def test_json_reflects_new_reviewer(self, tmp_path: pathlib.Path) -> None:
442 repo = _make_repo(tmp_path)
443 c = _write_commit(repo)
444 result = _invoke(repo, "--reviewed-by", "alice", "--json", c.commit_id)
445 assert result.exit_code == 0
446 data = _parse_json(result)
447 assert "alice" in data["reviewed_by"]
448 assert data["changed"] is True
449
450 def test_json_changed_false_when_no_change(self, tmp_path: pathlib.Path) -> None:
451 repo = _make_repo(tmp_path)
452 c = _write_commit(repo)
453 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
454 result = _invoke(repo, "--reviewed-by", "alice", "--json", c.commit_id)
455 data = _parse_json(result)
456 assert data["changed"] is False
457
458
459 # ---------------------------------------------------------------------------
460 # Integration — remove reviewer (--remove-reviewer)
461 # ---------------------------------------------------------------------------
462
463
464 class TestRemoveReviewer:
465 def test_remove_existing_reviewer(self, tmp_path: pathlib.Path) -> None:
466 repo = _make_repo(tmp_path)
467 c = _write_commit(repo)
468 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
469 result = _invoke(repo, "--remove-reviewer", "alice", c.commit_id)
470 assert result.exit_code == 0
471 rec = read_commit(repo, c.commit_id)
472 assert rec is not None
473 assert "alice" not in rec.reviewed_by
474
475 def test_remove_absent_reviewer_warns_to_stderr(
476 self, tmp_path: pathlib.Path
477 ) -> None:
478 repo = _make_repo(tmp_path)
479 c = _write_commit(repo)
480 result = _invoke(repo, "--remove-reviewer", "nobody", c.commit_id)
481 assert result.exit_code == 0
482 assert "⚠️" in result.output or "not present" in result.output
483
484 def test_remove_one_preserves_others(self, tmp_path: pathlib.Path) -> None:
485 repo = _make_repo(tmp_path)
486 c = _write_commit(repo)
487 _invoke(repo, "--reviewed-by", "alice,bob", c.commit_id)
488 _invoke(repo, "--remove-reviewer", "alice", c.commit_id)
489 rec = read_commit(repo, c.commit_id)
490 assert rec is not None
491 assert "alice" not in rec.reviewed_by
492 assert "bob" in rec.reviewed_by
493
494 def test_remove_json_reflects_change(self, tmp_path: pathlib.Path) -> None:
495 repo = _make_repo(tmp_path)
496 c = _write_commit(repo)
497 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
498 result = _invoke(
499 repo, "--remove-reviewer", "alice", "--json", c.commit_id
500 )
501 data = _parse_json(result)
502 assert "alice" not in data["reviewed_by"]
503 assert data["changed"] is True
504
505
506 # ---------------------------------------------------------------------------
507 # Integration — test-run counter
508 # ---------------------------------------------------------------------------
509
510
511 class TestTestRun:
512 def test_test_run_increments(self, tmp_path: pathlib.Path) -> None:
513 repo = _make_repo(tmp_path)
514 c = _write_commit(repo)
515 _invoke(repo, "--test-run", c.commit_id)
516 _invoke(repo, "--test-run", c.commit_id)
517 rec = read_commit(repo, c.commit_id)
518 assert rec is not None
519 assert rec.test_runs == 2
520
521 def test_test_run_shown_in_output(self, tmp_path: pathlib.Path) -> None:
522 repo = _make_repo(tmp_path)
523 c = _write_commit(repo)
524 result = _invoke(repo, "--test-run", c.commit_id)
525 assert result.exit_code == 0
526 assert "Test run recorded" in result.output
527
528 def test_test_run_json_schema(self, tmp_path: pathlib.Path) -> None:
529 repo = _make_repo(tmp_path)
530 c = _write_commit(repo)
531 result = _invoke(repo, "--test-run", "--json", c.commit_id)
532 assert result.exit_code == 0
533 data = _parse_json(result)
534 assert data["test_runs"] == 1
535 assert data["changed"] is True
536
537
538 # ---------------------------------------------------------------------------
539 # Integration — dry-run
540 # ---------------------------------------------------------------------------
541
542
543 class TestDryRun:
544 def test_dry_run_does_not_write(self, tmp_path: pathlib.Path) -> None:
545 repo = _make_repo(tmp_path)
546 c = _write_commit(repo)
547 _invoke(repo, "--reviewed-by", "alice", "--dry-run", c.commit_id)
548 rec = read_commit(repo, c.commit_id)
549 assert rec is not None
550 assert "alice" not in rec.reviewed_by
551
552 def test_dry_run_shows_prospective_state(self, tmp_path: pathlib.Path) -> None:
553 repo = _make_repo(tmp_path)
554 c = _write_commit(repo)
555 result = _invoke(repo, "--reviewed-by", "alice", "--dry-run", c.commit_id)
556 assert result.exit_code == 0
557 assert "dry-run" in result.output.lower() or "[dry-run]" in result.output
558
559 def test_dry_run_json_reflects_prospective_state(
560 self, tmp_path: pathlib.Path
561 ) -> None:
562 repo = _make_repo(tmp_path)
563 c = _write_commit(repo)
564 result = _invoke(
565 repo, "--reviewed-by", "alice", "--dry-run", "--json", c.commit_id
566 )
567 data = _parse_json(result)
568 assert "alice" in data["reviewed_by"] # prospective
569 assert data["dry_run"] is True
570 assert data["changed"] is True
571 # Verify disk was NOT modified
572 rec = read_commit(repo, c.commit_id)
573 assert rec is not None
574 assert "alice" not in rec.reviewed_by
575
576 def test_dry_run_test_run_not_incremented(self, tmp_path: pathlib.Path) -> None:
577 repo = _make_repo(tmp_path)
578 c = _write_commit(repo)
579 _invoke(repo, "--test-run", "--dry-run", c.commit_id)
580 rec = read_commit(repo, c.commit_id)
581 assert rec is not None
582 assert rec.test_runs == 0
583
584
585 # ---------------------------------------------------------------------------
586 # Integration — commit reference resolution
587 # ---------------------------------------------------------------------------
588
589
590 class TestCommitRefResolution:
591 def test_full_commit_id(self, tmp_path: pathlib.Path) -> None:
592 repo = _make_repo(tmp_path)
593 c = _write_commit(repo)
594 result = _invoke(repo, c.commit_id)
595 assert result.exit_code == 0
596
597 def test_short_prefix_resolves(self, tmp_path: pathlib.Path) -> None:
598 repo = _make_repo(tmp_path)
599 c = _write_commit(repo)
600 prefix = c.commit_id[:8]
601 result = _invoke(repo, prefix)
602 assert result.exit_code == 0
603
604 def test_head_default_resolves(self, tmp_path: pathlib.Path) -> None:
605 repo = _make_repo(tmp_path)
606 _write_commit(repo)
607 result = _invoke(repo)
608 assert result.exit_code == 0
609
610 def test_head_tilde_resolves(self, tmp_path: pathlib.Path) -> None:
611 repo = _make_repo(tmp_path)
612 c1 = _write_commit(repo, message="first")
613 # Write a second commit with c1 as parent
614 committed_at = datetime.datetime(2026, 3, 2, tzinfo=datetime.timezone.utc)
615 snap_id = compute_snapshot_id({})
616 cid2 = compute_commit_id(
617 [c1.commit_id], snap_id, "second", committed_at.isoformat()
618 )
619 c2 = CommitRecord(
620 commit_id=cid2,
621 repo_id="test-repo",
622 branch="main",
623 snapshot_id=snap_id,
624 message="second",
625 committed_at=committed_at,
626 author="test-author",
627 parent_commit_id=c1.commit_id,
628 )
629 write_commit(repo, c2)
630 (repo / ".muse" / "refs" / "heads" / "main").write_text(
631 cid2, encoding="utf-8"
632 )
633 result = _invoke(repo, "HEAD~1")
634 assert result.exit_code == 0
635
636 def test_nonexistent_commit_exits_not_found(
637 self, tmp_path: pathlib.Path
638 ) -> None:
639 repo = _make_repo(tmp_path)
640 result = _invoke(repo, "0" * 64)
641 assert result.exit_code == ExitCode.NOT_FOUND.value
642
643
644 # ---------------------------------------------------------------------------
645 # Security
646 # ---------------------------------------------------------------------------
647
648
649 class TestAnnotateSecurity:
650 _ANSI = "\x1b[31mevil\x1b[0m"
651
652 def test_ansi_in_reviewer_name_rejected(self, tmp_path: pathlib.Path) -> None:
653 repo = _make_repo(tmp_path)
654 c = _write_commit(repo)
655 result = _invoke(repo, "--reviewed-by", self._ANSI, c.commit_id)
656 assert result.exit_code == ExitCode.USER_ERROR.value
657
658 def test_ansi_not_stored_in_reviewed_by(self, tmp_path: pathlib.Path) -> None:
659 repo = _make_repo(tmp_path)
660 c = _write_commit(repo)
661 _invoke(repo, "--reviewed-by", self._ANSI, c.commit_id)
662 rec = read_commit(repo, c.commit_id)
663 assert rec is not None
664 for r in rec.reviewed_by:
665 assert "\x1b" not in r
666
667 def test_ansi_in_existing_reviewer_stripped_on_display(
668 self, tmp_path: pathlib.Path
669 ) -> None:
670 """Even if a legacy record has ANSI in reviewed_by, output is sanitized."""
671 repo = _make_repo(tmp_path)
672 c = _write_commit(repo)
673 # Force-write a record with ANSI in reviewed_by (simulating legacy data).
674 from muse.core.store import overwrite_commit
675 c.reviewed_by = [self._ANSI]
676 overwrite_commit(repo, c)
677 result = _invoke(repo, c.commit_id)
678 assert result.exit_code == 0
679 assert "\x1b[" not in result.output
680
681 def test_null_byte_in_reviewer_rejected(self, tmp_path: pathlib.Path) -> None:
682 repo = _make_repo(tmp_path)
683 c = _write_commit(repo)
684 result = _invoke(repo, "--reviewed-by", "foo\x00bar", c.commit_id)
685 assert result.exit_code == ExitCode.USER_ERROR.value
686
687 def test_commit_not_found_error_to_stderr_not_traceback(
688 self, tmp_path: pathlib.Path
689 ) -> None:
690 repo = _make_repo(tmp_path)
691 result = _invoke(repo, "nosuchcommit")
692 assert result.exit_code != 0
693 assert "Traceback" not in result.output
694
695 def test_invalid_reviewer_control_char_rejected(
696 self, tmp_path: pathlib.Path
697 ) -> None:
698 repo = _make_repo(tmp_path)
699 c = _write_commit(repo)
700 result = _invoke(repo, "--reviewed-by", "alice\x07bell", c.commit_id)
701 assert result.exit_code == ExitCode.USER_ERROR.value
702
703 def test_overlong_reviewer_rejected(self, tmp_path: pathlib.Path) -> None:
704 from muse.cli.commands.annotate import _MAX_REVIEWER_LEN
705
706 repo = _make_repo(tmp_path)
707 c = _write_commit(repo)
708 result = _invoke(
709 repo, "--reviewed-by", "a" * (_MAX_REVIEWER_LEN + 1), c.commit_id
710 )
711 assert result.exit_code == ExitCode.USER_ERROR.value
712
713 def test_json_output_on_stdout_errors_on_stderr(
714 self, tmp_path: pathlib.Path
715 ) -> None:
716 """JSON consumers must not see errors on stdout."""
717 repo = _make_repo(tmp_path)
718 c = _write_commit(repo)
719 result = _invoke(repo, "--reviewed-by", "alice", "--json", c.commit_id)
720 assert result.exit_code == 0
721 # First non-whitespace char on stdout should be '{' (start of JSON)
722 stripped = result.output.lstrip()
723 assert stripped.startswith("{"), f"Expected JSON, got: {result.output[:80]!r}"
724
725
726 # ---------------------------------------------------------------------------
727 # E2E — full CLI invocations
728 # ---------------------------------------------------------------------------
729
730
731 class TestE2E:
732 def test_json_schema_all_fields_present(self, tmp_path: pathlib.Path) -> None:
733 repo = _make_repo(tmp_path)
734 c = _write_commit(repo)
735 result = _invoke(repo, "--json", c.commit_id)
736 assert result.exit_code == 0
737 data = _parse_json(result)
738 for field in (
739 "commit_id", "message", "branch", "author",
740 "committed_at", "reviewed_by", "test_runs", "changed", "dry_run",
741 ):
742 assert field in data, f"Missing field in JSON: {field}"
743
744 def test_combination_reviewer_and_test_run(
745 self, tmp_path: pathlib.Path
746 ) -> None:
747 repo = _make_repo(tmp_path)
748 c = _write_commit(repo)
749 result = _invoke(
750 repo,
751 "--reviewed-by", "alice",
752 "--test-run",
753 c.commit_id,
754 )
755 assert result.exit_code == 0
756 rec = read_commit(repo, c.commit_id)
757 assert rec is not None
758 assert "alice" in rec.reviewed_by
759 assert rec.test_runs == 1
760
761 def test_head_tilde_used_for_annotation(self, tmp_path: pathlib.Path) -> None:
762 repo = _make_repo(tmp_path)
763 c1 = _write_commit(repo, message="first")
764 committed_at = datetime.datetime(2026, 3, 2, tzinfo=datetime.timezone.utc)
765 snap_id = compute_snapshot_id({})
766 cid2 = compute_commit_id(
767 [c1.commit_id], snap_id, "second", committed_at.isoformat()
768 )
769 c2 = CommitRecord(
770 commit_id=cid2,
771 repo_id="test-repo",
772 branch="main",
773 snapshot_id=snap_id,
774 message="second",
775 committed_at=committed_at,
776 author="test-author",
777 parent_commit_id=c1.commit_id,
778 )
779 write_commit(repo, c2)
780 (repo / ".muse" / "refs" / "heads" / "main").write_text(
781 cid2, encoding="utf-8"
782 )
783 # Annotate the first (parent) commit via HEAD~1
784 result = _invoke(repo, "HEAD~1", "--reviewed-by", "alice")
785 assert result.exit_code == 0
786 rec = read_commit(repo, c1.commit_id)
787 assert rec is not None
788 assert "alice" in rec.reviewed_by
789
790 def test_short_prefix_annotates_correct_commit(
791 self, tmp_path: pathlib.Path
792 ) -> None:
793 repo = _make_repo(tmp_path)
794 c = _write_commit(repo)
795 prefix = c.commit_id[:10]
796 result = _invoke(repo, prefix, "--reviewed-by", "bot")
797 assert result.exit_code == 0
798 rec = read_commit(repo, c.commit_id)
799 assert rec is not None
800 assert "bot" in rec.reviewed_by
801
802 def test_help_text_shows_new_flags(self, tmp_path: pathlib.Path) -> None:
803 result = runner.invoke(cli, ["annotate", "--help"])
804 assert result.exit_code == 0
805 assert "--remove-reviewer" in result.output
806 assert "--dry-run" in result.output
807 assert "--json" in result.output
808
809
810 # ---------------------------------------------------------------------------
811 # Stress
812 # ---------------------------------------------------------------------------
813
814
815 class TestStress:
816 def test_100_reviewers_stored_correctly(
817 self, tmp_path: pathlib.Path
818 ) -> None:
819 repo = _make_repo(tmp_path)
820 c = _write_commit(repo)
821 # Add 100 reviewers in a single comma-separated call
822 names = [f"reviewer-{i:03d}" for i in range(100)]
823 csv = ",".join(names)
824 result = _invoke(repo, "--reviewed-by", csv, c.commit_id)
825 assert result.exit_code == 0
826 rec = read_commit(repo, c.commit_id)
827 assert rec is not None
828 assert len(rec.reviewed_by) == 100
829 for name in names:
830 assert name in rec.reviewed_by
831
832 def test_50_test_run_increments(self, tmp_path: pathlib.Path) -> None:
833 repo = _make_repo(tmp_path)
834 c = _write_commit(repo)
835 for _ in range(50):
836 r = _invoke(repo, "--test-run", c.commit_id)
837 assert r.exit_code == 0
838 rec = read_commit(repo, c.commit_id)
839 assert rec is not None
840 assert rec.test_runs == 50
841
842 def test_concurrent_overwrite_isolated_repos(
843 self, tmp_path: pathlib.Path
844 ) -> None:
845 """Eight threads each call overwrite_commit on isolated repos.
846
847 Tests the core mutation layer for thread-safety without going through
848 the CliRunner (whose env patching is not thread-safe).
849 """
850 from muse.core.store import overwrite_commit
851
852 errors: list[str] = []
853
854 def worker(idx: int) -> None:
855 try:
856 repo = _make_repo(tmp_path / f"repo{idx}")
857 c = _write_commit(repo)
858 c.reviewed_by = [f"agent-{idx}"]
859 overwrite_commit(repo, c)
860 # Read back and verify
861 rec = read_commit(repo, c.commit_id)
862 if rec is None:
863 errors.append(f"Thread {idx}: read_commit returned None")
864 return
865 if f"agent-{idx}" not in rec.reviewed_by:
866 errors.append(f"Thread {idx}: reviewer not found in {rec.reviewed_by!r}")
867 except Exception as exc:
868 errors.append(f"Thread {idx}: {exc}")
869
870 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
871 for t in threads:
872 t.start()
873 for t in threads:
874 t.join()
875
876 assert errors == [], f"Concurrent annotation failures: {errors}"
877
878 def test_concurrent_validate_reviewer(self) -> None:
879 """Eight threads validating names concurrently — no shared mutable state."""
880 from muse.cli.commands.annotate import _validate_reviewer
881
882 errors: list[str] = []
883
884 def worker(idx: int) -> None:
885 try:
886 name = f"reviewer-{idx:03d}"
887 result = _validate_reviewer(name)
888 if result != name:
889 errors.append(f"Thread {idx}: got {result!r}")
890 except Exception as exc:
891 errors.append(f"Thread {idx}: {exc}")
892
893 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
894 for t in threads:
895 t.start()
896 for t in threads:
897 t.join()
898
899 assert errors == [], f"Concurrent validation failures: {errors}"
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago