Files
OPNexus/backend/tests/test_carp_auto_revert.py
T
Julian Boldt 1fc2d2c87c
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.11.1
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:

- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
  endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
  `/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
  Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
  gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
  es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
  `cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
  Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
  `/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
  Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
  (VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
  kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
  er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
  unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
  `SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
  Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
  („instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
  Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
  Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
  („Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
  einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
  und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
  Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
  sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
  Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
  „Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
  zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
  noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
  der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
  abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
  Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
  ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
  die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
  Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
  auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
  Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
  Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
  Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
  WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
  allen Seiten, hell und dunkel, nichts mehr.

- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
  blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
  bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
  Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
  jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
  (Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
  sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
  Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
  das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
2026-10-09 18:35:53 +02:00

246 lines
11 KiB
Python

import uuid
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models import HostOperation
from app.models.carp_maintenance_window import CarpMaintenanceWindow
from app.services import carp_maintenance_window_service, notification_checks
from tests.test_rules import _create_host
async def _backdate_and_acknowledge(client, host, operation_id: str):
async with SessionLocal() as db:
op = await db.get(HostOperation, uuid.UUID(operation_id))
op.started_at = datetime.now(timezone.utc) - timedelta(seconds=6)
await db.commit()
resp = await client.post(f"/api/hosts/{host['id']}/maintenance/{operation_id}/acknowledge", json={"confirm_name": host["name"]})
assert resp.status_code == 200, resp.text
def _stateful_cluster(respx_mock):
"""Same stateful role/maintenance mock shape as
test_maintenance.py::test_carp_handover_and_return_are_preview_bound_and_verified,
reused here since the auto-revert sweep needs the same realistic
toggle-flips-the-role behavior to verify against."""
roles = {
"https://fw-primary.test": {"role": "MASTER", "maintenance": False},
"https://fw-secondary.test": {"role": "BACKUP", "maintenance": False},
}
for base in roles:
def vip_response(request, base=base):
current = roles[base]
return httpx.Response(200, json={
"rows": [{"vhid": "1", "status": current["role"]}],
"carp": {"allow": "1", "maintenancemode": current["maintenance"], "demotion": "240" if current["maintenance"] else "0", "status_msg": ""},
})
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=vip_response)
respx_mock.get(f"{base}/api/core/firmware/running").respond(200, json={"status": "ready"})
def toggle(request):
current = roles["https://fw-primary.test"]
current["maintenance"] = not current["maintenance"]
if current["maintenance"]:
current["role"] = "BACKUP"; roles["https://fw-secondary.test"]["role"] = "MASTER"; action = "enter_maintenance"
else:
current["role"] = "MASTER"; roles["https://fw-secondary.test"]["role"] = "BACKUP"; action = "leave_maintenance"
return httpx.Response(200, json={"status": "ok", "action": action})
switch = respx_mock.post("https://fw-primary.test/api/diagnostics/interface/carp_status/maintenance").mock(side_effect=toggle)
return roles, switch
async def _make_cluster(client):
primary = await _create_host(client, "fw-primary", "https://fw-primary.test")
secondary = await _create_host(client, "fw-secondary", "https://fw-secondary.test")
await client.patch(f"/api/hosts/{primary['id']}", json={"cluster_name": "ha"})
await client.patch(f"/api/hosts/{secondary['id']}", json={"cluster_name": "ha"})
return primary, secondary
@pytest.mark.asyncio
async def test_carp_execute_with_auto_revert_creates_window(client, respx_mock):
_stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
resp = await client.post(
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
json={"preview_id": plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True, "auto_revert_minutes": 15},
)
assert resp.status_code == 200, resp.text
async with SessionLocal() as db:
window = await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"]))
assert window is not None
assert window.status == "active"
assert window.auto_revert_at > datetime.now(timezone.utc)
await _backdate_and_acknowledge(client, primary, resp.json()["id"])
# The preview for the follow-up carp_leave should surface the window.
leave_plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
assert leave_plan["state"]["auto_revert_window"] is not None
@pytest.mark.asyncio
async def test_manual_carp_leave_cancels_pending_window(client, respx_mock):
_stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
entered = await client.post(
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
json={"preview_id": plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True, "auto_revert_minutes": 30},
)
await _backdate_and_acknowledge(client, primary, entered.json()["id"])
leave_plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
assert leave_plan["action"] == "carp_leave"
resp = await client.post(
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
json={"preview_id": leave_plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True},
)
assert resp.status_code == 200, resp.text
async with SessionLocal() as db:
window = await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"]))
assert window is None
all_windows = (await db.execute(select(CarpMaintenanceWindow))).scalars().all()
assert len(all_windows) == 1
assert all_windows[0].status == "cancelled_manual"
@pytest.mark.asyncio
async def test_sweep_ignores_window_not_yet_due(client, respx_mock):
roles, switch = _stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
await client.post(
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
json={"preview_id": plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True, "auto_revert_minutes": 30},
)
assert switch.call_count == 1
async with SessionLocal() as db:
resolved = await carp_maintenance_window_service.sweep_expired(db)
assert resolved == []
assert switch.call_count == 1
@pytest.mark.asyncio
async def test_sweep_reverts_expired_window(client, respx_mock):
roles, switch = _stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
await client.post(
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
json={"preview_id": plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True, "auto_revert_minutes": 30},
)
assert roles["https://fw-primary.test"]["maintenance"] is True
async with SessionLocal() as db:
window = await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"]))
window.auto_revert_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await db.commit()
resolved = await carp_maintenance_window_service.sweep_expired(db)
assert len(resolved) == 1
assert resolved[0].status == "reverted"
assert switch.call_count == 2
assert roles["https://fw-primary.test"]["maintenance"] is False
async with SessionLocal() as db:
assert await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"])) is None
async def _enter_with_expired_window(client, primary):
plan = (await client.post(f"/api/hosts/{primary['id']}/maintenance/carp-preview")).json()
await client.post(
f"/api/hosts/{primary['id']}/maintenance/carp-execute",
json={"preview_id": plan["preview_id"], "confirm_name": "fw-primary", "acknowledge_downtime": True, "auto_revert_minutes": 30},
)
async with SessionLocal() as db:
window = await carp_maintenance_window_service.active_window(db, uuid.UUID(primary["id"]))
window.auto_revert_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await db.commit()
return plan["preview_id"]
async def test_sweep_revert_closes_the_enter_operation_and_unblocks_deploys(client, respx_mock):
"""Nach dem Auto-Revert darf die carp_enter-Aktion nicht offen bleiben:
sie ließe sich nie mehr bestätigen und blockiert sonst alle Deploys im Cluster."""
from app.services.host_maintenance_service import ACTIVE, ensure_no_maintenance
roles, switch = _stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
op_id = await _enter_with_expired_window(client, primary)
async with SessionLocal() as db:
op = await db.get(HostOperation, uuid.UUID(op_id))
assert op.status == "submitted"
await carp_maintenance_window_service.sweep_expired(db)
async with SessionLocal() as db:
op = await db.get(HostOperation, uuid.UUID(op_id))
assert op.status == "cancelled" and op.status not in ACTIVE
assert "Automatisch" in op.message
host = await db.get(__import__("app.models.host", fromlist=["OpnsenseHost"]).OpnsenseHost, uuid.UUID(primary["id"]))
await ensure_no_maintenance(db, [host]) # raises if still blocked
async def test_sweep_failed_revert_keeps_the_enter_operation_open(client, respx_mock):
"""Schlägt der automatische Rückschalt-Versuch fehl, muss ein Mensch nachsehen:
die Aktion bleibt offen."""
roles, switch = _stateful_cluster(respx_mock)
primary, secondary = await _make_cluster(client)
op_id = await _enter_with_expired_window(client, primary)
switch.mock(side_effect=httpx.ConnectError("boom"))
async with SessionLocal() as db:
resolved = await carp_maintenance_window_service.sweep_expired(db)
assert resolved[0].status == "revert_failed"
async with SessionLocal() as db:
assert (await db.get(HostOperation, uuid.UUID(op_id))).status == "submitted"
@pytest.mark.asyncio
async def test_sweep_marks_revert_failed_on_toggle_error(client, respx_mock):
respx_mock.get("https://fw-a.test/api/diagnostics/interface/getVipStatus").mock(
return_value=httpx.Response(200, json={"rows": [{"vhid": "1", "status": "BACKUP"}], "carp": {"allow": "1", "maintenancemode": True, "demotion": "240"}})
)
a = await _create_host(client, "fw-a", "https://fw-a.test")
async with SessionLocal() as db:
from app.models.host import OpnsenseHost
host = await db.get(OpnsenseHost, uuid.UUID(a["id"]))
window = await carp_maintenance_window_service.open_window(db, host, 30)
window.auto_revert_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await db.commit()
respx_mock.post("https://fw-a.test/api/diagnostics/interface/carp_status/maintenance").mock(side_effect=httpx.ConnectError("down"))
async with SessionLocal() as db:
resolved = await carp_maintenance_window_service.sweep_expired(db)
assert len(resolved) == 1
assert resolved[0].status == "revert_failed"
async with SessionLocal() as db:
problems = await notification_checks.check_carp_auto_revert_failed(db)
assert len(problems) == 1
assert next(iter(problems.values()))["host_name"] == "fw-a"
@pytest.mark.asyncio
async def test_settings_roundtrip_carp_auto_revert_failed_toggle(client):
resp = await client.get("/api/notifications/settings")
assert resp.json()["carp_auto_revert_failed_enabled"] is True
resp = await client.put("/api/notifications/settings", json={"carp_auto_revert_failed_enabled": False})
assert resp.status_code == 200, resp.text
assert resp.json()["carp_auto_revert_failed_enabled"] is False