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>
438 lines
20 KiB
Python
438 lines
20 KiB
Python
"""Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique,
|
|
mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par
|
|
PROPRIÉTAIRE de projet — pour TOUS les rôles, y compris "admin" (qui
|
|
garde seulement le droit d'avoir plusieurs projets À LUI, voir
|
|
core/auth_guard.py). Le tout premier compte jamais créé devient
|
|
automatiquement admin (auth/create_user.py) : la fixture `client` de
|
|
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() -> Generator[FlaskClient, None, None]:
|
|
flask_app.config["TESTING"] = True
|
|
with flask_app.test_client() as c:
|
|
yield c
|
|
|
|
|
|
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: 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
|
|
créé d'office ici (voir routes/onboarding/onboarding_new.py) — la
|
|
redirection renvoie désormais vers /onboarding, pas /game/<slug> ;
|
|
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 = 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: 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)
|
|
|
|
|
|
def _cleanup_project(email: str) -> None:
|
|
import os
|
|
|
|
user = auth.get_user_by_email(email)
|
|
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: 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: 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: 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: 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: FlaskClient) -> None:
|
|
resp = _register(anon_client, "flow@example.com")
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"] == "/register/2fa"
|
|
|
|
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
|
|
# ("user_id") n'est posée qu'après confirm_totp(), jamais avant).
|
|
resp = anon_client.get("/", follow_redirects=False)
|
|
assert resp.status_code == 302 # toujours redirigé vers /login
|
|
|
|
resp, secret = _confirm_2fa(anon_client)
|
|
assert resp.status_code == 302
|
|
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
|
|
# de projet créé d'office ici — connecté mais sans project_slug tant
|
|
# qu'il n'a pas choisi un type de jeu.
|
|
assert user["project_slug"] is None
|
|
try:
|
|
assert resp.headers["Location"] == "/onboarding"
|
|
# Connecté pour de vrai maintenant, mais renvoyé vers l'onboarding
|
|
# tant qu'aucun projet n'existe (voir core/auth_guard.py).
|
|
resp2 = anon_client.get("/", follow_redirects=False)
|
|
assert resp2.status_code == 302
|
|
assert resp2.headers["Location"] == "/onboarding"
|
|
|
|
onboard_resp = _complete_onboarding(anon_client)
|
|
assert onboard_resp.status_code == 302
|
|
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: 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 = 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: FlaskClient) -> None:
|
|
_register(anon_client, "login@example.com")
|
|
resp, secret = _confirm_2fa(anon_client)
|
|
_complete_onboarding(anon_client)
|
|
slug = not_none(auth.get_user_by_email("login@example.com"))["project_slug"]
|
|
try:
|
|
anon_client.post("/logout")
|
|
|
|
resp = anon_client.post("/login", data={"email": "login@example.com", "password": "wrong"})
|
|
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
|
|
)
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"] == "/login/2fa"
|
|
|
|
code = pyotp.TOTP(secret).now()
|
|
resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"] == "/game/" + slug
|
|
finally:
|
|
_cleanup_project("login@example.com")
|
|
|
|
|
|
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 = 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.
|
|
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien")
|
|
assert resp.status_code == 403
|
|
|
|
resp = anon_client.get(f"/game/{user['project_slug']}")
|
|
assert resp.status_code == 200
|
|
|
|
# Impossible de créer un second projet : redirigé vers le sien.
|
|
resp = anon_client.post("/games/new", data={"name": "second"}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"] == "/game/" + user["project_slug"]
|
|
finally:
|
|
_cleanup_project("isolated@example.com")
|
|
# Filet de sécurité si "second" avait quand même été créé (ne
|
|
# devrait jamais arriver, voir l'assertion ci-dessus).
|
|
_cleanup_project("second")
|
|
|
|
|
|
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
|
|
créer la victime) fait planter Werkzeug ("Popped wrong app context"),
|
|
une limite connue du client de test Flask quand deux contextes de
|
|
requête s'entremêlent plutôt que de s'imbriquer proprement."""
|
|
user_id = auth.create_user(email, "Sup3r$ecret!", "Test", "Victime")
|
|
auth.confirm_totp(user_id)
|
|
slug = db.create_game(name, owner_folder=str(user_id))
|
|
auth.set_project_slug(user_id, slug)
|
|
return auth.get_user_by_id(user_id)
|
|
|
|
|
|
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
|
|
connaissant simplement son slug. `client` (conftest.py) est déjà
|
|
connecté en admin ; désormais bloqué exactement comme un "user"."""
|
|
victim = _create_isolated_user_with_project("adminvictim@example.com")
|
|
try:
|
|
assert client.get(f"/game/{victim['project_slug']}").status_code == 403
|
|
assert client.get(f"/game/{victim['project_slug']}/screens/1/edit").status_code == 403
|
|
assert client.post(f"/game/{victim['project_slug']}/delete").status_code == 403
|
|
# Le projet de la victime doit être resté intact (la tentative de
|
|
# suppression ci-dessus doit avoir été bloquée AVANT toute écriture).
|
|
assert db.game_meta(victim["project_slug"]) is not None
|
|
finally:
|
|
_cleanup_project("adminvictim@example.com")
|
|
|
|
|
|
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
|
|
compte. Corrigé : ne renvoie plus que les projets du compte connecté,
|
|
admin compris."""
|
|
victim = _create_isolated_user_with_project("adminlistvictim@example.com")
|
|
try:
|
|
resp = client.get("/")
|
|
assert resp.status_code == 200
|
|
html = resp.get_data(as_text=True)
|
|
assert game in html
|
|
assert victim["project_slug"] not in html
|
|
finally:
|
|
_cleanup_project("adminlistvictim@example.com")
|
|
|
|
|
|
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 = 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
|
|
assert db.game_meta(user["project_slug"]) is not None
|
|
finally:
|
|
_cleanup_project("nodelete@example.com")
|
|
|
|
|
|
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: 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)
|
|
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 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).
|
|
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
|
|
body = resp.get_data(as_text=True)
|
|
assert "Trop de tentatives" in body
|
|
assert "5 minute" in body
|
|
locked_user = auth.get_user_by_email("bruteforce@example.com")
|
|
assert auth.seconds_locked_remaining(locked_user) > 0
|
|
|
|
# Même avec le BON mot de passe, verrouillé = verrouillé.
|
|
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "Sup3r$ecret!"})
|
|
assert "Trop de tentatives" in resp.get_data(as_text=True)
|
|
# 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 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: FlaskClient) -> None:
|
|
_register(anon_client, "bruteforce2fa@example.com")
|
|
resp, secret = _confirm_2fa(anon_client)
|
|
try:
|
|
anon_client.post("/logout")
|
|
anon_client.post("/login", data={"email": "bruteforce2fa@example.com", "password": "Sup3r$ecret!"})
|
|
|
|
for _ in range(3):
|
|
resp = anon_client.post("/login/2fa", data={"code": "000000"})
|
|
assert "Code invalide" in resp.get_data(as_text=True)
|
|
resp = anon_client.post("/login/2fa", data={"code": "000000"})
|
|
assert "Trop de tentatives" in resp.get_data(as_text=True)
|
|
|
|
# Le bon code ne débloque rien tant que le verrouillage est actif.
|
|
code = pyotp.TOTP(secret).now()
|
|
resp = anon_client.post("/login/2fa", data={"code": code})
|
|
assert "Trop de tentatives" in resp.get_data(as_text=True)
|
|
finally:
|
|
_cleanup_project("bruteforce2fa@example.com")
|
|
|
|
|
|
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 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 = 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: FlaskClient) -> None:
|
|
_register(anon_client, "recoveryshown@example.com")
|
|
resp, secret = _confirm_2fa(anon_client)
|
|
try:
|
|
html = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
|
|
assert "recoveryCodesModal" in html
|
|
|
|
# Jamais réaffichés sur un chargement suivant (jeton flash à usage
|
|
# unique, voir core/recovery_codes_flash.py).
|
|
html_again = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
|
|
assert "recoveryCodesModal" not in html_again
|
|
finally:
|
|
_cleanup_project("recoveryshown@example.com")
|
|
|
|
|
|
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 = not_none(auth.get_user_by_email("recoverylogin@example.com"))
|
|
slug = user["project_slug"]
|
|
codes = auth.generate_recovery_codes(user["id"])
|
|
try:
|
|
anon_client.post("/logout")
|
|
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
|
|
|
|
resp = anon_client.post("/login/2fa", data={"code": codes[0]}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
assert resp.headers["Location"] == "/game/" + slug
|
|
|
|
# Un code déjà utilisé ne fonctionne plus une seconde fois.
|
|
anon_client.post("/logout")
|
|
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
|
|
resp = anon_client.post("/login/2fa", data={"code": codes[0]})
|
|
assert "Code invalide" in resp.get_data(as_text=True)
|
|
|
|
# Un autre code du même lot, lui, fonctionne toujours.
|
|
anon_client.post("/logout")
|
|
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
|
|
resp = anon_client.post("/login/2fa", data={"code": codes[1]}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
finally:
|
|
_cleanup_project("recoverylogin@example.com")
|
|
|
|
|
|
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
|
|
fonctionner sans redirection forcée."""
|
|
resp = client.post("/games/new", data={"name": "pytest_second_admin_game"}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
|
|
assert slug != game
|
|
import os
|
|
import shutil
|
|
|
|
game_dir = db.game_dir(slug)
|
|
if os.path.isdir(game_dir):
|
|
shutil.rmtree(game_dir)
|