Files
ssh-jumphost/tests/test_rdp_drive_cleanup.py
2026-09-03 08:01:27 +02:00

200 lines
8.2 KiB
Python

"""Tests zu den beiden am 3.9. gefundenen, still fehlschlagenden Fehlern:
1. scripts/cleanup_rdp_drives.py -- der Aufraeummechanismus fuer die pro
Sitzung umgeleiteten RDP-Laufwerke (Befund E6). Die vorherige,
App-interne Loesung konnte prinzipiell nicht funktionieren (Rechte) und
hat ihren PermissionError wegen shutil.rmtree(..., ignore_errors=True)
auch noch verschluckt.
2. static/js/terminal.js -- Regressionsschutz gegen die Fehlerklasse
"Terminal-Option, die es in xterm.js gar nicht gibt". Genau daran ist
Copy-on-Select gescheitert (copyOnSelect stammt aus VS Code, nicht aus
xterm.js; xterm.js verwirft unbekannte Optionen kommentarlos).
"""
from __future__ import annotations
import importlib.util
import json
import re
import sqlite3
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _load_cleanup_module():
path = PROJECT_ROOT / "scripts" / "cleanup_rdp_drives.py"
spec = importlib.util.spec_from_file_location("cleanup_rdp_drives", path)
module = importlib.util.module_from_spec(spec)
sys.modules["cleanup_rdp_drives"] = module
spec.loader.exec_module(module)
return module
@pytest.fixture()
def data_dir(tmp_path: Path) -> Path:
"""Datenverzeichnis mit DB und ein paar Laufwerksverzeichnissen:
Sitzung 66 und 70 beendet, Sitzung 67 laeuft noch."""
(tmp_path / "rdp-drives").mkdir()
for name in ("4_66", "4_67", "5_70"):
(tmp_path / "rdp-drives" / name).mkdir()
(tmp_path / "rdp-drives" / "4_66" / "uebertragen.txt").write_text("inhalt")
conn = sqlite3.connect(tmp_path / "jumphost.db")
conn.execute("CREATE TABLE sessions (id INTEGER PRIMARY KEY, ended_at TEXT)")
conn.executemany(
"INSERT INTO sessions (id, ended_at) VALUES (?, ?)",
[(66, "2026-09-03T10:00:00.000Z"), (67, None), (70, "2026-09-03T09:00:00.000Z")],
)
conn.commit()
conn.close()
return tmp_path
def test_entfernt_nur_beendete_sitzungen(data_dir: Path):
"""Kern der Sache: eine NOCH LAUFENDE Sitzung darf ihr Laufwerk nicht
unter den Fuessen weggeraeumt bekommen. Deshalb entscheidet der
Datenbankzustand und keine Altersheuristik ueber mtime -- eine lange,
aber stille Sitzung (Fernwartung ueber Nacht) waere sonst betroffen."""
module = _load_cleanup_module()
removed, _ = module.cleanup(data_dir)
drives = data_dir / "rdp-drives"
assert removed == 2
assert not (drives / "4_66").exists()
assert not (drives / "5_70").exists()
assert (drives / "4_67").is_dir(), "laufende Sitzung wurde faelschlich entfernt"
def test_fremde_eintraege_bleiben_unberuehrt(data_dir: Path):
"""Das Skript laeuft als root. Alles, was nicht exakt dem von
rdp_drive_path() erzeugten Muster '{host_id}_{session_id}' entspricht,
wird bewusst nicht angefasst."""
module = _load_cleanup_module()
drives = data_dir / "rdp-drives"
(drives / "fremdes_verzeichnis").mkdir()
(drives / "lose_datei").write_text("x")
(drives / "4_66_backup").mkdir()
module.cleanup(data_dir)
assert (drives / "fremdes_verzeichnis").is_dir()
assert (drives / "lose_datei").is_file()
assert (drives / "4_66_backup").is_dir()
def test_ohne_lesbare_datenbank_wird_nichts_geloescht(tmp_path: Path):
"""Sicherung gegen den schlimmsten denkbaren Fall: ohne verlaessliche
Sitzungsliste wuerde JEDE Sitzung als beendet gelten. Die DB wird
deshalb nur-lesend geoeffnet (mode=ro) -- sqlite3.connect wuerde sonst
stillschweigend eine leere Datenbank anlegen."""
module = _load_cleanup_module()
(tmp_path / "rdp-drives" / "4_66").mkdir(parents=True)
with pytest.raises(sqlite3.Error):
module.cleanup(tmp_path)
assert (tmp_path / "rdp-drives" / "4_66").is_dir()
assert not (tmp_path / "jumphost.db").exists(), "DB wurde faelschlich angelegt"
def test_dry_run_loescht_nichts(data_dir: Path):
module = _load_cleanup_module()
removed, _ = module.cleanup(data_dir, dry_run=True)
assert removed == 2
assert (data_dir / "rdp-drives" / "4_66").is_dir()
def test_verzeichnisname_passt_zu_rdp_drive_path():
"""Bindet das Skript an die Anwendung: aendert jemand rdp_drive_path()
(etwa zurueck auf die verschachtelte Form host/session, die am 2.9. den
EACCES ausgeloest hat), schlaegt dieser Test fehl statt dass der
Aufraeumdienst stumm nichts mehr findet."""
from app.rdp_proxy.guacd_client import rdp_drive_path
module = _load_cleanup_module()
name = rdp_drive_path(4, 66).name
match = module._DRIVE_DIR_RE.match(name)
assert match is not None, f"rdp_drive_path erzeugt '{name}', das Skript erwartet '{{host}}_{{session}}'"
assert match.group("host_id") == "4"
assert match.group("session_id") == "66"
assert rdp_drive_path(4, 66).parent.name == "rdp-drives", "eine Ebene tiefer -> guacd kann sie nicht anlegen"
# --- Frontend-Regressionsschutz ---------------------------------------------
def _vendor_xterm() -> str:
return (PROJECT_ROOT / "static" / "js" / "vendor" / "xterm.js").read_text(
encoding="utf-8", errors="ignore"
)
def _terminal_js(ohne_kommentare: bool = False) -> str:
"""terminal.js, auf Wunsch ohne Kommentare.
Noetig, weil die Kommentare den frueheren Fehler ABSICHTLICH beim Namen
nennen ("KEIN copyOnSelect hier ...", "Bitte Strg+Umschalt+C verwenden"
als Zitat des alten, falschen Hinweistextes). Eine reine Textsuche ueber
die ganze Datei wuerde genau diese Dokumentation als Rueckfall werten.
"""
source = (PROJECT_ROOT / "static" / "js" / "terminal.js").read_text(encoding="utf-8")
if not ohne_kommentare:
return source
source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) # Blockkommentare
source = re.sub(r"^\s*//.*$", "", source, flags=re.MULTILINE) # ganze Kommentarzeilen
return source
def test_terminal_js_setzt_keine_unbekannten_xterm_optionen():
"""xterm.js verwirft unbekannte Konstruktor-Optionen KOMMENTARLOS. Genau
das ist mit `copyOnSelect: true` passiert (eine VS-Code-Einstellung, in
xterm.js nicht existent): kein Fehler, keine Warnung, keine Wirkung --
'Markieren kopiert' war seit jeher tot. Dieser Test prueft jede im
Terminal-Konstruktor gesetzte Option gegen die tatsaechlich mitgelieferte
xterm.js-Datei."""
source = _terminal_js(ohne_kommentare=True)
vendor = _vendor_xterm()
block = re.search(r"new Terminal\(\{(.*?)\n \}\);", source, re.DOTALL)
assert block, "Terminal-Konstruktoraufruf in terminal.js nicht gefunden"
options = set(re.findall(r"^\s{8}([A-Za-z][A-Za-z0-9]*):", block.group(1), re.MULTILINE))
assert options, "keine Optionen erkannt -- Test muss angepasst werden"
unbekannt = sorted(o for o in options if f"{o}" not in vendor)
assert not unbekannt, (
f"terminal.js setzt Optionen, die die ausgelieferte xterm.js nicht kennt: {unbekannt}. "
"xterm.js ignoriert sie stillschweigend -- die Funktion fehlt dann ohne jede Fehlermeldung."
)
def test_copyonselect_ist_und_bleibt_verschwunden():
"""Punktgenauer Schutz gegen exakt diesen Rueckfall."""
source = _terminal_js(ohne_kommentare=True)
konstruktor = re.search(r"new Terminal\(\{(.*?)\n \}\);", source, re.DOTALL)
assert konstruktor and "copyOnSelect" not in konstruktor.group(1)
assert "copyOnSelect" not in _vendor_xterm(), (
"xterm.js kennt copyOnSelect inzwischen doch -- dann darf dieser Test angepasst werden."
)
def test_kopieren_beim_markieren_ist_selbst_implementiert():
"""Der Ersatz fuer die nicht existente Option: onSelectionChange +
eigener Clipboard-Aufruf mit execCommand-Ruecksprung."""
source = _terminal_js(ohne_kommentare=True)
assert "this.term.onSelectionChange(" in source
assert "_copyToClipboard" in source
assert 'document.execCommand("copy")' in source
def test_keine_hinweise_auf_in_chrome_belegte_tastenkombinationen():
"""Strg+Umschalt+C/V sind in Chrome/Edge fest fuer die Entwicklertools
reserviert und erreichen die Seite nie -- ein Fehlertext, der genau das
empfiehlt, schickt den Benutzer in die Irre."""
source = _terminal_js(ohne_kommentare=True)
fehlerhafte_hinweise = re.findall(r'"[^"]*Bitte[^"]*Strg\+Umschalt\+[CV][^"]*"', source)
assert not fehlerhafte_hinweise, fehlerhafte_hinweise