test_knowtation_attachments.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
| 1 | """Tests for Phase 3.5 — Mist attachment co-references in the link indexer. |
| 2 | |
| 3 | Verifies: |
| 4 | * LinkIndex.attachment_index is populated from frontmatter.attachments[*] |
| 5 | * Invalid mist IDs are filtered out (security: only 12-char base58) |
| 6 | * note_entangle awards co-change credits for shared attachments |
| 7 | * note_impact include_attachments expands the blast radius correctly |
| 8 | |
| 9 | Test tiers (Rule #0): |
| 10 | 1. Unit — attachment_index population, co_attachment, validation |
| 11 | 2. Integration — entangle with attachments scenario; impact with attachments |
| 12 | 3. Data-integrity — 200-note fixture with shared attachments |
| 13 | 4. Security — invalid mist IDs rejected, no path injection |
| 14 | """ |
| 15 | |
| 16 | from __future__ import annotations |
| 17 | |
| 18 | import pathlib |
| 19 | import types |
| 20 | |
| 21 | import pytest |
| 22 | |
| 23 | from muse.plugins.knowtation.code_intel import note_impact |
| 24 | from muse.plugins.knowtation.link_index import build_link_index |
| 25 | from muse.plugins.knowtation.note_metrics import note_entangle |
| 26 | |
| 27 | |
| 28 | # ============================================================================ |
| 29 | # Helpers |
| 30 | # ============================================================================ |
| 31 | |
| 32 | |
| 33 | def _make_vault(tmp_path: pathlib.Path, files: dict[str, bytes]) -> pathlib.Path: |
| 34 | for rel, content in files.items(): |
| 35 | full = tmp_path / rel |
| 36 | full.parent.mkdir(parents=True, exist_ok=True) |
| 37 | full.write_bytes(content) |
| 38 | return tmp_path |
| 39 | |
| 40 | |
| 41 | def _fake_loader(snapshots: dict[str, dict[str, str]]): |
| 42 | def loader(commit_id: str) -> dict[str, str]: |
| 43 | return snapshots.get(commit_id, {}) |
| 44 | return loader |
| 45 | |
| 46 | |
| 47 | # A valid 12-char base58 ID (no 0, O, I, l). |
| 48 | _VALID_MIST_A = "abc123ABCdef" |
| 49 | _VALID_MIST_B = "xyz789XYZabc" |
| 50 | |
| 51 | # Invalid candidates — for security checks. |
| 52 | _INVALID_MIST_TOO_SHORT = "abc" |
| 53 | _INVALID_MIST_HAS_ZERO = "abc1230ABCde" # contains '0' |
| 54 | _INVALID_MIST_HAS_CAP_O = "abc123OABCde" # contains 'O' |
| 55 | |
| 56 | |
| 57 | # ============================================================================ |
| 58 | # TestAttachmentIndexPopulation |
| 59 | # ============================================================================ |
| 60 | |
| 61 | |
| 62 | class TestAttachmentIndexPopulation: |
| 63 | def test_valid_mist_id_populates_index(self, tmp_path: pathlib.Path) -> None: |
| 64 | _make_vault(tmp_path, { |
| 65 | "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), |
| 66 | }) |
| 67 | idx = build_link_index(tmp_path) |
| 68 | assert _VALID_MIST_A in idx.attachment_index |
| 69 | assert "a.md" in idx.attachment_index[_VALID_MIST_A] |
| 70 | |
| 71 | def test_two_notes_share_attachment(self, tmp_path: pathlib.Path) -> None: |
| 72 | _make_vault(tmp_path, { |
| 73 | "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), |
| 74 | "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), |
| 75 | "c.md": b"# Unrelated", |
| 76 | }) |
| 77 | idx = build_link_index(tmp_path) |
| 78 | assert idx.attachment_index[_VALID_MIST_A] == {"a.md", "b.md"} |
| 79 | |
| 80 | def test_invalid_mist_id_filtered(self, tmp_path: pathlib.Path) -> None: |
| 81 | _make_vault(tmp_path, { |
| 82 | "a.md": ( |
| 83 | f"---\nattachments:\n" |
| 84 | f" - {_INVALID_MIST_TOO_SHORT}\n" |
| 85 | f" - {_INVALID_MIST_HAS_ZERO}\n" |
| 86 | f" - {_INVALID_MIST_HAS_CAP_O}\n" |
| 87 | f" - {_VALID_MIST_A}\n" |
| 88 | f"---\nA" |
| 89 | ).encode(), |
| 90 | }) |
| 91 | idx = build_link_index(tmp_path) |
| 92 | # Only the valid one should be indexed |
| 93 | assert _VALID_MIST_A in idx.attachment_index |
| 94 | for invalid in (_INVALID_MIST_TOO_SHORT, _INVALID_MIST_HAS_ZERO, _INVALID_MIST_HAS_CAP_O): |
| 95 | assert invalid not in idx.attachment_index |
| 96 | |
| 97 | def test_empty_attachments_safe(self, tmp_path: pathlib.Path) -> None: |
| 98 | _make_vault(tmp_path, { |
| 99 | "a.md": b"---\nattachments: []\n---\nA", |
| 100 | }) |
| 101 | idx = build_link_index(tmp_path) |
| 102 | assert idx.attachment_index == {} |
| 103 | |
| 104 | def test_missing_attachments_safe(self, tmp_path: pathlib.Path) -> None: |
| 105 | _make_vault(tmp_path, {"a.md": b"# A"}) |
| 106 | idx = build_link_index(tmp_path) |
| 107 | assert idx.attachment_index == {} |
| 108 | |
| 109 | |
| 110 | # ============================================================================ |
| 111 | # TestCoAttachmentLookup |
| 112 | # ============================================================================ |
| 113 | |
| 114 | |
| 115 | class TestCoAttachmentLookup: |
| 116 | def test_co_attachment_returns_shared_notes(self, tmp_path: pathlib.Path) -> None: |
| 117 | _make_vault(tmp_path, { |
| 118 | "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), |
| 119 | "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), |
| 120 | }) |
| 121 | idx = build_link_index(tmp_path) |
| 122 | assert idx.co_attachment("a.md") == {"b.md"} |
| 123 | |
| 124 | def test_co_attachment_excludes_self(self, tmp_path: pathlib.Path) -> None: |
| 125 | _make_vault(tmp_path, { |
| 126 | "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), |
| 127 | }) |
| 128 | idx = build_link_index(tmp_path) |
| 129 | assert idx.co_attachment("a.md") == set() |
| 130 | |
| 131 | def test_co_attachment_empty_for_unlinked_note(self, tmp_path: pathlib.Path) -> None: |
| 132 | _make_vault(tmp_path, {"a.md": b"# A"}) |
| 133 | idx = build_link_index(tmp_path) |
| 134 | assert idx.co_attachment("a.md") == set() |
| 135 | |
| 136 | |
| 137 | # ============================================================================ |
| 138 | # TestEntangleWithAttachments |
| 139 | # ============================================================================ |
| 140 | |
| 141 | |
| 142 | class TestEntangleWithAttachments: |
| 143 | def test_shared_attachment_adds_co_change(self, tmp_path: pathlib.Path) -> None: |
| 144 | _make_vault(tmp_path, { |
| 145 | "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), |
| 146 | "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), |
| 147 | }) |
| 148 | idx = build_link_index(tmp_path) |
| 149 | result = note_entangle([], _fake_loader({}), min_co_changes=1, link_index=idx) |
| 150 | # No commit history at all, but attachment co-ref alone should yield |
| 151 | # one pair |
| 152 | pairs = {tuple(r["pair"]): r for r in result["ranking"]} |
| 153 | assert ("a.md", "b.md") in pairs |
| 154 | assert pairs[("a.md", "b.md")]["shared_attachments"] == 1 |
| 155 | assert pairs[("a.md", "b.md")]["co_changes"] >= 1 |
| 156 | |
| 157 | def test_no_attachment_no_pair(self, tmp_path: pathlib.Path) -> None: |
| 158 | _make_vault(tmp_path, { |
| 159 | "a.md": b"# A", |
| 160 | "b.md": b"# B", |
| 161 | }) |
| 162 | idx = build_link_index(tmp_path) |
| 163 | result = note_entangle([], _fake_loader({}), min_co_changes=1, link_index=idx) |
| 164 | assert result["ranking"] == [] |
| 165 | |
| 166 | def test_attachment_pairs_count(self, tmp_path: pathlib.Path) -> None: |
| 167 | _make_vault(tmp_path, { |
| 168 | "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), |
| 169 | "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), |
| 170 | "c.md": f"---\nattachments:\n - {_VALID_MIST_B}\n---\nC".encode(), |
| 171 | "d.md": f"---\nattachments:\n - {_VALID_MIST_B}\n---\nD".encode(), |
| 172 | }) |
| 173 | idx = build_link_index(tmp_path) |
| 174 | result = note_entangle([], _fake_loader({}), min_co_changes=1, link_index=idx) |
| 175 | # Two attachment pairs: (a,b) sharing mist_A, (c,d) sharing mist_B |
| 176 | assert result["attachment_pairs"] == 2 |
| 177 | |
| 178 | |
| 179 | # ============================================================================ |
| 180 | # TestImpactWithAttachments |
| 181 | # ============================================================================ |
| 182 | |
| 183 | |
| 184 | class TestImpactWithAttachments: |
| 185 | def test_impact_includes_attachment_neighbours(self, tmp_path: pathlib.Path) -> None: |
| 186 | _make_vault(tmp_path, { |
| 187 | "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), |
| 188 | "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), |
| 189 | "isolated.md": b"# Isolated", |
| 190 | }) |
| 191 | idx = build_link_index(tmp_path) |
| 192 | # b.md should appear in the blast radius of a.md via the attachment join |
| 193 | result = note_impact(idx, "a.md", include_attachments=True) |
| 194 | all_in_blast = [n for notes in result["by_depth"].values() for n in notes] |
| 195 | assert "b.md" in all_in_blast |
| 196 | assert "isolated.md" not in all_in_blast |
| 197 | |
| 198 | def test_impact_excludes_attachment_when_disabled(self, tmp_path: pathlib.Path) -> None: |
| 199 | _make_vault(tmp_path, { |
| 200 | "a.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nA".encode(), |
| 201 | "b.md": f"---\nattachments:\n - {_VALID_MIST_A}\n---\nB".encode(), |
| 202 | }) |
| 203 | idx = build_link_index(tmp_path) |
| 204 | result = note_impact(idx, "a.md", include_attachments=False) |
| 205 | all_in_blast = [n for notes in result["by_depth"].values() for n in notes] |
| 206 | assert "b.md" not in all_in_blast |
| 207 | |
| 208 | |
| 209 | # ============================================================================ |
| 210 | # TestDataIntegrity200Notes |
| 211 | # ============================================================================ |
| 212 | |
| 213 | |
| 214 | class TestDataIntegrity200Notes: |
| 215 | def test_200_notes_with_shared_attachments(self, tmp_path: pathlib.Path) -> None: |
| 216 | """Group of 200 notes; pairs share one of 5 mist IDs.""" |
| 217 | files: dict[str, bytes] = {} |
| 218 | # Generate 5 distinct valid mist IDs (12-char base58). |
| 219 | # Mist IDs must be exactly 12 base58 chars (no 0, O, I, l). |
| 220 | mist_ids = [f"mist{i}AbcDefg" for i in range(1, 6)] |
| 221 | for i in range(200): |
| 222 | mid = mist_ids[i % 5] |
| 223 | files[f"note_{i:03d}.md"] = ( |
| 224 | f"---\nattachments:\n - {mid}\n---\n# Note {i}" |
| 225 | ).encode() |
| 226 | _make_vault(tmp_path, files) |
| 227 | idx = build_link_index(tmp_path) |
| 228 | |
| 229 | # Each mist ID is shared by 40 notes → attachment_index has 5 keys |
| 230 | assert len(idx.attachment_index) == 5 |
| 231 | for mid in mist_ids: |
| 232 | assert len(idx.attachment_index[mid]) == 40 |
| 233 | |
| 234 | |
| 235 | # ============================================================================ |
| 236 | # TestSecurity |
| 237 | # ============================================================================ |
| 238 | |
| 239 | |
| 240 | class TestSecurity: |
| 241 | def test_path_traversal_in_mist_id_rejected(self, tmp_path: pathlib.Path) -> None: |
| 242 | _make_vault(tmp_path, { |
| 243 | "a.md": b"---\nattachments:\n - '../../etc/passwd'\n---\nA", |
| 244 | }) |
| 245 | idx = build_link_index(tmp_path) |
| 246 | # Invalid mist ID (slashes, dots) → filtered out |
| 247 | assert idx.attachment_index == {} |
| 248 | |
| 249 | def test_html_injection_in_mist_id_rejected(self, tmp_path: pathlib.Path) -> None: |
| 250 | _make_vault(tmp_path, { |
| 251 | "a.md": b"---\nattachments:\n - '<script>'\n---\nA", |
| 252 | }) |
| 253 | idx = build_link_index(tmp_path) |
| 254 | assert idx.attachment_index == {} |
| 255 | |
| 256 | def test_very_long_string_rejected(self, tmp_path: pathlib.Path) -> None: |
| 257 | huge = "a" * 100 |
| 258 | _make_vault(tmp_path, { |
| 259 | "a.md": f"---\nattachments:\n - {huge}\n---\nA".encode(), |
| 260 | }) |
| 261 | idx = build_link_index(tmp_path) |
| 262 | # Length != 12 → rejected |
| 263 | assert idx.attachment_index == {} |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago