Files
OPNexus/backend/tests/test_auth.py
T
Julian Boldt 218a1f14ac
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 / publish (updater) (push) Canceled after 0s
Release v1.18.0
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.

- **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten,
  Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit
  Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers.
  Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups,
  Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle
  `api_tokens` (Migration `0008`).
- **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle,
  offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls.
- **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse,
  Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens.
- **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht
  erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP).
- **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert
  0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe
  Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und
  auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`).
- Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die
  Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der
  Anfrage neu geladen.
2026-10-19 23:54:02 +02:00

107 lines
3.7 KiB
Python

import pytest
from starlette.requests import Request
from app.config import settings
from app.routers.auth import client_ip
def _make_request(headers: dict, client_host: str = "9.9.9.9") -> Request:
raw_headers = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
return Request({"type": "http", "headers": raw_headers, "client": (client_host, 12345)})
@pytest.mark.asyncio
async def test_login_success(anon_client):
resp = await anon_client.post(
"/api/auth/login",
json={"username": settings.admin_username, "password": settings.admin_password},
)
assert resp.status_code == 200
assert resp.json()["authenticated"] is True
assert "opnexus-session" in resp.cookies
@pytest.mark.asyncio
async def test_login_wrong_password(anon_client):
resp = await anon_client.post(
"/api/auth/login",
json={"username": settings.admin_username, "password": "definitely-wrong"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_lockout_after_repeated_failures(anon_client):
for _ in range(settings.login_max_attempts):
resp = await anon_client.post(
"/api/auth/login",
json={"username": settings.admin_username, "password": "wrong"},
)
assert resp.status_code == 401
resp = await anon_client.post(
"/api/auth/login",
json={"username": settings.admin_username, "password": settings.admin_password},
)
assert resp.status_code == 429
def test_client_ip_ignores_forwarded_header_by_default(monkeypatch):
monkeypatch.setattr(settings, "trust_proxy_headers", False)
req = _make_request({"x-forwarded-for": "1.2.3.4"}, client_host="9.9.9.9")
assert client_ip(req) == "9.9.9.9"
def test_client_ip_uses_forwarded_header_when_trusted(monkeypatch):
monkeypatch.setattr(settings, "trust_proxy_headers", True)
req = _make_request({"x-forwarded-for": "1.2.3.4, 10.0.0.5"}, client_host="10.0.0.5")
assert client_ip(req) == "1.2.3.4"
@pytest.mark.asyncio
async def test_unauthenticated_request_rejected(anon_client):
resp = await anon_client.get("/api/hosts")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_me_reports_authenticated_state(anon_client, client):
resp = await anon_client.get("/api/auth/me")
assert resp.json()["authenticated"] is False
resp = await client.get("/api/auth/me")
assert resp.json() == {"authenticated": True, "username": settings.admin_username, "role": "admin"}
@pytest.mark.asyncio
async def test_logout_invalidates_session(client):
resp = await client.get("/api/hosts")
assert resp.status_code == 200
await client.post("/api/auth/logout")
resp = await client.get("/api/hosts")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_health_stays_public_but_docs_require_auth(anon_client):
resp = await anon_client.get("/api/health")
assert resp.status_code == 200
# /docs/redoc/openapi.json used to be public — now require a session
# like any other endpoint, since fully documenting a firewall-fleet
# API surface to an anonymous caller is free recon for an attacker.
for path in ("/docs", "/redoc", "/openapi.json", "/api/openapi.json"):
resp = await anon_client.get(path)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_openapi_schema_reachable_once_logged_in(client):
# The schema lives under /api (the proxy only routes /api to the backend); the
# interactive Swagger/ReDoc pages are gone (their CDN scripts violate the CSP).
assert (await client.get("/api/openapi.json")).status_code == 200
for path in ("/docs", "/redoc", "/openapi.json"):
assert (await client.get(path)).status_code == 404