"""Weboberfläche: Anmeldung, CSRF, Abläufe, CSP und Trennung der Tenants (→ KONZEPT.md 13, 15.1, 16).""" import html import re import pytest from fastapi.testclient import TestClient from conftest import EXAMPLES from hugin_app import web from test_app import ADMIN, Env, tenant_package, env, zip_from_folder # noqa: F401 (Fixture) INLINE = [ (re.compile(r"<[^>]+\sstyle\s*=", re.I), "style-Attribut"), (re.compile(r"<[^>]+\son[a-z]+\s*=", re.I), "on…-Handler"), (re.compile(r"]*\ssrc=)[^>]*>", re.I), "Inline-Skript"), (re.compile(r""), (re.compile(r"hx-on", re.I), "hx-on"), ] def browser(env: Env, key: str | None) -> TestClient: """Eigener Client mit eigenen Cookies, angemeldet mit dem Key (oder dem Admin-Token).""" c = TestClient(env.client.app, follow_redirects=False) value = env.keys[key] if key else ADMIN["Authorization"].split()[1] response = c.post("/login", data={"key": value, "next": ""}) assert response.status_code == 303, response.text return c def csrf(c: TestClient, path: str = "/help") -> str: return re.search(r'name="_csrf" value="([^"]+)"', c.get(path).text).group(1) def csp_clean(html: str) -> list[str]: return [name for pattern, name in INLINE if pattern.search(html)] def test_redirect_to_login(env): c = TestClient(env.client.app, follow_redirects=False) response = c.get("/catalog") assert response.status_code == 303 and response.headers["location"].endswith("/login?next=/catalog") assert c.get("/catalog", headers={"HX-Request": "true"}).headers["hx-redirect"].endswith("next=/catalog") def test_login_and_logout(env): c = TestClient(env.client.app, follow_redirects=False) assert c.post("/login", data={"key": "hk_FALSCH"}).status_code == 401 response = c.post("/login", data={"key": env.keys["shop-lesen"], "next": "/help"}) assert response.headers["location"] == "/help" cookie = response.headers["set-cookie"] assert "HttpOnly" in cookie and "SameSite=lax" in cookie # Offene Weiterleitung nach außen wird verhindert c2 = TestClient(env.client.app, follow_redirects=False) assert c2.post("/login", data={"key": env.keys["shop-lesen"], "next": "//boese.example"} ).headers["location"].endswith("/catalog") assert c.get("/catalog").status_code == 200 c.post("/logout", data={"_csrf": csrf(c)}) assert c.get("/catalog").status_code == 303 # Sitzung ist serverseitig widerrufen def test_csrf(env): env.example("pub-hausregeln/1.0.0") c = browser(env, "shop-lesen") assert c.post("/logout").status_code == 403 assert c.post("/logout", data={"_csrf": "wrong"}).status_code == 403 assert c.post("/logout", headers={"X-CSRF-Token": csrf(c)}).status_code == 303 def test_flow_in_web(env): author = browser(env, "pub-autor") raw = zip_from_folder(EXAMPLES / "packages/pub-munin-nutzen/1.4.0") response = author.post("/upload", data={"_csrf": csrf(author)}, files={"file": ("package.zip", raw, "application/zip")}) assert response.status_code == 303 and response.headers["location"].endswith("/packages/pub-munin-nutzen/versions/1.4.0") page = author.get("/packages/pub-munin-nutzen/versions/1.4.0").text assert "Zur Prüfung einreichen" in page and ">Entwurf<" in page author.post("/packages/pub-munin-nutzen/versions/1.4.0/submit", data={"_csrf": csrf(author)}) # Der Autor sieht keinen Prüfen-Knopf assert 'data-target="decide"' not in author.get("/packages/pub-munin-nutzen/versions/1.4.0").text reviewer = browser(env, "pub-pruefer") assert "pub-munin-nutzen" in reviewer.get("/reviews").text page = reviewer.get("/packages/pub-munin-nutzen/versions/1.4.0").text assert 'data-target="decide"' in page assert ".claude/skills/pub-munin-nutzen/SKILL.md" in page # Vorschau je Werkzeug t = csrf(reviewer, "/packages/pub-munin-nutzen/versions/1.4.0") assert reviewer.post("/packages/pub-munin-nutzen/versions/1.4.0/decide", data={"_csrf": t, "decision": "return"}).status_code == 422 # ohne Kommentar response = reviewer.post("/packages/pub-munin-nutzen/versions/1.4.0/decide", data={"_csrf": t, "decision": "approve", "comment": "passt"}) assert response.status_code == 303 assert "Freigegeben" in reviewer.get(response.headers["location"]).text # Meldung erscheint einmal assert "Freigegeben." not in reviewer.get("/reviews").text reader = browser(env, "shop-lesen") assert "pub-munin-nutzen" in reader.get("/catalog").text fragment = reader.get("/catalog?q=munin", headers={"HX-Request": "true"}).text assert "-Beispiele stehen in' in page assert "referenz/beispiele.md entfernt" in page def test_linter_findings_on_upload(env): author = browser(env, "pub-autor") from test_app import zip_from_files raw = zip_from_files({"hugin.yaml": "id: falsch\nkind: skill\nversion: 1.0.0\nowner: x\n" "scope: public\ndescription: x\n", "SKILL.md": "x"}) response = author.post("/upload", data={"_csrf": csrf(author)}, files={"file": ("p.zip", raw)}) assert response.status_code == 422 and "H002" in response.text def test_package_page_shows_subscription(env): env.example("pub-munin-nutzen/1.4.0") page = browser(env, "shop-lesen").get("/packages/pub-munin-nutzen").text assert 'data-copy="subscribe"' in page and "packages:\n - pub-munin-nutzen" in page # Plattform-Admins haben kein Repo assert 'data-copy="subscribe"' not in browser(env, None).get("/packages/pub-munin-nutzen").text def test_roles_readable_and_revoke_asks(env): admin = browser(env, "shop-admin") admin.post("/manage/tenants/shop/keys", data={"_csrf": csrf(admin), "kind": "project", "name": "CI", "project": "api", "roles": ["author", "reviewer"]}) page = admin.get("/manage").text assert "Autor, Prüfer" in page and ">reviewer<" not in page assert 'data-confirm="Key „CI“ widerrufen?' in page def test_forms_guide_input(env): env.example("pub-munin-nutzen/1.4.0") env._upload(zip_from_folder(EXAMPLES / "packages/pub-munin-nutzen/1.5.0"), "pub-autor") env.post("/api/v1/packages/pub-munin-nutzen/versions/1.5.0/submit", env.h("pub-autor")) # Zurückgeben verlangt einen Kommentar, Freigeben nicht page = browser(env, "pub-pruefer").get("/packages/pub-munin-nutzen/versions/1.5.0").text assert re.search(r'', editor, re.S).group(1) manifest_new = html.unescape(manifest_new).replace("Was ist neu?", "Beispiele entfernt.") t = csrf(author, "/packages/pub-munin-nutzen/versions/1.4.0/edit") # Linter-Fehler: Eingaben bleiben erhalten response = author.post("/draft", data={"_csrf": t, "base": "pub-munin-nutzen@1.4.0", "manifest": manifest_new, "file:SKILL.md": "Nimm $1 bitte", "delete": "referenz/beispiele.md"}) assert response.status_code == 422 and "H010" in response.text and "Nimm $1 bitte" in response.text assert 'href="#file-SKILL.md"' in response.text and dangling_ids(response.text) == set() response = author.post("/draft", data={"_csrf": t, "base": "pub-munin-nutzen@1.4.0", "manifest": manifest_new, "file:SKILL.md": "# Munin nutzen\nKurz.\n", "delete": "referenz/beispiele.md", "new_path": "referenz/neu.md", "new_content": "new"}) assert response.status_code == 303 and response.headers["location"].endswith("/versions/1.4.1") page = author.get("/packages/pub-munin-nutzen/versions/1.4.1").text assert "referenz/neu.md" in page and "referenz/beispiele.md entfernt" in page def test_editor_saves_rendered_form_unchanged(env): """Das Formular so abschicken, wie der Editor es rendert: Jede Datei kommt unter ihrem Pfad an.""" env.publish(zip_from_folder(EXAMPLES / "packages/pub-munin-nutzen/1.4.0")) author = browser(env, "pub-autor") editor = author.get("/packages/pub-munin-nutzen/versions/1.4.0/edit").text fields = {name: html.unescape(text) for name, text in re.findall(r'', editor, re.S)} assert {"file:SKILL.md", "file:referenz/beispiele.md"} <= set(fields), set(fields) response = author.post("/draft", data={"_csrf": csrf(author), "base": "pub-munin-nutzen@1.4.0", **fields}) assert response.status_code == 303, response.text page = author.get("/packages/pub-munin-nutzen/versions/1.4.1").text assert "referenz/beispiele.md" in page and "entfernt" not in page def test_audit_filter_kept_when_paging(env): admin = browser(env, "shop-admin") for i in range(60): admin.post("/manage/tenants/shop/projects", data={"_csrf": csrf(admin), "project_slug": f"p{i}", "name": ""}) page = admin.get("/audit?action=project.").text older = html.unescape(re.search(r'href="([^"]+)"[^>]*>Ältere<', page).group(1)) assert "action=project." in older and "page=2" in older assert "Neuere" in admin.get(older).text def test_edit_only_owner(env): env.example("pub-hausregeln/1.0.0") assert browser(env, "shop-autor").get("/packages/pub-hausregeln/versions/1.0.0/edit").status_code == 403 foreign = browser(env, "shop-autor") response = foreign.post("/draft", data={"_csrf": csrf(foreign), "base": "pub-hausregeln@1.0.0", "manifest": "id: x"}) assert response.status_code == 403 def test_binary_files_kept_when_editing(env): import io import zipfile image = b"\x89PNG\r\n\x1a\n\x00\xff" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as z: z.writestr("hugin.yaml", (EXAMPLES / "packages/pub-hausregeln/1.0.0/hugin.yaml").read_text()) z.writestr("RULES.md", "# Regeln\n") z.writestr("bild.png", image) env._upload(buffer.getvalue(), "pub-autor") author = browser(env, "pub-autor") editor = author.get("/packages/pub-hausregeln/versions/1.0.0/edit").text assert "bild.png" in editor and 'name="file:bild.png"' not in editor manifest = html.unescape(re.search(r'', editor, re.S).group(1)) response = author.post("/draft", data={"_csrf": csrf(author), "base": "pub-hausregeln@1.0.0", "manifest": manifest, "file:RULES.md": "# Regeln neu\n"}) assert response.status_code == 303 raw = env.client.get("/api/v1/packages/pub-hausregeln/versions/1.0.0", headers=env.h("pub-autor")).content with zipfile.ZipFile(io.BytesIO(raw)) as z: assert z.read("bild.png") == image and z.read("RULES.md") == b"# Regeln neu\n" def test_restrict_integration_key_to_projects(env): env.post("/api/v1/admin/tenants/shop/projects", ADMIN, {"slug": "web", "name": "Web"}) admin = browser(env, "shop-admin") page = admin.get("/manage").text assert 'name="projects" multiple' in page response = admin.post("/manage/tenants/shop/keys", data={ "_csrf": csrf(admin), "kind": "integration", "name": "Verteilung Web", "projects": ["web"]}) assert response.status_code == 201 key = re.search(r"(hi_[A-Z2-7]{40,})", response.text).group(1) row = re.search(r"Verteilung Web.*?", response.text, re.S).group(0) assert "web" in row # Wirkung: Pakete mit Geltung projekt sieht der Key nur aus den gewählten Projekten manifest = ("id: api-intern\nkind: rules\nversion: 1.0.0\nowner: api\nscope: project\n" "description: Nur für api.\n") from test_app import zip_from_files env.publish(zip_from_files({"hugin.yaml": manifest, "RULES.md": "# x\n"}), author="shop-autor", reviewer="shop-pruefer") ids = [p["id"] for p in env.client.get("/api/v1/delivery/packages", headers={"Authorization": f"Bearer {key}"}).json()] assert "api-intern" not in ids admin.post("/manage/tenants/shop/keys", data={"_csrf": csrf(admin), "kind": "integration", "name": "Alle"}) def test_catalog_and_reviews_paginate(env): from test_app import zip_from_files for i in range(30): manifest = (f"id: pub-paket-{i:02d}\nkind: rules\nversion: 1.0.0\nowner: kern\nscope: public\n" f"description: Paket Nummer {i}.\n") v = env._upload(zip_from_files({"hugin.yaml": manifest, "RULES.md": "# x\n"}), "pub-autor") env.post(f"/api/v1/packages/{v['package']}/versions/1.0.0/submit", env.h("pub-autor")) if i < 28: env.post(f"/api/v1/packages/{v['package']}/versions/1.0.0/approve", env.h("pub-pruefer"), {}) reader = browser(env, "shop-lesen") first = reader.get("/catalog").text assert "1–25 von 28" in first and "pub-paket-24" in first and "pub-paket-25" not in first assert 'data-action="sort"' not in first # Sortieren im Browser sähe nur diese Seite assert 'data-action="sort"' in reader.get("/catalog?q=paket-0").text second = reader.get("/catalog?page=2", headers={"HX-Request": "true"}).text assert "26–28 von 28" in second and "pub-paket-27" in second and "]*>Weiter<', first).group(1)) assert "26–28 von 28" in reader.get(following).text # Zwei Versionen warten auf Prüfung: keine Seiten nötig reviewer = browser(env, "pub-pruefer") page = reviewer.get("/reviews").text assert "pub-paket-28" in page and "Seite 1 von" not in page def test_catalog_shows_newest_approved(env): env.example("pub-munin-nutzen/1.4.0") env.example("pub-munin-nutzen/1.5.0") env.post("/api/v1/packages/pub-munin-nutzen/versions/1.5.0/deprecate", env.h("pub-autor")) entry = env.client.get("/api/v1/packages", headers=env.h("shop-lesen")).json()[0] assert entry["version"] == "1.4.0" def test_mandatory_packages_in_web(env): env.example("pub-hausregeln/1.0.0") env.example("pub-munin-nutzen/1.4.0") tenant_package(env) # Tenant-Admin: Pflicht für den eigenen Tenant, nicht für alle admin = browser(env, "shop-admin") page = admin.get("/manage").text assert "Pflichtpakete in shop" in page and "in allen Tenants" not in page assert '' in page and '' in page t = csrf(admin, "/manage") assert admin.post("/manage/tenants/shop/mandatory", data={"_csrf": t, "package": "shop-intern"}).status_code == 303 assert admin.post("/manage/tenants/shop/mandatory", data={"_csrf": t, "package": "pub-hausregeln", "scope": "all"}).status_code == 403 # Plattform-Admin: für alle Tenants platform = browser(env, None) t = csrf(platform, "/manage?tenant=shop") platform.post("/manage/tenants/shop/mandatory", data={"_csrf": t, "package": "pub-hausregeln", "scope": "all"}) page = admin.get("/manage").text assert "alle Tenants" in page and "shop" in page # Wirkung: kommt ohne Abo mit, im Shop beide, im Vertrieb nur das öffentliche shop, _ = env.bundle("shop-lesen", packages=[]) vertrieb, _ = env.bundle("vertrieb-lesen", packages=[]) assert sorted(p["id"] for p in shop["packages"]) == ["pub-hausregeln", "shop-intern"] assert [p["id"] for p in vertrieb["packages"]] == ["pub-hausregeln"] and vertrieb["packages"][0]["mandatory"] # Hinweise in Katalog und Paketseite reader = browser(env, "shop-lesen") assert 'badge-info">Pflicht' in reader.get("/catalog").text assert 'badge-info" title="kommt bei jedem hugin sync mit">Pflicht' in reader.get("/packages/pub-hausregeln").text assert 'Pflicht' not in reader.get("/packages/pub-munin-nutzen").text # Tenant-Admin kann die Pflicht für alle nicht entfernen, die eigene schon assert admin.post("/manage/tenants/shop/mandatory/pub-hausregeln/remove", data={"_csrf": csrf(admin, "/manage"), "scope": "all"}).status_code == 403 assert admin.post("/manage/tenants/shop/mandatory/shop-intern/remove", data={"_csrf": csrf(admin, "/manage")}).status_code == 303 shop, _ = env.bundle("shop-lesen", packages=[]) assert [p["id"] for p in shop["packages"]] == ["pub-hausregeln"]