gabriel / muse public
test_knowtation_strategies.py python
482 lines 17.8 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for Phase 2.2 — Knowtation-specific merge strategies.
2
3 Test tiers covered
4 ------------------
5 1. Unit — VALID_STRATEGIES includes the three new strategy names.
6 2. Unit — prefer_newer_date: later date wins, one-side-missing fallback,
7 both-missing fallback, equal-date fallback, malformed date graceful.
8 3. Unit — union_sorted: set fields unioned and sorted, scalar fields from ours,
9 deletions honoured when both sides agree, no-frontmatter graceful.
10 4. Unit — knowtation_3way: falls back to ours when merger not available.
11 5. Unit — apply_strategy: dispatch table, unknown strategy returns None.
12 6. Integration — load_attributes() accepts all three new strategy strings
13 in .museattributes without raising.
14 7. Security — adversarial frontmatter: YAML injection, oversized tags array,
15 malformed date strings, binary content, NUL bytes.
16 """
17
18 from __future__ import annotations
19
20 import pathlib
21 import textwrap
22 from typing import Any
23
24 import pytest
25 import yaml
26
27 from muse.core.attributes import VALID_STRATEGIES
28 from muse.plugins.knowtation.strategies import (
29 STRATEGY_DISPATCH,
30 _parse_date,
31 apply_strategy,
32 knowtation_3way,
33 prefer_newer_date,
34 union_sorted,
35 )
36
37
38 # ---------------------------------------------------------------------------
39 # Helpers
40 # ---------------------------------------------------------------------------
41
42
43 def _note(frontmatter: str = "", body: str = "Body text.") -> bytes:
44 """Build a note from a YAML frontmatter string and optional body."""
45 if frontmatter:
46 return f"---\n{frontmatter}\n---\n{body}".encode()
47 return body.encode()
48
49
50 def _parse_yaml_fm(note: bytes) -> dict[str, Any]:
51 """Extract and parse YAML frontmatter from note bytes."""
52 text = note.decode("utf-8", errors="replace")
53 if not text.startswith("---"):
54 return {}
55 rest = text[3:]
56 end = -1
57 for delim in ("\n---", "\n..."):
58 pos = rest.find(delim)
59 if pos != -1 and (end == -1 or pos < end):
60 end = pos
61 if end == -1:
62 return {}
63 data = yaml.safe_load(rest[:end])
64 return data if isinstance(data, dict) else {}
65
66
67 _EMPTY = b""
68 _BASE = _note("date: 2025-01-01\ntags:\n - alpha\n - beta")
69
70
71 # ============================================================================
72 # TestValidStrategiesSet
73 # ============================================================================
74
75
76 class TestValidStrategiesSet:
77 def test_prefer_newer_date_in_valid_strategies(self) -> None:
78 assert "prefer-newer-date" in VALID_STRATEGIES
79
80 def test_union_sorted_in_valid_strategies(self) -> None:
81 assert "union-sorted" in VALID_STRATEGIES
82
83 def test_knowtation_3way_in_valid_strategies(self) -> None:
84 assert "knowtation-3way" in VALID_STRATEGIES
85
86 def test_core_strategies_still_present(self) -> None:
87 for s in ("ours", "theirs", "union", "base", "auto", "manual"):
88 assert s in VALID_STRATEGIES
89
90
91 # ============================================================================
92 # TestParseDateHelper
93 # ============================================================================
94
95
96 class TestParseDateHelper:
97 def test_plain_date(self) -> None:
98 from datetime import date
99
100 assert _parse_date("2025-01-15") == date(2025, 1, 15)
101
102 def test_datetime_string(self) -> None:
103 from datetime import date
104
105 assert _parse_date("2025-01-15T14:30:00") == date(2025, 1, 15)
106
107 def test_none_input(self) -> None:
108 assert _parse_date(None) is None
109
110 def test_empty_string(self) -> None:
111 assert _parse_date("") is None
112
113 def test_malformed_string(self) -> None:
114 assert _parse_date("yesterday") is None
115
116 def test_future_date(self) -> None:
117 from datetime import date
118
119 assert _parse_date("2099-12-31") == date(2099, 12, 31)
120
121
122 # ============================================================================
123 # TestPreferNewerDate
124 # ============================================================================
125
126
127 class TestPreferNewerDate:
128 def test_theirs_newer_returns_theirs(self) -> None:
129 ours = _note("date: 2025-01-01")
130 theirs = _note("date: 2025-06-15")
131 result = prefer_newer_date(ours, theirs, _BASE)
132 assert result == theirs
133
134 def test_ours_newer_returns_ours(self) -> None:
135 ours = _note("date: 2025-12-31")
136 theirs = _note("date: 2025-01-01")
137 result = prefer_newer_date(ours, theirs, _BASE)
138 assert result == ours
139
140 def test_equal_dates_returns_ours(self) -> None:
141 ours = _note("date: 2025-06-01\ntitle: ours")
142 theirs = _note("date: 2025-06-01\ntitle: theirs")
143 result = prefer_newer_date(ours, theirs, _BASE)
144 assert result == ours
145
146 def test_ours_no_date_theirs_has_date_returns_theirs(self) -> None:
147 ours = _note("title: no date here")
148 theirs = _note("date: 2025-01-01")
149 result = prefer_newer_date(ours, theirs, _BASE)
150 assert result == theirs
151
152 def test_theirs_no_date_returns_ours(self) -> None:
153 ours = _note("date: 2025-01-01")
154 theirs = _note("title: no date")
155 result = prefer_newer_date(ours, theirs, _BASE)
156 assert result == ours
157
158 def test_both_no_date_returns_ours(self) -> None:
159 ours = _note("title: ours")
160 theirs = _note("title: theirs")
161 result = prefer_newer_date(ours, theirs, _BASE)
162 assert result == ours
163
164 def test_malformed_date_treated_as_missing(self) -> None:
165 ours = _note("date: not-a-date\ntitle: ours")
166 theirs = _note("date: 2025-01-01")
167 result = prefer_newer_date(ours, theirs, _BASE)
168 # ours has malformed date → treated as missing → theirs wins
169 assert result == theirs
170
171 def test_empty_ours_theirs_has_date_returns_theirs(self) -> None:
172 result = prefer_newer_date(_EMPTY, _note("date: 2025-01-01"), _BASE)
173 assert result == _note("date: 2025-01-01")
174
175 def test_both_empty_returns_ours(self) -> None:
176 result = prefer_newer_date(_EMPTY, _EMPTY, _BASE)
177 assert result == _EMPTY
178
179 def test_base_not_used_for_decision(self) -> None:
180 ours = _note("date: 2025-06-01")
181 theirs = _note("date: 2025-01-01")
182 base = _note("date: 2099-12-31") # base has future date — must be ignored
183 result = prefer_newer_date(ours, theirs, base)
184 assert result == ours
185
186 def test_datetime_string_compared_as_date(self) -> None:
187 ours = _note("date: 2025-01-01T08:00:00")
188 theirs = _note("date: 2025-01-01T23:00:00")
189 # Both parse to 2025-01-01 → equal → ours wins
190 result = prefer_newer_date(ours, theirs, _BASE)
191 assert result == ours
192
193 def test_returns_bytes(self) -> None:
194 result = prefer_newer_date(_note("date: 2025-01-01"), _note("date: 2025-06-01"), _BASE)
195 assert isinstance(result, bytes)
196
197
198 # ============================================================================
199 # TestUnionSorted
200 # ============================================================================
201
202
203 class TestUnionSorted:
204 def test_tags_unioned_and_sorted(self) -> None:
205 ours = _note("tags:\n - beta\n - alpha")
206 theirs = _note("tags:\n - gamma\n - alpha")
207 result = union_sorted(ours, theirs, _BASE)
208 fm = _parse_yaml_fm(result)
209 assert fm["tags"] == ["alpha", "beta", "gamma"]
210
211 def test_entity_unioned_and_sorted(self) -> None:
212 ours = _note("entity:\n - zebra\n - alice")
213 theirs = _note("entity:\n - bob\n - alice")
214 result = union_sorted(ours, theirs, _BASE)
215 fm = _parse_yaml_fm(result)
216 assert fm["entity"] == ["alice", "bob", "zebra"]
217
218 def test_attachments_unioned(self) -> None:
219 ours = _note("attachments:\n - ABCDEFGHJKLM\n - 111111111111")
220 theirs = _note("attachments:\n - zzzzzzzzzzzz")
221 result = union_sorted(ours, theirs, _note(""))
222 fm = _parse_yaml_fm(result)
223 assert "ABCDEFGHJKLM" in fm["attachments"]
224 assert "zzzzzzzzzzzz" in fm["attachments"]
225 assert fm["attachments"] == sorted(fm["attachments"])
226
227 def test_scalar_field_from_ours(self) -> None:
228 ours = _note("project: ours-project\ndate: 2025-01-01")
229 theirs = _note("project: theirs-project\ndate: 2025-06-01")
230 result = union_sorted(ours, theirs, _BASE)
231 fm = _parse_yaml_fm(result)
232 assert fm["project"] == "ours-project"
233
234 def test_both_deletions_honoured(self) -> None:
235 """Tags deleted by both sides must be absent from result."""
236 base = _note("tags:\n - alpha\n - beta\n - gamma")
237 ours = _note("tags:\n - alpha") # deleted beta and gamma
238 theirs = _note("tags:\n - alpha") # also deleted beta and gamma
239 result = union_sorted(ours, theirs, base)
240 fm = _parse_yaml_fm(result)
241 assert "beta" not in fm.get("tags", [])
242 assert "gamma" not in fm.get("tags", [])
243 assert "alpha" in fm.get("tags", [])
244
245 def test_only_ours_deletion_not_honoured(self) -> None:
246 """Tags deleted only by ours are kept (theirs still wants them)."""
247 base = _note("tags:\n - alpha\n - beta")
248 ours = _note("tags:\n - alpha") # deleted beta
249 theirs = _note("tags:\n - alpha\n - beta") # kept beta
250 result = union_sorted(ours, theirs, base)
251 fm = _parse_yaml_fm(result)
252 assert "beta" in fm.get("tags", [])
253
254 def test_no_frontmatter_ours_returns_ours(self) -> None:
255 ours = b"# Plain note\nNo frontmatter."
256 result = union_sorted(ours, _note("tags:\n - new"), _BASE)
257 assert result == ours
258
259 def test_result_is_bytes(self) -> None:
260 result = union_sorted(_note("tags:\n - a"), _note("tags:\n - b"), _BASE)
261 assert isinstance(result, bytes)
262
263 def test_empty_result_when_all_deleted(self) -> None:
264 base = _note("tags:\n - alpha")
265 ours = _note("") # no tags
266 theirs = _note("") # no tags
267 result = union_sorted(ours, theirs, base)
268 fm = _parse_yaml_fm(result)
269 assert "tags" not in fm or fm["tags"] == []
270
271 def test_result_is_sorted_not_just_unioned(self) -> None:
272 ours = _note("tags:\n - zebra")
273 theirs = _note("tags:\n - aardvark")
274 result = union_sorted(ours, theirs, _note(""))
275 fm = _parse_yaml_fm(result)
276 tags = fm.get("tags", [])
277 assert tags == sorted(tags)
278
279
280 # ============================================================================
281 # TestKnowtation3way
282 # ============================================================================
283
284
285 class TestKnowtation3way:
286 def test_delegates_to_merger_when_available(self) -> None:
287 """With merger.py present (Phase 2.4), knowtation_3way runs the
288 structured three-way merge. Conflicting titles produce a result
289 containing standard git conflict markers; the call still returns
290 bytes (never raises) so downstream tooling can write the result
291 to disk for human resolution.
292 """
293 ours = _note("title: ours\ndate: 2025-01-01")
294 theirs = _note("title: theirs\ndate: 2025-01-01")
295 result = knowtation_3way(ours, theirs, _BASE)
296 assert isinstance(result, bytes)
297 # Ours's date matches base, theirs's date matches base — only the
298 # title differs. Both sides changed it differently → conflict
299 # markers must appear in the merged output.
300 assert b"<<<<<<<" in result
301 assert b">>>>>>>" in result
302
303 def test_returns_bytes(self) -> None:
304 result = knowtation_3way(_note(""), _note(""), _BASE)
305 assert isinstance(result, bytes)
306
307 def test_fallback_is_non_crashing(self) -> None:
308 """Must not raise even with unusual inputs."""
309 try:
310 knowtation_3way(b"", b"", b"")
311 except Exception as exc:
312 pytest.fail(f"knowtation_3way raised: {exc}")
313
314
315 # ============================================================================
316 # TestApplyStrategy
317 # ============================================================================
318
319
320 class TestApplyStrategy:
321 def test_prefer_newer_date_dispatched(self) -> None:
322 ours = _note("date: 2025-01-01")
323 theirs = _note("date: 2025-06-01")
324 result = apply_strategy("prefer-newer-date", ours, theirs, _BASE)
325 assert result == theirs
326
327 def test_union_sorted_dispatched(self) -> None:
328 ours = _note("tags:\n - b")
329 theirs = _note("tags:\n - a")
330 result = apply_strategy("union-sorted", ours, theirs, _note(""))
331 assert result is not None
332 fm = _parse_yaml_fm(result)
333 assert "a" in fm.get("tags", [])
334 assert "b" in fm.get("tags", [])
335
336 def test_knowtation_3way_dispatched(self) -> None:
337 result = apply_strategy("knowtation-3way", _note(""), _note(""), _BASE)
338 assert result is not None
339 assert isinstance(result, bytes)
340
341 def test_unknown_strategy_returns_none(self) -> None:
342 result = apply_strategy("ours", b"", b"", b"")
343 assert result is None
344
345 def test_none_for_auto(self) -> None:
346 assert apply_strategy("auto", b"", b"", b"") is None
347
348 def test_none_for_manual(self) -> None:
349 assert apply_strategy("manual", b"", b"", b"") is None
350
351 def test_strategy_dispatch_table_keys(self) -> None:
352 assert set(STRATEGY_DISPATCH.keys()) == {
353 "prefer-newer-date",
354 "union-sorted",
355 "knowtation-3way",
356 }
357
358
359 # ============================================================================
360 # TestIntegrationLoadAttributes
361 # ============================================================================
362
363
364 class TestIntegrationLoadAttributes:
365 """Verify that .museattributes files using the new strategies parse without error."""
366
367 def _load(self, tmp_path: pathlib.Path, content: str) -> None:
368 from muse.core.attributes import load_attributes
369
370 attrs_file = tmp_path / ".museattributes"
371 attrs_file.write_text(content, encoding="utf-8")
372 load_attributes(tmp_path, domain="knowtation")
373
374 def test_prefer_newer_date_accepted(self, tmp_path: pathlib.Path) -> None:
375 self._load(tmp_path, textwrap.dedent("""\
376 [meta]
377 domain = "knowtation"
378
379 [[rules]]
380 path = "inbox/**"
381 dimension = "*"
382 strategy = "prefer-newer-date"
383 """))
384
385 def test_union_sorted_accepted(self, tmp_path: pathlib.Path) -> None:
386 self._load(tmp_path, textwrap.dedent("""\
387 [meta]
388 domain = "knowtation"
389
390 [[rules]]
391 path = "**/*.md"
392 dimension = "frontmatter"
393 strategy = "union-sorted"
394 """))
395
396 def test_knowtation_3way_accepted(self, tmp_path: pathlib.Path) -> None:
397 self._load(tmp_path, textwrap.dedent("""\
398 [meta]
399 domain = "knowtation"
400
401 [[rules]]
402 path = "projects/**"
403 dimension = "*"
404 strategy = "knowtation-3way"
405 """))
406
407 def test_unknown_strategy_still_raises(self, tmp_path: pathlib.Path) -> None:
408 from muse.core.attributes import load_attributes
409
410 attrs_file = tmp_path / ".museattributes"
411 attrs_file.write_text(textwrap.dedent("""\
412 [[rules]]
413 path = "*"
414 dimension = "*"
415 strategy = "not-a-real-strategy"
416 """), encoding="utf-8")
417 with pytest.raises(ValueError, match="unknown strategy"):
418 load_attributes(tmp_path)
419
420
421 # ============================================================================
422 # TestSecurity
423 # ============================================================================
424
425
426 class TestSecurity:
427 def test_yaml_injection_in_frontmatter_does_not_execute(self) -> None:
428 """YAML safe_load must not execute arbitrary Python."""
429 malicious = _note(
430 "date: !!python/object/apply:os.system ['echo PWNED']\ntitle: x"
431 )
432 try:
433 result = prefer_newer_date(malicious, _note("date: 2025-01-01"), _BASE)
434 assert isinstance(result, bytes)
435 except Exception:
436 pass # Raising is acceptable; executing the payload is not.
437
438 def test_oversized_tags_array_does_not_crash(self) -> None:
439 many_tags = "\n".join(f" - tag{i:06d}" for i in range(10_000))
440 ours = _note(f"tags:\n{many_tags}")
441 theirs = _note("tags:\n - newtag")
442 try:
443 result = union_sorted(ours, theirs, _BASE)
444 assert isinstance(result, bytes)
445 except MemoryError:
446 pytest.skip("OOM on this platform — acceptable for 10k tags")
447
448 def test_nul_bytes_in_content_handled(self) -> None:
449 ours = b"---\ndate: 2025-01-01\n---\n\x00\x00\x00"
450 result = prefer_newer_date(ours, _note("date: 2024-01-01"), _BASE)
451 assert isinstance(result, bytes)
452
453 def test_binary_content_does_not_crash(self) -> None:
454 binary = bytes(range(256)) * 10
455 result = prefer_newer_date(binary, binary, binary)
456 assert isinstance(result, bytes)
457
458 def test_malformed_date_string_does_not_crash(self) -> None:
459 adversarial_dates = [
460 "'; DROP TABLE notes; --",
461 "2025-99-99",
462 "9" * 1000,
463 "\n\n\n",
464 "T",
465 ]
466 for bad_date in adversarial_dates:
467 note = _note(f"date: '{bad_date}'")
468 try:
469 result = prefer_newer_date(note, _note("date: 2025-01-01"), _BASE)
470 assert isinstance(result, bytes)
471 except Exception as exc:
472 pytest.fail(f"Crashed on date {bad_date!r}: {exc}")
473
474 def test_very_long_tag_string_does_not_crash(self) -> None:
475 long_tag = "a" * 100_000
476 ours = _note(f"tags:\n - {long_tag}")
477 result = union_sorted(ours, _note("tags:\n - short"), _note(""))
478 assert isinstance(result, bytes)
479
480 def test_apply_strategy_unknown_does_not_crash(self) -> None:
481 result = apply_strategy("nonexistent", b"a", b"b", b"c")
482 assert result is None
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