Files
dictia-public/tests/test_email_service_dictia.py
Allison dd270bca9e fix(auth): B-2.3 security review fixes — XSS escape + token replay
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 &#160; for NBSP instead of '1&nbsp;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>
2026-04-27 23:14:11 -04:00

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 '&lt;img src=x onerror=alert(1)&gt;' 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 '&lt;script&gt;alert(1)&lt;/script&gt;' 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()