From d2fc1f03eda85cfb89a79dcaef6d92b4ede93b7a Mon Sep 17 00:00:00 2001 From: Allison Date: Mon, 27 Apr 2026 22:29:12 -0400 Subject: [PATCH] feat(auth): B-2.2 signup Loi 25-compliant (4 consent checkboxes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
+, 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) --- src/api/auth.py | 208 +++++++++++++++++++------ templates/register.html | 251 ++++++++++++------------------ tests/test_signup_loi25.py | 304 +++++++++++++++++++++++++++++++++++++ 3 files changed, 563 insertions(+), 200 deletions(-) create mode 100644 tests/test_signup_loi25.py diff --git a/src/api/auth.py b/src/api/auth.py index 8aa8e21..8be61a2 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -12,7 +12,7 @@ import mimetypes from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, current_app from flask_login import login_user, logout_user, login_required, current_user from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, SubmitField, BooleanField +from wtforms import StringField, PasswordField, SubmitField, BooleanField, SelectField from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError from werkzeug.security import generate_password_hash, check_password_hash from urllib.parse import urlparse, urljoin @@ -20,6 +20,7 @@ import markdown from src.database import db from src.models import User, SystemSetting, GroupMembership +from src.models.consent import ConsentLog from src.utils import password_check from src.auth.sso import ( init_sso_client, @@ -86,24 +87,58 @@ def csrf_exempt(f): return wrapper +# --- Constants --- + +# B-2.9 will define the canonical LEGAL_VERSION constant in src/legal/__init__.py. +# Until then, use the ISO date of the current legal text revision as the placeholder. +SIGNUP_LEGAL_VERSION = '2026-04-27' + + # --- Forms --- -class RegistrationForm(FlaskForm): - username = StringField('Username', validators=[DataRequired(), Length(min=2, max=20)]) - email = StringField('Email', validators=[DataRequired(), Email()]) - password = PasswordField('Password', validators=[DataRequired(), password_check]) - confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) - submit = SubmitField('Sign Up') +class SignupLoi25Form(FlaskForm): + """Loi 25 art. 14 — granular consent (4 separate checkboxes). - def validate_username(self, username): - user = User.query.filter_by(username=username.data).first() - if user: - raise ValidationError('That username is already taken. Please choose a different one.') + Replaces the legacy RegistrationForm. Username is auto-generated from the + email local-part by the view (User.username is required + max 20 + unique). + Two consents are hard-required (CGU + politique de confidentialité); the + other two (marketing, analytics) are optional and recorded either way. + """ + email = StringField('Courriel', validators=[ + DataRequired(), Email(), Length(max=120) + ]) + password = PasswordField('Mot de passe', validators=[ + DataRequired(), password_check + ]) + confirm_password = PasswordField('Confirmer le mot de passe', validators=[ + DataRequired(), + EqualTo('password', message='Les mots de passe ne correspondent pas.'), + ]) + first_name = StringField('Prénom', validators=[DataRequired(), Length(max=49)]) + last_name = StringField('Nom', validators=[DataRequired(), Length(max=49)]) + cabinet = StringField('Cabinet / Organisation', validators=[Length(max=255)]) + ordre_pro = SelectField('Ordre professionnel', choices=[ + ('', 'Aucun ou autre'), + ('barreau', 'Barreau du Québec'), + ('cpa', 'CPA Québec'), + ('chad', 'ChAD'), + ('oaciq', 'OACIQ'), + ('cmq', 'CMQ'), + ('oiiq', 'OIIQ'), + ('oppq', 'OPPQ'), + ('oeq', 'OEQ'), + ('opq', 'OPQ'), + ], default='', validators=[]) + consent_cgu = BooleanField('CGU') + consent_confidentialite = BooleanField('Confidentialité') + consent_marketing = BooleanField('Marketing') + consent_analytics = BooleanField('Analytics') + submit = SubmitField('Créer mon compte') def validate_email(self, email): - user = User.query.filter_by(email=email.data).first() + user = User.query.filter_by(email=email.data.lower().strip()).first() if user: - raise ValidationError('That email is already registered. Please use a different one.') + raise ValidationError('Ce courriel est déjà utilisé.') class LoginForm(FlaskForm): @@ -148,59 +183,144 @@ def is_registration_domain_allowed(email: str) -> bool: # --- Routes --- -@auth_bp.route('/register', methods=['GET', 'POST']) -@rate_limit("10 per minute") -def register(): - # Check if registration is allowed - allow_registration = os.environ.get('ALLOW_REGISTRATION', 'true').lower() == 'true' +def _registration_open() -> bool: + """ALLOW_REGISTRATION env var (default: true) — gate for new signups.""" + return os.environ.get('ALLOW_REGISTRATION', 'true').lower() == 'true' - if not allow_registration: - flash('Registration is currently disabled. Please contact the administrator.', 'danger') + +def _client_ip() -> str: + """Return the originating client IP, honoring Cloudflare's CF-Connecting-IP header. + + Falls back to remote_addr (which is already corrected by ProxyFix when + behind a reverse proxy). Used for Loi 25 ConsentLog.ip_address. + """ + return request.headers.get('CF-Connecting-IP') or request.remote_addr or '0.0.0.0' + + +def _generate_unique_username(email: str) -> str: + """Derive a unique username from the email local-part (max 20 chars). + + Append a numeric suffix on collision. User.username is required, unique, + and capped at 20 chars by the schema. + """ + local = re.sub(r'[^a-z0-9]', '', email.split('@', 1)[0].lower())[:20] or 'user' + candidate = local + suffix = 0 + while User.query.filter_by(username=candidate).first(): + suffix += 1 + tail = str(suffix) + candidate = f"{local[:20 - len(tail)]}{tail}" + return candidate + + +@auth_bp.route('/register') +def register(): + """Backward-compat alias: /register → /signup (Loi 25 redesign B-2.2).""" + return redirect(url_for('auth.signup'), code=302) + + +@auth_bp.route('/signup', methods=['GET', 'POST']) +@rate_limit("10 per minute") +def signup(): + """Loi 25-compliant signup with 4 separate consent checkboxes. + + Each signup creates exactly 4 ConsentLog rows (cgu, confidentialite, + marketing, analytics) with the user's explicit choice. CGU and + confidentialité are hard-required; marketing and analytics are optional + but their refusal is also recorded (granted=False) for the audit trail. + """ + if not _registration_open(): + flash('La création de comptes est temporairement désactivée.', 'danger') return redirect(url_for('auth.login')) if current_user.is_authenticated: return redirect(url_for('recordings.index')) - form = RegistrationForm() + form = SignupLoi25Form() + if form.validate_on_submit(): - # Check if email domain is allowed + # Hard-stop: required Loi 25 consents (CGU + politique de confidentialité). + missing_required = [] + if not form.consent_cgu.data: + missing_required.append( + "Vous devez accepter les conditions d'utilisation pour créer un compte." + ) + if not form.consent_confidentialite.data: + missing_required.append( + "Vous devez accepter la politique de confidentialité pour créer un compte." + ) + if missing_required: + for msg in missing_required: + flash(msg, 'danger') + return render_template( + 'register.html', title='Créer un compte', form=form + ), 400 + if not is_registration_domain_allowed(form.email.data): - flash('Registration is restricted. Please contact the administrator.', 'danger') - return render_template('register.html', title='Register', form=form) + flash("Le domaine de votre courriel n'est pas autorisé.", 'danger') + return render_template( + 'register.html', title='Créer un compte', form=form + ), 400 - hashed_password = bcrypt.generate_password_hash(form.password.data).decode('utf-8') - - # Set email_verified based on whether verification is enabled - # If verification is enabled, new users start unverified - # If disabled, new users are considered verified by default - email_verified = not is_email_verification_enabled() + email_normalized = form.email.data.lower().strip() + full_name = ( + f"{form.first_name.data.strip()} {form.last_name.data.strip()}".strip() + ) + hashed_password = bcrypt.generate_password_hash( + form.password.data + ).decode('utf-8') user = User( - username=form.username.data, - email=form.email.data, + username=_generate_unique_username(email_normalized), + email=email_normalized, password=hashed_password, - email_verified=email_verified + name=full_name, + cabinet=(form.cabinet.data.strip() or None) if form.cabinet.data else None, + ordre_pro=(form.ordre_pro.data or None), + email_verified=not is_email_verification_enabled(), ) db.session.add(user) + db.session.flush() # need user.id for ConsentLog FK + + ip = _client_ip() + ua = (request.headers.get('User-Agent') or '')[:500] + consent_state = { + 'cgu': bool(form.consent_cgu.data), + 'confidentialite': bool(form.consent_confidentialite.data), + 'marketing': bool(form.consent_marketing.data), + 'analytics': bool(form.consent_analytics.data), + } + for ctype, granted in consent_state.items(): + db.session.add(ConsentLog( + user_id=user.id, + consent_type=ctype, + version=SIGNUP_LEGAL_VERSION, + granted=granted, + ip_address=ip, + user_agent=ua, + )) db.session.commit() audit_register(user.id) - # Send verification email if enabled if is_email_verification_enabled() and is_smtp_configured(): if send_verification_email(user): - return render_template('auth/check_email.html', - title='Check Your Email', - email=user.email, - action='verification') - else: - # Email failed to send, but account was created - flash('Your account has been created, but we could not send a verification email. Please contact support.', 'warning') - return redirect(url_for('auth.login')) + return render_template( + 'auth/check_email.html', + title='Vérifiez votre courriel', + email=user.email, + action='verification', + ) + flash( + "Compte créé, mais l'envoi du courriel de vérification a échoué. " + "Contactez le support.", + 'warning', + ) + return redirect(url_for('auth.login')) - flash('Your account has been created! You can now log in.', 'success') + flash('Votre compte a été créé. Vous pouvez vous connecter.', 'success') return redirect(url_for('auth.login')) - return render_template('register.html', title='Register', form=form) + return render_template('register.html', title='Créer un compte', form=form) @auth_bp.route('/login', methods=['GET', 'POST']) diff --git a/templates/register.html b/templates/register.html index fc405ef..0714d76 100644 --- a/templates/register.html +++ b/templates/register.html @@ -1,164 +1,103 @@ - - - - - - - {{ title }} - DictIA - - - - - - +{% extends 'marketing/base.html' %} - - {% include 'includes/loading_overlay.html' %} +{% block title %}Créer un compte — DictIA{% endblock %} +{% block description %}Créez votre compte DictIA. Conformité Loi 25 du Québec, hébergement local, consentement granulaire.{% endblock %} - - - -
-
-

- - DictIA - DictIA - -

-
+
+ + {{ form.email(id='email', type='email', autocomplete='email', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} + {% if form.email.errors %}

{{ form.email.errors[0] }}

{% endif %} +
-
-
-

Create an Account

- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} - -
- {{ form.hidden_tag() }} - -
- {{ form.username.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} - {% if form.username.errors %} - {{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} -
- {% for error in form.username.errors %} - {{ error }} - {% endfor %} -
- {% else %} - {{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} - {% endif %} -
- -
- {{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} - {% if form.email.errors %} - {{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} -
- {% for error in form.email.errors %} - {{ error }} - {% endfor %} -
- {% else %} - {{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} - {% endif %} -
- -
- {{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} - {% if form.password.errors %} - {{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} -
- {% for error in form.password.errors %} - {{ error }} - {% endfor %} -
- {% else %} - {{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} - {% endif %} -

Password must be at least 8 characters long.

-
- -
- {{ form.confirm_password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} - {% if form.confirm_password.errors %} - {{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} -
- {% for error in form.confirm_password.errors %} - {{ error }} - {% endfor %} -
- {% else %} - {{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }} - {% endif %} -
- -
- {{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }} - -
- Already have an account? - Login here -
-
-
-
-
+
+ + {{ form.password(id='password', autocomplete='new-password', required=true, minlength=8, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} + {% if form.password.errors %}

{{ form.password.errors[0] }}

{% endif %} +

8 caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.

+
- -
+
+ + {{ form.confirm_password(id='confirm_password', autocomplete='new-password', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} + {% if form.confirm_password.errors %}

{{ form.confirm_password.errors[0] }}

{% endif %} +
- - - +
+
+ + {{ form.first_name(id='first_name', autocomplete='given-name', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} + {% if form.first_name.errors %}

{{ form.first_name.errors[0] }}

{% endif %} +
+
+ + {{ form.last_name(id='last_name', autocomplete='family-name', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} + {% if form.last_name.errors %}

{{ form.last_name.errors[0] }}

{% endif %} +
+
+ +
+ + {{ form.cabinet(id='cabinet', autocomplete='organization', **{'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} + {% if form.cabinet.errors %}

{{ form.cabinet.errors[0] }}

{% endif %} +
+ +
+ + {{ form.ordre_pro(id='ordre_pro', **{'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy bg-white focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} +
+ + {# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #} +
+ {{ "Consentements — Loi 25" | safe }} + + + + + + + + +
+ + {{ form.submit(**{'class':'w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }} + + +

Déjà un compte ? Se connecter

+ + +{% endblock %} diff --git a/tests/test_signup_loi25.py b/tests/test_signup_loi25.py new file mode 100644 index 0000000..5d57ecf --- /dev/null +++ b/tests/test_signup_loi25.py @@ -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="" ... 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 = ' '; 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()