"""Text from the data reaches the page as text, in a real browser. The chart builder's filters and the chart tables built their HTML from strings, and a currency could hold markup (decision #130). The currency is now refused on the way in — `test_currency_codes.py` — but a value stored before that still sits in the database, so what protects an existing install is the page treating it as text. Only a browser can show that, so these run with the visual tests. """ from __future__ import annotations import re import subprocess import sys from pathlib import Path import pytest from freezegun import freeze_time from sqlalchemy import update import factories as fac import fixture_portfolio as ref from app.models import Instrument from test_routes import bind_to_only_portfolio, make_login, session_csrf ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from tools.browser import find_chrome # noqa: E402 pytestmark = pytest.mark.visual CHROME = find_chrome() if CHROME is None: pytest.skip("no headless Chrome on this machine", allow_module_level=True) HTML = {"accept": "text/html"} STATIC = ROOT / "app" / "static" # Lower case: it is stored directly, the way a value from before the fix would be. MARKUP = "" @pytest.fixture def poisoned(client, session_factory): """Two holdings, one whose currency was stored as markup before the rule.""" make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) acme = fac.make_instrument(s, "ACME", asset_class="share") zulu = fac.make_instrument(s, "ZULU", asset_class="share", currency="USD") fac.add_trade(s, acme, "2026-01-05", "buy", 10, "4.00") fac.add_trade(s, zulu, "2026-01-05", "buy", 10, "4.00") s.commit() # Around the model's validator on purpose: this is what is already there. s.execute(update(Instrument).where(Instrument.ticker == "ZULU") .values(currency=MARKUP)) s.commit() return client def _in_chrome(page: str, tmp_path: Path) -> str: """The page with its scripts inlined, as Chrome leaves it once they ran.""" def inline(match: re.Match) -> str: path = STATIC / match.group(1) # idle.js signs the page out after a while; nothing to measure here. if match.group(1) == "idle.js" or not path.is_file(): return "" return "" page = re.sub(r'', inline, page) target = tmp_path / "page.html" target.write_text(page, encoding="utf-8") return subprocess.run( [CHROME, "--headless", "--disable-gpu", "--dump-dom", "--no-sandbox", "--allow-file-access-from-files", "--virtual-time-budget=4000", target.as_uri()], capture_output=True, text=True, timeout=90).stdout def _assert_shown_as_text(dom: str) -> None: assert "INJECTED" not in dom, "markup from the data ran as script" assert ' str: saved = client.post("/charts/save", json={ "name": "A table", "width": "half", "spec": {"grain": "positions", "measures": ["pos_value"], "type": "table", **spec}}, headers={"X-CSRF-Token": session_csrf(session_factory)}) assert saved.status_code == 200 return client.get("/charts", headers=HTML).text @freeze_time(ref.TODAY) def test_a_currency_with_markup_is_text_in_a_chart_tables_rows( poisoned, session_factory, tmp_path): page = _table_page(poisoned, session_factory, x="currency") _assert_shown_as_text(_in_chrome(page, tmp_path)) @freeze_time(ref.TODAY) def test_a_currency_with_markup_is_text_in_a_chart_tables_header( poisoned, session_factory, tmp_path): """Split by currency, each currency is a series, and a series name is a column heading.""" page = _table_page(poisoned, session_factory, x="ticker", split="currency") _assert_shown_as_text(_in_chrome(page, tmp_path)) # The rotation editor upper-cases what it reads, so this payload is written in # capitals: the script is carried as hex character references, and the quote # closes the attribute the old code wrote the ticker into. _SCRIPT = "".join(f"&#X{ord(c):X};" for c in "document.title='INJECTED'") TICKER_MARKUP = f'X">' @freeze_time(ref.TODAY) def test_a_ticker_with_markup_is_text_in_the_plan_rotation( client, session_factory, tmp_path): """Tickers are checked on the way in, but one stored before that check is still read back into the rotation editor, from the database.""" make_login(client, session_factory) with session_factory() as s: bind_to_only_portfolio(s) zulu = fac.make_instrument(s, "ZULU", asset_class="share") fac.add_trade(s, zulu, "2026-01-05", "buy", 10, "4.00") s.commit() s.execute(update(Instrument).where(Instrument.ticker == "ZULU") .values(ticker=TICKER_MARKUP)) s.commit() dom = _in_chrome(client.get("/schedule", headers=HTML).text, tmp_path) assert "INJECTED" not in dom, "markup from the data ran as script" assert '