C-1: Add templates/register.html (and templates/auth/**) to tailwind.config.js
content array so utility classes used by the signup template don't get purged
on next build. Rebuilt static/css/marketing.css; verified text-brand-navy/90
and min-h-[calc(100vh-62px)] are now compiled.
I-1: Replace flash() calls for missing required consents with WTForms
field-level errors (form.consent_cgu.errors.append / form.consent_confidentialite
.errors.append). Errors render inline next to each consent checkbox via
{% if form.consent_cgu.errors %}<p role="alert">…</p>{% endif %}. Prevents
session-backed flash messages from leaking across unrelated navigations.
I-2: Wrap user creation + flush in IntegrityError retry loop (max 5 attempts);
import IntegrityError from sqlalchemy.exc. Absorbs the inherent race between
_generate_unique_username's lookup and the subsequent flush under concurrent
signups. Added docstring note to _generate_unique_username explaining the
wrapper.
I-3: Move db.create_all() inside the try/finally in
test_signup_route_csrf_enforced so WTF_CSRF_ENABLED is restored even if
table creation fails.
I-4: Pin test_signup_rejects_duplicate_email assertion to status_code == 200
(WTForms validate_email raises ValidationError → form fails validation →
fall-through to default 200 render_template).
I-5: Add id="password-help" to the password help paragraph and
aria-describedby="password-help" to the password input so screen readers
announce the password requirements when the field is focused.
I-6: Bump flash banner text colors from -700/-800 to -900 variants
(text-amber-900, text-blue-900, text-red-900, text-green-900) for safer
WCAG 2.2 AA contrast against the -50 backgrounds. Same bump applied to the
new consent and password inline error renders.
312 lines
12 KiB
Python
312 lines
12 KiB
Python
"""Tests for B-2.2 — /signup Loi 25-compliant flow.
|
|
|
|
Covers:
|
|
- GET /signup renders form with 4 unchecked consent checkboxes.
|
|
- POST /signup rejects when CGU or politique de confidentialité is missing.
|
|
- POST /signup creates User + 4 ConsentLog rows (one per consent type),
|
|
capturing IP (CF-Connecting-IP preferred) and User-Agent.
|
|
- /register is a 302 redirect to /signup (backward-compat).
|
|
- CSRF is enforced when WTF_CSRF_ENABLED is True.
|
|
|
|
The default test config sets WTF_CSRF_ENABLED=False for convenience; tests
|
|
that need to verify CSRF temporarily flip it back to True.
|
|
|
|
Note: pytest cannot collect this file on Windows native because
|
|
src/init_db.py imports `fcntl` (POSIX-only). Tests run in CI / Docker.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
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')
|
|
# Make sure no email sending is attempted during tests.
|
|
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
|
|
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
|
|
|
|
from src.app import app, db # noqa: E402
|
|
from src.models.user import User # noqa: E402
|
|
from src.models.consent import ConsentLog # noqa: E402
|
|
|
|
# Strong password that satisfies password_check (uppercase, lowercase, digit, special, len>=8).
|
|
STRONG_PASSWORD = 'SecurePass123!Long'
|
|
|
|
VALID_FORM = {
|
|
'email': 'jane@example.qc.ca',
|
|
'password': STRONG_PASSWORD,
|
|
'confirm_password': STRONG_PASSWORD,
|
|
'first_name': 'Jane',
|
|
'last_name': 'Bouchard',
|
|
'cabinet': '',
|
|
'ordre_pro': '',
|
|
}
|
|
|
|
|
|
def _disable_csrf():
|
|
app.config['WTF_CSRF_ENABLED'] = False
|
|
|
|
|
|
def test_signup_get_returns_200_with_4_unchecked_checkboxes():
|
|
"""GET /signup renders the form with 4 consent checkboxes, none pre-checked."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
resp = client.get('/signup')
|
|
assert resp.status_code == 200
|
|
body = resp.data.decode('utf-8')
|
|
|
|
# 4 distinct checkboxes (one per consent_type).
|
|
for ctype in ('consent_cgu', 'consent_confidentialite',
|
|
'consent_marketing', 'consent_analytics'):
|
|
assert f'name="{ctype}"' in body, f'missing checkbox {ctype}'
|
|
|
|
# None pre-checked: WTForms emits checked="checked" or checked attribute
|
|
# only when BooleanField data is truthy. With a blank GET, all 4 are False.
|
|
for ctype in ('consent_cgu', 'consent_confidentialite',
|
|
'consent_marketing', 'consent_analytics'):
|
|
# No occurrence of `name="<ctype>" ... checked` should appear.
|
|
idx = body.find(f'name="{ctype}"')
|
|
assert idx >= 0
|
|
# Look at a 200-char window around the input tag.
|
|
window = body[max(0, idx - 100): idx + 200]
|
|
assert 'checked' not in window, (
|
|
f'{ctype} must NOT be pre-checked at GET time'
|
|
)
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_signup_rejects_without_cgu_consent():
|
|
"""POST without consent_cgu returns 400 + French flash; no User created."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
data = dict(VALID_FORM)
|
|
# Cocher confidentialité mais PAS la CGU.
|
|
data['consent_confidentialite'] = 'y'
|
|
resp = client.post('/signup', data=data)
|
|
assert resp.status_code == 400
|
|
body = resp.data.decode('utf-8').lower()
|
|
assert "conditions d'utilisation" in body or 'cgu' in body
|
|
assert User.query.filter_by(email=VALID_FORM['email']).first() is None
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_signup_rejects_without_confidentialite_consent():
|
|
"""POST without consent_confidentialite returns 400 + French flash; no User created."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
data = dict(VALID_FORM)
|
|
data['consent_cgu'] = 'y'
|
|
# Politique de confidentialité non cochée.
|
|
resp = client.post('/signup', data=data)
|
|
assert resp.status_code == 400
|
|
body = resp.data.decode('utf-8').lower()
|
|
assert 'confidentialit' in body
|
|
assert User.query.filter_by(email=VALID_FORM['email']).first() is None
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_signup_creates_4_consent_logs_with_correct_state():
|
|
"""Granular consent — 4 rows, one per type, with the correct granted/refused state."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
data = dict(VALID_FORM)
|
|
data['consent_cgu'] = 'y'
|
|
data['consent_confidentialite'] = 'y'
|
|
# marketing absent => False
|
|
data['consent_analytics'] = 'y'
|
|
resp = client.post('/signup', data=data)
|
|
assert resp.status_code in (200, 302)
|
|
|
|
user = User.query.filter_by(email=VALID_FORM['email']).first()
|
|
assert user is not None, 'User should have been created'
|
|
|
|
consents = ConsentLog.query.filter_by(user_id=user.id).all()
|
|
assert len(consents) == 4, (
|
|
'One ConsentLog row per consent type — granular Loi 25 art. 14'
|
|
)
|
|
state = {c.consent_type: c.granted for c in consents}
|
|
assert state == {
|
|
'cgu': True,
|
|
'confidentialite': True,
|
|
'marketing': False, # explicit refusal recorded
|
|
'analytics': True,
|
|
}
|
|
assert all(c.version == '2026-04-27' for c in consents)
|
|
assert all(c.granted_at is not None for c in consents)
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_signup_captures_ip_and_user_agent_in_consent_logs():
|
|
"""CF-Connecting-IP is honored over remote_addr; User-Agent recorded on each row."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
data = dict(VALID_FORM)
|
|
data['email'] = 'audit@example.qc.ca'
|
|
data['consent_cgu'] = 'y'
|
|
data['consent_confidentialite'] = 'y'
|
|
resp = client.post(
|
|
'/signup',
|
|
data=data,
|
|
headers={
|
|
'CF-Connecting-IP': '203.0.113.42',
|
|
'User-Agent': 'TestAgent/1.0',
|
|
},
|
|
)
|
|
assert resp.status_code in (200, 302)
|
|
user = User.query.filter_by(email='audit@example.qc.ca').first()
|
|
assert user is not None
|
|
consents = ConsentLog.query.filter_by(user_id=user.id).all()
|
|
assert len(consents) == 4
|
|
assert all(c.ip_address == '203.0.113.42' for c in consents), (
|
|
'CF-Connecting-IP must take precedence over remote_addr'
|
|
)
|
|
assert all(c.user_agent == 'TestAgent/1.0' for c in consents)
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_signup_rejects_duplicate_email():
|
|
"""Duplicate email — form re-rendered with French error; no second User row."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
existing = User(
|
|
username='preexisting',
|
|
email='taken@example.qc.ca',
|
|
password='x' * 60,
|
|
)
|
|
db.session.add(existing)
|
|
db.session.commit()
|
|
|
|
client = app.test_client()
|
|
data = dict(VALID_FORM)
|
|
data['email'] = 'taken@example.qc.ca'
|
|
data['consent_cgu'] = 'y'
|
|
data['consent_confidentialite'] = 'y'
|
|
resp = client.post('/signup', data=data)
|
|
# Form re-rendered (200) with validation error from validate_email.
|
|
# Pinned to 200 exactly: validate_email raises ValidationError →
|
|
# form fails validate_on_submit() → fall-through to the final
|
|
# `return render_template(...)` which uses the default 200.
|
|
assert resp.status_code == 200, (
|
|
f"Form should re-render with error inline (200), got {resp.status_code}"
|
|
)
|
|
body = resp.data.decode('utf-8').lower()
|
|
assert 'courriel' in body and ('déjà' in body or 'deja' in body)
|
|
|
|
# Still only one user with that email.
|
|
count = User.query.filter_by(email='taken@example.qc.ca').count()
|
|
assert count == 1
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_signup_creates_user_with_combined_name_and_unique_username():
|
|
"""name = '<first> <last>'; username derived from email local-part, unique, max 20."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
data = dict(VALID_FORM)
|
|
data['email'] = 'jean.tremblay@example.qc.ca'
|
|
data['first_name'] = 'Jean'
|
|
data['last_name'] = 'Tremblay'
|
|
data['consent_cgu'] = 'y'
|
|
data['consent_confidentialite'] = 'y'
|
|
resp = client.post('/signup', data=data)
|
|
assert resp.status_code in (200, 302)
|
|
|
|
user = User.query.filter_by(email='jean.tremblay@example.qc.ca').first()
|
|
assert user is not None
|
|
assert user.name == 'Jean Tremblay'
|
|
assert user.username
|
|
assert len(user.username) <= 20
|
|
# Derived from local part 'jean.tremblay' (dots stripped).
|
|
assert user.username.startswith('jeantremblay')
|
|
|
|
# Second signup with email producing same local-part collision.
|
|
data2 = dict(VALID_FORM)
|
|
data2['email'] = 'jeantremblay@otherdomain.ca'
|
|
data2['first_name'] = 'Other'
|
|
data2['last_name'] = 'Person'
|
|
data2['consent_cgu'] = 'y'
|
|
data2['consent_confidentialite'] = 'y'
|
|
resp2 = client.post('/signup', data=data2)
|
|
assert resp2.status_code in (200, 302)
|
|
user2 = User.query.filter_by(email='jeantremblay@otherdomain.ca').first()
|
|
assert user2 is not None
|
|
assert user2.username != user.username, (
|
|
'collision must produce a distinct unique username'
|
|
)
|
|
assert len(user2.username) <= 20
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_register_redirects_to_signup_for_backward_compat():
|
|
"""GET /register returns 302 → /signup (backward-compat after redesign)."""
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
resp = client.get('/register', follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
assert resp.headers['Location'].endswith('/signup')
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
|
|
|
|
def test_signup_route_csrf_enforced():
|
|
"""When WTF_CSRF_ENABLED=True, POST /signup without csrf_token is rejected."""
|
|
with app.app_context():
|
|
prev = app.config.get('WTF_CSRF_ENABLED', True)
|
|
app.config['WTF_CSRF_ENABLED'] = True
|
|
try:
|
|
# create_all() is INSIDE the try so a failure here still restores
|
|
# WTF_CSRF_ENABLED for the rest of the test session.
|
|
db.create_all()
|
|
client = app.test_client()
|
|
data = dict(VALID_FORM)
|
|
data['email'] = 'csrf@example.qc.ca'
|
|
data['consent_cgu'] = 'y'
|
|
data['consent_confidentialite'] = 'y'
|
|
resp = client.post('/signup', data=data)
|
|
# Flask-WTF returns 400 by default on missing/invalid CSRF token;
|
|
# the app's error handler may also short-circuit via JSON. Either way,
|
|
# no User must have been created.
|
|
assert resp.status_code in (400, 403)
|
|
assert User.query.filter_by(email='csrf@example.qc.ca').first() is None
|
|
finally:
|
|
app.config['WTF_CSRF_ENABLED'] = prev
|
|
db.session.rollback()
|
|
db.drop_all()
|