firegex/tests/unit/test_mcp_control.py
av0idd4rk 863422f202 Add authenticated MCP server and agent guide
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.
2026-10-23 09:46:44 +03:00

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())