fix(tolaria): handle null success response from vault save
This commit is contained in:
parent
238536710a
commit
c5b3db153a
2 changed files with 150 additions and 0 deletions
|
|
@ -265,6 +265,11 @@ class TolariaClient:
|
|||
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
||||
"""Schreibt ein Vault-Objekt (POST /save). Isolierte Write-Komponente."""
|
||||
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:
|
||||
raise TolariaWriteError(
|
||||
f"Tolaria save fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA)
|
||||
|
|
|
|||
|
|
@ -984,5 +984,150 @@ class TestTolariaReadNotFoundSemantic(unittest.TestCase):
|
|||
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__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
|
|||
Loading…
Reference in a new issue