feat(auth): B-2.2 signup Loi 25-compliant (4 consent checkboxes)

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&nbsp;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>
This commit is contained in:
Allison
2026-04-27 22:29:12 -04:00
parent 8792ffb8a4
commit d2fc1f03ed
3 changed files with 563 additions and 200 deletions

304
tests/test_signup_loi25.py Normal file
View File

@@ -0,0 +1,304 @@
"""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()