"""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"