- Lien d'inscription protégé par un jeton (FormTemplate.access_token, /inscription/<token>), avec régénération depuis l'écran de configuration. - Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel. - Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée. - Infobulles (HelpTip et détail de cellule de la grille des besoins) repositionnées en position: fixed via JS, bornées à l'écran. - Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le jeton d'inscription), sujet retiré du log de remise des emails (pouvait porter un nom de bénévole), photo du formulaire public revalidée par décodage réel avant stockage, export Excel protégé contre l'injection de formule, sondage du statut de paiement limité en fréquence, pyjwt mis à jour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
183 lines
5.3 KiB
Python
183 lines
5.3 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 (
|
|
form_submission_limiter,
|
|
login_limiter,
|
|
magic_link_limiter,
|
|
pending_status_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()
|
|
form_submission_limiter.reset()
|
|
pending_status_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
|