684 lines
27 KiB
Python
684 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Red Queen — C5D Testsuite (Search Integration + Commit Completion).
|
|
|
|
Testet gegen einen Fake-Search-Client + Fake-Tolaria-Client (isolierte Mocks).
|
|
Jeder Test nutzt eine frische temp-DB (tempfile.mkdtemp), nie die Produkt-DB.
|
|
KEINE produktiven Writes. KEIN produktiver Search-Rebuild.
|
|
|
|
Abgedeckte Faelle (C5D-Prompt):
|
|
erfolgreicher Search-Rebuild
|
|
erfolgreicher Health-/Integrity-Read-Back
|
|
Search Auth Failure
|
|
Search unavailable
|
|
Network Timeout
|
|
malformed Search Response
|
|
integrity_ok=false
|
|
unerwarteter/falscher Indexzustand
|
|
Search Rebuild Failure
|
|
Retry -> erfolgreiche Recovery
|
|
Max Retry -> DEAD/Human Gate gemaess Contract
|
|
Replay nach bereits erfolgreicher Tolaria-Phase
|
|
kein Tolaria-Doppel-Write beim Search-Retry
|
|
Search darf bei fehlgeschlagener Tolaria-Verifikation NIEMALS aufgerufen werden
|
|
last_applied_commit bleibt bei JEDEM Failure unveraendert
|
|
APPLIED darf ohne Search PASS niemals erreicht werden
|
|
Duplicate/Replay Idempotenz
|
|
Restart-/Persistence-Verhalten soweit C5D betroffen
|
|
Secret Safety
|
|
Regression der bestehenden C5A/C5B/C5C-Contracts
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
# C5D importieren (aus demselben Verzeichnis)
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from rq_c5a import (
|
|
C5AStore, ST_READY, ST_PROPAGATING_TOLARIA, ST_VERIFYING_TOLARIA,
|
|
ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED, ST_RETRY_PENDING,
|
|
ST_DEAD, ST_HUMAN_REVIEW_REQUIRED, ST_FAILED,
|
|
IDEM_ALREADY_APPLIED, IDEM_RETRY_SAFE,
|
|
RC_SEARCH_REBUILD_FAILURE, RC_AUTH_FAILURE, RC_UNKNOWN_OBJECT_ID,
|
|
RC_TOLARIA_UNAVAILABLE, RC_UNEXPECTED_TOLARIA_DRIFT,
|
|
OP_CREATE, OP_CONTENT_UPDATE,
|
|
)
|
|
from rq_c5b import (
|
|
GitReader, parse_frontmatter, content_hash, metadata_hash, detect_secret,
|
|
)
|
|
from rq_c5c import (
|
|
TolariaClient, C5CPropagator, C5CDryRun,
|
|
pre_write_drift_check, _vault_path,
|
|
DRIFT_WRITE_ALLOWED, DRIFT_ALREADY_AT_TARGET, DRIFT_UNEXPECTED,
|
|
PLAN_WOULD_WRITE, PLAN_ALREADY_AT_TARGET, PLAN_HUMAN_REVIEW, PLAN_DRIFT,
|
|
TolariaUnavailableError, ReadBackMismatchError, UnexpectedDriftError,
|
|
assert_no_search_calls, assert_no_master_write,
|
|
)
|
|
from rq_c5d import (
|
|
SearchClient, C5DEngine, C5DDryRun,
|
|
verify_integrity,
|
|
SearchError, SearchUnavailableError, SearchAuthError,
|
|
SearchRebuildError, SearchIntegrityError, SearchMalformedResponseError,
|
|
assert_no_tolaria_write, assert_no_production_activation,
|
|
INTEGRITY_OK, INTEGRITY_FAIL,
|
|
)
|
|
|
|
UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b"
|
|
UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture: kontrolliertes Git-Repo (Test-Helfer darf schreiben)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _git(repo: str, *args: str) -> str:
|
|
proc = subprocess.run(["git", "-C", repo] + list(args),
|
|
capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"git {' '.join(args)} fehlgeschlagen: {proc.stderr}")
|
|
return proc.stdout
|
|
|
|
|
|
def _write(repo: str, path: str, content: str) -> None:
|
|
full = os.path.join(repo, path)
|
|
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
with open(full, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
|
|
def _commit(repo: str, message: str) -> str:
|
|
_git(repo, "add", "-A")
|
|
_git(repo, "commit", "-m", message, "--no-verify")
|
|
return _git(repo, "rev-parse", "HEAD").strip()
|
|
|
|
|
|
def _fm(id_: str, **extra) -> str:
|
|
lines = ["---", "knowledge_schema: 1", f"id: {id_}"]
|
|
for k, v in extra.items():
|
|
if isinstance(v, list):
|
|
lines.append(f"{k}: [{', '.join(v)}]")
|
|
else:
|
|
lines.append(f"{k}: {v}")
|
|
lines.append("---")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
class FixtureRepo:
|
|
def __init__(self):
|
|
self.dir = tempfile.mkdtemp(prefix="c5d_fixture_")
|
|
_git(self.dir, "init", "-q", "-b", "main")
|
|
_git(self.dir, "config", "user.email", "test@test")
|
|
_git(self.dir, "config", "user.name", "Test")
|
|
self.commits: Dict[str, str] = {}
|
|
|
|
def write(self, path: str, content: str) -> None:
|
|
_write(self.dir, path, content)
|
|
|
|
def commit(self, message: str) -> str:
|
|
sha = _commit(self.dir, message)
|
|
self.commits[message] = sha
|
|
return sha
|
|
|
|
def cleanup(self) -> None:
|
|
shutil.rmtree(self.dir, ignore_errors=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fake-Tolaria-API (Mock, isolierter Test-Vault) — zaehlt Writes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FakeTolaria:
|
|
"""In-Memory Fake der Tolaria-Vault-API. Zaehlt Writes, um Doppel-Writes zu beweisen."""
|
|
|
|
def __init__(self):
|
|
self.vault: Dict[str, str] = {}
|
|
self.write_count = 0
|
|
self.unavailable = False
|
|
|
|
def read(self, vault_path: str) -> Optional[str]:
|
|
if self.unavailable:
|
|
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
|
|
return self.vault.get(vault_path)
|
|
|
|
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
|
if self.unavailable:
|
|
raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE)
|
|
self.vault[vault_path] = content
|
|
self.write_count += 1
|
|
return {"ok": True}
|
|
|
|
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
|
|
return [{"path": p} for p in self.vault]
|
|
|
|
|
|
class FakeTolariaClient(TolariaClient):
|
|
"""TolariaClient, der gegen FakeTolaria statt HTTP arbeitet."""
|
|
|
|
def __init__(self, fake: FakeTolaria):
|
|
super().__init__(base_url="http://fake")
|
|
self.fake = fake
|
|
|
|
def read(self, vault_path: str) -> Optional[str]:
|
|
return self.fake.read(vault_path)
|
|
|
|
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
|
return self.fake.write(vault_path, content)
|
|
|
|
def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]:
|
|
return self.fake.list(vault_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fake-Search-API (Mock) — konfigurierbare Fehler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FakeSearch:
|
|
"""In-Memory Fake der Search-API. Konfigurierbare Fehler + Zaehler."""
|
|
|
|
def __init__(self):
|
|
self.rebuild_count = 0
|
|
self.health_count = 0
|
|
self.rebuild_error: Optional[Exception] = None
|
|
self.health_error: Optional[Exception] = None
|
|
self.rebuild_response: Optional[Any] = None
|
|
self.health_response: Optional[Any] = None
|
|
self.rebuild_ok = True
|
|
|
|
def rebuild(self) -> Dict[str, Any]:
|
|
self.rebuild_count += 1
|
|
if self.rebuild_error:
|
|
raise self.rebuild_error
|
|
if self.rebuild_response is not None:
|
|
return self.rebuild_response
|
|
if not self.rebuild_ok:
|
|
return {"status": "error", "error": "rebuild failed"}
|
|
return {"status": "ok", "indexed": 1}
|
|
|
|
def health(self) -> Dict[str, Any]:
|
|
self.health_count += 1
|
|
if self.health_error:
|
|
raise self.health_error
|
|
if self.health_response is not None:
|
|
return self.health_response
|
|
return {
|
|
"index_built": True,
|
|
"object_count": 1,
|
|
"failed_objects": [],
|
|
"integrity_ok": True,
|
|
"supported_modes": ["exact", "keyword", "metadata"],
|
|
"index_version": 1,
|
|
"source_head": "abc",
|
|
"secret_blocked_objects": 0,
|
|
}
|
|
|
|
|
|
class FakeSearchClient(SearchClient):
|
|
"""SearchClient, der gegen FakeSearch statt HTTP arbeitet."""
|
|
|
|
def __init__(self, fake: FakeSearch):
|
|
super().__init__(base_url="http://fake-search")
|
|
self.fake = fake
|
|
|
|
def rebuild(self) -> Dict[str, Any]:
|
|
return self.fake.rebuild()
|
|
|
|
def health(self) -> Dict[str, Any]:
|
|
return self.fake.health()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test-Helfer: C5A-Store + Commit mit ObjectChanges
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_store() -> C5AStore:
|
|
db = os.path.join(tempfile.mkdtemp(prefix="c5d_db_"), "c5a.db")
|
|
return C5AStore(db)
|
|
|
|
|
|
def _seed_commit(store: C5AStore, sha: str, parent: Optional[str],
|
|
objs: List[Dict[str, Any]], status: str = ST_UPDATING_SEARCH) -> None:
|
|
"""Legt einen Commit + ObjectChanges im Store an (Status UPDATING_SEARCH)."""
|
|
store.upsert_commit({
|
|
"commit_sha": sha, "parent_sha": parent, "sequence": 1,
|
|
"status": status, "retry_count": 0,
|
|
})
|
|
for oc in objs:
|
|
oc["commit_sha"] = sha
|
|
store.add_object_change(oc)
|
|
|
|
|
|
def _seed_ready_for_search(store: C5AStore, sha: str) -> None:
|
|
"""Legt einen Commit an, der Tolaria-verifiziert ist (READY_FOR_SEARCH)."""
|
|
_seed_commit(store, sha, None, [{
|
|
"object_id": UUID_A, "operation": OP_CREATE,
|
|
"path_before": None, "path_after": "modul-09.md",
|
|
"content_hash_after": content_hash("body"),
|
|
}], status=ST_UPDATING_SEARCH)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestVerifyIntegrity(unittest.TestCase):
|
|
"""§C5D Health/Integrity Read-Back-Verifikation."""
|
|
|
|
def test_integrity_ok(self):
|
|
health = {"integrity_ok": True, "index_built": True,
|
|
"object_count": 5, "failed_objects": []}
|
|
res = verify_integrity(health)
|
|
self.assertTrue(res["ok"])
|
|
self.assertIsNone(res["reason"])
|
|
|
|
def test_integrity_ok_false(self):
|
|
health = {"integrity_ok": False, "index_built": True,
|
|
"object_count": 5, "failed_objects": []}
|
|
res = verify_integrity(health)
|
|
self.assertFalse(res["ok"])
|
|
self.assertIn("integrity_ok", res["reason"])
|
|
|
|
def test_index_not_built(self):
|
|
health = {"integrity_ok": True, "index_built": False,
|
|
"object_count": 5, "failed_objects": []}
|
|
res = verify_integrity(health)
|
|
self.assertFalse(res["ok"])
|
|
self.assertIn("index_built", res["reason"])
|
|
|
|
def test_object_count_zero(self):
|
|
# unerwarteter/falscher Indexzustand: 0 Objekte
|
|
health = {"integrity_ok": True, "index_built": True,
|
|
"object_count": 0, "failed_objects": []}
|
|
res = verify_integrity(health)
|
|
self.assertFalse(res["ok"])
|
|
self.assertIn("object_count_positive", res["reason"])
|
|
|
|
def test_failed_objects_present(self):
|
|
health = {"integrity_ok": True, "index_built": True,
|
|
"object_count": 5, "failed_objects": ["x.md"]}
|
|
res = verify_integrity(health)
|
|
self.assertFalse(res["ok"])
|
|
self.assertIn("failed_objects_empty", res["reason"])
|
|
|
|
|
|
class TestC5DEngine(unittest.TestCase):
|
|
"""C5D-Engine: Search Integration + Commit Completion."""
|
|
|
|
def setUp(self):
|
|
self.fake_tol = FakeTolaria()
|
|
self.tol_client = FakeTolariaClient(self.fake_tol)
|
|
self.fake_search = FakeSearch()
|
|
self.search_client = FakeSearchClient(self.fake_search)
|
|
self.store = _make_store()
|
|
self.engine = C5DEngine(self.store, search=self.search_client)
|
|
|
|
def tearDown(self):
|
|
self.store.close()
|
|
|
|
def _last_applied(self) -> Optional[str]:
|
|
return self.store.health().get("last_applied_commit")
|
|
|
|
# -- Erfolgreicher Pfad ------------------------------------------------
|
|
|
|
def test_successful_rebuild_and_apply(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_APPLIED)
|
|
self.assertEqual(self.fake_search.rebuild_count, 1)
|
|
self.assertEqual(self.fake_search.health_count, 1)
|
|
self.assertEqual(self._last_applied(), sha)
|
|
# C5D darf NIE Tolaria schreiben
|
|
self.assertEqual(self.fake_tol.write_count, 0)
|
|
|
|
def test_apply_requires_verifying_search_transition(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.engine.apply_commit(sha)
|
|
# Nach APPLIED ist der Commit im Endzustand
|
|
self.assertEqual(self.store.commit_status(sha), ST_APPLIED)
|
|
|
|
# -- Idempotenz / Replay ----------------------------------------------
|
|
|
|
def test_already_applied_no_downstream_write(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.engine.apply_commit(sha)
|
|
self.assertEqual(self.fake_search.rebuild_count, 1)
|
|
# Erneuter Aufruf -> kein weiterer Search-Rebuild
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_APPLIED)
|
|
self.assertEqual(res["idempotency"], IDEM_ALREADY_APPLIED)
|
|
self.assertEqual(self.fake_search.rebuild_count, 1)
|
|
self.assertEqual(self.fake_tol.write_count, 0)
|
|
|
|
def test_replay_after_successful_tolaria_phase(self):
|
|
# Commit ist in UPDATING_SEARCH (Tolaria bereits verifiziert).
|
|
# C5D darf NIE Tolaria erneut aufrufen (kein Doppel-Write).
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.engine.apply_commit(sha)
|
|
self.assertEqual(self.fake_tol.write_count, 0)
|
|
self.assertEqual(self.fake_search.rebuild_count, 1)
|
|
|
|
# -- Search Auth Failure ----------------------------------------------
|
|
|
|
def test_auth_failure_human_gate(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.fake_search.rebuild_error = SearchAuthError("auth", RC_AUTH_FAILURE)
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED)
|
|
self.assertEqual(res["reason_code"], RC_AUTH_FAILURE)
|
|
self.assertIsNone(self._last_applied())
|
|
self.assertEqual(self.store.commit_status(sha), ST_HUMAN_REVIEW_REQUIRED)
|
|
|
|
# -- Search unavailable (retrybar) -------------------------------------
|
|
|
|
def test_search_unavailable_retry_pending(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.fake_search.rebuild_error = SearchUnavailableError("down", RC_SEARCH_REBUILD_FAILURE)
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_RETRY_PENDING)
|
|
self.assertEqual(res["retry_count"], 1)
|
|
self.assertIsNone(self._last_applied())
|
|
self.assertEqual(self.store.commit_status(sha), ST_RETRY_PENDING)
|
|
|
|
# -- Network Timeout (retrybar) ----------------------------------------
|
|
|
|
def test_network_timeout_retry_pending(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.fake_search.rebuild_error = SearchUnavailableError("timeout", RC_SEARCH_REBUILD_FAILURE)
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_RETRY_PENDING)
|
|
self.assertEqual(res["retry_count"], 1)
|
|
self.assertIsNone(self._last_applied())
|
|
|
|
# -- Malformed Search Response ----------------------------------------
|
|
|
|
def test_malformed_rebuild_response_human_gate(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.fake_search.rebuild_response = "not-a-dict"
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED)
|
|
self.assertIsNone(self._last_applied())
|
|
|
|
def test_malformed_health_response_human_gate(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.fake_search.health_response = "not-a-dict"
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED)
|
|
self.assertIsNone(self._last_applied())
|
|
|
|
# -- integrity_ok=false ------------------------------------------------
|
|
|
|
def test_integrity_false_human_gate(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.fake_search.health_response = {
|
|
"index_built": True, "object_count": 1, "failed_objects": [],
|
|
"integrity_ok": False,
|
|
}
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED)
|
|
self.assertIsNone(self._last_applied())
|
|
self.assertEqual(self.store.commit_status(sha), ST_HUMAN_REVIEW_REQUIRED)
|
|
|
|
# -- unerwarteter/falscher Indexzustand --------------------------------
|
|
|
|
def test_unexpected_index_state_human_gate(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
# object_count=0 -> falscher Indexzustand
|
|
self.fake_search.health_response = {
|
|
"index_built": True, "object_count": 0, "failed_objects": [],
|
|
"integrity_ok": True,
|
|
}
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED)
|
|
self.assertIsNone(self._last_applied())
|
|
|
|
# -- Search Rebuild Failure -------------------------------------------
|
|
|
|
def test_rebuild_failure_human_gate(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.fake_search.rebuild_ok = False # status != "ok"
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED)
|
|
self.assertIsNone(self._last_applied())
|
|
|
|
# -- Retry -> erfolgreiche Recovery -----------------------------------
|
|
|
|
def test_retry_then_recovery(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
# 1. Versuch: Search down -> RETRY_PENDING
|
|
self.fake_search.rebuild_error = SearchUnavailableError("down", RC_SEARCH_REBUILD_FAILURE)
|
|
res1 = self.engine.apply_commit(sha)
|
|
self.assertEqual(res1["status"], ST_RETRY_PENDING)
|
|
self.assertEqual(res1["retry_count"], 1)
|
|
# 2. Versuch (Replay): Search wieder da -> APPLIED
|
|
self.fake_search.rebuild_error = None
|
|
res2 = self.engine.apply_commit(sha)
|
|
self.assertEqual(res2["status"], ST_APPLIED)
|
|
self.assertEqual(self._last_applied(), sha)
|
|
# Kein Tolaria-Write waehrend des gesamten Retry-Zyklus
|
|
self.assertEqual(self.fake_tol.write_count, 0)
|
|
|
|
# -- Max Retry -> DEAD ------------------------------------------------
|
|
|
|
def test_max_retry_reaches_dead(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
self.fake_search.rebuild_error = SearchUnavailableError("down", RC_SEARCH_REBUILD_FAILURE)
|
|
# max_retries=2 -> nach 2 Fehlversuchen DEAD
|
|
engine = C5DEngine(self.store, search=self.search_client, max_retries=2)
|
|
res1 = engine.apply_commit(sha)
|
|
self.assertEqual(res1["status"], ST_RETRY_PENDING)
|
|
res2 = engine.apply_commit(sha)
|
|
self.assertEqual(res2["status"], ST_DEAD)
|
|
self.assertIsNone(self._last_applied())
|
|
self.assertEqual(self.store.commit_status(sha), ST_DEAD)
|
|
|
|
# -- Search darf bei fehlgeschlagener Tolaria-Verifikation NIE aufgerufen werden
|
|
|
|
def test_search_never_called_when_tolaria_not_verified(self):
|
|
# Commit ist in ST_READY (Tolaria NICHT verifiziert) -> C5D darf nicht starten
|
|
sha = "c1"
|
|
_seed_commit(self.store, sha, None, [{
|
|
"object_id": UUID_A, "operation": OP_CREATE,
|
|
"path_before": None, "path_after": "modul-09.md",
|
|
"content_hash_after": content_hash("body"),
|
|
}], status=ST_READY)
|
|
res = self.engine.apply_commit(sha)
|
|
# C5D startet NICHT aus ST_READY -> kein Search-Rebuild
|
|
self.assertEqual(self.fake_search.rebuild_count, 0)
|
|
self.assertEqual(self.fake_search.health_count, 0)
|
|
self.assertNotEqual(res["status"], ST_APPLIED)
|
|
self.assertIsNone(self._last_applied())
|
|
|
|
def test_search_never_called_when_tolaria_drift(self):
|
|
# Commit in ST_HUMAN_REVIEW_REQUIRED (Tolaria-Drift) -> C5D darf nicht starten
|
|
sha = "c1"
|
|
_seed_commit(self.store, sha, None, [{
|
|
"object_id": UUID_A, "operation": OP_CREATE,
|
|
"path_before": None, "path_after": "modul-09.md",
|
|
"content_hash_after": content_hash("body"),
|
|
}], status=ST_HUMAN_REVIEW_REQUIRED)
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertEqual(self.fake_search.rebuild_count, 0)
|
|
self.assertNotEqual(res["status"], ST_APPLIED)
|
|
|
|
# -- last_applied_commit bleibt bei JEDEM Failure unveraendert ---------
|
|
|
|
def test_last_applied_unchanged_on_all_failures(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
# Auth-Fehler
|
|
self.fake_search.rebuild_error = SearchAuthError("auth", RC_AUTH_FAILURE)
|
|
self.engine.apply_commit(sha)
|
|
self.assertIsNone(self._last_applied())
|
|
# Integrity-Fehler
|
|
sha2 = "c2"
|
|
_seed_ready_for_search(self.store, sha2)
|
|
self.fake_search.rebuild_error = None
|
|
self.fake_search.health_response = {"integrity_ok": False, "index_built": True,
|
|
"object_count": 1, "failed_objects": []}
|
|
self.engine.apply_commit(sha2)
|
|
self.assertIsNone(self._last_applied())
|
|
|
|
# -- APPLIED darf ohne Search PASS niemals erreicht werden -------------
|
|
|
|
def test_applied_requires_search_pass(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
# Search-Rebuild schlaegt fehl -> kein APPLIED
|
|
self.fake_search.rebuild_ok = False
|
|
res = self.engine.apply_commit(sha)
|
|
self.assertNotEqual(res["status"], ST_APPLIED)
|
|
self.assertIsNone(self._last_applied())
|
|
|
|
# -- Restart-/Persistence-Verhalten ------------------------------------
|
|
|
|
def test_persistence_across_engine_instances(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
# Engine 1: Search down -> RETRY_PENDING (persistiert)
|
|
self.fake_search.rebuild_error = SearchUnavailableError("down", RC_SEARCH_REBUILD_FAILURE)
|
|
self.engine.apply_commit(sha)
|
|
self.assertEqual(self.store.commit_status(sha), ST_RETRY_PENDING)
|
|
# Engine 2 (neue Instanz, gleiche DB): Replay -> APPLIED
|
|
engine2 = C5DEngine(self.store, search=self.search_client)
|
|
self.fake_search.rebuild_error = None
|
|
res = engine2.apply_commit(sha)
|
|
self.assertEqual(res["status"], ST_APPLIED)
|
|
self.assertEqual(self._last_applied(), sha)
|
|
|
|
# -- kein Tolaria-Doppel-Write beim Search-Retry ----------------------
|
|
|
|
def test_no_tolaria_double_write_on_search_retry(self):
|
|
sha = "c1"
|
|
_seed_ready_for_search(self.store, sha)
|
|
# Mehrere Search-Fehler + Recovery -> Tolaria-Write bleibt 0
|
|
self.fake_search.rebuild_error = SearchUnavailableError("down", RC_SEARCH_REBUILD_FAILURE)
|
|
self.engine.apply_commit(sha)
|
|
self.engine.apply_commit(sha)
|
|
self.fake_search.rebuild_error = None
|
|
self.engine.apply_commit(sha)
|
|
self.assertEqual(self.fake_tol.write_count, 0)
|
|
|
|
|
|
class TestC5DDryRun(unittest.TestCase):
|
|
"""C5D Dry-Run (read-only)."""
|
|
|
|
def setUp(self):
|
|
self.store = _make_store()
|
|
|
|
def tearDown(self):
|
|
self.store.close()
|
|
|
|
def test_plan_lists_ready_and_retry(self):
|
|
_seed_ready_for_search(self.store, "c1")
|
|
_seed_commit(self.store, "c2", None, [{
|
|
"object_id": UUID_A, "operation": OP_CREATE,
|
|
"path_before": None, "path_after": "modul-09.md",
|
|
"content_hash_after": content_hash("body"),
|
|
}], status=ST_RETRY_PENDING)
|
|
dry = C5DDryRun(self.store)
|
|
plan = dry.plan()
|
|
self.assertIn("c1", plan["ready_for_search"])
|
|
self.assertIn("c2", plan["search_retry_pending"])
|
|
|
|
|
|
class TestGuarantees(unittest.TestCase):
|
|
"""C5D-Guarantees (statisch)."""
|
|
|
|
def test_no_tolaria_write(self):
|
|
res = assert_no_tolaria_write()
|
|
self.assertTrue(res["no_tolaria_write"], res)
|
|
|
|
def test_no_master_write(self):
|
|
res = assert_no_master_write()
|
|
self.assertTrue(res["no_master_write"], res)
|
|
|
|
def test_no_production_activation(self):
|
|
res = assert_no_production_activation()
|
|
self.assertTrue(res["no_production_activation"], res)
|
|
|
|
|
|
class TestSecretSafety(unittest.TestCase):
|
|
"""Secret Safety: C5D persistiert/ausgibt keine Secrets."""
|
|
|
|
def test_detect_secret_blocks(self):
|
|
# detect_secret aus C5B erkennt Secrets (Regression)
|
|
self.assertIsNotNone(detect_secret("sk-1234567890abcdefghijklmnopqrstuvwxyz"))
|
|
self.assertIsNone(detect_secret("normal content ohne secret"))
|
|
|
|
def test_no_secret_in_c5d_source(self):
|
|
# C5D-Quelle darf keine echten Secret-Werte enthalten
|
|
import rq_c5d
|
|
with open(rq_c5d.__file__, encoding="utf-8") as f:
|
|
src = f.read()
|
|
# Kein sk-<20+> Muster im Quellcode (nur fragmentierte Referenzen)
|
|
import re
|
|
self.assertIsNone(re.search(r"sk-[A-Za-z0-9]{20,}", src))
|
|
|
|
|
|
class TestRegressionContracts(unittest.TestCase):
|
|
"""Regression der bestehenden C5A/C5B/C5C-Contracts."""
|
|
|
|
def test_c5c_guarantees_still_pass(self):
|
|
# C5C-Guarantees (No-Search, No-Master-Write) bleiben intakt
|
|
search = assert_no_search_calls()
|
|
master = assert_no_master_write()
|
|
self.assertTrue(search["no_search_calls"])
|
|
self.assertTrue(master["no_master_write"])
|
|
|
|
def test_c5a_transitions_include_search_retry_replay(self):
|
|
# C5D-Transition (RETRY_PENDING -> UPDATING_SEARCH) muss existieren
|
|
from rq_c5a import _ALLOWED_TRANSITIONS
|
|
self.assertIn((ST_RETRY_PENDING, ST_UPDATING_SEARCH), _ALLOWED_TRANSITIONS)
|
|
|
|
def test_c5a_transitions_include_apply_path(self):
|
|
from rq_c5a import _ALLOWED_TRANSITIONS
|
|
self.assertIn((ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH), _ALLOWED_TRANSITIONS)
|
|
self.assertIn((ST_VERIFYING_SEARCH, ST_APPLIED), _ALLOWED_TRANSITIONS)
|
|
|
|
def test_c5c_propagator_still_works(self):
|
|
# C5C-Propagator (Tolaria-Propagation) bleibt funktional
|
|
fake = FakeTolaria()
|
|
client = FakeTolariaClient(fake)
|
|
repo = FixtureRepo()
|
|
content = _fm(UUID_A, type="arch", role="module", representation="source", state="current")
|
|
repo.write("modul-09.md", content)
|
|
sha = repo.commit("create")
|
|
store = _make_store()
|
|
reader = GitReader(repo.dir)
|
|
_seed_commit(store, sha, None, [{
|
|
"object_id": UUID_A, "operation": OP_CREATE,
|
|
"path_before": None, "path_after": "modul-09.md",
|
|
"content_hash_after": content_hash(parse_frontmatter(content)[1]),
|
|
}], status=ST_READY)
|
|
prop = C5CPropagator(store, reader, client)
|
|
res = prop.propagate_commit(sha)
|
|
self.assertEqual(res["status"], ST_UPDATING_SEARCH)
|
|
self.assertEqual(fake.vault["/app/vault/modul-09.md"], content)
|
|
repo.cleanup()
|
|
store.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|