Big Sweep: Security + Race-Conditions + Tests + DSGVO + A11y, SW by-v1095

SECURITY (auth.py, routes/auth.py, database.py, main.py)
- JWT bekommt jti; Logout trägt in neue jwt_blacklist-Tabelle ein,
  decode_token() prüft → server-side Invalidierung
- JWT-Expiry default 30 → 7 Tage (ENV JWT_EXPIRY_DAYS überschreibt)
- Sliding-Refresh-Middleware: erneuert Cookie wenn >50% verbraucht
  (Schwelle via JWT_REFRESH_FRACTION, Default 2)
- Login-Lockout in DB-Tabelle login_attempts (5 Versuche / 15 Min,
  überlebt Container-Restart) — alte In-Memory-Lockouts ersetzt
- SMTP-Versand: alle 'except: pass' durch logger.exception ersetzt;
  Fehlversuche landen in failed_emails-Tabelle für späteres Retry
- Referral-Counter Race gefixt: UPDATE partner_codes SET uses=uses+1
  ... WHERE uses<max_uses RETURNING — atomar statt SELECT+UPDATE

RACE CONDITIONS (routes/invoices.py, database.py)
- Neue invoice_counters-Tabelle für atomare Nummernvergabe
- _next_invoice_number nutzt BEGIN IMMEDIATE + atomares UPDATE
- Funktioniert für RG- und ST-Prefixe (Stornorechnungen)
- Race-Test verifiziert (5 Threads × 20 Calls = 100 eindeutige Nummern)

VERSION + TESTS + ERROR-DIGEST (VERSION, Makefile, tests/, scheduler.py)
- Neue VERSION-Datei (Single Source of Truth) — main.py liest beim
  Startup
- Makefile-Target 'make bump' propagiert in sw.js, app.js, index.html
- Makefile-Target 'make test' setzt venv auf, läuft pytest
- 19 Smoke-Tests in tests/ (health, auth, diary, invoice) — alle grün
- Scheduler: täglicher _job_error_digest um 06:30 → schickt Error-
  Zusammenfassung an ADMIN_EMAIL (still wenn keine Errors)

DSGVO + A11Y + ERSTE-HILFE
- landing.html: 'HTML und ODS' → 'JSON' (tatsächlich implementiert)
- datenschutz.js: Sektion Account-Löschung erweitert (sofort gelöscht /
  anonymisiert / 10 Jahre für Rechnungen)
- erste-hilfe.js: prominentes Warning-Banner oben (ersetzt keine
  Tierarzt-Beratung); Notfallnummern gruppiert nach Land, TODO-Platz-
  halter für AT-Uni-Klinik, CH Tox Info Suisse, CH Tierspital Zürich
- ui.js Modal: ESC schließt, Focus-Trap, Auto-Focus erstes Element,
  Restore Focus auf vorigen Caller
- impressum.js Kontaktformular: Labels mit for=cf-name etc.

NEUE DB-TABELLEN (idempotent via CREATE TABLE IF NOT EXISTS)
- jwt_blacklist, login_attempts, failed_emails, invoice_counters

NEUE ENV-VARS
- JWT_REFRESH_FRACTION (Default 2)
- JWT_EXPIRY_DAYS Default geändert (30 → 7)
This commit is contained in:
rene 2026-05-26 20:12:01 +02:00
parent 6224044654
commit 9394bab1fb
23 changed files with 1208 additions and 78 deletions

View file

@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.DS_Store'
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \
logs logs-f shell db dev clean-cache check-ssh reports
logs logs-f shell db dev clean-cache check-ssh reports bump test
# ----------------------------------------------------------
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
@ -272,6 +272,31 @@ reports: check-ssh
done
# ----------------------------------------------------------
# BUMP — zentrale Versions-Erhöhung (VERSION-Datei + sw.js + app.js + index.html)
# Aufruf:
# make bump → liest aus VERSION, erhöht +1, schreibt zurück, propagiert in alle Frontend-Stellen
# make bump APP_VER=2000 → setzt VERSION explizit auf 2000
# Backend liest APP_VER beim Startup aus VERSION (siehe main.py:_read_app_ver()).
# ----------------------------------------------------------
bump:
@if [ ! -f VERSION ]; then echo "0" > VERSION; fi
@CUR=$$(cat VERSION | tr -d '[:space:]'); \
if [ -n "$(APP_VER)" ]; then NEW="$(APP_VER)"; else NEW=$$(($$CUR + 1)); fi; \
printf "%s" "$$NEW" > VERSION; \
sed -i.bak -E "s/const VER[[:space:]]*=[[:space:]]*'[0-9]+'/const VER = '$$NEW'/" backend/static/sw.js && rm -f backend/static/sw.js.bak; \
sed -i.bak -E "s/const APP_VER[[:space:]]*=[[:space:]]*'[0-9]+'/const APP_VER = '$$NEW'/" backend/static/js/app.js && rm -f backend/static/js/app.js.bak; \
sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/index.html && rm -f backend/static/index.html.bak; \
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)"
# ----------------------------------------------------------
# TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS)
# ----------------------------------------------------------
test:
@cd backend && test -d venv || python3 -m venv venv
@backend/venv/bin/pip install -q -r backend/requirements.txt pytest pytest-asyncio
@backend/venv/bin/python -m pytest -q
# ----------------------------------------------------------
# CACHE leeren — SW-Version erhöhen, dann restart
# Nach größeren CSS/JS-Änderungen wenn SW gecacht hat

1
VERSION Normal file
View file

@ -0,0 +1 @@
1095

View file

