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