diff --git a/chat/templates/_delete_impact_modal.html b/chat/templates/_delete_impact_modal.html new file mode 100644 index 0000000..e5bab40 --- /dev/null +++ b/chat/templates/_delete_impact_modal.html @@ -0,0 +1,34 @@ +{# T110.3: delete-impact modal partial. + +Rendered from :func:`chat.web.drawer.delete_preview` via a Jinja2 +TemplateResponse so HTML autoescape covers user-controllable fields +(item.kind, item.description, notes) automatically — the prior +f-string assembly required explicit html.escape() calls (T110.2) +which become redundant under autoescape. + +Inputs: + ``chat_id`` — the URL chat id (used to build the confirm form action). + ``impact`` — an :class:`~chat.services.delete_impact.ImpactReport`. +#} +
+

Delete event {{ impact.target_event_id }}?

+

This will discard {{ impact.cascading|length }} events. Cascade:

+ + +
+ +
+
diff --git a/chat/web/drawer.py b/chat/web/drawer.py index a29f281..b965e7a 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -27,7 +27,6 @@ one so a later inverse edit can restore state (§6.4 final paragraph). from __future__ import annotations -import html import json import uuid from pathlib import Path @@ -1235,39 +1234,18 @@ async def delete_preview( report = compute_delete_impact(conn, target_event_id=int(event_id)) - # Build the modal HTML directly — the impact report is small and - # reusing the drawer template would require a fragment include just - # for this surface. Mirrors the rewind-preview style in - # :func:`chat.web.turns.rewind_preview`. - # - # T110.2: ``item.kind``, ``item.description``, and the notes carry - # user-controllable content (turn prose, scene timestamps, etc.). - # Wrap them with :func:`html.escape` so a payload like - # ```` renders as inert text. ``chat_id`` - # is matched against the projected ``chats`` table at request time - # (404 above) so it isn't free-form, but we escape it for symmetry. - items_html = "".join( - f"
  • {html.escape(item.kind)}: " - f"{html.escape(item.description)}
  • " - for item in report.cascading + # T110.3: render via the ``_delete_impact_modal.html`` Jinja partial + # so HTML autoescape covers user-controllable fields (item.kind, + # item.description, notes) automatically. The prior implementation + # built the modal HTML via raw f-string concatenation and required + # explicit ``html.escape()`` calls (T110.2) on each interpolated + # field; under autoescape those calls become redundant. Mirrors the + # rewind-preview style in :func:`chat.web.turns.rewind_preview`. + return TEMPLATES.TemplateResponse( + request, + "_delete_impact_modal.html", + {"chat_id": chat_id, "impact": report}, ) - notes_html = "".join( - f"
  • {html.escape(note)}
  • " for note in report.notes - ) - body = ( - "
    " - f"

    Delete event {report.target_event_id}?

    " - f"

    This will discard {len(report.cascading)} events. Cascade:

    " - f"" - f"" - f"
    " - "" - "
    " - "
    " - ) - return HTMLResponse(body) @router.post( diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py index 0b2b473..20b428e 100644 --- a/tests/test_drawer_phase4.py +++ b/tests/test_drawer_phase4.py @@ -458,6 +458,35 @@ def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path): assert row is None, f"event {ev_id} should have been deleted" +def test_delete_impact_modal_uses_jinja_partial(client, tmp_path): + """T110.3: the modal HTML is rendered from a Jinja partial + (`_delete_impact_modal.html`) rather than f-string concatenation in + Python. Verify the partial-rendered shape: the wrapping + ``delete-impact-modal`` div, the cascade list, and the confirm form. + + The partial inherits Jinja2 autoescape so HTML safety follows + automatically — the explicit ``html.escape()`` calls from T110.2 + become redundant once this lands. + """ + db = tmp_path / "test.db" + _seed_chat(db) + user_id, _bot_id = _seed_turns(db) + + response = client.get( + f"/chats/chat_bot_a/drawer/turn/delete-preview/{user_id}" + ) + assert response.status_code == 200 + body = response.text + + # Markup shape that the partial produces. Double-quoted attributes + # signal Jinja rendering (the prior f-string used single quotes). + assert '
    ' in body + assert '