Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints): Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.). Pragmatische Limits: - Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000 - Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100 - Hund-Name/Rasse: 80 · Hund-Bio: 2000 Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py, notes.py, auth.py, profile.py. Manuelle len()-Checks in profile, chat, ki entfernt (jetzt durch Field abgedeckt). PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail): - test_security.py: require_owner (Places GET/PATCH/DELETE mit Fremduser → 403), JWT-Blacklist (Logout invalidiert Token), Login-Lockout (5 Fehlversuche → 429 + Retry-After Header) - test_race.py: Invoice-Counter (20 parallele Threads, alle unique), Founder-Number (atomare Vergabe, voll bei 100) - test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text 50k → 422 (verifiziert Pydantic max_length-Sweep) A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast): - #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44 - dog-profile Wrapped-Slider Prev/Next 40→44 - forum-Lightbox Close 40→44 - --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS) - --c-text-muted Dark: #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS) - Branding-Farben unangetastet
This commit is contained in:
parent
7751d303bb
commit
1ff66a7083
57 changed files with 1253 additions and 612 deletions
233
tests/test_race.py
Normal file
233
tests/test_race.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Race-Condition-Tests fuer atomare Counter (invoice_number, founder_number).
|
||||
|
||||
Diese Tests pruefen, dass die in Sprint60 eingefuehrten Race-Schutze
|
||||
(invoice_counters-Tabelle + atomare SQL-UPDATEs) tatsaechlich eindeutige
|
||||
Werte vergeben, wenn mehrere Threads gleichzeitig zugreifen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import secrets
|
||||
import threading
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# 1) Invoice-Counter — atomare Rechnungsnummern-Vergabe
|
||||
# ==================================================================
|
||||
class TestInvoiceCounterRace:
|
||||
"""_next_invoice_number() in routes/invoices.py nutzt:
|
||||
- dedizierte invoice_counters-Tabelle
|
||||
- BEGIN IMMEDIATE + busy_timeout
|
||||
- SQLite serialisiert Writer
|
||||
|
||||
Test: 20 parallele Aufrufe -> 20 EINDEUTIGE Nummern."""
|
||||
|
||||
def test_parallel_invoice_numbers_are_unique(self):
|
||||
from database import db
|
||||
from routes.invoices import _next_invoice_number
|
||||
|
||||
N = 20
|
||||
|
||||
# Counter fuer Pruefung zuruecksetzen — sonst koennten andere Tests
|
||||
# die Sequence schon hochgezaehlt haben (Konflikt vermeiden wir
|
||||
# ueber einen frischen, einzigartigen Prefix pro Testlauf).
|
||||
prefix = "TEST-" + secrets.token_hex(3).upper()
|
||||
|
||||
results: list[str] = []
|
||||
errors: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
try:
|
||||
with db() as conn:
|
||||
n = _next_invoice_number(conn, prefix=prefix)
|
||||
with lock:
|
||||
results.append(n)
|
||||
except Exception as exc:
|
||||
with lock:
|
||||
errors.append(repr(exc))
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=N) as pool:
|
||||
futures = [pool.submit(worker) for _ in range(N)]
|
||||
concurrent.futures.wait(futures)
|
||||
|
||||
assert not errors, f"Fehler in Threads: {errors}"
|
||||
assert len(results) == N, f"Erwartete {N} Ergebnisse, bekam {len(results)}"
|
||||
# Duplikate?
|
||||
assert len(set(results)) == N, (
|
||||
f"DUPLIKATE in vergebenen Nummern! {len(results)-len(set(results))} "
|
||||
f"Kollisionen. Beispiele: {results}"
|
||||
)
|
||||
|
||||
def test_invoice_counter_increments_monotonically(self):
|
||||
"""Ohne Parallelitaet muss next_num strikt um 1 steigen."""
|
||||
from database import db
|
||||
from routes.invoices import _next_invoice_number
|
||||
|
||||
prefix = "MONO-" + secrets.token_hex(3).upper()
|
||||
|
||||
nums = []
|
||||
for _ in range(5):
|
||||
with db() as conn:
|
||||
nums.append(_next_invoice_number(conn, prefix=prefix))
|
||||
|
||||
# letzte Komponente aus z. B. "MONO-XYZ-2026-0001"
|
||||
tails = [int(n.split("-")[-1]) for n in nums]
|
||||
assert tails == [1, 2, 3, 4, 5], (
|
||||
f"Counter steigt nicht monoton: {tails}"
|
||||
)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# 2) Founder-Number — atomare Gruender-Vergabe via partner.py
|
||||
# ==================================================================
|
||||
class TestFounderNumberAtomic:
|
||||
"""partner.py setzt is_founder + founder_number in EINEM UPDATE,
|
||||
routes/dogs.py macht dasselbe via atomarem Sub-Query.
|
||||
|
||||
Wir testen die SQL-Logik direkt (ohne HTTP), weil das partner-
|
||||
Endpunkt-Aufruf-Trace fuer Race-Tests zu komplex ist."""
|
||||
|
||||
def _make_pending_user(self, email_prefix: str = "fp") -> int:
|
||||
"""Legt einen User direkt in der DB an, der bereits
|
||||
is_founder_pending=1 hat."""
|
||||
from database import db
|
||||
email = f"{email_prefix}-{secrets.token_hex(4)}@example.com"
|
||||
name = f"founder{secrets.token_hex(3)}"
|
||||
with db() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO users
|
||||
(email, pw_hash, name, referral_code, email_verified, is_founder_pending)
|
||||
VALUES (?, ?, ?, ?, 1, 1)""",
|
||||
(email, "x", name, secrets.token_hex(4))
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
def _atomic_founder_update(self, user_id: int):
|
||||
"""Reproduziert das atomare UPDATE aus routes/dogs.py."""
|
||||
from database import db
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE users
|
||||
SET is_founder = 1,
|
||||
founder_number = (
|
||||
SELECT IFNULL(MAX(founder_number), 0) + 1
|
||||
FROM users WHERE is_founder = 1
|
||||
),
|
||||
is_founder_pending = 0
|
||||
WHERE id = ?
|
||||
AND is_founder_pending = 1
|
||||
AND (is_founder IS NULL OR is_founder = 0)
|
||||
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < 100""",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
def test_parallel_founder_assignments_get_unique_numbers(self):
|
||||
"""Zwei parallele Aufrufe fuer zwei verschiedene Pending-User
|
||||
muessen unterschiedliche founder_numbers bekommen."""
|
||||
from database import db
|
||||
|
||||
ids = [self._make_pending_user("race") for _ in range(2)]
|
||||
|
||||
threads = []
|
||||
errors: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker(uid):
|
||||
try:
|
||||
self._atomic_founder_update(uid)
|
||||
except Exception as exc:
|
||||
with lock:
|
||||
errors.append(repr(exc))
|
||||
|
||||
for uid in ids:
|
||||
t = threading.Thread(target=worker, args=(uid,))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"Fehler: {errors}"
|
||||
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, founder_number FROM users WHERE id IN ({','.join('?'*len(ids))})",
|
||||
ids
|
||||
).fetchall()
|
||||
|
||||
numbers = [r["founder_number"] for r in rows]
|
||||
assert all(n is not None for n in numbers), (
|
||||
f"Mindestens eine founder_number ist NULL: {numbers}"
|
||||
)
|
||||
assert len(set(numbers)) == len(numbers), (
|
||||
f"DUPLIKATE bei founder_number: {numbers}"
|
||||
)
|
||||
|
||||
def test_founder_full_means_no_more_numbers(self, monkeypatch):
|
||||
"""Wenn bereits 100 Founder existieren, vergibt die atomare
|
||||
Logik KEINE neue Nummer mehr (rowcount = 0)."""
|
||||
from database import db
|
||||
|
||||
# Wir koennen nicht einfach 100 Founder anlegen — stattdessen
|
||||
# mocken wir die Limit-Logik durch einen kleinen Limit-Test:
|
||||
# statt < 100 -> < N, in dem wir einen eigenen Test-User
|
||||
# einfuegen und davor genau N existierende Founder anlegen.
|
||||
N_LIMIT = 3
|
||||
|
||||
# Test-Schwelle waehlen: vorhandene Founder im System zaehlen,
|
||||
# dann auf N_LIMIT auffuellen.
|
||||
with db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||||
).fetchone()[0]
|
||||
|
||||
# Wir muessen auf den HARDCODED 100er-Wert in der SQL-Query
|
||||
# vertrauen — also auf 100 auffuellen. Das ist teuer, aber
|
||||
# eindeutig. Wir machen es in einem Insert mit GROUP BY trick.
|
||||
to_create = max(0, 100 - existing)
|
||||
if to_create > 0:
|
||||
with db() as conn:
|
||||
# Bulk-Insert ist am schnellsten
|
||||
conn.executemany(
|
||||
"""INSERT INTO users
|
||||
(email, pw_hash, name, referral_code, email_verified,
|
||||
is_founder, founder_number)
|
||||
VALUES (?, 'x', ?, ?, 1, 1, ?)""",
|
||||
[
|
||||
(
|
||||
f"founder{i}-{secrets.token_hex(3)}@x.test",
|
||||
f"founder{i}-{secrets.token_hex(3)}",
|
||||
secrets.token_hex(4),
|
||||
existing + i + 1,
|
||||
)
|
||||
for i in range(to_create)
|
||||
]
|
||||
)
|
||||
|
||||
# Pruefen: 100 Founder vorhanden
|
||||
with db() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||||
).fetchone()[0]
|
||||
assert count >= 100, f"Setup falsch: nur {count} Founder"
|
||||
|
||||
# Jetzt: ein neuer Pending-User darf KEINE Nummer mehr bekommen
|
||||
new_uid = self._make_pending_user("over")
|
||||
self._atomic_founder_update(new_uid)
|
||||
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT is_founder, founder_number, is_founder_pending FROM users WHERE id=?",
|
||||
(new_uid,)
|
||||
).fetchone()
|
||||
|
||||
assert row["is_founder"] in (0, None), (
|
||||
"User wurde Founder, obwohl bereits 100 vergeben sind."
|
||||
)
|
||||
assert row["founder_number"] is None, (
|
||||
f"founder_number wurde vergeben trotz vollem Slot: {row['founder_number']}"
|
||||
)
|
||||
# Pending bleibt erhalten — User kann spaeter bei Ausstieg eines
|
||||
# bestehenden Founders nachruecken.
|
||||
assert row["is_founder_pending"] == 1
|
||||
315
tests/test_security.py
Normal file
315
tests/test_security.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"""Security-Tests: require_owner, JWT-Blacklist, Login-Lockout.
|
||||
|
||||
Diese Tests verifizieren die in Sprint60 eingefuehrten Security-Helper
|
||||
und stellen sicher, dass z. B. eine versehentliche Aenderung an
|
||||
require_owner / blacklist_jti hier sofort einen roten Test ergibt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper: zweiten frischen User registrieren (wie das `user`-Fixture)
|
||||
# ------------------------------------------------------------------
|
||||
def _make_other_user(client) -> dict:
|
||||
"""Registriert einen zweiten verifizierten User mit JWT-Token."""
|
||||
email = f"other-{secrets.token_hex(4)}@example.com"
|
||||
pw = "OtherPass123!"
|
||||
name = f"other{secrets.token_hex(3)}"
|
||||
r = client.post("/api/auth/register", json={
|
||||
"email": email, "password": pw, "name": name
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
from database import db
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,))
|
||||
|
||||
r2 = client.post("/api/auth/login", json={"email": email, "password": pw})
|
||||
assert r2.status_code == 200, r2.text
|
||||
token = r2.json()["token"]
|
||||
return {
|
||||
"email": email,
|
||||
"password": pw,
|
||||
"name": name,
|
||||
"token": token,
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# 1) Owner-Check via require_owner — Places
|
||||
# ==================================================================
|
||||
class TestRequireOwnerPlaces:
|
||||
"""places.py nutzt `require_owner` fuer PATCH/DELETE — wir testen
|
||||
den vollen Lebenszyklus mit zwei verschiedenen Usern."""
|
||||
|
||||
def _create_place(self, client, user) -> dict:
|
||||
r = client.post(
|
||||
"/api/places",
|
||||
headers=user["headers"],
|
||||
json={
|
||||
"name": "Test-Cafe",
|
||||
"typ": "restaurant",
|
||||
"lat": 49.5,
|
||||
"lon": 11.0,
|
||||
"hund_rein": True,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
def test_get_place_is_public(self, client, user):
|
||||
"""places sind public — fremder User darf lesen."""
|
||||
place = self._create_place(client, user)
|
||||
other = _make_other_user(client)
|
||||
|
||||
r = client.get(f"/api/places/{place['id']}", headers=other["headers"])
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == place["id"]
|
||||
|
||||
def test_patch_place_other_user_is_forbidden(self, client, user):
|
||||
"""PATCH mit fremdem User -> 403 (require_owner greift)."""
|
||||
place = self._create_place(client, user)
|
||||
other = _make_other_user(client)
|
||||
|
||||
r = client.patch(
|
||||
f"/api/places/{place['id']}",
|
||||
headers=other["headers"],
|
||||
json={"name": "Hijacked"},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
# Owner kann immer noch patchen
|
||||
r2 = client.patch(
|
||||
f"/api/places/{place['id']}",
|
||||
headers=user["headers"],
|
||||
json={"name": "Cafe Updated"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["name"] == "Cafe Updated"
|
||||
|
||||
def test_delete_place_other_user_is_forbidden(self, client, user):
|
||||
"""DELETE mit fremdem User -> 403."""
|
||||
place = self._create_place(client, user)
|
||||
other = _make_other_user(client)
|
||||
|
||||
r = client.delete(f"/api/places/{place['id']}", headers=other["headers"])
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
# Owner kann loeschen
|
||||
r2 = client.delete(f"/api/places/{place['id']}", headers=user["headers"])
|
||||
assert r2.status_code == 204
|
||||
|
||||
def test_patch_nonexistent_place_is_404(self, client, user):
|
||||
"""require_owner wirft 404 wenn row None ist."""
|
||||
r = client.patch(
|
||||
"/api/places/9999999",
|
||||
headers=user["headers"],
|
||||
json={"name": "Ghost"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# 2) JWT-Blacklist — Logout invalidiert Token serverseitig
|
||||
# ==================================================================
|
||||
class TestJwtBlacklist:
|
||||
def test_logout_blacklists_bearer_token(self, client):
|
||||
"""Nach Logout muss das gleiche Token bei /auth/me 401 ergeben."""
|
||||
# Frischer User in diesem Test — nicht das `user`-Fixture verwenden,
|
||||
# weil andere Tests es weiter brauchen koennten.
|
||||
info = _make_other_user(client)
|
||||
|
||||
# 1) Token funktioniert
|
||||
r = client.get("/api/auth/me", headers=info["headers"])
|
||||
assert r.status_code == 200
|
||||
|
||||
# 2) Logout
|
||||
r2 = client.post("/api/auth/logout", headers=info["headers"])
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["ok"] is True
|
||||
|
||||
# 3) Token nun blacklisted -> 401
|
||||
r3 = client.get("/api/auth/me", headers=info["headers"])
|
||||
assert r3.status_code == 401, (
|
||||
f"Token wurde nach Logout NICHT blacklisted (status={r3.status_code})"
|
||||
)
|
||||
|
||||
def test_blacklist_entry_persisted_in_db(self, client):
|
||||
"""Pruefen, dass das jti tatsaechlich in jwt_blacklist landet."""
|
||||
info = _make_other_user(client)
|
||||
|
||||
# jti aus Token extrahieren
|
||||
import jwt as _jwt
|
||||
payload = _jwt.decode(
|
||||
info["token"], options={"verify_signature": False}
|
||||
)
|
||||
jti = payload.get("jti")
|
||||
assert jti, "Token enthaelt kein jti"
|
||||
|
||||
# Logout
|
||||
client.post("/api/auth/logout", headers=info["headers"])
|
||||
|
||||
from database import db
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT jti, expires_at FROM jwt_blacklist WHERE jti=?", (jti,)
|
||||
).fetchone()
|
||||
assert row is not None, "jwt_blacklist-Eintrag fehlt nach Logout"
|
||||
assert row["jti"] == jti
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# 3) Login-Lockout — 5 Fehlversuche -> 429 mit Retry-After
|
||||
# ==================================================================
|
||||
class TestLoginLockout:
|
||||
"""In conftest.py wird der Lockout fuer die Test-Session global
|
||||
deaktiviert (sonst wuerden Auth-Tests sich gegenseitig blocken).
|
||||
Hier aktivieren wir ihn fuer einzelne Tests gezielt zurueck."""
|
||||
|
||||
def _enable_lockout(self, monkeypatch):
|
||||
"""Aktiviert die Lockout-Logik fuer einen einzelnen Test wieder.
|
||||
|
||||
In conftest.py werden routes.auth._db_is_account_locked und
|
||||
_db_record_login_failure global gestubbt (damit Register-Spam
|
||||
durch andere Tests nicht zur Session-weiten Sperre fuehrt).
|
||||
Hier definieren wir die echten Implementierungen lokal neu
|
||||
(Kopie aus routes/auth.py) und setzen sie per monkeypatch
|
||||
zurueck — das wird nach dem Test automatisch revertiert.
|
||||
"""
|
||||
import routes.auth as _ra
|
||||
from database import db
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Counter leeren — sonst beeinflussen vorherige Tests die Sperre.
|
||||
with db() as conn:
|
||||
conn.execute("DELETE FROM login_attempts")
|
||||
|
||||
_LOCKOUT_WINDOW_MIN = 15
|
||||
_LOCKOUT_ATTEMPTS_MAX = 5
|
||||
|
||||
def _is_locked(email: str):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT locked_until FROM login_attempts WHERE email=? COLLATE NOCASE",
|
||||
(email,)
|
||||
).fetchone()
|
||||
if not row or not row["locked_until"]:
|
||||
return None
|
||||
try:
|
||||
locked_until = datetime.fromisoformat(row["locked_until"])
|
||||
except Exception:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
if locked_until.tzinfo is None:
|
||||
locked_until = locked_until.replace(tzinfo=timezone.utc)
|
||||
if locked_until <= now:
|
||||
return None
|
||||
return int((locked_until - now).total_seconds())
|
||||
|
||||
def _record(email: str):
|
||||
now = datetime.now(timezone.utc)
|
||||
window_start = now - timedelta(minutes=_LOCKOUT_WINDOW_MIN)
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT attempts, last_attempt FROM login_attempts WHERE email=? COLLATE NOCASE",
|
||||
(email,)
|
||||
).fetchone()
|
||||
if row:
|
||||
try:
|
||||
last = datetime.fromisoformat(row["last_attempt"])
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
last = now
|
||||
attempts = (row["attempts"] + 1) if last >= window_start else 1
|
||||
else:
|
||||
attempts = 1
|
||||
|
||||
locked_until = None
|
||||
if attempts >= _LOCKOUT_ATTEMPTS_MAX:
|
||||
locked_until = (now + timedelta(minutes=_LOCKOUT_WINDOW_MIN)).isoformat()
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO login_attempts (email, attempts, last_attempt, locked_until)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
attempts=excluded.attempts,
|
||||
last_attempt=excluded.last_attempt,
|
||||
locked_until=excluded.locked_until""",
|
||||
(email.lower(), attempts, now.isoformat(), locked_until)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_ra, "_db_is_account_locked", _is_locked)
|
||||
monkeypatch.setattr(_ra, "_db_record_login_failure", _record)
|
||||
|
||||
def test_lockout_after_five_failed_logins(self, client, monkeypatch):
|
||||
"""5x falsches PW -> 6. Versuch ergibt 429 mit Retry-After."""
|
||||
# Eigenen User anlegen, damit andere Tests das Lockout-Limit nicht
|
||||
# zufaellig schon erreicht haben.
|
||||
info = _make_other_user(client)
|
||||
self._enable_lockout(monkeypatch)
|
||||
|
||||
# 5 Fehlversuche
|
||||
for i in range(5):
|
||||
r = client.post("/api/auth/login", json={
|
||||
"email": info["email"], "password": "WRONG-PW!"
|
||||
})
|
||||
assert r.status_code == 401, (
|
||||
f"Versuch {i+1}: erwartete 401, bekam {r.status_code}"
|
||||
)
|
||||
|
||||
# 6. Versuch: jetzt gelockt
|
||||
r6 = client.post("/api/auth/login", json={
|
||||
"email": info["email"], "password": "WRONG-PW!"
|
||||
})
|
||||
assert r6.status_code == 429, (
|
||||
f"Erwartete 429 nach 5 Fehlversuchen, bekam {r6.status_code}"
|
||||
)
|
||||
assert "Retry-After" in r6.headers, "Retry-After-Header fehlt bei Lockout"
|
||||
|
||||
def test_lockout_writes_to_login_attempts_table(self, client, monkeypatch):
|
||||
"""login_attempts-Eintrag muss locked_until enthalten."""
|
||||
info = _make_other_user(client)
|
||||
self._enable_lockout(monkeypatch)
|
||||
|
||||
for _ in range(5):
|
||||
client.post("/api/auth/login", json={
|
||||
"email": info["email"], "password": "WRONG-PW!"
|
||||
})
|
||||
|
||||
from database import db
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT attempts, locked_until FROM login_attempts WHERE email=? COLLATE NOCASE",
|
||||
(info["email"],)
|
||||
).fetchone()
|
||||
assert row is not None, "Kein login_attempts-Eintrag angelegt"
|
||||
assert row["attempts"] >= 5
|
||||
assert row["locked_until"] is not None, "locked_until nicht gesetzt"
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# 4) Rate-Limit auf /auth/login (Brute-Force-Schutz auf IP-Ebene)
|
||||
# ==================================================================
|
||||
@pytest.mark.xfail(
|
||||
reason="rl_check ist in conftest fuer alle Tests gestubbt — Rate-Limit "
|
||||
"laesst sich pro Test nicht selektiv reaktivieren ohne andere "
|
||||
"parallele Tests zu beeintraechtigen."
|
||||
)
|
||||
def test_login_rate_limit_blocks_burst(client):
|
||||
"""20+ schnelle Logins -> 429 vom Rate-Limiter."""
|
||||
info = _make_other_user(client)
|
||||
statuses = []
|
||||
for _ in range(25):
|
||||
r = client.post("/api/auth/login", json={
|
||||
"email": info["email"], "password": "WRONG-PW!"
|
||||
})
|
||||
statuses.append(r.status_code)
|
||||
assert 429 in statuses, f"Kein Rate-Limit-Treffer in {statuses}"
|
||||
104
tests/test_validation.py
Normal file
104
tests/test_validation.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Pydantic-Validation-Tests: max_length verhindert massive Payloads.
|
||||
|
||||
Sprint60 hat in forum.py und diary.py max_length-Felder eingefuehrt
|
||||
(titel<=200, text<=10000). Wir testen, dass ueberlange Eingaben
|
||||
SOFORT mit 422 abgelehnt werden — bevor sie in die DB gelangen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Forum — ThreadCreate
|
||||
# ==================================================================
|
||||
class TestForumValidation:
|
||||
def test_forum_thread_with_overlong_title_is_422(self, client, user):
|
||||
"""30000-Zeichen-Titel -> 422 (max_length=200)."""
|
||||
r = client.post(
|
||||
"/api/forum/threads",
|
||||
headers=user["headers"],
|
||||
json={
|
||||
"kategorie": "allgemein",
|
||||
"titel": "T" * 30_000,
|
||||
"text": "Inhalt",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422, (
|
||||
f"Erwartete 422 (max_length), bekam {r.status_code}: {r.text[:200]}"
|
||||
)
|
||||
|
||||
def test_forum_thread_with_overlong_text_is_422(self, client, user):
|
||||
"""50000-Zeichen-Text -> 422 (max_length=10000)."""
|
||||
r = client.post(
|
||||
"/api/forum/threads",
|
||||
headers=user["headers"],
|
||||
json={
|
||||
"kategorie": "allgemein",
|
||||
"titel": "Ok-Titel",
|
||||
"text": "X" * 50_000,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422, (
|
||||
f"Erwartete 422, bekam {r.status_code}: {r.text[:200]}"
|
||||
)
|
||||
|
||||
def test_forum_thread_at_max_length_passes_validation(self, client, user):
|
||||
"""200-Zeichen-Titel + 10000-Zeichen-Text muss durchgehen."""
|
||||
r = client.post(
|
||||
"/api/forum/threads",
|
||||
headers=user["headers"],
|
||||
json={
|
||||
"kategorie": "allgemein",
|
||||
"titel": "T" * 200,
|
||||
"text": "X" * 10_000,
|
||||
},
|
||||
)
|
||||
# Darf nicht 422 sein — moegliche Codes 200/201/400 sind ok
|
||||
assert r.status_code != 422, (
|
||||
f"Grenzwerte sollten validieren, bekam 422: {r.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Diary — DiaryCreate
|
||||
# ==================================================================
|
||||
class TestDiaryValidation:
|
||||
def test_diary_with_overlong_text_is_422(self, client, user, dog):
|
||||
"""50000-Zeichen-Text -> 422 (max_length=10000)."""
|
||||
r = client.post(
|
||||
f"/api/dogs/{dog['id']}/diary",
|
||||
headers=user["headers"],
|
||||
json={
|
||||
"titel": "Mein Eintrag",
|
||||
"text": "X" * 50_000,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422, (
|
||||
f"Erwartete 422, bekam {r.status_code}: {r.text[:200]}"
|
||||
)
|
||||
|
||||
def test_diary_with_overlong_title_is_422(self, client, user, dog):
|
||||
"""5000-Zeichen-Titel -> 422 (max_length=200)."""
|
||||
r = client.post(
|
||||
f"/api/dogs/{dog['id']}/diary",
|
||||
headers=user["headers"],
|
||||
json={
|
||||
"titel": "T" * 5_000,
|
||||
"text": "kurzer Text",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422, (
|
||||
f"Erwartete 422, bekam {r.status_code}: {r.text[:200]}"
|
||||
)
|
||||
|
||||
def test_diary_with_normal_payload_succeeds(self, client, user, dog):
|
||||
"""Sanity-Check: normaler Eintrag geht durch."""
|
||||
r = client.post(
|
||||
f"/api/dogs/{dog['id']}/diary",
|
||||
headers=user["headers"],
|
||||
json={
|
||||
"titel": "Normal",
|
||||
"text": "Normaler Text-Inhalt.",
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
Loading…
Add table
Add a link
Reference in a new issue