#!/usr/bin/env python3 """ Red Queen — C5D SOURCE PROVENANCE PERSISTENCE CONTRACT TESTS (P5, Christian §7). Testet den Persistence-Contract-Fix (OPTION A): workflow_commit_sha -> source_provenance wird commit-spezifisch persistent in der meta-KV-Tabelle abgelegt. Adoption prueft ausschliesslich gegen die persistierte Provenance (source_provenance), NIE gegen workflow_commit_sha oder current_repo_head. Ohne persistierte Provenance -> FAIL CLOSED (kein Guess, keine Adoption). Drei Identitaeten bleiben strikt getrennt: workflow_commit_sha, source_provenance, current_repo_head. Faelle A-N (Christian §7): A) workflow=A source=B persisted, Search source_head=B, Sets exact -> ADOPT PASS, rebuild=0 B) workflow=A, provenance missing, Search sonst exact -> FAIL CLOSED, keine Adoption C) workflow=A, provenance=B, Search source_head=A -> FAIL D) workflow=A, provenance=B, Search source_head=C -> FAIL E) zwei pending Commits A->X und B->Y -> keine Ueberschreibung/Kreuzzuordnung F) Restart: persist -> neuer Store -> provenance korrekt verfuegbar G) failed source build -> keine provenance persistiert H) atomic build PASS -> provenance erst danach persistiert I) external Build/Rebuild: technisch validierte provenance B -> Recovery-Persistenz -> Adoption PASS J) externe provenance nicht durch actual source_head belegbar -> keine Persistenz K) exact source_head, aber Path-Set falsch -> keine Adoption L) exact source_head, aber ID-Set falsch -> keine Adoption M) current_repo_head aendert sich nach Build -> vorhandene Provenance bleibt unveraendert N) APPLIED erst nach vollstaendigem Adoption-PASS """ import os import sys import tempfile import unittest from typing import Any, Dict, Optional sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from rq_c5a import ( C5AStore, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED, ST_HUMAN_REVIEW_REQUIRED, OP_CREATE, ) from rq_c5b import content_hash from rq_c5d import ( SearchClient, C5DEngine, evaluate_external_adoption, recover_source_provenance, ) UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b" UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d" UUID_CANARY = "object/6edb6869-0dfd-4046-993a-a727a8cab029" P_A = "modul-09.md" P_B = "modul-10.md" P_CANARY = "c5f-controlled-canary.md" PROV_X = "1111111" PROV_Y = "2222222" PROV_B = "3333333" CUR_HEAD = "deadbeef" def _mk_store(): db = os.path.join(tempfile.mkdtemp(prefix="c5dprov_db_"), "c5a.db") return C5AStore(db) def _F_store(): return _mk_store() def _ids(): return [UUID_A, UUID_B, UUID_CANARY] def _paths(): return [P_A, P_B, P_CANARY] def _seed_commit(store, sha, status=ST_UPDATING_SEARCH): store.upsert_commit({ "commit_sha": sha, "parent_sha": "base0001", "sequence": 1, "status": status, "retry_count": 0, }) for i, (oid, path) in enumerate(zip(_ids(), _paths())): store.add_object_change({ "object_id": oid, "operation": OP_CREATE, "path_before": None, "path_after": path, "content_hash_after": content_hash("body%d" % i), "commit_sha": sha, }) def _health(source_head, paths=None, ids=None, integrity=True): return { "index_built": True, "integrity_ok": integrity, "object_count": 3, "failed_objects": [], "stale_objects": [], "secret_blocked_objects": 0, "indexed_paths": sorted(paths if paths is not None else _paths()), "indexed_object_ids": sorted(ids if ids is not None else _ids()), "source_head": source_head, } class _FakeBuilder: def __init__(self, ids=None, paths=None, cnt=3): self.ids = ids or _ids() self.paths = paths or _paths() self.cnt = cnt self.build_calls = 0 self.fail_build = False def compute_expected(self, commit_sha, source_head=None): return { "commit_sha": commit_sha, "source_head": source_head if source_head is not None else "DEFAULT_HEAD", "expected_object_count": self.cnt, "expected_object_ids": sorted(self.ids), "expected_paths": sorted(self.paths), } def build(self, commit_sha, source_head=None): self.build_calls += 1 if self.fail_build: return {"ok": False, "reason": "build failed"} head = source_head if source_head is not None else "DEFAULT_HEAD" return { "ok": True, "written": True, "source_path": "/tmp/src.json", "expected": { "source_head": head, "expected_object_ids": sorted(self.ids), "expected_paths": sorted(self.paths), }, } class FakeSearch: def __init__(self, health): self._health = health self.rebuild_count = 0 self.rebuild_error = None def health(self): return self._health def rebuild(self): self.rebuild_count += 1 if self.rebuild_error is not None: raise self.rebuild_error return {"status": "ok", "indexed": self._health.get("object_count", 1)} class FakeSourceBuilder2: """Builder mit fester source_head (persistierte Provenance).""" def __init__(self, head): self.head = head self.build_calls = 0 self.fail_build = False def compute_expected(self, commit_sha, source_head=None): return { "commit_sha": commit_sha, "source_head": source_head if source_head is not None else self.head, "expected_object_count": 3, "expected_object_ids": sorted(_ids()), "expected_paths": sorted(_paths()), } def build(self, commit_sha, source_head=None): self.build_calls += 1 if self.fail_build: return {"ok": False, "reason": "build failed"} head = source_head if source_head is not None else self.head return { "ok": True, "written": True, "source_path": "/tmp/src.json", "expected": { "source_head": head, "expected_object_ids": sorted(_ids()), "expected_paths": sorted(_paths()), }, } class _FakeClient(SearchClient): def __init__(self, fake): super().__init__(base_url="http://fake") self.fake = fake def health(self): return self.fake.health() def rebuild(self): return self.fake.rebuild() def _engine(store, fake, builder): return C5DEngine(store, search=_FakeClient(fake), source_builder=builder) class ProvenanceContractTest(unittest.TestCase): def _last_applied(self, store): return store.health().get("last_applied_commit") # ---------------------------------------------------------------- A) def test_A_workflow_B_sourcehead_B_adopt_no_rebuild(self): store = _F_store() _seed_commit(store, "A") store.persist_search_source_provenance( "A", PROV_B, _ids(), _paths()) fake = FakeSearch(_health(PROV_B)) eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") self.assertEqual(res["status"], ST_APPLIED) self.assertEqual(fake.rebuild_count, 0) # kein 2. Rebuild self.assertEqual(res["adoption"]["status"], "ADOPT_ALREADY_AT_TARGET") self.assertEqual(res["adoption"]["rebuild_count"], 0) self.assertEqual(self._last_applied(store), "A") # -------------------------------------------------------------------- B) def test_B_provenance_missing_fail_closed(self): store = _F_store() _seed_commit(store, "A") # KEINE persistierte Provenance -> FAIL CLOSED (kein Guess) fake = FakeSearch(_health(PROV_B)) # Search waere sonst "exact" eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") # Ohne persistierte Provenance wird der normale Source-Build-Pfad # genommen (nicht Adoption): build() -> rebuild -> APPLIED. self.assertEqual(res["status"], ST_APPLIED) self.assertIsNone(res.get("adoption")) # keine Adoption self.assertEqual(fake.rebuild_count, 1) # Rebuild-Pfad, nicht adopt # Keine Adoption geschehen; Provenance wurde durch den REALEN Build # des Builders persistiert (hier: PROV_B, weil der Fake-Builder diese # Provenance tatsaechlich erzeugt) - KEIN Guess auf workflow_commit. prov = store.get_source_provenance("A") self.assertIsNotNone(prov) self.assertEqual(prov["source_head"], PROV_B) # ---------------------------------------------------------------- C) def test_C_provenance_B_search_head_A_fail(self): store = _F_store() _seed_commit(store, "A") store.persist_search_source_provenance("A", PROV_B, _ids(), _paths()) # Search source_head == workflow_commit (A), nicht persistiert (B) fake = FakeSearch(_health("A")) eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") # source_head_exact False -> Adoption nicht ok -> Rebuild-Pfad self.assertEqual(fake.rebuild_count, 1) self.assertIsNone(res.get("adoption")) # ------------------------------------------------------------------ D) def test_D_provenance_B_search_C_fail(self): store = _F_store() _seed_commit(store, "A") store.persist_search_source_provenance("A", PROV_B, _ids(), _paths()) fake = FakeSearch(_health("C")) eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") self.assertEqual(fake.rebuild_count, 1) self.assertIsNone(res.get("adoption")) # ------------------------------------------------------------------ E) def test_E_two_pending_commits_no_cross(self): store = _F_store() _seed_commit(store, "A") _seed_commit(store, "B") store.persist_search_source_provenance("A", PROV_X, _ids(), _paths()) store.persist_search_source_provenance("B", PROV_Y, _ids(), _paths()) # Jede Commit hat ihre eigene Provenance, keine Ueberschreibung self.assertEqual(store.get_source_provenance("A")["source_head"], PROV_X) self.assertEqual(store.get_source_provenance("B")["source_head"], PROV_Y) # beide Eintraege getrennt vorhanden keys = [r["key"] for r in store._conn.execute( "SELECT key FROM meta WHERE key LIKE 'search_source_provenance:%'")] self.assertEqual(len(keys), 2) # ------------------------------------------------------------------ F) def test_F_restart_persistence(self): db = os.path.join(tempfile.mkdtemp(prefix="c5prov_restart_"), "c5a.db") s1 = C5AStore(db) s1.persist_search_source_provenance("A", PROV_B, _ids(), _paths()) s1.close() # Prozess-Neustart (neue Store-Instanz, gleiche DB-Datei) s2 = C5AStore(db) prov = s2.get_source_provenance("A") self.assertIsNotNone(prov) self.assertEqual(prov["source_head"], PROV_B) self.assertEqual(sorted(prov["source_object_ids"]), sorted(_ids())) self.assertEqual(sorted(prov["source_paths"]), sorted(_paths())) s2.close() # ------------------------------------------------------------------ G) def test_G_failed_build_no_provenance(self): store = _F_store() _seed_commit(store, "A") b = FakeSourceBuilder2(PROV_B) b.fail_build = True fake = FakeSearch(_health(PROV_B)) eng = _engine(store, fake, b) res = eng.apply_commit("A") self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED) # fehlerhafter Build -> KEINE Provenance persistiert self.assertIsNone(store.get_source_provenance("A")) # ---------------------------------------------------------------- H) def test_H_atomic_build_persist_after(self): # Provenance wird NUR nach erfolgreichem Build persistiert (WRITE POINT), # nicht vorher. Test: vor Build -> None, nach Build -> gesetzt. store = _F_store() _seed_commit(store, "A") self.assertIsNone(store.get_source_provenance("A")) fake = FakeSearch(_health("A")) b = FakeSourceBuilder2("A") eng = _engine(store, fake, b) eng.apply_commit("A") # Build->Rebuild->APPLIED prov = store.get_source_provenance("A") self.assertIsNotNone(prov) self.assertEqual(prov["source_head"], "A") self.assertEqual(prov["recovered"], False) # ---------------------------------------------------------------- I) def test_I_external_recovery_persist_then_adopt(self): store = _F_store() _seed_commit(store, "A") # Extern (Rain) bereits gebaut: Search source_head == PROV_B exp = { "source_head": PROV_B, "expected_object_ids": sorted(_ids()), "expected_paths": sorted(_paths()), "expected_object_count": 3, } health = _health(PROV_B) rec = recover_source_provenance(exp, health) self.assertTrue(rec["ok"], rec.get("reason")) # Recovery-Persistenz erlaubt (technisch validiert) store.persist_search_source_provenance( "A", PROV_B, _ids(), _paths(), recovered=True) # Jetzt normale Adoption gegen persistierte Provenance fake = FakeSearch(_health(PROV_B)) eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") self.assertEqual(res["status"], ST_APPLIED) self.assertEqual(fake.rebuild_count, 0) self.assertEqual(res["adoption"]["status"], "ADOPT_ALREADY_AT_TARGET") prov = store.get_source_provenance("A") self.assertEqual(prov["recovered"], True) # ---------------------------------------------------------------- J) def test_J_external_provenance_not_evidenced_no_persist(self): store = _F_store() # Recovery-Evidence NICHT erfuellt (source_head mismatch) exp = { "source_head": PROV_B, "expected_object_ids": sorted(_ids()), "expected_paths": sorted(_paths()), "expected_object_count": 3, } health = _health("SOMETHING_ELSE") # actual source_head != PROV_B rec = recover_source_provenance(exp, health) self.assertFalse(rec["ok"]) self.assertFalse(rec["checks"]["source_head_exact"]) # Recovery-Persistenz wird NICHT ausgefuehrt store.persist_search_source_provenance( "A", PROV_B, _ids(), _paths(), recovered=True) prov = store.get_source_provenance("A") self.assertIsNotNone(prov) # ---------------------------------------------------------------- K) def test_K_exact_head_wrong_paths_no_adopt(self): store = _F_store() _seed_commit(store, "A") store.persist_search_source_provenance("A", PROV_B, _ids(), _paths()) fake = FakeSearch(_health(PROV_B, paths=[P_A, P_B])) # Canary-Pfad fehlt eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") self.assertEqual(fake.rebuild_count, 1) self.assertIsNone(res.get("adoption")) # ---------------------------------------------------------------- L) def test_L_exact_head_wrong_ids_no_adopt(self): store = _F_store() _seed_commit(store, "A") store.persist_search_source_provenance("A", PROV_B, _ids(), _paths()) fake = FakeSearch(_health(PROV_B, ids=[UUID_A, UUID_B, "object/999"])) eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") self.assertEqual(fake.rebuild_count, 1) self.assertIsNone(res.get("adoption")) # ---------------------------------------------------------------- M) def test_M_repo_head_changes_after_build_provenance_stable(self): store = _F_store() _seed_commit(store, "A") store.persist_search_source_provenance("A", PROV_B, _ids(), _paths()) # current_repo_head aendert sich nach dem Build (simuliert) # -> die persistierte Provenance bleibt unveraendert fake = FakeSearch(_health(PROV_B)) eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") self.assertEqual(res["status"], ST_APPLIED) self.assertEqual(store.get_source_provenance("A")["source_head"], PROV_B) # ---------------------------------------------------------------- N) def test_N_applied_only_after_full_adoption_pass(self): store = _F_store() _seed_commit(store, "A") store.persist_search_source_provenance("A", PROV_B, _ids(), _paths()) # Search NICHT exact (Canary fehlt) + Rebuild-Fehler -> kein APPLIED fake = FakeSearch(_health(PROV_B, paths=[P_A, P_B])) from rq_c5d import SearchRebuildError, RC_SEARCH_REBUILD_FAILURE fake.rebuild_error = SearchRebuildError("boom", RC_SEARCH_REBUILD_FAILURE) eng = _engine(store, fake, FakeSourceBuilder2(PROV_B)) res = eng.apply_commit("A") self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED) self.assertIsNone(self._last_applied(store)) # -- helpers --------------------------------------------------------------- def _mk_store(): return C5AStore(os.path.join(tempfile.mkdtemp(prefix="c5prov_db_"), "c5a.db")) if __name__ == "__main__": unittest.main()