diff --git a/document_engine/__init__.py b/document_engine/__init__.py index 4517a76b..aad973ee 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -63,6 +63,7 @@ from .labels.quiz_config import ( quiz_total_points, sanitize_quiz_config, ) +from .labels.sanitize_element_attributes import sanitize_element_attributes from .labels.scenario_config import ( DEFAULT_SCENARIO_CONFIG, MAX_SCENARIO_CHOICES, @@ -103,6 +104,7 @@ __all__ = [ "render_document", "render_document_element", "sanitize_association_config", + "sanitize_element_attributes", "sanitize_memory_config", "sanitize_mots_config", "sanitize_quiz_config", diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md index 244368a6..1b8bad2f 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -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. - **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`). - **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). diff --git a/document_engine/labels/sanitize_element_attributes.py b/document_engine/labels/sanitize_element_attributes.py new file mode 100644 index 00000000..b7b15b3b --- /dev/null +++ b/document_engine/labels/sanitize_element_attributes.py @@ -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 diff --git a/routes/document/document_edit.py b/routes/document/document_edit.py index 4b468416..4cb589ec 100644 --- a/routes/document/document_edit.py +++ b/routes/document/document_edit.py @@ -11,9 +11,20 @@ def document_edit(slug: str) -> str: support = un projet = un document, voir docs/plan/PLAN.md). Contraste 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 - 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) - 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( "document/document_edit.html", support=support, diff --git a/routes/document/document_element_update.py b/routes/document/document_element_update.py index cdd5393e..bfef0deb 100644 --- a/routes/document/document_element_update.py +++ b/routes/document/document_element_update.py @@ -15,29 +15,22 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp systématiquement l'état complet de ses champs. Quiz, Association, Memory, Mots mêlés et Scénario sont les seuls - kinds revalidés côté serveur (sanitize_quiz_config/ - sanitize_association_config/sanitize_memory_config/ - sanitize_mots_config/sanitize_scenario_config, même convention que - game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste - de questions/choix, liste de paires, liste de cartes, liste de mots, - liste de scénarios) doit rester structurellement correcte pour que le - rendu ne plante jamais, contrairement aux autres kinds - (texte/forme/image...) dont les attributs sont de simples valeurs - scalaires sans structure à garantir.""" + kinds revalidés côté serveur (document_engine.sanitize_element_attributes, + même convention que game_engine/rendering/quiz_box_config.py côté + jeu) : leur forme (liste de questions/choix, liste de paires, liste + de cartes, liste de mots, arbre de scénarios) doit rester + structurellement correcte pour que le rendu ne plante jamais, + contrairement aux autres kinds (texte/forme/image...) dont les + attributs sont de simples valeurs scalaires sans structure à + garantir. Même sanitizer utilisé en LECTURE par document_edit.py/ + 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) if element is None: return jsonify({"error": "élément introuvable"}), 404 attributes: dict[str, Any] = request.get_json(force=True) or {} - if element["kind"] == "quiz": - 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) + attributes = document_engine.sanitize_element_attributes(element["kind"], attributes) document_engine.update_document_element_attributes(slug, element_id, attributes) element = db.assert_not_none( document_engine.get_document_element(slug, element_id), diff --git a/routes/document/document_render.py b/routes/document/document_render.py index b4008ec2..6a82627e 100644 --- a/routes/document/document_render.py +++ b/routes/document/document_render.py @@ -11,6 +11,14 @@ def document_render(slug: str) -> Response: layout côté client après toute mutation structurelle (ajout/déplacement/ suppression) pour reconstruire le canevas depuis la vérité serveur (recalcul réel des rangées, jamais une simple retouche DOM locale, voir - docs/plan/PLAN.md — "recalcul au déplacement").""" - elements = document_engine.list_document_elements(slug) + docs/plan/PLAN.md — "recalcul au déplacement"). + + `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}) diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index 521d210d..53ea4471 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -8,6 +8,7 @@ from flask.testing import FlaskClient import auth import db +import document_engine 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"] +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: 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"]