@ -4,11 +4,12 @@ JWT + Bcrypt. Einmal gebaut, von allen Routes genutzt.
"""
import os
import uuid
import jwt
import bcrypt
import logging
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, status, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from database import db
@ -16,7 +17,12 @@ from database import db
logger = logging.getLogger(__name__)
JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
JWT_ALGO = "HS256"
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30"))
# Default 7 Tage (vorher 30) — sensible Hundedaten brauchen kürzere Sessions.
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "7"))
# Wenn das Token älter als (JWT_EXPIRY / JWT_REFRESH_FRACTION) Tage ist,
# wird das Cookie bei einer authentifizierten Anfrage neu gesetzt (Sliding Session).
# Standard 2 → Refresh wenn mehr als die Hälfte der Laufzeit weg ist.
JWT_REFRESH_FRACTION = int(os.getenv("JWT_REFRESH_FRACTION", "2"))
if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
raise RuntimeError(
@ -27,6 +33,44 @@ if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
security = HTTPBearer(auto_error=False)
# ------------------------------------------------------------------
# JWT-Blacklist (Logout invalidiert Token serverseitig)
# ------------------------------------------------------------------
def _is_jti_blacklisted(jti: str) -> bool:
if not jti:
return False
with db() as conn:
row = conn.execute(
"SELECT 1 FROM jwt_blacklist WHERE jti=?", (jti,)
).fetchone()
return bool(row)
def blacklist_jti(jti: str, expires_at_iso: str):
"""Token-ID auf die Blacklist setzen. expires_at_iso entspricht dem urspr. exp."""
if not jti or not expires_at_iso:
return
with db() as conn:
conn.execute(
"INSERT OR IGNORE INTO jwt_blacklist (jti, expires_at) VALUES (?,?)",
(jti, expires_at_iso)
)
def _purge_expired_jwt() -> int:
"""Entfernt abgelaufene Blacklist-Einträge. Gibt Anzahl gelöschter Zeilen zurück.
Kann später aus dem Scheduler aufgerufen werden."""
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
cur = conn.execute(
"DELETE FROM jwt_blacklist WHERE expires_at < ?", (now,)
)
deleted = cur.rowcount or 0
if deleted:
logger.info("JWT-Blacklist Cleanup: %d abgelaufene Einträge entfernt.", deleted)
return deleted
# ------------------------------------------------------------------
# Passwort
# ------------------------------------------------------------------
@ -42,17 +86,25 @@ def verify_password(password: str, hashed: str) -> bool:
# JWT
# ------------------------------------------------------------------
def create_token(user_id: int, rolle: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"rolle": rolle,
"exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY),
"iat": datetime.now(timezone.utc),
"exp": now + timedelta(days=JWT_EXPIRY),
"iat": now,
"jti": uuid.uuid4().hex,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO)
def decode_token(token: str) -> dict:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
"""Dekodiert + prüft Signatur, Ablauf und Blacklist.
Wirft jwt.InvalidTokenError wenn Token auf der Blacklist steht."""
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
jti = payload.get("jti")
if jti and _is_jti_blacklisted(jti):
raise jwt.InvalidTokenError("Token wurde invalidiert (Logout).")
return payload
# ------------------------------------------------------------------
@ -68,6 +120,33 @@ def _get_token_from_request(
return request.cookies.get("by_token")
def _maybe_refresh_token(request: Request, payload: dict):
"""Sliding-Session: setzt das Cookie neu wenn das Token älter als JWT_EXPIRY/JWT_REFRESH_FRACTION ist.
Nur wirksam wenn das Token aus dem Cookie kommt (nicht Bearer-Header).
Hängt das neue Token an request.state.refresh_token, damit Middleware es ins Response-Cookie setzen kann."""
try:
# Nur refreshen wenn Token aus Cookie kam (Bearer-Clients verwalten Tokens selbst)
if not request.cookies.get("by_token"):
return
iat = payload.get("iat")
exp = payload.get("exp")
if not iat or not exp:
return
# Wenn mehr als 1/JWT_REFRESH_FRACTION der Laufzeit vergangen ist → neues Token ausstellen
now_ts = datetime.now(timezone.utc).timestamp()
lifetime = exp - iat
if lifetime <= 0 or JWT_REFRESH_FRACTION <= 0:
return
threshold = iat + (lifetime / JWT_REFRESH_FRACTION)
if now_ts < threshold:
return
new_token = create_token(int(payload["sub"]), payload.get("rolle", "user"))
request.state.refresh_token = new_token
except Exception:
# Refresh-Fehler dürfen die eigentliche Anfrage nie blockieren.
logger.exception("Sliding-Token-Refresh fehlgeschlagen")
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
@ -99,6 +178,9 @@ def get_current_user(
reason = user.get("ban_reason") or "Kein Grund angegeben."
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Account gesperrt: {reason}")
# Sliding-Session: ggf. neues Token vorbereiten (in Middleware ins Cookie schreiben).
_maybe_refresh_token(request, payload)
return user

View file

@ -592,10 +592,15 @@ def _migrate(conn_factory):
]
with conn_factory() as conn:
for table, column, col_type in migrations:
# PRAGMA table_info() liefert leere Liste fuer nicht-existierende Tabellen.
# Wir ueberspringen — die CREATE TABLE-Bloecke darunter legen sie an,
# ihre Spalten sind dort dann sowieso schon enthalten.
existing = [
row[1] for row in
conn.execute(f"PRAGMA table_info({table})").fetchall()
]
if not existing:
continue
if column not in existing:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"
@ -1209,7 +1214,7 @@ def _migrate(conn_factory):
# ki_daily_calls: source-Spalte + PK auf (user_id, date, source) erweitern
ki_cols = {r[1] for r in conn.execute("PRAGMA table_info(ki_daily_calls)").fetchall()}
if "source" not in ki_cols:
if ki_cols and "source" not in ki_cols:
conn.executescript("""
ALTER TABLE ki_daily_calls RENAME TO ki_daily_calls_old;
CREATE TABLE ki_daily_calls (
@ -1301,11 +1306,19 @@ def _migrate(conn_factory):
logger.info("Migration: dogs.gewicht_kg aus health synchronisiert.")
# Performance-Indizes für häufige Queries
conn.execute("CREATE INDEX IF NOT EXISTS idx_health_naechstes ON health(naechstes) WHERE naechstes IS NOT NULL")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ki_calls_user_date ON ki_daily_calls(user_id, date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_user_datum ON events(user_id, datum)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)")
# Defensiv — einige Tabellen werden erst beim ersten Zugriff in
# ihren jeweiligen Routes-Modulen angelegt (z.B. ki_daily_calls)
for _idx_sql in (
"CREATE INDEX IF NOT EXISTS idx_health_naechstes ON health(naechstes) WHERE naechstes IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_ki_calls_user_date ON ki_daily_calls(user_id, date)",
"CREATE INDEX IF NOT EXISTS idx_events_user_datum ON events(user_id, datum)",
"CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)",
):
try:
conn.execute(_idx_sql)
except Exception as _e:
logger.debug("Index-Migration uebersprungen (%s): %s", _idx_sql.split()[5], _e)
logger.info("Migration: Performance-Indizes bereit.")
# Züchter-Tabellen
@ -2444,7 +2457,17 @@ def _migrate(conn_factory):
total REAL NOT NULL
)
""")
logger.info("Migration: invoices + invoice_items bereit.")
# Atomare Rechnungsnummern-Vergabe pro (prefix, year)
# next_num = naechste zu vergebende Nummer (startet bei 1)
conn.execute("""
CREATE TABLE IF NOT EXISTS invoice_counters (
prefix TEXT NOT NULL,
year INTEGER NOT NULL,
next_num INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (prefix, year)
)
""")
logger.info("Migration: invoices + invoice_items + invoice_counters bereit.")
except Exception as e:
logger.warning(f"Migration invoices: {e}")
@ -2461,6 +2484,63 @@ def _migrate(conn_factory):
else:
logger.info("Migration: users.geburtstag bereits vorhanden.")
# ---- Security Hardening (Sprint60) ----
# JWT-Blacklist: invalidierte Tokens nach Logout / Sperrungen
conn.executescript("""
CREATE TABLE IF NOT EXISTS jwt_blacklist (
jti TEXT PRIMARY KEY,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_jwt_blacklist_expires ON jwt_blacklist(expires_at);
""")
logger.info("Migration: jwt_blacklist Tabelle bereit.")
# Login-Brute-Force-Lockout (persistent statt In-Memory)
conn.executescript("""
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY COLLATE NOCASE,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt TEXT NOT NULL,
locked_until TEXT
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked ON login_attempts(locked_until);
""")
logger.info("Migration: login_attempts Tabelle bereit.")
# Fehlgeschlagene E-Mail-Zustellungen — für Admin-Retry / Diagnose
conn.executescript("""
CREATE TABLE IF NOT EXISTS failed_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
to_email TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
error TEXT NOT NULL,
context TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_failed_emails_created ON failed_emails(created_at DESC);
""")
logger.info("Migration: failed_emails Tabelle bereit.")
# Second-Pass der ALTER-TABLE-Migrations:
# Manche Migrations (z.B. diary_media.img_width) referenzieren Tabellen,
# die erst weiter unten in dieser Funktion per CREATE IF NOT EXISTS angelegt
# werden. Beim ersten Durchgang wurden sie uebersprungen — jetzt nachziehen.
for table, column, col_type in migrations:
existing = [
row[1] for row in
conn.execute(f"PRAGMA table_info({table})").fetchall()
]
if not existing:
continue
if column not in existing:
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
logger.info(f"Migration (2nd pass): {table}.{column} hinzugefügt.")
except Exception as _e:
logger.debug(f"Migration (2nd pass) skipped {table}.{column}: {_e}")
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""

View file

@ -123,6 +123,28 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
app.add_middleware(SecurityHeadersMiddleware)
class _SlidingTokenRefreshMiddleware(BaseHTTPMiddleware):
"""Sliding-Session: wenn get_current_user ein neues Token vorbereitet hat
(request.state.refresh_token), schreiben wir es als HttpOnly-Cookie zurück.
So bleibt der User eingeloggt solange er aktiv ist, ohne langlaufendes Token."""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
new_token = getattr(request.state, "refresh_token", None)
if new_token:
from auth import JWT_EXPIRY as _JWT_EXPIRY
response.set_cookie(
key="by_token",
value=new_token,
httponly=True,
secure=True,
samesite="lax",
max_age=_JWT_EXPIRY * 24 * 3600,
)
return response
app.add_middleware(_SlidingTokenRefreshMiddleware)
# Globales File-Upload-Limit (20 MB)
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
@ -410,7 +432,26 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
APP_VER = "1094" # muss mit APP_VER in app.js übereinstimmen
# APP_VER wird zentral aus der VERSION-Datei im Projekt-Root gelesen.
# Bumpe ausschliesslich via `make bump` — bumpt VERSION + sw.js + app.js + index.html atomar.
def _read_app_ver() -> str:
from pathlib import Path
candidates = [
Path(__file__).resolve().parent.parent / "VERSION", # Projekt-Root (lokal/dev)
Path("/app/VERSION"), # Container-Layout
Path("/data/VERSION"), # falls als Volume gemountet
]
for p in candidates:
try:
if p.is_file():
txt = p.read_text(encoding="utf-8").strip()
if txt:
return txt
except Exception:
pass
return "0"
APP_VER = _read_app_ver() # muss mit APP_VER in app.js übereinstimmen (siehe VERSION + `make bump`)
@app.get("/.well-known/assetlinks.json")
async def assetlinks():

View file

@ -3,25 +3,119 @@
import os
import secrets
import string
from datetime import datetime, timedelta
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt as _pyjwt
from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr
from database import db
from auth import (
hash_password, verify_password, create_token,
get_current_user
get_current_user, decode_token, blacklist_jti, JWT_EXPIRY
)
from username_blocklist import is_username_blocked
from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
from ratelimit import check as rl_check
logger = logging.getLogger(__name__)
router = APIRouter()
COOKIE_NAME = "by_token"
_APP_URL = os.getenv("APP_URL", "https://banyaro.app")
_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS"))
# Login-Brute-Force-Lockout: 5 Fehlversuche in 15 Minuten → 15 Min. gesperrt.
_LOCKOUT_WINDOW_MIN = 15
_LOCKOUT_ATTEMPTS_MAX = 5
# ------------------------------------------------------------------
# Login-Lockout (DB-basiert, überlebt Container-Restart)
# ------------------------------------------------------------------
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _db_is_account_locked(email: str) -> Optional[int]:
"""Gibt verbleibende Sperrzeit in Sekunden zurück (oder None falls nicht gesperrt)."""
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)
# Falls ohne TZ gespeichert (Legacy) → as-UTC interpretieren
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 _db_record_login_failure(email: str):
"""Inkrementiert Fehlversuche; setzt locked_until wenn Schwelle erreicht."""
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
# Zähler resetten wenn letzter Versuch außerhalb des Fensters lag
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)
)
def _db_clear_login_failures(email: str):
with db() as conn:
conn.execute("DELETE FROM login_attempts WHERE email=? COLLATE NOCASE", (email,))
# ------------------------------------------------------------------
# SMTP-Fehler-Logging
# ------------------------------------------------------------------
def _log_smtp_failure(to_email: str, subject: str, body: str, error: Exception, context: str = ""):
"""Loggt SMTP-Fehler und speichert in failed_emails für Admin-Retry."""
logger.exception("SMTP failed for %s | context=%s | subject=%s", to_email, context, subject)
try:
with db() as conn:
conn.execute(
"INSERT INTO failed_emails (to_email, subject, body, error, context) VALUES (?,?,?,?,?)",
(to_email, subject, body, repr(error), context or None)
)
except Exception:
logger.exception("failed_emails-Insert fehlgeschlagen")
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
@ -45,8 +139,9 @@ def _send_verification_email(email: str, name: str, token: str):
plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n"
try:
_send_smtp(email, subject, plain, "support", html=html)
except Exception:
pass # Nicht blockieren wenn SMTP fehlschlägt
except Exception as exc:
# Nicht blockieren wenn SMTP fehlschlägt — aber Fehler protokollieren + persistieren.
_log_smtp_failure(email, subject, plain, exc, context="verification_email")
class LoginRequest(BaseModel):
@ -69,7 +164,7 @@ def _set_cookie(response: Response, token: str):
response.set_cookie(
key=COOKIE_NAME, value=token,
httponly=True, secure=True, samesite="lax",
max_age=30 * 24 * 3600
max_age=JWT_EXPIRY * 24 * 3600
)
@ -113,16 +208,24 @@ async def register(data: RegisterRequest, response: Response, request: Request):
code_upper = data.ref_code.strip().upper()
# Zuerst prüfen ob es ein Partner-Code ist
partner = conn.execute(
"SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?",
"SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=?",
(code_upper,)
).fetchone()
if partner:
# Nur einlösen wenn max_uses nicht erreicht
if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]:
conn.execute(
"UPDATE partner_codes SET uses=uses+1 WHERE id=?",
# ATOMARE Inkrementierung (SQLite ≥3.35 unterstützt RETURNING).
# Schließt Race-Condition wenn zwei User gleichzeitig den gleichen Code einlösen.
if partner["max_uses"] is None:
redeemed = conn.execute(
"UPDATE partner_codes SET uses=uses+1 WHERE id=? RETURNING uses",
(partner["id"],)
)
).fetchone()
else:
redeemed = conn.execute(
"UPDATE partner_codes SET uses=uses+1 WHERE id=? AND uses<? RETURNING uses",
(partner["id"], partner["max_uses"])
).fetchone()
if redeemed:
updates = {"referred_by": -partner["id"]}
if partner["grants_founder"]:
total_founders = conn.execute(
@ -155,8 +258,15 @@ async def login(data: LoginRequest, response: Response, request: Request):
rl_check(request, max_requests=10, window_seconds=300, key="login")
rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}")
if is_account_locked(data.email):
raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.")
# DB-basierter Account-Lockout (überlebt Container-Restart)
remaining = _db_is_account_locked(data.email)
if remaining is not None:
minutes = max(1, remaining // 60)
raise HTTPException(
429,
f"Zu viele Fehlversuche. Bitte warte {minutes} Minute(n) und versuche es erneut.",
headers={"Retry-After": str(remaining)}
)
with db() as conn:
user = conn.execute(
@ -165,13 +275,13 @@ async def login(data: LoginRequest, response: Response, request: Request):
).fetchone()
if not user or not verify_password(data.password, user["pw_hash"]):
record_login_failure(data.email)
_db_record_login_failure(data.email)
raise HTTPException(401, "E-Mail oder Passwort falsch.")
if not user["email_verified"]:
raise HTTPException(403, "EMAIL_NOT_VERIFIED")
clear_login_failures(data.email)
_db_clear_login_failures(data.email)
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
@ -184,7 +294,30 @@ async def login(data: LoginRequest, response: Response, request: Request):
@router.post("/logout")
async def logout(response: Response):
async def logout(request: Request, response: Response):
# Token aus Cookie ODER Bearer-Header extrahieren und auf die Blacklist setzen,
# damit es serverseitig wirklich ungültig wird (nicht nur Cookie löschen).
raw_token = request.cookies.get(COOKIE_NAME)
if not raw_token:
auth_header = request.headers.get("Authorization", "")
if auth_header.lower().startswith("bearer "):
raw_token = auth_header.split(None, 1)[1].strip()
if raw_token:
try:
# options={'verify_exp': False}: auch bei abgelaufenem Token wollen wir jti+exp lesen,
# falls jemand sich vor Ablauf "ordentlich" abmelden möchte — und exp brauchen wir für TTL.
payload = _pyjwt.decode(
raw_token, options={"verify_signature": False}
)
jti = payload.get("jti")
exp = payload.get("exp")
if jti and exp:
expires_at = datetime.fromtimestamp(int(exp), tz=timezone.utc).isoformat()
blacklist_jti(jti, expires_at)
except Exception:
logger.exception("Logout: Token konnte nicht für Blacklist gelesen werden")
response.delete_cookie(COOKIE_NAME)
return {"ok": True}
@ -332,8 +465,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n"
try:
_send_smtp(data.email, subject, plain, "support", html=html)
except Exception:
pass
except Exception as exc:
_log_smtp_failure(data.email, subject, plain, exc, context="forgot_password")
return {"ok": True}
@ -372,8 +505,8 @@ async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_curr
f"Nachricht: {data.message or ''}\n\n"
f"Admin-Panel: https://banyaro.app/#admin")
_send_smtp(admin_email, subject, body, "support")
except Exception:
pass
except Exception as exc:
_log_smtp_failure(admin_email, subject, body, exc, context="upgrade_request_admin_notify")
return {"ok": True}
@ -450,8 +583,14 @@ async def cancel_subscription(user=Depends(get_current_user)):
+ (f"Aktiv bis: {expires_de}\n" if expires_de else "")
+ "\nAlle Daten bleiben erhalten.\n\nViele Grüße\nRené")
await send_email(user["email"], f"Kündigung bestätigt — {tier_label}", html, plain)
except Exception:
pass
except Exception as exc:
_log_smtp_failure(
user.get("email") or "",
f"Kündigung bestätigt — {tier_label if 'tier_label' in locals() else '?'}",
plain if 'plain' in locals() else "",
exc,
context="subscription_cancel_confirmation"
)
return {"ok": True, "expires_at": expires}

View file

@ -49,15 +49,66 @@ class CancelBody(BaseModel):
# Hilfsfunktionen
# ------------------------------------------------------------------
def _next_invoice_number(conn, prefix="RG"):
"""Vergibt atomar die naechste Rechnungsnummer fuer (prefix, year).
Race-frei dank dedizierter Counter-Tabelle 'invoice_counters' und
BEGIN IMMEDIATE gleichzeitige Aufrufe von zwei Admins koennen nicht
dieselbe Nummer ziehen. SQLite serialisiert die Writer; der zweite
wartet bis busy_timeout.
Beim ersten Aufruf fuer (prefix, year) wird die Counter-Row angelegt;
dabei wird der aktuelle Stand aus der invoices-Tabelle uebernommen
(Backfill fuer bestehende Bestaende vor Einfuehrung des Counters).
"""
year = datetime.now().year
last = conn.execute(
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1",
(f"{prefix}-{year}-%",)
# Falls noch keine Transaktion offen ist: BEGIN IMMEDIATE,
# damit der naechste Writer serialisiert wird.
if not conn.in_transaction:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT next_num FROM invoice_counters WHERE prefix=? AND year=?",
(prefix, year)
).fetchone()
if last:
n = int(last[0].split("-")[-1]) + 1
if row is None:
# Counter fehlt → Backfill aus invoices-Tabelle (max. bisheriger
# Nummer + 1), damit ein nachtraeglich eingefuehrter Counter
# nicht bei 1 startet und Kollisionen erzeugt.
last = conn.execute(
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? "
"ORDER BY id DESC LIMIT 1",
(f"{prefix}-{year}-%",)
).fetchone()
# Auch cancellation_number kann den 'ST'-Prefix tragen
last_st = conn.execute(
"SELECT cancellation_number FROM invoices "
"WHERE cancellation_number LIKE ? ORDER BY id DESC LIMIT 1",
(f"{prefix}-{year}-%",)
).fetchone()
existing_max = 0
for r in (last, last_st):
if r and r[0]:
try:
existing_max = max(existing_max, int(r[0].split("-")[-1]))
except (ValueError, IndexError):
pass
n = existing_max + 1
conn.execute(
"INSERT INTO invoice_counters (prefix, year, next_num) VALUES (?,?,?)",
(prefix, year, n + 1)
)
else:
n = 1
n = row[0]
conn.execute(
"UPDATE invoice_counters SET next_num = next_num + 1 "
"WHERE prefix=? AND year=?",
(prefix, year)
)
return f"{prefix}-{year}-{n:04d}"

View file

@ -231,6 +231,15 @@ def start():
misfire_grace_time=3600,
coalesce=True,
)
# Täglich 06:30 — Error-Digest-Mail an ADMIN_EMAIL
_scheduler.add_job(
_job_error_digest,
CronTrigger(hour=6, minute=30),
id="error_digest",
replace_existing=True,
misfire_grace_time=1800,
coalesce=True,
)
_scheduler.start()
logger.info("Scheduler gestartet (gestaffelt) — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import 1.+2./4./7./10. 02:00, Rassen-Seed 1. 03:00, Wikidata-Seed 1. 04:00, Status-Report 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:10, KI-Gesundheitsbericht Mo 07:05, Streak-Reminder 19:00, Rückruf-Check 08:05, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. 10:00, Foto-Challenge Mo 08:10, Weekly-Praise Mo 09:05, Abo-Check 03:15, Invoice-Reminder 08:30. OSM-Cache: on-demand (kein Prewarm).")
@ -2092,3 +2101,133 @@ def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None,
best_wind = sum(winds) / len(winds) if winds else None
return best_start, best_score, best_temp, best_wind
# ------------------------------------------------------------------
# JOB: Error-Digest (täglich 06:30 Uhr)
# ------------------------------------------------------------------
# Quellen fuer Fehler:
# 1. _job_log dieser Datei — Status der einzelnen Scheduler-Jobs
# 2. main.log_buffer — In-Memory-Buffer der letzten 500 Log-Zeilen
# Limitierung: log_buffer ist nicht persistent. Ein vollstaendiger 24h-Digest
# wuerde eine externe Log-Senke (Datei oder DB) brauchen. Bis dahin liefert
# der Job das was im Memory verfuegbar ist plus alle Scheduler-Errors.
# ------------------------------------------------------------------
async def _job_error_digest():
"""Schickt eine Zusammenfassung aller bekannten ERROR/EXCEPTION-Eintraege
der letzten 24h an ADMIN_EMAIL."""
import os
import html as _html
from collections import Counter
from mailer import send_email, email_html
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
logger.info("Error-Digest: ADMIN_EMAIL nicht gesetzt, uebersprungen.")
_log_job("error_digest", "ok", "ADMIN_EMAIL nicht gesetzt")
return
now = datetime.now(tz=_TZ)
cutoff = now - timedelta(hours=24)
# ── 1. Scheduler-Job-Errors einsammeln ───────────────────────
scheduler_errors = []
for jid, log in _job_log.items():
last_run = log.get("last_run")
if last_run and last_run >= cutoff and log.get("status") == "error":
scheduler_errors.append({
"job": jid,
"ts": last_run.strftime("%d.%m. %H:%M"),
"result": log.get("result", ""),
})
# ── 2. In-Memory-Log-Buffer einsammeln (best-effort) ─────────
log_errors = []
try:
from main import log_buffer # type: ignore
for entry in list(log_buffer):
lvl = entry.get("l", "")
if lvl in ("ERROR", "CRITICAL", "EXCEPTION"):
log_errors.append({
"ts": entry.get("t", ""),
"lvl": lvl,
"name": entry.get("n", ""),
"msg": entry.get("m", ""),
})
except Exception as e:
logger.debug(f"Error-Digest: log_buffer nicht verfuegbar: {e}")
# Gruppieren: Fehler-Meldungen mit gleichem msg-Prefix zusammenfassen
grouped: Counter = Counter()
for e in log_errors:
# Erste 80 Zeichen als Schluessel — schluckt Variablenwerte am Ende
key = (e["name"], e["msg"][:80])
grouped[key] += 1
# ── Wenn nichts zu melden ist: leise raus ────────────────────
if not scheduler_errors and not grouped:
logger.info("Error-Digest: Keine Errors in den letzten 24h.")
_log_job("error_digest", "ok", "0 Errors")
return
# ── HTML-Body bauen ──────────────────────────────────────────
parts = []
parts.append(f'<p style="margin:0 0 16px;color:#444">Fehler-Zusammenfassung der letzten 24h ({now.strftime("%d.%m.%Y %H:%M")}).</p>')
if scheduler_errors:
rows_html = "".join(
f'<tr>'
f'<td style="padding:6px 12px;color:#c45000;font-weight:600">{_html.escape(e["job"])}</td>'
f'<td style="padding:6px 12px;font-family:monospace;font-size:12px">{e["ts"]}</td>'
f'<td style="padding:6px 12px;color:#555">{_html.escape(e["result"])}</td>'
f'</tr>'
for e in scheduler_errors
)
parts.append(
'<h2 style="font-size:14px;color:#c45000;margin:20px 0 6px">Scheduler-Job-Fehler ({0})</h2>'.format(len(scheduler_errors))
+ f'<table style="width:100%;border-collapse:collapse;font-size:13px"><tbody>{rows_html}</tbody></table>'
)
if grouped:
sorted_groups = sorted(grouped.items(), key=lambda kv: -kv[1])
rows_html = "".join(
f'<tr>'
f'<td style="padding:6px 12px;text-align:right;font-weight:700;color:#dc2626">{count}x</td>'
f'<td style="padding:6px 12px;font-family:monospace;font-size:11px;color:#888">{_html.escape(name)}</td>'
f'<td style="padding:6px 12px;color:#444">{_html.escape(msg)}</td>'
f'</tr>'
for (name, msg), count in sorted_groups[:30]
)
parts.append(
'<h2 style="font-size:14px;color:#c45000;margin:20px 0 6px">In-Memory-Log-Errors ({0} Typen, {1} Eintraege)</h2>'.format(len(grouped), sum(grouped.values()))
+ f'<table style="width:100%;border-collapse:collapse;font-size:12px"><tbody>{rows_html}</tbody></table>'
)
parts.append('<p style="margin:24px 0 0;font-size:11px;color:#aaa">Quellen: scheduler._job_log (24h) + main.log_buffer (Memory, max. 500 Eintraege). Fuer vollstaendige 24h-Historie waere eine persistente Log-Senke noetig.</p>')
body = "\n".join(parts)
html = email_html(body, footer_text="Ban Yaro · Error-Digest")
plain_lines = [f"Ban Yaro Error-Digest — {now.strftime('%d.%m.%Y %H:%M')}", ""]
if scheduler_errors:
plain_lines.append("Scheduler-Job-Fehler:")
for e in scheduler_errors:
plain_lines.append(f" - {e['ts']} {e['job']}: {e['result']}")
plain_lines.append("")
if grouped:
plain_lines.append("Log-Errors (Top 30 Typen, gruppiert):")
for (name, msg), count in sorted(grouped.items(), key=lambda kv: -kv[1])[:30]:
plain_lines.append(f" {count}x [{name}] {msg}")
plain = "\n".join(plain_lines)
try:
await send_email(
admin,
f"Ban Yaro Error-Digest ({len(scheduler_errors)} Scheduler + {len(grouped)} Log-Errors)",
html,
plain,
)
logger.info(f"Error-Digest gesendet an {admin}{len(scheduler_errors)} scheduler, {len(grouped)} log-types.")
_log_job("error_digest", "ok", f"{len(scheduler_errors)} scheduler / {len(grouped)} log-types")
except Exception as e:
logger.error(f"Error-Digest: Mail-Fehler: {e}")
_log_job("error_digest", "error", str(e))

View file

@ -101,9 +101,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1094">
<link rel="stylesheet" href="/css/layout.css?v=1094">
<link rel="stylesheet" href="/css/components.css?v=1094">
<link rel="stylesheet" href="/css/design-system.css?v=1095">
<link rel="stylesheet" href="/css/layout.css?v=1095">
<link rel="stylesheet" href="/css/components.css?v=1095">
</head>
<body>
@ -625,11 +625,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1094"></script>
<script src="/js/ui.js?v=1094"></script>
<script src="/js/app.js?v=1094"></script>
<script src="/js/worlds.js?v=1094"></script>
<script src="/js/offline-indicator.js?v=1094"></script>
<script src="/js/api.js?v=1095"></script>
<script src="/js/ui.js?v=1095"></script>
<script src="/js/app.js?v=1095"></script>
<script src="/js/worlds.js?v=1095"></script>
<script src="/js/offline-indicator.js?v=1095"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1094'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1095'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;

View file

@ -270,8 +270,14 @@ window.Page_datenschutz = (() => {
<strong>Datenexport (Art. 20 DSGVO):</strong> Du kannst jederzeit unter
Einstellungen Meine Daten exportieren" eine vollständige Kopie deiner
gespeicherten Daten als JSON-Datei herunterladen. Der Export enthält Profildaten,
Hundedaten, Tagebuch, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
Verhaltensprotokoll, Forum-Beiträge und Gassi-Teilnahmen.
Hundedaten, Tagebuch (inkl. Medien-URLs), Gesundheitseinträge, Trainingsfortschritt,
Ausgaben, Verhaltensprotokoll, Versicherung, Ernährungsprofil und Futter-Reaktionen,
eigene Routen, Forum-Beiträge sowie Gassi-Teilnahmen und Gassi-Fotos.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Das JSON-Format ist maschinenlesbar und kann z. B. mit jedem Texteditor geöffnet
oder in andere Anwendungen importiert werden. Der Export wird direkt im Browser
erzeugt und nicht dauerhaft auf dem Server gespeichert.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Zur Ausübung weiterer Rechte wende dich per E-Mail an
@ -303,10 +309,31 @@ window.Page_datenschutz = (() => {
${sec('Speicherdauer', `
<p style="${S.p}">
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst
einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil.
Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung.
Server-Logs werden nach 30 Tagen rotiert.
Server-Logs werden nach 30 Tagen rotiert. IP-Adressen werden ausschließlich
zur Sicherheit und für Rate-Limiting maximal 30 Tage gespeichert.
</p>`)}
${sec('Account-Löschung', `
<p style="${S.p}">
Wenn du deinen Account löschst, werden deine Daten nach folgendem Schema verarbeitet:
</p>
<ul style="${S.ul}">
<li><strong>Sofort und unwiderruflich gelöscht:</strong> Account, Hundeprofile, Tagebuch
und Tagebuch-Medien, Gesundheitseinträge, Trainingsfortschritt, Ausgaben,
Verhaltensprotokoll, Versicherung, Ernährungsprofil, Futter-Einträge und -Reaktionen,
Forum-Beiträge, eigene Notizen, Direktnachrichten, Freundschaften,
Push-Benachrichtigungen, Einstellungen und Welten-Konfiguration.</li>
<li><strong>Anonymisiert (Urheber-Bezug auf NULL gesetzt):</strong> Eigene Routen,
Forum-Threads sowie von dir angelegte Wiki-Inhalte bleiben zur Verfügbarkeit für
die Community erhalten, sind aber nicht mehr deinem Account zuordenbar.</li>
<li><strong>10 Jahre aufbewahrt (gesetzliche Pflicht):</strong> Rechnungen und
Rechnungspositionen aus kostenpflichtigen Abonnements gemäß § 147 AO. Diese
enthalten Name, E-Mail-Adresse und Rechnungsadresse zum Zeitpunkt der Rechnung
und können vor Ablauf der Frist nicht gelöscht werden.</li>
</ul>
<p style="${S.p};margin-top:var(--space-3)">
Es findet keine anonymisierte Weiterverarbeitung deiner privaten Inhalte
(Tagebuch, Gesundheit, Notizen) zu Trainings- oder Statistikzwecken statt.
</p>`)}
${sec('Mindestalter', `

View file

@ -15,10 +15,35 @@ window.Page_erste_hilfe = (() => {
// ----------------------------------------------------------------
// DATA
// ----------------------------------------------------------------
const NOTFALLNUMMERN = [
{ label: 'Tiergiftzentrale München', tel: '+4989 19240', display: '+49 89 19240' },
{ label: 'Tiergiftzentrale Berlin', tel: '+4930 19240', display: '+49 30 19240' },
{ label: 'Tiergiftzentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
// Liste von Notrufen, nach Land gruppiert.
// Struktur ist erweiterbar: weitere Länder/Städte einfach an die jeweilige
// Gruppe anhängen. Einträge mit tel:null werden als "TODO: Nummer einfügen"
// dargestellt — Rendering kümmert sich um Optik und tel: -Link.
const NOTFALLNUMMERN_GRUPPEN = [
{
land: 'Deutschland',
flag: 'DE',
eintraege: [
{ label: 'Tiergiftzentrale München', tel: '+4989 19240', display: '+49 89 19240' },
{ label: 'Tiergiftzentrale Berlin', tel: '+4930 19240', display: '+49 30 19240' },
],
},
{
land: 'Österreich',
flag: 'AT',
eintraege: [
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
{ label: 'Veterinärmedizinische Universität Wien (Notfallklinik)', tel: null, display: 'TODO: Nummer einfügen' },
],
},
{
land: 'Schweiz',
flag: 'CH',
eintraege: [
{ label: 'Tox Info Suisse (Tiergiftnotruf)', tel: null, display: 'TODO: Nummer einfügen (ggf. 145)' },
{ label: 'Tierspital Zürich', tel: null, display: 'TODO: Nummer einfügen' },
],
},
];
const SCHNELL = [
@ -213,6 +238,7 @@ window.Page_erste_hilfe = (() => {
_container.innerHTML = `
<div id="eh-wrap" style="padding-bottom:var(--space-8)">
${_renderDisclaimer()}
${_renderNotfallbanner()}
${_renderSchnell()}
@ -244,13 +270,51 @@ window.Page_erste_hilfe = (() => {
_activateTab('lebensgefahr');
}
function _renderDisclaimer() {
return `
<div role="alert" style="display:flex;align-items:flex-start;gap:var(--space-3);
background:#fef3c7;color:#78350f;border-left:4px solid #d97706;
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-4);font-size:var(--text-sm);line-height:1.5">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:#d97706;width:22px;height:22px;margin-top:2px"><use href="/icons/phosphor.svg#warning"></use></svg>
<div>
<strong style="display:block;margin-bottom:2px">Diese Hinweise ersetzen keine tierärztliche Beratung.</strong>
Im Notfall sofort einen Tierarzt aufsuchen!
</div>
</div>
`;
}
function _renderNotfallbanner() {
const nums = NOTFALLNUMMERN.map(n => `
<a href="tel:${n.tel}"
style="display:flex;align-items:center;gap:var(--space-2);color:#fff;text-decoration:none;font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.15);border-radius:var(--radius-md)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
<span><strong>${n.label}</strong><br>${n.display}</span>
</a>
const renderEintrag = (n) => {
// Eintrag mit verfügbarer Nummer → tel:-Link
if (n.tel) {
return `
<a href="tel:${n.tel}"
style="display:flex;align-items:center;gap:var(--space-2);color:#fff;text-decoration:none;font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.15);border-radius:var(--radius-md)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
<span><strong>${n.label}</strong><br>${n.display}</span>
</a>
`;
}
// Eintrag ohne Nummer → ausgegrauter Platzhalter
return `
<div style="display:flex;align-items:center;gap:var(--space-2);color:rgba(255,255,255,0.85);font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.08);border-radius:var(--radius-md);border:1px dashed rgba(255,255,255,0.35)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
<span><strong>${n.label}</strong><br><em style="font-style:normal;opacity:.85">${n.display}</em></span>
</div>
`;
};
const gruppen = NOTFALLNUMMERN_GRUPPEN.map(g => `
<div>
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:rgba(255,255,255,0.85);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:var(--space-1)">
${g.flag} · ${g.land}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${g.eintraege.map(renderEintrag).join('')}
</div>
</div>
`).join('');
return `
@ -259,8 +323,8 @@ window.Page_erste_hilfe = (() => {
<svg class="ph-icon" style="width:20px;height:20px" aria-hidden="true"><use href="/icons/phosphor.svg#siren"></use></svg>
Tiergiftzentralen jetzt anrufen
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${nums}
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${gruppen}
</div>
<p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)">
Tierärztlicher Notdienst: Über die Tierarztsuche in der Banyaro-Karte

View file

@ -33,7 +33,7 @@ window.Page_impressum = (() => {
<form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
<label for="cf-name" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
<input id="cf-name" type="text" required maxlength="100"
placeholder="Dein Name"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -41,7 +41,7 @@ window.Page_impressum = (() => {
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">E-Mail *</label>
<label for="cf-email" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">E-Mail *</label>
<input id="cf-email" type="email" required maxlength="200"
placeholder="deine@email.de"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -50,7 +50,7 @@ window.Page_impressum = (() => {
</div>
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Betreff *</label>
<label for="cf-subject" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Betreff *</label>
<input id="cf-subject" type="text" required maxlength="150"
placeholder="Worum geht es?"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -58,7 +58,7 @@ window.Page_impressum = (() => {
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Nachricht *</label>
<label for="cf-message" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Nachricht *</label>
<textarea id="cf-message" required maxlength="3000" rows="5"
placeholder="Deine Nachricht…"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);

View file

@ -123,20 +123,69 @@ const UI = (() => {
};
overlay.addEventListener('focusin', _onFocusin);
_current = { overlay, onClose, _vvCleanup, _onFocusin };
// -----------------------------------------------------
// Accessibility: ESC schließt + Focus-Trap
// -----------------------------------------------------
const FOCUSABLE_SEL = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const _getFocusables = () =>
Array.from(modal?.querySelectorAll(FOCUSABLE_SEL) || [])
.filter(el => el.offsetParent !== null || el === document.activeElement);
const _prevFocus = document.activeElement;
const _onKeydown = e => {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key !== 'Tab') return;
const focusables = _getFocusables();
if (!focusables.length) { e.preventDefault(); return; }
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first || !modal.contains(document.activeElement)) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last || !modal.contains(document.activeElement)) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', _onKeydown);
// Erstes fokussierbares Element autofokussieren (nach Render)
setTimeout(() => {
const focusables = _getFocusables();
// Schließen-Button überspringen, falls weitere Elemente vorhanden
const target = focusables.find(el => !el.classList.contains('modal-close-btn')) || focusables[0];
target?.focus();
}, 50);
_current = { overlay, onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus };
return overlay.querySelector('.modal');
}
function close() {
if (!_current) return;
const { onClose, _vvCleanup, _onFocusin } = _current;
const { onClose, _vvCleanup, _onFocusin, _onKeydown, _prevFocus } = _current;
onClose?.();
_vvCleanup?.();
if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin);
if (_onKeydown) document.removeEventListener('keydown', _onKeydown);
_current.overlay.remove();
document.documentElement.classList.remove('modal-open');
_current = null;
// Fokus auf vorheriges Element zurücksetzen (falls noch im DOM)
if (_prevFocus && typeof _prevFocus.focus === 'function' && document.body.contains(_prevFocus)) {
try { _prevFocus.focus(); } catch (_) {}
}
// iOS Safari setzt den Zoom nach Input-Fokus nicht zurück — Viewport kurz neu setzen
const meta = document.querySelector('meta[name="viewport"]');
if (meta) {

View file

@ -108,7 +108,7 @@
"Probeverpaarung mit IK-Simulation und genetischer Risikoanalyse",
"Tierschutz-Check automatisch bei jeder Verpaarung",
"KI-Züchter-Assistenz: Wurfankündigungen, Genetik, Paarungsanalyse",
"Datenexport als HTML und ODS",
"Datenexport als JSON (DSGVO Art. 20)",
"Hunde-Filmdatenbank: 68 Filme, Serien und Dokumentationen sortier- und filterbar",
"Filmdatenbank-Feature: Stirbt der Hund? — Taschentuch-Warnung",
"Berühmte Hunde der Geschichte",

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1094';
const VER = '1095';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

10
pytest.ini Normal file
View file

@ -0,0 +1,10 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -ra --strict-markers
filterwarnings =
ignore::DeprecationWarning
markers =
smoke: lightweight smoke tests

0
tests/__init__.py Normal file
View file

172
tests/conftest.py Normal file
View file

@ -0,0 +1,172 @@
"""
BAN YARO pytest Fixtures
Wichtig:
- ENV-Variablen werden VOR dem Import von `backend.main` gesetzt,
weil `database.DB_PATH = os.getenv("DB_PATH", ...)` beim Modul-Import gebunden wird.
- Wir mocken APScheduler weg, damit beim lifespan-Startup keine Hintergrund-Jobs starten.
- Pro Test-Session legen wir eine frische SQLite-Datei in tmp_path an.
"""
from __future__ import annotations
import os
import sys
import secrets
import tempfile
import shutil
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
BACKEND_DIR = ROOT / "backend"
# Backend-Pfad in sys.path, damit `from main import app` funktioniert
sys.path.insert(0, str(BACKEND_DIR))
# ------------------------------------------------------------------
# Session-weite App-Instanz
# ------------------------------------------------------------------
@pytest.fixture(scope="session")
def _tmp_db_dir():
"""Temp-Verzeichnis für DB + Media während der Test-Session."""
d = tempfile.mkdtemp(prefix="banyaro-tests-")
yield Path(d)
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture(scope="session")
def app(_tmp_db_dir):
"""
Initialisiert die FastAPI-App einmal pro Test-Session mit:
- Test-DB in tmp_path
- Stubbed Scheduler (keine Background-Jobs)
- KI_MODE=off (keine Netzwerk-Aufrufe)
- SMTP-Versand wird gemockt
"""
db_file = _tmp_db_dir / "test.db"
media_dir = _tmp_db_dir / "media"
breeder_dir = _tmp_db_dir / "breeder_docs"
media_dir.mkdir(exist_ok=True)
breeder_dir.mkdir(exist_ok=True)
os.environ["DB_PATH"] = str(db_file)
os.environ["MEDIA_DIR"] = str(media_dir)
os.environ["BREEDER_DOCS_DIR"] = str(breeder_dir)
os.environ["KI_MODE"] = "off"
os.environ["ENV"] = "test"
os.environ["JWT_SECRET"] = "test-secret-" + secrets.token_hex(8)
os.environ["STAGING"] = "false"
os.environ["ADMIN_EMAIL"] = "test-admin@example.com"
# SMTP nicht konfigurieren → _SMTP_READY=False, Mails werden geskippt
os.environ.pop("SMTP_SUPPORT_USER", None)
os.environ.pop("SMTP_SUPPORT_PASS", None)
# Scheduler-Modul vor main-Import stubben — sonst startet APScheduler beim Lifespan
import scheduler as _sched
_sched.start = lambda: None # type: ignore[assignment]
_sched.stop = lambda: None # type: ignore[assignment]
# Mailer abklemmen — keine echten E-Mails
import mailer as _mailer
async def _noop_send(*args, **kwargs):
return True
_mailer.send_email = _noop_send # type: ignore[assignment]
# Rate-Limit + Login-Lockout fuer Tests deaktivieren — sonst
# blockiert /api/auth/register nach 5 Aufrufen die ganze Test-Session.
import ratelimit as _rl
_noop_rl = lambda *a, **kw: None
_rl.check = _noop_rl # type: ignore[assignment]
_rl.is_account_locked = lambda *a, **kw: False # type: ignore[assignment]
_rl.record_login_failure = lambda *a, **kw: 0 # type: ignore[assignment]
# App importieren (initialisiert DB beim lifespan)
from main import app as fastapi_app
# Module-level Aliase ersetzen (Imports der Form `from ratelimit import check as rl_check`)
import routes.auth as _routes_auth
_routes_auth.rl_check = _noop_rl # type: ignore[assignment]
if hasattr(_routes_auth, "_db_is_account_locked"):
_routes_auth._db_is_account_locked = lambda *a, **kw: None # type: ignore[assignment]
if hasattr(_routes_auth, "_db_record_login_failure"):
_routes_auth._db_record_login_failure = lambda *a, **kw: 0 # type: ignore[assignment]
return fastapi_app
@pytest.fixture(scope="session")
def client(app):
"""FastAPI TestClient — triggert lifespan (init_db, seed_movies, …)."""
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
# ------------------------------------------------------------------
# Test-User
# ------------------------------------------------------------------
def _register_and_verify(client, email: str, password: str, name: str) -> dict:
"""Helper: Register User + setzt email_verified=1 direkt in der DB."""
r = client.post("/api/auth/register", json={
"email": email, "password": password, "name": name
})
assert r.status_code == 200, f"Register failed: {r.status_code} {r.text}"
# email_verified=1 setzen (umgeht E-Mail-Verifikation für Tests)
from database import db
with db() as conn:
conn.execute(
"UPDATE users SET email_verified=1 WHERE email=?", (email,)
)
return {"email": email, "password": password, "name": name}
def _login(client, email: str, password: str) -> str:
"""Helper: Login → JWT-Token zurueck."""
r = client.post("/api/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}"
return r.json()["token"]
@pytest.fixture
def user(client):
"""Frisch registrierter und verifizierter Test-User mit JWT-Token."""
email = f"user-{secrets.token_hex(4)}@example.com"
pw = "TestPass123!"
name = f"tester{secrets.token_hex(3)}"
info = _register_and_verify(client, email, pw, name)
token = _login(client, email, pw)
info["token"] = token
info["headers"] = {"Authorization": f"Bearer {token}"}
return info
@pytest.fixture
def admin(client):
"""Test-Admin: registriert, verifiziert, rolle='admin' direkt in DB gesetzt."""
email = f"admin-{secrets.token_hex(4)}@example.com"
pw = "AdminPass123!"
# "admin"/"administrator" sind in der Blocklist → Bezeichner "ops..." verwenden
name = f"ops{secrets.token_hex(3)}"
info = _register_and_verify(client, email, pw, name)
from database import db
with db() as conn:
conn.execute("UPDATE users SET rolle='admin' WHERE email=?", (email,))
token = _login(client, email, pw)
info["token"] = token
info["headers"] = {"Authorization": f"Bearer {token}"}
return info
@pytest.fixture
def dog(client, user):
"""Erstellt einen Hund fuer den Test-User und gibt das Hund-Objekt zurueck."""
r = client.post(
"/api/dogs",
headers=user["headers"],
json={"name": "Buddy", "rasse": "Labrador", "is_public": False},
)
assert r.status_code in (200, 201), f"create_dog failed: {r.status_code} {r.text}"
return r.json()

56
tests/test_auth.py Normal file
View file

@ -0,0 +1,56 @@
"""Smoke-Tests fuer Auth-Flows: Register, Login, Logout, /me."""
import secrets
def test_register_creates_pending_user(client):
"""Frischer User -> pending_verification=True."""
email = f"new-{secrets.token_hex(4)}@example.com"
r = client.post("/api/auth/register", json={
"email": email,
"password": "TestPass123!",
"name": f"user{secrets.token_hex(3)}",
})
assert r.status_code == 200, r.text
assert r.json().get("pending_verification") is True
def test_login_with_wrong_password_returns_401(client, user):
"""Falsches Passwort -> 401."""
r = client.post("/api/auth/login", json={
"email": user["email"], "password": "WrongPass!!"
})
assert r.status_code == 401
def test_login_returns_token(client, user):
"""Korrekte Credentials -> JWT-Token."""
r = client.post("/api/auth/login", json={
"email": user["email"], "password": user["password"]
})
assert r.status_code == 200
assert "token" in r.json()
def test_me_requires_auth(client):
"""/api/auth/me ohne Token -> 401."""
r = client.get("/api/auth/me")
assert r.status_code == 401
def test_me_returns_user_info(client, user):
"""/api/auth/me mit gueltigem Token -> User-Objekt."""
r = client.get("/api/auth/me", headers=user["headers"])
assert r.status_code == 200
data = r.json()
assert data["email"] == user["email"]
assert data["name"] == user["name"]
# email_verified wurde im Fixture per DB-Update auf 1 gesetzt
assert data["email_verified"] == 1
def test_logout_clears_cookie(client, user):
"""/api/auth/logout -> ok."""
r = client.post("/api/auth/logout", headers=user["headers"])
assert r.status_code == 200
assert r.json()["ok"] is True

71
tests/test_diary.py Normal file
View file

@ -0,0 +1,71 @@
"""Smoke-Tests fuer Tagebuch-CRUD."""
def test_create_diary_entry(client, user, dog):
"""POST /api/dogs/{id}/diary -> 201 mit Entry."""
r = client.post(
f"/api/dogs/{dog['id']}/diary",
headers=user["headers"],
json={"titel": "Mein erster Eintrag", "text": "Heute war ein toller Tag."},
)
assert r.status_code == 201, r.text
entry = r.json()
assert entry["titel"] == "Mein erster Eintrag"
assert entry["dog_id"] == dog["id"]
def test_list_diary_entries(client, user, dog):
"""GET /api/dogs/{id}/diary listet erstellte Eintraege."""
client.post(
f"/api/dogs/{dog['id']}/diary",
headers=user["headers"],
json={"titel": "Eintrag A", "text": "Erstes Mal Gassi."},
)
client.post(
f"/api/dogs/{dog['id']}/diary",
headers=user["headers"],
json={"titel": "Eintrag B", "text": "Tierarzt."},
)
r = client.get(f"/api/dogs/{dog['id']}/diary", headers=user["headers"])
assert r.status_code == 200
entries = r.json()
assert isinstance(entries, list)
titles = [e["titel"] for e in entries]
assert "Eintrag A" in titles
assert "Eintrag B" in titles
def test_get_single_diary_entry(client, user, dog):
"""GET /api/dogs/{id}/diary/{entry_id} liefert genauen Eintrag."""
r = client.post(
f"/api/dogs/{dog['id']}/diary",
headers=user["headers"],
json={"titel": "Einzeleintrag", "text": "Inhalt."},
)
eid = r.json()["id"]
r2 = client.get(f"/api/dogs/{dog['id']}/diary/{eid}", headers=user["headers"])
assert r2.status_code == 200
assert r2.json()["titel"] == "Einzeleintrag"
def test_delete_diary_entry(client, user, dog):
"""DELETE entfernt Eintrag (204) — danach 404."""
r = client.post(
f"/api/dogs/{dog['id']}/diary",
headers=user["headers"],
json={"titel": "Loeschen mich", "text": "."},
)
eid = r.json()["id"]
r2 = client.delete(f"/api/dogs/{dog['id']}/diary/{eid}", headers=user["headers"])
assert r2.status_code == 204
r3 = client.get(f"/api/dogs/{dog['id']}/diary/{eid}", headers=user["headers"])
assert r3.status_code == 404
def test_diary_unauth(client, dog):
"""Ohne Token -> 401."""
r = client.get(f"/api/dogs/{dog['id']}/diary")
assert r.status_code == 401

32
tests/test_health.py Normal file
View file

@ -0,0 +1,32 @@
"""Smoke-Tests fuer trivialste Endpoints (kein Auth noetig)."""
def test_api_version(client):
"""GET /api/version sollte die zentrale VERSION zurueckgeben."""
r = client.get("/api/version")
assert r.status_code == 200
data = r.json()
assert "version" in data
# APP_VER wird aus VERSION-Datei gelesen → muss ein Zahlen-String sein
assert data["version"].isdigit(), f"version='{data['version']}' ist nicht numerisch"
def test_assetlinks(client):
"""TWA-Verifikation fuer Play Store."""
r = client.get("/.well-known/assetlinks.json")
assert r.status_code == 200
assert "package_name" in r.text
def test_robots(client):
"""robots.txt muss erreichbar sein."""
r = client.get("/robots.txt")
assert r.status_code == 200
def test_manifest(client):
"""PWA-Manifest."""
r = client.get("/manifest.json")
assert r.status_code == 200
data = r.json()
assert "name" in data

91
tests/test_invoice.py Normal file
View file

@ -0,0 +1,91 @@
"""Smoke-Tests fuer Rechnungs-CRUD (Admin)."""
import os
import secrets
def test_admin_create_invoice(client, admin):
"""POST /api/admin/invoices als Admin -> 201 mit Rechnungsnummer."""
r = client.post(
"/api/admin/invoices",
headers=admin["headers"],
json={
"recipient_name": "Max Mustermann",
"recipient_email": "max@example.com",
"items": [{"description": "Pro-Jahresabo", "quantity": 1, "unit_price": 29.0}],
"service_period": "2026-01-01 - 2026-12-31",
"notes": "Test-Rechnung",
},
)
assert r.status_code == 201, r.text
data = r.json()
assert data["invoice_number"].startswith("RG-")
assert data["amount_gross"] == 29.0
assert data["status"] == "draft"
assert len(data["items"]) == 1
def test_admin_list_invoices(client, admin):
"""GET /api/admin/invoices listet vorhandene Rechnungen."""
# Eine erstellen
client.post(
"/api/admin/invoices",
headers=admin["headers"],
json={
"recipient_name": "Tester Listing",
"recipient_email": f"list-{secrets.token_hex(3)}@example.com",
"items": [{"description": "Sub", "quantity": 1, "unit_price": 49.0}],
},
)
r = client.get("/api/admin/invoices", headers=admin["headers"])
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
assert len(data) >= 1
def test_non_admin_cannot_create_invoice(client, user):
"""Normaler User -> 403."""
r = client.post(
"/api/admin/invoices",
headers=user["headers"],
json={
"recipient_name": "x",
"recipient_email": "x@example.com",
"items": [{"description": "x", "quantity": 1, "unit_price": 1.0}],
},
)
assert r.status_code == 403
def test_admin_send_invoice(client, admin, tmp_path, monkeypatch):
"""Send-Endpoint generiert PDF, ruft (gemockten) Mailer auf, setzt Status auf 'sent'."""
# Paperless-Verzeichnis auf tmp_path setzen
scaninput = tmp_path / "scaninput"
scaninput.mkdir()
monkeypatch.setenv("SCANINPUT_DIR", str(scaninput))
monkeypatch.setenv("PAPERLESS_URL", "") # kein HTTP-Call
monkeypatch.setenv("KLEINUNTERNEHMER", "true")
# Rechnung anlegen
r = client.post(
"/api/admin/invoices",
headers=admin["headers"],
json={
"recipient_name": "Send-Test",
"recipient_email": f"send-{secrets.token_hex(3)}@example.com",
"items": [{"description": "Sub", "quantity": 1, "unit_price": 29.0}],
"service_period": "2026-01-01 - 2026-12-31",
},
)
assert r.status_code == 201, r.text
inv = r.json()
# Senden
r2 = client.post(f"/api/admin/invoices/{inv['id']}/send", headers=admin["headers"])
assert r2.status_code == 200, r2.text
# Status auf 'sent'?
r3 = client.get(f"/api/admin/invoices/{inv['id']}", headers=admin["headers"])
assert r3.status_code == 200
assert r3.json()["status"] == "sent"