gabriel / muse public
test_knowtation_attachments.py python
263 lines 10.6 KB
Raw
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