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:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
+65
-23
@@ -7,10 +7,15 @@ simulé. Chaque jeu de test est créé avec un nom préfixé "pytest_" et son
|
||||
dossier est supprimé automatiquement à la fin du test (fixture `game`),
|
||||
pour ne jamais laisser de données de test dans projects/.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable, Generator
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
@@ -29,17 +34,20 @@ os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
|
||||
# vrai user_assets/ du poste pendant les tests.
|
||||
os.environ["FORGE_USER_ASSETS_DIR"] = os.path.join(_TEST_AUTH_DIR, "user_assets")
|
||||
|
||||
import pytest
|
||||
import pytest # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
|
||||
|
||||
from core.flask_app import app as flask_app
|
||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (colname, truncatesentences)
|
||||
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
|
||||
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
|
||||
from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
|
||||
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py)
|
||||
from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
|
||||
import auth
|
||||
import db
|
||||
import auth # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
|
||||
import db # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
|
||||
import routes # noqa: F401, E402 - enregistre toutes les routes sur flask_app
|
||||
from core import ( # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
|
||||
auth_guard, # noqa: F401 - enregistre la garde de connexion (après les routes)
|
||||
csrf, # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
|
||||
csrf_guard, # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py)
|
||||
db_teardown_guard, # noqa: F401 - enregistre la fermeture des connexions SQLite fuitées
|
||||
jinja_filters, # noqa: F401 - enregistre les filtres Jinja (colname, truncatesentences)
|
||||
recovery_codes_flash, # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
|
||||
)
|
||||
from core.flask_app import app as flask_app # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
|
||||
|
||||
# Onglet "IA" (voir ai/) : jamais un vrai appel réseau depuis les tests,
|
||||
# même si un .env réel existe sur ce poste avec de vraies clés —
|
||||
@@ -61,15 +69,26 @@ for _ai_env_key in ("ANTHROPIC_API_KEY", "SCENARIO_API_KEY", "SCENARIO_API_SECRE
|
||||
_TEST_ADMIN_ID = auth.create_user("admin@pytest.local", "Test1234!", "Test", "Admin")
|
||||
auth.confirm_totp(_TEST_ADMIN_ID)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def not_none(value: _T | None) -> _T:
|
||||
"""Pour les endroits où Mypy voit un `X | None` (ex. db.get_xxx) mais où
|
||||
le test sait, par construction, que la valeur existe forcément (vient
|
||||
d'être créée/insérée juste au-dessus) — lève AssertionError (message
|
||||
clair) plutôt qu'un TypeError cru si jamais cette hypothèse était fausse."""
|
||||
assert value is not None
|
||||
return value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_game_slug_cleanup():
|
||||
def tmp_game_slug_cleanup() -> Generator[Callable[[str], str], None, None]:
|
||||
"""Pour les tests qui appellent db.create_game(...) directement (sans
|
||||
passer par une route Flask) : enregistre un slug à nettoyer à la fin du
|
||||
test, quel que soit son résultat."""
|
||||
slugs = []
|
||||
|
||||
def register(slug):
|
||||
def register(slug: str) -> str:
|
||||
slugs.append(slug)
|
||||
return slug
|
||||
|
||||
@@ -80,8 +99,26 @@ def tmp_game_slug_cleanup():
|
||||
shutil.rmtree(game_dir)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cleanup_admin_assets() -> Generator[None, None, None]:
|
||||
"""Filet de sécurité : le compte admin (`_TEST_ADMIN_ID`) est PARTAGÉ
|
||||
par toute la session de tests (voir plus haut) — un test qui crée un
|
||||
"asset" dessus (upload, outil IA...) sans le nettoyer lui-même pollue
|
||||
tous les tests suivants. Complète (ne remplace pas) les nettoyages
|
||||
ponctuels déjà faits dans le corps de certains tests : ceux-ci restent
|
||||
nécessaires quand le test vérifie un invariant sur la liste des assets
|
||||
AVANT sa propre fin (ce filet ne rattrape qu'après coup). `_TEST_ADMIN_ID`
|
||||
est fixe pour toute la session (un seul admin existe, voir plus haut) —
|
||||
pas besoin de le résoudre dynamiquement par test."""
|
||||
before = {asset["id"] for asset in auth.list_user_assets(_TEST_ADMIN_ID)}
|
||||
yield
|
||||
for asset in auth.list_user_assets(_TEST_ADMIN_ID):
|
||||
if asset["id"] not in before:
|
||||
auth.delete_user_asset(asset["id"], _TEST_ADMIN_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
def client() -> Generator[FlaskClient, None, None]:
|
||||
flask_app.config["TESTING"] = True
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
@@ -93,7 +130,7 @@ _TEST_USER_COUNTER = [0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client():
|
||||
def user_client() -> Generator[FlaskClient, None, None]:
|
||||
"""Un compte "user" (jamais le premier de la session de tests, donc
|
||||
jamais admin — voir auth/create_user.py), connecté mais SANS projet
|
||||
assigné pour l'instant (voir la fixture `user_game` ci-dessous) —
|
||||
@@ -112,7 +149,7 @@ def user_client():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_game(user_client):
|
||||
def user_game(user_client: FlaskClient) -> Generator[str, None, None]:
|
||||
"""Un jeu appartenant réellement au compte `user_client` — le dossier
|
||||
PROPRIÉTAIRE (voir db/games/project_slug.py) doit correspondre à
|
||||
l'id de CE compte (core/auth_guard.py compare désormais les deux) :
|
||||
@@ -131,7 +168,7 @@ def user_game(user_client):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game(client):
|
||||
def game(client: FlaskClient) -> Generator[str, None, None]:
|
||||
"""Crée un jeu de test frais et le supprime après le test, quel que
|
||||
soit son résultat (succès ou échec)."""
|
||||
resp = client.post("/games/new", data={"name": "pytest_test_game"}, follow_redirects=False)
|
||||
@@ -147,7 +184,7 @@ _PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play")
|
||||
_SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit")
|
||||
|
||||
|
||||
def _js_bundle(directory):
|
||||
def _js_bundle(directory: str) -> str:
|
||||
parts = []
|
||||
for name in sorted(os.listdir(directory)):
|
||||
path = os.path.join(directory, name)
|
||||
@@ -157,7 +194,7 @@ def _js_bundle(directory):
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def play_js_bundle():
|
||||
def play_js_bundle() -> str:
|
||||
"""Concatène tout le JS du moteur jouable (voir le plan de
|
||||
modularisation — static/js/play/, un fichier par responsabilité,
|
||||
extrait de l'ancien <script> unique de templates/play.html). Plusieurs
|
||||
@@ -168,16 +205,21 @@ def play_js_bundle():
|
||||
return _js_bundle(_PLAY_JS_DIR)
|
||||
|
||||
|
||||
def screen_edit_js_bundle():
|
||||
def screen_edit_js_bundle() -> str:
|
||||
"""Même principe que play_js_bundle(), pour l'éditeur (static/js/
|
||||
screen_edit/, extrait de templates/screen_edit.html)."""
|
||||
return _js_bundle(_SCREEN_EDIT_JS_DIR)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mail_object(client, game):
|
||||
def mail_object(client: FlaskClient, game: str) -> dict[str, Any]:
|
||||
"""Un objet "Mail" avec deux champs texte (sujet, expediteur)."""
|
||||
definition_id = db.create_definition(game, "Mail", [
|
||||
{"name": "sujet", "type": "texte"}, {"name": "expediteur", "type": "texte"},
|
||||
])
|
||||
definition_id = db.create_definition(
|
||||
game,
|
||||
"Mail",
|
||||
[
|
||||
{"name": "sujet", "type": "texte"},
|
||||
{"name": "expediteur", "type": "texte"},
|
||||
],
|
||||
)
|
||||
return {"slug": game, "definition_id": definition_id}
|
||||
|
||||
Reference in New Issue
Block a user