"""Tests for Phase 3.5 — Mist attachment co-references in the link indexer. Verifies: * LinkIndex.attachment_index is populated from frontmatter.attachments[*] * Invalid mist IDs are filtered out (security: only 12-char base58) * note_entangle awards co-change credits for shared attachments * note_impact include_attachments expands the blast radius correctly Test tiers (Rule #0): 1. Unit — attachment_index population, co_attachment, validation 2. Integration — entangle with attachments scenario; impact with attachments 3. Data-integrity — 200-note fixture with shared attachments 4. Security — invalid mist IDs rejected, no path injection """ from __future__ import annotations import pathlib import types import pytest from muse.plugins.knowtation.code_intel import note_impact from muse.plugins.knowtation.link_index import build_link_index from muse.plugins.knowtation.note_metrics import note_entangle # ============================================================================ # Helpers # ============================================================================ def _make_vault(tmp_path: pathlib.Path, files: dict[str, bytes]) -> pathlib.Path: for rel, content in files.items(): full = tmp_path / rel full.parent.mkdir(parents=True, exist_ok=True) full.write_bytes(content) return tmp_path def _fake_loader(snapshots: dict[str, dict[str, str]]): def loader(commit_id: str) -> dict[str, str]: return snapshots.get(commit_id, {}) return loader # A valid 12-char base58 ID (no 0, O, I, l). _VALID_MIST_A = "abc123ABCdef" _VALID_MIST_B = "xyz789XYZabc" # Invalid candidates — for security checks. _INVALID_MIST_TOO_SHORT = "abc" _INVALID_MIST_HAS_ZERO = "abc1230ABCde" # contains '0' _INVALID_MIST_HAS_CAP_O = "abc123OABCde" # contains 'O' # ============================================================================ # TestAttachmentIndexPopulation # ============================================================================ class TestAttachmentIndexPopulation: def test_valid_mist_id_populates_index(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), }) idx = build_link_index(tmp_path) assert _VALID_MIST_A in idx.attachment_index assert "a.md" in idx.attachment_index[_VALID_MIST_A] def test_two_notes_share_attachment(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), "c.md": b"# Unrelated", }) idx = build_link_index(tmp_path) assert idx.attachment_index[_VALID_MIST_A] == {"a.md", "b.md"} def test_invalid_mist_id_filtered(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": ( f"---\nattachments:\n" f" - {_INVALID_MIST_TOO_SHORT}\n" f" - {_INVALID_MIST_HAS_ZERO}\n" f" - {_INVALID_MIST_HAS_CAP_O}\n" f" - {_VALID_MIST_A}\n" f"---\nA" ).encode(), }) idx = build_link_index(tmp_path) # Only the valid one should be indexed assert _VALID_MIST_A in idx.attachment_index for invalid in (_INVALID_MIST_TOO_SHORT, _INVALID_MIST_HAS_ZERO, _INVALID_MIST_HAS_CAP_O): assert invalid not in idx.attachment_index def test_empty_attachments_safe(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"---\nattachments: []\n---\nA", }) idx = build_link_index(tmp_path) assert idx.attachment_index == {} def test_missing_attachments_safe(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"a.md": b"# A"}) idx = build_link_index(tmp_path) assert idx.attachment_index == {} # ============================================================================ # TestCoAttachmentLookup # ============================================================================ class TestCoAttachmentLookup: def test_co_attachment_returns_shared_notes(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), }) idx = build_link_index(tmp_path) assert idx.co_attachment("a.md") == {"b.md"} def test_co_attachment_excludes_self(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), }) idx = build_link_index(tmp_path) assert idx.co_attachment("a.md") == set() def test_co_attachment_empty_for_unlinked_note(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"a.md": b"# A"}) idx = build_link_index(tmp_path) assert idx.co_attachment("a.md") == set() # ============================================================================ # TestEntangleWithAttachments # ============================================================================ class TestEntangleWithAttachments: def test_shared_attachment_adds_co_change(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), }) idx = build_link_index(tmp_path) result = note_entangle([], _fake_loader({}), min_co_changes=1, link_index=idx) # No commit history at all, but attachment co-ref alone should yield # one pair pairs = {tuple(r["pair"]): r for r in result["ranking"]} assert ("a.md", "b.md") in pairs assert pairs[("a.md", "b.md")]["shared_attachments"] == 1 assert pairs[("a.md", "b.md")]["co_changes"] >= 1 def test_no_attachment_no_pair(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"# A", "b.md": b"# B", }) idx = build_link_index(tmp_path) result = note_entangle([], _fake_loader({}), min_co_changes=1, link_index=idx) assert result["ranking"] == [] def test_attachment_pairs_count(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), "c.md": f"---\nattachments:\n - {_VALID_MIST_B}\n---\nC".encode(), "d.md": f"---\nattachments:\n - {_VALID_MIST_B}\n---\nD".encode(), }) idx = build_link_index(tmp_path) result = note_entangle([], _fake_loader({}), min_co_changes=1, link_index=idx) # Two attachment pairs: (a,b) sharing mist_A, (c,d) sharing mist_B assert result["attachment_pairs"] == 2 # ============================================================================ # TestImpactWithAttachments # ============================================================================ class TestImpactWithAttachments: def test_impact_includes_attachment_neighbours(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), "isolated.md": b"# Isolated", }) idx = build_link_index(tmp_path) # b.md should appear in the blast radius of a.md via the attachment join result = note_impact(idx, "a.md", include_attachments=True) all_in_blast = [n for notes in result["by_depth"].values() for n in notes] assert "b.md" in all_in_blast assert "isolated.md" not in all_in_blast def test_impact_excludes_attachment_when_disabled(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), }) idx = build_link_index(tmp_path) result = note_impact(idx, "a.md", include_attachments=False) all_in_blast = [n for notes in result["by_depth"].values() for n in notes] assert "b.md" not in all_in_blast # ============================================================================ # TestDataIntegrity200Notes # ============================================================================ class TestDataIntegrity200Notes: def test_200_notes_with_shared_attachments(self, tmp_path: pathlib.Path) -> None: """Group of 200 notes; pairs share one of 5 mist IDs.""" files: dict[str, bytes] = {} # Generate 5 distinct valid mist IDs (12-char base58). # Mist IDs must be exactly 12 base58 chars (no 0, O, I, l). mist_ids = [f"mist{i}AbcDefg" for i in range(1, 6)] for i in range(200): mid = mist_ids[i % 5] files[f"note_{i:03d}.md"] = ( f"---\nattachments:\n - {mid}\n---\n# Note {i}" ).encode() _make_vault(tmp_path, files) idx = build_link_index(tmp_path) # Each mist ID is shared by 40 notes → attachment_index has 5 keys assert len(idx.attachment_index) == 5 for mid in mist_ids: assert len(idx.attachment_index[mid]) == 40 # ============================================================================ # TestSecurity # ============================================================================ class TestSecurity: def test_path_traversal_in_mist_id_rejected(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"---\nattachments:\n - '../../etc/passwd'\n---\nA", }) idx = build_link_index(tmp_path) # Invalid mist ID (slashes, dots) → filtered out assert idx.attachment_index == {} def test_html_injection_in_mist_id_rejected(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"---\nattachments:\n - '