Files
OPNexus/backend/tests/test_policy_workflow.py
T
Julian Boldt e26b26eca6
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 v1.10.1-rc2
Siehe CHANGELOG.md
2026-10-07 09:34:26 +02:00

237 lines
13 KiB
Python

"""Management changes must stay local until an explicit push."""
import asyncio
from datetime import datetime, timedelta, timezone
import uuid
import httpx
from sqlalchemy import select
from app.database import SessionLocal
from app.models.rule_deployment import RuleDeployment
from app.services.rule_deploy_service import sweep_expired_deployments
from tests.test_rules import _create_host, _rule_payload
def mock_status(respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
def mock_push(respx_mock, name="fw1"):
add = respx_mock.post(f"https://{name}.test/api/firewall/filter/addRule").mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": f"{name}-rule"}))
respx_mock.post(f"https://{name}.test/api/firewall/filter/apply").mock(return_value=httpx.Response(200, json={"status": "ok"}))
# Only actually hit for multi-host deploys (the interface/alias
# consistency checks short-circuit for a single target) — same label
# on every host and no matching aliases here, since these tests aren't
# exercising the opt6/opt7-style mismatch.
respx_mock.get(f"https://{name}.test/api/firewall/filter/getRule").mock(
return_value=httpx.Response(200, json={"rule": {"interface": {"lan": {"value": "LAN", "selected": 0}, "wan": {"value": "WAN", "selected": 0}}}})
)
respx_mock.get(f"https://{name}.test/api/firewall/alias/searchItem").mock(
return_value=httpx.Response(200, json={"total": 0, "rowCount": 0, "current": 1, "rows": []})
)
return add
async def rule(client, hosts=None, **extra):
response = await client.post("/api/rules", json=_rule_payload(hosts or [], **extra))
assert response.status_code == 201, response.text
return response.json()
async def group(client, hosts, name="Branch"):
response = await client.post("/api/groups", json={"name": name, "host_ids": hosts})
assert response.status_code == 201, response.text
return response.json()
async def test_group_targets_deduplicated_and_disabled_hosts_skipped(client, respx_mock):
mock_status(respx_mock)
h1 = await _create_host(client, "fw1", "https://fw1.test")
h2 = await _create_host(client, "fw2", "https://fw2.test")
await client.patch(f"/api/hosts/{h2['id']}", json={"enabled": False})
g = await group(client, [h1["id"], h2["id"]])
r = await rule(client, [h1["id"]], target_group_ids=[g["id"]])
assert set(r["effective_host_ids"]) == {h1["id"], h2["id"]}
plan = (await client.get(f"/api/rules/{r['id']}/preview")).json()
assert [h["action"] for h in plan["hosts"]] == ["create", "disabled"]
add = mock_push(respx_mock)
result = await client.post(f"/api/rules/{r['id']}/deploy", json={"preview_token": plan["token"]})
assert result.status_code == 200, result.text
assert len(result.json()["deployments"]) == 1
assert add.call_count == 1
async def test_group_edits_invalidate_push_preview_without_firewall_writes(client, respx_mock):
mock_status(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
g = await group(client, [h["id"]])
r = await rule(client, target_group_ids=[g["id"]])
plan = (await client.get(f"/api/rules/{r['id']}/preview")).json()
response = await client.put(f"/api/groups/{g['id']}", json={"name": g["name"], "host_ids": []})
assert response.status_code == 200
response = await client.post(f"/api/rules/{r['id']}/deploy", json={"preview_token": plan["token"]})
assert response.status_code == 409
assert not any(c.request.method == "POST" for c in respx_mock.calls)
async def test_edit_history_restore_and_stale_editor_rejected(client):
r = await rule(client)
payload = _rule_payload([], description="Block SSH", action="block", expected_updated_at=r["updated_at"])
updated = await client.put(f"/api/rules/{r['id']}", json=payload)
assert updated.status_code == 200, updated.text
assert (await client.put(f"/api/rules/{r['id']}", json=payload)).status_code == 409
history = (await client.get(f"/api/rules/{r['id']}/revisions")).json()
assert len(history) == 2
assert history[0]["snapshot"]["action"] == "block"
restored = await client.put(f"/api/rules/{r['id']}", json={**history[1]["snapshot"], "expected_updated_at": updated.json()["updated_at"]})
assert restored.status_code == 200
assert restored.json()["action"] == "pass"
assert len((await client.get(f"/api/rules/{r['id']}/revisions")).json()) == 3
async def test_concurrent_edits_only_one_wins(client):
r = await rule(client)
a, b = await asyncio.gather(*[client.put(f"/api/rules/{r['id']}", json=_rule_payload([], description=name, expected_updated_at=r["updated_at"])) for name in ("A", "B")])
assert sorted([a.status_code, b.status_code]) == [200, 409]
async def test_pending_cannot_be_edited_or_confirmed_by_redeploy(client, respx_mock):
mock_status(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
r = await rule(client, [h["id"]])
add = mock_push(respx_mock)
deployed = (await client.post(f"/api/rules/{r['id']}/deploy")).json()
deadline = deployed["deployments"][0]["revert_deadline"]
assert (await client.post(f"/api/rules/{r['id']}/deploy")).status_code == 409
assert (await client.put(f"/api/rules/{r['id']}", json=_rule_payload([h["id"]], expected_updated_at=r["updated_at"]))).status_code == 409
current = (await client.get("/api/rules")).json()[0]
assert current["deployments"][0]["status"] == "pending"
assert current["deployments"][0]["revert_deadline"] == deadline
assert add.call_count == 1
async def test_edited_rule_push_and_rollback_restore_previous_payload(client, respx_mock):
mock_status(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
r = await rule(client, [h["id"]])
mock_push(respx_mock)
await client.post(f"/api/rules/{r['id']}/deploy")
await client.post(f"/api/rules/{r['id']}/confirm")
updated = await client.put(f"/api/rules/{r['id']}", json=_rule_payload([h["id"]], action="block", expected_updated_at=r["updated_at"]))
assert updated.status_code == 200, updated.text
plan = (await client.get(f"/api/rules/{r['id']}/preview")).json()
assert {"field": "action", "before": "pass", "after": "block"} in plan["hosts"][0]["changes"]
set_route = respx_mock.post("https://fw1.test/api/firewall/filter/setRule/fw1-rule").mock(return_value=httpx.Response(200, json={"result": "saved"}))
response = await client.post(f"/api/rules/{r['id']}/deploy", json={"preview_token": plan["token"]})
assert response.status_code == 200, response.text
async with SessionLocal() as db:
d = (await db.scalars(select(RuleDeployment))).one()
assert d.previous_payload["action"] == "pass"
assert d.last_payload["action"] == "block"
d.revert_deadline = datetime.now(timezone.utc) - timedelta(seconds=1)
await db.commit()
await sweep_expired_deployments(db)
assert d.last_payload["action"] == "pass"
assert set_route.call_count == 2
async def test_removed_targets_remain_visible_and_group_in_use_cannot_be_deleted(client, respx_mock):
mock_status(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
g = await group(client, [h["id"]])
r = await rule(client, target_group_ids=[g["id"]])
mock_push(respx_mock)
await client.post(f"/api/rules/{r['id']}/deploy")
await client.post(f"/api/rules/{r['id']}/confirm")
assert (await client.delete(f"/api/groups/{g['id']}")).status_code == 409
await client.put(f"/api/groups/{g['id']}", json={"name": g["name"], "host_ids": []})
plan = (await client.get(f"/api/rules/{r['id']}/preview")).json()
assert plan["hosts"][0]["action"] == "retained"
assert plan["hosts"][0]["host_name"] == "fw1"
assert not plan["can_deploy"]
assert (await client.get("/api/rules")).json()[0]["deployments"][0]["host_name"] == "fw1"
async def test_invalid_group_and_missing_members_rejected(client):
assert (await client.post("/api/groups", json={"name": " "})).status_code == 422
assert (await client.post("/api/groups", json={"name": "Bad", "host_ids": [str(uuid.uuid4())]})).status_code == 400
await group(client, [], "Unique")
assert (await client.post("/api/groups", json={"name": "Unique"})).status_code == 409
assert (await client.post("/api/rules", json=_rule_payload([], target_group_ids=[str(uuid.uuid4())]))).status_code == 400
async def test_new_endpoints_require_authentication(anon_client):
for path in ("/api/groups", f"/api/rules/{uuid.uuid4()}/revisions", f"/api/rules/{uuid.uuid4()}/preview"):
assert (await anon_client.get(path)).status_code == 401
async def test_apply_failure_preserves_uuid_and_retry_updates_existing_rule(client, respx_mock):
mock_status(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
r = await rule(client, [h["id"]])
add = mock_push(respx_mock)
apply = respx_mock.post("https://fw1.test/api/firewall/filter/apply").mock(side_effect=httpx.ConnectError("lost connection"))
response = await client.post(f"/api/rules/{r['id']}/deploy")
assert response.status_code == 200
assert response.json()["deployments"][0]["status"] == "error"
apply.mock(return_value=httpx.Response(200, json={"status": "ok"}))
update = respx_mock.post("https://fw1.test/api/firewall/filter/setRule/fw1-rule").mock(return_value=httpx.Response(200, json={"result": "saved"}))
response = await client.post(f"/api/rules/{r['id']}/deploy")
assert response.status_code == 200, response.text
assert response.json()["deployments"][0]["status"] == "pending"
assert add.call_count == 1
assert update.call_count == 1
async def test_failed_delete_retains_tracking_for_retry(client, respx_mock):
mock_status(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
r = await rule(client, [h["id"]])
mock_push(respx_mock)
await client.post(f"/api/rules/{r['id']}/deploy")
await client.post(f"/api/rules/{r['id']}/confirm")
delete = respx_mock.post("https://fw1.test/api/firewall/filter/delRule/fw1-rule").mock(side_effect=httpx.ConnectError("lost connection"))
response = await client.delete(f"/api/rules/{r['id']}")
assert response.status_code == 502, response.text
remaining = (await client.get("/api/rules")).json()
assert remaining[0]["deployments"][0]["status"] == "error"
delete.mock(return_value=httpx.Response(200, json={"status": "ok"}))
assert (await client.delete(f"/api/rules/{r['id']}")).status_code == 204
assert (await client.get("/api/rules")).json() == []
async def test_additive_schema_upgrade_keeps_existing_hosts_and_rules(client, respx_mock):
from app.database import Base, engine
from app.models.policy import DeviceGroup, RuleRevision, AliasRevision, group_members, rule_groups, alias_groups
from app.models.alias import FirewallAlias
from app.models.alias_deployment import AliasDeployment
from app.models.alias_target import alias_targets
mock_status(respx_mock)
h = await _create_host(client, "fw1", "https://fw1.test")
r = await rule(client, [h["id"]])
# Simulate the previous schema: the new tables do not exist yet.
# policy_hierarchy_groups is kept alongside opnsense_hosts (unlike the
# other newer tables dropped below) because opnsense_hosts.
# policy_hierarchy_group_id is a real FK to it in the current models —
# in production that FK column only exists after the documented manual
# ALTER TABLE step (see README), so it can never actually predate its
# own referenced table the way this drop/recreate simulation would
# otherwise require.
async with engine.begin() as conn:
await conn.run_sync(lambda sync: Base.metadata.drop_all(sync, tables=[
table for table in Base.metadata.sorted_tables
if table.name not in {"opnsense_hosts", "firewall_rules", "rule_targets", "rule_deployments",
"admin_users", "sessions", "policy_hierarchy_groups"}
]))
await conn.run_sync(Base.metadata.create_all)
current = (await client.get("/api/rules")).json()[0]
assert current["id"] == r["id"]
assert current["target_host_ids"] == [h["id"]]
assert (await client.get("/api/hosts")).json()[0]["id"] == h["id"]
assert (await client.get(f"/api/rules/{r['id']}/revisions")).json() == []
response = await client.put(f"/api/rules/{r['id']}", json=_rule_payload([h["id"]], description="Edited legacy rule", expected_updated_at=r["updated_at"]))
assert response.status_code == 200, response.text
history = (await client.get(f"/api/rules/{r['id']}/revisions")).json()
assert len(history) == 2
assert history[1]["snapshot"]["description"] == r["description"]