Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565e927a9b | ||
|
|
71493f720b | ||
|
|
4d940add1c | ||
|
|
7d67eccca2 | ||
|
|
8c6ad10bf6 | ||
|
|
532cb16f94 | ||
|
|
3fe29ba740 | ||
|
|
25af0bf3fe | ||
|
|
551c4c9c28 | ||
|
|
bf613eb1c4 | ||
|
|
d4d437c1a1 | ||
|
|
7883d0c458 | ||
|
|
637b711fa8 | ||
|
|
e0030ee94a | ||
|
|
c746a88cfb | ||
|
|
99aa81d682 | ||
|
|
43d5a6e2f9 |
+9
-5
@@ -138,9 +138,12 @@ bookmarks and scripts), and `GET /auth/logout` signs out whichever
|
|||||||
provider the session is logged in with.
|
provider the session is logged in with.
|
||||||
|
|
||||||
Sessions hold a single identity: logging in with another provider
|
Sessions hold a single identity: logging in with another provider
|
||||||
replaces the previous one. Each provider's `sub` is a separate account,
|
replaces the previous one. Accounts are keyed by the canonical principal
|
||||||
so match history and Elo ratings are per provider identity (someone using
|
`oidc:v1:<provider>:<sub>`, not by `sub` alone, so equal subjects at two
|
||||||
two providers has two accounts). Migrating from the pre-0.0.6 unindexed
|
providers cannot share games, history, or ratings (someone using two
|
||||||
|
providers has two accounts). Provider names are permanent security
|
||||||
|
identifiers: do not reuse a name for a different issuer, and treat renaming
|
||||||
|
one as an identity migration. Migrating from the pre-0.0.6 unindexed
|
||||||
variables: rename `OIDC_ISSUER`/`OIDC_CLIENT_ID`/... to `OIDC_0_...`, add
|
variables: rename `OIDC_ISSUER`/`OIDC_CLIENT_ID`/... to `OIDC_0_...`, add
|
||||||
`OIDC_0_PROVIDER`, and register the new callback path
|
`OIDC_0_PROVIDER`, and register the new callback path
|
||||||
(`/auth/<provider>/callback`) at the provider. Sessions survive only when
|
(`/auth/<provider>/callback`) at the provider. Sessions survive only when
|
||||||
@@ -249,8 +252,9 @@ aggregate with `max`, not `sum`.
|
|||||||
per-deal audit; for briscola chiamata: the calling side's points
|
per-deal audit; for briscola chiamata: the calling side's points
|
||||||
against the defenders', the declared target, the called card and
|
against the defenders', the declared target, the called card and
|
||||||
whether the caller played alone).
|
whether the caller played alone).
|
||||||
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
- `match_player` — one row per participant: the canonical provider-qualified
|
||||||
seat, game-defined team label (nullable), whether they won, the points
|
principal (`user_sub`), display name, seat, game-defined team label
|
||||||
|
(nullable), whether they won, the points
|
||||||
they scored, the Elo change the match produced (`elo_delta`) and
|
they scored, the Elo change the match produced (`elo_delta`) and
|
||||||
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
||||||
- `player_rating` — current Elo rating and leaderboard totals per
|
- `player_rating` — current Elo rating and leaderboard totals per
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "match_player" ALTER COLUMN "user_sub" TYPE VARCHAR(1024) USING "user_sub"::VARCHAR(1024);
|
||||||
|
ALTER TABLE "player_rating" ALTER COLUMN "user_sub" TYPE VARCHAR(1024) USING "user_sub"::VARCHAR(1024);"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "match_player" ALTER COLUMN "user_sub" TYPE VARCHAR(255) USING "user_sub"::VARCHAR(255);
|
||||||
|
ALTER TABLE "player_rating" ALTER COLUMN "user_sub" TYPE VARCHAR(255) USING "user_sub"::VARCHAR(255);"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"eJztmutv4jgQwP+VKJ96Uq8q6Wt1Wp0UKNVyC6UCunfaahWZxFCrxs46zrGo1//9bOf94t"
|
||||||
|
"EChYoP2yVjj+P8Jh7PjPOsT6gDsXfSAdx+1P/QnnUCJlD8yDYcazpw3UQsBRwMcdAz7jL0"
|
||||||
|
"OAM2F8IRwB4UIgd6NkMuR5TIrqZm04mLIYeOptQ0OtIogfI//gg1BsfI45CJ5rGYh8ZnLv"
|
||||||
|
"RO5NgOtcXgiIzfNoxP0E8fWpyOoejIxGAPP4QYEQf+gl506T5ZIwSxkwGCHDmAkltyQCm7"
|
||||||
|
"v29d36iecopDy6bYn5Cktzvjj5TE3X0fOSdSR7aNIYEMiEdI4SI+xiHWSBTMWAg482E8VS"
|
||||||
|
"cROHAEfCyh659HPrEla03dSf45/zOcWqqbZd12B1a/ObAsvWAjOYUc71BkUyLtiwiXoJ5f"
|
||||||
|
"gnETIEqqyxs0vpi9o7PL3xQC6vExU40Kl/6iFAEHgaqCnlCW9gp4FWA3HgErh51RyjEXU3"
|
||||||
|
"4N7UgwB3dEagNsxZr6ZWFIxlwuyjNjDutvZi/AbSjcVCzAYFnehi2GapLUE8oeB0ywsAAv"
|
||||||
|
"Yr4WlDiawHLUWc0caydUPYl+rId84ky2gH4O6kGr0+wPzM6dHH7ieT+x4mUOmrLFUNJZTn"
|
||||||
|
"p0mTNLPIj2d2vwRZOX2vfubTO/VuJ+g++6nBPwObUInVrASTOJxJEoY+YRIsh7fJWdc6oH"
|
||||||
|
"Q++0ocVQkk3Bxn/1u7fl9k008qZFNtf+07DYQDdl1NQ+NfQR5oh4J/K277FVSUIZK0du86"
|
||||||
|
"hj/pP3qI12t543nxygLtyrjBtGT6k9TQqGwH6aAuZYmZbEbi4GM8i8ouHqoeLN1x7EQD1v"
|
||||||
|
"0VTpEO1OjbS/yzCRhlutAkoNWkW02DQxJnkJIGCsHkneW96pBFlV0JsQXRD6Wm7Sc2EEfC"
|
||||||
|
"e2T2QjV9k0Clt9DzINEfVbDVkMeVfQK4lxH5IgXepYnj/UfxwC310KfGO7rBD3pnU+Xthb"
|
||||||
|
"OzXOlwh8ZbfK0DdozAa/DvLkirXU9Qq083qbIr5V95tFbpyeLkFc9KoErtpyyQYsCz/7E4"
|
||||||
|
"Bxi/CKNAOWxp1ye9hDxmM5id/PjKvLT6JVzVFeXM1h3e+Y7XbrdpBjySGYrPLORv1f9a6G"
|
||||||
|
"S39nMG44KZ4GQVYuEKMUQ0DK6U5Lw7KhUNnDt3QOznq3284EyfXWIMf1vlNvCl+scItOiC"
|
||||||
|
"tx8QX2bMpKvO4NpqDKFUQaOcwjqbIpzqfbh3zdva+3m9pdr9lo9VthVhJnmaoxC7fXNNs5"
|
||||||
|
"uhBTMWfMwar+NqP4Kqe7a85ifT7XgRwgXJKkVWfXKZVDer2W9DptkCDrWi0tSeusMznZXa"
|
||||||
|
"e9IBcp1CtyfEt8tPDDaEy+wplC3BITAcQuc835s6P9hFooSQgxA9M4Q868U+LpxTPDwDM3"
|
||||||
|
"zH7DvG7qL9U1oE1WN4LKRQ/w4CEL5Y1M+/G8+kZQ2bBY0nVhgaPhMwYJ15qYaoGeBoijiS"
|
||||||
|
"DKgWxIxcNpnHKhGxUxgntorvgnz3GKpY+1jFhaFEnnz8kZUr4w8pA9X0pgTIVnDVaXenOO"
|
||||||
|
"wzcCekFFyCmWWw71lUN9ZXdSqE0VWHb3DPdD5auJI8oyrgzyE4XtlVVqF6ebyqXCEN+onV"
|
||||||
|
"+dfzq7PI/j/FgyL9gvxvlF570k16Li9vjuC9xor1wSaby1HkDmQCbBxtIVlETlUEJZWELZ"
|
||||||
|
"v9MBfVOB2BbOBnxXfkHymg9Uspof8PsUnYkEp0vwTA8PxKvh7/j3KvFx/pLfR2wyNzYhQ+"
|
||||||
|
"VfuoYtx/PyYZD0WZQIV9t5zd+gVu6hpZliyQ4apntvSxE3X8p94w5anRn+C5mHys5+qp19"
|
||||||
|
"SuUDpirGxcUynv7iotrTy7asp5eLagXCYfcPSLe21D5am7OP1or7qLgjh2SlLwBTKts/o3"
|
||||||
|
"ifkvh7fOy3/s3s5X9zkOhU"
|
||||||
|
)
|
||||||
@@ -72,9 +72,10 @@ _DEFAULT_OIDC_CLIENT_ID = "tavolo"
|
|||||||
class OidcProviderSettings:
|
class OidcProviderSettings:
|
||||||
"""One OIDC provider, parsed from the ``OIDC_<n>_*`` variables.
|
"""One OIDC provider, parsed from the ``OIDC_<n>_*`` variables.
|
||||||
|
|
||||||
``provider`` names the provider in URLs (``/auth/<provider>/login``)
|
``provider`` names the provider in URLs (``/auth/<provider>/login``),
|
||||||
and namespaces its session state (``oidc:<provider>:...``); it must
|
namespaces its session state (``oidc:<provider>:...``), and is part of
|
||||||
match kaya-oidc's ``[A-Za-z0-9_-]+`` pattern.
|
the canonical user principal. It must match kaya-oidc's
|
||||||
|
``[A-Za-z0-9_-]+`` pattern and remain stable across deployments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
provider: str
|
provider: str
|
||||||
|
|||||||
@@ -95,10 +95,11 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
|
|||||||
The session id is the session cookie, i.e. a credential, so only a
|
The session id is the session cookie, i.e. a credential, so only a
|
||||||
truncated SHA-256 of it is exported; session ids are
|
truncated SHA-256 of it is exported; session ids are
|
||||||
``secrets.token_urlsafe(32)`` (256 bits), which makes the digest
|
``secrets.token_urlsafe(32)`` (256 bits), which makes the digest
|
||||||
irreversible in practice. The OIDC ``sub`` is a pseudonymous
|
irreversible in practice. The canonical provider-qualified principal
|
||||||
identifier already persisted in Postgres and served by the API, so it
|
is a pseudonymous identifier already persisted in Postgres and served
|
||||||
is exported verbatim as ``user.id`` (OpenTelemetry's current end-user
|
by the API, so it is exported verbatim as ``user.id`` (OpenTelemetry's
|
||||||
attribute; ``enduser.id`` is PII-flagged and being superseded).
|
current end-user attribute; ``enduser.id`` is PII-flagged and being
|
||||||
|
superseded).
|
||||||
"""
|
"""
|
||||||
session = getattr(carrier, "session", None)
|
session = getattr(carrier, "session", None)
|
||||||
if session is None:
|
if session is None:
|
||||||
@@ -111,9 +112,7 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
|
|||||||
)
|
)
|
||||||
user = session_user(session)
|
user = session_user(session)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
sub = user.get("sub")
|
span.set_attribute("user.id", user.principal)
|
||||||
if sub:
|
|
||||||
span.set_attribute("user.id", str(sub))
|
|
||||||
|
|
||||||
|
|
||||||
class _NoOtelLogs(logging.Filter):
|
class _NoOtelLogs(logging.Filter):
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ class _BriscolaBase(GameEngine):
|
|||||||
)
|
)
|
||||||
elif kind == "hand_end":
|
elif kind == "hand_end":
|
||||||
for player in state.players:
|
for player in state.players:
|
||||||
|
if state.phase != PHASE_HAND_END:
|
||||||
|
break
|
||||||
engine.acknowledge_hand(state, player.sub)
|
engine.acknowledge_hand(state, player.sub)
|
||||||
log.info(
|
log.info(
|
||||||
"deal %d auto-advanced after the acknowledgement timeout",
|
"deal %d auto-advanced after the acknowledgement timeout",
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
The platform has no application roles: every authenticated user may
|
The platform has no application roles: every authenticated user may
|
||||||
create and join games. Authorization beyond login is game membership,
|
create and join games. Authorization beyond login is game membership,
|
||||||
checked against the seats of the live session.
|
checked against the seats of the live session. Seats and persisted stats
|
||||||
|
use a canonical provider-plus-subject principal because a bare OIDC ``sub``
|
||||||
|
is not unique across providers.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Mapping, Optional
|
from typing import Any, Callable, Mapping, Optional
|
||||||
|
|
||||||
from kaya.core import HttpContext, WebSocket
|
from kaya.core import HttpContext, WebSocket
|
||||||
@@ -17,25 +21,62 @@ from kaya.session import Session
|
|||||||
# fixed key or provider name.
|
# fixed key or provider name.
|
||||||
_OIDC_PREFIX = "oidc:"
|
_OIDC_PREFIX = "oidc:"
|
||||||
_USER_SUFFIX = ":user"
|
_USER_SUFFIX = ":user"
|
||||||
|
_PRINCIPAL_PREFIX = "oidc:v1:"
|
||||||
|
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
|
||||||
def session_user(session: Session) -> Optional[OIDCUser]:
|
@dataclass(frozen=True)
|
||||||
"""Return the authenticated user stored in a session, if any.
|
class AuthenticatedUser:
|
||||||
|
"""OIDC claims together with their stable, provider-qualified identity."""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
claims: OIDCUser
|
||||||
|
principal: str
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_principal(provider: str, sub: str) -> str:
|
||||||
|
"""Return the global principal for one provider's OIDC subject.
|
||||||
|
|
||||||
|
Provider names are restricted to characters that cannot include ``:``,
|
||||||
|
so the tuple remains unambiguous even when the subject contains colons.
|
||||||
|
They are persisted security identifiers and must not be reused for a
|
||||||
|
different issuer.
|
||||||
|
"""
|
||||||
|
if not isinstance(provider, str) or _PROVIDER_NAME.fullmatch(provider) is None:
|
||||||
|
raise ValueError("invalid OIDC provider")
|
||||||
|
if not isinstance(sub, str) or not sub:
|
||||||
|
raise ValueError("invalid OIDC subject")
|
||||||
|
return f"{_PRINCIPAL_PREFIX}{provider}:{sub}"
|
||||||
|
|
||||||
|
|
||||||
|
def _authenticated_user(
|
||||||
|
provider: str, claims: OIDCUser
|
||||||
|
) -> Optional[AuthenticatedUser]:
|
||||||
|
try:
|
||||||
|
principal = canonical_principal(provider, claims.sub)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return AuthenticatedUser(provider=provider, claims=claims, principal=principal)
|
||||||
|
|
||||||
|
|
||||||
|
def session_user(session: Session) -> Optional[AuthenticatedUser]:
|
||||||
|
"""Return the authenticated identity stored in a session, if any.
|
||||||
|
|
||||||
The kaya-oidc mixin writes the userinfo at login under a per-provider
|
The kaya-oidc mixin writes the userinfo at login under a per-provider
|
||||||
key, so any provider's user is accepted (the configured provider is
|
key. Retaining that provider is essential: an OIDC subject is only
|
||||||
``default`` unless the app says otherwise).
|
unique within one provider, not across all of them.
|
||||||
"""
|
"""
|
||||||
for key in session:
|
for key in session:
|
||||||
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
|
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
|
||||||
claims = session.get(key)
|
claims = session.get(key)
|
||||||
if isinstance(claims, Mapping):
|
if isinstance(claims, Mapping):
|
||||||
return OIDCUser(claims)
|
provider = key.removeprefix(_OIDC_PREFIX).removesuffix(_USER_SUFFIX)
|
||||||
|
return _authenticated_user(provider, OIDCUser(claims))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
def get_ws_user(ws: WebSocket) -> Optional[AuthenticatedUser]:
|
||||||
"""Return the authenticated user of a WebSocket connection, if any.
|
"""Return the authenticated identity of a WebSocket connection, if any.
|
||||||
|
|
||||||
The session mixin injects ``session`` into the websocket wrapper; the
|
The session mixin injects ``session`` into the websocket wrapper; the
|
||||||
OIDC mixin stores the userinfo there at login. Patched in tests.
|
OIDC mixin stores the userinfo there at login. Patched in tests.
|
||||||
@@ -73,13 +114,19 @@ def picture(user: OIDCUser) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
|
def current_user(
|
||||||
"""Return the authenticated user of an HTTP request, if any.
|
oidc: OIDCProviders, ctx: HttpContext
|
||||||
|
) -> Optional[AuthenticatedUser]:
|
||||||
|
"""Return the authenticated identity of an HTTP request, if any.
|
||||||
|
|
||||||
Sessions are single-identity by default, so at most one provider has
|
Sessions are single-identity by default, so at most one provider has
|
||||||
a user; if several do, the first configured provider wins.
|
a user; if several do, the first configured provider wins.
|
||||||
"""
|
"""
|
||||||
return next(iter(oidc.get_users(ctx).values()), None)
|
users = oidc.get_users(ctx)
|
||||||
|
if not users:
|
||||||
|
return None
|
||||||
|
provider, claims = next(iter(users.items()))
|
||||||
|
return _authenticated_user(provider, claims)
|
||||||
|
|
||||||
|
|
||||||
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class Seat:
|
|||||||
persisting participations.
|
persisting participations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Canonical provider-plus-subject principal, despite the legacy field name.
|
||||||
user_sub: str
|
user_sub: str
|
||||||
display_name: str
|
display_name: str
|
||||||
# Game-defined team label ("A"/"B", ...); ``None`` for games without
|
# Game-defined team label ("A"/"B", ...); ``None`` for games without
|
||||||
@@ -73,6 +74,7 @@ class GameSession:
|
|||||||
id: str
|
id: str
|
||||||
game_type: str
|
game_type: str
|
||||||
join_code: str
|
join_code: str
|
||||||
|
# Canonical principal of the creator.
|
||||||
creator_sub: str
|
creator_sub: str
|
||||||
players: List[Seat] = field(default_factory=list)
|
players: List[Seat] = field(default_factory=list)
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@@ -81,13 +83,13 @@ class GameSession:
|
|||||||
stats_saved: bool = False
|
stats_saved: bool = False
|
||||||
state: Any = None
|
state: Any = None
|
||||||
|
|
||||||
def seated(self, sub: str) -> bool:
|
def seated(self, principal: str) -> bool:
|
||||||
"""Whether ``sub`` occupies a seat in this game."""
|
"""Whether ``principal`` occupies a seat in this game."""
|
||||||
return self.seat_of(sub) is not None
|
return self.seat_of(principal) is not None
|
||||||
|
|
||||||
def seat_of(self, sub: str) -> Optional[Seat]:
|
def seat_of(self, principal: str) -> Optional[Seat]:
|
||||||
for seat in self.players:
|
for seat in self.players:
|
||||||
if seat.user_sub == sub:
|
if seat.user_sub == principal:
|
||||||
return seat
|
return seat
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -112,6 +114,7 @@ class Deadline:
|
|||||||
class PlayerResult:
|
class PlayerResult:
|
||||||
"""The outcome of a finished match for one participant."""
|
"""The outcome of a finished match for one participant."""
|
||||||
|
|
||||||
|
# Canonical provider-plus-subject principal, despite the legacy field name.
|
||||||
user_sub: str
|
user_sub: str
|
||||||
seat: int
|
seat: int
|
||||||
won: bool
|
won: bool
|
||||||
@@ -126,7 +129,7 @@ class PlayerResult:
|
|||||||
class MatchResult:
|
class MatchResult:
|
||||||
"""The game-independent outcome of a finished match.
|
"""The game-independent outcome of a finished match.
|
||||||
|
|
||||||
``teams`` groups the participants' subs into the sides the Elo rating
|
``teams`` groups the participants' principals into the sides the Elo rating
|
||||||
treats as opponents (exactly two for now); ``winner_team`` is the
|
treats as opponents (exactly two for now); ``winner_team`` is the
|
||||||
index of the winning side. ``summary`` is persisted verbatim as the
|
index of the winning side. ``summary`` is persisted verbatim as the
|
||||||
match's JSON ``result`` column.
|
match's JSON ``result`` column.
|
||||||
@@ -145,6 +148,9 @@ class GameEngine(ABC):
|
|||||||
logging: all persistence, locking and transport concerns belong to
|
logging: all persistence, locking and transport concerns belong to
|
||||||
the platform. Rule violations are reported by raising
|
the platform. Rule violations are reported by raising
|
||||||
:class:`~tavolo.platform.errors.GameError` subclasses.
|
:class:`~tavolo.platform.errors.GameError` subclasses.
|
||||||
|
|
||||||
|
Every ``user_sub`` value in this contract is an opaque, canonical
|
||||||
|
provider-plus-subject principal, never a bare cross-provider OIDC ``sub``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: Unique id carried by sessions and persisted matches.
|
#: Unique id carried by sessions and persisted matches.
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ completed matches are written here. The schema is game-independent:
|
|||||||
* :class:`Match` — one row per finished match; everything game-specific
|
* :class:`Match` — one row per finished match; everything game-specific
|
||||||
(scores, hands played, options, per-hand audit trail) lives in the
|
(scores, hands played, options, per-hand audit trail) lives in the
|
||||||
JSON ``result`` column produced by the game's engine.
|
JSON ``result`` column produced by the game's engine.
|
||||||
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
* :class:`MatchPlayer` — one row per participant, linking a canonical
|
||||||
``sub`` to a seat, an optional team label, whether they won, the
|
provider-qualified principal to a seat, an optional team label, whether
|
||||||
points they scored and their Elo delta.
|
they won, the points they scored and their Elo delta.
|
||||||
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
|
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
|
||||||
totals (wins, points, latest display name) of a player for one game
|
totals (wins, points, latest display name) of a principal for one game
|
||||||
type, updated transactionally with every finished match (see
|
type, updated transactionally with every finished match (see
|
||||||
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
|
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
|
||||||
"""
|
"""
|
||||||
@@ -48,8 +48,8 @@ class MatchPlayer(Model):
|
|||||||
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
|
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
|
||||||
"models.Match", related_name="players", on_delete=fields.CASCADE
|
"models.Match", related_name="players", on_delete=fields.CASCADE
|
||||||
)
|
)
|
||||||
# OIDC subject of the player; no local users table.
|
# Canonical provider-plus-subject principal; no local users table.
|
||||||
user_sub = fields.CharField(max_length=255, db_index=True)
|
user_sub = fields.CharField(max_length=1024, db_index=True)
|
||||||
display_name = fields.CharField(max_length=200)
|
display_name = fields.CharField(max_length=200)
|
||||||
seat = fields.SmallIntField()
|
seat = fields.SmallIntField()
|
||||||
# Game-defined team label; null for games without fixed teams.
|
# Game-defined team label; null for games without fixed teams.
|
||||||
@@ -73,8 +73,8 @@ class PlayerRating(Model):
|
|||||||
"""Current Elo rating and leaderboard totals of one player per game."""
|
"""Current Elo rating and leaderboard totals of one player per game."""
|
||||||
|
|
||||||
id = fields.UUIDField(pk=True)
|
id = fields.UUIDField(pk=True)
|
||||||
# OIDC subject of the player; no local users table.
|
# Canonical provider-plus-subject principal; no local users table.
|
||||||
user_sub = fields.CharField(max_length=255)
|
user_sub = fields.CharField(max_length=1024)
|
||||||
# Which game the rating applies to (an id from the game registry).
|
# Which game the rating applies to (an id from the game registry).
|
||||||
game_type = fields.CharField(max_length=32)
|
game_type = fields.CharField(max_length=32)
|
||||||
rating = fields.IntField(default=INITIAL_RATING)
|
rating = fields.IntField(default=INITIAL_RATING)
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ def _array(items: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return {"type": "array", "items": items}
|
return {"type": "array", "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _principal() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Canonical provider-qualified principal: "
|
||||||
|
"oidc:v1:<provider>:<sub>.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _nullable(schema: Dict[str, Any]) -> Dict[str, Any]:
|
def _nullable(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""A copy of ``schema`` that also accepts ``null`` (OpenAPI 3.1 types)."""
|
"""A copy of ``schema`` that also accepts ``null`` (OpenAPI 3.1 types)."""
|
||||||
nullable = dict(schema)
|
nullable = dict(schema)
|
||||||
@@ -124,7 +132,7 @@ HEALTH_SCHEMA = _object(
|
|||||||
|
|
||||||
ME_SCHEMA = _object(
|
ME_SCHEMA = _object(
|
||||||
{
|
{
|
||||||
"sub": {"type": "string"},
|
"sub": _principal(),
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"picture": _nullable({"type": "string"}),
|
"picture": _nullable({"type": "string"}),
|
||||||
},
|
},
|
||||||
@@ -164,7 +172,7 @@ GAME_TYPES_PAGE_SCHEMA = _object(
|
|||||||
|
|
||||||
LOBBY_PLAYER_SCHEMA = _object(
|
LOBBY_PLAYER_SCHEMA = _object(
|
||||||
{
|
{
|
||||||
"sub": {"type": "string"},
|
"sub": _principal(),
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"seat": {"type": "integer"},
|
"seat": {"type": "integer"},
|
||||||
"team": _nullable({
|
"team": _nullable({
|
||||||
@@ -258,7 +266,7 @@ ONGOING_GAMES_PAGE_SCHEMA = _object(
|
|||||||
|
|
||||||
MATCH_PLAYER_SCHEMA = _object(
|
MATCH_PLAYER_SCHEMA = _object(
|
||||||
{
|
{
|
||||||
"user_sub": {"type": "string"},
|
"user_sub": _principal(),
|
||||||
"display_name": {"type": "string"},
|
"display_name": {"type": "string"},
|
||||||
"seat": {"type": "integer"},
|
"seat": {"type": "integer"},
|
||||||
"team": _nullable({"type": "string"}),
|
"team": _nullable({"type": "string"}),
|
||||||
@@ -314,7 +322,7 @@ MATCHES_PAGE_SCHEMA = _object(
|
|||||||
|
|
||||||
LEADERBOARD_ENTRY_SCHEMA = _object(
|
LEADERBOARD_ENTRY_SCHEMA = _object(
|
||||||
{
|
{
|
||||||
"user_sub": {"type": "string"},
|
"user_sub": _principal(),
|
||||||
"display_name": {"type": "string"},
|
"display_name": {"type": "string"},
|
||||||
"elo": {"type": "integer"},
|
"elo": {"type": "integer"},
|
||||||
"matches": {"type": "integer"},
|
"matches": {"type": "integer"},
|
||||||
|
|||||||
@@ -225,12 +225,13 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
|
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
assert user is not None # enforced by @require_auth
|
assert user is not None # enforced by @require_auth
|
||||||
|
principal = user.principal
|
||||||
session = GameSession(
|
session = GameSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code=await _unique_code(platform),
|
join_code=await _unique_code(platform),
|
||||||
creator_sub=user.sub,
|
creator_sub=principal,
|
||||||
players=[Seat(user_sub=user.sub, display_name=display_name(user))],
|
players=[Seat(user_sub=principal, display_name=display_name(user.claims))],
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -242,7 +243,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
log.info(
|
log.info(
|
||||||
"game %s created by %s (%s, options %r)",
|
"game %s created by %s (%s, options %r)",
|
||||||
session.id,
|
session.id,
|
||||||
user.sub,
|
principal,
|
||||||
engine.id,
|
engine.id,
|
||||||
options,
|
options,
|
||||||
)
|
)
|
||||||
@@ -290,9 +291,10 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
|
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
principal = user.principal
|
||||||
existing = await platform.game_store.find_by_code(code)
|
existing = await platform.game_store.find_by_code(code)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
log.debug("join rejected for %s: unknown code %r", user.sub, code)
|
log.debug("join rejected for %s: unknown code %r", principal, code)
|
||||||
await send_error(ctx, 404, "unknown join code")
|
await send_error(ctx, 404, "unknown join code")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -303,9 +305,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
return
|
return
|
||||||
engine = platform.registry.require(session.game_type)
|
engine = platform.registry.require(session.game_type)
|
||||||
try:
|
try:
|
||||||
engine.join(session, user.sub, display_name(user))
|
engine.join(session, principal, display_name(user.claims))
|
||||||
except GameError as exc:
|
except GameError as exc:
|
||||||
log.debug("join rejected for %s in game %s: %s", user.sub, session.id, exc)
|
log.debug("join rejected for %s in game %s: %s", principal, session.id, exc)
|
||||||
await send_error(ctx, 409, str(exc))
|
await send_error(ctx, 409, str(exc))
|
||||||
return
|
return
|
||||||
await platform.game_store.save(session)
|
await platform.game_store.save(session)
|
||||||
@@ -314,16 +316,16 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
# armed a deadline; queue it so it fires even if nobody ever
|
# armed a deadline; queue it so it fires even if nobody ever
|
||||||
# connects.
|
# connects.
|
||||||
await platform.scheduler.sync_deadline(session)
|
await platform.scheduler.sync_deadline(session)
|
||||||
seat = next(i for i, s in enumerate(session.players) if s.user_sub == user.sub)
|
seat = next(i for i, s in enumerate(session.players) if s.user_sub == principal)
|
||||||
if engine.in_lobby(session):
|
if engine.in_lobby(session):
|
||||||
log.info(
|
log.info(
|
||||||
"%s joined game %s (seat %d, %d/%d players)",
|
"%s joined game %s (seat %d, %d/%d players)",
|
||||||
user.sub, session.id, seat, len(session.players), engine.max_players,
|
principal, session.id, seat, len(session.players), engine.max_players,
|
||||||
)
|
)
|
||||||
await send_json(ctx, 200, _lobby_payload(platform, session))
|
await send_json(ctx, 200, _lobby_payload(platform, session))
|
||||||
return
|
return
|
||||||
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
|
log.info("%s joined game %s (seat %d); match started", principal, session.id, seat)
|
||||||
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
|
await send_json(ctx, 200, _view_payload(platform, session, principal))
|
||||||
|
|
||||||
@app.GET("/api/games/preview")
|
@app.GET("/api/games/preview")
|
||||||
@operation(summary="Preview a game by join code",
|
@operation(summary="Preview a game by join code",
|
||||||
@@ -376,7 +378,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
return
|
return
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
await send_json(
|
await send_json(
|
||||||
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
ctx, 200, _view_payload(platform, session, user.principal if user else None)
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.GET("/api/me/games")
|
@app.GET("/api/me/games")
|
||||||
@@ -395,11 +397,12 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
async def my_games(ctx: HttpContext) -> None:
|
async def my_games(ctx: HttpContext) -> None:
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
assert user is not None # enforced by @require_auth
|
assert user is not None # enforced by @require_auth
|
||||||
|
principal = user.principal
|
||||||
sessions = await platform.game_store.list_ongoing()
|
sessions = await platform.game_store.list_ongoing()
|
||||||
await send_json(ctx, 200, {
|
await send_json(ctx, 200, {
|
||||||
"results": [
|
"results": [
|
||||||
_ongoing_game_payload(platform, session, user.sub)
|
_ongoing_game_payload(platform, session, principal)
|
||||||
for session in sessions
|
for session in sessions
|
||||||
if session.seated(user.sub)
|
if session.seated(principal)
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
ctx,
|
ctx,
|
||||||
200,
|
200,
|
||||||
{
|
{
|
||||||
"sub": user.sub,
|
"sub": user.principal,
|
||||||
"name": display_name(user),
|
"name": display_name(user.claims),
|
||||||
"picture": picture(user),
|
"picture": picture(user.claims),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
return
|
return
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
queryset = Match.filter(players__user_sub=user.sub).distinct()
|
principal = user.principal
|
||||||
|
queryset = Match.filter(players__user_sub=principal).distinct()
|
||||||
if game_type is not None:
|
if game_type is not None:
|
||||||
queryset = queryset.filter(game_type=game_type)
|
queryset = queryset.filter(game_type=game_type)
|
||||||
matches, next_cursor = await paginate(
|
matches, next_cursor = await paginate(
|
||||||
@@ -173,7 +174,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
# declare it on the model class, hence the getattr.
|
# declare it on the model class, hence the getattr.
|
||||||
by_match[getattr(player, "match_id")].append(player)
|
by_match[getattr(player, "match_id")].append(player)
|
||||||
results = [
|
results = [
|
||||||
_serialize_match(m, user.sub, by_match.get(m.id, [])) for m in matches
|
_serialize_match(m, principal, by_match.get(m.id, [])) for m in matches
|
||||||
]
|
]
|
||||||
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
|
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
|
||||||
|
|
||||||
@@ -226,7 +227,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
yours: Optional[PlayerRating] = None
|
yours: Optional[PlayerRating] = None
|
||||||
if user is not None:
|
if user is not None:
|
||||||
yours = await PlayerRating.get_or_none(
|
yours = await PlayerRating.get_or_none(
|
||||||
user_sub=user.sub, game_type=rating_game
|
user_sub=user.principal, game_type=rating_game
|
||||||
)
|
)
|
||||||
your_rank: Optional[int] = None
|
your_rank: Optional[int] = None
|
||||||
your_page: Optional[int] = None
|
your_page: Optional[int] = None
|
||||||
@@ -276,7 +277,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
async def my_ratings(ctx: HttpContext) -> None:
|
async def my_ratings(ctx: HttpContext) -> None:
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
|
rows = await PlayerRating.filter(user_sub=user.principal).order_by("game_type")
|
||||||
await send_json(ctx, 200, {
|
await send_json(ctx, 200, {
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ log = getLogger(__name__)
|
|||||||
class PlayerStats:
|
class PlayerStats:
|
||||||
"""One participant's data feeding a ``player_rating`` row update."""
|
"""One participant's data feeding a ``player_rating`` row update."""
|
||||||
|
|
||||||
|
# Canonical provider-plus-subject principal, despite the legacy field name.
|
||||||
user_sub: str
|
user_sub: str
|
||||||
won: bool
|
won: bool
|
||||||
score: float
|
score: float
|
||||||
@@ -43,9 +44,9 @@ async def apply_match_stats(
|
|||||||
) -> Dict[str, int]:
|
) -> Dict[str, int]:
|
||||||
"""Update the ratings and leaderboard aggregates of ``teams``.
|
"""Update the ratings and leaderboard aggregates of ``teams``.
|
||||||
|
|
||||||
``teams`` groups the participants' subs into the two opposing sides;
|
``teams`` groups the participants' canonical principals into the two
|
||||||
``winner_team`` is the index of the winning side. Ratings are read
|
opposing sides; ``winner_team`` is the index of the winning side. Ratings
|
||||||
from (and written back to) the ``player_rating`` table; unrated
|
are read from (and written back to) the ``player_rating`` table; unrated
|
||||||
players start at the initial rating. Alongside the Elo delta the
|
players start at the initial rating. Alongside the Elo delta the
|
||||||
transaction records each participant's wins, accumulated points and
|
transaction records each participant's wins, accumulated points and
|
||||||
latest display name, so the leaderboard is a single-table query.
|
latest display name, so the leaderboard is a single-table query.
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
# Anonymous and non-seated viewers are welcome as read-only
|
# Anonymous and non-seated viewers are welcome as read-only
|
||||||
# spectators; only an unknown game id is refused.
|
# spectators; only an unknown game id is refused.
|
||||||
user = auth.get_ws_user(ws)
|
user = auth.get_ws_user(ws)
|
||||||
sub: Optional[str] = user.sub if user is not None else None
|
sub: Optional[str] = user.principal if user is not None else None
|
||||||
|
|
||||||
session = await platform.game_store.load(game_id)
|
session = await platform.game_store.load(game_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ from tavolo.platform import (
|
|||||||
PlayerResult,
|
PlayerResult,
|
||||||
Seat,
|
Seat,
|
||||||
)
|
)
|
||||||
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
|
from tavolo.platform.auth import (
|
||||||
|
AuthenticatedUser,
|
||||||
|
canonical_principal,
|
||||||
|
get_ws_user, # noqa: F401 (re-exported for patching)
|
||||||
|
)
|
||||||
from tavolo.platform.deadlines import DeadlineScheduler
|
from tavolo.platform.deadlines import DeadlineScheduler
|
||||||
from tavolo.platform.openapi import SCHEMAS
|
from tavolo.platform.openapi import SCHEMAS
|
||||||
from tavolo.platform.registry import GameRegistry
|
from tavolo.platform.registry import GameRegistry
|
||||||
@@ -326,13 +330,32 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
|||||||
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
||||||
|
|
||||||
|
|
||||||
|
def make_principal(sub: str, provider: str = "default") -> str:
|
||||||
|
return canonical_principal(provider, sub)
|
||||||
|
|
||||||
|
|
||||||
|
def make_identity(
|
||||||
|
sub: str, name: Optional[str] = None, provider: str = "default"
|
||||||
|
) -> AuthenticatedUser:
|
||||||
|
claims = make_user(sub, name)
|
||||||
|
return AuthenticatedUser(
|
||||||
|
provider=provider,
|
||||||
|
claims=claims,
|
||||||
|
principal=canonical_principal(provider, sub),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
def oidc_user(
|
||||||
"""Context manager: make ``oidc.get_users`` return this user for the
|
oidc: OIDCProviders,
|
||||||
first configured provider."""
|
sub: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
provider: Optional[str] = None,
|
||||||
|
) -> Iterator[OIDCUser]:
|
||||||
|
"""Context manager: fake a login, using the first provider by default."""
|
||||||
user = make_user(sub, name)
|
user = make_user(sub, name)
|
||||||
provider = next(iter(oidc.providers))
|
selected = provider or next(iter(oidc.providers))
|
||||||
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
|
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
|
||||||
patcher.start()
|
patcher.start()
|
||||||
try:
|
try:
|
||||||
yield user
|
yield user
|
||||||
@@ -341,13 +364,20 @@ def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iter
|
|||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
|
def ws_users(
|
||||||
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
|
users: Sequence[OIDCUser | AuthenticatedUser], provider: str = "default"
|
||||||
one per websocket connection, in order. Once exhausted it keeps
|
) -> Iterator[None]:
|
||||||
returning the last user."""
|
"""Patch ``auth.get_ws_user`` to hand out identities in sequence."""
|
||||||
from tavolo.platform import auth
|
from tavolo.platform import auth
|
||||||
|
|
||||||
remaining = list(users)
|
remaining = [
|
||||||
|
user if isinstance(user, AuthenticatedUser) else AuthenticatedUser(
|
||||||
|
provider=provider,
|
||||||
|
claims=user,
|
||||||
|
principal=canonical_principal(provider, user.sub),
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
last = remaining[-1] if remaining else None
|
last = remaining[-1] if remaining else None
|
||||||
|
|
||||||
def _next(_ws):
|
def _next(_ws):
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from kaya.session import InMemorySessionStore, Session, SessionMixin
|
|||||||
|
|
||||||
from helpers import async_test
|
from helpers import async_test
|
||||||
from tavolo.platform.auth import (
|
from tavolo.platform.auth import (
|
||||||
|
canonical_principal,
|
||||||
current_user,
|
current_user,
|
||||||
display_name,
|
display_name,
|
||||||
get_ws_user,
|
get_ws_user,
|
||||||
@@ -53,12 +54,16 @@ class SessionUserTest(unittest.TestCase):
|
|||||||
def test_finds_default_provider_user(self) -> None:
|
def test_finds_default_provider_user(self) -> None:
|
||||||
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
|
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
|
||||||
assert user is not None
|
assert user is not None
|
||||||
self.assertEqual("alice", user.sub)
|
self.assertEqual("alice", user.claims.sub)
|
||||||
|
self.assertEqual("default", user.provider)
|
||||||
|
self.assertEqual(canonical_principal("default", "alice"), user.principal)
|
||||||
|
|
||||||
def test_finds_user_of_any_provider(self) -> None:
|
def test_finds_user_of_any_provider(self) -> None:
|
||||||
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
|
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
|
||||||
assert user is not None
|
assert user is not None
|
||||||
self.assertEqual("alice", user.sub)
|
self.assertEqual("alice", user.claims.sub)
|
||||||
|
self.assertEqual("google", user.provider)
|
||||||
|
self.assertEqual(canonical_principal("google", "alice"), user.principal)
|
||||||
|
|
||||||
def test_ignores_flow_state_and_tokens(self) -> None:
|
def test_ignores_flow_state_and_tokens(self) -> None:
|
||||||
session = Session(
|
session = Session(
|
||||||
@@ -79,6 +84,12 @@ class SessionUserTest(unittest.TestCase):
|
|||||||
session = Session("sid", {"oidc:default:user": "alice"})
|
session = Session("sid", {"oidc:default:user": "alice"})
|
||||||
self.assertIsNone(session_user(session))
|
self.assertIsNone(session_user(session))
|
||||||
|
|
||||||
|
def test_ignores_missing_or_invalid_subjects(self) -> None:
|
||||||
|
for claims in ({}, {"sub": ""}, {"sub": 42}):
|
||||||
|
with self.subTest(claims=claims):
|
||||||
|
session = Session("sid", {"oidc:default:user": claims})
|
||||||
|
self.assertIsNone(session_user(session))
|
||||||
|
|
||||||
|
|
||||||
class GetWsUserTest(unittest.TestCase):
|
class GetWsUserTest(unittest.TestCase):
|
||||||
def test_returns_user_from_provider_namespaced_session(self) -> None:
|
def test_returns_user_from_provider_namespaced_session(self) -> None:
|
||||||
@@ -88,12 +99,33 @@ class GetWsUserTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
user = get_ws_user(_ws(session))
|
user = get_ws_user(_ws(session))
|
||||||
assert user is not None
|
assert user is not None
|
||||||
self.assertEqual("alice", user.sub)
|
self.assertEqual("alice", user.claims.sub)
|
||||||
|
|
||||||
def test_missing_session_returns_none(self) -> None:
|
def test_missing_session_returns_none(self) -> None:
|
||||||
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalPrincipalTest(unittest.TestCase):
|
||||||
|
def test_same_subject_at_different_providers_is_distinct(self) -> None:
|
||||||
|
google = canonical_principal("google", "shared")
|
||||||
|
github = canonical_principal("github", "shared")
|
||||||
|
self.assertNotEqual(google, github)
|
||||||
|
self.assertEqual("oidc:v1:google:shared", google)
|
||||||
|
self.assertEqual("oidc:v1:github:shared", github)
|
||||||
|
|
||||||
|
def test_subject_may_contain_colons(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"oidc:v1:google:tenant:alice",
|
||||||
|
canonical_principal("google", "tenant:alice"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_components_are_rejected(self) -> None:
|
||||||
|
for provider, sub in (("", "alice"), ("bad:provider", "alice"), ("google", "")):
|
||||||
|
with self.subTest(provider=provider, sub=sub):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
canonical_principal(provider, sub)
|
||||||
|
|
||||||
|
|
||||||
class DisplayNameTest(unittest.TestCase):
|
class DisplayNameTest(unittest.TestCase):
|
||||||
"""The claim priority used for the name shown at the table."""
|
"""The claim priority used for the name shown at the table."""
|
||||||
|
|
||||||
@@ -149,7 +181,9 @@ class CurrentUserTest(unittest.TestCase):
|
|||||||
_ctx({"oidc:google:user": {"sub": "alice"}}),
|
_ctx({"oidc:google:user": {"sub": "alice"}}),
|
||||||
)
|
)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
self.assertEqual("alice", user.sub)
|
self.assertEqual("alice", user.claims.sub)
|
||||||
|
self.assertEqual("google", user.provider)
|
||||||
|
self.assertEqual(canonical_principal("google", "alice"), user.principal)
|
||||||
|
|
||||||
def test_first_configured_provider_wins(self) -> None:
|
def test_first_configured_provider_wins(self) -> None:
|
||||||
# Single-identity sessions never hold two users; this only pins the
|
# Single-identity sessions never hold two users; this only pins the
|
||||||
@@ -164,7 +198,8 @@ class CurrentUserTest(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
self.assertEqual("alice", user.sub)
|
self.assertEqual("alice", user.claims.sub)
|
||||||
|
self.assertEqual(canonical_principal("default", "alice"), user.principal)
|
||||||
|
|
||||||
|
|
||||||
class RequireAuthTest(unittest.TestCase):
|
class RequireAuthTest(unittest.TestCase):
|
||||||
@@ -198,7 +233,7 @@ class RequireAuthTest(unittest.TestCase):
|
|||||||
async def handler(request_ctx: Any) -> None:
|
async def handler(request_ctx: Any) -> None:
|
||||||
user = current_user(oidc, request_ctx)
|
user = current_user(oidc, request_ctx)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
handled.append(user.sub)
|
handled.append(user.principal)
|
||||||
|
|
||||||
await handler(ctx)
|
await handler(ctx)
|
||||||
self.assertEqual(["alice"], handled)
|
self.assertEqual([canonical_principal("default", "alice")], handled)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import unittest
|
|||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from helpers import async_test, make_platform, oidc_user
|
from helpers import async_test, make_platform, make_principal, oidc_user
|
||||||
|
|
||||||
|
|
||||||
class GamesRouteTest(unittest.TestCase):
|
class GamesRouteTest(unittest.TestCase):
|
||||||
@@ -118,7 +118,7 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual("lobby", body["phase"])
|
self.assertEqual("lobby", body["phase"])
|
||||||
self.assertEqual(5, body["target"])
|
self.assertEqual(5, body["target"])
|
||||||
self.assertEqual(1, body["seats_open"])
|
self.assertEqual(1, body["seats_open"])
|
||||||
self.assertEqual("alice", body["players"][0]["sub"])
|
self.assertEqual(make_principal("alice"), body["players"][0]["sub"])
|
||||||
|
|
||||||
# Join codes are case-insensitive.
|
# Join codes are case-insensitive.
|
||||||
with oidc_user(platform.oidc, "mallory"):
|
with oidc_user(platform.oidc, "mallory"):
|
||||||
@@ -219,7 +219,7 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(1, entry["seats_open"])
|
self.assertEqual(1, entry["seats_open"])
|
||||||
self.assertEqual(0, entry["your_seat"])
|
self.assertEqual(0, entry["your_seat"])
|
||||||
self.assertNotIn("join_code", entry)
|
self.assertNotIn("join_code", entry)
|
||||||
self.assertEqual("alice", entry["players"][0]["sub"])
|
self.assertEqual(make_principal("alice"), entry["players"][0]["sub"])
|
||||||
|
|
||||||
# Bob's own game is the only one he sees; Alice's is not his.
|
# Bob's own game is the only one he sees; Alice's is not his.
|
||||||
with oidc_user(platform.oidc, "bob"):
|
with oidc_user(platform.oidc, "bob"):
|
||||||
@@ -246,6 +246,57 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
|
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
|
||||||
|
|
||||||
|
|
||||||
|
class CrossProviderGamesTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_same_sub_other_provider_does_not_inherit_player_access(self) -> None:
|
||||||
|
app, platform, _ = make_platform(oidc_providers=("default", "google"))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post("/api/games", json={})
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
game_id = created.json()["id"]
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
await client.post("/api/games/join", json={"code": code})
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice", provider="google"):
|
||||||
|
listed = await client.get("/api/me/games")
|
||||||
|
watched = await client.get(f"/api/games/{game_id}")
|
||||||
|
self.assertEqual([], listed.json()["results"])
|
||||||
|
self.assertTrue(watched.json()["spectator"])
|
||||||
|
self.assertFalse(watched.json()["viewer_seated"])
|
||||||
|
self.assertNotIn("join_code", watched.json())
|
||||||
|
|
||||||
|
# The original provider's identical subject remains seated.
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
owned = await client.get(f"/api/games/{game_id}")
|
||||||
|
self.assertFalse(owned.json()["spectator"])
|
||||||
|
self.assertTrue(owned.json()["viewer_seated"])
|
||||||
|
self.assertIn("join_code", owned.json())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_same_sub_at_two_providers_occupies_distinct_seats(self) -> None:
|
||||||
|
app, platform, _ = make_platform(oidc_providers=("default", "google"))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post("/api/games", json={})
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user(platform.oidc, "alice", provider="google"):
|
||||||
|
joined = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual(200, joined.status_code)
|
||||||
|
self.assertTrue(joined.json()["viewer_seated"])
|
||||||
|
session = await platform.game_store.load(created.json()["id"])
|
||||||
|
assert session is not None
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
make_principal("alice"),
|
||||||
|
make_principal("alice", "google"),
|
||||||
|
},
|
||||||
|
{seat.user_sub for seat in session.players},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GameTypesRouteTest(unittest.TestCase):
|
class GameTypesRouteTest(unittest.TestCase):
|
||||||
@async_test
|
@async_test
|
||||||
async def test_lists_available_game_types(self) -> None:
|
async def test_lists_available_game_types(self) -> None:
|
||||||
@@ -311,7 +362,7 @@ class MeRouteTest(unittest.TestCase):
|
|||||||
response = await client.get("/api/me")
|
response = await client.get("/api/me")
|
||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"sub": "alice", "name": "alice", "picture": None},
|
{"sub": make_principal("alice"), "name": "alice", "picture": None},
|
||||||
response.json(),
|
response.json(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
from tavolo.platform.elo import INITIAL_RATING
|
from tavolo.platform.elo import INITIAL_RATING
|
||||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||||
from tavolo.platform.stats import save_match_result
|
from tavolo.platform.stats import save_match_result
|
||||||
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
|
from helpers import DummyEngine, async_test, make_platform, make_principal, oidc_user, use_db
|
||||||
|
|
||||||
|
ALICE = make_principal("alice")
|
||||||
|
BOB = make_principal("bob")
|
||||||
|
|
||||||
|
|
||||||
def _finished_session(target: int = 2):
|
def _finished_session(target: int = 2):
|
||||||
@@ -24,12 +27,12 @@ def _finished_session(target: int = 2):
|
|||||||
id="stats-game",
|
id="stats-game",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="STATS1",
|
join_code="STATS1",
|
||||||
creator_sub="alice",
|
creator_sub=ALICE,
|
||||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
|
||||||
)
|
)
|
||||||
engine.create(session, {"target": target})
|
engine.create(session, {"target": target})
|
||||||
engine.join(session, "bob", "bob")
|
engine.join(session, BOB, "bob")
|
||||||
engine.handle_action(session, "alice", "play", {})
|
engine.handle_action(session, ALICE, "play", {})
|
||||||
return engine, session
|
return engine, session
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ class SaveMatchResultTest(unittest.TestCase):
|
|||||||
_, _, tortoise_mixin = make_platform()
|
_, _, tortoise_mixin = make_platform()
|
||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
engine, session = _finished_session()
|
engine, session = _finished_session()
|
||||||
engine.handle_action(session, "alice", "play", {})
|
engine.handle_action(session, ALICE, "play", {})
|
||||||
self.assertTrue(engine.is_finished(session))
|
self.assertTrue(engine.is_finished(session))
|
||||||
|
|
||||||
with ctx:
|
with ctx:
|
||||||
@@ -53,19 +56,19 @@ class SaveMatchResultTest(unittest.TestCase):
|
|||||||
# The game type travels from the session onto the row; the
|
# The game type travels from the session onto the row; the
|
||||||
# engine's summary is stored verbatim as the result.
|
# engine's summary is stored verbatim as the result.
|
||||||
self.assertEqual("dummy", match.game_type)
|
self.assertEqual("dummy", match.game_type)
|
||||||
self.assertEqual("alice", match.result["winner"])
|
self.assertEqual(ALICE, match.result["winner"])
|
||||||
self.assertEqual(2, match.result["plays"])
|
self.assertEqual(2, match.result["plays"])
|
||||||
winners = await MatchPlayer.filter(won=True)
|
winners = await MatchPlayer.filter(won=True)
|
||||||
self.assertEqual({"alice"}, {p.user_sub for p in winners})
|
self.assertEqual({ALICE}, {p.user_sub for p in winners})
|
||||||
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
|
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
|
||||||
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
|
self.assertEqual({ALICE: 2.0, BOB: 0.0}, scores)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_finished_match_updates_elo_ratings(self) -> None:
|
async def test_finished_match_updates_elo_ratings(self) -> None:
|
||||||
_, _, tortoise_mixin = make_platform()
|
_, _, tortoise_mixin = make_platform()
|
||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
engine, session = _finished_session()
|
engine, session = _finished_session()
|
||||||
engine.handle_action(session, "alice", "play", {})
|
engine.handle_action(session, ALICE, "play", {})
|
||||||
|
|
||||||
with ctx:
|
with ctx:
|
||||||
await save_match_result(session, engine)
|
await save_match_result(session, engine)
|
||||||
@@ -75,29 +78,59 @@ class SaveMatchResultTest(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
self.assertEqual(2, len(ratings))
|
self.assertEqual(2, len(ratings))
|
||||||
# Two players at 1500: winner gains K/2, loser loses it.
|
# Two players at 1500: winner gains K/2, loser loses it.
|
||||||
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
|
self.assertEqual(INITIAL_RATING + 16, ratings[ALICE].rating)
|
||||||
self.assertEqual(1, ratings["alice"].matches_played)
|
self.assertEqual(1, ratings[ALICE].matches_played)
|
||||||
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
self.assertEqual(INITIAL_RATING - 16, ratings[BOB].rating)
|
||||||
self.assertEqual(1, ratings["bob"].matches_played)
|
self.assertEqual(1, ratings[BOB].matches_played)
|
||||||
# The leaderboard aggregates ride along in the same transaction.
|
# The leaderboard aggregates ride along in the same transaction.
|
||||||
self.assertEqual(1, ratings["alice"].wins)
|
self.assertEqual(1, ratings[ALICE].wins)
|
||||||
self.assertEqual(0, ratings["bob"].wins)
|
self.assertEqual(0, ratings[BOB].wins)
|
||||||
self.assertEqual(2.0, ratings["alice"].points)
|
self.assertEqual(2.0, ratings[ALICE].points)
|
||||||
self.assertEqual(0.0, ratings["bob"].points)
|
self.assertEqual(0.0, ratings[BOB].points)
|
||||||
self.assertEqual("alice", ratings["alice"].display_name)
|
self.assertEqual("alice", ratings[ALICE].display_name)
|
||||||
|
|
||||||
# The per-match delta is recorded on each participation row.
|
# The per-match delta is recorded on each participation row.
|
||||||
deltas = {
|
deltas = {
|
||||||
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
|
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
|
||||||
}
|
}
|
||||||
self.assertEqual({"alice": 16, "bob": -16}, deltas)
|
self.assertEqual({ALICE: 16, BOB: -16}, deltas)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_same_sub_at_two_providers_has_independent_stats(self) -> None:
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
|
||||||
|
_, _, tortoise_mixin = make_platform()
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
engine = DummyEngine()
|
||||||
|
google_alice = make_principal("alice", "google")
|
||||||
|
session = GameSession(
|
||||||
|
id="cross-provider-stats",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="XPROV1",
|
||||||
|
creator_sub=ALICE,
|
||||||
|
players=[Seat(user_sub=ALICE, display_name="Alice", team="A")],
|
||||||
|
)
|
||||||
|
engine.create(session, {"target": 1})
|
||||||
|
engine.join(session, google_alice, "Alice")
|
||||||
|
engine.handle_action(session, ALICE, "play", {})
|
||||||
|
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
self.assertEqual(
|
||||||
|
{ALICE, google_alice},
|
||||||
|
{row.user_sub for row in await MatchPlayer.all()},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{ALICE, google_alice},
|
||||||
|
{row.user_sub for row in await PlayerRating.all()},
|
||||||
|
)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_elo_ratings_accumulate_across_matches(self) -> None:
|
async def test_elo_ratings_accumulate_across_matches(self) -> None:
|
||||||
_, _, tortoise_mixin = make_platform()
|
_, _, tortoise_mixin = make_platform()
|
||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
engine, session = _finished_session()
|
engine, session = _finished_session()
|
||||||
engine.handle_action(session, "alice", "play", {})
|
engine.handle_action(session, ALICE, "play", {})
|
||||||
|
|
||||||
from tavolo.platform import GameSession, Seat
|
from tavolo.platform import GameSession, Seat
|
||||||
|
|
||||||
@@ -105,14 +138,14 @@ class SaveMatchResultTest(unittest.TestCase):
|
|||||||
id="stats-game-2",
|
id="stats-game-2",
|
||||||
game_type="dummy",
|
game_type="dummy",
|
||||||
join_code="STATS2",
|
join_code="STATS2",
|
||||||
creator_sub="alice",
|
creator_sub=ALICE,
|
||||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
|
||||||
)
|
)
|
||||||
engine2.create(session2, {"target": 2})
|
engine2.create(session2, {"target": 2})
|
||||||
engine2.join(session2, "bob", "bob")
|
engine2.join(session2, BOB, "bob")
|
||||||
# Bob wins the second match.
|
# Bob wins the second match.
|
||||||
engine2.handle_action(session2, "bob", "play", {})
|
engine2.handle_action(session2, BOB, "play", {})
|
||||||
engine2.handle_action(session2, "bob", "play", {})
|
engine2.handle_action(session2, BOB, "play", {})
|
||||||
|
|
||||||
with ctx:
|
with ctx:
|
||||||
await save_match_result(session, engine)
|
await save_match_result(session, engine)
|
||||||
@@ -123,9 +156,9 @@ class SaveMatchResultTest(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
|
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
|
||||||
# is now the favourite (1516 vs 1484), so losing costs 17.
|
# is now the favourite (1516 vs 1484), so losing costs 17.
|
||||||
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
|
self.assertEqual(INITIAL_RATING - 1, ratings[ALICE])
|
||||||
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
|
self.assertEqual(INITIAL_RATING + 1, ratings[BOB])
|
||||||
bob = await PlayerRating.get(user_sub="bob")
|
bob = await PlayerRating.get(user_sub=BOB)
|
||||||
self.assertEqual(2, bob.matches_played)
|
self.assertEqual(2, bob.matches_played)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
@@ -160,11 +193,12 @@ async def _seed_two_matches(
|
|||||||
result={"winner": winner, "plays": 3 + index},
|
result={"winner": winner, "plays": 3 + index},
|
||||||
)
|
)
|
||||||
for seat, sub in enumerate(("alice", "bob")):
|
for seat, sub in enumerate(("alice", "bob")):
|
||||||
|
principal = make_principal(sub)
|
||||||
score = 2.0 + index if sub == winner else 1.0
|
score = 2.0 + index if sub == winner else 1.0
|
||||||
await MatchPlayer.create(
|
await MatchPlayer.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
match=match,
|
match=match,
|
||||||
user_sub=sub,
|
user_sub=principal,
|
||||||
display_name=sub,
|
display_name=sub,
|
||||||
seat=seat,
|
seat=seat,
|
||||||
team="A" if seat == 0 else "B",
|
team="A" if seat == 0 else "B",
|
||||||
@@ -172,22 +206,22 @@ async def _seed_two_matches(
|
|||||||
score=score,
|
score=score,
|
||||||
)
|
)
|
||||||
aggregate = aggregates.setdefault(
|
aggregate = aggregates.setdefault(
|
||||||
(sub, game_types[index]),
|
(principal, game_types[index]),
|
||||||
{"matches": 0, "wins": 0, "points": 0.0},
|
{"matches": 0, "wins": 0, "points": 0.0, "display_name": sub},
|
||||||
)
|
)
|
||||||
aggregate["matches"] += 1
|
aggregate["matches"] += 1
|
||||||
aggregate["wins"] += 1 if sub == winner else 0
|
aggregate["wins"] += 1 if sub == winner else 0
|
||||||
aggregate["points"] += score
|
aggregate["points"] += score
|
||||||
for (sub, game_type), aggregate in aggregates.items():
|
for (principal, game_type), aggregate in aggregates.items():
|
||||||
await PlayerRating.create(
|
await PlayerRating.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
user_sub=sub,
|
user_sub=principal,
|
||||||
game_type=game_type,
|
game_type=game_type,
|
||||||
rating=INITIAL_RATING,
|
rating=INITIAL_RATING,
|
||||||
matches_played=aggregate["matches"],
|
matches_played=aggregate["matches"],
|
||||||
wins=aggregate["wins"],
|
wins=aggregate["wins"],
|
||||||
points=aggregate["points"],
|
points=aggregate["points"],
|
||||||
display_name=sub,
|
display_name=aggregate["display_name"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -266,13 +300,13 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
response = await client.get("/api/leaderboard")
|
response = await client.get("/api/leaderboard")
|
||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
|
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
|
||||||
self.assertEqual(2, by_sub["alice"]["matches"])
|
self.assertEqual(2, by_sub[ALICE]["matches"])
|
||||||
self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1
|
self.assertEqual(1, by_sub[ALICE]["wins"]) # alice won match 1
|
||||||
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
|
self.assertEqual(3.0, by_sub[ALICE]["points"]) # 2.0 + 1.0
|
||||||
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
|
self.assertEqual(1, by_sub[BOB]["wins"]) # bob won match 2
|
||||||
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
|
self.assertEqual(4.0, by_sub[BOB]["points"]) # 1.0 + 3.0
|
||||||
# Bob leads on points after tying Alice on wins.
|
# Bob leads on points after tying Alice on wins.
|
||||||
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
|
self.assertEqual(BOB, response.json()["results"][0]["user_sub"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
|
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
|
||||||
@@ -282,7 +316,7 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
with ctx:
|
with ctx:
|
||||||
# Alice outranks everyone despite Bob leading on points.
|
# Alice outranks everyone despite Bob leading on points.
|
||||||
await PlayerRating.filter(
|
await PlayerRating.filter(
|
||||||
user_sub="alice", game_type="dummy"
|
user_sub=ALICE, game_type="dummy"
|
||||||
).update(rating=1600)
|
).update(rating=1600)
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
@@ -290,10 +324,10 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
results = response.json()["results"]
|
results = response.json()["results"]
|
||||||
by_sub = {row["user_sub"]: row for row in results}
|
by_sub = {row["user_sub"]: row for row in results}
|
||||||
self.assertEqual(1600, by_sub["alice"]["elo"])
|
self.assertEqual(1600, by_sub[ALICE]["elo"])
|
||||||
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
self.assertEqual(INITIAL_RATING, by_sub[BOB]["elo"])
|
||||||
# Elo outranks wins/points.
|
# Elo outranks wins/points.
|
||||||
self.assertEqual("alice", results[0]["user_sub"])
|
self.assertEqual(ALICE, results[0]["user_sub"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
|
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
|
||||||
@@ -304,13 +338,13 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
with ctx:
|
with ctx:
|
||||||
await PlayerRating.filter(
|
await PlayerRating.filter(
|
||||||
user_sub="bob", game_type="dummy"
|
user_sub=BOB, game_type="dummy"
|
||||||
).update(rating=1516)
|
).update(rating=1516)
|
||||||
# Bob's rating in another game must not leak into the
|
# Bob's rating in another game must not leak into the
|
||||||
# dummy leaderboard.
|
# dummy leaderboard.
|
||||||
await PlayerRating.create(
|
await PlayerRating.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
user_sub="bob",
|
user_sub=BOB,
|
||||||
game_type="second",
|
game_type="second",
|
||||||
rating=1800,
|
rating=1800,
|
||||||
matches_played=1,
|
matches_played=1,
|
||||||
@@ -321,8 +355,8 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
results = response.json()["results"]
|
results = response.json()["results"]
|
||||||
by_sub = {row["user_sub"]: row for row in results}
|
by_sub = {row["user_sub"]: row for row in results}
|
||||||
self.assertEqual(1516, by_sub["bob"]["elo"])
|
self.assertEqual(1516, by_sub[BOB]["elo"])
|
||||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
self.assertEqual(INITIAL_RATING, by_sub[ALICE]["elo"])
|
||||||
self.assertEqual("second", platform.registry.all()[1].id)
|
self.assertEqual("second", platform.registry.all()[1].id)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
@@ -334,7 +368,7 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
|
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
|
||||||
await PlayerRating.create(
|
await PlayerRating.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
user_sub=sub,
|
user_sub=make_principal(sub),
|
||||||
game_type="dummy",
|
game_type="dummy",
|
||||||
rating=1600 + index,
|
rating=1600 + index,
|
||||||
matches_played=1,
|
matches_played=1,
|
||||||
@@ -354,20 +388,21 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(6, body["total"])
|
self.assertEqual(6, body["total"])
|
||||||
self.assertEqual(2, len(body["results"]))
|
self.assertEqual(2, len(body["results"]))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["frank", "erin"], [row["user_sub"] for row in body["results"]]
|
[make_principal("frank"), make_principal("erin")],
|
||||||
|
[row["user_sub"] for row in body["results"]],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["dave", "carol"],
|
[make_principal("dave"), make_principal("carol")],
|
||||||
[row["user_sub"] for row in second.json()["results"]],
|
[row["user_sub"] for row in second.json()["results"]],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["bob", "alice"],
|
[BOB, ALICE],
|
||||||
[row["user_sub"] for row in third.json()["results"]],
|
[row["user_sub"] for row in third.json()["results"]],
|
||||||
)
|
)
|
||||||
# Out-of-range pages clamp to the last page.
|
# Out-of-range pages clamp to the last page.
|
||||||
self.assertEqual(3, beyond.json()["page"])
|
self.assertEqual(3, beyond.json()["page"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["bob", "alice"],
|
[BOB, ALICE],
|
||||||
[row["user_sub"] for row in beyond.json()["results"]],
|
[row["user_sub"] for row in beyond.json()["results"]],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -380,7 +415,7 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
for index, sub in enumerate(("carol", "dave")):
|
for index, sub in enumerate(("carol", "dave")):
|
||||||
await PlayerRating.create(
|
await PlayerRating.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
user_sub=sub,
|
user_sub=make_principal(sub),
|
||||||
game_type="dummy",
|
game_type="dummy",
|
||||||
rating=1600 + index,
|
rating=1600 + index,
|
||||||
matches_played=1,
|
matches_played=1,
|
||||||
@@ -398,7 +433,7 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(4, body["page"])
|
self.assertEqual(4, body["page"])
|
||||||
self.assertEqual(4, body["your_rank"])
|
self.assertEqual(4, body["your_rank"])
|
||||||
self.assertEqual(4, body["your_page"])
|
self.assertEqual(4, body["your_page"])
|
||||||
self.assertEqual("alice", body["results"][0]["user_sub"])
|
self.assertEqual(ALICE, body["results"][0]["user_sub"])
|
||||||
anonymous_body = anonymous.json()
|
anonymous_body = anonymous.json()
|
||||||
self.assertEqual(1, anonymous_body["page"])
|
self.assertEqual(1, anonymous_body["page"])
|
||||||
self.assertIsNone(anonymous_body["your_rank"])
|
self.assertIsNone(anonymous_body["your_rank"])
|
||||||
@@ -421,7 +456,7 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
app, platform, tortoise_mixin = make_platform()
|
app, platform, tortoise_mixin = make_platform()
|
||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
engine, session = _finished_session()
|
engine, session = _finished_session()
|
||||||
engine.handle_action(session, "alice", "play", {})
|
engine.handle_action(session, ALICE, "play", {})
|
||||||
with ctx:
|
with ctx:
|
||||||
await save_match_result(session, engine)
|
await save_match_result(session, engine)
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
@@ -433,10 +468,37 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
p["user_sub"]: p
|
p["user_sub"]: p
|
||||||
for p in response.json()["results"][0]["players"]
|
for p in response.json()["results"][0]["players"]
|
||||||
}
|
}
|
||||||
self.assertEqual(16, players["alice"]["elo_delta"])
|
self.assertEqual(16, players[ALICE]["elo_delta"])
|
||||||
self.assertEqual(-16, players["bob"]["elo_delta"])
|
self.assertEqual(-16, players[BOB]["elo_delta"])
|
||||||
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
|
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_same_sub_other_provider_cannot_read_history_or_ratings(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform(
|
||||||
|
oidc_providers=("default", "google")
|
||||||
|
)
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
engine, session = _finished_session()
|
||||||
|
engine.handle_action(session, ALICE, "play", {})
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice", provider="google"):
|
||||||
|
matches = await client.get("/api/me/matches")
|
||||||
|
ratings = await client.get("/api/me/ratings")
|
||||||
|
board = await client.get("/api/leaderboard?focus=me")
|
||||||
|
self.assertEqual([], matches.json()["results"])
|
||||||
|
self.assertEqual([], ratings.json()["results"])
|
||||||
|
self.assertIsNone(board.json()["your_rank"])
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
owned_matches = await client.get("/api/me/matches")
|
||||||
|
owned_ratings = await client.get("/api/me/ratings")
|
||||||
|
self.assertEqual(1, len(owned_matches.json()["results"]))
|
||||||
|
self.assertEqual(1, len(owned_ratings.json()["results"]))
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_my_ratings_requires_auth(self) -> None:
|
async def test_my_ratings_requires_auth(self) -> None:
|
||||||
app, _, _ = make_platform()
|
app, _, _ = make_platform()
|
||||||
@@ -452,14 +514,14 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
with ctx:
|
with ctx:
|
||||||
await PlayerRating.create(
|
await PlayerRating.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
user_sub="alice",
|
user_sub=ALICE,
|
||||||
game_type="dummy",
|
game_type="dummy",
|
||||||
rating=1516,
|
rating=1516,
|
||||||
matches_played=1,
|
matches_played=1,
|
||||||
)
|
)
|
||||||
await PlayerRating.create(
|
await PlayerRating.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
user_sub="bob",
|
user_sub=BOB,
|
||||||
game_type="dummy",
|
game_type="dummy",
|
||||||
rating=1484,
|
rating=1484,
|
||||||
matches_played=1,
|
matches_played=1,
|
||||||
@@ -516,9 +578,9 @@ class GameTypeFilterTest(unittest.TestCase):
|
|||||||
self.assertEqual(200, scoped.status_code)
|
self.assertEqual(200, scoped.status_code)
|
||||||
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
|
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
|
||||||
# Only the first match counts: one match per player, alice won.
|
# Only the first match counts: one match per player, alice won.
|
||||||
self.assertEqual(1, by_sub["alice"]["matches"])
|
self.assertEqual(1, by_sub[ALICE]["matches"])
|
||||||
self.assertEqual(1, by_sub["alice"]["wins"])
|
self.assertEqual(1, by_sub[ALICE]["wins"])
|
||||||
self.assertEqual(0, by_sub["bob"]["wins"])
|
self.assertEqual(0, by_sub[BOB]["wins"])
|
||||||
self.assertEqual(400, unknown.status_code)
|
self.assertEqual(400, unknown.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from httpx_ws import WebSocketDisconnect as HttpxWebSocketDisconnect, aconnect_w
|
|||||||
from httpx_ws.transport import ASGIWebSocketTransport
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
|
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
|
||||||
|
|
||||||
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
from helpers import async_test, make_identity, make_platform, make_user, oidc_user, ws_users
|
||||||
|
|
||||||
|
|
||||||
class AbruptCloseSocket(WebSocket):
|
class AbruptCloseSocket(WebSocket):
|
||||||
@@ -288,6 +288,27 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual("error", error["type"])
|
self.assertEqual("error", error["type"])
|
||||||
self.assertEqual("forbidden", error["code"])
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_same_sub_other_provider_is_a_spectator(self) -> None:
|
||||||
|
app, platform, _ = make_platform(oidc_providers=("default", "google"))
|
||||||
|
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([make_identity("alice", provider="google")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertFalse(first["game"]["viewer_seated"])
|
||||||
|
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"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
||||||
app, platform, _ = make_platform()
|
app, platform, _ = make_platform()
|
||||||
|
|||||||
@@ -224,6 +224,8 @@ class _ScopaBase(GameEngine):
|
|||||||
)
|
)
|
||||||
elif kind == "hand_end":
|
elif kind == "hand_end":
|
||||||
for player in state.players:
|
for player in state.players:
|
||||||
|
if state.phase != PHASE_HAND_END:
|
||||||
|
break
|
||||||
engine.acknowledge_hand(state, player.sub)
|
engine.acknowledge_hand(state, player.sub)
|
||||||
log.info(
|
log.info(
|
||||||
"hand %d auto-advanced after the acknowledgement timeout",
|
"hand %d auto-advanced after the acknowledgement timeout",
|
||||||
|
|||||||
@@ -234,6 +234,8 @@ class ScoponeEngine(GameEngine):
|
|||||||
)
|
)
|
||||||
elif kind == "hand_end":
|
elif kind == "hand_end":
|
||||||
for player in state.players:
|
for player in state.players:
|
||||||
|
if state.phase != PHASE_HAND_END:
|
||||||
|
break
|
||||||
engine.acknowledge_hand(state, player.sub)
|
engine.acknowledge_hand(state, player.sub)
|
||||||
log.info(
|
log.info(
|
||||||
"hand %d auto-advanced after the acknowledgement timeout",
|
"hand %d auto-advanced after the acknowledgement timeout",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Test helpers package for the tavolo-app integration suite."""
|
"""Test helpers package for the tavolo-app integration suite."""
|
||||||
from .asynctest import async_test
|
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.
|
# ``tavolo.platform.auth`` themselves.
|
||||||
from tavolo.app import oidc
|
from tavolo.app import oidc
|
||||||
from tavolo.platform import auth
|
from tavolo.platform import auth
|
||||||
|
from tavolo.platform.auth import AuthenticatedUser, canonical_principal
|
||||||
|
|
||||||
# The app's first configured provider (the test environment configures one
|
# The app's first configured provider (the test environment configures one
|
||||||
# named "default").
|
# named "default").
|
||||||
@@ -37,12 +38,17 @@ def make_user(
|
|||||||
return OIDCUser(claims)
|
return OIDCUser(claims)
|
||||||
|
|
||||||
|
|
||||||
|
def make_principal(sub: str, provider: str = _PROVIDER) -> str:
|
||||||
|
return canonical_principal(provider, sub)
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def oidc_user(
|
def oidc_user(
|
||||||
sub: str,
|
sub: str,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
email: Optional[str] = None,
|
email: Optional[str] = None,
|
||||||
picture: Optional[str] = None,
|
picture: Optional[str] = None,
|
||||||
|
provider: Optional[str] = None,
|
||||||
) -> Iterator[OIDCUser]:
|
) -> Iterator[OIDCUser]:
|
||||||
"""Context manager: make ``oidc.get_users`` return this user.
|
"""Context manager: make ``oidc.get_users`` return this user.
|
||||||
|
|
||||||
@@ -52,7 +58,8 @@ def oidc_user(
|
|||||||
scope's avatar claim.
|
scope's avatar claim.
|
||||||
"""
|
"""
|
||||||
user = make_user(sub, name, email, picture)
|
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()
|
patcher.start()
|
||||||
try:
|
try:
|
||||||
yield user
|
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``
|
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
|
||||||
one per websocket connection, in order. Once exhausted it keeps
|
one per websocket connection, in order. Once exhausted it keeps
|
||||||
returning the last user."""
|
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
|
last = remaining[-1] if remaining else None
|
||||||
|
|
||||||
def _next(_ws):
|
def _next(_ws):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from tavolo.briscola.state import (
|
|||||||
PlayerState,
|
PlayerState,
|
||||||
)
|
)
|
||||||
from tavolo.platform import GameSession, Seat
|
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):
|
class BriscolaRoutesTest(unittest.TestCase):
|
||||||
@@ -235,14 +235,14 @@ class BriscolaTurnTimeoutTest(unittest.TestCase):
|
|||||||
id="briscola-turn-timeout-1",
|
id="briscola-turn-timeout-1",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="BT0001",
|
join_code="BT0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[
|
players=[
|
||||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
engine.create(session, {})
|
engine.create(session, {})
|
||||||
session.state.turn_timeout = 1
|
session.state.turn_timeout = 1
|
||||||
engine.join(session, "bob", "Bob")
|
engine.join(session, make_principal("bob"), "Bob")
|
||||||
await game_store.save(session)
|
await game_store.save(session)
|
||||||
|
|
||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
@@ -283,10 +283,10 @@ async def _seed_hand_end_state() -> str:
|
|||||||
id="briscola-hand-end-1",
|
id="briscola-hand-end-1",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="BH0001",
|
join_code="BH0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[
|
players=[
|
||||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
session.state = BriscolaState(
|
session.state = BriscolaState(
|
||||||
@@ -298,11 +298,11 @@ async def _seed_hand_end_state() -> str:
|
|||||||
briscola_suit="S",
|
briscola_suit="S",
|
||||||
players=[
|
players=[
|
||||||
PlayerState(
|
PlayerState(
|
||||||
sub="alice", name="Alice", seat=0,
|
sub=make_principal("alice"), name="Alice", seat=0,
|
||||||
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
|
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
|
||||||
),
|
),
|
||||||
PlayerState(
|
PlayerState(
|
||||||
sub="bob", name="Bob", seat=1,
|
sub=make_principal("bob"), name="Bob", seat=1,
|
||||||
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
|
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from tavolo.briscola.chiamata import (
|
|||||||
Bid,
|
Bid,
|
||||||
)
|
)
|
||||||
from tavolo.platform import GameSession, Seat
|
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")
|
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ class ChiamataWebSocketTest(unittest.TestCase):
|
|||||||
session = await game_store.load(game_id)
|
session = await game_store.load(game_id)
|
||||||
assert session is not None
|
assert session is not None
|
||||||
for sub in ("carol", "dave", "erin", "alice"):
|
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)
|
await game_store.save(session)
|
||||||
self.assertEqual(PHASE_CALLING, session.state.phase)
|
self.assertEqual(PHASE_CALLING, session.state.phase)
|
||||||
self.assertEqual(1, session.state.caller)
|
self.assertEqual(1, session.state.caller)
|
||||||
@@ -227,15 +227,17 @@ class ChiamataTimeoutTest(unittest.TestCase):
|
|||||||
id=game_id,
|
id=game_id,
|
||||||
game_type="briscola_chiamata",
|
game_type="briscola_chiamata",
|
||||||
join_code=join_code,
|
join_code=join_code,
|
||||||
creator_sub=PLAYERS[0],
|
creator_sub=make_principal(PLAYERS[0]),
|
||||||
players=[
|
players=[
|
||||||
Seat(user_sub=name, display_name=name.capitalize())
|
Seat(user_sub=make_principal(name), display_name=name.capitalize())
|
||||||
for name in PLAYERS
|
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:]:
|
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)
|
state_mutator(session.state)
|
||||||
await game_store.save(session)
|
await game_store.save(session)
|
||||||
return session.id
|
return session.id
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Deadline-queue timeout tests (scopone game, full platform stack).
|
"""Deadline-queue timeout tests (full platform stack).
|
||||||
|
|
||||||
Timeouts must be driven by the persisted deadlines and the shared queue,
|
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||||
not by connected sockets: these tests seed sessions, queue their
|
not by connected sockets: these tests seed sessions, queue their
|
||||||
@@ -10,12 +10,15 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from itertools import permutations
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from tavolo.app import game_store, platform, scheduler, tortoise_mixin
|
from tavolo.app import game_store, platform, scheduler, tortoise_mixin
|
||||||
from tavolo.platform import GameSession, Seat
|
from tavolo.platform import GameSession, Seat
|
||||||
from tavolo.platform.deadlines import encode
|
from tavolo.platform.deadlines import DeadlineScheduler, encode
|
||||||
from tavolo.platform.models import Match
|
from tavolo.platform.models import Match
|
||||||
|
from tavolo.platform.registry import GameRegistry
|
||||||
|
from tavolo.platform.store import InMemoryGameStore
|
||||||
from tavolo.scopone.state import Card, PlayerState, ScoponeState
|
from tavolo.scopone.state import Card, PlayerState, ScoponeState
|
||||||
from tests.helpers import async_test
|
from tests.helpers import async_test
|
||||||
|
|
||||||
@@ -27,8 +30,9 @@ def _started_session(
|
|||||||
code: str,
|
code: str,
|
||||||
turn_timeout: int = 3600,
|
turn_timeout: int = 3600,
|
||||||
hand_ack_timeout: int = 3600,
|
hand_ack_timeout: int = 3600,
|
||||||
|
game_type: str = "scopone_scientifico",
|
||||||
) -> GameSession:
|
) -> GameSession:
|
||||||
engine = platform.registry.require("scopone_scientifico")
|
engine = platform.registry.require(game_type)
|
||||||
session = GameSession(
|
session = GameSession(
|
||||||
id=game_id,
|
id=game_id,
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
@@ -39,7 +43,7 @@ def _started_session(
|
|||||||
engine.create(session, {})
|
engine.create(session, {})
|
||||||
session.state.hand_ack_timeout = hand_ack_timeout
|
session.state.hand_ack_timeout = hand_ack_timeout
|
||||||
session.state.turn_timeout = turn_timeout
|
session.state.turn_timeout = turn_timeout
|
||||||
for name in PLAYERS[1:]:
|
for name in PLAYERS[1:engine.max_players]:
|
||||||
engine.join(session, name, name.capitalize())
|
engine.join(session, name, name.capitalize())
|
||||||
return session
|
return session
|
||||||
|
|
||||||
@@ -171,6 +175,85 @@ async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]:
|
|||||||
return session if session is not None and session.state.phase == phase else None
|
return session if session is not None and session.state.phase == phase else None
|
||||||
|
|
||||||
|
|
||||||
|
class PartialHandEndAckTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_every_partial_ack_ordering_advances_persisted_game(self) -> None:
|
||||||
|
# Use an isolated store so the exhaustive matrix cannot affect other
|
||||||
|
# integration tests through the app's ongoing-game list or queue.
|
||||||
|
registry = GameRegistry(platform.registry.all())
|
||||||
|
store = InMemoryGameStore(registry)
|
||||||
|
local_scheduler = DeadlineScheduler(store, registry, heartbeat_ms=60_000)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
case_count = 0
|
||||||
|
try:
|
||||||
|
for game_type in (
|
||||||
|
"scopa",
|
||||||
|
"scopa_a_quattro",
|
||||||
|
"scopone_scientifico",
|
||||||
|
"briscola",
|
||||||
|
"briscola_a_quattro",
|
||||||
|
):
|
||||||
|
engine = registry.require(game_type)
|
||||||
|
for ack_count in range(1, engine.max_players):
|
||||||
|
for ack_order in permutations(
|
||||||
|
range(engine.max_players), ack_count
|
||||||
|
):
|
||||||
|
case_count += 1
|
||||||
|
with self.subTest(game_type=game_type, ack_order=ack_order):
|
||||||
|
session = _started_session(
|
||||||
|
f"dl-partial-{case_count}",
|
||||||
|
f"DLP{case_count:03d}",
|
||||||
|
game_type=game_type,
|
||||||
|
)
|
||||||
|
state = session.state
|
||||||
|
state.phase = "hand_end"
|
||||||
|
state.turn_deadline = None
|
||||||
|
state.hand_end_deadline = (
|
||||||
|
datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||||
|
).isoformat()
|
||||||
|
for seat in ack_order:
|
||||||
|
engine.handle_action(
|
||||||
|
session, state.players[seat].sub, "ack", {}
|
||||||
|
)
|
||||||
|
self.assertEqual(list(ack_order), state.acked)
|
||||||
|
|
||||||
|
await store.save(session)
|
||||||
|
current = engine.next_deadline(session)
|
||||||
|
assert current is not None
|
||||||
|
member = encode({
|
||||||
|
"game_id": session.id,
|
||||||
|
"kind": current.kind,
|
||||||
|
"token": current.token,
|
||||||
|
})
|
||||||
|
# Avoid racing the background consumer with this
|
||||||
|
# direct, deterministic process_due call.
|
||||||
|
await store.add_deadline(
|
||||||
|
member,
|
||||||
|
due_at=datetime.now(timezone.utc).timestamp() + 3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
await local_scheduler.process_due(member)
|
||||||
|
|
||||||
|
result = await store.load(session.id)
|
||||||
|
assert result is not None
|
||||||
|
self.assertEqual("playing", result.state.phase)
|
||||||
|
self.assertEqual(2, result.state.hand_number)
|
||||||
|
self.assertEqual([], result.state.acked)
|
||||||
|
self.assertIsNone(result.state.hand_end_deadline)
|
||||||
|
following = engine.next_deadline(result)
|
||||||
|
assert following is not None
|
||||||
|
self.assertEqual("turn", following.kind)
|
||||||
|
self.assertNotIn(
|
||||||
|
member,
|
||||||
|
await store.due_deadlines(
|
||||||
|
float("inf"), limit=1000
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
local_scheduler.stop_consumer(loop)
|
||||||
|
self.assertEqual(124, case_count)
|
||||||
|
|
||||||
|
|
||||||
class ProcessDueTest(unittest.TestCase):
|
class ProcessDueTest(unittest.TestCase):
|
||||||
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import unittest
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from tavolo.app import app
|
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):
|
class GamesRouteTest(unittest.TestCase):
|
||||||
@@ -37,7 +37,7 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
with oidc_user("alice"):
|
with oidc_user("alice"):
|
||||||
snapshot = await client.get(f"/api/games/{game_id}")
|
snapshot = await client.get(f"/api/games/{game_id}")
|
||||||
self.assertEqual(200, snapshot.status_code)
|
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
|
# A logged-in non-player is a read-only spectator: no join
|
||||||
# code, and the view is marked as a spectator view.
|
# 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["captured_count"])
|
||||||
self.assertEqual(0, participant["scope"])
|
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_test
|
||||||
async def test_create_napola_option(self) -> None:
|
async def test_create_napola_option(self) -> None:
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
@@ -180,7 +206,7 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertFalse(body["napola"])
|
self.assertFalse(body["napola"])
|
||||||
self.assertTrue(body["asso_pigliatutto"])
|
self.assertTrue(body["asso_pigliatutto"])
|
||||||
self.assertEqual(3, body["seats_open"])
|
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"):
|
for player in ("bob", "carol", "dave"):
|
||||||
with oidc_user(player):
|
with oidc_user(player):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
|
|
||||||
from tavolo.app import app
|
from tavolo.app import app
|
||||||
from tavolo.config import settings
|
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):
|
class MeRouteTest(unittest.TestCase):
|
||||||
@@ -25,7 +25,7 @@ class MeRouteTest(unittest.TestCase):
|
|||||||
# The email claim the IdP returns is neither stored nor exposed;
|
# The email claim the IdP returns is neither stored nor exposed;
|
||||||
# no picture claim in this request.
|
# no picture claim in this request.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"sub": "alice", "name": "alice", "picture": None},
|
{"sub": make_principal("alice"), "name": "alice", "picture": None},
|
||||||
response.json(),
|
response.json(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -23,7 +23,7 @@ from tavolo.scopa.state import (
|
|||||||
ScopaState,
|
ScopaState,
|
||||||
)
|
)
|
||||||
from tavolo.platform import GameSession, Seat
|
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):
|
class ScopaRoutesTest(unittest.TestCase):
|
||||||
@@ -249,11 +249,11 @@ class ScopaWebSocketTest(unittest.TestCase):
|
|||||||
turn=0,
|
turn=0,
|
||||||
players=[
|
players=[
|
||||||
PlayerState(
|
PlayerState(
|
||||||
sub="alice", name="Alice", seat=0,
|
sub=make_principal("alice"), name="Alice", seat=0,
|
||||||
hand=[Card.parse("01D"), Card.parse("09C")],
|
hand=[Card.parse("01D"), Card.parse("09C")],
|
||||||
),
|
),
|
||||||
PlayerState(
|
PlayerState(
|
||||||
sub="bob", name="Bob", seat=1,
|
sub=make_principal("bob"), name="Bob", seat=1,
|
||||||
hand=[Card.parse("04D")],
|
hand=[Card.parse("04D")],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -264,10 +264,10 @@ class ScopaWebSocketTest(unittest.TestCase):
|
|||||||
id="scopa-asso-pigliatutto-1",
|
id="scopa-asso-pigliatutto-1",
|
||||||
game_type="scopa",
|
game_type="scopa",
|
||||||
join_code="SA0001",
|
join_code="SA0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[
|
players=[
|
||||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
session.state = state
|
session.state = state
|
||||||
@@ -304,10 +304,10 @@ class ScopaWebSocketTest(unittest.TestCase):
|
|||||||
turn=0,
|
turn=0,
|
||||||
players=[
|
players=[
|
||||||
PlayerState(
|
PlayerState(
|
||||||
sub="alice", name="Alice", seat=0,
|
sub=make_principal("alice"), name="Alice", seat=0,
|
||||||
hand=[Card.parse("02D")],
|
hand=[Card.parse("02D")],
|
||||||
),
|
),
|
||||||
PlayerState(sub="bob", name="Bob", seat=1),
|
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
|
||||||
],
|
],
|
||||||
table=[Card.parse("02C")],
|
table=[Card.parse("02C")],
|
||||||
)
|
)
|
||||||
@@ -315,10 +315,10 @@ class ScopaWebSocketTest(unittest.TestCase):
|
|||||||
id="scopa-hand-end-1",
|
id="scopa-hand-end-1",
|
||||||
game_type="scopa",
|
game_type="scopa",
|
||||||
join_code="SH0001",
|
join_code="SH0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[
|
players=[
|
||||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
session.state = state
|
session.state = state
|
||||||
@@ -374,14 +374,14 @@ class ScopaTurnTimeoutTest(unittest.TestCase):
|
|||||||
id="scopa-turn-timeout-1",
|
id="scopa-turn-timeout-1",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="ST0001",
|
join_code="ST0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[
|
players=[
|
||||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
engine.create(session, {})
|
engine.create(session, {})
|
||||||
session.state.turn_timeout = 1
|
session.state.turn_timeout = 1
|
||||||
engine.join(session, "bob", "Bob")
|
engine.join(session, make_principal("bob"), "Bob")
|
||||||
await game_store.save(session)
|
await game_store.save(session)
|
||||||
|
|
||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
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.platform.stats import save_match_result
|
||||||
from tavolo.scopa import engine as scopa_rules
|
from tavolo.scopa import engine as scopa_rules
|
||||||
from tavolo.scopone import engine as 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")
|
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ async def _finished_session(target_score: int = 1) -> GameSession:
|
|||||||
id="stats-scope-1",
|
id="stats-scope-1",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="SS0001",
|
join_code="SS0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||||
)
|
)
|
||||||
engine.create(session, {"target_score": target_score})
|
engine.create(session, {"target_score": target_score})
|
||||||
for name in PLAYERS[1:]:
|
for name in PLAYERS[1:]:
|
||||||
engine.join(session, name, name.capitalize())
|
engine.join(session, make_principal(name), name.capitalize())
|
||||||
moves = 0
|
moves = 0
|
||||||
while not engine.is_finished(session) and moves < 200000:
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
state = session.state
|
state = session.state
|
||||||
@@ -160,11 +160,11 @@ async def _finished_briscola_session() -> GameSession:
|
|||||||
id="stats-briscola-1",
|
id="stats-briscola-1",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="BS0001",
|
join_code="BS0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||||
)
|
)
|
||||||
engine.create(session, {})
|
engine.create(session, {})
|
||||||
engine.join(session, "bob", "Bob")
|
engine.join(session, make_principal("bob"), "Bob")
|
||||||
moves = 0
|
moves = 0
|
||||||
while not engine.is_finished(session) and moves < 200000:
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
state = session.state
|
state = session.state
|
||||||
@@ -246,15 +246,15 @@ async def _finished_chiamata_session() -> GameSession:
|
|||||||
id="stats-chiamata-1",
|
id="stats-chiamata-1",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="BC0001",
|
join_code="BC0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[
|
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")
|
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
engine.create(session, {})
|
engine.create(session, {})
|
||||||
for name in ("bob", "carol", "dave", "erin"):
|
for name in ("bob", "carol", "dave", "erin"):
|
||||||
engine.join(session, name, name.capitalize())
|
engine.join(session, make_principal(name), name.capitalize())
|
||||||
moves = 0
|
moves = 0
|
||||||
while not engine.is_finished(session) and moves < 200000:
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
state = session.state
|
state = session.state
|
||||||
@@ -344,15 +344,15 @@ class ScopaStatsTest(unittest.TestCase):
|
|||||||
id=f"stats-scopa-{player_count}",
|
id=f"stats-scopa-{player_count}",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="SC0001",
|
join_code="SC0001",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||||
)
|
)
|
||||||
engine.create(
|
engine.create(
|
||||||
session,
|
session,
|
||||||
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
|
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
|
||||||
)
|
)
|
||||||
for name in PLAYERS[1:player_count]:
|
for name in PLAYERS[1:player_count]:
|
||||||
engine.join(session, name, name.capitalize())
|
engine.join(session, make_principal(name), name.capitalize())
|
||||||
moves = 0
|
moves = 0
|
||||||
while not engine.is_finished(session) and moves < 200000:
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
state = session.state
|
state = session.state
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from tavolo.telemetry import (
|
|||||||
)
|
)
|
||||||
from tavolo.config import Settings
|
from tavolo.config import Settings
|
||||||
|
|
||||||
from tests.helpers import async_test
|
from tests.helpers import async_test, make_principal
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
|
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
|
||||||
@@ -158,7 +158,7 @@ class SessionTagHookTest(unittest.TestCase):
|
|||||||
hashlib.sha256(b"sid").hexdigest()[:16],
|
hashlib.sha256(b"sid").hexdigest()[:16],
|
||||||
span.attributes["session.id"],
|
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:
|
def test_tags_user_of_any_provider(self) -> None:
|
||||||
span = _RecordedSpan()
|
span = _RecordedSpan()
|
||||||
@@ -166,7 +166,7 @@ class SessionTagHookTest(unittest.TestCase):
|
|||||||
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
|
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
|
||||||
)
|
)
|
||||||
session_tag_hook(span, carrier)
|
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:
|
def test_ignores_legacy_unprefixed_user_key(self) -> None:
|
||||||
span = _RecordedSpan()
|
span = _RecordedSpan()
|
||||||
@@ -190,7 +190,7 @@ class SessionTagHookTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
session_tag_hook(span, carrier)
|
session_tag_hook(span, carrier)
|
||||||
self.assertNotIn("session.id", span.attributes)
|
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:
|
def test_missing_session_is_a_noop(self) -> None:
|
||||||
span = _RecordedSpan()
|
span = _RecordedSpan()
|
||||||
@@ -266,7 +266,7 @@ class SessionTagIntegrationTest(unittest.TestCase):
|
|||||||
attributes = span.attributes
|
attributes = span.attributes
|
||||||
assert attributes is not None
|
assert attributes is not None
|
||||||
self.assertEqual(digest, attributes["session.id"], name)
|
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:
|
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.app import app, game_store, platform
|
||||||
from tavolo.platform import GameSession, Seat
|
from tavolo.platform import GameSession, Seat
|
||||||
from tavolo.scopone.state import Card, PlayerState, ScoponeState
|
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")
|
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ def _started_session(
|
|||||||
id=game_id,
|
id=game_id,
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code=code,
|
join_code=code,
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||||
)
|
)
|
||||||
engine.create(
|
engine.create(
|
||||||
session,
|
session,
|
||||||
@@ -42,7 +42,7 @@ def _started_session(
|
|||||||
session.state.hand_ack_timeout = hand_ack_timeout
|
session.state.hand_ack_timeout = hand_ack_timeout
|
||||||
session.state.turn_timeout = turn_timeout
|
session.state.turn_timeout = turn_timeout
|
||||||
for name in PLAYERS[1:]:
|
for name in PLAYERS[1:]:
|
||||||
engine.join(session, name, name.capitalize())
|
engine.join(session, make_principal(name), name.capitalize())
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
alice_first = await alice_ws.receive_json()
|
alice_first = await alice_ws.receive_json()
|
||||||
self.assertEqual("state", alice_first["type"])
|
self.assertEqual("state", alice_first["type"])
|
||||||
self.assertEqual(
|
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])
|
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",
|
id="hand-end-1",
|
||||||
game_type=engine.id,
|
game_type=engine.id,
|
||||||
join_code="HEND01",
|
join_code="HEND01",
|
||||||
creator_sub="alice",
|
creator_sub=make_principal("alice"),
|
||||||
players=[
|
players=[
|
||||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||||
Seat(user_sub="carol", display_name="Carol", team="A"),
|
Seat(user_sub=make_principal("carol"), display_name="Carol", team="A"),
|
||||||
Seat(user_sub="dave", display_name="Dave", team="B"),
|
Seat(user_sub=make_principal("dave"), display_name="Dave", team="B"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
session.state = ScoponeState(
|
session.state = ScoponeState(
|
||||||
@@ -203,10 +203,10 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
|||||||
turn=0,
|
turn=0,
|
||||||
table=[Card.parse("02C")],
|
table=[Card.parse("02C")],
|
||||||
players=[
|
players=[
|
||||||
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
|
PlayerState(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),
|
||||||
PlayerState(sub="carol", name="Carol", seat=2),
|
PlayerState(sub=make_principal("carol"), name="Carol", seat=2),
|
||||||
PlayerState(sub="dave", name="Dave", seat=3),
|
PlayerState(sub=make_principal("dave"), name="Dave", seat=3),
|
||||||
],
|
],
|
||||||
hand_ack_timeout=hand_ack_timeout,
|
hand_ack_timeout=hand_ack_timeout,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! User avatar: the provider's `picture` claim when available, a
|
//! User avatar: the provider's `picture` claim when available, a
|
||||||
//! deterministic identicon generated from the OIDC `sub` otherwise.
|
//! deterministic identicon generated from the canonical user principal
|
||||||
|
//! otherwise.
|
||||||
//!
|
//!
|
||||||
//! The identicon is a 5x5 mirrored pixel grid derived from an inline
|
//! The identicon is a 5x5 mirrored pixel grid derived from an inline
|
||||||
//! FNV-1a hash. The hash is implemented here (instead of
|
//! FNV-1a hash. The hash is implemented here (instead of
|
||||||
@@ -62,8 +63,8 @@ fn safe_picture_url(url: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The user's avatar: their profile picture, or an identicon generated
|
/// The user's avatar: their profile picture, or an identicon generated
|
||||||
/// from the `sub`. A picture that fails to load falls back to the
|
/// from the canonical principal. A picture that fails to load falls back to
|
||||||
/// identicon as well.
|
/// the identicon as well.
|
||||||
pub fn avatar(sub: &str, picture: Option<&str>) -> View {
|
pub fn avatar(sub: &str, picture: Option<&str>) -> View {
|
||||||
let Some(url) = picture.and_then(safe_picture_url) else {
|
let Some(url) = picture.and_then(safe_picture_url) else {
|
||||||
return identicon(sub);
|
return identicon(sub);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ fn default_true() -> bool {
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
/// Canonical provider-qualified principal (`oidc:v1:<provider>:<sub>`).
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -18,6 +19,7 @@ pub struct User {
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct PlayerView {
|
pub struct PlayerView {
|
||||||
|
/// Canonical provider-qualified principal.
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub seat: usize,
|
pub seat: usize,
|
||||||
@@ -386,6 +388,7 @@ pub enum ServerMessage {
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct MatchPlayer {
|
pub struct MatchPlayer {
|
||||||
|
/// Canonical provider-qualified principal (legacy API field name).
|
||||||
pub user_sub: String,
|
pub user_sub: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub seat: usize,
|
pub seat: usize,
|
||||||
@@ -475,6 +478,7 @@ fn default_page() -> u32 {
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct LeaderboardEntry {
|
pub struct LeaderboardEntry {
|
||||||
|
/// Canonical provider-qualified principal (legacy API field name).
|
||||||
pub user_sub: String,
|
pub user_sub: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
/// Chess-style Elo rating for the requested game type.
|
/// Chess-style Elo rating for the requested game type.
|
||||||
|
|||||||
Reference in New Issue
Block a user