"""Integration: the WebSocket endpoint, handshake, and connection guardrails (phase 06 task 02; US-01/US-02) — every path driven by **real sockets** against a live `uvicorn.Server` on an ephemeral `127.0.0.1` port (the `ws_server` factory in `tests/integration/conftest.py`), with the app's own supervisor reached for state assertions (the seat's `connected` flag, the `mark_disconnected` call count, the socket census). * **the happy handshake** — `POST /api/rooms` (a known token) → `/ws/{code}` → `hello` → `welcome` (the joiner's seat index / host flag correct) + the current `lobby` (the host's seat, `can_start`); a `ping` inside the handshake window gets a `pong` before the `hello`, * **the refusal closes** — unknown code → `err room_gone` + close `4401`; a full room (a *new* token) → `err room_full` + close `4402`; no `hello` within the (shortened) handshake window → close `4400`; a `p` outside the supported range → `err protocol_mismatch` + close `4400` on the first bad frame (before any seat is taken); * **the origin check** — an explicit `allowed_origins` list that the request's `Origin` misses is refused at the HTTP level (the client sees a 403, never a close frame); the same-host rule (the empty setting) admits the matching `Origin`; * **one socket per token** — a second `hello` for a token evicts the first (`err evicted` + close `4403`), the first stops receiving, and the *evicted* socket's teardown does not mark the seat disconnected (the new socket owns it — the crown stays put); * **the size/rate guardrails** — an oversized frame is `too_big` and three consecutive refusals close `4404`; a message flood is `rate_limited` and three consecutive refusals close `4404`; five shape violations in the handshake window close `4404`; a `wrong_state` / `unknown_type` frame is refused with the named code and the connection stays open; * **the disconnect** — an abrupt transport close (the client's socket vanishes without a close handshake) reaches the room as exactly one `mark_disconnected` (the seat is kept for the rejoin path), releases the registry slot, and leaves no error in the server log. Note on the "abrupt close" mechanics: the installed `websockets` (17.2) client has no `close(transport=True)` keyword, so the test drives the abrupt path through `ws.transport.close()` — the same primitive. A bare FIN is enough: the server's transport sees the EOF, uvicorn closes the connection, and the endpoint's `finally` reports the disconnect. """ from __future__ import annotations import asyncio import json import logging from collections.abc import Callable from typing import Any import httpx import pytest import websockets from websockets.exceptions import ConnectionClosed, InvalidHandshake pytestmark = pytest.mark.integration HELLO_TIMEOUT_S = 0.5 # the shortened window, set via settings (task 02) # --------------------------------------------------------------------- helpers def _uri(url: str, code: str) -> str: return url.replace("http://", "ws://", 1) + f"/ws/{code}" async def _connect( url: str, code: str, token: str | None = None, *, p: int = 1, name: str | None = None, origin: str | None = None, ) -> Any: """A connected client with a `hello` already sent (the handshake is the point under test, so the hello goes out immediately).""" kwargs: dict[str, Any] = {"open_timeout": 10.0} if origin is not None: kwargs["origin"] = origin ws = await websockets.connect(_uri(url, code), **kwargs) if token is not None or name is not None or p != 1: hello: dict[str, Any] = {"type": "hello", "p": p} if token is not None: hello["token"] = token if name is not None: hello["name"] = name await ws.send(json.dumps(hello)) return ws async def _next_json(ws: Any) -> dict[str, Any]: return json.loads(await ws.recv()) async def _close_code(ws: Any) -> int: with pytest.raises(ConnectionClosed) as excinfo: await ws.recv() return int(excinfo.value.code) async def _drain_until_close( ws: Any, max_frames: int, wait_s: float = 5.0 ) -> tuple[list[dict[str, Any]], int | None]: """Read frames until the connection closes. A WebSocket client may hold *buffered* frames — the `too_big` / `rate_limited` errors the server queued before the abuse close arrive ahead of the close frame — so the close must be expected after draining them, not on the very next read. Returns the frames (in arrival order) and the close code (`None` if the window ran out first, which the caller asserts against). """ frames: list[dict[str, Any]] = [] close_code: int | None = None for _ in range(max_frames): try: raw = await asyncio.wait_for(ws.recv(), wait_s) except ConnectionClosed as exc: close_code = int(exc.code) break frames.append(json.loads(raw)) return frames, close_code async def _poll(predicate: Callable[[], bool], what: str, wait_s: float = 5.0) -> None: """Poll across threads (the server's loop does the work; this thread only observes it).""" loop = asyncio.get_running_loop() deadline = loop.time() + wait_s while not predicate(): if loop.time() > deadline: pytest.fail(f"timed out waiting for {what}") await asyncio.sleep(0.01) def _create_room(url: str, token: str | None = None) -> tuple[str, str | None]: """`POST /api/rooms` over the live server; returns (code, you.token).""" body: dict[str, Any] = {} if token is not None: body["token"] = token response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0) assert response.status_code == 201, response.text data = response.json() return data["room_code"], (data["you"] or {}).get("token") def _join(url: str, code: str, token: str) -> None: response = httpx.post( f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0 ) assert response.status_code == 200, response.text # ---------------------------------------------------------------- the happy path async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None: with ws_server() as (url, _app): code, you_echo = _create_room(url, "tok-creator") assert you_echo == "tok-creator" async with await _connect(url, code, "tok-joiner", name="Joiner") as ws: welcome = await _next_json(ws) assert welcome["type"] == "welcome" assert welcome["p"] == 1 assert welcome["room"] == code assert welcome["state"] == "lobby" assert welcome["you"] == { "token": "tok-joiner", "seat": 1, "host": False, } seats = welcome["seats"] assert len(seats) == 2 assert seats[0]["index"] == 0 assert seats[0]["host"] is True assert seats[1]["index"] == 1 assert seats[1]["name"] == "Joiner" assert seats[1]["host"] is False lobby = await _next_json(ws) assert lobby["type"] == "lobby" assert lobby["code"] == code assert lobby["state"] == "lobby" assert lobby["host_seat"] == 0 # The fixture campaign is level-free, so `can_start` is False # (the start preconditions — level selected, ready players — are # covered by the phase-05 lifecycle suite over real levels). assert lobby["can_start"] is False # The ready-up: a toggle that re-broadcasts the lobby. await ws.send(json.dumps({"type": "ready"})) lobby2 = await _next_json(ws) assert lobby2["can_start"] is False # still: no level selected assert lobby2["seats"][1]["ready"] is True assert len(lobby2["seats"]) == 2 async def test_ping_before_hello_gets_pong(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) ws = await websockets.connect(_uri(url, code), open_timeout=10.0) await ws.send(json.dumps({"type": "ping", "t": 7})) pong = await _next_json(ws) assert pong["type"] == "pong" assert pong["t"] == 7 await ws.send(json.dumps({"type": "hello", "p": 1})) welcome = await _next_json(ws) assert welcome["type"] == "welcome" await ws.close() # ---------------------------------------------------------------- refusal closes async def test_unknown_code_closes_4401(ws_server: Any) -> None: with ws_server() as (url, _app): ws = await websockets.connect(_uri(url, "ZZZZZ"), open_timeout=10.0) err = await _next_json(ws) assert err["type"] == "err" assert err["code"] == "room_gone" assert await _close_code(ws) == 4401 async def test_full_room_new_token_closes_4402(ws_server: Any) -> None: with ws_server(max_players=2) as (url, _app): code, _ = _create_room(url, "tok-a") _join(url, code, "tok-b") # the second (and last) seat async with await _connect(url, code, "tok-c") as ws: err = await _next_json(ws) assert err["type"] == "err" assert err["code"] == "room_full" assert await _close_code(ws) == 4402 async def test_no_hello_within_timeout_closes_4400(ws_server: Any) -> None: with ws_server(hello_timeout_s=HELLO_TIMEOUT_S) as (url, _app): code, _ = _create_room(url) ws = await websockets.connect(_uri(url, code), open_timeout=10.0) # No hello at all: the shortened window closes the socket. assert await _close_code(ws) == 4400 async def test_wrong_protocol_version_closes_4400(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) async with await _connect(url, code, "tok-x", p=99) as ws: err = await _next_json(ws) assert err["type"] == "err" assert err["code"] == "protocol_mismatch" assert await _close_code(ws) == 4400 # No seat was taken on the refused version. room = _app.state.supervisor.get(code) assert len(room.seats) == 1 # ---------------------------------------------------------------- the origin check async def test_disallowed_origin_refused_at_http_level(ws_server: Any) -> None: with ws_server(allowed_origins=("https://elsewhere.example",)) as (url, _app): code, _ = _create_room(url) # The client's Origin (http://127.0.0.1:) is not in the # explicit list: the upgrade is refused before the accept — the # client sees a 403 handshake failure, never a close frame. with pytest.raises(InvalidHandshake): await websockets.connect(_uri(url, code), open_timeout=10.0) async def test_same_host_origin_allowed(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) origin = "http://" + url.removeprefix("http://") async with await _connect(url, code, "tok-orig", origin=origin) as ws: welcome = await _next_json(ws) assert welcome["type"] == "welcome" # ------------------------------------------------- one socket per token (eviction) async def test_second_socket_same_token_evicts_first(ws_server: Any) -> None: with ws_server() as (url, app): code, _ = _create_room(url) first = await _connect(url, code, "tok-shared") welcome = await _next_json(first) assert welcome["type"] == "welcome" await _next_json(first) # the lobby second = await _connect(url, code, "tok-shared") welcome2 = await _next_json(second) assert welcome2["type"] == "welcome" assert welcome2["you"]["seat"] == 1 # the creator holds seat 0 # The evicted socket: the named refusal, then the close. err = await _next_json(first) assert err["type"] == "err" assert err["code"] == "evicted" assert await _close_code(first) == 4403 # The evicted socket's teardown must not mark the seat # disconnected (the new socket owns it now), and the crown stays # with the creator (an eviction is not a host leave — no # `transfer_host`). Checked before `second` closes: closing it # is a genuine disconnect for the token. room = app.state.supervisor.get(code) seat = room.seat_for_token("tok-shared") assert seat is not None assert seat.connected is True assert room.seats[0].host is True await second.close() async def test_evicted_socket_stops_receiving(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) first = await _connect(url, code, "tok-shared") await _next_json(first) # welcome await _next_json(first) # lobby second = await _connect(url, code, "tok-shared") await _next_json(second) # welcome await _next_json(second) # lobby await _next_json(first) # the evicted err await _close_code(first) # 4403 (asserted in the other test; the # point here is what happens *after* the close) # The evicted socket is out of the broadcast: a ready-up from the # second socket re-broadcasts the lobby to the second only. await second.send(json.dumps({"type": "ready"})) lobby = await _next_json(second) assert lobby["type"] == "lobby" assert any(seat["ready"] for seat in lobby["seats"]) await second.close() # ----------------------------------------------------------- size / rate guardrails async def test_oversized_frame_refused_then_4404(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) async with await _connect(url, code, "tok-big") as ws: await _next_json(ws) # welcome await _next_json(ws) # lobby oversized = json.dumps( {"type": "ping", "t": 1, "pad": "x" * 5000} # > max_message_bytes ) for _ in range(3): # three consecutive refusals -> the abuse close await ws.send(oversized) frames, close_code = await _drain_until_close(ws, 8) assert frames, "the oversized frames must be refused" assert all( frame["type"] == "err" and frame["code"] == "too_big" for frame in frames ), f"unexpected frames before the close: {frames}" assert close_code == 4404 async def test_flood_rate_limited_then_4404(ws_server: Any) -> None: with ws_server(max_inputs_per_s=5) as (url, _app): code, _ = _create_room(url) async with await _connect(url, code, "tok-flood") as ws: await _next_json(ws) # welcome await _next_json(ws) # lobby for index in range(8): # burst 5: the 6th..8th exhaust the bucket await ws.send(json.dumps({"type": "ping", "t": index})) frames, close_code = await _drain_until_close(ws, 16) assert any( frame.get("type") == "err" and frame["code"] == "rate_limited" for frame in frames ), f"the flood never tripped the token bucket: {frames}" assert close_code == 4404 async def test_shape_violations_in_handshake_window_close_4404(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) ws = await websockets.connect(_uri(url, code), open_timeout=10.0) for _ in range(5): # five shape violations inside the window await ws.send("{not json") assert await _close_code(ws) == 4404 async def test_wrong_state_refused_and_connection_stays(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) async with await _connect(url, code, "tok-state") as ws: await _next_json(ws) # welcome await _next_json(ws) # lobby await ws.send( json.dumps( { "type": "input", # legal in PLAYING, not in LOBBY "seq": 1, "move": {"x": 1.0, "y": 0.0}, "act": False, "face": None, } ) ) err = await _next_json(ws) assert err["type"] == "err" assert err["code"] == "wrong_state" # One shape violation: the connection survives. await ws.send(json.dumps({"type": "ping", "t": 3})) pong = await _next_json(ws) assert pong["type"] == "pong" async def test_unknown_type_refused_and_connection_stays(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) async with await _connect(url, code, "tok-bogus") as ws: await _next_json(ws) # welcome await _next_json(ws) # lobby await ws.send(json.dumps({"type": "teleport", "x": 1})) err = await _next_json(ws) assert err["type"] == "err" assert err["code"] == "unknown_type" await ws.send(json.dumps({"type": "ping", "t": 4})) pong = await _next_json(ws) assert pong["type"] == "pong" # ------------------------------------------------------------------- the disconnect async def test_abrupt_close_notifies_room_once_and_releases( ws_server: Any, ) -> None: with ws_server() as (url, app): code, _ = _create_room(url, "tok-abrupt") supervisor = app.state.supervisor calls: list[tuple[str, str]] = [] original = supervisor.mark_disconnected def counting(raw_code: str, token: str) -> None: calls.append((raw_code, token)) original(raw_code, token) supervisor.mark_disconnected = counting records: list[logging.LogRecord] = [] class _Collector(logging.Handler): def emit(self, record: logging.LogRecord) -> None: records.append(record) collector = _Collector(level=logging.ERROR) logging.getLogger().addHandler(collector) try: ws = await websockets.connect(_uri(url, code), open_timeout=10.0) await ws.send(json.dumps({"type": "hello", "p": 1, "token": "tok-abrupt"})) assert json.loads(await ws.recv())["type"] == "welcome" room = supervisor.get(code) assert room.seat_for_token("tok-abrupt").connected is True ws.transport.close() # abrupt: the transport vanishes, no close handshake # Both halves of the teardown (the `mark_disconnected` and the # registry release) must be visible before the assertions — they # run in the server's loop, this one in the test's. await _poll( lambda: room.seat_for_token("tok-abrupt").connected is False and app.state.ws_registry.socket_count == 0, "the seat to be marked disconnected and the socket released", ) finally: supervisor.mark_disconnected = original logging.getLogger().removeHandler(collector) # Exactly one on_disconnect — and for the right token. assert calls == [(code, "tok-abrupt")] # No exception in the server log: the drop is a clean no-op. errors = [record for record in records if record.levelno >= logging.ERROR] exceptions = [ record for record in errors if record.exc_info is not None ] assert not exceptions, ( "the server log carries an exception for the abrupt close:\n" + "\n".join(repr(record.exc_info) for record in exceptions) )