- 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>
138 lines
4.5 KiB
Python
138 lines
4.5 KiB
Python
from types import SimpleNamespace
|
|
|
|
from app.email import sender
|
|
|
|
|
|
def test_disabled_delivery_is_a_noop():
|
|
# email_enabled=False par défaut en test : aucune exception, pas de SMTP.
|
|
sender.send("a@b.fr", "Sujet", "Corps")
|
|
sender.send_many([("a@b.fr", "S", "B"), ("c@d.fr", "S", "B")])
|
|
|
|
|
|
def test_smtp_is_used_when_enabled(monkeypatch):
|
|
fake = SimpleNamespace(
|
|
email_enabled=True,
|
|
smtp_host="mail.test",
|
|
smtp_port=587,
|
|
smtp_user="u",
|
|
smtp_password="p",
|
|
smtp_starttls=True,
|
|
smtp_ssl=False,
|
|
smtp_from="Festival <no-reply@test>",
|
|
)
|
|
monkeypatch.setattr(sender, "get_settings", lambda: fake)
|
|
events: list = []
|
|
|
|
class FakeSMTP:
|
|
def __init__(self, host, port, timeout=None):
|
|
events.append(("connect", host, port))
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_a):
|
|
return False
|
|
|
|
def starttls(self, context=None):
|
|
events.append(("starttls",))
|
|
|
|
def login(self, user, _password):
|
|
events.append(("login", user))
|
|
|
|
def send_message(self, message):
|
|
events.append(("send", message["To"], message["Subject"]))
|
|
|
|
monkeypatch.setattr(sender.smtplib, "SMTP", FakeSMTP)
|
|
|
|
sender.send_many([("x@y.fr", "Sujet 1", "B"), ("z@y.fr", "Sujet 2", "B")])
|
|
|
|
assert ("connect", "mail.test", 587) in events
|
|
assert ("starttls",) in events
|
|
assert ("login", "u") in events
|
|
assert ("send", "x@y.fr", "Sujet 1") in events
|
|
assert ("send", "z@y.fr", "Sujet 2") in events
|
|
|
|
|
|
def test_recipient_refusal_does_not_log_the_email_address(monkeypatch, caplog):
|
|
# RGPD : `SMTPRecipientsRefused` embarque l'adresse dans ses `args` —
|
|
# elle ne doit jamais atterrir dans les logs, seulement le(s) code(s).
|
|
fake = SimpleNamespace(
|
|
email_enabled=True,
|
|
smtp_host="mail.test",
|
|
smtp_port=587,
|
|
smtp_user="u",
|
|
smtp_password="p",
|
|
smtp_starttls=True,
|
|
smtp_ssl=False,
|
|
smtp_from="Festival <no-reply@test>",
|
|
)
|
|
monkeypatch.setattr(sender, "get_settings", lambda: fake)
|
|
recipient = "jean.dupont@example.fr"
|
|
|
|
class FakeSMTP:
|
|
def __init__(self, host, port, timeout=None):
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_a):
|
|
return False
|
|
|
|
def starttls(self, context=None):
|
|
pass
|
|
|
|
def login(self, user, _password):
|
|
pass
|
|
|
|
def send_message(self, message):
|
|
raise sender.smtplib.SMTPRecipientsRefused(
|
|
{recipient: (450, b"Recipient address rejected: Domain not found")}
|
|
)
|
|
|
|
monkeypatch.setattr(sender.smtplib, "SMTP", FakeSMTP)
|
|
|
|
with caplog.at_level("ERROR", logger="app.email"):
|
|
sender.send(recipient, "Sujet", "Corps")
|
|
|
|
assert recipient not in caplog.text
|
|
assert "450" in caplog.text
|
|
|
|
|
|
def test_render_magic_link_uses_translations():
|
|
from app.email import render_magic_link
|
|
|
|
subject, body = render_magic_link(link="https://x/connexion?token=abc", minutes=30)
|
|
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>"
|