Neuer Reiter zwischen Audit-Log und Verbrauchsrechner, Anlass war die Dashboard-Kachel API-Kosten gesamt, die nirgendwo hinführte (sie springt jetzt hierher). Kennzahlen-Set nach Betreiber-Best-Practices für nutzungsbasierte KI-Produkte (Kostenverlauf, Marge je Kunde, Betrieb). - GET /api/statistik?months=N liefert alles in einem JSON. Kosten und Läufe je Monat und Lagentyp (führende Quelle refresh_log mit lückenlosem incidents-Join), Quellen-Split aus token_usage_monthly, Erfolgsquote, normalisiertes Fehler-Ranking, Dauer je Typ, Wachstum, MAU/DAU aus user_activity_days und je Organisation Ist-Kosten plus Margen-Schätzung zu heutigen Sätzen (Recherche-Anlage zählt dreifach, weil der Multi-Pass nur eine Log-Zeile erzeugt). Aggregation als pure, unit-getestete Funktionen, Monatsachse lückenlos gefüllt, Mediane nur über Läufe mit Kosten (Erfassung begann erst 03/2026). - statistik.js injiziert sich selbst nach dem rechner.js-Muster, Platzierung doppelt gesichert (Ladereihenfolge mit async=false plus insertBefore). Charts als Inline-SVG ohne Bibliothek, eine Achse je Chart, Hover-Titel mit großen Trefferflächen, Legenden, Serienfarben je Theme mit dem dataviz-Palette-Validator geprüft (alle Checks PASS). Zeitraum 6/12/Alles, Datenstand-Hinweis, ehrliche Fußnote unter der Margen-Tabelle, Leerzustand für die junge Aktivitäts-Erfassung. - Migration user_activity_days für die Portal-Staging-DB (auf Live legt sie der Monitor an, dort läuft die Erfassung in get_current_user). - 10 neue Unit-Tests plus Auth-Smoke für den Endpoint, 96 Tests grün (die zwei bekannten shared-Import-Fehlschläge bestehen unverändert). Gegenstück im Monitor: Commit d367c60 (Aktivitäts-Erfassung). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
99 Zeilen
3.8 KiB
Python
99 Zeilen
3.8 KiB
Python
"""Smoke-Tests fuer alle API-Endpoints: Auth-Coverage.
|
|
|
|
Pruef, dass jeder geschuetzte Endpoint ohne Auth-Header 401/403 liefert -
|
|
verhindert, dass jemand versehentlich einen Endpoint ohne `get_current_admin`
|
|
schreibt und ihn oeffentlich macht.
|
|
"""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
from main import app
|
|
# raise_server_exceptions=False -> Exceptions werden als 500 ausgeliefert.
|
|
# Wir testen nur, dass Auth korrekt vor DB-Aufrufen greift.
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
|
|
# (method, path, expected_status)
|
|
# Auth-geschuetzte Endpoints -> 401 (HTTPBearer ohne credentials wirft 403,
|
|
# aber FastAPI HTTPBearer auto_error=True liefert 403; wir akzeptieren beides).
|
|
AUTH_PROTECTED = [
|
|
("GET", "/api/orgs"),
|
|
("POST", "/api/orgs"),
|
|
("GET", "/api/orgs/1"),
|
|
("PUT", "/api/orgs/1"),
|
|
("DELETE", "/api/orgs/1"),
|
|
("GET", "/api/licenses"),
|
|
("POST", "/api/licenses"),
|
|
("PUT", "/api/licenses/1/revoke"),
|
|
("PUT", "/api/licenses/1/extend"),
|
|
("GET", "/api/licenses/expiring"),
|
|
("GET", "/api/users"),
|
|
("POST", "/api/users"),
|
|
("PUT", "/api/users/1/deactivate"),
|
|
("PUT", "/api/users/1/activate"),
|
|
("PUT", "/api/users/1/globe-access"),
|
|
("PUT", "/api/users/1/network-access"),
|
|
("PUT", "/api/users/1/role"),
|
|
("DELETE", "/api/users/1"),
|
|
("GET", "/api/dashboard/stats"),
|
|
("GET", "/api/sources/meta"),
|
|
("GET", "/api/sources/global"),
|
|
("POST", "/api/sources/global"),
|
|
("PUT", "/api/sources/global/1"),
|
|
("DELETE", "/api/sources/global/1"),
|
|
("GET", "/api/sources/global/stats"),
|
|
("GET", "/api/sources/tenant"),
|
|
("POST", "/api/sources/tenant/1/promote"),
|
|
("POST", "/api/sources/tenant/bulk-promote"),
|
|
("POST", "/api/sources/discover"),
|
|
("POST", "/api/sources/discover/add"),
|
|
("GET", "/api/sources/health"),
|
|
("GET", "/api/sources/suggestions"),
|
|
("PUT", "/api/sources/suggestions/1"),
|
|
("POST", "/api/sources/health/run"),
|
|
("POST", "/api/sources/health/run-stream"),
|
|
("POST", "/api/sources/health/search-fix/1"),
|
|
("GET", "/api/statistik"),
|
|
("GET", "/api/token-usage/overview"),
|
|
("GET", "/api/token-usage/1"),
|
|
("GET", "/api/token-usage/1/current"),
|
|
("PUT", "/api/token-usage/budget/1"),
|
|
("GET", "/api/audit-log"),
|
|
("GET", "/api/audit-log/distinct"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("method,path", AUTH_PROTECTED)
|
|
def test_endpoint_requires_auth(client, method, path):
|
|
"""Ohne Authorization-Header muss jeder Endpoint 401 oder 403 liefern."""
|
|
r = client.request(method, path, json={})
|
|
assert r.status_code in (401, 403), (
|
|
f"{method} {path} sollte 401/403 sein, war {r.status_code}: {r.text[:200]}"
|
|
)
|
|
|
|
|
|
def test_magic_link_endpoint_is_public(client):
|
|
"""/api/auth/magic-link ist absichtlich oeffentlich (sonst kann sich keiner einloggen)."""
|
|
r = client.post("/api/auth/magic-link", json={"email": "stranger@example.com"})
|
|
# Mit gueltigem JSON -> 200 generische Antwort, ohne Auth-Header.
|
|
# 200 erwartet (Anti-Enumeration), aber DB-Aufruf koennte mit /tmp/x.db failen ->
|
|
# akzeptieren wir auch 500. Wir wollen nur sicherstellen, dass NICHT 401/403 kommt.
|
|
assert r.status_code != 401 and r.status_code != 403
|
|
|
|
|
|
def test_verify_endpoint_is_public(client):
|
|
"""/api/auth/verify ist auch oeffentlich (ohne Token koennen wir keinen JWT haben)."""
|
|
r = client.post("/api/auth/verify", json={"token": "x" * 20})
|
|
assert r.status_code != 401 and r.status_code != 403
|
|
|
|
|
|
def test_static_routes_public(client):
|
|
"""/ und /dashboard liefern HTML ohne Auth (Frontend regelt Login-Redirect)."""
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
r = client.get("/dashboard")
|
|
assert r.status_code == 200
|