"""Neustarts und SSH-Zugang zu Clients. Zwei Luecken, die erst im Betrieb auffielen: 1. **Die Neustart-Historie zeigte nur die halbe Wahrheit.** Sie hiess "PoE-Vorgaenge" und enthielt auch nur PoE-Vorgaenge. Ein Geraet, das die Wartung per ``reboot`` neu gestartet hatte, fehlte dort vollstaendig -- obwohl genau dort nachsieht, wer wissen will, warum ein Geraet weg war. 2. **Fuer Clients gab es keine Host-Schluessel-Freigabe.** Die Wartung verweigerte den Dienst ("Schluessel unbekannt"), und es gab keine Stelle in der Oberflaeche, an der man ihn haette ansehen und freigeben koennen. Eine Sackgasse, aus der nur ein Shell-Zugang auf den Server herausfuehrte. """ from __future__ import annotations from typing import Any import pytest @pytest.fixture() def tesm_app(instance_env: Any): from tesm import create_app return create_app(TESTING=True) @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 session(tclient: Any, admin: dict[str, str]): tclient.post( "/login", data={ "username": admin["username"], "password": admin["password"], "csrf_token": _token(tclient), }, follow_redirects=True, ) return tclient @pytest.fixture() def device(tesm_app: Any): """Ein Client mit Zugangsdaten -- Voraussetzung fuer Wartung und Konsole.""" from tesm_core.extension import core extension = core(tesm_app) with extension.database.session() as conn, extension.database.transaction(conn): conn.execute( "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " "updated_at) VALUES ('linux-login','wartung','', 'linux', datetime('now'), " "datetime('now'))" ) conn.execute( "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, " "created_at, updated_at) VALUES ('Kamera-1','aa:bb:cc:dd:ee:01','10.9.9.9',22,1,1," "datetime('now'), datetime('now'))" ) return {"id": 1, "ip": "10.9.9.9", "port": 22} # --------------------------------------------------------------------------- # # Neustart-Historie # --------------------------------------------------------------------------- # def test_restart_log_replaces_the_poe_only_page(session): """Die alte Adresse ist weg, die neue zeigt Neustarts jeder Art.""" assert session.get("/protokolle/poe").status_code == 404 response = session.get("/protokolle/neustarts") assert response.status_code == 200 body = response.get_data(as_text=True) assert "Neustarts" in body assert "PoE-Vorgaenge" not in body def test_restart_log_shows_both_methods(tesm_app, session, device): from tesm.services import monitor from tesm_core.extension import core extension = core(tesm_app) with extension.database.session() as conn, extension.database.transaction(conn): monitor.record_restart( conn, device_id=1, action="cycle", result="ok", trigger="auto", actor="system", duration_ms=1200, detail="Port getrennt und wieder zugeschaltet", ) monitor.record_restart( conn, device_id=1, action="reboot", result="ok", trigger="maintenance", actor="root", duration_ms=45000, detail="Neustart ueber SSH", method=monitor.METHOD_SSH, ) body = session.get("/protokolle/neustarts").get_data(as_text=True) assert "PoE-Port" in body assert "SSH" in body assert "Kamera-1" in body # Filter je Methode only_ssh = session.get("/protokolle/neustarts?methode=ssh").get_data(as_text=True) assert "maintenance" in only_ssh assert only_ssh.count(" None: self._output = output self._status = status self._sent = False self.closed = False def settimeout(self, _value: float) -> None: pass def set_combine_stderr(self, _value: bool) -> None: pass def exec_command(self, _command: str) -> None: pass def recv_ready(self) -> bool: return not self._sent def recv(self, _size: int) -> bytes: self._sent = True return self._output def exit_status_ready(self) -> bool: return self._sent def recv_exit_status(self) -> int: return self._status def close(self) -> None: self.closed = True class FakeTransport: def __init__(self, channel: FakeChannel) -> None: self.channel = channel def is_active(self) -> bool: return True def open_session(self) -> FakeChannel: return self.channel def close(self) -> None: pass def test_shell_session_returns_output_and_status(): from tesm.services.shellcli import ShellSession channel = FakeChannel(b"Linux poetest 6.8.0\n", 0) session = ShellSession(None, host="h", port=22, username="u", password="p") session._transport = FakeTransport(channel) # noqa: SLF001 result = session.run("uname -sr") assert result["output"].startswith("Linux poetest") assert result["status"] == 0 assert result["duration_ms"] >= 0 assert channel.closed, "Der Kanal muss geschlossen werden." def test_shell_session_reports_a_failing_command(): from tesm.services.shellcli import ShellSession session = ShellSession(None, host="h", port=22, username="u", password="p") session._transport = FakeTransport(FakeChannel(b"bash: nope: not found\n", 127)) # noqa: SLF001 result = session.run("nope") assert result["status"] == 127 def test_shell_session_refuses_a_dead_transport(): from tesm.services.shellcli import ShellSession from tesm.services.ssh import SshError session = ShellSession(None, host="h", port=22, username="u", password="p") with pytest.raises(SshError): session.run("uptime") def test_shell_session_truncates_a_flood(): from tesm.services.shellcli import MAX_OUTPUT_BYTES, ShellSession flood = b"x" * (MAX_OUTPUT_BYTES + 1024) session = ShellSession(None, host="h", port=22, username="u", password="p") session._transport = FakeTransport(FakeChannel(flood, 0)) # noqa: SLF001 result = session.run("yes") assert "abgeschnitten" in result["output"] assert len(result["output"]) < len(flood.decode()) # --------------------------------------------------------------------------- # # Auftragsausgabe # --------------------------------------------------------------------------- # def test_job_output_keeps_one_line_per_entry(tesm_app): """Jede Ausgabe gehoert auf eine eigene Zeile. ``splitlines()`` wirft das abschliessende Zeilenende weg. Ohne Korrektur klebte jede neue Zeile an der vorherigen, und das ganze Protokoll stand am Ende auf einer einzigen Zeile -- genau so im Betrieb aufgefallen. """ from tesm.services import maintenance from tesm_core.db import Database from tesm_core.extension import core extension = core(tesm_app) with extension.database.session() as conn, extension.database.transaction(conn): handle = maintenance.create_job( conn, kind=maintenance.JOB_KIND_UPDATE, subject="Testgeraet", actor="root" ) for line in ("--- Paketlisten aktualisieren ---", "sudo: a password is required", "Schritt endete mit Rueckgabewert 1."): maintenance.update_job(extension.database, handle.id, append_output=f"12:00:00 {line}\n") with extension.database.session() as conn: output = str(Database.value(conn, "SELECT output FROM jobs WHERE id=?", (handle.id,))) lines = output.splitlines() assert len(lines) == 3, f"Erwartet drei Zeilen, bekommen: {output!r}" assert lines[0].endswith("--- Paketlisten aktualisieren ---") assert lines[1].endswith("sudo: a password is required") assert "---sudo:" not in output, "Zeilen kleben aneinander." def test_job_output_is_trimmed_but_stays_line_wise(tesm_app): from tesm.services import maintenance from tesm_core.db import Database from tesm_core.extension import core extension = core(tesm_app) with extension.database.session() as conn, extension.database.transaction(conn): handle = maintenance.create_job( conn, kind=maintenance.JOB_KIND_UPDATE, subject="Flut", actor="root" ) for index in range(maintenance.MAX_OUTPUT_LINES + 50): maintenance.update_job(extension.database, handle.id, append_output=f"Zeile {index}\n") with extension.database.session() as conn: output = str(Database.value(conn, "SELECT output FROM jobs WHERE id=?", (handle.id,))) lines = output.splitlines() assert len(lines) == maintenance.MAX_OUTPUT_LINES # Die juengsten Zeilen bleiben stehen, nicht die aeltesten. assert lines[-1] == f"Zeile {maintenance.MAX_OUTPUT_LINES + 49}" def test_known_failures_get_an_actionable_hint(): """Die rohe Meldung sagt die Wahrheit, aber nicht, was zu tun ist.""" from tesm.services.maintenance import hint_for # Passwort verlangt, aber keines hinterlegt -> auf die Zugangsdaten zeigen. hint = hint_for("sudo: a password is required") assert "Zugangsdaten" in hint assert "NOPASSWD" not in hint, "Der Weg ueber die Zugangsdaten ist der einfachere." # Passwort abgewiesen -> nicht die Zugangsdaten, sondern sudo ist das Problem. assert "abgewiesen" in hint_for("sudo: 1 incorrect password attempt") # Konto darf gar kein sudo -> sudoers-Regel noetig. not_allowed = hint_for("wartung is not in the sudoers file. This incident will be reported.") assert "sudoers" in not_allowed assert "NOPASSWD" in not_allowed assert "requiretty" in hint_for("sudo: no tty present and no askpass program specified") assert "apt-get" in hint_for("bash: apt-get: command not found") assert "unattended" in hint_for("E: Could not get lock /var/lib/dpkg/lock-frontend") assert hint_for("alles in Ordnung") == "" def test_hint_survives_surrounding_output(): from tesm.services.maintenance import hint_for output = "\n".join( ["--- Paketlisten aktualisieren ---", "sudo: a password is required", "Ende"] ) assert hint_for(output) # --------------------------------------------------------------------------- # # sudo mit dem hinterlegten Passwort # --------------------------------------------------------------------------- # class SudoTransport: """Zeichnet auf, welche Kommandos laufen und was ueber stdin hereinkommt.""" def __init__(self, passwordless_ok: bool) -> None: self.passwordless_ok = passwordless_ok self.commands: list[str] = [] self.stdin: list[str] = [] def is_active(self) -> bool: return True def open_session(self): # type: ignore[no-untyped-def] return SudoChannel(self) def close(self) -> None: pass class SudoChannel: def __init__(self, transport: SudoTransport) -> None: self.transport = transport self.command = "" self.written = b"" self._done = False def settimeout(self, _v: float) -> None: pass def set_combine_stderr(self, _v: bool) -> None: pass def exec_command(self, command: str) -> None: self.command = command self.transport.commands.append(command) def sendall(self, data: bytes) -> None: self.written += data def shutdown_write(self) -> None: self.transport.stdin.append(self.written.decode()) def recv_ready(self) -> bool: return False def exit_status_ready(self) -> bool: return True def recv(self, _n: int) -> bytes: return b"" def recv_exit_status(self) -> int: if self.command.startswith("sudo -n true"): return 0 if self.transport.passwordless_ok else 1 return 0 def close(self) -> None: pass def test_passwordless_sudo_keeps_the_password_on_the_server(): """Geht es ohne, verlaesst das Passwort den Server gar nicht erst.""" from tesm.services.maintenance import SUDO_PASSWORDLESS, _sudo_mode transport = SudoTransport(passwordless_ok=True) lines: list[str] = [] sudo, stdin = _sudo_mode(transport, "geheim", lines.append) assert sudo == SUDO_PASSWORDLESS assert stdin == "" assert transport.stdin == [], "Ohne Not wird kein Passwort uebertragen." assert "ohne Passwort" in " ".join(lines) def test_sudo_falls_back_to_the_stored_password(): from tesm.services.maintenance import SUDO_WITH_PASSWORD, _sudo_mode transport = SudoTransport(passwordless_ok=False) lines: list[str] = [] sudo, stdin = _sudo_mode(transport, "geheim", lines.append) assert sudo == SUDO_WITH_PASSWORD assert stdin == "geheim\n" assert "Zugangsdaten" in " ".join(lines) def test_password_never_appears_on_the_command_line(): """Auf dem Zielgeraet steht die Kommandozeile in ``ps`` -- fuer jeden lesbar.""" from tesm.services.maintenance import ( REBOOT_COMMAND, UPDATE_COMMANDS, _run_remote_command, _sudo_mode, ) transport = SudoTransport(passwordless_ok=False) sudo, stdin = _sudo_mode(transport, "streng-geheim", lambda _line: None) for _label, template in UPDATE_COMMANDS: _run_remote_command( transport, template.format(sudo=sudo), timeout=5, on_line=lambda _l: None, stdin_text=stdin, ) _run_remote_command( transport, REBOOT_COMMAND.format(sudo=sudo), timeout=5, on_line=lambda _l: None, stdin_text=stdin, ) assert all("streng-geheim" not in command for command in transport.commands), ( "Das Passwort darf nie in einem Kommando stehen." ) # Es geht ausschliesslich ueber stdin. assert transport.stdin, "Das Passwort muss ueber stdin gehen." assert all(entry == "streng-geheim\n" for entry in transport.stdin) def test_sudo_prompt_is_suppressed(): """Ohne ``-p ''`` landet die Passwortaufforderung mitten in der Ausgabe.""" from tesm.services.maintenance import SUDO_WITH_PASSWORD assert "-S" in SUDO_WITH_PASSWORD assert "-p ''" in SUDO_WITH_PASSWORD assert "-k" in SUDO_WITH_PASSWORD def test_commands_carry_no_hardcoded_sudo(): """Das Verfahren wird zur Laufzeit gewaehlt -- nicht in der Konstante festgelegt.""" from tesm.services.maintenance import REBOOT_COMMAND, UPDATE_COMMANDS for _label, template in UPDATE_COMMANDS: assert "{sudo}" in template assert "sudo -n" not in template assert "{sudo}" in REBOOT_COMMAND def test_without_a_stored_password_nothing_is_invented(): from tesm.services.maintenance import SUDO_PASSWORDLESS, _sudo_mode transport = SudoTransport(passwordless_ok=False) lines: list[str] = [] sudo, stdin = _sudo_mode(transport, "", lines.append) assert sudo == SUDO_PASSWORDLESS assert stdin == "" assert "keines hinterlegt" in " ".join(lines) # --------------------------------------------------------------------------- # # Rueckkehr nach einem Neustart # --------------------------------------------------------------------------- # def test_reboot_waits_on_tcp_not_on_icmp(): """ICMP kann der Webprozess gar nicht. Die systemd-Unit der Weboberflaeche hat ein leeres Die systemd-Unit der Weboberflaeche bekommt keine AmbientCapabilities -- damit greift die Datei-Capability von ``/usr/bin/ping`` nicht. Jeder Ping schlaegt dort fehl, und der Wartungsauftrag lief deshalb *immer* in die Zeitueberschreitung, obwohl das Geraet laengst zurueck war. """ import inspect from tesm.services import maintenance source = inspect.getsource(maintenance.run_reboot) assert "tcp_reachable(" in source assert "ping(" not in source, "ICMP ist im Webprozess nicht verfuegbar." def test_web_unit_has_no_net_raw_but_the_monitor_does(): """Die Aufteilung ist Absicht und muss so bleiben.""" from pathlib import Path root = Path(__file__).resolve().parent.parent / "deploy" / "systemd" web = (root / "tesm.service").read_text(encoding="utf-8") monitor = (root / "tesm-monitor.service").read_text(encoding="utf-8") # Kein CapabilityBoundingSet mehr -- es bricht sudo (gemessen). Was den # Webprozess von ICMP fernhaelt, ist das Fehlen von AmbientCapabilities. assert "CapabilityBoundingSet=" not in web assert "AmbientCapabilities=CAP_NET_RAW" not in web, ( "Der Webprozess soll keine Rohsockets duerfen -- er ist der exponierte." ) assert "AmbientCapabilities=CAP_NET_RAW" in monitor, ( "Der Ueberwachungsdienst braucht CAP_NET_RAW fuer ICMP." ) def test_tcp_probe_needs_no_privileges(): import socket import threading from tesm.services.monitor import tcp_reachable server = socket.socket() server.bind(("127.0.0.1", 0)) server.listen(1) port = server.getsockname()[1] threading.Thread(target=lambda: server.accept(), daemon=True).start() assert tcp_reachable("127.0.0.1", port, timeout=2) server.close() def test_tcp_probe_reports_a_closed_port(): from tesm.services.monitor import tcp_reachable # Port 1 ist auf keinem sinnvoll konfigurierten System offen. assert not tcp_reachable("127.0.0.1", 1, timeout=1) # --------------------------------------------------------------------------- # # Keine geschachtelten Datenbankverbindungen # --------------------------------------------------------------------------- # def test_restart_takes_the_keystore_not_a_callback(): """Ein Rueckruf kannte die offene Verbindung nicht und oeffnete eine neue. Der Ueberwachungsdienst starb dadurch bei jedem automatischen PoE-Neustart mit ``NestedConnectionError`` -- und der Geraetestatus blieb stehen. Mit dem Schluesselspeicher als Argument gibt es die falsche Variante nicht mehr. """ import inspect from tesm.services import monitor for function in (monitor.restart_device_poe, monitor.run_sweep): signature = inspect.signature(function) assert "keystore" in signature.parameters, function.__name__ assert "decrypt_secret" not in signature.parameters, function.__name__ source = inspect.getsource(monitor.restart_device_poe) assert "credential_secret(conn," in source, "Die vorhandene Verbindung muss benutzt werden." def test_no_caller_passes_a_decrypt_callback(): from pathlib import Path root = Path(__file__).resolve().parent.parent / "apps" / "tesm" / "src" / "tesm" offenders = [ str(path.relative_to(root)) for path in root.rglob("*.py") if "__pycache__" not in path.parts and "decrypt_secret" in path.read_text(encoding="utf-8") ] assert not offenders, f"Rueckruf lebt noch in: {offenders}" def test_sweep_survives_a_restart_without_nested_connection(tesm_app, device): """Der ganze Durchlauf, inklusive Neustartversuch, ohne zweite Verbindung.""" from tesm.services import monitor from tesm_core.extension import core extension = core(tesm_app) live = extension.registry["live_log"] with tesm_app.app_context(): report = monitor.run_sweep( database=extension.database, live=live, keystore=extension.keystore, license_allows_auto_restart=True, failures_before_restart=1, ) # Das Geraet ist nicht erreichbar -- entscheidend ist, dass der Durchlauf # ueberhaupt durchlaeuft und nicht an einer geschachtelten Verbindung stirbt. assert report.checked >= 1 # --------------------------------------------------------------------------- # # Windows: kein SSH, kein Host-Schluessel # --------------------------------------------------------------------------- # def test_windows_is_not_an_ssh_target(): from tesm.services import inventory assert inventory.is_ssh_managed("linux") assert inventory.is_ssh_managed("switch") assert inventory.is_ssh_managed(None) assert not inventory.is_ssh_managed("windows") def test_windows_devices_do_not_appear_in_the_host_key_page(tesm_app, session): """Sonst stuenden sie fuer immer auf "Freigabe offen".""" from tesm_core.extension import core extension = core(tesm_app) with extension.database.session() as conn, extension.database.transaction(conn): conn.execute( "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " "updated_at) VALUES ('win','admin','','windows',datetime('now'),datetime('now'))" ) conn.execute( "INSERT INTO credentials (name, username, secret_encrypted, category, created_at, " "updated_at) VALUES ('lin','wartung','','linux',datetime('now'),datetime('now'))" ) conn.execute( "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, created_at, " "updated_at) VALUES ('PC-Win','aa:bb:cc:00:00:01','10.5.5.1',22,1,1,datetime('now')," "datetime('now'))" ) conn.execute( "INSERT INTO devices (name, mac, ip, ssh_port, credential_id, is_active, created_at, " "updated_at) VALUES ('PC-Linux','aa:bb:cc:00:00:02','10.5.5.2',22,2,1,datetime('now')," "datetime('now'))" ) body = session.get("/hostschluessel/").get_data(as_text=True) assert "PC-Linux" in body assert "PC-Win" not in body, "Windows braucht keinen Host-Schluessel." def test_windows_reboot_uses_rpc_not_ssh(): import inspect from tesm.services import maintenance source = inspect.getsource(maintenance.run_reboot) assert "_reboot_windows(" in source assert "WINDOWS_PROBE_PORT" in source # Der SSH-Zweig darf fuer Windows gar nicht erst betreten werden. assert "if is_windows:" in source def test_windows_password_never_reaches_the_command_line(monkeypatch, tmp_path): """``net -U benutzer%pass`` waere in ``ps`` sichtbar -- auf beiden Seiten.""" import subprocess from tesm.services import windowsops seen: dict[str, object] = {} def fake_run(command, **kwargs): seen["command"] = command authfile = command[command.index("--authentication-file") + 1] seen["authfile_content"] = pathlib_read(authfile) return subprocess.CompletedProcess(command, 0, "Shutdown ausgeloest", "") def pathlib_read(path: str) -> str: with open(path, encoding="utf-8") as handle: return handle.read() monkeypatch.setattr(windowsops.shutil, "which", lambda _name: "/usr/bin/net") monkeypatch.setattr(windowsops.subprocess, "run", fake_run) result = windowsops.reboot("10.5.5.1", "administrator", "streng-geheim") assert result.ok command = " ".join(str(part) for part in seen["command"]) assert "streng-geheim" not in command, "Passwort steht in der Kommandozeile." assert "%" not in command # Es geht ausschliesslich ueber die Anmeldedatei. assert "password=streng-geheim" in str(seen["authfile_content"]) def test_windows_auth_file_is_removed_afterwards(monkeypatch): import os import subprocess from tesm.services import windowsops captured: dict[str, str] = {} def fake_run(command, **kwargs): captured["path"] = command[command.index("--authentication-file") + 1] return subprocess.CompletedProcess(command, 0, "", "") monkeypatch.setattr(windowsops.shutil, "which", lambda _name: "/usr/bin/net") monkeypatch.setattr(windowsops.subprocess, "run", fake_run) windowsops.reboot("10.5.5.1", "administrator", "geheim") assert not os.path.exists(captured["path"]), "Die Anmeldedatei muss verschwinden." def test_windows_domain_prefix_is_split(): from tesm.services import windowsops captured: dict[str, str] = {} class FakeCompleted: returncode = 0 stdout = "" stderr = "" def fake_run(command, **kwargs): with open(command[command.index("--authentication-file") + 1], encoding="utf-8") as handle: captured["content"] = handle.read() return FakeCompleted() import subprocess original_which = windowsops.shutil.which original_run = windowsops.subprocess.run windowsops.shutil.which = lambda _name: "/usr/bin/net" windowsops.subprocess.run = fake_run try: windowsops.reboot("10.5.5.1", "EERTMOED" + chr(92) + "administrator", "geheim") finally: windowsops.shutil.which = original_which windowsops.subprocess.run = original_run assert "username=administrator" in captured["content"] assert "domain=EERTMOED" in captured["content"] assert subprocess is not None def test_missing_samba_is_explained_not_silently_ignored(monkeypatch): from tesm.services import windowsops monkeypatch.setattr(windowsops.shutil, "which", lambda _name: None) result = windowsops.reboot("10.5.5.1", "administrator", "geheim") assert not result.ok assert windowsops.REQUIRED_PACKAGE in result.hint def test_windows_errors_get_a_hint(): from tesm.services.windowsops import hint_for assert "DOMAENE" in hint_for("NT_STATUS_LOGON_FAILURE") assert "Remotesystem" in hint_for("NT_STATUS_ACCESS_DENIED") assert "445" in hint_for("NT_STATUS_CONNECTION_REFUSED") assert hint_for("alles gut") == "" def test_samba_package_is_installable_through_the_helper(): from pathlib import Path helper = (Path(__file__).resolve().parent.parent / "deploy" / "tesm-helper").read_text( encoding="utf-8" ) from tesm.services.windowsops import REQUIRED_PACKAGE assert REQUIRED_PACKAGE in helper, "Sonst laesst es sich nicht nachinstallieren." # --------------------------------------------------------------------------- # # Seiten mit Inhalt rendern # --------------------------------------------------------------------------- # @pytest.fixture() def populated(tesm_app: Any): """Ein Bestand, der jede Verzweigung der Vorlagen erreicht. Ein Rauchtest auf leeren Seiten prueft zu wenig: Schleifenkoerper laufen dort nie. Genau so ist eine fehlende Vorlagenvariable durchgerutscht -- lokal gruen, auf dem Zielsystem HTTP 500, sobald der erste Datensatz da war. """ from tesm_core.extension import core extension = core(tesm_app) with extension.database.session() as conn, extension.database.transaction(conn): for index, (name, category) in enumerate( [("sw-login", "switch"), ("linux-login", "linux"), ("win-login", "windows")], start=1 ): conn.execute( "INSERT INTO credentials (name, username, secret_encrypted, category, " "created_at, updated_at) VALUES (?,?,'',?,datetime('now'),datetime('now'))", (name, f"user{index}", category), ) conn.execute( "INSERT INTO switches (hostname, ip, ssh_port, credential_id, created_at, updated_at) " "VALUES ('sw-1','10.7.0.1',22,1,datetime('now'),datetime('now'))" ) for index, (name, credential) in enumerate( [("Linux-PC", 2), ("Windows-PC", 3), ("Ohne-Login", None)], start=1 ): conn.execute( "INSERT INTO devices (name, mac, ip, ssh_port, switch_id, port, credential_id, " "is_active, created_at, updated_at) " "VALUES (?,?,?,22,1,?,?,1,datetime('now'),datetime('now'))", (name, f"aa:bb:cc:00:0{index}:01", f"10.7.0.{10 + index}", str(index), credential), ) conn.execute( "INSERT INTO switch_host_keys (host, port, key_type, fingerprint, added_at, added_by) " "VALUES ('10.7.0.1',22,'ssh-ed25519',?,datetime('now'),'test')", ("SHA256:" + "A" * 43,), ) return True PAGES_WITH_CONTENT = [ "/", "/clients/", "/clients/1", "/switche/", "/switche/1", "/zugangsdaten/", "/hostschluessel/", "/protokolle/neustarts", "/papierkorb/", "/diagnose/", ] @pytest.mark.parametrize("path", PAGES_WITH_CONTENT) def test_pages_render_with_real_data(session, populated, path): response = session.get(path) body = response.get_data(as_text=True) assert response.status_code == 200, f"{path}: HTTP {response.status_code}" assert "Interner Fehler" not in body, path # Eine fehlende Vorlagenvariable schlaegt als UndefinedError durch -- die # Fehlerseite nennt sie beim Namen. assert "is undefined" not in body, path def test_maintenance_pages_render_with_real_data(session, populated, licensed): for path in ("/wartung/", "/dateifreigaben/", "/dhcp/"): response = session.get(path) assert response.status_code == 200, f"{path}: HTTP {response.status_code}" assert "Interner Fehler" not in response.get_data(as_text=True), path def test_device_form_offers_creating_credentials(session, populated): body = session.get("/clients/").get_data(as_text=True) assert "neue Zugangsdaten anlegen" in body assert "new_credential_name" in body assert "new_credential_secret" in body def test_new_device_can_bring_its_own_credentials(session, populated, tesm_app): from tesm_core.db import Database from tesm_core.extension import core response = session.post( "/clients/neu", data={ "csrf_token": _token(session, "/clients/"), "name": "Frisch", "mac": "aa:bb:cc:11:22:33", "ip": "10.7.0.90", "ssh_port": "22", "credential_id": "__neu__", "new_credential_name": "Frische Zugangsdaten", "new_credential_username": "wartung", "new_credential_secret": "Ein-Passwort-1", "new_credential_category": "linux", "is_active": "1", }, follow_redirects=True, ) assert "angelegt" in response.get_data(as_text=True) extension = core(tesm_app) with extension.database.session() as conn: credential = Database.one( conn, "SELECT * FROM credentials WHERE name='Frische Zugangsdaten'" ) device = Database.one(conn, "SELECT * FROM devices WHERE name='Frisch'") assert credential is not None, "Die Zugangsdaten muessen mit angelegt werden." assert device is not None assert device["credential_id"] == credential["id"], "Sie muessen auch zugeordnet sein." assert credential["secret_encrypted"], "Das Passwort muss verschluesselt gespeichert sein." def test_a_nameless_new_credential_blocks_the_device(session, populated, tesm_app): """Halb ausgefuehrt waere schlimmer als gar nicht: das Geraet haette dann keine Zugangsdaten, obwohl welche gewollt waren.""" from tesm_core.db import Database from tesm_core.extension import core response = session.post( "/clients/neu", data={ "csrf_token": _token(session, "/clients/"), "name": "Halb", "mac": "aa:bb:cc:44:55:66", "ip": "10.7.0.91", "ssh_port": "22", "credential_id": "__neu__", "new_credential_name": "", "is_active": "1", }, follow_redirects=True, ) assert "Bezeichnung" in response.get_data(as_text=True) extension = core(tesm_app) with extension.database.session() as conn: assert Database.one(conn, "SELECT * FROM devices WHERE name='Halb'") is None def test_sweep_holds_no_write_lock_across_the_ssh_session(): """Eine SSH-Sitzung zum Switch dauert Sekunden -- eine Schreibsperre auch. Auf POETEST dauerte ein Durchlauf mit drei nicht erreichbaren Switchen knapp 40 Sekunden, und die Oberflaeche antwortete waehrenddessen mit "database is locked". Die langsame Netzarbeit gehoert deshalb ausserhalb der Transaktion; geschrieben wird kurz und am Ende. """ import inspect from tesm.services import monitor source = inspect.getsource(monitor.run_sweep) assert "with database.session() as conn, database.transaction(conn):\n outcome" not in source assert "database=database," in source, "Die Aufzeichnung braucht eigene Transaktionen." restart = inspect.getsource(monitor.restart_device_poe) assert "with write():" in restart # Die SSH-Sitzung darf in keiner Transaktion stehen. ssh_line = restart.index("with SwitchCli(") assert "with write():" not in restart[max(0, ssh_line - 400) : ssh_line] # --------------------------------------------------------------------------- # # Neustart: mehrere Wege, klare Rangfolge # --------------------------------------------------------------------------- # def _device(**overrides): base = { "id": 1, "name": "Testgeraet", "switch_id": None, "port": "", "credential_id": None, "credential_category": "", } return {**base, **overrides} def test_poe_wins_when_several_ways_are_open(): """PoE zuerst: es wirkt auch, wenn das Betriebssystem nicht mehr antwortet.""" from tesm.services import restart device = _device(switch_id=1, port="7", credential_id=2, credential_category="linux") options = restart.available(device) assert [entry.key for entry in options] == ["poe", "ssh"] assert restart.preferred(device).key == "poe" def test_only_ssh_when_there_is_no_port(): from tesm.services import restart device = _device(credential_id=2, credential_category="linux") assert [entry.key for entry in restart.available(device)] == ["ssh"] def test_windows_offers_rpc_never_ssh(): from tesm.services import restart device = _device(credential_id=2, credential_category="windows") assert [entry.key for entry in restart.available(device)] == ["rpc"] def test_a_switch_without_a_port_is_not_a_way(): """Ein Knopf, der verlaesslich nichts bewirkt, ist schlimmer als kein Knopf.""" from tesm.services import restart assert restart.available(_device(switch_id=1, port="")) == [] assert restart.available(_device(switch_id=None, port="7")) == [] def test_nothing_configured_means_no_button(): from tesm.services import restart assert restart.available(_device()) == [] assert restart.preferred(_device()) is None def test_ssh_and_rpc_need_the_maintenance_module(): """PoE bleibt frei -- ein haengendes Geraet muss man immer neu starten koennen.""" from tesm.services import restart linux = _device(switch_id=1, port="7", credential_id=2, credential_category="linux") assert [entry.key for entry in restart.available(linux, maintenance_active=False)] == ["poe"] windows = _device(credential_id=2, credential_category="windows") assert restart.available(windows, maintenance_active=False) == [] def test_an_impossible_request_is_refused_not_substituted(): """Sonst startet jemand ein Geraet anders neu, als er dachte.""" from tesm.services import restart device = _device(switch_id=1, port="7") assert restart.resolve(device, "ssh") is None assert restart.resolve(device, "poe").key == "poe" # Ohne Angabe gilt die Rangfolge. assert restart.resolve(device, "").key == "poe" def test_restart_route_refuses_an_unavailable_method(session, populated): """Geraet 1 ("Linux-PC") haengt an Switch 1 Port 1 und hat Linux-Zugangsdaten.""" response = session.post( "/clients/3/neustart", data={"csrf_token": _token(session, "/clients/"), "method": "poe"}, follow_redirects=True, ) body = response.get_data(as_text=True) # Geraet 3 ("Ohne-Login") hat Switch und Port, aber keine Zugangsdaten -- # PoE geht, SSH nicht. Umgekehrt geprueft: response = session.post( "/clients/3/neustart", data={"csrf_token": _token(session, "/clients/"), "method": "ssh"}, follow_redirects=True, ) assert "steht fuer dieses Geraet nicht zur Verfuegung" in response.get_data(as_text=True) assert body is not None def test_device_list_shows_the_credential_category(session, populated): body = session.get("/clients/").get_data(as_text=True) assert "Linux-Client" in body assert "Windows-Client" in body def test_restart_buttons_appear_per_available_way(session, populated, licensed): body = session.get("/clients/").get_data(as_text=True) assert "/neustart" in body assert "PoE-Port" in body assert "Neustart ueber RPC" in body def test_the_old_poe_only_endpoint_is_gone(): """Ein zweiter Endpunkt fuer denselben Zweck waere eine Verzweigung zu viel.""" from tesm import create_app rules = {rule.rule for rule in create_app(TESTING=True).url_map.iter_rules()} assert "/clients//neustart" in rules assert "/clients//poe-neustart" not in rules