Files
OPNexus/backend/tests/test_api_rights_gateway_mute.py
T
Julian Boldt d93b154660
Release / verify (push) Waiting to run
Release / test (push) Blocked by required conditions
CI / backend (push) Blocked by required conditions
CI / frontend (push) Blocked by required conditions
CI / checks (push) Blocked by required conditions
Release v1.33.0
- **Aus dem Testlauf 1.31.0 behoben (T2 bis T6, B15):**
  - **Benutzerliste:** `GET /api/users` zeigte jedem angemeldeten Konto (auch Nur-Lese, auch per Token) alle Benutzernamen, Rollen, letzte Anmeldung und 2FA-Status.
    Jetzt sehen **nur Admins** die ganze Liste, alle anderen nur das eigene Konto.
  - **Hosts:** Dieselbe Adresse zweimal einzutragen wird abgelehnt (`HOST_URL_EXISTS`, auch beim Ändern der Adresse). Die Hosts-Seite und der Einrichtungsassistent
    prüfen die Verbindung beim Anlegen (Schlüssel abgelehnt / zu wenig Rechte / Zertifikat / keine Verbindung), speichern bei einem Fehler nichts und bieten
    „Trotzdem anlegen“. Über die API ist das `verify_connection: true` (Standard aus). **Löschen verlangt jetzt auch am Server den Hostnamen:**
    `DELETE /api/hosts/{id}?confirm_name=<Name>` (sonst 409); Skripte, die Hosts löschen, müssen das anpassen.
  - **Zertifikate:** `cert_type: v3_ca` wird nicht mehr angenommen (es entstand ein Zertifikat mit CA-Erweiterung, keine nutzbare CA); die Oberfläche bot es nie an.
  - **Traceroute:** Zwischenstationen ohne Antwort stehen als Zeile „keine Antwort“ in der Liste (vorher sprang die Zählung 1 → 3); ein Weg ganz ohne Antwort wird erklärt.
  - **Netzwerk:** Die Vorschau prüft Zahlenbereiche vorab (VLAN-Tag 1–4094, PCP 0–7, VHID 1–255, Basis 1–254, Skew 0–254, gegen das Modell der OPNsense gelesen): `422` mit dem
    Feldnamen statt `502` von der Firewall.
  - **Gruppen:** Eine Gruppe, die ein VPN-Objekt als Ziel hat, lässt sich nicht mehr löschen (die Verknüpfung fiel still weg).
  - Das Skript `scripts/verify_i18n.mjs` läuft jetzt auch gegen die HTTPS-Test-Instanz.
- **Regel-Bereinigung: OPNexus selbst ist automatisch geschützt (B10).** Beim Planen fragt die Bereinigung jede Firewall, von welcher Adresse sie OPNexus kommen sieht
  (die eigene API-Verbindung in der Zustandstabelle), und schützt diese Adresse zusätzlich zu den Verwaltungsquellen. Vorher konnte eine Regel abgeschaltet werden, über
  die nur OPNexus die Firewall erreicht, wenn in den Quellen nur Arbeitsplätze standen; dann scheiterte auch das automatische Zurücksetzen. Die Vorschau zeigt die Adresse
  je Firewall (nur Admins) oder sagt, dass sie sich nicht ermitteln ließ (API-Recht „Diagnose: Zustände“, Management-Tunnel).
- **Keine verlorenen Regeltreffer mehr (B11):** Jedes Anwenden setzt die Regelzähler der Firewall zurück. OPNexus liest sie jetzt unmittelbar vor jedem Anwenden, das es
  selbst auslöst (Regeln, NAT, Aliase, IPsec/OpenVPN/WireGuard, Netzwerk); vorher konnte eine Regel deshalb fälschlich als „ungenutzt“ gelten.
- **Einstellungen › Regel-Bereinigung:** schlägt die eigene Browser-Adresse als Verwaltungsquelle vor (B12). Geprüft: OPNsense 26.7 hat kein „savepoint“ zum Zurücknehmen
  durch die Firewall selbst (Quellcode und live 404), das automatische Zurücksetzen bleibt bei OPNexus.

