Le document de cadrage produit cible des formateurs non techniques créant des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de logique en nœuds, timeline d'animation, définitions d'objets/relations, templates réutilisables) est une complexité hors cible que l'effort d'ingénierie récent avait déjà abandonnée au profit du jeu_2d. - Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only) - Suppression en bloc des modules exclusifs au document : routes/elements, routes/element_types, routes/objects, routes/legacy_actions, screens/elements, screens/element_types, screens/widgets, screens/legacy_actions, le rendu render_element_html.py et son cluster, templates/screen_edit.html, templates/game_dashboard.html, flow-editor.js/tabs-and-blocks.js/animation-timeline.js - Dashboard toujours simplifié (un seul mode possible désormais) - Tests document-only supprimés, tests de logique partagée (flow, événements personnalisés, animations) retargetés sur des écrans jeu_2d - Aucune régression jeu_2d : 299 tests passent Carte d'onboarding retravaillée : argumentaire RH non technique (liste à coche, badge "Compatible LMS"), taille et interaction de retournement ajustées.
359 lines
16 KiB
Python
359 lines
16 KiB
Python
"""Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique,
|
|
mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par
|
|
utilisateur d'un SEUL projet (sauf le rôle "admin", illimité — 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
|
|
|
|
import pyotp
|
|
import pytest
|
|
|
|
import auth
|
|
import db
|
|
from core.flask_app import app as flask_app
|
|
|
|
|
|
@pytest.fixture
|
|
def anon_client():
|
|
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 _confirm_2fa(client):
|
|
"""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 = 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"):
|
|
"""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):
|
|
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"])
|
|
|
|
|
|
def test_anonymous_request_redirects_to_login(anon_client):
|
|
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",
|
|
})
|
|
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",
|
|
})
|
|
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):
|
|
_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):
|
|
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")
|
|
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 = 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 = 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):
|
|
_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")
|
|
assert user["totp_confirmed"] == 0
|
|
|
|
|
|
def test_login_requires_correct_password_then_totp(anon_client):
|
|
_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"]
|
|
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):
|
|
_register(anon_client, "isolated@example.com")
|
|
_confirm_2fa(anon_client)
|
|
_complete_onboarding(anon_client)
|
|
user = 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 test_non_admin_user_cannot_delete_their_only_project(anon_client):
|
|
"""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")
|
|
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():
|
|
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):
|
|
"""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 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 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):
|
|
_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!"})
|
|
|
|
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):
|
|
_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
|
|
|
|
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")
|
|
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):
|
|
_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):
|
|
_register(anon_client, "recoverylogin@example.com")
|
|
_confirm_2fa(anon_client)
|
|
_complete_onboarding(anon_client)
|
|
user = 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, game):
|
|
"""`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 shutil
|
|
import os
|
|
game_dir = db.game_dir(slug)
|
|
if os.path.isdir(game_dir):
|
|
shutil.rmtree(game_dir)
|