From 238536710add10d2fab556af9f5e410d6c5534e9 Mon Sep 17 00:00:00 2001 From: Red Queen Date: Wed, 26 Aug 2026 10:03:40 +0000 Subject: [PATCH] fix(tolaria): handle missing vault object in C5C read path --- tolaria/c5-sync-service/rq_c5c.py | 31 ++++++++++- tolaria/c5-sync-service/test_c5c.py | 86 ++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/tolaria/c5-sync-service/rq_c5c.py b/tolaria/c5-sync-service/rq_c5c.py index 91132bd..315d728 100644 --- a/tolaria/c5-sync-service/rq_c5c.py +++ b/tolaria/c5-sync-service/rq_c5c.py @@ -93,6 +93,12 @@ ENV_TOLARIA_BASE = "C5_TOLARIA_BASE" # README statt des Vault-Objekts -> false drift) VAULT_PREFIX = "/app/vault" +# Eindeutige, enge Not-Found-Semantik der Tolaria-Vault-API: +# Ein nicht existierendes Objekt wird als HTTP 400 mit dieser Fehlermeldung +# zurueckgemeldet. NUR dieser exakte Fall darf von read() als None (Objekt +# nicht vorhanden) interpretiert werden — kein pauschales 400-Schlucken. +TOLARIA_NOT_FOUND_MSG = "Invalid or missing path" + # Pre-Write-Drift-Ergebnisse DRIFT_WRITE_ALLOWED = "WRITE_ALLOWED" DRIFT_ALREADY_AT_TARGET = "ALREADY_AT_TARGET" @@ -154,7 +160,17 @@ class TolariaUnavailableError(C5CError): class TolariaWriteError(C5CError): - """Tolaria-Write fehlgeschlagen (nicht retrybar, z.B. Auth/Schema).""" + """Tolaria-Write fehlgeschlagen (nicht retrybar, z.B. Auth/Schema). + + Tragt optional den HTTP-Statuscode der Antwort (http_code), damit + Caller (z.B. read()) den engen 'Objekt existiert nicht'-Fall gezielt + von anderen 400-/Schema-/Auth-Fehlern unterscheiden koennen. + """ + + def __init__(self, message: str, reason_code: Optional[str] = None, + http_code: Optional[int] = None): + super().__init__(message, reason_code) + self.http_code = http_code class UnexpectedDriftError(C5CError): @@ -214,7 +230,8 @@ class TolariaClient: f"Tolaria HTTP {e.code} auf {endpoint}", RC_TOLARIA_UNAVAILABLE) raise TolariaWriteError( f"Tolaria HTTP {e.code} auf {endpoint}: {e.read().decode('utf-8', 'replace')[:200]}", - RC_AUTH_FAILURE if e.code in (401, 403) else RC_INVALID_SCHEMA) + RC_AUTH_FAILURE if e.code in (401, 403) else RC_INVALID_SCHEMA, + http_code=e.code) except (urllib.error.URLError, TimeoutError, OSError) as e: raise TolariaUnavailableError( f"Tolaria nicht erreichbar ({endpoint}): {e}", RC_TOLARIA_UNAVAILABLE) @@ -223,7 +240,15 @@ class TolariaClient: def read(self, vault_path: str) -> Optional[str]: """Read-Back eines Vault-Objekts. Gibt Inhalt oder None (nicht vorhanden).""" - resp = self._post("content", {"path": vault_path}) + try: + resp = self._post("content", {"path": vault_path}) + except TolariaWriteError as e: + # Enger Not-Found-Fall: HTTP 400 + eindeutige 'Invalid or missing + # path'-Semantik -> Objekt existiert nicht -> None (kein Fehler). + # Alle anderen Fehler (andere 400, Auth 401/403, ...) bleiben fail-closed. + if e.http_code == 400 and TOLARIA_NOT_FOUND_MSG in (e.message or ""): + return None + raise if "error" in resp: return None # Invalid or missing path -> Objekt nicht vorhanden return resp.get("content") diff --git a/tolaria/c5-sync-service/test_c5c.py b/tolaria/c5-sync-service/test_c5c.py index cf8a8d5..4694d38 100644 --- a/tolaria/c5-sync-service/test_c5c.py +++ b/tolaria/c5-sync-service/test_c5c.py @@ -34,6 +34,7 @@ import subprocess import sys import tempfile import unittest +from unittest import mock from typing import Any, Dict, List, Optional # C5C importieren (aus demselben Verzeichnis) @@ -57,7 +58,8 @@ from rq_c5c import ( 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, + TolariaUnavailableError, TolariaWriteError, ReadBackMismatchError, + UnexpectedDriftError, assert_no_search_calls, assert_no_master_write, ) @@ -900,5 +902,87 @@ class TestDryRun(unittest.TestCase): self.assertEqual(self.fake.write_count, 0) +# --------------------------------------------------------------------------- +# C5C HOTFIX: Not-Found-Read-Semantik (regression) +# Testet die ECHTE TolariaClient.read() gegen nachgestelltes HTTP-Verhalten. +# Kein Fake-Client, kein Netzwerk: _post wird gemockt. +# --------------------------------------------------------------------------- + +class TestTolariaReadNotFoundSemantic(unittest.TestCase): + """C5C-Hotfix: fehlendes Objekt -> None; alle anderen Fehler fail-closed.""" + + def setUp(self): + self.client = TolariaClient(base_url="http://fake") + + def _post(self, status: int, body: str, reason_code=None): + """Liefert einen Callable, der ein TolariaWriteError/JSON wirft/liefert.""" + def fake_post(endpoint, payload): + if status == 200: + return json.loads(body) + raise TolariaWriteError( + f"Tolaria HTTP {status} auf {endpoint}: {body}", + reason_code or "INVALID_SCHEMA", + http_code=status, + ) + return fake_post + + # A) EXISTING OBJECT -> HTTP 200 -> content zurück + def test_a_existing_returns_content(self): + content = "hello vault" + with mock.patch.object(self.client, "_post", + side_effect=self._post(200, json.dumps({"content": content}))): + self.assertEqual(self.client.read("/app/vault/x.md"), content) + + # B) MISSING OBJECT -> HTTP 400 + "Invalid or missing path" -> None + def test_b_missing_object_returns_none(self): + with mock.patch.object(self.client, "_post", + side_effect=self._post(400, json.dumps({"error": "Invalid or missing path"}))): + self.assertIsNone(self.client.read("/app/vault/c5f-controlled-canary.md")) + + # C) OTHER HTTP 400 -> weiterhin fail-closed (Exception) + def test_c_other_400_fails_closed(self): + with mock.patch.object(self.client, "_post", + side_effect=self._post(400, json.dumps({"error": "Malformed request"}))): + with self.assertRaises(TolariaWriteError): + self.client.read("/app/vault/x.md") + + # D) HTTP 401/403 -> weiterhin Auth-Fehler + def test_d_auth_fails_closed(self): + for status in (401, 403): + with mock.patch.object(self.client, "_post", + side_effect=self._post(status, json.dumps({"error": "unauthorized"}))): + with self.assertRaises(TolariaWriteError): + self.client.read("/app/vault/x.md") + + # E) HTTP 500 -> weiterhin technischer Fehler (retrybar, Unavailable) + def test_e_500_is_unavailable(self): + def fake_500(endpoint, payload): + raise TolariaUnavailableError("Tolaria HTTP 500", "TOLARIA_UNAVAILABLE") + with mock.patch.object(self.client, "_post", side_effect=fake_500): + with self.assertRaises(TolariaUnavailableError): + self.client.read("/app/vault/x.md") + + # F) CREATE pre_write_drift_check: before=None current=None -> WRITE_ALLOWED + def test_f_create_no_target_no_current_write_allowed(self): + # read() liefert None (missing) -> drift check => WRITE_ALLOWED + with mock.patch.object(self.client, "_post", + side_effect=self._post(400, json.dumps({"error": "Invalid or missing path"}))): + drift, cur = pre_write_drift_check( + self.client, "/app/vault/c5f-controlled-canary.md", None, + _fm(UUID_A, state="current") + "body") + self.assertEqual(drift, DRIFT_WRITE_ALLOWED) + self.assertIsNone(cur) + + # G) existierendes unerwartetes Target -> weiterhin Drift/Human Gate + def test_g_unexpected_target_drift(self): + target = _fm(UUID_A, state="current") + "body v2" + # Tolaria CURRENT weder None (missing) noch BEFORE -> UNEXPECTED_DRIFT + with mock.patch.object(self.client, "_post", + side_effect=self._post(200, json.dumps({"content": _fm(UUID_A, state="current") + "DRIFT"}))): + drift, cur = pre_write_drift_check( + self.client, "/app/vault/x.md", _fm(UUID_A, state="current") + "body v1", target) + self.assertEqual(drift, DRIFT_UNEXPECTED) + + if __name__ == "__main__": unittest.main(verbosity=2)