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
176 lines
5.1 KiB
Python
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
|