fix(tolaria): handle null success response from vault save

This commit is contained in:
Red Queen 2026-08-26 10:23:14 +00:00
parent 238536710a
commit c5b3db153a
2 changed files with 150 additions and 0 deletions

View file

@ -265,6 +265,11 @@ class TolariaClient:
def write(self, vault_path: str, content: str) -> Dict[str, Any]: def write(self, vault_path: str, content: str) -> Dict[str, Any]:
"""Schreibt ein Vault-Objekt (POST /save). Isolierte Write-Komponente.""" """Schreibt ein Vault-Objekt (POST /save). Isolierte Write-Komponente."""
resp = self._post("save", {"path": vault_path, "content": content}) resp = self._post("save", {"path": vault_path, "content": content})
# HTTP 2xx + JSON null / leerer Payload -> erfolgreicher Transport.
# Nur hier (write /save) wird dieser Fall als Erfolg gewertet; der
# nachgelagerte Read-Back (verify) bleibt zwingend für einen C5C-Step.
if resp is None:
resp = {}
if "error" in resp: if "error" in resp:
raise TolariaWriteError( raise TolariaWriteError(
f"Tolaria save fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA) f"Tolaria save fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA)

View file

@ -984,5 +984,150 @@ class TestTolariaReadNotFoundSemantic(unittest.TestCase):
self.assertEqual(drift, DRIFT_UNEXPECTED) self.assertEqual(drift, DRIFT_UNEXPECTED)
class TestTolariaWriteNullResponseSemantic(unittest.TestCase):
"""C5C-Hotfix: /save HTTP 200 + JSON null -> erfolgreicher Transport;
Read-Back bleibt zwingend; 4xx/5xx weiterhin 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
def _content(self, body="body"):
return _fm(UUID_A, state="current") + body
# A) /save HTTP 200 + JSON null -> write() kein Crash, Save-Step erfolgreich
def test_a_save_200_null_no_crash(self):
def fake_post(endpoint, payload):
self.assertEqual(endpoint, "save")
return None # HTTP 200 + JSON null
with mock.patch.object(self.client, "_post", side_effect=fake_post):
resp = self.client.write("/app/vault/x.md", self._content())
# write() liefert ein Dict ({}), kein Crash
self.assertIsInstance(resp, dict)
# B) /save HTTP 200 + {} -> weiterhin erfolgreich
def test_b_save_200_empty_ok(self):
with mock.patch.object(self.client, "_post", side_effect=lambda ep, pl: {}):
resp = self.client.write("/app/vault/x.md", self._content())
self.assertIsInstance(resp, dict)
# C) /save HTTP 200 + normaler Success-Payload -> erfolgreich
def test_c_save_200_payload_ok(self):
with mock.patch.object(self.client, "_post", side_effect=lambda ep, pl: {"ok": True}):
resp = self.client.write("/app/vault/x.md", self._content())
self.assertEqual(resp, {"ok": True})
# D) /save 400 -> weiterhin FAIL CLOSED
def test_d_save_400_fail_closed(self):
with mock.patch.object(self.client, "_post", side_effect=self._post(400, json.dumps({"error": "Invalid or missing path"}))):
with self.assertRaises(TolariaWriteError):
self.client.write("/app/vault/x.md", self._content())
# E) /save 401/403 -> Auth-Failure (fail-closed)
def test_e_save_401_403_fail_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.write("/app/vault/x.md", self._content())
# F) /save 500 -> technischer Fehler / Retry Contract (Unavailable)
def test_f_save_500_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.write("/app/vault/x.md", self._content())
# G) erfolgreicher Save + Read-Back Match -> C5C darf weiter
def test_g_save_then_readback_match(self):
content = self._content()
calls = {"n": 0}
def fake_post(endpoint, payload):
calls["n"] += 1
if endpoint == "save":
return None # HTTP 200 + JSON null
if endpoint == "content":
return {"content": content} # Read-Back liefert exakt dasselbe
raise AssertionError(f"unerwarteter endpoint {endpoint}")
with mock.patch.object(self.client, "_post", side_effect=fake_post):
self.client.write("/app/vault/x.md", content)
v = self.client.verify("/app/vault/x.md", content)
self.assertTrue(v["ok"])
self.assertEqual(calls["n"], 2) # save + read-back
# H) erfolgreicher Save + Read-Back Mismatch -> FAIL CLOSED / Drift -> KEIN Search-Step
def test_h_save_then_readback_mismatch_fail_closed(self):
content = self._content("TARGET")
drifted = self._content("DRIFTED")
def fake_post(endpoint, payload):
if endpoint == "save":
return None
if endpoint == "content":
return {"content": drifted}
raise AssertionError(f"unerwarteter endpoint {endpoint}")
with mock.patch.object(self.client, "_post", side_effect=fake_post):
self.client.write("/app/vault/x.md", content)
v = self.client.verify("/app/vault/x.md", content)
self.assertFalse(v["ok"]) # Mismatch -> fail-closed, kein falscher Erfolg
self.assertTrue(v["content_hash_match"] is False or v["field_mismatches"])
# I) erfolgreicher Save + Read-Back unavailable -> kein falscher Erfolg
def test_i_save_then_readback_unavailable(self):
content = self._content()
n = {"n": 0}
def fake_post(endpoint, payload):
n["n"] += 1
if endpoint == "save":
return None
raise TolariaUnavailableError("Tolaria down", "TOLARIA_UNAVAILABLE")
with mock.patch.object(self.client, "_post", side_effect=fake_post):
self.client.write("/app/vault/x.md", content)
with self.assertRaises(TolariaUnavailableError):
self.client.verify("/app/vault/x.md", content)
# J) bestehende read()-Not-Found-Regression bleibt PASS
def test_j_read_not_found_regression_kept(self):
# missing -> None (Hotfix 1 unverändert)
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"))
# anderer 400 -> fail-closed
with mock.patch.object(self.client, "_post",
side_effect=self._post(400, json.dumps({"error": "Malformed"}))):
with self.assertRaises(TolariaWriteError):
self.client.read("/app/vault/x.md")
# K) C5A-E-Regression wird separat über die volle Suite in w4 geprüft;
# hier zusätzlich: read() akzeptiert KEIN None-Swallowing (andere Endpoints unverändert)
def test_k_read_other_endpoint_none_still_fails_closed(self):
# read() mit None-Response (nur bei content-Endpoint + Not-Found ist None ok,
# aber ein pauschales None -> muss fail-closed bleiben). content-Endpoint liefert
# bei HTTP-200 normalerweise ein dict; ein None hier ist unerwartet -> read()
# würde sonst `if "error" in resp` crashen. read() darf None nur aus dem
# 400+Not-Found-Fall zurückgeben, NICHT pauschal aus einer None-Response.
def fake_null(endpoint, payload):
return None # unerwartete None-Response auf content
with mock.patch.object(self.client, "_post", side_effect=fake_null):
with self.assertRaises(TypeError):
self.client.read("/app/vault/x.md")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)