- 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.
628 lines
25 KiB
Python
628 lines
25 KiB
Python
"""
|
||
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()
|