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:
@@ -0,0 +1 @@
|
||||
In-process integration tests: FastAPI app boot, HTTP routes, rooms, and the WebSocket protocol.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user