trading-system-docs/tolaria/c5-sync-service/test_job_channel.py
Red Queen 10761f52b2 AUTH.3E: Executor Command Channel + Runtime Boundary Contract
- job_schema: geschlossene Job-Type-Allowlist (C5_SAVE_OBJECT/C5_DELETE_OBJECT), Pfad-/Längen-Validierung
- job_state_machine: deterministische States (CREATED/READY/CLAIMED/EXECUTING/SUCCEEDED/FAILED)
- job_claim: atomare Claim-/Lease-/Recovery-Logik (kein TOCTOU)
- job_store: getrennte SQLite-Inbox-DBs (c5a_save.db/c5a_delete.db), delegiert an job_claim
- save_executor_core: SAVE-only, Content-Rekonstruktion, Provenance-Validierung
- delete_executor_core: DELETE-only, AUTH.3D-Composition, TOCTOU-Defense (Re-Read nach Claim)
- test_job_channel: T1-T40 + adversarial (72 Tests)
- test_job_channel_adversarial: adversarial + Substitution + DB-Manipulation
- sensitivity_proof_auth3e: Mutationen A-L (12/12 Invarianten PRESENT)
- AUTH3B/3C/3D/3E_DESIGN: autoritative Security-Dokumentation (e25 Reconciliation)

COMMAND != AUTHORIZATION. Kein generischer Dispatcher. RQ credential-free.
Keine produktive Mutation. Keine echten Credentials.
2026-08-27 10:03:32 +00:00

628 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
AUTH.3E — test_job_channel.py
==============================
Isolierte Tests für den Executor Command Channel (T1T40 + 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):
"""T1T10: 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):
"""T16T20: 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, T21T32, 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()