""" AUTH.3E — test_job_channel.py ============================== Isolierte Tests für den Executor Command Channel (T1–T40 + adversarial). Testumgebung: * Synthetische Secrets (keine echten Credentials) * Fake-Tolaria (kein echtes HTTP) * Temp-DB (keine produktive DB) * Keine produktiven Mutationsprobes Abdeckung (Mission §21): T1 gültiger SAVE Job akzeptiert T2 gültiger DELETE Job akzeptiert T3 unknown job_type rejected T4 malformed schema rejected T5 extra privileged field rejected T6 URL injection rejected T7 Authorization header injection rejected T8 shell field rejected T9 absolute outside path rejected T10 traversal rejected T11 SAVE Executor kann DELETE Job nicht claimen T12 DELETE Executor kann SAVE Job nicht claimen T13 SAVE Executor besitzt kein DELETE Credential T14 DELETE Executor besitzt kein SAVE Credential T15 RQ besitzt kein Credential T16 duplicate job id rejected/idempotent T17 duplicate idempotency key sicher T18 two-worker claim atomar T19 stale SAVE claim recovery sicher T20 stale DELETE claim führt nicht zu blindem Delete T21 DELETE Job ohne Approval denied T22 DELETE Job mit invalid Signature denied T23 DELETE Job mit falschem Object denied T24 falscher Path denied T25 falscher Commit denied T26 falsche Provenance denied T27 falsche Mission denied T28 expired Approval denied T29 consumed Approval denied T30 replay denied T31 valid DELETE Job + valid AUTH3D Approval reaches executor mutation boundary T32 DELETE credential missing -> no HTTP T33 SAVE credential missing -> no HTTP T34 Tolaria timeout vor Mutation -> safe failure T35 Tolaria timeout mit unklarem Ergebnis -> OUTCOME_UNKNOWN T36 OUTCOME_UNKNOWN -> no blind retry T37 confirmed DELETE -> retry does not second-delete T38 rejected job -> zero filesystem side effect T39 rejected job -> zero HTTP side effect T40 rejected job -> no state escalation """ from __future__ import annotations import hashlib import json import os import tempfile import unittest import uuid from job_schema import ( JOB_TYPE_DELETE, JOB_TYPE_SAVE, JobRejectedError, make_delete_job, make_save_job, validate_job, ) from job_state_machine import ( ST_CLAIMED, ST_CREATED, ST_EXECUTING, ST_FAILED, ST_OUTCOME_UNKNOWN, ST_READY, ST_REJECTED, ST_SUCCEEDED, InvalidTransitionError, transition, ) from job_store import JobStore from save_executor_core import SaveExecutorCore from delete_executor_core import DeleteExecutorCore def _uuid() -> str: return str(uuid.uuid4()) def _sha256(content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() def _now_iso() -> str: return "2026-08-27T00:00:00Z" def _make_save_job(**overrides) -> dict: job = make_save_job( job_id=_uuid(), mission_id=_uuid(), object_id="obj-001", vault_path="/app/vault/notes/obj-001.md", source_commit=_sha256("commit"), provenance_hash=_sha256("content"), expected_state="ready", created_at=_now_iso(), idempotency_key=_uuid(), ) job.update(overrides) return job def _make_delete_job(**overrides) -> dict: job = make_delete_job( job_id=_uuid(), mission_id=_uuid(), delete_request_id=_uuid(), object_id="obj-001", vault_path="/app/vault/notes/obj-001.md", expected_commit=_sha256("commit"), expected_provenance_hash=_sha256("content"), approval_id=_uuid(), created_at=_now_iso(), idempotency_key=_uuid(), ) job.update(overrides) return job def _valid_approval(job: dict) -> dict: """Erzeugt eine gültige AUTH.3D-Approval, die exakt zum DELETE-Job passt.""" return { "approval_id": job["approval_id"], "mission_id": job["mission_id"], "object_id": job["object_id"], "vault_path": job["vault_path"], "expected_commit": job["expected_commit"], "expected_provenance_hash": job["expected_provenance_hash"], "nonce": _uuid(), "expired": False, "consumed": False, "signature_valid": True, } class JobSchemaTests(unittest.TestCase): """T1–T10: Schema-Validierung.""" def test_t1_valid_save_job_accepted(self): job = _make_save_job() result = validate_job(job) self.assertEqual(result["job_type"], JOB_TYPE_SAVE) def test_t2_valid_delete_job_accepted(self): job = _make_delete_job() result = validate_job(job) self.assertEqual(result["job_type"], JOB_TYPE_DELETE) def test_t3_unknown_job_type_rejected(self): job = _make_save_job(job_type="C5_EXECUTE") with self.assertRaises(JobRejectedError): validate_job(job) def test_t4_malformed_schema_rejected(self): job = _make_save_job() del job["object_id"] with self.assertRaises(JobRejectedError): validate_job(job) def test_t5_extra_privileged_field_rejected(self): job = _make_save_job(command="rm -rf /") with self.assertRaises(JobRejectedError): validate_job(job) def test_t6_url_injection_rejected(self): job = _make_save_job(url="http://evil.example.com") with self.assertRaises(JobRejectedError): validate_job(job) def test_t7_authorization_header_injection_rejected(self): job = _make_save_job(authorization="Bearer evil") with self.assertRaises(JobRejectedError): validate_job(job) def test_t8_shell_field_rejected(self): job = _make_save_job(shell="/bin/sh") with self.assertRaises(JobRejectedError): validate_job(job) def test_t9_absolute_outside_path_rejected(self): job = _make_save_job(vault_path="/etc/passwd") with self.assertRaises(JobRejectedError): validate_job(job) def test_t10_traversal_rejected(self): job = _make_save_job(vault_path="/app/vault/../../etc/passwd") with self.assertRaises(JobRejectedError): validate_job(job) class JobStoreTests(unittest.TestCase): """T16–T20: Idempotenz, Claim, Recovery.""" def setUp(self): self.tmpdir = tempfile.mkdtemp() self.save_db = os.path.join(self.tmpdir, "c5a_save.db") self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db") def tearDown(self): pass def test_t16_duplicate_job_id_idempotent(self): store = JobStore(self.save_db, "SAVE") job = _make_save_job() store.create_job(job) # Zweiter create mit gleicher job_id -> idempotent (INSERT OR IGNORE) store.create_job(job) jobs = store.list_jobs() self.assertEqual(len(jobs), 1) store.close() def test_t17_duplicate_idempotency_key_safe(self): store = JobStore(self.save_db, "SAVE") key = _uuid() job1 = _make_save_job(idempotency_key=key) job2 = _make_save_job(idempotency_key=key) store.create_job(job1) # Zweiter mit gleichem idempotency_key -> UNIQUE-Verletzung abgefangen from job_store import JobStoreError with self.assertRaises(JobStoreError): store.create_job(job2) store.close() def test_t18_two_worker_claim_atomic(self): store = JobStore(self.save_db, "SAVE") job = _make_save_job() store.create_job(job) store.mark_ready(job["job_id"]) # Worker 1 claimt store.claim_job(job["job_id"], "worker-1") # Worker 2 kann NICHT claimen (bereits geclaimt) from job_claim import JobAlreadyClaimedError with self.assertRaises(JobAlreadyClaimedError): store.claim_job(job["job_id"], "worker-2") store.close() def test_t19_stale_save_claim_recovery_safe(self): store = JobStore(self.save_db, "SAVE") job = _make_save_job() store.create_job(job) store.mark_ready(job["job_id"]) store.claim_job(job["job_id"], "worker-1", lease_seconds=0) # sofort abgelaufen recovered = store.recover_stale_claims("worker-2") # SAVE -> zurück zu READY (wieder claimbar) self.assertEqual(store.get_job(job["job_id"])["state"], ST_READY) store.close() def test_t20_stale_delete_claim_no_blind_delete(self): store = JobStore(self.delete_db, "DELETE") job = _make_delete_job() store.create_job(job) store.mark_ready(job["job_id"]) store.claim_job(job["job_id"], "worker-1", lease_seconds=0) # sofort abgelaufen recovered = store.recover_stale_claims("worker-2") # DELETE -> OUTCOME_UNKNOWN (NICHT zurück zu READY, kein blinder Retry) self.assertEqual(store.get_job(job["job_id"])["state"], ST_OUTCOME_UNKNOWN) store.close() class SaveExecutorTests(unittest.TestCase): """T11, T13, T33, T34, T35, T36, T38, T39, T40 (SAVE).""" def setUp(self): self.tmpdir = tempfile.mkdtemp() self.save_db = os.path.join(self.tmpdir, "c5a_save.db") self.store = JobStore(self.save_db, "SAVE") self.http_calls = [] self.content = "hello world" self.provenance = _sha256(self.content) def _source_loader(self, commit, object_id): return self.content def _tolaria_save(self, vault_path, content): self.http_calls.append(("SAVE", vault_path)) return {"status": "ok"} def test_t11_save_executor_cannot_claim_delete_job(self): # SAVE-Store kann keinen DELETE-Job erzeugen (Schema) — aber selbst wenn # ein DELETE-Job in den SAVE-Store injiziert würde, wird er REJECTED. job = _make_delete_job() # Direkt in DB injizieren (Cross-Executor-Injection-Simulation) self.store.create_job(job) # Schema erlaubt DELETE-Job im Store? Nein — validate_job erlaubt beide # Aber SaveExecutorCore.process_job lehnt DELETE-Job ab (Scope) exec_core = SaveExecutorCore(self.store, self._source_loader, self._tolaria_save) result = exec_core.process_job(job["job_id"], "save-worker") self.assertEqual(result["state"], ST_REJECTED) self.assertEqual(self.http_calls, []) # kein HTTP def test_t13_save_executor_has_no_delete_credential(self): # SAVE-Executor kennt nur SAVE-Credential (injiziert), kein DELETE-Token self.assertNotIn("delete_token", dir(self.store)) self.assertNotIn("delete_token", dir(SaveExecutorCore)) def test_t33_save_credential_missing_no_http(self): # tolaria_save fail-closed: ohne Credential kein HTTP def no_cred_save(vault_path, content): self.http_calls.append(("SAVE", vault_path)) return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False} job = _make_save_job(provenance_hash=self.provenance) self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = SaveExecutorCore(self.store, self._source_loader, no_cred_save) result = exec_core.process_job(job["job_id"], "save-worker") self.assertEqual(result["state"], ST_FAILED) self.assertEqual(result["result_code"], "CREDENTIAL_MISSING") def test_t34_tolaria_timeout_before_mutation_safe_failure(self): # Timeout VOR Mutation -> safe failure (kein Write, kein OUTCOME_UNKNOWN) def timeout_save(vault_path, content): self.http_calls.append(("SAVE", vault_path)) return {"status": "error", "code": "TOLARIA_UNAVAILABLE", "uncertain": False} job = _make_save_job(provenance_hash=self.provenance) self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = SaveExecutorCore(self.store, self._source_loader, timeout_save) result = exec_core.process_job(job["job_id"], "save-worker") self.assertEqual(result["state"], ST_FAILED) self.assertEqual(result["result_code"], "TOLARIA_UNAVAILABLE") def test_t35_tolaria_timeout_uncertain_outcome_unknown(self): # Timeout mit unklarem Ergebnis -> OUTCOME_UNKNOWN def uncertain_save(vault_path, content): self.http_calls.append(("SAVE", vault_path)) return {"status": "error", "code": "TIMEOUT", "uncertain": True} job = _make_save_job(provenance_hash=self.provenance) self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = SaveExecutorCore(self.store, self._source_loader, uncertain_save) result = exec_core.process_job(job["job_id"], "save-worker") self.assertEqual(result["state"], ST_OUTCOME_UNKNOWN) def test_t36_outcome_unknown_no_blind_retry(self): # OUTCOME_UNKNOWN -> kein blinder Retry (Job bleibt OUTCOME_UNKNOWN) def uncertain_save(vault_path, content): self.http_calls.append(("SAVE", vault_path)) return {"status": "error", "code": "TIMEOUT", "uncertain": True} job = _make_save_job(provenance_hash=self.provenance) self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = SaveExecutorCore(self.store, self._source_loader, uncertain_save) exec_core.process_job(job["job_id"], "save-worker") # Erneuter Versuch: Job ist OUTCOME_UNKNOWN, nicht claimbar -> kein 2. HTTP exec_core.process_job(job["job_id"], "save-worker") self.assertEqual(len(self.http_calls), 1) def test_t38_rejected_job_zero_fs_side_effect(self): # Rejected Job -> kein Dateisystem-Effekt (kein Write) job = _make_save_job(job_type="C5_EXECUTE") # invalid with self.assertRaises(JobRejectedError): self.store.create_job(job) # Kein Job in DB self.assertEqual(self.store.list_jobs(), []) def test_t39_rejected_job_zero_http_side_effect(self): job = _make_save_job(job_type="C5_EXECUTE") with self.assertRaises(JobRejectedError): self.store.create_job(job) self.assertEqual(self.http_calls, []) def test_t40_rejected_job_no_state_escalation(self): # Rejected Job -> kein State-Escalation (bleibt REJECTED, kein EXECUTING) job = _make_delete_job() self.store.create_job(job) exec_core = SaveExecutorCore(self.store, self._source_loader, self._tolaria_save) result = exec_core.process_job(job["job_id"], "save-worker") self.assertEqual(result["state"], ST_REJECTED) self.assertNotEqual(result["state"], ST_EXECUTING) class DeleteExecutorTests(unittest.TestCase): """T12, T14, T21–T32, T37 (DELETE).""" def setUp(self): self.tmpdir = tempfile.mkdtemp() self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db") self.store = JobStore(self.delete_db, "DELETE") self.http_calls = [] self.approvals = {} def _approval_loader(self, approval_id): return self.approvals.get(approval_id) def _approval_verify(self, approval): return {"valid": approval.get("signature_valid", False), "reason": "ok"} def _tolaria_delete(self, vault_path): self.http_calls.append(("DELETE", vault_path)) return {"status": "ok"} def _register_valid_approval(self, job): approval = _valid_approval(job) self.approvals[job["approval_id"]] = approval return approval def test_t12_delete_executor_cannot_claim_save_job(self): job = _make_save_job() self.store.create_job(job) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["state"], ST_REJECTED) self.assertEqual(self.http_calls, []) def test_t14_delete_executor_has_no_save_credential(self): self.assertNotIn("save_token", dir(self.store)) self.assertNotIn("save_token", dir(DeleteExecutorCore)) def test_t21_delete_job_without_approval_denied(self): job = _make_delete_job() self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["state"], ST_FAILED) self.assertEqual(result["result_code"], "APPROVAL_MISSING") self.assertEqual(self.http_calls, []) def test_t22_delete_job_invalid_signature_denied(self): job = _make_delete_job() approval = _valid_approval(job) approval["signature_valid"] = False self.approvals[job["approval_id"]] = approval self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["result_code"], "APPROVAL_INVALID") self.assertEqual(self.http_calls, []) def test_t23_delete_job_wrong_object_denied(self): job = _make_delete_job() approval = _valid_approval(job) approval["object_id"] = "obj-OTHER" self.approvals[job["approval_id"]] = approval self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["result_code"], "OBJECT_MISMATCH") self.assertEqual(self.http_calls, []) def test_t24_wrong_path_denied(self): job = _make_delete_job() approval = _valid_approval(job) approval["vault_path"] = "/app/vault/notes/OTHER.md" self.approvals[job["approval_id"]] = approval self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["result_code"], "PATH_MISMATCH") self.assertEqual(self.http_calls, []) def test_t25_wrong_commit_denied(self): job = _make_delete_job() approval = _valid_approval(job) approval["expected_commit"] = _sha256("other-commit") self.approvals[job["approval_id"]] = approval self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["result_code"], "COMMIT_MISMATCH") self.assertEqual(self.http_calls, []) def test_t26_wrong_provenance_denied(self): job = _make_delete_job() approval = _valid_approval(job) approval["expected_provenance_hash"] = _sha256("other-content") self.approvals[job["approval_id"]] = approval self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["result_code"], "PROVENANCE_MISMATCH") self.assertEqual(self.http_calls, []) def test_t27_wrong_mission_denied(self): job = _make_delete_job() approval = _valid_approval(job) approval["mission_id"] = _uuid() self.approvals[job["approval_id"]] = approval self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["result_code"], "MISSION_MISMATCH") self.assertEqual(self.http_calls, []) def test_t28_expired_approval_denied(self): job = _make_delete_job() approval = _valid_approval(job) approval["expired"] = True self.approvals[job["approval_id"]] = approval self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["result_code"], "APPROVAL_EXPIRED") self.assertEqual(self.http_calls, []) def test_t29_consumed_approval_denied(self): job = _make_delete_job() approval = _valid_approval(job) approval["consumed"] = True self.approvals[job["approval_id"]] = approval self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["result_code"], "APPROVAL_CONSUMED") self.assertEqual(self.http_calls, []) def test_t30_replay_denied(self): # Nach bestätigtem DELETE: gleicher Job niemals erneut mutieren job = _make_delete_job() self._register_valid_approval(job) self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["state"], ST_SUCCEEDED) self.assertEqual(len(self.http_calls), 1) # Replay: Job ist SUCCEEDED, nicht claimbar -> kein 2. HTTP exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(len(self.http_calls), 1) def test_t31_valid_delete_reaches_mutation_boundary(self): job = _make_delete_job() self._register_valid_approval(job) self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["state"], ST_SUCCEEDED) self.assertEqual(self.http_calls, [("DELETE", job["vault_path"])]) def test_t32_delete_credential_missing_no_http(self): def no_cred_delete(vault_path): self.http_calls.append(("DELETE", vault_path)) return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False} job = _make_delete_job() self._register_valid_approval(job) self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, no_cred_delete ) result = exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(result["state"], ST_FAILED) self.assertEqual(result["result_code"], "CREDENTIAL_MISSING") def test_t37_confirmed_delete_retry_no_second_delete(self): job = _make_delete_job() self._register_valid_approval(job) self.store.create_job(job) self.store.mark_ready(job["job_id"]) exec_core = DeleteExecutorCore( self.store, self._approval_loader, self._approval_verify, self._tolaria_delete ) exec_core.process_job(job["job_id"], "delete-worker") # Retry-Versuch -> kein zweiter Delete exec_core.process_job(job["job_id"], "delete-worker") self.assertEqual(len(self.http_calls), 1) class StateMachineTests(unittest.TestCase): """State-Machine-Transitionen (FAIL CLOSED).""" def test_valid_transitions(self): self.assertEqual(transition(ST_CREATED, ST_READY), ST_READY) self.assertEqual(transition(ST_READY, ST_CLAIMED), ST_CLAIMED) self.assertEqual(transition(ST_CLAIMED, ST_EXECUTING), ST_EXECUTING) self.assertEqual(transition(ST_EXECUTING, ST_SUCCEEDED), ST_SUCCEEDED) self.assertEqual(transition(ST_EXECUTING, ST_OUTCOME_UNKNOWN), ST_OUTCOME_UNKNOWN) def test_invalid_transition_fail_closed(self): # SUCCEEDED -> READY ist ungültig with self.assertRaises(InvalidTransitionError): transition(ST_SUCCEEDED, ST_READY) # REJECTED -> EXECUTING ist ungültig with self.assertRaises(InvalidTransitionError): transition(ST_REJECTED, ST_EXECUTING) # OUTCOME_UNKNOWN -> READY ist ungültig (kein blinder Retry) with self.assertRaises(InvalidTransitionError): transition(ST_OUTCOME_UNKNOWN, ST_READY) if __name__ == "__main__": unittest.main()