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
+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