Files
benevoles/backend/tests/conftest.py
T
AdamThomasandClaude Sonnet 5 0ede409841 Limiteur de tentatives sur /auth/login et /auth/magic-link
Suite de la revue de sécurité (commit précédent) : ni /auth/login (mot
de passe COORDINATION) ni /auth/magic-link n'avaient de limite de
tentatives — aucun verrou de compte, aucun throttling, avant ouverture
au public.

`app/auth/rate_limit.py` : limiteur en mémoire (un seul processus
uvicorn, cf. Dockerfile), à créneau glissant, par clé (édition, email)
plutôt que par IP — le VPS ne transmet pas encore l'IP réelle du
client à travers Caddy, et le risque réel ici est de deviner le mot de
passe d'un compte connu (peu de comptes protégés par mot de passe),
pas une attaque distribuée sur des emails au hasard.

- `/auth/login` : 5 tentatives/minute → 429 `error.too_many_requests`.
- `/auth/magic-link` : 3 demandes/5 min (évite aussi le spam d'emails
  vers une victime ou vers le SMTP).

Fixture `_reset_rate_limiters` (autouse) pour ne pas faire fuiter l'état
d'un test à l'autre. Testé : dépassement, indépendance des clés,
expiration de la fenêtre, scénarios bout en bout sur les deux
endpoints.

Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés
en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-16 08:19:27 +02:00

176 lines
5.1 KiB
Python

import datetime as dt
import itertools
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine, select
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
from app.auth.deps import get_current_account
from app.auth.rate_limit import login_limiter, magic_link_limiter
from app.config import get_settings
from app.db import get_session
from app.main import app
from app.models import Account, DayPart, Edition, Role
@pytest.fixture(autouse=True)
def _never_use_real_smtp(monkeypatch):
"""Quel que soit le `.env` local, les tests n'ouvrent jamais de connexion
SMTP : `EMAIL_ENABLED` est forcé à false."""
monkeypatch.setenv("EMAIL_ENABLED", "false")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
"""Les limiteurs de `/auth/login` et `/auth/magic-link` sont des
singletons en mémoire (voir `app/auth/rate_limit.py`) : un test qui en
déclenche un ne doit pas polluer les suivants."""
login_limiter.reset()
magic_link_limiter.reset()
yield
@pytest.fixture(scope="session")
def engine():
"""Vraie base PostgreSQL de test (voir docker/postgres-init)."""
eng = create_engine(get_settings().test_database_url, pool_pre_ping=True)
SQLModel.metadata.drop_all(eng)
SQLModel.metadata.create_all(eng)
yield eng
SQLModel.metadata.drop_all(eng)
@pytest.fixture
def session(engine):
"""Session isolée : chaque test s'exécute dans une transaction annulée
à la fin, y compris ses `commit()` (savepoints)."""
connection = engine.connect()
transaction = connection.begin()
s = Session(bind=connection, join_transaction_mode="create_savepoint")
try:
yield s
finally:
s.close()
transaction.rollback()
connection.close()
@pytest.fixture
def edition(session) -> Edition:
"""Édition de 4 jours avec 3 demi-journées paramétrées."""
ed = Edition(
year=2026,
name="Festival Test",
start_date=dt.date(2026, 7, 9),
end_date=dt.date(2026, 7, 12),
retention_days=365,
)
session.add(ed)
session.commit()
session.refresh(ed)
session.add_all(
[
DayPart(
edition_id=ed.id, code="MORNING", label="Matin",
start_time=dt.time(6, 0), end_time=dt.time(13, 0), order=0,
),
DayPart(
edition_id=ed.id, code="AFTERNOON", label="Après-midi",
start_time=dt.time(13, 0), end_time=dt.time(19, 0), order=1,
),
DayPart(
edition_id=ed.id, code="EVENING", label="Soirée",
start_time=dt.time(19, 0), end_time=dt.time(6, 0), order=2,
),
]
)
session.commit()
return ed
@pytest.fixture
def day_parts(session, edition) -> dict[str, DayPart]:
parts = session.exec(
select(DayPart).where(DayPart.edition_id == edition.id)
)
return {p.code: p for p in parts}
@pytest.fixture
def make_account(session, edition):
"""Fabrique de comptes rattachés à l'édition de test."""
counter = itertools.count()
def _make(
role: Role,
*,
volunteer_id=None,
email: str | None = None,
password: str | None = None,
) -> Account:
from app.auth.passwords import hash_password
account = Account(
edition_id=edition.id,
role=role,
email=email or f"{role.value.lower()}{next(counter)}@test",
volunteer_id=volunteer_id,
password_hash=hash_password(password) if password else None,
)
session.add(account)
session.commit()
session.refresh(account)
return account
return _make
@pytest.fixture
def coordinator(make_account) -> Account:
return make_account(Role.COORDINATOR)
@pytest.fixture
def as_account(session):
"""Fabrique de clients HTTP authentifiés. Fixe l'identité **active** de
l'app : le dernier appel l'emporte. Pour rejouer un autre rôle après en
avoir simulé un, rappeler `as_account(...)`."""
def _make(account: Account) -> TestClient:
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_current_account] = lambda: account
return TestClient(app)
yield _make
app.dependency_overrides.clear()
@pytest.fixture
def client(coordinator, as_account) -> TestClient:
"""Client authentifié en COORDINATION (rôle par défaut des tests)."""
return as_account(coordinator)
@pytest.fixture
def raw_client(session):
"""Client sans authentification simulée (pour tester le lien magique)."""
app.dependency_overrides[get_session] = lambda: session
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture
def outbox(monkeypatch) -> list:
"""Capture les emails au lieu de les remettre (SMTP désactivé de toute
façon en test ; ceci permet de les inspecter)."""
box: list = []
monkeypatch.setattr(
"app.email.sender.deliver_many", lambda messages: box.extend(messages)
)
return box