Files
undercooked/tests/unit/test_no_external_assets.py
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
2026-10-10 18:03:52 -04:00

414 lines
17 KiB
Python

"""PLAN A12 guard: the browser must load nothing this process does not serve.
This is the mechanical half of "no external CDNs" (LOCKED decision A12, AGENTS.md §5).
It has two halves on purpose:
1. **A text scan** of every `app/static/**` asset (`.html`, `.css`, `.js`, `.svg`) for the
shapes that reach off-origin: `http(s)://`, protocol-relative `//host/`, hosted-library
mirrors (`jsdelivr`, `unpkg`, …), CSS `@import url(`, protocol-relative stylesheets,
`srcset`, `fetch()` / `import` of an absolute URL, an absolute-URL `WebSocket`, and
`EventSource` (SSE is not this project's transport). A single remote reference is both
an availability and a privacy defect, so the scan is blunt and needs no allow-list —
with one documented exception, the `xmlns` namespace URIs of SVG/XML, which are
identifiers the browser never fetches (A11 ships in-repo SVG art).
2. **An element check** of `index.html`: every `<script src>`, `<link href>` and
`<img src>` must be a same-origin `/static/...` path (a `data:` URL is allowed where
the CSP allows it — `img-src 'self' data:` — because it is the app's own bytes).
The root it scans is imported from `undercooked.server.app.STATIC_DIR`, i.e. the very
directory the server mounts at `/static`, so the guard cannot pass by scanning some other
folder. The last group of tests plants each forbidden shape in a temporary tree and
asserts the scan names the offending file, which is what keeps this file from rotting
into a test that scans nothing.
"""
from __future__ import annotations
import re
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
import pytest
from undercooked.config import Settings
from undercooked.server.app import STATIC_DIR, resolve_static_dir
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
#: Every file type a browser can be told to load or parse.
ASSET_SUFFIXES = frozenset({".html", ".css", ".js", ".svg"})
#: Files whose text may name a hosted library. CSS/SVG are covered by the URL rules below;
#: HTML and JS are where a `<script>`/`import` of a hosted bundle actually shows up.
HOSTED_TOKEN_SUFFIXES = frozenset({".html", ".js"})
#: Hosted-library / hosted-font mirrors that must never appear in shipped client text.
HOSTED_LIBRARY_TOKENS = ("googleapis", "gstatic", "jsdelivr", "unpkg", "cdnjs", "tailwindcss")
#: An absolute or protocol-relative authority in a URL-ish position.
_ABS = r"(?:https?:)?//"
#: XML namespace declarations: identifiers, not requests (SVG art is first-class, A11).
_NAMESPACE_DECL = re.compile(
r"""xmlns(?::[\w.-]+)?\s*=\s*["']?https?://www\.w3\.org/[^\s"'<>]*["']?""",
re.IGNORECASE,
)
#: (rule name, pattern) pairs applied to each masked line of every asset.
BANNED_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
(
"absolute URL",
re.compile(r"https?://", re.IGNORECASE),
),
(
"protocol-relative URL",
# `//host.tld/...` — the scheme is deliberately NOT part of this pattern, so the
# lookbehind really does keep `https://` from being reported twice, and the
# host-shaped lookahead keeps JS/CSS comments (`// note`) from matching at all.
re.compile(r"(?<![A-Za-z0-9:])//(?![\s/])(?:[\w-]+\.)+[a-z]{2,}", re.IGNORECASE),
),
(
"CSS @import url()",
re.compile(r"@import\s+url\s*\(", re.IGNORECASE),
),
(
"protocol-relative stylesheet",
re.compile(
rf"""<link\b[^>]*?rel=["']?[^"'<>]*stylesheet[^>"']*["']?[^>]*?href=["']?{_ABS}""",
re.IGNORECASE,
),
),
(
"srcset attribute",
re.compile(r"\bsrcset\s*=", re.IGNORECASE),
),
(
"fetch() of an off-origin URL",
re.compile(rf"\bfetch\s*\(\s*[\"'`]{_ABS}", re.IGNORECASE),
),
(
"import of an off-origin URL",
re.compile(rf"\b(?:import|export)\b[^;\n]*?\bfrom\s*[\"'`]{_ABS}", re.IGNORECASE),
),
(
"bare import of an off-origin URL",
re.compile(rf"\bimport\s*[\"'`]{_ABS}", re.IGNORECASE),
),
(
"WebSocket with an absolute URL",
# Same-origin is built from `location`, never written out (`WebSocket("ws…`).
re.compile(r"\bWebSocket\s*\(\s*[\"'`](?:wss?|https?)://", re.IGNORECASE),
),
(
"EventSource (server-sent events)",
re.compile(r"\bnew\s+EventSource\s*\(|\bEventSource\s*\(", re.IGNORECASE),
),
)
#: Same-origin prefixes an element URL may start with.
STATIC_PREFIX = "/static/"
@dataclass(frozen=True, slots=True)
class Violation:
"""One forbidden reference, in one file, on one line."""
path: Path
line: int
rule: str
text: str
def describe(self) -> str:
"""Human-readable failure line naming the offending file."""
return f"{self.path}:{self.line}: {self.rule}: {self.text.strip()}"
def _mask(text: str) -> str:
"""Blank out the text that looks external but cannot cause a request."""
return _NAMESPACE_DECL.sub(lambda match: " " * len(match.group(0)), text)
def iter_asset_files(root: Path) -> Iterator[Path]:
"""Every scannable asset under ``root``, depth-first and in a stable order."""
if not root.is_dir(): # pragma: no cover - guarded by the caller's own assertion
return
for path in sorted(root.rglob("*")):
if path.is_file() and path.suffix.lower() in ASSET_SUFFIXES:
yield path
def scan_text(path: Path, text: str) -> list[Violation]:
"""Every forbidden reference in ``text``, attributed to ``path``."""
violations: list[Violation] = []
for number, raw in enumerate(_mask(text).splitlines(), start=1):
for rule, pattern in BANNED_PATTERNS:
if pattern.search(raw):
violations.append(Violation(path=path, line=number, rule=rule, text=raw))
if path.suffix.lower() in HOSTED_TOKEN_SUFFIXES:
lowered = raw.lower()
violations.extend(
Violation(path=path, line=number, rule=f"hosted library {token!r}", text=raw)
for token in HOSTED_LIBRARY_TOKENS
if token in lowered
)
if path.suffix.lower() == ".html":
violations.extend(scan_element_urls(path, text))
return violations
def scan_file(path: Path) -> list[Violation]:
"""Read one asset and return its forbidden references."""
return scan_text(path, path.read_text(encoding="utf-8"))
def scan_tree(root: Path) -> list[Violation]:
"""Scan every asset under ``root`` (the shipped client, when called with STATIC_DIR)."""
return [violation for path in iter_asset_files(root) for violation in scan_file(path)]
def _element_attributes(tag: str) -> dict[str, str]:
"""Parse `name="value"` pairs of one tag into a lower-cased mapping."""
found: dict[str, str] = {}
for name, value in re.findall(r"""([\w-]+)\s*=\s*["']([^"']*)["']""", tag):
found[name.lower()] = value
return found
def _is_allowed(value: str, *, data_ok: bool) -> bool:
if value.startswith(STATIC_PREFIX):
return True
return data_ok and value.startswith("data:")
def scan_element_urls(path: Path, text: str) -> list[Violation]:
"""Every `<script src>` / `<link href>` / `<img src>` in an HTML document that this
app does not serve.
`<script src>` and stylesheet `<link>`s must be `/static/...`. `<img>` and
non-stylesheet `<link>`s (the `data:` favicon) may additionally be `data:`, matching
the CSP's `img-src 'self' data:` — in-app bytes either way.
"""
violations: list[Violation] = []
for match in re.finditer(r"<(script|link|img)\b((?:[^>'\"]|\"[^\"]*\"|'[^']*')*)>", text, re.I):
name = match.group(1).lower()
attrs = _element_attributes(match.group(2))
if name == "script":
attribute, data_ok = "src", False
elif name == "link":
attribute, data_ok = "href", "stylesheet" not in attrs.get("rel", "").lower()
else:
attribute, data_ok = "src", True
value = attrs.get(attribute)
if value is None or _is_allowed(value, data_ok=data_ok):
continue
line = text.count("\n", 0, match.start()) + 1
rule = f"<{name} {attribute}> is not a same-origin /static/ path"
violations.append(Violation(path=path, line=line, rule=rule, text=match.group(0)))
return violations
def format_violations(violations: list[Violation]) -> str:
"""Render violations for a pytest failure message."""
return "\n".join(violation.describe() for violation in violations)
@pytest.fixture
def static_tree(tmp_path: Path) -> Path:
"""A throwaway static root, shaped like the real one, for planted violations."""
root = tmp_path / "static"
(root / "js").mkdir(parents=True)
(root / "css").mkdir(parents=True)
(root / "index.html").write_text("<!doctype html><title>t</title>", encoding="utf-8")
return root
# --------------------------------------------------------------------------- the scanned root
def test_the_guard_scans_the_directory_the_server_actually_serves() -> None:
"""Re-deriving the root here is what stops a green run against the wrong folder."""
assert STATIC_DIR == REPO_ROOT / "app" / "static"
assert STATIC_DIR.is_dir(), f"{STATIC_DIR} is not a directory"
assert (STATIC_DIR / "index.html").is_file()
assert resolve_static_dir(Settings()) == STATIC_DIR
def test_the_scan_walks_the_shipped_client_files() -> None:
names = {path.relative_to(STATIC_DIR).as_posix() for path in iter_asset_files(STATIC_DIR)}
assert {"index.html", "js/app.js", "css/base.css"} <= names, names
# --------------------------------------------------------------------------- the shipped client
def test_shipped_client_references_nothing_external() -> None:
"""A12: zero forbidden references anywhere under app/static/."""
violations = scan_tree(STATIC_DIR)
report = format_violations(violations)
assert violations == [], f"the client loads bytes this app does not serve:\n{report}"
def test_shipped_index_html_loads_only_same_origin_assets() -> None:
"""Non-vacuous on purpose: the index must actually declare a script and a stylesheet."""
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
tags = re.findall(r"<(script|link|img)\b", html, re.I)
assert len(tags) >= 3, "index.html no longer declares the assets this test guards"
# The page's single entry module (the composition root, phase 07 task 03;
# `main.js` imports `app.js` and the render/decode modules).
assert 'src="/static/js/main.js"' in html
assert scan_element_urls(STATIC_DIR / "index.html", html) == []
# --------------------------------------------------------------------------- planted violations
@pytest.mark.parametrize(
("name", "content"),
[
("script_src", '<!doctype html><script src="https://cdn.example.com/x.js"></script>'),
(
"module_src",
'<!doctype html><script type="module" src="//cdn.example.com/x.js"></script>',
),
("stylesheet_link", '<link rel="stylesheet" href="https://fonts.example.com/f.css">'),
("protocol_relative_stylesheet", '<link rel="stylesheet" href="//cdn.example.com/a.css">'),
("img_src", '<img src="https://evil.example.com/pixel.png">'),
("img_srcset", '<img src="/static/a.svg" srcset="https://cdn.example.com/b.svg 2x">'),
],
)
def test_a_planted_html_reference_fails_the_scan(
static_tree: Path, name: str, content: str
) -> None:
page = static_tree / f"{name}.html"
page.write_text(content, encoding="utf-8")
violations = scan_tree(static_tree)
assert violations, content
assert all(violation.path == page for violation in violations), format_violations(violations)
assert page.name in format_violations(violations)
@pytest.mark.parametrize(
("suffix", "content", "rule"),
[
(
".js",
'import { x } from "https://cdn.example.com/lib.js";',
"import of an off-origin URL",
),
(
".js",
'import "https://cdn.example.com/side-effect.js";',
"bare import of an off-origin URL",
),
(".js", 'export * from "//cdn.example.com/lib.js";', "import of an off-origin URL"),
(".js", 'fetch("https://api.example.com/state");', "fetch() of an off-origin URL"),
(".js", 'fetch("//api.example.com/state");', "fetch() of an off-origin URL"),
(
".js",
'new WebSocket("wss://remote.example.com/ws/ABCDE");',
"WebSocket with an absolute URL",
),
(".js", 'new EventSource("/events");', "EventSource (server-sent events)"),
(".js", 'const u = "https://cdn.example.com/x.js";', "absolute URL"),
(".css", "@import url(https://fonts.example.com/one.css);", "CSS @import url()"),
(".css", "@import url(//fonts.example.com/one.css);", "CSS @import url()"),
(".css", "body { background: url(//cdn.example.com/bg.png); }", "protocol-relative URL"),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg">'
'<image href="https://x.example.com/a.png"/></svg>',
"absolute URL",
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="https://evil.example/x.svg"/></svg>',
"absolute URL",
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="https://evil.example/x.svg"/></svg>',
"absolute URL",
),
(".html", '<a href="https://external.example.com">out</a>', "absolute URL"),
],
)
def test_a_planted_asset_reference_fails_the_scan(
static_tree: Path, suffix: str, content: str, rule: str
) -> None:
planted = static_tree / "js" / f"planted{suffix}"
planted.write_text(content, encoding="utf-8")
violations = scan_tree(static_tree)
assert violations, content
assert rule in {violation.rule for violation in violations}, format_violations(violations)
assert any(violation.path == planted for violation in violations)
assert planted.name in format_violations(violations)
@pytest.mark.parametrize("token", HOSTED_LIBRARY_TOKENS)
@pytest.mark.parametrize("suffix", [".js", ".html"])
def test_a_planted_hosted_library_token_fails_the_scan(
static_tree: Path, token: str, suffix: str
) -> None:
planted = static_tree / f"planted{suffix}"
planted.write_text(f"/* vendored from {token} once */", encoding="utf-8")
violations = [v for v in scan_tree(static_tree) if v.path == planted]
assert violations, token
assert token in format_violations(violations)
# --------------------------------------------------------------------------- what must stay green
@pytest.mark.parametrize(
("suffix", "content"),
[
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
'<rect width="16" height="16"/></svg>',
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="#tile"/></svg>',
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg"'
' xmlns:xml="http://www.w3.org/XML/1998/namespace"'
' xmlns:xhtml="http://www.w3.org/1999/xhtml">'
'<rect width="16" height="16"/></svg>',
),
(".js", "// a comment about docs/PROTOCOL.md and a/b/c paths\nexport const ok = 1;\n"),
(".js", 'const ws = new WebSocket(`${location.origin}/ws/${code}`);\n'),
(".js", 'fetch("/static/api/levels");\n'),
(".css", ".a { background: url(/static/assets/tile.svg); }\n"),
(".html", '<img src="data:image/svg+xml,<svg/>">\n'),
],
)
def test_same_origin_and_namespace_references_are_allowed(
static_tree: Path, suffix: str, content: str
) -> None:
"""The guard must not train anyone to delete real code: same-origin stays green."""
(static_tree / "js" / f"fine{suffix}").write_text(content, encoding="utf-8")
assert scan_tree(static_tree) == []
def test_non_asset_files_are_out_of_scope(static_tree: Path) -> None:
"""Notes and images are not client instructions; the scan is about loadable code."""
(static_tree / "NOTES.md").write_text("see https://example.com for details", encoding="utf-8")
(static_tree / "logo.png").write_bytes(b"\x89PNG\r\n")
assert scan_tree(static_tree) == []
def test_an_empty_tree_is_not_a_passing_signal(tmp_path: Path) -> None:
"""Documented explicitly: an empty root scans clean, so the root tests above matter."""
assert scan_tree(tmp_path) == []
assert list(iter_asset_files(tmp_path)) == []