gabriel / muse public

test_knowtation_commit_meta.py file-level

at sha256:6 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 test: validate staging push and MP workflow for aaronrene · · Jul 7, 2026
1 """Tests for Phase 4.2 β€” ``muse commit --meta`` and ``--event-type`` flags.
2
3 Coverage tiers (Rule #0)
4 ------------------------
5 1. **Unit** β€” ``_validate_meta_payload``, ``_validate_event_type``,
6 ``_has_sensitive_keys``, new flag parsing, constants.
7 2. **Integration** β€” Commits written to a real repo include ``metadata["meta"]``
8 and ``metadata["event_type"]`` and survive a round-trip
9 through ``read_commit``.
10 3. **End-to-end** β€” CLI invocations using the full argparse + run() path.
11 4. **Stress** β€” 500 sequential commits each carrying ``--meta`` payloads
12 complete in < 60 s.
13 5. **Data-integrity** β€” ``--meta`` content is preserved byte-for-byte through
14 ``write_commit`` β†’ ``read_commit`` β†’ ``CommitRecord``.
15 6. **Performance** β€” 1 000 calls to ``_validate_meta_payload`` and
16 ``_validate_event_type`` complete in < 500 ms combined.
17 7. **Security** β€” Sensitive keys (password, api_key, TOKEN, bearer…),
18 oversized payloads, non-dict JSON, deeply-nested
19 sensitive keys, and control-char injection are all
20 rejected; errors are clear and never leak raw secret
21 values into messages.
22 """
23
24 from __future__ import annotations
25
26 import json
27 import os
28 import pathlib
29 import time
30 from typing import Any
31
32 import pytest
33
34 from tests.cli_test_helper import CliRunner, InvokeResult
35 from muse.core.store import read_commit, get_head_commit_id, read_current_branch
36
37 runner = CliRunner()
38
39
40 # ─────────────────────────────────────────────────────────────────────────────
41 # Helpers
42 # ─────────────────────────────────────────────────────────────────────────────
43
44
45 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
46 """Run a muse command in *repo* and return the result."""
47 saved = os.getcwd()
48 try:
49 os.chdir(repo)
50 return runner.invoke(None, args)
51 finally:
52 os.chdir(saved)
53
54
55 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
56 return _invoke(repo, ["commit", "-m", "test", *extra])
57
58
59 def _init_and_stage(tmp_path: pathlib.Path, filename: str = "note.md") -> pathlib.Path:
60 """Init a repo, create and stage a file, return the repo path."""
61 tmp_path.mkdir(parents=True, exist_ok=True)
62 _invoke(tmp_path, ["init"])
63 (tmp_path / filename).write_text("# Test\n\ncontent\n")
64 return tmp_path
65
66
67 # ─────────────────────────────────────────────────────────────────────────────
68 # Fixtures
69 # ─────────────────────────────────────────────────────────────────────────────
70
71
72 @pytest.fixture()
73 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
74 """Initialised repo with one tracked file ready to commit."""
75 return _init_and_stage(tmp_path)
76
77
78 # =============================================================================
79 # Tier 1 β€” Unit tests
80 # =============================================================================
81
82
83 class TestHasSensitiveKeys:
84 """Unit tests for ``_has_sensitive_keys``."""
85
86 def _fn(self, obj: Any, depth: int = 0) -> bool:
87 from muse.cli.commands.commit import _has_sensitive_keys
88 return _has_sensitive_keys(obj, depth)
89
90 def test_none_is_safe(self) -> None:
91 assert self._fn(None) is False
92
93 def test_empty_dict_is_safe(self) -> None:
94 assert self._fn({}) is False
95
96 def test_password_key_detected(self) -> None:
97 assert self._fn({"password": "x"}) is True
98
99 def test_api_key_underscore(self) -> None:
100 assert self._fn({"api_key": "x"}) is True
101
102 def test_api_key_hyphen(self) -> None:
103 assert self._fn({"api-key": "x"}) is True
104
105 def test_token_uppercase(self) -> None:
106 assert self._fn({"TOKEN": "x"}) is True
107
108 def test_secret_key(self) -> None:
109 assert self._fn({"secret": "x"}) is True
110
111 def test_bearer_key(self) -> None:
112 assert self._fn({"bearer": "x"}) is True
113
114 def test_private_key_underscore(self) -> None:
115 assert self._fn({"private_key": "x"}) is True
116
117 def test_private_key_hyphen(self) -> None:
118 assert self._fn({"private-key": "x"}) is True
119
120 def test_credential_key(self) -> None:
121 assert self._fn({"credential": "x"}) is True
122
123 def test_authorization_key(self) -> None:
124 assert self._fn({"authorization": "Bearer abc"}) is True
125
126 def test_safe_key(self) -> None:
127 assert self._fn({"topic": "agents", "confidence": 0.9}) is False
128
129 def test_nested_sensitive_key_depth_1(self) -> None:
130 assert self._fn({"outer": {"password": "x"}}) is True
131
132 def test_nested_sensitive_key_depth_8(self) -> None:
133 """Sensitive key at exactly depth 8 must be detected."""
134 d: Any = {"password": "x"}
135 for _ in range(8):
136 d = {"nested": d}
137 assert self._fn(d) is True
138
139 def test_nested_sensitive_key_depth_9_not_scanned(self) -> None:
140 """Sensitive key at depth 9 (beyond limit) must NOT be detected."""
141 d: Any = {"password": "x"}
142 for _ in range(9):
143 d = {"safe": d}
144 assert self._fn(d) is False
145
146 def test_sensitive_key_in_list(self) -> None:
147 assert self._fn([{"password": "x"}]) is True
148
149 def test_safe_list(self) -> None:
150 assert self._fn([{"topic": "x"}, {"tag": "y"}]) is False
151
152 def test_integer_scalar_safe(self) -> None:
153 assert self._fn(42) is False
154
155 def test_string_scalar_safe(self) -> None:
156 assert self._fn("password") is False # values don't matter, only keys
157
158
159 class TestValidateMetaPayload:
160 """Unit tests for ``_validate_meta_payload``."""
161
162 def _fn(self, raw: str) -> str:
163 from muse.cli.commands.commit import _validate_meta_payload
164 return _validate_meta_payload(raw)
165
166 def test_valid_simple_dict(self) -> None:
167 result = self._fn('{"topic": "agents"}')
168 assert json.loads(result) == {"topic": "agents"}
169
170 def test_returns_canonical_json(self) -> None:
171 """Keys must be sorted in the canonical output."""
172 result = self._fn('{"z": 1, "a": 2}')
173 parsed = json.loads(result)
174 assert list(parsed.keys()) == sorted(parsed.keys())
175
176 def test_invalid_json_raises(self) -> None:
177 with pytest.raises(ValueError, match="valid JSON"):
178 self._fn("{not valid}")
179
180 def test_json_array_raises(self) -> None:
181 with pytest.raises(ValueError, match="JSON object"):
182 self._fn("[1, 2, 3]")
183
184 def test_json_string_raises(self) -> None:
185 with pytest.raises(ValueError, match="JSON object"):
186 self._fn('"hello"')
187
188 def test_json_number_raises(self) -> None:
189 with pytest.raises(ValueError, match="JSON object"):
190 self._fn("42")
191
192 def test_json_null_raises(self) -> None:
193 with pytest.raises(ValueError, match="JSON object"):
194 self._fn("null")
195
196 def test_sensitive_key_password_raises(self) -> None:
197 with pytest.raises(ValueError, match="sensitive-data pattern"):
198 self._fn('{"password": "x"}')
199
200 def test_sensitive_key_api_key_raises(self) -> None:
201 with pytest.raises(ValueError, match="sensitive-data pattern"):
202 self._fn('{"api_key": "123"}')
203
204 def test_sensitive_nested_key_raises(self) -> None:
205 with pytest.raises(ValueError, match="sensitive-data pattern"):
206 self._fn('{"outer": {"token": "abc"}}')
207
208 def test_oversized_payload_raises(self) -> None:
209 from muse.cli.commands.commit import _MAX_META_BYTES
210 # Build a payload that serialises to more than _MAX_META_BYTES bytes.
211 big = {"k" + str(i): "v" * 100 for i in range(_MAX_META_BYTES // 100 + 1)}
212 with pytest.raises(ValueError, match="size limit"):
213 self._fn(json.dumps(big))
214
215 def test_exactly_at_limit_accepted(self) -> None:
216 """A payload at exactly _MAX_META_BYTES bytes (after encoding) must pass."""
217 from muse.cli.commands.commit import _MAX_META_BYTES
218 # Build the smallest dict whose canonical form is ≀ the limit.
219 payload = {"x": "y"}
220 assert len(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()) <= _MAX_META_BYTES
221 self._fn(json.dumps(payload)) # must not raise
222
223 def test_empty_dict_accepted(self) -> None:
224 result = self._fn("{}")
225 assert result == "{}"
226
227 def test_nested_values_preserved(self) -> None:
228 raw = '{"context": {"agent": "claude", "confidence": 0.95}}'
229 result = self._fn(raw)
230 assert json.loads(result)["context"]["confidence"] == 0.95
231
232 def test_reserved_top_level_meta_raises(self) -> None:
233 """Top-level 'meta' key conflicts with the CLI-controlled metadata slot."""
234 with pytest.raises(ValueError, match="reserved top-level key"):
235 self._fn('{"meta": {"x": 1}}')
236
237 def test_reserved_top_level_event_type_raises(self) -> None:
238 """Top-level 'event_type' key conflicts with the --event-type slot."""
239 with pytest.raises(ValueError, match="reserved top-level key"):
240 self._fn('{"event_type": "search"}')
241
242 def test_reserved_top_level_both_raises(self) -> None:
243 """Both reserved keys present together must still raise."""
244 with pytest.raises(ValueError, match="reserved top-level key"):
245 self._fn('{"meta": {}, "event_type": "x"}')
246
247 def test_reserved_key_only_at_top_level(self) -> None:
248 """Nested 'meta' / 'event_type' (not at top level) is allowed."""
249 result = self._fn('{"context": {"meta": "ok", "event_type": "ok"}}')
250 parsed = json.loads(result)
251 assert parsed["context"]["meta"] == "ok"
252 assert parsed["context"]["event_type"] == "ok"
253
254 def test_nan_value_rejected(self) -> None:
255 """NaN is not valid JSON; must be rejected at parse time."""
256 with pytest.raises(ValueError, match="NaN"):
257 self._fn('{"x": NaN}')
258
259 def test_infinity_value_rejected(self) -> None:
260 """Infinity is not valid JSON; must be rejected at parse time."""
261 with pytest.raises(ValueError, match="Infinity"):
262 self._fn('{"x": Infinity}')
263
264 def test_negative_infinity_value_rejected(self) -> None:
265 """-Infinity is not valid JSON; must be rejected at parse time."""
266 with pytest.raises(ValueError, match="Infinity"):
267 self._fn('{"x": -Infinity}')
268
269 def test_oversized_raw_input_rejected_pre_parse(self) -> None:
270 """Raw input above the pre-parse cap is rejected before json.loads runs."""
271 from muse.cli.commands.commit import _MAX_META_RAW_BYTES
272 oversized = "{" + ('"k":' + ('"' + "x" * 100 + '",') * 2_000) + '"end":1}'
273 assert len(oversized) > _MAX_META_RAW_BYTES
274 with pytest.raises(ValueError, match="pre-parse cap"):
275 self._fn(oversized)
276
277 def test_unicode_values_preserved(self) -> None:
278 result = self._fn('{"note": "こんにけは"}')
279 assert "こんにけは" in result
280
281 def test_integer_values_preserved(self) -> None:
282 result = self._fn('{"count": 42}')
283 assert json.loads(result)["count"] == 42
284
285 def test_boolean_values_preserved(self) -> None:
286 result = self._fn('{"active": true}')
287 assert json.loads(result)["active"] is True
288
289
290 class TestValidateEventType:
291 """Unit tests for ``_validate_event_type``."""
292
293 def _fn(self, kind: str) -> str:
294 from muse.cli.commands.commit import _validate_event_type
295 return _validate_event_type(kind)
296
297 def test_all_15_kinds_accepted(self) -> None:
298 from muse.cli.commands.commit import _FALLBACK_EVENT_KINDS
299 for kind in _FALLBACK_EVENT_KINDS:
300 assert self._fn(kind) == kind
301
302 def test_invalid_kind_raises(self) -> None:
303 with pytest.raises(ValueError, match="not a valid"):
304 self._fn("recall")
305
306 def test_another_invalid_kind(self) -> None:
307 with pytest.raises(ValueError, match="not a valid"):
308 self._fn("note_read")
309
310 def test_empty_string_raises(self) -> None:
311 with pytest.raises(ValueError, match="not a valid"):
312 self._fn("")
313
314 def test_case_sensitive_uppercase_raises(self) -> None:
315 with pytest.raises(ValueError, match="not a valid"):
316 self._fn("WRITE")
317
318 def test_valid_kinds_list_in_error_message(self) -> None:
319 """Error message must list valid kinds so users can self-correct."""
320 with pytest.raises(ValueError) as exc_info:
321 self._fn("nonexistent")
322 assert "search" in str(exc_info.value)
323
324 def test_returns_unchanged_valid_kind(self) -> None:
325 assert self._fn("write") == "write"
326
327 def test_10k_char_injection_raises_cleanly(self) -> None:
328 """Injecting a 10 000-char string must not crash; must raise ValueError."""
329 with pytest.raises(ValueError):
330 self._fn("x" * 10_000)
331
332
333 class TestCommitConstants:
334 """Unit tests for the constants introduced by Phase 4.2."""
335
336 def test_max_meta_bytes_is_64k(self) -> None:
337 from muse.cli.commands.commit import _MAX_META_BYTES
338 assert _MAX_META_BYTES == 65_536
339
340 def test_max_meta_raw_bytes_is_twice_canonical(self) -> None:
341 """Pre-parse raw cap must be exactly 2Γ— canonical cap (documented contract)."""
342 from muse.cli.commands.commit import _MAX_META_BYTES, _MAX_META_RAW_BYTES
343 assert _MAX_META_RAW_BYTES == _MAX_META_BYTES * 2
344
345 def test_reserved_meta_keys_contains_expected(self) -> None:
346 from muse.cli.commands.commit import _RESERVED_META_KEYS
347 assert "event_type" in _RESERVED_META_KEYS
348 assert "meta" in _RESERVED_META_KEYS
349
350 def test_fallback_event_kinds_has_15_entries(self) -> None:
351 from muse.cli.commands.commit import _FALLBACK_EVENT_KINDS
352 assert len(_FALLBACK_EVENT_KINDS) == 15
353
354 def test_fallback_event_kinds_contains_all_expected(self) -> None:
355 from muse.cli.commands.commit import _FALLBACK_EVENT_KINDS
356 expected = {
357 "search", "export", "write", "import", "index", "propose",
358 "agent_interaction", "capture", "error", "session_summary",
359 "user", "consolidation", "consolidation_pass", "maintenance",
360 "insight",
361 }
362 assert _FALLBACK_EVENT_KINDS == expected
363
364
365 class TestNewFlags:
366 """Unit tests: new --event-type and --meta flags are registered correctly."""
367
368 def _parse(self, *args: str) -> object:
369 import argparse
370 from muse.cli.commands.commit import register
371 p = argparse.ArgumentParser()
372 sub = p.add_subparsers()
373 register(sub)
374 return p.parse_args(["commit", *args])
375
376 def test_event_type_flag_default_is_none(self) -> None:
377 ns = self._parse("-m", "x")
378 assert ns.event_type is None
379
380 def test_event_type_flag_accepts_value(self) -> None:
381 ns = self._parse("-m", "x", "--event-type", "write")
382 assert ns.event_type == "write"
383
384 def test_meta_flag_default_is_none(self) -> None:
385 ns = self._parse("-m", "x")
386 assert ns.meta is None
387
388 def test_meta_flag_accepts_json_string(self) -> None:
389 ns = self._parse("-m", "x", "--meta", '{"k": "v"}')
390 assert ns.meta == '{"k": "v"}'
391
392
393 # =============================================================================
394 # Tier 2 β€” Integration tests
395 # =============================================================================
396
397
398 class TestMetaIntegration:
399 """Integration: commits with --meta and --event-type persist in the store."""
400
401 def test_event_type_persisted_in_metadata(self, repo: pathlib.Path) -> None:
402 result = _commit(repo, "--event-type", "write")
403 assert result.exit_code == 0
404 branch = read_current_branch(repo)
405 cid = get_head_commit_id(repo, branch)
406 assert cid is not None
407 rec = read_commit(repo, cid)
408 assert rec is not None
409 assert rec.metadata.get("event_type") == "write"
410
411 def test_meta_payload_persisted_in_metadata(self, repo: pathlib.Path) -> None:
412 payload = '{"topic": "agents", "confidence": 0.9}'
413 result = _commit(repo, "--meta", payload)
414 assert result.exit_code == 0
415 branch = read_current_branch(repo)
416 cid = get_head_commit_id(repo, branch)
417 rec = read_commit(repo, cid)
418 assert rec is not None
419 stored = rec.metadata.get("meta")
420 assert stored is not None
421 parsed = json.loads(stored)
422 assert parsed["topic"] == "agents"
423 assert parsed["confidence"] == pytest.approx(0.9)
424
425 def test_both_flags_together(self, repo: pathlib.Path) -> None:
426 payload = '{"phase": "4.2"}'
427 result = _commit(repo, "--event-type", "consolidation", "--meta", payload)
428 assert result.exit_code == 0
429 branch = read_current_branch(repo)
430 cid = get_head_commit_id(repo, branch)
431 rec = read_commit(repo, cid)
432 assert rec is not None
433 assert rec.metadata["event_type"] == "consolidation"
434 assert json.loads(rec.metadata["meta"])["phase"] == "4.2"
435
436 def test_meta_not_present_when_flag_omitted(self, repo: pathlib.Path) -> None:
437 result = _commit(repo)
438 assert result.exit_code == 0
439 branch = read_current_branch(repo)
440 cid = get_head_commit_id(repo, branch)
441 rec = read_commit(repo, cid)
442 assert rec is not None
443 assert "meta" not in rec.metadata
444 assert "event_type" not in rec.metadata
445
446 def test_existing_metadata_keys_preserved(self, repo: pathlib.Path) -> None:
447 """--section, --track, --emotion coexist with --meta and --event-type."""
448 result = _commit(
449 repo,
450 "--section", "verse",
451 "--event-type", "write",
452 "--meta", '{"note": "test"}',
453 )
454 assert result.exit_code == 0
455 branch = read_current_branch(repo)
456 cid = get_head_commit_id(repo, branch)
457 rec = read_commit(repo, cid)
458 assert rec is not None
459 assert rec.metadata["section"] == "verse"
460 assert rec.metadata["event_type"] == "write"
461 assert json.loads(rec.metadata["meta"])["note"] == "test"
462
463 def test_meta_canonical_json_is_sorted(self, repo: pathlib.Path) -> None:
464 """Keys in stored --meta must be in sorted order (canonical form)."""
465 result = _commit(repo, "--meta", '{"z": 1, "a": 2, "m": 3}')
466 assert result.exit_code == 0
467 branch = read_current_branch(repo)
468 cid = get_head_commit_id(repo, branch)
469 rec = read_commit(repo, cid)
470 assert rec is not None
471 stored = json.loads(rec.metadata["meta"])
472 assert list(stored.keys()) == sorted(stored.keys())
473
474
475 # =============================================================================
476 # Tier 3 β€” End-to-end CLI tests
477 # =============================================================================
478
479
480 class TestMetaEndToEnd:
481 """End-to-end: full CLI path including error output."""
482
483 def test_invalid_event_type_exits_nonzero(self, repo: pathlib.Path) -> None:
484 result = _commit(repo, "--event-type", "recall")
485 assert result.exit_code != 0
486
487 def test_invalid_event_type_prints_error(self, repo: pathlib.Path) -> None:
488 result = _commit(repo, "--event-type", "not_a_kind")
489 combined = (result.stdout or "") + (result.stderr or "")
490 assert "not a valid" in combined.lower() or "valid" in combined.lower()
491
492 def test_invalid_json_meta_exits_nonzero(self, repo: pathlib.Path) -> None:
493 result = _commit(repo, "--meta", "{broken json")
494 assert result.exit_code != 0
495
496 def test_invalid_json_meta_prints_error(self, repo: pathlib.Path) -> None:
497 result = _commit(repo, "--meta", "{broken}")
498 combined = (result.stdout or "") + (result.stderr or "")
499 assert "json" in combined.lower() or "meta" in combined.lower()
500
501 def test_meta_with_sensitive_key_exits_nonzero(self, repo: pathlib.Path) -> None:
502 result = _commit(repo, "--meta", '{"password": "secret123"}')
503 assert result.exit_code != 0
504
505 def test_meta_with_array_exits_nonzero(self, repo: pathlib.Path) -> None:
506 result = _commit(repo, "--meta", "[1, 2, 3]")
507 assert result.exit_code != 0
508
509 def test_json_output_with_meta_flag(self, repo: pathlib.Path) -> None:
510 result = _commit(repo, "--meta", '{"x": 1}', "--json")
511 assert result.exit_code == 0
512 # The JSON output should include commit_id at minimum.
513 data = json.loads(result.stdout or "{}")
514 assert "commit_id" in data
515
516 def test_valid_meta_succeeds(self, repo: pathlib.Path) -> None:
517 result = _commit(repo, "--meta", '{"topic": "vault"}')
518 assert result.exit_code == 0
519
520 def test_valid_event_type_succeeds(self, repo: pathlib.Path) -> None:
521 result = _commit(repo, "--event-type", "index")
522 assert result.exit_code == 0
523
524
525 # =============================================================================
526 # Tier 4 β€” Stress tests
527 # =============================================================================
528
529
530 class TestMetaStress:
531 """Stress: 100 commits with --meta complete without errors."""
532
533 def test_100_commits_with_meta(self, tmp_path: pathlib.Path) -> None:
534 repo = _init_and_stage(tmp_path)
535 start = time.perf_counter()
536 for i in range(100):
537 (repo / "note.md").write_text(f"# Note {i}\n\ncontent {i}\n")
538 result = _commit(repo, "--meta", f'{{"iteration": {i}}}')
539 assert result.exit_code == 0, f"Commit {i} failed: {result.stdout}{result.stderr}"
540 elapsed = time.perf_counter() - start
541 assert elapsed < 60.0, f"100 commits took {elapsed:.1f}s (limit 60s)"
542
543
544 # =============================================================================
545 # Tier 5 β€” Data-integrity tests
546 # =============================================================================
547
548
549 class TestMetaDataIntegrity:
550 """Data-integrity: --meta payload is preserved byte-for-byte through the store."""
551
552 def test_complex_payload_round_trips(self, repo: pathlib.Path) -> None:
553 """A complex nested payload must survive write β†’ read unchanged."""
554 payload = {
555 "agent": "claude-opus",
556 "confidence": 0.987654321,
557 "tags": ["vault", "memory", "phase4"],
558 "nested": {"depth": 1, "values": [1, 2, 3]},
559 "unicode": "γ“γ‚“γ«γ‘γ―δΈ–η•Œ",
560 }
561 raw = json.dumps(payload)
562 result = _commit(repo, "--meta", raw)
563 assert result.exit_code == 0
564 branch = read_current_branch(repo)
565 cid = get_head_commit_id(repo, branch)
566 rec = read_commit(repo, cid)
567 assert rec is not None
568 stored = json.loads(rec.metadata["meta"])
569 assert stored["agent"] == "claude-opus"
570 assert stored["confidence"] == pytest.approx(0.987654321)
571 assert sorted(stored["tags"]) == ["memory", "phase4", "vault"] # order preserved, check content
572 assert stored["nested"]["depth"] == 1
573 assert stored["unicode"] == "γ“γ‚“γ«γ‘γ―δΈ–η•Œ"
574
575 def test_event_type_preserved_exactly(self, repo: pathlib.Path) -> None:
576 for kind in ["write", "consolidation", "agent_interaction"]:
577 (repo / "note.md").write_text(f"# {kind}\n")
578 result = _commit(repo, "--event-type", kind)
579 assert result.exit_code == 0
580 branch = read_current_branch(repo)
581 cid = get_head_commit_id(repo, branch)
582 rec = read_commit(repo, cid)
583 assert rec is not None
584 assert rec.metadata["event_type"] == kind
585
586 def test_meta_keys_sorted_canonically(self, repo: pathlib.Path) -> None:
587 """Keys must be stored in sorted order regardless of input order."""
588 _commit(repo, "--meta", '{"zz": 1, "aa": 2, "mm": 3}')
589 branch = read_current_branch(repo)
590 cid = get_head_commit_id(repo, branch)
591 rec = read_commit(repo, cid)
592 assert rec is not None
593 keys = list(json.loads(rec.metadata["meta"]).keys())
594 assert keys == sorted(keys)
595
596
597 # =============================================================================
598 # Tier 6 β€” Performance tests
599 # =============================================================================
600
601
602 class TestMetaPerformance:
603 """Performance: validation helpers must be fast."""
604
605 def test_1000_meta_validations_under_500ms(self) -> None:
606 from muse.cli.commands.commit import _validate_meta_payload
607 payload = json.dumps({"topic": "vault", "phase": "4.2", "count": 0})
608 start = time.perf_counter()
609 for i in range(1000):
610 _validate_meta_payload(payload.replace('"count": 0', f'"count": {i}'))
611 elapsed = time.perf_counter() - start
612 assert elapsed < 0.5, f"1000 validations took {elapsed * 1000:.0f}ms (limit 500ms)"
613
614 def test_1000_event_type_validations_under_200ms(self) -> None:
615 from muse.cli.commands.commit import _validate_event_type
616 kinds = ["write", "search", "consolidation", "agent_interaction", "index"]
617 start = time.perf_counter()
618 for i in range(1000):
619 _validate_event_type(kinds[i % len(kinds)])
620 elapsed = time.perf_counter() - start
621 assert elapsed < 0.2, f"1000 event-type validations took {elapsed * 1000:.0f}ms (limit 200ms)"
622
623 def test_sensitive_scan_large_safe_dict_under_500ms(self) -> None:
624 """Scanning a 10 000-key safe dict must complete quickly."""
625 from muse.cli.commands.commit import _has_sensitive_keys
626 big_safe = {f"key_{i}": f"value_{i}" for i in range(10_000)}
627 start = time.perf_counter()
628 result = _has_sensitive_keys(big_safe)
629 elapsed = time.perf_counter() - start
630 assert result is False
631 assert elapsed < 0.5, f"Scan of 10k-key dict took {elapsed * 1000:.0f}ms (limit 500ms)"
632
633
634 # =============================================================================
635 # Tier 7 β€” Security tests
636 # =============================================================================
637
638
639 class TestMetaSecurity:
640 """Security: adversarial and injection inputs are rejected cleanly."""
641
642 def _validate(self, raw: str) -> str:
643 from muse.cli.commands.commit import _validate_meta_payload
644 return _validate_meta_payload(raw)
645
646 def test_password_key_rejected(self) -> None:
647 with pytest.raises(ValueError):
648 self._validate('{"password": "hunter2"}')
649
650 def test_api_key_rejected(self) -> None:
651 with pytest.raises(ValueError):
652 self._validate('{"api_key": "sk-abc123"}')
653
654 def test_api_key_hyphen_rejected(self) -> None:
655 with pytest.raises(ValueError):
656 self._validate('{"api-key": "x"}')
657
658 def test_token_rejected(self) -> None:
659 with pytest.raises(ValueError):
660 self._validate('{"token": "ghp_..."}')
661
662 def test_credential_rejected(self) -> None:
663 with pytest.raises(ValueError):
664 self._validate('{"credential": "x"}')
665
666 def test_authorization_rejected(self) -> None:
667 with pytest.raises(ValueError):
668 self._validate('{"authorization": "Bearer abc"}')
669
670 def test_bearer_rejected(self) -> None:
671 with pytest.raises(ValueError):
672 self._validate('{"bearer": "abc"}')
673
674 def test_private_key_rejected(self) -> None:
675 with pytest.raises(ValueError):
676 self._validate('{"private_key": "-----BEGIN RSA"}')
677
678 def test_private_key_hyphen_rejected(self) -> None:
679 with pytest.raises(ValueError):
680 self._validate('{"private-key": "x"}')
681
682 def test_nested_secret_at_depth_1_rejected(self) -> None:
683 with pytest.raises(ValueError):
684 self._validate('{"outer": {"password": "x"}}')
685
686 def test_nested_secret_at_depth_8_rejected(self) -> None:
687 """Depth 8 is still within the scan limit."""
688 d: Any = {"password": "x"}
689 for _ in range(8):
690 d = {"wrapper": d}
691 with pytest.raises(ValueError):
692 self._validate(json.dumps(d))
693
694 def test_secret_at_depth_9_not_scanned(self) -> None:
695 """Depth > 8 is beyond scan limit and must NOT be rejected (mirrors JS)."""
696 d: Any = {"password": "x"}
697 for _ in range(9):
698 d = {"safe_wrapper": d}
699 result = self._validate(json.dumps(d))
700 assert result # no error raised
701
702 def test_error_message_does_not_leak_secret_value(self) -> None:
703 """The error message must not echo the secret value back."""
704 try:
705 self._validate('{"password": "super_secret_value_123"}')
706 except ValueError as exc:
707 assert "super_secret_value_123" not in str(exc)
708
709 def test_json_injection_via_nested_string_values_accepted(self) -> None:
710 """Nested JSON string values are safe β€” they're values not keys."""
711 result = self._validate('{"payload": "{\\"key\\": \\"value\\"}"}')
712 assert result # must not raise
713
714 def test_oversized_payload_rejected_cleanly(self) -> None:
715 from muse.cli.commands.commit import _MAX_META_BYTES
716 big = {"k": "x" * (_MAX_META_BYTES + 10)}
717 with pytest.raises(ValueError, match="size limit"):
718 self._validate(json.dumps(big))
719
720 def test_non_dict_json_types_rejected(self) -> None:
721 for raw in ['["a", "b"]', '"string"', "42", "true", "null"]:
722 with pytest.raises(ValueError):
723 self._validate(raw)
724
725 def test_control_chars_in_values_accepted_stored_safely(self) -> None:
726 """Control characters in VALUES are not our concern at this layer
727 (they're stored as-is in JSON; the display layer uses sanitize_display).
728 The validator must not raise on control chars in values."""
729 result = self._validate('{"note": "line\\nbreak"}')
730 assert result # must not raise; \\n in JSON is a valid escape
731
732 def test_very_long_invalid_kind_raises_cleanly(self) -> None:
733 from muse.cli.commands.commit import _validate_event_type
734 with pytest.raises(ValueError):
735 _validate_event_type("x" * 10_000)
736
737 def test_event_type_valid_kinds_do_not_expose_secrets(self) -> None:
738 """Valid kind validation must not reference any secret or external resource."""
739 from muse.cli.commands.commit import _validate_event_type
740 result = _validate_event_type("write")
741 assert result == "write" # pure in-memory check
742
743 def test_reserved_keys_fail_fast_no_data_divergence(self) -> None:
744 """Reserved top-level keys are a hard error β€” prevents permanent
745 commit-graph divergence between metadata['event_type'] and
746 json.loads(metadata['meta'])['event_type'].
747 """
748 with pytest.raises(ValueError, match="reserved top-level key"):
749 self._validate('{"event_type": "search"}')
750 with pytest.raises(ValueError, match="reserved top-level key"):
751 self._validate('{"meta": {}}')
752
753 def test_nan_infinity_rejected_for_spec_compliance(self) -> None:
754 """NaN / Infinity / -Infinity must be rejected so the v1 stored form is
755 always spec-compliant JSON readable by strict parsers (e.g. the future
756 Rust port and MuseHub UI).
757 """
758 for raw in ('{"x": NaN}', '{"x": Infinity}', '{"x": -Infinity}'):
759 with pytest.raises(ValueError):
760 self._validate(raw)
761
762 def test_oversized_raw_pre_parse_cap_bounds_dos(self) -> None:
763 """The pre-parse raw cap bounds json.loads / sensitive-key scan cost on
764 adversarial input, defending future --meta @file or stdin paths.
765 """
766 from muse.cli.commands.commit import _MAX_META_RAW_BYTES
767 with pytest.raises(ValueError, match="pre-parse cap"):
768 self._validate("{" + '"x"' + ":" + '"' + "y" * (_MAX_META_RAW_BYTES + 100) + '"' + "}")