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
+95
-66
@@ -8,31 +8,49 @@ conftest.py en a déjà créé un pour authentifier tous les AUTRES tests du
|
||||
moteur — ces tests-ci utilisent volontairement un client Flask NU (pas la
|
||||
fixture `client`, déjà connectée) pour exercer le parcours anonyme
|
||||
(inscription/connexion) de bout en bout."""
|
||||
|
||||
import re
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
import auth
|
||||
import db
|
||||
from core.flask_app import app as flask_app
|
||||
from tests.conftest import not_none
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anon_client():
|
||||
def anon_client() -> Generator[FlaskClient, None, None]:
|
||||
flask_app.config["TESTING"] = True
|
||||
with flask_app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!"):
|
||||
return client.post("/register", data={
|
||||
"prenom": prenom, "nom": nom, "email": email,
|
||||
"password": password, "password_confirm": password,
|
||||
}, follow_redirects=False)
|
||||
def _register(
|
||||
client: FlaskClient,
|
||||
email: str,
|
||||
prenom: str = "Alice",
|
||||
nom: str = "Test",
|
||||
password: str = "Sup3r$ecret!", # nosec B107 # noqa: S107 - mot de passe de fixture de test, jamais un vrai compte
|
||||
) -> Any:
|
||||
return client.post(
|
||||
"/register",
|
||||
data={
|
||||
"prenom": prenom,
|
||||
"nom": nom,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"password_confirm": password,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def _confirm_2fa(client):
|
||||
def _confirm_2fa(client: FlaskClient) -> Any:
|
||||
"""Récupère la clé TOTP affichée sur la page de confirmation et
|
||||
soumet le code du moment — comme un humain qui vient de scanner le
|
||||
QR code avec son application. Un compte "user" n'a plus de projet
|
||||
@@ -41,75 +59,85 @@ def _confirm_2fa(client):
|
||||
voir _complete_onboarding ci-dessous pour les tests qui ont besoin
|
||||
d'un vrai projet ensuite."""
|
||||
html = client.get("/register/2fa").get_data(as_text=True)
|
||||
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
|
||||
secret = not_none(re.search(r"<code>([A-Z0-9]+)</code>", html)).group(1)
|
||||
code = pyotp.TOTP(secret).now()
|
||||
return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret
|
||||
|
||||
|
||||
def _complete_onboarding(client, name="Mon jeu", onboarding_type="rpg"):
|
||||
def _complete_onboarding(client: FlaskClient, name: str = "Mon jeu", onboarding_type: str = "rpg") -> Any:
|
||||
"""Termine l'onboarding guidé (voir routes/onboarding/onboarding_new.py)
|
||||
— la plupart des tests de ce fichier n'ont pas besoin de tester
|
||||
l'onboarding lui-même (voir test_onboarding.py), juste d'obtenir un
|
||||
project_slug réel pour la suite ; "rpg" est le seul type restant."""
|
||||
return client.post(
|
||||
"/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False
|
||||
)
|
||||
return client.post("/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False)
|
||||
|
||||
|
||||
def _cleanup_project(email):
|
||||
def _cleanup_project(email: str) -> None:
|
||||
import os
|
||||
|
||||
user = auth.get_user_by_email(email)
|
||||
if user and user.get("project_slug"):
|
||||
if os.path.isdir(db.game_dir(user["project_slug"])):
|
||||
# db.delete_game (voir db/games/project_slug.py — structure de
|
||||
# dossiers par utilisateur) nettoie aussi le dossier
|
||||
# propriétaire s'il devient vide, contrairement à un simple
|
||||
# shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires
|
||||
# vides s'accumulent sous projects/ d'un run de tests à l'autre
|
||||
# (déjà observé : ça a fini par provoquer une collision de nom
|
||||
# dans db.move_game, un dossier vide comptant comme "occupé").
|
||||
db.delete_game(user["project_slug"])
|
||||
if user and user.get("project_slug") and os.path.isdir(db.game_dir(user["project_slug"])):
|
||||
# db.delete_game (voir db/games/project_slug.py — structure de
|
||||
# dossiers par utilisateur) nettoie aussi le dossier
|
||||
# propriétaire s'il devient vide, contrairement à un simple
|
||||
# shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires
|
||||
# vides s'accumulent sous projects/ d'un run de tests à l'autre
|
||||
# (déjà observé : ça a fini par provoquer une collision de nom
|
||||
# dans db.move_game, un dossier vide comptant comme "occupé").
|
||||
db.delete_game(user["project_slug"])
|
||||
|
||||
|
||||
def test_anonymous_request_redirects_to_login(anon_client):
|
||||
def test_anonymous_request_redirects_to_login(anon_client: FlaskClient) -> None:
|
||||
resp = anon_client.get("/", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].startswith("/login")
|
||||
|
||||
|
||||
def test_registration_requires_matching_password_confirmation(anon_client):
|
||||
resp = anon_client.post("/register", data={
|
||||
"prenom": "A", "nom": "B", "email": "mismatch@example.com",
|
||||
"password": "Sup3r$ecret!", "password_confirm": "Autre$ecret1",
|
||||
})
|
||||
def test_registration_requires_matching_password_confirmation(anon_client: FlaskClient) -> None:
|
||||
resp = anon_client.post(
|
||||
"/register",
|
||||
data={
|
||||
"prenom": "A",
|
||||
"nom": "B",
|
||||
"email": "mismatch@example.com",
|
||||
"password": "Sup3r$ecret!",
|
||||
"password_confirm": "Autre$ecret1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "ne correspondent pas" in resp.get_data(as_text=True)
|
||||
assert auth.get_user_by_email("mismatch@example.com") is None
|
||||
|
||||
|
||||
def test_registration_rejects_a_weak_password(anon_client):
|
||||
resp = anon_client.post("/register", data={
|
||||
"prenom": "A", "nom": "B", "email": "weak@example.com",
|
||||
"password": "abcdefgh", "password_confirm": "abcdefgh",
|
||||
})
|
||||
def test_registration_rejects_a_weak_password(anon_client: FlaskClient) -> None:
|
||||
resp = anon_client.post(
|
||||
"/register",
|
||||
data={
|
||||
"prenom": "A",
|
||||
"nom": "B",
|
||||
"email": "weak@example.com",
|
||||
"password": "abcdefgh",
|
||||
"password_confirm": "abcdefgh",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "faible" in resp.get_data(as_text=True)
|
||||
assert auth.get_user_by_email("weak@example.com") is None
|
||||
|
||||
|
||||
def test_registration_rejects_a_duplicate_email(anon_client):
|
||||
def test_registration_rejects_a_duplicate_email(anon_client: FlaskClient) -> None:
|
||||
_register(anon_client, "dup@example.com")
|
||||
resp = _register(anon_client, "dup@example.com")
|
||||
assert resp.status_code == 200
|
||||
assert "existe déjà" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_full_registration_flow_requires_totp_before_account_is_usable(anon_client):
|
||||
def test_full_registration_flow_requires_totp_before_account_is_usable(anon_client: FlaskClient) -> None:
|
||||
resp = _register(anon_client, "flow@example.com")
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == "/register/2fa"
|
||||
|
||||
user = auth.get_user_by_email("flow@example.com")
|
||||
user = not_none(auth.get_user_by_email("flow@example.com"))
|
||||
assert user["totp_confirmed"] == 0
|
||||
# Compte pas encore confirmé -> pas encore une vraie session connectée,
|
||||
# même après l'étape de mot de passe (voir register.py : la connexion
|
||||
@@ -119,7 +147,7 @@ def test_full_registration_flow_requires_totp_before_account_is_usable(anon_clie
|
||||
|
||||
resp, secret = _confirm_2fa(anon_client)
|
||||
assert resp.status_code == 302
|
||||
user = auth.get_user_by_email("flow@example.com")
|
||||
user = not_none(auth.get_user_by_email("flow@example.com"))
|
||||
assert user["totp_confirmed"] == 1
|
||||
assert user["role"] == "user" # jamais le premier compte de la session de tests
|
||||
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) : plus
|
||||
@@ -136,27 +164,27 @@ def test_full_registration_flow_requires_totp_before_account_is_usable(anon_clie
|
||||
|
||||
onboard_resp = _complete_onboarding(anon_client)
|
||||
assert onboard_resp.status_code == 302
|
||||
user = auth.get_user_by_email("flow@example.com")
|
||||
user = not_none(auth.get_user_by_email("flow@example.com"))
|
||||
assert user["project_slug"] is not None
|
||||
assert onboard_resp.headers["Location"] == "/game/" + user["project_slug"]
|
||||
finally:
|
||||
_cleanup_project("flow@example.com")
|
||||
|
||||
|
||||
def test_wrong_totp_code_is_rejected(anon_client):
|
||||
def test_wrong_totp_code_is_rejected(anon_client: FlaskClient) -> None:
|
||||
_register(anon_client, "badcode@example.com")
|
||||
resp = anon_client.post("/register/2fa", data={"code": "000000"})
|
||||
assert resp.status_code == 200
|
||||
assert "invalide" in resp.get_data(as_text=True)
|
||||
user = auth.get_user_by_email("badcode@example.com")
|
||||
user = not_none(auth.get_user_by_email("badcode@example.com"))
|
||||
assert user["totp_confirmed"] == 0
|
||||
|
||||
|
||||
def test_login_requires_correct_password_then_totp(anon_client):
|
||||
def test_login_requires_correct_password_then_totp(anon_client: FlaskClient) -> None:
|
||||
_register(anon_client, "login@example.com")
|
||||
resp, secret = _confirm_2fa(anon_client)
|
||||
_complete_onboarding(anon_client)
|
||||
slug = auth.get_user_by_email("login@example.com")["project_slug"]
|
||||
slug = not_none(auth.get_user_by_email("login@example.com"))["project_slug"]
|
||||
try:
|
||||
anon_client.post("/logout")
|
||||
|
||||
@@ -164,7 +192,9 @@ def test_login_requires_correct_password_then_totp(anon_client):
|
||||
assert resp.status_code == 200
|
||||
assert "incorrect" in resp.get_data(as_text=True)
|
||||
|
||||
resp = anon_client.post("/login", data={"email": "login@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False)
|
||||
resp = anon_client.post(
|
||||
"/login", data={"email": "login@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == "/login/2fa"
|
||||
|
||||
@@ -176,11 +206,11 @@ def test_login_requires_correct_password_then_totp(anon_client):
|
||||
_cleanup_project("login@example.com")
|
||||
|
||||
|
||||
def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
|
||||
def test_non_admin_user_is_isolated_to_their_own_project(anon_client: FlaskClient) -> None:
|
||||
_register(anon_client, "isolated@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
_complete_onboarding(anon_client)
|
||||
user = auth.get_user_by_email("isolated@example.com")
|
||||
user = not_none(auth.get_user_by_email("isolated@example.com"))
|
||||
try:
|
||||
# Un autre projet (celui de l'admin partagé par conftest.py, ou
|
||||
# n'importe quel autre slug) doit rester inaccessible.
|
||||
@@ -201,7 +231,7 @@ def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
|
||||
_cleanup_project("second")
|
||||
|
||||
|
||||
def _create_isolated_user_with_project(email, name="Jeu de la victime"):
|
||||
def _create_isolated_user_with_project(email: str, name: str = "Jeu de la victime") -> Any:
|
||||
"""Compte + projet créés directement (sans passer par le client HTTP
|
||||
de test) — combiner ici deux instances de `test_client()` actives en
|
||||
parallèle (la fixture `client`, admin partagé, ET une seconde pour
|
||||
@@ -215,7 +245,7 @@ def _create_isolated_user_with_project(email, name="Jeu de la victime"):
|
||||
return auth.get_user_by_id(user_id)
|
||||
|
||||
|
||||
def test_admin_cannot_access_another_users_project(client):
|
||||
def test_admin_cannot_access_another_users_project(client: FlaskClient) -> None:
|
||||
"""Faille corrigée : le rôle "admin" contournait entièrement
|
||||
l'isolation par projet (voir core/auth_guard.py) — un admin pouvait
|
||||
ouvrir/modifier/supprimer le jeu de N'IMPORTE QUEL autre compte en
|
||||
@@ -233,7 +263,7 @@ def test_admin_cannot_access_another_users_project(client):
|
||||
_cleanup_project("adminvictim@example.com")
|
||||
|
||||
|
||||
def test_admin_home_page_only_lists_their_own_projects(client, game):
|
||||
def test_admin_home_page_only_lists_their_own_projects(client: FlaskClient, game: str) -> None:
|
||||
"""routes/games/index.py (page "Mes jeux") scannait auparavant TOUT
|
||||
projects/ sans filtrage (db.list_games()) — n'importe quel admin
|
||||
voyait donc le slug (et le nom de dossier propriétaire) de chaque
|
||||
@@ -250,14 +280,14 @@ def test_admin_home_page_only_lists_their_own_projects(client, game):
|
||||
_cleanup_project("adminlistvictim@example.com")
|
||||
|
||||
|
||||
def test_non_admin_user_cannot_delete_their_only_project(anon_client):
|
||||
def test_non_admin_user_cannot_delete_their_only_project(anon_client: FlaskClient) -> None:
|
||||
"""Sans issue de secours (games_new renvoie toujours vers son
|
||||
project_slug, existant ou non), le supprimer serait un piège sans
|
||||
retour — bloqué plutôt que risqué."""
|
||||
_register(anon_client, "nodelete@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
_complete_onboarding(anon_client)
|
||||
user = auth.get_user_by_email("nodelete@example.com")
|
||||
user = not_none(auth.get_user_by_email("nodelete@example.com"))
|
||||
try:
|
||||
resp = anon_client.post(f"/game/{user['project_slug']}/delete")
|
||||
assert resp.status_code == 403
|
||||
@@ -266,23 +296,22 @@ def test_non_admin_user_cannot_delete_their_only_project(anon_client):
|
||||
_cleanup_project("nodelete@example.com")
|
||||
|
||||
|
||||
def test_lockout_escalation_is_5_10_20_40_capped_at_60():
|
||||
def test_lockout_escalation_is_5_10_20_40_capped_at_60() -> None:
|
||||
assert [auth.lockout_minutes_for(n) for n in range(9)] == [0, 0, 0, 0, 5, 10, 20, 40, 60]
|
||||
|
||||
|
||||
def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client):
|
||||
def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client: FlaskClient) -> None:
|
||||
"""3 tentatives libres, puis un verrouillage qui double à chaque
|
||||
nouvel échec (5, 10, 20 min...) — voir auth/rate_limit.py."""
|
||||
_register(anon_client, "bruteforce@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
user = auth.get_user_by_email("bruteforce@example.com")
|
||||
try:
|
||||
anon_client.post("/logout")
|
||||
|
||||
for _ in range(3):
|
||||
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
|
||||
assert "incorrect" in resp.get_data(as_text=True)
|
||||
assert auth.get_user_by_email("bruteforce@example.com")["failed_attempts"] == 3
|
||||
assert not_none(auth.get_user_by_email("bruteforce@example.com"))["failed_attempts"] == 3
|
||||
assert auth.seconds_locked_remaining(auth.get_user_by_email("bruteforce@example.com")) == 0
|
||||
|
||||
# 4e échec -> premier verrouillage (5 minutes).
|
||||
@@ -299,15 +328,14 @@ def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client):
|
||||
# Un essai bloqué par le verrouillage n'incrémente pas davantage
|
||||
# le compteur (pas d'escalade supplémentaire tant qu'on est
|
||||
# encore dans la fenêtre déjà posée).
|
||||
assert auth.get_user_by_email("bruteforce@example.com")["failed_attempts"] == 4
|
||||
assert not_none(auth.get_user_by_email("bruteforce@example.com"))["failed_attempts"] == 4
|
||||
finally:
|
||||
_cleanup_project("bruteforce@example.com")
|
||||
|
||||
|
||||
def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client):
|
||||
def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client: FlaskClient) -> None:
|
||||
_register(anon_client, "bruteforce2fa@example.com")
|
||||
resp, secret = _confirm_2fa(anon_client)
|
||||
user = auth.get_user_by_email("bruteforce2fa@example.com")
|
||||
try:
|
||||
anon_client.post("/logout")
|
||||
anon_client.post("/login", data={"email": "bruteforce2fa@example.com", "password": "Sup3r$ecret!"})
|
||||
@@ -326,28 +354,28 @@ def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client):
|
||||
_cleanup_project("bruteforce2fa@example.com")
|
||||
|
||||
|
||||
def test_successful_login_resets_the_failed_attempts_counter(anon_client):
|
||||
def test_successful_login_resets_the_failed_attempts_counter(anon_client: FlaskClient) -> None:
|
||||
_register(anon_client, "resetcounter@example.com")
|
||||
resp, secret = _confirm_2fa(anon_client)
|
||||
try:
|
||||
anon_client.post("/logout")
|
||||
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
|
||||
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
|
||||
assert auth.get_user_by_email("resetcounter@example.com")["failed_attempts"] == 2
|
||||
assert not_none(auth.get_user_by_email("resetcounter@example.com"))["failed_attempts"] == 2
|
||||
|
||||
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "Sup3r$ecret!"})
|
||||
code = pyotp.TOTP(secret).now()
|
||||
resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
|
||||
user = auth.get_user_by_email("resetcounter@example.com")
|
||||
user = not_none(auth.get_user_by_email("resetcounter@example.com"))
|
||||
assert user["failed_attempts"] == 0
|
||||
assert user["locked_until"] is None
|
||||
finally:
|
||||
_cleanup_project("resetcounter@example.com")
|
||||
|
||||
|
||||
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
|
||||
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client: FlaskClient) -> None:
|
||||
_register(anon_client, "recoveryshown@example.com")
|
||||
resp, secret = _confirm_2fa(anon_client)
|
||||
try:
|
||||
@@ -362,11 +390,11 @@ def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
|
||||
_cleanup_project("recoveryshown@example.com")
|
||||
|
||||
|
||||
def test_login_with_a_recovery_code_instead_of_totp(anon_client):
|
||||
def test_login_with_a_recovery_code_instead_of_totp(anon_client: FlaskClient) -> None:
|
||||
_register(anon_client, "recoverylogin@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
_complete_onboarding(anon_client)
|
||||
user = auth.get_user_by_email("recoverylogin@example.com")
|
||||
user = not_none(auth.get_user_by_email("recoverylogin@example.com"))
|
||||
slug = user["project_slug"]
|
||||
codes = auth.generate_recovery_codes(user["id"])
|
||||
try:
|
||||
@@ -392,7 +420,7 @@ def test_login_with_a_recovery_code_instead_of_totp(anon_client):
|
||||
_cleanup_project("recoverylogin@example.com")
|
||||
|
||||
|
||||
def test_admin_role_is_not_restricted_to_a_single_project(client, game):
|
||||
def test_admin_role_is_not_restricted_to_a_single_project(client: FlaskClient, game: str) -> None:
|
||||
"""`client`/`game` (conftest.py) sont déjà connectés en tant qu'admin
|
||||
de la session de tests — la création d'un second jeu, déjà exercée par
|
||||
des dizaines d'autres tests via la fixture `game`, doit continuer de
|
||||
@@ -401,8 +429,9 @@ def test_admin_role_is_not_restricted_to_a_single_project(client, game):
|
||||
assert resp.status_code == 302
|
||||
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
|
||||
assert slug != game
|
||||
import shutil
|
||||
import os
|
||||
import shutil
|
||||
|
||||
game_dir = db.game_dir(slug)
|
||||
if os.path.isdir(game_dir):
|
||||
shutil.rmtree(game_dir)
|
||||
|
||||
Reference in New Issue
Block a user