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:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
22
tests/conftest.py
Normal file
22
tests/conftest.py
Normal 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
59
tests/fakelogin.py
Normal 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
443
tests/test_core_contract.py
Normal 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
529
tests/test_flow.py
Normal 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
|
||||
Reference in New Issue
Block a user