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