- **Installation unter Windows:** Neue Anleitung auf der Website (`/docs/windows/`, auch auf Englisch) und im Handbuch: Docker Desktop mit WSL 2, Zertifizierungsstelle importieren, Zugriff aus dem Netz (Windows-Firewall), Updates mit `opnexus` in WSL und Fehlerbehebung. Entwickelt und getestet wird weiterhin unter Linux; die Anleitung sagt das offen.
- **Sicherheit und Stabilität (aus einer externen Prüfung):**
  - **Rückweg bleibt erhalten:** Wer nach „Konfiguration geschrieben, Anwenden fehlgeschlagen“ erneut ausrollte, überschrieb den ursprünglichen Rückweg; die automatische
    Rücknahme stellte dann den unbestätigten Stand wieder her. Jetzt bleibt der Rückweg der Stand vor dem ersten Versuch. Wartung, Regel-Bereinigung und
    „Konfiguration als Code“ behandeln eine solche Zeile wie eine offene Bestätigung. Gilt für Regeln, Aliase, Port-Forwards, 1:1-NAT, Outbound-NAT und Hierarchie-Regeln.
  - **Anmeldung und 2FA:** Eine Passwort-Anmeldung, die genau während der Aktivierung von 2FA lief, bekam eine Sitzung ohne zweiten Faktor. Jetzt wird sie mit `TOTP_REQUIRED` abgewiesen.
  - **Letzter Administrator:** Zwei Administratoren, die sich gleichzeitig herabstuften oder gegenseitig löschten, konnten alle Administratoren entfernen. Beides ist jetzt serialisiert.
  - **Operatoren:** Mit ausgeschaltetem „Operatoren dürfen ausrollen“ konnten sie noch Snapshots (Boot-Umgebungen) anlegen. Ein Test hält künftig jede schreibende Route
    fest, die keiner Rechte-Regel zugeordnet ist.
  - **Zertifikate und ACME:** Der Ziel-Host ist nach dem ersten Push fest (`409 ZIEL_HOST_NACH_AUSROLLEN_FEST`); vorher meldete ein Host-Wechsel Erfolg für einen Host, der nie
    etwas erhielt. Ein ACME-Konto mit Anfragen lässt sich nicht auf einen anderen Host verschieben (`ACME_KONTO_WIRD_VON_ANFRAGEN_VERWENDET`).
  - **VPN-Ausrollen:** IPsec, OpenVPN und WireGuard sichern jede von der Firewall vergebene Kennung sofort; bei einem Abbruch entstehen beim nächsten Versuch keine Duplikate.
    Pro VPN läuft nur ein Ausrollen oder Entfernen zugleich (`409 VPN_DEPLOY_LAEUFT_BEREITS`).
  - **Konfiguration als Code:** Gültiges YAML mit falsch geformtem Abschnitt (z. B. `aliases: 42`) ergibt `422 CONFIG_INVALID` statt eines Serverfehlers.
  - **`.env`:** Die Anleitungen legen sie mit `install -m 600` an; `opnexus update` und `opnexus status` setzen eine zu offene Datei auf 0600. `opnexus rollback` liest seinen
    Update-Zustand nur noch als Text und führt ihn nie aus.
  - **Abhängigkeiten:** Next.js 15.5.28, PyJWT 2.15.1.
2026-10-24 00:22:22 +02:00

419 lines
22 KiB
Python

