Files
timetracker/tests/test_node_tree.py
T
lukas 0c6c536d07 Ban SafeText-as-child: only Safe nodes render unescaped
Tightens the child model so the type is honest end to end. Previously a
``SafeText``/``mark_safe`` string passed as a child rendered unescaped — a
trusted-HTML-as-string backdoor that ``Child = Node | str`` couldn't express
(every ``SafeText`` is a ``str``). Now ``_child_key`` escapes *every* string
child; the only way to put trusted pre-rendered HTML into the tree is a
``Safe`` node. So a ``str`` child is always untrusted text — which is exactly
what the renderer escapes.

Converted the trusted-HTML children that relied on the old passthrough:

- ``CsrfInput`` and the Alpine selectors (``GameStatusSelector`` /
  ``SessionDeviceSelector``) now return ``Safe`` nodes instead of ``mark_safe``
  strings — they are always tree children.
- ``popover_content`` is now a ``Child`` (it is rendered as a child); the one
  HTML caller (``LinkedPurchase``) passes ``Safe(...)``.
- View-side children that were ``mark_safe`` strings → ``Safe(...)``:
  ``_played_row`` (game detail), the stat SVGs and `` `` spacer (game),
  the login table (auth), the manual session-form field/label markup
  (session), and ``_purchase_name`` (stats).
- ``SimpleTable.header_action`` typed ``Child``.

The script-tag string helpers (``ModuleScript`` / ``StaticScript`` /
``ExternalScript``) stay ``SafeText`` strings: they are only ever joined into
the ``scripts=`` string, never used as tree children.

``Children`` regains a bare ``Node`` member (a single node child is valid);
the one ``*children`` site (``Popover``) normalises via ``as_children`` first.
Tests that asserted the old SafeText-passthrough now assert the new rule
(mark_safe child escaped; ``Safe`` node passes through). Full suite green
(445; +2 new escaping tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:35:43 +02:00

179 lines
5.9 KiB
Python

"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
These cover the new machinery directly: rendering, escaping, media bubbling.
"""
import unittest
from django.utils.safestring import mark_safe
from common.components import (
BaseComponent,
Element,
Fragment,
Media,
Node,
Safe,
collect_media,
render,
)
class ElementRenderTest(unittest.TestCase):
def test_renders_tag_attrs_children(self):
element = Element("div", [("class", "test")], "hello")
self.assertEqual(render(element), '<div class="test">hello</div>')
def test_plain_string_children_escaped(self):
self.assertEqual(
render(Element("span", children=["<b>"])), "<span>&lt;b&gt;</span>"
)
def test_safe_node_child_passes_through(self):
self.assertEqual(
render(Element("span", children=[Safe("<b>x</b>")])),
"<span><b>x</b></span>",
)
def test_safetext_child_is_escaped(self):
# A string child is always escaped — even a mark_safe/SafeText one.
# Trusted markup must be a Safe node, not a safe string.
self.assertEqual(
render(Element("span", children=[mark_safe("<b>x</b>")])),
"<span>&lt;b&gt;x&lt;/b&gt;</span>",
)
def test_node_children_render_safely(self):
inner = Element("b", children=["x"])
self.assertEqual(
render(Element("span", children=[inner])), "<span><b>x</b></span>"
)
class SafeAndFragmentTest(unittest.TestCase):
def test_safe_passes_html_through(self):
self.assertEqual(render(Safe("<i>raw</i>")), "<i>raw</i>")
def test_fragment_concatenates(self):
frag = Fragment(
Element("span", children=["a"]), Element("span", children=["b"])
)
self.assertEqual(render(frag), "<span>a</span><span>b</span>")
def test_fragment_skips_empty_children(self):
frag = Fragment("", None, Element("span", children=["a"]))
self.assertEqual(render(frag), "<span>a</span>")
def test_fragment_escapes_plain_strings(self):
self.assertEqual(render(Fragment("<x>", Safe("<y>"))), "&lt;x&gt;<y>")
class MediaTest(unittest.TestCase):
def test_merge_dedups_preserving_order(self):
merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"])
self.assertEqual(merged.js, ("a.js", "b.js", "c.js"))
def test_external_kept_separate(self):
merged = Media(js=["a.js"]) + Media(js_external=["umd.js"])
self.assertEqual(merged.js, ("a.js",))
self.assertEqual(merged.js_external, ("umd.js",))
def test_sum_with_radd(self):
merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media())
self.assertEqual(merged.js, ("a.js", "b.js"))
def test_falsy_when_empty(self):
self.assertFalse(Media())
self.assertTrue(Media(js=["a.js"]))
class MediaCollectionTest(unittest.TestCase):
def test_bubbles_through_element_children(self):
class Widget(BaseComponent):
media = Media(js=["widget.js"])
def render(self) -> Node:
return Element("div", children=["x"])
tree = Element("section", children=[Element("div", children=[Widget()])])
self.assertEqual(collect_media(tree).js, ("widget.js",))
def test_bubbles_through_fragment(self):
class Widget(BaseComponent):
media = Media(js=["w.js"])
def render(self) -> Node:
return Element("div")
self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",))
def test_component_merges_own_and_subtree_media(self):
class Inner(BaseComponent):
media = Media(js=["inner.js"])
def render(self) -> Node:
return Element("span")
class Outer(BaseComponent):
media = Media(js=["outer.js"])
def render(self) -> Node:
return Element("div", children=[Inner()])
self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js"))
def test_bare_string_has_no_media(self):
self.assertFalse(collect_media("just a string"))
class RealComponentMediaTest(unittest.TestCase):
"""Phase 3: JS-bearing components declare media that bubbles up the tree."""
def test_search_select_declares_its_script(self):
from common.components import SearchSelect
self.assertEqual(
collect_media(SearchSelect(name="games")).js, ("search_select.js",)
)
def test_filter_select_declares_its_script(self):
from common.components import FilterSelect
self.assertIn(
"search_select.js", collect_media(FilterSelect(field_name="type")).js
)
def test_date_range_picker_declares_its_script(self):
from common.components import DateRangePicker
media = collect_media(
DateRangePicker(label="Played", input_name_prefix="played")
)
self.assertEqual(media.js, ("date_range_picker.js",))
def test_range_slider_declares_its_script(self):
from common.components.filters import RangeSlider
media = collect_media(
RangeSlider(
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
)
)
self.assertEqual(media.js, ("range_slider.js",))
def test_filter_bar_collects_chrome_and_widget_media(self):
"""A FilterBar's media merges its own chrome script with the scripts that
bubble up from the FilterSelect and RangeSlider widgets it contains —
exactly the set the view used to thread by hand. (FilterBar wraps its DB
aggregates in try/except, so it builds without a database.)"""
from common.components import FilterBar
media = collect_media(FilterBar())
self.assertIn("filter_bar.js", media.js)
self.assertIn("search_select.js", media.js)
self.assertIn("range_slider.js", media.js)
if __name__ == "__main__":
unittest.main()