Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+26 -13
View File
@@ -5,13 +5,18 @@ autres tests du moteur qui appellent les routes directement via le client
de test Flask. Ces tests-ci réactivent volontairement la vérification
(flask_app.config["TESTING"] = False) pour la mettre à l'épreuve pour de
vrai, puis la remettent dans l'état attendu par le reste de la suite."""
from collections.abc import Generator
from typing import Any
import pytest
from flask.testing import FlaskClient
from core.flask_app import app as flask_app
@pytest.fixture
def csrf_client():
def csrf_client() -> Generator[FlaskClient, None, None]:
"""Comme `client` (conftest.py), mais avec la vérification CSRF
RÉACTIVÉE le temps du test — connecté en tant qu'admin de test
(session_transaction), pour isoler la seule variable qui nous
@@ -28,30 +33,31 @@ def csrf_client():
flask_app.config["TESTING"] = True
def _get_session_csrf_token(client):
def _get_session_csrf_token(client: FlaskClient) -> Any:
with client.session_transaction() as sess:
return sess.get("csrf_token")
def test_get_requests_are_never_blocked_by_csrf(csrf_client):
def test_get_requests_are_never_blocked_by_csrf(csrf_client: FlaskClient) -> None:
resp = csrf_client.get("/")
assert resp.status_code == 200
def test_post_without_any_csrf_token_is_rejected(csrf_client):
def test_post_without_any_csrf_token_is_rejected(csrf_client: FlaskClient) -> None:
resp = csrf_client.post("/games/new", data={"name": "should not be created"})
assert resp.status_code == 400
def test_post_with_a_wrong_csrf_token_is_rejected(csrf_client):
def test_post_with_a_wrong_csrf_token_is_rejected(csrf_client: FlaskClient) -> None:
resp = csrf_client.post(
"/games/new", data={"name": "should not be created"},
"/games/new",
data={"name": "should not be created"},
headers={"X-CSRFToken": "not-the-right-token"},
)
assert resp.status_code == 400
def test_post_with_the_correct_csrf_token_succeeds(csrf_client):
def test_post_with_the_correct_csrf_token_succeeds(csrf_client: FlaskClient) -> None:
# Une requête GET fait générer/exposer le jeton de CETTE session (voir
# core/csrf.py::get_csrf_token, appelé par base.html au rendu).
csrf_client.get("/")
@@ -59,21 +65,25 @@ def test_post_with_the_correct_csrf_token_succeeds(csrf_client):
assert token
resp = csrf_client.post(
"/games/new", data={"name": "pytest_csrf_ok"},
headers={"X-CSRFToken": token}, follow_redirects=False,
"/games/new",
data={"name": "pytest_csrf_ok"},
headers={"X-CSRFToken": token},
follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
import db
import os
import shutil
import db
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
def test_post_with_the_token_in_the_form_field_also_works(csrf_client):
def test_post_with_the_token_in_the_form_field_also_works(csrf_client: FlaskClient) -> None:
"""Filet de sécurité pour un éventuel formulaire natif qui ne passerait
pas par static/csrf_fetch.js (aucun cas de ce genre actuellement dans
le moteur, voir core/csrf_guard.py) — le champ de formulaire
@@ -82,15 +92,18 @@ def test_post_with_the_token_in_the_form_field_also_works(csrf_client):
token = _get_session_csrf_token(csrf_client)
resp = csrf_client.post(
"/games/new", data={"name": "pytest_csrf_form_field", "csrf_token": token},
"/games/new",
data={"name": "pytest_csrf_form_field", "csrf_token": token},
follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
import db
import os
import shutil
import db
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)