"""1.29: missing API rights become visible (OpnsenseForbiddenError, `denied` in the sample, api_rights_missing alert,
dashboard, metrics) and the gateway alert can be switched off per gateway."""
import uuid
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
from app.models.host import OpnsenseHost
from app.models.notification import NotificationProblem
from app.models.resource_sample import HostResourceSample
from app.services import monitor_checks, notification_delivery, opnsense_client, resource_monitor_service
from app.services.notification_service import run_notification_checks
from tests.test_monitoring_alerts import (
STATUS_PAYLOAD, _add_samples, _gw, _gateway_problems, _only, _open_count, _set,
)
from tests.test_notifications import _create_host
from tests.test_resource_monitor import _mock_resource_endpoints, _mock_vip
from tests.helpers import delete_host
async def _host_row(host_id: str) -> OpnsenseHost:
async with SessionLocal() as db:
return await db.get(OpnsenseHost, uuid.UUID(host_id))
async def _clear_samples():
async with SessionLocal() as db:
await db.execute(HostResourceSample.__table__.delete())
await db.commit()
async def _rights_problems():
async with SessionLocal() as db:
return await monitor_checks.check_api_rights_missing(db)
# ── Client: 403 is told apart from everything else (a 401 is a wrong key, not a missing right) ──────────────────────
@pytest.mark.asyncio
@pytest.mark.parametrize("status", [403])
async def test_403_raises_forbidden_error_with_unchanged_message(client, respx_mock, status):
_mock_vip(respx_mock)
host = await _host_row((await _create_host(client, "fw1", "https://fw1.test"))["id"])
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(status, text="denied")
with pytest.raises(opnsense_client.OpnsenseForbiddenError) as exc:
await opnsense_client.get_gateway_status(host)
assert isinstance(exc.value, opnsense_client.OpnsenseAPIError) # existing `except OpnsenseAPIError` stays valid
assert not isinstance(exc.value, opnsense_client.OpnsenseAmbiguousError)
assert str(exc.value).startswith(f"{status} /routes/gateway/status")
@pytest.mark.asyncio
@pytest.mark.parametrize("status,ambiguous", [(500, True), (502, True), (404, False), (400, False), (401, False)])
async def test_other_errors_are_not_forbidden(client, respx_mock, status, ambiguous):
_mock_vip(respx_mock)
host = await _host_row((await _create_host(client, "fw1", "https://fw1.test"))["id"])
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(status)
with pytest.raises(opnsense_client.OpnsenseAPIError) as exc:
await opnsense_client.get_gateway_status(host)
assert not isinstance(exc.value, opnsense_client.OpnsenseForbiddenError)
assert isinstance(exc.value, opnsense_client.OpnsenseAmbiguousError) is ambiguous
@pytest.mark.asyncio
async def test_gateway_endpoint_reports_missing_rights_not_a_generic_502(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
route = respx_mock.get("https://fw1.test/api/routes/gateway/status")
route.respond(403)
resp = await client.get(f"/api/hosts/{host['id']}/gateways")
assert resp.status_code == 403 and resp.json()["detail"]["code"] == "API_RIGHTS_MISSING"
route.respond(500)
assert (await client.get(f"/api/hosts/{host['id']}/gateways")).status_code == 502
# ── Sampler records what was refused ─────────────────────────────────────────
def _mock_optional_reads(respx_mock, *, gateways=200, services=200, swap=200, cpu=200):
base = "https://fw1.test/api"
respx_mock.get(f"{base}/diagnostics/system/systemSwap").respond(swap, json={"swap": []})
respx_mock.get(f"{base}/diagnostics/cpu_usage/getCPUType").respond(cpu, json=["X (4 cores, 4 threads)"])
respx_mock.get(f"{base}/routes/gateway/status").respond(gateways, json=STATUS_PAYLOAD)
respx_mock.get(f"{base}/core/service/search").respond(services, json={"rows": []})
async def _sample_raw() -> dict:
async with SessionLocal() as db:
await resource_monitor_service.poll_all_hosts(db)
return (await db.scalars(select(HostResourceSample).order_by(HostResourceSample.sampled_at.desc()))).first().raw
@pytest.mark.asyncio
async def test_sample_records_only_refused_reads_in_denied(client, respx_mock):
_mock_vip(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
_mock_resource_endpoints(respx_mock)
_mock_optional_reads(respx_mock, gateways=403, services=403, swap=500, cpu=401)
raw = await _sample_raw()
# forbidden -> denied (sorted); a server error and a missing endpoint are "unknown", no finding about rights
assert raw["denied"] == ["gateways", "services"]
assert raw["gateways"] is None and raw["services"] is None and raw["swap_used_pct"] is None
assert raw["ok"] == [] # nothing went through
@pytest.mark.asyncio
async def test_sample_denied_is_an_empty_list_when_nothing_is_refused(client, respx_mock):
_mock_vip(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
_mock_resource_endpoints(respx_mock)
_mock_optional_reads(respx_mock)
raw = await _sample_raw()
assert raw["denied"] == [] and raw["ok"] == ["cpu", "gateways", "services", "swap"]
# unreachable optional endpoints (connect error / timeout) are not refusals either
_mock_optional_reads(respx_mock)
respx_mock.get("https://fw1.test/api/routes/gateway/status").mock(side_effect=httpx.ReadTimeout("slow"))
assert (await _sample_raw())["denied"] == []
@pytest.mark.asyncio
async def test_base_read_refusal_still_fails_the_sample(client, respx_mock):
_mock_vip(respx_mock)
await _create_host(client, "fw1", "https://fw1.test")
_mock_resource_endpoints(respx_mock)
respx_mock.get("https://fw1.test/api/diagnostics/system/systemResources").respond(403)
async with SessionLocal() as db:
result = await resource_monitor_service.poll_all_hosts(db)
assert result["hosts_failed"] == 1 and result["sampled"] == 0
# ── The alert ────────────────────────────────────────────────────────────────
DENIED_GW = {"denied": ["gateways"], "ok": ["cpu", "services", "swap"], "gateways": None, "services": {"unbound": 1}, "cpu_threads": 4}
FINE = {"denied": [], "ok": ["cpu", "gateways", "services", "swap"], "gateways": [_gw()], "services": {"unbound": 1}, "cpu_threads": 4}
@pytest.mark.asyncio
async def test_api_rights_alert_needs_consecutive_samples_and_names_the_missing_right(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _add_samples(hid, [DENIED_GW, DENIED_GW, FINE])
assert dict(await _rights_problems()) == {} # two in a row are not enough (debounce)
await _clear_samples()
await _add_samples(hid, [DENIED_GW] * 3)
problems = await _rights_problems()
assert list(problems) == [f"{hid}:gateways"]
ctx = problems[f"{hid}:gateways"]
assert ctx["host_name"] == "fw1" and ctx["fehlendes_recht"] == "System: Gateways"
assert ctx["endpunkt"] == "/api/routes/gateway/status" and "fehlt das Recht" in ctx["hinweis"]
@pytest.mark.asyncio
async def test_api_rights_alert_opens_once_resolves_and_is_delivered(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _only("api_rights_enabled")
await _set(webhook_enabled=True, webhook_url="https://hooks.test/n")
hook = respx_mock.post("https://hooks.test/n").mock(return_value=httpx.Response(200))
await _add_samples(hid, [DENIED_GW] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 1
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 0
assert await _open_count(db, "api_rights_missing") == 1
assert hook.call_count == 1
assert b"API-Rechte fehlen" in hook.calls.last.request.content and b"System: Gateways" in hook.calls.last.request.content
await _clear_samples()
await _add_samples(hid, [FINE] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 1
assert hook.call_count == 2 and b"behoben" in hook.calls.last.request.content
@pytest.mark.asyncio
async def test_api_rights_alert_stays_open_through_unknown_and_flapping_samples(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
key = f"{hid}:gateways"
await _only("api_rights_enabled")
await _add_samples(hid, [DENIED_GW] * 3)
async with SessionLocal() as db:
await run_notification_checks(db)
# the gateway read now times out (gateways None, not denied): no proof the right is back
await _clear_samples()
await _add_samples(hid, [{"denied": [], "gateways": None}] * 3)
assert list(await _rights_problems()) == [key]
# samples from before the denied list existed are unknown too
await _clear_samples()
await _add_samples(hid, [{"gateways": [_gw()]}] * 3)
assert list(await _rights_problems()) == [key]
# one good sample between refusals does not close it
await _clear_samples()
await _add_samples(hid, [DENIED_GW, FINE, DENIED_GW])
assert list(await _rights_problems()) == [key]
@pytest.mark.asyncio
async def test_api_rights_alert_ignores_stale_probe_and_keeps_open_problem(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _only("api_rights_enabled")
await _add_samples(hid, [DENIED_GW] * 3)
async with SessionLocal() as db:
await run_notification_checks(db)
await _clear_samples()
await _add_samples(hid, [FINE] * 3, age_minutes=30) # the host stopped answering
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 0
assert await _open_count(db) == 1
# a stale refusal does not open anything either
await _clear_samples()
async with SessionLocal() as db:
await db.execute(NotificationProblem.__table__.delete())
await db.commit()
await _add_samples(hid, [DENIED_GW] * 3, age_minutes=30)
assert dict(await _rights_problems()) == {}
@pytest.mark.asyncio
async def test_api_rights_alert_is_on_by_default_and_can_be_switched_off(client):
data = (await client.get("/api/notifications/settings")).json()
assert data["api_rights_enabled"] is True
resp = await client.put("/api/notifications/settings", json={"api_rights_enabled": False})
assert resp.status_code == 200 and resp.json()["api_rights_enabled"] is False
host = await _create_host(client, "fw1", "https://fw1.test")
await _add_samples(uuid.UUID(host["id"]), [DENIED_GW] * 3)
await _only() # everything off, including api_rights_enabled
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 0
def test_alert_label_exists():
assert notification_delivery._LABELS["api_rights_missing"] == "API-Rechte fehlen"
# ── Dashboard and metrics ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_dashboard_and_metrics_show_missing_rights(client):
host = await _create_host(client, "fw1", "https://fw1.test", cluster_name=None)
hid = uuid.UUID(host["id"])
await _add_samples(hid, [{"mem": 10, "load": 0.1, **DENIED_GW, "denied": ["gateways", "services"]}])
summary = (await client.get("/api/dashboard")).json()["hosts"][0]
assert summary["api_rights_missing"] == ["gateways", "services"] and summary["gateways"] is None
text = (await client.get("/api/metrics")).text
assert 'opnexus_host_api_rights_missing{host="fw1",cluster="",read="gateways"} 1' in text
assert 'opnexus_host_api_rights_missing{host="fw1",cluster="",read="services"} 1' in text
await _clear_samples()
await _add_samples(hid, [{"mem": 10, "load": 0.1, **FINE}])
assert (await client.get("/api/dashboard")).json()["hosts"][0]["api_rights_missing"] == []
assert 'api_rights_missing{' not in (await client.get("/api/metrics")).text.replace("# HELP", "").replace("# TYPE", "")
await _clear_samples()
await _add_samples(hid, [DENIED_GW], age_minutes=20)
assert (await client.get("/api/dashboard")).json()["hosts"][0]["api_rights_missing"] is None
# ── Mute a single gateway ────────────────────────────────────────────────────
BAD = {"gateways": [_gw("offline", 100.0, None), _gw("offline", 100.0, None, name="LTE_GW")]}
@pytest.mark.asyncio
async def test_muted_gateway_raises_no_alert_but_the_others_do(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = host["id"]
await _add_samples(uuid.UUID(hid), [BAD] * 3)
assert sorted(await _gateway_problems()) == sorted([f"{hid}:WAN_GW", f"{hid}:LTE_GW"])
resp = await client.put(f"/api/hosts/{hid}/gateways/LTE_GW/alert", json={"enabled": False})
assert resp.status_code == 200 and resp.json() == {"name": "LTE_GW", "alert_enabled": False}
assert list(await _gateway_problems()) == [f"{hid}:WAN_GW"]
# a mute is per host: the same name on another host is still watched
other = await _create_host(client, "fw2", "https://fw2.test")
await _add_samples(uuid.UUID(other["id"]), [BAD] * 3)
assert f"{other['id']}:LTE_GW" in await _gateway_problems()
# switching it on again brings the alert back
await client.put(f"/api/hosts/{hid}/gateways/LTE_GW/alert", json={"enabled": True})
assert f"{hid}:LTE_GW" in await _gateway_problems()
@pytest.mark.asyncio
async def test_muting_resolves_an_open_gateway_problem(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = host["id"]
await _only("gateway_down_enabled")
await _add_samples(uuid.UUID(hid), [BAD] * 3)
async with SessionLocal() as db:
assert (await run_notification_checks(db))["opened"] == 2
await client.put(f"/api/hosts/{hid}/gateways/LTE_GW/alert", json={"enabled": False})
async with SessionLocal() as db:
assert (await run_notification_checks(db))["resolved"] == 1
open_keys = (await db.scalars(select(NotificationProblem.dedup_key).where(NotificationProblem.status == "open"))).all()
assert open_keys == [f"{hid}:WAN_GW"]
@pytest.mark.asyncio
async def test_mute_endpoint_is_idempotent_validates_and_audits(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = host["id"]
for _ in range(2):
assert (await client.put(f"/api/hosts/{hid}/gateways/WAN_DHCP6/alert", json={"enabled": False})).status_code == 200
stored = (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"]
assert stored == [f"{hid}:WAN_DHCP6"] # no duplicate
assert (await client.put(f"/api/hosts/{hid}/gateways/WAN_DHCP6/alert", json={})).status_code == 422
assert (await client.put(f"/api/hosts/{hid}/gateways/WAN_DHCP6/alert", json={"enabled": "maybe"})).status_code == 422
for bad in ["x" * 65, "a%0Ab", "a%20b", "%E2%80%AE", "a%3Ab", "a%2Fb"]:
resp = await client.put(f"/api/hosts/{hid}/gateways/{bad}/alert", json={"enabled": False})
assert resp.status_code in (404, 422), (bad, resp.status_code)
assert resp.status_code != 200
assert (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"] == stored
missing = await client.put(f"/api/hosts/{uuid.uuid4()}/gateways/WAN_GW/alert", json={"enabled": False})
assert missing.status_code == 404
async with SessionLocal() as db:
events = (await db.scalars(select(AuditEvent).where(AuditEvent.action.like("gateway_alert_%")))).all()
assert [e.action for e in events] == ["gateway_alert_off", "gateway_alert_off"]
assert events[0].host_id == hid and events[0].message == "Gateway WAN_DHCP6" and events[0].actor
@pytest.mark.asyncio
async def test_mute_list_is_capped(client, monkeypatch):
from app.routers import gateways
monkeypatch.setattr(gateways, "MAX_MUTED_GATEWAYS", 3)
hid = (await _create_host(client, "fw1", "https://fw1.test"))["id"]
for i in range(3):
assert (await client.put(f"/api/hosts/{hid}/gateways/GW{i}/alert", json={"enabled": False})).status_code == 200
resp = await client.put(f"/api/hosts/{hid}/gateways/GW3/alert", json={"enabled": False})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "GATEWAY_MUTE_LIMIT"
# an already muted one and un-muting still work at the limit
assert (await client.put(f"/api/hosts/{hid}/gateways/GW0/alert", json={"enabled": False})).status_code == 200
assert (await client.put(f"/api/hosts/{hid}/gateways/GW0/alert", json={"enabled": True})).status_code == 200
@pytest.mark.asyncio
async def test_only_admins_may_switch_a_gateway_alert(client):
from tests.test_api_tokens import _login
from tests.test_operator_role import PW, _operator
hid = (await _create_host(client, "fw1", "https://fw1.test"))["id"]
ro = await client.post("/api/users", json={"username": "reader", "password": PW, "role": "read_only"})
assert ro.status_code == 201
readers = [await _operator(client, "gw-op"), await _login("reader", PW)]
try:
for who in readers:
resp = await who.put(f"/api/hosts/{hid}/gateways/WAN_GW/alert", json={"enabled": False})
assert resp.status_code == 403, resp.text
finally:
for who in readers:
await who.aclose()
assert (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"] == []
@pytest.mark.asyncio
async def test_gateway_list_and_dashboard_carry_the_alert_flag(client, respx_mock):
_mock_vip(respx_mock)
host = await _create_host(client, "fw1", "https://fw1.test")
hid = host["id"]
respx_mock.get("https://fw1.test/api/routes/gateway/status").respond(200, json=STATUS_PAYLOAD)
await client.put(f"/api/hosts/{hid}/gateways/LTE_GW/alert", json={"enabled": False})
rows = {g["name"]: g for g in (await client.get(f"/api/hosts/{hid}/gateways")).json()}
assert rows["LTE_GW"]["alert_enabled"] is False and rows["WAN_GW"]["alert_enabled"] is True
await _add_samples(uuid.UUID(hid), [{"gateways": [_gw(), _gw(name="LTE_GW")]}])
dash = {g["name"]: g for g in (await client.get("/api/dashboard")).json()["hosts"][0]["gateways"]}
assert dash["LTE_GW"]["alert_enabled"] is False and dash["WAN_GW"]["alert_enabled"] is True
@pytest.mark.asyncio
async def test_api_rights_endpoint_reads_the_newest_fresh_sample(client):
hid = (await _create_host(client, "fw1", "https://fw1.test"))["id"]
url = f"/api/hosts/{hid}/gateways/api-rights"
assert (await client.get(url)).json() == {"missing": None} # no sample at all: unknown, not "fine"
await _add_samples(uuid.UUID(hid), [DENIED_GW, FINE])
assert (await client.get(url)).json() == {"missing": ["gateways"]}
await _clear_samples()
await _add_samples(uuid.UUID(hid), [FINE, DENIED_GW])
assert (await client.get(url)).json() == {"missing": []}
await _clear_samples()
await _add_samples(uuid.UUID(hid), [DENIED_GW], age_minutes=20)
assert (await client.get(url)).json() == {"missing": None} # stale
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/gateways/api-rights")).status_code == 404
@pytest.mark.asyncio
async def test_right_back_closes_the_alert_even_when_the_answer_is_unparsable(client):
"""cpu_threads stays None when the CPU string has no "threads"/"cores": only the `ok` list proves the read went through."""
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _only("api_rights_enabled")
await _add_samples(hid, [{"denied": ["cpu"], "ok": ["gateways", "services", "swap"], "cpu_threads": None}] * 3)
assert list(await _rights_problems()) == [f"{hid}:cpu"]
await _clear_samples()
await _add_samples(hid, [{"denied": [], "ok": ["cpu", "gateways", "services", "swap"], "cpu_threads": None}] * 3)
assert list(await _rights_problems()) == []
@pytest.mark.asyncio
async def test_mute_refuses_an_unknown_gateway_name_but_unmute_always_works(client):
host = await _create_host(client, "fw1", "https://fw1.test")
hid = uuid.UUID(host["id"])
await _add_samples(hid, [FINE])
assert (await client.put(f"/api/hosts/{hid}/gateways/wan_gw/alert", json={"enabled": False})).status_code == 404
assert (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"] == []
assert (await client.put(f"/api/hosts/{hid}/gateways/{_gw()['name']}/alert", json={"enabled": False})).status_code == 200
assert (await client.put(f"/api/hosts/{hid}/gateways/wan_gw/alert", json={"enabled": True})).status_code == 200
@pytest.mark.asyncio
async def test_deleting_a_host_removes_its_gateway_mutes(client):
keep = await _create_host(client, "fw-keep", "https://fwkeep.test")
gone = await _create_host(client, "fw-gone", "https://fwgone.test")
for h in (keep, gone):
assert (await client.put(f"/api/hosts/{h['id']}/gateways/WAN_GW/alert", json={"enabled": False})).status_code == 200
assert (await delete_host(client, gone['id'])).status_code == 204
assert (await client.get("/api/notifications/settings")).json()["gateway_alert_muted"] == [f"{keep['id']}:WAN_GW"]