Files
OPNexus/backend/tests/test_rule_cleanup.py
T
Julian Boldt b020e38d0a
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.31.0
- **Regel-Bereinigung: Entfernen, Bestätigen, Löschen, Wiederherstellen:** Im Tab **Bereinigung** (Regelwerk › Regel-Analyse) lassen sich Kandidaten jetzt auswählen
  (Checkbox, höchstens so viele Regeln je Lauf wie in den Einstellungen; „vermutlich“ nur mit einer ausdrücklichen Einzelbestätigung je Regel, „unbekannt“ nie).
  **Vorschau** zeigt je Firewall (im Cluster zuerst der Backup) die Regeln mit Befund, Sicherheit und Schutzprüfung; **Ausrollen** deaktiviert die Regeln und wendet an.
  Danach läuft ein Countdown: **Bestätigen** heißt „ich erreiche die Firewall noch“, ohne Bestätigung setzt OPNexus **automatisch zurück**; bis dahin geht auch
  **Jetzt zurücksetzen**. Bestätigte Regeln lassen sich nach der Wartezeit (einstellbar, Standard 7 Tage) **löschen**; vorher sichert OPNexus den Regelinhalt, und
  unter **Verlauf der Bereinigungen** kommt die Regel mit **Wiederherstellen** deaktiviert und möglichst an alter Position zurück (sonst mit Hinweis am Ende der Gruppe);
  **Wieder aktivieren** hat wieder ein Bestätigungsfenster. Wichtig: **Ausrollen, Wiederholen und Wiederherstellen darf nur ein Administrator**, eine Vorschau gehört
  dem, der sie erstellt hat (ein Admin öffnet selbst eine neue); **Bestätigen geht nur in der Browser-Sitzung**, nie per API-Token. **Die Bereinigung bleibt gesperrt,
  bis unter Einstellungen die Verwaltungsquellen** (IP-Adressen oder Netze, von denen aus verwaltet wird) **eingetragen sind**: Regeln, die diesen Zugang treffen
  könnten, deaktiviert OPNexus nie. Dort stehen auch die Obergrenze je Lauf, die Wartezeit bis zum Löschen und **weitere Verwaltungsports** (zusätzlich zu
  API-Port, 443, 80, 22). Tragen Sie die Adresse ein, mit der OPNexus selbst bei den Firewalls ankommt (im Cluster für jeden Knoten). Quellen, Ports und
  Firewall-Adressen sehen nur Administratoren. Der Hinweis „Entfernen folgt“ entfällt.
- **Update: neuere CLI aus dem Zielrelease:** `opnexus update` (auch per Klick im Webinterface) holt sich nach dem Pull die CLI aus dem Updater-Image des
  Zielreleases und startet sich damit neu, damit Korrekturen am Update-Ablauf sofort greifen statt erst beim übernächsten Update. Das geschieht nur, wenn
  `cosign` und `cosign.pub` vorhanden sind und die Signatur des Image-**Digests** in diesem Lauf geprüft wurde (ausgeführt wird genau dieser Digest);
  sonst und bei jedem Fehler läuft das Update mit der vorhandenen CLI weiter. Abschalten: `OPNEXUS_NO_CLI_REFRESH=1`. Wirksam wird es ab dem Update
  *von* dieser Version.
2026-10-22 21:04:32 +02:00

995 lines
54 KiB
Python

