"""Tests for Phase 2.2 — Knowtation-specific merge strategies. Test tiers covered ------------------ 1. Unit — VALID_STRATEGIES includes the three new strategy names. 2. Unit — prefer_newer_date: later date wins, one-side-missing fallback, both-missing fallback, equal-date fallback, malformed date graceful. 3. Unit — union_sorted: set fields unioned and sorted, scalar fields from ours, deletions honoured when both sides agree, no-frontmatter graceful. 4. Unit — knowtation_3way: falls back to ours when merger not available. 5. Unit — apply_strategy: dispatch table, unknown strategy returns None. 6. Integration — load_attributes() accepts all three new strategy strings in .museattributes without raising. 7. Security — adversarial frontmatter: YAML injection, oversized tags array, malformed date strings, binary content, NUL bytes. """ from __future__ import annotations import pathlib import textwrap from typing import Any import pytest import yaml from muse.core.attributes import VALID_STRATEGIES from muse.plugins.knowtation.strategies import ( STRATEGY_DISPATCH, _parse_date, apply_strategy, knowtation_3way, prefer_newer_date, union_sorted, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _note(frontmatter: str = "", body: str = "Body text.") -> bytes: """Build a note from a YAML frontmatter string and optional body.""" if frontmatter: return f"---\n{frontmatter}\n---\n{body}".encode() return body.encode() def _parse_yaml_fm(note: bytes) -> dict[str, Any]: """Extract and parse YAML frontmatter from note bytes.""" text = note.decode("utf-8", errors="replace") if not text.startswith("---"): return {} rest = text[3:] end = -1 for delim in ("\n---", "\n..."): pos = rest.find(delim) if pos != -1 and (end == -1 or pos < end): end = pos if end == -1: return {} data = yaml.safe_load(rest[:end]) return data if isinstance(data, dict) else {} _EMPTY = b"" _BASE = _note("date: 2025-01-01\ntags:\n - alpha\n - beta") # ============================================================================ # TestValidStrategiesSet # ============================================================================ class TestValidStrategiesSet: def test_prefer_newer_date_in_valid_strategies(self) -> None: assert "prefer-newer-date" in VALID_STRATEGIES def test_union_sorted_in_valid_strategies(self) -> None: assert "union-sorted" in VALID_STRATEGIES def test_knowtation_3way_in_valid_strategies(self) -> None: assert "knowtation-3way" in VALID_STRATEGIES def test_core_strategies_still_present(self) -> None: for s in ("ours", "theirs", "union", "base", "auto", "manual"): assert s in VALID_STRATEGIES # ============================================================================ # TestParseDateHelper # ============================================================================ class TestParseDateHelper: def test_plain_date(self) -> None: from datetime import date assert _parse_date("2025-01-15") == date(2025, 1, 15) def test_datetime_string(self) -> None: from datetime import date assert _parse_date("2025-01-15T14:30:00") == date(2025, 1, 15) def test_none_input(self) -> None: assert _parse_date(None) is None def test_empty_string(self) -> None: assert _parse_date("") is None def test_malformed_string(self) -> None: assert _parse_date("yesterday") is None def test_future_date(self) -> None: from datetime import date assert _parse_date("2099-12-31") == date(2099, 12, 31) # ============================================================================ # TestPreferNewerDate # ============================================================================ class TestPreferNewerDate: def test_theirs_newer_returns_theirs(self) -> None: ours = _note("date: 2025-01-01") theirs = _note("date: 2025-06-15") result = prefer_newer_date(ours, theirs, _BASE) assert result == theirs def test_ours_newer_returns_ours(self) -> None: ours = _note("date: 2025-12-31") theirs = _note("date: 2025-01-01") result = prefer_newer_date(ours, theirs, _BASE) assert result == ours def test_equal_dates_returns_ours(self) -> None: ours = _note("date: 2025-06-01\ntitle: ours") theirs = _note("date: 2025-06-01\ntitle: theirs") result = prefer_newer_date(ours, theirs, _BASE) assert result == ours def test_ours_no_date_theirs_has_date_returns_theirs(self) -> None: ours = _note("title: no date here") theirs = _note("date: 2025-01-01") result = prefer_newer_date(ours, theirs, _BASE) assert result == theirs def test_theirs_no_date_returns_ours(self) -> None: ours = _note("date: 2025-01-01") theirs = _note("title: no date") result = prefer_newer_date(ours, theirs, _BASE) assert result == ours def test_both_no_date_returns_ours(self) -> None: ours = _note("title: ours") theirs = _note("title: theirs") result = prefer_newer_date(ours, theirs, _BASE) assert result == ours def test_malformed_date_treated_as_missing(self) -> None: ours = _note("date: not-a-date\ntitle: ours") theirs = _note("date: 2025-01-01") result = prefer_newer_date(ours, theirs, _BASE) # ours has malformed date → treated as missing → theirs wins assert result == theirs def test_empty_ours_theirs_has_date_returns_theirs(self) -> None: result = prefer_newer_date(_EMPTY, _note("date: 2025-01-01"), _BASE) assert result == _note("date: 2025-01-01") def test_both_empty_returns_ours(self) -> None: result = prefer_newer_date(_EMPTY, _EMPTY, _BASE) assert result == _EMPTY def test_base_not_used_for_decision(self) -> None: ours = _note("date: 2025-06-01") theirs = _note("date: 2025-01-01") base = _note("date: 2099-12-31") # base has future date — must be ignored result = prefer_newer_date(ours, theirs, base) assert result == ours def test_datetime_string_compared_as_date(self) -> None: ours = _note("date: 2025-01-01T08:00:00") theirs = _note("date: 2025-01-01T23:00:00") # Both parse to 2025-01-01 → equal → ours wins result = prefer_newer_date(ours, theirs, _BASE) assert result == ours def test_returns_bytes(self) -> None: result = prefer_newer_date(_note("date: 2025-01-01"), _note("date: 2025-06-01"), _BASE) assert isinstance(result, bytes) # ============================================================================ # TestUnionSorted # ============================================================================ class TestUnionSorted: def test_tags_unioned_and_sorted(self) -> None: ours = _note("tags:\n - beta\n - alpha") theirs = _note("tags:\n - gamma\n - alpha") result = union_sorted(ours, theirs, _BASE) fm = _parse_yaml_fm(result) assert fm["tags"] == ["alpha", "beta", "gamma"] def test_entity_unioned_and_sorted(self) -> None: ours = _note("entity:\n - zebra\n - alice") theirs = _note("entity:\n - bob\n - alice") result = union_sorted(ours, theirs, _BASE) fm = _parse_yaml_fm(result) assert fm["entity"] == ["alice", "bob", "zebra"] def test_attachments_unioned(self) -> None: ours = _note("attachments:\n - ABCDEFGHJKLM\n - 111111111111") theirs = _note("attachments:\n - zzzzzzzzzzzz") result = union_sorted(ours, theirs, _note("")) fm = _parse_yaml_fm(result) assert "ABCDEFGHJKLM" in fm["attachments"] assert "zzzzzzzzzzzz" in fm["attachments"] assert fm["attachments"] == sorted(fm["attachments"]) def test_scalar_field_from_ours(self) -> None: ours = _note("project: ours-project\ndate: 2025-01-01") theirs = _note("project: theirs-project\ndate: 2025-06-01") result = union_sorted(ours, theirs, _BASE) fm = _parse_yaml_fm(result) assert fm["project"] == "ours-project" def test_both_deletions_honoured(self) -> None: """Tags deleted by both sides must be absent from result.""" base = _note("tags:\n - alpha\n - beta\n - gamma") ours = _note("tags:\n - alpha") # deleted beta and gamma theirs = _note("tags:\n - alpha") # also deleted beta and gamma result = union_sorted(ours, theirs, base) fm = _parse_yaml_fm(result) assert "beta" not in fm.get("tags", []) assert "gamma" not in fm.get("tags", []) assert "alpha" in fm.get("tags", []) def test_only_ours_deletion_not_honoured(self) -> None: """Tags deleted only by ours are kept (theirs still wants them).""" base = _note("tags:\n - alpha\n - beta") ours = _note("tags:\n - alpha") # deleted beta theirs = _note("tags:\n - alpha\n - beta") # kept beta result = union_sorted(ours, theirs, base) fm = _parse_yaml_fm(result) assert "beta" in fm.get("tags", []) def test_no_frontmatter_ours_returns_ours(self) -> None: ours = b"# Plain note\nNo frontmatter." result = union_sorted(ours, _note("tags:\n - new"), _BASE) assert result == ours def test_result_is_bytes(self) -> None: result = union_sorted(_note("tags:\n - a"), _note("tags:\n - b"), _BASE) assert isinstance(result, bytes) def test_empty_result_when_all_deleted(self) -> None: base = _note("tags:\n - alpha") ours = _note("") # no tags theirs = _note("") # no tags result = union_sorted(ours, theirs, base) fm = _parse_yaml_fm(result) assert "tags" not in fm or fm["tags"] == [] def test_result_is_sorted_not_just_unioned(self) -> None: ours = _note("tags:\n - zebra") theirs = _note("tags:\n - aardvark") result = union_sorted(ours, theirs, _note("")) fm = _parse_yaml_fm(result) tags = fm.get("tags", []) assert tags == sorted(tags) # ============================================================================ # TestKnowtation3way # ============================================================================ class TestKnowtation3way: def test_delegates_to_merger_when_available(self) -> None: """With merger.py present (Phase 2.4), knowtation_3way runs the structured three-way merge. Conflicting titles produce a result containing standard git conflict markers; the call still returns bytes (never raises) so downstream tooling can write the result to disk for human resolution. """ ours = _note("title: ours\ndate: 2025-01-01") theirs = _note("title: theirs\ndate: 2025-01-01") result = knowtation_3way(ours, theirs, _BASE) assert isinstance(result, bytes) # Ours's date matches base, theirs's date matches base — only the # title differs. Both sides changed it differently → conflict # markers must appear in the merged output. assert b"<<<<<<<" in result assert b">>>>>>>" in result def test_returns_bytes(self) -> None: result = knowtation_3way(_note(""), _note(""), _BASE) assert isinstance(result, bytes) def test_fallback_is_non_crashing(self) -> None: """Must not raise even with unusual inputs.""" try: knowtation_3way(b"", b"", b"") except Exception as exc: pytest.fail(f"knowtation_3way raised: {exc}") # ============================================================================ # TestApplyStrategy # ============================================================================ class TestApplyStrategy: def test_prefer_newer_date_dispatched(self) -> None: ours = _note("date: 2025-01-01") theirs = _note("date: 2025-06-01") result = apply_strategy("prefer-newer-date", ours, theirs, _BASE) assert result == theirs def test_union_sorted_dispatched(self) -> None: ours = _note("tags:\n - b") theirs = _note("tags:\n - a") result = apply_strategy("union-sorted", ours, theirs, _note("")) assert result is not None fm = _parse_yaml_fm(result) assert "a" in fm.get("tags", []) assert "b" in fm.get("tags", []) def test_knowtation_3way_dispatched(self) -> None: result = apply_strategy("knowtation-3way", _note(""), _note(""), _BASE) assert result is not None assert isinstance(result, bytes) def test_unknown_strategy_returns_none(self) -> None: result = apply_strategy("ours", b"", b"", b"") assert result is None def test_none_for_auto(self) -> None: assert apply_strategy("auto", b"", b"", b"") is None def test_none_for_manual(self) -> None: assert apply_strategy("manual", b"", b"", b"") is None def test_strategy_dispatch_table_keys(self) -> None: assert set(STRATEGY_DISPATCH.keys()) == { "prefer-newer-date", "union-sorted", "knowtation-3way", } # ============================================================================ # TestIntegrationLoadAttributes # ============================================================================ class TestIntegrationLoadAttributes: """Verify that .museattributes files using the new strategies parse without error.""" def _load(self, tmp_path: pathlib.Path, content: str) -> None: from muse.core.attributes import load_attributes attrs_file = tmp_path / ".museattributes" attrs_file.write_text(content, encoding="utf-8") load_attributes(tmp_path, domain="knowtation") def test_prefer_newer_date_accepted(self, tmp_path: pathlib.Path) -> None: self._load(tmp_path, textwrap.dedent("""\ [meta] domain = "knowtation" [[rules]] path = "inbox/**" dimension = "*" strategy = "prefer-newer-date" """)) def test_union_sorted_accepted(self, tmp_path: pathlib.Path) -> None: self._load(tmp_path, textwrap.dedent("""\ [meta] domain = "knowtation" [[rules]] path = "**/*.md" dimension = "frontmatter" strategy = "union-sorted" """)) def test_knowtation_3way_accepted(self, tmp_path: pathlib.Path) -> None: self._load(tmp_path, textwrap.dedent("""\ [meta] domain = "knowtation" [[rules]] path = "projects/**" dimension = "*" strategy = "knowtation-3way" """)) def test_unknown_strategy_still_raises(self, tmp_path: pathlib.Path) -> None: from muse.core.attributes import load_attributes attrs_file = tmp_path / ".museattributes" attrs_file.write_text(textwrap.dedent("""\ [[rules]] path = "*" dimension = "*" strategy = "not-a-real-strategy" """), encoding="utf-8") with pytest.raises(ValueError, match="unknown strategy"): load_attributes(tmp_path) # ============================================================================ # TestSecurity # ============================================================================ class TestSecurity: def test_yaml_injection_in_frontmatter_does_not_execute(self) -> None: """YAML safe_load must not execute arbitrary Python.""" malicious = _note( "date: !!python/object/apply:os.system ['echo PWNED']\ntitle: x" ) try: result = prefer_newer_date(malicious, _note("date: 2025-01-01"), _BASE) assert isinstance(result, bytes) except Exception: pass # Raising is acceptable; executing the payload is not. def test_oversized_tags_array_does_not_crash(self) -> None: many_tags = "\n".join(f" - tag{i:06d}" for i in range(10_000)) ours = _note(f"tags:\n{many_tags}") theirs = _note("tags:\n - newtag") try: result = union_sorted(ours, theirs, _BASE) assert isinstance(result, bytes) except MemoryError: pytest.skip("OOM on this platform — acceptable for 10k tags") def test_nul_bytes_in_content_handled(self) -> None: ours = b"---\ndate: 2025-01-01\n---\n\x00\x00\x00" result = prefer_newer_date(ours, _note("date: 2024-01-01"), _BASE) assert isinstance(result, bytes) def test_binary_content_does_not_crash(self) -> None: binary = bytes(range(256)) * 10 result = prefer_newer_date(binary, binary, binary) assert isinstance(result, bytes) def test_malformed_date_string_does_not_crash(self) -> None: adversarial_dates = [ "'; DROP TABLE notes; --", "2025-99-99", "9" * 1000, "\n\n\n", "T", ] for bad_date in adversarial_dates: note = _note(f"date: '{bad_date}'") try: result = prefer_newer_date(note, _note("date: 2025-01-01"), _BASE) assert isinstance(result, bytes) except Exception as exc: pytest.fail(f"Crashed on date {bad_date!r}: {exc}") def test_very_long_tag_string_does_not_crash(self) -> None: long_tag = "a" * 100_000 ours = _note(f"tags:\n - {long_tag}") result = union_sorted(ours, _note("tags:\n - short"), _note("")) assert isinstance(result, bytes) def test_apply_strategy_unknown_does_not_crash(self) -> None: result = apply_strategy("nonexistent", b"a", b"b", b"c") assert result is None