527 lines
32 KiB
Python
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"]
|