Files
Onyscribe-Admin/tests/test_core_contract.py

613 lines
34 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", "CORTECS_API_KEY"} <= set(contract.required_secrets({}))
assert "COST_INGEST_KEY" not in contract.required_secrets({}) # optional: ohne Key bleibt die Kostenerfassung inaktiv
# ── 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
def test_live_actions_are_locked_without_login(client, monkeypatch):
"""Ohne aktiven Login der Admin-Plattform bleibt alles im Dry-Run – auch mit DEPLOY_LIVE=true / OIDC_SYNC_LIVE=true."""
from app.services import onyva_login
monkeypatch.setenv("DEPLOY_LIVE", "true")
monkeypatch.setenv("OIDC_SYNC_LIVE", "true")
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
assert deploy.live_enabled() and onyva_login.writes_allowed()
monkeypatch.delenv("ALLOW_NO_AUTH_LIVE") # kein Login, keine Ausnahme
assert not deploy.live_enabled() and not onyva_login.writes_allowed()
assert client.post("/api/tenants/sprinz/deploy", json={"dry_run": False}).status_code == 403
assert client.post("/api/tenants/sprinz/oidc/sync", json={"apply": True}).status_code == 403
assert client.get("/api/deploy/mode").json()["live"] is False
# ── Stack-Auflösung & Env-Rückfall ──────────────────────────────────────────
def test_stack_resolution_falls_back_to_project_uuid_and_explains_failures():
from app.services.mittwald import DeployError, Mittwald
calls = []
def http_ok(method, url, headers, body):
path = url.split("/v2")[1]
calls.append(path)
if path.endswith("/stacks/") and "/projects/" in path:
return 200, [], "" # Liste leer
if path == "/projects/p-1/":
return 200, {"id": "uuid-1"}, ""
if path == "/stacks/uuid-1/":
return 200, {"services": {}}, ""
return 404, None, "nope"
assert Mittwald("t", "https://api.test/v2", False, lambda m: None, http_ok).resolve_stack_id("p-1", "") == "uuid-1"
assert Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda *a: (200, [{"id": "s-9", "description": "default"}], "")).resolve_stack_id("p-1", "") == "s-9"
assert Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda *a: (200, {"stacks": [{"id": "s-7"}]}, "")).resolve_stack_id("p-1", "") == "s-7"
with pytest.raises(DeployError, match="Antwort der API.*STACK_ID in den Einstellungen"):
Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda m, u, h, b: (200, [], "") if u.endswith("/stacks/") else (404, None, "x")).resolve_stack_id("p-1", "")
def test_cortecs_key_from_admin_env_counts_as_set_and_reaches_the_container(client, monkeypatch):
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": ""}) # kein DB-Wert (andere Tests setzen ihn)
with session() as db:
t = db.get(Tenant, "sprinz")
monkeypatch.delenv("CORTECS_API_KEY", raising=False)
assert "CORTECS_API_KEY" in deploy.missing_secrets(db, t)
monkeypatch.setenv("CORTECS_API_KEY", "ck-aus-env")
assert "CORTECS_API_KEY" not in deploy.missing_secrets(db, t)
assert deploy.build_env(db, t, reveal=True)["CORTECS_API_KEY"] == "ck-aus-env"
assert "ck-aus-env" not in str(deploy.build_env(db, t, reveal=False))
assert "aus Admin-Env" in deploy.build_env(db, t, reveal=False)["CORTECS_API_KEY"]
def test_http_clients_follow_redirects_and_3xx_is_never_success(monkeypatch):
import httpx
from app.services import mittwald as mwmod, onyva_login
from app.services.mittwald import DeployError, Mittwald
seen = []
class R:
status_code, content, text, headers = 200, b"[]", "[]", {}
def json(self): return []
monkeypatch.setattr(httpx, "request", lambda *a, **k: (seen.append(k), R())[1])
Mittwald._real_http("GET", "https://x/v2/stacks/", {}, None)
onyva_login.OnyvaLogin._real("GET", "https://x/api/v1/apps", {}, None)
assert all(k.get("follow_redirects") is True for k in seen) and len(seen) == 2
# Eine nicht gefolgte 308-Weiterleitung (leerer Body) ist ein Fehler, kein „null“
with pytest.raises(DeployError, match="308"):
Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda *a: (308, None, "")).api("GET", "/projects/p/stacks/", write=False)
# ── Health-Check bei neuem Ingress (Zertifikat/DNS dauert) ──────────────────
def test_verify_health_distinguishes_unreachable_from_wrong_version():
from app.services.deploy import HealthTimeout
with pytest.raises(HealthTimeout) as e1:
deploy.verify_health("h", "abcdef1", None, lambda m: None, get=lambda u: (_ for _ in ()).throw(OSError("ssl handshake")), tries=2, delay=0, sleep=lambda s: None)
assert e1.value.answered is False
with pytest.raises(HealthTimeout) as e2:
deploy.verify_health("h", "abcdef1", None, lambda m: None, get=lambda u: (502, {}), tries=2, delay=0, sleep=lambda s: None)
assert e2.value.answered is False # Proxy antwortet, App nicht
with pytest.raises(HealthTimeout) as e3:
deploy.verify_health("h", "abcdef1", None, lambda m: None, get=lambda u: (200, health_body("9999999aaaa")), tries=1)
assert e3.value.answered is True
def test_new_ingress_with_slow_certificate_ends_with_warning_not_failure(client, live_env):
from app.services.deploy import HealthTimeout
_ready(client, "hc1")
mw = FakeMW() # GET /ingresses → [] ⇒ Ingress wird neu angelegt
never = lambda *a, **k: (_ for _ in ()).throw(HealthTimeout("keine Antwort", answered=False)) # noqa: E731
dep = deploy.start("hc1", "deploy", "t", http=mw, health=never, wait=True)
d = client.get(f"/api/deployments/{dep}").json()
assert d["status"] == "warning" and "Zertifikat/DNS" in d["log"] and "Fertig mit Warnung" in d["log"]
with session() as db:
t = db.get(Tenant, "hc1")
assert t.deployed_version == t.current_version and t.deployed_image # Zustand wurde trotzdem festgehalten
# Zertifikat inzwischen da → nachträglicher Check macht den Eintrag grün
r = deploy.recheck_health("hc1", health=lambda *a: None)
assert r["ok"] and client.get(f"/api/deployments/{dep}").json()["status"] == "success"
def test_wrong_version_or_existing_ingress_still_fails(client, live_env):
from app.services.deploy import HealthTimeout
_ready(client, "hc2")
mw = FakeMW()
wrong = lambda *a, **k: (_ for _ in ()).throw(HealthTimeout("Core falsch", answered=True)) # noqa: E731
d = client.get(f"/api/deployments/{deploy.start('hc2', 'deploy', 't', http=mw, health=wrong, wait=True)}").json()
assert d["status"] == "failed" # neuer Ingress, aber echte Antwort mit falscher Version
# bestehender Ingress + keine Antwort ⇒ echter Fehler
class WithIngress(FakeMW):
def __call__(self, method, url, headers, body):
if method == "GET" and "ingresses" in url:
return 200, [{"hostname": "hc2.onyscribe.de", "id": "i1"}], ""
return super().__call__(method, url, headers, body)
never = lambda *a, **k: (_ for _ in ()).throw(HealthTimeout("keine Antwort", answered=False)) # noqa: E731
d2 = client.get(f"/api/deployments/{deploy.start('hc2', 'deploy', 't', http=WithIngress(), health=never, wait=True)}").json()
assert d2["status"] == "failed"
# ── DNS-Rückfall beim Health-Check ──────────────────────────────────────────
def test_dns_failure_falls_back_to_doh_and_connects_by_ip(monkeypatch):
import socket
import httpx
from app.services import netcheck
calls = []
class Resp:
status_code, headers = 200, {"content-type": "application/json"}
def __init__(self, body): self._b = body
def json(self): return self._b
def fake_get(url, **kw):
calls.append((url, kw.get("headers"), kw.get("extensions")))
if url == "https://demo.example.de/health":
raise httpx.ConnectError("[Errno -2] Name or service not known") from socket.gaierror(-2, "Name or service not known")
if "dns-query" in url:
return Resp({"Answer": [{"type": 1, "data": "203.0.113.7"}]})
return Resp({"status": "ok", "core": {"version": "abcdef1234567"}})
monkeypatch.setattr(httpx, "get", fake_get)
status, body = netcheck.get_json("https://demo.example.de/health")
assert status == 200 and body["_dns_fallback"] and body["core"]["version"].startswith("abcdef1")
ip_call = calls[-1]
assert ip_call[0] == "https://203.0.113.7/health" and ip_call[1] == {"Host": "demo.example.de"} and ip_call[2] == {"sni_hostname": "demo.example.de"}
# Verbindungsfehler ohne DNS-Ursache werden NICHT umgangen
monkeypatch.setattr(httpx, "get", lambda url, **kw: (_ for _ in ()).throw(httpx.ConnectError("refused")))
with pytest.raises(httpx.ConnectError):
netcheck.get_json("https://demo.example.de/health")
def test_unresolvable_host_is_a_warning_for_single_deploys_but_fails_strict_rollouts(client, live_env):
from app.services.deploy import HealthTimeout
_ready(client, "dn1")
class WithIngress(FakeMW):
def __call__(self, method, url, headers, body):
if method == "GET" and "ingresses" in url:
return 200, [{"hostname": "dn1.onyscribe.de", "id": "i1"}], ""
return super().__call__(method, url, headers, body)
dns = lambda *a, **k: (_ for _ in ()).throw(HealthTimeout("Name or service not known", answered=False, dns=True)) # noqa: E731
d = client.get(f"/api/deployments/{deploy.start('dn1', 'deploy', 't', http=WithIngress(), health=dns, wait=True)}").json()
assert d["status"] == "warning" and "kann den Hostnamen nicht auflösen" in d["log"] # bestehender Ingress, aber nur DNS-Problem des Admin-Servers
# strikt (Core-Rollout): ohne neuen Ingress bleibt es ein Fehler
rid = _approved_release(client, "5" * 40)
batch, _ = releases.rollout(rid, "t", dry_run=False, http=WithIngress(), health=dns, wait=True)
assert [r["status"] for r in client.get(f"/api/deployments?batch={batch}").json() if r["tenant"] == "dn1"] == ["failed"]