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

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

444 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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