"""Betrieb: /live, /ready, /metrics, Worker-Herzschlag und -Probes, Logs, Backup und Prüfung.""" import io import json import logging import socket import tarfile import urllib.error import urllib.request from pathlib import Path import pytest from fastapi.testclient import TestClient from sqlalchemy import select from app import backup from app.core import logs from app.core.crypto import Vault, generate_key from app.data_sources.models import DataSource from app.identity import Principal from app.main import create_app from app.tasks import monitor from app.tasks.monitor import WorkerHeartbeat from app.tasks.worker import Worker from app.tools import service as tools from app.uploads.models import Upload from helpers import create_workspace CSV = b"region,umsatz\nNord,100\nSued,250\n" TOKEN = "metrik-token-1234" @pytest.fixture def ws(browser, people, db): anna = browser("anna") workspace_id = create_workspace(anna, people["team_demo"], "Betrieb") anna.post(f"/workspaces/{workspace_id}/uploads", files={"file": ("umsatz.csv", CSV)}) admin = Principal(user_id=people["anna"], username="anna", display_name="Anna", is_admin=True, team_ids=frozenset({people["team_demo"]})) with db.session() as session: tools.classify_suggested(session, admin) chat_id = int(anna.post(f"/workspaces/{workspace_id}/chats").headers["location"].rsplit("/", 1)[1]) return {"anna": anna, "id": workspace_id, "chat_id": chat_id} def test_live_und_ready(app, settings): client = TestClient(app) for path in ("/live", "/healthz"): assert client.get(path).json() == {"status": "ok"} body = client.get("/ready").json() assert body["status"] == "ok" assert set(body["checks"]) == {"database", "migrations", "uploads", "secret_key"} assert client.get("/readyz").status_code == 200 def test_ready_meldet_fehlenden_key_und_kaputte_datenbank(settings, db): client = TestClient(create_app(settings.model_copy(update={"secret_key": ""}), listen=False)) response = client.get("/ready") assert response.status_code == 503 and response.json()["checks"]["secret_key"] == "AIW_SECRET_KEY fehlt" broken = settings.model_copy(update={"database_url": "postgresql+psycopg://aiw:aiw@127.0.0.1:1/nix"}) client = TestClient(create_app(broken, listen=False)) response = client.get("/ready") assert response.status_code == 503 and "nicht erreichbar" in response.json()["checks"]["database"] assert client.get("/live").status_code == 200 # live prüft bewusst keine Datenbank def test_metrics_nur_mit_token(settings, db, ws, fake_provider): client = TestClient(create_app(settings, listen=False)) assert client.get("/metrics").status_code == 404 # ohne AIW_METRICS_TOKEN aus client = TestClient(create_app(settings.model_copy(update={"metrics_token": TOKEN}), listen=False)) assert client.get("/metrics").status_code == 401 assert client.get("/metrics", headers={"Authorization": "Bearer falsch"}).status_code == 401 fake_provider.script = ["Antwort."] ws["anna"].post(f"/chats/{ws['chat_id']}/messages", {"content": "Hallo?"}, headers={"HX-Request": "true"}) Worker(db, settings).run_once() ws["anna"].get(f"/chats/{ws['chat_id']}") text = client.get("/metrics", headers={"Authorization": f"Bearer {TOKEN}"}).text assert 'aiw_runs_finished_total{kind="chat",status="completed"}' in text assert "aiw_model_calls_total{" in text and 'outcome="ok"' in text assert 'aiw_http_requests_total{method="GET",route="/chats/{chat_id}",status="200"}' in text assert "aiw_workers_alive 1.0" in text and "aiw_state_up 1.0" in text assert "aiw_runs_oldest_queued_seconds 0.0" in text assert 'route="/live"' not in text and f'/chats/{ws["chat_id"]}' not in text # keine Probes, keine IDs def test_worker_herzschlag_und_probes(db, settings): settings = settings.model_copy(update={"worker_http_port": 0}) worker = Worker(db, settings) worker.run_once() with db.session() as session: beat = session.scalar(select(WorkerHeartbeat)) assert beat.worker_id == worker.worker_id probe = monitor.Probe() assert monitor.serve(db, settings, probe) is None # Port 0 = aus with socket.socket() as s: s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] live = settings.model_copy(update={"worker_http_port": port, "worker_http_host": "127.0.0.1", "metrics_token": TOKEN, "worker_stale_seconds": 60}) server = monitor.serve(db, live, probe) try: base = f"http://127.0.0.1:{port}" assert json.load(urllib.request.urlopen(f"{base}/live"))["status"] == "ok" assert json.load(urllib.request.urlopen(f"{base}/ready"))["status"] == "ok" request = urllib.request.Request(f"{base}/metrics", headers={"Authorization": f"Bearer {TOKEN}"}) assert b"aiw_worker_last_loop_timestamp_seconds" in urllib.request.urlopen(request).read() probe.last -= 120 # Schleife hängt with pytest.raises(urllib.error.HTTPError) as e: urllib.request.urlopen(f"{base}/live") assert e.value.code == 503 finally: server.shutdown() monitor.forget(db, worker.worker_id) with db.session() as session: assert session.scalar(select(WorkerHeartbeat)) is None def test_request_id_und_zugriffszeile(app, caplog): client = TestClient(app) with caplog.at_level(logging.INFO, logger="aiw.access"): response = client.get("/login?next=/geheim", headers={"X-Request-ID": "proxy-abc-123"}) client.get("/live") assert response.headers["X-Request-ID"] == "proxy-abc-123" lines = [r for r in caplog.records if r.name == "aiw.access"] assert len(lines) == 1 # Probes nicht im Log assert lines[0].route == "/login" and lines[0].status == 200 and "geheim" not in lines[0].getMessage() assert client.get("/live", headers={"X-Request-ID": "