From 2c2231b6842b97f7243f2c4595230d3aee32bfef Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 25 Jun 2026 18:24:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20serverseitige=20Pydantic-Validat?= =?UTF-8?q?oren=20f=C3=BCr=20Notfall-Profilfelder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Härtung zusätzlich zur Frontend-Prüfung: AdminUserPrefsUpdate validiert nicht-leere Werte (leer/None = konform) und liefert sonst 422: - postal_code: 5 Ziffern · phone_mobile/phone_landline: Telefonformat - birth_date: TT.MM.JJJJ (echtes Datum) · emergency_contacts: CSV gültiger E-Mails · emergency_phones: CSV gültiger Nummern Regeln spiegeln die Frontend-Validierung. 10 neue Tests (gültig/leer/ungültig), Suite 250 grün. Co-Authored-By: Claude Opus 4.8 --- app/schemas.py | 71 +++++++++++++++++++++++++++- tests/test_admin_prefs_validation.py | 36 ++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/test_admin_prefs_validation.py diff --git a/app/schemas.py b/app/schemas.py index 7b2190c..ac7f96c 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,5 +1,8 @@ +import re +from datetime import date from typing import Literal -from pydantic import BaseModel, Field + +from pydantic import BaseModel, Field, field_validator class EndpointCapabilities(BaseModel): @@ -127,8 +130,37 @@ class EmergencyRequest(BaseModel): device: DeviceInfo | None = None +# Format-Validierung der Notfall-Profilfelder (spiegelt die Frontend-Prüfung). +_PLZ_RE = re.compile(r"^\d{5}$") +_PHONE_RE = re.compile(r"^\+?[\d\s/().\-]{6,20}$") +_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") +_DATE_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{4})$") + + +def _valid_german_date(v: str) -> bool: + m = _DATE_RE.match(v) + if not m: + return False + d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) + if not (1900 <= y <= 2100): + return False + try: + date(y, mo, d) + except ValueError: + return False + return True + + +def _csv_all(v: str, rx: "re.Pattern") -> bool: + return all(rx.match(p.strip()) for p in v.split(",") if p.strip()) + + class AdminUserPrefsUpdate(BaseModel): - """Admin setzt Nutzer-Einstellungen: erlaubte Sprachen + Notfall-Profil.""" + """Admin setzt Nutzer-Einstellungen: erlaubte Sprachen + Notfall-Profil. + + Optionale Felder dürfen leer/None sein; nicht-leere Werte werden auf ihr + Format geprüft (serverseitige Härtung zusätzlich zur Frontend-Validierung). + """ allowed_languages: str | None = None # Notfall-Profil (in user.prefs gespeichert, fließt in die Notruf-Meldungen ein) full_name: str | None = None @@ -143,6 +175,41 @@ class AdminUserPrefsUpdate(BaseModel): emergency_phones: str | None = None # Kontakt-Telefon(e), CSV location_consent: bool | None = None # Standort im Notfall mitsenden? + @field_validator("postal_code") + @classmethod + def _check_plz(cls, v): + if v and not _PLZ_RE.match(v.strip()): + raise ValueError("PLZ muss genau 5 Ziffern sein") + return v + + @field_validator("phone_mobile", "phone_landline") + @classmethod + def _check_phone(cls, v): + if v and not _PHONE_RE.match(v.strip()): + raise ValueError("ungültige Telefonnummer") + return v + + @field_validator("birth_date") + @classmethod + def _check_date(cls, v): + if v and not _valid_german_date(v.strip()): + raise ValueError("Geburtsdatum muss TT.MM.JJJJ (gültiges Datum) sein") + return v + + @field_validator("emergency_contacts") + @classmethod + def _check_emails(cls, v): + if v and not _csv_all(v, _EMAIL_RE): + raise ValueError("Kontakt-E-Mail(s): ungültige Adresse") + return v + + @field_validator("emergency_phones") + @classmethod + def _check_phones_csv(cls, v): + if v and not _csv_all(v, _PHONE_RE): + raise ValueError("Kontakt-Telefon(e): ungültige Nummer") + return v + class UserAdminUpdate(BaseModel): """Admin schaltet Admin-Rechte für einen Nutzer ein/aus.""" diff --git a/tests/test_admin_prefs_validation.py b/tests/test_admin_prefs_validation.py new file mode 100644 index 0000000..a62c211 --- /dev/null +++ b/tests/test_admin_prefs_validation.py @@ -0,0 +1,36 @@ +import pytest +from pydantic import ValidationError + +from app.schemas import AdminUserPrefsUpdate + + +def test_valid_profile_passes(): + AdminUserPrefsUpdate( + full_name="Frieda Schlüter", postal_code="50670", + phone_mobile="+4915127747511", phone_landline="040 123456", + birth_date="14.02.1976", + emergency_contacts="a@b.de, c@d.com", + emergency_phones="+4915127747511, 040 12345", + location_consent=True, + ) + + +def test_empty_and_none_pass(): + # Leere/None-Felder gelten als konform (optional). + AdminUserPrefsUpdate(postal_code="", phone_mobile="", birth_date="", + emergency_contacts="", emergency_phones=None) + + +@pytest.mark.parametrize("field,value", [ + ("postal_code", "123"), + ("postal_code", "abcde"), + ("phone_mobile", "hallo"), + ("phone_landline", "xx"), + ("birth_date", "1976-02-14"), + ("birth_date", "31.02.2020"), + ("emergency_contacts", "a@b.de, nope"), + ("emergency_phones", "kein-telefon!!"), +]) +def test_invalid_rejected(field, value): + with pytest.raises(ValidationError): + AdminUserPrefsUpdate(**{field: value})