Logos officiels installés : - static/images/dictia-logo.png (28 KB optimisé 256×256) - static/images/dictia-logo-128.png (10 KB retina) - static/images/dictia-logo-fullres.png (originaux conservés OG/social) - static/images/dictia-logo.svg + dictia-logo-nom.svg (cleaned C2PA metadata) - Header marketing/base.html : <img> 40×40 + wordmark "DictIA" + tagline "Transcription" - Footer marketing/_footer.html : <img> 36×36 + wordmark - Favicon mis à jour vers logo PNG Note : SVG sources sont des PNG base64 wrappés (pas de vrais paths) — PNG utilisé en production (8× plus léger), SVG conservé pour fallback. Palette canonique alignée sur le logo : - brand-b1 : #7c3aed (mauve) → #2563eb (blue-600 vibrant — primary) - brand-b2 : #a855f7 (mauve clair) → #06b6d4 (cyan-500 — aqua mid) - brand-b3 : #06b6d4 (aqua) → #c026d3 (fuchsia-600 — magenta accent) - Gradient signature : linear-gradient(118deg, #2563eb, #06b6d4 52%, #c026d3) - Box shadow CTA : rgba(37,99,235,0.28/0.42) - 72 remplacements hex/rgba dans 5 templates marketing/legal + email service Tests : 81 passed / 3 failed (3 échecs pré-existants /blog + trust-bar phrasing, non liés à ce changement). 0 régression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
405 lines
17 KiB
Python
405 lines
17 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():
|
|
"""Header bg must use the official DictIA brand gradient (blue → cyan → fuchsia,
|
|
matches the official logo). The legacy #0062ff/#00bdd8/#00c896 palette must be gone."""
|
|
with app.app_context():
|
|
from src.services.email import _get_email_template
|
|
html, _ = _get_email_template('x', 'x', 'Test')
|
|
# Legacy palette must be removed
|
|
assert '#0062ff' not in html, 'Legacy header color #0062ff must be removed'
|
|
assert '#00bdd8' not in html, 'Legacy mid color #00bdd8 must be removed'
|
|
assert '#00c896' not in html, 'Legacy end color #00c896 must be removed'
|
|
# New official-logo palette must be present
|
|
assert '#2563eb' in html, 'DictIA brand blue (#2563eb) must be present'
|
|
assert '#06b6d4' in html, 'DictIA brand cyan (#06b6d4) must be present'
|
|
assert '#c026d3' in html, 'DictIA brand fuchsia (#c026d3) 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()
|