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>
This commit is contained in:
78
tests/_run_email_service_dictia_windows.py
Normal file
78
tests/_run_email_service_dictia_windows.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Windows manual driver for tests/test_email_service_dictia.py.
|
||||
|
||||
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
|
||||
before src.app gets imported, then run each test_* function and report.
|
||||
|
||||
Run from the repo root:
|
||||
py -3 tests/_run_email_service_dictia_windows.py
|
||||
|
||||
This script is local-dev only (not picked up by pytest collection).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import traceback
|
||||
|
||||
# 1) Stub fcntl BEFORE any import of src.* happens.
|
||||
if 'fcntl' not in sys.modules:
|
||||
fcntl_stub = types.ModuleType('fcntl')
|
||||
fcntl_stub.LOCK_EX = 2
|
||||
fcntl_stub.LOCK_NB = 4
|
||||
fcntl_stub.LOCK_UN = 8
|
||||
fcntl_stub.LOCK_SH = 1
|
||||
fcntl_stub.flock = lambda *_args, **_kw: None
|
||||
fcntl_stub.fcntl = lambda *_args, **_kw: 0
|
||||
sys.modules['fcntl'] = fcntl_stub
|
||||
|
||||
# 2) Make repo root importable
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HERE)
|
||||
sys.path.insert(0, REPO)
|
||||
|
||||
# 3) Set test config
|
||||
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
||||
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
||||
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
|
||||
# Avoid sys.exit(1) in src/config/app_config.py legacy validation.
|
||||
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
|
||||
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
|
||||
# Disable rate limits for forgot_password endpoint test.
|
||||
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
|
||||
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4) Import the test module and run every test_* function it defines
|
||||
import importlib.util # noqa: E402
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'test_email_service_dictia',
|
||||
os.path.join(HERE, 'test_email_service_dictia.py'),
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
tests = [(name, fn) for name, fn in vars(mod).items()
|
||||
if name.startswith('test_') and callable(fn)]
|
||||
|
||||
passed = 0
|
||||
failed = []
|
||||
for name, fn in tests:
|
||||
try:
|
||||
fn()
|
||||
print(f' PASS {name}')
|
||||
passed += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f' FAIL {name}: {type(e).__name__}: {e}')
|
||||
failed.append((name, traceback.format_exc()))
|
||||
|
||||
total = len(tests)
|
||||
print()
|
||||
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
|
||||
if failed:
|
||||
print('\n--- Failures ---\n')
|
||||
for name, tb in failed:
|
||||
print(f'### {name}\n{tb}\n')
|
||||
sys.exit(0 if not failed else 1)
|
||||
292
tests/test_email_service_dictia.py
Normal file
292
tests/test_email_service_dictia.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user