Files
tesm/tests/test_tesm_smoke.py
T
alientimandClaude Opus 5 7354ff352b TESM 2.0.0 -- Neubau
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>
2026-09-02 18:00:06 +02:00

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"