Audit conducted 2026-04-27 against signed PDFs in DOCS_DictIA/. All 6 legal
markdown files + 3 marketing templates aligned on the contractual ground truth
(documents signed by Allison Rioux + Jean-David Lévesque-Rioux 9 mars 2026).
CRITICAL DISCREPANCIES FIXED (D1-D9 — Loi 25 / contractual)
D1. Entity identity: removed false "filiale d'InnovA AI S.E.N.C." claim.
Canonical (PDC §1.1, CGU §1, RPRP doc): DictIA Inc. is a standalone SPA
constituted 22 mars 2026 (LSAQ), 50/50 owned by Allison Rioux + Jean-David
Lévesque-Rioux. NOT a subsidiary of InnovA AI.
D2. NEQ: replaced placeholder with canonical NEQ 1181949562 (DictIA Inc.).
D3. Sub-processors list: PDC §6.2 declares 5 sub-processors. Site listed only
OVH, Stripe, Resend (the latter two not in canonical). Now aligned:
OVH Beauharnois QC + GCP Toronto ON (RAM-only, 5min) + Cloudflare US (CDN)
+ HubSpot US (CRM) + Stripe US (paiements). Resend removed.
D4. GCP Toronto disclosure: NEW. PDC §6.2, §11.2, EFVP_GCP all declare GPU
processing on GCP Toronto Ontario as a transfer hors-Québec under art. 17
LSP. Site previously claimed "100 % au Québec" without GCP disclosure.
Now declared in confidentialite.md §6, §7 + conditions.md §2.4, §9 +
conformite.html pillar.
D5. Biometrics: NEW dedicated section. PDC §12, CGU §6, EFVP_BIOVOCAL all
require disclosure of voice biometrics (pyannote.audio embeddings) per
LCCJTI art. 44-45 + CAI declaration K1. Site had ZERO mention. Now
documented in confidentialite.md §12 + conditions.md §8.
D6. Wrong article number: landing.html cited "art. 60.1 LPRPSP" for biometric
sanctions — that article does NOT exist. Replaced with canonical citation:
"art. 44-45 LCCJTI + art. 12 LSP".
D7. Speakr fork attribution: CGU §13.1.1 explicitly requires the AGPL §13
disclosure URL to be gitea.dictia.ca (not gitea.innova-ai.ca). Mentions.md
+ conformite.html + footer normalized.
D8. Conservation periods: aligned to canonical CGU §8.1.2 + PDC §7.2.
Audio: 30 jours par défaut (extensible 12 mois opt-in) — was "indéfinie".
Biométrie inter-sessions: max 12 mois — était absent.
Facturation: 7 ans — était "6 ans".
Sauvegardes: 30 jours OVH QC.
D9. RPRP contact: confirmed canonical rprp@dictia.ca (per PDC §1.2 + RPRP
designation §1.3) — was already correct on site, kept as-is.
MEDIUM (M1-M3)
M1. Cookies categories: aligned to PDC §5.1 (5 categories: essentiels +
Cloudflare + perf + fonctionnels + HubSpot). Removed "Plausible Analytics
auto-hébergé" claim (not in any signed doc).
M2. DPA status: noted as "signed" for OVH + HubSpot (signed PDFs verified),
"in vigueur" for Stripe.
M3. Footer mentions légales link: added (was missing).
MINOR (N1-N2)
N1. Stripe entity: "Stripe Inc., San Francisco CA" (canonical PDC §2.6),
not "Stripe Payments Canada Ltd." (which doesn't appear in any signed doc).
N2. Engagement de non-entraînement IA: added to conditions.md §10 (canonical
CGU §10).
NOT MODIFIED (per scope boundaries)
- src/api/auth.py, src/billing/*.py, src/models/*.py — code not touched.
- templates/marketing/{tarifs,fonctionnalites}.html — frontend A-2.x final.
- landing.html — only minimal art. 60.1 → art. 44-45 fix (factual law error).
PENDING ALLISON REVIEW
- landing.html line 167-174 marketing claim "Vos données ne sortent jamais
de vos murs ou nos serveurs OVH Beauharnois" is technically inaccurate for
DictIA Cloud users (audio briefly transits to GCP Toronto for GPU processing,
RAM-only, 5min, zero persistence — encadré par EFVP signée). Decision
required: rephrase OR add asterisk pointing to /conformite for Cloud
architecture caveat.
- CAI form (CAI_FO_Declaration_Biometrie_DictIA_COMPLET_signé.pdf) declares
90 jours retention for inter-sessions vectors, while PDC + CGU + EFVP
all say 12 mois. Site uses 12 mois (latest, contractual). Allison should
verify CAI form needs amendment before submission.
TESTS
9/9 test_legal_pages.py passing (added biometrics + decisions automatisees
to required_topics; corrected "transfert hors-québec" → "transferts hors
québec" to match canonical PDC §11 OQLF wording).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
6.6 KiB
Python
167 lines
6.6 KiB
Python
"""Tests for the 6 legal pages blueprint (Task B-2.9).
|
|
|
|
All 6 markdown-rendered pages plus the index must:
|
|
- Return HTTP 200 with DictIA branding
|
|
- Be publicly indexable (no X-Robots-Tag noindex header — Loi 25 transparency)
|
|
- Share the same _layout.html structure (extends marketing/base.html)
|
|
- Be marked DRAFT pending legal review by Allison Rioux
|
|
- The privacy policy must satisfy the 12 mandatory Loi 25 sections
|
|
- LEGAL_VERSION constant must match SIGNUP_LEGAL_VERSION used by the signup route
|
|
"""
|
|
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')
|
|
|
|
from src.app import app, db # noqa: E402
|
|
|
|
|
|
VALID_PAGES = ('conditions', 'confidentialite', 'cookies', 'remboursement', 'accessibilite', 'mentions')
|
|
|
|
|
|
def test_legal_index_returns_200_with_all_6_pages_listed():
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
resp = client.get('/legal/')
|
|
assert resp.status_code == 200
|
|
body = resp.data.decode('utf-8')
|
|
for page in VALID_PAGES:
|
|
assert f'/legal/{page}' in body
|
|
assert 'Documents légaux' in body
|
|
finally:
|
|
db.session.rollback(); db.drop_all()
|
|
|
|
|
|
def test_each_legal_page_returns_200_with_dictia_branding():
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
for page in VALID_PAGES:
|
|
resp = client.get(f'/legal/{page}')
|
|
assert resp.status_code == 200, f'/legal/{page} returned {resp.status_code}'
|
|
body = resp.data.decode('utf-8')
|
|
assert 'DictIA' in body
|
|
assert 'rprp@dictia.ca' in body or 'info@dictia.ca' in body
|
|
finally:
|
|
db.session.rollback(); db.drop_all()
|
|
|
|
|
|
def test_unknown_legal_page_returns_404():
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
resp = client.get('/legal/unknown-page')
|
|
assert resp.status_code == 404
|
|
finally:
|
|
db.session.rollback(); db.drop_all()
|
|
|
|
|
|
def test_confidentialite_has_all_12_loi25_sections():
|
|
"""LPRPSP (Loi 25) requires 12 mandatory sections in privacy policy."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
resp = client.get('/legal/confidentialite')
|
|
assert resp.status_code == 200
|
|
body = resp.data.decode('utf-8').lower()
|
|
required_topics = [
|
|
'identité du responsable',
|
|
'rprp', # responsable de la protection
|
|
'renseignements personnels collectés',
|
|
'finalités',
|
|
'base légale', # base légale et consentement
|
|
'destinataires', # destinataires et sous-traitants
|
|
'transferts hors québec', # canonical PDC §11 wording (no hyphen, plural)
|
|
'durée de conservation',
|
|
'droits', # droits de l'utilisateur
|
|
'plainte', # procédure de plainte CAI
|
|
'cookies', # cookies et traceurs
|
|
'biométriques', # données biométriques (LCCJTI 44-45) — ajout 2026-04-27
|
|
'décisions automatisées', # ajout 2026-04-27 (PDC §10)
|
|
'date de mise à jour',
|
|
]
|
|
for topic in required_topics:
|
|
assert topic in body, f'Missing Loi 25 mandatory section: {topic!r}'
|
|
finally:
|
|
db.session.rollback(); db.drop_all()
|
|
|
|
|
|
def test_legal_pages_use_layout_template_with_shared_layout():
|
|
"""All 6 pages should share the same _layout.html structure."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
for page in VALID_PAGES:
|
|
resp = client.get(f'/legal/{page}')
|
|
body = resp.data.decode('utf-8')
|
|
assert 'Document légal DictIA' in body, f'_layout.html header missing on /legal/{page}'
|
|
assert 'Index des documents légaux' in body, f'_layout.html footer link missing on /legal/{page}'
|
|
finally:
|
|
db.session.rollback(); db.drop_all()
|
|
|
|
|
|
def test_legal_pages_publicly_indexable():
|
|
"""legal.* endpoints must NOT have X-Robots-Tag noindex header (Loi 25 transparency)."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
for page in VALID_PAGES:
|
|
resp = client.get(f'/legal/{page}')
|
|
tag = resp.headers.get('X-Robots-Tag', '')
|
|
assert 'noindex' not in tag, f'/legal/{page} has noindex header: {tag!r}'
|
|
# Also test the index
|
|
resp = client.get('/legal/')
|
|
tag = resp.headers.get('X-Robots-Tag', '')
|
|
assert 'noindex' not in tag
|
|
finally:
|
|
db.session.rollback(); db.drop_all()
|
|
|
|
|
|
def test_legal_version_constant_matches_signup():
|
|
"""LEGAL_VERSION in src/legal must equal SIGNUP_LEGAL_VERSION used by signup route."""
|
|
from src.legal import LEGAL_VERSION
|
|
from src.api.auth import SIGNUP_LEGAL_VERSION
|
|
assert LEGAL_VERSION == SIGNUP_LEGAL_VERSION, (
|
|
f'LEGAL_VERSION ({LEGAL_VERSION!r}) must match SIGNUP_LEGAL_VERSION ({SIGNUP_LEGAL_VERSION!r})'
|
|
)
|
|
|
|
|
|
def test_legal_pages_extend_marketing_base_template():
|
|
"""All 6 pages extend marketing/base.html (verify by looking for header markers)."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
for page in VALID_PAGES:
|
|
resp = client.get(f'/legal/{page}')
|
|
body = resp.data.decode('utf-8')
|
|
# marketing/base.html has the glassmorphism header at the top
|
|
assert 'class="fixed top-0' in body, f'/legal/{page} missing marketing/base.html header'
|
|
finally:
|
|
db.session.rollback(); db.drop_all()
|
|
|
|
|
|
def test_legal_pages_have_loi25_draft_callout():
|
|
"""All 6 pages should be marked DRAFT pending legal review by Allison."""
|
|
with app.app_context():
|
|
db.create_all()
|
|
try:
|
|
client = app.test_client()
|
|
for page in VALID_PAGES:
|
|
resp = client.get(f'/legal/{page}')
|
|
body = resp.data.decode('utf-8').lower()
|
|
assert 'draft' in body or 'allison rioux' in body, (
|
|
f'/legal/{page} missing draft+legal-review callout'
|
|
)
|
|
finally:
|
|
db.session.rollback(); db.drop_all()
|