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:
@@ -10,13 +10,16 @@ os.environ.setdefault(
|
||||
"postgresql+asyncpg://moneyfy:moneyfy@127.0.0.1:5432/moneyfy_test",
|
||||
),
|
||||
)
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
|
||||
os.environ.setdefault("ENVIRONMENT", "test")
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
@@ -24,9 +27,14 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import get_session
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Base
|
||||
from app.models import Account, AppUser, Base, Category
|
||||
from app.models.enums import AccountType
|
||||
from app.services.seed import seed_all
|
||||
|
||||
TEST_PASSWORD = "sicher-genug-123"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -76,3 +84,55 @@ async def client(session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user(session: AsyncSession) -> AppUser:
|
||||
"""Ein angemeldefähiger Benutzer mit bekanntem Passwort."""
|
||||
account = AppUser(
|
||||
username="tester",
|
||||
email="tester@example.com",
|
||||
password_hash=hash_password(TEST_PASSWORD),
|
||||
must_change_password=False,
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
return account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(client: AsyncClient, user: AppUser) -> AsyncClient:
|
||||
"""Bereits angemeldeter Client – die Cookies bleiben am Client hängen."""
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": user.username, "password": TEST_PASSWORD}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded(session: AsyncSession) -> dict[str, int]:
|
||||
"""Kategoriebaum plus ein Konto – die Grundlage der meisten Integrationstests."""
|
||||
await seed_all(session)
|
||||
|
||||
account = Account(
|
||||
name="Girokonto",
|
||||
type=AccountType.CHECKING,
|
||||
opening_balance=Decimal("1000.00"),
|
||||
opening_balance_date=date(2026, 1, 1),
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
|
||||
async def category_id(name: str) -> int:
|
||||
stmt = select(Category).where(Category.name == name)
|
||||
return (await session.execute(stmt)).scalars().first().id
|
||||
|
||||
return {
|
||||
"account_id": account.id,
|
||||
"streaming": await category_id("Streaming"),
|
||||
"miete": await category_id("Miete"),
|
||||
"kredite": await category_id("Kredite"),
|
||||
"gehalt": await category_id("Gehalt"),
|
||||
"lebensmittel": await category_id("Lebensmittel"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Integrationstests der CRUD-Endpunkte."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
# --- Konten --------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_konto_lebenszyklus(auth_client: AsyncClient) -> None:
|
||||
angelegt = await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
"name": "Girokonto",
|
||||
"type": "checking",
|
||||
"iban_last4": "4711",
|
||||
"opening_balance": "1500.00",
|
||||
"opening_balance_date": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert angelegt.status_code == 201
|
||||
konto = angelegt.json()
|
||||
assert konto["opening_balance"] == "1500.00"
|
||||
|
||||
geaendert = await auth_client.patch(
|
||||
f"/api/accounts/{konto['id']}", json={"name": "Gehaltskonto", "sort_order": 3}
|
||||
)
|
||||
assert geaendert.status_code == 200
|
||||
assert geaendert.json()["name"] == "Gehaltskonto"
|
||||
assert geaendert.json()["iban_last4"] == "4711" # unverändert
|
||||
|
||||
liste = (await auth_client.get("/api/accounts")).json()
|
||||
assert [item["name"] for item in liste] == ["Gehaltskonto"]
|
||||
|
||||
geloescht = await auth_client.delete(f"/api/accounts/{konto['id']}")
|
||||
assert geloescht.status_code == 200
|
||||
assert (await auth_client.get(f"/api/accounts/{konto['id']}")).status_code == 404
|
||||
|
||||
|
||||
async def test_doppelter_kontoname_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
payload = {"name": "Girokonto", "opening_balance_date": "2026-01-01"}
|
||||
assert (await auth_client.post("/api/accounts", json=payload)).status_code == 201
|
||||
|
||||
zweites = await auth_client.post("/api/accounts", json=payload)
|
||||
assert zweites.status_code == 409
|
||||
assert zweites.json()["code"] == "conflict"
|
||||
|
||||
|
||||
async def test_unbekanntes_feld_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={"name": "X", "opening_balance_date": "2026-01-01", "tippfehler": 1},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "validation_error"
|
||||
|
||||
|
||||
async def test_ungueltige_farbe_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={"name": "X", "opening_balance_date": "2026-01-01", "color": "blau"},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
# --- Kategorien ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_kategoriebaum_ist_zweistufig(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
baum = (await auth_client.get("/api/categories")).json()
|
||||
|
||||
wohnen = next(item for item in baum if item["name"] == "Wohnen")
|
||||
assert wohnen["parent_id"] is None
|
||||
assert {kind["name"] for kind in wohnen["children"]} == {
|
||||
"Miete",
|
||||
"Nebenkosten",
|
||||
"Strom",
|
||||
"Internet",
|
||||
"Rundfunkbeitrag",
|
||||
}
|
||||
assert next(k for k in wohnen["children"] if k["name"] == "Miete")["is_fixed_cost"] is True
|
||||
|
||||
|
||||
async def test_dritte_ebene_wird_verweigert(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/categories",
|
||||
json={"name": "Netflix", "kind": "expense", "parent_id": seeded["streaming"]},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "category_too_deep"
|
||||
|
||||
|
||||
async def test_unterkategorie_erbt_die_richtung(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
baum = (await auth_client.get("/api/categories")).json()
|
||||
wohnen = next(item for item in baum if item["name"] == "Wohnen")
|
||||
|
||||
# Absichtlich die falsche Richtung mitschicken – der Elternknoten gewinnt.
|
||||
antwort = await auth_client.post(
|
||||
"/api/categories",
|
||||
json={"name": "Gartenpflege", "kind": "income", "parent_id": wohnen["id"]},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 201
|
||||
assert antwort.json()["kind"] == "expense"
|
||||
|
||||
|
||||
async def test_verwendete_kategorie_kann_nicht_geloescht_werden(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Miete",
|
||||
"category_id": seeded["miete"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "950.00",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
antwort = await auth_client.delete(f"/api/categories/{seeded['miete']}")
|
||||
assert antwort.status_code == 409
|
||||
assert antwort.json()["code"] == "category_in_use"
|
||||
|
||||
|
||||
# --- Firmen --------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_firma_wird_normalisiert(auth_client: AsyncClient) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/merchants", json={"name": "Netflix International B.V.", "domain": "netflix.com"}
|
||||
)
|
||||
|
||||
assert antwort.status_code == 201
|
||||
firma = antwort.json()
|
||||
assert firma["normalized_name"] == "netflixinternational"
|
||||
assert firma["logo_status"] == "pending"
|
||||
assert firma["logo_asset_id"] is None
|
||||
|
||||
|
||||
async def test_firmensuche(auth_client: AsyncClient) -> None:
|
||||
for name in ["Netflix", "Spotify", "Deutsche Telekom AG"]:
|
||||
await auth_client.post("/api/merchants", json={"name": name})
|
||||
|
||||
treffer = (await auth_client.get("/api/merchants", params={"q": "telekom"})).json()
|
||||
assert treffer["total"] == 1
|
||||
assert treffer["items"][0]["name"] == "Deutsche Telekom AG"
|
||||
|
||||
alle = (await auth_client.get("/api/merchants")).json()
|
||||
assert alle["total"] == 3
|
||||
|
||||
|
||||
async def test_doppelte_firma_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
await auth_client.post("/api/merchants", json={"name": "Netflix"})
|
||||
|
||||
# Andere Schreibweise, gleicher normalisierter Name.
|
||||
antwort = await auth_client.post("/api/merchants", json={"name": "NETFLIX"})
|
||||
assert antwort.status_code == 409
|
||||
|
||||
|
||||
async def test_firma_loeschen_loest_verweise(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
firma = (await auth_client.post("/api/merchants", json={"name": "Rewe"})).json()
|
||||
buchung = (
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Wocheneinkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"merchant_id": firma["id"],
|
||||
"amount": "84.30",
|
||||
"booking_date": "2026-03-05",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert (await auth_client.delete(f"/api/merchants/{firma['id']}")).status_code == 200
|
||||
danach = (await auth_client.get(f"/api/transactions/{buchung['id']}")).json()
|
||||
assert danach["merchant_id"] is None
|
||||
|
||||
|
||||
# --- Buchungen -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_buchung_mit_falscher_kategorierichtung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "income",
|
||||
"title": "Falsch einsortiert",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "10.00",
|
||||
"booking_date": "2026-03-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "category_kind_mismatch"
|
||||
|
||||
|
||||
async def test_buchungen_filtern_und_blaettern(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
for tag in range(1, 6):
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": f"Einkauf {tag}",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "20.00",
|
||||
"booking_date": f"2026-03-0{tag}",
|
||||
},
|
||||
)
|
||||
|
||||
seite = (await auth_client.get("/api/transactions", params={"limit": 2, "offset": 0})).json()
|
||||
assert seite["total"] == 5
|
||||
assert len(seite["items"]) == 2
|
||||
# Neueste zuerst.
|
||||
assert seite["items"][0]["booking_date"] == "2026-03-05"
|
||||
|
||||
zeitraum = (
|
||||
await auth_client.get(
|
||||
"/api/transactions", params={"from": "2026-03-02", "to": "2026-03-03"}
|
||||
)
|
||||
).json()
|
||||
assert zeitraum["total"] == 2
|
||||
|
||||
suche = (await auth_client.get("/api/transactions", params={"q": "Einkauf 4"})).json()
|
||||
assert suche["total"] == 1
|
||||
|
||||
|
||||
# --- Budgets und Sparziele -----------------------------------------------------
|
||||
|
||||
|
||||
async def test_budget_wird_auf_den_monatsersten_normalisiert(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/budgets",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"period_month": "2026-03-17",
|
||||
"limit_amount": "450.00",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 201
|
||||
assert antwort.json()["period_month"] == "2026-03-01"
|
||||
|
||||
|
||||
async def test_budget_pro_kategorie_und_monat_nur_einmal(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
payload = {
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"period_month": "2026-03-01",
|
||||
"limit_amount": "450.00",
|
||||
}
|
||||
await auth_client.post("/api/budgets", json=payload)
|
||||
|
||||
zweites = await auth_client.post("/api/budgets", json=payload)
|
||||
assert zweites.status_code == 409
|
||||
|
||||
|
||||
async def test_budgetvorlage(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/budget-templates",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"valid_from": "2026-01-15",
|
||||
"limit_amount": "500.00",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 201
|
||||
assert antwort.json()["valid_from"] == "2026-01-01"
|
||||
assert antwort.json()["valid_until"] is None
|
||||
|
||||
|
||||
async def test_sparziel_lebenszyklus(auth_client: AsyncClient) -> None:
|
||||
angelegt = await auth_client.post(
|
||||
"/api/savings-goals",
|
||||
json={"name": "Neues Fahrrad", "target_amount": "1800.00", "target_date": "2027-04-01"},
|
||||
)
|
||||
assert angelegt.status_code == 201
|
||||
ziel = angelegt.json()
|
||||
assert ziel["current_amount"] == "0.00"
|
||||
|
||||
geaendert = await auth_client.patch(
|
||||
f"/api/savings-goals/{ziel['id']}", json={"current_amount": "450.00"}
|
||||
)
|
||||
assert geaendert.json()["current_amount"] == "450.00"
|
||||
|
||||
await auth_client.patch(f"/api/savings-goals/{ziel['id']}", json={"is_archived": True})
|
||||
assert (await auth_client.get("/api/savings-goals")).json() == []
|
||||
assert (
|
||||
len((await auth_client.get("/api/savings-goals", params={"include_archived": True})).json())
|
||||
== 1
|
||||
)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Integrationstests der Fälligkeiten-Overlays und der Saldoberechnung."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def abo(client: AsyncClient, seeded: dict, **overrides) -> dict:
|
||||
payload = {
|
||||
"kind": "expense",
|
||||
"title": "Netflix",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "13.99",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
|
||||
"dtstart": "2026-01-15",
|
||||
"business_day_shift": "none",
|
||||
}
|
||||
payload.update(overrides)
|
||||
antwort = await client.post("/api/recurrences", json=payload)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
return antwort.json()
|
||||
|
||||
|
||||
async def test_bestaetigen_ohne_betrag_uebernimmt_das_soll(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
body = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert body["actual_amount"] == "13.99"
|
||||
assert body["effective_amount"] == "13.99"
|
||||
assert body["status"] == "confirmed"
|
||||
|
||||
|
||||
async def test_auslassen_nimmt_die_faelligkeit_aus_der_rechnung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
body = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/skip",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"note": "Monat geschenkt",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
assert body["status"] == "skipped"
|
||||
assert body["effective_amount"] == "0.00"
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-02-01"})).json()
|
||||
assert report["planned"]["expenses"] == "0.00"
|
||||
assert report["skipped_count"] == 1
|
||||
|
||||
|
||||
async def test_zuruecksetzen_entfernt_die_abweichung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "20.00",
|
||||
},
|
||||
)
|
||||
|
||||
zurueck = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/reset",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert zurueck["status"] == "planned"
|
||||
assert zurueck["actual_amount"] is None
|
||||
assert zurueck["occurrence_id"] is None
|
||||
|
||||
|
||||
async def test_bestaetigung_an_einem_termin_ohne_faelligkeit(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-16"},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "occurrence_not_due"
|
||||
|
||||
|
||||
async def test_nominales_datum_bleibt_schluessel_trotz_verschiebung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Der 01.02.2026 ist ein Sonntag; der Zahltag rutscht auf den 02.02."""
|
||||
posten = await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-02-01",
|
||||
business_day_shift="next",
|
||||
)
|
||||
|
||||
faelligkeiten = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
assert faelligkeiten[0]["nominal_date"] == "2026-02-01"
|
||||
assert faelligkeiten[0]["due_date"] == "2026-02-02"
|
||||
|
||||
# Bestätigt wird über das nominale Datum.
|
||||
bestaetigt = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-01"},
|
||||
)
|
||||
assert bestaetigt.status_code == 200
|
||||
assert bestaetigt.json()["due_date"] == "2026-02-02"
|
||||
|
||||
|
||||
async def test_verschobene_faelligkeit_zaehlt_im_zahlmonat(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Der 31.05.2026 ist ein Sonntag – die Zahlung gehört in den Juni."""
|
||||
await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=-1",
|
||||
dtstart="2026-01-31",
|
||||
business_day_shift="next",
|
||||
)
|
||||
|
||||
mai = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-05-01", "to": "2026-05-31"})
|
||||
).json()
|
||||
juni = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-06-01", "to": "2026-06-30"})
|
||||
).json()
|
||||
|
||||
assert [item["nominal_date"] for item in mai] == []
|
||||
assert [item["nominal_date"] for item in juni] == ["2026-05-31", "2026-06-30"]
|
||||
|
||||
# Nach nominalem Datum gefiltert sieht es anders aus.
|
||||
nominal = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-05-01", "to": "2026-05-31", "by_due_date": False},
|
||||
)
|
||||
).json()
|
||||
assert [item["nominal_date"] for item in nominal] == ["2026-05-31"]
|
||||
|
||||
|
||||
async def test_faelligkeiten_lassen_sich_filtern(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await abo(auth_client, seeded)
|
||||
await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Gehalt",
|
||||
kind="income",
|
||||
category_id=seeded["gehalt"],
|
||||
amount="3000.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
|
||||
dtstart="2026-01-28",
|
||||
)
|
||||
|
||||
einkuenfte = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-02-01", "to": "2026-02-28", "kind": "income"},
|
||||
)
|
||||
).json()
|
||||
assert [item["recurrence_title"] for item in einkuenfte] == ["Gehalt"]
|
||||
|
||||
alle = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
assert len(alle) == 2
|
||||
# Sortiert nach tatsächlichem Zahltag.
|
||||
assert [item["effective_date"] for item in alle] == ["2026-02-15", "2026-02-28"]
|
||||
|
||||
|
||||
async def test_deaktivierte_posten_erscheinen_nicht(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.patch(f"/api/recurrences/{posten['id']}", json={"is_active": False})
|
||||
|
||||
ohne = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
mit = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-02-01", "to": "2026-02-28", "include_inactive": True},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert ohne == []
|
||||
assert len(mit) == 1
|
||||
|
||||
|
||||
# --- Kontosalden ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_saldo_beruecksichtigt_buchungen_und_bestaetigungen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Wocheneinkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": konto,
|
||||
"amount": "84.30",
|
||||
"booking_date": "2026-02-05",
|
||||
},
|
||||
)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "13.99",
|
||||
},
|
||||
)
|
||||
|
||||
saldo = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-28"})
|
||||
).json()
|
||||
|
||||
assert saldo["opening_balance"] == "1000.00"
|
||||
assert saldo["booked_transactions"] == "-84.30"
|
||||
assert saldo["booked_occurrences"] == "-13.99"
|
||||
assert saldo["balance"] == "901.71"
|
||||
|
||||
|
||||
async def test_saldo_zaehlt_nur_bis_zum_stichtag(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
|
||||
davor = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-14"})
|
||||
).json()
|
||||
danach = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-15"})
|
||||
).json()
|
||||
|
||||
assert davor["balance"] == "1000.00"
|
||||
assert danach["balance"] == "986.01"
|
||||
|
||||
|
||||
async def test_saldo_folgt_dem_istdatum(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
"""Wird eine Zahlung später erfasst, zählt sie erst ab dem Ist-Datum."""
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_date": "2026-03-02",
|
||||
},
|
||||
)
|
||||
|
||||
februar = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-28"})
|
||||
).json()
|
||||
maerz = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-03-31"})
|
||||
).json()
|
||||
|
||||
assert februar["balance"] == "1000.00"
|
||||
assert maerz["balance"] == "986.01"
|
||||
|
||||
|
||||
async def test_geplante_faelligkeiten_veraendern_den_saldo_nicht(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
konto = seeded["account_id"]
|
||||
await abo(auth_client, seeded)
|
||||
|
||||
saldo = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-12-31"})
|
||||
).json()
|
||||
|
||||
assert saldo["balance"] == "1000.00"
|
||||
|
||||
|
||||
async def test_abweichendes_konto_bei_der_bestaetigung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
zweitkonto = (
|
||||
await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
"name": "Kreditkarte",
|
||||
"type": "credit_card",
|
||||
"opening_balance": "0.00",
|
||||
"opening_balance_date": "2026-01-01",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"account_id": zweitkonto["id"],
|
||||
},
|
||||
)
|
||||
|
||||
giro = (
|
||||
await auth_client.get(
|
||||
f"/api/accounts/{seeded['account_id']}/balance", params={"as_of": "2026-12-31"}
|
||||
)
|
||||
).json()
|
||||
karte = (
|
||||
await auth_client.get(
|
||||
f"/api/accounts/{zweitkonto['id']}/balance", params={"as_of": "2026-12-31"}
|
||||
)
|
||||
).json()
|
||||
|
||||
assert giro["balance"] == "1000.00"
|
||||
assert karte["balance"] == "-13.99"
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Integrationstests: Posten anlegen, Fälligkeiten abrufen, bestätigen, Monatsreport."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def anlegen(client: AsyncClient, seeded: dict, **overrides) -> dict:
|
||||
"""Legt ein Abo an; einzelne Felder lassen sich überschreiben."""
|
||||
payload = {
|
||||
"kind": "expense",
|
||||
"title": "Netflix Standard",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "13.99",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
|
||||
"dtstart": "2026-01-15",
|
||||
"business_day_shift": "none",
|
||||
}
|
||||
payload.update(overrides)
|
||||
antwort = await client.post("/api/recurrences", json=payload)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
return antwort.json()
|
||||
|
||||
|
||||
# --- Der geforderte Durchstich -------------------------------------------------
|
||||
|
||||
|
||||
async def test_durchstich_anlegen_abrufen_bestaetigen_report(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Recurrence anlegen → Fälligkeiten abrufen → mit abweichendem Betrag
|
||||
bestätigen → der Monatsreport weist das Ist aus."""
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
assert posten["amount"] == "13.99"
|
||||
assert len(posten["amount_versions"]) == 1
|
||||
assert posten["annual_burden"] == "167.88" # zwölf Monate à 13,99
|
||||
|
||||
# Fälligkeiten des ersten Quartals abrufen.
|
||||
faelligkeiten = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-01-01", "to": "2026-03-31"})
|
||||
).json()
|
||||
assert [item["nominal_date"] for item in faelligkeiten] == [
|
||||
"2026-01-15",
|
||||
"2026-02-15",
|
||||
"2026-03-15",
|
||||
]
|
||||
assert all(item["status"] == "planned" for item in faelligkeiten)
|
||||
assert all(item["occurrence_id"] is None for item in faelligkeiten)
|
||||
|
||||
# Februar mit abweichendem Betrag und Datum bestätigen.
|
||||
bestaetigt = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "15.49",
|
||||
"actual_date": "2026-02-17",
|
||||
"note": "Preis erhöht",
|
||||
},
|
||||
)
|
||||
assert bestaetigt.status_code == 200
|
||||
body = bestaetigt.json()
|
||||
assert body["status"] == "confirmed"
|
||||
assert body["amount"] == "13.99" # Soll bleibt stehen
|
||||
assert body["actual_amount"] == "15.49"
|
||||
assert body["effective_amount"] == "15.49"
|
||||
assert body["effective_date"] == "2026-02-17"
|
||||
assert body["occurrence_id"] is not None
|
||||
|
||||
# Der Monatsreport weist Soll und Ist getrennt aus.
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-02-01"})).json()
|
||||
assert report["month"] == "2026-02-01"
|
||||
assert report["planned"]["expenses"] == "13.99"
|
||||
assert report["actual"]["expenses"] == "15.49"
|
||||
assert report["confirmed_count"] == 1
|
||||
assert report["open_count"] == 0
|
||||
|
||||
# Im Januar ist nichts bestätigt – dort bleibt das Ist leer.
|
||||
januar = (await auth_client.get("/api/reports/month", params={"month": "2026-01-01"})).json()
|
||||
assert januar["planned"]["expenses"] == "13.99"
|
||||
assert januar["actual"]["expenses"] == "0.00"
|
||||
assert januar["open_count"] == 1
|
||||
|
||||
|
||||
async def test_report_verrechnet_einkuenfte_und_fixkosten(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Gehalt",
|
||||
kind="income",
|
||||
category_id=seeded["gehalt"],
|
||||
amount="3200.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
|
||||
dtstart="2026-01-28",
|
||||
)
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Miete",
|
||||
category_id=seeded["miete"],
|
||||
amount="950.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
)
|
||||
await anlegen(auth_client, seeded) # Netflix – Abos zählen laut Seed als Fixkosten
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Wocheneinkauf",
|
||||
category_id=seeded["lebensmittel"],
|
||||
amount="120.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=5",
|
||||
dtstart="2026-01-05",
|
||||
is_variable=True,
|
||||
)
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-03-10"})).json()
|
||||
|
||||
assert report["planned"]["income"] == "3200.00"
|
||||
assert report["planned"]["expenses"] == "1083.99"
|
||||
assert report["planned"]["balance"] == "2116.01"
|
||||
# Miete und Netflix sind Fixkostenkategorien, Lebensmittel nicht.
|
||||
assert report["fixed_costs"] == "963.99"
|
||||
assert report["variable_costs"] == "120.00"
|
||||
assert report["available_after_fixed"] == "2236.01"
|
||||
|
||||
|
||||
async def test_report_vergleicht_mit_dem_vormonat(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(auth_client, seeded, amount="10.00")
|
||||
await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "20.00", "valid_from": "2026-03-01"},
|
||||
)
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-03-01"})).json()
|
||||
|
||||
assert report["planned"]["expenses"] == "20.00"
|
||||
assert report["previous_planned"]["expenses"] == "10.00"
|
||||
assert report["delta_to_previous"]["expenses"] == "10.00"
|
||||
assert report["delta_to_previous"]["balance"] == "-10.00"
|
||||
|
||||
|
||||
# --- Anlegen und Prüfen --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ungueltige_rrule_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Kaputt",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "5.00",
|
||||
"rrule": "FREQ=QUARTERLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "validation_error"
|
||||
|
||||
|
||||
async def test_dtstart_in_der_rrule_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Mit DTSTART",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "5.00",
|
||||
"rrule": "DTSTART=20260101;FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
async def test_falsche_kategorierichtung_wird_abgewiesen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "income",
|
||||
"title": "Gehalt in Ausgabenkategorie",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "3000.00",
|
||||
"rrule": "FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "category_kind_mismatch"
|
||||
|
||||
|
||||
async def test_negativer_betrag_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Negativ",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "-5.00",
|
||||
"rrule": "FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
# --- Preisversionen ------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_preisversion_wirkt_ab_dem_stichtag(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(auth_client, seeded, amount="9.99")
|
||||
|
||||
await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "13.99", "valid_from": "2026-04-01", "note": "Preiserhöhung"},
|
||||
)
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2026-06-30"},
|
||||
)
|
||||
).json()
|
||||
|
||||
betraege = {item["nominal_date"]: item["amount"] for item in vorschau}
|
||||
assert betraege["2026-03-15"] == "9.99"
|
||||
assert betraege["2026-04-15"] == "13.99"
|
||||
|
||||
# Der Basisbetrag folgt der jüngsten Version.
|
||||
detail = (await auth_client.get(f"/api/recurrences/{posten['id']}")).json()
|
||||
assert detail["amount"] == "13.99"
|
||||
assert len(detail["amount_versions"]) == 2
|
||||
|
||||
|
||||
async def test_doppelte_preisversion_wird_abgewiesen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "20.00", "valid_from": "2026-01-15"},
|
||||
)
|
||||
assert antwort.status_code == 409
|
||||
|
||||
|
||||
# --- Vorschau, Raten, Verträge -------------------------------------------------
|
||||
|
||||
|
||||
async def test_vorschau_liefert_die_naechsten_termine(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2026-03-31"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert [item["nominal_date"] for item in vorschau] == [
|
||||
"2026-01-15",
|
||||
"2026-02-15",
|
||||
"2026-03-15",
|
||||
]
|
||||
assert vorschau[0]["recurrence_title"] == "Netflix Standard"
|
||||
|
||||
|
||||
async def test_kredit_zeigt_restschuld_und_raten(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Autokredit",
|
||||
category_id=seeded["kredite"],
|
||||
amount="250.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
installments_total=36,
|
||||
principal_amount="9000.00",
|
||||
)
|
||||
|
||||
assert posten["installments"]["total"] == 36
|
||||
assert posten["installments"]["final_due_date"] == "2028-12-01"
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2032-12-31"},
|
||||
)
|
||||
).json()
|
||||
assert len(vorschau) == 36
|
||||
assert vorschau[0]["installment_number"] == 1
|
||||
assert vorschau[-1]["installment_number"] == 36
|
||||
|
||||
|
||||
async def test_vertrag_kuendigen_beendet_die_serie(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Handyvertrag",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-03-01",
|
||||
contract_start="2026-03-01",
|
||||
contract_min_term_months=24,
|
||||
contract_notice_period_days=90,
|
||||
contract_auto_renew_months=12,
|
||||
)
|
||||
|
||||
assert posten["contract_term"]["term_end"] == "2028-02-29"
|
||||
assert posten["contract_term"]["notice_deadline"] == "2027-12-01"
|
||||
assert posten["contract_term"]["renews_on"] == "2028-03-01"
|
||||
|
||||
gekuendigt = (await auth_client.post(f"/api/recurrences/{posten['id']}/cancel")).json()
|
||||
assert gekuendigt["contract_cancelled_at"] == "2028-02-29"
|
||||
assert gekuendigt["contract_term"]["is_cancelled"] is True
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2030-12-31"},
|
||||
)
|
||||
).json()
|
||||
assert vorschau[-1]["nominal_date"] == "2028-02-01"
|
||||
|
||||
|
||||
async def test_ruecklage_wird_ausgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Kfz-Versicherung",
|
||||
amount="612.00",
|
||||
rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15",
|
||||
dtstart="2026-01-15",
|
||||
reserve_enabled=True,
|
||||
)
|
||||
|
||||
assert posten["monthly_reserve"] == "51.00"
|
||||
assert posten["annual_burden"] == "612.00"
|
||||
@@ -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