**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`.
339 lines
12 KiB
Python
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
|