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>
This commit is contained in:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

0
tests/__init__.py Normal file
View File

22
tests/conftest.py Normal file
View File

@@ -0,0 +1,22 @@
"""Test-Isolation: Tests dürfen NIE echte Keys/Dienste aus der lokalen .env oder der Shell erreichen."""
import os
import tempfile
os.environ["ONYSCRIBE_SKIP_DOTENV"] = "1" # .env nicht einlesen
for var in ("CORTECS_API_KEY", "ONYVA_LOGIN_API_KEY", "MITTWALD_API_TOKEN", "MITTWALD_API_BASE", "DEPLOY_LIVE", "OIDC_SYNC_LIVE",
"AUTH_ENABLED", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "OIDC_LOGOUT_REDIRECT_URI", "OIDC_ISSUER",
"ADMIN_ALLOWED_EMAILS", "DEPLOY_ALLOWED_TENANTS", "CORE_RELEASE_TOKEN", "ADMIN_REQUIRED_ROLE", "SECRET_KEY", "APP_COOKIE_SECURE"):
os.environ.pop(var, None)
os.environ["DATABASE_URL"] = f"sqlite:///{tempfile.mkdtemp()}/test.db"
import pytest # noqa: E402
@pytest.fixture(scope="module", autouse=True)
def fresh_db():
"""Jedes Testmodul startet mit leerer Datenbank (die Module teilen sich sonst Zustand über die Temp-DB)."""
from app.db import Base, engine, init_db
from app import models # noqa: F401
Base.metadata.drop_all(engine)
init_db()
yield

59
tests/fakelogin.py Normal file
View File

@@ -0,0 +1,59 @@
"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md) für Tests und lokale Entwicklung."""
class FakeLogin:
"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md)."""
def __init__(self, users=("anna@x.de", "bob@x.de")):
self.apps, self.members, self.users, self.calls, self.n, self.rate_limit_once = {}, {}, set(users), [], 0, False
def __call__(self, method, url, headers, body):
path = url.split("/api/v1")[1]
self.calls.append((method, path, body))
if headers.get("Authorization") != "Bearer onyva_testkey":
return 401, {"detail": "invalid"}, {}
if self.rate_limit_once:
self.rate_limit_once = False
return 429, {"detail": "slow down"}, {"Retry-After": "2"}
parts = path.strip("/").split("/")
if parts == ["apps"]:
if method == "GET":
return 200, {"apps": list(self.apps.values())}, {}
self.n += 1
cid = f"app_{self.n:04d}"
app = {"client_id": cid, "name": body["name"], "slug": body["name"].lower(), "redirect_uris": body["redirect_uris"],
"auth_mode": body.get("auth_mode", "required"), "totp_mode": body.get("totp_mode", "disabled"),
"allow_registration": body.get("allow_registration", False), "backchannel_logout_uri": body.get("backchannel_logout_uri")}
self.apps[cid], self.members[cid] = app, {}
return 201, {**app, "client_secret": f"secret-{self.n}"}, {}
cid = parts[1]
if cid not in self.apps:
return 404, {"detail": "not found"}, {}
if len(parts) == 2:
if method == "GET":
return 200, self.apps[cid], {}
if method == "PATCH":
self.apps[cid].update(body)
return 200, self.apps[cid], {}
if method == "DELETE":
del self.apps[cid]
return 204, None, {}
if parts[2] == "rotate-secret":
self.n += 1
return 200, {"client_id": cid, "client_secret": f"rotated-{self.n}"}, {}
if parts[2] == "memberships":
if method == "GET":
return 200, {"memberships": [{"email": e, "display_name": e, "role": r} for e, r in self.members[cid].items()]}, {}
if method == "PUT":
if body["user_email"] not in self.users:
return 404, {"detail": "user"}, {}
self.members[cid][body["user_email"]] = body.get("role", "viewer")
return 200, body, {}
if method == "DELETE":
self.members[cid].pop(parts[3], None)
return 204, None, {}
return 404, {}, {}
@property
def writes(self):
return [c for c in self.calls if c[0] != "GET"]

