"""End-to-End-Rauchtest des Lizenzservers gegen eine laufende Installation. Gegenstueck zu ``e2e_smoke.py`` (das prueft TESM). Beide Anwendungen teilen sich den Kern; hier geht es um die Seiten und Schranken, die nur der Lizenzserver hat -- Kunden, Tickets, Lizenzen, Kundenportal, Selbstbedienung und die Maschinen-API. Aufruf: python3 e2e_license_server.py https://lizenz.example.org admin 'Passwort' """ from __future__ import annotations import http.cookiejar import json import re import ssl import sys import urllib.error import urllib.parse import urllib.request CSRF_RE = re.compile(r'name="csrf_token" value="([^"]+)"') BODY_CSRF_RE = re.compile(r'data-csrf="([^"]+)"') PASSED: list[str] = [] FAILED: list[str] = [] def check(name: str, condition: bool, detail: str = "") -> None: if condition: PASSED.append(name) print(f" [ok] {name}") else: FAILED.append(f"{name}: {detail}") print(f" [FEHL] {name} -- {detail}") class Client: def __init__(self, base: str) -> None: self.base = base.rstrip("/") self.https = self.base.startswith("https://") jar = http.cookiejar.CookieJar() context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE tls = urllib.request.HTTPSHandler(context=context) self.opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(jar), tls ) self.opener.addheaders = [("User-Agent", "tesm-e2e-lic/2.0")] class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *_args, **_kwargs): # type: ignore[override] return None self.plain = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(jar), _NoRedirect(), tls ) self.plain.addheaders = [("User-Agent", "tesm-e2e-lic/2.0")] def get(self, path: str) -> tuple[int, str, dict[str, str]]: request = urllib.request.Request(self.base + path) try: with self.opener.open(request, timeout=30) as response: return ( response.status, response.read().decode("utf-8", "replace"), dict(response.headers), ) except urllib.error.HTTPError as exc: return exc.code, exc.read().decode("utf-8", "replace"), dict(exc.headers) def get_raw(self, path: str) -> int: try: with self.plain.open(urllib.request.Request(self.base + path), timeout=30) as response: return response.status except urllib.error.HTTPError as exc: return exc.code def post(self, path: str, data: dict[str, object]) -> tuple[int, str]: encoded = urllib.parse.urlencode( [ (key, str(item)) for key, value in data.items() for item in (value if isinstance(value, (list, tuple)) else [value]) ] ).encode("utf-8") request = urllib.request.Request( self.base + path, data=encoded, headers={ "Content-Type": "application/x-www-form-urlencoded", "Origin": self.base, "Referer": self.base + path, }, ) try: with self.opener.open(request, timeout=45) as response: return response.status, response.read().decode("utf-8", "replace") except urllib.error.HTTPError as exc: return exc.code, exc.read().decode("utf-8", "replace") def post_json(self, path: str, payload: dict[str, object]) -> tuple[int, str]: request = urllib.request.Request( self.base + path, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, ) try: with self.opener.open(request, timeout=30) as response: return response.status, response.read().decode("utf-8", "replace") except urllib.error.HTTPError as exc: return exc.code, exc.read().decode("utf-8", "replace") def token(self, path: str) -> str: _, body, _ = self.get(path) match = CSRF_RE.search(body) or BODY_CSRF_RE.search(body) if not match: raise AssertionError(f"Kein CSRF-Token auf {path}") return match.group(1) def main(base: str, username: str, password: str) -> int: client = Client(base) print(f"\n== Rauchtest Lizenzserver gegen {base} ==\n") print("Oeffentlich erreichbar:") status, body, headers = client.get("/gesundheit") check( "Gesundheitsendpunkt", status == 200 and json.loads(body)["status"] == "ok", body[:80], ) check( "Antwortet als Lizenzserver", json.loads(body).get("version") == "tesm-license", body[:80], ) status, body, headers = client.get("/login") check("Anmeldeseite", status == 200 and "Anmelden" in body, f"HTTP {status}") csp = headers.get("Content-Security-Policy", "") check("CSP mit Nonce", "nonce-" in csp, csp[:80]) check( "X-Frame-Options", headers.get("X-Frame-Options") == "DENY", str(headers.get("X-Frame-Options")), ) if client.https: cookie = headers.get("Set-Cookie", "") check("Sitzungscookie mit Secure", "Secure" in cookie, cookie[:80] or "kein Cookie") print("\nKundenportal (ohne Anmeldung erreichbar):") status, body, _ = client.get("/portal/") check("Portalseite", status == 200 and "Kundenportal" in body, f"HTTP {status}") check("Fragt nach der Kennung, nicht nach einem Passwort", "Passwort" not in body, "unerwartet") print("\nSchranken ohne Anmeldung:") for path in ("/kunden/", "/tickets/", "/lizenzen/", "/verwaltung/benutzer", "/diagnose/"): status = client.get_raw(path) check(f"{path} verlangt Anmeldung", status in (302, 401), f"HTTP {status}") print("\nCSRF:") status, body = client.post("/login", {"username": "x", "password": "y"}) check("POST ohne Token wird abgewiesen", status == 403, f"HTTP {status}") print("\nMaschinen-API (eigene Authentisierung, kein Cookie):") status, body = client.post_json("/api/v1/licenses/activate", {"unsinn": True}) check( "Unsinniger Antrag wird abgewiesen", status in (400, 401, 403, 404, 422), f"HTTP {status}", ) check( "Antwort ist maschinenlesbar, nicht HTML", body.lstrip().startswith("{") and "error" in body, body[:100], ) check("Keine Interna in der Antwort", "Traceback" not in body, "Stacktrace nach aussen") check( "Kein CSRF-Token noetig (Maschinenschnittstelle)", "Sicherheitstoken" not in body, "API verlangt ein Formulartoken", ) print("\nAnmeldung:") token = client.token("/login") status, body = client.post( "/login", {"username": username, "password": password, "csrf_token": token} ) check("Anmeldung erfolgreich", status == 200 and "Anmelden" not in body, f"HTTP {status}") print("\nSeiten (angemeldet):") pages = [ ("/", "Uebersicht"), ("/kunden/", "Kunden"), ("/tickets/", "Tickets"), ("/tickets/neu", "Ticket"), ("/lizenzen/", "Lizenzen"), ("/lizenzen/offline-code", "Offline"), ("/lizenzen/sub-lizenzserver", "Lizenzserver"), ("/eigene-lizenz/", "Lizenz"), ("/einstellungen/system", "System"), ("/einstellungen/anbieter", "Anbieter"), ("/einstellungen/email", "Mail"), ("/einstellungen/netzwerk", "Netzwerk"), ("/einstellungen/webserver", "Webserver"), ("/einstellungen/verzeichnisdienst", "Verzeichnisdienst"), ("/protokolle/aenderungen", "Aenderungsprotokoll"), ("/sicherung/", "Sicherung"), ("/papierkorb/", "Papierkorb"), ("/diagnose/", "Diagnose"), ("/verwaltung/benutzer", "Benutzer"), ("/verwaltung/gruppen", "Gruppen"), ("/konto/sicherheit", "Sicherheit"), ] for path, needle in pages: status, body, _ = client.get(path) check( f"{path}", status == 200 and needle in body and "Interner Fehler" not in body, f"HTTP {status}", ) print("\nEigene Master-Lizenz:") status, body, _ = client.get("/eigene-lizenz/") check("Master-Rolle erkannt", "master" in body.lower(), "Rolle fehlt") print("\nDiagnose:") status, body, _ = client.get("/diagnose/") check("Audit-Kette unversehrt", "unversehrt" in body, "Kette gebrochen?") check("Signaturschluessel vorhanden", "Signatur" in body, "kein Hinweis") print("\nAbmeldung:") token = client.token("/konto/") client.post("/logout", {"csrf_token": token}) check("Nach Abmeldung gesperrt", client.get_raw("/kunden/") in (302, 401), "weiter offen") print(f"\n== Ergebnis: {len(PASSED)} bestanden, {len(FAILED)} fehlgeschlagen ==") for entry in FAILED: print(f" FEHLER: {entry}") return 1 if FAILED else 0 if __name__ == "__main__": if len(sys.argv) != 4: print(__doc__) raise SystemExit(2) raise SystemExit(main(sys.argv[1], sys.argv[2], sys.argv[3]))