"""Prueft das Repository selbst -- die Fehlerklassen, die erst beim Ausrollen auffallen. Der Vorgaenger hat sich hier zweimal die Finger verbrannt: CRLF-Zeilenenden aus einer Windows-Buildmaschine machten Shell-Skripte auf dem Ziel unbrauchbar ("bad interpreter"), und Schluesseldateien landeten versehentlich im Repo. Beides faellt hier auf, bevor ein Paket gebaut wird. """ from __future__ import annotations import os import re import shutil import subprocess from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent SKIP_DIRS = { ".git", ".venv", "__pycache__", "instance", ".pytest_cache", ".ruff_cache", "node_modules", } TEXT_SUFFIXES = { ".py", ".sh", ".html", ".css", ".js", ".json", ".toml", ".md", ".yaml", ".yml", ".cfg", ".ini", ".txt", ".service", ".conf", } #: Dateien ohne Endung, die trotzdem Text sind. EXTENSIONLESS_TEXT = {"tesm-helper", "tesm", "tesm-license"} SECRET_NAMES = { "data.keys", "secret.key", "license.json", "license_key.json", "master_signing_key.json", "fernet.key", "known_hosts", } SECRET_SUFFIXES = {".db", ".db-wal", ".db-shm", ".pem", ".p12", ".pfx"} def _repo_files() -> list[Path]: files: list[Path] = [] for path in REPO_ROOT.rglob("*"): if not path.is_file(): continue if any(part in SKIP_DIRS for part in path.relative_to(REPO_ROOT).parts): continue files.append(path) return files def _text_files() -> list[Path]: return [ path for path in _repo_files() if path.suffix in TEXT_SUFFIXES or path.name in EXTENSIONLESS_TEXT ] def test_no_crlf_line_endings(): """CRLF im Paket macht Shell-Skripte auf dem Linux-Ziel unbrauchbar.""" offenders = [ str(path.relative_to(REPO_ROOT)) for path in _text_files() if b"\r\n" in path.read_bytes() ] assert not offenders, ( "Diese Dateien haben CRLF-Zeilenenden und wuerden auf dem Zielsystem brechen:\n " + "\n ".join(offenders) ) def test_no_secrets_committed(): """Schluessel, Datenbanken und Lizenzdateien gehoeren nie ins Repo.""" offenders = [ str(path.relative_to(REPO_ROOT)) for path in _repo_files() if path.name in SECRET_NAMES or path.suffix in SECRET_SUFFIXES ] assert not offenders, "Geheimnisse im Repository:\n " + "\n ".join(offenders) def test_shell_scripts_have_shebang_and_strict_mode(): scripts = [path for path in _repo_files() if path.suffix == ".sh" or path.name == "tesm-helper"] assert scripts, "Es wurden keine Shell-Skripte gefunden -- Test prueft ins Leere." for script in scripts: text = script.read_text(encoding="utf-8") assert text.startswith("#!"), f"{script.name}: Shebang fehlt" assert "set -euo pipefail" in text, f"{script.name}: 'set -euo pipefail' fehlt" def test_helper_validates_every_verb(): """Jedes Verb des privilegierten Helfers muss im case-Block auftauchen. Der Helfer ist die einzige Stelle mit Root-Rechten. Ein Verb, das die Anwendung kennt, der Helfer aber nicht, waere entweder tot -- oder, schlimmer, ein Hinweis auf eine ungeprueft durchgereichte Aktion. """ from tesm_core.sysops import HELPER_VERBS helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8") # case-Muster koennen mehrere Verben mit | buendeln. patterns: set[str] = set() for line in helper.splitlines(): match = re.match(r"^\s{2,4}([a-z|-]+)\)\s*$", line) if match: patterns.update(match.group(1).split("|")) missing = [verb for verb in HELPER_VERBS if verb not in patterns] assert not missing, f"Verben fehlen im Helfer: {missing}" def test_helper_has_no_unquoted_argument_passthrough(): """Der Helfer darf Argumente nie ungeprueft an eine Shell weiterreichen.""" helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8") for forbidden in ("eval ", "bash -c", "sh -c"): assert forbidden not in helper, f"Der Helfer enthaelt {forbidden!r}" def test_helper_blocks_path_traversal_everywhere(): """Jede Pfadpruefung im Helfer muss ".." abweisen. Ohne diese Sperre laesst sich eine Positivliste ueber den Basisnamen unterlaufen: ``/etc/nginx/sites-available/../../../root/tesm`` hat den zulaessigen Basisnamen ``tesm``, liest aber ``/root/tesm``. """ helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8") checks = re.findall( r"^check_\w*(?:path|target|webroot)\w*\(\) \{(.*?)^\}", helper, re.S | re.M ) assert checks, "Es wurden keine Pfadpruefungen gefunden -- Test prueft ins Leere." for body in checks: assert '*".."*' in body, "Pfadpruefung ohne ..-Sperre: " + body @pytest.mark.skipif(shutil.which("bash") is None, reason="bash nicht verfuegbar") def test_helper_accepts_instances_but_no_invented_keys(): """Der Namensraum einer Instanz ist eng begrenzt. Er landet ungefiltert in Pfaden wie ``/etc/nginx/sites-available/``. """ script = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8") start = script.index("check_app_key() {") end = script.index(chr(10) + "}", start) + 2 prelude = 'APP_KEYS_ALLOWED=("tesm" "tesm-license")' + chr(10) + "fail() { exit 1; }" + chr(10) snippet = prelude + script[start:end] def accepted(key: str) -> bool: command = snippet + chr(10) + 'check_app_key "' + key + '"' return subprocess.run(["bash", "-c", command], capture_output=True).returncode == 0 for good in ("tesm", "tesm-license", "tesm-opus", "tesm-license-opus", "tesm-test2"): assert accepted(good), good for bad in ("", "foo", "tesm-", "tesm-../etc", "../tesm", "tesm-GROSS", "tesm-" + "x" * 20): assert not accepted(bad), bad def test_systemd_units_are_hardened(): units = list((REPO_ROOT / "deploy" / "systemd").glob("*.service")) assert units for unit in units: text = unit.read_text(encoding="utf-8") assert "User=root" not in text, f"{unit.name} laeuft als root" # ProtectSystem=full, nicht strict: strict sperrt auch den privilegierten # Helfer aus /etc aus -- gemessen, siehe test_core_security.py. for directive in ("ProtectSystem=full", "PrivateTmp=yes"): assert directive in text, f"{unit.name}: {directive} fehlt" # NoNewPrivileges waere hier kontraproduktiv: es schaltet setuid ab und # damit sudo -- der Dienst koennte den privilegierten Helfer nicht mehr # aufrufen, und jede Systemaktion aus der Oberflaeche waere tot. Genau # so war es, und die Diagnoseseite meldete trotzdem "Helfer erreichbar". assert "NoNewPrivileges=yes" not in text, ( f"{unit.name}: NoNewPrivileges=yes verhindert sudo und damit den Helfer." ) def test_sudoers_grants_only_the_helper(): for name in ("tesm", "tesm-license"): raw = (REPO_ROOT / "deploy" / "sudoers" / name).read_text(encoding="utf-8") # Kommentare erklaeren, was bewusst *nicht* getan wird -- sie duerfen den # Test nicht ausloesen. rules = [line for line in raw.splitlines() if line.strip() and not line.startswith("#")] body = "\n".join(rules) assert "NOPASSWD: ALL" not in body, f"{name}: zu weite sudo-Regel" assert "/usr/local/lib/tesm/tesm-helper" in body for line in rules: if "NOPASSWD" in line: assert line.strip().endswith("/usr/local/lib/tesm/tesm-helper"), ( f"{name}: NOPASSWD-Regel erlaubt mehr als den Helfer: {line}" ) @pytest.mark.parametrize("app", ["tesm", "tesm-license"]) def test_nginx_template_serves_static_from_app_root(app: str): """Der alias-Pfad ist genau der Fehler, der im Vorgaenger jahrelang ueberlebte.""" text = (REPO_ROOT / "deploy" / "nginx" / f"{app}.conf").read_text(encoding="utf-8") assert f"alias /srv/{app}/static/;" in text assert f"alias /srv/{app}/static-core/;" in text def test_documentation_referenced_by_the_units_exists(): """``Documentation=`` in der Unit muss auf eine Datei zeigen, die es gibt. Sonst laeuft ``systemctl help tesm`` ins Leere -- und schlimmer: der Verweis suggeriert eine Anleitung, die niemand geschrieben hat. """ for unit in (REPO_ROOT / "deploy" / "systemd").glob("*.service"): for line in unit.read_text(encoding="utf-8").splitlines(): if not line.startswith("Documentation=file:"): continue target = line.split("file:", 1)[1].strip() # /srv//docs/X -> docs/X im Repository relative = target.split("/docs/", 1)[1] assert (REPO_ROOT / "docs" / relative).is_file(), ( f"{unit.name} verweist auf {target}, docs/{relative} fehlt" ) def test_readme_links_point_to_existing_files(): readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") for target in re.findall(r"\]\((docs/[^)#]+)\)", readme): assert (REPO_ROOT / target).is_file(), f"README verweist auf {target}" def _templates() -> list[Path]: return [ path for path in _repo_files() if path.suffix == ".html" and "templates" in path.parts ] def test_no_html_entities_inside_escaped_macro_calls(): """Makros escapen ihren Text -- Entities darin erscheinen woertlich. Auf der Wartungsseite stand deshalb ``„Linux-Client“`` als sichtbarer Text. Der Fehler ist unauffaellig, weil dieselbe Schreibweise im uebrigen Vorlagentext voellig richtig ist. """ # Genau bis zum schliessenden "}}" des Aufrufs -- ein greedy Ausdruck # verschluckte sonst den nachfolgenden Seiteninhalt und meldete dessen # Tabellenmarkup als Treffer. pattern = re.compile( r"\{\{\s*(?:alert|empty_state|pill|stat)\((?:[^{}]|\{[^{}]*\})*?\)\s*" r"(?:\|\s*safe\s*)?\}\}", re.S, ) offenders: list[str] = [] for path in _templates(): for match in pattern.finditer(path.read_text(encoding="utf-8")): call = match.group(0) # Frueher stand hier eine Ausnahme fuer "| safe". Sie war falsch: # das Filter wirkt auf das *Ergebnis* des Makros, escaped wurde aber # schon beim Einsetzen des Arguments. Auf der Geraeteseite stand # deshalb woertlich "und" im Text. if re.search(r"&[a-zA-Z]{2,10};|", call): offenders.append( f"{path.relative_to(REPO_ROOT)}: {' '.join(call.split())[:100]}" ) assert not offenders, ( "HTML im escapten Makrotext -- das erscheint woertlich auf der Seite:\n " + "\n ".join(offenders) ) def test_refresh_regions_never_point_at_a_whole_page(): """``data-refresh-url`` schreibt die Antwort in ``innerHTML``. Zeigt die URL auf eine vollstaendige Seite, landet die ganze Seite in einem Ausschnitt -- bei jedem Durchlauf eine Ebene tiefer. Genau das ist auf der Wartungsseite passiert: die Karte "Auftraege" holte sich ``maintenance.overview``, also sich selbst. Erlaubt sind nur Endpunkte, deren Name sie als Ausschnitt ausweist. """ fragment_markers = ("fragment", "tiles", "output", "status", "partial", "ausschnitt") pattern = re.compile(r"data-refresh-url=\"\{\{\s*url_for\('([^']+)'") found = 0 for path in _templates(): for match in pattern.finditer(path.read_text(encoding="utf-8")): endpoint = match.group(1) found += 1 assert any(marker in endpoint.lower() for marker in fragment_markers), ( f"{path.relative_to(REPO_ROOT)}: data-refresh-url zeigt auf {endpoint!r} -- " "das sieht nach einer ganzen Seite aus, nicht nach einem Ausschnitt." ) assert found, "Keine Aktualisierungsbereiche gefunden -- Test prueft ins Leere." def test_refresh_loop_treats_zero_as_off(): """``Number(x || 15000)`` macht aus einer 0 klammheimlich 15000.""" script = ( REPO_ROOT / "packages" / "tesm-core" / "src" / "tesm_core" / "static" / "tesm_core" / "js" / "app.js" ).read_text(encoding="utf-8") assert "refreshInterval || " not in script assert "interval <= 0" in script, "Ein Intervall von 0 muss die Aktualisierung abschalten." def test_licensing_package_is_shared_not_copied(): """Es darf keine zweite Kopie des Lizenzprotokolls geben. Genau diese Doppelung -- zwei byte-identisch zu haltende ``licensing.py`` -- war im Vorgaenger die gefaehrlichste Invariante. """ copies = [ str(path.relative_to(REPO_ROOT)) for path in _repo_files() if path.name == "licensing.py" ] assert not copies, f"Kopien des Lizenzmoduls gefunden: {copies}"