Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
- 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:
co-authored by
Claude Sonnet 5
parent
a2b748566b
commit
096b7cd26a
+131
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user