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>
530 lines
29 KiB
Python
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
|