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:
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user