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.
235 lines
7.9 KiB
Python
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
|