fix(auth): B-2.1 — FK erasure policy, totp_secret_encrypted, validates, docs

- ConsentLog.user_id: nullable=True + ondelete='SET NULL' for Loi 25 art. 28.1
  right-to-erasure (audit row survives user deletion, user_id nulled out).
  Matches existing pattern in auth_log.py / access_log.py.
- Add ConsentLog.@validates('consent_type') to reject typos at ORM level
  (silent typos in audit data are very hard to detect later).
- Rename User.totp_secret -> totp_secret_encrypted (size 64->255 for Fernet
  envelope). Self-documenting contract: never assign plaintext to this column.
- init_db.py: drop NOT NULL from totp_enabled migration string for consistency
  with every other Boolean column in the file (model-side nullable=False is
  sufficient).
- Docs: User class docstring updated to reflect MFA/billing/ordre context;
  webauthn_credentials shape documented; version column policy documented.
- Tests: cleaner IntegrityError catch; add survives_user_deletion test
  (right-to-erasure); add rejects_invalid_consent_type test (validator).
This commit is contained in:
Allison
2026-04-27 21:57:32 -04:00
parent 48d2abfa74
commit 8792ffb8a4
4 changed files with 117 additions and 21 deletions

View File

@@ -119,22 +119,34 @@ def test_consent_log_user_backref():
def test_consent_log_requires_ip_and_user_agent():
"""ip_address and user_agent are NOT NULL — required for Loi 25 traceability."""
from sqlalchemy.exc import IntegrityError
with app.app_context():
db.create_all()
try:
user = _make_user()
# Missing ip_address — should fail at flush
log = ConsentLog(
user = _make_user(username='erica', email='erica@example.com')
# Missing ip_address
log_no_ip = ConsentLog(
user_id=user.id, consent_type='cgu', version='1.0',
granted=True, user_agent='UA'
# ip_address intentionally omitted
)
db.session.add(log)
db.session.add(log_no_ip)
try:
db.session.commit()
raise AssertionError("Expected IntegrityError on missing ip_address")
except Exception as e:
assert 'ip_address' in str(e).lower() or 'NOT NULL' in str(e) or 'integrity' in str(e).lower()
except IntegrityError:
db.session.rollback()
# Missing user_agent
log_no_ua = ConsentLog(
user_id=user.id, consent_type='cgu', version='1.0',
granted=True, ip_address='192.0.2.1'
)
db.session.add(log_no_ua)
try:
db.session.commit()
raise AssertionError("Expected IntegrityError on missing user_agent")
except IntegrityError:
db.session.rollback()
finally:
db.session.rollback()
@@ -142,12 +154,12 @@ def test_consent_log_requires_ip_and_user_agent():
def test_user_has_new_b21_fields():
"""User model gained: totp_secret, totp_enabled, webauthn_credentials, ordre_pro, cabinet, stripe_customer_id, subscription_status."""
"""User model gained: totp_secret_encrypted, totp_enabled, webauthn_credentials, ordre_pro, cabinet, stripe_customer_id, subscription_status."""
with app.app_context():
db.create_all()
try:
user = _make_user()
user.totp_secret = 'JBSWY3DPEHPK3PXP'
user.totp_secret_encrypted = 'gAAAAABh-encrypted-fernet-token-placeholder'
user.totp_enabled = True
user.webauthn_credentials = [{'id': 'cred1', 'public_key': 'abc'}]
user.ordre_pro = 'barreau'
@@ -157,7 +169,7 @@ def test_user_has_new_b21_fields():
db.session.commit()
fetched = User.query.filter_by(username='alice').first()
assert fetched.totp_secret == 'JBSWY3DPEHPK3PXP'
assert fetched.totp_secret_encrypted == 'gAAAAABh-encrypted-fernet-token-placeholder'
assert fetched.totp_enabled is True
assert fetched.webauthn_credentials == [{'id': 'cred1', 'public_key': 'abc'}]
assert fetched.ordre_pro == 'barreau'
@@ -176,7 +188,7 @@ def test_user_b21_fields_default_to_safe_values():
try:
user = _make_user(username='bob', email='bob@example.com')
assert user.totp_enabled is False, "totp_enabled must default to False (no MFA bypass)"
assert user.totp_secret is None
assert user.totp_secret_encrypted is None
assert user.webauthn_credentials is None
assert user.stripe_customer_id is None
assert user.subscription_status is None
@@ -185,3 +197,54 @@ def test_user_b21_fields_default_to_safe_values():
finally:
db.session.rollback()
db.drop_all()
def test_consent_log_survives_user_deletion_with_null_user_id():
"""Loi 25 art. 28.1 right-to-erasure: deleting a User must NOT delete their
consent log rows. The user_id is set to NULL, the audit row survives.
"""
with app.app_context():
db.create_all()
try:
user = _make_user(username='claire', email='claire@example.com')
uid = user.id
log = ConsentLog(
user_id=uid, consent_type='cgu', version='2026-04-27',
granted=True, ip_address='192.0.2.1', user_agent='UA'
)
db.session.add(log)
db.session.commit()
log_id = log.id
db.session.delete(user)
db.session.commit()
# Audit row must survive
surviving = ConsentLog.query.get(log_id)
assert surviving is not None, "Consent log row must survive user deletion (Loi 25 audit trail)"
assert surviving.user_id is None, "user_id must be NULL after user deletion (data minimization)"
assert surviving.consent_type == 'cgu'
assert surviving.granted is True
finally:
db.session.rollback()
db.drop_all()
def test_consent_log_rejects_invalid_consent_type():
"""Typos like 'comfidentialite' must be rejected at ORM level."""
with app.app_context():
db.create_all()
try:
user = _make_user(username='diane', email='diane@example.com')
try:
ConsentLog(
user_id=user.id, consent_type='comfidentialite', # typo
version='1.0', granted=True,
ip_address='192.0.2.1', user_agent='UA'
)
raise AssertionError("ValueError expected on invalid consent_type")
except ValueError as e:
assert 'Invalid consent_type' in str(e)
finally:
db.session.rollback()
db.drop_all()