443
tests/test_core_contract.py Normal file
View File

@@ -0,0 +1,443 @@
"""Zusammenspiel Admin ↔ Core: Vertrag/Schema, schema-getriebene Env, Release-Eingang, Kompatibilitätsprüfung, Rollout, Rollback, Zustand."""
import json
import os
import re
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.db import session
from app.main import app
from app.models import CoreRelease, Tenant
from app.services import contract, deploy, releases, status as status_svc
from app.services.importer import import_all
from app.services.mittwald import DeployError
from app.settings import LEGACY_PLATFORM, ROOT
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
with session() as db:
import_all(db, Path(LEGACY_PLATFORM))
yield c
# ── Vertrag / Schema ────────────────────────────────────────────────────────
def test_contract_is_loaded_and_supported():
assert contract.available() and contract.supported() and contract.contract_version() == 1
assert len(contract.schema()["fields"]) >= 30 and contract.build_info()["sha"] not in ("", "unbekannt")
def test_all_imported_tenants_conform_to_schema(client):
for tid in ("demo", "onyva", "revier", "rotary", "rotary-wil", "sportklinik", "sprinz"):
cfg = client.get(f"/api/tenants/{tid}/state").json()["config"]
assert contract.check_tenant_json(cfg) == [], tid
def test_admin_knows_every_color_the_core_declares():
from app.services.defaults import COLOR_LABELS, DEFAULT_COLORS
core_colors = {p.split(".", 1)[1] for p in contract.field_paths() if p.startswith("colors.")}
assert core_colors == set(COLOR_LABELS) == set(DEFAULT_COLORS), "Farben im Core-Schema weichen von den Admin-Defaults ab"
def test_bespoke_form_paths_exist_in_schema():
js = (ROOT / "app" / "static" / "tenant.js").read_text()
bespoke = re.search(r"BESPOKE_PATHS = \[(.*?)\]", js, re.S).group(1)
for p in re.findall(r"'([a-z_.]+)'", bespoke):
assert p in contract.field_paths(), f"{p} wird von einer festen Admin-Maske genutzt, fehlt aber im Core-Schema"
def test_schema_warnings_flow_into_validate(client):
cfg = client.get("/api/tenants/demo/state").json()["config"]
client.put("/api/tenants/demo/config", json={**cfg, "flags": {"ui_kontakt_feld": False}}) # Tippfehler
v = client.post("/api/tenants/demo/validate").json()
client.post("/api/tenants/demo/draft/discard")
assert any("flags.ui_kontakt_feld" in w for w in v["warnings"])
def test_contract_endpoint_and_settings_get(client):
c = client.get("/api/contract").json()
assert c["available"] and c["supported"] and c["schema"]["env"]
assert client.get("/api/settings").json()["base_domain"] == "onyscribe.de"
assert client.get("/core").status_code == 200
# ── schema-getriebene Container-Env ─────────────────────────────────────────
def test_env_is_built_from_core_schema_and_picks_up_new_variables(client, monkeypatch):
client.put("/api/settings", json={"llm_model": "claude-x", "smtp_host": "smtp.test"})
with session() as db:
env = deploy.build_env(db, db.get(Tenant, "sprinz"), reveal=False)
assert env["LLM_MODEL"] == "claude-x" and env["SMTP_HOST"] == "smtp.test" and env["TENANT"] == "sprinz" and "STT_PROVIDER" not in env
# Der Core deklariert eine neue geteilte Variable → sie landet ohne Admin-Änderung im Env
spec = contract.env_spec() + [{"name": "NEW_FEATURE_LEVEL", "kind": "setting", "scope": "shared", "setting": "new_feature_level", "group": "X", "label": "X"}]
monkeypatch.setattr(contract, "env_spec", lambda: spec)
client.put("/api/settings", json={"new_feature_level": "3"})
with session() as db:
assert deploy.build_env(db, db.get(Tenant, "sprinz"), reveal=False)["NEW_FEATURE_LEVEL"] == "3"
def test_required_secrets_follow_schema_conditions():
assert "MISTRAL_API_KEY" not in contract.required_secrets({"stt": {"provider": "cortecs"}})
assert "MISTRAL_API_KEY" in contract.required_secrets({"stt": {"provider": "mistral"}})
assert "MISTRAL_API_KEY" in contract.required_secrets({})
assert {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY", "CORTECS_API_KEY"} <= set(contract.required_secrets({}))
# ── Health-Verifikation & Zustandsabgleich ──────────────────────────────────
def health_body(sha="abcdef1234567", bundle=None, status="ok"):
return {"status": status, "tenant": "t", "core": {"version": sha, "contract": 1, "hook_api": 1}, "bundle": bundle or {"source": "git", "version": None, "stale": False, "error": None}}
def test_verify_health_accepts_correct_and_rejects_wrong_versions():
logs = []
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, health_body()), tries=1)
assert any("✔" in l for l in logs)
with pytest.raises(DeployError, match="Core 1234567890 statt abcdef1"):
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, health_body("1234567890abc")), tries=2, delay=0, sleep=lambda s: None)
with pytest.raises(DeployError, match="Bundle cache v2 \\(veraltet\\) statt admin v3"):
deploy.verify_health("h", None, 3, logs.append, get=lambda u: (200, health_body(bundle={"source": "cache", "version": 2, "stale": True})), tries=1)
with pytest.raises(DeployError, match="nicht erreichbar|Connect"):
def boom(u):
raise OSError("Connect refused")
deploy.verify_health("h", None, None, logs.append, get=boom, tries=2, delay=0, sleep=lambda s: None)
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, {"status": "ok"}), tries=1) # Image vor dem Vertrag → nur Hinweis
assert any("Image vor dem Vertrag" in l for l in logs)
def test_status_probe_detects_drift():
ok = status_svc.probe("h", "abcdef1234", 5, get=lambda u: (200, health_body("abcdef12349999", {"source": "admin", "version": 5, "stale": False})))
assert ok["reachable"] and ok["drift"] == []
bad = status_svc.probe("h", "abcdef1234", 5, get=lambda u: (200, health_body("0000000aaaa", {"source": "baked", "version": None, "stale": True})))
assert len(bad["drift"]) == 2
old = status_svc.probe("h", "abcdef1234", None, get=lambda u: (200, {"status": "ok"}))
assert "Core ohne Vertrag" in old["drift"][0]
down = status_svc.probe("h", "x", None, get=lambda u: (_ for _ in ()).throw(OSError("timeout")))
assert not down["reachable"] and down["drift"] == ["nicht erreichbar"]
# ── Release-Eingang ─────────────────────────────────────────────────────────
SHA_ADMIN = None
def _admin_sha():
return contract.sha()
def test_release_intake_requires_token_and_is_idempotent(client, monkeypatch):
body = {"sha": _admin_sha(), "tag": _admin_sha(), "ref": "main", "image": "reg/x", "message": "Neuer Core"}
monkeypatch.delenv("CORE_RELEASE_TOKEN", raising=False)
assert client.post("/api/core/releases", json=body).status_code == 503
monkeypatch.setenv("CORE_RELEASE_TOKEN", "geheim")
assert client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer falsch"}).status_code == 401
assert client.post("/api/core/releases", json={**body, "sha": "zzz"}, headers={"Authorization": "Bearer geheim"}).status_code == 400
r = client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer geheim"}).json()
assert r["created"] and r["admin_matches"] and r["status"] == "new"
assert not client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer geheim"}).json()["created"]
other = client.post("/api/core/releases", json={**body, "sha": "f" * 40, "tag": "f" * 40, "message": "Fremder Core"}, headers={"Authorization": "Bearer geheim"}).json()
assert not other["admin_matches"]
def test_release_list_needs_session_when_auth_enabled(monkeypatch):
from app import auth
monkeypatch.setattr(auth, "AUTH_ENABLED", True)
with TestClient(app) as c:
assert c.get("/api/core/releases").status_code == 401
monkeypatch.setenv("CORE_RELEASE_TOKEN", "geheim")
assert c.post("/api/core/releases", json={"sha": "a" * 40}, headers={"Authorization": "Bearer geheim"}).status_code == 200 # CI braucht keine Session
assert c.get("/api/tenants/demo/state").status_code == 401
monkeypatch.setattr(auth, "AUTH_ENABLED", False)
# ── Kompatibilitätsprüfung & Freigabe ───────────────────────────────────────
def _rid(sha):
with session() as db:
return db.query(CoreRelease).filter_by(sha=sha).one().id
def test_check_requires_matching_admin_core(client):
rid = _rid("f" * 40)
r = client.post(f"/api/core/releases/{rid}/check", json={})
assert r.status_code == 400 and "Admin" in r.json()["detail"] and "neu bauen" in r.json()["detail"]
def test_check_matrix_baseline_changes_and_approval_rules(client):
rid = _rid(_admin_sha())
releases.check(rid, wait=True)
with session() as db:
c = db.get(CoreRelease, rid).compat
assert set(c["tenants"]) >= {"demo", "rotary-wil", "sprinz"} and c["summary"]["error"] == 0
assert all(v["status"] == "new" for v in c["tenants"].values()) # noch keine Baseline
assert c["tenants"]["rotary-wil"]["fp"]["pdf_pages"] >= 1
# Freigabe ohne Änderungen möglich
assert client.post(f"/api/core/releases/{rid}/approve", json={}).status_code == 200
# Baseline setzen (wie nach einem erfolgreichen Rollout) und Core-Änderung simulieren
with session() as db:
for tid, v in c["tenants"].items():
t = db.get(Tenant, tid)
t.render_baseline = {"version": t.current_version, "core": "alt", "fp": {**v["fp"], "pdf_text": "0" * 64}}
releases.check(rid, wait=True)
with session() as db:
c2 = db.get(CoreRelease, rid).compat
assert c2["tenants"]["sprinz"]["status"] == "changed" and "PDF-Text" in c2["tenants"]["sprinz"]["changed"]
r = client.post(f"/api/core/releases/{rid}/approve", json={})
assert r.status_code == 400 and "bestätigen" in r.json()["detail"]
assert client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}).status_code == 200
# unveränderte Baseline → ok
with session() as db:
for tid, v in c2["tenants"].items():
t = db.get(Tenant, tid)
t.render_baseline = {"version": t.current_version, "core": "x", "fp": v["fp"]}
releases.check(rid, wait=True)
with session() as db:
assert db.get(CoreRelease, rid).compat["summary"]["ok"] >= 7
def test_broken_tenant_blocks_approval(client):
rid = _rid(_admin_sha())
client.put("/api/tenants/revier/files/protocol.html", json={"content": "{% for %}"})
client.post("/api/tenants/revier/publish", json={"message": "kaputt"})
releases.check(rid, wait=True)
with session() as db:
c = db.get(CoreRelease, rid).compat
assert c["tenants"]["revier"]["status"] == "error" and c["summary"]["error"] == 1
r = client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True})
assert r.status_code == 400 and "revier" in r.json()["detail"]
client.post("/api/tenants/revier/versions/1/restore") # reparieren
client.post("/api/tenants/revier/publish", json={"message": "repariert"})
releases.check(rid, wait=True)
assert client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}).status_code == 200
# ── Rollout / Rollback ──────────────────────────────────────────────────────
def _ready(client, tid):
"""Tenant mit allen Pflicht-Secrets, Quelle git, OIDC-Verwaltung aus – bereit für einen (gefakten) Live-Deploy."""
from app.services import tenants as tsvc
client.post("/api/tenants", json={"id": tid, "name": f"Tenant {tid}"}) # existiert er schon, bleibt er und wird nur reaktiviert
client.put(f"/api/tenants/{tid}/meta", json={"status": "active"})
client.put(f"/api/tenants/{tid}/core-pin", json={"pin": ""})
client.put(f"/api/tenants/{tid}/source", json={"source": "git"})
client.put(f"/api/tenants/{tid}/oidc", json={"mode": "off"})
with session() as db:
for k in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"):
tsvc.set_secret(db, tid, k, "x", "t")
class FakeMW:
def __init__(self):
self.calls, self.images = [], {}
def __call__(self, method, url, headers, body):
path = url.split("/v2")[1]
self.calls.append((method, path))
if method == "GET" and path.endswith("/stacks/s-1/"):
return 200, {"services": {"legacy": {"id": "svc-0"}, **{n: {"id": f"id-{n}"} for n in self.images}}}, ""
if method == "GET" and "ingresses" in path:
return 200, [], ""
if method == "GET" and "/projects/p-1/" in path:
return 200, {"id": "uuid"}, ""
if method == "PATCH":
for name, svc in ((body or {}).get("services") or {}).items():
if svc:
self.images[name] = svc["image"]
return 200, {}, ""
@pytest.fixture()
def live_env(client, monkeypatch):
monkeypatch.setenv("DEPLOY_LIVE", "true")
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
monkeypatch.setenv("MITTWALD_API_TOKEN", "tok")
client.put("/api/settings", json={"mittwald_project_id": "p-1", "mittwald_stack_id": "s-1", "registry": "reg.test/o", "core_image_tag": "alt1234", "canary_tenants": "ro2"})
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"})
with session() as db: # alle bestehenden Tenants aus dem Rollout nehmen (andere Tests laufen weiter gegen sie)
paused = [t.id for t in db.query(Tenant).filter(Tenant.status == "active")]
for t in db.query(Tenant):
t.status = "paused"
yield
with session() as db:
for t in db.query(Tenant):
t.status = "active" if t.id in paused else t.status
for t in db.query(Tenant).filter(Tenant.id.like("ro%")):
t.status = "archived"
def _approved_release(client, sha="c" * 40):
with session() as db:
r, _ = releases.announce(db, sha, sha, "main", "reg.test/o", "Rollout-Test")
r.status = "approved"
r.compat = {"tenants": {}, "summary": {}, "core_sha": sha, "running": False}
rid = r.id
return rid
def test_dry_run_rollout_changes_nothing(client, live_env, monkeypatch):
monkeypatch.delenv("DEPLOY_LIVE")
_ready(client, "ro1")
rid = _approved_release(client, "d" * 40)
mw = FakeMW()
batch, ids = releases.rollout(rid, "t", dry_run=True, http=mw, wait=True)
assert not [c for c in mw.calls if c[0] != "GET"] # Dry-Run liest höchstens
rows = client.get(f"/api/deployments?batch={batch}").json()
assert rows and all(r["status"] == "success" and r["kind"] == "dry-run" and r["image"] == "d" * 40 for r in rows)
with session() as db:
assert db.get(CoreRelease, rid).status == "approved"
assert deploy.setting(db, "core_image_tag") == "alt1234"
with pytest.raises(releases.ReleaseError, match="gesperrt"):
releases.rollout(rid, "t", dry_run=False)
def test_live_rollout_canary_first_pin_baseline_and_rollback(client, live_env):
for t in ("ro1", "ro2", "ro3"):
_ready(client, t)
client.put("/api/tenants/ro3/core-pin", json={"pin": "fest99"})
rid = _approved_release(client, "e" * 40)
with session() as db:
db.get(CoreRelease, rid).compat = {"tenants": {"ro1": {"fp": {"x": 1}}, "ro2": {"fp": {"x": 2}}}, "summary": {}, "core_sha": "e" * 40, "running": False}
mw, seen = FakeMW(), []
health = lambda host, tag, bundle, log: seen.append((host, tag)) # noqa: E731
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=health, wait=True)
rows = client.get(f"/api/deployments?batch={batch}").json()
assert [r["tenant"] for r in rows][0] == "ro2" and {r["tenant"] for r in rows} == {"ro1", "ro2"} # Canary zuerst, ro3 gepinnt → nicht dabei
assert all(r["status"] == "success" and not r["simulated"] for r in rows)
assert mw.images == {"app-ro1": f"reg.test/o/transkript-platform:{'e' * 40}", "app-ro2": f"reg.test/o/transkript-platform:{'e' * 40}"}
assert [t for _, t in seen] == ["e" * 40, "e" * 40]
with session() as db:
r = db.get(CoreRelease, rid)
assert r.status == "active" and r.previous_tag == "alt1234" and deploy.setting(db, "core_image_tag") == "e" * 40
assert db.get(Tenant, "ro1").render_baseline["fp"] == {"x": 1} and db.get(Tenant, "ro1").deployed_image == "e" * 40
assert deploy.image_ref(db, db.get(Tenant, "ro3")).endswith(":fest99") # Pin gewinnt
# Rollback aller Tenants auf den vorherigen Tag
mw2 = FakeMW()
releases.rollback_release(rid, "t", http=mw2, health=health, wait=True)
assert set(mw2.images.values()) == {"reg.test/o/transkript-platform:alt1234"}
with session() as db:
assert db.get(CoreRelease, rid).status == "rolled_back" and deploy.setting(db, "core_image_tag") == "alt1234"
def test_failed_canary_aborts_rollout_and_marks_release_failed(client, live_env):
for t in ("ro1", "ro2"):
_ready(client, t)
rid = _approved_release(client, "9" * 40)
mw = FakeMW()
def health(host, tag, bundle, log):
if host.startswith("ro2."):
raise DeployError("Health-Check fehlgeschlagen: Core falsch")
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=health, wait=True)
rows = {r["tenant"]: r for r in client.get(f"/api/deployments?batch={batch}").json()}
assert rows["ro2"]["status"] == "failed" and rows["ro1"]["status"] == "cancelled" and "übersprungen" in rows["ro1"]["log"]
assert "app-ro1" not in mw.images # nichts ausgerollt
with session() as db:
assert db.get(CoreRelease, rid).status == "failed" and deploy.setting(db, "core_image_tag") == "alt1234"
def test_tenant_rollback_uses_previous_successful_live_tag(client, live_env, monkeypatch):
from app.services.mittwald import Mittwald
_ready(client, "ro4")
mw, health = FakeMW(), (lambda *a: None)
monkeypatch.setattr(deploy, "_client", lambda live, log, http=None: Mittwald("tok", "https://api.test/v2", live, log, mw))
monkeypatch.setattr(deploy, "verify_health", lambda *a, **k: None)
deploy.start("ro4", "deploy", "t", http=mw, core_tag="1111111", health=health, wait=True)
deploy.start("ro4", "deploy", "t", http=mw, core_tag="2222222", health=health, wait=True)
with session() as db:
assert db.get(Tenant, "ro4").deployed_image == "2222222" and releases.tenant_rollback_tag(db, db.get(Tenant, "ro4")) == "1111111"
r = client.post("/api/tenants/ro4/rollback").json()
assert r["tag"] == "1111111"
time.sleep(1.5)
assert mw.images["app-ro4"].endswith(":1111111")
def test_rollout_blocked_for_unapproved_and_unsupported(client, live_env):
with session() as db:
r, _ = releases.announce(db, "8" * 40, "8" * 40, "main", "", "neu")
rid = r.id
with pytest.raises(releases.ReleaseError, match="erst prüfen und freigeben"):
releases.rollout(rid, "t", dry_run=False)
with pytest.raises(releases.ReleaseError):
releases.approve(session().__enter__(), rid, "t")
# ── Schreib-Freigabe pro Tenant (DEPLOY_ALLOWED_TENANTS) ───────────────────
def test_allowlist_parsing(monkeypatch):
from app.services import guard
monkeypatch.delenv("DEPLOY_ALLOWED_TENANTS", raising=False)
assert guard.allowed_tenants() == set() and not guard.live_allowed("demo") and guard.describe() == "keiner"
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", " demo-admin , x ")
assert guard.live_allowed("demo-admin") and guard.live_allowed("x") and not guard.live_allowed("demo")
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
assert guard.allowed_tenants() is None and guard.live_allowed("irgendwer")
def test_live_deploy_oidc_and_rollout_respect_allowlist(client, live_env, monkeypatch):
for t in ("ro1", "ro2"):
_ready(client, t)
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "ro2")
# API: nicht freigegebener Tenant → 403, freigegebener → ok
assert client.post("/api/tenants/ro1/deploy", json={"dry_run": False}).status_code == 403
assert client.post("/api/tenants/ro1/offboard", json={"dry_run": False}).status_code == 403
assert client.post("/api/tenants/ro1/rollback").status_code == 403
assert client.post("/api/tenants/ro1/oidc/sync", json={"apply": True}).status_code == 403
dry = client.post("/api/tenants/ro1/deploy", json={"dry_run": True}) # Dry-Run immer erlaubt
assert dry.status_code == 200
for _ in range(40):
if client.get(f"/api/deployments/{dry.json()['id']}").json()["status"] != "running":
break
time.sleep(0.25)
assert client.get("/api/deploy/mode").json()["allowed"] == "ro2"
# Engine blockiert auch direkt
d = client.get(f"/api/deployments/{deploy.start('ro1', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json()
assert d["status"] == "failed" and "nicht freigegeben" in d["log"]
# Rollout: nur ro2 wird live ausgerollt, ro1 bleibt unberührt
rid = _approved_release(client, "7" * 40)
mw = FakeMW()
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=lambda *a: None, wait=True)
assert [r["tenant"] for r in client.get(f"/api/deployments?batch={batch}").json()] == ["ro2"] and list(mw.images) == ["app-ro2"]
# niemand freigegeben → Rollout wird abgelehnt
monkeypatch.delenv("DEPLOY_ALLOWED_TENANTS")
with pytest.raises(releases.ReleaseError, match="Kein Tenant für Live-Rollouts freigegeben"):
releases.rollout(rid, "t", dry_run=False)
# ── Vorflug-Check ───────────────────────────────────────────────────────────
def test_preflight_reports_problems_and_readiness(client, live_env, monkeypatch):
from tests.test_flow import FakeLogin # noqa: F401
_ready(client, "pf1")
client.put("/api/settings", json={"admin_public_url": "", "core_supports_bundle": "false", "base_domain": "onyscribe.de"})
client.put("/api/tenants/pf1/source", json={"source": "admin"})
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "")
mw = FakeMW()
from app.services import preflight
r = preflight.run("pf1", mw_http=mw, self_get=lambda u: 200)
lv = {i["id"]: i["level"] for i in r["items"]}
assert not r["ready"] and lv["bundle"] == "fail" and lv["adminurl"] == "fail" and lv["allow"] == "warn" and lv["stack"] == "ok" and lv["tag"] == "warn"
assert "legacy" in next(i for i in r["items"] if i["id"] == "stack")["detail"] # bestehende Services werden als „bleiben unangetastet“ gezeigt
assert [c for c in mw.calls if c[0] != "GET"] == [] # schreibt nie
# alles in Ordnung bringen
client.put("/api/settings", json={"admin_public_url": "https://admin.example.de", "core_supports_bundle": "true"})
client.put("/api/tenants/pf1/core-pin", json={"pin": "a" * 40})
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "pf1")
r2 = preflight.run("pf1", mw_http=mw, self_get=lambda u: 200)
assert r2["ready"], [i for i in r2["items"] if i["level"] == "fail"]
bad = preflight.run("pf1", mw_http=mw, self_get=lambda u: 502)
assert not bad["ready"] and any(i["id"] == "adminreach" and i["level"] == "fail" for i in bad["items"])
def test_live_deploy_requires_admin_public_url_for_admin_source(client, live_env):
_ready(client, "pf2")
client.put("/api/tenants/pf2/source", json={"source": "admin"})
client.put("/api/settings", json={"core_supports_bundle": "true", "admin_public_url": ""})
d = client.get(f"/api/deployments/{deploy.start('pf2', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json()
assert d["status"] == "failed" and "Öffentliche URL der Admin-Plattform" in d["log"]
assert client.get("/api/tenants/pf2/preflight").status_code == 200

529
tests/test_flow.py Normal file
View File

@@ -0,0 +1,529 @@
"""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