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

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

339 lines
12 KiB
Python

"""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/main.js"' in html # the composition root (task 03)
assert 'href="/static/css/base.css"' in html
assert 'href="/static/css/screens.css"' in html
assert 'id="screen-root"' 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:
"""The controls are mounted by the landing screen module (phase 07
task 02); the shell offers the screen root they mount into."""
html = client.get("/").text
assert 'id="screen-root"' in html
landing = client.get("/static/js/screens/landing.js")
assert landing.status_code == 200
text = landing.text
assert 'id: "create-lobby"' in text
assert 'id: "join-form"' in text
assert 'id: "join-code"' in text
assert 'inputmode: "text"' in text
assert 'autocapitalize: "characters"' in text
assert "maxlength: String(CODE_LENGTH)" in text # five characters: no 20-char intrinsic box
assert 'for: "join-code"' in text # 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
screens_css = client.get("/static/css/screens.css")
assert screens_css.status_code == 200
assert screens_css.headers["content-type"].startswith("text/css")
script = client.get("/static/js/main.js")
assert script.status_code == 200
assert script.headers["content-type"].startswith("text/javascript")
assert "createApp" in script.text # the composition root imports the machine
app = client.get("/static/js/app.js")
assert app.status_code == 200
assert app.headers["content-type"].startswith("text/javascript")
assert "createApp" in app.text
identity = client.get("/static/js/identity.js")
assert identity.status_code == 200
assert identity.headers["content-type"].startswith("text/javascript")
assert "uc.token" in identity.text # the per-tab token lives in identity.js (A16)
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