Files
Onyscribe-Admin/tests/test_flow.py
RubenRWU 7d8c226da2 Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:30:26 +02:00

530 lines
29 KiB
Python

"""End-to-End-Test des Kernablaufs gegen eine temporäre SQLite-DB (echter Core-Render in der Sandbox)."""
import json
import os
import tarfile
import io
import tempfile
import time
from pathlib import Path
_tmp = tempfile.mkdtemp()
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp}/t.db"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.db import session # noqa: E402
from app.main import app # noqa: E402
from app.services.importer import import_all # noqa: E402
from app.settings import LEGACY_PLATFORM # noqa: E402
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
with session() as db:
import_all(db, Path(LEGACY_PLATFORM))
yield c
def test_import_all_tenants(client):
for tid in ["demo", "onyva", "revier", "rotary", "rotary-wil", "sportklinik", "sprinz"]:
s = client.get(f"/api/tenants/{tid}/state").json()
assert s["current_version"] == 1 and s["config"]["id"] == tid and "deploy" not in s["config"]
def test_previews_render_with_real_core(client):
for tid in ("rotary-wil", "sprinz"):
assert client.get(f"/preview/{tid}/pdf").content[:5] == b"%PDF-"
html = client.get(f"/preview/{tid}/web").text
assert "@@" not in html and "<html" in html.lower()
def test_draft_publish_cycle(client):
assert client.put("/api/tenants/demo/config", json={**client.get("/api/tenants/demo/state").json()["config"],
"colors": {**client.get("/api/tenants/demo/state").json()["config"]["colors"], "accent": "#112233"}}).json()["has_draft"]
d = client.get("/api/tenants/demo/diff").json()
assert d["changed"] and d["items"][0]["path"] == "tenant.json"
assert "#112233" in client.get("/preview/demo/web").text
assert "#112233" not in client.get("/preview/demo/web?source=current").text # veröffentlicht unverändert
assert client.post("/api/tenants/demo/validate").json()["ok"]
v = client.post("/api/tenants/demo/publish", json={"message": "Akzent"}).json()["version"]
assert v == 2
assert not client.get("/api/tenants/demo/state").json()["has_draft"]
def test_invalid_python_is_rejected(client):
r = client.put("/api/tenants/demo/files/overrides.py", json={"content": "def x(:\n"})
assert r.status_code == 400 and "Syntax" in r.json()["detail"]
def test_broken_template_fails_validation(client):
client.put("/api/tenants/sprinz/files/protocol.html", json={"content": "{% for %}"})
v = client.post("/api/tenants/sprinz/validate").json()
assert not v["ok"]
client.post("/api/tenants/sprinz/draft/discard")
def test_restore_and_discard(client):
client.post("/api/tenants/demo/versions/1/restore")
assert client.get("/api/tenants/demo/state").json()["has_draft"]
client.post("/api/tenants/demo/draft/discard")
assert not client.get("/api/tenants/demo/state").json()["has_draft"]
def test_new_tenant_clone_and_bundle(client):
r = client.post("/api/tenants", json={"id": "musterklinik", "name": "Musterklinik", "clone_from": "sportklinik"})
assert r.status_code == 200
assert client.post("/api/tenants", json={"id": "musterklinik", "name": "x"}).status_code == 400
# Bundle nur mit Token
assert client.get("/api/tenants/musterklinik/bundle").status_code == 401
from app.models import Secret
from app.services import crypto
from sqlalchemy import select
with session() as db:
tok = crypto.decrypt(db.scalar(select(Secret).where(Secret.tenant_id == "musterklinik", Secret.key == "ADMIN_BUNDLE_TOKEN")).value_enc)
b = client.get("/api/tenants/musterklinik/bundle", headers={"Authorization": f"Bearer {tok}"})
assert b.status_code == 200
names = tarfile.open(fileobj=io.BytesIO(b.content), mode="r:gz").getnames()
assert "tenant.json" in names and "MANIFEST.json" in names and "logo.png" in names
def _wait(client, dep, tries=60):
for _ in range(tries):
d = client.get(f"/api/deployments/{dep}").json()
if d["status"] not in ("running", "queued"):
return d
time.sleep(0.4)
return d
def test_dry_run_deploy_and_cutover(client):
dep = client.post("/api/tenants/sprinz/deploy", json={"dry_run": True}).json()["id"]
d = _wait(client, dep)
assert d["status"] == "success" and "Dry-Run beendet" in d["log"] and "[DRY-RUN] PATCH" in d["log"]
assert "••••" in d["log"] or "SESSION_SECRET" not in d["log"] # Secrets maskiert
checks = {c["id"]: c["ok"] for c in client.get("/api/tenants/sprinz/cutover").json()["checks"]}
assert checks["dryrun"] and checks["validate"] and not checks["secrets"]
def test_real_deploy_is_locked(client):
r = client.post("/api/tenants/sprinz/deploy", json={"dry_run": False})
assert r.status_code == 403 and "DEPLOY_LIVE" in r.json()["detail"]
assert client.post("/api/deploy-all", json={"dry_run": False}).status_code == 403
assert client.post("/api/tenants/sprinz/offboard", json={"dry_run": False}).status_code == 403
def test_deploy_all_dry_run(client):
r = client.post("/api/deploy-all", json={"dry_run": True}).json()
for _ in range(120):
rows = client.get(f"/api/deployments?batch={r['batch']}").json()
if all(x["status"] not in ("running", "queued") for x in rows):
break
time.sleep(0.5)
assert len(rows) >= 7 and all(x["status"] == "success" for x in rows)
def test_mittwald_client_live_sequence_with_guard():
"""Gegen einen Fake-HTTP: richtige Reihenfolge, PATCH statt PUT, Guard gegen verschwundene Services."""
from app.services.mittwald import DeployError, Mittwald
calls, lines = [], []
stack = {"services": {"legacy": {"id": "svc-0"}, "app-x": {"id": "svc-1"}}}
def http(method, url, headers, body):
calls.append((method, url.split("/v2")[1], body))
if method == "GET" and url.endswith("/stacks/s-1/"):
return 200, stack, ""
if method == "GET" and "ingresses" in url:
return 200, [], ""
if method == "GET" and "/projects/p-1/" in url:
return 200, {"id": "uuid-1"}, ""
return 200, {}, ""
mw = Mittwald("tok", "https://api.test/v2", True, lines.append, http)
mw.deploy_service(stack_id="s-1", service="app-x", volume="vol-x", definition={"image": "i"}, container_port="8276",
project_id="p-1", hostname="x.example.de")
verbs = [(m, p) for m, p, _ in calls]
assert ("PUT", "/stacks/s-1/") not in verbs
assert verbs.index(("PATCH", "/stacks/s-1/")) < verbs.index(("POST", "/stacks/s-1/services/svc-1/actions/recreate/"))
assert any(p == "/ingresses" and m == "POST" for m, p, _ in calls)
assert any("Guard" in l for l in lines)
# Guard schlägt an, wenn ein fremder Service verschwindet
state = {"n": 0}
def http2(method, url, headers, body):
if method == "GET" and url.endswith("/stacks/s-1/"):
state["n"] += 1
return 200, ({"services": {"legacy": {}}} if state["n"] == 1 else {"services": {"app-x": {"id": "svc-1"}}}), ""
return 200, {}, ""
try:
Mittwald("tok", "https://api.test/v2", True, lambda m: None, http2).deploy_service(
stack_id="s-1", service="app-x", volume="vol-x", definition={}, container_port="8276", project_id="p-1", hostname=None)
raise AssertionError("Guard hat nicht angeschlagen")
except DeployError as exc:
assert "verschwunden" in str(exc)
def test_dry_run_client_never_writes():
from app.services.mittwald import Mittwald
calls = []
mw = Mittwald("tok", "https://api.test/v2", False, lambda m: None, lambda *a: calls.append(a) or (200, {}, ""))
mw.deploy_service(stack_id="s", service="app-x", volume="v", definition={"environment": {"X_SECRET": "geheim"}}, container_port="1", project_id="p", hostname="h.de")
assert all(c[0] == "GET" for c in calls)
def _sse(resp):
return [json.loads(l[6:]) for l in resp.text.split("\n\n") if l.startswith("data: ")]
def test_agent_mock_stream_and_apply(client):
r = client.post("/api/tenants/revier/chat", json={"message": "Setze die Akzentfarbe auf #0B6E4F"})
ev = _sse(r)
assert [e["type"] for e in ev][0] == "mode" and ev[0]["backend"] == "mock"
assert any(e["type"] == "step" and e["tool"] == "validate" for e in ev)
msg = ev[-1]["message"]
assert msg["proposal_status"] == "open"
assert client.get("/api/tenants/revier/state").json()["config"]["colors"]["accent"] != "#0B6E4F" # Entwurf unberührt
d = client.get(f"/api/tenants/revier/chat/{msg['id']}/diff").json()
assert d["changed"] and "#0B6E4F" in json.dumps(d)
client.post(f"/api/tenants/revier/chat/{msg['id']}/apply")
assert client.get("/api/tenants/revier/state").json()["config"]["colors"]["accent"] == "#0B6E4F"
client.post("/api/tenants/revier/draft/discard")
def test_agent_tools_with_scripted_backend(client):
"""Echter Loop + echte Tools + Core-Rendering, nur das LLM ist geskriptet."""
from app.agent import loop
from app.agent.backends import Turn
class Scripted:
name, model = "scripted", "x"
def __init__(self):
self.i = 0
def step(self, system, messages, tools):
self.i += 1
if self.i == 1:
return Turn([{"type": "tool_use", "id": "1", "name": "read_file", "input": {"path": "namensliste.txt"}},
{"type": "tool_use", "id": "2", "name": "write_file", "input": {"path": "namensliste.txt", "content": "x"}},
{"type": "tool_use", "id": "3", "name": "write_file", "input": {"path": "protocol.css", "content": "body{color:red}"}},
{"type": "tool_use", "id": "4", "name": "write_file", "input": {"path": "overrides.py", "content": "def x(:"}},
{"type": "tool_use", "id": "5", "name": "render_pdf", "input": {}}])
return Turn([{"type": "text", "text": "Fertig."}])
with session() as db:
from app.models import Tenant
t = db.get(Tenant, "rotary-wil")
ev = list(loop.run(db, t, "teste", backend=Scripted()))
steps = [e for e in ev if e["type"] == "step"]
assert "gesperrt" in steps[0]["result"] # Namensliste für den Agenten unlesbar
assert steps[1]["error"] and "personenbezogen" in steps[1]["result"] # … und unveränderbar
assert not steps[2]["error"]
assert steps[3]["error"] # Syntaxfehler in overrides.py abgefangen
assert "Seite" in steps[4]["result"]
prop = [e for e in ev if e["type"] == "proposal"][0]["proposal"]
assert [i["path"] for i in prop["items"]] == ["protocol.css"]
with session() as db: # Entwurf bleibt unberührt, bis freigegeben
assert not db.get(Tenant, "rotary-wil").draft
def test_prompt_test_endpoint(client, monkeypatch):
from app.services import llm
monkeypatch.setattr(llm, "summarize", lambda db, system, user, model=None, tenant_id=None: {"text": "===TERMINART===\nKick-off\n===/TERMINART===", "model": "m", "input_tokens": 10, "output_tokens": 5, "truncated": False})
r = client.post("/api/tenants/rotary-wil/prompt-test", json={}).json()
assert r["text"].startswith("===TERMINART") and r["prompt_chars"] > 1000
# Namensregister landet über den build_prompt-Hook im finalen Prompt
from app.services import preview
with session() as db:
from app.models import Tenant
fin = preview.run(db, db.get(Tenant, "rotary-wil"), "draft", "prompt", args={"title": "x"})["prompt"]
assert "Namensregister des Clubs" in fin
def test_prompt_test_without_key_is_clean_error(client, monkeypatch):
monkeypatch.delenv("CORTECS_API_KEY", raising=False)
r = client.post("/api/tenants/sprinz/prompt-test", json={})
assert r.status_code == 400 and "CORTECS_API_KEY" in r.json()["detail"]
def test_meta_archive_delete(client):
client.post("/api/tenants", json={"id": "wegdamit", "name": "Weg damit"})
assert client.put("/api/tenants/wegdamit/meta", json={"cpu": 99}).status_code == 400
assert client.put("/api/tenants/wegdamit/meta", json={"cpu": 1, "memory": "1G", "notes": "n"}).status_code == 200
assert client.delete("/api/tenants/wegdamit?confirm=wegdamit").status_code == 400 # erst archivieren
client.put("/api/tenants/wegdamit/meta", json={"status": "archived"})
assert client.delete("/api/tenants/wegdamit?confirm=falsch").status_code == 400
assert client.delete("/api/tenants/wegdamit?confirm=wegdamit").status_code == 200
assert client.get("/api/tenants/wegdamit/state").status_code == 404
def test_oidc_login_flow_and_access_rules(monkeypatch):
from app import auth
monkeypatch.setattr(auth, "AUTH_ENABLED", True)
monkeypatch.setenv("OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("OIDC_CLIENT_SECRET", "sec")
import base64 as b64
idt = lambda claims: "h." + b64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=") + ".s" # noqa: E731
with TestClient(app, follow_redirects=False) as c:
assert c.get("/").status_code == 302 and c.get("/").headers["location"] == "/auth/login"
assert c.get("/api/tenants/demo/state").status_code == 401
assert c.get("/healthz").status_code == 200
r = c.get("/auth/login")
assert r.status_code == 302 and "code_challenge_method=S256" in r.headers["location"]
state = dict(p.split("=") for p in r.headers["location"].split("?")[1].split("&"))["state"]
# Default-Deny: keine Zugriffsregel konfiguriert
monkeypatch.setattr(auth, "exchange_code", lambda code, cv: {"id_token": idt({"sub": "1", "email": "chef@onyva.de", "app_roles": ["admin"]})})
assert c.get(f"/auth/callback?code=x&state={state}").status_code == 403
# Mit Allowlist
monkeypatch.setenv("ADMIN_ALLOWED_EMAILS", "chef@onyva.de")
state2 = dict(p.split("=") for p in c.get("/auth/login").headers["location"].split("?")[1].split("&"))["state"]
ok = c.get(f"/auth/callback?code=x&state={state2}")
assert ok.status_code == 303
assert c.get("/api/tenants/demo/state").status_code == 200
assert c.get("/auth/me").json()["user"]["email"] == "chef@onyva.de"
# falscher State → neu starten, keine Session
with TestClient(app, follow_redirects=False) as c2:
c2.get("/auth/login")
assert c2.get("/auth/callback?code=x&state=falsch").headers["location"] == "/auth/login"
# Bundle bleibt tokenbasiert erreichbar (nicht über Cookie)
assert c2.get("/api/tenants/demo/bundle").status_code == 401
monkeypatch.setattr(auth, "AUTH_ENABLED", False)
def test_messages_backend_talks_to_cortecs_endpoint():
"""Anthropic-SDK gegen Cortecs: richtige URL (/v1/messages), Bearer-Auth, Tool-Schema, Antwort-Parsing."""
import anthropic
try:
import httpx2 as httpx # neuere Anthropic-SDKs bringen einen eigenen HTTP-Client mit
except ImportError:
import httpx
from app.agent.backends import MessagesBackend
from app.agent.tools import TOOLS
seen = {}
def handler(req: httpx.Request):
seen["url"], seen["auth"], seen["body"] = str(req.url), req.headers.get("authorization"), json.loads(req.content)
return httpx.Response(200, json={"id": "m", "type": "message", "role": "assistant", "model": "x", "stop_reason": "tool_use",
"content": [{"type": "text", "text": "ok"}, {"type": "tool_use", "id": "t1", "name": "validate", "input": {}}],
"usage": {"input_tokens": 11, "output_tokens": 7}})
b = MessagesBackend("KEY", "https://api.cortecs.ai/v1", "claude-4-6-sonnet")
b.client = anthropic.Anthropic(api_key="KEY", auth_token="KEY", base_url="https://api.cortecs.ai", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
turn = b.step("sys", [{"role": "user", "content": "hi"}], TOOLS)
assert seen["url"] == "https://api.cortecs.ai/v1/messages" and seen["auth"] == "Bearer KEY"
assert seen["body"]["model"] == "claude-4-6-sonnet" and {t["name"] for t in seen["body"]["tools"]} >= {"edit_file", "validate"}
assert turn.blocks[1] == {"type": "tool_use", "id": "t1", "name": "validate", "input": {}} and turn.usage == {"in": 11, "out": 7}
def test_chat_backend_message_conversion():
from app.agent.backends import ChatBackend
conv = ChatBackend._convert("sys", [
{"role": "user", "content": "los"},
{"role": "assistant", "content": [{"type": "text", "text": "ich lese"}, {"type": "tool_use", "id": "a", "name": "read_file", "input": {"path": "x"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "inhalt"}]}])
assert [m["role"] for m in conv] == ["system", "user", "assistant", "tool"]
assert conv[2]["tool_calls"][0]["function"]["name"] == "read_file" and conv[3]["tool_call_id"] == "a"
def test_plan_masks_secrets_but_not_tokens_limit(client):
p = client.get("/api/tenants/sprinz/plan").json()["patch"]["environment"]
assert p["LLM_MAX_TOKENS"] == "16000" and p["TENANT"] == "sprinz" and "TENANT_SOURCE" not in p
def test_final_prompt_and_files_privacy(client):
r = client.get("/api/tenants/rotary-wil/final-prompt").json()
assert "Namensregister des Clubs" in r["prompt"]
st = client.get("/api/tenants/rotary-wil/state").json()
assert st["files"]["namensliste.txt"]["personal"] is True
# ── OIDC-Abgleich gegen die Developer-API (Fake-Login-Server) ───────────────
from tests.fakelogin import FakeLogin # noqa: E402
@pytest.fixture()
def login(monkeypatch):
from app.services import onyva_login
fake = FakeLogin()
monkeypatch.setenv("ONYVA_LOGIN_API_KEY", "onyva_testkey")
monkeypatch.setenv("OIDC_SYNC_LIVE", "true")
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
monkeypatch.setattr(onyva_login, "client_from_env",
lambda db, http=None: onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", fake, sleep=lambda s: None))
return fake
def _secret_value(tenant, key):
from app.models import Secret
from app.services import crypto
from sqlalchemy import select
with session() as db:
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant, Secret.key == key))
return crypto.decrypt(r.value_enc) if r and r.value_enc else None
def test_oidc_creates_app_stores_secret_and_is_idempotent(client, login):
client.post("/api/tenants", json={"id": "klinik1", "name": "Klinik Eins"})
client.put("/api/tenants/klinik1/oidc", json={"members": [{"email": "anna@x.de", "role": "editor"}, {"email": "ghost@x.de", "role": "viewer"}]})
plan = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": False}).json()
assert plan["ok"] and [a["kind"] for a in plan["actions"]][0] == "create" and not login.writes # Dry-Run schreibt nichts
rep = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": True}).json()
assert rep["created"] and rep["client_id"] == "app_0001"
app = login.apps["app_0001"]
assert app["redirect_uris"] == ["https://klinik1.onyscribe.de/auth/callback"] and app["auth_mode"] == "required"
assert _secret_value("klinik1", "OIDC_CLIENT_ID") == "app_0001" and _secret_value("klinik1", "OIDC_CLIENT_SECRET") == "secret-1"
assert login.members["app_0001"] == {"anna@x.de": "editor"} and any("ghost@x.de" in w for w in rep["warnings"])
login.calls.clear()
again = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": True}).json()
assert [a["kind"] for a in again["actions"]] == ["member_put"] and not any(c[0] in ("POST",) for c in login.writes) # nur der (weiter fehlende) Geist-Nutzer
assert client.get("/api/tenants/klinik1/oidc").json()["last_sync"]["ok"]
def test_oidc_adopt_only_adds_missing_redirect(client, login):
from app.services import tenants as tsvc
login.apps["app_legacy"] = {"client_id": "app_legacy", "name": "Alt", "redirect_uris": ["https://wil-rotary.onyscribe.de/auth/callback", "https://alt.example/cb"],
"auth_mode": "optional", "totp_mode": "required", "allow_registration": True, "backchannel_logout_uri": None}
with session() as db:
tsvc.set_secret(db, "rotary-wil", "OIDC_CLIENT_ID", "app_legacy", "t")
tsvc.set_secret(db, "rotary-wil", "OIDC_CLIENT_SECRET", "legacy-secret", "t")
client.put("/api/tenants/rotary-wil/meta", json={})
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "wil-rotary"})
rep = client.post("/api/tenants/rotary-wil/oidc/sync", json={"apply": True}).json()
assert rep["ok"] and not rep["created"] and not rep["warnings"]
assert login.apps["app_legacy"]["redirect_uris"][-1:] == ["https://alt.example/cb"] and login.apps["app_legacy"]["auth_mode"] == "optional" # nichts Fremdes verändert
assert _secret_value("rotary-wil", "OIDC_CLIENT_SECRET") == "legacy-secret"
# Subdomain ändern → adopt ergänzt nur die neue URI
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "neu"})
client.post("/api/tenants/rotary-wil/oidc/sync", json={"apply": True})
assert "https://neu.onyscribe.de/auth/callback" in login.apps["app_legacy"]["redirect_uris"] and len(login.apps["app_legacy"]["redirect_uris"]) == 3
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "wil-rotary"})
def test_oidc_managed_corrects_drift_and_exact_members(client, login):
client.post("/api/tenants", json={"id": "klinik2", "name": "Klinik Zwei"})
client.put("/api/tenants/klinik2/oidc", json={"members": [{"email": "anna@x.de", "role": "viewer"}], "members_mode": "exact", "totp_mode": "optional"})
client.post("/api/tenants/klinik2/oidc/sync", json={"apply": True})
cid = _secret_value("klinik2", "OIDC_CLIENT_ID")
assert login.apps[cid]["totp_mode"] == "optional"
login.apps[cid].update(auth_mode="disabled", redirect_uris=["https://evil.example/cb"]) # Drift
login.members[cid]["bob@x.de"] = "admin" # fremde Freigabe
rep = client.post("/api/tenants/klinik2/oidc/sync", json={"apply": True}).json()
assert login.apps[cid]["auth_mode"] == "required" and login.apps[cid]["redirect_uris"] == ["https://klinik2.onyscribe.de/auth/callback"]
assert "bob@x.de" not in login.members[cid] and {a["kind"] for a in rep["actions"]} >= {"patch", "member_delete"}
def test_oidc_stale_client_id_recreates_and_rotate(client, login):
from app.services import tenants as tsvc
client.post("/api/tenants", json={"id": "klinik3", "name": "Klinik Drei"})
with session() as db:
tsvc.set_secret(db, "klinik3", "OIDC_CLIENT_ID", "app_gone", "t")
rep = client.post("/api/tenants/klinik3/oidc/sync", json={"apply": True}).json()
assert rep["created"] and any("existiert im Login nicht mehr" in w for w in rep["warnings"])
assert _secret_value("klinik3", "OIDC_CLIENT_ID") != "app_gone"
old = _secret_value("klinik3", "OIDC_CLIENT_SECRET")
client.post("/api/tenants/klinik3/oidc/sync", json={"apply": True, "rotate": True})
assert _secret_value("klinik3", "OIDC_CLIENT_SECRET") not in (None, old)
def test_oidc_write_gate_and_missing_key(client, monkeypatch):
monkeypatch.delenv("OIDC_SYNC_LIVE", raising=False)
monkeypatch.delenv("DEPLOY_LIVE", raising=False)
assert client.post("/api/tenants/sprinz/oidc/sync", json={"apply": True}).status_code == 403
monkeypatch.delenv("ONYVA_LOGIN_API_KEY", raising=False)
r = client.post("/api/tenants/sprinz/oidc/sync", json={"apply": False}).json()
assert r["ok"] and "ONYVA_LOGIN_API_KEY" in r["skipped"]
assert client.delete("/api/tenants/sprinz/oidc/app?confirm=sprinz").status_code == 403
assert client.put("/api/tenants/sprinz/oidc", json={"mode": "kaputt"}).status_code == 400
assert client.put("/api/tenants/sprinz/oidc", json={"members": [{"email": "keine-mail"}]}).status_code == 400
def test_oidc_rate_limit_retry_and_bad_key():
from app.services import onyva_login
fake = FakeLogin()
waits = []
c = onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", fake, sleep=waits.append)
fake.rate_limit_once = True
assert c.list_apps() == [] and waits == [2]
try:
onyva_login.OnyvaLogin("https://login.test/api/v1", "falsch", fake, sleep=lambda s: None).list_apps()
raise AssertionError
except onyva_login.OnyvaError as exc:
assert "401" in str(exc)
def test_oidc_import_members_and_delete_app(client, login):
client.post("/api/tenants", json={"id": "klinik4", "name": "Klinik Vier"})
client.post("/api/tenants/klinik4/oidc/sync", json={"apply": True})
cid = _secret_value("klinik4", "OIDC_CLIENT_ID")
login.members[cid]["bob@x.de"] = "admin"
got = client.post("/api/tenants/klinik4/oidc/import-members").json()
assert got["members"] == [{"email": "bob@x.de", "role": "admin"}]
assert client.delete("/api/tenants/klinik4/oidc/app?confirm=nein").status_code == 400
assert client.delete("/api/tenants/klinik4/oidc/app?confirm=klinik4").status_code == 200
assert cid not in login.apps and _secret_value("klinik4", "OIDC_CLIENT_ID") is None
def test_deploy_runs_oidc_sync_before_service_patch(client, login, monkeypatch):
"""Live-Deploy (alles gefakt): OIDC-Client wird VOR dem Service-PATCH angelegt und landet in dessen Env; Fehler bricht ab."""
from app.services import deploy
client.post("/api/tenants", json={"id": "klinik5", "name": "Klinik Fünf"})
client.put("/api/tenants/klinik5/source", json={"source": "git"})
client.put("/api/settings", json={"mittwald_project_id": "p-1", "mittwald_stack_id": "s-1", "registry": "reg.test/o"})
monkeypatch.setenv("DEPLOY_LIVE", "true")
monkeypatch.setenv("MITTWALD_API_TOKEN", "tok")
# Pflicht-Secrets laut Core-Schema: erst fehlen sie → Deploy wird VOR dem Service-Eingriff blockiert
blocked = deploy.start("klinik5", "deploy", "test", http=lambda *a: (200, {}, ""), oidc_client=None, wait=True, health=lambda *a: None)
d0 = client.get(f"/api/deployments/{blocked}").json()
assert d0["status"] == "failed" and ("COST_INGEST_KEY" in d0["log"] or "OIDC-Abgleich" in d0["log"] or "ONYVA_LOGIN_API_KEY" in d0["log"])
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"})
client.put("/api/tenants/klinik5/secrets/COST_INGEST_KEY", json={"value": "ik"})
order = []
stack = {"services": {"legacy": {"id": "svc-0"}, "app-klinik5": {"id": "svc-5"}}}
def mw_http(method, url, headers, body):
order.append(("mittwald", method, url.split("/v2")[1], body))
if method == "GET" and url.endswith("/stacks/s-1/"):
return 200, stack, ""
if method == "GET" and "ingresses" in url:
return 200, [], ""
if method == "GET" and "/projects/p-1/" in url:
return 200, {"id": "uuid-1"}, ""
return 200, {}, ""
class Spy(FakeLogin):
def __call__(self, method, url, headers, body):
order.append(("login", method, url.split("/api/v1")[1], body))
return super().__call__(method, url, headers, body)
spy = Spy()
from app.services import onyva_login
oc = onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", spy, sleep=lambda s: None)
dep = deploy.start("klinik5", "deploy", "test", http=mw_http, oidc_client=oc, wait=True, health=lambda *a: None)
d = client.get(f"/api/deployments/{dep}").json()
assert d["status"] == "success", d["log"]
first_login_write = next(i for i, o in enumerate(order) if o[0] == "login" and o[1] == "POST")
first_patch = next(i for i, o in enumerate(order) if o[0] == "mittwald" and o[1] == "PATCH")
assert first_login_write < first_patch
env = next(o[3]["services"]["app-klinik5"]["environment"] for o in order if o[0] == "mittwald" and o[1] == "PATCH" and o[3].get("services"))
assert env["OIDC_CLIENT_ID"] == "app_0001" and env["OIDC_CLIENT_SECRET"] == "secret-1" and env["OIDC_REDIRECT_URI"].startswith("https://klinik5.")
# Login-API kaputt → Deploy bricht VOR dem Service-PATCH ab
order.clear()
client.put("/api/tenants/klinik5/oidc", json={"auth_mode": "optional"})
bad = onyva_login.OnyvaLogin("https://login.test/api/v1", "falscher-key", spy, sleep=lambda s: None)
dep2 = deploy.start("klinik5", "deploy", "test", http=mw_http, oidc_client=bad, wait=True, health=lambda *a: None)
d2 = client.get(f"/api/deployments/{dep2}").json()
assert d2["status"] == "failed" and "OIDC-Abgleich fehlgeschlagen" in d2["log"]
assert not any(o[0] == "mittwald" and o[1] == "PATCH" for o in order)
def test_dry_run_deploy_shows_oidc_plan_without_writes(client, login, monkeypatch):
monkeypatch.delenv("DEPLOY_LIVE", raising=False)
monkeypatch.delenv("OIDC_SYNC_LIVE", raising=False)
from app.services import deploy
client.post("/api/tenants", json={"id": "klinik6", "name": "Klinik Sechs"})
dep = deploy.start("klinik6", "dry-run", "test", wait=True)
d = client.get(f"/api/deployments/{dep}").json()
assert d["status"] == "success" and "würde App" in d["log"] and not login.writes