Files
tesm/tests/test_repo_hygiene.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

327 lines
13 KiB
Python

"""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/<key>``.
"""
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/<app>/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 ``&bdquo;Linux-Client&ldquo;`` 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 "<em>und</em>" im Text.
if re.search(r"&[a-zA-Z]{2,10};|</?[a-z]{1,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}"