gabriel / muse public
test_cmd_verify_hardening.py python
871 lines 29.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 days ago
1 """Hardening tests for ``muse verify`` — security, performance, agent UX.
2
3 Covers:
4 Unit (core):
5 - _branch_refs symlink guard (symlinks silently skipped)
6 - _branch_refs size cap (oversized ref file treated as invalid)
7 - _branch_refs branch filter (only named branch returned)
8 - _MAX_COMMITS >= guard (walk stops at budget, not budget+1)
9 - _make_result helper produces correct all_ok
10 - missing-key reported as kind="key_missing" not kind="signature"
11 - fail_fast stops after first failure
12
13 Security:
14 - Symlink inside .muse/refs/heads/ is silently skipped
15 - Oversized ref file content capped — no memory explosion
16 - Invalid ref (bad hex) reported as kind="ref" not passed to BFS
17 - kind column in text output passes through sanitize_display
18
19 Error routing:
20 - I/O error during run_verify goes to stderr
21 - Failures exit with code 1
22
23 JSON schema:
24 - All _VerifyJson fields present: repo_id, branch, fail_fast, check_objects
25 - failures[].kind is one of the documented literals
26 - all_ok=True when failures=[]
27 - --branch reflected in JSON output
28 - --no-objects reflected as check_objects=false in JSON
29
30 New flags:
31 - --branch limits walk to one branch
32 - --fail-fast stops after first failure in text and json mode
33 - --json flag replaces --format json (old flag rejected)
34 - --no-objects flag sets check_objects=False in JSON
35
36 Integration:
37 - Two-branch repo: --branch verifies one, other not touched
38 - Healthy chain + corrupt branch: fail-fast returns exactly one failure
39 - Missing snapshot reported correctly
40 - Multiple failures all listed in JSON
41
42 E2E:
43 - --help shows --json, --branch, --fail-fast flags
44 - Help mentions key_missing in description
45
46 Stress:
47 - 500-commit chain passes with check_objects=True
48 - 500-commit chain with --fail-fast on first corrupt object
49 - Concurrent reads of the same repo (10 threads)
50 """
51
52 from __future__ import annotations
53
54 import datetime
55 import json
56 import pathlib
57 import threading
58
59 import pytest
60 from tests.cli_test_helper import CliRunner, InvokeResult
61
62 from muse.core.object_store import object_path, write_object
63 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
64 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
65
66 from muse.core._types import Manifest, blob_id, long_id, fake_id
67 from muse.core.verify import (
68 VerifyFailure,
69 VerifyResult,
70 _MAX_COMMITS,
71 _branch_refs,
72 _make_result,
73 run_verify,
74 )
75
76 runner = CliRunner()
77 cli = None # argparse migration — CliRunner ignores this arg
78
79 _REPO_ID = "verify-hardening-test"
80
81
82 # ---------------------------------------------------------------------------
83 # TypedDicts for parsing JSON output
84 # ---------------------------------------------------------------------------
85
86
87 from typing import TypedDict
88
89
90 class _FailureOut(TypedDict):
91 kind: str
92 id: str
93 error: str
94
95
96 class _VerifyOut(TypedDict):
97 repo_id: str
98 refs_checked: int
99 commits_checked: int
100 snapshots_checked: int
101 objects_checked: int
102 signatures_checked: int
103 all_ok: bool
104 check_objects: bool
105 branch: str | None
106 fail_fast: bool
107 failures: list[_FailureOut]
108
109
110 # ---------------------------------------------------------------------------
111 # Helpers
112 # ---------------------------------------------------------------------------
113
114
115 def _sha(data: bytes) -> str:
116 return blob_id(data)
117
118
119 def _init_repo(path: pathlib.Path) -> pathlib.Path:
120 muse = path / ".muse"
121 for d in ("commits", "snapshots", "objects", "refs/heads", "keys"):
122 (muse / d).mkdir(parents=True, exist_ok=True)
123 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
124 (muse / "repo.json").write_text(
125 json.dumps({"repo_id": _REPO_ID, "domain": "midi"}), encoding="utf-8"
126 )
127 return path
128
129
130 def _env(repo: pathlib.Path) -> Manifest:
131 return {"MUSE_REPO_ROOT": str(repo)}
132
133
134 def _make_commit(
135 root: pathlib.Path,
136 parent_id: str | None = None,
137 content: bytes = b"data",
138 branch: str = "main",
139 idx: int = 0,
140 ) -> str:
141 raw = content + str(idx).encode()
142 obj_id = _sha(raw)
143 write_object(root, obj_id, raw)
144 manifest = {f"file_{idx}.txt": obj_id}
145 snap_id = compute_snapshot_id(manifest)
146 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
147 committed_at = (
148 datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
149 + datetime.timedelta(hours=idx)
150 )
151 parent_ids = [parent_id] if parent_id else []
152 commit_id = compute_commit_id(
153 repo_id=_REPO_ID,
154 parent_ids=parent_ids,
155 snapshot_id=snap_id,
156 message=f"commit {idx}",
157 committed_at_iso=committed_at.isoformat(),
158 )
159 write_commit(
160 root,
161 CommitRecord(
162 commit_id=commit_id,
163 repo_id=_REPO_ID,
164 created_on_branch=branch,
165 snapshot_id=snap_id,
166 message=f"commit {idx}",
167 committed_at=committed_at,
168 parent_commit_id=parent_id,
169 ),
170 )
171 # Branch names with '/' require subdirectory creation.
172 ref_path = root / ".muse" / "refs" / "heads" / branch
173 ref_path.parent.mkdir(parents=True, exist_ok=True)
174 ref_path.write_text(commit_id, encoding="utf-8")
175 return commit_id
176
177
178 _invoke_lock = threading.Lock()
179
180
181 def _invoke(args: list[str], env: Manifest) -> InvokeResult:
182 with _invoke_lock:
183 return runner.invoke(cli, args, env=env)
184
185
186 def _parse_json(result: InvokeResult) -> _VerifyOut:
187 raw: _VerifyOut = json.loads(result.output)
188 return raw
189
190
191 # ---------------------------------------------------------------------------
192 # Unit: _branch_refs
193 # ---------------------------------------------------------------------------
194
195
196 def test_branch_refs_skips_symlink(tmp_path: pathlib.Path) -> None:
197 _init_repo(tmp_path)
198 heads = tmp_path / ".muse" / "refs" / "heads"
199 real_file = heads / "main"
200 real_file.write_text("a" * 64, encoding="utf-8")
201 link = heads / "evil"
202 link.symlink_to(real_file)
203 refs = _branch_refs(tmp_path)
204 branch_names = [br for br, _ in refs]
205 assert "evil" not in branch_names
206
207
208 def test_branch_refs_size_cap(tmp_path: pathlib.Path) -> None:
209 """Oversized ref file is capped; the truncated content fails hex validation
210 in run_verify and is reported as kind='ref' — not read entirely into memory."""
211 _init_repo(tmp_path)
212 heads = tmp_path / ".muse" / "refs" / "heads"
213 # Write 10 MB into a ref file — _branch_refs reads at most 72 bytes.
214 (heads / "main").write_bytes(b"x" * (10 * 1024 * 1024))
215 refs = _branch_refs(tmp_path)
216 # The truncated content (72 'x' chars) is returned but is not a valid ID.
217 # _branch_refs does not validate format — run_verify does.
218 assert len(refs) == 1
219 branch_name, commit_id = refs[0]
220 assert branch_name == "main"
221 # Neither bare hex (64 chars) nor sha256-prefixed (71 chars) — it's garbage.
222 assert commit_id not in (long_id("x" * 64),) and not commit_id.startswith("sha256:")
223 # run_verify must report this as a ref failure.
224 result = run_verify(tmp_path)
225 assert result["all_ok"] is False
226 kinds = [f["kind"] for f in result["failures"]]
227 assert "ref" in kinds
228
229
230 def test_branch_refs_branch_filter_returns_one(tmp_path: pathlib.Path) -> None:
231 _init_repo(tmp_path)
232 _make_commit(tmp_path, content=b"main", branch="main", idx=0)
233 _make_commit(tmp_path, content=b"dev", branch="dev", idx=1)
234 refs = _branch_refs(tmp_path, branch="main")
235 assert len(refs) == 1
236 assert refs[0][0] == "main"
237
238
239 def test_branch_refs_branch_filter_missing_branch(tmp_path: pathlib.Path) -> None:
240 _init_repo(tmp_path)
241 refs = _branch_refs(tmp_path, branch="nonexistent")
242 assert refs == []
243
244
245 def test_branch_refs_branch_filter_skips_symlink(tmp_path: pathlib.Path) -> None:
246 _init_repo(tmp_path)
247 heads = tmp_path / ".muse" / "refs" / "heads"
248 real = heads / "main"
249 real.write_text("a" * 64, encoding="utf-8")
250 link = heads / "evil"
251 link.symlink_to(real)
252 refs = _branch_refs(tmp_path, branch="evil")
253 assert refs == []
254
255
256 # ---------------------------------------------------------------------------
257 # Unit: _make_result helper
258 # ---------------------------------------------------------------------------
259
260
261 def test_make_result_all_ok_true_when_no_failures() -> None:
262 result = _make_result(1, 1, 1, 1, 0, [])
263 assert result["all_ok"] is True
264 assert result["failures"] == []
265
266
267 def test_make_result_all_ok_false_when_failures() -> None:
268 failure = VerifyFailure(kind="commit", id="abc", error="missing")
269 result = _make_result(1, 0, 0, 0, 0, [failure])
270 assert result["all_ok"] is False
271 assert len(result["failures"]) == 1
272
273
274 # ---------------------------------------------------------------------------
275 # Unit: _MAX_COMMITS guard — >= not >
276 # ---------------------------------------------------------------------------
277
278
279 def test_max_commits_guard_stops_at_budget(tmp_path: pathlib.Path) -> None:
280 """Walk should stop at _MAX_COMMITS, not _MAX_COMMITS+1."""
281 _init_repo(tmp_path)
282 # Create 3 chained commits and monkey-patch _MAX_COMMITS to 2.
283 import muse.core.verify as verify_mod
284
285 orig = verify_mod._MAX_COMMITS
286 try:
287 verify_mod._MAX_COMMITS = 2
288 prev: str | None = None
289 for i in range(5):
290 prev = _make_commit(tmp_path, parent_id=prev, idx=i)
291 result = run_verify(tmp_path)
292 # Walk stopped early — commits_checked <= 2.
293 assert result["commits_checked"] <= 2
294 finally:
295 verify_mod._MAX_COMMITS = orig
296
297
298 # ---------------------------------------------------------------------------
299 # Unit: missing key → kind="key_missing", not kind="signature"
300 # ---------------------------------------------------------------------------
301
302
303 def test_missing_key_reported_as_key_missing(tmp_path: pathlib.Path) -> None:
304 """A signed commit whose key file is absent → kind='key_missing'."""
305 _init_repo(tmp_path)
306 # Write a commit with a non-empty signature and agent_id but no key file.
307 content = b"signed commit"
308 obj_id = _sha(content)
309 write_object(tmp_path, obj_id, content)
310 manifest = {"signed.txt": obj_id}
311 snap_id = compute_snapshot_id(manifest)
312 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
313 committed_at = datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc)
314 commit_id = compute_commit_id(
315 repo_id=_REPO_ID,
316 parent_ids=[],
317 snapshot_id=snap_id,
318 message="signed",
319 committed_at_iso=committed_at.isoformat(),
320 )
321 write_commit(
322 tmp_path,
323 CommitRecord(
324 commit_id=commit_id,
325 repo_id=_REPO_ID,
326 created_on_branch="main",
327 snapshot_id=snap_id,
328 message="signed",
329 committed_at=committed_at,
330 signature="ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
331 agent_id="agent-42",
332 ),
333 )
334 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
335 commit_id, encoding="utf-8"
336 )
337 result = run_verify(tmp_path)
338 kinds = [f["kind"] for f in result["failures"]]
339 assert "key_missing" in kinds
340 assert "signature" not in kinds
341
342
343 # ---------------------------------------------------------------------------
344 # Unit: fail_fast stops after first failure
345 # ---------------------------------------------------------------------------
346
347
348 def test_fail_fast_stops_after_first_failure(tmp_path: pathlib.Path) -> None:
349 _init_repo(tmp_path)
350 # Point main at a nonexistent commit — first failure.
351 fake = "a" * 64
352 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
353 fake, encoding="utf-8"
354 )
355 # Point dev at another nonexistent commit — potential second failure.
356 fake2 = "b" * 64
357 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
358 fake2, encoding="utf-8"
359 )
360 result = run_verify(tmp_path, fail_fast=True)
361 # fail_fast: should stop after first failure, not accumulate both.
362 assert not result["all_ok"]
363 assert len(result["failures"]) == 1
364
365
366 def test_fail_fast_still_ok_when_healthy(tmp_path: pathlib.Path) -> None:
367 _init_repo(tmp_path)
368 _make_commit(tmp_path, content=b"all good", idx=0)
369 result = run_verify(tmp_path, fail_fast=True)
370 assert result["all_ok"] is True
371 assert result["failures"] == []
372
373
374 # ---------------------------------------------------------------------------
375 # Security: ANSI in branch name sanitized in text output
376 # ---------------------------------------------------------------------------
377
378
379 def test_ansi_in_branch_name_sanitized_in_text(tmp_path: pathlib.Path) -> None:
380 _init_repo(tmp_path)
381 # Write a bad ref (invalid ID) for a "branch" with ANSI escape in name.
382 heads = tmp_path / ".muse" / "refs" / "heads"
383 evil_name = "\x1b[31mevil\x1b[0m"
384 (heads / evil_name).write_text("not-valid-hex-id", encoding="utf-8")
385 result = _invoke(["verify"], env=_env(tmp_path))
386 assert "\x1b[" not in result.output
387
388
389 def test_ansi_in_error_sanitized_in_text(tmp_path: pathlib.Path) -> None:
390 _init_repo(tmp_path)
391 # Point main at bad ref — the error text is sanitized.
392 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
393 "not-a-valid-commit-id", encoding="utf-8"
394 )
395 result = _invoke(["verify"], env=_env(tmp_path))
396 assert "\x1b[" not in result.output
397
398
399 # ---------------------------------------------------------------------------
400 # Error routing
401 # ---------------------------------------------------------------------------
402
403
404 def test_failure_exits_with_nonzero(tmp_path: pathlib.Path) -> None:
405 _init_repo(tmp_path)
406 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
407 "b" * 64, encoding="utf-8"
408 )
409 result = _invoke(["verify"], env=_env(tmp_path))
410 assert result.exit_code != 0
411
412
413 def test_quiet_mode_no_stdout(tmp_path: pathlib.Path) -> None:
414 _init_repo(tmp_path)
415 _make_commit(tmp_path, content=b"quiet", idx=0)
416 result = _invoke(["verify", "--quiet"], env=_env(tmp_path))
417 assert result.exit_code == 0
418 assert result.output.strip() == ""
419
420
421 def test_quiet_mode_fails_silently(tmp_path: pathlib.Path) -> None:
422 _init_repo(tmp_path)
423 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
424 "c" * 64, encoding="utf-8"
425 )
426 result = _invoke(["verify", "--quiet"], env=_env(tmp_path))
427 assert result.exit_code != 0
428 assert result.output.strip() == ""
429
430
431 # ---------------------------------------------------------------------------
432 # JSON schema
433 # ---------------------------------------------------------------------------
434
435
436 def test_json_all_fields_present(tmp_path: pathlib.Path) -> None:
437 _init_repo(tmp_path)
438 _make_commit(tmp_path, content=b"schema", idx=0)
439 result = _invoke(["verify", "--json"], env=_env(tmp_path))
440 assert result.exit_code == 0
441 data = _parse_json(result)
442 assert data["repo_id"] == _REPO_ID
443 assert data["all_ok"] is True
444 assert data["failures"] == []
445 assert isinstance(data["refs_checked"], int)
446 assert isinstance(data["commits_checked"], int)
447 assert isinstance(data["snapshots_checked"], int)
448 assert isinstance(data["objects_checked"], int)
449 assert isinstance(data["signatures_checked"], int)
450 assert isinstance(data["check_objects"], bool)
451 assert data["branch"] is None
452 assert data["fail_fast"] is False
453
454
455 def test_json_no_objects_reflected(tmp_path: pathlib.Path) -> None:
456 _init_repo(tmp_path)
457 _make_commit(tmp_path, content=b"no-obj", idx=0)
458 result = _invoke(["verify", "--json", "--no-objects"], env=_env(tmp_path))
459 assert result.exit_code == 0
460 data = _parse_json(result)
461 assert data["check_objects"] is False
462
463
464 def test_json_branch_reflected(tmp_path: pathlib.Path) -> None:
465 _init_repo(tmp_path)
466 _make_commit(tmp_path, content=b"branch-json", branch="feat/x", idx=0)
467 result = _invoke(["verify", "--json", "--branch", "feat/x"], env=_env(tmp_path))
468 assert result.exit_code == 0
469 data = _parse_json(result)
470 assert data["branch"] == "feat/x"
471
472
473 def test_json_fail_fast_reflected(tmp_path: pathlib.Path) -> None:
474 _init_repo(tmp_path)
475 _make_commit(tmp_path, content=b"fail-fast-json", idx=0)
476 result = _invoke(["verify", "--json", "--fail-fast"], env=_env(tmp_path))
477 assert result.exit_code == 0
478 data = _parse_json(result)
479 assert data["fail_fast"] is True
480
481
482 def test_json_failures_have_kind_id_error(tmp_path: pathlib.Path) -> None:
483 _init_repo(tmp_path)
484 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
485 "d" * 64, encoding="utf-8"
486 )
487 result = _invoke(["verify", "--json"], env=_env(tmp_path))
488 assert result.exit_code != 0
489 data = _parse_json(result)
490 assert data["all_ok"] is False
491 for failure in data["failures"]:
492 assert "kind" in failure
493 assert "id" in failure
494 assert "error" in failure
495
496
497 def test_json_failure_kind_is_valid_literal(tmp_path: pathlib.Path) -> None:
498 _init_repo(tmp_path)
499 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
500 "e" * 64, encoding="utf-8"
501 )
502 result = _invoke(["verify", "--json"], env=_env(tmp_path))
503 data = _parse_json(result)
504 valid_kinds = {"ref", "commit", "snapshot", "object", "signature", "key_missing"}
505 for failure in data["failures"]:
506 assert failure["kind"] in valid_kinds
507
508
509 def test_json_missing_snapshot_kind(tmp_path: pathlib.Path) -> None:
510 """A commit pointing at a nonexistent snapshot shows kind='snapshot'."""
511 _init_repo(tmp_path)
512 snap_id = "f" * 64
513 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
514 commit_id = compute_commit_id(
515 repo_id=_REPO_ID,
516 parent_ids=[],
517 snapshot_id=snap_id,
518 message="no snap",
519 committed_at_iso=committed_at.isoformat(),
520 )
521 write_commit(
522 tmp_path,
523 CommitRecord(
524 commit_id=commit_id,
525 repo_id=_REPO_ID,
526 created_on_branch="main",
527 snapshot_id=snap_id,
528 message="no snap",
529 committed_at=committed_at,
530 ),
531 )
532 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
533 commit_id, encoding="utf-8"
534 )
535 result = _invoke(["verify", "--json"], env=_env(tmp_path))
536 data = _parse_json(result)
537 kinds = [f["kind"] for f in data["failures"]]
538 assert "snapshot" in kinds
539
540
541 def test_json_missing_object_kind(tmp_path: pathlib.Path) -> None:
542 """A manifest referencing a nonexistent object shows kind='object'."""
543 _init_repo(tmp_path)
544 obj_id = long_id("0" * 64)
545 manifest = {"ghost.txt": obj_id}
546 snap_id = compute_snapshot_id(manifest)
547 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
548 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
549 commit_id = compute_commit_id(
550 repo_id=_REPO_ID,
551 parent_ids=[],
552 snapshot_id=snap_id,
553 message="ghost",
554 committed_at_iso=committed_at.isoformat(),
555 )
556 write_commit(
557 tmp_path,
558 CommitRecord(
559 commit_id=commit_id,
560 repo_id=_REPO_ID,
561 created_on_branch="main",
562 snapshot_id=snap_id,
563 message="ghost",
564 committed_at=committed_at,
565 ),
566 )
567 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
568 commit_id, encoding="utf-8"
569 )
570 result = _invoke(["verify", "--json"], env=_env(tmp_path))
571 data = _parse_json(result)
572 kinds = [f["kind"] for f in data["failures"]]
573 assert "object" in kinds
574
575
576 # ---------------------------------------------------------------------------
577 # New flags: --branch
578 # ---------------------------------------------------------------------------
579
580
581 def test_branch_flag_limits_to_named_branch(tmp_path: pathlib.Path) -> None:
582 _init_repo(tmp_path)
583 _make_commit(tmp_path, content=b"main ok", branch="main", idx=0)
584 # dev points at a nonexistent commit — if not limited, would fail.
585 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
586 "1" * 64, encoding="utf-8"
587 )
588 result = _invoke(["verify", "--branch", "main"], env=_env(tmp_path))
589 assert result.exit_code == 0
590
591
592 def test_branch_flag_catches_failure_in_named_branch(tmp_path: pathlib.Path) -> None:
593 _init_repo(tmp_path)
594 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
595 "2" * 64, encoding="utf-8"
596 )
597 result = _invoke(["verify", "--branch", "main"], env=_env(tmp_path))
598 assert result.exit_code != 0
599
600
601 def test_branch_flag_missing_branch_is_clean(tmp_path: pathlib.Path) -> None:
602 _init_repo(tmp_path)
603 result = _invoke(["verify", "--branch", "nonexistent"], env=_env(tmp_path))
604 assert result.exit_code == 0
605
606
607 def test_branch_flag_json_branch_field(tmp_path: pathlib.Path) -> None:
608 _init_repo(tmp_path)
609 _make_commit(tmp_path, content=b"b json", branch="main", idx=0)
610 result = _invoke(["verify", "--json", "--branch", "main"], env=_env(tmp_path))
611 data = _parse_json(result)
612 assert data["branch"] == "main"
613 assert data["all_ok"] is True
614
615
616 def test_branch_flag_shown_in_text_output(tmp_path: pathlib.Path) -> None:
617 _init_repo(tmp_path)
618 _make_commit(tmp_path, content=b"text branch", branch="feat/my", idx=0)
619 result = _invoke(["verify", "--branch", "feat/my"], env=_env(tmp_path))
620 assert result.exit_code == 0
621 assert "feat/my" in result.output
622
623
624 # ---------------------------------------------------------------------------
625 # New flags: --fail-fast
626 # ---------------------------------------------------------------------------
627
628
629 def test_fail_fast_cli_stops_early(tmp_path: pathlib.Path) -> None:
630 _init_repo(tmp_path)
631 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
632 "3" * 64, encoding="utf-8"
633 )
634 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
635 "4" * 64, encoding="utf-8"
636 )
637 result = _invoke(["verify", "--json", "--fail-fast"], env=_env(tmp_path))
638 assert result.exit_code != 0
639 data = _parse_json(result)
640 assert len(data["failures"]) == 1
641 assert data["fail_fast"] is True
642
643
644 def test_fail_fast_no_effect_on_healthy_repo(tmp_path: pathlib.Path) -> None:
645 _init_repo(tmp_path)
646 _make_commit(tmp_path, content=b"healthy ff", idx=0)
647 result = _invoke(["verify", "--fail-fast"], env=_env(tmp_path))
648 assert result.exit_code == 0
649
650
651 # ---------------------------------------------------------------------------
652 # New flags: --json replaces --format json
653 # ---------------------------------------------------------------------------
654
655
656 def test_json_flag_works(tmp_path: pathlib.Path) -> None:
657 _init_repo(tmp_path)
658 _make_commit(tmp_path, content=b"json flag", idx=0)
659 result = _invoke(["verify", "--json"], env=_env(tmp_path))
660 assert result.exit_code == 0
661 data = _parse_json(result)
662 assert "all_ok" in data
663
664
665 def test_format_flag_rejected(tmp_path: pathlib.Path) -> None:
666 _init_repo(tmp_path)
667 _make_commit(tmp_path, content=b"fmt flag", idx=0)
668 result = _invoke(["verify", "--format", "json"], env=_env(tmp_path))
669 # --format is no longer a valid flag — argparse will reject it.
670 assert result.exit_code != 0
671
672
673 # ---------------------------------------------------------------------------
674 # Integration
675 # ---------------------------------------------------------------------------
676
677
678 def test_two_branch_repo_healthy(tmp_path: pathlib.Path) -> None:
679 _init_repo(tmp_path)
680 _make_commit(tmp_path, content=b"main", branch="main", idx=0)
681 _make_commit(tmp_path, content=b"dev", branch="dev", idx=1)
682 result = run_verify(tmp_path)
683 assert result["all_ok"] is True
684 assert result["refs_checked"] == 2
685
686
687 def test_two_branch_one_broken_full_check(tmp_path: pathlib.Path) -> None:
688 _init_repo(tmp_path)
689 _make_commit(tmp_path, content=b"main ok", branch="main", idx=0)
690 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
691 fake_id("nonexistent-dev-commit"), encoding="utf-8"
692 )
693 result = run_verify(tmp_path)
694 assert result["all_ok"] is False
695 kinds = {f["kind"] for f in result["failures"]}
696 assert "commit" in kinds
697
698
699 def test_two_branch_one_broken_with_branch_filter(tmp_path: pathlib.Path) -> None:
700 _init_repo(tmp_path)
701 _make_commit(tmp_path, content=b"main ok", branch="main", idx=0)
702 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
703 fake_id("nonexistent-dev-6"), encoding="utf-8"
704 )
705 # Limiting to main only — should pass.
706 result = run_verify(tmp_path, branch="main")
707 assert result["all_ok"] is True
708
709
710 def test_corrupt_object_and_fail_fast(tmp_path: pathlib.Path) -> None:
711 """Corrupt the object, run with fail_fast — exactly one failure returned."""
712 import os
713
714 _init_repo(tmp_path)
715 content = b"will be corrupted"
716 obj_id = _sha(content)
717 write_object(tmp_path, obj_id, content)
718 manifest = {"c.txt": obj_id}
719 snap_id = compute_snapshot_id(manifest)
720 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
721 committed_at = datetime.datetime(2026, 3, 5, tzinfo=datetime.timezone.utc)
722 commit_id = compute_commit_id(
723 repo_id=_REPO_ID,
724 parent_ids=[],
725 snapshot_id=snap_id,
726 message="corrupt",
727 committed_at_iso=committed_at.isoformat(),
728 )
729 write_commit(
730 tmp_path,
731 CommitRecord(
732 commit_id=commit_id,
733 repo_id=_REPO_ID,
734 created_on_branch="main",
735 snapshot_id=snap_id,
736 message="corrupt",
737 committed_at=committed_at,
738 ),
739 )
740 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
741 commit_id, encoding="utf-8"
742 )
743 obj_file = object_path(tmp_path, obj_id)
744 os.chmod(obj_file, 0o644)
745 obj_file.write_bytes(b"bad data!")
746 result = run_verify(tmp_path, check_objects=True, fail_fast=True)
747 assert result["all_ok"] is False
748 assert len(result["failures"]) == 1
749 assert result["failures"][0]["kind"] == "object"
750
751
752 def test_multiple_failures_all_listed(tmp_path: pathlib.Path) -> None:
753 _init_repo(tmp_path)
754 for i in range(5):
755 (tmp_path / ".muse" / "refs" / "heads" / f"br{i}").write_text(
756 chr(ord("a") + i) * 64, encoding="utf-8"
757 )
758 result = run_verify(tmp_path)
759 assert result["all_ok"] is False
760 assert len(result["failures"]) >= 5
761
762
763 # ---------------------------------------------------------------------------
764 # E2E: help output
765 # ---------------------------------------------------------------------------
766
767
768 def test_help_shows_json_flag() -> None:
769 result = runner.invoke(cli, ["verify", "--help"])
770 assert result.exit_code == 0
771 assert "--json" in result.output
772
773
774 def test_help_shows_branch_flag() -> None:
775 result = runner.invoke(cli, ["verify", "--help"])
776 assert result.exit_code == 0
777 assert "--branch" in result.output or "-b" in result.output
778
779
780 def test_help_shows_fail_fast_flag() -> None:
781 result = runner.invoke(cli, ["verify", "--help"])
782 assert result.exit_code == 0
783 assert "--fail-fast" in result.output
784
785
786 def test_help_mentions_key_missing() -> None:
787 result = runner.invoke(cli, ["verify", "--help"])
788 assert result.exit_code == 0
789 assert "key_missing" in result.output or "key" in result.output
790
791
792 # ---------------------------------------------------------------------------
793 # Stress
794 # ---------------------------------------------------------------------------
795
796
797 def test_stress_500_commit_chain(tmp_path: pathlib.Path) -> None:
798 _init_repo(tmp_path)
799 prev: str | None = None
800 for i in range(500):
801 prev = _make_commit(tmp_path, parent_id=prev, content=b"chain", idx=i)
802 result = run_verify(tmp_path, check_objects=True)
803 assert result["all_ok"] is True
804 assert result["commits_checked"] == 500
805
806
807 def test_stress_500_commit_no_objects(tmp_path: pathlib.Path) -> None:
808 _init_repo(tmp_path)
809 prev: str | None = None
810 for i in range(500):
811 prev = _make_commit(tmp_path, parent_id=prev, content=b"fast", idx=i)
812 result = run_verify(tmp_path, check_objects=False)
813 assert result["all_ok"] is True
814 assert result["commits_checked"] == 500
815
816
817 def test_stress_concurrent_reads(tmp_path: pathlib.Path) -> None:
818 _init_repo(tmp_path)
819 prev: str | None = None
820 for i in range(20):
821 prev = _make_commit(tmp_path, parent_id=prev, content=b"conc", idx=i)
822
823 errors: list[str] = []
824 lock = threading.Lock()
825
826 def _read() -> None:
827 res = _invoke(["verify", "--json"], env=_env(tmp_path))
828 with lock:
829 if res.exit_code != 0:
830 errors.append(f"exit_code={res.exit_code}")
831 else:
832 try:
833 data = _parse_json(res)
834 if not data["all_ok"]:
835 errors.append("all_ok=False")
836 except Exception as exc:
837 errors.append(str(exc))
838
839 threads = [threading.Thread(target=_read) for _ in range(10)]
840 for t in threads:
841 t.start()
842 for t in threads:
843 t.join()
844 assert errors == [], f"Concurrent read failures: {errors}"
845
846
847 # ---------------------------------------------------------------------------
848 # Flag registration
849 # ---------------------------------------------------------------------------
850
851
852 class TestRegisterFlags:
853 def _parse(self, *args: str):
854 import argparse
855 from muse.cli.commands.verify import register
856 p = argparse.ArgumentParser()
857 sub = p.add_subparsers()
858 register(sub)
859 return p.parse_args(["verify", *args])
860
861 def test_default_json_out_is_false(self) -> None:
862 ns = self._parse()
863 assert ns.json_out is False
864
865 def test_json_flag_sets_json_out(self) -> None:
866 ns = self._parse("--json")
867 assert ns.json_out is True
868
869 def test_j_shorthand_sets_json_out(self) -> None:
870 ns = self._parse("-j")
871 assert ns.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 141 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 144 days ago