Files
undercooked/tests/integration/test_content_api.py
T
ducoterra 3426264ffb phase: 02_level_content_and_validation
Phase `02_level_content_and_validation` — final verification **green**.

- **Gate:** `bash .agents/validate.sh` → ruff ✅ · pyright 0 errors ✅ · `undercooked-levels validate` OK (2 levels) ✅ · **668 passed** (incl. 10 Playwright e2e) ✅ · coverage **100%** ✅ · repo hygiene ✅
- **Criteria:** `validate` exit 0, one line per level; `list` = `01_first_kitchen`, `02_double_shift` in campaign order; live `:8000` `/healthz` 200, `/api/levels` (+detail) carries both levels with numeric `target_score`/`time_limit` and no grid/station leakage, `/api/config` correct, unknown id → 404, startup log `content loaded: 2 level(s), 4 recipe(s)`; `validate --dir tests/fixtures/levels/_bad/` → exit 1 with named `RECIPE_STATION_MISSING` / `SERVE_REACHABLE` / `TARGET_IMPLAUSIBLE`; A8 lock test proves a YAML-only level reaches loader+API+CLI.
- **Defect fixed (pre-existing path, phase-02-triggered):** `undercooked` with unusable content printed the report then a raw traceback and exited `1`. `src/undercooked/__main__.py` now catches `ContentError` → one closing line, exit `2` (like a bad setting); 2 new tests in `tests/unit/test_main.py` (incl. non-content failures still propagate).
- **Docs pass (full, project-wide):** README (content gate + exit `2`, config section), AGENTS.md §3, `docs/LEVELS.md` (startup refusal + exact CLI exit codes), `docs/DEPLOYMENT.md` (crash-loop diagnosis bullet); DESIGN/TESTING re-read, consistent.
- **Container re-verified (no phase-01 regression):** `podman build -f Containerfile` succeeds; the image ships `levels/` and serves `/healthz` 200, `/api/levels` (2 levels), `/` 200 as non-root.
- **Deviation to note:** the phase overview names `tests/fixtures/levels/bad/`; the actual broken-fixture dir is `tests/fixtures/levels/_bad/` (the `_` prefix is what keeps it out of normal discovery). Overview files are read-only to me — README/LEVELS.md document the real path.
- No git index/history commands run; all changes left unstaged for the harness. ntfy notification sent.
2026-10-06 11:03:28 -04:00

235 lines
7.9 KiB
Python

