Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
ci-cd / build-backend (push) Successful in 1m12s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

- 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>
This commit is contained in:
AdamThomas
2026-10-02 07:47:17 +02:00
co-authored by Claude Sonnet 5
parent a2b748566b
commit 096b7cd26a
44 changed files with 1228 additions and 156 deletions
+97 -1
View File
@@ -3,9 +3,10 @@ import io
import uuid
from openpyxl import load_workbook
from pypdf import PdfReader
from sqlmodel import select
from app.models import Assignment, Availability, Role, Volunteer
from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer
from tests.factories import (
dt_at,
make_artist,
@@ -426,7 +427,102 @@ def test_export_volunteers_as_excel(client, edition, session):
assert row[6] == "Chilla"
def test_export_neutralizes_formula_injection(client, edition, session):
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
# authentification) contrôle son nom, son commentaire, etc. — ouverts
# tels quels dans Excel par la coordination, une valeur comme
# `=cmd|'/c calc'!A0` y serait exécutée comme une formule. Un préfixe
# `'` force l'interprétation en texte (voir `app/exporting/xlsx.py`).
volunteer = make_volunteer(
session, edition, first_name="=cmd|'/c calc'!A0", last_name="Martin"
)
client.patch(
_url(edition, f"/{volunteer.id}"),
json={"comment": "@SUM(1+1)*cmd|' /C calc'!A0"},
)
resp = client.get(_url(edition, "/export"))
workbook = load_workbook(io.BytesIO(resp.content))
row = list(workbook.active.iter_rows(values_only=True))[1]
assert row[0] == "'=cmd|'/c calc'!A0"
assert row[17] == "'@SUM(1+1)*cmd|' /C calc'!A0"
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/export"))
assert resp.status_code == 403
def _mark_mecenat_paid(session, volunteer, *, amount=50.0):
"""Simule l'état d'un bénévole ayant payé un type de paiement « mécénat »
(voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
marqueur qui distingue ce chemin d'un simple paiement classique."""
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_amount = amount
volunteer.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
volunteer.mecenat_address = "12 rue des Lilas"
volunteer.mecenat_city = "Paris"
volunteer.mecenat_postal_code = "75020"
volunteer.mecenat_country = "France"
session.add(volunteer)
session.commit()
def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors(
client, edition, session
):
paid_mecenat = make_volunteer(
session, edition, first_name="Alex", last_name="Martin"
)
_mark_mecenat_paid(session, paid_mecenat)
# Cotisation classique payée (pas de mécénat) : ne doit pas apparaître.
paid_classique = make_volunteer(
session, edition, first_name="Sam", last_name="Petit"
)
paid_classique.fee_payment_status = FeePaymentStatus.PAID
paid_classique.fee_amount = 15.0
paid_classique.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
session.add(paid_classique)
session.commit()
# Mécénat choisi mais paiement non abouti : ne doit pas apparaître.
failed_mecenat = make_volunteer(
session, edition, first_name="Jo", last_name="Dupuis"
)
failed_mecenat.fee_payment_status = FeePaymentStatus.FAILED
failed_mecenat.mecenat_address = "1 rue X"
session.add(failed_mecenat)
session.commit()
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert f"recus-mecenat-{edition.year}.pdf" in resp.headers["content-disposition"]
reader = PdfReader(io.BytesIO(resp.content))
# Un reçu = 2 pages (le cerfa officiel) — un seul bénévole qualifie ici.
assert len(reader.pages) == 2
donor_page_text = reader.pages[1].extract_text()
assert "Martin" in donor_page_text
assert "Alex" in donor_page_text
assert "50,00" in donor_page_text
assert "05/03/2026" in donor_page_text
assert "Petit" not in donor_page_text
def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "mecenat.no_receipts"
def test_mecenat_receipts_is_coordinator_only(
edition, session, make_account, as_account
):
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, volunteer)
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 403