Fix OIDC subject collisions by introducing canonical provider-plus-subject principals
- Add canonical_principal(provider, sub) => 'oidc:v1:<provider>:<sub>' - Preserve provider identity across HTTP sessions and WebSockets - Apply principals throughout: game creation/joining/seating, WebSocket views/actions, Redis engine state, match history, leaderboard focus, ratings, /api/me, OpenAPI, and telemetry - Widen identity columns to 1024 chars with schema-only aerich migration (5_20261024221522_canonical_principals.py) - Document provider names as stable security identifiers - Add regression tests: same subject at different providers cannot share games, private hands, join codes, moves, history, or ratings; distinct seat and rating ownership verified - All platform, integration, game, and frontend tests pass; mypy clean
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""Test helpers package for the tavolo-app integration suite."""
|
||||
from .asynctest import async_test
|
||||
from .oidc import make_user, oidc_user, ws_users
|
||||
from .oidc import make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
__all__ = ["async_test", "make_user", "oidc_user", "ws_users"]
|
||||
__all__ = ["async_test", "make_principal", "make_user", "oidc_user", "ws_users"]
|
||||
|
||||
@@ -17,6 +17,7 @@ from kaya.oidc import OIDCUser
|
||||
# ``tavolo.platform.auth`` themselves.
|
||||
from tavolo.app import oidc
|
||||
from tavolo.platform import auth
|
||||
from tavolo.platform.auth import AuthenticatedUser, canonical_principal
|
||||
|
||||
# The app's first configured provider (the test environment configures one
|
||||
# named "default").
|
||||
@@ -37,12 +38,17 @@ def make_user(
|
||||
return OIDCUser(claims)
|
||||
|
||||
|
||||
def make_principal(sub: str, provider: str = _PROVIDER) -> str:
|
||||
return canonical_principal(provider, sub)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def oidc_user(
|
||||
sub: str,
|
||||
name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
picture: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
) -> Iterator[OIDCUser]:
|
||||
"""Context manager: make ``oidc.get_users`` return this user.
|
||||
|
||||
@@ -52,7 +58,8 @@ def oidc_user(
|
||||
scope's avatar claim.
|
||||
"""
|
||||
user = make_user(sub, name, email, picture)
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
||||
selected = provider or _PROVIDER
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
|
||||
patcher.start()
|
||||
try:
|
||||
yield user
|
||||
@@ -65,7 +72,14 @@ def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
|
||||
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
|
||||
one per websocket connection, in order. Once exhausted it keeps
|
||||
returning the last user."""
|
||||
remaining = list(users)
|
||||
remaining = [
|
||||
AuthenticatedUser(
|
||||
provider=_PROVIDER,
|
||||
claims=user,
|
||||
principal=canonical_principal(_PROVIDER, user.sub),
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
last = remaining[-1] if remaining else None
|
||||
|
||||
def _next(_ws):
|
||||
|
||||
@@ -23,7 +23,7 @@ from tavolo.briscola.state import (
|
||||
PlayerState,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
|
||||
class BriscolaRoutesTest(unittest.TestCase):
|
||||
@@ -235,14 +235,14 @@ class BriscolaTurnTimeoutTest(unittest.TestCase):
|
||||
id="briscola-turn-timeout-1",
|
||||
game_type=engine.id,
|
||||
join_code="BT0001",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
session.state.turn_timeout = 1
|
||||
engine.join(session, "bob", "Bob")
|
||||
engine.join(session, make_principal("bob"), "Bob")
|
||||
await game_store.save(session)
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
@@ -283,10 +283,10 @@ async def _seed_hand_end_state() -> str:
|
||||
id="briscola-hand-end-1",
|
||||
game_type=engine.id,
|
||||
join_code="BH0001",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = BriscolaState(
|
||||
@@ -298,11 +298,11 @@ async def _seed_hand_end_state() -> str:
|
||||
briscola_suit="S",
|
||||
players=[
|
||||
PlayerState(
|
||||
sub="alice", name="Alice", seat=0,
|
||||
sub=make_principal("alice"), name="Alice", seat=0,
|
||||
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
|
||||
),
|
||||
PlayerState(
|
||||
sub="bob", name="Bob", seat=1,
|
||||
sub=make_principal("bob"), name="Bob", seat=1,
|
||||
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -23,7 +23,7 @@ from tavolo.briscola.chiamata import (
|
||||
Bid,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
|
||||
|
||||
@@ -163,7 +163,7 @@ class ChiamataWebSocketTest(unittest.TestCase):
|
||||
session = await game_store.load(game_id)
|
||||
assert session is not None
|
||||
for sub in ("carol", "dave", "erin", "alice"):
|
||||
engine.handle_action(session, sub, "pass", {})
|
||||
engine.handle_action(session, make_principal(sub), "pass", {})
|
||||
await game_store.save(session)
|
||||
self.assertEqual(PHASE_CALLING, session.state.phase)
|
||||
self.assertEqual(1, session.state.caller)
|
||||
@@ -227,15 +227,17 @@ class ChiamataTimeoutTest(unittest.TestCase):
|
||||
id=game_id,
|
||||
game_type="briscola_chiamata",
|
||||
join_code=join_code,
|
||||
creator_sub=PLAYERS[0],
|
||||
creator_sub=make_principal(PLAYERS[0]),
|
||||
players=[
|
||||
Seat(user_sub=name, display_name=name.capitalize())
|
||||
Seat(user_sub=make_principal(name), display_name=name.capitalize())
|
||||
for name in PLAYERS
|
||||
],
|
||||
)
|
||||
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
|
||||
session.state = rules.create_game(
|
||||
make_principal(PLAYERS[0]), "Alice", turn_timeout=1
|
||||
)
|
||||
for name in PLAYERS[1:]:
|
||||
rules.join_game(session.state, name, name.capitalize())
|
||||
rules.join_game(session.state, make_principal(name), name.capitalize())
|
||||
state_mutator(session.state)
|
||||
await game_store.save(session)
|
||||
return session.id
|
||||
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app
|
||||
from tests.helpers import async_test, oidc_user
|
||||
from tests.helpers import async_test, make_principal, oidc_user
|
||||
|
||||
|
||||
class GamesRouteTest(unittest.TestCase):
|
||||
@@ -37,7 +37,7 @@ class GamesRouteTest(unittest.TestCase):
|
||||
with oidc_user("alice"):
|
||||
snapshot = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, snapshot.status_code)
|
||||
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
|
||||
self.assertEqual(make_principal("alice"), snapshot.json()["players"][0]["sub"])
|
||||
|
||||
# A logged-in non-player is a read-only spectator: no join
|
||||
# code, and the view is marked as a spectator view.
|
||||
@@ -98,6 +98,32 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(0, participant["captured_count"])
|
||||
self.assertEqual(0, participant["scope"])
|
||||
|
||||
@async_test
|
||||
async def test_same_sub_other_provider_cannot_read_private_hand(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
game_id = created.json()["id"]
|
||||
for player in ("bob", "carol", "dave"):
|
||||
with oidc_user(player):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
|
||||
with oidc_user("alice", provider="google"):
|
||||
watched = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, watched.status_code)
|
||||
spectator = watched.json()
|
||||
self.assertTrue(spectator["spectator"])
|
||||
self.assertNotIn("join_code", spectator)
|
||||
for participant in spectator["players"]:
|
||||
self.assertNotIn("hand", participant)
|
||||
|
||||
with oidc_user("alice"):
|
||||
owned = await client.get(f"/api/games/{game_id}")
|
||||
self.assertFalse(owned.json()["spectator"])
|
||||
self.assertIn("hand", owned.json()["players"][0])
|
||||
|
||||
@async_test
|
||||
async def test_create_napola_option(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -180,7 +206,7 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertFalse(body["napola"])
|
||||
self.assertTrue(body["asso_pigliatutto"])
|
||||
self.assertEqual(3, body["seats_open"])
|
||||
self.assertEqual(["alice"], [p["sub"] for p in body["players"]])
|
||||
self.assertEqual([make_principal("alice")], [p["sub"] for p in body["players"]])
|
||||
|
||||
for player in ("bob", "carol", "dave"):
|
||||
with oidc_user(player):
|
||||
|
||||
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app
|
||||
from tavolo.config import settings
|
||||
from tests.helpers import async_test, oidc_user
|
||||
from tests.helpers import async_test, make_principal, oidc_user
|
||||
|
||||
|
||||
class MeRouteTest(unittest.TestCase):
|
||||
@@ -25,7 +25,7 @@ class MeRouteTest(unittest.TestCase):
|
||||
# The email claim the IdP returns is neither stored nor exposed;
|
||||
# no picture claim in this request.
|
||||
self.assertEqual(
|
||||
{"sub": "alice", "name": "alice", "picture": None},
|
||||
{"sub": make_principal("alice"), "name": "alice", "picture": None},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
|
||||
+14
-14
@@ -23,7 +23,7 @@ from tavolo.scopa.state import (
|
||||
ScopaState,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
|
||||
class ScopaRoutesTest(unittest.TestCase):
|
||||
@@ -249,11 +249,11 @@ class ScopaWebSocketTest(unittest.TestCase):
|
||||
turn=0,
|
||||
players=[
|
||||
PlayerState(
|
||||
sub="alice", name="Alice", seat=0,
|
||||
sub=make_principal("alice"), name="Alice", seat=0,
|
||||
hand=[Card.parse("01D"), Card.parse("09C")],
|
||||
),
|
||||
PlayerState(
|
||||
sub="bob", name="Bob", seat=1,
|
||||
sub=make_principal("bob"), name="Bob", seat=1,
|
||||
hand=[Card.parse("04D")],
|
||||
),
|
||||
],
|
||||
@@ -264,10 +264,10 @@ class ScopaWebSocketTest(unittest.TestCase):
|
||||
id="scopa-asso-pigliatutto-1",
|
||||
game_type="scopa",
|
||||
join_code="SA0001",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = state
|
||||
@@ -304,10 +304,10 @@ class ScopaWebSocketTest(unittest.TestCase):
|
||||
turn=0,
|
||||
players=[
|
||||
PlayerState(
|
||||
sub="alice", name="Alice", seat=0,
|
||||
sub=make_principal("alice"), name="Alice", seat=0,
|
||||
hand=[Card.parse("02D")],
|
||||
),
|
||||
PlayerState(sub="bob", name="Bob", seat=1),
|
||||
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
|
||||
],
|
||||
table=[Card.parse("02C")],
|
||||
)
|
||||
@@ -315,10 +315,10 @@ class ScopaWebSocketTest(unittest.TestCase):
|
||||
id="scopa-hand-end-1",
|
||||
game_type="scopa",
|
||||
join_code="SH0001",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = state
|
||||
@@ -374,14 +374,14 @@ class ScopaTurnTimeoutTest(unittest.TestCase):
|
||||
id="scopa-turn-timeout-1",
|
||||
game_type=engine.id,
|
||||
join_code="ST0001",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
session.state.turn_timeout = 1
|
||||
engine.join(session, "bob", "Bob")
|
||||
engine.join(session, make_principal("bob"), "Bob")
|
||||
await game_store.save(session)
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
|
||||
+13
-13
@@ -21,7 +21,7 @@ from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from tavolo.scopa import engine as scopa_rules
|
||||
from tavolo.scopone import engine as rules
|
||||
from tests.helpers import async_test, oidc_user
|
||||
from tests.helpers import async_test, make_principal, oidc_user
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
@@ -32,12 +32,12 @@ async def _finished_session(target_score: int = 1) -> GameSession:
|
||||
id="stats-scope-1",
|
||||
game_type=engine.id,
|
||||
join_code="SS0001",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||
)
|
||||
engine.create(session, {"target_score": target_score})
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join(session, name, name.capitalize())
|
||||
engine.join(session, make_principal(name), name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
@@ -160,11 +160,11 @@ async def _finished_briscola_session() -> GameSession:
|
||||
id="stats-briscola-1",
|
||||
game_type=engine.id,
|
||||
join_code="BS0001",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||
)
|
||||
engine.create(session, {})
|
||||
engine.join(session, "bob", "Bob")
|
||||
engine.join(session, make_principal("bob"), "Bob")
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
@@ -246,15 +246,15 @@ async def _finished_chiamata_session() -> GameSession:
|
||||
id="stats-chiamata-1",
|
||||
game_type=engine.id,
|
||||
join_code="BC0001",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub=name, display_name=name.capitalize())
|
||||
Seat(user_sub=make_principal(name), display_name=name.capitalize())
|
||||
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
for name in ("bob", "carol", "dave", "erin"):
|
||||
engine.join(session, name, name.capitalize())
|
||||
engine.join(session, make_principal(name), name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
@@ -344,15 +344,15 @@ class ScopaStatsTest(unittest.TestCase):
|
||||
id=f"stats-scopa-{player_count}",
|
||||
game_type=engine.id,
|
||||
join_code="SC0001",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||
)
|
||||
engine.create(
|
||||
session,
|
||||
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
|
||||
)
|
||||
for name in PLAYERS[1:player_count]:
|
||||
engine.join(session, name, name.capitalize())
|
||||
engine.join(session, make_principal(name), name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
|
||||
@@ -25,7 +25,7 @@ from tavolo.telemetry import (
|
||||
)
|
||||
from tavolo.config import Settings
|
||||
|
||||
from tests.helpers import async_test
|
||||
from tests.helpers import async_test, make_principal
|
||||
|
||||
try:
|
||||
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
|
||||
@@ -158,7 +158,7 @@ class SessionTagHookTest(unittest.TestCase):
|
||||
hashlib.sha256(b"sid").hexdigest()[:16],
|
||||
span.attributes["session.id"],
|
||||
)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
|
||||
|
||||
def test_tags_user_of_any_provider(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
@@ -166,7 +166,7 @@ class SessionTagHookTest(unittest.TestCase):
|
||||
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
self.assertEqual(make_principal("alice", "google"), span.attributes["user.id"])
|
||||
|
||||
def test_ignores_legacy_unprefixed_user_key(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
@@ -190,7 +190,7 @@ class SessionTagHookTest(unittest.TestCase):
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertNotIn("session.id", span.attributes)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
|
||||
|
||||
def test_missing_session_is_a_noop(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
@@ -266,7 +266,7 @@ class SessionTagIntegrationTest(unittest.TestCase):
|
||||
attributes = span.attributes
|
||||
assert attributes is not None
|
||||
self.assertEqual(digest, attributes["session.id"], name)
|
||||
self.assertEqual("alice", attributes["user.id"], name)
|
||||
self.assertEqual(make_principal("alice"), attributes["user.id"], name)
|
||||
|
||||
|
||||
def _metric_points(reader) -> dict:
|
||||
|
||||
@@ -11,7 +11,7 @@ from httpx_ws.transport import ASGIWebSocketTransport
|
||||
from tavolo.app import app, game_store, platform
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.scopone.state import Card, PlayerState, ScoponeState
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
@@ -27,8 +27,8 @@ def _started_session(
|
||||
id=game_id,
|
||||
game_type=engine.id,
|
||||
join_code=code,
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||
)
|
||||
engine.create(
|
||||
session,
|
||||
@@ -42,7 +42,7 @@ def _started_session(
|
||||
session.state.hand_ack_timeout = hand_ack_timeout
|
||||
session.state.turn_timeout = turn_timeout
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join(session, name, name.capitalize())
|
||||
engine.join(session, make_principal(name), name.capitalize())
|
||||
return session
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class WebSocketTest(unittest.TestCase):
|
||||
alice_first = await alice_ws.receive_json()
|
||||
self.assertEqual("state", alice_first["type"])
|
||||
self.assertEqual(
|
||||
"alice", alice_first["game"]["players"][0]["sub"]
|
||||
make_principal("alice"), alice_first["game"]["players"][0]["sub"]
|
||||
)
|
||||
self.assertNotIn("hand", alice_first["game"]["players"][1])
|
||||
|
||||
@@ -189,12 +189,12 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||
id="hand-end-1",
|
||||
game_type=engine.id,
|
||||
join_code="HEND01",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
Seat(user_sub="carol", display_name="Carol", team="A"),
|
||||
Seat(user_sub="dave", display_name="Dave", team="B"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||
Seat(user_sub=make_principal("carol"), display_name="Carol", team="A"),
|
||||
Seat(user_sub=make_principal("dave"), display_name="Dave", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = ScoponeState(
|
||||
@@ -203,10 +203,10 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||
turn=0,
|
||||
table=[Card.parse("02C")],
|
||||
players=[
|
||||
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub="bob", name="Bob", seat=1),
|
||||
PlayerState(sub="carol", name="Carol", seat=2),
|
||||
PlayerState(sub="dave", name="Dave", seat=3),
|
||||
PlayerState(sub=make_principal("alice"), name="Alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
|
||||
PlayerState(sub=make_principal("carol"), name="Carol", seat=2),
|
||||
PlayerState(sub=make_principal("dave"), name="Dave", seat=3),
|
||||
],
|
||||
hand_ack_timeout=hand_ack_timeout,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user