"""Rule cleanup (1.31, B4 part 2): preview, deploy, confirm, revert/sweeper, delete and restore against a stateful
fake firewall (tests/rule_cleanup_fake.py) and the test database. Numbers refer to DESIGN-1.31 §6. Roles and API
tokens are checked in the router (package 6), not here."""
import asyncio
import uuid as uuid_lib
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import httpx
import pytest
import pytest_asyncio
from fastapi import HTTPException
from sqlalchemy import select, update
from app.config import settings
from app.database import SessionLocal
from app.models.audit import AuditEvent
from app.models.governance import GovernanceSettings
from app.models.host import OpnsenseHost
from app.models.host_operation import HostOperation
from app.models.rule_cleanup import RuleCleanupItem, RuleCleanupOperation
from app.models.rule_hit import RuleHit
from app.models.rule_history import LiveRuleState, RuleChangeEvent
from app.services import rule_cleanup_service as svc
from app.services import rule_history_service as rhs
from app.services.rule_analysis import context as ctx_mod
from tests.rule_cleanup_fake import (
AUTO, MGMT_SOURCE, R_ALIAS, R_BASE, R_BLOCK, R_CARP, R_DUP, R_LEGACY, R_MGMT, R_TAIL, R_UNUSED, FakeFirewall,
default_rules, rule,
)
from tests.test_rules import _create_host
FW1, FW2 = "https://203.0.113.57", "https://203.0.113.58"
UNUSED_SEEDED = (R_UNUSED, R_BLOCK, R_MGMT, R_CARP, R_ALIAS, R_TAIL)
@pytest.fixture(autouse=True)
def _fresh_cache():
ctx_mod.clear_cache()
yield
ctx_mod.clear_cache()
def _now():
return datetime.now(timezone.utc)
async def _settings(sources=(MGMT_SOURCE,), max_rules=10, min_days=7, ports=()):
async with SessionLocal() as db:
row = await db.get(GovernanceSettings, 1)
if row is None:
row = GovernanceSettings(id=1, operators_may_deploy=True)
db.add(row)
row.cleanup_management_sources = list(sources)
row.cleanup_management_ports = list(ports)
row.cleanup_max_rules, row.cleanup_delete_min_days = max_rules, min_days
await db.commit()
async def _seed_hits(host_id, uuids, *, days=100, hit_days=None, sampled_minutes=10):
async with SessionLocal() as db:
now = _now()
for u in uuids:
db.add(RuleHit(host_id=host_id, rule_uuid=u.lower(), description=u, enabled=True,
first_seen_at=now - timedelta(days=days), last_packets=0, last_bytes=0,
last_sampled_at=now - timedelta(minutes=sampled_minutes),
last_hit_at=None if hit_days is None else now - timedelta(days=hit_days)))
await db.commit()
async def call(host_id, fn, *args, **kw):
async with SessionLocal() as db:
host = await db.get(OpnsenseHost, host_id)
return await fn(db, host, *args, **kw)
async def refused(host_id, fn, *args, **kw) -> dict:
with pytest.raises(HTTPException) as info:
await call(host_id, fn, *args, **kw)
return {"status": info.value.status_code, **info.value.detail}
async def preview(host_id, uuids, actor="admin", **kw):
kw.setdefault("kind", "disable")
return await call(host_id, svc.preview, actor=actor, uuids=uuids, **kw)
async def deploy(host_id, preview_id, actor="admin", name=None):
async with SessionLocal() as db:
host = await db.get(OpnsenseHost, host_id)
return await svc.deploy(db, host, uuid_lib.UUID(preview_id), actor=actor, confirm_name=name or host.name)
async def _items(op_id):
async with SessionLocal() as db:
return {i.rule_uuid: i for i in (await db.scalars(select(RuleCleanupItem).where(
RuleCleanupItem.operation_id == uuid_lib.UUID(str(op_id))))).all()}
async def _op(op_id):
async with SessionLocal() as db:
return await db.get(RuleCleanupOperation, uuid_lib.UUID(str(op_id)))
async def _backdate(op_id, **fields):
async with SessionLocal() as db:
await db.execute(update(RuleCleanupOperation).where(RuleCleanupOperation.id == uuid_lib.UUID(str(op_id))).values(**fields))
await db.commit()
async def _sweep():
async with SessionLocal() as db:
return await svc.sweep_expired_cleanups(db)
@pytest_asyncio.fixture
async def env(client, respx_mock):
log: list = []
fw = FakeFirewall(FW1, log=log).mount(respx_mock)
host_id = uuid_lib.UUID((await _create_host(client, "fw-a", FW1))["id"])
await _settings()
await _seed_hits(host_id, UNUSED_SEEDED)
fw.calls.clear()
log.clear()
return SimpleNamespace(fw=fw, host_id=host_id, log=log)
@pytest_asyncio.fixture
async def cluster(client, respx_mock):
log: list = []
backup = FakeFirewall(FW2, role="BACKUP", pfsync="lan", peer="10.20.30.2", lan_addr="10.20.30.3",
wan_addr="203.0.113.58", log=log).mount(respx_mock)
master = FakeFirewall(FW1, role="MASTER", pfsync="lan", peer="10.20.30.3", log=log).mount(respx_mock)
a = uuid_lib.UUID((await _create_host(client, "fw-a", FW1))["id"])
b = uuid_lib.UUID((await _create_host(client, "fw-b", FW2))["id"])
async with SessionLocal() as db:
await db.execute(update(OpnsenseHost).where(OpnsenseHost.id.in_([a, b])).values(cluster_name="ha-test"))
await db.commit()
await _settings()
await _seed_hits(a, UNUSED_SEEDED)
await _seed_hits(b, UNUSED_SEEDED)
for f in (master, backup):
f.calls.clear()
log.clear()
return SimpleNamespace(master=master, backup=backup, a=a, b=b, log=log)
def _writes(log):
return [(base, path) for base, method, path in log if method == "POST"]
# ── Preview ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_preview_recomputes_from_the_live_firewall_and_stores_a_bound_token(env):
result = await preview(env.host_id, [R_UNUSED, R_DUP])
plan = result["plan"]
items = {i["uuid"]: i for i in plan["nodes"][0]["items"]}
assert set(items) == {R_UNUSED, R_DUP}
assert items[R_UNUSED]["level"] == "sure" and items[R_UNUSED]["protection"]["result"] == "false"
assert items[R_DUP]["cover_uuid"] == R_BASE and items[R_DUP]["cover_fp"]
assert env.fw.writes() == [] # nothing is written by a preview
assert ("GET", "/firewall/filter/searchRule") in env.fw.calls # read live, not from a cache or the request
op = await _op(result["preview_id"])
assert op.status == "preview" and op.actor == "admin" and len(op.preview_fingerprint) == 64
assert timedelta(minutes=4) < op.expires_at - _now() <= timedelta(minutes=5)
async with SessionLocal() as db:
audit = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "rule_cleanup"))).all()
assert [(a.action, a.status) for a in audit] == [("preview", "requested")]
assert "remote_uuid" not in audit[0].details # a preview never appears as a change in the rule history
# a second preview of the same actor replaces the first
again = await preview(env.host_id, [R_UNUSED])
assert (await _op(result["preview_id"])).status == "cancelled" and (await _op(again["preview_id"])).status == "preview"
@pytest.mark.asyncio
async def test_without_management_sources_everything_is_locked(env):
await _settings(sources=())
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_VERWALTUNGSQUELLEN_FEHLEN"
await _settings(sources=("not-an-address",))
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_VERWALTUNGSQUELLEN_FEHLEN"
@pytest.mark.asyncio
@pytest.mark.parametrize("uuid,reason", [
(R_MGMT, "management"), # allows the management source to the firewall's 443
(R_ALIAS, "management"), # source is a URL table: match unknown = protected (case 18)
(R_CARP, "infrastructure"), # CARP protocol
(R_BASE, "not_candidate"), # nothing says it is removable
])
async def test_rules_that_must_not_be_switched_off_are_refused(env, uuid, reason):
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[uuid], acknowledge=[uuid])
assert got["status"] == 409 and got["code"] == "BEREINIGUNG_REGEL_NICHT_AENDERBAR"
assert got["params"]["reason"] == reason and got["params"]["uuid"] == uuid
assert env.fw.writes() == []
@pytest.mark.asyncio
async def test_managed_legacy_and_unknown_confidence_are_refused(env):
from app.models.rule import FirewallRule
from app.models.rule_deployment import RuleDeployment
env.fw.rules.append(rule(R_LEGACY, 900, src="10.20.30.0/24", dport="6000", legacy=True))
await _seed_hits(env.host_id, [R_LEGACY])
async with SessionLocal() as db:
managed = FirewallRule(description="Managed", interface="lan", action="pass", direction="in", protocol="tcp",
source_net="any", destination_net="any", destination_port="8081")
db.add(managed)
await db.flush()
db.add(RuleDeployment(rule_id=managed.id, host_id=env.host_id, status="success", opnsense_rule_uuid=R_UNUSED))
# stale counters (sampled two days ago): "unused" is unknown, never selectable
await db.execute(update(RuleHit).where(RuleHit.rule_uuid == R_TAIL).values(last_sampled_at=_now() - timedelta(days=2)))
await db.commit()
for uuid, reason in ((R_UNUSED, "managed"), (R_LEGACY, "legacy"), (R_TAIL, "confidence")):
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[uuid], acknowledge=[uuid])
assert got["params"]["reason"] == reason, (uuid, got)
@pytest.mark.asyncio
async def test_probable_needs_an_explicit_acknowledgement_per_rule(env):
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_BLOCK, R_UNUSED])
assert got["code"] == "BEREINIGUNG_BESTAETIGUNG_FEHLT" and got["params"]["uuids"] == R_BLOCK
ok = await preview(env.host_id, [R_BLOCK, R_UNUSED], acknowledge=[R_BLOCK], ack_text_version="1")
item = next(i for i in ok["plan"]["nodes"][0]["items"] if i["uuid"] == R_BLOCK)
assert item["level"] == "probable" and item["needs_ack"] and "UNUSED_BLOCK_RULE" in item["notes"]
op = await _op(ok["preview_id"])
assert op.options["acknowledged"] == [{"uuid": R_BLOCK, "text_version": "1"}]
@pytest.mark.asyncio
async def test_short_window_and_external_change_cap_at_probable(env):
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED], unused_days=14)
assert got["code"] == "BEREINIGUNG_BESTAETIGUNG_FEHLT"
async with SessionLocal() as db:
db.add(RuleChangeEvent(host_id=env.host_id, rule_uuid=R_UNUSED, seq=1, observed_at=_now() - timedelta(days=2),
kind="changed", before={}, after={}, origin="external"))
await db.commit()
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_BESTAETIGUNG_FEHLT"
ok = await preview(env.host_id, [R_UNUSED], acknowledge=[R_UNUSED])
assert "EXTERNAL_CHANGE" in ok["plan"]["nodes"][0]["items"][0]["notes"]
@pytest.mark.asyncio
async def test_rule_limit_and_selection_validation(env):
await _settings(max_rules=2)
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED, R_DUP, R_TAIL])
assert got["status"] == 422 and got["code"] == "BEREINIGUNG_ZU_VIELE_REGELN" and got["params"]["max"] == 2
for uuids in ([], ["../apply"]):
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=uuids)
assert got["code"] == "BEREINIGUNG_AUSWAHL_UNGUELTIG"
got = await refused(env.host_id, svc.preview, actor="admin", kind="wipe", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_AUSWAHL_UNGUELTIG"
@pytest.mark.asyncio
async def test_maintenance_open_deployments_and_open_cleanups_block(env):
from app.models.rule import FirewallRule
from app.models.rule_deployment import RuleDeployment
async with SessionLocal() as db:
op = HostOperation(host_id=env.host_id, host_name="fw-a", actor="admin", action="reboot", status="submitted",
expires_at=_now(), fingerprint="x", snapshot={})
db.add(op)
await db.commit()
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["code"] == "OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY"
async with SessionLocal() as db:
await db.execute(update(HostOperation).values(status="done"))
managed = FirewallRule(description="x", interface="lan", action="pass", direction="in", protocol="tcp",
source_net="any", destination_net="any")
db.add(managed)
await db.flush()
dep = RuleDeployment(rule_id=managed.id, host_id=env.host_id, status="pending", opnsense_rule_uuid=R_TAIL)
db.add(dep)
await db.commit()
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_OFFENE_OPERATION" and got["params"]["reason"] == "deployment"
async with SessionLocal() as db:
await db.execute(update(RuleDeployment).values(status="success"))
await db.commit()
first = await preview(env.host_id, [R_UNUSED])
await deploy(env.host_id, first["preview_id"])
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_DUP])
assert got["code"] == "BEREINIGUNG_OFFENE_OPERATION" and got["params"]["reason"] == "cleanup"
async with SessionLocal() as db:
assert await svc.has_open_cleanup(db, [env.host_id])
# ── Deploy ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_deploy_switches_off_samples_before_apply_and_waits_for_confirmation(env):
p = await preview(env.host_id, [R_UNUSED, R_DUP])
env.fw.calls.clear()
result = await deploy(env.host_id, p["preview_id"])
assert result["status"] == "awaiting_confirm" and result["can_confirm"]
writes = env.fw.writes()
assert sorted(writes[:2]) == sorted([("POST", f"/firewall/filter/toggleRule/{R_DUP}/0"),
("POST", f"/firewall/filter/toggleRule/{R_UNUSED}/0")])
assert writes[2:] == [("POST", "/firewall/filter/apply")] # one apply per node, after the toggles
apply_at = env.fw.calls.index(("POST", "/firewall/filter/apply"))
last_toggle = max(i for i, c in enumerate(env.fw.calls) if "toggleRule" in c[1])
assert ("GET", "/firewall/filter_util/ruleStats") in env.fw.calls[last_toggle:apply_at] # sampled before the apply
assert env.fw.get(R_UNUSED)["enabled"] == "0" and env.fw.get(R_DUP)["enabled"] == "0"
op = await _op(p["preview_id"])
window = timedelta(seconds=settings.deploy_confirm_window_seconds)
assert window - timedelta(seconds=10) < op.revert_deadline - _now() <= window
items = await _items(p["preview_id"])
assert {i.state for i in items.values()} == {"disabled"}
assert items[R_UNUSED].fp_before and items[R_UNUSED].row_before["enabled"] == "1"
async with SessionLocal() as db:
audits = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "rule_cleanup", AuditEvent.action == "disable"))).all()
assert {(a.details["remote_uuid"], a.status) for a in audits} == {(R_UNUSED, "accepted"), (R_DUP, "accepted")}
assert all(a.host_id == str(env.host_id) and a.details["preview_fingerprint"] == op.preview_fingerprint for a in audits)
@pytest.mark.asyncio
async def test_rule_history_shows_the_cleanup_as_opnexus(env):
async with SessionLocal() as db:
host = await db.get(OpnsenseHost, env.host_id)
await rhs.observe_rows(db, host, {"rows": env.fw.rules}) # baseline
p = await preview(env.host_id, [R_UNUSED])
await deploy(env.host_id, p["preview_id"])
async with SessionLocal() as db:
host = await db.get(OpnsenseHost, env.host_id)
await rhs.observe_rows(db, host, {"rows": env.fw.rules})
event = (await db.scalars(select(RuleChangeEvent).where(RuleChangeEvent.rule_uuid == R_UNUSED))).one()
assert event.kind == "disabled" and event.origin == "opnexus" and event.audit_id is not None
@pytest.mark.asyncio
@pytest.mark.parametrize("change", ["edited", "moved", "cover_edited", "hit"])
async def test_deploy_refuses_a_stale_preview_without_writing(env, change):
p = await preview(env.host_id, [R_UNUSED, R_DUP])
if change == "edited":
env.fw.get(R_UNUSED)["destination_port"] = "8082"
elif change == "moved":
env.fw.get(R_UNUSED)["sort_order"] = "400000.0000301"
elif change == "cover_edited":
env.fw.get(R_BASE)["description"] = "base changed"
else: # a hit since the preview: the fresh sample at deploy sees the counter grow
async with SessionLocal() as db:
await db.execute(update(RuleHit).where(RuleHit.rule_uuid == R_UNUSED).values(last_hit_at=_now()))
await db.commit()
env.fw.calls.clear()
got = await refused(env.host_id, svc.deploy, uuid_lib.UUID(p["preview_id"]), actor="admin", confirm_name="fw-a")
expected = R_DUP if change == "cover_edited" else R_UNUSED
if change == "hit":
assert got["code"] == "BEREINIGUNG_REGEL_NICHT_AENDERBAR" and got["params"]["uuid"] == R_UNUSED
else:
assert got["code"] == "BEREINIGUNG_VORSCHAU_VERALTET" and got["params"]["uuids"] == expected
assert env.fw.writes() == []
assert (await _op(p["preview_id"])).status == "expired"
@pytest.mark.asyncio
async def test_preview_token_is_single_use_bound_to_actor_and_name(env):
p = await preview(env.host_id, [R_UNUSED])
pid = uuid_lib.UUID(p["preview_id"])
got = await refused(env.host_id, svc.deploy, pid, actor="someone-else", confirm_name="fw-a")
assert got["status"] == 404
got = await refused(env.host_id, svc.deploy, pid, actor="admin", confirm_name="FW-A")
assert got["code"] == "HOSTNAMEN_EXAKT_EINGEBEN"
await deploy(env.host_id, p["preview_id"])
got = await refused(env.host_id, svc.deploy, pid, actor="admin", confirm_name="fw-a")
assert got["code"] == "VORSCHAU_ABGELAUFEN_ODER_BEREITS_VERWENDET"
# an expired preview
await call(env.host_id, svc.revert_now, pid)
q = await preview(env.host_id, [R_DUP])
await _backdate(q["preview_id"], expires_at=_now() - timedelta(seconds=1))
got = await refused(env.host_id, svc.deploy, uuid_lib.UUID(q["preview_id"]), actor="admin", confirm_name="fw-a")
assert got["code"] == "VORSCHAU_ABGELAUFEN_ODER_BEREITS_VERWENDET"
@pytest.mark.asyncio
async def test_refused_toggle_stops_writing_but_applies_what_is_switched(env):
uuids = [R_DUP, R_UNUSED, R_BLOCK, R_TAIL]
p = await preview(env.host_id, uuids, acknowledge=[R_BLOCK])
env.fw.fail[("toggle", R_BLOCK)] = httpx.Response(200, json={"result": "failed"})
env.fw.calls.clear()
result = await deploy(env.host_id, p["preview_id"])
states = {i.rule_uuid: i.state for i in (await _items(p["preview_id"])).values()}
assert states == {R_DUP: "disabled", R_UNUSED: "disabled", R_BLOCK: "failed", R_TAIL: "queued"}
assert ("POST", f"/firewall/filter/toggleRule/{R_TAIL}/0") not in env.fw.calls # nothing after the error
assert env.fw.writes()[-1] == ("POST", "/firewall/filter/apply") # but the switched ones are applied
assert result["status"] == "partial" and not result["can_confirm"] and result["can_revert"]
assert (await _op(p["preview_id"])).revert_deadline > _now()
got = await refused(env.host_id, svc.confirm, uuid_lib.UUID(p["preview_id"]))
assert got["code"] == "BEREINIGUNG_NICHT_BESTAETIGBAR"
@pytest.mark.asyncio
@pytest.mark.parametrize("applied,readable,state", [(True, True, "disabled"), (False, True, "failed"), (False, False, "unknown")])
async def test_unclear_toggle_is_settled_by_reading(env, applied, readable, state):
p = await preview(env.host_id, [R_UNUSED, R_DUP])
items_order = [i["uuid"] for i in p["plan"]["nodes"][0]["items"]]
first = items_order[0]
env.fw.fail[("toggle", first)] = httpx.ReadTimeout
env.fw.fail[("toggle_applies", first)] = applied
if not readable:
env.fw.fail[("get", first)] = httpx.ConnectError
result = await deploy(env.host_id, p["preview_id"])
items = await _items(p["preview_id"])
assert items[first].state == state
# no further write after an unclear answer (never attempted: "skipped" once nothing changed at all)
assert items[items_order[1]].state == ("skipped" if state == "failed" else "queued")
expect_apply = state in ("disabled", "unknown")
assert (("POST", "/firewall/filter/apply") in env.fw.calls) is expect_apply
assert result["status"] == {"disabled": "partial", "failed": "failed", "unknown": "partial"}[state]
assert not result["can_confirm"]
@pytest.mark.asyncio
async def test_failed_apply_keeps_the_deadline_and_the_sweeper_reverts(env):
p = await preview(env.host_id, [R_UNUSED])
env.fw.fail[("apply", "")] = [httpx.Response(500, json={}), None]
result = await deploy(env.host_id, p["preview_id"])
assert result["status"] == "apply_failed" and result["revert_deadline"]
await _sweep()
assert (await _op(p["preview_id"])).status == "apply_failed" # deadline not reached yet
await _backdate(p["preview_id"], revert_deadline=_now() - timedelta(seconds=1))
env.fw.calls.clear()
await _sweep()
assert (await _op(p["preview_id"])).status == "reverted"
assert env.fw.writes() == [("POST", f"/firewall/filter/toggleRule/{R_UNUSED}/1"), ("POST", "/firewall/filter/apply")]
assert env.fw.get(R_UNUSED)["enabled"] == "1"
# ── Confirm ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_confirm_reads_the_firewall_and_refuses_when_unreachable_or_different(env):
p = await preview(env.host_id, [R_UNUSED, R_DUP])
await deploy(env.host_id, p["preview_id"])
pid = uuid_lib.UUID(p["preview_id"])
env.fw.down = True
got = await refused(env.host_id, svc.confirm, pid)
assert got["status"] == 502 and got["code"] == "BEREINIGUNG_FIREWALL_NICHT_ERREICHBAR"
assert (await _op(pid)).status == "awaiting_confirm" # stays open: the sweeper reverts it
env.fw.down = False
env.fw.get(R_DUP)["enabled"] = "1" # someone switched it back on in the GUI
got = await refused(env.host_id, svc.confirm, pid)
assert got["code"] == "BEREINIGUNG_LIVESTAND_ABWEICHEND" and got["params"]["uuids"] == R_DUP
env.fw.get(R_DUP)["enabled"] = "0"
env.fw.calls.clear()
result = await call(env.host_id, svc.confirm, pid)
assert result["status"] == "confirmed" and {i["state"] for i in result["items"]} == {"confirmed"}
assert env.fw.writes() == [] # confirming only reads
op = await _op(pid)
assert op.revert_deadline is None and op.confirmed_at is not None
assert await _sweep() == []
@pytest.mark.asyncio
async def test_confirm_after_the_deadline_is_refused_and_the_sweeper_reverts(env):
p = await preview(env.host_id, [R_UNUSED])
await deploy(env.host_id, p["preview_id"])
await _backdate(p["preview_id"], revert_deadline=_now() - timedelta(seconds=1))
got = await refused(env.host_id, svc.confirm, uuid_lib.UUID(p["preview_id"]))
assert got["code"] == "BEREINIGUNG_FRIST_ABGELAUFEN"
assert len(await _sweep()) == 1
assert env.fw.get(R_UNUSED)["enabled"] == "1"
assert {i.state for i in (await _items(p["preview_id"])).values()} == {"reverted"}
async with SessionLocal() as db:
audit = (await db.scalars(select(AuditEvent).where(AuditEvent.action == "revert"))).one()
assert audit.actor == "system:rollback" and audit.details["remote_uuid"] == R_UNUSED
@pytest.mark.asyncio
async def test_revert_now_and_failed_revert_is_not_retried_automatically(env):
p = await preview(env.host_id, [R_UNUSED, R_DUP])
await deploy(env.host_id, p["preview_id"])
env.fw.fail[("toggle", R_DUP)] = httpx.Response(200, json={"result": "failed"})
result = await call(env.host_id, svc.revert_now, uuid_lib.UUID(p["preview_id"]))
assert result["status"] == "revert_failed" and result["can_retry"]
states = {i["rule_uuid"]: i["state"] for i in result["items"]}
assert states == {R_UNUSED: "reverted", R_DUP: "revert_failed"} # the others are still reverted
assert env.fw.get(R_UNUSED)["enabled"] == "1" and env.fw.writes()[-1] == ("POST", "/firewall/filter/apply")
env.fw.calls.clear()
await _backdate(p["preview_id"], revert_deadline=_now() - timedelta(minutes=5))
await _sweep()
assert env.fw.writes() == [] # never retried by the sweeper
del env.fw.fail[("toggle", R_DUP)]
result = await call(env.host_id, svc.retry, uuid_lib.UUID(p["preview_id"]))
assert result["status"] == "reverted" and env.fw.get(R_DUP)["enabled"] == "1"
# ── Sweeper and two backends ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_sweeper_skips_a_live_run_and_takes_over_an_abandoned_one(env):
p = await preview(env.host_id, [R_UNUSED])
await deploy(env.host_id, p["preview_id"])
# simulate a backend that died mid-run: running, item half-way
async with SessionLocal() as db:
await db.execute(update(RuleCleanupOperation).values(status="running", heartbeat_at=_now(),
revert_deadline=_now() + timedelta(minutes=10)))
await db.execute(update(RuleCleanupItem).values(state="disabling"))
await db.commit()
env.fw.calls.clear()
assert await _sweep() == [] # (b) fresh heartbeat: someone is still working on it
assert env.fw.writes() == []
await _backdate(p["preview_id"], heartbeat_at=_now() - timedelta(hours=1))
assert await _sweep() == [p["preview_id"]] # (c) stale heartbeat: taken over and reverted
assert env.fw.writes() == [("POST", f"/firewall/filter/toggleRule/{R_UNUSED}/1"), ("POST", "/firewall/filter/apply")]
assert (await _op(p["preview_id"])).status == "reverted"
@pytest.mark.asyncio
async def test_two_deploys_of_one_preview_write_once(env):
p = await preview(env.host_id, [R_UNUSED, R_DUP])
outcomes = await asyncio.gather(deploy(env.host_id, p["preview_id"]), deploy(env.host_id, p["preview_id"]),
return_exceptions=True)
errors = [o for o in outcomes if isinstance(o, HTTPException)]
assert len(errors) == 1 and errors[0].detail["code"] == "VORSCHAU_ABGELAUFEN_ODER_BEREITS_VERWENDET"
assert sum(1 for c in env.fw.calls if "toggleRule" in c[1]) == 2 # (a) each rule exactly once
@pytest.mark.asyncio
async def test_second_operation_on_the_same_rule_is_refused(env):
a = await preview(env.host_id, [R_UNUSED], actor="admin")
b = await preview(env.host_id, [R_UNUSED], actor="admin2")
await deploy(env.host_id, a["preview_id"])
got = await refused(env.host_id, svc.deploy, uuid_lib.UUID(b["preview_id"]), actor="admin2", confirm_name="fw-a")
assert got["code"] == "BEREINIGUNG_OFFENE_OPERATION" # (d)
# and the partial unique index holds even without the service's checks
async with SessionLocal() as db:
op = await db.get(RuleCleanupOperation, uuid_lib.UUID(b["preview_id"]))
db.add(RuleCleanupItem(operation_id=op.id, host_id=env.host_id, rule_uuid=R_UNUSED, state="queued", description=""))
with pytest.raises(Exception):
await db.commit()
@pytest.mark.asyncio
async def test_confirm_and_sweeper_at_the_deadline_decide_once(env):
p = await preview(env.host_id, [R_UNUSED])
await deploy(env.host_id, p["preview_id"])
await _backdate(p["preview_id"], revert_deadline=_now() + timedelta(milliseconds=300))
async def late_confirm():
await asyncio.sleep(0.4)
return await call(env.host_id, svc.confirm, uuid_lib.UUID(p["preview_id"]))
async def late_sweep():
await asyncio.sleep(0.4)
return await _sweep()
outcomes = await asyncio.gather(late_confirm(), late_sweep(), return_exceptions=True)
op = await _op(p["preview_id"])
assert op.status == "reverted" # (e) the deadline passed: confirm refused, exactly one revert
assert isinstance(outcomes[0], HTTPException) and outcomes[0].detail["code"] == "BEREINIGUNG_FRIST_ABGELAUFEN"
assert sum(1 for c in env.fw.calls if c[1].endswith(f"{R_UNUSED}/1")) == 1
# ── Cluster ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_cluster_writes_backup_first_and_reverts_master_first(cluster):
p = await preview(cluster.a, [R_UNUSED])
assert [n["role"] for n in p["plan"]["nodes"]] == ["BACKUP", "MASTER"]
await deploy(cluster.a, p["preview_id"])
writes = _writes(cluster.log)
assert writes == [(FW2, f"/firewall/filter/toggleRule/{R_UNUSED}/0"), (FW2, "/firewall/filter/apply"),
(FW1, f"/firewall/filter/toggleRule/{R_UNUSED}/0"), (FW1, "/firewall/filter/apply")]
items = await _items(p["preview_id"])
assert len(items) == 1 # (same rule_uuid on both nodes: keyed by uuid here)
async with SessionLocal() as db:
roles = {i.host_id: i.carp_role for i in (await db.scalars(select(RuleCleanupItem))).all()}
assert roles == {cluster.a: "MASTER", cluster.b: "BACKUP"}
cluster.log.clear()
await _backdate(p["preview_id"], revert_deadline=_now() - timedelta(seconds=1))
await _sweep()
assert _writes(cluster.log) == [(FW1, f"/firewall/filter/toggleRule/{R_UNUSED}/1"), (FW1, "/firewall/filter/apply"),
(FW2, f"/firewall/filter/toggleRule/{R_UNUSED}/1"), (FW2, "/firewall/filter/apply")]
@pytest.mark.asyncio
async def test_cluster_master_failure_leaves_backup_switched_and_unconfirmable(cluster):
p = await preview(cluster.a, [R_UNUSED])
cluster.master.fail[("toggle", R_UNUSED)] = httpx.Response(200, json={"result": "failed"})
result = await deploy(cluster.a, p["preview_id"])
assert result["status"] == "partial" and not result["can_confirm"]
assert cluster.backup.get(R_UNUSED)["enabled"] == "0" and cluster.master.get(R_UNUSED)["enabled"] == "1"
@pytest.mark.asyncio
async def test_cluster_refuses_missing_uuid_offline_peer_and_two_masters(cluster):
cluster.backup.rules = [r for r in cluster.backup.rules if r["uuid"] != R_UNUSED]
got = await refused(cluster.a, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_CLUSTER_UNVOLLSTAENDIG" and got["params"]["reason"] == "uuid_missing"
cluster.backup.rules = default_rules()
cluster.backup.role = "MASTER"
got = await refused(cluster.a, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_CLUSTER_UNVOLLSTAENDIG" and got["params"]["reason"] == "roles"
cluster.backup.role = "BACKUP"
cluster.backup.down = True
got = await refused(cluster.a, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["status"] == 502 and got["params"]["host"] == "fw-b"
@pytest.mark.asyncio
async def test_cluster_usage_is_judged_over_all_nodes(cluster):
# case 16: the backup saw no hit, the master did: not unused
async with SessionLocal() as db:
await db.execute(update(RuleHit).where(RuleHit.host_id == cluster.a, RuleHit.rule_uuid == R_UNUSED)
.values(last_hit_at=_now() - timedelta(days=1)))
await db.commit()
got = await refused(cluster.b, svc.preview, actor="admin", kind="disable", uuids=[R_UNUSED])
assert got["params"]["reason"] == "not_candidate"
# no usage data on one node: at best probable
async with SessionLocal() as db:
await db.execute(RuleHit.__table__.delete().where(RuleHit.host_id == cluster.a, RuleHit.rule_uuid == R_TAIL))
await db.commit()
got = await refused(cluster.b, svc.preview, actor="admin", kind="disable", uuids=[R_TAIL])
assert got["code"] == "BEREINIGUNG_BESTAETIGUNG_FEHLT"
@pytest.mark.asyncio
async def test_cluster_pfsync_interface_rules_are_infrastructure(cluster):
# "any" from the LAN net on the pfsync interface (LAN) would also carry pfsync
for fw in (cluster.master, cluster.backup):
fw.rules.append(rule("aaaaaaaa-0000-4000-8000-000000000900", 900, proto="any", src="10.20.30.0/24", description="lan any"))
await _seed_hits(cluster.a, ["aaaaaaaa-0000-4000-8000-000000000900"])
await _seed_hits(cluster.b, ["aaaaaaaa-0000-4000-8000-000000000900"])
got = await refused(cluster.a, svc.preview, actor="admin", kind="disable", uuids=["aaaaaaaa-0000-4000-8000-000000000900"])
assert got["params"]["reason"] == "infrastructure"
# ── Delete and restore ────────────────────────────────────────────────────
async def _confirmed_disable(host_id, uuids, *, days_ago=8):
p = await preview(host_id, uuids)
await deploy(host_id, p["preview_id"])
await call(host_id, svc.confirm, uuid_lib.UUID(p["preview_id"]))
await _backdate(p["preview_id"], confirmed_at=_now() - timedelta(days=days_ago))
return p["preview_id"]
@pytest.mark.asyncio
async def test_delete_needs_the_waiting_time_an_unchanged_rule_and_no_external_change(env):
await _confirmed_disable(env.host_id, [R_UNUSED], days_ago=2)
got = await refused(env.host_id, svc.preview, actor="admin", kind="delete", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_WARTEZEIT_NICHT_ERREICHT" and got["params"]["days"] == 5
async with SessionLocal() as db:
await db.execute(update(RuleCleanupOperation).values(confirmed_at=_now() - timedelta(days=8)))
await db.commit()
ok = await preview(env.host_id, [R_UNUSED], kind="delete")
assert ok["plan"]["nodes"][0]["items"][0]["basis"]["type"] == "confirmed"
env.fw.get(R_UNUSED)["destination_port"] = "8099"
got = await refused(env.host_id, svc.preview, actor="admin", kind="delete", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_VORSCHAU_VERALTET"
env.fw.get(R_UNUSED)["destination_port"] = "8081"
async with SessionLocal() as db:
db.add(RuleChangeEvent(host_id=env.host_id, rule_uuid=R_UNUSED, seq=5, observed_at=_now() - timedelta(days=1),
kind="changed", before={}, after={}, origin="external"))
await db.commit()
got = await refused(env.host_id, svc.preview, actor="admin", kind="delete", uuids=[R_UNUSED])
assert got["code"] == "BEREINIGUNG_VORSCHAU_VERALTET"
env.fw.get(R_DUP)["enabled"] = "1"
got = await refused(env.host_id, svc.preview, actor="admin", kind="delete", uuids=[R_DUP])
assert got["params"]["reason"] == "not_candidate" # an enabled rule is never deleted
@pytest.mark.asyncio
async def test_delete_commits_the_snapshot_before_delrule(env, monkeypatch):
from app.services import opnsense_client as client_mod
await _confirmed_disable(env.host_id, [R_UNUSED])
p = await preview(env.host_id, [R_UNUSED], kind="delete")
seen: list = []
real_del = client_mod.del_rule
async def spy(host, uuid): # what another session sees in the database at the moment delRule goes out
async with SessionLocal() as db:
item = (await db.scalars(select(RuleCleanupItem).where(
RuleCleanupItem.operation_id == uuid_lib.UUID(p["preview_id"])))).one()
seen.append((item.state, bool(item.snapshot), item.anchor_next_uuid))
return await real_del(host, uuid)
monkeypatch.setattr(client_mod, "del_rule", spy)
result = await deploy(env.host_id, p["preview_id"])
assert seen == [("deleting", True, R_BLOCK)] # durable before the remote delete
assert result["status"] == "done"
item = next(iter((await _items(p["preview_id"])).values()))
assert item.state == "deleted" and item.snapshot["rule"]["destination_port"] == "8081"
assert item.snapshot["rule"]["action"] == "pass" and "uuid" not in item.snapshot["rule"]
assert item.prio_group == "400000" and item.anchor_prev_uuid == R_DUP
assert env.fw.get(R_UNUSED) is None and env.fw.writes()[-1] == ("POST", "/firewall/filter/apply")
@pytest.mark.asyncio
@pytest.mark.parametrize("applied,state", [(True, "deleted"), (False, "failed")])
async def test_unclear_delete_is_settled_with_getrule(env, applied, state):
await _confirmed_disable(env.host_id, [R_UNUSED])
p = await preview(env.host_id, [R_UNUSED], kind="delete")
env.fw.fail[("delete", R_UNUSED)] = httpx.ReadTimeout
env.fw.fail[("delete_applies", R_UNUSED)] = applied
result = await deploy(env.host_id, p["preview_id"])
assert next(iter((await _items(p["preview_id"])).values())).state == state
assert result["status"] == ("done" if state == "deleted" else "failed")
@pytest.mark.asyncio
async def test_restore_adds_disabled_at_the_anchor_with_a_new_uuid(env):
await _confirmed_disable(env.host_id, [R_UNUSED])
p = await preview(env.host_id, [R_UNUSED], kind="delete")
await deploy(env.host_id, p["preview_id"])
deleted = next(iter((await _items(p["preview_id"])).values()))
env.fw.calls.clear()
result = await call(env.host_id, svc.restore_item, deleted.id, actor="admin", confirm_name="fw-a")
assert result["status"] == "done" and result["warnings"] == []
item = result["items"][0]
new = item["restored_uuid"]
assert new and new != R_UNUSED and item["state"] == "restored"
restored = env.fw.get(new)
assert restored["enabled"] == "0" and restored["destination_port"] == "8081"
assert ("POST", f"/firewall/filter/moveRuleBefore/{new}/{R_BLOCK}") in env.fw.calls
order = [r["uuid"] for r in sorted(env.fw.rules, key=lambda r: r["sort_order"]) if not r.get("is_automatic")]
assert order.index(new) == order.index(R_BLOCK) - 1 and order.index(new) == order.index(R_DUP) + 1
async with SessionLocal() as db:
audit = (await db.scalars(select(AuditEvent).where(AuditEvent.action == "restore"))).one()
source = await db.get(RuleCleanupItem, deleted.id)
assert audit.details["remote_uuid"] == new and audit.details["restored_from"] == R_UNUSED
assert source.restored_uuid == new
got = await refused(env.host_id, svc.restore_item, deleted.id, actor="admin", confirm_name="fw-a")
assert got["code"] == "BEREINIGUNG_AKTION_NICHT_MOEGLICH" # restored once only
# re-enabling the restored rule is an operation with a window
e = await preview(env.host_id, [new], kind="enable")
env.fw.calls.clear()
r = await deploy(env.host_id, e["preview_id"])
assert r["status"] == "awaiting_confirm" and ("POST", f"/firewall/filter/toggleRule/{new}/1") in env.fw.calls
@pytest.mark.asyncio
async def test_restore_without_anchor_warns_and_keeps_the_rule(env):
await _confirmed_disable(env.host_id, [R_UNUSED])
p = await preview(env.host_id, [R_UNUSED], kind="delete")
await deploy(env.host_id, p["preview_id"])
deleted = next(iter((await _items(p["preview_id"])).values()))
env.fw.rules = [r for r in env.fw.rules if r["uuid"] != R_BLOCK] # the anchor is gone meanwhile
result = await call(env.host_id, svc.restore_item, deleted.id, actor="admin", confirm_name="fw-a")
assert result["status"] == "done" and result["warnings"] == ["BEREINIGUNG_POSITION_NICHT_WIEDERHERSTELLBAR"]
assert result["items"][0]["message"] == "BEREINIGUNG_POSITION_NICHT_WIEDERHERSTELLBAR"
assert env.fw.get(result["items"][0]["restored_uuid"])["enabled"] == "0"
@pytest.mark.asyncio
async def test_restore_refused_by_the_firewall_is_failed_with_its_message(env):
await _confirmed_disable(env.host_id, [R_UNUSED])
p = await preview(env.host_id, [R_UNUSED], kind="delete")
await deploy(env.host_id, p["preview_id"])
deleted = next(iter((await _items(p["preview_id"])).values()))
env.fw.fail[("add", "")] = httpx.Response(200, json={"result": "failed", "validations": {"rule.source_net": "alias missing"}})
result = await call(env.host_id, svc.restore_item, deleted.id, actor="admin", confirm_name="fw-a")
assert result["status"] == "failed" and "alias missing" in result["items"][0]["message"]
async with SessionLocal() as db:
assert (await db.get(RuleCleanupItem, deleted.id)).restored_uuid is None # can be tried again
@pytest.mark.asyncio
async def test_cluster_delete_restore_and_reenable_follow_the_restore_link(cluster):
p = await preview(cluster.a, [R_UNUSED])
await deploy(cluster.a, p["preview_id"])
await call(cluster.a, svc.confirm, uuid_lib.UUID(p["preview_id"]))
await _backdate(p["preview_id"], confirmed_at=_now() - timedelta(days=8))
d = await preview(cluster.a, [R_UNUSED], kind="delete")
cluster.log.clear()
assert (await deploy(cluster.a, d["preview_id"]))["status"] == "done"
assert [w for w in _writes(cluster.log) if "delRule" in w[1]] == [(FW2, f"/firewall/filter/delRule/{R_UNUSED}"),
(FW1, f"/firewall/filter/delRule/{R_UNUSED}")]
async with SessionLocal() as db:
deleted = (await db.scalars(select(RuleCleanupItem).where(
RuleCleanupItem.operation_id == uuid_lib.UUID(d["preview_id"]), RuleCleanupItem.host_id == cluster.a))).one()
restored = await call(cluster.a, svc.restore_item, deleted.id, actor="admin", confirm_name="fw-a")
assert restored["status"] == "done" and len(restored["items"]) == 2 # both nodes, backup first
new = {i["host_id"]: i["restored_uuid"] for i in restored["items"]}
assert new[str(cluster.a)] != new[str(cluster.b)] # addRule: a new UUID on every node
assert cluster.backup.get(new[str(cluster.b)])["enabled"] == "0" and cluster.master.get(new[str(cluster.a)])["enabled"] == "0"
# re-enable through the master's UUID: the backup's copy is found through the restore operation
e = await preview(cluster.a, [new[str(cluster.a)]], kind="enable")
assert {n["host_name"]: n["items"][0]["uuid"] for n in e["plan"]["nodes"]} == {"fw-a": new[str(cluster.a)], "fw-b": new[str(cluster.b)]}
cluster.log.clear()
r = await deploy(cluster.a, e["preview_id"])
assert r["status"] == "awaiting_confirm"
assert [w for w in _writes(cluster.log) if "toggleRule" in w[1]] == [
(FW2, f"/firewall/filter/toggleRule/{new[str(cluster.b)]}/1"), (FW1, f"/firewall/filter/toggleRule/{new[str(cluster.a)]}/1")]
@pytest.mark.asyncio
async def test_restore_with_a_refused_move_is_verified_by_reading_and_only_warns(env):
await _confirmed_disable(env.host_id, [R_UNUSED])
p = await preview(env.host_id, [R_UNUSED], kind="delete")
await deploy(env.host_id, p["preview_id"])
deleted = next(iter((await _items(p["preview_id"])).values()))
# moveRuleBefore answers HTTP 500 (an Ambiguous error for the client, P3): the position is read back
env.fw.fail[("move", "*")] = httpx.Response(500, json={"errorMessage": "Unexpected error, check log for details"})
result = await call(env.host_id, svc.restore_item, deleted.id, actor="admin", confirm_name="fw-a")
assert result["status"] == "done" and result["warnings"] == ["BEREINIGUNG_POSITION_NICHT_WIEDERHERSTELLBAR"]
@pytest.mark.asyncio
async def test_deadline_passing_while_confirm_reads_the_firewall_refuses(env):
import time
p = await preview(env.host_id, [R_UNUSED])
await deploy(env.host_id, p["preview_id"])
await _backdate(p["preview_id"], revert_deadline=_now() + timedelta(milliseconds=300))
env.fw.fail[("search", "")] = lambda: time.sleep(0.6) # a slow firewall: the window closes during the read
got = await refused(env.host_id, svc.confirm, uuid_lib.UUID(p["preview_id"]))
assert got["code"] == "BEREINIGUNG_FRIST_ABGELAUFEN"
assert (await _op(p["preview_id"])).status == "awaiting_confirm" # left to the sweeper
@pytest.mark.asyncio
async def test_sweeper_leaves_a_live_run_alone_even_past_its_deadline(env, monkeypatch): # review finding 2
from app.services import opnsense_client as client_mod
p = await preview(env.host_id, [R_UNUSED, R_DUP])
real = client_mod.toggle_rule
swept: list = []
async def toggle(host, uuid, enabled): # a second backend's sweeper starts while the first toggle is on the wire
if not swept:
await _backdate(p["preview_id"], revert_deadline=_now() - timedelta(seconds=1))
swept.append(asyncio.create_task(_sweep())) # it gets the lock in the deploy's next checkpoint gap
await asyncio.sleep(0.05)
return await real(host, uuid, enabled)
monkeypatch.setattr(client_mod, "toggle_rule", toggle)
result = await deploy(env.host_id, p["preview_id"])
assert await swept[0] == [] # fresh heartbeat: not taken over although the deadline passed
assert result["status"] == "awaiting_confirm"
assert {i.state for i in (await _items(p["preview_id"])).values()} == {"disabled"}
@pytest.mark.asyncio
async def test_a_run_taken_over_by_another_backend_writes_nothing_more(env, monkeypatch): # review finding 2
from app.services import opnsense_client as client_mod
p = await preview(env.host_id, [R_UNUSED, R_DUP])
real = client_mod.toggle_rule
calls: list = []
async def toggle(host, uuid, enabled):
calls.append(uuid)
if len(calls) == 1: # another backend took the operation over while this toggle was on the wire
await _backdate(p["preview_id"], status="reverting")
return await real(host, uuid, enabled)
monkeypatch.setattr(client_mod, "toggle_rule", toggle)
await deploy(env.host_id, p["preview_id"])
assert len(calls) == 1 # the conditional heartbeat before the second toggle found the operation gone
assert (await _op(p["preview_id"])).status == "reverting" # left to the new owner, not overwritten
states = sorted(i.state for i in (await _items(p["preview_id"])).values())
assert states == ["disabled", "queued"]
@pytest.mark.asyncio
async def test_extra_management_ports_protect_rules_on_them(env): # review finding 3
custom = "aaaaaaaa-0000-4000-8000-000000000910"
env.fw.rules.append(rule(custom, 910, interface="wan", src="203.0.113.0/24", dport="8443", description="gui 8443"))
await _seed_hits(env.host_id, [custom])
ok = await preview(env.host_id, [custom]) # 8443 is not one of the fixed ports: not about management
assert ok["plan"]["ports"] == []
await _settings(ports=(8443,))
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[custom])
assert got["params"]["reason"] == "management"
@pytest.mark.asyncio
async def test_cluster_peer_may_confirm_and_read_but_strangers_may_not(cluster, client, respx_mock): # review finding 4
p = await preview(cluster.a, [R_UNUSED])
await deploy(cluster.a, p["preview_id"])
pid = uuid_lib.UUID(p["preview_id"])
assert (await call(cluster.b, svc.get_operation, pid))["id"] == p["preview_id"]
FakeFirewall("https://203.0.113.59").mount(respx_mock)
other = uuid_lib.UUID((await _create_host(client, "fw-c", "https://203.0.113.59"))["id"])
for fn in (svc.get_operation, svc.confirm, svc.revert_now, svc.retry):
assert (await refused(other, fn, pid))["status"] == 404
got = await refused(cluster.b, svc.cancel, pid, actor="admin") # previews/deploys stay bound to the anchor
assert got["status"] == 404
result = await call(cluster.b, svc.confirm, pid)
assert result["status"] == "confirmed"
async def _half_deleted_cluster(cluster):
p = await preview(cluster.a, [R_UNUSED])
await deploy(cluster.a, p["preview_id"])
await call(cluster.a, svc.confirm, uuid_lib.UUID(p["preview_id"]))
await _backdate(p["preview_id"], confirmed_at=_now() - timedelta(days=8))
d = await preview(cluster.a, [R_UNUSED], kind="delete")
cluster.master.fail[("delete", R_UNUSED)] = httpx.Response(200, json={"result": "failed"})
result = await deploy(cluster.a, d["preview_id"])
assert result["status"] == "failed"
assert cluster.backup.get(R_UNUSED) is None and cluster.master.get(R_UNUSED) is not None
del cluster.master.fail[("delete", R_UNUSED)]
return d["preview_id"]
@pytest.mark.asyncio
async def test_half_done_cluster_delete_can_be_finished(cluster): # review finding 5
await _half_deleted_cluster(cluster)
again = await preview(cluster.a, [R_UNUSED], kind="delete")
assert [(n["host_name"], len(n["items"])) for n in again["plan"]["nodes"]] == [("fw-b", 0), ("fw-a", 1)]
cluster.log.clear()
assert (await deploy(cluster.a, again["preview_id"]))["status"] == "done"
assert [w for w in _writes(cluster.log) if "delRule" in w[1]] == [(FW1, f"/firewall/filter/delRule/{R_UNUSED}")]
assert cluster.master.get(R_UNUSED) is None
got = await refused(cluster.a, svc.preview, actor="admin", kind="delete", uuids=[R_UNUSED]) # nothing left
assert got["params"]["reason"] == "not_candidate"
@pytest.mark.asyncio
async def test_half_done_cluster_delete_can_be_restored_where_it_was_deleted(cluster): # review finding 5
delete_op = await _half_deleted_cluster(cluster)
async with SessionLocal() as db:
deleted = (await db.scalars(select(RuleCleanupItem).where(
RuleCleanupItem.operation_id == uuid_lib.UUID(delete_op), RuleCleanupItem.state == "deleted"))).one()
result = await call(cluster.a, svc.restore_item, deleted.id, actor="admin", confirm_name="fw-a")
assert result["status"] == "done" and [i["host_id"] for i in result["items"]] == [str(cluster.b)]
assert cluster.backup.get(result["items"][0]["restored_uuid"])["enabled"] == "0"
assert cluster.master.get(R_UNUSED)["enabled"] == "0" # the master's copy was never deleted: untouched
@pytest.mark.asyncio
async def test_switching_a_block_rule_back_on_needs_an_acknowledgement_unless_proven_harmless(env): # review finding 7
lock_out = "aaaaaaaa-0000-4000-8000-000000000920"
env.fw.rules.append(rule(lock_out, 920, action="block", interface="wan", src="203.0.113.0/24", dport="443",
enabled="0", description="block mgmt"))
unknown = "aaaaaaaa-0000-4000-8000-000000000940" # source is a URL table: match unknown
env.fw.rules.append(rule(unknown, 940, action="block", interface="wan", src="tbl", dport="443", enabled="0",
description="block table"))
harmless = "aaaaaaaa-0000-4000-8000-000000000930"
env.fw.rules.append(rule(harmless, 930, action="block", proto="UDP", dport="5353", enabled="0", description="block mdns"))
async with SessionLocal() as db: # both were switched off by a confirmed cleanup earlier
op = RuleCleanupOperation(host_id=env.host_id, kind="disable", status="confirmed", actor="admin",
expires_at=_now(), preview_fingerprint="x", options={}, confirmed_at=_now())
db.add(op)
await db.flush()
for u in (lock_out, harmless, unknown):
db.add(RuleCleanupItem(operation_id=op.id, host_id=env.host_id, rule_uuid=u, state="confirmed", description=u))
await db.commit()
got = await refused(env.host_id, svc.preview, actor="admin", kind="enable", uuids=[lock_out, harmless, unknown])
assert got["code"] == "BEREINIGUNG_BESTAETIGUNG_FEHLT" and got["params"]["uuids"] == ", ".join(sorted([lock_out, unknown]))
ok = await preview(env.host_id, [lock_out, harmless, unknown], kind="enable", acknowledge=[lock_out, unknown])
items = {i["uuid"]: i for i in ok["plan"]["nodes"][0]["items"]}
assert items[lock_out]["needs_ack"] and items[lock_out]["notes"] == ["ENABLE_MAY_BLOCK_MANAGEMENT"]
assert not items[harmless]["needs_ack"] and items[harmless]["protection"]["result"] == "false"
async def _unknown_restore(env):
"""A restore whose addRule answer was lost: the item stays unknown."""
await _confirmed_disable(env.host_id, [R_UNUSED])
p = await preview(env.host_id, [R_UNUSED], kind="delete")
await deploy(env.host_id, p["preview_id"])
deleted = next(iter((await _items(p["preview_id"])).values()))
snapshot_row = dict(deleted.snapshot["row"])
env.fw.fail[("add", "")] = httpx.ReadTimeout
result = await call(env.host_id, svc.restore_item, deleted.id, actor="admin", confirm_name="fw-a")
assert result["items"][0]["state"] == "unknown"
del env.fw.fail[("add", "")]
return result, snapshot_row
@pytest.mark.asyncio
async def test_unclear_restore_uses_the_stored_uuid_before_searching_by_content(env): # review finding 10
result, row = await _unknown_restore(env)
# on the firewall: the real copy and a content-identical twin someone made by hand
real, twin = "cccccccc-0000-4000-8000-000000000001", "cccccccc-0000-4000-8000-000000000002"
for u, seq in ((real, 950), (twin, 960)):
env.fw.rules.append({**row, "uuid": u, "sequence": str(seq), "sort_order": f"400000.{seq:07d}"})
async with SessionLocal() as db:
await db.execute(update(RuleCleanupItem).where(RuleCleanupItem.id == uuid_lib.UUID(result["items"][0]["id"]))
.values(restored_uuid=real))
await db.commit()
done = await call(env.host_id, svc.retry, uuid_lib.UUID(result["id"]))
assert done["items"][0]["state"] == "restored" and done["items"][0]["restored_uuid"] == real
@pytest.mark.asyncio
async def test_unclear_restore_with_twins_and_no_stored_uuid_stays_unknown(env): # review finding 10
result, row = await _unknown_restore(env)
for u, seq in (("cccccccc-0000-4000-8000-000000000003", 950), ("cccccccc-0000-4000-8000-000000000004", 960)):
env.fw.rules.append({**row, "uuid": u, "sequence": str(seq), "sort_order": f"400000.{seq:07d}"})
done = await call(env.host_id, svc.retry, uuid_lib.UUID(result["id"]))
assert done["items"][0]["state"] == "unknown" and done["items"][0]["message"] == "BEREINIGUNG_AUSGANG_UNKLAR"
env.fw.rules = [r for r in env.fw.rules if not r["uuid"].startswith("cccccccc")]
done = await call(env.host_id, svc.retry, uuid_lib.UUID(result["id"])) # nothing there: it was never created
assert done["items"][0]["state"] == "failed"
@pytest.mark.asyncio
async def test_an_outdated_acknowledgement_text_is_refused(env): # review finding 11
for version in ("0", "2", ""):
got = await refused(env.host_id, svc.preview, actor="admin", kind="disable", uuids=[R_BLOCK],
acknowledge=[R_BLOCK], ack_text_version=version)
assert got["status"] == 409 and got["code"] == "BEREINIGUNG_BESTAETIGUNGSTEXT_VERALTET", version
assert env.fw.calls == [] # refused before anything is read
ok = await preview(env.host_id, [R_BLOCK], acknowledge=[R_BLOCK], ack_text_version=svc.ACK_TEXT_VERSION)
assert ok["plan"]["acknowledged"] == [R_BLOCK]