hugin/server/tests/test_web.py
2026-10-27 08:33:21 +01:00

500 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"<script(?![^>]*\ssrc=)[^>]*>", re.I), "Inline-Skript"),
(re.compile(r"<style", re.I), "<style>"),
(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 "<html" not in fragment and "pub-munin-nutzen" in fragment
assert "Keine Pakete" in reader.get("/catalog?q=gibtesnicht", headers={"HX-Request": "true"}).text
def test_diff_to_previous_version(env):
env.example("pub-munin-nutzen/1.4.0")
env.example("pub-munin-nutzen/1.5.0")
page = browser(env, "shop-lesen").get("/packages/pub-munin-nutzen/versions/1.5.0").text
assert "Unterschied zu 1.4.0" in page
assert 'class="diff-minus">-Beispiele stehen in' in page
assert "referenz/beispiele.md</code> <span class=\"badge badge-danger\">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</code>" 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'<textarea id="comment"[^>]*\srequired', page)
assert re.search(r'value="approve"[^>]*formnovalidate', page)
# Präfix der Geltung ist vorausgefüllt
assert 'id="new-id"' in (new := browser(env, "pub-autor").get("/new").text) and 'value="pub-"' in new
# Felder je Key-Art
manage = browser(env, "shop-admin").get("/manage").text
assert 'data-when="key-kind:project"' in manage and 'data-when="key-kind:integration"' in manage
def test_relative_time():
from datetime import UTC, datetime, timedelta
now = datetime(2026, 10, 20, 12, 0, tzinfo=UTC)
cases = {timedelta(seconds=5): "gerade eben", timedelta(minutes=12): "vor 12 Min.",
timedelta(hours=5): "vor 5 Std.", timedelta(hours=30): "gestern", timedelta(days=9): "vor 9 Tagen",
timedelta(days=45): "05.09.2026"}
for ago, text in cases.items():
assert web.relative(now - ago, now) == text
assert web.relative((now - timedelta(days=3)).replace(tzinfo=None), now) == "vor 3 Tagen" # SQLite: ohne Zone
def test_project_filters_outdated(env):
env.example("pub-munin-nutzen/1.4.0")
env.example("pub-hausregeln/1.0.0")
env.post("/api/v1/usage", env.h("shop-lesen"), {"repo": "git.intern/shop/api", "tools": {"claude": "2.1.302"},
"installed": {"pub-munin-nutzen": "1.4.0", "pub-hausregeln": "1.0.0"}})
env.example("pub-munin-nutzen/1.5.0")
reader = browser(env, "shop-lesen")
page = reader.get("/project").text
assert "Alle (2)" in page and "Mit neuerer Version (1)" in page
assert page.count("<code>git.intern/shop/api</code>") == 1 # Repo steht einmal je Gruppe
only = reader.get("/project?outdated=1").text
assert "pub-munin-nutzen" in only and "pub-hausregeln</code>" not in only
def test_overview_card_jumps_to_table(env):
env.example("pub-munin-nutzen/1.4.0")
env.post("/api/v1/usage", env.h("shop-lesen"), {"repo": "r", "tools": {}, "installed": {"pub-munin-nutzen": "1.4.0"}})
env.example("pub-munin-nutzen/1.5.0")
page = browser(env, "shop-admin").get("/overview").text
assert 'href="#outdated"' in page and dangling_ids(page) == set()
def test_key_shown_once(env):
admin = browser(env, "shop-admin")
response = admin.post("/manage/tenants/shop/keys",
data={"_csrf": csrf(admin), "kind": "project", "name": "CI", "project": "api",
"roles": ["author"]})
assert response.status_code == 201
key = re.search(r"(hk_[A-Z2-7]{40,})", response.text).group(1)
assert key not in admin.get("/manage").text
# Tenant-Admin vergibt keine Plattform-Rollen
response = admin.post("/manage/tenants/shop/keys",
data={"_csrf": csrf(admin), "kind": "project", "name": "x", "project": "api",
"roles": ["pub_author"]})
assert response.status_code == 403
PAGES = {
"login": ("/login", None),
"start": ("/", "shop-lesen"),
"catalog": ("/catalog", "shop-lesen"),
"package": ("/packages/pub-munin-nutzen", "shop-lesen"),
"version": ("/packages/pub-munin-nutzen/versions/1.5.0", "shop-lesen"),
"reviews": ("/reviews", "pub-pruefer"),
"upload": ("/upload", "pub-autor"),
"project": ("/project", "shop-lesen"),
"manage": ("/manage", None),
"help": ("/help", "shop-lesen"),
"overview": ("/overview", "shop-admin"),
"system": ("/system", None),
"audit": ("/audit", "shop-admin"),
"new": ("/new", "pub-autor"),
"edit": ("/packages/pub-munin-nutzen/versions/1.5.0/edit", "pub-autor"),
"download": ("/download", "shop-lesen"),
"help_topic": ("/help/teams", "shop-lesen"),
}
DOWNLOADS = {"export", "program_file"} # keine HTML-Seiten; eigene Tests
POST_ROUTES = {"login", "logout", "submit", "decide", "create_tenant", "create_project",
"create_key", "revoke_key", "set_system", "feedback", "feedback_resolved",
"promote", "create_person", "person_key", "lock_person", "deprecate_package",
"deprecate_version", "lock_tenant",
"save_draft", "set_mandatory", "remove_mandatory"}
def test_all_web_routes_covered(env):
names = {r.name for r in web.router(env.hugin).routes}
assert names <= set(PAGES) | POST_ROUTES | DOWNLOADS | {"login_page", "upload_page", "management_page"}, names
assert {r.name for r in web.router(env.hugin).routes if "GET" in r.methods} - {
"login_page", "upload_page", "management_page"} <= set(PAGES) | DOWNLOADS
@pytest.mark.parametrize("name", PAGES)
def test_pages_csp_fixed(env, name):
env.example("pub-munin-nutzen/1.4.0")
env.example("pub-munin-nutzen/1.5.0")
path, key = PAGES[name]
c = browser(env, key) if name != "login" else TestClient(env.client.app)
response = c.get(path, follow_redirects=True)
assert response.status_code == 200, response.text
assert "script-src 'self'" in response.headers["content-security-policy"]
assert response.headers["x-frame-options"] == "DENY"
assert csp_clean(response.text) == []
title = re.search(r"<title>(.*?)</title>", response.text, re.S).group(1)
assert "<" not in title and len(title) < 120, title
assert dangling_ids(response.text) == set()
def dangling_ids(page: str) -> set[str]:
"""IDs, auf die htmx, Tabs, Dialoge oder Labels verweisen, die es auf der Seite aber nicht gibt."""
ids = set(re.findall(r'\sid="([^"]+)"', page))
refs = set(re.findall(r'hx-target="#([^"]+)"', page)) | set(re.findall(r"from:#([\w-]+)", page))
refs |= set(re.findall(r'data-target="([^"]+)"', page)) | set(re.findall(r'<label[^>]*\sfor="([^"]+)"', page))
refs |= set(re.findall(r'data-copy="([^"]+)"', page)) | set(re.findall(r'href="#([^"]+)"', page))
refs |= {when.split(":")[0] for when in re.findall(r'data-when="([^"]+)"', page)}
refs |= set(re.findall(r'aria-(?:controls|labelledby)="([^"]+)"', page))
return refs - ids
def test_foreign_tenant_in_web(env):
tenant_package(env)
env.key("vertrieb", "vertrieb-admin", kind="tenant")
c = browser(env, "vertrieb-lesen")
assert "shop-intern" not in c.get("/catalog").text
assert c.get("/packages/shop-intern").status_code == 404
assert c.get("/packages/shop-intern/versions/1.0.0").status_code == 404
assert "Nur für den Shop" not in c.get("/catalog?q=shop").text
admin = browser(env, "vertrieb-admin")
page = admin.get("/manage?tenant=shop").text
assert "Keys in vertrieb" in page and "shop-autor" not in page
t = csrf(admin, "/manage")
assert admin.post("/manage/tenants/shop/projects", data={"_csrf": t, "project_slug": "xy"}).status_code == 404
assert admin.post("/manage/tenants/shop/keys", data={"_csrf": t, "kind": "project", "name": "x",
"project": "api"}).status_code == 404
def test_system_only_platform(env):
assert browser(env, "shop-admin").get("/system").status_code == 403
admin = browser(env, None)
response = admin.post("/system/floors", data={"_csrf": csrf(admin), "claude": "2.1.250", "opencode": "1.15.0"})
assert response.status_code == 303
assert 'value="2.1.250"' in admin.get("/system").text
assert admin.post("/system/floors", data={"_csrf": csrf(admin), "claude": "x", "opencode": "1"}).status_code == 422
def test_overview_shows_deprecated(env):
env.example("pub-munin-nutzen/1.4.0")
env.example("pub-munin-nutzen/1.5.0")
env.post("/api/v1/usage", env.h("shop-lesen"),
{"repo": "git.intern/shop/api", "installed": {"pub-munin-nutzen": "1.4.0"}})
page = browser(env, "shop-admin").get("/overview").text
assert "git.intern/shop/api" in page and "1.5.0" in page
assert "git.intern/shop/api" not in browser(env, "vertrieb-lesen").get("/overview").text
def test_locked_tenant_loses_session(env):
c = browser(env, "shop-lesen")
assert c.get("/catalog").status_code == 200
admin = browser(env, None)
assert admin.post("/manage/tenants/shop/lock", data={"_csrf": csrf(admin), "action": "lock"}).status_code == 303
assert c.get("/catalog").status_code == 303 # zurück zur Anmeldung
def test_export_in_web(env):
assert browser(env, "shop-admin").get("/system/export").status_code == 403
response = browser(env, None).get("/system/export")
assert response.status_code == 200 and response.headers["content-type"] == "application/zip"
def test_new_package_in_browser(env):
author = browser(env, "pub-autor")
page = author.get("/new?kind=command&id=pub-web-befehl").text
assert "id: pub-web-befehl" in page and "{{input}}" in page
manifest = ("id: pub-web-befehl\r\nkind: command\r\nversion: 1.0.0\r\nowner: kern\r\n"
"scope: public\r\ndescription: Aus dem Browser.\r\narguments: [ziel]\r\n")
response = author.post("/draft", data={"_csrf": csrf(author), "base": "", "manifest": manifest,
"file:COMMAND.md": "Prüfe {{ziel}}.\r\n"})
assert response.status_code == 303, response.text
assert response.headers["location"].endswith("/packages/pub-web-befehl/versions/1.0.0")
zip_raw = env.client.get("/api/v1/packages/pub-web-befehl/versions/1.0.0", headers=env.h("pub-autor")).content
import io
import zipfile
with zipfile.ZipFile(io.BytesIO(zip_raw)) as z:
assert z.read("COMMAND.md") == "Prüfe {{ziel}}.\n".encode() # CRLF aus dem Browser → LF
def test_new_version_on_base(env):
env.publish(zip_from_folder(EXAMPLES / "packages/pub-munin-nutzen/1.4.0"))
author = browser(env, "pub-autor")
page = author.get("/packages/pub-munin-nutzen/versions/1.4.0").text
assert "Neue Version auf dieser Basis" in page
editor = author.get("/packages/pub-munin-nutzen/versions/1.4.0/edit").text
assert "version: 1.4.1" in editor and "changes: &gt;" in editor
manifest_new = re.search(r'<textarea name="manifest"[^>]*>(.*?)</textarea>', 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</code> <span class=\"badge badge-danger\">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'<textarea name="([^"]+)"[^>]*>(.*?)</textarea>', 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 "<code>bild.png</code>" in editor and 'name="file:bild.png"' not in editor
manifest = html.unescape(re.search(r'<textarea name="manifest"[^>]*>(.*?)</textarea>', 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"<td>Verteilung Web</td>.*?</tr>", response.text, re.S).group(0)
assert "<td>web</td>" 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 "<html" not in second
assert "26–28 von 28" in reader.get("/catalog?page=99").text # zu große Seite → letzte Seite
# Suche behält ihre Parameter beim Blättern; der gerenderte Weiter-Link führt auf Seite 2
assert "q=paket" in reader.get("/catalog?q=paket").text
following = html.unescape(re.search(r'href="([^"]+)"[^>]*>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 '<option>shop-intern</option>' in page and '<option>pub-hausregeln</option>' 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 "<td>alle Tenants</td>" in page and "<td>shop</td>" 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</span>' 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"]