gabriel / muse public

test_cmd_verify_commit.py file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:e fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived with entity_… · gabriel · Oct 22, 2026
1 """Tests for ``muse verify-commit`` — verify Ed25519 signatures on commits.
2
3 Coverage tiers
4 --------------
5 Unit:
6 _resolve_ref — HEAD→tip, HEAD→missing branch, sha256:-prefixed passthrough,
7 branch name→ref file, missing branch ref→None
8 _verify_one — valid sig, unsigned, missing commit, missing pubkey, bit-flip
9 in signature, unknown sig
10 algo, unknown pubkey algo, committed_at tamper, model_id tamper,
11 wrong keypair, decode_pubkey ValueError, signed_at non-empty,
12 error None on success, key_status cache hit
13 _fetch_key_status — active, revoked, unknown status value, network error
14
15 Integration:
16 Text output: OK/BAD/ERR lines, (unsigned) signer, key= part present/absent
17 JSON output: all schema fields, error field stripped, signed_at non-empty,
18 signer matches agent_id
19 --strict: unsigned exits nonzero; without --strict exits 0
20 --check-key-status: unknown without hub; caches per key_id
21 HEAD shorthand, branch name ref
22 Batch: all valid exits 0; one invalid exits nonzero; result order preserved
23 Nonexistent sha256:- ref → USER_ERROR
24 --json flag accepted (shorthand alias)
25
26 Security:
27 ANSI escape in ref → rejected, error to stderr, stdout empty
28 Null byte in ref → rejected
29 Path traversal ref → rejected
30 Bare hex ref → rejected with clear message, error to stderr, stdout empty
31 No traceback on bad ref or bad format
32
33 Data integrity:
34 committed_at tamper → invalid through full CLI flow
35 model_id tamper → invalid through full CLI flow
36 Wrong keypair → invalid (public key doesn't match signing key)
37
38 Stress:
39 100 signed commits all verify correctly (unit)
40 Batch of 50 commits via CLI → all results emitted
41 key_status_cache: N commits with same key → exactly 1 network call
42 """
43
44 from __future__ import annotations
45 from collections.abc import Mapping
46
47 import datetime
48 import json
49 import pathlib
50 from typing import TYPE_CHECKING
51 from unittest.mock import MagicMock, patch
52
53 import pytest
54
55 from muse.core.types import blob_id, decode_sig, encode_sig, split_id
56
57 if TYPE_CHECKING:
58 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
59 from muse.core.object_store import write_object
60 from muse.core.provenance import (
61 encode_public_key,
62 provenance_payload,
63 sign_commit_record,
64 verify_commit_ed25519,
65 )
66 from muse.core.ids import hash_commit, hash_snapshot
67 from muse.core.commits import (
68 CommitRecord,
69 commit_path,
70 write_commit,
71 )
72 from muse.core.snapshots import (
73 SnapshotRecord,
74 write_snapshot,
75 )
76 from muse.core.types import Manifest
77 from muse.core.paths import heads_dir, muse_dir, ref_path
78 from tests.cli_test_helper import CliRunner, InvokeResult
79
80 runner = CliRunner()
81
82 _REPO_ID = "verify-commit-test"
83 _counter = 0
84
85
86 # ---------------------------------------------------------------------------
87 # Helpers
88 # ---------------------------------------------------------------------------
89
90
91 def _init_repo(path: pathlib.Path) -> pathlib.Path:
92 muse = muse_dir(path)
93 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
94 (muse / d).mkdir(parents=True, exist_ok=True)
95 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
96 (muse / "repo.json").write_text(
97 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
98 )
99 return path
100
101
102 def _env(repo: pathlib.Path) -> Mapping[str, str]:
103 return {"MUSE_REPO_ROOT": str(repo)}
104
105
106 def _make_key() -> "Ed25519PrivateKey":
107 """Generate a fresh Ed25519 private key."""
108 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
109 return Ed25519PrivateKey.generate()
110
111
112 def _commit_files(
113 root: pathlib.Path,
114 files: Mapping[str, bytes],
115 branch: str = "main",
116 message: str | None = None,
117 sign: bool = False,
118 private_key: "Ed25519PrivateKey | None" = None,
119 agent_id: str = "test-agent",
120 model_id: str = "",
121 author: str = "",
122 ) -> tuple[str, CommitRecord]:
123 """Create a commit; optionally sign it. Returns (commit_id, CommitRecord)."""
124 global _counter
125 _counter += 1
126 manifest: Manifest = {}
127 for rel_path, content in files.items():
128 obj_id = blob_id(content)
129 write_object(root, obj_id, content)
130 manifest[rel_path] = obj_id
131 abs_path = root / rel_path
132 abs_path.parent.mkdir(parents=True, exist_ok=True)
133 abs_path.write_bytes(content)
134 snap_id = hash_snapshot(manifest)
135 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
136 committed_at = datetime.datetime.now(datetime.timezone.utc)
137 branch_ref = ref_path(root, branch)
138 parent_id = branch_ref.read_text(encoding="utf-8").strip() if branch_ref.exists() else None
139 parents = [parent_id] if parent_id else []
140 msg = message or f"commit {_counter}"
141
142 # Resolve public key before compute_commit_id so signer_public_key is
143 # bound into the v2 hash (matching what _verify_commit_id expects).
144 sig = ""
145 pub_b64 = ""
146 key_id = ""
147 if sign and private_key is not None:
148 from muse.core.provenance import encode_public_key
149 _, pub_b64 = encode_public_key(private_key)
150
151 commit_id = hash_commit(
152 parent_ids=parents,
153 snapshot_id=snap_id,
154 message=msg,
155 committed_at_iso=committed_at.isoformat(),
156 author=author,
157 signer_public_key=pub_b64,
158 )
159
160 if sign and private_key is not None:
161 result = sign_commit_record(
162 commit_id,
163 agent_id,
164 private_key,
165 model_id=model_id,
166 committed_at=committed_at.isoformat(),
167 )
168 if result:
169 sig, pub_b64, key_id = result
170
171 record = CommitRecord(
172 commit_id=commit_id,
173 branch=branch,
174 snapshot_id=snap_id,
175 message=msg,
176 committed_at=committed_at,
177 parent_commit_id=parent_id,
178 author=author,
179 agent_id=agent_id if sign else "",
180 model_id=model_id if sign else "",
181 signature=sig,
182 signer_public_key=pub_b64,
183 signer_key_id=key_id,
184 )
185 write_commit(root, record)
186 branch_ref.write_text(commit_id, encoding="utf-8")
187 return commit_id, record
188
189
190 def _invoke(repo: pathlib.Path, *args: str) -> InvokeResult:
191 from muse.cli.app import main as cli
192 return runner.invoke(cli, ["verify-commit", *args], env=_env(repo))
193
194
195 def _force_write_commit(root: pathlib.Path, record: CommitRecord) -> None:
196 """Overwrite a commit object unconditionally (bypasses write_commit idempotency).
197
198 Writes to the unified object store path so read_commit can find it.
199 Uses JSON format with the 'commit <size>\0<payload>' header.
200 """
201 import json as _json
202 from muse.core.object_store import object_path as _object_path
203 obj_file = _object_path(root, record.commit_id)
204 obj_file.parent.mkdir(parents=True, exist_ok=True)
205 payload = _json.dumps(record.to_dict(), separators=(",", ":")).encode()
206 obj_file.write_bytes(f"commit {len(payload)}\0".encode() + payload)
207
208
209 # ---------------------------------------------------------------------------
210 # Unit — _resolve_ref
211 # ---------------------------------------------------------------------------
212
213
214 class TestResolveRef:
215 def test_head_resolves_to_branch_tip(self, tmp_path: pathlib.Path) -> None:
216 from muse.cli.commands.verify_commit import _resolve_ref
217 root = _init_repo(tmp_path)
218 key = _make_key()
219 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
220 resolved = _resolve_ref(root, "HEAD")
221 assert resolved == commit_id
222
223 def test_head_returns_none_when_branch_has_no_commits(self, tmp_path: pathlib.Path) -> None:
224 from muse.cli.commands.verify_commit import _resolve_ref
225 root = _init_repo(tmp_path)
226 # HEAD points to main but main ref file doesn't exist yet
227 resolved = _resolve_ref(root, "HEAD")
228 assert resolved is None
229
230 def test_sha256_prefixed_id_passthrough(self, tmp_path: pathlib.Path) -> None:
231 """sha256:-prefixed IDs are returned as-is (no ref-file lookup)."""
232 from muse.cli.commands.verify_commit import _resolve_ref
233 root = _init_repo(tmp_path)
234 key = _make_key()
235 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
236 resolved = _resolve_ref(root, commit_id)
237 assert resolved == commit_id
238
239 def test_bare_hex_normalised_to_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
240 """A 64-char bare hex is normalised to sha256: prefix."""
241 from muse.cli.commands.verify_commit import _resolve_ref
242 root = _init_repo(tmp_path)
243 key = _make_key()
244 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
245 bare = split_id(commit_id)[1]
246 resolved = _resolve_ref(root, bare)
247 assert resolved == commit_id
248
249 def test_branch_name_resolves_via_ref_file(self, tmp_path: pathlib.Path) -> None:
250 from muse.cli.commands.verify_commit import _resolve_ref
251 root = _init_repo(tmp_path)
252 key = _make_key()
253 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, branch="dev")
254 resolved = _resolve_ref(root, "dev")
255 assert resolved == commit_id
256
257 def test_missing_branch_ref_returns_none(self, tmp_path: pathlib.Path) -> None:
258 from muse.cli.commands.verify_commit import _resolve_ref
259 root = _init_repo(tmp_path)
260 resolved = _resolve_ref(root, "nonexistent-branch")
261 assert resolved is None
262
263
264 # ---------------------------------------------------------------------------
265 # Unit — _verify_one
266 # ---------------------------------------------------------------------------
267
268
269 class TestVerifyOne:
270 def test_valid_signature(self, tmp_path: pathlib.Path) -> None:
271 from muse.cli.commands.verify_commit import _verify_one
272 root = _init_repo(tmp_path)
273 key = _make_key()
274 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
275 result = _verify_one(root, commit_id)
276 assert result["valid"] is True
277 assert result["commit_id"] == commit_id
278 assert len(result["key_id"]) > 0
279
280 def test_unsigned_commit_valid_false_no_error(self, tmp_path: pathlib.Path) -> None:
281 from muse.cli.commands.verify_commit import _verify_one
282 root = _init_repo(tmp_path)
283 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
284 result = _verify_one(root, commit_id)
285 assert result["valid"] is False
286 assert result["error"] is None
287 assert result["signer"] == ""
288
289 def test_human_commit_signer_falls_back_to_author(self, tmp_path: pathlib.Path) -> None:
290 """musehub#223: a human-signed commit has no agent_id -- signer must
291 fall back to the local author handle instead of reading as "" (which
292 looks broken/confusing, since humans have no agent_id by design)."""
293 from muse.cli.commands.verify_commit import _verify_one
294 root = _init_repo(tmp_path)
295 key = _make_key()
296 commit_id, _ = _commit_files(
297 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key,
298 agent_id="", author="gabriel",
299 )
300 result = _verify_one(root, commit_id)
301 assert result["signer"] == "gabriel"
302
303 def test_agent_commit_signer_prefers_agent_id_over_author(self, tmp_path: pathlib.Path) -> None:
304 """When both are present, agent_id (not author) remains the signer --
305 the fallback only applies when agent_id is empty."""
306 from muse.cli.commands.verify_commit import _verify_one
307 root = _init_repo(tmp_path)
308 key = _make_key()
309 commit_id, _ = _commit_files(
310 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key,
311 agent_id="claude-code", author="gabriel",
312 )
313 result = _verify_one(root, commit_id)
314 assert result["signer"] == "claude-code"
315
316 def test_missing_commit_returns_error(self, tmp_path: pathlib.Path) -> None:
317 from muse.cli.commands.verify_commit import _verify_one
318 root = _init_repo(tmp_path)
319 result = _verify_one(root, blob_id(b"nonexistent commit"))
320 assert result["valid"] is False
321 assert "not found" in (result["error"] or "")
322
323 def test_missing_public_key_valid_false(self, tmp_path: pathlib.Path) -> None:
324 from muse.cli.commands.verify_commit import _verify_one
325 root = _init_repo(tmp_path)
326 key = _make_key()
327 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
328 tampered = CommitRecord(
329 commit_id=record.commit_id, branch=record.branch,
330 snapshot_id=record.snapshot_id, message=record.message,
331 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
332 agent_id=record.agent_id, signature=record.signature,
333 signer_public_key="", # stripped
334 signer_key_id=record.signer_key_id,
335 )
336 _force_write_commit(root, tampered)
337 result = _verify_one(root, commit_id)
338 assert result["valid"] is False
339
340 def test_bit_flip_in_signature(self, tmp_path: pathlib.Path) -> None:
341 """A single bit flip in the stored signature must invalidate it."""
342 from muse.cli.commands.verify_commit import _verify_one
343 root = _init_repo(tmp_path)
344 key = _make_key()
345 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
346 algo, sig_bytes = decode_sig(record.signature)
347 flipped = bytes([sig_bytes[0] ^ 0xFF]) + sig_bytes[1:]
348 bad_sig = encode_sig(algo, flipped)
349 tampered = CommitRecord(
350 commit_id=record.commit_id, branch=record.branch,
351 snapshot_id=record.snapshot_id, message=record.message,
352 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
353 agent_id=record.agent_id, signature=bad_sig,
354 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
355 )
356 _force_write_commit(root, tampered)
357 result = _verify_one(root, commit_id)
358 assert result["valid"] is False
359
360 def test_unknown_signature_algorithm(self, tmp_path: pathlib.Path) -> None:
361 """A commit whose signature carries an unknown algorithm prefix returns valid=False."""
362 from muse.cli.commands.verify_commit import _verify_one
363 root = _init_repo(tmp_path)
364 key = _make_key()
365 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
366 _, raw_sig_bytes = decode_sig(record.signature)
367 unknown_sig = encode_sig("mldsa65", raw_sig_bytes)
368 tampered = CommitRecord(
369 commit_id=record.commit_id, branch=record.branch,
370 snapshot_id=record.snapshot_id, message=record.message,
371 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
372 agent_id=record.agent_id, signature=unknown_sig,
373 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
374 )
375 _force_write_commit(root, tampered)
376 result = _verify_one(root, commit_id)
377 assert result["valid"] is False
378 assert "mldsa65" in (result.get("error") or "")
379
380 def test_unknown_public_key_algorithm(self, tmp_path: pathlib.Path) -> None:
381 """A commit with an unknown pubkey algorithm prefix returns valid=False with error."""
382 from muse.cli.commands.verify_commit import _verify_one
383 from muse.core.types import encode_pubkey, encode_sig
384 from muse.core.ids import hash_snapshot
385 from muse.core.snapshots import (
386 SnapshotRecord,
387 write_snapshot,
388 )
389 root = _init_repo(tmp_path)
390
391 # Build commit with mldsa65 key from scratch so commit_id is consistent.
392 fake_mldsa_key = encode_pubkey("mldsa65", b"\xab" * 32)
393 fake_sig = encode_sig("ed25519", b"\x00" * 64)
394 snap_id = hash_snapshot({"a.py": blob_id(b"x = 1\n")})
395 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": blob_id(b"x = 1\n")}))
396 import datetime
397 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
398 commit_id = hash_commit( parent_ids=[], snapshot_id=snap_id,
399 message="mldsa-test", committed_at_iso=committed_at.isoformat(),
400 signer_public_key=fake_mldsa_key,
401 )
402 record = CommitRecord(
403 commit_id=commit_id, branch="main",
404 snapshot_id=snap_id, message="mldsa-test", committed_at=committed_at,
405 agent_id="test-agent", signature=fake_sig,
406 signer_public_key=fake_mldsa_key,
407 )
408 commit_path(root, commit_id).parent.mkdir(parents=True, exist_ok=True)
409 _force_write_commit(root, record)
410 (heads_dir(root) / "main").write_text(commit_id, encoding="utf-8")
411
412 result = _verify_one(root, commit_id)
413 assert result["valid"] is False
414 assert "mldsa65" in (result.get("error") or "")
415
416 def test_committed_at_tamper_invalidates_signature(self, tmp_path: pathlib.Path) -> None:
417 """Mutating committed_at in the stored record must invalidate the signature."""
418 from muse.cli.commands.verify_commit import _verify_one
419 root = _init_repo(tmp_path)
420 key = _make_key()
421 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
422 # Shift committed_at by one day.
423 original_ts = record.committed_at
424 tampered_ts = original_ts + datetime.timedelta(days=1)
425 tampered = CommitRecord(
426 commit_id=record.commit_id, branch=record.branch,
427 snapshot_id=record.snapshot_id, message=record.message,
428 committed_at=tampered_ts, # mutated
429 parent_commit_id=record.parent_commit_id,
430 agent_id=record.agent_id, signature=record.signature,
431 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
432 )
433 _force_write_commit(root, tampered)
434 result = _verify_one(root, commit_id)
435 assert result["valid"] is False
436
437 def test_agent_id_tamper_invalidates_signature(self, tmp_path: pathlib.Path) -> None:
438 """Changing agent_id in the stored record must invalidate the signature."""
439 from muse.cli.commands.verify_commit import _verify_one
440 root = _init_repo(tmp_path)
441 key = _make_key()
442 commit_id, record = _commit_files(
443 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, agent_id="agent-A"
444 )
445 tampered = CommitRecord(
446 commit_id=record.commit_id, branch=record.branch,
447 snapshot_id=record.snapshot_id, message=record.message,
448 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
449 agent_id="agent-B", # tampered
450 signature=record.signature,
451 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
452 )
453 _force_write_commit(root, tampered)
454 result = _verify_one(root, commit_id)
455 assert result["valid"] is False
456
457 def test_model_id_tamper_invalidates_signature(self, tmp_path: pathlib.Path) -> None:
458 """Changing model_id in the stored record must invalidate the signature."""
459 from muse.cli.commands.verify_commit import _verify_one
460 root = _init_repo(tmp_path)
461 key = _make_key()
462 commit_id, record = _commit_files(
463 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, model_id="claude-sonnet-4-6"
464 )
465 tampered = CommitRecord(
466 commit_id=record.commit_id, branch=record.branch,
467 snapshot_id=record.snapshot_id, message=record.message,
468 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
469 agent_id=record.agent_id, model_id="claude-opus-4-6", # tampered
470 signature=record.signature,
471 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
472 )
473 _force_write_commit(root, tampered)
474 result = _verify_one(root, commit_id)
475 assert result["valid"] is False
476
477 def test_wrong_keypair_invalid(self, tmp_path: pathlib.Path) -> None:
478 """Public key from a different keypair must not verify the signature."""
479 from muse.cli.commands.verify_commit import _verify_one
480 root = _init_repo(tmp_path)
481 signing_key = _make_key()
482 wrong_key = _make_key()
483 commit_id, record = _commit_files(
484 root, {"a.py": b"x = 1\n"}, sign=True, private_key=signing_key
485 )
486 # Swap in the public key from wrong_key.
487 _, wrong_pub_b64 = encode_public_key(wrong_key)
488 tampered = CommitRecord(
489 commit_id=record.commit_id, branch=record.branch,
490 snapshot_id=record.snapshot_id, message=record.message,
491 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
492 agent_id=record.agent_id, signature=record.signature,
493 signer_public_key=wrong_pub_b64, signer_key_id=record.signer_key_id,
494 )
495 _force_write_commit(root, tampered)
496 result = _verify_one(root, commit_id)
497 assert result["valid"] is False
498
499 def test_signed_at_populated_for_signed_commit(self, tmp_path: pathlib.Path) -> None:
500 """signed_at is a non-empty ISO string for a signed commit."""
501 from muse.cli.commands.verify_commit import _verify_one
502 root = _init_repo(tmp_path)
503 key = _make_key()
504 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
505 result = _verify_one(root, commit_id)
506 assert result["signed_at"]
507 assert "T" in result["signed_at"] # ISO 8601 format
508
509 def test_error_none_for_valid_commit(self, tmp_path: pathlib.Path) -> None:
510 """error field is None when the signature is valid."""
511 from muse.cli.commands.verify_commit import _verify_one
512 root = _init_repo(tmp_path)
513 key = _make_key()
514 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
515 result = _verify_one(root, commit_id)
516 assert result["error"] is None
517
518 def test_key_status_unknown_without_hub(self, tmp_path: pathlib.Path) -> None:
519 from muse.cli.commands.verify_commit import _verify_one
520 root = _init_repo(tmp_path)
521 key = _make_key()
522 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
523 result = _verify_one(root, commit_id, check_key_status=True, hub_url=None)
524 assert result["key_status"] == "unknown"
525
526 def test_key_status_cache_hit_skips_network(self, tmp_path: pathlib.Path) -> None:
527 """Cache hit must prevent a second _fetch_key_status call."""
528 from muse.cli.commands.verify_commit import _verify_one
529 root = _init_repo(tmp_path)
530 key = _make_key()
531 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
532 cache: dict[str, str] = {}
533 call_count = 0
534
535 def mock_fetch(hub_url: str, key_id: str) -> str:
536 nonlocal call_count
537 call_count += 1
538 return "active"
539
540 with patch("muse.cli.commands.verify_commit._fetch_key_status", side_effect=mock_fetch):
541 _verify_one(root, commit_id, check_key_status=True, hub_url="http://fake", key_status_cache=cache)
542 _verify_one(root, commit_id, check_key_status=True, hub_url="http://fake", key_status_cache=cache)
543
544 assert call_count == 1
545
546 def test_json_schema_all_keys_present(self, tmp_path: pathlib.Path) -> None:
547 from muse.cli.commands.verify_commit import _verify_one
548 root = _init_repo(tmp_path)
549 key = _make_key()
550 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
551 result = _verify_one(root, commit_id)
552 assert {"commit_id", "valid", "signer", "key_id", "signed_at", "key_status", "error"} == set(result)
553
554
555 # ---------------------------------------------------------------------------
556 # Unit — _fetch_key_status
557 # ---------------------------------------------------------------------------
558
559
560 class TestFetchKeyStatus:
561 def test_returns_active(self) -> None:
562 from muse.cli.commands.verify_commit import _fetch_key_status
563 mock_resp = MagicMock()
564 mock_resp.read.return_value = json.dumps({"status": "active"}).encode()
565 mock_resp.__enter__ = lambda s: s
566 mock_resp.__exit__ = MagicMock(return_value=False)
567 with patch("urllib.request.urlopen", return_value=mock_resp):
568 assert _fetch_key_status("http://hub", "key123") == "active"
569
570 def test_returns_revoked(self) -> None:
571 from muse.cli.commands.verify_commit import _fetch_key_status
572 mock_resp = MagicMock()
573 mock_resp.read.return_value = json.dumps({"status": "revoked"}).encode()
574 mock_resp.__enter__ = lambda s: s
575 mock_resp.__exit__ = MagicMock(return_value=False)
576 with patch("urllib.request.urlopen", return_value=mock_resp):
577 assert _fetch_key_status("http://hub", "key123") == "revoked"
578
579 def test_returns_unknown_for_unrecognised_status(self) -> None:
580 from muse.cli.commands.verify_commit import _fetch_key_status
581 mock_resp = MagicMock()
582 mock_resp.read.return_value = json.dumps({"status": "pending"}).encode()
583 mock_resp.__enter__ = lambda s: s
584 mock_resp.__exit__ = MagicMock(return_value=False)
585 with patch("urllib.request.urlopen", return_value=mock_resp):
586 assert _fetch_key_status("http://hub", "key123") == "unknown"
587
588 def test_returns_unknown_on_network_error(self) -> None:
589 from muse.cli.commands.verify_commit import _fetch_key_status
590 with patch("urllib.request.urlopen", side_effect=OSError("connection refused")):
591 assert _fetch_key_status("http://hub", "key123") == "unknown"
592
593 def test_returns_unknown_on_timeout(self) -> None:
594 from muse.cli.commands.verify_commit import _fetch_key_status
595 import socket
596 with patch("urllib.request.urlopen", side_effect=socket.timeout("timed out")):
597 assert _fetch_key_status("http://hub", "key123") == "unknown"
598
599 def test_returns_unknown_on_invalid_json(self) -> None:
600 from muse.cli.commands.verify_commit import _fetch_key_status
601 mock_resp = MagicMock()
602 mock_resp.read.return_value = b"not json"
603 mock_resp.__enter__ = lambda s: s
604 mock_resp.__exit__ = MagicMock(return_value=False)
605 with patch("urllib.request.urlopen", return_value=mock_resp):
606 assert _fetch_key_status("http://hub", "key123") == "unknown"
607
608
609 # ---------------------------------------------------------------------------
610 # Integration — text output
611 # ---------------------------------------------------------------------------
612
613
614 class TestTextOutput:
615 def test_ok_line_format(self, tmp_path: pathlib.Path) -> None:
616 """Text output: 'OK <short_id> signer=<agent_id> key=<key_id>'"""
617 root = _init_repo(tmp_path)
618 key = _make_key()
619 commit_id, _ = _commit_files(
620 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, agent_id="claude-code"
621 )
622 result = _invoke(root, commit_id)
623 assert result.exit_code == 0
624 assert "OK" in result.output
625 assert "claude-code" in result.output
626 assert "key=" in result.output
627
628 def test_bad_line_for_invalid_signature(self, tmp_path: pathlib.Path) -> None:
629 root = _init_repo(tmp_path)
630 key = _make_key()
631 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
632 algo, sig_bytes = decode_sig(record.signature)
633 flipped = bytes([sig_bytes[0] ^ 0xFF]) + sig_bytes[1:]
634 tampered = CommitRecord(
635 commit_id=record.commit_id, branch=record.branch,
636 snapshot_id=record.snapshot_id, message=record.message,
637 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
638 agent_id=record.agent_id, signature=encode_sig(algo, flipped),
639 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
640 )
641 _force_write_commit(root, tampered)
642 result = _invoke(root, commit_id)
643 assert result.exit_code != 0
644 assert "BAD" in result.output
645
646 def test_err_line_for_missing_commit(self, tmp_path: pathlib.Path) -> None:
647 """Text output: 'ERR <short_id> (commit not found)'"""
648 root = _init_repo(tmp_path)
649 key = _make_key()
650 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
651 # Write a ref that points to a commit that doesn't exist.
652 ghost_id = blob_id(b"ghost commit that does not exist")
653 result = _invoke(root, ghost_id)
654 assert result.exit_code != 0
655 assert "ERR" in result.output
656
657 def test_unsigned_shows_unsigned_signer(self, tmp_path: pathlib.Path) -> None:
658 """Unsigned commits show '(unsigned)' as the signer in text output."""
659 root = _init_repo(tmp_path)
660 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
661 result = _invoke(root, commit_id)
662 assert result.exit_code == 0
663 assert "(unsigned)" in result.output
664
665 def test_key_absent_from_text_output_for_unsigned(self, tmp_path: pathlib.Path) -> None:
666 """key= part must not appear in text output for unsigned commits."""
667 root = _init_repo(tmp_path)
668 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
669 result = _invoke(root, commit_id)
670 assert "key=" not in result.output
671
672 def test_short_commit_id_in_text_output(self, tmp_path: pathlib.Path) -> None:
673 """Text output uses short_id, not the full 71-char commit ID."""
674 root = _init_repo(tmp_path)
675 key = _make_key()
676 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
677 result = _invoke(root, commit_id)
678 assert commit_id not in result.output # full ID not present
679 assert commit_id[len("sha256:"):len("sha256:") + 12] in result.output # short hex present
680
681
682 # ---------------------------------------------------------------------------
683 # Integration — JSON output
684 # ---------------------------------------------------------------------------
685
686
687 class TestJsonOutput:
688 def test_valid_commit_all_fields(self, tmp_path: pathlib.Path) -> None:
689 root = _init_repo(tmp_path)
690 key = _make_key()
691 commit_id, _ = _commit_files(
692 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, agent_id="claude-code"
693 )
694 result = _invoke(root, commit_id, "--json")
695 assert result.exit_code == 0
696 data = json.loads(result.stdout)
697 assert data["commit_id"] == commit_id
698 assert data["valid"] is True
699 assert data["signer"] == "claude-code"
700 assert data["key_id"]
701 assert data["key_status"] == "unknown"
702
703 def test_signed_at_non_empty_for_signed_commit(self, tmp_path: pathlib.Path) -> None:
704 root = _init_repo(tmp_path)
705 key = _make_key()
706 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
707 result = _invoke(root, commit_id, "--json")
708 data = json.loads(result.stdout)
709 assert data["signed_at"]
710 assert "T" in data["signed_at"]
711
712 def test_error_field_stripped_from_json_output(self, tmp_path: pathlib.Path) -> None:
713 """The internal 'error' field must not appear in emitted JSON."""
714 root = _init_repo(tmp_path)
715 key = _make_key()
716 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
717 result = _invoke(root, commit_id, "--json")
718 data = json.loads(result.stdout)
719 assert "error" not in data
720
721 def test_duration_ms_and_exit_code_in_json(self, tmp_path: pathlib.Path) -> None:
722 """duration_ms and exit_code are present in every JSON result line."""
723 root = _init_repo(tmp_path)
724 key = _make_key()
725 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
726 result = _invoke(root, commit_id, "--json")
727 data = json.loads(result.stdout)
728 assert "duration_ms" in data
729 assert isinstance(data["duration_ms"], float)
730 assert data["duration_ms"] >= 0
731 assert "exit_code" in data
732 assert data["exit_code"] == 0
733
734 def test_exit_code_nonzero_in_json_on_failure(self, tmp_path: pathlib.Path) -> None:
735 """exit_code reflects the actual exit status — non-zero when verification fails."""
736 root = _init_repo(tmp_path)
737 result = _invoke(root, blob_id(b"nonexistent commit"), "--json")
738 data = json.loads(result.stdout)
739 assert data["exit_code"] != 0
740 assert data["duration_ms"] >= 0
741
742 def test_unsigned_commit_json(self, tmp_path: pathlib.Path) -> None:
743 root = _init_repo(tmp_path)
744 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
745 result = _invoke(root, commit_id, "--json")
746 assert result.exit_code == 0
747 data = json.loads(result.stdout)
748 assert data["valid"] is False
749 assert data["signer"] == ""
750 assert "error" not in data
751
752 def test_batch_each_line_is_valid_json(self, tmp_path: pathlib.Path) -> None:
753 """Batch output: one valid JSON object per line."""
754 root = _init_repo(tmp_path)
755 key = _make_key()
756 ids = [_commit_files(root, {f"f{i}.py": f"x={i}".encode()}, sign=True, private_key=key)[0]
757 for i in range(3)]
758 result = _invoke(root, *ids, "--json")
759 assert result.exit_code == 0
760 lines = [l for l in result.stdout.strip().splitlines() if l]
761 assert len(lines) == 3
762 for line in lines:
763 obj = json.loads(line)
764 assert obj["valid"] is True
765
766 def test_batch_results_in_submission_order(self, tmp_path: pathlib.Path) -> None:
767 """Batch results must arrive in the same order as the input commit IDs."""
768 root = _init_repo(tmp_path)
769 key = _make_key()
770 ids = [_commit_files(root, {f"f{i}.py": f"x={i}".encode()}, sign=True, private_key=key)[0]
771 for i in range(5)]
772 result = _invoke(root, *ids, "--json")
773 assert result.exit_code == 0
774 lines = [l for l in result.stdout.strip().splitlines() if l]
775 returned_ids = [json.loads(l)["commit_id"] for l in lines]
776 assert returned_ids == ids
777
778 def test_json_flag_alias(self, tmp_path: pathlib.Path) -> None:
779 """--json is accepted and produces JSON output."""
780 root = _init_repo(tmp_path)
781 key = _make_key()
782 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
783 result = _invoke(root, "--json", commit_id)
784 assert result.exit_code == 0
785 assert "valid" in json.loads(result.stdout)
786
787
788 # ---------------------------------------------------------------------------
789 # Integration — HEAD and branch name refs
790 # ---------------------------------------------------------------------------
791
792
793 class TestRefResolution:
794 def test_head_shorthand(self, tmp_path: pathlib.Path) -> None:
795 root = _init_repo(tmp_path)
796 key = _make_key()
797 _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
798 result = _invoke(root, "HEAD", "--json")
799 assert result.exit_code == 0
800 assert json.loads(result.stdout)["valid"] is True
801
802 def test_branch_name_ref(self, tmp_path: pathlib.Path) -> None:
803 root = _init_repo(tmp_path)
804 key = _make_key()
805 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, branch="dev")
806 result = _invoke(root, "dev", "--json")
807 assert result.exit_code == 0
808 data = json.loads(result.stdout)
809 assert data["commit_id"] == commit_id
810 assert data["valid"] is True
811
812 def test_nonexistent_sha256_ref_exits_user_error(self, tmp_path: pathlib.Path) -> None:
813 """A sha256:-prefixed ID that doesn't exist in the store exits USER_ERROR."""
814 root = _init_repo(tmp_path)
815 key = _make_key()
816 _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
817 ghost = blob_id(b"ghost commit")
818 result = _invoke(root, ghost, "--json")
819 assert result.exit_code != 0
820 data = json.loads(result.stdout)
821 assert data["valid"] is False
822
823 def test_nonexistent_branch_exits_user_error(self, tmp_path: pathlib.Path) -> None:
824 root = _init_repo(tmp_path)
825 result = _invoke(root, "no-such-branch")
826 assert result.exit_code != 0
827 assert result.stdout_bytes == b"" # error went to stderr
828
829
830 # ---------------------------------------------------------------------------
831 # Integration — --strict
832 # ---------------------------------------------------------------------------
833
834
835 class TestStrictMode:
836 def test_unsigned_no_strict_exits_0(self, tmp_path: pathlib.Path) -> None:
837 root = _init_repo(tmp_path)
838 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
839 result = _invoke(root, commit_id, "--json")
840 assert result.exit_code == 0
841 assert json.loads(result.stdout)["valid"] is False
842
843 def test_unsigned_strict_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
844 root = _init_repo(tmp_path)
845 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
846 result = _invoke(root, commit_id, "--strict")
847 assert result.exit_code != 0
848
849 def test_signed_strict_exits_0(self, tmp_path: pathlib.Path) -> None:
850 root = _init_repo(tmp_path)
851 key = _make_key()
852 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
853 result = _invoke(root, commit_id, "--strict")
854 assert result.exit_code == 0
855
856 def test_batch_one_unsigned_strict_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
857 root = _init_repo(tmp_path)
858 key = _make_key()
859 cid1, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
860 cid2, _ = _commit_files(root, {"a.py": b"x = 2\n"}, sign=False)
861 result = _invoke(root, cid1, cid2, "--strict")
862 assert result.exit_code != 0
863
864
865 # ---------------------------------------------------------------------------
866 # Integration — --check-key-status
867 # ---------------------------------------------------------------------------
868
869
870 class TestCheckKeyStatus:
871 def test_unknown_without_hub(self, tmp_path: pathlib.Path) -> None:
872 root = _init_repo(tmp_path)
873 key = _make_key()
874 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
875 result = _invoke(root, commit_id, "--check-key-status", "--json")
876 assert result.exit_code == 0
877 assert json.loads(result.stdout)["key_status"] == "unknown"
878
879 def test_reports_active_status_from_configured_hub(self, tmp_path: pathlib.Path) -> None:
880 """musehub#223 end-to-end: with a hub configured and the hub reporting
881 "active", --check-key-status must surface it through to CLI JSON
882 output -- not "unknown". This is the regression test for the
883 muse.core.repo.read_hub_url import bug: that function never existed,
884 so hub_url silently stayed None on every call regardless of config,
885 and the real GET /api/keys/{key_id}/status endpoint was never reached."""
886 from muse.cli.config import set_hub_url
887
888 root = _init_repo(tmp_path)
889 set_hub_url("https://staging.musehub.ai", root)
890 key = _make_key()
891 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
892
893 mock_resp = MagicMock()
894 mock_resp.read.return_value = json.dumps({"status": "active"}).encode()
895 mock_resp.__enter__ = lambda s: s
896 mock_resp.__exit__ = MagicMock(return_value=False)
897 with patch("urllib.request.urlopen", return_value=mock_resp) as m_open:
898 result = _invoke(root, commit_id, "--check-key-status", "--json")
899
900 assert result.exit_code == 0
901 data = json.loads(result.stdout)
902 assert data["key_status"] == "active"
903 called_url = m_open.call_args.args[0].full_url
904 assert called_url == f"https://staging.musehub.ai/api/keys/{data['key_id']}/status"
905
906
907
908 # ---------------------------------------------------------------------------
909 # Security
910 # ---------------------------------------------------------------------------
911
912
913 class TestSecurity:
914 def test_ansi_in_ref_rejected(self, tmp_path: pathlib.Path) -> None:
915 root = _init_repo(tmp_path)
916 result = _invoke(root, "\x1b[31mbad\x1b[0m")
917 assert result.exit_code != 0
918 assert result.stdout_bytes == b"" # error went to stderr
919
920 def test_null_byte_in_ref_rejected(self, tmp_path: pathlib.Path) -> None:
921 root = _init_repo(tmp_path)
922 result = _invoke(root, "sha256:abc\x00def")
923 assert result.exit_code != 0
924
925 def test_path_traversal_in_ref_rejected(self, tmp_path: pathlib.Path) -> None:
926 root = _init_repo(tmp_path)
927 result = _invoke(root, "../../etc/passwd")
928 assert result.exit_code != 0
929 assert result.stdout_bytes == b""
930
931 def test_bare_hex_ref_rejected_with_message(self, tmp_path: pathlib.Path) -> None:
932 """Bare hex without sha256: prefix → rejected; message mentions sha256:."""
933 root = _init_repo(tmp_path)
934 bare = "a" * 64
935 result = _invoke(root, bare)
936 assert result.exit_code != 0
937 assert result.stdout_bytes == b""
938 assert "sha256:" in result.stderr
939
940 def test_no_traceback_on_bad_ref(self, tmp_path: pathlib.Path) -> None:
941 root = _init_repo(tmp_path)
942 result = _invoke(root, "not-a-real-ref")
943 assert "Traceback" not in result.output
944 assert "Traceback" not in result.stderr
945
946
947 # ---------------------------------------------------------------------------
948 # Data integrity — full CLI flow
949 # ---------------------------------------------------------------------------
950
951
952 class TestDataIntegrity:
953 def test_committed_at_tamper_fails_cli(self, tmp_path: pathlib.Path) -> None:
954 """committed_at mutation detected through the full CLI verify-commit flow."""
955 root = _init_repo(tmp_path)
956 key = _make_key()
957 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
958 tampered_ts = record.committed_at + datetime.timedelta(seconds=1)
959 tampered = CommitRecord(
960 commit_id=record.commit_id, branch=record.branch,
961 snapshot_id=record.snapshot_id, message=record.message,
962 committed_at=tampered_ts,
963 parent_commit_id=record.parent_commit_id,
964 agent_id=record.agent_id, signature=record.signature,
965 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
966 )
967 _force_write_commit(root, tampered)
968 result = _invoke(root, commit_id, "--json")
969 assert result.exit_code != 0
970 assert json.loads(result.stdout)["valid"] is False
971
972 def test_model_id_tamper_fails_cli(self, tmp_path: pathlib.Path) -> None:
973 root = _init_repo(tmp_path)
974 key = _make_key()
975 commit_id, record = _commit_files(
976 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, model_id="claude-sonnet-4-6"
977 )
978 tampered = CommitRecord(
979 commit_id=record.commit_id, branch=record.branch,
980 snapshot_id=record.snapshot_id, message=record.message,
981 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
982 agent_id=record.agent_id, model_id="gpt-5", # tampered
983 signature=record.signature,
984 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
985 )
986 _force_write_commit(root, tampered)
987 result = _invoke(root, commit_id, "--json")
988 assert result.exit_code != 0
989 assert json.loads(result.stdout)["valid"] is False
990
991 def test_wrong_keypair_fails_cli(self, tmp_path: pathlib.Path) -> None:
992 """Public key from a different keypair → invalid through full CLI flow."""
993 root = _init_repo(tmp_path)
994 signing_key = _make_key()
995 wrong_key = _make_key()
996 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=signing_key)
997 _, wrong_pub_b64 = encode_public_key(wrong_key)
998 tampered = CommitRecord(
999 commit_id=record.commit_id, branch=record.branch,
1000 snapshot_id=record.snapshot_id, message=record.message,
1001 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
1002 agent_id=record.agent_id, signature=record.signature,
1003 signer_public_key=wrong_pub_b64, signer_key_id=record.signer_key_id,
1004 )
1005 _force_write_commit(root, tampered)
1006 result = _invoke(root, commit_id, "--json")
1007 assert result.exit_code != 0
1008 assert json.loads(result.stdout)["valid"] is False
1009
1010 def test_ed25519_prefix_survives_store_roundtrip(self, tmp_path: pathlib.Path) -> None:
1011 """Signature and public key must keep 'ed25519:' prefix through write→read."""
1012 from muse.core.commits import read_commit
1013 root = _init_repo(tmp_path)
1014 key = _make_key()
1015 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
1016 reloaded = read_commit(root, commit_id)
1017 assert reloaded is not None
1018 assert reloaded.signature.startswith("ed25519:")
1019 assert reloaded.signer_public_key.startswith("ed25519:")
1020
1021 def test_force_writecommit_path_matches_read_commit(self, tmp_path: pathlib.Path) -> None:
1022 """_force_write_commit must write to the path that read_commit reads from."""
1023 from muse.core.commits import read_commit
1024 root = _init_repo(tmp_path)
1025 key = _make_key()
1026 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
1027 sentinel = CommitRecord(
1028 commit_id=record.commit_id, branch=record.branch,
1029 snapshot_id=record.snapshot_id, message=record.message,
1030 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
1031 agent_id="sentinel-agent", signature=record.signature,
1032 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
1033 )
1034 _force_write_commit(root, sentinel)
1035 reloaded = read_commit(root, commit_id)
1036 assert reloaded is not None
1037 assert reloaded.agent_id == "sentinel-agent"
1038
1039
1040 # ---------------------------------------------------------------------------
1041 # Stress
1042 # ---------------------------------------------------------------------------
1043
1044
1045 class TestStress:
1046 def test_100_signed_commits_all_valid(self, tmp_path: pathlib.Path) -> None:
1047 """100 signed commits all verify correctly (unit path)."""
1048 from muse.cli.commands.verify_commit import _verify_one
1049 root = _init_repo(tmp_path)
1050 key = _make_key()
1051 for i in range(100):
1052 commit_id, _ = _commit_files(
1053 root, {f"f{i}.py": f"v = {i}\n".encode()}, sign=True, private_key=key
1054 )
1055 result = _verify_one(root, commit_id)
1056 assert result["valid"] is True, f"commit {i} failed"
1057
1058 def test_batch_50_commits_all_results_emitted(self, tmp_path: pathlib.Path) -> None:
1059 """Batch of 50 commits → CLI emits exactly 50 JSON lines."""
1060 root = _init_repo(tmp_path)
1061 key = _make_key()
1062 ids = [
1063 _commit_files(root, {f"f{i}.py": f"x={i}".encode()}, sign=True, private_key=key)[0]
1064 for i in range(50)
1065 ]
1066 result = _invoke(root, *ids, "--json")
1067 assert result.exit_code == 0
1068 lines = [l for l in result.stdout.strip().splitlines() if l]
1069 assert len(lines) == 50
1070 assert all(json.loads(l)["valid"] for l in lines)
1071
1072 def test_10_different_keys_all_valid(self, tmp_path: pathlib.Path) -> None:
1073 """Each commit signed with a different key verifies independently."""
1074 from muse.cli.commands.verify_commit import _verify_one
1075 root = _init_repo(tmp_path)
1076 for i in range(10):
1077 key = _make_key()
1078 commit_id, _ = _commit_files(
1079 root, {f"k{i}.py": f"v={i}".encode()}, sign=True, private_key=key
1080 )
1081 result = _verify_one(root, commit_id)
1082 assert result["valid"] is True, f"key {i} failed"
1083
1084 def test_key_status_cache_n_commits_same_key_one_call(self, tmp_path: pathlib.Path) -> None:
1085 """N commits sharing a key_id → exactly 1 network call via cache."""
1086 from muse.cli.commands.verify_commit import _verify_one
1087 root = _init_repo(tmp_path)
1088 key = _make_key()
1089 call_count = 0
1090
1091 def mock_fetch(hub_url: str, key_id: str) -> str:
1092 nonlocal call_count
1093 call_count += 1
1094 return "active"
1095
1096 cache: dict[str, str] = {}
1097 with patch("muse.cli.commands.verify_commit._fetch_key_status", side_effect=mock_fetch):
1098 for i in range(10):
1099 commit_id, _ = _commit_files(
1100 root, {f"f{i}.py": f"x={i}".encode()}, sign=True, private_key=key
1101 )
1102 _verify_one(root, commit_id, check_key_status=True, hub_url="http://fake", key_status_cache=cache)
1103
1104 assert call_count == 1, f"expected 1 network call, got {call_count}"
1105
1106
1107 # ---------------------------------------------------------------------------
1108 # Flag registration
1109 # ---------------------------------------------------------------------------
1110
1111
1112 class TestRegisterFlags:
1113 def _parse(self, *args: str) -> "argparse.Namespace":
1114 import argparse
1115 from muse.cli.commands.verify_commit import register
1116 p = argparse.ArgumentParser()
1117 sub = p.add_subparsers()
1118 register(sub)
1119 return p.parse_args(["verify-commit", *args])
1120
1121 def test_default_json_out_is_false(self) -> None:
1122 ns = self._parse("HEAD")
1123 assert ns.json_out is False
1124
1125 def test_json_flag_sets_json_out(self) -> None:
1126 ns = self._parse("HEAD", "--json")
1127 assert ns.json_out is True
1128
1129 def test_j_shorthand_sets_json_out(self) -> None:
1130 ns = self._parse("HEAD", "-j")
1131 assert ns.json_out is True