#!/usr/bin/env python3 """ Red Queen — AUTH.3A C5 Caller Auth Integration Testsuite. Testet die clientseitige Auth-Unterstuetzung der legitimen C5-Tolaria-Writer gegen ein Fake/Mock-Tolaria (KEINE Produktion, KEINE echten Tokens). Abgedeckte Faelle (AUTH.3A PHASE 9): SAVE CALLER (TolariaClient.write -> /save, SAVE-Scope) T1 SAVE mit SAVE token -> Authorization korrekt T2 SAVE ohne token -> lokal DENIED -> HTTP niemals aufgerufen T3 SAVE leeres token -> lokal DENIED T4 READ ohne SAVE token -> funktioniert T5 SAVE token wird bei READ nicht gesendet DELETE CALLER (TolariaClient.delete -> /delete, DELETE-Scope) T6 DELETE mit Approval + DELETE token -> Request erlaubt T7 DELETE mit Approval aber ohne token -> lokal DENIED T8 DELETE mit token aber ohne Approval -> DENIED T9 DELETE ohne Approval und ohne token -> DENIED T10 DELETE token nicht bei SAVE nutzbar T11 SAVE token nicht bei DELETE nutzbar LOGGING T12 Token nicht in Logs T13 Token nicht in Exceptions T14 Token nicht in Evidence RETRY/REPLAY T15 Retry behaelt korrekten Scope T16 Replay erzeugt keinen unauthentifizierten Write T17 fehlendes Credential bei Recovery -> fail-closed AUTH.3A PHASE 10 (Wrong-Scope Adversarial): T18 SAVE credential beim DeleteExecutor -> scheitert T19 DELETE credential beim Save-Caller -> scheitert Nur synthetische Test-Tokens. KEINE echten Secrets. """ from __future__ import annotations import json import os import sys import tempfile import unittest from unittest import mock from typing import Any, Dict, List, Optional # C5-Module importieren (aus demselben Verzeichnis) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from rq_c5c import ( TolariaClient, TolariaWriteError, RC_AUTH_FAILURE, ENV_TOLARIA_SAVE_TOKEN, ENV_TOLARIA_DELETE_TOKEN, ) from rq_c5_delete import DeleteExecutor, DeleteApprovalError, DeleteExecutionError from rq_c5a import ( C5AStore, ST_DELETE_APPROVED, ST_UPDATING_SEARCH, OP_DELETE_REQUEST, ) # Synthetische Test-Tokens (AUTH.1 §6: nur synthetische Werte, keine echten) SAVE_TOKEN = "auth3a-save-synthetic-token-0001" DELETE_TOKEN = "auth3a-delete-synthetic-token-0002" class FakeTolaria: """In-memory Fake-Tolaria (kein Netzwerk, keine Produktion).""" def __init__(self) -> None: self.objects: Dict[str, str] = {} self.calls: List[Dict[str, Any]] = [] def read(self, vault_path: str) -> Optional[str]: self.calls.append({"op": "read", "path": vault_path}) return self.objects.get(vault_path) def write(self, vault_path: str, content: str) -> Dict[str, Any]: self.calls.append({"op": "write", "path": vault_path}) self.objects[vault_path] = content return {} def delete(self, vault_path: str) -> Dict[str, Any]: self.calls.append({"op": "delete", "path": vault_path}) self.objects.pop(vault_path, None) return {} def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: self.calls.append({"op": "list", "path": vault_path}) return [{"path": p} for p in self.objects] class FakeTolariaClient(TolariaClient): """TolariaClient, der gegen FakeTolaria statt HTTP arbeitet. Ueberschreibt write()/delete()/read()/list() -> _require_token wird in den ueberschriebenen Methoden NICHT aufgerufen. Fuer die Auth-Tests (T1-T17) wird stattdessen der ECHTE TolariaClient mit gemocktem _post verwendet, um den Authorization-Header zu verifizieren. """ def __init__(self, fake: FakeTolaria, **kwargs: Any): super().__init__(base_url="http://fake", **kwargs) 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 delete(self, vault_path: str) -> Dict[str, Any]: return self.fake.delete(vault_path) def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: return self.fake.list(vault_path) # --------------------------------------------------------------------------- # Test-Helfer: Echter TolariaClient mit gemocktem _post (Auth-Header pruefen) # --------------------------------------------------------------------------- def _capture_post(client: TolariaClient) -> List[Dict[str, Any]]: """Mockt urllib.request.urlopen global und zeichnet (endpoint, auth_header) auf. Testet den ECHTEN _post-Pfad inkl. Header-Bau, damit Mutationen am Authorization-Header (z. B. Header entfernt) erkannt werden. Der Mock bleibt aktiv, bis die Tests die Aufrufe gemacht haben. """ captured: List[Dict[str, Any]] = [] class FakeResp: def __init__(self, body: bytes = b"{}"): self._body = body def read(self) -> bytes: return self._body def __enter__(self): return self def __exit__(self, *a): return False def fake_urlopen(req: Any, timeout: float = 15.0) -> FakeResp: url = req.full_url endpoint = url.rsplit("/", 1)[-1] headers = dict(req.headers) captured.append({ "endpoint": endpoint, "auth_header": headers.get("Authorization"), }) return FakeResp() patcher = mock.patch("urllib.request.urlopen", side_effect=fake_urlopen) patcher.start() # Mock bleibt aktiv; Tests rufen danach client.write()/delete()/read() auf. return captured def _make_store() -> C5AStore: db = os.path.join(tempfile.mkdtemp(prefix="auth3a_db_"), "c5a.db") return C5AStore(db) def _seed_delete_approved(store: C5AStore, sha: str, oid: str, path: str) -> int: """Legt einen DELETE_APPROVED-Commit + Approval an (fuer T6-T9). Gibt die echte object_change_id (DB-Zeilen-ID) zurueck. """ store.upsert_commit({ "commit_sha": sha, "parent_sha": None, "sequence": 1, "status": ST_DELETE_APPROVED, "retry_count": 0, }) change = store.add_object_change({ "commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST, "path_before": path, "path_after": None, }) assert change is not None change_id = int(change["id"]) store.create_delete_approval( sha, change_id, oid, path, "human:christian", approval_id="appr-1", ) return change_id # --------------------------------------------------------------------------- # SAVE CALLER Tests (T1-T5) # --------------------------------------------------------------------------- class TestSaveCallerAuth(unittest.TestCase): """AUTH.3A PHASE 9: SAVE-Caller (TolariaClient.write -> /save).""" def test_t1_save_with_token_sends_authorization(self): """T1: SAVE mit SAVE token -> Authorization korrekt.""" client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) captured = _capture_post(client) client.write("/app/vault/x.md", "content") self.assertEqual(len(captured), 1) self.assertEqual(captured[0]["endpoint"], "save") self.assertEqual(captured[0]["auth_header"], f"Bearer {SAVE_TOKEN}") def test_t2_save_without_token_fails_closed_no_http(self): """T2: SAVE ohne token -> lokal DENIED -> HTTP niemals aufgerufen.""" client = TolariaClient(base_url="http://fake") # kein save_token captured = _capture_post(client) with self.assertRaises(TolariaWriteError) as ctx: client.write("/app/vault/x.md", "content") self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) # HTTP (via urlopen) wurde NICHT aufgerufen self.assertEqual(len(captured), 0) def test_t3_save_empty_token_fails_closed(self): """T3: SAVE leeres token -> lokal DENIED.""" client = TolariaClient(base_url="http://fake", save_token="") captured = _capture_post(client) with self.assertRaises(TolariaWriteError) as ctx: client.write("/app/vault/x.md", "content") self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) self.assertEqual(len(captured), 0) def test_t4_read_without_save_token_works(self): """T4: READ ohne SAVE token -> funktioniert.""" client = TolariaClient(base_url="http://fake") # kein save_token captured = _capture_post(client) # read() -> _post("content", ...) ohne auth_token client.read("/app/vault/x.md") self.assertEqual(len(captured), 1) self.assertEqual(captured[0]["endpoint"], "content") self.assertIsNone(captured[0]["auth_header"]) def test_t5_save_token_not_sent_on_read(self): """T5: SAVE token wird bei READ nicht gesendet (Least Privilege).""" client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) captured = _capture_post(client) client.read("/app/vault/x.md") client.list("/app/vault") # Weder content noch list senden ein Credential for c in captured: self.assertIsNone(c["auth_header"]) # --------------------------------------------------------------------------- # DELETE CALLER Tests (T6-T11) # --------------------------------------------------------------------------- class TestDeleteCallerAuth(unittest.TestCase): """AUTH.3A PHASE 9: DELETE-Caller (TolariaClient.delete -> /delete).""" def test_t6_delete_with_approval_and_token_allowed(self): """T6: DELETE mit Approval + DELETE token -> Request erlaubt. Testet die Client-Ebene: delete() mit delete_token sendet den Authorization-Header. (Die Approval-Gates des Executors werden in T8/T9 separat getestet; hier geht es um die Credential-Integration.) """ client = TolariaClient(base_url="http://fake", delete_token=DELETE_TOKEN) captured = _capture_post(client) result = client.delete("/app/vault/notes/x.md") self.assertEqual(len(captured), 1) self.assertEqual(captured[0]["endpoint"], "delete") self.assertEqual(captured[0]["auth_header"], f"Bearer {DELETE_TOKEN}") def test_t7_delete_with_approval_but_no_token_fails_closed(self): """T7: DELETE mit Approval aber ohne token -> lokal DENIED.""" client = TolariaClient(base_url="http://fake") # kein delete_token captured = _capture_post(client) with self.assertRaises(TolariaWriteError) as ctx: client.delete("/app/vault/notes/x.md") self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) self.assertEqual(len(captured), 0) def test_t8_delete_with_token_but_no_approval_denied(self): """T8: DELETE mit token aber ohne Approval -> DENIED (Human Gate).""" store = _make_store() # Kein Approval angelegt -> DeleteExecutor.execute() muss fail-closed store.upsert_commit({ "commit_sha": "sha2", "parent_sha": None, "sequence": 1, "status": ST_DELETE_APPROVED, "retry_count": 0, }) store.add_object_change({ "commit_sha": "sha2", "id": "chg-2", "object_id": "object/2", "operation": OP_DELETE_REQUEST, "path_before": "notes/y.md", "path_after": None, }) client = TolariaClient(base_url="http://fake", delete_token=DELETE_TOKEN) executor = DeleteExecutor(store, client) with self.assertRaises(DeleteApprovalError): executor.execute("sha2") store.close() def test_t9_delete_no_approval_no_token_denied(self): """T9: DELETE ohne Approval und ohne token -> DENIED.""" store = _make_store() store.upsert_commit({ "commit_sha": "sha3", "parent_sha": None, "sequence": 1, "status": ST_DELETE_APPROVED, "retry_count": 0, }) store.add_object_change({ "commit_sha": "sha3", "id": "chg-3", "object_id": "object/3", "operation": OP_DELETE_REQUEST, "path_before": "notes/z.md", "path_after": None, }) client = TolariaClient(base_url="http://fake") # kein delete_token executor = DeleteExecutor(store, client) with self.assertRaises(DeleteApprovalError): executor.execute("sha3") store.close() def test_t10_delete_token_not_usable_for_save(self): """T10: DELETE token nicht bei SAVE nutzbar (Scope-Separation).""" # Client mit NUR delete_token -> write() (SAVE) muss fail-closed client = TolariaClient(base_url="http://fake", delete_token=DELETE_TOKEN) captured = _capture_post(client) with self.assertRaises(TolariaWriteError) as ctx: client.write("/app/vault/x.md", "content") self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) self.assertEqual(len(captured), 0) def test_t11_save_token_not_usable_for_delete(self): """T11: SAVE token nicht bei DELETE nutzbar (Scope-Separation).""" # Client mit NUR save_token -> delete() (DELETE) muss fail-closed client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) captured = _capture_post(client) with self.assertRaises(TolariaWriteError) as ctx: client.delete("/app/vault/x.md") self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) self.assertEqual(len(captured), 0) # --------------------------------------------------------------------------- # LOGGING Tests (T12-T14) # --------------------------------------------------------------------------- class TestLoggingRedaction(unittest.TestCase): """AUTH.3A PHASE 8/9: Token nicht in Logs/Exceptions/Evidence.""" def test_t12_token_not_in_logs(self): """T12: Token nicht in Logs (kein repr/str enthaelt den Wert).""" client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN, delete_token=DELETE_TOKEN) # repr/str des Clients darf keinen Token-Wert enthalten self.assertNotIn(SAVE_TOKEN, repr(client)) self.assertNotIn(DELETE_TOKEN, repr(client)) self.assertNotIn(SAVE_TOKEN, str(client)) self.assertNotIn(DELETE_TOKEN, str(client)) def test_t13_token_not_in_exceptions(self): """T13: Token nicht in Exceptions (fail-closed Fehler).""" client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) # Fehler bei fehlendem Credential (delete ohne delete_token) try: client.delete("/app/vault/x.md") self.fail("sollte fail-closed werfen") except TolariaWriteError as e: self.assertNotIn(DELETE_TOKEN, str(e)) self.assertNotIn(SAVE_TOKEN, str(e)) self.assertNotIn("Bearer", str(e)) def test_t14_token_not_in_evidence(self): """T14: Token nicht in Evidence (to_dict / Fehler-Dict).""" client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) try: client.delete("/app/vault/x.md") self.fail("sollte fail-closed werfen") except TolariaWriteError as e: d = e.to_dict() self.assertNotIn(SAVE_TOKEN, json.dumps(d)) self.assertNotIn(DELETE_TOKEN, json.dumps(d)) # --------------------------------------------------------------------------- # RETRY/REPLAY Tests (T15-T17) # --------------------------------------------------------------------------- class TestRetryReplay(unittest.TestCase): """AUTH.3A PHASE 9: Retry/Replay behalten korrekten Scope.""" def test_t15_retry_keeps_correct_scope(self): """T15: Retry behaelt korrekten Scope (SAVE-Token bleibt SAVE).""" client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) captured = _capture_post(client) # Zwei aufeinanderfolgende SAVE-Aufrufe (Retry-Szenario) client.write("/app/vault/a.md", "a") client.write("/app/vault/b.md", "b") self.assertEqual(len(captured), 2) for c in captured: self.assertEqual(c["endpoint"], "save") self.assertEqual(c["auth_header"], f"Bearer {SAVE_TOKEN}") def test_t16_replay_no_unauthenticated_write(self): """T16: Replay erzeugt keinen unauthentifizierten Write.""" # Client ohne save_token -> write() fail-closed (kein unauthentifizierter Write) client = TolariaClient(base_url="http://fake") captured = _capture_post(client) with self.assertRaises(TolariaWriteError): client.write("/app/vault/x.md", "content") self.assertEqual(len(captured), 0) def test_t17_missing_credential_recovery_fails_closed(self): """T17: fehlendes Credential bei Recovery -> fail-closed.""" # Recovery = erneuter Write nach Crash. Ohne Credential -> fail-closed. client = TolariaClient(base_url="http://fake") captured = _capture_post(client) with self.assertRaises(TolariaWriteError) as ctx: client.write("/app/vault/recover.md", "content") self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) self.assertEqual(len(captured), 0) # --------------------------------------------------------------------------- # Wrong-Scope Adversarial (PHASE 10) # --------------------------------------------------------------------------- class TestWrongScopeAdversarial(unittest.TestCase): """AUTH.3A PHASE 10: Wrong-Scope-Credentials muessen scheitern.""" def test_t18_save_credential_at_delete_executor_fails(self): """T18: SAVE credential beim DeleteExecutor -> scheitert.""" # DeleteExecutor bekommt einen Client mit NUR save_token. # delete() (DELETE-Scope) muss fail-closed (kein DELETE-Token). client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) captured = _capture_post(client) with self.assertRaises(TolariaWriteError) as ctx: client.delete("/app/vault/x.md") self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) self.assertEqual(len(captured), 0) def test_t19_delete_credential_at_save_caller_fails(self): """T19: DELETE credential beim Save-Caller -> scheitert.""" # Save-Caller bekommt einen Client mit NUR delete_token. # write() (SAVE-Scope) muss fail-closed (kein SAVE-Token). client = TolariaClient(base_url="http://fake", delete_token=DELETE_TOKEN) captured = _capture_post(client) with self.assertRaises(TolariaWriteError) as ctx: client.write("/app/vault/x.md", "content") self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) self.assertEqual(len(captured), 0) if __name__ == "__main__": unittest.main()