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