gabriel / muse public
test_cmd_annotate_hardening.py python
961 lines 34.6 KB
Raw
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 30 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.ids import hash_commit, hash_snapshot
52 from muse.core.store import CommitRecord, read_commit, write_commit
53 from muse.core.errors import ExitCode
54 from muse.core.types import NULL_COMMIT_ID
55 from muse.core.paths import heads_dir, muse_dir, ref_path
56 from tests.cli_test_helper import CliRunner, InvokeResult
57
58 if TYPE_CHECKING:
59 from muse.cli.commands.annotate import _AnnotateJson
60
61 runner = CliRunner()
62 cli = None # argparse migration — CliRunner ignores this
63
64
65 # ---------------------------------------------------------------------------
66 # Helpers
67 # ---------------------------------------------------------------------------
68
69
70 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
71 """Create a minimal Muse repo layout."""
72 muse = muse_dir(tmp_path)
73 for sub in ("commits", "snapshots", "refs/heads", "objects"):
74 (muse / sub).mkdir(parents=True, exist_ok=True)
75 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
76 (muse / "repo.json").write_text(
77 json.dumps({"repo_id": "test-repo"}), encoding="utf-8"
78 )
79 return tmp_path
80
81
82 def _write_commit(
83 root: pathlib.Path,
84 message: str = "test commit",
85 branch: str = "main",
86 ) -> CommitRecord:
87 """Write a content-addressed CommitRecord and update the branch ref."""
88 committed_at = datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc)
89 snap_id = hash_snapshot({})
90 cid = hash_commit(
91 parent_ids=[],
92 snapshot_id=snap_id,
93 message=message,
94 committed_at_iso=committed_at.isoformat(),
95 author="test-author",
96 )
97 record = CommitRecord(
98 commit_id=cid,
99 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 (ref_path(root, 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 branch=str(raw.get("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[31mmalicious\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[31mmalicious\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 = hash_snapshot({})
275 cid = hash_commit(
276 parent_ids=[],
277 snapshot_id=snap_id,
278 message="msg",
279 committed_at_iso=committed_at.isoformat(),
280 author="alice",
281 )
282 return CommitRecord(
283 commit_id=cid,
284 branch="main",
285 snapshot_id=snap_id,
286 message="msg",
287 committed_at=committed_at,
288 author="alice",
289 reviewed_by=["bob"],
290 test_runs=3,
291 )
292
293 def test_all_fields_present(self) -> None:
294 from muse.cli.commands.annotate import _commit_to_json
295
296 rec = self._record()
297 j = _commit_to_json(rec, changed=True, dry_run=False)
298 for field in (
299 "commit_id", "message", "branch", "author",
300 "committed_at", "reviewed_by", "test_runs", "changed", "dry_run",
301 ):
302 assert field in j, f"Missing field: {field}"
303
304 def test_changed_and_dry_run_flags(self) -> None:
305 from muse.cli.commands.annotate import _commit_to_json
306
307 rec = self._record()
308 j = _commit_to_json(rec, changed=True, dry_run=True)
309 assert j["changed"] is True
310 assert j["dry_run"] is True
311
312 def test_reviewed_by_is_list(self) -> None:
313 from muse.cli.commands.annotate import _commit_to_json
314
315 rec = self._record()
316 j = _commit_to_json(rec, changed=False, dry_run=False)
317 assert isinstance(j["reviewed_by"], list)
318 assert "bob" in j["reviewed_by"]
319
320
321 # ---------------------------------------------------------------------------
322 # Integration — show mode (no mutation flags)
323 # ---------------------------------------------------------------------------
324
325
326 class TestShowMode:
327 def test_show_no_reviewers(self, tmp_path: pathlib.Path) -> None:
328 repo = _make_repo(tmp_path)
329 c = _write_commit(repo)
330 result = _invoke(repo, c.commit_id)
331 assert result.exit_code == 0
332 assert "reviewed-by: (none)" in result.output
333
334 def test_show_existing_reviewers(self, tmp_path: pathlib.Path) -> None:
335 repo = _make_repo(tmp_path)
336 c = _write_commit(repo)
337 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
338 result = _invoke(repo, c.commit_id)
339 assert result.exit_code == 0
340 assert "alice" in result.output
341
342 def test_show_test_runs(self, tmp_path: pathlib.Path) -> None:
343 repo = _make_repo(tmp_path)
344 c = _write_commit(repo)
345 _invoke(repo, "--test-run", c.commit_id)
346 result = _invoke(repo, c.commit_id)
347 assert result.exit_code == 0
348 assert "test-runs: 1" in result.output
349
350 def test_show_head_default(self, tmp_path: pathlib.Path) -> None:
351 repo = _make_repo(tmp_path)
352 _write_commit(repo)
353 result = _invoke(repo)
354 assert result.exit_code == 0
355 assert "reviewed-by" in result.output
356
357 def test_show_json_schema(self, tmp_path: pathlib.Path) -> None:
358 repo = _make_repo(tmp_path)
359 c = _write_commit(repo)
360 result = _invoke(repo, "--json", c.commit_id)
361 assert result.exit_code == 0
362 data = _parse_json(result)
363 assert data["commit_id"] == c.commit_id
364 assert data["changed"] is False
365 assert data["dry_run"] is False
366 assert isinstance(data["reviewed_by"], list)
367 assert isinstance(data["test_runs"], int)
368
369 def test_show_json_has_message_and_branch(self, tmp_path: pathlib.Path) -> None:
370 repo = _make_repo(tmp_path)
371 c = _write_commit(repo, message="feat: some feature")
372 result = _invoke(repo, "--json", c.commit_id)
373 data = _parse_json(result)
374 assert data["message"] == "feat: some feature"
375 assert data["branch"] == "main"
376
377 def test_unknown_commit_exits_not_found(self, tmp_path: pathlib.Path) -> None:
378 repo = _make_repo(tmp_path)
379 result = _invoke(repo, "deadbeef")
380 assert result.exit_code == ExitCode.NOT_FOUND.value
381
382 def test_unknown_commit_no_traceback(self, tmp_path: pathlib.Path) -> None:
383 repo = _make_repo(tmp_path)
384 result = _invoke(repo, "deadbeef")
385 assert "Traceback" not in result.output
386
387
388 # ---------------------------------------------------------------------------
389 # Integration — add reviewer (--reviewed-by)
390 # ---------------------------------------------------------------------------
391
392
393 class TestAddReviewer:
394 def test_single_reviewer_added(self, tmp_path: pathlib.Path) -> None:
395 repo = _make_repo(tmp_path)
396 c = _write_commit(repo)
397 result = _invoke(repo, "--reviewed-by", "alice", c.commit_id)
398 assert result.exit_code == 0
399 rec = read_commit(repo, c.commit_id)
400 assert rec is not None
401 assert "alice" in rec.reviewed_by
402
403 def test_comma_separated_reviewers(self, tmp_path: pathlib.Path) -> None:
404 repo = _make_repo(tmp_path)
405 c = _write_commit(repo)
406 result = _invoke(repo, "--reviewed-by", "alice,bob", c.commit_id)
407 assert result.exit_code == 0
408 rec = read_commit(repo, c.commit_id)
409 assert rec is not None
410 assert "alice" in rec.reviewed_by
411 assert "bob" in rec.reviewed_by
412
413 def test_multiple_reviewed_by_flags(self, tmp_path: pathlib.Path) -> None:
414 repo = _make_repo(tmp_path)
415 c = _write_commit(repo)
416 result = _invoke(
417 repo,
418 "--reviewed-by", "alice",
419 "--reviewed-by", "bob",
420 c.commit_id,
421 )
422 assert result.exit_code == 0
423 rec = read_commit(repo, c.commit_id)
424 assert rec is not None
425 assert "alice" in rec.reviewed_by
426 assert "bob" in rec.reviewed_by
427
428 def test_orset_idempotent(self, tmp_path: pathlib.Path) -> None:
429 repo = _make_repo(tmp_path)
430 c = _write_commit(repo)
431 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
432 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
433 rec = read_commit(repo, c.commit_id)
434 assert rec is not None
435 assert rec.reviewed_by.count("alice") == 1
436
437 def test_reviewer_added_message_shown(self, tmp_path: pathlib.Path) -> None:
438 repo = _make_repo(tmp_path)
439 c = _write_commit(repo)
440 result = _invoke(repo, "--reviewed-by", "alice", c.commit_id)
441 assert "alice" in result.output
442 assert "Added" in result.output
443
444 def test_no_changes_when_reviewer_already_present(
445 self, tmp_path: pathlib.Path
446 ) -> None:
447 repo = _make_repo(tmp_path)
448 c = _write_commit(repo)
449 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
450 result = _invoke(repo, "--reviewed-by", "alice", c.commit_id)
451 assert result.exit_code == 0
452 assert "no changes" in result.output
453
454 def test_json_reflects_new_reviewer(self, tmp_path: pathlib.Path) -> None:
455 repo = _make_repo(tmp_path)
456 c = _write_commit(repo)
457 result = _invoke(repo, "--reviewed-by", "alice", "--json", c.commit_id)
458 assert result.exit_code == 0
459 data = _parse_json(result)
460 assert "alice" in data["reviewed_by"]
461 assert data["changed"] is True
462
463 def test_json_changed_false_when_no_change(self, tmp_path: pathlib.Path) -> None:
464 repo = _make_repo(tmp_path)
465 c = _write_commit(repo)
466 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
467 result = _invoke(repo, "--reviewed-by", "alice", "--json", c.commit_id)
468 data = _parse_json(result)
469 assert data["changed"] is False
470
471
472 # ---------------------------------------------------------------------------
473 # Integration — remove reviewer (--remove-reviewer)
474 # ---------------------------------------------------------------------------
475
476
477 class TestRemoveReviewer:
478 def test_remove_existing_reviewer(self, tmp_path: pathlib.Path) -> None:
479 repo = _make_repo(tmp_path)
480 c = _write_commit(repo)
481 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
482 result = _invoke(repo, "--remove-reviewer", "alice", c.commit_id)
483 assert result.exit_code == 0
484 rec = read_commit(repo, c.commit_id)
485 assert rec is not None
486 assert "alice" not in rec.reviewed_by
487
488 def test_remove_absent_reviewer_warns_to_stderr(
489 self, tmp_path: pathlib.Path
490 ) -> None:
491 repo = _make_repo(tmp_path)
492 c = _write_commit(repo)
493 result = _invoke(repo, "--remove-reviewer", "nobody", c.commit_id)
494 assert result.exit_code == 0
495 assert "⚠️" in result.stderr or "not present" in result.stderr
496
497 def test_remove_one_preserves_others(self, tmp_path: pathlib.Path) -> None:
498 repo = _make_repo(tmp_path)
499 c = _write_commit(repo)
500 _invoke(repo, "--reviewed-by", "alice,bob", c.commit_id)
501 _invoke(repo, "--remove-reviewer", "alice", c.commit_id)
502 rec = read_commit(repo, c.commit_id)
503 assert rec is not None
504 assert "alice" not in rec.reviewed_by
505 assert "bob" in rec.reviewed_by
506
507 def test_remove_json_reflects_change(self, tmp_path: pathlib.Path) -> None:
508 repo = _make_repo(tmp_path)
509 c = _write_commit(repo)
510 _invoke(repo, "--reviewed-by", "alice", c.commit_id)
511 result = _invoke(
512 repo, "--remove-reviewer", "alice", "--json", c.commit_id
513 )
514 data = _parse_json(result)
515 assert "alice" not in data["reviewed_by"]
516 assert data["changed"] is True
517
518
519 # ---------------------------------------------------------------------------
520 # Integration — test-run counter
521 # ---------------------------------------------------------------------------
522
523
524 class TestTestRun:
525 def test_test_run_increments(self, tmp_path: pathlib.Path) -> None:
526 repo = _make_repo(tmp_path)
527 c = _write_commit(repo)
528 _invoke(repo, "--test-run", c.commit_id)
529 _invoke(repo, "--test-run", c.commit_id)
530 rec = read_commit(repo, c.commit_id)
531 assert rec is not None
532 assert rec.test_runs == 2
533
534 def test_test_run_shown_in_output(self, tmp_path: pathlib.Path) -> None:
535 repo = _make_repo(tmp_path)
536 c = _write_commit(repo)
537 result = _invoke(repo, "--test-run", c.commit_id)
538 assert result.exit_code == 0
539 assert "Test run recorded" in result.output
540
541 def test_test_run_json_schema(self, tmp_path: pathlib.Path) -> None:
542 repo = _make_repo(tmp_path)
543 c = _write_commit(repo)
544 result = _invoke(repo, "--test-run", "--json", c.commit_id)
545 assert result.exit_code == 0
546 data = _parse_json(result)
547 assert data["test_runs"] == 1
548 assert data["changed"] is True
549
550
551 # ---------------------------------------------------------------------------
552 # Integration — dry-run
553 # ---------------------------------------------------------------------------
554
555
556 class TestDryRun:
557 def test_dry_run_does_not_write(self, tmp_path: pathlib.Path) -> None:
558 repo = _make_repo(tmp_path)
559 c = _write_commit(repo)
560 _invoke(repo, "--reviewed-by", "alice", "--dry-run", c.commit_id)
561 rec = read_commit(repo, c.commit_id)
562 assert rec is not None
563 assert "alice" not in rec.reviewed_by
564
565 def test_dry_run_shows_prospective_state(self, tmp_path: pathlib.Path) -> None:
566 repo = _make_repo(tmp_path)
567 c = _write_commit(repo)
568 result = _invoke(repo, "--reviewed-by", "alice", "--dry-run", c.commit_id)
569 assert result.exit_code == 0
570 assert "dry-run" in result.output.lower() or "[dry-run]" in result.output
571
572 def test_dry_run_json_reflects_prospective_state(
573 self, tmp_path: pathlib.Path
574 ) -> None:
575 repo = _make_repo(tmp_path)
576 c = _write_commit(repo)
577 result = _invoke(
578 repo, "--reviewed-by", "alice", "--dry-run", "--json", c.commit_id
579 )
580 data = _parse_json(result)
581 assert "alice" in data["reviewed_by"] # prospective
582 assert data["dry_run"] is True
583 assert data["changed"] is True
584 # Verify disk was NOT modified
585 rec = read_commit(repo, c.commit_id)
586 assert rec is not None
587 assert "alice" not in rec.reviewed_by
588
589 def test_dry_run_test_run_not_incremented(self, tmp_path: pathlib.Path) -> None:
590 repo = _make_repo(tmp_path)
591 c = _write_commit(repo)
592 _invoke(repo, "--test-run", "--dry-run", c.commit_id)
593 rec = read_commit(repo, c.commit_id)
594 assert rec is not None
595 assert rec.test_runs == 0
596
597
598 # ---------------------------------------------------------------------------
599 # Integration — commit reference resolution
600 # ---------------------------------------------------------------------------
601
602
603 class TestCommitRefResolution:
604 def test_full_commit_id(self, tmp_path: pathlib.Path) -> None:
605 repo = _make_repo(tmp_path)
606 c = _write_commit(repo)
607 result = _invoke(repo, c.commit_id)
608 assert result.exit_code == 0
609
610 def test_short_prefix_resolves(self, tmp_path: pathlib.Path) -> None:
611 repo = _make_repo(tmp_path)
612 c = _write_commit(repo)
613 prefix = c.commit_id.removeprefix("sha256:")[:8]
614 result = _invoke(repo, prefix)
615 assert result.exit_code == 0
616
617 def test_head_default_resolves(self, tmp_path: pathlib.Path) -> None:
618 repo = _make_repo(tmp_path)
619 _write_commit(repo)
620 result = _invoke(repo)
621 assert result.exit_code == 0
622
623 def test_head_tilde_resolves(self, tmp_path: pathlib.Path) -> None:
624 repo = _make_repo(tmp_path)
625 c1 = _write_commit(repo, message="first")
626 # Write a second commit with c1 as parent
627 committed_at = datetime.datetime(2026, 3, 2, tzinfo=datetime.timezone.utc)
628 snap_id = hash_snapshot({})
629 cid2 = hash_commit(
630 parent_ids=[c1.commit_id],
631 snapshot_id=snap_id,
632 message="second",
633 committed_at_iso=committed_at.isoformat(),
634 author="test-author",
635 )
636 c2 = CommitRecord(
637 commit_id=cid2,
638 branch="main",
639 snapshot_id=snap_id,
640 message="second",
641 committed_at=committed_at,
642 author="test-author",
643 parent_commit_id=c1.commit_id,
644 )
645 write_commit(repo, c2)
646 (heads_dir(repo) / "main").write_text(
647 cid2, encoding="utf-8"
648 )
649 result = _invoke(repo, "HEAD~1")
650 assert result.exit_code == 0
651
652 def test_nonexistent_commit_exits_not_found(
653 self, tmp_path: pathlib.Path
654 ) -> None:
655 repo = _make_repo(tmp_path)
656 result = _invoke(repo, NULL_COMMIT_ID)
657 assert result.exit_code == ExitCode.NOT_FOUND.value
658
659
660 # ---------------------------------------------------------------------------
661 # Security
662 # ---------------------------------------------------------------------------
663
664
665 class TestAnnotateSecurity:
666 _ANSI = "\x1b[31mmalicious\x1b[0m"
667
668 def test_ansi_in_reviewer_name_rejected(self, tmp_path: pathlib.Path) -> None:
669 repo = _make_repo(tmp_path)
670 c = _write_commit(repo)
671 result = _invoke(repo, "--reviewed-by", self._ANSI, c.commit_id)
672 assert result.exit_code == ExitCode.USER_ERROR.value
673
674 def test_ansi_not_stored_in_reviewed_by(self, tmp_path: pathlib.Path) -> None:
675 repo = _make_repo(tmp_path)
676 c = _write_commit(repo)
677 _invoke(repo, "--reviewed-by", self._ANSI, c.commit_id)
678 rec = read_commit(repo, c.commit_id)
679 assert rec is not None
680 for r in rec.reviewed_by:
681 assert "\x1b" not in r
682
683 def test_ansi_in_existing_reviewer_stripped_on_display(
684 self, tmp_path: pathlib.Path
685 ) -> None:
686 """Even if a legacy record has ANSI in reviewed_by, output is sanitized."""
687 repo = _make_repo(tmp_path)
688 c = _write_commit(repo)
689 # Force-write a record with ANSI in reviewed_by (simulating legacy data).
690 from muse.core.store import overwrite_commit
691 c.reviewed_by = [self._ANSI]
692 overwrite_commit(repo, c)
693 result = _invoke(repo, c.commit_id)
694 assert result.exit_code == 0
695 assert "\x1b[" not in result.output
696
697 def test_null_byte_in_reviewer_rejected(self, tmp_path: pathlib.Path) -> None:
698 repo = _make_repo(tmp_path)
699 c = _write_commit(repo)
700 result = _invoke(repo, "--reviewed-by", "foo\x00bar", c.commit_id)
701 assert result.exit_code == ExitCode.USER_ERROR.value
702
703 def test_commit_not_found_error_to_stderr_not_traceback(
704 self, tmp_path: pathlib.Path
705 ) -> None:
706 repo = _make_repo(tmp_path)
707 result = _invoke(repo, "nosuchcommit")
708 assert result.exit_code != 0
709 assert "Traceback" not in result.output
710
711 def test_invalid_reviewer_control_char_rejected(
712 self, tmp_path: pathlib.Path
713 ) -> None:
714 repo = _make_repo(tmp_path)
715 c = _write_commit(repo)
716 result = _invoke(repo, "--reviewed-by", "alice\x07bell", c.commit_id)
717 assert result.exit_code == ExitCode.USER_ERROR.value
718
719 def test_overlong_reviewer_rejected(self, tmp_path: pathlib.Path) -> None:
720 from muse.cli.commands.annotate import _MAX_REVIEWER_LEN
721
722 repo = _make_repo(tmp_path)
723 c = _write_commit(repo)
724 result = _invoke(
725 repo, "--reviewed-by", "a" * (_MAX_REVIEWER_LEN + 1), c.commit_id
726 )
727 assert result.exit_code == ExitCode.USER_ERROR.value
728
729 def test_json_output_on_stdout_errors_on_stderr(
730 self, tmp_path: pathlib.Path
731 ) -> None:
732 """JSON consumers must not see errors on stdout."""
733 repo = _make_repo(tmp_path)
734 c = _write_commit(repo)
735 result = _invoke(repo, "--reviewed-by", "alice", "--json", c.commit_id)
736 assert result.exit_code == 0
737 # First non-whitespace char on stdout should be '{' (start of JSON)
738 stripped = result.output.lstrip()
739 assert stripped.startswith("{"), f"Expected JSON, got: {result.output[:80]!r}"
740
741
742 # ---------------------------------------------------------------------------
743 # E2E — full CLI invocations
744 # ---------------------------------------------------------------------------
745
746
747 class TestE2E:
748 def test_json_schema_all_fields_present(self, tmp_path: pathlib.Path) -> None:
749 repo = _make_repo(tmp_path)
750 c = _write_commit(repo)
751 result = _invoke(repo, "--json", c.commit_id)
752 assert result.exit_code == 0
753 data = _parse_json(result)
754 for field in (
755 "commit_id", "message", "branch", "author",
756 "committed_at", "reviewed_by", "test_runs", "changed", "dry_run",
757 ):
758 assert field in data, f"Missing field in JSON: {field}"
759
760 def test_combination_reviewer_and_test_run(
761 self, tmp_path: pathlib.Path
762 ) -> None:
763 repo = _make_repo(tmp_path)
764 c = _write_commit(repo)
765 result = _invoke(
766 repo,
767 "--reviewed-by", "alice",
768 "--test-run",
769 c.commit_id,
770 )
771 assert result.exit_code == 0
772 rec = read_commit(repo, c.commit_id)
773 assert rec is not None
774 assert "alice" in rec.reviewed_by
775 assert rec.test_runs == 1
776
777 def test_head_tilde_used_for_annotation(self, tmp_path: pathlib.Path) -> None:
778 repo = _make_repo(tmp_path)
779 c1 = _write_commit(repo, message="first")
780 committed_at = datetime.datetime(2026, 3, 2, tzinfo=datetime.timezone.utc)
781 snap_id = hash_snapshot({})
782 cid2 = hash_commit(
783 parent_ids=[c1.commit_id],
784 snapshot_id=snap_id,
785 message="second",
786 committed_at_iso=committed_at.isoformat(),
787 author="test-author",
788 )
789 c2 = CommitRecord(
790 commit_id=cid2,
791 branch="main",
792 snapshot_id=snap_id,
793 message="second",
794 committed_at=committed_at,
795 author="test-author",
796 parent_commit_id=c1.commit_id,
797 )
798 write_commit(repo, c2)
799 (heads_dir(repo) / "main").write_text(
800 cid2, encoding="utf-8"
801 )
802 # Annotate the first (parent) commit via HEAD~1
803 result = _invoke(repo, "HEAD~1", "--reviewed-by", "alice")
804 assert result.exit_code == 0
805 rec = read_commit(repo, c1.commit_id)
806 assert rec is not None
807 assert "alice" in rec.reviewed_by
808
809 def test_short_prefix_annotates_correct_commit(
810 self, tmp_path: pathlib.Path
811 ) -> None:
812 repo = _make_repo(tmp_path)
813 c = _write_commit(repo)
814 prefix = c.commit_id[:10]
815 result = _invoke(repo, prefix, "--reviewed-by", "bot")
816 assert result.exit_code == 0
817 rec = read_commit(repo, c.commit_id)
818 assert rec is not None
819 assert "bot" in rec.reviewed_by
820
821 def test_help_text_shows_new_flags(self, tmp_path: pathlib.Path) -> None:
822 result = runner.invoke(cli, ["annotate", "--help"])
823 assert result.exit_code == 0
824 assert "--remove-reviewer" in result.output
825 assert "--dry-run" in result.output
826 assert "--json" in result.output
827
828
829 # ---------------------------------------------------------------------------
830 # Stress
831 # ---------------------------------------------------------------------------
832
833
834 class TestStress:
835 def test_100_reviewers_stored_correctly(
836 self, tmp_path: pathlib.Path
837 ) -> None:
838 repo = _make_repo(tmp_path)
839 c = _write_commit(repo)
840 # Add 100 reviewers in a single comma-separated call
841 names = [f"reviewer-{i:03d}" for i in range(100)]
842 csv = ",".join(names)
843 result = _invoke(repo, "--reviewed-by", csv, c.commit_id)
844 assert result.exit_code == 0
845 rec = read_commit(repo, c.commit_id)
846 assert rec is not None
847 assert len(rec.reviewed_by) == 100
848 for name in names:
849 assert name in rec.reviewed_by
850
851 def test_50_test_run_increments(self, tmp_path: pathlib.Path) -> None:
852 repo = _make_repo(tmp_path)
853 c = _write_commit(repo)
854 for _ in range(50):
855 r = _invoke(repo, "--test-run", c.commit_id)
856 assert r.exit_code == 0
857 rec = read_commit(repo, c.commit_id)
858 assert rec is not None
859 assert rec.test_runs == 50
860
861 def test_concurrent_overwrite_isolated_repos(
862 self, tmp_path: pathlib.Path
863 ) -> None:
864 """Eight threads each call overwrite_commit on isolated repos.
865
866 Tests the core mutation layer for thread-safety without going through
867 the CliRunner (whose env patching is not thread-safe).
868 """
869 from muse.core.store import overwrite_commit
870
871 errors: list[str] = []
872
873 def worker(idx: int) -> None:
874 try:
875 repo = _make_repo(tmp_path / f"repo{idx}")
876 c = _write_commit(repo)
877 c.reviewed_by = [f"agent-{idx}"]
878 overwrite_commit(repo, c)
879 # Read back and verify
880 rec = read_commit(repo, c.commit_id)
881 if rec is None:
882 errors.append(f"Thread {idx}: read_commit returned None")
883 return
884 if f"agent-{idx}" not in rec.reviewed_by:
885 errors.append(f"Thread {idx}: reviewer not found in {rec.reviewed_by!r}")
886 except Exception as exc:
887 errors.append(f"Thread {idx}: {exc}")
888
889 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
890 for t in threads:
891 t.start()
892 for t in threads:
893 t.join()
894
895 assert errors == [], f"Concurrent annotation failures: {errors}"
896
897 def test_concurrent_validate_reviewer(self) -> None:
898 """Eight threads validating names concurrently — no shared mutable state."""
899 from muse.cli.commands.annotate import _validate_reviewer
900
901 errors: list[str] = []
902
903 def worker(idx: int) -> None:
904 try:
905 name = f"reviewer-{idx:03d}"
906 result = _validate_reviewer(name)
907 if result != name:
908 errors.append(f"Thread {idx}: got {result!r}")
909 except Exception as exc:
910 errors.append(f"Thread {idx}: {exc}")
911
912 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
913 for t in threads:
914 t.start()
915 for t in threads:
916 t.join()
917
918 assert errors == [], f"Concurrent validation failures: {errors}"
919
920
921 class TestRegisterFlags:
922 """Argparse registration tests for ``muse annotate``."""
923
924 def _parse(self, *args: str) -> argparse.Namespace:
925 from muse.cli.commands.annotate import register
926 p = argparse.ArgumentParser()
927 sub = p.add_subparsers()
928 register(sub)
929 return p.parse_args(["annotate", *args])
930
931 def test_default_json_out_is_false(self) -> None:
932 ns = self._parse()
933 assert ns.json_out is False
934
935 def test_json_flag_sets_json_out(self) -> None:
936 ns = self._parse("--json")
937 assert ns.json_out is True
938
939 def test_j_shorthand_sets_json_out(self) -> None:
940 ns = self._parse("-j")
941 assert ns.json_out is True
942
943 def test_dry_run_default(self) -> None:
944 ns = self._parse()
945 assert ns.dry_run is False
946
947 def test_dry_run_flag(self) -> None:
948 ns = self._parse("--dry-run")
949 assert ns.dry_run is True
950
951 def test_dry_run_n_shorthand(self) -> None:
952 ns = self._parse("-n")
953 assert ns.dry_run is True
954
955 def test_commit_arg_default(self) -> None:
956 ns = self._parse()
957 assert ns.commit_arg is None
958
959 def test_reviewed_by_default(self) -> None:
960 ns = self._parse()
961 assert ns.reviewed_by is None
File History 2 commits
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago