"""End-to-end node tests: init, import, provenance, dedup, supersession, persistence, and whole-log tamper detection.""" from __future__ import annotations import re from pathlib import Path import pytest from helpers import blob, evidence_ops, find_log_file, signalled_failure from pmp.errors import PMPError from pmp.node import Node def test_init_creates_verifiable_node(node, node_dir: Path): assert node_dir.is_dir() ops = list(node.log) assert len(ops) >= 1, "initialisation must record at least a genesis operation" assert not signalled_failure(node.verify) def test_import_notes_creates_signed_evidence(node, samples_dir: Path): node.import_path("notes", samples_dir / "notes") ev = evidence_ops(node) assert len(ev) >= 5 # All operations in a single-device node are signed by one author key. authors = {op.author for op in node.log} assert len(authors) == 1 assert not signalled_failure(node.verify) def test_evidence_carries_provenance_back_to_source(node, samples_dir: Path): node.import_path("notes", samples_dir / "notes") ev = evidence_ops(node) matched = [ op for op in ev if "sourdough" in blob(op.body).lower() ] assert matched, "evidence derived from project-sourdough.md must exist" # The operation body must reference where the evidence came from — # the file name appears somewhere in the body (provenance or content). assert any("sourdough" in blob(op.body).lower() for op in matched) def test_reimport_is_deduplicated(node, samples_dir: Path): node.import_path("notes", samples_dir / "notes") before = len(evidence_ops(node)) node.import_path("notes", samples_dir / "notes") after = len(evidence_ops(node)) assert after == before, "re-importing identical sources must not duplicate evidence" assert not signalled_failure(node.verify) def test_changed_source_supersedes_rather_than_duplicates(node, samples_dir: Path, tmp_path: Path): # Work on a private copy of one note so the repo samples stay pristine. work = tmp_path / "notes" work.mkdir() note = work / "journal.md" note.write_text("# Journal\n\nStarted couch-to-5k this week.\n", encoding="utf-8") node.import_path("notes", work) first_count = len(evidence_ops(node)) assert first_count >= 1 # Idempotent re-import. node.import_path("notes", work) assert len(evidence_ops(node)) == first_count # Modify the note: a new evidence operation must be recorded. note.write_text( "# Journal\n\nStarted couch-to-5k this week. Week two: knee held up fine.\n", encoding="utf-8", ) node.import_path("notes", work) second_count = len(evidence_ops(node)) assert second_count == first_count + 1 assert not signalled_failure(node.verify) def test_import_all_three_adapters(node, samples_dir: Path): node.import_path("calendar", samples_dir / "calendar" / "avery-personal.ics") node.import_path("calendar", samples_dir / "calendar" / "avery-work.ics") node.import_path("notes", samples_dir / "notes") node.import_path("photos", samples_dir / "photos" / "avery-photos.json") ev = evidence_ops(node) rendered = blob([op.body for op in ev]).lower() assert "dentist" in rendered or "berlin" in rendered or "sourdough" in rendered assert len(ev) >= 8 assert not signalled_failure(node.verify) def test_node_state_persists_across_reopen(node, node_dir: Path, samples_dir: Path): node.import_path("notes", samples_dir / "notes") expected_ids = [op.op_id for op in node.log] reopened = Node.open(node_dir) actual_ids = [op.op_id for op in reopened.log] assert actual_ids == expected_ids assert not signalled_failure(reopened.verify) def test_tampered_node_log_is_detected(node, node_dir: Path, samples_dir: Path): node.import_path("notes", samples_dir / "notes") log_file = find_log_file(node_dir) text = log_file.read_text(encoding="utf-8") tampered = re.sub("sourdough", "s0urd0ugh", text, count=1, flags=re.IGNORECASE) assert tampered != text, "expected sample note content inside the log file" log_file.write_text(tampered, encoding="utf-8") def attempt(): reopened = Node.open(node_dir) return reopened.verify() assert signalled_failure(attempt) def test_opening_missing_node_fails(tmp_path: Path): with pytest.raises((PMPError, FileNotFoundError, OSError)): Node.open(tmp_path / "does-not-exist")