diff --git a/tolaria/c5-sync-service/rq_c5c.py b/tolaria/c5-sync-service/rq_c5c.py index 315d728..a5c471e 100644 --- a/tolaria/c5-sync-service/rq_c5c.py +++ b/tolaria/c5-sync-service/rq_c5c.py @@ -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) diff --git a/tolaria/c5-sync-service/test_c5c.py b/tolaria/c5-sync-service/test_c5c.py index 4694d38..7a97ee0 100644 --- a/tolaria/c5-sync-service/test_c5c.py +++ b/tolaria/c5-sync-service/test_c5c.py @@ -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)