gabriel / muse public
test_knowtation_compare.py python
563 lines 19.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for Phase 2.6 — Knowtation ``muse code compare`` JSON output.
2
3 Test tiers covered
4 ------------------
5 1. Unit — _note_title: frontmatter title, H1 heading fallback, empty note
6 2. Unit — _summarise_note_delta: tags added/removed, section add/remove/move,
7 scalar frontmatter change, body line changes, empty delta
8 3. Unit — JSON schema: _KnowtationCompareJson has required top-level keys;
9 stat keys all present; each op has required fields
10 4. Integration — _run_knowtation_compare: two synthetic manifests with one
11 added, one removed, one modified note → correct op list and stats
12 5. Integration — JSON round-trip: output is valid JSON, deserialises correctly,
13 schema validated
14 6. Integration — stat_only mode: prints note counts but not op details
15 7. Security — path traversal in note paths; malformed YAML frontmatter; NUL
16 bytes in note content; object_store returning None
17 """
18
19 from __future__ import annotations
20
21 import hashlib
22 import json
23 import pathlib
24 from typing import Any
25 from unittest.mock import MagicMock, patch
26
27 import pytest
28
29 # ---------------------------------------------------------------------------
30 # Helpers
31 # ---------------------------------------------------------------------------
32
33
34 def _make_note(
35 title: str = "Test Note",
36 tags: list[str] | None = None,
37 body: str = "Some body text.",
38 ) -> bytes:
39 fm_parts = [f"title: {title}"]
40 if tags:
41 tag_lines = "\n".join(f" - {t}" for t in tags)
42 fm_parts.append(f"tags:\n{tag_lines}")
43 fm = "\n".join(fm_parts)
44 return f"---\n{fm}\n---\n{body}".encode()
45
46
47 def _sha256(content: bytes) -> str:
48 return hashlib.sha256(content).hexdigest()
49
50
51 def _store_object(root: pathlib.Path, content: bytes) -> str:
52 """Write *content* to the Muse object store and return its SHA-256 hash."""
53 from muse.core.object_store import write_object
54
55 obj_id = _sha256(content)
56 write_object(root, obj_id, content)
57 return obj_id
58
59
60 # ============================================================================
61 # TestNoteTitleHelper
62 # ============================================================================
63
64
65 class TestNoteTitleHelper:
66 def test_frontmatter_title(self) -> None:
67 from muse.cli.commands.compare import _note_title
68
69 note = _make_note(title="My Note Title")
70 assert _note_title(note) == "My Note Title"
71
72 def test_h1_fallback_no_frontmatter(self) -> None:
73 from muse.cli.commands.compare import _note_title
74
75 note = b"# Heading Title\n\nBody text."
76 assert _note_title(note) == "Heading Title"
77
78 def test_empty_note_returns_empty_string(self) -> None:
79 from muse.cli.commands.compare import _note_title
80
81 assert _note_title(b"") == ""
82
83 def test_binary_content_does_not_crash(self) -> None:
84 from muse.cli.commands.compare import _note_title
85
86 result = _note_title(bytes(range(256)))
87 assert isinstance(result, str)
88
89 def test_frontmatter_title_preferred_over_h1(self) -> None:
90 from muse.cli.commands.compare import _note_title
91
92 note = b"---\ntitle: FM Title\n---\n# H1 Title\n"
93 assert _note_title(note) == "FM Title"
94
95
96 # ============================================================================
97 # TestSummariseNoteDelta
98 # ============================================================================
99
100
101 class TestSummariseNoteDelta:
102 def test_empty_delta_returns_empty_diffs(self) -> None:
103 from muse.cli.commands.compare import _summarise_note_delta
104
105 delta = {"ops": [], "summary": "no changes", "domain": "knowtation"}
106 fm, sec, body = _summarise_note_delta(delta)
107 assert fm == {}
108 assert sec == {}
109 assert body == 0
110
111 def test_tags_added(self) -> None:
112 from muse.cli.commands.compare import _summarise_note_delta
113
114 delta = {
115 "ops": [
116 {"op": "insert", "address": "tags::machine-learning"},
117 {"op": "insert", "address": "tags::python"},
118 ],
119 "domain": "knowtation",
120 }
121 fm, _, _ = _summarise_note_delta(delta)
122 assert "machine-learning" in fm.get("tags_added", [])
123 assert "python" in fm.get("tags_added", [])
124
125 def test_tags_removed(self) -> None:
126 from muse.cli.commands.compare import _summarise_note_delta
127
128 delta = {
129 "ops": [{"op": "delete", "address": "tags::old-tag"}],
130 "domain": "knowtation",
131 }
132 fm, _, _ = _summarise_note_delta(delta)
133 assert "old-tag" in fm.get("tags_removed", [])
134
135 def test_section_added(self) -> None:
136 from muse.cli.commands.compare import _summarise_note_delta
137
138 delta = {
139 "ops": [{"op": "insert", "address": "section:2:Background#0", "content": "..."}],
140 "domain": "knowtation",
141 }
142 _, sec, _ = _summarise_note_delta(delta)
143 assert "section:2:Background#0" in sec.get("added", [])
144
145 def test_section_removed(self) -> None:
146 from muse.cli.commands.compare import _summarise_note_delta
147
148 delta = {
149 "ops": [{"op": "delete", "address": "section:2:Deprecated#0"}],
150 "domain": "knowtation",
151 }
152 _, sec, _ = _summarise_note_delta(delta)
153 assert "section:2:Deprecated#0" in sec.get("removed", [])
154
155 def test_section_moved(self) -> None:
156 from muse.cli.commands.compare import _summarise_note_delta
157
158 delta = {
159 "ops": [{"op": "move", "address": "section:2:Background#0", "content_id": "abc"}],
160 "domain": "knowtation",
161 }
162 _, sec, _ = _summarise_note_delta(delta)
163 assert "section:2:Background#0" in sec.get("moved", [])
164
165 def test_body_line_changes_counted(self) -> None:
166 from muse.cli.commands.compare import _summarise_note_delta
167
168 delta = {
169 "ops": [
170 {"op": "insert", "address": "section:2:Background#0::line:3"},
171 {"op": "delete", "address": "section:2:Background#0::line:5"},
172 {"op": "replace", "address": "section:2:Background#0::line:7"},
173 ],
174 "domain": "knowtation",
175 }
176 _, _, body = _summarise_note_delta(delta)
177 assert body == 3
178
179 def test_scalar_frontmatter_change(self) -> None:
180 from muse.cli.commands.compare import _summarise_note_delta
181
182 delta = {
183 "ops": [{"op": "replace", "address": "fm::project", "new_content": "new-project"}],
184 "domain": "knowtation",
185 }
186 fm, _, _ = _summarise_note_delta(delta)
187 assert "project" in fm.get("scalar_changes", {})
188
189 def test_non_dict_delta_handled_gracefully(self) -> None:
190 from muse.cli.commands.compare import _summarise_note_delta
191
192 fm, sec, body = _summarise_note_delta(None) # type: ignore[arg-type]
193 assert fm == {}
194 assert sec == {}
195 assert body == 0
196
197
198 # ============================================================================
199 # TestKnowtationJsonSchema
200 # ============================================================================
201
202
203 class TestKnowtationJsonSchema:
204 def _make_minimal_output(self) -> dict[str, Any]:
205 """Build a minimal _KnowtationCompareJson dict."""
206 from muse.cli.commands.compare import _KnowtationCompareJson, _CommitRef, _KnowtationStat
207
208 return {
209 "from": {"commit_id": "a" * 64, "message": "First commit"},
210 "to": {"commit_id": "b" * 64, "message": "Second commit"},
211 "domain": "knowtation",
212 "stat": {
213 "notes_added": 1,
214 "notes_removed": 0,
215 "notes_modified": 2,
216 "frontmatter_changes": 3,
217 "section_changes": 4,
218 "tag_changes": 1,
219 },
220 "ops": [],
221 }
222
223 def test_required_top_level_keys(self) -> None:
224 out = self._make_minimal_output()
225 for key in ("from", "to", "domain", "stat", "ops"):
226 assert key in out
227
228 def test_domain_is_knowtation(self) -> None:
229 out = self._make_minimal_output()
230 assert out["domain"] == "knowtation"
231
232 def test_stat_has_all_keys(self) -> None:
233 out = self._make_minimal_output()
234 stat = out["stat"]
235 for key in (
236 "notes_added", "notes_removed", "notes_modified",
237 "frontmatter_changes", "section_changes", "tag_changes",
238 ):
239 assert key in stat, f"Missing stat key: {key}"
240
241 def test_serialisable_to_json(self) -> None:
242 out = self._make_minimal_output()
243 dumped = json.dumps(out)
244 loaded = json.loads(dumped)
245 assert loaded["domain"] == "knowtation"
246
247
248 # ============================================================================
249 # TestRunKnowtationCompare (integration)
250 # ============================================================================
251
252
253 class TestRunKnowtationCompare:
254 """Integration tests for _run_knowtation_compare using a tmp root."""
255
256 def _setup_vault(
257 self, tmp_path: pathlib.Path
258 ) -> tuple[dict[str, str], dict[str, str], dict, dict]:
259 """Create a minimal vault layout in tmp_path and return two manifests."""
260 # Three notes: note_a.md stays, note_b.md is modified, note_c.md is added
261 note_a_v1 = _make_note("Note A", tags=["old"], body="Version 1")
262 note_b_v1 = _make_note("Note B", tags=["original"], body="Old body.")
263 note_b_v2 = _make_note("Note B", tags=["original", "new-tag"], body="New body.")
264 note_c_v2 = _make_note("Note C", body="Newly added.")
265
266 hash_a = _store_object(tmp_path, note_a_v1)
267 hash_b1 = _store_object(tmp_path, note_b_v1)
268 hash_b2 = _store_object(tmp_path, note_b_v2)
269 hash_c = _store_object(tmp_path, note_c_v2)
270
271 manifest_1: dict[str, str] = {
272 "note_a.md": hash_a,
273 "note_b.md": hash_b1,
274 }
275 manifest_2: dict[str, str] = {
276 "note_a.md": hash_a,
277 "note_b.md": hash_b2,
278 "note_c.md": hash_c,
279 }
280
281 commit_1 = MagicMock()
282 commit_1.commit_id = "a" * 64
283 commit_1.message = "First commit"
284
285 commit_2 = MagicMock()
286 commit_2.commit_id = "b" * 64
287 commit_2.message = "Second commit"
288
289 return manifest_1, manifest_2, commit_1, commit_2
290
291 def test_run_json_output_has_correct_structure(
292 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
293 ) -> None:
294 from muse.cli.commands.compare import _run_knowtation_compare
295
296 manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path)
297
298 _run_knowtation_compare(
299 root=tmp_path,
300 commit_a=commit_1,
301 commit_b=commit_2,
302 manifest_a=manifest_1,
303 manifest_b=manifest_2,
304 as_json=True,
305 stat_only=False,
306 )
307
308 captured = capsys.readouterr()
309 out = json.loads(captured.out)
310 assert out["domain"] == "knowtation"
311 assert "stat" in out
312 assert "ops" in out
313 assert out["from"]["commit_id"] == "a" * 64
314
315 def test_added_note_appears_as_insert(
316 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
317 ) -> None:
318 from muse.cli.commands.compare import _run_knowtation_compare
319
320 manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path)
321
322 _run_knowtation_compare(
323 root=tmp_path,
324 commit_a=commit_1,
325 commit_b=commit_2,
326 manifest_a=manifest_1,
327 manifest_b=manifest_2,
328 as_json=True,
329 stat_only=False,
330 )
331
332 out = json.loads(capsys.readouterr().out)
333 insert_ops = [o for o in out["ops"] if o["op"] == "insert"]
334 assert any(o["path"] == "note_c.md" for o in insert_ops)
335
336 def test_modified_note_appears_as_modify(
337 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
338 ) -> None:
339 from muse.cli.commands.compare import _run_knowtation_compare
340
341 manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path)
342
343 _run_knowtation_compare(
344 root=tmp_path,
345 commit_a=commit_1,
346 commit_b=commit_2,
347 manifest_a=manifest_1,
348 manifest_b=manifest_2,
349 as_json=True,
350 stat_only=False,
351 )
352
353 out = json.loads(capsys.readouterr().out)
354 modify_ops = [o for o in out["ops"] if o["op"] == "modify"]
355 assert any(o["path"] == "note_b.md" for o in modify_ops)
356
357 def test_unchanged_note_not_in_ops(
358 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
359 ) -> None:
360 from muse.cli.commands.compare import _run_knowtation_compare
361
362 manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path)
363
364 _run_knowtation_compare(
365 root=tmp_path,
366 commit_a=commit_1,
367 commit_b=commit_2,
368 manifest_a=manifest_1,
369 manifest_b=manifest_2,
370 as_json=True,
371 stat_only=False,
372 )
373
374 out = json.loads(capsys.readouterr().out)
375 assert all(o["path"] != "note_a.md" for o in out["ops"])
376
377 def test_stat_counts_correct(
378 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
379 ) -> None:
380 from muse.cli.commands.compare import _run_knowtation_compare
381
382 manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path)
383
384 _run_knowtation_compare(
385 root=tmp_path,
386 commit_a=commit_1,
387 commit_b=commit_2,
388 manifest_a=manifest_1,
389 manifest_b=manifest_2,
390 as_json=True,
391 stat_only=False,
392 )
393
394 out = json.loads(capsys.readouterr().out)
395 assert out["stat"]["notes_added"] == 1 # note_c added
396 assert out["stat"]["notes_modified"] == 1 # note_b modified
397
398 def test_stat_only_mode_emits_text(
399 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
400 ) -> None:
401 from muse.cli.commands.compare import _run_knowtation_compare
402
403 manifest_1, manifest_2, commit_1, commit_2 = self._setup_vault(tmp_path)
404
405 _run_knowtation_compare(
406 root=tmp_path,
407 commit_a=commit_1,
408 commit_b=commit_2,
409 manifest_a=manifest_1,
410 manifest_b=manifest_2,
411 as_json=False,
412 stat_only=True,
413 )
414
415 out = capsys.readouterr().out
416 assert "Notes added" in out
417 assert "Notes modified" in out
418
419 def test_empty_manifests_no_crash(
420 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
421 ) -> None:
422 from muse.cli.commands.compare import _run_knowtation_compare
423
424 commit = MagicMock()
425 commit.commit_id = "a" * 64
426 commit.message = "empty"
427
428 _run_knowtation_compare(
429 root=tmp_path,
430 commit_a=commit,
431 commit_b=commit,
432 manifest_a={},
433 manifest_b={},
434 as_json=True,
435 stat_only=False,
436 )
437
438 out = json.loads(capsys.readouterr().out)
439 assert out["ops"] == []
440 assert out["stat"]["notes_added"] == 0
441
442 def test_removed_note_appears_as_delete(
443 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
444 ) -> None:
445 from muse.cli.commands.compare import _run_knowtation_compare
446
447 note = _make_note("Gone Note")
448 h = _store_object(tmp_path, note)
449
450 commit_a = MagicMock()
451 commit_a.commit_id = "a" * 64
452 commit_a.message = "before"
453 commit_b = MagicMock()
454 commit_b.commit_id = "b" * 64
455 commit_b.message = "after"
456
457 _run_knowtation_compare(
458 root=tmp_path,
459 commit_a=commit_a,
460 commit_b=commit_b,
461 manifest_a={"gone.md": h},
462 manifest_b={},
463 as_json=True,
464 stat_only=False,
465 )
466
467 out = json.loads(capsys.readouterr().out)
468 delete_ops = [o for o in out["ops"] if o["op"] == "delete"]
469 assert any(o["path"] == "gone.md" for o in delete_ops)
470 assert out["stat"]["notes_removed"] == 1
471
472
473 # ============================================================================
474 # TestSecurity
475 # ============================================================================
476
477
478 class TestSecurity:
479 def test_path_traversal_in_manifest_key(
480 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
481 ) -> None:
482 """Paths with .. in them should not cause os.path traversal issues."""
483 from muse.cli.commands.compare import _run_knowtation_compare
484
485 commit = MagicMock()
486 commit.commit_id = "a" * 64
487 commit.message = "test"
488
489 # Object does not exist for this hash — should not crash
490 _run_knowtation_compare(
491 root=tmp_path,
492 commit_a=commit,
493 commit_b=commit,
494 manifest_a={"../../etc/passwd.md": "a" * 64},
495 manifest_b={"../../etc/passwd.md": "b" * 64},
496 as_json=True,
497 stat_only=False,
498 )
499
500 out_text = capsys.readouterr().out
501 # Should either produce valid JSON or be empty — must not crash
502 if out_text.strip():
503 json.loads(out_text) # must be valid JSON
504
505 def test_malformed_yaml_in_note_does_not_crash(
506 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
507 ) -> None:
508 from muse.cli.commands.compare import _run_knowtation_compare
509
510 malformed = b"---\ntitle: [unclosed\n---\nBody."
511 h1 = _store_object(tmp_path, malformed)
512 fixed = b"---\ntitle: fixed\n---\nBody."
513 h2 = _store_object(tmp_path, fixed)
514
515 commit_a = MagicMock()
516 commit_a.commit_id = "a" * 64
517 commit_a.message = "before"
518 commit_b = MagicMock()
519 commit_b.commit_id = "b" * 64
520 commit_b.message = "after"
521
522 try:
523 _run_knowtation_compare(
524 root=tmp_path,
525 commit_a=commit_a,
526 commit_b=commit_b,
527 manifest_a={"note.md": h1},
528 manifest_b={"note.md": h2},
529 as_json=True,
530 stat_only=False,
531 )
532 except Exception as exc:
533 pytest.fail(f"Should not crash on malformed YAML: {exc}")
534
535 def test_nul_bytes_in_note_content_handled(
536 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture
537 ) -> None:
538 from muse.cli.commands.compare import _run_knowtation_compare
539
540 note_v1 = b"---\ntitle: NUL test\n---\n\x00\x00\x00"
541 note_v2 = b"---\ntitle: NUL test\n---\n\x00\x01\x02"
542 h1 = _store_object(tmp_path, note_v1)
543 h2 = _store_object(tmp_path, note_v2)
544
545 commit_a = MagicMock()
546 commit_a.commit_id = "a" * 64
547 commit_a.message = "a"
548 commit_b = MagicMock()
549 commit_b.commit_id = "b" * 64
550 commit_b.message = "b"
551
552 try:
553 _run_knowtation_compare(
554 root=tmp_path,
555 commit_a=commit_a,
556 commit_b=commit_b,
557 manifest_a={"nul.md": h1},
558 manifest_b={"nul.md": h2},
559 as_json=True,
560 stat_only=False,
561 )
562 except Exception as exc:
563 pytest.fail(f"NUL bytes caused crash: {exc}")
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