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>
500 lines
20 KiB
Python
500 lines
20 KiB
Python
"""End-to-End-Rauchtest gegen eine laufende Installation.
|
|
|
|
Laeuft auf dem Zielhost, spricht ueber HTTP mit nginx und der echten Anwendung
|
|
-- kein Testclient, keine Attrappen. Prueft genau das, was ein Mensch beim
|
|
ersten Durchklicken pruefen wuerde, plus die Dinge, die man beim Klicken leicht
|
|
uebersieht (Sicherheitsheader, CSRF, statische Dateien, Rechteschranken).
|
|
|
|
Aufruf:
|
|
python3 e2e_smoke.py http://127.0.0.1:8080 admin 'Passwort'
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.cookiejar
|
|
import json
|
|
import re
|
|
import secrets
|
|
import ssl
|
|
import subprocess
|
|
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="([^"]+)"')
|
|
|
|
|
|
class Client:
|
|
def __init__(self, base: str) -> None:
|
|
self.base = base.rstrip("/")
|
|
self.https = self.base.startswith("https://")
|
|
self.jar = http.cookiejar.CookieJar()
|
|
|
|
# Der Test laeuft auf dem Zielhost selbst gegen ein Zertifikat, das
|
|
# dieselbe Maschine ausgestellt hat. Es zu pruefen hiesse, das eigene
|
|
# Zertifikat gegen sich selbst zu pruefen -- geprueft wird hier, ob TLS
|
|
# steht und die Anwendung darueber antwortet.
|
|
context = ssl.create_default_context()
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
self.tls = urllib.request.HTTPSHandler(context=context)
|
|
|
|
self.opener = urllib.request.build_opener(
|
|
urllib.request.HTTPCookieProcessor(self.jar),
|
|
urllib.request.HTTPRedirectHandler(),
|
|
self.tls,
|
|
)
|
|
self.opener.addheaders = [("User-Agent", "tesm-e2e/2.0")]
|
|
|
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, *_args, **_kwargs): # type: ignore[override]
|
|
return None
|
|
|
|
# Zweiter Opener ohne Weiterleitung: nur so laesst sich pruefen, ob eine
|
|
# Seite tatsaechlich mit 302 auf die Anmeldung verweist.
|
|
self.plain = urllib.request.build_opener(
|
|
urllib.request.HTTPCookieProcessor(self.jar), _NoRedirect(), self.tls
|
|
)
|
|
self.plain.addheaders = [("User-Agent", "tesm-e2e/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=20) 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:
|
|
"""Statuscode ohne Weiterleitung zu folgen."""
|
|
request = urllib.request.Request(self.base + path)
|
|
try:
|
|
with self.plain.open(request, timeout=20) 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=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)
|
|
|
|
|
|
PASSED: list[str] = []
|
|
FAILED: list[str] = []
|
|
|
|
#: Jeder Lauf legt eigene Datensaetze an. Sonst laesst sich der Test kein
|
|
#: zweites Mal gegen dieselbe Installation ausfuehren -- beim ersten Lauf
|
|
#: landet ein Geraet im Papierkorb, und die Anwendung weigert sich danach
|
|
#: voellig zu Recht, dieselbe MAC noch einmal zu vergeben.
|
|
RUN = secrets.token_hex(3)
|
|
MAC = ":".join(("AA", "BB", RUN[0:2], RUN[2:4], RUN[4:6], "01")).upper()
|
|
SUBNET = f"10.99.{int(RUN[0:2], 16)}"
|
|
|
|
FLASH_RE = re.compile(r'id="flashed-messages">(.*?)</script>', re.S)
|
|
ROW_SPLIT = re.compile(r"<tr[ >]|<li[ >]")
|
|
|
|
|
|
def flash(body: str) -> str:
|
|
"""Die Meldungen der Seite -- damit ein Fehlschlag erklaert, was schiefging."""
|
|
match = FLASH_RE.search(body)
|
|
if not match:
|
|
return "keine Meldung"
|
|
try:
|
|
return " | ".join(text for _level, text in json.loads(match.group(1)))
|
|
except (ValueError, TypeError):
|
|
return match.group(1)[:120]
|
|
|
|
|
|
def id_for(body: str, needle: str, pattern: str) -> str:
|
|
"""Sucht die Kennung in der Zeile, in der ``needle`` steht.
|
|
|
|
Die Listenansicht nennt die Kennung nur in Formular- und Link-Zielen. Der
|
|
Test darf sie nicht raten -- eine feste ``1`` funktioniert genau einmal und
|
|
trifft beim naechsten Lauf einen fremden Datensatz.
|
|
"""
|
|
for chunk in ROW_SPLIT.split(body):
|
|
if needle in chunk:
|
|
match = re.search(pattern, chunk)
|
|
if match:
|
|
return match.group(1)
|
|
return ""
|
|
|
|
|
|
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}")
|
|
|
|
|
|
def local_host_fingerprints() -> set[str]:
|
|
"""Die Fingerprints, die ``ssh-keyscan`` fuer diesen Host meldet.
|
|
|
|
Der Vergleich ist der Kern der Pruefung: die Anwendung darf nicht
|
|
irgendeinen plausiblen Wert anzeigen, sondern genau den des Gegenuebers.
|
|
"""
|
|
try:
|
|
scan = subprocess.run(
|
|
["ssh-keyscan", "-T", "5", "-p", "22", "127.0.0.1"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=20,
|
|
)
|
|
fingerprints: set[str] = set()
|
|
for line in scan.stdout.splitlines():
|
|
if not line.strip() or line.startswith("#"):
|
|
continue
|
|
result = subprocess.run(
|
|
["ssh-keygen", "-lf", "-"], input=line, capture_output=True, text=True, timeout=10
|
|
)
|
|
match = re.search(r"(SHA256:[A-Za-z0-9+/]{43})", result.stdout)
|
|
if match:
|
|
fingerprints.add(match.group(1))
|
|
return fingerprints
|
|
except (OSError, subprocess.SubprocessError):
|
|
return set()
|
|
|
|
|
|
def main(base: str, username: str, password: str) -> int:
|
|
client = Client(base)
|
|
print(f"\n== Rauchtest gegen {base} (Lauf {RUN}) ==\n")
|
|
|
|
# -- oeffentlich ---------------------------------------------------------
|
|
print("Oeffentlich erreichbar:")
|
|
status, body, headers = client.get("/gesundheit")
|
|
check("Gesundheitsendpunkt", status == 200 and json.loads(body)["status"] == "ok", body[:80])
|
|
|
|
status, body, headers = client.get("/login")
|
|
check("Anmeldeseite", status == 200 and "Anmelden" in body, f"HTTP {status}")
|
|
if client.https:
|
|
cookie = headers.get("Set-Cookie", "")
|
|
check("Sitzungscookie mit Secure", "Secure" in cookie, cookie[:80] or "kein Cookie")
|
|
check("Sitzungscookie HttpOnly", "HttpOnly" in cookie, cookie[:80] or "kein Cookie")
|
|
check("Sitzungscookie SameSite", "SameSite" in cookie, cookie[:80] or "kein Cookie")
|
|
csp = headers.get("Content-Security-Policy", "")
|
|
check("CSP mit Nonce", "nonce-" in csp and "unsafe-inline" not in csp.split("style-src")[0],
|
|
csp[:90])
|
|
check("X-Frame-Options", headers.get("X-Frame-Options") == "DENY", str(headers.get("X-Frame-Options")))
|
|
check("Vorgangsnummer im Header", bool(headers.get("X-Request-Id")), "fehlt")
|
|
|
|
status, body, _ = client.get("/")
|
|
check("Oeffentliche Statusuebersicht", status == 200 and "Statusuebersicht" in body,
|
|
f"HTTP {status}")
|
|
check("Ohne Anmeldung keine Details", "oeffentliche Ansicht" in body, "Hinweis fehlt")
|
|
|
|
for asset in ("/core-assets/css/base.css", "/core-assets/js/app.js"):
|
|
status, body, _ = client.get(asset)
|
|
check(f"Statische Datei {asset}", status == 200 and len(body) > 1000, f"HTTP {status}")
|
|
|
|
print("\nSchranken ohne Anmeldung:")
|
|
for path in ("/clients/", "/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}")
|
|
|
|
# -- Anmeldung -----------------------------------------------------------
|
|
print("\nAnmeldung:")
|
|
token = client.token("/login")
|
|
status, body = client.post(
|
|
"/login", {"username": username, "password": password, "csrf_token": token}
|
|
)
|
|
check("Anmeldung erfolgreich", "Willkommen" in body or status == 200, f"HTTP {status}")
|
|
|
|
status, body, _ = client.get("/konto/")
|
|
check("Kontoseite erreichbar", status == 200 and username in body, f"HTTP {status}")
|
|
|
|
# -- Seiten --------------------------------------------------------------
|
|
print("\nSeiten (angemeldet):")
|
|
pages = [
|
|
("/", "Statusuebersicht"),
|
|
("/clients/", "Clients"),
|
|
("/switche/", "Switche"),
|
|
("/zugangsdaten/", "Zugangsdaten"),
|
|
("/protokolle/laufend", "Protokoll"),
|
|
("/protokolle/aenderungen", "Aenderungsprotokoll"),
|
|
("/protokolle/neustarts", "Neustarts"),
|
|
("/einstellungen/system", "Systemeinstellungen"),
|
|
("/einstellungen/netzwerk", "Netzwerk"),
|
|
("/einstellungen/webserver", "Webserver"),
|
|
("/einstellungen/verzeichnisdienst", "Verzeichnisdienst"),
|
|
("/lizenz/", "Lizenz"),
|
|
("/sicherung/", "Sicherung"),
|
|
("/papierkorb/", "Papierkorb"),
|
|
("/diagnose/", "Diagnose"),
|
|
("/verwaltung/benutzer", "Benutzer"),
|
|
("/verwaltung/gruppen", "Gruppen"),
|
|
("/verwaltung/sitzungen", "Sitzungen"),
|
|
("/konto/sicherheit", "Sicherheit"),
|
|
]
|
|
for path, needle in pages:
|
|
status, body, _ = client.get(path)
|
|
ok = status == 200 and needle in body and "Interner Fehler" not in body
|
|
check(f"{path}", ok, f"HTTP {status}")
|
|
|
|
# Die Modulrouten sind an die Lizenz gebunden. Welcher Zustand gerade gilt,
|
|
# darf der Test nicht vorschreiben -- er darf nur verlangen, dass alle drei
|
|
# dasselbe sagen. Ein Modul, das ohne Lizenz durchkommt, waere ein Loch;
|
|
# ein Modul, das mit Lizenz 404 liefert, waere ein Ausfall.
|
|
print("\nLizenzpflichtige Module:")
|
|
module_status = {
|
|
path: client.get(path)[0] for path in ("/dhcp/", "/wartung/", "/dateifreigaben/")
|
|
}
|
|
licensed = set(module_status.values()) == {200}
|
|
unlicensed = set(module_status.values()) == {404}
|
|
check(
|
|
"Alle Modulrouten im selben Zustand",
|
|
licensed or unlicensed,
|
|
", ".join(f"{path} {code}" for path, code in module_status.items()),
|
|
)
|
|
print(f" [info] Lizenz ist {'aktiv' if licensed else 'nicht aktiv'}.")
|
|
|
|
# -- Daten anlegen -------------------------------------------------------
|
|
print("\nDaten anlegen:")
|
|
credential_name = f"E2E-Switchlogin-{RUN}"
|
|
switch_name = f"e2e-switch-{RUN}"
|
|
device_name = f"E2E-Kamera-{RUN}"
|
|
secret = f"Geheim-fuer-den-Test-{RUN}"
|
|
|
|
token = client.token("/zugangsdaten/")
|
|
status, body = client.post(
|
|
"/zugangsdaten/neu",
|
|
{
|
|
"csrf_token": token,
|
|
"name": credential_name,
|
|
"username": "operator",
|
|
"secret": secret,
|
|
"category": "switch",
|
|
},
|
|
)
|
|
check("Zugangsdaten angelegt", "angelegt" in body, flash(body))
|
|
credential_id = id_for(body, credential_name, r"/zugangsdaten/(\d+)/")
|
|
check("Kennung der Zugangsdaten gefunden", bool(credential_id), "nicht gefunden")
|
|
|
|
token = client.token("/switche/")
|
|
status, body = client.post(
|
|
"/switche/neu",
|
|
{
|
|
"csrf_token": token,
|
|
"hostname": switch_name,
|
|
"ip": f"{SUBNET}.2",
|
|
"ssh_port": "22",
|
|
"credential_id": credential_id,
|
|
},
|
|
)
|
|
check("Switch angelegt", "angelegt" in body, flash(body))
|
|
switch_id = id_for(body, switch_name, r"/switche/(\d+)")
|
|
check("Kennung des Switches gefunden", bool(switch_id), "nicht gefunden")
|
|
|
|
token = client.token("/clients/")
|
|
status, body = client.post(
|
|
"/clients/neu",
|
|
{
|
|
"csrf_token": token,
|
|
"name": device_name,
|
|
"mac": MAC,
|
|
"ip": f"{SUBNET}.50",
|
|
"switch_id": switch_id,
|
|
"port": "7",
|
|
"ssh_port": "22",
|
|
"is_active": "1",
|
|
"auto_restart": "1",
|
|
},
|
|
)
|
|
check("Geraet angelegt", "angelegt" in body, flash(body))
|
|
device_id = id_for(body, device_name, r"/clients/(\d+)")
|
|
check("Kennung des Geraets gefunden", bool(device_id), "nicht gefunden")
|
|
|
|
status, body, _ = client.get("/clients/")
|
|
check(
|
|
"Geraet in der Liste",
|
|
device_name in body and MAC.lower() in body,
|
|
"nicht gefunden",
|
|
)
|
|
|
|
status, body, _ = client.get("/")
|
|
check("Geraet auf der Uebersicht", device_name in body, "nicht gefunden")
|
|
|
|
print("\nValidierung:")
|
|
token = client.token("/clients/")
|
|
status, body = client.post(
|
|
"/clients/neu",
|
|
{"csrf_token": token, "name": "Kaputt", "mac": "unsinn", "ip": f"{SUBNET}.51"},
|
|
)
|
|
check("Ungueltige MAC abgelehnt", "hexadezimale" in body, flash(body))
|
|
|
|
token = client.token("/clients/")
|
|
status, body = client.post(
|
|
"/clients/neu",
|
|
{"csrf_token": token, "name": "Kaputt", "mac": MAC[:-2] + "FE", "ip": "999.1.1.1"},
|
|
)
|
|
check("Ungueltige IP abgelehnt", "gueltige IP-Adresse" in body, flash(body))
|
|
|
|
token = client.token("/clients/")
|
|
status, body = client.post(
|
|
"/clients/neu",
|
|
{"csrf_token": token, "name": "Doppelt", "mac": MAC, "ip": f"{SUBNET}.52"},
|
|
)
|
|
check("Doppelte MAC abgelehnt", "angelegt" not in body, "zweimal dieselbe MAC")
|
|
|
|
# -- Host-Schluessel eines Clients ---------------------------------------
|
|
# Gegen einen echten SSH-Dienst: der Host selbst. Nur so ist geprueft, dass
|
|
# der gelesene Fingerprint tatsaechlich der des Gegenuebers ist -- eine
|
|
# Attrappe wuerde genau den Fehler durchlassen, auf den es hier ankommt.
|
|
print("\nSSH-Host-Schluessel eines Clients:")
|
|
token = client.token("/clients/")
|
|
ssh_name = f"E2E-SSH-{RUN}"
|
|
status, body = client.post(
|
|
"/clients/neu",
|
|
{
|
|
"csrf_token": token,
|
|
"name": ssh_name,
|
|
"mac": MAC[:-2] + "0F",
|
|
"ip": "127.0.0.1",
|
|
"ssh_port": "22",
|
|
"credential_id": credential_id,
|
|
"is_active": "0",
|
|
},
|
|
)
|
|
check("Client fuer den SSH-Test angelegt", "angelegt" in body, flash(body))
|
|
ssh_id = id_for(body, ssh_name, r"/clients/(\d+)")
|
|
check("Kennung gefunden", bool(ssh_id), "nicht gefunden")
|
|
|
|
if ssh_id:
|
|
status, body, _ = client.get(f"/clients/{ssh_id}")
|
|
check("Detailseite zeigt die Schluesselkarte", "SSH-Host-Schluessel" in body, "fehlt")
|
|
check("Zustand offen", "Noch kein Schluessel hinterlegt" in body, "unerwartet")
|
|
|
|
token = client.token(f"/clients/{ssh_id}")
|
|
status, body = client.post(
|
|
f"/clients/{ssh_id}/hostkey/pruefen", {"csrf_token": token}
|
|
)
|
|
check("Schluessel ausgelesen", "Host-Schluessel gelesen" in body, flash(body))
|
|
|
|
match = re.search(r"(SHA256:[A-Za-z0-9+/]{43})", body)
|
|
fingerprint = match.group(1) if match else ""
|
|
check("Fingerprint erhalten", bool(fingerprint), "nicht gefunden")
|
|
|
|
expected = local_host_fingerprints()
|
|
check(
|
|
"Fingerprint stimmt mit ssh-keyscan ueberein",
|
|
bool(expected) and fingerprint in expected,
|
|
f"gelesen {fingerprint}, erwartet eines von {sorted(expected)}",
|
|
)
|
|
|
|
key_match = re.search(r'name="key_type" value="([^"]+)"', body)
|
|
key_type = key_match.group(1) if key_match else ""
|
|
token = client.token(f"/clients/{ssh_id}")
|
|
status, body = client.post(
|
|
f"/clients/{ssh_id}/hostkey/freigeben",
|
|
{"csrf_token": token, "key_type": key_type, "fingerprint": fingerprint},
|
|
)
|
|
check("Schluessel freigegeben", "freigegeben" in body, flash(body))
|
|
check("Fingerprint steht auf der Seite", fingerprint in body, "nicht gefunden")
|
|
|
|
token = client.token(f"/clients/{ssh_id}")
|
|
status, body = client.post(
|
|
f"/clients/{ssh_id}/hostkey/freigeben",
|
|
{"csrf_token": token, "key_type": key_type, "fingerprint": "SHA256:zu-kurz"},
|
|
)
|
|
check("Unsinniger Fingerprint abgelehnt", "kein gueltiger Fingerprint" in body, flash(body))
|
|
|
|
status, body, _ = client.get("/protokolle/aenderungen")
|
|
check("Freigabe protokolliert", "device.host_key_trusted" in body, "kein Eintrag")
|
|
|
|
token = client.token(f"/clients/{ssh_id}")
|
|
status, body = client.post(
|
|
f"/clients/{ssh_id}/hostkey/verwerfen", {"csrf_token": token}
|
|
)
|
|
check("Schluessel verworfen", "entfernt" in body, flash(body))
|
|
|
|
print("\nNeustart-Protokoll:")
|
|
status, body, _ = client.get("/protokolle/neustarts")
|
|
check("Seite heisst Neustarts", status == 200 and "Neustarts" in body, f"HTTP {status}")
|
|
check("Alte PoE-Adresse ist weg", client.get_raw("/protokolle/poe") == 404, "noch da")
|
|
check("Filter nach Methode vorhanden", "methode=ssh" in body, "kein Filter")
|
|
|
|
print("\nGeheimnisse:")
|
|
token = client.token("/zugangsdaten/")
|
|
status, body = client.post(
|
|
f"/zugangsdaten/{credential_id}/anzeigen", {"csrf_token": token}
|
|
)
|
|
check("Klartextanzeige moeglich (frisch angemeldet)", secret in body, f"HTTP {status}")
|
|
status, body, _ = client.get("/protokolle/aenderungen")
|
|
check("Zugriff protokolliert", "credential.secret_revealed" in body, "kein Eintrag")
|
|
|
|
print("\nPapierkorb:")
|
|
token = client.token("/clients/")
|
|
client.post(f"/clients/{device_id}/loeschen", {"csrf_token": token})
|
|
status, body, _ = client.get("/papierkorb/")
|
|
check("Geloeschtes Geraet im Papierkorb", device_name in body, "nicht gefunden")
|
|
token = client.token("/papierkorb/")
|
|
status, body = client.post(
|
|
f"/papierkorb/devices/{device_id}/wiederherstellen", {"csrf_token": token}
|
|
)
|
|
if licensed:
|
|
check("Wiederherstellen mit Lizenz moeglich", "wiederhergestellt" in body, flash(body))
|
|
else:
|
|
check("Wiederherstellen ohne Lizenz gesperrt", "Lizenz" in body, flash(body))
|
|
|
|
print("\nDiagnose:")
|
|
status, body, _ = client.get("/diagnose/")
|
|
check("Audit-Kette unversehrt", "unversehrt" in body, "Kette gebrochen?")
|
|
# Nicht "liegt die Datei da", sondern "antwortet er". Genau der
|
|
# Unterschied hat einmal verdeckt, dass jede Systemaktion an
|
|
# NoNewPrivileges scheiterte, waehrend die Seite "erreichbar" meldete.
|
|
check("Systemhelfer antwortet", "Helfer antwortet" in body, "Helfer meldet sich nicht")
|
|
check("Schluesselrechte in Ordnung", "in Ordnung" in body, "Problem gemeldet")
|
|
|
|
print("\nAbmeldung:")
|
|
token = client.token("/konto/")
|
|
status, body = client.post("/logout", {"csrf_token": token})
|
|
status = client.get_raw("/clients/")
|
|
check("Nach Abmeldung gesperrt", status in (302, 401), f"HTTP {status}")
|
|
|
|
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]))
|