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>
444 lines
24 KiB
Python
444 lines
24 KiB
Python
"""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
|