phase: 01_runnable_service_scaffold

**Phase 01 final verification pass — all criteria green**

- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
This commit is contained in:
2026-10-06 01:51:20 -04:00
parent f84c6ebaef
commit 6fd83446ec
43 changed files with 5478 additions and 30 deletions
+161
View File
@@ -0,0 +1,161 @@
"""Shared pytest configuration and fixtures for the Undercooked test suite.
`live_server` is the one fixture that binds a real socket (an ephemeral loopback
port) because the Playwright e2e tests need a URL a browser can load; everything
else stays in-process.
Fixtures that depend on modules created by later phase-01 tasks
(`undercooked.server.app`) import them lazily via `importlib`, so this conftest
keeps working before those modules exist.
"""
from __future__ import annotations
import importlib
import os
import threading
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from fastapi.testclient import TestClient
TESTS_DIR = Path(__file__).resolve().parent
FIXTURE_LEVELS_DIR = TESTS_DIR / "fixtures" / "levels"
LIVE_HOST = "127.0.0.1" # loopback only: tests never listen on a routable interface
def _as_env_value(value: object) -> str:
"""Render a Python override value the way `load_settings` would read it."""
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, str | Path):
return str(value)
if isinstance(value, list | tuple):
return ",".join(str(item) for item in value)
return str(value)
@pytest.fixture(scope="session", autouse=True)
def isolated_levels_dir() -> Iterator[None]:
"""Point `UNDERCOOKED_LEVELS_DIR` at an empty fixture dir for every test.
No test may read the real `levels/` content implicitly; the shipped
content pipeline only arrives with phase 02.
"""
FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True)
previous = os.environ.get("UNDERCOOKED_LEVELS_DIR")
os.environ["UNDERCOOKED_LEVELS_DIR"] = str(FIXTURE_LEVELS_DIR)
yield
if previous is None:
os.environ.pop("UNDERCOOKED_LEVELS_DIR", None)
else:
os.environ["UNDERCOOKED_LEVELS_DIR"] = previous
def _wait_for_health(url: str, thread: threading.Thread) -> None:
"""Poll `/healthz` until it answers 200, failing fast if the server thread died."""
timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30"))
deadline = time.monotonic() + timeout
probe = httpx.Client(timeout=1.0, trust_env=False)
try:
while time.monotonic() < deadline:
try:
if probe.get(f"{url}/healthz").status_code == 200:
return
except httpx.HTTPError:
pass
thread.join(timeout=0.1)
if not thread.is_alive():
raise RuntimeError(f"live server on {url} died during startup")
finally:
probe.close()
raise RuntimeError(f"live server on {url} never became healthy")
@pytest.fixture(scope="session")
def live_server() -> Iterator[str]:
"""Base URL of a real uvicorn server, started once for the session.
The Playwright e2e tests need an address a browser can actually load, which
the in-process `TestClient` cannot provide. uvicorn binds the socket before
the serving thread starts, so the ephemeral port is known exactly (no log
scraping, no collisions between concurrent runs), and shutdown is requested
through `should_exit` so the lifespan stops cleanly.
"""
uvicorn = importlib.import_module("uvicorn")
app_module = importlib.import_module("undercooked.server.app")
config = uvicorn.Config(
app_module.create_app(),
host=LIVE_HOST,
port=0,
log_level="warning",
lifespan="on",
)
sock = config.bind_socket()
url = f"http://{LIVE_HOST}:{sock.getsockname()[1]}"
server = uvicorn.Server(config)
thread = threading.Thread(
target=server.run, args=([sock],), name="undercooked-live", daemon=True
)
thread.start()
_wait_for_health(url, thread)
try:
yield url
finally:
server.should_exit = True
thread.join(timeout=15)
if thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating
pytest.fail("live server did not shut down within 15 s")
@pytest.fixture
def random_seed() -> int:
"""Deterministic seed for sim/replay tests (determinism is a contract)."""
return 0x5EED_0401
@pytest.fixture
def make_settings() -> Callable[..., Any]:
"""Build `undercooked.config.Settings` from field-name overrides.
Overrides are translated into their env vars using the authoritative
`config.ENV_KEYS` contract (field name -> `UNDERCOOKED_*` / `DEBUGPY*`), and
`load_settings(env)` is called with a copy of the process environment that
has those vars stripped, so ambient env and `.env` files cannot leak into
tests.
"""
def _make(**overrides: object) -> Any:
config = importlib.import_module("undercooked.config")
env: dict[str, str] = {
key: value
for key, value in os.environ.items()
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
}
for name, value in overrides.items():
if name not in config.ENV_KEYS:
pytest.fail(f"unknown settings field override: {name!r}")
env[config.ENV_KEYS[name]] = _as_env_value(value)
return config.load_settings(env)
return _make
@pytest.fixture
def app() -> Any:
"""Fresh FastAPI application built through `undercooked.server.app.create_app()`."""
module = importlib.import_module("undercooked.server.app")
return module.create_app()
@pytest.fixture
def client(app: Any) -> Iterator[TestClient]:
"""`TestClient` bound to the `app` fixture, running startup/shutdown."""
with TestClient(app) as test_client:
yield test_client
+1
View File
@@ -0,0 +1 @@
Playwright browser suites, one per user story (`test_usNN_*.py`, marker `e2e`).
+66
View File
@@ -0,0 +1,66 @@
"""Playwright fixtures for the browser suites in `tests/e2e/`.
The project depends on the `playwright` package, not on `pytest-playwright`, so
the three fixtures the plugin would provide are defined here — a headless chromium,
a fresh context and a fresh page, all function-scoped (see `browser` for why the scope
is load-bearing). A fresh context per test matters too: `sessionStorage` (the anonymous
`uc.token`, `uc.lastCode`) is scoped to it, so tests cannot observe each other's
identity.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import sync_playwright
if TYPE_CHECKING:
from collections.abc import Iterator
from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize
# The default desktop viewport; story tests that care about small screens call
# `page.set_viewport_size` before navigating.
VIEWPORT: ViewportSize = {"width": 1280, "height": 720}
@pytest.fixture
def browser() -> Iterator[Browser]:
"""A headless chromium, live for exactly one test.
Function scope is load-bearing, not shyness about startup time. The
*synchronous* Playwright API drives the browser from an asyncio event loop it
owns in the main thread, and that loop stays flagged as running for as long as
`sync_playwright()` is open — so a session- or module-scoped fixture makes every
later `async def` test in the run die with
`RuntimeError: Runner.run() cannot be called from a running event loop`
(pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside the
test leaves the main thread clean for whatever runs next.
"""
with sync_playwright() as playwright:
launched = playwright.chromium.launch(headless=True)
try:
yield launched
finally:
launched.close()
@pytest.fixture
def context(browser: Browser) -> Iterator[BrowserContext]:
"""A brand-new browser context per test: isolated storage and cookies."""
created = browser.new_context(viewport=VIEWPORT)
try:
yield created
finally:
created.close()
@pytest.fixture
def page(context: BrowserContext) -> Iterator[Page]:
"""A tab inside `context`, closed after the test."""
opened = context.new_page()
try:
yield opened
finally:
opened.close()
+174
View File
@@ -0,0 +1,174 @@
"""US-01 (landing surface) e2e: a real browser loads the shell, cleanly.
Phase 01 has no gameplay, so this suite asserts only what a browser can prove and
`TestClient` cannot: the document parses, the ES module runs without a single
console error, the lobby controls are reachable, and — the point of PLAN A12 —
loading and clicking the page makes requests to *nothing* but this process.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
from undercooked import __version__
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
# Strings a player sees, kept in sync with app/static/index.html and
# app/static/js/app.js by hand: changing one must be a deliberate act.
READY = "Pick a kitchen to start."
NOT_IMPLEMENTED = "Not implemented until phase 05."
SHORT_CODE = "A room code is 5 characters."
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$")
@pytest.fixture
def console_errors(page: Page) -> list[str]:
"""Console errors and uncaught exceptions raised while the page runs (empty = good)."""
errors: list[str] = []
page.on(
"console",
lambda message: errors.append(f"{message.type}: {message.text}")
if message.type == "error"
else None,
)
page.on("pageerror", lambda exception: errors.append(f"pageerror: {exception}"))
return errors
def open_shell(page: Page, live_server: str) -> None:
"""Load the lobby and wait for its one module request to settle."""
page.goto(f"{live_server}/", wait_until="networkidle")
def test_the_shell_loads_without_console_errors(
page: Page, live_server: str, console_errors: list[str]
) -> None:
open_shell(page, live_server)
assert page.title() == "Undercooked"
expect(page.get_by_role("heading", name="Kitchen lobby")).to_be_visible()
expect(page.get_by_role("button", name="Create lobby")).to_be_visible()
expect(page.locator("#status")).to_have_text(READY)
assert console_errors == []
def test_the_lobby_is_readable_and_zoomable(page: Page, live_server: str) -> None:
"""Viewport meta + a single fluid column: the shell works on a phone (PLAN A19)."""
page.set_viewport_size({"width": 360, "height": 640})
open_shell(page, live_server)
assert page.evaluate(
"document.querySelector('meta[name=viewport]').content"
) == "width=device-width,initial-scale=1,viewport-fit=cover"
for selector in ("#create-lobby", "#join-code"):
box = page.locator(selector).bounding_box()
assert box is not None and box["width"] >= 44 and box["height"] >= 44, (selector, box)
# Nothing sticks out of the 360 px viewport: no horizontal scrolling.
assert page.evaluate("document.documentElement.scrollWidth") <= 360
def test_create_and_join_explain_that_the_phase_is_missing(
page: Page, live_server: str, console_errors: list[str]
) -> None:
open_shell(page, live_server)
status = page.locator("#status")
page.get_by_role("button", name="Create lobby").click()
expect(status).to_have_text(NOT_IMPLEMENTED)
page.locator("#join-code").press_sequentially("abcde")
page.get_by_role("button", name="Join").click()
expect(status).to_have_text(NOT_IMPLEMENTED)
# The code is normalised for the player: uppercase, five characters.
assert page.locator("#join-code").input_value() == "ABCDE"
assert console_errors == []
def test_a_short_code_is_rejected_before_any_network_use(page: Page, live_server: str) -> None:
open_shell(page, live_server)
page.locator("#join-code").press_sequentially("ab")
page.get_by_role("button", name="Join").click()
expect(page.locator("#status")).to_have_text(SHORT_CODE)
# The field refuses a sixth character rather than letting it reach the server.
page.locator("#join-code").fill("")
page.locator("#join-code").press_sequentially("abcdef")
assert page.locator("#join-code").input_value() == "abcde"
def test_the_only_requests_the_page_makes_are_ours(page: Page, live_server: str) -> None:
"""PLAN A12 in a real browser: no CDN, no web font, no beacon, no analytics."""
requested: list[str] = []
page.on("request", lambda request: requested.append(request.url))
open_shell(page, live_server)
page.get_by_role("button", name="Create lobby").click()
assert [url for url in requested if not url.startswith(live_server)] == []
# …and the check above is not vacuous: the shell really did fetch its own assets.
assert sorted(url for url in requested if url != f"{live_server}/") == [
f"{live_server}/static/css/base.css",
f"{live_server}/static/js/app.js",
]
def test_the_document_arrives_locked_down(page: Page, live_server: str) -> None:
response = page.goto(f"{live_server}/")
assert response is not None
assert response.status == 200
assert response.headers["content-security-policy"].startswith("default-src 'self';")
assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "DENY"
def test_the_page_can_still_call_its_own_health_endpoint(
page: Page, live_server: str, console_errors: list[str]
) -> None:
"""`connect-src 'self'` must allow same-origin fetches, or phase 05 cannot work."""
open_shell(page, live_server)
result = page.evaluate(
"""async () => {
const response = await fetch('/healthz');
return {status: response.status, body: await response.json()};
}"""
)
assert result == {"status": 200, "body": {"status": "ok", "version": __version__, "rooms": 0}}
assert console_errors == []
def test_one_anonymous_token_per_tab_and_no_cookies(
page: Page, live_server: str
) -> None:
"""Identity is a random per-tab token (PLAN A16); a reload must not mint another."""
open_shell(page, live_server)
token = page.evaluate("sessionStorage.getItem('uc.token')")
assert isinstance(token, str), token
assert TOKEN_PATTERN.match(token), token
open_shell(page, live_server)
assert page.evaluate("sessionStorage.getItem('uc.token')") == token
assert page.context.cookies() == [] # anonymous by design: nothing identifying
def test_the_last_code_is_remembered_for_the_next_visit(page: Page, live_server: str) -> None:
open_shell(page, live_server)
page.locator("#join-code").press_sequentially("k7xyz")
page.get_by_role("button", name="Join").click()
expect(page.locator("#status")).to_have_text(NOT_IMPLEMENTED)
open_shell(page, live_server)
assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ"
assert page.locator("#join-code").input_value() == "K7XYZ"
def test_the_canvas_stays_hidden_until_the_renderer_exists(page: Page, live_server: str) -> None:
"""Phase 07 unhides `#kcanvas`; until then it is out of the layout and a11y tree."""
open_shell(page, live_server)
canvas = page.locator("#kcanvas")
expect(canvas).to_be_hidden()
assert canvas.get_attribute("aria-hidden") == "true"
View File
+1
View File
@@ -0,0 +1 @@
In-process integration tests: FastAPI app boot, HTTP routes, rooms, and the WebSocket protocol.
+319
View File
@@ -0,0 +1,319 @@
"""Integration: the app boots through `create_app()` and serves the real surface.
Covers the HTTP contract of phase 01 task 03 — `/healthz`, the client shell at
`/`, the `/static` mount, path-traversal refusal, the startup hook, and the
security headers on every kind of response (PLAN.md §4, §11, A12).
"""
from __future__ import annotations
import dataclasses
import importlib
import logging
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from undercooked import __version__
from undercooked.config import ENV_KEYS, ConfigError, Settings, reset_settings_cache
from undercooked.server import app as app_module
from undercooked.server.app import (
LOGGED_SETTINGS,
STATIC_DIR,
create_app,
get_app,
resolve_static_dir,
settings_log_line,
)
from undercooked.server.headers import SECURITY_HEADERS
if TYPE_CHECKING:
from collections.abc import Callable
from starlette.types import Message
pytestmark = pytest.mark.integration
REPO_ROOT = Path(__file__).resolve().parents[2]
APP_LOGGER = "undercooked.server.app"
#: One route of every kind the app answers with: HTML page, JSON endpoint,
#: static asset, and the 404 that catches everything else.
ROUTE_KINDS = ("/", "/healthz", "/static/css/base.css", "/definitely-not-a-route")
@pytest.fixture(autouse=True)
def fresh_settings_cache() -> Iterator[None]:
"""No test may inherit a cached `Settings` (or a `.env` read) from another."""
reset_settings_cache()
yield
reset_settings_cache()
async def _asgi_get(application: FastAPI, target: str) -> tuple[int, bytes]:
"""GET ``target`` against the ASGI app verbatim; return `(status, body)`.
httpx normalizes dot-segments, so a traversal attempt written through
`TestClient` never reaches the static mount as `..`. Driving the ASGI app by
hand is how a raw client (curl, a browser proxy, a hostile socket) reaches it.
"""
scope: dict[str, Any] = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": target,
"raw_path": target.encode("ascii"),
"query_string": b"",
"root_path": "",
"headers": [(b"host", b"testserver"), (b"accept", b"*/*")],
"client": ("127.0.0.1", 54321),
"server": ("testserver", 80),
}
sent: list[Message] = []
async def receive() -> dict[str, Any]:
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message: Message) -> None:
sent.append(message)
await application(scope, receive, send)
start = next(message for message in sent if message["type"] == "http.response.start")
body = b"".join(
message.get("body", b"") for message in sent if message["type"] == "http.response.body"
)
return int(start["status"]), body
# ----------------------------------------------------------------------------- /healthz
def test_healthz_is_ok_with_the_expected_shape(client: TestClient) -> None:
response = client.get("/healthz")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
assert response.json() == {"status": "ok", "version": __version__, "rooms": 0}
# ----------------------------------------------------------------------------- client shell
def test_root_serves_the_shell_html(client: TestClient) -> None:
response = client.get("/")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert int(response.headers["content-length"]) > 500 # streamed from the file
html = response.text
assert 'src="/static/js/app.js"' in html
assert 'href="/static/css/base.css"' in html
assert 'id="screen-lobby"' in html
assert 'aria-live="polite"' in html
assert (
'name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"' in html
)
def test_shell_offers_the_lobby_controls(client: TestClient) -> None:
html = client.get("/").text
assert 'id="create-lobby"' in html
assert 'id="join-form"' in html
assert 'id="join-code"' in html
assert 'inputmode="text"' in html
assert 'autocapitalize="characters"' in html
assert 'size="5"' in html # five characters wide: no 20-char intrinsic box
assert 'maxlength="5"' in html
assert 'for="join-code"' in html # the code input has a real label
def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) -> None:
css = client.get("/static/css/base.css")
assert css.status_code == 200
assert css.headers["content-type"].startswith("text/css")
assert "--uc-hit: 44px" in css.text # the 44px hit-target contract lives in CSS
script = client.get("/static/js/app.js")
assert script.status_code == 200
assert script.headers["content-type"].startswith("text/javascript")
assert "uc.token" in script.text
def test_unknown_path_is_404(client: TestClient) -> None:
assert client.get("/definitely-not-a-route").status_code == 404
@pytest.mark.parametrize("path", ROUTE_KINDS)
def test_every_response_carries_the_security_headers(client: TestClient, path: str) -> None:
response = client.get(path)
for header, value in SECURITY_HEADERS.items():
assert response.headers[header] == value, f"{path} is missing {header}"
# ----------------------------------------------------------------------------- traversal
@pytest.mark.parametrize(
"path",
[
"/static/%2e%2e/pyproject.toml", # encoded ".."
"/static/%2e%2e%2fpyproject.toml", # encoded "../"
"/static/./../../pyproject.toml",
"/static/README.md",
"/static/.env",
],
)
def test_static_mount_cannot_escape_its_directory(client: TestClient, path: str) -> None:
response = client.get(path)
assert response.status_code in (400, 404)
assert "requires-python" not in response.text
@pytest.mark.parametrize(
"target",
[
"/static/../pyproject.toml", # what a raw client sends: no normalization
"/static/../../../etc/passwd",
"/static/%2e%2e/%2e%2e/pyproject.toml",
],
)
async def test_asgi_layer_refuses_a_literal_traversal(target: str) -> None:
status, body = await _asgi_get(create_app(Settings()), target)
assert status in (400, 404)
assert b"requires-python" not in body
assert b"root:" not in body
# ----------------------------------------------------------------------------- settings wiring
def test_app_state_holds_the_settings_it_was_built_with() -> None:
settings = Settings(max_players=2, port=8137, tick_hz=30, snapshot_hz=30)
application = create_app(settings)
assert application.state.settings is settings
def test_overridden_settings_are_visible_through_a_live_request(
make_settings: Callable[..., Settings],
) -> None:
application = create_app(make_settings(max_players=1, max_rooms=3))
with TestClient(application) as test_client:
assert test_client.get("/healthz").status_code == 200
assert application.state.settings.max_players == 1
assert application.state.settings.max_rooms == 3
def test_static_dir_override_is_served(tmp_path: Path) -> None:
(tmp_path / "probe.txt").write_text("from the override", encoding="utf-8")
settings = Settings(static_dir=tmp_path)
assert resolve_static_dir(settings) == tmp_path
with TestClient(create_app(settings)) as test_client:
assert test_client.get("/static/probe.txt").text == "from the override"
def test_default_static_dir_is_the_repository_shell() -> None:
assert STATIC_DIR == REPO_ROOT / "app" / "static"
assert resolve_static_dir(Settings()) == STATIC_DIR
assert (STATIC_DIR / "index.html").is_file()
def test_missing_static_directory_fails_at_startup(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match=r"static directory .* does not exist"):
create_app(Settings(static_dir=tmp_path / "nope"))
# ----------------------------------------------------------------------------- startup hook
def test_startup_logs_the_resolved_settings_once(caplog: pytest.LogCaptureFixture) -> None:
with (
caplog.at_level(logging.INFO, logger=APP_LOGGER),
TestClient(create_app(Settings())) as test_client,
):
assert test_client.get("/healthz").status_code == 200
assert caplog.text.count("starting") == 1
assert "port=8000" in caplog.text
assert "tick_hz=20" in caplog.text
assert "stopped" in caplog.text # the shutdown half of the lifespan
def test_startup_log_never_dumps_the_environment(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""The hook logs resolved *values*, never variable names or a `.env` file."""
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
monkeypatch.setenv("UNDERCOOKED_SIM_LATENCY_MS", "5")
reset_settings_cache()
# settings straight from the environment, exactly as `uv run undercooked` does
with caplog.at_level(logging.INFO, logger=APP_LOGGER), TestClient(create_app()):
pass
text = caplog.text
assert "port=8000" in text
assert "sim_latency_ms=5" in text
assert not any(name in text for name in ENV_KEYS.values())
assert ".env" not in text
def test_settings_log_line_is_a_fixed_vocabulary_summary() -> None:
line = settings_log_line(Settings())
assert line == (
"host=127.0.0.1 port=8000 tick_hz=20 snapshot_hz=20 max_players=4 max_rooms=200 "
"levels_dir=levels lobby_idle_timeout_s=900.0 guest_idle_timeout_s=45.0 "
"max_message_bytes=4096 max_inputs_per_s=40 sim_latency_ms=0 "
"allowed_origins=() debugpy=False"
)
names = {field.name for field in dataclasses.fields(Settings)}
assert set(LOGGED_SETTINGS) <= names
def test_startup_hook_passes_the_app_settings_to_the_debugpy_bootstrap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[Settings] = []
monkeypatch.setattr(
app_module, "maybe_start_debugpy", lambda settings: calls.append(settings) or False
)
settings = Settings(debugpy=True, debugpy_port=5999)
with TestClient(create_app(settings)):
pass
assert calls == [settings]
def test_booting_the_app_with_debugpy_disabled_never_imports_it(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A `None` entry makes any real `import debugpy` raise, so a stray listener
would fail loudly instead of binding a port during the suite."""
monkeypatch.setitem(sys.modules, "debugpy", None)
with caplog.at_level(logging.DEBUG, logger="undercooked.debug"), TestClient(
create_app(Settings())
):
pass
assert caplog.records == []
# ----------------------------------------------------------------------------- module `app`
def test_module_level_app_is_lazy_and_cached() -> None:
module = importlib.import_module("undercooked.server.app")
assert module.app is module.app # built once, then cached
assert isinstance(module.app, FastAPI)
assert get_app() is module.app
def test_unknown_module_attribute_raises() -> None:
with pytest.raises(AttributeError, match="has no attribute"):
_ = app_module.not_a_real_attribute
def test_docs_endpoints_are_disabled_because_they_load_a_cdn() -> None:
"""A12: FastAPI renders /docs and /redoc with assets from a public CDN."""
with TestClient(create_app(Settings())) as test_client:
for path in ("/docs", "/redoc", "/openapi.json"):
assert test_client.get(path).status_code == 404
+1
View File
@@ -0,0 +1 @@
Headless determinism and replay tests against the pure simulation kernel (`sim/`).
+1
View File
@@ -0,0 +1 @@
Pure, fast unit tests: config parsing, helpers, and other no-I/O logic.
+471
View File
@@ -0,0 +1,471 @@
"""Unit tests for `undercooked.config` (PLAN.md A17): defaults, overrides, validation.
Everything here calls `load_settings({...})` with an explicit mapping wherever
possible, so a developer's local `.env` can never change what a test asserts.
The `.env` / cache behaviour is exercised separately, in an isolated directory.
"""
from __future__ import annotations
import dataclasses
import os
import subprocess
import sys
from collections.abc import Callable, Iterator
from pathlib import Path
import pytest
from undercooked import config
from undercooked.config import (
ENV_KEYS,
ConfigError,
Settings,
apply_cli_overrides,
get_settings,
load_settings,
reset_settings_cache,
)
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture(autouse=True)
def pristine_env() -> Iterator[None]:
"""Undo env/cache side effects - `load_dotenv` mutates `os.environ` in place."""
saved = dict(os.environ)
reset_settings_cache()
yield
os.environ.clear()
os.environ.update(saved)
reset_settings_cache()
# ----------------------------------------------------------------------------- defaults
def test_defaults_match_the_documented_table() -> None:
settings = load_settings({})
assert settings == Settings()
assert settings.host == "127.0.0.1"
assert settings.port == 8000
assert settings.tick_hz == 20
assert settings.snapshot_hz == 20
assert settings.max_players == 4
assert settings.max_rooms == 200
assert settings.levels_dir == Path("levels")
assert settings.static_dir is None # derived from the package layout, see server.app
assert settings.lobby_idle_timeout_s == 900.0
assert settings.guest_idle_timeout_s == 45.0
assert settings.max_message_bytes == 4096
assert settings.max_inputs_per_s == 40
assert settings.sim_latency_ms == 0
assert settings.debugpy is False
assert settings.debugpy_port == 5678
assert settings.allowed_origins == ()
def test_env_keys_cover_exactly_the_settings_fields() -> None:
assert set(ENV_KEYS) == {field.name for field in dataclasses.fields(Settings)}
def test_settings_is_frozen_and_uses_slots() -> None:
settings = Settings()
with pytest.raises(dataclasses.FrozenInstanceError):
settings.port = 1234 # type: ignore[misc]
assert not hasattr(settings, "__dict__")
def test_config_error_is_a_value_error() -> None:
assert issubclass(ConfigError, ValueError)
# ----------------------------------------------------------------------------- overrides
@pytest.mark.parametrize(
("field", "raw", "expected"),
[
("host", "localhost", "localhost"),
("port", "8123", 8123),
("tick_hz", "30", 30),
("snapshot_hz", "15", 15),
("max_players", "2", 2),
("max_rooms", "1", 1),
("lobby_idle_timeout_s", "60", 60.0),
("guest_idle_timeout_s", "7.5", 7.5),
("max_message_bytes", "8192", 8192),
("max_inputs_per_s", "60", 60),
("sim_latency_ms", "150", 150),
("debugpy_port", "5679", 5679),
],
)
def test_single_variable_override(field: str, raw: str, expected: object) -> None:
settings = load_settings({ENV_KEYS[field]: raw})
assert getattr(settings, field) == expected
assert settings.levels_dir == Path("levels") # untouched fields keep their default
@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on"])
def test_debugpy_truthy_values(raw: str) -> None:
assert load_settings({"DEBUGPY": raw}).debugpy is True
@pytest.mark.parametrize("raw", ["0", "false", "False", "no", "off"])
def test_debugpy_falsy_values(raw: str) -> None:
assert load_settings({"DEBUGPY": raw}).debugpy is False
@pytest.mark.parametrize(
("raw", "expected"),
[
("", ()),
(" ", ()),
("http://localhost:3000", ("http://localhost:3000",)),
("http://a , http://b", ("http://a", "http://b")),
("http://a,,http://b,", ("http://a", "http://b")),
(",,", ()),
],
)
def test_allowed_origins_is_comma_split(raw: str, expected: tuple[str, ...]) -> None:
assert load_settings({ENV_KEYS["allowed_origins"]: raw}).allowed_origins == expected
@pytest.mark.parametrize(
("raw", "expected"),
[
("levels", Path("levels")),
("levels/", Path("levels")),
("/srv/undercooked/levels", Path("/srv/undercooked/levels")),
("~/kitchens", Path.home() / "kitchens"),
("./kitchens", Path("kitchens")),
],
)
def test_levels_dir_path_handling(raw: str, expected: Path) -> None:
assert load_settings({ENV_KEYS["levels_dir"]: raw}).levels_dir == expected
@pytest.mark.parametrize(
("raw", "expected"),
[
("/app/app/static", Path("/app/app/static")),
("~/static", Path.home() / "static"),
("static/", Path("static")),
("", None),
(" ", None),
],
)
def test_static_dir_is_optional_and_expanded(raw: str, expected: Path | None) -> None:
"""An unset `UNDERCOOKED_STATIC_DIR` means "derive it from the package"."""
assert load_settings({ENV_KEYS["static_dir"]: raw}).static_dir == expected
@pytest.mark.parametrize(
"field", ["port", "tick_hz", "lobby_idle_timeout_s", "host", "allowed_origins"]
)
@pytest.mark.parametrize("blank", ["", " ", "\t"])
def test_blank_value_counts_as_unset(field: str, blank: str) -> None:
assert load_settings({ENV_KEYS[field]: blank}) == Settings()
def test_surrounding_whitespace_is_tolerated() -> None:
settings = load_settings({ENV_KEYS["port"]: " 8123 ", ENV_KEYS["host"]: "\nlocalhost\t"})
assert (settings.port, settings.host) == (8123, "localhost")
def test_snapshot_hz_may_equal_tick_hz_but_not_exceed_it() -> None:
both_ten = load_settings({ENV_KEYS["tick_hz"]: "10", ENV_KEYS["snapshot_hz"]: "10"})
assert both_ten.snapshot_hz == 10
with pytest.raises(ConfigError, match=r"UNDERCOOKED_SNAPSHOT_HZ.*must be >= 1, <= 10"):
load_settings({ENV_KEYS["tick_hz"]: "10", ENV_KEYS["snapshot_hz"]: "20"})
# ----------------------------------------------------------------------------- validation errors
@pytest.mark.parametrize(
("field", "raw", "message"),
[
("port", "http", "UNDERCOOKED_PORT='http' is not an integer"),
("port", "8.5", "UNDERCOOKED_PORT='8.5' is not an integer"),
("port", "0", "UNDERCOOKED_PORT=0 is invalid: must be >= 1, <= 65535"),
("port", "65536", "UNDERCOOKED_PORT=65536 is invalid: must be >= 1, <= 65535"),
("tick_hz", "4", "UNDERCOOKED_TICK_HZ=4 is invalid: must be >= 5, <= 60"),
("tick_hz", "99", "UNDERCOOKED_TICK_HZ=99 is invalid: must be >= 5, <= 60"),
("tick_hz", "twenty", "UNDERCOOKED_TICK_HZ='twenty' is not an integer"),
("snapshot_hz", "0", "UNDERCOOKED_SNAPSHOT_HZ=0 is invalid: must be >= 1, <= 20"),
("max_players", "0", "UNDERCOOKED_MAX_PLAYERS=0 is invalid: must be >= 1, <= 4"),
("max_players", "5", "UNDERCOOKED_MAX_PLAYERS=5 is invalid: must be >= 1, <= 4"),
("max_rooms", "0", "UNDERCOOKED_MAX_ROOMS=0 is invalid: must be >= 1"),
("max_rooms", "-3", "UNDERCOOKED_MAX_ROOMS=-3 is invalid: must be >= 1"),
(
"lobby_idle_timeout_s",
"-1",
"UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S=-1.0 is invalid: must be >= 0.0",
),
("lobby_idle_timeout_s", "soon", "UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S='soon' is not a number"),
(
"guest_idle_timeout_s",
"-0.5",
"UNDERCOOKED_GUEST_IDLE_TIMEOUT_S=-0.5 is invalid: must be >= 0.0",
),
(
"max_message_bytes",
"255",
"UNDERCOOKED_MAX_MESSAGE_BYTES=255 is invalid: must be >= 256",
),
("max_inputs_per_s", "0", "UNDERCOOKED_MAX_INPUTS_PER_S=0 is invalid: must be >= 1"),
("sim_latency_ms", "-1", "UNDERCOOKED_SIM_LATENCY_MS=-1 is invalid: must be >= 0"),
("debugpy_port", "0", "DEBUGPY_PORT=0 is invalid: must be >= 1, <= 65535"),
(
"debugpy",
"maybe",
"DEBUGPY='maybe' is not a boolean (use 1/0, true/false, yes/no, on/off)",
),
],
)
def test_invalid_values_raise_config_error(field: str, raw: str, message: str) -> None:
with pytest.raises(ConfigError) as excinfo:
load_settings({ENV_KEYS[field]: raw})
assert str(excinfo.value) == message
def test_out_of_range_tick_hz_fails_from_the_real_environment() -> None:
"""The documented criterion: `UNDERCOOKED_TICK_HZ=99` fails loudly at startup."""
env = {
key: value
for key, value in os.environ.items()
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
}
env["UNDERCOOKED_TICK_HZ"] = "99"
result = subprocess.run( # fixed argv, repo's own interpreter
[sys.executable, "-c", "from undercooked.config import load_settings; load_settings()"],
cwd=REPO_ROOT,
env=env,
capture_output=True,
text=True,
check=False,
timeout=120,
)
assert result.returncode != 0
assert "ConfigError" in result.stderr
assert "UNDERCOOKED_TICK_HZ=99" in result.stderr
# ----------------------------------------------------------------------------- cached accessor
def test_get_settings_caches_until_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
first = get_settings()
assert first.port == 8123
monkeypatch.setenv("UNDERCOOKED_PORT", "9999")
assert get_settings() is first # still the cached object
reset_settings_cache()
assert get_settings().port == 9999
def test_get_settings_applies_defaults_with_an_empty_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
for key in list(os.environ):
if key.startswith(("UNDERCOOKED_", "DEBUGPY")):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "true")
assert get_settings() == Settings()
# ----------------------------------------------------------------------------- .env loading
def test_dotenv_file_is_read_from_the_current_directory(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
monkeypatch.delenv("UNDERCOOKED_MAX_ROOMS", raising=False)
(tmp_path / ".env").write_text(
"UNDERCOOKED_PORT=8123\nUNDERCOOKED_MAX_ROOMS=7\n", encoding="utf-8"
)
monkeypatch.chdir(tmp_path)
settings = load_settings()
assert (settings.port, settings.max_rooms) == (8123, 7)
def test_dotenv_is_found_in_a_parent_directory(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8245\n", encoding="utf-8")
nested = tmp_path / "src" / "undercooked"
nested.mkdir(parents=True)
monkeypatch.chdir(nested)
assert load_settings().port == 8245
def test_dotenv_never_overrides_an_exported_variable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("UNDERCOOKED_PORT", "7000")
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8123\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
assert load_settings().port == 7000
def test_dotenv_is_read_once_until_the_cache_is_reset(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
loaded: list[str] = []
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8123\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def fake_load_dotenv(path: object, **_kwargs: object) -> bool:
loaded.append(str(path))
return True
monkeypatch.setattr(config, "load_dotenv", fake_load_dotenv)
load_settings()
load_settings()
assert len(loaded) == 1 # one `.env` read per process, not one per call
reset_settings_cache()
load_settings()
assert len(loaded) == 2
def test_missing_dotenv_file_is_not_an_error(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
monkeypatch.chdir(tmp_path)
assert load_settings().port == 8000
def test_explicit_env_mapping_skips_dotenv(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.chdir(tmp_path)
def never_find(**_kwargs: object) -> str:
pytest.fail("dotenv must not be read when an explicit env mapping is given")
monkeypatch.setattr(config, "find_dotenv", never_find)
assert load_settings({"UNDERCOOKED_PORT": "8123"}).port == 8123
# ----------------------------------------------------------------------------- .env.example
def parse_env_example(path: Path) -> dict[str, str]:
"""Read `KEY=value` lines from an env template, ignoring comments."""
values: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
values[key.strip()] = value.strip()
return values
def test_env_example_documents_every_variable_with_its_default() -> None:
example = parse_env_example(REPO_ROOT / ".env.example")
assert set(example) == set(ENV_KEYS.values())
assert load_settings(example) == Settings()
@pytest.mark.parametrize("forbidden", ["token", "password", "secret", "api_key", "reese", ".com"])
def test_env_example_values_carry_no_secrets_or_hostnames(forbidden: str) -> None:
"""The template is committed, so its values must stay placeholders (A16/A17)."""
for key, value in parse_env_example(REPO_ROOT / ".env.example").items():
assert forbidden not in value.lower(), f"{key} looks like it holds a secret or a real host"
assert len(value) <= 40, f"{key} is too long to be a default"
# ----------------------------------------------------------------------------- README table
def parse_readme_config_table(path: Path) -> dict[str, str]:
"""Read the VAR / default / meaning rows of the README configuration table.
A default written as *(empty)* means "no value", i.e. an empty string.
"""
lines = path.read_text(encoding="utf-8").splitlines()
try:
start = lines.index("## Configuration")
except ValueError: # pragma: no cover - fails loudly if the section is renamed
pytest.fail("README.md has no '## Configuration' section")
table: dict[str, str] = {}
for line in lines[start + 1 :]:
if line.startswith("## "):
break
stripped = line.strip()
if not stripped.startswith("|"):
continue
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
key = cells[0].strip("`")
if not key.isupper():
continue # header and separator rows
default = cells[1].strip("`")
table[key] = "" if default.startswith("*") else default
return table
def test_readme_documents_every_variable_with_its_default() -> None:
table = parse_readme_config_table(REPO_ROOT / "README.md")
assert set(table) == set(ENV_KEYS.values())
assert load_settings(table) == Settings()
# ----------------------------------------------------------------------------- CLI overrides
def test_apply_cli_overrides_publishes_env_and_rebuilds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("UNDERCOOKED_LEVELS_DIR", raising=False)
settings = apply_cli_overrides(levels_dir=Path("kitchens"), port=9123)
assert settings.levels_dir == Path("kitchens")
assert settings.port == 9123
assert os.environ["UNDERCOOKED_LEVELS_DIR"] == "kitchens"
assert get_settings() == settings # a later get_settings() sees the same thing
def test_apply_cli_overrides_beats_an_existing_environment_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
assert apply_cli_overrides(port=9200).port == 9200
def test_apply_cli_overrides_validates_the_values_it_writes() -> None:
with pytest.raises(ConfigError, match=r"UNDERCOOKED_PORT=0 is invalid"):
apply_cli_overrides(port=0)
def test_apply_cli_overrides_rejects_an_unknown_field() -> None:
with pytest.raises(ConfigError, match="'nonsense' is not a configuration field"):
apply_cli_overrides(**{"nonsense": 1})
def test_apply_cli_overrides_renders_booleans_as_env_flags() -> None:
assert apply_cli_overrides(debugpy=True).debugpy is True
assert os.environ["DEBUGPY"] == "1"
assert apply_cli_overrides(debugpy=False).debugpy is False
assert os.environ["DEBUGPY"] == "0"
def test_apply_cli_overrides_with_nothing_still_validates_the_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("UNDERCOOKED_TICK_HZ", "99")
with pytest.raises(ConfigError, match="UNDERCOOKED_TICK_HZ=99"):
apply_cli_overrides()
# ----------------------------------------------------------------------------- conftest factory
def test_make_settings_fixture_builds_settings(make_settings: Callable[..., Settings]) -> None:
settings = make_settings(tick_hz=30, snapshot_hz=15, debugpy=True, allowed_origins=["http://a", "http://b"])
assert (settings.tick_hz, settings.snapshot_hz) == (30, 15)
assert settings.debugpy is True
assert settings.allowed_origins == ("http://a", "http://b")
assert settings.levels_dir == Path("levels")
+114
View File
@@ -0,0 +1,114 @@
"""Unit tests for the debugpy bootstrap (PLAN.md A18).
The real debugger must never bind a port during the suite: each test injects a
fake `debugpy` through `sys.modules`, which is exactly what the lazy import in
`undercooked.debug` resolves against.
"""
from __future__ import annotations
import logging
import sys
from typing import Any
import pytest
from undercooked import debug
from undercooked.config import Settings
pytestmark = pytest.mark.unit
class FakeDebugpy:
"""Stand-in for the `debugpy` module; records `listen` calls, may fail."""
def __init__(self, error: Exception | None = None) -> None:
self.error = error
self.calls: list[tuple[Any, dict[str, Any]]] = []
def listen(self, address: Any, **kwargs: Any) -> Any:
self.calls.append((address, kwargs))
if self.error is not None:
raise self.error
return address
def install(monkeypatch: pytest.MonkeyPatch, fake: FakeDebugpy | None) -> None:
"""Put `fake` (or `None`, for "not installed") where `import debugpy` looks."""
monkeypatch.setitem(sys.modules, "debugpy", fake)
def test_disabled_is_a_no_op(monkeypatch: pytest.MonkeyPatch) -> None:
# `None` in sys.modules makes any `import debugpy` raise, so reaching the
# import at all would fail the test instead of silently binding a port.
install(monkeypatch, None)
assert debug.maybe_start_debugpy(Settings()) is False
def test_disabled_never_logs(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.DEBUG, logger="undercooked.debug"):
debug.maybe_start_debugpy(Settings(debugpy=False, debugpy_port=5999))
assert caplog.records == []
def test_enabled_listens_once_without_waiting(monkeypatch: pytest.MonkeyPatch) -> None:
fake = FakeDebugpy()
install(monkeypatch, fake)
assert debug.maybe_start_debugpy(Settings(debugpy=True)) is True
assert len(fake.calls) == 1
address, kwargs = fake.calls[0]
assert address == (debug.DEBUGPY_BIND_HOST, 5678)
# Non-blocking is the whole point: startup must not stall waiting for an editor.
assert kwargs.get("wait_for_client", False) is False
def test_enabled_honours_a_custom_port(monkeypatch: pytest.MonkeyPatch) -> None:
fake = FakeDebugpy()
install(monkeypatch, fake)
assert debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5999)) is True
assert fake.calls[0][0] == (debug.DEBUGPY_BIND_HOST, 5999)
def test_enabled_logs_the_endpoint(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
install(monkeypatch, FakeDebugpy())
with caplog.at_level(logging.INFO, logger="undercooked.debug"):
debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5999))
assert "5999" in caplog.text
assert "debugpy" in caplog.text.lower()
def test_missing_debugpy_does_not_stop_the_server(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
install(monkeypatch, None)
with caplog.at_level(logging.WARNING, logger="undercooked.debug"):
assert debug.maybe_start_debugpy(Settings(debugpy=True)) is False
assert "not installed" in caplog.text
@pytest.mark.parametrize(
"error",
[
OSError(98, "Address already in use"),
RuntimeError("already listening"),
ValueError("bad address"),
],
)
def test_listen_failures_are_swallowed(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
error: Exception,
) -> None:
install(monkeypatch, FakeDebugpy(error))
with caplog.at_level(logging.WARNING, logger="undercooked.debug"):
assert debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5678)) is False
assert "could not listen" in caplog.text
def test_logger_is_named_after_the_module() -> None:
assert isinstance(debug.logger, logging.Logger)
assert debug.logger.name == "undercooked.debug"
+409
View File
@@ -0,0 +1,409 @@
"""Docs guard: what `README.md` tells a human to type must be real (phase 01 task 04).
Documentation rots in two ways this file makes impossible:
1. **Pseudo-commands.** A fenced block that reads plausibly but cannot run — an invented
script, a tool that is not part of this project, a placeholder like `make deploy`.
Every command line inside a `bash`/`sh`/`console`/`shell` fence of `README.md` or
`docs/*.md` must start with a known-safe prefix, and whatever it launches through
`uv run` must be a console script or a dependency declared in `pyproject.toml`. That is
what keeps the Quickstart true after a rename — and what stops a promise (e.g. the
phase-02 `undercooked-levels` CLI) from being written as a runnable command before it
exists.
2. **Config drift.** A variable documented but not implemented, or implemented but never
documented. AGENTS.md §5 requires the `Settings` field, the `ENV_KEYS` entry, the
`.env.example` line and the README row to land together; here the documentation side is
compared against `config.py` and `.env.example`, so they cannot disagree.
"""
from __future__ import annotations
import re
import tomllib
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
import pytest
from undercooked.config import ENV_KEYS
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
README = REPO_ROOT / "README.md"
ENV_EXAMPLE = REPO_ROOT / ".env.example"
PYPROJECT = REPO_ROOT / "pyproject.toml"
#: Markdown documents whose command blocks a human may copy.
DOCUMENTED_MARKDOWN: tuple[Path, ...] = (README, *sorted((REPO_ROOT / "docs").glob("*.md")))
#: Shell fences that promise "type this".
COMMAND_FENCE_LANGUAGES = frozenset({"bash", "sh", "console", "shell"})
#: The only things a documented command line may start with. Anything else is prose
#: wearing a code fence, and it will be deleted rather than run.
SAFE_PREFIXES = ("uv ", "podman ", "bash .agents/validate.sh", "curl ", "cd ", "#")
#: `FOO=bar cmd …` is still a command; only documented variables may lead a line.
ENV_ASSIGNMENT = re.compile(r"""^(?:[A-Z][A-Z0-9_]*=(?:"[^"]*"|'[^']*'|\S)+\s+)+""")
#: Every `UNDERCOOKED_*` / `DEBUGPY*` name written anywhere in prose, a table or a fence.
#: A name has to start with a real character, so the `UNDERCOOKED_*` wildcard in prose is
#: not read as a variable nobody implemented.
DOCUMENTED_VARIABLE = re.compile(r"\bUNDERCOOKED_[A-Z0-9_]*[A-Z0-9]\b|\bDEBUGPY(?:_[A-Z0-9]+)?\b")
#: `uv run` options that consume the token after them, so the program name is further along.
UV_VALUE_OPTIONS = frozenset({"--extra", "--python", "--with", "--directory", "--project", "-m"})
#: `KEY=value` lines of an env template.
_TEMPLATE_VARIABLE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*=")
@dataclass(frozen=True, slots=True)
class Command:
"""One logical command line from a fenced block (continuations joined)."""
document: str
line: int
text: str
def __str__(self) -> str:
return f"{self.document}:{self.line}: {self.text}"
def _strip_assignment(command: str) -> tuple[tuple[str, ...], str]:
"""Split `A=1 B=2 uv run …` into its variable names and the command underneath."""
names: list[str] = []
while match := ENV_ASSIGNMENT.match(command):
names.extend(part.split("=", 1)[0] for part in match.group(0).split())
command = command[match.end(0) :].lstrip()
return tuple(names), command
def fenced_commands(markdown: str, *, document: str = "<memory>") -> list[Command]:
"""Every command inside a `bash`-family fence, with the line it started on.
A trailing-backslash continuation becomes one `Command`, because only the first line
of a wrapped command carries the program name.
"""
commands: list[Command] = []
fence: str | None = None
pending: list[str] = []
pending_line = 0
for number, raw in enumerate(markdown.splitlines(), start=1):
marker = re.match(r"^\s*```(\w*)\s*$", raw)
if marker:
fence = None if fence is not None else marker.group(1).lower()
if pending:
commands.append(Command(document, pending_line, " ".join(pending)))
pending = []
continue
if fence is None or fence not in COMMAND_FENCE_LANGUAGES:
continue
stripped = raw.strip()
if not stripped:
if pending:
commands.append(Command(document, pending_line, " ".join(pending)))
pending = []
continue
if not pending:
pending_line = number
if stripped.endswith("\\"):
pending.append(stripped[:-1].strip())
continue
pending.append(stripped)
commands.append(Command(document, pending_line, " ".join(pending)))
pending = []
if pending: # pragma: no cover - a document ending mid-continuation
commands.append(Command(document, pending_line, " ".join(pending)))
return commands
def uv_run_program(command: str) -> str | None:
"""The program a `uv run …` line launches, with uv's own options skipped; else `None`.
That program must be declared in `pyproject.toml`, which is what makes a doc line
inventing a script fail the build.
"""
if not command.startswith("uv run"):
return None
tokens = command.split()[2:]
index = 0
while index < len(tokens):
token = tokens[index]
if token in UV_VALUE_OPTIONS:
index += 2
continue
if token.startswith("-"):
index += 1
continue
return token.lower()
return None
def declared_uv_programs() -> frozenset[str]:
"""Program names `uv run` may launch: this project's scripts plus its dependencies."""
def names(requirements: list[str]) -> set[str]:
return {re.split(r"[<>=!;\[ ]", name)[0].strip().lower() for name in requirements}
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
project = data["project"]
packages = names(list(project.get("dependencies", [])))
for extra in project.get("optional-dependencies", {}).values():
packages |= names(list(extra))
return frozenset(set(project.get("scripts", {})) | packages | {"python"})
def template_variables() -> set[str]:
"""Names declared in the committed `.env.example` template (comments ignored)."""
return {
match.group(1)
for line in ENV_EXAMPLE.read_text(encoding="utf-8").splitlines()
if (match := _TEMPLATE_VARIABLE.match(line.strip()))
}
def variables_in(path: Path) -> set[str]:
"""Every configuration variable named anywhere in one markdown file."""
return set(DOCUMENTED_VARIABLE.findall(path.read_text(encoding="utf-8")))
def _fail(header: str, offenders: list[str]) -> None:
pytest.fail(f"{header}\n" + "\n".join(f" {offender}" for offender in offenders))
def unsafe_commands(commands: Iterable[Command]) -> list[str]:
"""Commands whose first token is none of `SAFE_PREFIXES`, once env assignments are peeled."""
offenders: list[str] = []
for command in commands:
_, bare = _strip_assignment(command.text)
if not bare.startswith(SAFE_PREFIXES):
offenders.append(str(command))
return offenders
def undeclared_programs(commands: Iterable[Command], allowed: frozenset[str]) -> list[str]:
"""`uv run <thing>` lines whose `<thing>` pyproject.toml does not declare."""
offenders: list[str] = []
for command in commands:
_, bare = _strip_assignment(command.text)
program = uv_run_program(bare)
if program is not None and program not in allowed:
offenders.append(f"{command} -> {program!r} is not in pyproject.toml")
return offenders
def documented_but_unimplemented(documented: set[str], implemented: set[str]) -> list[str]:
"""Variables the docs name and `config.py` does not read."""
return sorted(documented - implemented)
def implemented_but_undocumented(implemented: set[str], documented: set[str]) -> list[str]:
"""Variables `config.py` reads and the docs never mention."""
return sorted(set(implemented) - documented)
# --------------------------------------------------------------------------- command blocks
def test_readme_has_command_blocks_worth_checking() -> None:
"""Non-vacuous: if the Quickstart stops being a bash fence, this has to notice."""
commands = fenced_commands(README.read_text(encoding="utf-8"), document="README.md")
assert len(commands) >= 8, f"README.md has lost its command blocks: {commands}"
assert any(command.text.startswith("uv run undercooked") for command in commands)
assert any(command.text.startswith("uv run pytest") for command in commands)
def test_documented_command_lines_start_with_a_known_prefix() -> None:
"""No pseudo-commands in the docs a player or an operator copy-pastes from."""
offenders: list[str] = []
for document in DOCUMENTED_MARKDOWN:
commands = fenced_commands(document.read_text(encoding="utf-8"), document=document.name)
offenders.extend(unsafe_commands(commands))
if offenders:
_fail(f"documented commands must start with one of {SAFE_PREFIXES}:", offenders)
def test_documented_commands_only_name_declared_programs() -> None:
"""`uv run <thing>` may only launch a script or dependency of this project."""
allowed = declared_uv_programs()
offenders: list[str] = []
for document in DOCUMENTED_MARKDOWN:
commands = fenced_commands(document.read_text(encoding="utf-8"), document=document.name)
offenders.extend(undeclared_programs(commands, allowed))
if offenders:
_fail(f"`uv run` may only run {sorted(allowed)}:", offenders)
def test_command_fence_is_a_fence_and_not_a_table() -> None:
"""Pins the extractor: fenced bash is a command, indented prose and other fences are not."""
sample = "\n".join(
[
"text `not-a-command --yolo`", # 1: inline code is not a command
"",
"```bash", # 3
"uv run undercooked \\", # 4 \\ the continuation joins the next line
" --port 8137", # 5
"", # 6: a blank line ends a command, it never joins across it
"uv run pytest -q", # 7
"# a comment", # 8
"uv run ruff check \\", # 9 \\ a continuation cut off by a blank line still counts
"",
"uv run pyright \\", # 11 \\ and one cut off by the closing fence counts too
"```",
"",
"```text", # 14: not a command fence
"made-up-command --yolo",
"```",
]
)
assert fenced_commands(sample) == [
Command("<memory>", 4, "uv run undercooked --port 8137"),
Command("<memory>", 7, "uv run pytest -q"),
Command("<memory>", 8, "# a comment"),
Command("<memory>", 9, "uv run ruff check"),
Command("<memory>", 11, "uv run pyright"),
]
assert str(Command("README.md", 12, "uv run undercooked")) == "README.md:12: uv run undercooked"
def test_leading_environment_assignments_are_peeled_off() -> None:
names, bare = _strip_assignment("DEBUGPY=1 UNDERCOOKED_PORT=8137 uv run undercooked")
assert names == ("DEBUGPY", "UNDERCOOKED_PORT")
assert bare == "uv run undercooked"
@pytest.mark.parametrize(
("command", "program"),
[
("uv run pytest -q", "pytest"),
("uv run --extra dev ruff check .", "ruff"),
("uv run undercooked --reload", "undercooked"),
("uv run python -c 'print(1)'", "python"),
],
)
def test_the_uv_run_extractor_finds_the_program(command: str, program: str) -> None:
assert uv_run_program(command) == program
@pytest.mark.parametrize("command", ["podman build .", "uv sync --extra dev", "cd .", "uv run", ""])
def test_the_uv_run_extractor_returns_none_for_everything_else(command: str) -> None:
assert uv_run_program(command) is None
def test_the_declared_programs_cover_the_real_toolchain() -> None:
"""Derived from pyproject.toml, so a dependency removal breaks the docs loudly."""
allowed = declared_uv_programs()
assert {"undercooked", "pytest", "ruff", "pyright", "playwright", "python"} <= allowed
assert "undercooked-levels" in allowed # declared now, importable from phase 02
# --------------------------------------------------------------------------- variable names
def test_every_variable_named_in_the_docs_is_implemented() -> None:
"""README and docs may only name a variable `config.py` or `.env.example` defines."""
implemented = set(ENV_KEYS.values()) | template_variables()
offenders = [
f"{document.name}: {name}"
for document in DOCUMENTED_MARKDOWN
for name in documented_but_unimplemented(variables_in(document), implemented)
]
if offenders:
_fail("documented but not implemented:", offenders)
def test_every_implemented_variable_is_documented() -> None:
"""The reverse direction: a knob nobody documented is a knob nobody can find."""
documented = variables_in(README) | variables_in(ENV_EXAMPLE)
missing = implemented_but_undocumented(set(ENV_KEYS.values()), documented)
if missing:
_fail("config.py defines variables neither README.md nor .env.example mentions:", missing)
def test_readme_and_env_template_name_the_same_variables() -> None:
"""The two operator-facing lists cannot diverge from each other."""
documented, template = variables_in(README), variables_in(ENV_EXAMPLE)
only_readme = sorted(documented - template)
only_template = sorted(template - documented)
assert not only_readme, f"named in README.md but absent from .env.example: {only_readme}"
assert not only_template, f"named in .env.example but absent from README.md: {only_template}"
# --------------------------------------------------------------------------- the gate is documented
def test_readme_documents_the_gate_and_the_cdn_rule() -> None:
"""The commands AGENTS.md calls binding are findable in the human-facing docs."""
readme = README.read_text(encoding="utf-8")
assert "bash .agents/validate.sh" in readme, "the pipeline gate is not documented in README"
assert re.search(r"no external cdns", readme, re.IGNORECASE), "A12 is not stated in README"
def test_a_pseudo_command_is_reported() -> None:
"""The prefix rule actually fires: planted prose-commands are named, real ones are not."""
sample = "\n".join(
[
"```bash", # 1
"uv run undercooked --port 8137", # 2
"DEBUGPY=1 uv run undercooked", # 3: an env lead is still a command
"make deploy-to-staging", # 4
"kubectl port-forward svc/undercooked 8000", # 5
"# a comment is a valid line", # 6
"```",
]
)
offenders = unsafe_commands(fenced_commands(sample, document="<memory>"))
assert offenders == [
"<memory>:4: make deploy-to-staging",
"<memory>:5: kubectl port-forward svc/undercooked 8000",
]
def test_an_invented_program_is_reported() -> None:
"""A doc line cannot run a script pyproject.toml does not declare."""
sample = "\n".join(
[
"```bash",
"uv run --frozen undercooked-levels validate",
"uv run made-up-cli validate",
"```",
]
)
commands = fenced_commands(sample, document="<memory>")
offenders = undeclared_programs(commands, frozenset({"undercooked-levels"}))
assert len(offenders) == 1, offenders
assert "made-up-cli" in offenders[0]
def test_the_variable_diffs_work_in_both_directions() -> None:
"""The two drift directions are tested, not just asserted to be empty on today's docs."""
implemented = {"UNDERCOOKED_PORT", "DEBUGPY"}
documented = {"UNDERCOOKED_PORT", "DEBUGPY", "UNDERCOOKED_MADE_UP", "DEBUGPY_PORT"}
assert documented_but_unimplemented(documented, implemented) == [
"DEBUGPY_PORT",
"UNDERCOOKED_MADE_UP",
]
assert implemented_but_undocumented(implemented, documented) == []
assert implemented_but_undocumented({"UNDERCOOKED_NEW_KNOB"}, documented) == [
"UNDERCOOKED_NEW_KNOB"
]
def test_the_failure_helper_lists_every_offender() -> None:
"""`_fail` is the message an executor reads; it has to enumerate, not truncate."""
with pytest.raises(pytest.fail.Exception, match="two"):
_fail("broken:", ["one", "two"])
def test_agents_md_only_names_test_paths_that_exist() -> None:
"""Every concrete `tests/**.py|md` path AGENTS.md cites is a real file."""
agents = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8")
cited = re.findall(r"tests/[A-Za-z0-9_./-]+\.(?:py|md)", agents)
assert "tests/unit/test_no_external_assets.py" in cited
assert cited, "AGENTS.md cites no test files at all"
missing = sorted({path for path in cited if not (REPO_ROOT / path).is_file()})
assert missing == [], f"AGENTS.md points at files that do not exist: {missing}"
+357
View File
@@ -0,0 +1,357 @@
"""PLAN §13 doc-index guard: the promised documentation set and the `docs/` tree cannot drift.
`.agents/PLAN.md` §13 closes with the canonical documentation index and the sentence
"`tests/unit/test_docs_index.py` (phase 01) keeps this table and the `docs/` tree identical,
so neither can drift silently." This is that file. It closes both directions of drift:
* **A promised doc is missing.** Every index row whose creating phase has already completed
(according to the `.agents/phases/complete/` tree) must exist on disk, so no phase can
reference a document that was never written. A range in the "Created by" column —
`phase 02/03` — promises the file by the *end* of that range, so its obligation starts at
the later phase; a `→` marks the stub-then-fill pattern, where the earlier phase owes the
stub.
* **A doc exists that nothing promises.** Every `docs/*.md` must be listed in the index or be
a document a phase file of the roadmap itself creates — `docs/ROOMS.md` comes from phase 05
and is not in §13, so the roadmap may extend the doc set, but an untracked file may not
appear there silently.
Only markdown is compared: the index promises *documents*, and a diagram or fixture a phase
puts under `docs/` is not a documentation promise.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
PLAN = REPO_ROOT / ".agents/PLAN.md"
DOCS_DIR = REPO_ROOT / "docs"
PHASES_DIR = REPO_ROOT / ".agents/phases"
COMPLETE_DIR = PHASES_DIR / "complete"
INDEX_HEADING = "### Documentation index (canonical)"
PHASE_DIRECTORY = re.compile(r"^(\d{2})_")
PHASE_REFERENCE = re.compile(r"phase\s+(\d{1,2})", re.IGNORECASE)
NUMBER = re.compile(r"\d{1,2}")
DOC_REFERENCE = re.compile(r"docs/[A-Za-z0-9_-]+\.md")
#: The index exactly as PLAN §13 writes it. Changing this set means editing PLAN.md first.
CANONICAL_DOCS = frozenset(
{
"README.md",
"AGENTS.md",
"BUDGETS.md",
"docs/DESIGN.md",
"docs/SIMULATION.md",
"docs/LEVELS.md",
"docs/PROTOCOL.md",
"docs/CLIENT.md",
"docs/DEPLOYMENT.md",
"docs/TESTING.md",
"docs/ACCESSIBILITY.md",
"docs/SECURITY.md",
"tests/e2e/README.md",
}
)
@dataclass(frozen=True, slots=True)
class DocRow:
"""One row of the PLAN §13 index: path, contents, and the phase(s) that create it."""
path: str
contents: str
created_by: str
@property
def promised_from(self) -> int:
"""First phase after which this file must exist.
The first comma/arrow-separated group is the creation promise. Inside it, a slash
range (`phase 02/03`) is owed by the end of the range, so the later number wins;
anything else is owed by the earliest number mentioned.
"""
first_group = re.split(r"[→,;]", self.created_by, maxsplit=1)[0]
numbers = [int(number) for number in NUMBER.findall(first_group)]
if not numbers:
numbers = [int(number) for number in PHASE_REFERENCE.findall(self.created_by)]
if not numbers:
pytest.fail(f"PLAN index row {self.path!r} names no phase: {self.created_by!r}")
return numbers[-1] if "/" in first_group and len(numbers) > 1 else numbers[0]
def parse_documentation_index(plan_text: str) -> list[DocRow]:
"""Read the canonical documentation-index table out of PLAN.md text."""
lines = plan_text.splitlines()
try:
start = lines.index(INDEX_HEADING)
except ValueError: # pragma: no cover - PLAN is locked; fail loudly if it is renamed
pytest.fail(f".agents/PLAN.md has no {INDEX_HEADING!r} heading")
rows: list[DocRow] = []
for raw in lines[start + 1 :]:
stripped = raw.strip()
if not stripped.startswith("|"):
if rows:
break
continue
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
if len(cells) < 3 or cells[0].strip("`") in {"Path", ""} or set(cells[0]) <= {"-", ":"}:
continue
rows.append(DocRow(path=cells[0].strip("`"), contents=cells[1], created_by=cells[2]))
return rows
def latest_completed_phase(complete_dir: Path = COMPLETE_DIR) -> int:
"""Highest phase number with anything in `complete/` (0 before the first phase finishes)."""
return max(
(
int(match.group(1))
for entry in complete_dir.glob("*")
if (match := PHASE_DIRECTORY.match(entry.name))
),
default=0,
)
def roadmap_promised_docs(phases_dir: Path = PHASES_DIR) -> set[str]:
"""Every `docs/*.md` a phase overview or task file itself names."""
return {
match.group(0)
for phase_file in sorted(phases_dir.rglob("*.md"))
for match in DOC_REFERENCE.finditer(phase_file.read_text(encoding="utf-8"))
}
def documentation_index(plan: Path = PLAN) -> list[DocRow]:
return parse_documentation_index(plan.read_text(encoding="utf-8"))
def written_docs(docs_dir: Path = DOCS_DIR, *, root: Path = REPO_ROOT) -> set[str]:
"""The markdown currently in `docs/`, as repo-relative paths."""
return {path.relative_to(root).as_posix() for path in sorted(docs_dir.rglob("*.md"))}
# --------------------------------------------------------------------------- the index itself
def test_the_documentation_index_is_parseable() -> None:
rows = documentation_index()
assert len(rows) >= len(CANONICAL_DOCS), rows
paths = [row.path for row in rows]
assert len(paths) == len(set(paths)), f"duplicate index rows: {paths}"
assert all(row.contents and row.created_by for row in rows), paths
def test_the_index_still_lists_the_canonical_doc_set() -> None:
"""Adding or dropping a row is a PLAN §13 change and must fail a test first."""
assert {row.path for row in documentation_index()} == CANONICAL_DOCS
@pytest.mark.parametrize(
("created_by", "expected"),
[
("phase 01", 1),
("phase 01, refined throughout", 1),
("phase 01 (stub) \u2192 phase 09", 1),
("phase 02, phase 11", 2),
("phase 02/03", 3),
("phase 14", 14),
("refined throughout, phase 05", 5),
],
)
def test_the_promised_from_rule_reads_the_phase_column(created_by: str, expected: int) -> None:
row = DocRow(path="docs/X.md", contents="whatever", created_by=created_by)
assert row.promised_from == expected
def test_an_index_row_that_names_no_phase_fails_loudly() -> None:
row = DocRow(path="docs/X.md", contents="whatever", created_by="someday, maybe")
with pytest.raises(pytest.fail.Exception, match="names no phase"):
_ = row.promised_from
# --------------------------------------------------------------------------- promised vs present
def test_every_doc_promised_by_a_completed_phase_exists() -> None:
"""No phase may reference a document whose creating phase is already in `complete/`."""
completed = latest_completed_phase()
assert completed >= 1, "no completed phase found — the guard would be vacuous"
missing = [
f"{row.path} (owed by phase {row.promised_from:02d}, {completed:02d} is complete)"
for row in documentation_index()
if row.promised_from <= completed and not (REPO_ROOT / row.path).is_file()
]
report = "\n ".join(missing)
assert missing == [], f"documentation the project owes but does not have:\n {report}"
def test_no_doc_file_exists_that_nothing_promises() -> None:
"""The `docs/` tree is exactly the index plus what the phase roadmap creates."""
promised = {row.path for row in documentation_index()} | roadmap_promised_docs()
orphans = sorted(written_docs() - promised)
report = "\n ".join(orphans)
assert orphans == [], (
f"unpromised files under docs/ — add each to PLAN §13's index or to the phase "
f"that creates it:\n {report}"
)
def test_the_docs_on_disk_are_the_three_phase_01_documents() -> None:
"""Pins today's tree so that adding a document is a deliberate, named act."""
assert written_docs() == {"docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/TESTING.md"}
def stub_without_owner(relative: str, text: str) -> str | None:
"""One sentence of complaint about a document, or `None` when it is honest.
Split out of the test below so a planted stub can prove each check fires; a guard that
only ever sees healthy input is not a guard.
"""
if not text.startswith("# "):
return f"{relative} does not open with a title"
if "## Purpose" not in text:
return f"{relative} has no Purpose section"
purpose = text.split("## Purpose", 1)[1].split("\n##", 1)[0].strip()
if len(purpose.split()) < 25:
return f"{relative}'s Purpose paragraph is too thin to guide"
names_stub = re.search(r"\bstub\b", text, re.IGNORECASE) is not None
has_owner = re.search(r"[Ff]illed in by phase \d\d", text) is not None
if names_stub and not has_owner:
return f"{relative} is a stub with no 'Filled in by phase NN' marker"
return None
def dishonest_references(
references: dict[str, set[str]], *, exists: Callable[[str], bool], owed_later: set[str]
) -> list[str]:
"""`document: path` for each cited doc that neither exists nor is owed by a later phase."""
offenders: list[str] = []
for name, cited in sorted(references.items()):
offenders.extend(
f"{name}: {path}"
for path in sorted(cited)
if not exists(path) and path not in owed_later
)
return offenders
def test_the_stub_docs_say_who_fills_them_in() -> None:
"""A stub with no owner stays a stub forever, so each one names its phase."""
complaints = [
complaint
for relative in sorted(written_docs())
if (complaint := stub_without_owner(relative, (REPO_ROOT / relative).read_text("utf-8")))
]
report = "\n ".join(complaints)
assert complaints == [], f"documents that are not self-describing:\n {report}"
def test_readme_and_agents_reference_only_honest_doc_paths() -> None:
"""A doc path in README/AGENTS either exists or is owed by a phase that has not run."""
completed = latest_completed_phase()
rows = documentation_index()
owed_later = {row.path for row in rows if row.promised_from > completed}
owed_later |= roadmap_promised_docs()
root_docs = {row.path for row in rows if not row.path.startswith("docs/")}
references: dict[str, set[str]] = {}
for name in ("README.md", "AGENTS.md"):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
references[name] = set(DOC_REFERENCE.findall(text)) | {
path for path in root_docs if path in text
}
offenders = dishonest_references(
references, exists=lambda path: (REPO_ROOT / path).is_file(), owed_later=owed_later
)
report = "\n ".join(offenders)
assert offenders == [], f"references to documentation nothing promises to create:\n {report}"
# --------------------------------------------------------------------------- the guard itself
def test_a_missing_promised_doc_is_named(tmp_path: Path) -> None:
"""Plant a row whose phase already shipped: the parse must expose it as owed."""
plan = tmp_path / "PLAN.md"
plan.write_text(
"\n".join(
[
"## Risks",
"",
INDEX_HEADING,
"",
"| Path | Contents | Created by |",
"|---|---|---|",
"| `docs/GONE.md` | whatever it says | phase 01 |",
"| `docs/LATER.md` | not owed yet | phase 09 |",
"",
"prose after the table",
]
),
encoding="utf-8",
)
rows = parse_documentation_index(plan.read_text(encoding="utf-8"))
assert [row.path for row in rows] == ["docs/GONE.md", "docs/LATER.md"]
owed = [row.path for row in rows if row.promised_from <= 1]
assert owed == ["docs/GONE.md"]
assert not (tmp_path / owed[0]).is_file()
def test_an_unpromised_doc_file_is_reported(tmp_path: Path) -> None:
"""The orphan rule works on a temp tree too — it is not README-specific magic."""
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "WANTED.md").write_text("# WANTED\n", encoding="utf-8")
(tmp_path / "docs" / "MYSTERY.md").write_text("# MYSTERY\n", encoding="utf-8")
(tmp_path / "docs" / "tile.svg").write_text("<svg/>", encoding="utf-8")
assert written_docs(tmp_path, root=tmp_path) == {
"docs/MYSTERY.md",
"docs/WANTED.md",
}
assert written_docs(tmp_path, root=tmp_path) - {"docs/WANTED.md"} == {"docs/MYSTERY.md"}
def test_an_unhonest_document_is_described_precisely() -> None:
"""Each complaint the stub check can make is exercised on planted text."""
body = "word " * 30
good = f"# Title\n\n## Purpose\n{body}\nA stub here. Filled in by phase 09.\n"
assert stub_without_owner("docs/GOOD.md", good) is None
assert stub_without_owner("docs/ORPHAN.md", good.replace(" Filled in by phase 09.", "")) == (
"docs/ORPHAN.md is a stub with no 'Filled in by phase NN' marker"
)
assert stub_without_owner("docs/UNTITLED.md", f"## Purpose\n{body}") == (
"docs/UNTITLED.md does not open with a title"
)
assert stub_without_owner("docs/NOPURPOSE.md", "# Title\nProse only.\n") == (
"docs/NOPURPOSE.md has no Purpose section"
)
assert stub_without_owner("docs/THIN.md", "# Title\n\n## Purpose\ntoo short\n") == (
"docs/THIN.md's Purpose paragraph is too thin to guide"
)
def test_a_reference_to_a_doc_nobody_promises_is_named() -> None:
references = {
"AGENTS.md": {"docs/HERE.md"},
"README.md": {"docs/HERE.md", "docs/GHOST.md", "docs/OWED.md"},
}
offenders = dishonest_references(
references,
exists=lambda path: path in {"docs/HERE.md", "docs/OWED.md"},
owed_later={"docs/OWED.md"},
)
assert offenders == ["README.md: docs/GHOST.md"]
def test_the_roadmap_extension_of_the_doc_set_is_tracked() -> None:
"""`docs/ROOMS.md` is phase 05's, not PLAN §13's — and must not read as an orphan."""
roadmap = roadmap_promised_docs()
assert "docs/ROOMS.md" in roadmap
assert "docs/ROOMS.md" not in CANONICAL_DOCS
assert latest_completed_phase() >= 1
+90
View File
@@ -0,0 +1,90 @@
"""Phase-01 task-01 harness smoke tests: package metadata and pytest wiring."""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
import undercooked
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
HARNESS_MARKERS = ("unit", "integration", "e2e", "slow")
TEST_SUBDIRS = ("unit", "integration", "sim", "e2e")
def test_package_metadata() -> None:
assert undercooked.__version__ == "0.1.0"
assert undercooked.__all__ == []
def test_harness_markers_registered(pytestconfig: pytest.Config) -> None:
registered = {line.split(":", 1)[0].strip() for line in pytestconfig.getini("markers")}
assert set(HARNESS_MARKERS) <= registered
def test_strict_markers_enabled(pytestconfig: pytest.Config) -> None:
assert "--strict-markers" in pytestconfig.getini("addopts")
@pytest.mark.parametrize("name", TEST_SUBDIRS)
def test_test_tree_directories_exist_with_readme(name: str) -> None:
directory = REPO_ROOT / "tests" / name
assert directory.is_dir()
readme = directory / "README.md"
assert readme.is_file()
assert readme.read_text(encoding="utf-8").strip()
def test_tests_dir_is_not_a_package() -> None:
assert not (REPO_ROOT / "tests" / "__init__.py").exists()
def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"])
assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels"
assert levels_dir.is_dir()
assert not list(levels_dir.glob("*.yaml"))
def test_e2e_tests_follow_the_story_naming_convention() -> None:
"""`pytest -m e2e --collect-only` selects only `test_usNN_*.py` story suites.
Task 01 could only assert "the marker collects nothing yet". Now that the first
browser suite exists the durable invariant is the naming contract in
`tests/e2e/README.md`: a story suite that is not named for its story is one that
nobody can find, and one that cannot be collected is one that never runs.
"""
result = subprocess.run( # fixed argv, repo's own interpreter
[
sys.executable,
"-m",
"pytest",
"-m",
"e2e",
"--collect-only",
"-q",
"-o",
"addopts=", # drop the outer coverage flags: no nested .coverage writes
"-p",
"no:cacheprovider",
],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
timeout=120,
)
assert result.returncode == pytest.ExitCode.OK, result.stdout + result.stderr
assert "error" not in result.stderr.lower(), result.stderr
files = sorted({line.split("::")[0] for line in result.stdout.splitlines() if "::" in line})
assert files, f"the e2e marker selected nothing:\n{result.stdout}"
for path in files:
assert path.startswith("tests/e2e/"), path
assert re.fullmatch(r"test_us\d{2}_.+\.py", Path(path).name), path
+105
View File
@@ -0,0 +1,105 @@
"""Unit tests for the security headers (PLAN.md §11, A12).
These assert the *values*, not just their presence: a header that silently
weakened (a `unsafe-inline`, a `*`, a reporting endpoint) is a security
regression dressed up as a passing test.
"""
from __future__ import annotations
import pytest
from starlette.responses import JSONResponse, PlainTextResponse, Response
from undercooked.server.headers import (
CONTENT_SECURITY_POLICY,
SECURITY_HEADERS,
SecurityHeadersMiddleware,
apply_security_headers,
)
pytestmark = pytest.mark.unit
EXPECTED = {
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
def test_the_five_documented_headers_are_defined() -> None:
assert dict(SECURITY_HEADERS) == EXPECTED
def test_apply_adds_every_header_to_a_response() -> None:
response = PlainTextResponse("hi")
apply_security_headers(response)
for header, value in EXPECTED.items():
assert response.headers[header] == value
def test_apply_works_on_json_and_empty_bodies() -> None:
payload = JSONResponse({"a": 1})
apply_security_headers(payload)
assert payload.headers["content-security-policy"] == CONTENT_SECURITY_POLICY
empty = Response(status_code=204)
apply_security_headers(empty)
assert empty.headers["x-content-type-options"] == "nosniff"
def test_apply_is_idempotent_and_overrides_weaker_values() -> None:
response = PlainTextResponse("hi", headers={"X-Frame-Options": "SAMEORIGIN"})
apply_security_headers(response)
apply_security_headers(response)
assert response.headers["x-frame-options"] == "DENY"
def _csp() -> dict[str, str]:
"""The CSP as `directive -> whole clause`, so assertions read like the header."""
return {part.split(" ", 1)[0]: part for part in CONTENT_SECURITY_POLICY.split("; ")}
def test_csp_locks_scripts_and_styles_to_self() -> None:
directives = _csp()
assert directives["default-src"] == "default-src 'self'"
assert directives["script-src"] == "script-src 'self'"
assert directives["style-src"] == "style-src 'self'"
assert directives["font-src"] == "font-src 'self'"
assert directives["base-uri"] == "base-uri 'none'"
assert directives["frame-ancestors"] == "frame-ancestors 'none'"
def test_csp_allows_only_same_origin_websockets_and_data_images() -> None:
directives = _csp()
assert directives["connect-src"] == "connect-src 'self' ws: wss:"
assert directives["img-src"] == "img-src 'self' data:"
@pytest.mark.parametrize(
"forbidden", ["unsafe-inline", "unsafe-eval", " https://", "http://", "*", "report-uri"]
)
def test_csp_contains_no_unsafe_source_or_remote_endpoint(forbidden: str) -> None:
assert forbidden not in CONTENT_SECURITY_POLICY
def test_no_header_points_at_a_remote_reporting_service() -> None:
"""`Report-To`/`report-uri` would phone a third party: A12 forbids it."""
assert not any(name.lower() in {"report-to", "nel"} for name in SECURITY_HEADERS)
for value in SECURITY_HEADERS.values():
assert "://" not in value
assert "report" not in value.lower()
def test_headers_are_immutable() -> None:
with pytest.raises(TypeError):
SECURITY_HEADERS["X-Frame-Options"] = "ALLOWALL" # type: ignore[index]
def test_middleware_is_constructible_with_an_app_stub() -> None:
async def stub_app(scope: object, receive: object, send: object) -> None: # pragma: no cover
return None
middleware = SecurityHeadersMiddleware(stub_app)
assert middleware.app is stub_app
+257
View File
@@ -0,0 +1,257 @@
"""Unit tests for the `undercooked` CLI (task 03): precedence, exit codes, wiring.
`_resolve` and `_uvicorn_log_config` are pure, so precedence is testable without
a socket. `main()` is tested with uvicorn's `run` replaced by a recorder — the
real server is started by the integration/phase criteria, not by a unit test.
"""
from __future__ import annotations
import copy
import os
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
import uvicorn
import uvicorn.config
from undercooked.__main__ import (
APP_IMPORT_STRING,
EXIT_CONFIG_ERROR,
EXIT_OK,
RunOptions,
_cli_overrides,
_resolve,
_uvicorn_log_config,
build_parser,
main,
)
from undercooked.config import (
ENV_KEYS,
ConfigError,
Settings,
apply_cli_overrides,
get_settings,
reset_settings_cache,
)
if TYPE_CHECKING:
from collections.abc import Iterator
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
# A string in an assertion, never a socket bind: the CLI flag is what is under test.
ALL_INTERFACES = "0.0.0.0" # noqa: S104 - a test assertion, not a socket bind
@pytest.fixture(autouse=True)
def pristine_env() -> Iterator[None]:
"""`main()` publishes CLI flags into `os.environ`; nothing may leak out."""
saved = dict(os.environ)
reset_settings_cache()
yield
os.environ.clear()
os.environ.update(saved)
reset_settings_cache()
@pytest.fixture
def uvicorn_calls(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Replace uvicorn's `run` with a recorder; returns the recorded invocations."""
calls: list[dict[str, Any]] = []
def fake_run(target: str, **kwargs: Any) -> None:
calls.append({"target": target, **kwargs})
monkeypatch.setattr(uvicorn, "run", fake_run)
return calls
@pytest.fixture
def bare_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""No `UNDERCOOKED_*`/`DEBUGPY*` in the environment: defaults are observable."""
for key in list(os.environ):
if key.startswith(("UNDERCOOKED_", "DEBUGPY")):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "true")
def parse(argv: list[str]) -> Any:
"""Parse ``argv`` the way `main` would, without serving anything."""
return build_parser().parse_args(argv)
# ----------------------------------------------------------------------------- parser
def test_every_flag_defaults_to_unset() -> None:
args = parse([])
assert (args.host, args.port, args.reload, args.levels_dir) == (None, None, False, None)
def test_flags_are_parsed() -> None:
args = parse(
["--host", ALL_INTERFACES, "--port", "9000", "--reload", "--levels-dir", "kitchens"]
)
assert (args.host, args.port, args.reload) == (ALL_INTERFACES, 9000, True)
assert args.levels_dir == Path("kitchens")
def test_help_names_the_env_fallback() -> None:
text = build_parser().format_help()
assert "--levels-dir" in text
assert "UNDERCOOKED_PORT" in text
# ----------------------------------------------------------------------------- precedence
def test_cli_beats_environment_and_defaults() -> None:
settings = Settings(host="10.0.0.1", port=8123, levels_dir=Path("env-levels"))
options = _resolve(
parse(["--host", "127.0.0.1", "--port", "9999", "--levels-dir", "cli-levels"]), settings
)
assert options == RunOptions(
host="127.0.0.1", port=9999, reload=False, levels_dir=Path("cli-levels")
)
def test_settings_supply_everything_the_cli_left_unset() -> None:
settings = Settings(host=ALL_INTERFACES, port=8123, levels_dir=Path("env-levels"))
options = _resolve(parse([]), settings)
assert options == RunOptions(
host=ALL_INTERFACES, port=8123, reload=False, levels_dir=Path("env-levels")
)
def test_settings_default_to_the_documented_defaults_in_a_bare_environment(
bare_env: None,
) -> None:
options = _resolve(parse([]), get_settings())
assert options == RunOptions(
host="127.0.0.1", port=8000, reload=False, levels_dir=Path("levels")
)
def test_environment_beats_the_builtin_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
monkeypatch.setenv("UNDERCOOKED_HOST", ALL_INTERFACES)
reset_settings_cache()
options = _resolve(parse([]), get_settings())
assert (options.host, options.port) == (ALL_INTERFACES, 8123)
def test_reload_is_a_flag_not_an_environment_variable() -> None:
assert _resolve(parse(["--reload"]), Settings()).reload is True
assert _resolve(parse([]), Settings()).reload is False
def test_cli_overrides_only_report_flags_that_were_given() -> None:
assert _cli_overrides(parse([])) == {}
assert _cli_overrides(parse(["--port", "9000", "--levels-dir", "x"])) == {
"port": 9000,
"levels_dir": Path("x"),
}
# ----------------------------------------------------------------------------- main()
def test_main_serves_the_app_string_with_resolved_options(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
assert main([]) == EXIT_OK
assert len(uvicorn_calls) == 1
call = uvicorn_calls[0]
assert call["target"] == APP_IMPORT_STRING # reload-capable "module:attribute" form
assert (call["host"], call["port"], call["reload"]) == ("127.0.0.1", 8000, False)
assert "undercooked" in call["log_config"]["loggers"]
def test_main_passes_cli_flags_to_uvicorn(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
assert main(["--host", ALL_INTERFACES, "--port", "9123", "--reload"]) == EXIT_OK
call = uvicorn_calls[0]
assert (call["host"], call["port"], call["reload"]) == (ALL_INTERFACES, 9123, True)
def test_main_publishes_cli_flags_to_the_environment(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
"""A uvicorn --reload child re-reads the environment, so flags must live there."""
assert main(["--levels-dir", "kitchens", "--port", "9124"]) == EXIT_OK
assert os.environ[ENV_KEYS["levels_dir"]] == "kitchens"
assert os.environ[ENV_KEYS["port"]] == "9124"
assert get_settings().levels_dir == Path("kitchens")
assert get_settings().port == 9124
def test_main_validates_cli_values_like_environment_values(
uvicorn_calls: list[dict[str, Any]], capsys: pytest.CaptureFixture[str]
) -> None:
assert main(["--port", "0"]) == EXIT_CONFIG_ERROR
assert uvicorn_calls == []
err = capsys.readouterr().err
assert err.strip().startswith("undercooked:")
assert "UNDERCOOKED_PORT" in err
assert err.count("\n") == 1 # a one-line message, not a traceback
def test_main_reports_an_invalid_environment(
uvicorn_calls: list[dict[str, Any]],
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("UNDERCOOKED_TICK_HZ", "99")
reset_settings_cache()
assert main([]) == EXIT_CONFIG_ERROR
assert uvicorn_calls == []
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
def test_unknown_flag_is_an_argument_error() -> None:
with pytest.raises(SystemExit) as excinfo:
main(["--nope"])
assert excinfo.value.code == EXIT_CONFIG_ERROR
def test_an_unknown_settings_field_is_a_config_error() -> None:
with pytest.raises(ConfigError, match="not a configuration field"):
apply_cli_overrides(**{"nonsense": 1})
# ----------------------------------------------------------------------------- logging
def test_uvicorn_log_config_adds_our_loggers_without_touching_uvicorns() -> None:
base = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
config = _uvicorn_log_config(base)
assert config is not base
assert set(base["loggers"]) == {"uvicorn", "uvicorn.error", "uvicorn.access"}
ours = config["loggers"]["undercooked"]
assert ours["handlers"] == ["default"]
assert ours["level"] == "INFO"
assert config["loggers"]["uvicorn"] is base["loggers"]["uvicorn"]
assert set(config["formatters"]) == set(base["formatters"]) # the handler exists
# ----------------------------------------------------------------------------- the console script
def test_python_m_undercooked_prints_usage_and_exits_zero() -> None:
result = subprocess.run( # fixed argv, the repo's own interpreter
[sys.executable, "-m", "undercooked", "--help"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
timeout=120,
)
assert result.returncode == EXIT_OK, result.stderr
assert "usage: undercooked" in result.stdout
assert "--reload" in result.stdout
+392
View File
@@ -0,0 +1,392 @@
"""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"
assert 'src="/static/js/app.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",
),
(".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>',
),
(".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)) == []