"""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"]