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
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.
107 lines
4.5 KiB
Python
107 lines
4.5 KiB
Python
import httpx
|
|
import pytest
|
|
from tests.test_rules import _create_host
|
|
from tests.test_users import _login_as
|
|
|
|
|
|
def _mock_vip(respx_mock):
|
|
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
|
|
|
|
|
def _mock_service_list(respx_mock):
|
|
respx_mock.get("https://fw1.test/api/core/service/search").respond(
|
|
200,
|
|
json={"rows": [
|
|
{"id": "unbound", "name": "unbound", "locked": 0, "running": 1},
|
|
{"id": "ntpd", "name": "ntpd", "locked": 0, "running": 1},
|
|
{"id": "pf", "name": "pf", "locked": 1, "running": 1},
|
|
]},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_services(client, respx_mock):
|
|
_mock_vip(respx_mock)
|
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
|
respx_mock.get("https://fw1.test/api/core/service/search").respond(
|
|
200, json={"rows": [{"id": "unbound", "name": "unbound", "description": "Unbound DNS", "locked": 0, "running": 1}]}
|
|
)
|
|
r = await client.get(f"/api/hosts/{h['id']}/services")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["rows"][0]["id"] == "unbound"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_service_success(client, respx_mock):
|
|
_mock_vip(respx_mock)
|
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
|
_mock_service_list(respx_mock)
|
|
restart = respx_mock.post("https://fw1.test/api/core/service/restart/unbound").respond(200, json={"result": "ok"})
|
|
r = await client.post(f"/api/hosts/{h['id']}/services/unbound/restart")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["result"] == "ok"
|
|
assert restart.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_and_stop_service(client, respx_mock):
|
|
_mock_vip(respx_mock)
|
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
|
_mock_service_list(respx_mock)
|
|
start = respx_mock.post("https://fw1.test/api/core/service/start/ntpd").respond(200, json={"result": "ok"})
|
|
stop = respx_mock.post("https://fw1.test/api/core/service/stop/ntpd").respond(200, json={"result": "ok"})
|
|
r1 = await client.post(f"/api/hosts/{h['id']}/services/ntpd/start")
|
|
r2 = await client.post(f"/api/hosts/{h['id']}/services/ntpd/stop")
|
|
assert r1.status_code == 200 and r2.status_code == 200
|
|
assert start.call_count == 1 and stop.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_service_failure_result_is_rejected(client, respx_mock):
|
|
_mock_vip(respx_mock)
|
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
|
_mock_service_list(respx_mock)
|
|
respx_mock.post("https://fw1.test/api/core/service/restart/unbound").respond(200, json={"result": "failed"})
|
|
r = await client.post(f"/api/hosts/{h['id']}/services/unbound/restart")
|
|
assert r.status_code == 502, r.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_only_user_can_list_but_not_restart(client, respx_mock):
|
|
_mock_vip(respx_mock)
|
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
|
respx_mock.get("https://fw1.test/api/core/service/search").respond(200, json={"rows": []})
|
|
await client.post("/api/users", json={"username": "viewer-svc", "password": "password-123456", "role": "read_only"})
|
|
viewer = await _login_as("viewer-svc", "password-123456")
|
|
try:
|
|
listed = await viewer.get(f"/api/hosts/{h['id']}/services")
|
|
assert listed.status_code == 200, listed.text
|
|
blocked = await viewer.post(f"/api/hosts/{h['id']}/services/unbound/restart")
|
|
assert blocked.status_code == 403
|
|
finally:
|
|
await viewer.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_service_is_rejected_without_calling_the_firewall(client, respx_mock):
|
|
_mock_vip(respx_mock)
|
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
|
_mock_service_list(respx_mock)
|
|
start = respx_mock.post(url__regex=r".*/api/core/service/start/.*").respond(200, json={"result": "ok"})
|
|
r = await client.post(f"/api/hosts/{h['id']}/services/doesnotexist/start")
|
|
assert r.status_code == 404, r.text
|
|
assert r.json()["detail"]["code"] == "SERVICE_NOT_FOUND"
|
|
assert start.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_locked_service_cannot_be_controlled_via_api(client, respx_mock):
|
|
_mock_vip(respx_mock)
|
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
|
_mock_service_list(respx_mock)
|
|
stop = respx_mock.post(url__regex=r".*/api/core/service/stop/.*").respond(200, json={"result": "ok"})
|
|
r = await client.post(f"/api/hosts/{h['id']}/services/pf/stop")
|
|
assert r.status_code == 409, r.text
|
|
assert r.json()["detail"]["code"] == "SERVICE_LOCKED"
|
|
assert stop.call_count == 0
|