Rollenbasiertes, langlebiges Token neben dem normalen JWT-Login-Flow - für automatisierte Zugriffe/Skripte. Admin wählt bei Erzeugung eine oder mehrere Rollen, Klartext wird einmalig gezeigt (wie ein Passwort), Token ist jederzeit deaktivierbar/reaktivierbar. Architektur: jedes Token bekommt einen Begleit-"Dienstkonto"-Benutzer (Benutzer.ist_dienstkonto=True, kein echter Login), der die Rollen über die bestehende benutzer_rolle-Tabelle trägt - dadurch funktionieren require_roles()/require_permission() und alle Audit-FKs unverändert, keine bestehende Endpunkt-Dependency musste angepasst werden. get_current_user() erkennt ein Token am Präfix mabea_pat_ und löst es über den Hash (SHA-256, kein bcrypt - Token ist bereits hochentropisch) auf den Begleit-Benutzer auf. Dienstkonten sind aus GET /benutzer ausgeblendet, damit sie keine Auswahl-Dropdowns verunreinigen. Neuer Admin-Tab "API-Tokens" (Gruppe System, nur Administration). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
135 lines
5.1 KiB
Python
135 lines
5.1 KiB
Python
import pytest
|
|
|
|
from tests.conftest import auth_header, login
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_token_anlegen_und_verwenden(client, admin_user):
|
|
"""FOUND-016: Token wird einmalig im Klartext zurückgegeben und
|
|
funktioniert danach als Bearer-Token mit den zugewiesenen Rollen."""
|
|
admin_token = await login(client, "admin1")
|
|
|
|
erstellt = await client.post(
|
|
"/api/v1/api-tokens",
|
|
json={"name": "CI-Zugriff", "rollen": ["mitarbeiter"]},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert erstellt.status_code == 201
|
|
body = erstellt.json()
|
|
assert body["token"].startswith("mabea_pat_")
|
|
assert body["rollen"] == ["mitarbeiter"]
|
|
klartext = body["token"]
|
|
|
|
# Funktioniert wie ein normaler Bearer-Token gegen einen mitarbeiter+-Endpunkt.
|
|
me = await client.get("/api/v1/objekte", headers=auth_header(klartext))
|
|
assert me.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_token_erscheint_nicht_in_benutzer_liste(client, admin_user):
|
|
admin_token = await login(client, "admin1")
|
|
await client.post(
|
|
"/api/v1/api-tokens", json={"name": "Verstecktes Token", "rollen": []}, headers=auth_header(admin_token)
|
|
)
|
|
liste = await client.get("/api/v1/benutzer", headers=auth_header(admin_token))
|
|
namen = {b["name"] for b in liste.json()}
|
|
assert not any("Verstecktes Token" in n for n in namen)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_token_deaktivieren_sperrt_zugriff(client, admin_user):
|
|
admin_token = await login(client, "admin1")
|
|
erstellt = await client.post(
|
|
"/api/v1/api-tokens",
|
|
json={"name": "Wird deaktiviert", "rollen": ["administration"]},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
token_id = erstellt.json()["id"]
|
|
klartext = erstellt.json()["token"]
|
|
|
|
vorher = await client.get("/api/v1/objekte", headers=auth_header(klartext))
|
|
assert vorher.status_code == 200
|
|
|
|
deaktiviert = await client.patch(
|
|
f"/api/v1/api-tokens/{token_id}", json={"aktiv": False}, headers=auth_header(admin_token)
|
|
)
|
|
assert deaktiviert.status_code == 200
|
|
assert deaktiviert.json()["aktiv"] is False
|
|
|
|
nachher = await client.get("/api/v1/objekte", headers=auth_header(klartext))
|
|
assert nachher.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_token_reaktivieren_erlaubt_zugriff_wieder(client, admin_user):
|
|
admin_token = await login(client, "admin1")
|
|
erstellt = await client.post(
|
|
"/api/v1/api-tokens",
|
|
json={"name": "Reaktivierbar", "rollen": ["mitarbeiter"]},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
token_id = erstellt.json()["id"]
|
|
klartext = erstellt.json()["token"]
|
|
|
|
await client.patch(f"/api/v1/api-tokens/{token_id}", json={"aktiv": False}, headers=auth_header(admin_token))
|
|
gesperrt = await client.get("/api/v1/objekte", headers=auth_header(klartext))
|
|
assert gesperrt.status_code == 401
|
|
|
|
await client.patch(f"/api/v1/api-tokens/{token_id}", json={"aktiv": True}, headers=auth_header(admin_token))
|
|
wieder_erlaubt = await client.get("/api/v1/objekte", headers=auth_header(klartext))
|
|
assert wieder_erlaubt.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ungueltiges_api_token_wird_abgelehnt(client):
|
|
response = await client.get("/api/v1/objekte", headers=auth_header("mabea_pat_frei-erfunden"))
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_token_rollen_werden_respektiert(client, admin_user):
|
|
"""Token mit mitarbeiter-Rolle darf keine admin-only-Aktion (Objekttyp
|
|
anlegen) - dieselbe require_roles()-Prüfung wie bei echten Nutzern."""
|
|
admin_token = await login(client, "admin1")
|
|
erstellt = await client.post(
|
|
"/api/v1/api-tokens",
|
|
json={"name": "Nur Mitarbeiter", "rollen": ["mitarbeiter"]},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
klartext = erstellt.json()["token"]
|
|
|
|
verboten = await client.post(
|
|
"/api/v1/objekttypen",
|
|
json={"bereich_id": 1, "name": "Test", "typ": "lager"},
|
|
headers=auth_header(klartext),
|
|
)
|
|
assert verboten.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_token_liste_und_verwaltung_nur_admin(client, mitarbeiter_user):
|
|
token = await login(client, "mitarbeiter1")
|
|
liste = await client.get("/api/v1/api-tokens", headers=auth_header(token))
|
|
assert liste.status_code == 403
|
|
|
|
erstellen = await client.post(
|
|
"/api/v1/api-tokens", json={"name": "x", "rollen": []}, headers=auth_header(token)
|
|
)
|
|
assert erstellen.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_token_liste_zeigt_rollen_und_status(client, admin_user):
|
|
admin_token = await login(client, "admin1")
|
|
await client.post(
|
|
"/api/v1/api-tokens",
|
|
json={"name": "Sichtbar", "rollen": ["materialverantwortlicher"]},
|
|
headers=auth_header(admin_token),
|
|
)
|
|
liste = await client.get("/api/v1/api-tokens", headers=auth_header(admin_token))
|
|
assert liste.status_code == 200
|
|
eintrag = next(t for t in liste.json() if t["name"] == "Sichtbar")
|
|
assert eintrag["rollen"] == ["materialverantwortlicher"]
|
|
assert eintrag["aktiv"] is True
|
|
assert eintrag["letzte_verwendung_am"] is None
|