"""Tests for Phase 2.6 — Knowtation ``muse code compare`` JSON output. Test tiers covered ------------------ 1. Unit — _note_title: frontmatter title, H1 heading fallback, empty note 2. Unit — _summarise_note_delta: tags added/removed, section add/remove/move, scalar frontmatter change, body line changes, empty delta 3. Unit — JSON schema: _KnowtationCompareJson has required top-level keys; stat keys all present; each op has required fields 4. Integration — _run_knowtation_compare: two synthetic manifests with one added, one removed, one modified note → correct op list and stats 5. Integration — JSON round-trip: output is valid JSON, deserialises correctly, schema validated 6. Integration — stat_only mode: prints note counts but not op details 7. Security — path traversal in note paths; malformed YAML frontmatter; NUL bytes in note content; object_store returning None """ from __future__ import annotations import hashlib import json import pathlib from typing import Any from unittest.mock import MagicMock, patch import pytest # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_note( title: str = "Test Note", tags: list[str] | None = None, body: str = "Some body text.", ) -> bytes: fm_parts = [f"title: {title}"] if tags: tag_lines = "\n".join(f" - {t}" for t in tags) fm_parts.append(f"tags:\n{tag_lines}") fm = "\n".join(fm_parts) return f"---\n{fm}\n---\n{body}".encode() def _sha256(content: bytes) -> str: return hashlib.sha256(content).hexdigest() def _store_object(root: pathlib.Path, content: bytes) -> str: """Write *content* to the Muse object store and return its SHA-256 hash.""" from muse.core.object_store import write_object obj_id = _sha256(content) write_object(root, obj_id, content) return obj_id # ============================================================================ # TestNoteTitleHelper # ============================================================================ class TestNoteTitleHelper: def test_frontmatter_title(self) -> None: from muse.cli.commands.compare import _note_title note = _make_note(title="My Note Title") assert _note_title(note) == "My Note Title" def test_h1_fallback_no_frontmatter(self) -> None: from muse.cli.commands.compare import _note_title note = b"# Heading Title\n\nBody text." assert _note_title(note) == "Heading Title" def test_empty_note_returns_empty_string(self) -> None: from muse.cli.commands.compare import _note_title assert _note_title(b"") == "" def test_binary_content_does_not_crash(self) -> None: from muse.cli.commands.compare import _note_title result = _note_title(bytes(range(256))) assert isinstance(result, str) def test_frontmatter_title_preferred_over_h1(self) -> None: from muse.cli.commands.compare import _note_title note = b"---\ntitle: FM Title\n---\n# H1 Title\n" assert _note_title(note) == "FM Title" # ============================================================================ # TestSummariseNoteDelta # ============================================================================ class TestSummariseNoteDelta: def test_empty_delta_returns_empty_diffs(self) -> None: from muse.cli.commands.compare import _summarise_note_delta delta = {"ops": [], "summary": "no changes", "domain": "knowtation"} fm, sec, body = _summarise_note_delta(delta) assert fm == {} assert sec == {} assert body == 0 def test_tags_added(self) -> None: from muse.cli.commands.compare import _summarise_note_delta delta = { "ops": [ {"op": "insert", "address": "tags::machine-learning"}, {"op": "insert", "address": "tags::python"}, ], "domain": "knowtation", } fm, _, _ = _summarise_note_delta(delta) assert "machine-learning" in fm.get("tags_added", []) assert "python" in fm.get("tags_added", []) def test_tags_removed(self) -> None: from muse.cli.commands.compare import _summarise_note_delta delta = { "ops": [{"op": "delete", "address": "tags::old-tag"}], "domain": "knowtation", } fm, _, _ = _summarise_note_delta(delta) assert "old-tag" in fm.get("tags_removed", []) def test_section_added(self) -> None: from muse.cli.commands.compare import _summarise_note_delta delta = { "ops": [{"op": "insert", "address": "section:2:Background#0", "content": "..."}], "domain": "knowtation", } _, sec, _ = _summarise_note_delta(delta) assert "section:2:Background#0" in sec.get("added", []) def test_section_removed(self) -> None: from muse.cli.commands.compare import _summarise_note_delta delta = { "ops": [{"op": "delete", "address": "section:2:Deprecated#0"}], "domain": "knowtation", } _, sec, _ = _summarise_note_delta(delta) assert "section:2:Deprecated#0" in sec.get("removed", []) def test_section_moved(self) -> None: from muse.cli.commands.compare import _summarise_note_delta delta = { "ops": [{"op": "move", "address": "section:2:Background#0", "content_id": "abc"}], "domain": "knowtation", } _, sec, _ = _summarise_note_delta(delta) assert "section:2:Background#0" in sec.get("moved", []) def test_body_line_changes_counted(self) -> None: from muse.cli.commands.compare import _summarise_note_delta delta = { "ops": [ {"op": "insert", "address": "section:2:Background#0::line:3"}, {"op": "delete", "address": "section:2:Background#0::line:5"}, {"op": "replace", "address": "section:2:Background#0::line:7"}, ], "domain": "knowtation", } _, _, body = _summarise_note_delta(delta) assert body == 3 def test_scalar_frontmatter_change(self) -> None: from muse.cli.commands.compare import _summarise_note_delta delta = { "ops": [{"op": "replace", "address": "fm::project", "new_content": "new-project"}], "domain": "knowtation", } fm, _, _ = _summarise_note_delta(delta) assert "project" in fm.get("scalar_changes", {}) def test_non_dict_delta_handled_gracefully(self) -> None: from muse.cli.commands.compare import _summarise_note_delta fm, sec, body = _summarise_note_delta(None) # type: ignore[arg-type] assert fm == {} assert sec == {} assert body == 0 # ============================================================================ # TestKnowtationJsonSchema # ============================================================================ class TestKnowtationJsonSchema: def _make_minimal_output(self) -> dict[str, Any]: """Build a minimal _KnowtationCompareJson dict.""" from muse.cli.commands.compare import _KnowtationCompareJson, _CommitRef, _KnowtationStat return { "from": {"commit_id": "a" * 64, "message": "First commit"}, "to": {"commit_id": "b" * 64, "message": "Second commit"}, "domain": "knowtation", "stat": { "notes_added": 1, "notes_removed": 0, "notes_modified": 2, "frontmatter_changes": 3, "section_changes": 4, "tag_changes": 1, }, "ops": [], } def test_required_top_level_keys(self) -> None: out = self._make_minimal_output() for key in ("from", "to", "domain", "stat", "ops"): assert key in out def test_domain_is_knowtation(self) -> None: out = self._make_minimal_output() assert out["domain"] == "knowtation" def test_stat_has_all_keys(self) -> None: out = self._make_minimal_output() stat = out["stat"] for key in ( "notes_added", "notes_removed", "notes_modified", "frontmatter_changes", "section_changes", "tag_changes", ): assert key in stat, f"Missing stat key: {key}" def test_serialisable_to_json(self) -> None: out = self._make_minimal_output() dumped = json.dumps(out) loaded = json.loads(dumped) assert loaded["domain"] == "knowtation" # ============================================================================ # TestRunKnowtationCompare (integration) # ============================================================================ class TestRunKnowtationCompare: """Integration tests for _run_knowtation_compare using a tmp root.""" def _setup_vault( self, tmp_path: pathlib.Path ) -> tuple[dict[str, str], dict[str, str], dict, dict]: """Create a minimal vault layout in tmp_path and return two manifests.""" # Three notes: note_a.md stays, note_b.md is modified, note_c.md is added note_a_v1 = _make_note("Note A", tags=["old"], body="Version 1") note_b_v1 = _make_note("Note B", tags=["original"], body="Old body.") note_b_v2 = _make_note("Note B", tags=["original", "new-tag"], body="New body.") note_c_v2 = _make_note("Note C", body="Newly added.") hash_a = _store_object(tmp_path, note_a_v1) hash_b1 = _store_object(tmp_path, note_b_v1) hash_b2 = _store_object(tmp_path, note_b_v2) hash_c = _store_object(tmp_path, note_c_v2) manifest_1: dict[str, str] = { "note_a.md": hash_a, "note_b.md": hash_b1, } manifest_2: dict[str, str] = { "note_a.md": hash_a, "note_b.md": hash_b2, "note_c.md": hash_c, } commit_1 = MagicMock() commit_1.commit_id = "a" * 64 commit_1.message = "First commit" commit_2 = MagicMock() commit_2.commit_id = "b" * 64 commit_2.message = "Second commit" return manifest_1, manifest_2, commit_1, commit_2 def test_run_json_output_has_correct_structure( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path) _run_knowtation_compare( root=tmp_path, commit_a=commit_1, commit_b=commit_2, manifest_a=manifest_1, manifest_b=manifest_2, as_json=True, stat_only=False, ) captured = capsys.readouterr() out = json.loads(captured.out) assert out["domain"] == "knowtation" assert "stat" in out assert "ops" in out assert out["from"]["commit_id"] == "a" * 64 def test_added_note_appears_as_insert( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path) _run_knowtation_compare( root=tmp_path, commit_a=commit_1, commit_b=commit_2, manifest_a=manifest_1, manifest_b=manifest_2, as_json=True, stat_only=False, ) out = json.loads(capsys.readouterr().out) insert_ops = [o for o in out["ops"] if o["op"] == "insert"] assert any(o["path"] == "note_c.md" for o in insert_ops) def test_modified_note_appears_as_modify( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path) _run_knowtation_compare( root=tmp_path, commit_a=commit_1, commit_b=commit_2, manifest_a=manifest_1, manifest_b=manifest_2, as_json=True, stat_only=False, ) out = json.loads(capsys.readouterr().out) modify_ops = [o for o in out["ops"] if o["op"] == "modify"] assert any(o["path"] == "note_b.md" for o in modify_ops) def test_unchanged_note_not_in_ops( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path) _run_knowtation_compare( root=tmp_path, commit_a=commit_1, commit_b=commit_2, manifest_a=manifest_1, manifest_b=manifest_2, as_json=True, stat_only=False, ) out = json.loads(capsys.readouterr().out) assert all(o["path"] != "note_a.md" for o in out["ops"]) def test_stat_counts_correct( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path) _run_knowtation_compare( root=tmp_path, commit_a=commit_1, commit_b=commit_2, manifest_a=manifest_1, manifest_b=manifest_2, as_json=True, stat_only=False, ) out = json.loads(capsys.readouterr().out) assert out["stat"]["notes_added"] == 1 # note_c added assert out["stat"]["notes_modified"] == 1 # note_b modified def test_stat_only_mode_emits_text( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path) _run_knowtation_compare( root=tmp_path, commit_a=commit_1, commit_b=commit_2, manifest_a=manifest_1, manifest_b=manifest_2, as_json=False, stat_only=True, ) out = capsys.readouterr().out assert "Notes added" in out assert "Notes modified" in out def test_empty_manifests_no_crash( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare commit = MagicMock() commit.commit_id = "a" * 64 commit.message = "empty" _run_knowtation_compare( root=tmp_path, commit_a=commit, commit_b=commit, manifest_a={}, manifest_b={}, as_json=True, stat_only=False, ) out = json.loads(capsys.readouterr().out) assert out["ops"] == [] assert out["stat"]["notes_added"] == 0 def test_removed_note_appears_as_delete( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare note = _make_note("Gone Note") h = _store_object(tmp_path, note) commit_a = MagicMock() commit_a.commit_id = "a" * 64 commit_a.message = "before" commit_b = MagicMock() commit_b.commit_id = "b" * 64 commit_b.message = "after" _run_knowtation_compare( root=tmp_path, commit_a=commit_a, commit_b=commit_b, manifest_a={"gone.md": h}, manifest_b={}, as_json=True, stat_only=False, ) out = json.loads(capsys.readouterr().out) delete_ops = [o for o in out["ops"] if o["op"] == "delete"] assert any(o["path"] == "gone.md" for o in delete_ops) assert out["stat"]["notes_removed"] == 1 # ============================================================================ # TestSecurity # ============================================================================ class TestSecurity: def test_path_traversal_in_manifest_key( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: """Paths with .. in them should not cause os.path traversal issues.""" from muse.cli.commands.compare import _run_knowtation_compare commit = MagicMock() commit.commit_id = "a" * 64 commit.message = "test" # Object does not exist for this hash — should not crash _run_knowtation_compare( root=tmp_path, commit_a=commit, commit_b=commit, manifest_a={"../../etc/passwd.md": "a" * 64}, manifest_b={"../../etc/passwd.md": "b" * 64}, as_json=True, stat_only=False, ) out_text = capsys.readouterr().out # Should either produce valid JSON or be empty — must not crash if out_text.strip(): json.loads(out_text) # must be valid JSON def test_malformed_yaml_in_note_does_not_crash( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare malformed = b"---\ntitle: [unclosed\n---\nBody." h1 = _store_object(tmp_path, malformed) fixed = b"---\ntitle: fixed\n---\nBody." h2 = _store_object(tmp_path, fixed) commit_a = MagicMock() commit_a.commit_id = "a" * 64 commit_a.message = "before" commit_b = MagicMock() commit_b.commit_id = "b" * 64 commit_b.message = "after" try: _run_knowtation_compare( root=tmp_path, commit_a=commit_a, commit_b=commit_b, manifest_a={"note.md": h1}, manifest_b={"note.md": h2}, as_json=True, stat_only=False, ) except Exception as exc: pytest.fail(f"Should not crash on malformed YAML: {exc}") def test_nul_bytes_in_note_content_handled( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture ) -> None: from muse.cli.commands.compare import _run_knowtation_compare note_v1 = b"---\ntitle: NUL test\n---\n\x00\x00\x00" note_v2 = b"---\ntitle: NUL test\n---\n\x00\x01\x02" h1 = _store_object(tmp_path, note_v1) h2 = _store_object(tmp_path, note_v2) commit_a = MagicMock() commit_a.commit_id = "a" * 64 commit_a.message = "a" commit_b = MagicMock() commit_b.commit_id = "b" * 64 commit_b.message = "b" try: _run_knowtation_compare( root=tmp_path, commit_a=commit_a, commit_b=commit_b, manifest_a={"nul.md": h1}, manifest_b={"nul.md": h2}, as_json=True, stat_only=False, ) except Exception as exc: pytest.fail(f"NUL bytes caused crash: {exc}")