mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 14:31:00 +00:00
Expose 28 tools with shared REST operations, role-scoped bearer authentication, revisions, durable retries, snapshots, jobs and timed rollback. Add Docker verification harnesses and setup/workflow documentation in docs/mcp.md and inst.md. Validated with 330 unit tests, two consecutive 25-case MCP/import runs, and restart recovery; the full upstream regression run remains incomplete.
109 lines
4.3 KiB
Python
109 lines
4.3 KiB
Python
"""Durable operations: failures, cancellation and recovery must preserve newer work."""
|
|
import asyncio
|
|
import copy
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from mcp_api import control as module
|
|
from mcp_api.auth import principal, MCPError
|
|
from mcp_api.control import Control, revision, request_context
|
|
from mcp_api.schemas import WriteOptions
|
|
from mcp_api.store import Store
|
|
|
|
|
|
@pytest.fixture
|
|
def harness(tmp_path, monkeypatch):
|
|
store = Store(tmp_path / "control.sqlite3")
|
|
store.open()
|
|
state = {"scope": "firewall", "enabled": False, "rules": [], "settings": {}}
|
|
def snapshot(*args):
|
|
return copy.deepcopy(state)
|
|
async def restore(value):
|
|
state.clear()
|
|
state.update(copy.deepcopy(value))
|
|
monkeypatch.setattr(module, "snapshot", snapshot)
|
|
monkeypatch.setattr(module, "restore", restore)
|
|
monkeypatch.setattr(module, "command_lock", asyncio.Lock())
|
|
marker = principal.set({"id": "tester", "name": "test", "role": "admin"})
|
|
yield Control(store, {}), state, snapshot, restore
|
|
principal.reset(marker)
|
|
store.close()
|
|
|
|
|
|
def test_failed_apply_compensates_and_retry_keeps_failure(harness):
|
|
control, state, snapshot, restore = harness
|
|
async def scenario():
|
|
before = snapshot()
|
|
async def broken():
|
|
state["enabled"] = True
|
|
raise RuntimeError("engine refused")
|
|
req = {"actor": "tester", "id": "once", "fingerprint": "same"}
|
|
marker = request_context.set(req)
|
|
try:
|
|
result = await control.mutate("test", before, {**before, "enabled": True},
|
|
WriteOptions(request_id="once", if_revision=revision(before)), broken)
|
|
finally:
|
|
request_context.reset(marker)
|
|
assert not result["ok"]
|
|
assert result["change"]["status"] == "rolled_back"
|
|
assert state == before
|
|
assert control.replay(req)["error"] == result["error"]
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_cancelling_running_job_compensates(harness):
|
|
control, state, snapshot, restore = harness
|
|
async def scenario():
|
|
before = snapshot()
|
|
started = asyncio.Event()
|
|
async def slow():
|
|
state["enabled"] = True
|
|
started.set()
|
|
await asyncio.Event().wait()
|
|
result = await control.mutate("test", before, {**before, "enabled": True},
|
|
WriteOptions(request_id="cancel", if_revision=revision(before), background=True), slow)
|
|
await started.wait()
|
|
task = control.tasks[result["data"]["job_id"]]
|
|
task.cancel()
|
|
await task
|
|
saved = control.change(result["data"]["job_id"])
|
|
assert saved["status"] == "rolled_back"
|
|
assert saved["result"]["error"]["code"] == "CANCELLED"
|
|
assert state == before
|
|
asyncio.run(scenario())
|
|
|
|
|
|
@pytest.mark.parametrize("intervening", [False, True])
|
|
def test_restart_recovery_refuses_unknown_configuration(harness, intervening):
|
|
control, state, snapshot, restore = harness
|
|
before = snapshot()
|
|
candidate = {**before, "enabled": True}
|
|
state.update(candidate)
|
|
if intervening:
|
|
state["rules"] = [{"name": "newer operator rule"}]
|
|
control.store.put("change", "c", {"id": "c", "scope": "firewall", "status": "applying",
|
|
"rollback_available": True, "before": before, "candidate": candidate,
|
|
"before_revision": revision(before)})
|
|
asyncio.run(control.recover())
|
|
assert control.change("c")["status"] == ("recovery_required" if intervening else "rolled_back")
|
|
assert state["enabled"] is intervening
|
|
|
|
|
|
def test_expired_lease_never_overwrites_newer_state(harness):
|
|
control, state, snapshot, restore = harness
|
|
async def scenario():
|
|
before = snapshot()
|
|
candidate = {**before, "enabled": True}
|
|
result = await control.mutate("test", before, candidate,
|
|
WriteOptions(request_id="lease", if_revision=revision(before), confirm_within_seconds=5),
|
|
lambda: restore(candidate))
|
|
change = control.change(result["change"]["id"])
|
|
change["confirm_by"] = time.time() - 1
|
|
control.store.put("change", change["id"], change)
|
|
state["rules"] = [{"name": "newer rule"}]
|
|
await control.expire()
|
|
assert control.change(change["id"])["status"] == "recovery_required"
|
|
assert state["rules"] == [{"name": "newer rule"}]
|
|
asyncio.run(scenario())
|