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