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
+2
View File
@@ -11,6 +11,7 @@ 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
@@ -36,6 +37,7 @@ def _reset_rate_limiters():
login_limiter.reset()
magic_link_limiter.reset()
form_submission_limiter.reset()
pending_status_limiter.reset()
yield
+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
+29
View File
@@ -106,3 +106,32 @@ def test_render_magic_link_uses_translations():
assert subject
assert "https://x/connexion?token=abc" in body
assert "30" in body
def test_message_without_logo_has_no_inline_image():
message = sender.build_message("a@b.fr", "Sujet", "Corps")
html_part = message.get_body(preferencelist=("html",))
assert "cid:" not in html_part.get_content()
assert not any(part.get_content_type() == "image/png" for part in message.walk())
def test_logo_is_embedded_inline_never_as_an_external_url():
# Le logo est une pièce jointe « inline » (Content-ID), jamais une URL :
# `GET .../form/logo` exige le `access_token` du formulaire public
# depuis l'étape 27 — une URL externe dans l'email l'aurait exposé à
# quiconque lit la source du message (voir `app/email/branding.py`).
logo_bytes = b"\x89PNG\r\n\x1a\n" + b"fake-png-bytes"
message = sender.build_message(
"a@b.fr", "Sujet", "Corps", logo=(logo_bytes, "image/png")
)
html_part = message.get_body(preferencelist=("html",))
assert "cid:edition-logo" in html_part.get_content()
assert "access_token" not in html_part.get_content()
assert "http://" not in html_part.get_content()
assert "https://" not in html_part.get_content()
image_parts = [p for p in message.walk() if p.get_content_type() == "image/png"]
assert len(image_parts) == 1
assert image_parts[0].get_content() == logo_bytes
assert image_parts[0]["Content-ID"] == "<edition-logo>"
+131 -13
View File
@@ -6,14 +6,27 @@ import datetime as dt
import io
import uuid
from app.auth.rate_limit import form_submission_limiter
from app.models import PendingFormSubmission, Role, local_now
import pytest
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter
from app.models import FormTemplate, PendingFormSubmission, Role, local_now
from app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position
from tests.mail import plain_text
_MERCHANT = "MXXXXX"
_API_KEY = "sup_sk_secret"
# `FormTemplate.access_token` est aléatoire par défaut (voir
# `app/models/domain.py`) — fixé pour toute inscription en attente créée
# par ce module, pour ne pas avoir à le relire dynamiquement dans chaque
# test (voir `_fixed_access_token` plus bas).
_ACCESS_TOKEN = "test-access-token"
@pytest.fixture(autouse=True)
def _fixed_access_token(session, edition):
session.add(FormTemplate(edition_id=edition.id, access_token=_ACCESS_TOKEN))
session.commit()
def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique"):
@@ -58,7 +71,14 @@ def _form_url(edition, suffix=""):
return f"/editions/{edition.id}/form{suffix}"
def _public(edition, suffix):
"""`GET .../form/public` et `GET .../form/logo` exigent `?token=...` en
plus de l'édition (voir `app/api/forms.py:_require_access_token`)."""
return f"{_form_url(edition, suffix)}?token={_ACCESS_TOKEN}"
def _submit(client, edition, **fields):
fields.setdefault("access_token", _ACCESS_TOKEN)
return client.post(_form_url(edition, "/public/submit"), data=fields)
@@ -112,11 +132,55 @@ def test_clearing_description_with_an_explicit_null(client, edition):
assert resp.json()["description"] is None
def test_public_form_requires_the_access_token(raw_client, edition):
resp = raw_client.get(_form_url(edition, "/public"))
assert resp.status_code == 422 # `token` absent
resp = raw_client.get(_form_url(edition, "/public?token=wrong"))
assert resp.status_code == 404
assert resp.json()["detail"]["code"] == "error.not_found"
def test_submitting_without_the_right_access_token_is_rejected(raw_client, edition):
resp = raw_client.post(
_form_url(edition, "/public/submit"),
data={
"first_name": "Alex",
"last_name": "Martin",
"hcaptcha_token": "x",
"access_token": "wrong",
},
)
assert resp.status_code == 404
assert resp.json()["detail"]["code"] == "error.not_found"
def test_regenerating_the_access_token_invalidates_the_old_link(
client, raw_client, edition
):
old_token = client.get(_form_url(edition)).json()["access_token"]
assert old_token == _ACCESS_TOKEN
resp = client.post(_form_url(edition, "/regenerate-token"))
assert resp.status_code == 200
new_token = resp.json()["access_token"]
assert new_token != old_token
assert raw_client.get(_form_url(edition, f"/public?token={old_token}")).status_code == 404
assert raw_client.get(_form_url(edition, f"/public?token={new_token}")).status_code == 200
def test_regenerate_token_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).post(_form_url(edition, "/regenerate-token"))
assert resp.status_code == 403
def test_sumup_not_configured_by_default(client, raw_client, edition):
body = client.get(_form_url(edition)).json()
assert body["sumup_merchant_code"] is None
assert body["sumup_api_key_set"] is False
public = raw_client.get(_form_url(edition, "/public")).json()
public = raw_client.get(_public(edition, "/public")).json()
assert public["sumup_enabled"] is False
assert public["payment_options"] == []
@@ -134,10 +198,10 @@ def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client,
# Identifiants seuls, sans aucun type de paiement : pas encore activable.
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is False
assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is False
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is True
assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is True
def test_payment_options_start_empty(client, edition):
@@ -162,7 +226,7 @@ def test_creating_and_listing_payment_options(client, raw_client, edition):
assert [o["label"] for o in listed] == ["Cotisation classique", "Mécénat"]
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
public_options = raw_client.get(_form_url(edition, "/public")).json()["payment_options"]
public_options = raw_client.get(_public(edition, "/public")).json()["payment_options"]
assert [o["label"] for o in public_options] == ["Cotisation classique", "Mécénat"]
assert [o["amount"] for o in public_options] == [20, 50]
@@ -251,7 +315,7 @@ def test_logo_is_coordinator_only(edition, make_account, as_account):
def test_no_logo_by_default(client, raw_client, edition):
assert client.get(_form_url(edition)).json()["has_logo"] is False
resp = raw_client.get(_form_url(edition, "/logo"))
resp = raw_client.get(_public(edition, "/logo"))
assert resp.status_code == 404
@@ -264,7 +328,7 @@ def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, editi
assert client.get(_form_url(edition)).json()["has_logo"] is True
logo = raw_client.get(_form_url(edition, "/logo"))
logo = raw_client.get(_public(edition, "/logo"))
assert logo.status_code == 200
assert logo.headers["content-type"] == "image/png"
assert logo.content == _PNG
@@ -285,7 +349,7 @@ def test_public_config_reports_the_logo_and_description(client, raw_client, edit
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
)
body = raw_client.get(_form_url(edition, "/public")).json()
body = raw_client.get(_public(edition, "/public")).json()
assert body["description"] == "Bienvenue au festival !"
assert body["has_logo"] is True
@@ -296,7 +360,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled(
make_position(session, edition, name="Bar central")
make_artist(session, edition, name="Chilla")
resp = raw_client.get(_form_url(edition, "/public"))
resp = raw_client.get(_public(edition, "/public"))
body = resp.json()
assert body["positions"] == []
assert body["artists"] == []
@@ -305,7 +369,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled(
_form_url(edition, "/fields"),
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
)
resp = raw_client.get(_form_url(edition, "/public"))
resp = raw_client.get(_public(edition, "/public"))
body = resp.json()
assert [p["name"] for p in body["positions"]] == ["Bar central"]
assert [a["name"] for a in body["artists"]] == ["Chilla"]
@@ -429,7 +493,12 @@ def test_submitting_with_a_photo(raw_client, client, edition):
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
resp = raw_client.post(
_form_url(edition, "/public/submit"),
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
data={
"first_name": "Alex",
"last_name": "Martin",
"hcaptcha_token": "x",
"access_token": _ACCESS_TOKEN,
},
files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")},
)
assert resp.status_code == 201
@@ -447,13 +516,41 @@ def test_submitting_an_unsupported_photo_type_is_rejected(raw_client, client, ed
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
resp = raw_client.post(
_form_url(edition, "/public/submit"),
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
data={
"first_name": "Alex",
"last_name": "Martin",
"hcaptcha_token": "x",
"access_token": _ACCESS_TOKEN,
},
files={"photo": ("selfie.gif", b"not-a-real-gif", "image/gif")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
def test_submitting_a_fake_photo_with_a_spoofed_content_type_is_rejected(
raw_client, client, edition
):
# Endpoint public, sans authentification : le `Content-Type` du fichier
# n'est qu'un en-tête fourni par l'appelant, jamais vérifié par
# lui-même — un fichier qui prétend être une image sans en être une
# planterait sinon la génération des badges/feuilles de poste plus tard
# (voir `app/api/forms.py:_read_photo`).
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
resp = raw_client.post(
_form_url(edition, "/public/submit"),
data={
"first_name": "Alex",
"last_name": "Martin",
"hcaptcha_token": "x",
"access_token": _ACCESS_TOKEN,
},
files={"photo": ("selfie.png", b"this is not actually a png file", "image/png")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
def test_submitting_more_than_two_position_wishes_is_rejected(
raw_client, client, edition, session
):
@@ -884,3 +981,24 @@ def test_submitting_is_rate_limited_per_edition(raw_client, edition):
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
)
assert resp.status_code == 429
def test_pending_status_polling_is_rate_limited_per_token(
raw_client, client, edition, monkeypatch
):
# Chaque appel relit l'état réel auprès de SumUp (voir
# `get_pending_status`) — sans limite, un jeton connu (ex. une
# inscription abandonnée par son propre auteur) permettrait de
# solliciter l'API SumUp de l'édition indéfiniment.
option_id = _configure_sumup(client, edition)
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
for _ in range(pending_status_limiter.max_attempts):
pending_status_limiter.hit(token)
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 429
@@ -0,0 +1,59 @@
from app.mecenat.number_words import amount_in_words
def test_zero_centimes_only_euros():
assert amount_in_words(15.0) == "Quinze euros"
def test_singular_euro_has_no_s():
assert amount_in_words(1.0) == "Un euro"
def test_et_un_forms():
assert amount_in_words(21.0) == "Vingt et un euros"
assert amount_in_words(31.0) == "Trente et un euros"
assert amount_in_words(61.0) == "Soixante et un euros"
def test_seventies_irregular_et_onze():
assert amount_in_words(70.0) == "Soixante-dix euros"
assert amount_in_words(71.0) == "Soixante et onze euros"
assert amount_in_words(72.0) == "Soixante-douze euros"
assert amount_in_words(79.0) == "Soixante-dix-neuf euros"
def test_eighties_no_et_ever():
assert amount_in_words(80.0) == "Quatre-vingts euros"
assert amount_in_words(81.0) == "Quatre-vingt-un euros"
assert amount_in_words(91.0) == "Quatre-vingt-onze euros"
assert amount_in_words(99.0) == "Quatre-vingt-dix-neuf euros"
def test_hundreds_agreement():
assert amount_in_words(100.0) == "Cent euros"
assert amount_in_words(101.0) == "Cent un euros"
assert amount_in_words(200.0) == "Deux cents euros"
assert amount_in_words(201.0) == "Deux cent un euros"
def test_thousands_are_invariable():
assert amount_in_words(1000.0) == "Mille euros"
assert amount_in_words(2000.0) == "Deux mille euros"
assert amount_in_words(1234.0) == "Mille deux cent trente-quatre euros"
def test_cents_only_when_no_euros():
assert amount_in_words(0.5) == "Cinquante centimes"
assert amount_in_words(0.01) == "Un centime"
def test_euros_and_cents_joined_with_et():
assert amount_in_words(1234.56) == (
"Mille deux cent trente-quatre euros et cinquante-six centimes"
)
assert amount_in_words(1.01) == "Un euro et un centime"
def test_rounds_to_the_nearest_cent():
# Erreurs de flottant (ex. 0.1 + 0.2) ne doivent jamais se voir.
assert amount_in_words(19.999999) == "Vingt euros"
+19 -1
View File
@@ -1,6 +1,6 @@
from sqlmodel import select
from app.models import Edition, Volunteer
from app.models import Edition, FeePaymentStatus, Volunteer
from app.seed import DEMO_EDITION_NAME, build
@@ -21,6 +21,9 @@ def test_build_creates_a_realistic_dataset(session):
assert summary.position_wishes > 0
assert summary.artist_wishes > 0
assert summary.companion_wishes > 0
# Paiement de la cotisation : au moins un exemple de chaque chemin.
assert summary.fee_paid_classique > 0
assert summary.fee_paid_mecenat > 0
edition = session.exec(
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
@@ -30,6 +33,19 @@ def test_build_creates_a_realistic_dataset(session):
).all()
assert len(volunteers) == 120
paid = [v for v in volunteers if v.fee_payment_status == FeePaymentStatus.PAID]
assert len(paid) == summary.fee_paid_classique + summary.fee_paid_mecenat
mecenat = [v for v in paid if v.mecenat_address is not None]
assert len(mecenat) == summary.fee_paid_mecenat
for volunteer in mecenat:
assert volunteer.mecenat_city and volunteer.mecenat_postal_code
assert volunteer.mecenat_country == "France"
assert volunteer.fee_payment_label == "Mécénat"
classique = [v for v in paid if v.mecenat_address is None]
for volunteer in classique:
assert volunteer.fee_payment_label == "Cotisation classique"
assert volunteer.fee_amount == 15.0
def test_build_is_deterministic_and_idempotent(session):
first = build(session)
@@ -47,6 +63,8 @@ def test_build_is_deterministic_and_idempotent(session):
assert second.position_wishes == first.position_wishes
assert second.artist_wishes == first.artist_wishes
assert second.companion_wishes == first.companion_wishes
assert second.fee_paid_classique == first.fee_paid_classique
assert second.fee_paid_mecenat == first.fee_paid_mecenat
editions = session.exec(
select(Edition).where(Edition.name == DEMO_EDITION_NAME)