Let anyone with the game URL watch as a read-only spectator
CI / Build and push docker image (push) Successful in 3m0s
CI / Build and push docker image (push) Successful in 3m0s
Anonymous users and logged-in non-players can now open /game/<id> and
follow the match: they see the table, the score and each player's
captured cards and scope, but never a hand and never the join code.
Spectator actions are rejected with a forbidden error.
Backend (tavolo-platform / tavolo-scopone):
- /ws/games/{id} accepts anonymous and non-seated connections as
spectators; only unknown games are refused. The state payload carries
spectator: true and withholds join_code unless the viewer is seated.
- GET /api/games/{id} is public and returns the same personalized
(player) or read-only (spectator) view.
- GameEngine.view_for/game_over_view take Optional[str] (None = spectator)
and are documented to hide privileged information for non-seated
viewers.
Frontend (Sycamore):
- Parse the spectator flag and render a spectator table: card backs plus
captured/scope for every seat, no clickable hand, "Spectating" badge.
- A pre-start spectator sees a "game not started yet" waiting room with
the watch link and no join code.
- "Copy watch link" buttons in the waiting room and the game HUD.
- The hand-end summary carries no acknowledge button for spectators and
the game-over overlay hides "My matches".
Tests and docs updated.
This commit is contained in:
@@ -11,7 +11,6 @@ from httpx_ws.transport import ASGIWebSocketTransport
|
||||
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
|
||||
|
||||
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
|
||||
class AbruptCloseSocket(WebSocket):
|
||||
@@ -117,6 +116,9 @@ class WebSocketTest(unittest.TestCase):
|
||||
self.assertEqual("dummy", first["game"]["game_type"])
|
||||
self.assertEqual(0, first["game"]["plays"])
|
||||
self.assertEqual(game_id, first["game"]["id"])
|
||||
# Seated players get the join code; spectators don't.
|
||||
self.assertIn("join_code", first["game"])
|
||||
self.assertFalse(first["game"]["spectator"])
|
||||
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||
alice_first = await alice_ws.receive_json()
|
||||
@@ -237,7 +239,7 @@ class WebSocketTest(unittest.TestCase):
|
||||
self.assertEqual(404, caught.exception.code)
|
||||
|
||||
@async_test
|
||||
async def test_non_player_is_closed(self) -> None:
|
||||
async def test_non_player_watches_as_spectator(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
@@ -247,21 +249,38 @@ class WebSocketTest(unittest.TestCase):
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("mallory")]):
|
||||
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||
pass
|
||||
self.assertEqual(403, caught.exception.code)
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual("state", first["type"])
|
||||
self.assertTrue(first["game"]["spectator"])
|
||||
self.assertFalse(first["game"]["viewer_seated"])
|
||||
self.assertNotIn("join_code", first["game"])
|
||||
# Spectators may not act.
|
||||
await ws.send_json({"action": "play"})
|
||||
error = await ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
@async_test
|
||||
async def test_unauthenticated_is_closed(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([]):
|
||||
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||
pass
|
||||
self.assertEqual(401, caught.exception.code)
|
||||
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client, platform.oidc)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual("state", first["type"])
|
||||
self.assertTrue(first["game"]["spectator"])
|
||||
self.assertNotIn("join_code", first["game"])
|
||||
await ws.send_json({"action": "play"})
|
||||
error = await ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
|
||||
class RejectionStatusTest(unittest.TestCase):
|
||||
@@ -271,33 +290,19 @@ class RejectionStatusTest(unittest.TestCase):
|
||||
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
|
||||
close code like 4401 is not a valid status and panics the worker
|
||||
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
|
||||
dies). Drive the rejections through the app with a recording socket
|
||||
so the invariant is checked without a real server.
|
||||
dies). Drive the rejection through the app with a recording socket
|
||||
so the invariant is checked without a real server. Spectators are
|
||||
accepted, so the only pre-accept rejection left is an unknown game.
|
||||
"""
|
||||
|
||||
@async_test
|
||||
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
session = GameSession(
|
||||
id="seated-game",
|
||||
game_type="dummy",
|
||||
join_code="SEAT01",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
)
|
||||
engine = platform.registry.require("dummy")
|
||||
engine.create(session, {})
|
||||
await platform.game_store.save(session)
|
||||
cases = [
|
||||
("unauthenticated", [], "/ws/games/whatever", 401),
|
||||
("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404),
|
||||
("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403),
|
||||
]
|
||||
for label, users, path, expected in cases:
|
||||
ws = RecordingSocket(path)
|
||||
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
|
||||
ws = RecordingSocket("/ws/games/no-such-game")
|
||||
with ws_users(users):
|
||||
await app.handle_websocket(ws)
|
||||
self.assertEqual([expected], ws.close_codes, label)
|
||||
self.assertEqual([404], ws.close_codes, label)
|
||||
for code in ws.close_codes:
|
||||
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user