Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code kommentiert, an der der Fehler entstanden ist: * Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert. * Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea). * Kein Hostname im Lizenz-Fingerabdruck. * Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY). * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
969 lines
34 KiB
Python
969 lines
34 KiB
Python
"""Rauchtest der TESM-Anwendung: jede Seite rendert, jede Aktion greift.
|
|
|
|
Deckt bewusst auch die Faelle ab, die im Vorgaengerprojekt kaputtgingen:
|
|
Rechteschranken, Lizenzgates auf Modulrouten, MAC-Normalisierung, und die
|
|
Trennung zwischen oeffentlicher und angemeldeter Statusuebersicht.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture()
|
|
def tesm_app(instance_env: Any):
|
|
from tesm import create_app
|
|
|
|
app = create_app(TESTING=True)
|
|
yield app
|
|
|
|
|
|
@pytest.fixture()
|
|
def tclient(tesm_app: Any):
|
|
return tesm_app.test_client()
|
|
|
|
|
|
@pytest.fixture()
|
|
def admin(tesm_app: Any):
|
|
from tesm_core.extension import core
|
|
from tesm_core.security import passwords
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, "
|
|
"updated_at) VALUES ('root',?,1,'local',datetime('now'),datetime('now'))",
|
|
(passwords.hash_password("Ein-gutes-Passwort-1"),),
|
|
)
|
|
return {"username": "root", "password": "Ein-gutes-Passwort-1"}
|
|
|
|
|
|
def _token(client: Any, path: str = "/login") -> str:
|
|
body = client.get(path).get_data(as_text=True)
|
|
for marker in ('name="csrf_token" value="', 'data-csrf="'):
|
|
if marker in body:
|
|
start = body.index(marker) + len(marker)
|
|
return body[start : body.index('"', start)]
|
|
raise AssertionError(f"Kein CSRF-Token in {path}")
|
|
|
|
|
|
@pytest.fixture()
|
|
def login_admin(tclient: Any, admin: dict[str, str]):
|
|
def do() -> Any:
|
|
return tclient.post(
|
|
"/login",
|
|
data={
|
|
"username": admin["username"],
|
|
"password": admin["password"],
|
|
"csrf_token": _token(tclient),
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
|
|
return do
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Start und Grundzustand
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_app_boots_and_migrations_applied(tesm_app):
|
|
from tesm_core.extension import core
|
|
|
|
status = core(tesm_app).migration_status()
|
|
components = {row["component"] for row in status}
|
|
assert components == {"core", "tesm"}
|
|
names = {(row["component"], row["name"]) for row in status}
|
|
assert ("tesm", "baseline") in names
|
|
assert ("tesm", "seed-dhcp-options") in names
|
|
|
|
|
|
def test_dhcp_option_catalogue_seeded(tesm_app):
|
|
from tesm_core.db import Database
|
|
from tesm_core.extension import core
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn:
|
|
count = Database.value(conn, "SELECT COUNT(*) FROM dhcp_options WHERE is_standard=1")
|
|
host_name = Database.value(conn, "SELECT COUNT(*) FROM dhcp_options WHERE code=12")
|
|
assert count == 56
|
|
# host-name (12) ist bewusst nicht im Katalog -- Kea beantwortet das ueber
|
|
# das hostname-Feld der Reservierung.
|
|
assert host_name == 0
|
|
|
|
|
|
def test_role_presets_created(tesm_app):
|
|
from tesm_core.extension import core
|
|
from tesm_core.rbac import service as rbac
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn:
|
|
names = {group.name for group in rbac.list_groups(conn)}
|
|
assert {"Benutzer", "Betrieb", "Support", "Netzwerk", "Revision", "Administration"} <= names
|
|
|
|
|
|
def test_public_dashboard_hides_details(tclient):
|
|
response = tclient.get("/")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert "Statusuebersicht" in body
|
|
assert "oeffentliche Ansicht" in body
|
|
|
|
|
|
def test_health_endpoint(tclient):
|
|
assert tclient.get("/gesundheit").get_json()["status"] == "ok"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Seiten
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
ADMIN_PAGES = [
|
|
"/",
|
|
"/clients/",
|
|
"/switche/",
|
|
"/zugangsdaten/",
|
|
"/protokolle/laufend",
|
|
"/protokolle/verlauf",
|
|
"/protokolle/aenderungen",
|
|
"/protokolle/neustarts",
|
|
"/einstellungen/system",
|
|
"/einstellungen/netzwerk",
|
|
"/einstellungen/webserver",
|
|
"/einstellungen/verzeichnisdienst",
|
|
"/lizenz/",
|
|
"/sicherung/",
|
|
"/papierkorb/",
|
|
"/diagnose/",
|
|
"/verwaltung/benutzer",
|
|
"/verwaltung/gruppen",
|
|
"/verwaltung/sitzungen",
|
|
"/konto/",
|
|
"/konto/sicherheit",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("path", ADMIN_PAGES)
|
|
def test_admin_pages_render(tclient, login_admin, path):
|
|
login_admin()
|
|
response = tclient.get(path)
|
|
assert response.status_code == 200, f"{path} -> {response.status_code}"
|
|
assert "Interner Fehler" not in response.get_data(as_text=True)
|
|
|
|
|
|
def test_licensed_modules_are_hidden_without_license(tclient, login_admin):
|
|
"""Ohne Lizenz sind DHCP, Wartung und Dateifreigaben komplett unerreichbar."""
|
|
login_admin()
|
|
for path in ("/dhcp/", "/wartung/", "/dateifreigaben/", "/protokolle/kea"):
|
|
assert tclient.get(path).status_code == 404, path
|
|
body = tclient.get("/").get_data(as_text=True)
|
|
assert "DHCP-Server" not in body
|
|
assert "Wartung" not in body
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Geraete
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_device_lifecycle(tclient, login_admin):
|
|
login_admin()
|
|
token = _token(tclient, "/clients/")
|
|
|
|
created = tclient.post(
|
|
"/clients/neu",
|
|
data={
|
|
"csrf_token": token,
|
|
"name": "Kamera Eingang",
|
|
"mac": "AA-BB-CC-DD-EE-FF",
|
|
"ip": "192.168.1.50",
|
|
"is_active": "1",
|
|
"auto_restart": "1",
|
|
"ssh_port": "22",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert created.status_code == 200
|
|
body = created.get_data(as_text=True)
|
|
assert "angelegt" in body
|
|
# MAC wird normalisiert.
|
|
assert "aa:bb:cc:dd:ee:ff" in tclient.get("/clients/").get_data(as_text=True)
|
|
|
|
listing = tclient.get("/clients/").get_data(as_text=True)
|
|
assert "Kamera Eingang" in listing
|
|
|
|
# Loeschen legt in den Papierkorb, MAC bleibt reserviert.
|
|
tclient.post("/clients/1/loeschen", data={"csrf_token": token}, follow_redirects=True)
|
|
trash = tclient.get("/papierkorb/").get_data(as_text=True)
|
|
assert "Kamera Eingang" in trash
|
|
|
|
again = tclient.post(
|
|
"/clients/neu",
|
|
data={
|
|
"csrf_token": token,
|
|
"name": "Zweite Kamera",
|
|
"mac": "aa:bb:cc:dd:ee:ff",
|
|
"ip": "192.168.1.51",
|
|
"ssh_port": "22",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert "Papierkorb" in again.get_data(as_text=True)
|
|
|
|
|
|
def test_device_requires_port_when_switch_assigned(tclient, login_admin):
|
|
login_admin()
|
|
token = _token(tclient, "/switche/")
|
|
tclient.post(
|
|
"/switche/neu",
|
|
data={"csrf_token": token, "hostname": "sw01", "ip": "192.168.1.2", "ssh_port": "22"},
|
|
follow_redirects=True,
|
|
)
|
|
response = tclient.post(
|
|
"/clients/neu",
|
|
data={
|
|
"csrf_token": token,
|
|
"name": "Ohne Port",
|
|
"mac": "11:22:33:44:55:66",
|
|
"ip": "192.168.1.60",
|
|
"switch_id": "1",
|
|
"ssh_port": "22",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert "Portbezeichnung" in response.get_data(as_text=True)
|
|
|
|
|
|
def test_invalid_mac_rejected(tclient, login_admin):
|
|
login_admin()
|
|
token = _token(tclient, "/clients/")
|
|
response = tclient.post(
|
|
"/clients/neu",
|
|
data={"csrf_token": token, "name": "Kaputt", "mac": "xyz", "ip": "192.168.1.70"},
|
|
follow_redirects=True,
|
|
)
|
|
assert "hexadezimale" in response.get_data(as_text=True)
|
|
|
|
|
|
def test_invalid_ip_rejected(tclient, login_admin):
|
|
login_admin()
|
|
token = _token(tclient, "/clients/")
|
|
response = tclient.post(
|
|
"/clients/neu",
|
|
data={"csrf_token": token, "name": "Kaputt", "mac": "11:22:33:44:55:77", "ip": "999.1.1.1"},
|
|
follow_redirects=True,
|
|
)
|
|
assert "gueltige IP-Adresse" in response.get_data(as_text=True)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Zugangsdaten und Verschluesselung
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_credential_secret_is_encrypted_at_rest(tclient, tesm_app, login_admin):
|
|
from tesm_core.db import Database
|
|
from tesm_core.extension import core
|
|
|
|
login_admin()
|
|
token = _token(tclient, "/zugangsdaten/")
|
|
tclient.post(
|
|
"/zugangsdaten/neu",
|
|
data={
|
|
"csrf_token": token,
|
|
"name": "Switch-Admin",
|
|
"username": "admin",
|
|
"secret": "SuperGeheim-2026",
|
|
"category": "switch",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn:
|
|
stored = Database.value(conn, "SELECT secret_encrypted FROM credentials WHERE name=?", ("Switch-Admin",))
|
|
assert stored
|
|
assert "SuperGeheim" not in stored
|
|
assert stored.startswith("v1.")
|
|
|
|
|
|
def test_credential_reveal_requires_permission(tclient, tesm_app, login_admin):
|
|
from tesm_core.extension import core
|
|
from tesm_core.security import passwords
|
|
|
|
login_admin()
|
|
token = _token(tclient, "/zugangsdaten/")
|
|
tclient.post(
|
|
"/zugangsdaten/neu",
|
|
data={
|
|
"csrf_token": token,
|
|
"name": "Nur-Lesen",
|
|
"username": "admin",
|
|
"secret": "Geheim-Passwort-9",
|
|
"category": "switch",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
tclient.get("/logout")
|
|
|
|
# Ein Konto mit credentials.view aber ohne credentials.secrets.
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
cursor = conn.execute(
|
|
"INSERT INTO users (username, password_hash, auth_source, created_at, updated_at) "
|
|
"VALUES ('lisa',?,'local',datetime('now'),datetime('now'))",
|
|
(passwords.hash_password("Ein-gutes-Passwort-1"),),
|
|
)
|
|
user_id = int(cursor.lastrowid or 0)
|
|
group = conn.execute(
|
|
"INSERT INTO groups (name, created_at, updated_at) "
|
|
"VALUES ('nur-lesen',datetime('now'),datetime('now'))"
|
|
)
|
|
group_id = int(group.lastrowid or 0)
|
|
conn.executemany(
|
|
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
|
|
[(group_id, "monitoring.view"), (group_id, "credentials.view")],
|
|
)
|
|
conn.execute("INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, group_id))
|
|
|
|
client = tclient
|
|
client.post(
|
|
"/login",
|
|
data={"username": "lisa", "password": "Ein-gutes-Passwort-1", "csrf_token": _token(client)},
|
|
follow_redirects=True,
|
|
)
|
|
assert client.get("/zugangsdaten/").status_code == 200
|
|
response = client.post("/zugangsdaten/1/anzeigen", data={"csrf_token": _token(client, "/zugangsdaten/")})
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_credential_reveal_is_audited(tclient, tesm_app, login_admin):
|
|
from tesm_core import audit
|
|
from tesm_core.extension import core
|
|
|
|
login_admin()
|
|
token = _token(tclient, "/zugangsdaten/")
|
|
tclient.post(
|
|
"/zugangsdaten/neu",
|
|
data={
|
|
"csrf_token": token,
|
|
"name": "Geheim",
|
|
"username": "admin",
|
|
"secret": "Geheim-Passwort-9",
|
|
"category": "switch",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
response = tclient.post(
|
|
"/zugangsdaten/1/anzeigen", data={"csrf_token": token}, follow_redirects=True
|
|
)
|
|
assert response.status_code == 200
|
|
assert "Geheim-Passwort-9" in response.get_data(as_text=True)
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn:
|
|
rows = audit.search(conn, action="credential.secret_revealed")
|
|
assert rows and rows[0]["severity"] == "warning"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Switche und Host-Schluessel
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_switch_created_without_trusted_host_key(tclient, login_admin):
|
|
login_admin()
|
|
token = _token(tclient, "/switche/")
|
|
response = tclient.post(
|
|
"/switche/neu",
|
|
data={"csrf_token": token, "hostname": "sw-kern", "ip": "10.0.0.2", "ssh_port": "22"},
|
|
follow_redirects=True,
|
|
)
|
|
assert "freigegeben werden" in response.get_data(as_text=True)
|
|
detail = tclient.get("/switche/1").get_data(as_text=True)
|
|
assert "Noch kein Schluessel hinterlegt" in detail
|
|
|
|
|
|
def test_switch_delete_blocked_while_devices_attached(tclient, login_admin):
|
|
login_admin()
|
|
token = _token(tclient, "/switche/")
|
|
tclient.post(
|
|
"/switche/neu",
|
|
data={"csrf_token": token, "hostname": "sw01", "ip": "10.0.0.3", "ssh_port": "22"},
|
|
follow_redirects=True,
|
|
)
|
|
tclient.post(
|
|
"/clients/neu",
|
|
data={
|
|
"csrf_token": token,
|
|
"name": "Kamera",
|
|
"mac": "aa:11:22:33:44:55",
|
|
"ip": "10.0.0.50",
|
|
"switch_id": "1",
|
|
"port": "1",
|
|
"ssh_port": "22",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
response = tclient.post("/switche/1/loeschen", data={"csrf_token": token}, follow_redirects=True)
|
|
assert "zugeordnet" in response.get_data(as_text=True)
|
|
|
|
|
|
def test_command_not_on_allowlist_rejected(tclient, login_admin):
|
|
login_admin()
|
|
token = _token(tclient, "/switche/")
|
|
tclient.post(
|
|
"/switche/neu",
|
|
data={"csrf_token": token, "hostname": "sw01", "ip": "10.0.0.4", "ssh_port": "22"},
|
|
follow_redirects=True,
|
|
)
|
|
response = tclient.post(
|
|
"/switche/1/kommando",
|
|
data={"csrf_token": token, "command": "erase startup-config"},
|
|
follow_redirects=True,
|
|
)
|
|
assert "Positivliste" in response.get_data(as_text=True)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Rechte
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_viewer_cannot_reach_admin_pages(tclient, tesm_app):
|
|
from tesm_core.extension import core
|
|
from tesm_core.rbac import service as rbac
|
|
from tesm_core.security import passwords
|
|
|
|
extension = core(tesm_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
cursor = conn.execute(
|
|
"INSERT INTO users (username, password_hash, auth_source, created_at, updated_at) "
|
|
"VALUES ('gast',?,'local',datetime('now'),datetime('now'))",
|
|
(passwords.hash_password("Ein-gutes-Passwort-1"),),
|
|
)
|
|
user_id = int(cursor.lastrowid or 0)
|
|
default = next(group for group in rbac.list_groups(conn) if group.is_default)
|
|
conn.execute(
|
|
"INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, default.id)
|
|
)
|
|
|
|
tclient.post(
|
|
"/login",
|
|
data={"username": "gast", "password": "Ein-gutes-Passwort-1", "csrf_token": _token(tclient)},
|
|
follow_redirects=True,
|
|
)
|
|
assert tclient.get("/clients/").status_code == 200
|
|
for path in ("/zugangsdaten/", "/verwaltung/benutzer", "/einstellungen/system", "/diagnose/"):
|
|
assert tclient.get(path).status_code == 403, path
|
|
|
|
|
|
def test_permission_matrix_columns_per_area(tesm_app):
|
|
from tesm.permissions import PERMISSIONS
|
|
from tesm_core.rbac.model import Action
|
|
|
|
monitoring = PERMISSIONS.area("monitoring")
|
|
assert Action.SECRETS in monitoring.columns() # Zugangsdaten
|
|
assert Action.DELETE in monitoring.columns()
|
|
|
|
network = PERMISSIONS.area("network")
|
|
assert Action.CREATE not in network.columns() # DHCP kennt kein "Anlegen"
|
|
assert Action.SECRETS not in network.columns()
|
|
|
|
|
|
def test_delete_is_a_separate_right(tesm_app):
|
|
"""Kernaenderung gegenueber dem Vorgaenger: Aendern deckt nicht mehr Loeschen ab."""
|
|
from tesm.permissions import PERMISSIONS
|
|
|
|
granted = {"monitoring.view", "devices.view", "devices.edit"}
|
|
effective = PERMISSIONS.effective(granted)
|
|
assert "devices.edit" in effective
|
|
assert "devices.delete" not in effective
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Offline-Aktivierung: der Code muss sichtbar sein
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_offline_request_keeps_its_code():
|
|
"""Die Seite forderte zum Uebermitteln eines Codes auf -- und zeigte keinen.
|
|
|
|
``handshake`` erzeugte ihn, der Blueprint verwarf ihn, und nach der
|
|
Weiterleitung war er weg. Er gehoert deshalb zur gespeicherten Anfrage:
|
|
zwischen Anfrage und Antwort liegen in der Praxis eine E-Mail und ein
|
|
Arbeitstag.
|
|
"""
|
|
import inspect
|
|
|
|
from tesm_core import licensing_client
|
|
|
|
source = inspect.getsource(licensing_client.LicenseManager.handshake)
|
|
assert '"code": code,' in source
|
|
assert "code = lic.encode_code(request_payload)" in source
|
|
|
|
|
|
def test_the_reason_for_going_offline_is_named():
|
|
""""War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch."""
|
|
from tesm_core.licensing_client import describe_transport_error
|
|
|
|
certificate = describe_transport_error(
|
|
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate"),
|
|
"https://10.0.0.1:8443",
|
|
)
|
|
assert "selbstsigniertes Zertifikat" in certificate
|
|
assert "https://10.0.0.1:8443" in certificate
|
|
|
|
assert "aufloesen" in describe_transport_error(
|
|
Exception("Name or service not known"), "https://nope.invalid"
|
|
)
|
|
assert "nimmt keine Verbindung an" in describe_transport_error(
|
|
Exception("Connection refused"), "https://10.0.0.1:8443"
|
|
)
|
|
assert "rechtzeitig" in describe_transport_error(
|
|
Exception("operation timed out"), "https://10.0.0.1:8443"
|
|
)
|
|
|
|
|
|
def test_license_page_shows_the_request_code():
|
|
from pathlib import Path
|
|
|
|
template = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "apps" / "tesm" / "src" / "tesm" / "templates" / "tesm" / "license.html"
|
|
).read_text(encoding="utf-8")
|
|
assert "pending.code" in template
|
|
assert "Anfragecode" in template
|
|
assert 'data-copy="#offline-request"' in template
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Dateifreigaben
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _license_all_modules(tesm_app):
|
|
"""Alle Module freischalten -- die Freigabeseiten haengen an der Lizenz."""
|
|
from tesm_core.extension import core
|
|
|
|
manager = core(tesm_app).license
|
|
if manager is not None:
|
|
manager.module_active = lambda _name: True # type: ignore[method-assign]
|
|
|
|
|
|
def test_share_setup_page_is_reachable_and_searches_groups(tesm_app, tclient, login_admin):
|
|
"""Die Einrichtung war nur ueber die Adresszeile erreichbar.
|
|
|
|
Der Menuepunkt haengte an vorhandenen Einbindungen -- die es ohne
|
|
Zuordnung nie gibt. Wer Zuordnungen pflegen darf, muss die Seite auch
|
|
finden, und der Gruppen-DN muss suchbar sein statt abtippbar.
|
|
"""
|
|
_license_all_modules(tesm_app)
|
|
login_admin()
|
|
|
|
page = tclient.get("/dateifreigaben/zuordnungen")
|
|
assert page.status_code == 200
|
|
body = page.get_data(as_text=True)
|
|
assert "data-group-query" in body, "Gruppensuche fehlt"
|
|
assert 'name="ad_group_dn"' in body
|
|
|
|
nav = tclient.get("/").get_data(as_text=True)
|
|
assert "/dateifreigaben/zuordnungen" in nav, "Zuordnungen fehlen in der Navigation"
|
|
|
|
|
|
def test_share_mapping_round_trip(tesm_app, tclient, login_admin):
|
|
_license_all_modules(tesm_app)
|
|
login_admin()
|
|
|
|
token = _token(tclient, "/dateifreigaben/zuordnungen")
|
|
response = tclient.post(
|
|
"/dateifreigaben/zuordnungen",
|
|
data={
|
|
"label": "Technik",
|
|
"unc": r"\\fileserver\technik",
|
|
"ad_group_name": "GG_Technik",
|
|
"ad_group_dn": "CN=GG_Technik,OU=Gruppen,DC=firma,DC=local",
|
|
"csrf_token": token,
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert response.status_code == 200
|
|
assert "Zuordnung gespeichert" in response.get_data(as_text=True)
|
|
|
|
from tesm.services import fileshare
|
|
from tesm_core.extension import core
|
|
|
|
with core(tesm_app).database.session() as conn:
|
|
assert len(fileshare.list_mappings(conn)) == 1
|
|
# Der Anspruch entsteht ueber die Verzeichnisgruppe -- und nur darueber.
|
|
assert fileshare.shares_for_groups(
|
|
conn, ["cn=gg_technik,ou=gruppen,dc=firma,dc=local"]
|
|
)
|
|
assert not fileshare.shares_for_groups(conn, [])
|
|
|
|
|
|
def test_login_triggers_share_mounting(tesm_app, tclient, login_admin, monkeypatch):
|
|
"""Der Ausloeser fehlte: Zuordnungen bestanden, eingebunden wurde nie.
|
|
|
|
Ohne diesen Haken blieb der gesamte Freigabe-Teil wirkungslos -- und weil
|
|
der Menuepunkt an Einbindungen haengt, war er auch unsichtbar.
|
|
"""
|
|
from tesm_core.extension import core
|
|
|
|
seen: list[dict] = []
|
|
|
|
def fake_mount(conn, **kwargs):
|
|
seen.append(kwargs)
|
|
return []
|
|
|
|
_license_all_modules(tesm_app)
|
|
monkeypatch.setattr("tesm.services.fileshare.mount_for_login", fake_mount)
|
|
assert core(tesm_app).login_hooks, "kein Anmeldehaken registriert"
|
|
|
|
login_admin()
|
|
assert seen, "die Anmeldung hat die Einbindung nicht ausgeloest"
|
|
# Das Passwort wird durchgereicht, aber nirgends abgelegt.
|
|
assert seen[0]["password"]
|
|
with tclient.session_transaction() as session:
|
|
assert not any("passw" in str(key).lower() for key in session)
|
|
|
|
|
|
def test_logout_releases_shares(tesm_app, tclient, login_admin, monkeypatch):
|
|
"""Was die Anmeldung eingebunden hat, muss die Abmeldung wieder loesen.
|
|
|
|
Sonst bliebe der Zugriff mit dem Konto des Benutzers offen, bis die
|
|
Zeitgrenze der Einbindung greift -- Stunden spaeter.
|
|
"""
|
|
from tesm_core.extension import core
|
|
|
|
_license_all_modules(tesm_app)
|
|
released: list[int] = []
|
|
monkeypatch.setattr(
|
|
"tesm.services.fileshare.mount_for_login", lambda conn, **kw: []
|
|
)
|
|
monkeypatch.setattr(
|
|
"tesm.services.fileshare.release_all_for_user",
|
|
lambda conn, user_id: released.append(user_id) or 1,
|
|
)
|
|
assert core(tesm_app).logout_hooks, "kein Abmeldehaken registriert"
|
|
|
|
login_admin()
|
|
token = _token(tclient, "/")
|
|
tclient.post("/logout", data={"csrf_token": token})
|
|
assert released, "die Abmeldung hat die Freigaben nicht geloest"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"eingabe",
|
|
[
|
|
r"\\dc2022\daten",
|
|
"//dc2022/daten",
|
|
r"\\dc2022/daten",
|
|
"//dc2022/daten/",
|
|
"\\\\dc2022\\daten\\", # Trenner am Ende
|
|
"//dc2022//daten",
|
|
" //dc2022/daten ",
|
|
],
|
|
)
|
|
def test_unc_accepts_both_separators(eingabe):
|
|
"""Backslash und Schraegstrich muessen beide gehen -- und zwar gleich.
|
|
|
|
Wer den Pfad aus dem Explorer kopiert, bekommt Backslashes; wer ihn aus
|
|
einer Weboberflaeche oder von einem Linux-Kollegen bekommt, Schraegstriche.
|
|
Ueberzaehlige Trenner und einer am Ende gehoeren zum Alltag und duerfen
|
|
keine Ablehnung ausloesen.
|
|
"""
|
|
from tesm.services.fileshare import validate_unc
|
|
|
|
assert validate_unc(eingabe) == "\\\\dc2022\\daten"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"eingabe",
|
|
[
|
|
"/dc2022/daten", # ein Trenner: absoluter Pfad auf diesem Rechner
|
|
"//dc2022", # ohne Freigabenamen
|
|
"dc2022/daten",
|
|
"//dc 2022/daten",
|
|
],
|
|
)
|
|
def test_unc_rejects_what_is_not_a_share(eingabe):
|
|
from tesm.services.fileshare import FileshareError, validate_unc
|
|
|
|
with pytest.raises(FileshareError):
|
|
validate_unc(eingabe)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# DHCP: Reservierungen und Optionen je Geraet
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _netz(cidr: str = "192.168.80.0/24", interface: str = "eth0"):
|
|
from tesm.services.kea import InterfaceNetwork
|
|
|
|
return InterfaceNetwork(interface=interface, cidr=cidr)
|
|
|
|
|
|
def _geraet(conn, name: str, mac: str, ip: str) -> int:
|
|
cur = conn.execute(
|
|
"INSERT INTO devices (name, mac, ip, is_active, created_at, updated_at) "
|
|
"VALUES (?,?,?,1,datetime('now'),datetime('now'))",
|
|
(name, mac, ip),
|
|
)
|
|
return int(cur.lastrowid)
|
|
|
|
|
|
def test_reservations_only_for_existing_ranges(tesm_app):
|
|
"""Eine Reservierung ausserhalb aller Bereiche liefert Kea nie aus.
|
|
|
|
Sie trotzdem zu zeigen, hat Reservierungen versprochen, die es nicht gab --
|
|
in der Liste standen Adressen, die in der erzeugten Konfiguration fehlten.
|
|
"""
|
|
from tesm.services import kea
|
|
from tesm_core.extension import core
|
|
|
|
ext = core(tesm_app)
|
|
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
_geraet(conn, "drinnen", "aa:bb:cc:dd:ee:01", "192.168.80.50")
|
|
_geraet(conn, "draussen", "aa:bb:cc:dd:ee:02", "10.99.0.50")
|
|
conn.execute(
|
|
"INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, "
|
|
"created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1,"
|
|
"datetime('now'),datetime('now'))"
|
|
)
|
|
|
|
with ext.database.session() as conn:
|
|
netze = kea.configured_networks(conn, [_netz()])
|
|
assert [str(n) for n in netze] == ["192.168.80.0/24"]
|
|
|
|
uebersprungen: list[dict[str, str]] = []
|
|
eintraege = kea.collect_reservations(
|
|
conn, only_networks=netze, skipped=uebersprungen
|
|
)
|
|
namen = {e["name"] for e in eintraege}
|
|
assert namen == {"drinnen"}, namen
|
|
assert [e["ip"] for e in uebersprungen] == ["10.99.0.50"]
|
|
|
|
# Ohne Eingrenzung bleibt das alte Verhalten -- der Erzeuger filtert selbst.
|
|
assert len(kea.collect_reservations(conn)) == 2
|
|
|
|
|
|
def test_device_option_lands_in_its_reservation(tesm_app):
|
|
"""Ein Wert je Geraet gehoert in dessen Reservierung, nicht in den globalen Teil."""
|
|
from tesm.services import kea
|
|
from tesm_core.extension import core
|
|
|
|
ext = core(tesm_app)
|
|
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
device_id = _geraet(conn, "autodarts", "4c:52:62:25:b9:e4", "192.168.80.137")
|
|
conn.execute(
|
|
"INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, "
|
|
"created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1,"
|
|
"datetime('now'),datetime('now'))"
|
|
)
|
|
option_id = conn.execute(
|
|
"INSERT INTO dhcp_options (code, name, type, is_standard) VALUES (225,'url','string',0)"
|
|
).lastrowid
|
|
conn.execute(
|
|
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?)",
|
|
(option_id, device_id, "http://autodarts.local"),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,NULL,?)",
|
|
(option_id, "http://global.local"),
|
|
)
|
|
|
|
with ext.database.session() as conn:
|
|
config, _ = kea.render_config(conn, networks=[_netz()], settings={})
|
|
|
|
subnet = config["Dhcp4"]["subnet4"][0]
|
|
reservierung = subnet["reservations"][0]
|
|
assert reservierung["ip-address"] == "192.168.80.137"
|
|
assert reservierung["option-data"] == [{"name": "url", "data": "http://autodarts.local"}]
|
|
# Der globale Wert bleibt global und wird nicht verdraengt.
|
|
assert {"name": "url", "data": "http://global.local"} in config["Dhcp4"]["option-data"]
|
|
|
|
|
|
def test_reservation_state_from_leases():
|
|
"""Wie im Windows-DHCP: eine Reservierung ist aktiv, wenn sie benutzt wird.
|
|
|
|
Ohne diese Angabe sah eine nie abgeholte Reservierung genauso aus wie eine
|
|
benutzte -- und ein Geraet, das noch auf einer alten Adresse haengt und die
|
|
Reservierung deshalb gar nicht uebernimmt, fiel ueberhaupt nicht auf.
|
|
"""
|
|
import time
|
|
|
|
from tesm.services.kea import Lease, annotate_reservations
|
|
|
|
jetzt = int(time.time())
|
|
benutzt = Lease("192.168.80.10", "aa:bb:cc:00:00:01", "a", jetzt + 3600, "0")
|
|
veraltet = Lease("192.168.80.99", "aa:bb:cc:00:00:03", "c", jetzt + 3600, "0")
|
|
abgelaufen = Lease("192.168.80.12", "aa:bb:cc:00:00:04", "d", jetzt - 60, "0")
|
|
|
|
eintraege = annotate_reservations(
|
|
[
|
|
{"mac": "aa:bb:cc:00:00:01", "ip": "192.168.80.10"},
|
|
{"mac": "aa:bb:cc:00:00:02", "ip": "192.168.80.11"},
|
|
{"mac": "aa:bb:cc:00:00:03", "ip": "192.168.80.30"},
|
|
{"mac": "aa:bb:cc:00:00:04", "ip": "192.168.80.12"},
|
|
],
|
|
[benutzt, veraltet, abgelaufen],
|
|
)
|
|
assert [e["state"] for e in eintraege] == [
|
|
"aktiv",
|
|
"unbenutzt",
|
|
"andere_adresse",
|
|
"abgelaufen",
|
|
]
|
|
# Der Hinweis erklaert den Zustand, statt ihn nur zu benennen.
|
|
assert all(e["state_hint"] for e in eintraege)
|
|
assert eintraege[2]["lease"].address == "192.168.80.99"
|
|
|
|
|
|
def test_lease_expiry_is_readable():
|
|
"""Der rohe Unix-Zeitstempel stand vorher unveraendert in der Tabelle."""
|
|
import time
|
|
from datetime import datetime
|
|
|
|
from tesm.services.kea import Lease
|
|
|
|
jetzt = int(time.time())
|
|
lease = Lease("192.168.80.10", "aa:bb:cc:00:00:01", "a", jetzt + 5400, "0")
|
|
erwartet = datetime.fromtimestamp(jetzt + 5400).strftime("%d.%m.%Y %H:%M")
|
|
assert lease.expires_text == erwartet
|
|
assert lease.remaining_text.startswith("in 1 h")
|
|
assert str(jetzt) not in lease.expires_text
|
|
|
|
assert Lease("x", "aa:bb:cc:00:00:01", "a", 0, "0").expires_text == "unbegrenzt"
|
|
assert Lease("x", "aa:bb:cc:00:00:01", "a", jetzt - 5, "0").remaining_text == "abgelaufen"
|
|
|
|
|
|
def test_custom_option_used_only_in_reservation_is_declared(tesm_app):
|
|
"""Eine eigene Option muss deklariert sein, auch ohne globalen Wert.
|
|
|
|
Genau das hat den Dienst umgebracht: der Wert stand nur in einer
|
|
Reservierung, die ``option-def`` wurde aber nur fuer Optionen mit globalem
|
|
Wert erzeugt. Kea startet dann nicht mehr --
|
|
"definition for the option 'dhcp4.url' does not exist" -- und weil das erst
|
|
beim naechsten Start auffiel, lag der DHCP-Server stundenlang.
|
|
"""
|
|
from tesm.services import kea
|
|
from tesm_core.extension import core
|
|
|
|
ext = core(tesm_app)
|
|
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
device_id = _geraet(conn, "autodarts", "4c:52:62:25:b9:e4", "192.168.80.137")
|
|
conn.execute(
|
|
"INSERT INTO dhcp_subnets (interface, range_start, range_end, enabled, "
|
|
"created_at, updated_at) VALUES ('eth0','192.168.80.100','192.168.80.200',1,"
|
|
"datetime('now'),datetime('now'))"
|
|
)
|
|
option_id = conn.execute(
|
|
"INSERT INTO dhcp_options (code, name, type, is_standard) "
|
|
"VALUES (225,'url','string',0)"
|
|
).lastrowid
|
|
# Bewusst *kein* globaler Wert -- nur der Wert fuer dieses eine Geraet.
|
|
conn.execute(
|
|
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?)",
|
|
(option_id, device_id, "https://play.autodarts.com"),
|
|
)
|
|
|
|
with ext.database.session() as conn:
|
|
config, _ = kea.render_config(conn, networks=[_netz()], settings={})
|
|
|
|
dhcp4 = config["Dhcp4"]
|
|
benutzt = dhcp4["subnet4"][0]["reservations"][0]["option-data"]
|
|
assert benutzt == [{"name": "url", "data": "https://play.autodarts.com"}]
|
|
assert "option-data" not in dhcp4, "kein globaler Wert erwartet"
|
|
|
|
definitionen = {entry["name"]: entry for entry in dhcp4["option-def"]}
|
|
assert "url" in definitionen, "ohne option-def verweigert Kea den Start"
|
|
assert definitionen["url"] == {
|
|
"name": "url",
|
|
"code": 225,
|
|
"type": "string",
|
|
"space": "dhcp4",
|
|
}
|
|
|
|
|
|
def test_restart_marks_device_as_restarting(tesm_app):
|
|
"""Nach einem Neustart muss die Kachel das sofort zeigen.
|
|
|
|
Der Zustand wechselte vorher erst beim naechsten Pruefdurchlauf -- Vorgabe
|
|
fuenf Minuten. Ein Neustart ueber SSH ist in vierzig Sekunden durch: das
|
|
Geraet war weg und wieder da, ohne dass es je jemand gesehen haette. Die
|
|
Kachel blieb die ganze Zeit auf "online".
|
|
"""
|
|
from tesm.services import monitor
|
|
from tesm_core.extension import core
|
|
|
|
ext = core(tesm_app)
|
|
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
device_id = _geraet(conn, "kasse", "aa:bb:cc:00:00:07", "192.168.80.7")
|
|
conn.execute(
|
|
"INSERT INTO device_status (device_id, state, last_change_at) "
|
|
"VALUES (?, 'online', datetime('now'))",
|
|
(device_id,),
|
|
)
|
|
monitor.record_restart(
|
|
conn,
|
|
device_id=device_id,
|
|
action="reboot",
|
|
result="ok",
|
|
trigger="manuell",
|
|
actor="test",
|
|
duration_ms=100,
|
|
detail="",
|
|
method=monitor.METHOD_SSH,
|
|
)
|
|
|
|
with ext.database.session() as conn:
|
|
zeile = conn.execute(
|
|
"SELECT state, restart_count FROM device_status WHERE device_id=?", (device_id,)
|
|
).fetchone()
|
|
assert zeile["state"] == monitor.STATE_RESTARTING
|
|
assert zeile["restart_count"] == 1
|
|
assert monitor.dashboard_counts(conn)["restarting"] == 1
|
|
|
|
# Solange es schweigt, bleibt es "startet neu" -- nicht "ausgefallen".
|
|
vorher, nachher = monitor.record_status(
|
|
conn, device_id, monitor.PingResult(ok=False, latency_ms=None, detail="")
|
|
)
|
|
assert nachher == monitor.STATE_RESTARTING, "ein laufender Neustart ist kein Ausfall"
|
|
|
|
# Antwortet es wieder, ist es online.
|
|
_, nachher = monitor.record_status(
|
|
conn, device_id, monitor.PingResult(ok=True, latency_ms=3, detail="")
|
|
)
|
|
assert nachher == "online"
|
|
|
|
|
|
def test_restart_state_gives_up_after_the_grace_period(tesm_app):
|
|
"""Ein Neustart, der fuenf Minuten dauert, ist keiner mehr."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from tesm.services import monitor
|
|
from tesm_core.extension import core
|
|
|
|
ext = core(tesm_app)
|
|
lange_her = (
|
|
datetime.now(timezone.utc) - timedelta(seconds=monitor.RESTART_GRACE_SECONDS + 60)
|
|
).isoformat()
|
|
with ext.database.session() as conn, ext.database.transaction(conn):
|
|
device_id = _geraet(conn, "haenger", "aa:bb:cc:00:00:08", "192.168.80.8")
|
|
conn.execute(
|
|
"INSERT INTO device_status (device_id, state, last_restart_at, last_change_at) "
|
|
"VALUES (?, ?, ?, datetime('now'))",
|
|
(device_id, monitor.STATE_RESTARTING, lange_her),
|
|
)
|
|
|
|
with ext.database.session() as conn:
|
|
_, nachher = monitor.record_status(
|
|
conn, device_id, monitor.PingResult(ok=False, latency_ms=None, detail="")
|
|
)
|
|
assert nachher == "offline"
|