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