Targeted fixes for issues raised by code review on commit 37639a7
(B-2.3 DictIA email rebrand). All fixes verified against the Windows
manual driver: 16/16 tests pass (12 pre-existing + 4 new regression).
Critical:
- C1 Stored XSS in transactional emails: user.name (validated only on
Length(max=49), no character class) was rendered raw into the f-string
HTML body of verification + reset emails. Added html.escape on the
HTML branch; text body keeps the raw string (no XSS surface). Also
hardened the fallback chain to ((name or '').strip() or username or
'utilisateur').strip() so a None/whitespace name never produces
'Bonjour ,'.
- C2 Reflected XSS in templates/auth/check_email.html: the email value
from request.form was concatenated with literal '<strong>' tags then
fed through | safe, defeating Jinja's autoescape. Split the string so
template-author HTML stays literal and {{ email }} is autoescaped.
Used   for NBSP instead of '1 heure' | safe (more readable).
Important:
- I1 Dropped {{ message | safe }} on flash blocks in
forgot_password.html and reset_password.html (matches check_email.html).
No XSS today (flashes are static literals) but removes the landmine.
- I2 Password reset token replay: URLSafeTimedSerializer is stateless,
so the same valid link could be clicked twice within the 1h window.
Added a check that user.password_reset_token == token after the user
lookup — runs before BOTH GET (form render) and POST (password update).
The existing 'user.password_reset_token = None' on success now
actually invalidates the token.
- I5 MIMEText defaults to us-ascii, which Q-encodes accented French
characters and produces mojibake in some clients. Added explicit
'utf-8' charset on both text and html parts in _send_email.
New regression tests (tests/test_email_service_dictia.py):
- test_verification_email_falls_back_when_name_is_whitespace (I4)
- test_verification_email_handles_unicode_name (I5)
- test_verification_email_escapes_html_in_user_name (C1)
- test_check_email_template_escapes_email_in_response (C2)
Out of scope (per review): M1 (already addressed via solid-color
fallback), M2 (datetime.utcnow — pre-existing, separate cleanup),
M3 (Windows test driver — documented in tests file docstring),
M4-M6 (deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
399 lines
16 KiB
Python
399 lines
16 KiB
Python
"""Tests for B-2.3 — DictIA-branded French transactional emails (verification + reset).
|
|
|
|
Covers:
|
|
- _get_email_template uses DictIA branding (no "Speakr" leaks).
|
|
- send_verification_email subject/body in French + DictIA.
|
|
- send_password_reset_email subject/body in French + DictIA.
|
|
- User display name (user.name) used in greetings, fallback to username.
|
|
- Anti-enumeration: /forgot-password gives the same flash for known/unknown emails.
|
|
- Cooldowns are enforced (60s) for resend-verification.
|
|
- SMTP_FROM_NAME defaults to "DictIA" when env var unset.
|
|
- send_verification_email returns False (no exception) when SMTP misconfigured.
|
|
- check_email.html refondu — extends marketing/base.html (DictIA brand tokens, no
|
|
legacy `var(--text-primary)` styles).
|
|
|
|
Note: pytest cannot collect this file on Windows native because src/init_db.py
|
|
imports `fcntl` (POSIX-only). Tests run in CI / Docker. A manual driver may be
|
|
provided alongside this file for Windows verification.
|
|
"""
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
|
|
|
from src.app import app, db # noqa: E402
|
|
from src.models.user import User # noqa: E402
|
|
|
|
|
|
# --- Helpers ----------------------------------------------------------------
|
|
|
|
def _set_smtp_env():
|
|
os.environ['ENABLE_EMAIL_VERIFICATION'] = 'true'
|
|
os.environ['SMTP_HOST'] = 'smtp.test'
|
|
os.environ['SMTP_USERNAME'] = 'u'
|
|
os.environ['SMTP_PASSWORD'] = 'p'
|
|
|
|
|
|
def _clear_smtp_env():
|
|
for k in ('ENABLE_EMAIL_VERIFICATION', 'REQUIRE_EMAIL_VERIFICATION',
|
|
'SMTP_HOST', 'SMTP_USERNAME', 'SMTP_PASSWORD',
|
|
'SMTP_FROM_NAME', 'SMTP_FROM_ADDRESS'):
|
|
os.environ.pop(k, None)
|
|
|
|
|
|
def _make_user(username='jane', email='jane@x.qc.ca', name='Jane Bouchard'):
|
|
user = User(username=username, email=email, password='x' * 60,
|
|
name=name, email_verified=False)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
return user
|
|
|
|
|
|
# --- Tests ------------------------------------------------------------------
|
|
|
|
def test_email_template_uses_dictia_branding():
|
|
"""_get_email_template wraps content in DictIA-branded HTML scaffold (no Speakr)."""
|
|
with app.app_context():
|
|
from src.services.email import _get_email_template
|
|
html, text = _get_email_template(
|
|
content_html='<p>hello</p>',
|
|
content_text='hello',
|
|
subject='Test',
|
|
)
|
|
assert 'DictIA' in html, 'HTML must contain DictIA brand'
|
|
assert 'DictIA' in text, 'Plain text must contain DictIA brand'
|
|
assert 'Speakr' not in html, 'No "Speakr" string must remain in template'
|
|
assert 'Speakr' not in text, 'No "Speakr" string must remain in plain text'
|
|
# French footer copy + canonical contact email
|
|
assert 'info@dictia.ca' in html
|
|
assert 'Loi' in html and '25' in html, 'Tagline must mention Loi 25'
|
|
|
|
|
|
def test_email_template_header_uses_brand_gradient_not_speakr_blue():
|
|
"""Header bg must use DictIA brand color #0062ff (or gradient), not the legacy
|
|
Speakr #2563eb."""
|
|
with app.app_context():
|
|
from src.services.email import _get_email_template
|
|
html, _ = _get_email_template('x', 'x', 'Test')
|
|
assert '#2563eb' not in html, 'Legacy Speakr header color must be removed'
|
|
assert '#0062ff' in html, 'DictIA brand blue must be present'
|
|
|
|
|
|
def test_verification_email_subject_is_french_with_dictia():
|
|
"""Subject = 'Vérifiez votre courriel — DictIA'."""
|
|
with app.test_request_context('/'):
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user()
|
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
|
from src.services.email import send_verification_email
|
|
send_verification_email(user)
|
|
args, _ = mock_send.call_args
|
|
_to, subject, _html, _text = args
|
|
assert subject == 'Vérifiez votre courriel — DictIA'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_verification_email_body_uses_user_name_when_set():
|
|
"""Greeting uses user.name (display name) when populated."""
|
|
with app.test_request_context('/'):
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(username='jane123', email='jane@x.qc.ca',
|
|
name='Jane Bouchard')
|
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
|
from src.services.email import send_verification_email
|
|
send_verification_email(user)
|
|
args, _ = mock_send.call_args
|
|
_to, _subject, html, text = args
|
|
assert 'Bonjour Jane Bouchard' in html
|
|
assert 'Bonjour Jane Bouchard' in text
|
|
assert 'Bonjour jane123' not in html
|
|
# French body copy
|
|
assert 'Vérifier mon courriel' in html
|
|
assert 'Bienvenue chez DictIA' in html or "Bienvenue chez DictIA" in text
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_verification_email_body_falls_back_to_username():
|
|
"""When user.name is None, greeting uses user.username."""
|
|
with app.test_request_context('/'):
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(username='bob42', email='bob@x.qc.ca', name=None)
|
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
|
from src.services.email import send_verification_email
|
|
send_verification_email(user)
|
|
args, _ = mock_send.call_args
|
|
_to, _subject, html, _text = args
|
|
assert 'Bonjour bob42' in html
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_password_reset_subject_french():
|
|
"""Subject = 'Réinitialiser votre mot de passe — DictIA'."""
|
|
with app.test_request_context('/'):
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(username='carol', email='carol@x.qc.ca',
|
|
name='Carol Tremblay')
|
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
|
from src.services.email import send_password_reset_email
|
|
send_password_reset_email(user)
|
|
args, _ = mock_send.call_args
|
|
_to, subject, html, _text = args
|
|
assert subject == 'Réinitialiser votre mot de passe — DictIA'
|
|
assert 'Bonjour Carol Tremblay' in html
|
|
assert 'Réinitialiser mon mot de passe' in html
|
|
assert 'Speakr' not in html
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_send_verification_returns_false_when_smtp_not_configured():
|
|
"""No exception, just False — keeps registration robust."""
|
|
with app.app_context():
|
|
_clear_smtp_env()
|
|
# Verification enabled but SMTP missing
|
|
os.environ['ENABLE_EMAIL_VERIFICATION'] = 'true'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user()
|
|
from src.services.email import send_verification_email
|
|
assert send_verification_email(user) is False
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_smtp_from_name_defaults_to_dictia():
|
|
"""When SMTP_FROM_NAME is unset, get_email_config() returns 'DictIA'."""
|
|
_clear_smtp_env()
|
|
from src.services.email import get_email_config
|
|
cfg = get_email_config()
|
|
assert cfg['from_name'] == 'DictIA', (
|
|
'Default SMTP_FROM_NAME must be "DictIA", not "Speakr"'
|
|
)
|
|
|
|
|
|
def test_forgot_password_returns_generic_message_for_unknown_email():
|
|
"""Anti-enumeration: unknown email gets the same generic message."""
|
|
with app.app_context():
|
|
_set_smtp_env()
|
|
app.config['WTF_CSRF_ENABLED'] = False
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
# No user exists with this email
|
|
with patch('src.services.email._send_email', return_value=True):
|
|
resp = client.post('/forgot-password',
|
|
data={'email': 'nobody@nope.qc.ca'})
|
|
# Page should render the generic message in body
|
|
body = resp.data.decode('utf-8')
|
|
assert 'Si un compte' in body or 'lien de réinitialisation' in body
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_forgot_password_returns_same_message_for_known_email():
|
|
"""Anti-enumeration: known email gets the SAME generic message."""
|
|
with app.app_context():
|
|
_set_smtp_env()
|
|
app.config['WTF_CSRF_ENABLED'] = False
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(username='dora', email='dora@x.qc.ca')
|
|
client = app.test_client()
|
|
with patch('src.services.email._send_email', return_value=True):
|
|
resp = client.post('/forgot-password',
|
|
data={'email': user.email})
|
|
body = resp.data.decode('utf-8')
|
|
assert 'Si un compte' in body or 'lien de réinitialisation' in body
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_check_email_template_extends_marketing_base():
|
|
"""check_email.html uses DictIA marketing layout, no legacy Vue styles."""
|
|
with app.test_request_context('/'):
|
|
_set_smtp_env()
|
|
app.config['WTF_CSRF_ENABLED'] = False
|
|
db.create_all()
|
|
try:
|
|
from flask import render_template
|
|
html = render_template(
|
|
'auth/check_email.html',
|
|
title='Vérifiez votre courriel',
|
|
email='alice@x.qc.ca',
|
|
action='verification',
|
|
show_resend=True,
|
|
)
|
|
# New marketing layout markers
|
|
assert 'marketing.css' in html or 'grad-text' in html or 'brand-navy' in html
|
|
# Legacy Vue/Tailwind v3 design tokens MUST be gone
|
|
assert 'var(--text-primary)' not in html
|
|
assert 'var(--bg-secondary)' not in html
|
|
# French + brand
|
|
assert 'DictIA' in html
|
|
assert 'alice@x.qc.ca' in html
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_verification_email_falls_back_when_name_is_whitespace():
|
|
"""Empty/whitespace name must NOT produce 'Bonjour ,' — falls back to username."""
|
|
with app.test_request_context('/'):
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
user = User(username='claire42', email='claire@example.qc.ca',
|
|
password='x' * 60, name=' ', email_verified=False)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
|
from src.services.email import send_verification_email
|
|
send_verification_email(user)
|
|
args, _ = mock_send.call_args
|
|
_, _, html_body, text_body = args
|
|
assert 'Bonjour ,' not in html_body
|
|
assert 'Bonjour claire42' in html_body
|
|
assert 'Bonjour claire42' in text_body
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_verification_email_handles_unicode_name():
|
|
"""Accented French names must round-trip through email without mojibake."""
|
|
with app.test_request_context('/'):
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
user = User(username='francois', email='francois@example.qc.ca',
|
|
password='x' * 60, name='François Mélanie',
|
|
email_verified=False)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
|
from src.services.email import send_verification_email
|
|
send_verification_email(user)
|
|
args, _ = mock_send.call_args
|
|
_, _, html_body, text_body = args
|
|
assert 'Bonjour François Mélanie' in html_body
|
|
assert 'Bonjour François Mélanie' in text_body
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_verification_email_escapes_html_in_user_name():
|
|
"""user.name with HTML payload must be escaped in HTML body, raw in text body.
|
|
|
|
Regression test for C1 (stored XSS). A signup with name='<img onerror=...>'
|
|
persists the payload — without escape it executes when the verification
|
|
email renders.
|
|
"""
|
|
with app.test_request_context('/'):
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
payload = '<img src=x onerror=alert(1)>'
|
|
user = User(username='attacker', email='attacker@x.ca',
|
|
password='x' * 60, name=payload, email_verified=False)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
|
from src.services.email import send_verification_email
|
|
send_verification_email(user)
|
|
args, _ = mock_send.call_args
|
|
_, _, html_body, text_body = args
|
|
# HTML body MUST escape the payload
|
|
assert payload not in html_body, \
|
|
'Raw HTML payload leaked into HTML email body!'
|
|
assert '<img src=x onerror=alert(1)>' in html_body
|
|
# Text body keeps the raw string (it's plaintext, no XSS surface)
|
|
assert payload in text_body
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_check_email_template_escapes_email_in_response():
|
|
"""email value rendered into check_email.html must be HTML-escaped.
|
|
|
|
Regression test for C2 (reflected XSS). Posting a script payload to
|
|
/forgot-password reflected it unescaped via concat-then-safe pattern.
|
|
"""
|
|
with app.app_context():
|
|
app.config['WTF_CSRF_ENABLED'] = False
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
payload = '<script>alert(1)</script>'
|
|
resp = client.post('/forgot-password', data={'email': payload})
|
|
assert resp.status_code == 200
|
|
body = resp.data.decode('utf-8')
|
|
assert payload not in body, \
|
|
'Raw <script> payload leaked into rendered HTML!'
|
|
assert '<script>alert(1)</script>' in body
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|
|
|
|
|
|
def test_resend_verification_rate_limited_per_user():
|
|
"""can_resend_verification returns (False, remaining) within the 60s cooldown."""
|
|
with app.app_context():
|
|
_set_smtp_env()
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(username='eric', email='eric@x.qc.ca')
|
|
from src.services.email import can_resend_verification
|
|
# Simulate recent send
|
|
user.email_verification_sent_at = datetime.utcnow()
|
|
db.session.commit()
|
|
can, remaining = can_resend_verification(user)
|
|
assert can is False
|
|
assert remaining is not None and remaining > 0
|
|
# Simulate older send (>60s) — should now allow
|
|
user.email_verification_sent_at = datetime.utcnow() - timedelta(seconds=120)
|
|
db.session.commit()
|
|
can, remaining = can_resend_verification(user)
|
|
assert can is True
|
|
assert remaining is None
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_smtp_env()
|