Sanitize les attributs en LECTURE aussi, pas seulement a l'ecriture
Build and deploy / test-python (push) Successful in 11m5s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / lint-python (push) Successful in 6m29s
Build and deploy / lint-js (push) Failing after 1m39s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m18s
Build and deploy / test-python (push) Successful in 11m5s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / lint-python (push) Successful in 6m29s
Build and deploy / lint-js (push) Failing after 1m39s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m18s
Bug reel constate le 21/09/2026 : un element Scenario cree avant la refonte en arbre de decision (ancien schema plat "situation"/"choices"/ "correct_index") faisait planter silencieusement le panneau Proprietes cote client des la selection - scenario.nodes etait inexistant sur l'ancienne forme, aucune erreur visible, juste "il ne se passe rien". Cause : document_edit.py et document_render.py renvoyaient les attributs BRUTS de la base au client, jamais revalides - contrairement a la route d'ecriture qui, elle, sanitize deja avant de persister. Ajoute document_engine.sanitize_element_attributes(kind, attributes), point d'entree unique de dispatch kind -> sanitize_X_config, utilise desormais a la fois en ecriture (document_element_update.py, qui reutilise ce nouveau dispatch au lieu de son if/elif duplique) ET en lecture (document_edit.py/document_render.py). Elimine toute la classe de bug "schema devenu obsolete apres une evolution du modele de donnees d'un mini-jeu, donnee jamais retouchee depuis" - present et futur, pas seulement pour Scenario. Migre les donnees reelles deja affectees (support de test, element 46) vers le nouveau schema en arbre, en preservant integralement le contenu deja redige par l'utilisateur (situation + 3 choix/consequences du scenario "chat sur la route"). Ajoute un test de non-regression qui ecrit delibirement l'ancien schema en base (en contournant le sanitize de la route d'ecriture, pour simuler une donnee reellement ancienne jamais nettoyee) puis verifie que /edit et /render renvoient une structure saine au client. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a529857379
commit
7e504b7865
@@ -63,6 +63,7 @@ from .labels.quiz_config import (
|
|||||||
quiz_total_points,
|
quiz_total_points,
|
||||||
sanitize_quiz_config,
|
sanitize_quiz_config,
|
||||||
)
|
)
|
||||||
|
from .labels.sanitize_element_attributes import sanitize_element_attributes
|
||||||
from .labels.scenario_config import (
|
from .labels.scenario_config import (
|
||||||
DEFAULT_SCENARIO_CONFIG,
|
DEFAULT_SCENARIO_CONFIG,
|
||||||
MAX_SCENARIO_CHOICES,
|
MAX_SCENARIO_CHOICES,
|
||||||
@@ -103,6 +104,7 @@ __all__ = [
|
|||||||
"render_document",
|
"render_document",
|
||||||
"render_document_element",
|
"render_document_element",
|
||||||
"sanitize_association_config",
|
"sanitize_association_config",
|
||||||
|
"sanitize_element_attributes",
|
||||||
"sanitize_memory_config",
|
"sanitize_memory_config",
|
||||||
"sanitize_mots_config",
|
"sanitize_mots_config",
|
||||||
"sanitize_quiz_config",
|
"sanitize_quiz_config",
|
||||||
|
|||||||
@@ -240,3 +240,23 @@ philosophie que le reste de ce module : ne jamais faire échouer une
|
|||||||
structure entière pour une seule référence cassée.
|
structure entière pour une seule référence cassée.
|
||||||
- **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`).
|
- **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`).
|
||||||
- **Exceptions** : aucune.
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `sanitize_element_attributes.py` — point d'entrée unique de revalidation par kind
|
||||||
|
|
||||||
|
Dispatch `kind -> sanitize_X_config` (`_SANITIZERS`, privée) pour les 5
|
||||||
|
mini-jeux à structure garantie (Quiz/Association/Memory/Mots
|
||||||
|
mêlés/Scénario) — les autres kinds n'ont pas de sanitizer et sont
|
||||||
|
renvoyés tels quels. Utilisé à la fois à l'ÉCRITURE
|
||||||
|
(`routes/document/document_element_update.py`) ET à la LECTURE
|
||||||
|
(`routes/document/document_edit.py`, `routes/document/document_render.py`)
|
||||||
|
: sans ce second usage, des attributs stockés dans un schéma devenu
|
||||||
|
obsolète (ex. Scénario, passé d'une liste plate à un arbre de décision)
|
||||||
|
atteindraient le panneau Propriétés côté client TELS QUELS, qui suppose
|
||||||
|
la forme ACTUELLE — bug réel constaté le 21/09/2026, un élément Scénario
|
||||||
|
créé avant la refonte en arbre faisait planter silencieusement le
|
||||||
|
panneau Propriétés (`scenario.nodes` inexistant sur l'ancienne forme).
|
||||||
|
|
||||||
|
### `sanitize_element_attributes(kind: str, attributes: Any) -> Any`
|
||||||
|
- **Retour** : `attributes` revalidé si `kind` a un sanitizer, sinon
|
||||||
|
`attributes` tel quel.
|
||||||
|
- **Exceptions** : aucune (délègue à des sanitizers qui ne lèvent jamais).
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Point d'entrée UNIQUE de revalidation des attributs d'un élément par
|
||||||
|
kind — utilisé à la fois à l'ÉCRITURE (routes/document/
|
||||||
|
document_element_update.py) ET à la LECTURE (routes/document/
|
||||||
|
document_edit.py, routes/document/document_render.py). Sans ce second
|
||||||
|
usage en lecture, des attributs stockés dans un schéma devenu obsolète
|
||||||
|
(ex. le mini-jeu Scénario, passé d'une liste plate à un arbre de
|
||||||
|
décision) atteindraient le panneau Propriétés côté client TELS QUELS,
|
||||||
|
qui suppose la forme ACTUELLE — bug réel constaté le 21/09/2026 : un
|
||||||
|
élément Scénario créé avant la refonte en arbre faisait planter
|
||||||
|
silencieusement le panneau Propriétés (`scenario.nodes` inexistant sur
|
||||||
|
l'ancienne forme), sans qu'aucun message n'indique pourquoi."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .association_config import sanitize_association_config
|
||||||
|
from .memory_config import sanitize_memory_config
|
||||||
|
from .mots_config import sanitize_mots_config
|
||||||
|
from .quiz_config import sanitize_quiz_config
|
||||||
|
from .scenario_config import sanitize_scenario_config
|
||||||
|
|
||||||
|
_SANITIZERS = {
|
||||||
|
"quiz": sanitize_quiz_config,
|
||||||
|
"association": sanitize_association_config,
|
||||||
|
"memory": sanitize_memory_config,
|
||||||
|
"mots": sanitize_mots_config,
|
||||||
|
"scenario": sanitize_scenario_config,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_element_attributes(kind: str, attributes: Any) -> Any:
|
||||||
|
"""Revalide `attributes` selon `kind`, UNIQUEMENT pour les kinds à
|
||||||
|
structure garantie (les 5 mini-jeux ci-dessus, voir `_SANITIZERS`) —
|
||||||
|
les autres kinds (texte/forme/image/bouton/rangée) sont de simples
|
||||||
|
valeurs scalaires sans structure à garantir, renvoyés TELS QUELS
|
||||||
|
(jamais sanitizés : ce ne sont pas leur responsabilité ici)."""
|
||||||
|
sanitizer = _SANITIZERS.get(kind)
|
||||||
|
return sanitizer(attributes) if sanitizer else attributes
|
||||||
@@ -11,9 +11,20 @@ def document_edit(slug: str) -> str:
|
|||||||
support = un projet = un document, voir docs/plan/PLAN.md). Contraste
|
support = un projet = un document, voir docs/plan/PLAN.md). Contraste
|
||||||
avec l'environnement 2D (clic sur une carte -> game_dashboard qui liste
|
avec l'environnement 2D (clic sur une carte -> game_dashboard qui liste
|
||||||
ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène
|
ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène
|
||||||
directement ici."""
|
directement ici.
|
||||||
|
|
||||||
|
Les attributs de chaque élément sont revalidés (document_engine.
|
||||||
|
sanitize_element_attributes) avant d'atteindre le client — jamais les
|
||||||
|
valeurs brutes stockées telles quelles : un élément dont le schéma a
|
||||||
|
évolué depuis sa création (ex. le mini-jeu Scénario, passé d'une liste
|
||||||
|
plate à un arbre de décision) ferait sinon planter silencieusement le
|
||||||
|
panneau Propriétés côté client, qui suppose la forme ACTUELLE (bug réel
|
||||||
|
constaté le 21/09/2026)."""
|
||||||
support = db.support_meta(slug)
|
support = db.support_meta(slug)
|
||||||
elements = document_engine.list_document_elements(slug)
|
elements = [
|
||||||
|
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
|
||||||
|
for el in document_engine.list_document_elements(slug)
|
||||||
|
]
|
||||||
return render_template(
|
return render_template(
|
||||||
"document/document_edit.html",
|
"document/document_edit.html",
|
||||||
support=support,
|
support=support,
|
||||||
|
|||||||
@@ -15,29 +15,22 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
|
|||||||
systématiquement l'état complet de ses champs.
|
systématiquement l'état complet de ses champs.
|
||||||
|
|
||||||
Quiz, Association, Memory, Mots mêlés et Scénario sont les seuls
|
Quiz, Association, Memory, Mots mêlés et Scénario sont les seuls
|
||||||
kinds revalidés côté serveur (sanitize_quiz_config/
|
kinds revalidés côté serveur (document_engine.sanitize_element_attributes,
|
||||||
sanitize_association_config/sanitize_memory_config/
|
même convention que game_engine/rendering/quiz_box_config.py côté
|
||||||
sanitize_mots_config/sanitize_scenario_config, même convention que
|
jeu) : leur forme (liste de questions/choix, liste de paires, liste
|
||||||
game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste
|
de cartes, liste de mots, arbre de scénarios) doit rester
|
||||||
de questions/choix, liste de paires, liste de cartes, liste de mots,
|
structurellement correcte pour que le rendu ne plante jamais,
|
||||||
liste de scénarios) doit rester structurellement correcte pour que le
|
contrairement aux autres kinds (texte/forme/image...) dont les
|
||||||
rendu ne plante jamais, contrairement aux autres kinds
|
attributs sont de simples valeurs scalaires sans structure à
|
||||||
(texte/forme/image...) dont les attributs sont de simples valeurs
|
garantir. Même sanitizer utilisé en LECTURE par document_edit.py/
|
||||||
scalaires sans structure à garantir."""
|
document_render.py — un élément dont les attributs stockés datent
|
||||||
|
d'un schéma devenu obsolète est ainsi normalisé partout, jamais
|
||||||
|
seulement à l'écriture."""
|
||||||
element = document_engine.get_document_element(slug, element_id)
|
element = document_engine.get_document_element(slug, element_id)
|
||||||
if element is None:
|
if element is None:
|
||||||
return jsonify({"error": "élément introuvable"}), 404
|
return jsonify({"error": "élément introuvable"}), 404
|
||||||
attributes: dict[str, Any] = request.get_json(force=True) or {}
|
attributes: dict[str, Any] = request.get_json(force=True) or {}
|
||||||
if element["kind"] == "quiz":
|
attributes = document_engine.sanitize_element_attributes(element["kind"], attributes)
|
||||||
attributes = document_engine.sanitize_quiz_config(attributes)
|
|
||||||
elif element["kind"] == "association":
|
|
||||||
attributes = document_engine.sanitize_association_config(attributes)
|
|
||||||
elif element["kind"] == "memory":
|
|
||||||
attributes = document_engine.sanitize_memory_config(attributes)
|
|
||||||
elif element["kind"] == "mots":
|
|
||||||
attributes = document_engine.sanitize_mots_config(attributes)
|
|
||||||
elif element["kind"] == "scenario":
|
|
||||||
attributes = document_engine.sanitize_scenario_config(attributes)
|
|
||||||
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
||||||
element = db.assert_not_none(
|
element = db.assert_not_none(
|
||||||
document_engine.get_document_element(slug, element_id),
|
document_engine.get_document_element(slug, element_id),
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ def document_render(slug: str) -> Response:
|
|||||||
layout côté client après toute mutation structurelle (ajout/déplacement/
|
layout côté client après toute mutation structurelle (ajout/déplacement/
|
||||||
suppression) pour reconstruire le canevas depuis la vérité serveur
|
suppression) pour reconstruire le canevas depuis la vérité serveur
|
||||||
(recalcul réel des rangées, jamais une simple retouche DOM locale, voir
|
(recalcul réel des rangées, jamais une simple retouche DOM locale, voir
|
||||||
docs/plan/PLAN.md — "recalcul au déplacement")."""
|
docs/plan/PLAN.md — "recalcul au déplacement").
|
||||||
elements = document_engine.list_document_elements(slug)
|
|
||||||
|
`elements` est revalidé (document_engine.sanitize_element_attributes)
|
||||||
|
avant d'être renvoyé au client — même raison que routes/document/
|
||||||
|
document_edit.py : le panneau Propriétés reçoit `data.elements`
|
||||||
|
directement depuis cette route à chaque rafraîchissement du canevas."""
|
||||||
|
elements = [
|
||||||
|
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
|
||||||
|
for el in document_engine.list_document_elements(slug)
|
||||||
|
]
|
||||||
return jsonify({"html": document_engine.render_document(elements), "elements": elements})
|
return jsonify({"html": document_engine.render_document(elements), "elements": elements})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from flask.testing import FlaskClient
|
|||||||
|
|
||||||
import auth
|
import auth
|
||||||
import db
|
import db
|
||||||
|
import document_engine
|
||||||
|
|
||||||
|
|
||||||
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
|
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
|
||||||
@@ -137,6 +138,45 @@ def test_document_element_update_sanitizes_memory_config_on_write(client: FlaskC
|
|||||||
assert "mode simple" in payload["rendered_html"]
|
assert "mode simple" in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
|
||||||
|
client: FlaskClient, support: str
|
||||||
|
) -> None:
|
||||||
|
"""Bug réel constaté le 21/09/2026 : un élément Scénario créé AVANT la
|
||||||
|
refonte en arbre de décision (ancien schéma plat "situation"/"choices"/
|
||||||
|
"correct_index", sans "title"/"nodes") faisait planter silencieusement
|
||||||
|
le panneau Propriétés côté client (`scenario.nodes` inexistant), sans
|
||||||
|
qu'aucune erreur ne remonte — voir document_engine/labels/
|
||||||
|
sanitize_element_attributes.py. `update_document_element_attributes`
|
||||||
|
(bas niveau, jamais utilisée directement par une route) sert ici à
|
||||||
|
écrire ce schéma obsolète tel quel, en contournant volontairement le
|
||||||
|
sanitize de la route d'update — pour simuler une ligne réellement
|
||||||
|
ancienne en base, jamais nettoyée depuis."""
|
||||||
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "scenario"})
|
||||||
|
element_id = add_resp.get_json()["id"]
|
||||||
|
stale_attributes = {
|
||||||
|
"theme_color": "#ff5f2e",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"situation": "Un chat va se faire renverser sous vos yeux, que faites-vous ?",
|
||||||
|
"choices": [{"text": "Je le sauve", "consequence": "Le chat est sauvé."}],
|
||||||
|
"correct_index": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
document_engine.update_document_element_attributes(support, element_id, stale_attributes)
|
||||||
|
|
||||||
|
edit_resp = client.get(f"/document/{support}/edit")
|
||||||
|
assert edit_resp.status_code == 200
|
||||||
|
render_resp = client.get(f"/document/{support}/render")
|
||||||
|
assert render_resp.status_code == 200
|
||||||
|
rendered_element = next(el for el in render_resp.get_json()["elements"] if el["id"] == element_id)
|
||||||
|
# Le vieux schéma n'a ni "title" ni "nodes" valides : sanitize_scenario_config
|
||||||
|
# le rejette entièrement plutôt que de renvoyer une structure à moitié
|
||||||
|
# ancienne/à moitié nouvelle — c'est la structure ACTUELLE garantie qui
|
||||||
|
# compte ici, jamais un plantage silencieux côté client.
|
||||||
|
assert rendered_element["attributes"] == document_engine.DEFAULT_SCENARIO_CONFIG
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
|
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
|
||||||
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"]
|
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"]
|
||||||
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"]
|
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"]
|
||||||
|
|||||||
Reference in New Issue
Block a user