"""Browser-Lauf zur Anmeldung (Teil 10) mit Playwright (Windows-Python), gegen das Probe-Backend mit Zugang. Koordinator-Pruefskript, nicht fuers Repo; ein Fangnetz, kein Nachweis. Fragen (Herkunft: Projektvorgabe, Spec Teil 10, Teil B): - Zeigt die Oberflaeche ohne Sitzung das Anmeldeformular und NICHT die App? - Fuehrt eine falsche Anmeldung zu einer Fehlerzeile (api.ts: "Benutzer, Passwort oder Code stimmt nicht.") und nicht in die App? - Fuehrt die richtige Anmeldung (Passwort plus frischer TOTP-Code) in die App, mit Benutzername und Knopf "Abmelden" in der Kopfzeile, laufendem Strom ("Backend verbunden") und der Karte "Paper-Betrieb"? - Traegt die CSP die gebaute Oberflaeche: keine Konsolenmeldung, die "Content Security Policy" nennt, in keiner Ansicht (auch "Markt" mit dem Chart)? - Fuehrt "Abmelden" zurueck zum Formular, und bleibt es nach dem Neuladen dort? Was es nicht sieht: das Secure-Merkmal und HSTS (dafuer angriffe.py mit einer https-Adresse), ob das Zertifikat taugt (--selbstsigniert schaltet die Zertifikatspruefung des Browsers ab), Safari und Firefox (nur Chromium), ob es gut aussieht (dafuer die Screenshots, die der Koordinator selbst ansieht). Regressionsnachweis: --sabotage erwartet nach der FALSCHEN Anmeldung die App und muss rot enden. Unter https belegt der Lauf, dass Anmeldung, Strom (wss) und alle Ansichten ueber TLS tragen: Ein Browser schickt ein Secure-Cookie nur ueber https zurueck, und ohne Cookie gaebe es keinen Strom. Aufruf: python -X utf8 browserlauf_zugang.py [--sabotage] [--selbstsigniert] Exit 0 ohne Befund, 2 mit Befund, 3 wenn eine Voraussetzung fehlt. """ import base64 import hashlib import hmac import json import struct import sys import time from pathlib import Path from playwright.sync_api import TimeoutError as PlaywrightTimeout from playwright.sync_api import sync_playwright def totp(geheimnis: str, zeit_s: float) -> str: schluessel = base64.b32decode(geheimnis.upper() + "=" * (-len(geheimnis) % 8)) mac = hmac.new(schluessel, struct.pack(">Q", int(zeit_s // 30)), hashlib.sha1).digest() versatz = mac[-1] & 0x0F zahl = struct.unpack(">I", mac[versatz : versatz + 4])[0] & 0x7FFFFFFF return f"{zahl % 1_000_000:06d}" def main() -> int: if len(sys.argv) < 4: print(__doc__) return 3 basis = sys.argv[1].rstrip("/") zugang_datei = Path(sys.argv[2]) ziel = Path(sys.argv[3]) sabotage = "--sabotage" in sys.argv[4:] selbstsigniert = "--selbstsigniert" in sys.argv[4:] if not zugang_datei.is_file(): print(f"VORAUSSETZUNG FEHLT: {zugang_datei}") return 3 zugang = json.loads(zugang_datei.read_text(encoding="utf-8")) ziel.mkdir(parents=True, exist_ok=True) befunde: list[str] = [] konsole: list[str] = [] antworten: list[str] = [] # jede Antwort ab 400 mit Phase, Status und Pfad phase = ["start"] def pruefe(name: str, bedingung: bool, einzelheit: str = "") -> None: print((" ok " if bedingung else " ROT ") + name + ("" if bedingung else f": {einzelheit}")) if not bedingung: befunde.append(f"{name}: {einzelheit}") with sync_playwright() as spiel: browser = spiel.chromium.launch() seite = browser.new_page(viewport={"width": 1440, "height": 900}, ignore_https_errors=selbstsigniert) seite.on("console", lambda m: konsole.append(f"{m.type}: {m.text}") if m.type in ("error", "warning") else None) seite.on("pageerror", lambda f: konsole.append(f"pageerror: {f}")) seite.on( "response", lambda a: antworten.append(f"{phase[0]} {a.status} {a.request.method} {a.url.replace(basis, '')}") if a.status >= 400 else None, ) def formular_da() -> bool: try: seite.get_by_label("Passwort").wait_for(timeout=8000) return True except PlaywrightTimeout: return False def app_da() -> bool: try: seite.get_by_role("button", name="Abmelden").wait_for(timeout=8000) return True except PlaywrightTimeout: return False print("== ohne Sitzung") phase[0] = "ohne Sitzung" seite.goto(basis + "/") pruefe("Anmeldeformular sichtbar", formular_da(), "kein Feld 'Passwort'") pruefe("App nicht sichtbar", seite.get_by_role("button", name="Abmelden").count() == 0, "Knopf 'Abmelden' ist da") seite.screenshot(path=str(ziel / "01-anmeldung.png")) print("== falsche Anmeldung") phase[0] = "falsche Anmeldung" seite.get_by_label("Benutzer").fill(zugang["benutzer"]) seite.get_by_label("Passwort").fill("ganz-falsches-passwort") seite.get_by_label("Code").fill("000000") seite.get_by_role("button", name="Anmelden", exact=True).click() seite.wait_for_timeout(1500) if sabotage: pruefe("SABOTAGE: nach der falschen Anmeldung ist die App sichtbar", app_da(), "App nicht sichtbar (so soll es sein)") else: pruefe("weiter das Formular", seite.get_by_label("Passwort").count() == 1, "Formular weg") pruefe("Fehlerzeile 'stimmt nicht'", seite.get_by_text("stimmt nicht").count() >= 1, "kein Fehlertext") seite.screenshot(path=str(ziel / "02-abgelehnt.png")) print("== richtige Anmeldung") phase[0] = "richtige Anmeldung" # Ein frischer Zeitschritt, falls der Code dieses Schritts gerade verbraucht wurde. rest = 30 - (time.time() % 30) if rest < 5: time.sleep(rest + 1) seite.get_by_label("Passwort").fill(zugang["passwort"]) seite.get_by_label("Code").fill(totp(zugang["geheimnis"], time.time())) seite.get_by_role("button", name="Anmelden", exact=True).click() pruefe("App sichtbar (Knopf 'Abmelden')", app_da(), "kein Knopf 'Abmelden'") pruefe("Benutzername in der Kopfzeile", seite.get_by_text(zugang["benutzer"], exact=True).count() >= 1, "Name fehlt") try: seite.get_by_text("Backend verbunden").wait_for(timeout=15000) verbunden = True except PlaywrightTimeout: verbunden = False pruefe("Strom laeuft ('Backend verbunden')", verbunden, "Statuspunkt zeigt es nicht") pruefe("Karte 'Paper-Betrieb' da", seite.get_by_text("Paper-Betrieb").count() >= 1, "Karte fehlt") seite.wait_for_timeout(1500) seite.screenshot(path=str(ziel / "03-uebersicht.png"), full_page=True) print("== Ansichten unter der CSP") phase[0] = "Ansichten unter der CSP" for name in ("Agenten", "Laeufe", "Markt", "Bestand"): knopf = seite.get_by_role("button", name=name.replace("ae", "ä") if name == "Laeufe" else name) if knopf.count() == 0: knopf = seite.get_by_role("link", name=name.replace("ae", "ä") if name == "Laeufe" else name) if knopf.count() == 0: pruefe(f"Ansicht {name} erreichbar", False, "kein Knopf und kein Link mit diesem Namen") continue knopf.first.click() seite.wait_for_timeout(2500) seite.screenshot(path=str(ziel / f"04-{name.lower()}.png")) csp_meldungen = [m for m in konsole if "Content Security Policy" in m or "content security policy" in m.lower()] pruefe("keine CSP-Meldung in der Konsole", not csp_meldungen, " | ".join(csp_meldungen[:3])) # Chromium schreibt jede Antwort ab 400 als Konsolenfehler. Die 401 der absichtlich falschen # Anmeldung ist erwartet; jede andere Antwort ab 400 wird mit Phase und Pfad zum Befund. unerwartet = [a for a in antworten if not (" 401 POST /api/anmeldung" in a and a.startswith("falsche Anmeldung"))] pruefe("keine unerwartete Antwort ab 400", not unerwartet, " | ".join(unerwartet[:5])) andere = [m for m in konsole if m not in csp_meldungen and "Failed to load resource" not in m] pruefe("keine andere Fehlermeldung in der Konsole", not andere, " | ".join(andere[:3])) print("== Abmelden") phase[0] = "Abmelden" seite.get_by_role("button", name="Abmelden").click() pruefe("zurueck zum Formular", formular_da(), "Formular erscheint nicht") seite.reload() pruefe("nach dem Neuladen weiter das Formular", formular_da(), "Formular erscheint nicht") seite.screenshot(path=str(ziel / "05-abgemeldet.png")) print("== schmale Breite") phase[0] = "schmale Breite" seite.set_viewport_size({"width": 375, "height": 800}) seite.reload() pruefe("Formular bei 375 px", formular_da(), "Formular erscheint nicht") masse = seite.evaluate("() => ({scroll: document.documentElement.scrollWidth, sicht: document.documentElement.clientWidth})") pruefe("kein waagerechter Scroll bei 375 px", masse["scroll"] <= masse["sicht"], str(masse)) seite.screenshot(path=str(ziel / "06-anmeldung-375.png")) browser.close() (ziel / "konsole.json").write_text(json.dumps(konsole, ensure_ascii=False, indent=1), encoding="utf-8") (ziel / "antworten-ab-400.json").write_text(json.dumps(antworten, ensure_ascii=False, indent=1), encoding="utf-8") print(f"\n{len(befunde)} Befund(e); Screenshots unter {ziel}") for befund in befunde: print("BEFUND:", befund) return 2 if befunde else 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Pruefskripts ist selbst ein Befund print(f"BEFUND: das Pruefskript ist abgebrochen: {ausnahme!r}") sys.exit(2)