"""Content API contract over in-process HTTP (task 04).
`/api/levels`, `/api/levels/{id}` and `/api/config` are the only channels the
client uses for content and settings. These tests build the real app against
the shipped ``levels/`` campaign and assert the exact wire shape: shipped
ids in campaign order, responses that validate against the strict response
models (which are ``extra="forbid"``, so a leaked field is a failure, not a
silence), 404 for unknown ids, and the four-key client-config subset with no
secret-, operator- or origin-named key. A content bug must crash startup
(see ``tests/unit/test_content_startup.py``), never a request.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from undercooked.config import Settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import (
PROTOCOL_VERSION,
ClientConfig,
LevelDetail,
LevelSummary,
)
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_IDS = ["01_first_kitchen", "02_double_shift"]
#: Keys that must never appear anywhere in a content response. Layout and
#: internals stay server-side (PLAN §8); operator settings stay in config.py.
FORBIDDEN_KEYS = {
"grid",
"tiles",
"legend",
"spawns",
"spawn",
"stations",
"station",
"station_limits",
"orders",
"pool",
"steps",
"secret",
"password",
"origins",
}
@pytest.fixture(scope="module")
def campaign_client() -> Iterator[TestClient]:
with TestClient(create_app(Settings(levels_dir=SHIPPED_LEVELS))) as test_client:
yield test_client
def walk_keys(node: Any) -> set[str]:
"""Every JSON object key at any depth of a parsed response body."""
if isinstance(node, dict):
return set(node) | {k for child in node.values() for k in walk_keys(child)}
if isinstance(node, list):
return {k for child in node for k in walk_keys(child)}
return set()
# --- GET /api/levels -----------------------------------------------------------
def test_lists_the_shipped_levels_in_campaign_order(campaign_client: TestClient) -> None:
response = campaign_client.get("/api/levels")
assert response.status_code == 200
body = response.json()
assert [level["id"] for level in body] == SHIPPED_IDS
assert [level["order"] for level in body] == [1, 2]
def test_level_cards_match_the_response_model_exactly(
campaign_client: TestClient,
) -> None:
body = campaign_client.get("/api/levels").json()
for card in body:
# extra="forbid" on LevelSummary: an unexpected key fails this, and
# the explicit key-set check catches a *missing* field too.
LevelSummary.model_validate(card)
assert set(card) == set(LevelSummary.model_fields)
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_level_card_exposes_the_expected_fields(
campaign_client: TestClient, level_id: str
) -> None:
card = next(
level for level in campaign_client.get("/api/levels").json()
if level["id"] == level_id
)
assert isinstance(card["name"], str) and card["name"]
assert isinstance(card["blurb"], str) and len(card["blurb"]) >= 20
assert isinstance(card["difficulty"], int) and 1 <= card["difficulty"] <= 10
assert isinstance(card["target_score"], int)
assert isinstance(card["time_limit"], (int, float))
# Recipes exist — the ids the client may later send back in `join.ready`.
assert card["recipes"]
known_recipe_names = {"Tomato Soup", "Garden Salad", "Fried Rice", "Chef's Burger"}
assert set(card["recipes"]) <= known_recipe_names
# --- GET /api/levels/{id} -------------------------------------------------------
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_level_detail_matches_the_response_model(
campaign_client: TestClient, level_id: str
) -> None:
response = campaign_client.get(f"/api/levels/{level_id}")
assert response.status_code == 200
detail = response.json()
LevelDetail.model_validate(detail)
assert set(detail) == set(LevelSummary.model_fields) | {"hazards"}
assert detail["id"] == level_id
def test_level_detail_hazards_are_type_only(campaign_client: TestClient) -> None:
detail = campaign_client.get("/api/levels/02_double_shift").json()
# Neither shipped level carries a hazard; when one does, each entry is
# exactly {"type": ...} — kind only, never a position (US-07).
assert detail["hazards"] == []
def test_unknown_level_is_404(campaign_client: TestClient) -> None:
response = campaign_client.get("/api/levels/99_ghost_kitchen")
assert response.status_code == 404
def test_path_style_level_ids_cannot_reach_the_filesystem(
campaign_client: TestClient,
) -> None:
# Raw ".." would be normalised away by the HTTP client before it leaves;
# the encoded forms are what a hostile browser can actually send.
for hostile in ["..%2F..%2Fetc%2Fpasswd", "%2e%2e/levels", "%2e%2e"]:
response = campaign_client.get(f"/api/levels/{hostile}")
assert response.status_code == 404, hostile
# --- no internals leak ----------------------------------------------------------
@pytest.mark.parametrize(
"path",
[
"/api/levels",
"/api/levels/01_first_kitchen",
"/api/levels/02_double_shift",
"/api/config",
],
)
def test_no_internal_or_secret_keys_any_in_response(
campaign_client: TestClient, path: str
) -> None:
response = campaign_client.get(path)
assert response.status_code == 200
leaked = walk_keys(response.json()) & FORBIDDEN_KEYS
assert not leaked, f"{path} leaked keys: {sorted(leaked)}"
def test_no_grid_rows_leak_into_any_content_response(
campaign_client: TestClient,
) -> None:
for path in [
"/api/levels",
"/api/levels/01_first_kitchen",
"/api/levels/02_double_shift",
"/api/config",
]:
body = campaign_client.get(path).text
assert "####" not in body, f"{path} appears to carry a raw grid row"
# --- GET /api/config --------------------------------------------------------------
def test_config_exposes_exactly_the_client_safe_fields(
campaign_client: TestClient,
) -> None:
response = campaign_client.get("/api/config")
assert response.status_code == 200
body = response.json()
ClientConfig.model_validate(body)
assert set(body) == {"max_players", "tick_hz", "snapshot_hz", "protocol_version"}
assert body == {
"max_players": 4,
"tick_hz": 20,
"snapshot_hz": 20,
"protocol_version": PROTOCOL_VERSION,
}
def test_config_has_no_secret_operator_or_origin_named_key(
campaign_client: TestClient,
) -> None:
body = campaign_client.get("/api/config").json()
for key in body:
lowered = key.lower()
assert not any(
marker in lowered
for marker in ("secret", "password", "origins", "token", "key")
), key
def test_config_reflects_the_settings_built_into_this_app() -> None:
settings = Settings(levels_dir=SHIPPED_LEVELS, max_players=2, tick_hz=20, snapshot_hz=10)
with TestClient(create_app(settings)) as test_client:
body = test_client.get("/api/config").json()
assert body == {
"max_players": 2,
"tick_hz": 20,
"snapshot_hz": 10,
"protocol_version": PROTOCOL_VERSION,
}
# --- the test-isolated campaign ----------------------------------------------------
def test_the_env_isolated_app_starts_with_zero_levels(client: TestClient) -> None:
# The session's isolated levels dir holds only a recipe book (A8 allows
# a zero-level campaign); the app must still boot and answer cleanly.
response = client.get("/api/levels")
assert response.status_code == 200
assert response.json() == []
assert client.get("/api/levels/01_first_kitchen").status_code == 404