phase: 05_anonymous_lobbies_and_room_lifecycle

All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
This commit is contained in:
2026-10-08 05:17:21 -04:00
parent 79b7f1e4e0
commit f51ba919af
35 changed files with 8391 additions and 32 deletions
+144
View File
@@ -0,0 +1,144 @@
"""Unit: the session's privacy surface (phase 05, task 04, US-07).
The store is anonymous by design (A16): the identity is a 24-byte
random token and *nothing else*. These tests lock the surface so a PII
field can never join without a test failing first:
* **the `SessionRecord` field set** — exactly the task's nine fields
(identity, name, progression, counters, stamps),
* **a `repr()` scan** — a representative record (and a representative
store) never carries `ip`, `user_agent`, `authorization` or `cookie`
(case-insensitive),
* **`SessionStore` has no attribute holding raw headers** — neither the
record's fields nor the store's attributes name a `header` of any
kind, and no dict-valued store attribute holds a header-like key.
"""
from __future__ import annotations
import dataclasses
import pytest
from undercooked.server.clock import FakeClock
from undercooked.server.sessions import BestRun, SessionRecord, SessionStore
pytestmark = pytest.mark.unit
#: The only fields a session record may hold (task 04's list).
ALLOWED_RECORD_FIELDS = frozenset(
{
"token",
"display_name",
"created_at",
"unlocked_level",
"best",
"rounds_played",
"dishes_served",
"last_seen",
"endless_best",
}
)
#: The strings a session's repr must never contain (the PII markers).
FORBIDDEN_REPR_SUBSTRINGS = ("ip", "user_agent", "authorization", "cookie")
#: A request-header key that must never appear in the store's own data.
HEADER_LIKE_KEYS = frozenset(
{"host", "user-agent", "authorization", "cookie", "x-forwarded-for"}
)
def _benign_record() -> SessionRecord:
"""A representative record with fixed, benign values (a random
base64url token could incidentally contain a marker; the test needs
a *guaranteed* shape, so the token is fixed too)."""
return SessionRecord(
token="t" * 32,
display_name="Ada",
created_at=1.0,
unlocked_level=2,
best={"01_kitchen": BestRun(score=100, stars=3, completed_at=2.0)},
rounds_played=4,
dishes_served=9,
last_seen=3.0,
endless_best=None,
)
def _benign_store() -> SessionStore:
"""A representative store: a `FakeClock`, one campaign entry, and one
record inside (so a walk of its data covers the record's shape)."""
store = SessionStore(
max_entries=10, clock=FakeClock(start=1.0), campaign={"01_kitchen": 1}
)
record = _benign_record()
store.put(record)
return store
def test_the_record_field_set_is_exactly_the_task_list() -> None:
fields = {field.name for field in dataclasses.fields(SessionRecord)}
assert fields == ALLOWED_RECORD_FIELDS
def test_the_record_repr_carrys_no_pii_markers() -> None:
text = repr(_benign_record()).lower()
for marker in FORBIDDEN_REPR_SUBSTRINGS:
assert marker not in text, f"the record's repr carries {marker!r}"
def test_the_store_repr_carrys_no_pii_markers() -> None:
text = repr(_benign_store()).lower()
for marker in FORBIDDEN_REPR_SUBSTRINGS:
assert marker not in text, f"the store's repr carries {marker!r}"
def test_the_record_and_the_best_run_are_slots_dataclasses() -> None:
"""Slots (not a `__dict__` bag) is the shape that keeps the field
set auditable: a new field is a named, visible change."""
assert hasattr(SessionRecord, "__slots__")
assert hasattr(BestRun, "__slots__")
def test_neither_record_nor_store_names_a_header_attribute() -> None:
record_fields = {field.name for field in dataclasses.fields(SessionRecord)}
assert not any("header" in name.lower() for name in record_fields)
store = _benign_store()
assert not any("header" in name.lower() for name in dir(store))
assert not any("header" in name.lower() for name in vars(store))
def test_the_store_holds_no_request_header_mapping() -> None:
"""Every dict-valued attribute of the store is either `token ->
record` or `level id -> order`: neither may hold a header-like key."""
store = _benign_store()
for name, value in vars(store).items():
if isinstance(value, dict):
assert not (
set(value) & HEADER_LIKE_KEYS
), f"store attribute {name!r} holds header-like keys"
record = _benign_record()
for field in dataclasses.fields(record):
assert field.name.lower() not in HEADER_LIKE_KEYS, f"record field {field.name!r}"
value = getattr(record, field.name)
if isinstance(value, dict):
assert not (
set(value) & HEADER_LIKE_KEYS
), f"record field {field.name!r} holds header-like keys"
def test_the_store_never_minted_with_headers_keeps_none() -> None:
"""A store built the way the supervisor builds it (a clock and the
campaign mapping only) exposes exactly its own five attributes — no
header bag can hide in a constructor that does not take one."""
store = SessionStore(
clock=FakeClock(), campaign={"01_kitchen": 1}
)
assert set(vars(store)) == {
"_max_entries",
"_clock",
"_campaign",
"_records",
"_evictions",
}