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
+37 -12
View File
@@ -3,12 +3,19 @@ screens/labels/animal_sprite_library.py) — réservée au rôle admin
(licence CraftPix, voir core/sprite_gate.py et le plan "Bibliothèque de
sprites animaux CraftPix"). Un compte "user" ne doit ni la voir dans la
galerie de l'éditeur, ni pouvoir l'assigner par un POST direct."""
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def test_admin_sprite_library_loaded_from_manifest():
def test_admin_sprite_library_loaded_from_manifest() -> None:
assert len(screens.ADMIN_SPRITE_LIBRARY) > 0
bunny = screens.ADMIN_SPRITE_LIBRARY["animal-bunny-01"]
assert bunny["family"] == "bunny"
@@ -16,13 +23,13 @@ def test_admin_sprite_library_loaded_from_manifest():
assert len(bunny["animations"]["idle"]) == 20
def test_sprite_library_is_public_plus_admin_merged():
def test_sprite_library_is_public_plus_admin_merged() -> None:
assert set(screens.SPRITE_LIBRARY) == set(screens.PUBLIC_SPRITE_LIBRARY) | set(screens.ADMIN_SPRITE_LIBRARY)
assert screens.ADMIN_ONLY_CHARACTER_SLUGS == set(screens.ADMIN_SPRITE_LIBRARY)
assert set(screens.ADMIN_SPRITE_LIBRARY) == screens.ADMIN_ONLY_CHARACTER_SLUGS
assert "male-adventurer" not in screens.ADMIN_ONLY_CHARACTER_SLUGS
def test_sprite_gallery_families_shows_one_tile_per_animal():
def test_sprite_gallery_families_shows_one_tile_per_animal() -> None:
families = screens.sprite_gallery_families(screens.SPRITE_LIBRARY)
slugs = [slug for slug, _ in families]
assert len(slugs) == len(screens.PUBLIC_SPRITE_LIBRARY) + 14 # 6 Kenney + 14 familles animales
@@ -30,7 +37,7 @@ def test_sprite_gallery_families_shows_one_tile_per_animal():
assert "animal-bunny-02" not in slugs
def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
def _create_jeu2d_game_as(post_client: FlaskClient, name: str, slug_owner_client: FlaskClient | None = None) -> Any:
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
# jeu n'a plus de type propre — seul l'écran créé ensuite par chaque
# test (screens.create_screen(..., kind="jeu_2d")) est une scène.
@@ -39,20 +46,26 @@ def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
return resp.headers["Location"].rstrip("/").split("/")[-1]
def test_scene_editor_hides_animal_characters_from_a_user_account(client, user_client, tmp_game_slug_cleanup):
def test_scene_editor_hides_animal_characters_from_a_user_account(
client: FlaskClient, user_client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_user"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
html = user_client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" not in html
def test_scene_object_add_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
def test_scene_object_add_rejects_animal_character_for_a_user_account(
client: FlaskClient, user_client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_gate"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
resp = user_client.post(
@@ -62,7 +75,9 @@ def test_scene_object_add_rejects_animal_character_for_a_user_account(client, us
assert resp.status_code == 403
def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slug_cleanup):
def test_scene_object_add_allows_animal_character_for_admin(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_admin"))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
resp = client.post(
@@ -72,14 +87,17 @@ def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slu
)
assert resp.status_code == 302
obj_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_personnage_data(obj)["forge_character"] == "animal-bunny-01"
def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_account(
client: FlaskClient, user_client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_swap_gate"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
obj_id = screens.add_scene_object(slug, screen_id)
@@ -91,7 +109,9 @@ def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_accoun
assert resp.status_code == 403
def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp_game_slug_cleanup):
def test_list_used_forge_characters_scans_elements_and_scene_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_used_characters"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
@@ -100,16 +120,20 @@ def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp
assert used == {"animal-bunny-01"}
def test_build_scorm_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path):
def test_build_scorm_package_only_copies_used_animal_character_folders(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], tmp_path: Path
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_scorm_animals"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
with client.application.test_request_context():
from publish import build_scorm_package
zip_path, _name = build_scorm_package.build_scorm_zip(slug)
try:
import zipfile
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
animal_paths = {n for n in names if "static/characters/animals/" in n and not n.endswith("manifest.json")}
@@ -121,4 +145,5 @@ def test_build_scorm_package_only_copies_used_animal_character_folders(client, t
assert any("static/characters/male-adventurer/" in n for n in names)
finally:
import os
os.remove(zip_path)