"""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. # 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 = ' '; 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()