Ajoute 4 mécanismes moteur manquants pour le thème sécurité incendie : étiquette, carte, image SVG inline, bouton avec pièce jointe
Contenu et mécanisme uniquement, aucun style ajouté (voir consigne du 24/09/2026) : deux nouveaux kinds de contenu (badge/carte, rendu en div brutes sans CSS), un mode SVG inline pour l'image (svg_markup, nettoyé par un nouveau sanitizer allow-list avant chaque rendu) et un fichier téléchargeable joignable à un bouton (upload/download routes, stockage sous db.support_dir). Le futur système de templates portera l'habillage visuel de ces éléments. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e7c6ed7159
commit
a34bcf4159
@@ -225,3 +225,125 @@ def test_liste_puces_with_no_items_renders_an_empty_list(tmp_support_slug_cleanu
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert html == f'<ul class="docList" data-element-id="{element_id}" data-kind="liste_puces"></ul>'
|
||||
|
||||
|
||||
def test_badge_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
||||
element = doc_engine.get_document_element(slug, element_id)
|
||||
assert element is not None
|
||||
assert element["attributes"] == {"content": "Étiquette"}
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert html == f'<div class="docBadge" data-element-id="{element_id}" data-kind="badge">Étiquette</div>'
|
||||
|
||||
|
||||
def test_badge_escapes_content(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(slug, element_id, {"content": "<script>alert(1)</script>"})
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_carte_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "carte", page_id=page_id)
|
||||
element = doc_engine.get_document_element(slug, element_id)
|
||||
assert element is not None
|
||||
assert element["attributes"] == {
|
||||
"label": "A",
|
||||
"title": "Titre de la carte",
|
||||
"description": "Description de la carte.",
|
||||
}
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert html == (
|
||||
f'<div class="docCard" data-element-id="{element_id}" data-kind="carte">'
|
||||
f'<div class="docCardLabel">A</div>'
|
||||
f'<div class="docCardTitle">Titre de la carte</div>'
|
||||
f'<div class="docCardDescription">Description de la carte.</div>'
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
def test_carte_escapes_all_fields(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "carte", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"label": "<b>", "title": "<i>", "description": "<script>alert(1)</script>"}
|
||||
)
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "<script>" not in html
|
||||
assert "<b>" not in html
|
||||
assert "<i>" not in html
|
||||
|
||||
|
||||
def test_image_default_attributes_include_empty_svg_markup(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
element = doc_engine.get_document_element(slug, element_id)
|
||||
assert element is not None
|
||||
assert element["attributes"] == {"src": "", "alt": "", "svg_markup": ""}
|
||||
|
||||
|
||||
def test_image_with_svg_markup_takes_priority_over_src(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug,
|
||||
element_id,
|
||||
{"src": "https://exemple.test/photo.png", "alt": "", "svg_markup": '<svg><circle cx="5" cy="5" r="3"/></svg>'},
|
||||
)
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "<img" not in html
|
||||
assert f'<div class="docImage" data-element-id="{element_id}" data-kind="image">' in html
|
||||
assert '<circle cx="5" cy="5" r="3">' in html or '<circle cx="5" cy="5" r="3"/>' in html
|
||||
|
||||
|
||||
def test_image_svg_markup_strips_script_tag(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
malicious = '<svg onload="alert(1)"><script>alert(2)</script><circle cx="1" cy="1" r="1" onclick="alert(3)"/></svg>'
|
||||
doc_engine.update_document_element_attributes(slug, element_id, {"src": "", "alt": "", "svg_markup": malicious})
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "<script>" not in html
|
||||
assert "onload" not in html
|
||||
assert "onclick" not in html
|
||||
assert "alert(" not in html
|
||||
|
||||
|
||||
def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
||||
element = doc_engine.get_document_element(slug, element_id)
|
||||
assert element is not None
|
||||
assert element["attributes"] == {
|
||||
"label": "Bouton",
|
||||
"target": "",
|
||||
"attachment_stored_name": "",
|
||||
"attachment_filename": "",
|
||||
}
|
||||
|
||||
|
||||
def test_bouton_with_attachment_filename_renders_data_attribute(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug,
|
||||
element_id,
|
||||
{
|
||||
"label": "Télécharger",
|
||||
"target": "",
|
||||
"attachment_stored_name": "abc123.pdf",
|
||||
"attachment_filename": "fiche-consignes.pdf",
|
||||
},
|
||||
)
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert 'data-attachment-filename="fiche-consignes.pdf"' in html
|
||||
|
||||
@@ -8,6 +8,7 @@ pages elles-mêmes) — _page_id() renvoie l'id de la page par défaut
|
||||
("Page 1") créée avec chaque support de test, réutilisé par toutes les
|
||||
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
@@ -344,3 +345,58 @@ def test_cannot_open_another_owners_support(client: FlaskClient, user_client: Fl
|
||||
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
|
||||
finally:
|
||||
db.delete_support(victim_slug)
|
||||
|
||||
|
||||
def _add_bouton(client: FlaskClient, support: str) -> int:
|
||||
resp = client.post(f"/document/{support}/elements/add", data={"kind": "bouton", "page_id": _page_id(support)})
|
||||
return int(resp.get_json()["id"])
|
||||
|
||||
|
||||
def test_upload_attachment_stores_file_and_updates_button_attributes(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_bouton(client, support)
|
||||
resp = client.post(
|
||||
f"/document/{support}/elements/{element_id}/upload-attachment",
|
||||
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
payload = resp.get_json()
|
||||
assert payload["attributes"]["attachment_filename"] == "fiche-consignes.pdf"
|
||||
assert payload["attributes"]["attachment_stored_name"]
|
||||
assert 'data-attachment-filename="fiche-consignes.pdf"' in payload["rendered_html"]
|
||||
|
||||
|
||||
def test_upload_attachment_rejects_a_non_bouton_element(client: FlaskClient, support: str) -> None:
|
||||
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
||||
element_id = resp.get_json()["id"]
|
||||
resp = client.post(
|
||||
f"/document/{support}/elements/{element_id}/upload-attachment",
|
||||
data={"file": (io.BytesIO(b"peu importe"), "x.pdf")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_upload_attachment_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_bouton(client, support)
|
||||
resp = client.post(f"/document/{support}/elements/{element_id}/upload-attachment", data={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_download_attachment_serves_the_file_under_its_original_name(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_bouton(client, support)
|
||||
client.post(
|
||||
f"/document/{support}/elements/{element_id}/upload-attachment",
|
||||
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b"%PDF-1.4 fake pdf content"
|
||||
assert "fiche-consignes.pdf" in resp.headers["Content-Disposition"]
|
||||
|
||||
|
||||
def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_bouton(client, support)
|
||||
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Nettoyage du SVG inline collé comme contenu d'image (voir
|
||||
document_engine/rendering/sanitize_svg_markup.py) — chaque cas ici
|
||||
reproduit une charge malveillante RÉELLE plutôt qu'une simple assertion
|
||||
"pas de régression" (voir CLAUDE.md, exigence pour tout changement de
|
||||
comportement lié à l'échappement/la sécurité)."""
|
||||
|
||||
from document_engine.rendering.sanitize_svg_markup import sanitize_svg_markup
|
||||
|
||||
|
||||
def test_strips_script_tag_and_its_content() -> None:
|
||||
result = sanitize_svg_markup("<svg><script>alert(document.cookie)</script></svg>")
|
||||
assert "<script>" not in result
|
||||
assert "alert(document.cookie)" not in result
|
||||
|
||||
|
||||
def test_strips_event_handler_attributes() -> None:
|
||||
result = sanitize_svg_markup('<svg onload="alert(1)"><circle onclick="alert(2)" cx="5" cy="5" r="3"/></svg>')
|
||||
assert "onload" not in result
|
||||
assert "onclick" not in result
|
||||
assert "alert(" not in result
|
||||
|
||||
|
||||
def test_strips_href_to_block_javascript_uri() -> None:
|
||||
result = sanitize_svg_markup('<svg><a href="javascript:alert(1)"><circle cx="1" cy="1" r="1"/></a></svg>')
|
||||
assert "javascript:" not in result
|
||||
assert "<a" not in result
|
||||
assert "href" not in result
|
||||
|
||||
|
||||
def test_strips_foreignobject_and_embedded_html() -> None:
|
||||
result = sanitize_svg_markup(
|
||||
'<svg><foreignObject><body onload="alert(1)"><img src="x" onerror="alert(2)"></body></foreignObject></svg>'
|
||||
)
|
||||
assert "foreignObject".lower() not in result.lower()
|
||||
assert "onerror" not in result
|
||||
assert "alert(" not in result
|
||||
|
||||
|
||||
def test_strips_style_attribute_and_style_tag() -> None:
|
||||
result = sanitize_svg_markup(
|
||||
'<svg><style>*{display:none}</style><circle style="fill:red" cx="1" cy="1" r="1"/></svg>'
|
||||
)
|
||||
assert "<style>" not in result
|
||||
assert "style=" not in result
|
||||
assert "display:none" not in result
|
||||
|
||||
|
||||
def test_strips_use_tag_referencing_external_content() -> None:
|
||||
result = sanitize_svg_markup('<svg><use href="https://evil.test/x.svg#payload"/></svg>')
|
||||
assert "<use" not in result
|
||||
assert "evil.test" not in result
|
||||
|
||||
|
||||
def test_keeps_allowed_shape_and_presentation_attributes() -> None:
|
||||
result = sanitize_svg_markup(
|
||||
'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ff0000" stroke="#000"/></svg>'
|
||||
)
|
||||
assert "<svg" in result
|
||||
assert "<circle" in result
|
||||
assert 'cx="12"' in result
|
||||
assert 'fill="#ff0000"' in result
|
||||
assert 'stroke="#000"' in result
|
||||
|
||||
|
||||
def test_self_closing_disallowed_tag_does_not_swallow_following_content() -> None:
|
||||
result = sanitize_svg_markup('<svg><script/><circle cx="1" cy="1" r="1"/></svg>')
|
||||
assert "<circle" in result
|
||||
|
||||
|
||||
def test_empty_markup_returns_empty_string() -> None:
|
||||
assert sanitize_svg_markup("") == ""
|
||||
Reference in New Issue
Block a user