Files
dictia-public/tests/test_email_service_dictia.py
Allison 37639a7d09 feat(auth): B-2.3 emails FR + DictIA branding (SMTP Resend)
Rebrand src/services/email.py IN PLACE: French + DictIA + brand gradient
(#0062ff/#00bdd8/#00c896) — replaces legacy "Speakr" / #2563eb. Greetings now
use user.name with fallback to user.username. Subjects:
"Vérifiez votre courriel — DictIA" + "Réinitialiser votre mot de passe — DictIA".
SMTP_FROM_NAME defaults to DictIA. Footer points to info@dictia.ca with the
Loi 25 tagline.

Refonte 4 auth templates IN PLACE pour étendre marketing/base.html : check_email,
forgot_password, reset_password, verify_success. Tokens DictIA (brand-navy,
brand-bg, grad-bg, shadow-cta), French copy, WCAG patterns (label for,
focus-visible:outline-2, role=alert, aria-required, text-brand-navy/70 minimum,
NBSP français pour Loi 25 / 24 heures / 1 heure / 8 caractères).

Translate inline French flash messages in src/api/auth.py for /verify-email,
/resend-verification, /forgot-password, /reset-password. Anti-enumeration fix:
forgot_password no longer flashes the cooldown remaining (would leak account
existence) — silently skips resend, generic flash unchanged. Cooldown logic
in src/services/email.py UNCHANGED (60s — verified by test).

config/env.email.example: defaults to Resend SMTP at the top + adds Resend
to the provider examples list (preserves Gmail/SendGrid/Mailgun/SES/M365).

Tests: tests/test_email_service_dictia.py — 12 tests covering DictIA branding,
French copy, display-name fallback, anti-enumeration parity (forgot_password
returns identical message for known/unknown emails), 60s cooldown, SMTP-not-
configured returns False (no exception), check_email.html extends marketing/base
(no var(--text-primary) leaks). Includes Windows manual driver
(_run_email_service_dictia_windows.py) since pytest cannot collect on Windows
native (fcntl POSIX-only).

NO new dependency added (no resend SDK — SMTP via existing _send_email).
NO new route added or removed.
NO src/auth_extended/ created.
NO change to itsdangerous-based token logic.
templates/auth/**/*.html already in tailwind.config.js content array (B-2.2).

Verified locally on Windows manual driver: 12/12 PASS B-2.3, 9/9 PASS regression
on B-2.2 signup, 9/9 PASS regression on B-2.1 ConsentLog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:02:20 -04:00

293 lines
12 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_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()