Files
netmover/tests/test_auth_modes.py
Florian WildeandClaude Opus 5.5 cf7a3bcc88 feat: Anmeldung je Netz – ohne Passwort, mit Passwort oder abgewiesen
Zwei Netzlisten statt Modusauswahl; fremde Netze werden abgewiesen, wenn
„mit Passwort“ ausgefüllt ist; /healthz bleibt erreichbar; Schutz vor
Aussperren; reset-password hebt Sperren auf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:46:43 +02:00

168 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen."""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from netmover.settings_store import load_settings, save_settings
from netmover.web.auth import (
ACCESS_DENY,
ACCESS_NOPW,
ACCESS_PW,
access_decision,
includes_loopback,
parse_networks,
)
from tests.webutil import PASSWORD, csrf_of
def _other(client: TestClient, host: str) -> TestClient:
"""Weiterer Client für dieselbe App (ohne eigenen Lebenszyklus) mit fester Adresse."""
return TestClient(client.app, follow_redirects=False, client=(host, 51000))
def _save(c: TestClient, trusted: str = "", allowed: str = "", current: str = PASSWORD) -> Any:
token = (
csrf_of(c.get("/settings").text)
if "X-Test-CSRF" not in c.headers
else c.headers["X-Test-CSRF"]
)
return c.post(
"/settings/auth",
data={
"csrf_token": token,
"trusted_networks": trusted,
"allowed_networks": allowed,
"current": current,
},
)
def _set(state: Any, **kw: str) -> None:
cfg = load_settings(state.session_factory)
for k, v in kw.items():
setattr(cfg, k, v)
save_settings(state.session_factory, cfg)
# -- Entscheidung ------------------------------------------------------------------------------
@pytest.mark.parametrize(
("host", "trusted", "allowed", "expected"),
[
("10.0.10.50", "", "", ACCESS_PW), # Standard: überall mit Passwort
("10.0.10.50", "10.0.10.0/24", "", ACCESS_NOPW),
("10.1.5.9", "10.0.10.0/24", "", ACCESS_PW), # übrige Netze mit Passwort
("10.1.5.9", "10.0.10.0/24", "10.1.5.0/24", ACCESS_PW),
("192.168.5.5", "10.0.10.0/24", "10.1.5.0/24", ACCESS_DENY),
("10.0.10.50", "10.0.10.0/24", "10.1.5.0/24", ACCESS_NOPW), # ohne PW schlägt Liste
("2001:db8::5", "0.0.0.0/0, ::/0", "", ACCESS_NOPW), # Anmeldung ganz aus
("testclient", "", "10.0.0.0/8", ACCESS_DENY), # keine IP-Adresse
],
)
def test_access_decision(host: str, trusted: str, allowed: str, expected: str) -> None:
assert access_decision(host, "always", trusted, allowed) == expected
def test_legacy_off_mode_still_works() -> None:
assert access_decision("192.168.5.5", "off", "", "10.1.5.0/24") == ACCESS_NOPW
# -- Oberfläche --------------------------------------------------------------------------------
def test_default_requires_login(client: TestClient) -> None:
assert _other(client, "10.0.10.50").get("/").status_code == 303
def test_change_needs_current_password(client: TestClient, state: Any) -> None:
_save(client, trusted="10.0.10.0/24", current="falsch")
assert load_settings(state.session_factory).trusted_networks == ""
assert "Aktuelles Passwort falsch" in client.get("/settings").text
def test_without_password_from_trusted_network(client: TestClient, state: Any) -> None:
_save(client, trusted="10.0.10.0/24")
lan = _other(client, "10.0.10.50")
r = lan.get("/")
assert r.status_code == 200 and "ohne Anmeldung (vertrautes Netz)" in r.text
assert "Abmelden" not in r.text and lan.get("/login").headers["location"] == "/"
assert lan.post("/maintenance").status_code == 403 # CSRF gilt weiterhin
assert (
lan.post("/maintenance", data={"csrf_token": csrf_of(lan.get("/").text)}).status_code == 303
)
assert _other(client, "10.1.5.9").get("/").status_code == 303 # übrige mit Passwort
def test_other_networks_are_rejected_when_password_list_is_set(
client: TestClient, state: Any
) -> None:
_save(client, trusted="0.0.0.0/0") # damit der Test-Client (keine IP) weiterkommt …
_set(state, trusted_networks="10.0.10.0/24", allowed_networks="10.1.5.0/24") # … dann scharf
assert _other(client, "10.0.10.50").get("/").status_code == 200
assert _other(client, "10.1.5.9").get("/").headers["location"].startswith("/login")
blocked = _other(client, "192.168.5.5")
for url in ("/", "/login", "/setup", "/mounts"):
assert blocked.get(url).status_code == 403, url
assert blocked.get("/healthz").status_code == 200 # Überwachung bleibt erreichbar
assert blocked.get("/static/app.css").status_code == 200
def test_cannot_lock_yourself_out(client: TestClient, state: Any) -> None:
lan = _other(client, "10.0.10.50")
_save(client, trusted="10.0.10.0/24")
# aus 10.0.10.50 eine Liste speichern, in der das eigene Netz fehlt
r = _save(lan, trusted="", allowed="10.1.5.0/24")
assert r.status_code == 303
assert "ausgesperrt" in lan.get("/settings").text
cfg = load_settings(state.session_factory)
assert cfg.trusted_networks == "10.0.10.0/24" and cfg.allowed_networks == ""
# eigenes Netz bei „mit Passwort“ – erlaubt
_save(lan, trusted="10.0.10.0/24", allowed="10.1.5.0/24")
assert load_settings(state.session_factory).allowed_networks == "10.1.5.0/24"
def test_everything_without_password(client: TestClient) -> None:
_save(client, trusted="0.0.0.0/0, ::/0")
r = _other(client, "192.168.77.1").get("/rules")
assert r.status_code == 200 and ">ohne Anmeldung<" in r.text
def test_setup_still_required_without_password(raw_client: TestClient, state: Any) -> None:
_set(state, trusted_networks="0.0.0.0/0")
assert raw_client.get("/").headers["location"] == "/setup"
def test_validation_and_loopback_hint(client: TestClient, state: Any) -> None:
_save(client, trusted="10.0.10.0/24, quatsch")
assert "kein gültiges Netz" in client.get("/settings").text
assert load_settings(state.session_factory).trusted_networks == ""
_save(client, trusted="127.0.0.1")
assert "jede</strong> Anfrage über den Proxy" in client.get("/settings").text
def test_network_parsing() -> None:
nets = parse_networks("10.0.10.0/24; 192.168.1.20 fe80::/10")
assert [str(n) for n in nets] == ["10.0.10.0/24", "192.168.1.20/32", "fe80::/10"]
assert includes_loopback("10.0.0.0/8, 127.0.0.0/8") and not includes_loopback("10.0.0.0/8")
with pytest.raises(ValueError):
parse_networks("10.0.0.0/33")
def test_reset_password_turns_login_back_on(
state: Any, settings: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
from netmover import cli
from netmover.web.auth import set_password
set_password(state.session_factory, PASSWORD)
_set(state, auth_mode="off", trusted_networks="0.0.0.0/0", allowed_networks="10.1.5.0/24")
monkeypatch.setattr(cli, "get_settings", lambda: settings)
assert cli.main(["reset-password"]) == 0
cfg = load_settings(state.session_factory)
assert cfg.auth_mode == "always" and cfg.trusted_networks == "" and cfg.allowed_networks == ""