feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport
- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter Rotation über die neue Tabelle refresh_token - AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim Erststart mit erzwungenem Passwortwechsel - CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences, Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele - Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen - Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich - SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt - 61 neue Integrationstests, insgesamt 148 grün Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""Tests der Anmeldung, Token-Rotation und Zugriffsbeschränkung."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.cookies import ACCESS_COOKIE, REFRESH_COOKIE
|
||||
from app.core.security import hash_password
|
||||
from app.models import AppUser, RefreshToken
|
||||
from tests.conftest import TEST_PASSWORD
|
||||
|
||||
|
||||
async def test_anmeldung_setzt_beide_cookies(client: AsyncClient, user: AppUser) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": "tester", "password": TEST_PASSWORD}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "tester"
|
||||
assert ACCESS_COOKIE in response.cookies
|
||||
assert REFRESH_COOKIE in response.cookies
|
||||
|
||||
# Beide Cookies sind httpOnly und auf SameSite=Lax gesetzt.
|
||||
header = "; ".join(response.headers.get_list("set-cookie"))
|
||||
assert header.count("HttpOnly") == 2
|
||||
assert header.count("SameSite=lax") == 2
|
||||
|
||||
|
||||
async def test_anmeldung_ist_unabhaengig_von_der_gross_schreibung(
|
||||
client: AsyncClient, user: AppUser
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": "TESTER", "password": TEST_PASSWORD}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_falsches_passwort_wird_abgewiesen(client: AsyncClient, user: AppUser) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": "tester", "password": "falsch"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "invalid_credentials"
|
||||
assert ACCESS_COOKIE not in response.cookies
|
||||
|
||||
|
||||
async def test_unbekannter_benutzer_erhaelt_dieselbe_meldung(client: AsyncClient) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": "gibtesnicht", "password": "egal"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "invalid_credentials"
|
||||
|
||||
|
||||
async def test_geschuetzte_route_ohne_anmeldung(client: AsyncClient) -> None:
|
||||
response = await client.get("/api/accounts")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "not_authenticated"
|
||||
|
||||
|
||||
async def test_geschuetzte_route_mit_anmeldung(auth_client: AsyncClient) -> None:
|
||||
assert (await auth_client.get("/api/accounts")).status_code == 200
|
||||
|
||||
|
||||
async def test_me_liefert_den_angemeldeten_benutzer(auth_client: AsyncClient) -> None:
|
||||
body = (await auth_client.get("/api/me")).json()
|
||||
|
||||
assert body["username"] == "tester"
|
||||
assert body["email"] == "tester@example.com"
|
||||
assert body["must_change_password"] is False
|
||||
assert "password_hash" not in body
|
||||
|
||||
|
||||
async def test_systemendpunkte_bleiben_offen(client: AsyncClient) -> None:
|
||||
assert (await client.get("/api/health")).status_code == 200
|
||||
assert (await client.get("/api/version")).status_code == 200
|
||||
|
||||
|
||||
async def test_refresh_rotiert_das_token(auth_client: AsyncClient, session: AsyncSession) -> None:
|
||||
altes_token = auth_client.cookies[REFRESH_COOKIE]
|
||||
|
||||
response = await auth_client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert auth_client.cookies[REFRESH_COOKIE] != altes_token
|
||||
|
||||
tokens = (await session.execute(select(RefreshToken))).scalars().all()
|
||||
assert len(tokens) == 2
|
||||
assert sum(1 for token in tokens if token.revoked_at is None) == 1
|
||||
|
||||
|
||||
async def test_wiederverwendetes_refresh_token_beendet_alle_sitzungen(
|
||||
auth_client: AsyncClient, session: AsyncSession
|
||||
) -> None:
|
||||
altes_token = auth_client.cookies[REFRESH_COOKIE]
|
||||
await auth_client.post("/api/auth/refresh")
|
||||
|
||||
# Das bereits verbrauchte Token noch einmal einlösen.
|
||||
auth_client.cookies.set(REFRESH_COOKIE, altes_token, path="/api/auth")
|
||||
response = await auth_client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "token_reused"
|
||||
|
||||
tokens = (await session.execute(select(RefreshToken))).scalars().all()
|
||||
assert all(token.revoked_at is not None for token in tokens)
|
||||
|
||||
|
||||
async def test_refresh_ohne_cookie(client: AsyncClient) -> None:
|
||||
response = await client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "not_authenticated"
|
||||
|
||||
|
||||
async def test_abmelden_loescht_die_cookies(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.post("/api/auth/logout")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not auth_client.cookies.get(ACCESS_COOKIE)
|
||||
assert (await auth_client.get("/api/accounts")).status_code == 401
|
||||
|
||||
|
||||
async def test_passwortwechsel_beendet_alle_sitzungen(
|
||||
auth_client: AsyncClient, session: AsyncSession, user: AppUser
|
||||
) -> None:
|
||||
response = await auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": TEST_PASSWORD, "new_password": "noch-sicherer-456"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tokens = (await session.execute(select(RefreshToken))).scalars().all()
|
||||
assert all(token.revoked_at is not None for token in tokens)
|
||||
|
||||
# Anmeldung nur noch mit dem neuen Passwort.
|
||||
assert (
|
||||
await auth_client.post(
|
||||
"/api/auth/login", json={"username": "tester", "password": TEST_PASSWORD}
|
||||
)
|
||||
).status_code == 401
|
||||
assert (
|
||||
await auth_client.post(
|
||||
"/api/auth/login", json={"username": "tester", "password": "noch-sicherer-456"}
|
||||
)
|
||||
).status_code == 200
|
||||
|
||||
|
||||
async def test_passwortwechsel_prueft_das_alte_passwort(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "falsch", "new_password": "noch-sicherer-456"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "invalid_credentials"
|
||||
|
||||
|
||||
async def test_zu_kurzes_passwort_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": TEST_PASSWORD, "new_password": "kurz"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "validation_error"
|
||||
|
||||
|
||||
async def test_gleiches_passwort_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": TEST_PASSWORD, "new_password": TEST_PASSWORD},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "password_unchanged"
|
||||
|
||||
|
||||
async def test_erzwungener_passwortwechsel_sperrt_die_fachrouten(
|
||||
client: AsyncClient, session: AsyncSession
|
||||
) -> None:
|
||||
session.add(
|
||||
AppUser(
|
||||
username="neuling",
|
||||
password_hash=hash_password("start-passwort-1"),
|
||||
must_change_password=True,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
await client.post(
|
||||
"/api/auth/login", json={"username": "neuling", "password": "start-passwort-1"}
|
||||
)
|
||||
|
||||
# /api/me bleibt erreichbar, damit die Oberfläche den Zustand erkennt.
|
||||
me = await client.get("/api/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["must_change_password"] is True
|
||||
|
||||
gesperrt = await client.get("/api/accounts")
|
||||
assert gesperrt.status_code == 403
|
||||
assert gesperrt.json()["code"] == "password_change_required"
|
||||
|
||||
# Nach dem Wechsel ist der Zugriff frei.
|
||||
await client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "start-passwort-1", "new_password": "richtig-sicher-9"},
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login", json={"username": "neuling", "password": "richtig-sicher-9"}
|
||||
)
|
||||
assert (await client.get("/api/accounts")).status_code == 200
|
||||
Reference in New Issue
Block a user