500 lines
27 KiB
Python
500 lines
27 KiB
Python
"""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: >" 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"]
|