- 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>
1005 lines
38 KiB
Python
1005 lines
38 KiB
Python
"""Formulaire public d'inscription : configuration (COORDINATION) et
|
||
soumission publique (aucune authentification)."""
|
||
|
||
import base64
|
||
import datetime as dt
|
||
import io
|
||
import uuid
|
||
|
||
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"):
|
||
"""Active le champ, renseigne les identifiants et crée un type de
|
||
paiement — renvoie son id, à passer en `payment_option_id` à `_submit`."""
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||
client.patch(
|
||
_form_url(edition),
|
||
json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY},
|
||
)
|
||
resp = client.post(
|
||
_form_url(edition, "/payment-options"), json={"label": label, "amount": amount}
|
||
)
|
||
return resp.json()["id"]
|
||
|
||
|
||
def _pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="PAID"):
|
||
"""Monte le checkout ensuite renvoyé par `get_checkout` sur `status_after`
|
||
(`PAID`/`FAILED`/`EXPIRED`) — les tests posent d'abord `create_checkout`
|
||
et soumettent eux-mêmes, cette aide ne fait que la partie webhook."""
|
||
monkeypatch.setattr(
|
||
"app.api.forms.get_checkout",
|
||
lambda **kw: SumUpCheckout(id=checkout_id, status=status_after, hosted_checkout_url=None),
|
||
)
|
||
return raw_client.post(
|
||
_form_url(edition, "/public/sumup-webhook"),
|
||
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": checkout_id},
|
||
)
|
||
|
||
|
||
def _pending_status(raw_client, edition, token):
|
||
return raw_client.get(_form_url(edition, f"/public/pending/{token}")).json()
|
||
|
||
# PNG 1×1 valide (transparent), le plus petit possible.
|
||
_PNG = base64.b64decode(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||
"42YAAAAASUVORK5CYII="
|
||
)
|
||
|
||
|
||
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)
|
||
|
||
|
||
def test_config_starts_open_with_no_fields(client, edition):
|
||
resp = client.get(_form_url(edition))
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["is_open"] is True
|
||
assert body["fields"] == []
|
||
|
||
|
||
def test_config_is_coordinator_only(edition, make_account, as_account):
|
||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||
lead_client = as_account(lead)
|
||
assert lead_client.get(_form_url(edition)).status_code == 403
|
||
resp = lead_client.put(_form_url(edition, "/fields"), json={"fields": []})
|
||
assert resp.status_code == 403
|
||
|
||
|
||
def test_setting_fields_replaces_them_entirely(client, edition):
|
||
resp = client.put(
|
||
_form_url(edition, "/fields"),
|
||
json={"fields": ["EMAIL", "PHONE", "PHOTO"]},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert sorted(resp.json()["fields"]) == ["EMAIL", "PHONE", "PHOTO"]
|
||
|
||
# Un second PUT remplace, ne cumule pas.
|
||
resp = client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
|
||
assert resp.json()["fields"] == ["EMAIL"]
|
||
|
||
|
||
def test_closing_the_form(client, edition):
|
||
resp = client.patch(_form_url(edition), json={"is_open": False})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["is_open"] is False
|
||
|
||
|
||
def test_setting_description_does_not_touch_is_open(client, edition):
|
||
client.patch(_form_url(edition), json={"is_open": False})
|
||
resp = client.patch(_form_url(edition), json={"description": "Bienvenue !"})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["description"] == "Bienvenue !"
|
||
assert resp.json()["is_open"] is False # inchangé, absent du corps
|
||
|
||
|
||
def test_clearing_description_with_an_explicit_null(client, edition):
|
||
client.patch(_form_url(edition), json={"description": "Bienvenue !"})
|
||
resp = client.patch(_form_url(edition), json={"description": None})
|
||
assert resp.status_code == 200
|
||
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(_public(edition, "/public")).json()
|
||
assert public["sumup_enabled"] is False
|
||
assert public["payment_options"] == []
|
||
|
||
|
||
def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client, edition):
|
||
resp = client.patch(
|
||
_form_url(edition),
|
||
json={"sumup_merchant_code": "MXXXXX", "sumup_api_key": "sup_sk_secret"},
|
||
)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["sumup_merchant_code"] == "MXXXXX"
|
||
assert body["sumup_api_key_set"] is True
|
||
assert "sumup_api_key" not in body
|
||
|
||
# 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(_public(edition, "/public")).json()["sumup_enabled"] is False
|
||
|
||
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
|
||
assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is True
|
||
|
||
|
||
def test_payment_options_start_empty(client, edition):
|
||
assert client.get(_form_url(edition, "/payment-options")).json() == []
|
||
|
||
|
||
def test_creating_and_listing_payment_options(client, raw_client, edition):
|
||
client.post(
|
||
_form_url(edition, "/payment-options"),
|
||
json={"label": "Mécénat", "amount": 50, "order": 1},
|
||
)
|
||
resp = client.post(
|
||
_form_url(edition, "/payment-options"),
|
||
json={"label": "Cotisation classique", "amount": 20, "order": 0},
|
||
)
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["label"] == "Cotisation classique"
|
||
assert body["amount"] == 20
|
||
|
||
listed = client.get(_form_url(edition, "/payment-options")).json()
|
||
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(_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]
|
||
|
||
|
||
def test_updating_and_deleting_a_payment_option(client, edition):
|
||
option_id = client.post(
|
||
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}
|
||
).json()["id"]
|
||
|
||
resp = client.patch(
|
||
_form_url(edition, f"/payment-options/{option_id}"), json={"amount": 25}
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["amount"] == 25
|
||
|
||
resp = client.delete(_form_url(edition, f"/payment-options/{option_id}"))
|
||
assert resp.status_code == 204
|
||
assert client.get(_form_url(edition, "/payment-options")).json() == []
|
||
|
||
|
||
def test_payment_options_are_coordinator_only(edition, make_account, as_account):
|
||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||
lead_client = as_account(lead)
|
||
resp = lead_client.post(
|
||
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}
|
||
)
|
||
assert resp.status_code == 403
|
||
|
||
|
||
def test_rejects_a_non_positive_payment_option_amount(client, edition):
|
||
resp = client.post(
|
||
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 0}
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "form.invalid_sumup_amount"
|
||
|
||
|
||
def test_rejects_a_duplicate_payment_option_label(client, edition):
|
||
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
|
||
resp = client.post(
|
||
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 30}
|
||
)
|
||
assert resp.status_code == 409
|
||
assert resp.json()["detail"]["code"] == "form.duplicate_payment_option_label"
|
||
|
||
|
||
def test_deleting_a_payment_option_keeps_the_volunteers_snapshot(
|
||
raw_client, client, edition, monkeypatch
|
||
):
|
||
option_id = _configure_sumup(client, edition)
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||
)
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
payment_option_id=option_id,
|
||
)
|
||
token = resp.json()["pending_token"]
|
||
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
|
||
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
|
||
|
||
client.delete(_form_url(edition, f"/payment-options/{option_id}"))
|
||
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||
assert fiche["fee_payment_label"] == "Cotisation classique"
|
||
assert fiche["fee_amount"] == 12.5
|
||
|
||
|
||
def test_clearing_the_sumup_api_key(client, edition):
|
||
client.patch(_form_url(edition), json={"sumup_api_key": "sup_sk_secret"})
|
||
assert client.get(_form_url(edition)).json()["sumup_api_key_set"] is True
|
||
resp = client.patch(_form_url(edition), json={"sumup_api_key": ""})
|
||
assert resp.json()["sumup_api_key_set"] is False
|
||
|
||
|
||
def test_logo_is_coordinator_only(edition, make_account, as_account):
|
||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||
lead_client = as_account(lead)
|
||
resp = lead_client.put(
|
||
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||
)
|
||
assert resp.status_code == 403
|
||
|
||
|
||
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(_public(edition, "/logo"))
|
||
assert resp.status_code == 404
|
||
|
||
|
||
def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, edition):
|
||
resp = client.put(
|
||
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["has_logo"] is True
|
||
|
||
assert client.get(_form_url(edition)).json()["has_logo"] is True
|
||
|
||
logo = raw_client.get(_public(edition, "/logo"))
|
||
assert logo.status_code == 200
|
||
assert logo.headers["content-type"] == "image/png"
|
||
assert logo.content == _PNG
|
||
|
||
|
||
def test_uploading_an_unsupported_logo_type_is_rejected(client, edition):
|
||
resp = client.put(
|
||
_form_url(edition, "/logo"),
|
||
files={"file": ("logo.gif", b"not-a-real-gif", "image/gif")},
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "form.logo_type_not_supported"
|
||
|
||
|
||
def test_public_config_reports_the_logo_and_description(client, raw_client, edition):
|
||
client.patch(_form_url(edition), json={"description": "Bienvenue au festival !"})
|
||
client.put(
|
||
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||
)
|
||
|
||
body = raw_client.get(_public(edition, "/public")).json()
|
||
assert body["description"] == "Bienvenue au festival !"
|
||
assert body["has_logo"] is True
|
||
|
||
|
||
def test_public_form_hides_positions_and_artists_unless_enabled(
|
||
client, raw_client, edition, session
|
||
):
|
||
make_position(session, edition, name="Bar central")
|
||
make_artist(session, edition, name="Chilla")
|
||
|
||
resp = raw_client.get(_public(edition, "/public"))
|
||
body = resp.json()
|
||
assert body["positions"] == []
|
||
assert body["artists"] == []
|
||
|
||
client.put(
|
||
_form_url(edition, "/fields"),
|
||
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
|
||
)
|
||
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"]
|
||
|
||
|
||
def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
|
||
client.put(
|
||
_form_url(edition, "/fields"),
|
||
json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]},
|
||
)
|
||
# Pas de payment_option_id : « je paierai sur place » — pas de paiement
|
||
# en ligne, le bénévole est créé tout de suite.
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="alex", last_name="martin", hcaptcha_token="x",
|
||
email="alex@example.org", phone="0600000000", available="true",
|
||
)
|
||
assert resp.status_code == 201
|
||
volunteer_id = resp.json()["volunteer_id"]
|
||
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||
assert fiche["first_name"] == "Alex"
|
||
assert fiche["last_name"] == "Martin"
|
||
assert fiche["email"] == "alex@example.org"
|
||
assert fiche["self_declared_available"] is True
|
||
assert fiche["willing_to_pay_fee"] is True
|
||
assert fiche["fee_payment_status"] is None
|
||
|
||
# Le bénévole créé apparaît dans la liste de la coordination.
|
||
volunteers = client.get(f"/editions/{edition.id}/volunteers").json()
|
||
assert any(v["id"] == volunteer_id for v in volunteers)
|
||
|
||
|
||
def test_submitting_sends_a_registration_confirmation_email(raw_client, client, edition, outbox):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
email="alex@example.org",
|
||
)
|
||
assert resp.status_code == 201
|
||
assert len(outbox) == 1
|
||
assert outbox[0]["To"] == "alex@example.org"
|
||
assert "Alex" in plain_text(outbox[0])
|
||
|
||
|
||
def test_submitting_without_an_email_sends_no_confirmation(raw_client, edition, outbox):
|
||
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
|
||
assert resp.status_code == 201
|
||
assert outbox == []
|
||
|
||
|
||
def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
|
||
# Aucun champ activé : email/téléphone envoyés quand même, ignorés.
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
email="alex@example.org", phone="0600000000",
|
||
)
|
||
assert resp.status_code == 201
|
||
volunteer_id = resp.json()["volunteer_id"]
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||
assert fiche["email"] is None
|
||
assert fiche["phone"] is None
|
||
|
||
|
||
def test_submitting_without_an_email_is_rejected_when_email_enabled(raw_client, client, edition):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
|
||
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
|
||
|
||
|
||
def test_submitting_without_a_phone_is_rejected_when_phone_enabled(raw_client, client, edition):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["PHONE"]})
|
||
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
|
||
|
||
|
||
def test_submitting_without_checking_available_is_rejected_when_enabled(
|
||
raw_client, client, edition
|
||
):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["AVAILABLE"]})
|
||
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
|
||
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x", available="true",
|
||
)
|
||
assert resp.status_code == 201
|
||
|
||
|
||
def test_submitting_resolves_position_and_artist_wishes(
|
||
raw_client, client, edition, session
|
||
):
|
||
position = make_position(session, edition, name="Bar central")
|
||
artist = make_artist(session, edition, name="Chilla")
|
||
client.put(
|
||
_form_url(edition, "/fields"),
|
||
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES", "COMPANION_WISH"]},
|
||
)
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
position_wish_ids=f'["{position.id}"]',
|
||
artist_wish_ids=f'["{artist.id}"]',
|
||
companion_wish_note="Sam, il/elle s'inscrira bientôt",
|
||
)
|
||
assert resp.status_code == 201
|
||
volunteer_id = resp.json()["volunteer_id"]
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||
assert fiche["wished_position_ids"] == [str(position.id)]
|
||
assert fiche["artist_ids"] == [str(artist.id)]
|
||
assert fiche["companion_wish_note"] == "Sam, il/elle s'inscrira bientôt"
|
||
|
||
|
||
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",
|
||
"access_token": _ACCESS_TOKEN,
|
||
},
|
||
files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")},
|
||
)
|
||
assert resp.status_code == 201
|
||
volunteer_id = resp.json()["volunteer_id"]
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||
assert fiche["has_photo"] is True
|
||
|
||
photo = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}/photo")
|
||
assert photo.status_code == 200
|
||
assert photo.headers["content-type"] == "image/png"
|
||
assert photo.content == _PNG
|
||
|
||
|
||
def test_submitting_an_unsupported_photo_type_is_rejected(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",
|
||
"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
|
||
):
|
||
positions = [
|
||
make_position(session, edition, name=f"Poste {i}") for i in range(3)
|
||
]
|
||
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
position_wish_ids=ids,
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "form.too_many_position_wishes"
|
||
|
||
|
||
def test_submitting_two_position_wishes_is_accepted(raw_client, client, edition, session):
|
||
positions = [make_position(session, edition, name=f"Poste {i}") for i in range(2)]
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
position_wish_ids=ids,
|
||
)
|
||
assert resp.status_code == 201
|
||
|
||
|
||
def test_submitting_two_artist_wishes_on_the_same_day_is_rejected(
|
||
raw_client, client, edition, session
|
||
):
|
||
a1 = make_artist(session, edition, name="Chilla")
|
||
a2 = make_artist(session, edition, name="Kntongo") # même jour par défaut
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "form.too_many_artists_same_day"
|
||
|
||
|
||
def test_submitting_artist_wishes_on_different_days_is_accepted(
|
||
raw_client, client, edition, session
|
||
):
|
||
a1 = make_artist(session, edition, name="Chilla", performance_date=dt.date(2026, 7, 9))
|
||
a2 = make_artist(session, edition, name="Kntongo", performance_date=dt.date(2026, 7, 10))
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
|
||
)
|
||
assert resp.status_code == 201
|
||
|
||
|
||
def test_submitting_a_comment(raw_client, client, edition):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["COMMENT"]})
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
comment="Dispo seulement le samedi",
|
||
)
|
||
assert resp.status_code == 201
|
||
volunteer_id = resp.json()["volunteer_id"]
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||
assert fiche["comment"] == "Dispo seulement le samedi"
|
||
|
||
|
||
def test_submitting_with_an_unknown_position_id_is_rejected(raw_client, client, edition):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
position_wish_ids='["00000000-0000-0000-0000-000000000000"]',
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "position.unknown"
|
||
|
||
|
||
def test_submitting_when_the_form_is_closed_is_rejected(raw_client, client, edition):
|
||
client.patch(_form_url(edition), json={"is_open": False})
|
||
resp = _submit(
|
||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "form.closed"
|
||
|
||
|
||
def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
|
||
monkeypatch.setattr("app.api.forms.verify_hcaptcha", lambda token: False)
|
||
resp = _submit(
|
||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "form.captcha_failed"
|
||
|
||
|
||
def test_choosing_an_online_option_does_not_create_the_volunteer_yet(
|
||
raw_client, client, edition, monkeypatch
|
||
):
|
||
option_id = _configure_sumup(client, edition)
|
||
before = len(client.get(f"/editions/{edition.id}/volunteers").json())
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(
|
||
id="co_123", status="PENDING", hosted_checkout_url="https://checkout.sumup.com/pay/co_123"
|
||
),
|
||
)
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
payment_option_id=option_id,
|
||
)
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["volunteer_id"] is None
|
||
assert body["pending_token"] is not None
|
||
assert body["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123"
|
||
|
||
# Toujours rien en base tant que le paiement n'est pas confirmé.
|
||
after = len(client.get(f"/editions/{edition.id}/volunteers").json())
|
||
assert after == before
|
||
|
||
status = _pending_status(raw_client, edition, body["pending_token"])
|
||
assert status["status"] == "PENDING"
|
||
assert status["label"] == "Cotisation classique"
|
||
assert status["amount"] == 12.5
|
||
|
||
|
||
def test_paying_promotes_the_pending_submission_to_a_volunteer(
|
||
raw_client, client, edition, monkeypatch
|
||
):
|
||
option_id = _configure_sumup(client, edition)
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"),
|
||
)
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
payment_option_id=option_id,
|
||
)
|
||
token = resp.json()["pending_token"]
|
||
|
||
webhook_resp = _pay(raw_client, edition, monkeypatch, checkout_id="co_123")
|
||
assert webhook_resp.status_code == 200
|
||
|
||
status = _pending_status(raw_client, edition, token)
|
||
assert status["status"] == "PAID"
|
||
volunteer_id = status["volunteer_id"]
|
||
assert volunteer_id is not None
|
||
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||
assert fiche["first_name"] == "Alex"
|
||
assert fiche["fee_payment_status"] == "PAID"
|
||
assert fiche["fee_payment_label"] == "Cotisation classique"
|
||
assert fiche["fee_amount"] == 12.5
|
||
assert fiche["fee_paid_at"] is not None
|
||
assert fiche["willing_to_pay_fee"] is True
|
||
|
||
|
||
def test_payment_confirmation_sends_an_email_on_promotion(
|
||
raw_client, client, edition, monkeypatch, outbox
|
||
):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
|
||
client.patch(
|
||
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
|
||
)
|
||
option_id = client.post(
|
||
_form_url(edition, "/payment-options"),
|
||
json={"label": "Cotisation classique", "amount": 12.5},
|
||
).json()["id"]
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||
)
|
||
_submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
email="alex@example.org",
|
||
payment_option_id=option_id,
|
||
)
|
||
assert outbox == [] # pas encore payé : pas d'email à ce stade
|
||
|
||
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
|
||
|
||
assert len(outbox) == 1
|
||
assert outbox[0]["To"] == "alex@example.org"
|
||
body = plain_text(outbox[0])
|
||
assert "Alex" in body
|
||
assert "Cotisation classique" in body
|
||
assert "12.5" in body
|
||
|
||
|
||
def test_choosing_to_pay_on_site_creates_the_volunteer_immediately(
|
||
raw_client, client, edition, monkeypatch
|
||
):
|
||
_configure_sumup(client, edition)
|
||
called = []
|
||
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw))
|
||
resp = _submit(
|
||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||
)
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["volunteer_id"] is not None
|
||
assert body["pending_token"] is None
|
||
assert body["sumup_checkout_url"] is None
|
||
assert called == []
|
||
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
|
||
assert fiche["willing_to_pay_fee"] is True
|
||
assert fiche["fee_payment_status"] is None
|
||
|
||
|
||
def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition):
|
||
_configure_sumup(client, edition)
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
payment_option_id="00000000-0000-0000-0000-000000000000",
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "form.unknown_payment_option"
|
||
|
||
|
||
def test_a_failed_checkout_call_can_be_retried_without_resubmitting(
|
||
raw_client, client, edition, monkeypatch
|
||
):
|
||
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,
|
||
)
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["sumup_checkout_url"] is None
|
||
token = body["pending_token"]
|
||
assert _pending_status(raw_client, edition, token)["status"] == "PENDING"
|
||
|
||
# SumUp remarche : la relance réutilise les infos déjà saisies.
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_retry", status="PENDING", hosted_checkout_url="https://x"),
|
||
)
|
||
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
|
||
assert retry_resp.status_code == 200
|
||
assert retry_resp.json()["sumup_checkout_url"] == "https://x"
|
||
|
||
_pay(raw_client, edition, monkeypatch, checkout_id="co_retry")
|
||
assert _pending_status(raw_client, edition, token)["status"] == "PAID"
|
||
|
||
|
||
def test_retrying_after_an_expired_checkout(raw_client, client, edition, monkeypatch):
|
||
option_id = _configure_sumup(client, edition)
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||
)
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
payment_option_id=option_id,
|
||
)
|
||
token = resp.json()["pending_token"]
|
||
|
||
_pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="EXPIRED")
|
||
assert _pending_status(raw_client, edition, token)["status"] == "EXPIRED"
|
||
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_2", status="PENDING", hosted_checkout_url="https://y"),
|
||
)
|
||
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
|
||
assert retry_resp.status_code == 200
|
||
|
||
|
||
def test_retrying_an_already_paid_pending_submission_is_rejected(
|
||
raw_client, client, edition, monkeypatch
|
||
):
|
||
option_id = _configure_sumup(client, edition)
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||
)
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
payment_option_id=option_id,
|
||
)
|
||
token = resp.json()["pending_token"]
|
||
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
|
||
|
||
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
|
||
assert retry_resp.status_code == 422
|
||
assert retry_resp.json()["detail"]["code"] == "form.pending_already_paid"
|
||
|
||
|
||
def test_an_unknown_pending_token_is_a_404(raw_client, edition):
|
||
resp = raw_client.get(
|
||
_form_url(edition, "/public/pending/00000000-0000-0000-0000-000000000000")
|
||
)
|
||
assert resp.status_code == 404
|
||
|
||
|
||
def test_choosing_a_mecenat_option_requires_the_address_step_first(
|
||
raw_client, client, edition, monkeypatch
|
||
):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||
client.patch(
|
||
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
|
||
)
|
||
option_id = client.post(
|
||
_form_url(edition, "/payment-options"),
|
||
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
|
||
).json()["id"]
|
||
|
||
called = []
|
||
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw))
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
payment_option_id=option_id,
|
||
)
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["requires_mecenat_info"] is True
|
||
assert body["sumup_checkout_url"] is None
|
||
assert called == [] # pas de checkout avant d'avoir l'adresse
|
||
|
||
token = body["pending_token"]
|
||
monkeypatch.setattr(
|
||
"app.api.forms.create_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_don", status="PENDING", hosted_checkout_url="https://don"),
|
||
)
|
||
info_resp = raw_client.post(
|
||
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
|
||
json={
|
||
"address": "1 rue du Festival",
|
||
"city": "Rennes",
|
||
"postal_code": "35000",
|
||
"country": "France",
|
||
},
|
||
)
|
||
assert info_resp.status_code == 200
|
||
assert info_resp.json()["sumup_checkout_url"] == "https://don"
|
||
|
||
_pay(raw_client, edition, monkeypatch, checkout_id="co_don")
|
||
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
|
||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||
assert fiche["mecenat_address"] == "1 rue du Festival"
|
||
assert fiche["mecenat_city"] == "Rennes"
|
||
assert fiche["mecenat_postal_code"] == "35000"
|
||
assert fiche["mecenat_country"] == "France"
|
||
|
||
|
||
def test_mecenat_info_requires_every_field(raw_client, client, edition, monkeypatch):
|
||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||
client.patch(
|
||
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
|
||
)
|
||
option_id = client.post(
|
||
_form_url(edition, "/payment-options"),
|
||
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
|
||
).json()["id"]
|
||
resp = _submit(
|
||
raw_client, edition,
|
||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||
payment_option_id=option_id,
|
||
)
|
||
token = resp.json()["pending_token"]
|
||
|
||
info_resp = raw_client.post(
|
||
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
|
||
json={
|
||
"address": "1 rue du Festival",
|
||
"city": "Rennes",
|
||
"postal_code": "",
|
||
"country": "France",
|
||
},
|
||
)
|
||
assert info_resp.status_code == 422
|
||
|
||
|
||
def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch):
|
||
_configure_sumup(client, edition)
|
||
monkeypatch.setattr(
|
||
"app.api.forms.get_checkout",
|
||
lambda **kw: SumUpCheckout(id="co_unknown", status="PAID", hosted_checkout_url=None),
|
||
)
|
||
resp = raw_client.post(
|
||
_form_url(edition, "/public/sumup-webhook"),
|
||
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_unknown"},
|
||
)
|
||
assert resp.status_code == 200
|
||
|
||
|
||
def test_stale_pending_submissions_are_pruned(raw_client, client, edition, session, monkeypatch):
|
||
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"]
|
||
|
||
pending = session.get(PendingFormSubmission, uuid.UUID(token))
|
||
pending.created_at = local_now() - dt.timedelta(hours=49)
|
||
session.add(pending)
|
||
session.commit()
|
||
|
||
# N'importe quelle nouvelle soumission déclenche le nettoyage paresseux.
|
||
_submit(raw_client, edition, first_name="Sam", last_name="Dupuis", hcaptcha_token="x")
|
||
|
||
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 404
|
||
|
||
|
||
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
|
||
for _ in range(form_submission_limiter.max_attempts):
|
||
form_submission_limiter.hit(str(edition.id))
|
||
resp = _submit(
|
||
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
|