Refondre /register en /signup avec consentement granulaire (LPRPSP art. 14):
- SignupLoi25Form (Flask-WTF) remplace RegistrationForm
- 4 BooleanField séparés: cgu, confidentialite (obligatoires) + marketing,
analytics (optionnels). Chaque consentement crée 1 row ConsentLog avec
ip_address (CF-Connecting-IP > remote_addr), user_agent (tronqué 500),
version='2026-04-27' (B-2.9 substituera LEGAL_VERSION canonique).
- Marketing/analytics non cochés -> ConsentLog row avec granted=False
(refus explicite tracé pour audit Loi 25).
- /register reste 302 -> /signup (backward compat).
- Username auto-généré unique depuis email local-part (max 20, alphanum,
suffixe numérique sur collision).
- name = "{first_name} {last_name}".strip() persisté dans User.name
(pas de colonnes first_name/last_name au modèle).
- send_verification_email() existant réutilisé (smtplib via env SMTP_*).
Template register.html refondu IN PLACE pour étendre marketing/base.html:
- 4 checkboxes dans <fieldset>+<legend>, AUCUNE pré-cochée
- WCAG 2.2 AA: focus-visible outlines, aria-required, label for=, role=alert
- OQLF: NBSP via | safe pour "Loi 25"
Tests: 9 cas couvrent GET 200, refus CGU, refus RPRP, happy path 4 rows,
capture IP+UA, duplicate email, username collision, /register redirect,
CSRF enforcement. Pattern test_consent_log.py (no conftest, env setup
avant imports, app_context, db.create_all/drop_all).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
305 lines
12 KiB
Python
305 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.
|
|
assert resp.status_code in (200, 400)
|
|
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
|
|
db.create_all()
|
|
try:
|
|
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()
|