All verification complete. Final state summary: **Report — phase 180 final verification pass** - Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`). - Completion criteria: - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E) - ``→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅ - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅ - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe) - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅ - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅ - Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate. - Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181. - Next pending phase: `181_svg_document_preview`.
1141 lines
54 KiB
Python
1141 lines
54 KiB
Python
"""Unit: the shared renderer's inline link/image/heading transforms (phase 180).
|
||
|
||
Browser behavior is E2E-covered by ``tests/e2e/test_markdown_inline_links_images.py``
|
||
(task 05); here we pin the source-level contract of the ONE ``inline()``
|
||
chain in ``frontend/assets/markdown.js`` — the chain ORDER (code → strong
|
||
→ em → linked-image → image → link → headings → lists), the exact
|
||
``<a class="md-link"`` / ``<img class="md-img"`` markers, the TWO URL
|
||
allowlist functions (locked A5), the ``target``/``rel`` rule, the
|
||
always-emitted ``alt`` + ``loading="lazy"`` and the absence of any inline
|
||
handler, and the escape-first boundary (no unescape/decode anywhere) — so
|
||
a silent regression is caught without a browser (the established frontend
|
||
pattern, cf. phases 44 and 138). A node-driven behavioral check (skipped
|
||
when node is unavailable, like the table/blockquote checks) proves the
|
||
story acceptance shapes: an absolute link with ``target``/``rel``,
|
||
``mailto:``/relative/``#`` links without them, ``javascript:``/``data:``
|
||
inert, ``&`` in a query string surviving verbatim, an empty link
|
||
text, a link inside a fence staying code, a link inside a table cell
|
||
rendering; a served-path / ``data:image/*`` / absolute / ``./``/``../``
|
||
image with its alt and lazy hint, an empty alt, an escaped alt, a
|
||
non-image ``data:`` and ``javascript:`` src inert, a linked image wrapped
|
||
with no stray bracket, an image inside a fence and inside a table cell;
|
||
and non-construct input byte-identical to the pre-phase renderer (fixture
|
||
string checked into the test, per the phase-44/138 shape). Task 03
|
||
(TODO.md L5) adds the ``####``/``#####``/``######`` rung of the heading
|
||
ladder: its two rules sit deeper-first immediately before ``/^### /``, the
|
||
existing ``#``/``##`` → ``<h3>`` and ``###`` → ``<h4>`` output is
|
||
byte-identical, and the step-3 paragraph guard is UNCHANGED (its ``h\\d``
|
||
already covers ``h5``/``h6`` — pinned, not assumed).
|
||
|
||
Task 04's stylesheet contract is pinned here too (the phase's ONE unit
|
||
file): the ``.md-link`` / ``.md-img`` rules and the ``h5``/``h6`` type
|
||
scale exist, reference :root palette variables ONLY (no hex/rgb/hsl
|
||
literal — a new hue would break the monochrome invariant), the link is
|
||
underlined (a link never relies on colour alone, WCAG 1.4.1), the user
|
||
bubble carries its own ``.md-link`` inversion, every image is capped to
|
||
its container (``max-width: 100%`` + ``height: auto``), the deep rungs
|
||
extend the existing ``h3``/``h4`` scale MONOTONICALLY, and the ``.md-link`
|
||
rule carries its house contrast-ratio note.
|
||
|
||
The browser-side acceptance evidence is the phase's dedicated Playwright
|
||
suite, ``tests/e2e/test_markdown_inline_links_images.py`` (task 05).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
ASSETS = FRONTEND / "assets"
|
||
MARKDOWN_JS = ASSETS / "markdown.js"
|
||
STYLES_CSS = ASSETS / "styles.css"
|
||
|
||
HAVE_NODE = shutil.which("node") is not None
|
||
|
||
#: The exact output marker of the link transform (task 01).
|
||
LINK_MARKER = '<a class="md-link"'
|
||
|
||
#: The external-target pair, emitted ONLY for absolute ``http(s)://``
|
||
#: hrefs (a mailto/relative/fragment link stays in the same tab).
|
||
TARGET_REL = ' target="_blank" rel="noopener noreferrer"'
|
||
|
||
#: The link rule as written in the chain — an explicit regex plus a
|
||
#: FUNCTION replacement (the URL goes through the allowlist, never a
|
||
#: string-interpolated replacement).
|
||
LINK_RULE = r""".replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, text, url) => {"""
|
||
|
||
#: The exact output marker of the image transform (task 02).
|
||
IMG_MARKER = '<img class="md-img"'
|
||
|
||
#: Lazy decoding: every rendered image defers to the browser's own
|
||
#: loading hint (the phase's acceptance shape).
|
||
LAZY_ATTR = ' loading="lazy"'
|
||
|
||
#: The linked-image rule (LOCKED A6): THREE captures (alt, src, url) and
|
||
#: a FUNCTION replacement, so BOTH URLs go through their own allowlist.
|
||
LINKED_IMAGE_RULE = (
|
||
r""".replace(/\[!\[([^\]]*)\]\(([^)\s]+)\)\]\(([^)\s]+)\)/g, (_m, alt, src, url) => {"""
|
||
)
|
||
|
||
#: The plain image rule — same shape, two captures.
|
||
IMAGE_RULE = r""".replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, src) => {"""
|
||
|
||
#: The attributes every ``<img>`` template in the file carries: ``alt`` is
|
||
#: ALWAYS emitted (empty allowed — an ``<img>`` without alt fails WCAG
|
||
#: 1.1) next to ``loading="lazy"``, right after the allowlisted ``src``.
|
||
IMG_ALT_LAZY = 'alt="${alt}" loading="lazy">'
|
||
|
||
#: An input containing NONE of the phase-180 constructs (no ``[t](u)``,
|
||
#: no ````, no ``####``+) and its byte-EXACT pre-phase renderer
|
||
#: output — the byte-identity contract (phase 180 design D7): anything
|
||
#: without a link/image/deep-heading renders exactly as before. Covers
|
||
#: strong/em/code, the ``#``–``###`` ladder, both list kinds, a
|
||
#: blockquote, a fence and a pipe table.
|
||
NO_CONSTRUCT_INPUT = (
|
||
"A plain paragraph with **bold**, *em* and `code` plus 1 < 2 & 3 > 2.\n"
|
||
"\n"
|
||
"# Top heading\n"
|
||
"\n"
|
||
"## Second heading\n"
|
||
"\n"
|
||
"### Third heading\n"
|
||
"\n"
|
||
"- bullet one\n"
|
||
"- bullet two\n"
|
||
"\n"
|
||
"1. first\n"
|
||
"2. second\n"
|
||
"\n"
|
||
"> a quoted line with `code`\n"
|
||
"\n"
|
||
"```\n"
|
||
"fenced: a * b, x_y_z\n"
|
||
"```\n"
|
||
"\n"
|
||
"| Col A | Col B |\n"
|
||
"|---|---|\n"
|
||
"| 1 | 2 |\n"
|
||
)
|
||
NO_CONSTRUCT_PRE_PHASE_OUTPUT = (
|
||
"<p>A plain paragraph with <strong>bold</strong>, <em>em</em> and "
|
||
"<code>code</code> plus 1 < 2 & 3 > 2.</p>"
|
||
"<h3>Top heading</h3><h3>Second heading</h3><h4>Third heading</h4>\n"
|
||
"<ul><li>bullet one</li>\n<li>bullet two</li></ul><li>first</li>\n<li>second</li>"
|
||
'<blockquote class="md-quote">a quoted line with <code>code</code></blockquote>'
|
||
"<p><pre><code>fenced: a * b, x_y_z</code></pre></p>"
|
||
'<p><div class="md-table-wrap">\n'
|
||
'<table class="md-table">\n'
|
||
'<thead><tr><th scope="col">Col A</th><th scope="col">Col B</th></tr></thead>\n'
|
||
"<tbody><tr><td>1</td><td>2</td></tr></tbody>\n"
|
||
"</table>\n</div></p>"
|
||
)
|
||
|
||
|
||
def _text(path: Path) -> str:
|
||
assert path.is_file(), f"missing frontend file: {path}"
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _rules_for(css: str, selector: str) -> list[str]:
|
||
"""The body of every rule whose selector list contains *selector*
|
||
verbatim (comment-stripped, whitespace-normalized) — the shape the
|
||
phase-138 ``.md-quote`` pin uses, generalized to the grouped
|
||
selectors task 04 writes for ``h5``/``h6``."""
|
||
stripped = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||
bodies: list[str] = []
|
||
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", stripped):
|
||
selectors = [s.strip() for s in " ".join(match.group(1).split()).split(",")]
|
||
if selector in selectors:
|
||
bodies.append(match.group(2))
|
||
return bodies
|
||
|
||
|
||
def _root_palette(css: str) -> set[str]:
|
||
"""Every variable name the ``:root`` block declares (the single
|
||
source of truth the palette-only pins check against)."""
|
||
root = re.search(r":root\s*\{", css)
|
||
assert root, "styles.css must define :root"
|
||
body = css[root.end() : css.find("}", root.end())]
|
||
palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", body))
|
||
assert palette, "the :root palette must declare variables"
|
||
return palette
|
||
|
||
|
||
def _assert_palette_only(body: str, label: str) -> None:
|
||
"""No colour literal anywhere in the rule, and every ``var()`` reference
|
||
is a declared :root palette var (the monochrome invariant + the
|
||
:root-drift guard, cf. tests/unit/test_markdown_blockquotes.py)."""
|
||
refs = re.findall(r"var\((--[a-z0-9-]+)\)", body)
|
||
assert refs, f"{label} must reference palette vars"
|
||
for ref in refs:
|
||
assert ref in _ROOT, f"{label}: {ref} is not a :root palette var"
|
||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", body), f"no hex color literal in {label}"
|
||
assert "rgb(" not in body and "rgba(" not in body and "hsl(" not in body, (
|
||
f"no rgb()/hsl() color literal in {label}"
|
||
)
|
||
|
||
|
||
_ROOT: set[str] = _root_palette(_text(STYLES_CSS))
|
||
|
||
|
||
def _run_node(script: str) -> str:
|
||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||
return proc.stdout
|
||
|
||
|
||
def _render(cases: dict[str, str]) -> dict[str, str]:
|
||
"""Render every case through the REAL renderer under node and return
|
||
the ``{name: html}`` map (the phase-138 harness shape)."""
|
||
js = _text(MARKDOWN_JS)
|
||
driver = (
|
||
js
|
||
+ "\nconst out = {}; "
|
||
+ "".join(f"out.{name} = renderMarkdown({value!r}); " for name, value in cases.items())
|
||
+ "console.log(JSON.stringify(out));"
|
||
)
|
||
results: dict[str, str] = json.loads(_run_node(driver))
|
||
return results
|
||
|
||
|
||
# ---------- markdown.js: the link rule in the inline chain ----------
|
||
|
||
|
||
def test_inline_chain_order_is_emphasis_images_link_headings_lists() -> None:
|
||
"""The inline chain order is code → strong → em → **linked-image →
|
||
image → link** → headings → lists (phase 180 D1/D3/D4):
|
||
|
||
* the link rule runs AFTER the emphasis rules (so link text carrying
|
||
``**strong**``/``*em*``/``code`` is already transformed when it gets
|
||
wrapped) and BEFORE the ``^### `` heading rule;
|
||
* BOTH image rules sit AHEAD of the link rule — the linked-image rule
|
||
first of all, so ``[](url)`` cannot leave a stray ``[``
|
||
inside the link text (LOCKED A6), then the plain image rule, so the
|
||
``!`` of an image is never orphaned into ``!<a>``.
|
||
|
||
Everything else in the chain stays where the pre-phase renderer had
|
||
it; all of it lives inside the shared ``inline()`` helper, so a table
|
||
cell gets the identical pipeline (proved by the cell cases below)."""
|
||
js = _text(MARKDOWN_JS)
|
||
anchors = [
|
||
('s.replace(/`([^`\\n]+)`/g, "<code>$1</code>")', "code span"),
|
||
('.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>")', "strong"),
|
||
('.replace(/(^|[\\s(])\\*([^*\\n]+)\\*/g, "$1<em>$2</em>")', "em"),
|
||
(LINKED_IMAGE_RULE, "linked image"),
|
||
(IMAGE_RULE, "image"),
|
||
(LINK_RULE, "link"),
|
||
(H3_RULE, "### heading"),
|
||
('.replace(/^\\s*[-*] (.*)$/gm, "<li>$1</li>")', "bullet list"),
|
||
]
|
||
positions: list[int] = []
|
||
for anchor, label in anchors:
|
||
pos = js.find(anchor)
|
||
assert pos != -1, f"missing the {label} rule in the inline chain: {anchor!r}"
|
||
assert js.count(anchor) == 1, f"the {label} rule must appear exactly once"
|
||
positions.append(pos)
|
||
assert positions == sorted(positions), (
|
||
"the inline chain order must be code → strong → em → linked-image → "
|
||
f"image → link → headings → lists, got rules at {positions!r} for "
|
||
f"{[label for _, label in anchors]!r}"
|
||
)
|
||
# every phase-180 rule is INSIDE the shared `inline` helper (so a
|
||
# table cell gets it too — the cell cases below prove the behavior)
|
||
inline_start = js.find("const inline = (s) =>")
|
||
assert inline_start != -1, "the shared inline() helper is missing"
|
||
for anchor, label in anchors:
|
||
assert inline_start < js.find(anchor) < js.find("// 1b."), (
|
||
f"the {label} rule must live in the shared inline() chain, "
|
||
"not in a one-off pass"
|
||
)
|
||
|
||
|
||
def test_link_marker_and_external_target_rel_pair() -> None:
|
||
"""The exact output marker is ``<a class="md-link"`` and the
|
||
``target="_blank" rel="noopener noreferrer"`` pair is emitted ONLY
|
||
for absolute ``http(s)://`` hrefs — the pair appears once in the
|
||
source, inside the conditional that tests ``^https?://`` (a
|
||
``mailto:``/relative/fragment link carries neither). Task 02 gave the
|
||
linked-image rule the SAME anchor markup, so both callers share the
|
||
ONE ``mdAnchorOpen`` helper instead of duplicating the pair (which is
|
||
what keeps this single-source pin true)."""
|
||
js = _text(MARKDOWN_JS)
|
||
assert LINK_MARKER in js, "the exact <a class=\"md-link\" marker must be emitted"
|
||
assert js.count(TARGET_REL) == 1, (
|
||
"the target/rel pair must appear exactly once (the external branch)"
|
||
)
|
||
assert 'const external = /^https?:\\/\\//i.test(safe);' in js, (
|
||
"the external test must be the ^https?:// shape (case-insensitive)"
|
||
)
|
||
# the one anchor builder: defined once, used by the link rule and by
|
||
# the linked-image rule (locked A6)
|
||
assert js.count("function mdAnchorOpen(safe) {") == 1
|
||
assert js.count("mdAnchorOpen(safeHref)") == 1, "the linked-image rule wraps its <img>"
|
||
assert js.count("${mdAnchorOpen(safe)}") == 1, "the link rule uses the same builder"
|
||
|
||
|
||
def test_link_url_allowlist_function_is_the_explicit_gate() -> None:
|
||
"""LOCKED A5: the URL is validated by an allowlist FUNCTION (not a
|
||
regex-in-replacement). ``allowLinkUrl`` accepts ``http(s)://``,
|
||
``mailto:``, ``/``-rooted, ``./``/``../`` relative and ``#fragment``
|
||
URLs and returns them VERBATIM; everything else (``javascript:``,
|
||
``data:``, ``vbscript:``, ``file:``, ``blob:``) returns ``null``, and
|
||
the caller then returns the ORIGINAL match — inert escaped text,
|
||
never a live element and never a dropped string."""
|
||
js = _text(MARKDOWN_JS)
|
||
assert "function allowLinkUrl(url) {" in js, "the allowlist is a named function"
|
||
allowlist = re.search(r"function allowLinkUrl\(url\) \{(?P<body>.*?)\n\}", js, re.S)
|
||
assert allowlist, "allowLinkUrl must be a top-level function"
|
||
body = allowlist.group("body")
|
||
for shape, label in [
|
||
(r"https?:\/\/", "http(s)://"),
|
||
(r"mailto:", "mailto:"),
|
||
(r"\/", "/-rooted"),
|
||
(r"\.{1,2}\/", "./ and ../ relative"),
|
||
(r"#", "#fragment"),
|
||
]:
|
||
assert shape in body, f"the allowlist must accept {label} URLs ({shape})"
|
||
assert "null" in body, "a rejected URL must return null"
|
||
assert "return _m;" in js, (
|
||
"a rejected URL must return the ORIGINAL matched text (inert, not dropped)"
|
||
)
|
||
# the allowlist is a function call inside the replacement, and the
|
||
# rejected branch is checked BEFORE any anchor is built. The anchor
|
||
# markup itself is single-sourced in mdAnchorOpen (task 02's
|
||
# linked-image rule is its second caller), so "precedes the anchor"
|
||
# is asserted per rule: the allowlist call comes first, the builder
|
||
# call only ever runs on an accepted URL.
|
||
assert "const safe = allowLinkUrl(url);" in js, "the replacement calls the allowlist"
|
||
anchor_code = js.find('<a class="md-link" href="${safe}"')
|
||
assert anchor_code != -1, "the anchor template interpolates the allowlisted URL"
|
||
helper = js.find("function mdAnchorOpen(safe) {")
|
||
assert helper != -1 and helper < anchor_code, (
|
||
"the anchor markup is built by the shared mdAnchorOpen helper"
|
||
)
|
||
assert js.find("function allowLinkUrl(url) {") < helper, (
|
||
"the helper takes an ALREADY-allowlisted URL, it does not decide"
|
||
)
|
||
for rule, end, allow_call in (
|
||
(LINKED_IMAGE_RULE, IMAGE_RULE, "const safeHref = allowLinkUrl(url);"),
|
||
(LINK_RULE, H3_RULE, "const safe = allowLinkUrl(url);"),
|
||
):
|
||
start = js.find(rule)
|
||
assert start != -1, f"missing rule: {rule!r}"
|
||
body = js[start : js.find(end, start)]
|
||
assert allow_call in body, f"the rule must call the link allowlist: {allow_call}"
|
||
assert "mdAnchorOpen(" in body, "the rule builds its anchor through the helper"
|
||
assert body.find(allow_call) < body.find("mdAnchorOpen("), (
|
||
"the allowlist check precedes the anchor output in every rule"
|
||
)
|
||
assert "return _m;" in body, "a rejected href keeps the original text"
|
||
|
||
|
||
def test_renderer_never_unescapes_or_decodes_urls() -> None:
|
||
"""Design D2 — escape-first IS the security boundary: ``inline()``
|
||
always receives HTML-escaped text, so a captured URL may contain
|
||
``&``/``'``/``"`` (valid in an href) and is interpolated
|
||
VERBATIM. Any decoding/re-escaping would re-open the injection
|
||
surface the renderer exists to close, so the file must contain no
|
||
decoder and no HTML sink."""
|
||
js = _text(MARKDOWN_JS)
|
||
for forbidden in (
|
||
"decodeURIComponent",
|
||
"decodeURI(",
|
||
"unescape(",
|
||
"DOMParser",
|
||
"innerHTML",
|
||
"insertAdjacentHTML",
|
||
"eval(",
|
||
):
|
||
assert forbidden not in js, f"markdown.js must never use {forbidden} (design D2)"
|
||
|
||
|
||
# ---------- markdown.js: the image + linked-image rules (task 02) ----------
|
||
|
||
|
||
def test_image_rules_emit_marker_alt_and_lazy() -> None:
|
||
"""Task 02's output contract at source level: BOTH image templates
|
||
are ``<img class="md-img" src="…" alt="…" loading="lazy">`` — the
|
||
marker appears exactly twice (the plain rule + the linked-image rule)
|
||
and EACH of them carries ``alt`` (always emitted, empty allowed — an
|
||
``<img>`` without an alt attribute fails WCAG 1.1) and
|
||
``loading="lazy"``. There is deliberately no inline ``onerror``
|
||
handler anywhere in the renderer: it returns an HTML string into
|
||
innerHTML-style surfaces and the CSP/no-CDN posture forbids inline
|
||
handlers, so a broken src degrades to the browser's alt text."""
|
||
js = _text(MARKDOWN_JS)
|
||
# the two CODE templates (the marker string also appears in the prose
|
||
# that explains it, so the interpolation shape is what is counted)
|
||
assert js.count('<img class="md-img" src="${safe}"') == 1, (
|
||
"the plain image rule emits exactly one <img class=\"md-img\" template"
|
||
)
|
||
assert js.count('<img class="md-img" src="${safeSrc}"') == 1, (
|
||
"the linked-image rule emits exactly one <img class=\"md-img\" template"
|
||
)
|
||
assert js.count(IMG_ALT_LAZY) == 2, (
|
||
"every image template ends in alt=\"${alt}\" loading=\"lazy\"> — "
|
||
"alt is always emitted, next to the lazy hint"
|
||
)
|
||
assert js.count(LAZY_ATTR) >= 2
|
||
assert js.count(IMG_MARKER) >= 2
|
||
# (the word appears in the prose that explains WHY there is none — an
|
||
# inline handler would be an attribute, so that is what is forbidden)
|
||
for forbidden in (" onerror=", " onload=", " onclick=", " onerror ="):
|
||
assert forbidden not in js, f"markdown.js must never emit {forbidden.strip()!r}"
|
||
|
||
|
||
def test_linked_image_rule_checks_both_allowlists_before_emitting() -> None:
|
||
"""LOCKED A5 + A6 at source level: the linked-image rule captures
|
||
THREE groups (alt, src, url) and validates BOTH URLs — the src
|
||
through ``allowImageUrl``, the href through ``allowLinkUrl`` — with
|
||
either rejection returning the ORIGINAL match. So an image wrapped in
|
||
a link is only ever live when both halves pass."""
|
||
js = _text(MARKDOWN_JS)
|
||
start = js.find(LINKED_IMAGE_RULE)
|
||
assert start != -1, "the linked-image rule is missing"
|
||
body = js[start : js.find(IMAGE_RULE)]
|
||
assert "allowImageUrl(src);" in body, "the src goes through the image allowlist"
|
||
assert "allowLinkUrl(url);" in body, "the href goes through the link allowlist"
|
||
assert body.count("return _m;") == 2, "either rejection keeps the original text"
|
||
assert IMG_MARKER in body, "the accepted case wraps the image inside the anchor"
|
||
assert "</a>" in body, "the anchor is closed after the image"
|
||
# the plain image rule validates its src the same way
|
||
plain = js[js.find(IMAGE_RULE) : js.find(LINK_RULE)]
|
||
assert "allowImageUrl(src);" in plain and "return _m;" in plain, (
|
||
"the plain image rule is allowlist-gated too (locked A5)"
|
||
)
|
||
|
||
|
||
def test_image_url_allowlist_function_is_the_explicit_gate() -> None:
|
||
"""LOCKED A5 for images: a named ``allowImageUrl`` function is the
|
||
gate (not a regex-in-replacement). It accepts ``/``-rooted served
|
||
paths, ``./``/``../`` relative paths, ``http(s)://`` and
|
||
``data:image/…`` (the app's own CSP already allows ``data:`` images —
|
||
``img-src 'self' data:``, so no CSP change is involved) and returns
|
||
them VERBATIM; everything else returns ``null``."""
|
||
js = _text(MARKDOWN_JS)
|
||
assert "function allowImageUrl(url) {" in js, "the image allowlist is a named function"
|
||
allowlist = re.search(r"function allowImageUrl\(url\) \{(?P<body>.*?)\n\}", js, re.S)
|
||
assert allowlist, "allowImageUrl must be a top-level function"
|
||
body = allowlist.group("body")
|
||
for shape, label in [
|
||
(r"https?:\/\/", "http(s)://"),
|
||
(r"data:image\/", "data:image/*"),
|
||
(r"|\/|", "/-rooted"),
|
||
(r".{0,2}\/", "./ and ../ relative"),
|
||
]:
|
||
assert shape in body, f"the image allowlist must accept {label} URLs ({shape})"
|
||
assert r"/^(?:https?:\/\/|data:image\/|\/|.{0,2}\/)/i" in body, (
|
||
"the image allowlist is ONE anchored alternation, case-insensitive"
|
||
)
|
||
assert "null" in body, "a rejected src must return null"
|
||
# the image allowlist never accepts a link-only scheme (a fragment is
|
||
# not an image) and the link allowlist never accepts data: URLs
|
||
assert "mailto" not in body, "mailto: is not an image src"
|
||
link_allowlist = re.search(r"function allowLinkUrl\(url\) \{(?P<b>.*?)\n\}", js, re.S)
|
||
assert link_allowlist, "allowLinkUrl must be a top-level function"
|
||
assert "data:" not in link_allowlist.group("b"), "links stay closed to data: URLs (A5)"
|
||
|
||
|
||
def test_module_docstring_records_the_image_contract() -> None:
|
||
"""Task 02 work item 4: the module docstring carries the final chain
|
||
order, the image allowlist, and the documented limits — including the
|
||
size-less-SVG one that belongs to phase 181 (so the next reader does
|
||
not re-decide it)."""
|
||
js = _text(MARKDOWN_JS)
|
||
docstring = js[: js.find("*/")]
|
||
for needle, label in [
|
||
("linked-image → image → link", "the final chain order"),
|
||
("allowImageUrl", "the image allowlist by name"),
|
||
('alt=""', "the empty-alt (decorative image) case"),
|
||
("img-src 'self' data:", "the CSP reason data: images are allowed"),
|
||
("viewBox", "the size-less-SVG limit"),
|
||
("phase 181", "where the vector sizing contract lives instead"),
|
||
("NO `onerror` handler", "the deliberate no-inline-handler choice"),
|
||
]:
|
||
assert needle in docstring, f"the module docstring must record {label}: {needle!r}"
|
||
|
||
|
||
# ---------- markdown.js: the heading ladder (task 03) ----------
|
||
|
||
|
||
#: The three pre-existing heading rules, whose output must stay exactly as
|
||
#: it was (LOCKED A4 — the phase ADDS levels below ``###``, it does not
|
||
#: re-rank the existing ones).
|
||
H3_RULE = '.replace(/^### (.*)$/gm, "<h4>$1</h4>")'
|
||
H2_RULE = '.replace(/^## (.*)$/gm, "<h3>$1</h3>")'
|
||
H1_RULE = '.replace(/^# (.*)$/gm, "<h3>$1</h3>")'
|
||
#: The two rules task 03 adds (locked A4): ``####`` → ``<h5>`` and
|
||
#: ``#####``/``######`` → ``<h6>`` (HTML has no ``<h7>``, and the app's
|
||
#: type scale stops two levels below ``###``).
|
||
H4_RULE = '.replace(/^#### (.*)$/gm, "<h5>$1</h5>")'
|
||
H56_RULE = '.replace(/^#{5,6} (.*)$/gm, "<h6>$1</h6>")'
|
||
#: The step-3 paragraph guard. ``h\\d`` already covers ``h5``/``h6``, so
|
||
#: task 03 deliberately changed NOTHING here — pinned, not assumed.
|
||
PARAGRAPH_GUARD = "/^<(h\\d|ul|ol|pre|li|blockquote)/"
|
||
|
||
|
||
def test_heading_ladder_rules_sit_deeper_first_before_h3() -> None:
|
||
"""Task 03 work items 1–2 (LOCKED A4) at source level: the two new
|
||
rules are written DEEPER-FIRST — ``/^#### /`` then ``/^#{5,6} /`` —
|
||
immediately BEFORE the existing ``/^### /`` rule, and the existing
|
||
``#``/``##``/``###`` rules are untouched (``#``/``##`` still map to
|
||
``<h3>``, ``###`` still to ``<h4>``).
|
||
|
||
Every rule requires a SPACE after its hash run, which is what makes
|
||
cross-matching impossible (``#### x`` never matches ``/^### /``,
|
||
``##### x`` never matches ``/^#### /``) so a deeper heading can never
|
||
be partially eaten by a shallower rule. Every rung lives in the shared
|
||
``inline()`` chain, so a table cell gets the ladder too."""
|
||
js = _text(MARKDOWN_JS)
|
||
ladder = [
|
||
(H4_RULE, "#### → <h5>"),
|
||
(H56_RULE, "#####/###### → <h6>"),
|
||
(H3_RULE, "### → <h4>"),
|
||
(H2_RULE, "## → <h3>"),
|
||
(H1_RULE, "# → <h3>"),
|
||
]
|
||
for rule, label in ladder:
|
||
assert js.count(rule) == 1, f"the {label} rule must appear exactly once: {rule!r}"
|
||
# every rule captures AFTER a literal space, which is what makes
|
||
# cross-matching impossible (`#### x` never matches `/^### /`)
|
||
assert " (.*)$/gm" in rule, f"{rule!r} must require a space after its hashes"
|
||
# the ladder reads top-down by depth: the new rungs come first
|
||
for earlier, later, label in [
|
||
(H4_RULE, H56_RULE, "#### before #####/######"),
|
||
(H56_RULE, H3_RULE, "#####/###### before ###"),
|
||
(H3_RULE, H2_RULE, "### before ##"),
|
||
(H2_RULE, H1_RULE, "## before #"),
|
||
]:
|
||
assert js.find(earlier) < js.find(later), f"ladder order broken: {label}"
|
||
assert js.find(H1_RULE) < js.find('.replace(/^\\s*[-*] '), (
|
||
"the whole ladder must stay above the list rules"
|
||
)
|
||
inline_start = js.find("const inline = (s) =>")
|
||
for rule, label in [(H4_RULE, "####"), (H56_RULE, "#####/######")]:
|
||
assert inline_start < js.find(rule) < js.find("// 1b."), (
|
||
f"the {label} rule must live in the shared inline() chain"
|
||
)
|
||
|
||
|
||
def test_step3_paragraph_guard_already_covers_h5_h6() -> None:
|
||
"""Task 03 work item 3: the step-3 block guard is ``h\\d``-based, so it
|
||
ALREADY lets an ``<h5>``/``<h6>`` block through unwrapped and NO change
|
||
was needed there — this pins that the guard is still the untouched one
|
||
(narrowing it to ``h[1-4]`` would silently re-wrap every deep heading
|
||
in a ``<p>``)."""
|
||
js = _text(MARKDOWN_JS)
|
||
assert PARAGRAPH_GUARD in js, (
|
||
f"the step-3 guard must stay {PARAGRAPH_GUARD!r} (h\\d covers h5/h6)"
|
||
)
|
||
assert js.count(PARAGRAPH_GUARD) == 1, "there is exactly one paragraph guard"
|
||
assert "h[1-4]" not in js, "the guard must not be narrowed below h\\d"
|
||
|
||
|
||
def test_module_docstring_records_the_heading_ladder() -> None:
|
||
"""Task 03 work item 4: the module docstring documents the deliberate
|
||
flattening (``#``/``##`` both → ``<h3>``, ``#####``/``######`` both →
|
||
``<h6>``) and the space-after-hashes / column-0 contract, so the next
|
||
reader does not re-decide it."""
|
||
js = _text(MARKDOWN_JS)
|
||
docstring = js[: js.find("*/")]
|
||
# strip the comment-continuation ` * ` markers, then re-flow: the pin is
|
||
# about the CONTENT of the docstring, not its line-wrapping
|
||
flat = " ".join(
|
||
" ".join(line.strip().removeprefix("*").strip() for line in docstring.splitlines()).split()
|
||
)
|
||
for needle, label in [
|
||
("`#`/`##` → `<h3>`", "the deliberate #/## flattening"),
|
||
("`###` → `<h4>`", "the ### rung"),
|
||
("`####` → `<h5>`", "the #### rung"),
|
||
("`#####` and `######` → `<h6>`", "the #####/###### rung"),
|
||
("locked A4", "the locked decision behind the ladder"),
|
||
("column 0", "the column-0 (m-flag) contract"),
|
||
("DELIBERATE", "that the flattening is deliberate"),
|
||
]:
|
||
assert needle in flat, f"the module docstring must record {label}: {needle!r}"
|
||
|
||
|
||
# ---------- styles.css: the task 04 stylesheet contract ----------
|
||
|
||
|
||
def test_md_link_rules_exist_and_reference_palette_vars_only() -> None:
|
||
"""Task 04's link styling: ``.md-link`` paints the theme's AA-safe
|
||
brand text colour (``--brand-ink``, 9.9:1 on ``--surface``) and is
|
||
underlined UNCONDITIONALLY — a link in running text must not rely on
|
||
colour alone (WCAG 1.4.1) — with ``--brand-hover`` on ``:hover``;
|
||
``.msg.user .bubble .md-link`` is the ``.md-quote`` inversion (the
|
||
bubble's own ``--bg`` ink on the brand fill, 5.2:1) and keeps its own
|
||
``:hover``. Every one of those rules references :root palette vars
|
||
ONLY (no hex/rgb/hsl literal: the :root-drift unit test parses the
|
||
built-ins out of this file, so a literal would be a second source of
|
||
truth and a new hue would break the monochrome invariant), and the
|
||
rule block carries the house contrast-ratio note."""
|
||
css = _text(STYLES_CSS)
|
||
for selector in (
|
||
".md-link",
|
||
".md-link:hover",
|
||
".msg.user .bubble .md-link",
|
||
".msg.user .bubble .md-link:hover",
|
||
):
|
||
bodies = _rules_for(css, selector)
|
||
assert bodies, f"styles.css must define {selector}"
|
||
for body in bodies:
|
||
_assert_palette_only(body, selector)
|
||
link_body = _rules_for(css, ".md-link")[0]
|
||
assert "color: var(--brand-ink)" in link_body, (
|
||
".md-link must use the :root AA-safe brand text token"
|
||
)
|
||
assert "text-decoration: underline" in link_body, (
|
||
"a link must never rely on colour alone (WCAG 1.4.1)"
|
||
)
|
||
assert "color: var(--brand-hover)" in _rules_for(css, ".md-link:hover")[0], (
|
||
".md-link:hover must brighten on the shared --brand-hover token"
|
||
)
|
||
# the user-bubble inversion (the .md-quote precedent at L588)
|
||
inversion = _rules_for(css, ".msg.user .bubble .md-link")[0]
|
||
assert "color: var(--bg)" in inversion, (
|
||
"the link inside the brand-filled user bubble keeps the bubble's ink"
|
||
)
|
||
# the house convention: the comment above the rule carries the ratio
|
||
rule_pos = css.find(".md-link {")
|
||
assert rule_pos != -1
|
||
comment_block = css[css.rfind("/*", 0, rule_pos) : rule_pos]
|
||
ratios = [float(r) for r in re.findall(r"(\d+(?:\.\d+)?)\s*:\s*1", comment_block)]
|
||
assert ratios and max(ratios) >= 4.5, (
|
||
f"the .md-link rule must carry a >=4.5:1 contrast note, got {ratios!r}"
|
||
)
|
||
|
||
|
||
def test_md_img_rule_caps_the_image_and_uses_the_card_language() -> None:
|
||
"""Task 04's image styling: ``.md-img`` is capped to its container
|
||
(``max-width: 100%`` + ``height: auto`` + ``object-fit: contain``, no
|
||
fixed width/height) so a wide diagram can never push a horizontal
|
||
scrollbar out of an answer at 375px or 1440px, and it carries the
|
||
app's existing bordered-image card language (the ``.source-image-img`
|
||
treatment: ``--surface`` plate, ``--line`` hairline, ``--radius-sm``).
|
||
Palette vars only — no literal."""
|
||
bodies = _rules_for(_text(STYLES_CSS), ".md-img")
|
||
assert bodies, "styles.css must define .md-img"
|
||
body = bodies[0]
|
||
_assert_palette_only(body, ".md-img")
|
||
for declaration in ("max-width: 100%", "height: auto", "object-fit: contain"):
|
||
assert declaration in body, f".md-img must declare {declaration}"
|
||
for token in ("var(--surface)", "var(--line)", "var(--radius-sm)"):
|
||
assert token in body, f".md-img must carry the card language ({token})"
|
||
assert not re.search(r"^\s*(?:width|height):\s*\d", body, re.M), (
|
||
".md-img must not pin a fixed width/height (any aspect ratio fits)"
|
||
)
|
||
|
||
|
||
def test_h5_h6_type_scale_extends_the_ladder_monotonically() -> None:
|
||
"""Task 04's heading ladder: the renderer emits ``<h5>``/``<h6>`` and
|
||
neither tag had a rule before the phase, so ONE grouped rule per level
|
||
covers every renderer context (the viewer's ``.doc-md``, the chat and
|
||
shared-page ``.msg .bubble``, the ``.thinking-text`` scratchpad — no
|
||
chat-specific duplication) and sizes them into the existing
|
||
``.doc-md`` scale MONOTONICALLY (h3 1.15rem → h4 1rem → h5 0.95rem →
|
||
h6 0.88rem), in rem (never px) so the ladder survives zoom, and with
|
||
NO colour declared (each context keeps its own AA ink, so the two new
|
||
rungs add no new contrast pair)."""
|
||
css = _text(STYLES_CSS)
|
||
for level, context in (("h5", ".doc-md h5"), ("h6", ".doc-md h6")):
|
||
bodies = _rules_for(css, context)
|
||
assert bodies, f"styles.css must size the {level} rung ({context})"
|
||
body = bodies[0]
|
||
size = re.search(r"font-size:\s*([\d.]+)rem", body)
|
||
assert size, f"{context} must set font-size in rem"
|
||
assert "px" not in body.split("font-size")[1].split(";")[0], (
|
||
f"{context} font-size must be rem, not px"
|
||
)
|
||
assert "color" not in body, f"{context} must not declare a colour"
|
||
# ONE rule set for every renderer context (no chat duplication)
|
||
assert _rules_for(css, f".msg .bubble {level}"), f"missing .msg .bubble {level}"
|
||
assert _rules_for(css, f".thinking-text {level}"), f"missing .thinking-text {level}"
|
||
|
||
def rem(selector: str) -> float:
|
||
found = re.search(
|
||
r"font-size:\s*([\d.]+)rem", _rules_for(css, selector)[0]
|
||
)
|
||
assert found, f"{selector} must set a rem font-size"
|
||
return float(found.group(1))
|
||
|
||
ladder = [rem(s) for s in (".doc-md h3", ".doc-md h4", ".doc-md h5", ".doc-md h6")]
|
||
assert ladder == sorted(ladder, reverse=True) and len(set(ladder)) == 4, (
|
||
f"the type scale must be strictly monotonic, got {ladder!r}"
|
||
)
|
||
|
||
|
||
# ---------- behavior (node): the story's acceptance shapes ----------
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_link_transform_shapes_under_node() -> None:
|
||
"""The story shapes executed under node (TODO.md L4): (1) an absolute
|
||
link renders with ``target="_blank" rel="noopener noreferrer"`` and a
|
||
``&`` in the query string keeping its escaped ``&`` form verbatim;
|
||
(2) ``http://`` too; (3) ``mailto:``, ``/``-rooted, ``./``/``../``
|
||
and ``#fragment`` links render WITHOUT target/rel (same tab);
|
||
(4) link text carrying already-transformed markup keeps it inside the
|
||
anchor; (5) HTML in the link text/URL stays escaped — the anchor is
|
||
the only live element; (6) an empty link text is allowed; (7) a link
|
||
inside a fenced block stays fence content (step 1 protects first);
|
||
(8) a link inside a table cell renders (the cell goes through the
|
||
same ``inline()``)."""
|
||
empty = "[](https://x.test)"
|
||
cases = {
|
||
"abs": "see [the docs](https://example.com/a?x=1&y=2)",
|
||
"http": "[a](http://x.test)",
|
||
"mailto": "[m](mailto:reese@example.com)",
|
||
"root": "[r](/docs/thing.md)",
|
||
"dotrel": "[d](./x.md)\n\n[u](../y.md)",
|
||
"frag": "[f](#section-2)",
|
||
"rich": "[**bold** *i* `c`](https://x.test)",
|
||
"esc": "[<script>alert(1)</script>](https://x.test?a=1&b=2)",
|
||
"empty": empty,
|
||
"fence": "```\n[a](https://x.test)\n```",
|
||
"cell": "| h |\n|---|\n| [cell](https://x.test) |",
|
||
}
|
||
exact = {
|
||
"abs": (
|
||
'<p>see <a class="md-link" href="https://example.com/a?x=1&y=2"'
|
||
' target="_blank" rel="noopener noreferrer">the docs</a></p>'
|
||
),
|
||
"http": (
|
||
'<p><a class="md-link" href="http://x.test"'
|
||
' target="_blank" rel="noopener noreferrer">a</a></p>'
|
||
),
|
||
"mailto": '<p><a class="md-link" href="mailto:reese@example.com">m</a></p>',
|
||
"root": '<p><a class="md-link" href="/docs/thing.md">r</a></p>',
|
||
"dotrel": (
|
||
'<p><a class="md-link" href="./x.md">d</a></p>'
|
||
'<p><a class="md-link" href="../y.md">u</a></p>'
|
||
),
|
||
"frag": '<p><a class="md-link" href="#section-2">f</a></p>',
|
||
"rich": (
|
||
'<p><a class="md-link" href="https://x.test"'
|
||
' target="_blank" rel="noopener noreferrer">'
|
||
"<strong>bold</strong> <em>i</em> <code>c</code></a></p>"
|
||
),
|
||
"esc": (
|
||
'<p><a class="md-link" href="https://x.test?a=1&b=2"'
|
||
' target="_blank" rel="noopener noreferrer">'
|
||
"<script>alert(1)</script></a></p>"
|
||
),
|
||
"empty": (
|
||
'<p><a class="md-link" href="https://x.test"'
|
||
' target="_blank" rel="noopener noreferrer"></a></p>'
|
||
),
|
||
"fence": "<p><pre><code>[a](https://x.test)</code></pre></p>",
|
||
}
|
||
results = _render(cases)
|
||
for name, html in results.items():
|
||
if name in exact:
|
||
assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}"
|
||
assert (
|
||
'<td><a class="md-link" href="https://x.test"'
|
||
' target="_blank" rel="noopener noreferrer">cell</a></td>'
|
||
) in results["cell"], "a link inside a table cell renders through inline()"
|
||
assert "<blockquote" not in results["cell"], "the link must not disturb the table pass"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_disallowed_urls_stay_inert_text() -> None:
|
||
"""LOCKED A5 under node: a ``javascript:``, ``data:``, ``vbscript:``,
|
||
``file:`` or ``blob:`` URL (any case) never becomes a live element —
|
||
the whole ``[text](url)`` construct renders verbatim as the inert
|
||
escaped text it was before, with no ``<a`` and no ``href`` anywhere
|
||
in the output."""
|
||
bad = {
|
||
"js": "[click](javascript:alert(1))",
|
||
"js_upper": "[click](JaVaScRiPt:alert(1))",
|
||
"data": "[d](data:text/html;base64,PHN2Zz4=)",
|
||
"vbscript": "[v](vbscript:msgbox(1))",
|
||
"file": "[f](file:///etc/passwd)",
|
||
"blob": "[b](blob:https://x.test/uuid)",
|
||
}
|
||
results = _render(bad)
|
||
for name, src in bad.items():
|
||
html = results[name]
|
||
assert html == f"<p>{src}</p>", f"{name}: {html!r} must stay verbatim text"
|
||
assert "<a" not in html and "href" not in html, f"{name}: {html!r} is a live anchor"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_allowlink_url_function_accepts_and_rejects() -> None:
|
||
"""The allowlist function itself under node: accepted URLs come back
|
||
VERBATIM (escape-first — a ``&`` in the query string is not
|
||
decoded), everything else comes back ``null``."""
|
||
cases: list[tuple[str, str | None]] = [
|
||
# accepted — returned VERBATIM (escape-first: the & stays)
|
||
("https://x.test/a?x=1&y=2", "https://x.test/a?x=1&y=2"),
|
||
("http://x.test", "http://x.test"),
|
||
("HTTPS://X.TEST", "HTTPS://X.TEST"),
|
||
("mailto:a@x.test", "mailto:a@x.test"),
|
||
("/docs/x.md", "/docs/x.md"),
|
||
("./x.md", "./x.md"),
|
||
("../x.md", "../x.md"),
|
||
("#frag", "#frag"),
|
||
# rejected -> null (LOCKED A5): the construct stays inert text
|
||
("javascript:alert(1)", None),
|
||
("JaVaScRiPt:alert(1)", None),
|
||
("data:text/html,x", None),
|
||
("vbscript:x", None),
|
||
("file:///etc/passwd", None),
|
||
("blob:https://x.test/u", None),
|
||
("x.test/no-scheme", None),
|
||
("", None),
|
||
("..%2fetc%2fpasswd", None),
|
||
("javascript:alert(1)", None),
|
||
]
|
||
driver = (
|
||
_text(MARKDOWN_JS)
|
||
+ "\nconst in_ = "
|
||
+ json.dumps([c[0] for c in cases])
|
||
+ ";\nconsole.log(JSON.stringify(in_.map(allowLinkUrl)));"
|
||
)
|
||
got = json.loads(_run_node(driver))
|
||
assert got == [expected for _, expected in cases], f"allowLinkUrl mismatch: {got!r}"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_image_transform_shapes_under_node() -> None:
|
||
"""The image shapes executed under node (TODO.md L3): a served-path
|
||
image (the app's own ``/api/documents/<id>/image`` shape), a
|
||
``data:image/*`` image (CSP ``img-src 'self' data:`` already permits
|
||
it), an absolute ``http(s)://`` image, ``./``/``../`` relative images,
|
||
an EMPTY alt (decorative — the attribute is still there), HTML in the
|
||
alt staying escaped (so the attribute cannot be broken out of), two
|
||
images on one line, an image inside a blockquote, an image inside a
|
||
fenced block staying code (step 1 protects first), and an image inside
|
||
a table cell rendering through the same ``inline()``."""
|
||
cases = {
|
||
"served": "",
|
||
"assets": "",
|
||
"data": "",
|
||
"abs": "",
|
||
"dotrel": "\n\n",
|
||
"empty_alt": "",
|
||
"esc_alt": "",
|
||
"two": "",
|
||
"quote": "> ",
|
||
"fence": "```\n\n```",
|
||
"cell": "| h |\n|---|\n|  |",
|
||
}
|
||
exact = {
|
||
"served": (
|
||
'<p><img class="md-img" src="/api/documents/aaaa/image" '
|
||
'alt="topology" loading="lazy"></p>'
|
||
),
|
||
"assets": (
|
||
'<p><img class="md-img" src="/assets/brand.svg" alt="logo" loading="lazy"></p>'
|
||
),
|
||
"data": (
|
||
'<p><img class="md-img" src="data:image/svg+xml;base64,PHN2Zz4=" '
|
||
'alt="i" loading="lazy"></p>'
|
||
),
|
||
"abs": (
|
||
'<p><img class="md-img" src="https://x.test/i.png" alt="a" loading="lazy"></p>'
|
||
),
|
||
"dotrel": (
|
||
'<p><img class="md-img" src="./x.png" alt="img" loading="lazy"></p>'
|
||
'<p><img class="md-img" src="../y.png" alt="up" loading="lazy"></p>'
|
||
),
|
||
"empty_alt": '<p><img class="md-img" src="/a.png" alt="" loading="lazy"></p>',
|
||
"esc_alt": (
|
||
'<p><img class="md-img" src="/a.png" '
|
||
'alt="he said "hi" & <b>more</b>" '
|
||
'loading="lazy"></p>'
|
||
),
|
||
"two": (
|
||
'<p><img class="md-img" src="/a.png" alt="a" loading="lazy">'
|
||
'<img class="md-img" src="/b.png" alt="b" loading="lazy"></p>'
|
||
),
|
||
"quote": (
|
||
'<blockquote class="md-quote">'
|
||
'<img class="md-img" src="/a.png" alt="q" loading="lazy">'
|
||
"</blockquote>"
|
||
),
|
||
"fence": "<p><pre><code></code></pre></p>",
|
||
}
|
||
results = _render(cases)
|
||
for name, html in results.items():
|
||
if name in exact:
|
||
assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}"
|
||
assert '<td><img class="md-img" src="/a.png" alt="c" loading="lazy"></td>' in results[
|
||
"cell"
|
||
], "an image inside a table cell renders through inline()"
|
||
# every live image carries an alt AND the lazy hint — no exceptions
|
||
for name in ("served", "assets", "data", "abs", "empty_alt", "cell"):
|
||
start = results[name].find(IMG_MARKER)
|
||
assert start != -1, f"{name}: no <img> rendered — {results[name]!r}"
|
||
tag = results[name][start : results[name].find(">", start) + 1]
|
||
assert "alt=" in tag and LAZY_ATTR in tag, f"{name}: {tag!r} misses alt/lazy"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_disallowed_image_srcs_stay_inert_text() -> None:
|
||
"""LOCKED A5 under node: a ``javascript:``, ``file:``, ``blob:``,
|
||
``vbscript:`` or NON-image ``data:`` src (any case) never becomes a
|
||
live ``<img>`` — the whole ```` construct renders verbatim
|
||
as the inert escaped text it was before, with no ``<img`` and no
|
||
``src=`` anywhere in the output. A bare relative path whose first
|
||
segment is longer than two characters is the documented miss."""
|
||
# value = the expected output: the construct verbatim, i.e. exactly as
|
||
# escape-first leaves it (an `&` in the source is `&` on the way out)
|
||
bad = {
|
||
"js": (")", ")"),
|
||
"js_upper": (")", ")"),
|
||
"data_html": (
|
||
"",
|
||
"",
|
||
),
|
||
# the `:` in the source is escaped like any other `&`, so the
|
||
# inert output carries `&#58;`
|
||
"data_escaped_colon": (
|
||
"",
|
||
"",
|
||
),
|
||
"file": ("", ""),
|
||
"blob": ("", ""),
|
||
"vbscript": (")", ")"),
|
||
"bare_relative": ("", ""),
|
||
"empty_src": ("![decorative]()", "![decorative]()"),
|
||
}
|
||
results = _render({name: src for name, (src, _e) in bad.items()})
|
||
for name, (src, expected) in bad.items():
|
||
html = results[name]
|
||
assert html == f"<p>{expected}</p>", (
|
||
f"{name}: {html!r} must stay verbatim text for {src!r}"
|
||
)
|
||
assert "<img" not in html and "src=" not in html, f"{name}: {html!r} is a live img"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_link_only_image_src_falls_through_to_the_link_rule() -> None:
|
||
"""Documented limit (pinned so a change is deliberate): an image src
|
||
rejected by ``allowImageUrl`` but ACCEPTED by ``allowLinkUrl`` — a
|
||
link-only shape used as an image src, i.e. ``#fragment`` or
|
||
``mailto:`` — reaches the link rule, so ```` renders as a
|
||
link behind the leftover ``!``. The rejected src never becomes a live
|
||
image, and the surviving element is an ordinary allowlisted link (the
|
||
same shape the pre-phase renderer produced for that input)."""
|
||
cases = {
|
||
"frag": (
|
||
"",
|
||
'<p>!<a class="md-link" href="#frag">f</a></p>',
|
||
),
|
||
"mailto": (
|
||
"",
|
||
'<p>!<a class="md-link" href="mailto:x@y.test">a</a></p>',
|
||
),
|
||
}
|
||
results = _render({name: src for name, (src, _e) in cases.items()})
|
||
for name, (src, expected) in cases.items():
|
||
assert results[name] == expected, f"{name} ({src}): {results[name]!r}"
|
||
assert IMG_MARKER not in results[name], f"{name}: {src} must not become an <img>"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_linked_image_wraps_without_stray_brackets() -> None:
|
||
"""LOCKED A6 under node: ``[](url)`` renders as ONE
|
||
``<a class="md-link">`` wrapping ONE ``<img class="md-img">`` with no
|
||
stray ``[``/`]` left in the output — for an absolute href (which
|
||
carries ``target``/``rel``, and keeps ``&`` in a query string
|
||
verbatim), for a root-relative href and for a ``mailto:`` href (both
|
||
without ``target``/``rel``). When only the HREF is rejected the
|
||
construct falls through to the plain image rule (the image renders
|
||
inside the still-inert ``[…](url)`` text — documented limit, never a
|
||
live anchor); when only the SRC is rejected the whole construct stays
|
||
verbatim text."""
|
||
cases = {
|
||
"abs": "[](https://example.com)",
|
||
"query": "[](https://x.test/x?a=1&b=2)",
|
||
"relative": "[](/page)",
|
||
"mailto": "[](mailto:a@x.test)",
|
||
"bad_href": "[](javascript:alert(1))",
|
||
"bad_src": "[)](https://example.com)",
|
||
}
|
||
exact = {
|
||
"abs": (
|
||
'<a class="md-link" href="https://example.com"'
|
||
' target="_blank" rel="noopener noreferrer">'
|
||
'<img class="md-img" src="/a.svg" alt="d" loading="lazy"></a>'
|
||
),
|
||
"query": (
|
||
'<a class="md-link" href="https://x.test/x?a=1&b=2"'
|
||
' target="_blank" rel="noopener noreferrer">'
|
||
'<img class="md-img" src="data:image/png;base64,YQ==" alt="linked" '
|
||
'loading="lazy"></a>'
|
||
),
|
||
"relative": (
|
||
'<a class="md-link" href="/page">'
|
||
'<img class="md-img" src="/a.svg" alt="d" loading="lazy"></a>'
|
||
),
|
||
"mailto": (
|
||
'<a class="md-link" href="mailto:a@x.test">'
|
||
'<img class="md-img" src="/a.svg" alt="d" loading="lazy"></a>'
|
||
),
|
||
}
|
||
results = _render(cases)
|
||
for name, inner in exact.items():
|
||
assert results[name] == f"<p>{inner}</p>", (
|
||
f"{name}: {results[name]!r} != contract {inner!r}"
|
||
)
|
||
assert "[" not in results[name][3:-4] and "]" not in results[name][3:-4], (
|
||
f"{name}: {results[name]!r} keeps a stray bracket"
|
||
)
|
||
for name in ("abs", "query", "relative", "mailto"):
|
||
html = results[name]
|
||
assert html.count(LINK_MARKER) == 1 and html.count(IMG_MARKER) == 1
|
||
assert f"{LINK_MARKER} " in html and "</a></p>" in html
|
||
# the href-only rejection: the image still renders, the anchor does not
|
||
href_rejected = results["bad_href"]
|
||
assert LINK_MARKER not in href_rejected and "href" not in href_rejected
|
||
assert IMG_MARKER in href_rejected, "documented fall-through to the image rule"
|
||
# the src-only rejection: everything stays verbatim text
|
||
src_rejected = results["bad_src"]
|
||
assert src_rejected == f"<p>{cases['bad_src']}</p>", f"bad_src: {src_rejected!r}"
|
||
assert IMG_MARKER not in src_rejected and LINK_MARKER not in src_rejected
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_allowimage_url_function_accepts_and_rejects() -> None:
|
||
"""The image allowlist itself under node: accepted srcs come back
|
||
VERBATIM (escape-first — an ``&`` inside the src is not decoded),
|
||
everything else comes back ``null``. ``data:`` is accepted ONLY for
|
||
``image/*`` (the app's CSP shape), and a link-only shape (``#frag``,
|
||
``mailto:``) is not an image."""
|
||
cases: list[tuple[str, str | None]] = [
|
||
# accepted — returned VERBATIM
|
||
("/api/documents/aaaa/image", "/api/documents/aaaa/image"),
|
||
("/assets/brand.svg", "/assets/brand.svg"),
|
||
("./x.png", "./x.png"),
|
||
("../y.png", "../y.png"),
|
||
("https://x.test/i.png", "https://x.test/i.png"),
|
||
("HTTP://X.TEST/I.PNG", "HTTP://X.TEST/I.PNG"),
|
||
("data:image/svg+xml;base64,PHN2Zz4=", "data:image/svg+xml;base64,PHN2Zz4="),
|
||
("DATA:IMAGE/PNG;base64,YQ==", "DATA:IMAGE/PNG;base64,YQ=="),
|
||
("/x.png?a=1&b=2", "/x.png?a=1&b=2"),
|
||
# rejected -> null (LOCKED A5): the construct stays inert text
|
||
("javascript:alert(1)", None),
|
||
("JaVaScRiPt:alert(1)", None),
|
||
("data:text/html,x", None),
|
||
("data:image/png;base64,YQ==", None),
|
||
("file:///etc/passwd", None),
|
||
("blob:https://x.test/u", None),
|
||
("vbscript:x", None),
|
||
("x.test/i.png", None),
|
||
("images/x.png", None),
|
||
("#frag", None),
|
||
("mailto:a@x.test", None),
|
||
("", None),
|
||
("..%2fetc%2fpasswd", None),
|
||
]
|
||
driver = (
|
||
_text(MARKDOWN_JS)
|
||
+ "\nconst in_ = "
|
||
+ json.dumps([c[0] for c in cases])
|
||
+ ";\nconsole.log(JSON.stringify(in_.map(allowImageUrl)));"
|
||
)
|
||
got = json.loads(_run_node(driver))
|
||
assert got == [expected for _, expected in cases], f"allowImageUrl mismatch: {got!r}"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_heading_ladder_shapes_under_node() -> None:
|
||
"""TODO.md L5 under node: ``#### Deep`` renders ``<h5>Deep</h5>`` with
|
||
NO ``<p>`` wrapper and no literal ``####``; ``##### `` and ``###### ``
|
||
both render ``<h6>`` (two consecutive deep lines become two ``<h6>``
|
||
elements, still unwrapped); ``###``/``##``/``#`` are BYTE-IDENTICAL to
|
||
the pre-phase output; a deep heading immediately followed by a
|
||
paragraph is not wrapped in ``<p>``; inline markup inside a deep
|
||
heading still transforms; a ``####`` line inside a fence stays fence
|
||
content (step 1 protects first); a ``####`` in a table cell renders
|
||
through the same ``inline()``; and the non-heading shapes (a mid-line
|
||
``####``, an indented ``####``, a hash run with no space) stay text."""
|
||
cases = {
|
||
"h4": "#### Deep",
|
||
"h5_h6": "##### Deeper\n###### Deepest",
|
||
"h6": "###### Deepest",
|
||
"existing": "### A\n\n## B\n\n# C",
|
||
"then_para": "#### H\n\nA paragraph after.",
|
||
"rich": "#### Deep with **bold** and `c`",
|
||
"fence": "```\n#### Deep\n```",
|
||
"cell": "| h |\n|---|\n| #### cell head |",
|
||
"midline": "text with a #### mid-line hash",
|
||
"indented": " #### Not a heading",
|
||
"no_space": "####No space",
|
||
}
|
||
exact = {
|
||
"h4": "<h5>Deep</h5>",
|
||
"h5_h6": "<h6>Deeper</h6>\n<h6>Deepest</h6>",
|
||
"h6": "<h6>Deepest</h6>",
|
||
# the pre-existing ladder, unchanged (LOCKED A4)
|
||
"existing": "<h4>A</h4><h3>B</h3><h3>C</h3>",
|
||
"then_para": "<h5>H</h5><p>A paragraph after.</p>",
|
||
"rich": "<h5>Deep with <strong>bold</strong> and <code>c</code></h5>",
|
||
"fence": "<p><pre><code>#### Deep</code></pre></p>",
|
||
"midline": "<p>text with a #### mid-line hash</p>",
|
||
# the paragraph pass trims the block, the heading rule still does not fire
|
||
"indented": "<p>#### Not a heading</p>",
|
||
"no_space": "<p>####No space</p>",
|
||
}
|
||
results = _render(cases)
|
||
for name, html in results.items():
|
||
if name in exact:
|
||
assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}"
|
||
# the untouched step-3 guard does its job: no heading inside a <p>
|
||
for name in ("h4", "h5_h6", "h6", "then_para", "rich"):
|
||
assert "<p><h" not in results[name], f"{name}: a heading was wrapped in <p>"
|
||
assert results["h5_h6"].count("<h6>") == 2 and results["h5_h6"].count("</h6>") == 2, (
|
||
"##### and ###### both render <h6>"
|
||
)
|
||
assert "####" not in results["h4"], "the hash syntax must not survive"
|
||
assert "<h5>cell head</h5>" in results["cell"], (
|
||
"a deep heading inside a table cell renders through inline()"
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_heading_ladder_stops_at_h6_and_never_invents_levels() -> None:
|
||
"""LOCKED A4 under node: the deep run is ``#{5,6}``, so a 7+ hash run
|
||
is NOT a heading (``####### Seven`` has no space after five/six hashes
|
||
at column 0) and the renderer never invents an element HTML does not
|
||
have — there is no ``<h7>``/``<h8>`` in the source or the output."""
|
||
js = _text(MARKDOWN_JS)
|
||
# (`<h7>` appears in the prose that explains WHY there is none, so the
|
||
# pin is on the output-template form, not the bare tag name)
|
||
for forbidden in ('"<h7', '"</h7', '"<h8', '"</h8', "^#{7"):
|
||
assert forbidden not in js, f"markdown.js must never emit {forbidden!r}"
|
||
bad = {"h7": "####### Seven", "h8": "######## Eight"}
|
||
results = _render(bad)
|
||
for name, src in bad.items():
|
||
assert results[name] == f"<p>{src}</p>", f"{name}: {results[name]!r} stays text"
|
||
assert "<h" not in results[name], f"{name}: {results[name]!r} invented a heading"
|
||
|
||
|
||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||
def test_input_without_links_renders_byte_identical() -> None:
|
||
"""The byte-identity contract (phase 180 design D7): an input with no
|
||
link, no image ref and no ``####``+ heading renders BYTE-EXACTLY as
|
||
the pre-phase renderer did (the fixture string and its pre-phase
|
||
output are checked into this test, per the phase-44/138 shape) —
|
||
which is what keeps the phase-44 table suite and the phase-138
|
||
blockquote suite green unedited."""
|
||
results = _render({"no_construct": NO_CONSTRUCT_INPUT})
|
||
assert results["no_construct"] == NO_CONSTRUCT_PRE_PHASE_OUTPUT
|
||
assert LINK_MARKER not in results["no_construct"]
|
||
assert IMG_MARKER not in results["no_construct"]
|
||
assert LAZY_ATTR not in results["no_construct"]
|
||
# the ladder added no levels ABOVE ####, so an input whose deepest
|
||
# heading is ### gains no h5/h6 either
|
||
for tag in ("<h5", "<h6"):
|
||
assert tag not in results["no_construct"], f"{tag} appeared unasked"
|
||
for expected in ("<h3>Top heading</h3>", "<h3>Second heading</h3>", "<h4>Third heading</h4>"):
|
||
assert expected in results["no_construct"], f"ladder regression: {expected}"
|