**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`.
414 lines
17 KiB
Python
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)) == []
|