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