"""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