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

527 lines
32 KiB
Python

"""Die App von außen: Lebenszyklus, Auslieferung, Rechte, Trennung der Tenants."""
import io
import json
import zipfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from conftest import EXAMPLES
from hugin_app.app import create_app
from hugin_app.settings import Settings
from hugin_app.hugin import Hugin
from hugin_app.migration import migrate
from hugin_app.store.models import AuditEvent, Usage
ADMIN = {"Authorization": "Bearer admin-geheim"}
TOOLS = {"claude": "2.1.302", "opencode": "1.19.3"}
def zip_from_folder(folder: Path) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as z:
for file in sorted(folder.rglob("*")):
if file.is_file():
z.write(file, file.relative_to(folder).as_posix())
return buffer.getvalue()
def zip_from_files(files: dict[str, str]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as z:
for name, content in files.items():
z.writestr(name, content)
return buffer.getvalue()
def auth(key: str) -> dict[str, str]:
return {"Authorization": f"Bearer {key}"}
class Env:
"""Drei Tenants: plattform (pflegt öffentliche Pakete), shop und vertrieb."""
def __init__(self, client: TestClient, hugin: Hugin):
self.client, self.hugin = client, hugin
self.keys: dict[str, str] = {}
for tenant, project in [("plattform", "kern"), ("shop", "api"), ("vertrieb", "crm")]:
self.post("/api/v1/admin/tenants", ADMIN, {"slug": tenant, "name": tenant.title()})
self.post(f"/api/v1/admin/tenants/{tenant}/projects", ADMIN, {"slug": project, "name": project})
self.key(tenant, f"{tenant}-lesen", project=project)
self.key("plattform", "pub-autor", project="kern", roles=["pub_author"])
self.key("plattform", "pub-pruefer", project="kern", roles=["pub_reviewer"])
self.key("shop", "shop-autor", project="api", roles=["author"])
self.key("shop", "shop-pruefer", project="api", roles=["reviewer"])
self.key("shop", "shop-admin", kind="tenant")
def post(self, path, header, body=None, expected=(200, 201, 204), **kwargs):
response = self.client.post(path, headers=header, json=body, **kwargs)
assert response.status_code in expected, response.text
return response
def key(self, tenant, name, kind="project", project=None, roles=()):
body = {"kind": kind, "name": name, "project": project, "roles": list(roles)}
self.keys[name] = self.post(f"/api/v1/admin/tenants/{tenant}/keys", ADMIN, body).json()["key"]
def h(self, name):
return auth(self.keys[name])
def publish(self, raw: bytes, author="pub-autor", reviewer="pub-pruefer") -> dict:
v = self._upload(raw, author)
base = f"/api/v1/packages/{v['package']}/versions/{v['version']}"
self.post(f"{base}/submit", self.h(author))
return self.post(f"{base}/approve", self.h(reviewer), {"comment": "passt"}).json()
def _upload(self, raw: bytes, author: str) -> dict:
response = self.client.post("/api/v1/drafts", headers=self.h(author) | {"Content-Type": "application/zip"},
content=raw)
assert response.status_code == 201, response.text
return response.json()
def example(self, name: str) -> dict:
return self.publish(zip_from_folder(EXAMPLES / "packages" / name))
def bundle(self, key: str, **bundle_request) -> tuple[dict, dict[str, bytes]]:
response = self.post("/api/v1/bundle", self.h(key), {"tools": TOOLS} | bundle_request)
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
files = {n: z.read(n) for n in z.namelist()}
return json.loads(files.pop("hugin-bundle.json")), files
@pytest.fixture
def env(database_url) -> Env:
url = database_url
migrate(url)
hugin = Hugin(Settings(database_url=url, admin_token="admin-geheim"))
return Env(TestClient(create_app(hugin)), hugin)
def golden(name: str) -> tuple[dict, dict[str, bytes]]:
folder = EXAMPLES / "bundles" / name
instructions = json.loads((folder / "hugin-bundle.json").read_text())
files = {p.relative_to(folder).as_posix(): p.read_bytes()
for p in folder.rglob("*") if p.is_file() and p.name != "hugin-bundle.json"}
return instructions, files
def without_time(instructions: dict) -> dict:
return {k: v for k, v in instructions.items() if k != "generated"}
def test_health(env):
assert env.client.get("/healthz").json() == {"status": "ok"}
assert env.client.get("/readyz").json() == {"status": "ok"}
def test_delivery_like_golden(env):
"""Über die API ausgelieferte Bundles gleichen den goldenen Bundles, die das CLI testet."""
for name in ("pub-munin-nutzen/1.4.0", "pub-hausregeln/1.0.0", "pub-munin-anbindung/1.0.0"):
env.example(name)
assert env.client.put("/api/v1/admin/mandatory/pub-munin-anbindung", headers=ADMIN,
json={"tenant": None}).status_code == 204
instructions, files = env.bundle("shop-lesen", packages=["pub-munin-nutzen", "pub-hausregeln"])
expected, expected_files = golden("first-install")
assert without_time(instructions) == without_time(expected)
assert files == expected_files
env.example("pub-munin-nutzen/1.5.0")
instructions, files = env.bundle(
"shop-lesen", packages=["pub-munin-nutzen"],
installed={"pub-munin-nutzen": "1.4.0", "pub-hausregeln": "1.0.0", "pub-munin-anbindung": "1.0.0"})
expected, expected_files = golden("update")
assert without_time(instructions) == without_time(expected)
assert files == expected_files
def test_opencode_only_writes_nothing_for_claude(env):
"""Ohne Claude Code im Repo nichts unter .claude/: Skills nach .opencode/skills/."""
for name in ("pub-munin-nutzen/1.4.0", "pub-hausregeln/1.0.0", "pub-release-notiz/1.0.0"):
env.example(name)
packages = ["pub-munin-nutzen", "pub-hausregeln", "pub-release-notiz"]
_, files = env.bundle("shop-lesen", packages=packages, tools={"opencode": "1.19.3"})
names = sorted(n.removeprefix("files/") for n in files)
assert ".opencode/skills/pub-munin-nutzen/SKILL.md" in names
assert ".opencode/skills/pub-munin-nutzen/referenz/beispiele.md" in names
assert ".opencode/commands/pub-release-notiz.md" in names
assert not [n for n in names if n.startswith(".claude/") or n in (".mcp.json", "CLAUDE.md")], names
# Mit beiden Werkzeugen bleibt es bei einer Datei für beide
_, files = env.bundle("shop-lesen", packages=packages)
assert "files/.claude/skills/pub-munin-nutzen/SKILL.md" in files
assert not [n for n in files if n.startswith("files/.opencode/skills/")]
def test_etag(env):
env.example("pub-hausregeln/1.0.0")
body = {"packages": ["pub-hausregeln"], "tools": TOOLS}
first = env.post("/api/v1/bundle", env.h("shop-lesen"), body)
second = env.client.post("/api/v1/bundle", json=body,
headers=env.h("shop-lesen") | {"If-None-Match": first.headers["etag"]})
assert second.status_code == 304
def test_major_not_automatic(env):
def version(number, changes=""):
manifest = (EXAMPLES / "packages/pub-hausregeln/1.0.0/hugin.yaml").read_text().replace("1.0.0", number)
manifest += f"changes: {changes or 'new'}\n"
env.publish(zip_from_files({"hugin.yaml": manifest, "RULES.md": f"# Regeln {number}\n"}))
env.example("pub-hausregeln/1.0.0")
version("1.1.0")
version("2.0.0")
instructions, _ = env.bundle("shop-lesen", packages=["pub-hausregeln"], installed={"pub-hausregeln": "1.0.0"})
assert instructions["packages"][0]["version"] == "1.1.0"
assert {"kind": "major_available", "package": "pub-hausregeln", "version": "2.0.0"} in instructions["hints"]
instructions, _ = env.bundle("shop-lesen", packages=["pub-hausregeln"], installed={"pub-hausregeln": "1.0.0"},
major=True)
assert instructions["packages"][0]["version"] == "2.0.0"
def test_lifecycle_and_permissions(env):
raw = zip_from_folder(EXAMPLES / "packages/pub-hausregeln/1.0.0")
header = {"Content-Type": "application/zip"}
c = env.client
# Lese-Key und Tenant-Autor dürfen keine öffentlichen Pakete hochladen
assert c.post("/api/v1/drafts", headers=env.h("shop-lesen") | header, content=raw).status_code == 403
assert c.post("/api/v1/drafts", headers=env.h("shop-autor") | header, content=raw).status_code == 403
v = env._upload(raw, "pub-autor")
base = f"/api/v1/packages/{v['package']}/versions/{v['version']}"
# Entwurf: für andere unsichtbar und nicht lieferbar
assert c.get(base, headers=env.h("shop-lesen")).status_code == 404
assert c.post("/api/v1/bundle", headers=env.h("shop-lesen"),
json={"packages": ["pub-hausregeln"], "tools": TOOLS}).status_code == 422
# Freigeben geht erst nach dem Einreichen
assert c.post(f"{base}/approve", headers=env.h("pub-pruefer"), json={}).status_code == 409
env.post(f"{base}/submit", env.h("pub-autor"))
# Eingereicht ist unveränderlich
assert c.post("/api/v1/drafts", headers=env.h("pub-autor") | header, content=raw).status_code == 409
# Nicht der Autor selbst, nicht ein Prüfer ohne pub_pruefer
assert c.post(f"{base}/approve", headers=env.h("pub-autor"), json={}).status_code == 403
assert c.post(f"{base}/approve", headers=env.h("shop-pruefer"), json={}).status_code == 403
# Zurückgeben verlangt einen Kommentar; danach ist es wieder ein Entwurf
assert c.post(f"{base}/return", headers=env.h("pub-pruefer"), json={}).status_code == 422
assert env.post(f"{base}/return", env.h("pub-pruefer"),
{"comment": "Bitte genauer"}).json()["status"] == "draft"
env._upload(raw, "pub-autor") # Entwurf ersetzen trotz Rückgabe
env.post(f"{base}/submit", env.h("pub-autor"))
assert [p["package"] for p in c.get("/api/v1/reviews", headers=env.h("pub-pruefer")).json()] == ["pub-hausregeln"]
assert env.post(f"{base}/approve", env.h("pub-pruefer"), {}).json()["status"] == "approved"
with env.hugin.database.sessions() as s:
actions = [e.action for e in s.scalars(select(AuditEvent).where(AuditEvent.target_kind == "version"))]
assert actions == ["version.uploaded", "version.submitted", "version.returned",
"version.uploaded", "version.submitted", "version.approved"]
def test_linter_rejects(env):
manifest = (EXAMPLES / "packages/pub-hausregeln/1.0.0/hugin.yaml").read_text().replace("pub-hausregeln", "hausregeln")
response = env.client.post("/api/v1/drafts", content=zip_from_files({"hugin.yaml": manifest, "RULES.md": "x"}),
headers=env.h("pub-autor") | {"Content-Type": "application/zip"})
assert response.status_code == 422
assert [b["code"] for b in response.json()["detail"]["findings"]] == ["H002"]
def test_scripts_only_from_stage_4(env):
manifest = (EXAMPLES / "packages/pub-munin-nutzen/1.4.0/hugin.yaml").read_text()
raw = zip_from_files({"hugin.yaml": manifest, "SKILL.md": "# x\n", "scripts/a.sh": "echo"})
response = env.client.post("/api/v1/drafts", content=raw,
headers=env.h("pub-autor") | {"Content-Type": "application/zip"})
assert response.status_code == 422 and "H043" in response.text
def test_validate_without_saving(env):
raw = zip_from_folder(EXAMPLES / "packages/pub-munin-nutzen/1.4.0")
response = env.client.post("/api/v1/validate", content=raw,
headers=env.h("shop-lesen") | {"Content-Type": "application/zip"}).json()
assert response["findings"] == [] and response["compatibility"] == "portable"
assert response["preview"]["claude"][0]["path"] == ".claude/skills/pub-munin-nutzen/SKILL.md"
assert env.client.get("/api/v1/packages/pub-munin-nutzen", headers=env.h("shop-lesen")).status_code == 404
def test_usage(env):
message = {"repo": "git.intern/shop/api", "tools": TOOLS,
"installed": {"pub-a": "1.0.0", "pub-b": "2.0.0"}}
env.post("/api/v1/usage", env.h("shop-lesen"), message)
env.post("/api/v1/usage", env.h("shop-lesen"), message | {"installed": {"pub-a": "1.1.0"}})
with env.hugin.database.sessions() as s:
lines = [(n.package_id, n.version) for n in s.scalars(select(Usage))]
assert lines == [("pub-a", "1.1.0")]
def test_keys(env):
c = env.client
assert c.get("/api/v1/packages").status_code == 401
assert c.get("/api/v1/packages", headers=auth("hk_FALSCH")).status_code == 401
# Tenant-Admin verwaltet nur den eigenen Tenant und vergibt keine Plattform-Rollen
admin = env.h("shop-admin")
assert c.post("/api/v1/admin/tenants/shop/projects", headers=admin, json={"slug": "web", "name": "Web"}).status_code == 201
assert c.post("/api/v1/admin/tenants/vertrieb/projects", headers=admin, json={"slug": "xy", "name": "x"}).status_code == 404
assert c.post("/api/v1/admin/tenants/shop/keys", headers=admin,
json={"kind": "project", "name": "x", "project": "web", "roles": ["pub_author"]}).status_code == 403
new = c.post("/api/v1/admin/tenants/shop/keys", headers=admin,
json={"kind": "project", "name": "web", "project": "web"}).json()
assert new["key"].startswith("hk_") and len(new["key"]) > 50
assert c.get("/api/v1/packages", headers=auth(new["key"])).status_code == 200
assert c.delete(f"/api/v1/admin/tenants/shop/keys/{new['id']}", headers=admin).status_code == 204
assert c.get("/api/v1/packages", headers=auth(new["key"])).status_code == 401
# Reservierte Slugs
assert c.post("/api/v1/admin/tenants", headers=ADMIN, json={"slug": "pub", "name": "x"}).status_code == 422
# Trennung der Tenants (→ KONZEPT.md 15.1)
def tenant_package(env) -> None:
manifest = ("id: shop-intern\nkind: rules\nversion: 1.0.0\nowner: api\nscope: tenant\n"
"description: Nur für den Shop.\n")
env.publish(zip_from_files({"hugin.yaml": manifest, "RULES.md": "# Intern\n"}),
author="shop-autor", reviewer="shop-pruefer")
def test_tenant_sees_only_own_and_public(env):
env.example("pub-hausregeln/1.0.0")
tenant_package(env)
catalog = lambda key: [p["id"] for p in env.client.get("/api/v1/packages", headers=env.h(key)).json()] # noqa: E731
assert catalog("shop-lesen") == ["pub-hausregeln", "shop-intern"]
assert catalog("vertrieb-lesen") == ["pub-hausregeln"]
assert env.client.get("/api/v1/packages?q=shop", headers=env.h("vertrieb-lesen")).json() == []
# Jede Route wird mit dem Key von vertrieb gegen ein Paket von shop aufgerufen.
# Erwartet: nie ein Inhalt von shop. Neue Routen ohne Eintrag lassen den Test scheitern.
FOREIGN = "shop-intern"
ISOLATION = {
("POST", "/api/v1/bundle"): lambda c, h: c.post("/api/v1/bundle", headers=h, json={"packages": [FOREIGN], "tools": TOOLS}),
("POST", "/api/v1/usage"): None, # schreibt nur in den eigenen Tenant
("GET", "/api/v1/config"): lambda c, h: c.get("/api/v1/config", headers=h),
("GET", "/api/v1/packages"): lambda c, h: c.get("/api/v1/packages", headers=h),
("GET", "/api/v1/packages/{package_id}"): lambda c, h: c.get(f"/api/v1/packages/{FOREIGN}", headers=h),
("GET", "/api/v1/packages/{package_id}/versions/{number}"): lambda c, h: c.get(f"/api/v1/packages/{FOREIGN}/versions/1.0.0", headers=h),
("POST", "/api/v1/validate"): None, # liest nichts aus der Datenbank außer der letzten Versionsnummer
("POST", "/api/v1/trial"): None, # wie pruefen; liefert nur das hochgeladene Paket zurück
("POST", "/api/v1/drafts"): None, # verlangt die Rolle autor; Fremdes führt zu 409 ohne Inhalt
("POST", "/api/v1/packages/{package_id}/versions/{number}/submit"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/versions/1.0.0/submit", headers=h),
("GET", "/api/v1/reviews"): lambda c, h: c.get("/api/v1/reviews", headers=h),
("POST", "/api/v1/packages/{package_id}/versions/{number}/approve"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/versions/1.0.0/approve", headers=h, json={}),
("POST", "/api/v1/packages/{package_id}/versions/{number}/return"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/versions/1.0.0/return", headers=h, json={"comment": "x"}),
("POST", "/api/v1/admin/tenants"): lambda c, h: c.post("/api/v1/admin/tenants", headers=h, json={"slug": "new", "name": "x"}),
("POST", "/api/v1/admin/tenants/{slug}/lock"): lambda c, h: c.post("/api/v1/admin/tenants/shop/lock", headers=h),
("POST", "/api/v1/admin/tenants/{slug}/unlock"): lambda c, h: c.post("/api/v1/admin/tenants/shop/unlock", headers=h),
("GET", "/api/v1/admin/tenants"): lambda c, h: c.get("/api/v1/admin/tenants", headers=h),
("POST", "/api/v1/admin/tenants/{slug}/projects"): lambda c, h: c.post("/api/v1/admin/tenants/shop/projects", headers=h, json={"slug": "xy", "name": "x"}),
("GET", "/api/v1/admin/tenants/{slug}/projects"): lambda c, h: c.get("/api/v1/admin/tenants/shop/projects", headers=h),
("POST", "/api/v1/admin/tenants/{slug}/keys"): lambda c, h: c.post("/api/v1/admin/tenants/shop/keys", headers=h, json={"kind": "project", "name": "x", "project": "api"}),
("GET", "/api/v1/admin/tenants/{slug}/keys"): lambda c, h: c.get("/api/v1/admin/tenants/shop/keys", headers=h),
("DELETE", "/api/v1/admin/tenants/{slug}/keys/{key_id}"): lambda c, h: c.delete("/api/v1/admin/tenants/shop/keys/1", headers=h),
("PUT", "/api/v1/admin/mandatory/{package_id}"): lambda c, h: c.put(f"/api/v1/admin/mandatory/{FOREIGN}", headers=h, json={"tenant": "shop"}),
("GET", "/api/v1/packages/{package_id}/feedback"): lambda c, h: c.get(f"/api/v1/packages/{FOREIGN}/feedback", headers=h),
("POST", "/api/v1/packages/{package_id}/feedback"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/feedback", headers=h, json={"version": "1.0.0", "kind": "wrong", "text": "x"}),
("POST", "/api/v1/packages/{package_id}/feedback/{feedback_id}/resolve"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/feedback/1/resolve", headers=h),
("GET", "/api/v1/delivery/status"): lambda c, h: c.get("/api/v1/delivery/status", headers=h),
("GET", "/api/v1/delivery/packages"): lambda c, h: c.get("/api/v1/delivery/packages", headers=h),
("GET", "/api/v1/delivery/packages/{package_id}"): lambda c, h: c.get(f"/api/v1/delivery/packages/{FOREIGN}", headers=h),
("POST", "/api/v1/delivery/bundle"): lambda c, h: c.post("/api/v1/delivery/bundle", headers=h, json={"packages": [FOREIGN], "tools": TOOLS}),
("POST", "/api/v1/delivery/usage"): None, # schreibt nur in den eigenen Tenant
("GET", "/api/v1/delivery/client.ps1"): None, # statisches Skript, keine Daten
("POST", "/api/v1/delivery/resolve"): lambda c, h: c.post("/api/v1/delivery/resolve", headers=h, json={"packages": [FOREIGN]}),
("GET", "/api/v1/delivery/packages/{package_id}/versions/{number}/source"): lambda c, h: c.get(f"/api/v1/delivery/packages/{FOREIGN}/versions/1.0.0/source", headers=h),
("POST", "/api/v1/delivery/feedback"): lambda c, h: c.post("/api/v1/delivery/feedback", headers=h, json={"package": FOREIGN, "version": "1.0.0", "kind": "wrong", "text": "x"}),
("POST", "/api/v1/packages/{package_id}/versions/{number}/promote"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/versions/1.0.0/promote", headers=h),
("POST", "/api/v1/admin/tenants/{slug}/people"): lambda c, h: c.post("/api/v1/admin/tenants/shop/people", headers=h, json={"name": "x", "mail": "x@y.de"}),
("GET", "/api/v1/admin/tenants/{slug}/people"): lambda c, h: c.get("/api/v1/admin/tenants/shop/people", headers=h),
("POST", "/api/v1/admin/tenants/{slug}/people/{person_id}/key"): lambda c, h: c.post("/api/v1/admin/tenants/shop/people/1/key", headers=h),
("POST", "/api/v1/admin/tenants/{slug}/people/{person_id}/lock"): lambda c, h: c.post("/api/v1/admin/tenants/shop/people/1/lock", headers=h),
("POST", "/api/v1/packages/{package_id}/deprecate"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/deprecate", headers=h, json={}),
("POST", "/api/v1/packages/{package_id}/reactivate"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/reactivate", headers=h),
("POST", "/api/v1/packages/{package_id}/versions/{number}/deprecate"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/versions/1.0.0/deprecate", headers=h),
("POST", "/api/v1/packages/{package_id}/versions/{number}/reactivate"): lambda c, h: c.post(f"/api/v1/packages/{FOREIGN}/versions/1.0.0/reactivate", headers=h),
("GET", "/api/v1/admin/export"): lambda c, h: c.get("/api/v1/admin/export", headers=h),
("GET", "/api/v1/admin/audit"): lambda c, h: c.get("/api/v1/admin/audit", headers=h),
("GET", "/api/v1/admin/settings"): lambda c, h: c.get("/api/v1/admin/settings", headers=h),
("PUT", "/api/v1/admin/settings/{key}"): lambda c, h: c.put("/api/v1/admin/settings/floors", headers=h, json={"claude": "9.9.9", "opencode": "9.9.9"}),
("DELETE", "/api/v1/admin/mandatory/{package_id}"): lambda c, h: c.delete(f"/api/v1/admin/mandatory/{FOREIGN}?tenant=shop", headers=h),
}
def test_all_routes_have_isolation_test(env):
paths = env.client.app.openapi()["paths"]
routes = {(method.upper(), path) for path, methods in paths.items() for method in methods}
assert routes == set(ISOLATION), f"ohne Eintrag: {routes - set(ISOLATION)}, veraltet: {set(ISOLATION) - routes}"
@pytest.mark.parametrize("route", [k for k, v in ISOLATION.items() if v], ids=lambda k: f"{k[0]} {k[1]}")
def test_foreign_tenant_sees_nothing(env, route):
tenant_package(env)
env.key("vertrieb", "vertrieb-alles", project="crm", roles=["author", "reviewer"])
env.key("vertrieb", "vertrieb-admin", kind="tenant")
env.key("vertrieb", "vertrieb-integration", kind="integration")
for key in ("vertrieb-lesen", "vertrieb-alles", "vertrieb-admin", "vertrieb-integration"):
response = ISOLATION[route](env.client, env.h(key))
assert response.status_code in (200, 403, 404, 422), response.text
assert FOREIGN not in response.text or response.status_code == 422 and "gibt es nicht" in response.text
assert "Nur für den Shop" not in response.text
def test_trial_bundle(env):
raw = zip_from_folder(EXAMPLES / "packages/pub-release-notiz/1.0.0")
response = env.client.post("/api/v1/trial?tools=claude:2.1.302,opencode:1.19.3", content=raw,
headers=env.h("shop-lesen") | {"Content-Type": "application/zip"})
assert response.status_code == 200
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
assert sorted(n for n in z.namelist() if n.startswith("files/")) == [
"files/.claude/commands/pub-release-notiz.md", "files/.opencode/commands/pub-release-notiz.md"]
skill = zip_from_folder(EXAMPLES / "packages/pub-munin-nutzen/1.4.0")
response = env.client.post("/api/v1/trial?tools=opencode:1.19.3", content=skill,
headers=env.h("shop-lesen") | {"Content-Type": "application/zip"})
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
assert "files/.opencode/skills/pub-munin-nutzen/SKILL.md" in z.namelist()
# Nichts gespeichert
assert env.client.get("/api/v1/packages/pub-release-notiz", headers=env.h("shop-lesen")).status_code == 404
def test_settings(env):
c = env.client
assert c.get("/api/v1/admin/settings", headers=env.h("shop-admin")).status_code == 403
assert c.put("/api/v1/admin/settings/floors", headers=ADMIN, json={"claude": "2"}).status_code == 422
assert c.put("/api/v1/admin/settings/floors", headers=ADMIN,
json={"claude": "2.1.200", "opencode": "1.15.0"}).status_code == 200
assert c.get("/api/v1/config", headers=env.h("shop-lesen")).json()["floors"]["claude"] == "2.1.200"
models = {"claude": {"fast": "haiku", "standard": "sonnet", "thorough": "claude-opus-6"},
"opencode": {"fast": "a/h", "standard": "a/s", "thorough": "a/o"}}
assert c.put("/api/v1/admin/settings/models", headers=ADMIN, json=models).status_code == 200
env.publish(zip_from_folder(EXAMPLES / "packages/pub-code-review/1.0.0"))
_, files = env.bundle("shop-lesen", packages=["pub-code-review"])
assert b"model: claude-opus-6" in files["files/.claude/agents/pub-code-review.md"]
assert b"model: a/o" in files["files/.opencode/agents/pub-code-review.md"]
def test_delivery_only_with_integrations_key(env):
env.example("pub-hausregeln/1.0.0")
env.key("shop", "shop-integration", kind="integration")
c = env.client
assert c.get("/api/v1/delivery/status", headers=env.h("shop-lesen")).status_code == 403
assert c.get("/api/v1/delivery/status", headers=ADMIN).status_code == 403
status = c.get("/api/v1/delivery/status", headers=env.h("shop-integration")).json()
assert status["key"]["tenant"] == "shop" and status["format"] == 1
packages = c.get("/api/v1/delivery/packages", headers=env.h("shop-integration")).json()
assert [(p["id"], p["channels"]) for p in packages] == [("pub-hausregeln", {"stable": "1.0.0"})]
assert c.get("/api/v1/delivery/packages?changed_since=2999-01-01T00:00:00Z",
headers=env.h("shop-integration")).json() == []
response = c.post("/api/v1/delivery/bundle", headers=env.h("shop-integration"),
json={"packages": ["pub-hausregeln"], "tools": TOOLS})
assert response.status_code == 200 and response.headers["content-type"] == "application/zip"
script = c.get("/api/v1/delivery/client.ps1", headers=env.h("shop-integration")).content
assert script.startswith(b"\xef\xbb\xbf") and b"Windows PowerShell 5.1" in script
def test_delivery_for_platforms(env):
"""Auflösen ohne Übersetzung, Quellformat und Rückmeldung für Plattformen wie den AI Workspace."""
env.example("pub-hausregeln/1.0.0")
env.example("pub-release-notiz/1.0.0")
env.example("pub-munin-nutzen/1.4.0")
env.key("shop", "shop-integration", kind="integration")
c, h = env.client, env.h("shop-integration")
assert c.put("/api/v1/admin/mandatory/pub-hausregeln", headers=ADMIN, json={"tenant": "shop"}).status_code == 204
# Nur mit Integrations-Key
assert c.post("/api/v1/delivery/resolve", headers=env.h("shop-lesen"), json={}).status_code == 403
result = c.post("/api/v1/delivery/resolve", headers=h, json={"packages": ["pub-release-notiz@stable"]}).json()
packages = {p["id"]: p for p in result["packages"]}
assert set(packages) == {"pub-hausregeln", "pub-release-notiz"}
assert packages["pub-hausregeln"]["mandatory"] and packages["pub-hausregeln"]["kind"] == "rules"
notiz = packages["pub-release-notiz"]
assert notiz["version"] == "1.0.0" and notiz["risk"] == "text" and notiz["arguments"] == ["version", "zielgruppe"]
assert notiz["checksum"].startswith("sha256:") and not notiz["mandatory"]
# Ohne Pflichtpakete; Fehler bei unbekannten Paketen
assert [p["id"] for p in c.post("/api/v1/delivery/resolve", headers=h,
json={"packages": ["pub-munin-nutzen"], "mandatory": False}).json()["packages"]] \
== ["pub-munin-nutzen"]
assert c.post("/api/v1/delivery/resolve", headers=h, json={"packages": ["gibt-es-nicht"]}).status_code == 422
response = c.get("/api/v1/delivery/packages/pub-release-notiz/versions/1.0.0/source", headers=h)
assert response.status_code == 200 and response.headers["x-hugin-checksum"] == notiz["checksum"]
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
assert sorted(z.namelist()) == ["COMMAND.md", "hugin.yaml"]
assert b"{{version}}" in z.read("COMMAND.md")
# Gleicher Inhalt, gleiche Bytes
assert c.get("/api/v1/delivery/packages/pub-release-notiz/versions/1.0.0/source", headers=h).content \
== response.content
assert c.get("/api/v1/delivery/packages/pub-release-notiz/versions/9.9.9/source", headers=h).status_code == 404
response = c.post("/api/v1/delivery/feedback", headers=h, json={
"package": "pub-release-notiz", "version": "1.0.0", "kind": "unclear", "text": "Zielgruppe unklar",
"client": "ai-workspace"})
assert response.status_code == 201 and response.json()["status"] == "open"
feedback = c.get("/api/v1/packages/pub-release-notiz/feedback", headers=ADMIN).json()
assert any(f["text"] == "Zielgruppe unklar" and f["tool"] == "ai-workspace" for f in feedback)
assert c.post("/api/v1/delivery/feedback", headers=h, json={
"package": "pub-release-notiz", "version": "1.0.0", "kind": "quatsch", "text": "x"}).status_code == 422
def test_deprecate(env):
env.example("pub-munin-nutzen/1.4.0")
env.example("pub-munin-nutzen/1.5.0")
env.example("pub-hausregeln/1.0.0")
c = env.client
# Leser dürfen nicht, das besitzende Projekt schon
assert c.post("/api/v1/packages/pub-munin-nutzen/versions/1.5.0/deprecate", headers=env.h("shop-lesen")).status_code == 403
env.post("/api/v1/packages/pub-munin-nutzen/versions/1.5.0/deprecate", env.h("pub-autor"))
instructions, _ = env.bundle("shop-lesen", packages=["pub-munin-nutzen"])
assert instructions["packages"][0]["version"] == "1.4.0" # veraltete kommt nicht automatisch
instructions, _ = env.bundle("shop-lesen", packages=["pub-munin-nutzen@1.5.0"])
assert {"kind": "deprecated", "package": "pub-munin-nutzen"} in instructions["hints"] # gepinnt: abrufbar, mit Hinweis
env.post("/api/v1/packages/pub-munin-nutzen/versions/1.5.0/reactivate", env.h("pub-pruefer"))
# Ganzes Paket mit Nachfolger
assert c.post("/api/v1/packages/pub-hausregeln/deprecate", headers=env.h("pub-autor"),
json={"successor": "does-not-exist"}).status_code == 422
env.post("/api/v1/packages/pub-hausregeln/deprecate", env.h("pub-autor"), {"successor": "pub-munin-nutzen"})
instructions, _ = env.bundle("shop-lesen", packages=["pub-hausregeln"])
assert {"kind": "deprecated", "package": "pub-hausregeln", "successor": "pub-munin-nutzen"} in instructions["hints"]
assert c.get("/api/v1/packages/pub-hausregeln", headers=env.h("shop-lesen")).json()["successor"] == "pub-munin-nutzen"
env.post("/api/v1/packages/pub-hausregeln/reactivate", env.h("pub-autor"))
instructions, _ = env.bundle("shop-lesen", packages=["pub-hausregeln"])
assert not [h for h in instructions["hints"] if h["kind"] == "deprecated"]
def test_lock_tenant(env):
c = env.client
assert c.post("/api/v1/admin/tenants/shop/lock", headers=env.h("shop-admin")).status_code == 403
assert c.get("/api/v1/packages", headers=env.h("shop-lesen")).status_code == 200
env.post("/api/v1/admin/tenants/shop/lock", ADMIN)
for key in ("shop-lesen", "shop-admin", "shop-autor"):
assert c.get("/api/v1/packages", headers=env.h(key)).status_code == 401
assert c.get("/api/v1/packages", headers=env.h("vertrieb-lesen")).status_code == 200
assert c.get("/api/v1/admin/tenants", headers=ADMIN).json()[1] == {"slug": "shop", "name": "Shop", "locked": True}
env.post("/api/v1/admin/tenants/shop/unlock", ADMIN)
assert c.get("/api/v1/packages", headers=env.h("shop-lesen")).status_code == 200
def test_audit(env):
env.example("pub-hausregeln/1.0.0")
c = env.client
assert c.get("/api/v1/admin/audit", headers=env.h("shop-lesen")).status_code == 403
everything = c.get("/api/v1/admin/audit?action=version.", headers=ADMIN).json()["entries"]
assert [e["action"] for e in everything] == ["version.approved", "version.submitted", "version.uploaded"]
# Ereignisse gehören zum Tenant dessen, der gehandelt hat
assert everything[0]["actor"] == "pub-pruefer (Projekt-Key)" and everything[0]["tenant"] == "plattform"
shop = c.get("/api/v1/admin/audit", headers=env.h("shop-admin")).json()["entries"]
assert shop and all(e["tenant"] == "shop" for e in shop) # nur der eigene Tenant
assert not [e for e in shop if e["action"].startswith("version.")]
def test_validate_after_upload(env):
"""Ein Entwurf derselben Version zählt beim Prüfen nicht (Hochladen ersetzt ihn); eine eingereichte schon."""
raw = zip_from_folder(EXAMPLES / "packages/pub-release-notiz/1.0.0")
zip_header = {"Content-Type": "application/zip"}
assert env.client.post("/api/v1/drafts", content=raw, headers=env.h("pub-autor") | zip_header).status_code == 201
for path in ("/api/v1/validate", "/api/v1/trial?tools=opencode:1.19.3"):
response = env.client.post(path, content=raw, headers=env.h("pub-autor") | zip_header)
assert response.status_code == 200 and "H004" not in response.text, (path, response.text)
env.client.post("/api/v1/packages/pub-release-notiz/versions/1.0.0/submit", headers=env.h("pub-autor"))
response = env.client.post("/api/v1/validate", content=raw, headers=env.h("pub-autor") | zip_header).json()
assert [b["code"] for b in response["findings"] if b["severity"] == "error"] == ["H004", "H005"]