gabriel / muse public
test_cmd_verify_hardening.py python
816 lines 28.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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
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 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 65 bytes.
211 (heads / "main").write_bytes(b"x" * (10 * 1024 * 1024))
212 refs = _branch_refs(tmp_path)
213 # The truncated content (65 'x' chars) is returned but is not valid hex.
214 # _branch_refs does not validate hex — run_verify does.
215 assert len(refs) == 1
216 branch_name, commit_id = refs[0]
217 assert branch_name == "main"
218 assert len(commit_id) != 64 or not all(c in "0123456789abcdef" for c in commit_id)
219 # run_verify must report this as a ref failure.
220 result = run_verify(tmp_path)
221 assert result["all_ok"] is False
222 kinds = [f["kind"] for f in result["failures"]]
223 assert "ref" in kinds
224
225
226 def test_branch_refs_branch_filter_returns_one(tmp_path: pathlib.Path) -> None:
227 _init_repo(tmp_path)
228 _make_commit(tmp_path, content=b"main", branch="main", idx=0)
229 _make_commit(tmp_path, content=b"dev", branch="dev", idx=1)
230 refs = _branch_refs(tmp_path, branch="main")
231 assert len(refs) == 1
232 assert refs[0][0] == "main"
233
234
235 def test_branch_refs_branch_filter_missing_branch(tmp_path: pathlib.Path) -> None:
236 _init_repo(tmp_path)
237 refs = _branch_refs(tmp_path, branch="nonexistent")
238 assert refs == []
239
240
241 def test_branch_refs_branch_filter_skips_symlink(tmp_path: pathlib.Path) -> None:
242 _init_repo(tmp_path)
243 heads = tmp_path / ".muse" / "refs" / "heads"
244 real = heads / "main"
245 real.write_text("a" * 64, encoding="utf-8")
246 link = heads / "evil"
247 link.symlink_to(real)
248 refs = _branch_refs(tmp_path, branch="evil")
249 assert refs == []
250
251
252 # ---------------------------------------------------------------------------
253 # Unit: _make_result helper
254 # ---------------------------------------------------------------------------
255
256
257 def test_make_result_all_ok_true_when_no_failures() -> None:
258 result = _make_result(1, 1, 1, 1, 0, [])
259 assert result["all_ok"] is True
260 assert result["failures"] == []
261
262
263 def test_make_result_all_ok_false_when_failures() -> None:
264 failure = VerifyFailure(kind="commit", id="abc", error="missing")
265 result = _make_result(1, 0, 0, 0, 0, [failure])
266 assert result["all_ok"] is False
267 assert len(result["failures"]) == 1
268
269
270 # ---------------------------------------------------------------------------
271 # Unit: _MAX_COMMITS guard — >= not >
272 # ---------------------------------------------------------------------------
273
274
275 def test_max_commits_guard_stops_at_budget(tmp_path: pathlib.Path) -> None:
276 """Walk should stop at _MAX_COMMITS, not _MAX_COMMITS+1."""
277 _init_repo(tmp_path)
278 # Create 3 chained commits and monkey-patch _MAX_COMMITS to 2.
279 import muse.core.verify as verify_mod
280
281 orig = verify_mod._MAX_COMMITS
282 try:
283 verify_mod._MAX_COMMITS = 2
284 prev: str | None = None
285 for i in range(5):
286 prev = _make_commit(tmp_path, parent_id=prev, idx=i)
287 result = run_verify(tmp_path)
288 # Walk stopped early — commits_checked <= 2.
289 assert result["commits_checked"] <= 2
290 finally:
291 verify_mod._MAX_COMMITS = orig
292
293
294 # ---------------------------------------------------------------------------
295 # Unit: missing key → kind="key_missing", not kind="signature"
296 # ---------------------------------------------------------------------------
297
298
299 def test_missing_key_reported_as_key_missing(tmp_path: pathlib.Path) -> None:
300 """A signed commit whose key file is absent → kind='key_missing'."""
301 _init_repo(tmp_path)
302 # Write a commit with a non-empty signature and agent_id but no key file.
303 content = b"signed commit"
304 obj_id = _sha(content)
305 write_object(tmp_path, obj_id, content)
306 manifest = {"signed.txt": obj_id}
307 snap_id = compute_snapshot_id(manifest)
308 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
309 committed_at = datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc)
310 commit_id = compute_commit_id([], snap_id, "signed", committed_at.isoformat())
311 write_commit(
312 tmp_path,
313 CommitRecord(
314 commit_id=commit_id,
315 repo_id=_REPO_ID,
316 branch="main",
317 snapshot_id=snap_id,
318 message="signed",
319 committed_at=committed_at,
320 signature="fakesig",
321 agent_id="agent-42",
322 ),
323 )
324 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
325 commit_id, encoding="utf-8"
326 )
327 result = run_verify(tmp_path)
328 kinds = [f["kind"] for f in result["failures"]]
329 assert "key_missing" in kinds
330 assert "signature" not in kinds
331
332
333 # ---------------------------------------------------------------------------
334 # Unit: fail_fast stops after first failure
335 # ---------------------------------------------------------------------------
336
337
338 def test_fail_fast_stops_after_first_failure(tmp_path: pathlib.Path) -> None:
339 _init_repo(tmp_path)
340 # Point main at a nonexistent commit — first failure.
341 fake = "a" * 64
342 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
343 fake, encoding="utf-8"
344 )
345 # Point dev at another nonexistent commit — potential second failure.
346 fake2 = "b" * 64
347 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
348 fake2, encoding="utf-8"
349 )
350 result = run_verify(tmp_path, fail_fast=True)
351 # fail_fast: should stop after first failure, not accumulate both.
352 assert not result["all_ok"]
353 assert len(result["failures"]) == 1
354
355
356 def test_fail_fast_still_ok_when_healthy(tmp_path: pathlib.Path) -> None:
357 _init_repo(tmp_path)
358 _make_commit(tmp_path, content=b"all good", idx=0)
359 result = run_verify(tmp_path, fail_fast=True)
360 assert result["all_ok"] is True
361 assert result["failures"] == []
362
363
364 # ---------------------------------------------------------------------------
365 # Security: ANSI in branch name sanitized in text output
366 # ---------------------------------------------------------------------------
367
368
369 def test_ansi_in_branch_name_sanitized_in_text(tmp_path: pathlib.Path) -> None:
370 _init_repo(tmp_path)
371 # Write a bad ref (invalid ID) for a "branch" with ANSI escape in name.
372 heads = tmp_path / ".muse" / "refs" / "heads"
373 evil_name = "\x1b[31mevil\x1b[0m"
374 (heads / evil_name).write_text("not-valid-hex-id", encoding="utf-8")
375 result = _invoke(["verify"], env=_env(tmp_path))
376 assert "\x1b[" not in result.output
377
378
379 def test_ansi_in_error_sanitized_in_text(tmp_path: pathlib.Path) -> None:
380 _init_repo(tmp_path)
381 # Point main at bad ref — the error text is sanitized.
382 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
383 "not-a-valid-commit-id", encoding="utf-8"
384 )
385 result = _invoke(["verify"], env=_env(tmp_path))
386 assert "\x1b[" not in result.output
387
388
389 # ---------------------------------------------------------------------------
390 # Error routing
391 # ---------------------------------------------------------------------------
392
393
394 def test_failure_exits_with_nonzero(tmp_path: pathlib.Path) -> None:
395 _init_repo(tmp_path)
396 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
397 "b" * 64, encoding="utf-8"
398 )
399 result = _invoke(["verify"], env=_env(tmp_path))
400 assert result.exit_code != 0
401
402
403 def test_quiet_mode_no_stdout(tmp_path: pathlib.Path) -> None:
404 _init_repo(tmp_path)
405 _make_commit(tmp_path, content=b"quiet", idx=0)
406 result = _invoke(["verify", "--quiet"], env=_env(tmp_path))
407 assert result.exit_code == 0
408 assert result.output.strip() == ""
409
410
411 def test_quiet_mode_fails_silently(tmp_path: pathlib.Path) -> None:
412 _init_repo(tmp_path)
413 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
414 "c" * 64, encoding="utf-8"
415 )
416 result = _invoke(["verify", "--quiet"], env=_env(tmp_path))
417 assert result.exit_code != 0
418 assert result.output.strip() == ""
419
420
421 # ---------------------------------------------------------------------------
422 # JSON schema
423 # ---------------------------------------------------------------------------
424
425
426 def test_json_all_fields_present(tmp_path: pathlib.Path) -> None:
427 _init_repo(tmp_path)
428 _make_commit(tmp_path, content=b"schema", idx=0)
429 result = _invoke(["verify", "--json"], env=_env(tmp_path))
430 assert result.exit_code == 0
431 data = _parse_json(result)
432 assert data["repo_id"] == _REPO_ID
433 assert data["all_ok"] is True
434 assert data["failures"] == []
435 assert isinstance(data["refs_checked"], int)
436 assert isinstance(data["commits_checked"], int)
437 assert isinstance(data["snapshots_checked"], int)
438 assert isinstance(data["objects_checked"], int)
439 assert isinstance(data["signatures_checked"], int)
440 assert isinstance(data["check_objects"], bool)
441 assert data["branch"] is None
442 assert data["fail_fast"] is False
443
444
445 def test_json_no_objects_reflected(tmp_path: pathlib.Path) -> None:
446 _init_repo(tmp_path)
447 _make_commit(tmp_path, content=b"no-obj", idx=0)
448 result = _invoke(["verify", "--json", "--no-objects"], env=_env(tmp_path))
449 assert result.exit_code == 0
450 data = _parse_json(result)
451 assert data["check_objects"] is False
452
453
454 def test_json_branch_reflected(tmp_path: pathlib.Path) -> None:
455 _init_repo(tmp_path)
456 _make_commit(tmp_path, content=b"branch-json", branch="feat/x", idx=0)
457 result = _invoke(["verify", "--json", "--branch", "feat/x"], env=_env(tmp_path))
458 assert result.exit_code == 0
459 data = _parse_json(result)
460 assert data["branch"] == "feat/x"
461
462
463 def test_json_fail_fast_reflected(tmp_path: pathlib.Path) -> None:
464 _init_repo(tmp_path)
465 _make_commit(tmp_path, content=b"fail-fast-json", idx=0)
466 result = _invoke(["verify", "--json", "--fail-fast"], env=_env(tmp_path))
467 assert result.exit_code == 0
468 data = _parse_json(result)
469 assert data["fail_fast"] is True
470
471
472 def test_json_failures_have_kind_id_error(tmp_path: pathlib.Path) -> None:
473 _init_repo(tmp_path)
474 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
475 "d" * 64, encoding="utf-8"
476 )
477 result = _invoke(["verify", "--json"], env=_env(tmp_path))
478 assert result.exit_code != 0
479 data = _parse_json(result)
480 assert data["all_ok"] is False
481 for failure in data["failures"]:
482 assert "kind" in failure
483 assert "id" in failure
484 assert "error" in failure
485
486
487 def test_json_failure_kind_is_valid_literal(tmp_path: pathlib.Path) -> None:
488 _init_repo(tmp_path)
489 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
490 "e" * 64, encoding="utf-8"
491 )
492 result = _invoke(["verify", "--json"], env=_env(tmp_path))
493 data = _parse_json(result)
494 valid_kinds = {"ref", "commit", "snapshot", "object", "signature", "key_missing"}
495 for failure in data["failures"]:
496 assert failure["kind"] in valid_kinds
497
498
499 def test_json_missing_snapshot_kind(tmp_path: pathlib.Path) -> None:
500 """A commit pointing at a nonexistent snapshot shows kind='snapshot'."""
501 _init_repo(tmp_path)
502 snap_id = "f" * 64
503 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
504 commit_id = compute_commit_id([], snap_id, "no snap", committed_at.isoformat())
505 write_commit(
506 tmp_path,
507 CommitRecord(
508 commit_id=commit_id,
509 repo_id=_REPO_ID,
510 branch="main",
511 snapshot_id=snap_id,
512 message="no snap",
513 committed_at=committed_at,
514 ),
515 )
516 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
517 commit_id, encoding="utf-8"
518 )
519 result = _invoke(["verify", "--json"], env=_env(tmp_path))
520 data = _parse_json(result)
521 kinds = [f["kind"] for f in data["failures"]]
522 assert "snapshot" in kinds
523
524
525 def test_json_missing_object_kind(tmp_path: pathlib.Path) -> None:
526 """A manifest referencing a nonexistent object shows kind='object'."""
527 _init_repo(tmp_path)
528 obj_id = "0" * 64
529 manifest = {"ghost.txt": obj_id}
530 snap_id = compute_snapshot_id(manifest)
531 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
532 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
533 commit_id = compute_commit_id([], snap_id, "ghost", committed_at.isoformat())
534 write_commit(
535 tmp_path,
536 CommitRecord(
537 commit_id=commit_id,
538 repo_id=_REPO_ID,
539 branch="main",
540 snapshot_id=snap_id,
541 message="ghost",
542 committed_at=committed_at,
543 ),
544 )
545 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
546 commit_id, encoding="utf-8"
547 )
548 result = _invoke(["verify", "--json"], env=_env(tmp_path))
549 data = _parse_json(result)
550 kinds = [f["kind"] for f in data["failures"]]
551 assert "object" in kinds
552
553
554 # ---------------------------------------------------------------------------
555 # New flags: --branch
556 # ---------------------------------------------------------------------------
557
558
559 def test_branch_flag_limits_to_named_branch(tmp_path: pathlib.Path) -> None:
560 _init_repo(tmp_path)
561 _make_commit(tmp_path, content=b"main ok", branch="main", idx=0)
562 # dev points at a nonexistent commit — if not limited, would fail.
563 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
564 "1" * 64, encoding="utf-8"
565 )
566 result = _invoke(["verify", "--branch", "main"], env=_env(tmp_path))
567 assert result.exit_code == 0
568
569
570 def test_branch_flag_catches_failure_in_named_branch(tmp_path: pathlib.Path) -> None:
571 _init_repo(tmp_path)
572 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
573 "2" * 64, encoding="utf-8"
574 )
575 result = _invoke(["verify", "--branch", "main"], env=_env(tmp_path))
576 assert result.exit_code != 0
577
578
579 def test_branch_flag_missing_branch_is_clean(tmp_path: pathlib.Path) -> None:
580 _init_repo(tmp_path)
581 result = _invoke(["verify", "--branch", "nonexistent"], env=_env(tmp_path))
582 assert result.exit_code == 0
583
584
585 def test_branch_flag_json_branch_field(tmp_path: pathlib.Path) -> None:
586 _init_repo(tmp_path)
587 _make_commit(tmp_path, content=b"b json", branch="main", idx=0)
588 result = _invoke(["verify", "--json", "--branch", "main"], env=_env(tmp_path))
589 data = _parse_json(result)
590 assert data["branch"] == "main"
591 assert data["all_ok"] is True
592
593
594 def test_branch_flag_shown_in_text_output(tmp_path: pathlib.Path) -> None:
595 _init_repo(tmp_path)
596 _make_commit(tmp_path, content=b"text branch", branch="feat/my", idx=0)
597 result = _invoke(["verify", "--branch", "feat/my"], env=_env(tmp_path))
598 assert result.exit_code == 0
599 assert "feat/my" in result.output
600
601
602 # ---------------------------------------------------------------------------
603 # New flags: --fail-fast
604 # ---------------------------------------------------------------------------
605
606
607 def test_fail_fast_cli_stops_early(tmp_path: pathlib.Path) -> None:
608 _init_repo(tmp_path)
609 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
610 "3" * 64, encoding="utf-8"
611 )
612 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
613 "4" * 64, encoding="utf-8"
614 )
615 result = _invoke(["verify", "--json", "--fail-fast"], env=_env(tmp_path))
616 assert result.exit_code != 0
617 data = _parse_json(result)
618 assert len(data["failures"]) == 1
619 assert data["fail_fast"] is True
620
621
622 def test_fail_fast_no_effect_on_healthy_repo(tmp_path: pathlib.Path) -> None:
623 _init_repo(tmp_path)
624 _make_commit(tmp_path, content=b"healthy ff", idx=0)
625 result = _invoke(["verify", "--fail-fast"], env=_env(tmp_path))
626 assert result.exit_code == 0
627
628
629 # ---------------------------------------------------------------------------
630 # New flags: --json replaces --format json
631 # ---------------------------------------------------------------------------
632
633
634 def test_json_flag_works(tmp_path: pathlib.Path) -> None:
635 _init_repo(tmp_path)
636 _make_commit(tmp_path, content=b"json flag", idx=0)
637 result = _invoke(["verify", "--json"], env=_env(tmp_path))
638 assert result.exit_code == 0
639 data = _parse_json(result)
640 assert "all_ok" in data
641
642
643 def test_format_flag_rejected(tmp_path: pathlib.Path) -> None:
644 _init_repo(tmp_path)
645 _make_commit(tmp_path, content=b"fmt flag", idx=0)
646 result = _invoke(["verify", "--format", "json"], env=_env(tmp_path))
647 # --format is no longer a valid flag — argparse will reject it.
648 assert result.exit_code != 0
649
650
651 # ---------------------------------------------------------------------------
652 # Integration
653 # ---------------------------------------------------------------------------
654
655
656 def test_two_branch_repo_healthy(tmp_path: pathlib.Path) -> None:
657 _init_repo(tmp_path)
658 _make_commit(tmp_path, content=b"main", branch="main", idx=0)
659 _make_commit(tmp_path, content=b"dev", branch="dev", idx=1)
660 result = run_verify(tmp_path)
661 assert result["all_ok"] is True
662 assert result["refs_checked"] == 2
663
664
665 def test_two_branch_one_broken_full_check(tmp_path: pathlib.Path) -> None:
666 _init_repo(tmp_path)
667 _make_commit(tmp_path, content=b"main ok", branch="main", idx=0)
668 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
669 "5" * 64, encoding="utf-8"
670 )
671 result = run_verify(tmp_path)
672 assert result["all_ok"] is False
673 kinds = {f["kind"] for f in result["failures"]}
674 assert "commit" in kinds
675
676
677 def test_two_branch_one_broken_with_branch_filter(tmp_path: pathlib.Path) -> None:
678 _init_repo(tmp_path)
679 _make_commit(tmp_path, content=b"main ok", branch="main", idx=0)
680 (tmp_path / ".muse" / "refs" / "heads" / "dev").write_text(
681 "6" * 64, encoding="utf-8"
682 )
683 # Limiting to main only — should pass.
684 result = run_verify(tmp_path, branch="main")
685 assert result["all_ok"] is True
686
687
688 def test_corrupt_object_and_fail_fast(tmp_path: pathlib.Path) -> None:
689 """Corrupt the object, run with fail_fast — exactly one failure returned."""
690 import os
691
692 _init_repo(tmp_path)
693 content = b"will be corrupted"
694 obj_id = _sha(content)
695 write_object(tmp_path, obj_id, content)
696 manifest = {"c.txt": obj_id}
697 snap_id = compute_snapshot_id(manifest)
698 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
699 committed_at = datetime.datetime(2026, 3, 5, tzinfo=datetime.timezone.utc)
700 commit_id = compute_commit_id([], snap_id, "corrupt", committed_at.isoformat())
701 write_commit(
702 tmp_path,
703 CommitRecord(
704 commit_id=commit_id,
705 repo_id=_REPO_ID,
706 branch="main",
707 snapshot_id=snap_id,
708 message="corrupt",
709 committed_at=committed_at,
710 ),
711 )
712 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(
713 commit_id, encoding="utf-8"
714 )
715 obj_file = object_path(tmp_path, obj_id)
716 os.chmod(obj_file, 0o644)
717 obj_file.write_bytes(b"bad data!")
718 result = run_verify(tmp_path, check_objects=True, fail_fast=True)
719 assert result["all_ok"] is False
720 assert len(result["failures"]) == 1
721 assert result["failures"][0]["kind"] == "object"
722
723
724 def test_multiple_failures_all_listed(tmp_path: pathlib.Path) -> None:
725 _init_repo(tmp_path)
726 for i in range(5):
727 (tmp_path / ".muse" / "refs" / "heads" / f"br{i}").write_text(
728 chr(ord("a") + i) * 64, encoding="utf-8"
729 )
730 result = run_verify(tmp_path)
731 assert result["all_ok"] is False
732 assert len(result["failures"]) >= 5
733
734
735 # ---------------------------------------------------------------------------
736 # E2E: help output
737 # ---------------------------------------------------------------------------
738
739
740 def test_help_shows_json_flag() -> None:
741 result = runner.invoke(cli, ["verify", "--help"])
742 assert result.exit_code == 0
743 assert "--json" in result.output
744
745
746 def test_help_shows_branch_flag() -> None:
747 result = runner.invoke(cli, ["verify", "--help"])
748 assert result.exit_code == 0
749 assert "--branch" in result.output or "-b" in result.output
750
751
752 def test_help_shows_fail_fast_flag() -> None:
753 result = runner.invoke(cli, ["verify", "--help"])
754 assert result.exit_code == 0
755 assert "--fail-fast" in result.output or "-F" in result.output
756
757
758 def test_help_mentions_key_missing() -> None:
759 result = runner.invoke(cli, ["verify", "--help"])
760 assert result.exit_code == 0
761 assert "key_missing" in result.output or "key" in result.output
762
763
764 # ---------------------------------------------------------------------------
765 # Stress
766 # ---------------------------------------------------------------------------
767
768
769 def test_stress_500_commit_chain(tmp_path: pathlib.Path) -> None:
770 _init_repo(tmp_path)
771 prev: str | None = None
772 for i in range(500):
773 prev = _make_commit(tmp_path, parent_id=prev, content=b"chain", idx=i)
774 result = run_verify(tmp_path, check_objects=True)
775 assert result["all_ok"] is True
776 assert result["commits_checked"] == 500
777
778
779 def test_stress_500_commit_no_objects(tmp_path: pathlib.Path) -> None:
780 _init_repo(tmp_path)
781 prev: str | None = None
782 for i in range(500):
783 prev = _make_commit(tmp_path, parent_id=prev, content=b"fast", idx=i)
784 result = run_verify(tmp_path, check_objects=False)
785 assert result["all_ok"] is True
786 assert result["commits_checked"] == 500
787
788
789 def test_stress_concurrent_reads(tmp_path: pathlib.Path) -> None:
790 _init_repo(tmp_path)
791 prev: str | None = None
792 for i in range(20):
793 prev = _make_commit(tmp_path, parent_id=prev, content=b"conc", idx=i)
794
795 errors: list[str] = []
796 lock = threading.Lock()
797
798 def _read() -> None:
799 res = _invoke(["verify", "--json"], env=_env(tmp_path))
800 with lock:
801 if res.exit_code != 0:
802 errors.append(f"exit_code={res.exit_code}")
803 else:
804 try:
805 data = _parse_json(res)
806 if not data["all_ok"]:
807 errors.append("all_ok=False")
808 except Exception as exc:
809 errors.append(str(exc))
810
811 threads = [threading.Thread(target=_read) for _ in range(10)]
812 for t in threads:
813 t.start()
814 for t in threads:
815 t.join()
816 assert errors == [], f"Concurrent read failures: {errors}"
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago