Files
OPNexus/backend/tests/test_ipsec_deploy_safety.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

233 lines
11 KiB
Python

"""Covers IpsecTunnel's deliberately simpler status model (draft/success/
error only — see ipsec_deploy_service.py's module docstring) — unlike
test_deploy_safety.py for FirewallRule, there is no pending/confirm/sweep
behaviour to test here, because those states don't exist for this entity.
What matters instead: a failed config-write surfaces as "error" with a
concrete message, and a failed reconfigure (after all 5 config writes
already succeeded) surfaces as "error" with the SPECIFIC message the plan
requires — not a generic exception string — while still recording that the
config itself was written (no rollback exists, so partial state is kept
and reported honestly rather than hidden)."""
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.ipsec_tunnel_deployment import IpsecTunnelDeployment
from tests.test_ipsec import _create_host, _tunnel_payload
from tests.helpers import deploy_approved
def _mock_ipsec_enabled(respx_mock, base_url: str, enabled: bool = True):
# The service-wide switch is checked before every deploy (live 26.7.6:
# off by default, and reconfigure still answers ok while it is off).
respx_mock.get(f"{base_url}/api/ipsec/connections/is_enabled").mock(
return_value=httpx.Response(200, json={"enabled": enabled})
)
def _mock_full_success(respx_mock, base_url: str, enabled: bool = True):
_mock_ipsec_enabled(respx_mock, base_url, enabled)
respx_mock.post(f"{base_url}/api/ipsec/connections/addConnection").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "conn-1"})
)
respx_mock.post(f"{base_url}/api/ipsec/connections/addLocal").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "local-1"})
)
respx_mock.post(f"{base_url}/api/ipsec/connections/addRemote").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "remote-1"})
)
respx_mock.post(f"{base_url}/api/ipsec/connections/addChild").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "child-1"})
)
respx_mock.post(f"{base_url}/api/ipsec/preSharedKeys/addItem").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "psk-1"})
)
respx_mock.post(f"{base_url}/api/ipsec/service/reconfigure").mock(
return_value=httpx.Response(200, json={"status": "ok"})
)
@pytest.mark.asyncio
async def test_deploy_success_persists_all_five_uuids(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
resp = await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "success"
assert dep["message"] is None
async with SessionLocal() as db:
row = (await db.execute(select(IpsecTunnelDeployment))).scalar_one()
assert row.opnsense_connection_uuid == "conn-1"
assert row.opnsense_local_uuid == "local-1"
assert row.opnsense_remote_uuid == "remote-1"
assert row.opnsense_child_uuid == "child-1"
assert row.opnsense_psk_uuid == "psk-1"
assert row.last_payload is not None
@pytest.mark.asyncio
async def test_deploy_config_write_failure_surfaces_as_error_without_rollback(client, respx_mock):
"""addChild fails after Connection/Local/Remote already succeeded —
status must be "error" with a concrete message, and the 3 UUIDs that
DID get written must still be persisted (no rollback exists for this
entity, and a retry should update those 3 rather than duplicate them)."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
respx_mock.post("https://fw1.test/api/ipsec/connections/addConnection").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "conn-1"})
)
respx_mock.post("https://fw1.test/api/ipsec/connections/addLocal").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "local-1"})
)
respx_mock.post("https://fw1.test/api/ipsec/connections/addRemote").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "remote-1"})
)
respx_mock.post("https://fw1.test/api/ipsec/connections/addChild").mock(
side_effect=httpx.ConnectError("connection refused")
)
reconfigure_route = respx_mock.post("https://fw1.test/api/ipsec/service/reconfigure").mock(
return_value=httpx.Response(200, json={"status": "ok"})
)
resp = await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "error"
assert dep["message"]
# Reconfigure must never be reached — the config write itself failed.
assert not reconfigure_route.called
async with SessionLocal() as db:
row = (await db.execute(select(IpsecTunnelDeployment))).scalar_one()
assert row.opnsense_connection_uuid == "conn-1"
assert row.opnsense_local_uuid == "local-1"
assert row.opnsense_remote_uuid == "remote-1"
assert row.opnsense_child_uuid is None
assert row.opnsense_psk_uuid is None
@pytest.mark.asyncio
async def test_deploy_reconfigure_failure_uses_exact_specified_message(client, respx_mock):
"""All 5 config writes succeed, but the follow-up reconfigure call
fails — the plan specifies an exact, non-generic message here because
"config written, reload unconfirmed" is a materially different failure
mode from "config write itself failed"."""
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
respx_mock.post("https://fw1.test/api/ipsec/connections/addConnection").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "conn-1"})
)
respx_mock.post("https://fw1.test/api/ipsec/connections/addLocal").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "local-1"})
)
respx_mock.post("https://fw1.test/api/ipsec/connections/addRemote").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "remote-1"})
)
respx_mock.post("https://fw1.test/api/ipsec/connections/addChild").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "child-1"})
)
respx_mock.post("https://fw1.test/api/ipsec/preSharedKeys/addItem").mock(
return_value=httpx.Response(200, json={"result": "saved", "uuid": "psk-1"})
)
_mock_ipsec_enabled(respx_mock, "https://fw1.test")
respx_mock.post("https://fw1.test/api/ipsec/service/reconfigure").mock(
side_effect=httpx.ConnectError("connection refused")
)
resp = await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "error"
assert dep["message"] == "Konfiguration wurde geschrieben, Dienst-Reload aber nicht bestätigt — Erreichbarkeit manuell prüfen"
async with SessionLocal() as db:
row = (await db.execute(select(IpsecTunnelDeployment))).scalar_one()
# All 5 objects were in fact written — no rollback happens.
assert row.opnsense_connection_uuid == "conn-1"
assert row.opnsense_local_uuid == "local-1"
assert row.opnsense_remote_uuid == "remote-1"
assert row.opnsense_child_uuid == "child-1"
assert row.opnsense_psk_uuid == "psk-1"
assert row.last_payload is not None
@pytest.mark.asyncio
async def test_redeploy_unchanged_skips_opnsense_entirely(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
add_route = respx_mock.post("https://fw1.test/api/ipsec/connections/addConnection")
resp = await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
dep = resp.json()["deployments"][0]
assert dep["status"] == "success"
assert add_route.call_count == 1
@pytest.mark.asyncio
async def test_undeploy_deletes_child_local_remote_connection_then_psk(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test")
await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
del_child = respx_mock.post("https://fw1.test/api/ipsec/connections/delChild/child-1").mock(
return_value=httpx.Response(200, json={"result": "deleted"})
)
del_local = respx_mock.post("https://fw1.test/api/ipsec/connections/delLocal/local-1").mock(
return_value=httpx.Response(200, json={"result": "deleted"})
)
del_remote = respx_mock.post("https://fw1.test/api/ipsec/connections/delRemote/remote-1").mock(
return_value=httpx.Response(200, json={"result": "deleted"})
)
del_conn = respx_mock.post("https://fw1.test/api/ipsec/connections/delConnection/conn-1").mock(
return_value=httpx.Response(200, json={"result": "deleted"})
)
del_psk = respx_mock.post("https://fw1.test/api/ipsec/preSharedKeys/delItem/psk-1").mock(
return_value=httpx.Response(200, json={"result": "deleted"})
)
resp = await client.delete(f"/api/ipsec/tunnels/{tunnel_id}")
assert resp.status_code == 204, resp.text
assert del_child.called and del_local.called and del_remote.called and del_conn.called and del_psk.called
@pytest.mark.asyncio
async def test_deploy_switches_on_the_global_ipsec_service_when_disabled(client, respx_mock):
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
host = await _create_host(client, "fw1", "https://fw1.test")
resp = await client.post("/api/ipsec/tunnels", json=_tunnel_payload([host["id"]]))
tunnel_id = resp.json()["id"]
_mock_full_success(respx_mock, "https://fw1.test", enabled=False)
toggle = respx_mock.post("https://fw1.test/api/ipsec/connections/toggle/1").mock(
return_value=httpx.Response(200, json={"status": "ok"})
)
resp = await deploy_approved(client, f"/api/ipsec/tunnels/{tunnel_id}/deploy")
assert resp.status_code == 200, resp.text
assert resp.json()["deployments"][0]["status"] == "success"
assert toggle.call_count == 1