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
+66 -28
View File
@@ -11,19 +11,24 @@ déclencher ne fait que signaler, jamais choisir un élément — c'est à
l'ÉCOUTEUR (déclencheur → condition → action) de décider quoi faire
ensuite, avec ses propres réglages habituels (cible fixe, "Ligne
cliquée"...)."""
import json
import re
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
def _add_flow_node(client: FlaskClient, slug: str, screen_id: int, **fields: Any) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
@@ -33,11 +38,16 @@ def _add_flow_node(client, slug, screen_id, **fields):
return resp.get_json()["id"]
def test_create_event_is_idempotent_by_name(client, game):
def test_create_event_is_idempotent_by_name(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/events/new", data={
"name": "NEED_ACTION", "description": "Besoin d'action", "screen_id": screen_id,
})
resp = client.post(
f"/game/{game}/events/new",
data={
"name": "NEED_ACTION",
"description": "Besoin d'action",
"screen_id": screen_id,
},
)
assert resp.status_code == 302
events = db.list_custom_events(game)
assert len(events) == 1
@@ -48,27 +58,37 @@ def test_create_event_is_idempotent_by_name(client, game):
assert len(db.list_custom_events(game)) == 1
def test_edit_event_updates_description(client, game):
def test_edit_event_updates_description(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
event_id = db.create_custom_event(game, "NEED_ACTION", "old")
resp = client.post(f"/game/{game}/events/{event_id}/edit", data={
"description": "new", "screen_id": screen_id,
})
resp = client.post(
f"/game/{game}/events/{event_id}/edit",
data={
"description": "new",
"screen_id": screen_id,
},
)
assert resp.status_code == 302
event = db.get_custom_event(game, event_id)
event = not_none(db.get_custom_event(game, event_id))
assert event["description"] == "new"
def test_usages_reports_listener_and_dispatcher_across_different_screens(client, game):
event_id = db.create_custom_event(game, "NEED_ACTION")
def test_usages_reports_listener_and_dispatcher_across_different_screens(client: FlaskClient, game: str) -> None:
event_id = not_none(db.create_custom_event(game, "NEED_ACTION"))
listener_screen = _create_screen(client, game, "Écran écouteur")
_add_flow_node(client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id)
_add_flow_node(
client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id
)
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
_add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
client,
game,
dispatcher_screen,
node_type="action",
action_type="declencher_evenement",
target_custom_event_id=event_id,
)
usages = screens.list_custom_event_usages(game)
@@ -78,24 +98,34 @@ def test_usages_reports_listener_and_dispatcher_across_different_screens(client,
assert screen_ids == {listener_screen, dispatcher_screen}
def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client, game):
def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client: FlaskClient, game: str) -> None:
event_id = db.create_custom_event(game, "NEED_ACTION")
listener_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node(
client, game, listener_screen,
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
client,
game,
listener_screen,
node_type="trigger",
trigger_event="evenement",
trigger_custom_event_id=event_id,
)
# Une AUTRE scène — la cascade doit atteindre TOUS les écrans, pas
# seulement celui qui a servi à déclencher la suppression.
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
dispatcher_node_id = _add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
client,
game,
dispatcher_screen,
node_type="action",
action_type="declencher_evenement",
target_custom_event_id=event_id,
)
resp = client.post(f"/game/{game}/events/{event_id}/delete", data={"screen_id": listener_screen}, follow_redirects=False)
resp = client.post(
f"/game/{game}/events/{event_id}/delete", data={"screen_id": listener_screen}, follow_redirects=False
)
assert resp.status_code == 302
assert db.get_custom_event(game, event_id) is None
@@ -106,7 +136,7 @@ def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client,
assert screens.get_screen(game, dispatcher_screen) is not None
def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game):
def test_runtime_payload_exposes_both_nodes_for_the_client_js(client: FlaskClient, game: str) -> None:
"""templates/play.html::dispatchGameEvent()/findTriggerNode() scannent
gameData.flows (toutes les scènes ET tous les modèles à la fois) pour
retrouver un nœud par trigger_custom_event_id/target_custom_event_id —
@@ -118,14 +148,22 @@ def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game):
listener_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node(
client, game, listener_screen,
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
client,
game,
listener_screen,
node_type="trigger",
trigger_event="evenement",
trigger_custom_event_id=event_id,
)
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
dispatcher_node_id = _add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
client,
game,
dispatcher_screen,
node_type="action",
action_type="declencher_evenement",
target_custom_event_id=event_id,
)
payload = screens.full_game_payload(game)