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:
rene 2026-05-27 13:40:30 +02:00
parent 7751d303bb
commit 1ff66a7083
57 changed files with 1253 additions and 612 deletions

View file

@ -3,7 +3,7 @@
import os, uuid
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
@ -22,59 +22,59 @@ TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie
# Schemas
# ------------------------------------------------------------------
class HealthCreate(BaseModel):
typ: str
bezeichnung: Optional[str] = None
datum: str
naechstes: Optional[str] = None
notiz: Optional[str] = None
typ: str = Field(..., max_length=50)
bezeichnung: Optional[str] = Field(None, max_length=200)
datum: str = Field(..., max_length=32)
naechstes: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=5000)
# Gewicht
wert: Optional[float] = None
einheit: Optional[str] = "kg"
einheit: Optional[str] = Field("kg", max_length=20)
# Impfung
charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = None
charge_nr: Optional[str] = Field(None, max_length=100)
tierarzt_name: Optional[str] = Field(None, max_length=200)
# Tierarztbesuch
kosten: Optional[float] = None
diagnose: Optional[str] = None
diagnose: Optional[str] = Field(None, max_length=2000)
# Medikament
dosierung: Optional[str] = None
haeufigkeit: Optional[str] = None
dosierung: Optional[str] = Field(None, max_length=200)
haeufigkeit: Optional[str] = Field(None, max_length=200)
aktiv: Optional[int] = 1
bis_datum: Optional[str] = None
bis_datum: Optional[str] = Field(None, max_length=32)
# Allergie
schweregrad: Optional[str] = None # leicht | mittel | schwer
reaktion: Optional[str] = None
schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer
reaktion: Optional[str] = Field(None, max_length=1000)
erinnerung: Optional[int] = 1
intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage
# Tierarzt-Verknüpfung
tierarzt_id: Optional[int] = None
# Züchter
deckdatum: Optional[str] = None
wurftermin: Optional[str] = None
deckdatum: Optional[str] = Field(None, max_length=32)
wurftermin: Optional[str] = Field(None, max_length=32)
class HealthUpdate(BaseModel):
bezeichnung: Optional[str] = None
datum: Optional[str] = None
naechstes: Optional[str] = None
notiz: Optional[str] = None
bezeichnung: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = Field(None, max_length=32)
naechstes: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=5000)
wert: Optional[float] = None
einheit: Optional[str] = None
charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = None
einheit: Optional[str] = Field(None, max_length=20)
charge_nr: Optional[str] = Field(None, max_length=100)
tierarzt_name: Optional[str] = Field(None, max_length=200)
kosten: Optional[float] = None
diagnose: Optional[str] = None
dosierung: Optional[str] = None
haeufigkeit: Optional[str] = None
diagnose: Optional[str] = Field(None, max_length=2000)
dosierung: Optional[str] = Field(None, max_length=200)
haeufigkeit: Optional[str] = Field(None, max_length=200)
aktiv: Optional[int] = None
bis_datum: Optional[str] = None
schweregrad: Optional[str] = None
reaktion: Optional[str] = None
bis_datum: Optional[str] = Field(None, max_length=32)
schweregrad: Optional[str] = Field(None, max_length=50)
reaktion: Optional[str] = Field(None, max_length=1000)
erinnerung: Optional[int] = None
intervall_tage: Optional[int] = None
tierarzt_id: Optional[int] = None
deckdatum: Optional[str] = None
wurftermin: Optional[str] = None
deckdatum: Optional[str] = Field(None, max_length=32)
wurftermin: Optional[str] = Field(None, max_length=32)
# ------------------------------------------------------------------
@ -390,7 +390,7 @@ async def list_gewicht(dog_id: int, user=Depends(get_current_user)):
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
# ------------------------------------------------------------------
class SymptomCheckRequest(BaseModel):
symptoms: str
symptoms: str = Field(..., min_length=3, max_length=5000)
@router.post("/{dog_id}/health/symptom-check")
@ -576,20 +576,20 @@ async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)):
# ==================================================================
class InsuranceCreate(BaseModel):
anbieter: str
police_nr: Optional[str] = None
anbieter: str = Field(..., min_length=1, max_length=200)
police_nr: Optional[str] = Field(None, max_length=100)
jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = None
ablaufdatum: Optional[str] = None
notizen: Optional[str] = None
kontakt: Optional[str] = Field(None, max_length=500)
ablaufdatum: Optional[str] = Field(None, max_length=32)
notizen: Optional[str] = Field(None, max_length=5000)
class InsuranceUpdate(BaseModel):
anbieter: Optional[str] = None
police_nr: Optional[str] = None
anbieter: Optional[str] = Field(None, max_length=200)
police_nr: Optional[str] = Field(None, max_length=100)
jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = None
ablaufdatum: Optional[str] = None
notizen: Optional[str] = None
kontakt: Optional[str] = Field(None, max_length=500)
ablaufdatum: Optional[str] = Field(None, max_length=32)
notizen: Optional[str] = Field(None, max_length=5000)
@router.get("/{dog_id}/insurance")
@ -674,12 +674,12 @@ TRIGGER_LABELS = {
class BehaviorCreate(BaseModel):
datum: str
uhrzeit: Optional[str] = None
kategorie: str
intensitaet: int = 3
trigger: Optional[str] = None
notiz: Optional[str] = None
datum: str = Field(..., max_length=32)
uhrzeit: Optional[str] = Field(None, max_length=20)
kategorie: str = Field(..., max_length=50)
intensitaet: int = 3
trigger: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=5000)
@router.get("/{dog_id}/behavior")