"""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='

hello

', 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='' 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 = '' 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 = '' 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