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.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user