Compare commits

..
17 Commits
Author SHA1 Message Date
woggioni 565e927a9b Fix hand-end timeouts after partial acknowledgements 2026-10-25 13:22:17 +00:00
woggioni 71493f720b Fix OIDC subject collisions by introducing canonical provider-plus-subject principals
- Add canonical_principal(provider, sub) => 'oidc:v1:<provider>:<sub>'
- Preserve provider identity across HTTP sessions and WebSockets
- Apply principals throughout: game creation/joining/seating, WebSocket views/actions,
  Redis engine state, match history, leaderboard focus, ratings, /api/me, OpenAPI,
  and telemetry
- Widen identity columns to 1024 chars with schema-only aerich migration (5_20261024221522_canonical_principals.py)
- Document provider names as stable security identifiers
- Add regression tests: same subject at different providers cannot share games, private hands,
  join codes, moves, history, or ratings; distinct seat and rating ownership verified
- All platform, integration, game, and frontend tests pass; mypy clean
2026-10-24 22:25:44 +00:00
woggioni 4d940add1c Migrate the backend from pip to uv
Replace requirements.txt with a uv workspace and lockfile, install the five tavolo packages from packages/* in development and non-editable in the image, and build the Docker image with uv. Move dev tools to a PEP 735 dependency group and refresh the locked dependency versions.
2026-10-19 18:19:20 +00:00
woggioni 7d67eccca2 Notify when another player plays a card
Add an opt-in bell to the game HUD: the first click asks for the
browser's notification permission, and the choice is remembered per
device. A play by any other seat then raises a desktop notification
with the player's name, the card and the next player, while the page
is in the background and the viewer is actually seated in the game.

Detect the newest play across the game families: scopone and scopa
report it in last_move, while briscola and briscola chiamata only
expose the trick, which mid-trick or once completed ends with the
card just played. The first snapshot after every (re)connection is a
resync baseline, so reconnects and the initial load never replay old
moves; so are the viewer's own plays and the server's auto-plays.

Deliver through the service worker registration (installed PWAs on
Android reject page-context notifications), falling back to a page
notification when no worker is active, under a per-game tag that
replaces the previous move. sw.js now focuses the game window when a
notification is clicked.
2026-10-15 10:34:14 +00:00
woggioni 8c6ad10bf6 Make the frontend an installable PWA
Add a web app manifest and an install-only service worker (no offline
caching: live state always comes from the server and hashed asset names
change every build), registered from the WASM bundle. The worker must
live at the site root for its scope to cover the app, and Granian only
serves /static and /assets, so tavolo/static.py answers
/manifest.webmanifest and /sw.js from STATIC_DIR with the content types
browsers require; index.html gains the manifest link, icons, theme-color
and Apple standalone meta tags.

Icons are committed under web/assets/icons/ with vector sources and a
make-icons.sh regeneration script, mirroring the card art workflow.
2026-10-14 22:14:43 +00:00
woggioni 532cb16f94 Show the OIDC profile picture with an identicon fallback
GET /api/me now carries the profile picture URL when the provider
supplies an absolute http(s) one; the SPA shows it in the lobby navbar
and draws a deterministic identicon from the sub otherwise, falling back
to the identicon when the image fails to load.

The identicon is a 5x5 mirrored grid seeded by an inline FNV-1a hash so
it stays stable across Rust versions and is unit-testable on the host.
The dev mock IdP hands alice and bob a picture claim.
2026-10-14 16:13:10 +00:00
woggioni 3fe29ba740 Stop requesting the user email from the OIDC provider
OIDCConfig now asks for the openid and profile scopes only: kaya-oidc's
default would also request email, which the platform never stores or
exposes. The display-name fallback drops the email claim too
(preferred_username, name, then the sub).

Tests pin the configured scopes and cover an IdP that still returns an
email claim: the claim is ignored and absent from /api/me.
2026-10-14 15:56:35 +00:00
woggioni 25af0bf3fe Document every API response body in the OpenAPI spec
kaya-openapi 0.0.8 supports named component schemas, so the routes'
description-only responses become $refs: schemas for the core payloads
(auth providers, lobby, game view, ongoing games, match history,
leaderboard, ratings) live in tavolo.platform.openapi and are registered
through the mixin's components. Engine-defined parts (options echo, game
views, match results) stay open objects on purpose. Error responses now
carry the error envelope schema, and a new suite sweeps the document to
keep every /api response typed.
2026-10-14 14:05:11 +00:00
woggioni 551c4c9c28 Paginate the leaderboard from materialized player ratings
Store each player's leaderboard totals (wins, accumulated points and the
latest display name) on player_rating alongside the Elo rating, updated
in the same transaction as the match, so the board is a single indexed
table query instead of aggregating every participation on each request.

GET /api/leaderboard is now page-number paginated (page/limit) and
returns the page, page count and total, plus the caller's own rank and
page when authenticated; focus=me returns the caller's page directly.
The migration adds the columns, backfills them from the match history
(inserting rows for players from pre-Elo matches at the initial rating)
and replaces the index. backfill_elo rebuilds the aggregates too.

The web page opens on the page containing the signed-in player, marks
their row and adds a numbered footer pager.
2026-10-09 08:35:03 +00:00
woggioni bf613eb1c4 Set scopa targets to their traditional defaults
Partnership games go to 21: scopone scientifico (the tournament target)
and scopa a quattro, while two-player scopa keeps its usual 11. The scopa
package resolves the default from the player count and exposes a per
variant options_schema default, so the lobby creation form shows the
right value without frontend changes.
2026-10-09 06:36:06 +00:00
woggioni d4d437c1a1 Add scopa: classic two-player and scopa a quattro
New tavolo-scopa package with a pure rules engine and the platform
adapters for two-player scopa and four-player fixed-partnership scopa a
quattro: three cards each and four on the table, capture by rank or sum,
sweep for scope and score carte/denara/settebello/primiera.

The optional asso pigliatutto creation option turns either variant into
scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing
scopone's rule semantics.

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
2026-10-09 05:39:00 +00:00
woggioni 7883d0c458 Give the page background a casino felt texture
Replace the flat radial green with a layered body background: a soft
overhead light and an edge vignette over a fine grain tiled across the
green gradient.

The grain is a tiny seamless SVG tile (feTurbulence fractal noise mapped
to semi-transparent black speckles, stitchTiles so it repeats without
seams) committed under web/assets, so it rides the existing asset
pipeline and works offline; the palette and panel/card colors are
unchanged.
2026-10-09 01:26:31 +00:00
woggioni 637b711fa8 Give the match history table fixed columns
Rows rendered only their own match's team labels while the header was
the union across all loaded matches. Scopone and briscola have two team
labels and briscola chiamata has three, so a mixed history produced a
five-column header above rows with two or three team cells - every cell
after them (score, winner, you, elo) landed under the wrong header, and
the columns changed with the set of game types loaded.

Replace the variable team columns with a single "Teams" column: the
table is now Finished | Game | Teams | Score | Winner | You | Elo for
every game. The cell lists each team or role on its own line, ordered
Chiamante/Socio/Difensori for chiamata and alphabetically (Team A,
Team B, ...) otherwise, with team-less players last; names read
"Bob, Carol & Dave".
2026-10-09 01:18:05 +00:00
woggioni e0030ee94a Make an absent chiamata bidder pass instead of bidding randomly
The auction timeout bid a random legal rank, which could hand the
auction (and the call) to a disconnected player. An absent bidder now
always passes; only the last player standing before any bid cannot
pass, and is made to bid the ace - its suit is picked at random by the
existing call timeout. This keeps a deal from stalling without a caller
while never letting an absent player outbid someone.

The deadline log line now says which of the two happened.
2026-10-08 22:47:32 +00:00
woggioni c746a88cfb Stop the chiamata call picker from clipping on short windows
The call picker stacked the four 120px cards vertically, making the
dialog about 780px tall, and the overlay centered its content with no
scroll fallback. On typical laptop window heights that pushed the title
and options off-screen (the heading sat at y=-110 on a 560px viewport),
leaving what looked like a free-floating selection with no explanation
and no way to scroll.

Make dialogs scroll instead of clipping: the overlay scrolls and the
panel centers with `margin: auto`, which keeps every modal (the capture
picker and the scoring summaries too) centered when it fits and
reachable when it does not. Lay the call options out in a compact grid
(four across, 2x2 on phones) with 84px cards, name the won rank in the
title ("You won with the Re - name the card") and label each option
"Partner" or "Plays alone" so the choice reads at a glance.
2026-10-08 22:30:06 +00:00
woggioni 99aa81d682 Redirect to login on 401 and return afterwards
The SPA now sends the user to a new /login page whenever an API call
answers 401, remembering the page they were on. The OIDC callback always
lands back on the configured post-login redirect, so the saved page is
restored client-side once a session exists.

- auth: save/restore the return path in sessionStorage; defer the
  navigation so the failing request can finish first
- api: centralize 401 handling (me() still treats 401 as logged out)
- login page + shared login card, extracted from the lobby
- guard async signal writes so an in-flight request cannot touch a page
  unmounted by the redirect
2026-10-08 21:52:51 +00:00
woggioni 43d5a6e2f9 Add briscola chiamata
Five-player briscola with a descending calling auction and a secret
partner. Starting with the player after the dealer, each player bids a
lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the
standing bid is the two the auction continues on points (62, 64, ...).
The last player standing must bid, and the winner names any card of the
won rank: its holder is the secret partner, or the caller plays alone
when it is one of their own cards, and its suit becomes the trump. The
calling side must reach 61 (or the raised target); a 60-60 deal with the
plain target is a patta and is replayed. A match is a single deal, so
the caller and partner (or the lone caller) against the other players
map onto the platform's two-team result, Elo and match history.

The rules live in tavolo.briscola.chiamata with the
BriscolaChiamataEngine adapter registered alongside the other briscola
variants; the partner's role and the side totals stay hidden until the
called card is played. The web client gets a five-seat table, the
auction panel with legal bids and point raises, the call picker, the
reveal bands and a chiamata-aware final summary.
2026-10-08 04:27:15 +00:00
34 changed files with 684 additions and 232 deletions
+9 -5
View File
@@ -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",
+2 -2
View File
@@ -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"]
+16 -2
View File
@@ -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):
+9 -9
View File
@@ -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")],
), ),
], ],
+8 -6
View File
@@ -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
+87 -4
View File
@@ -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."""
+29 -3
View File
@@ -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):
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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:
+14 -14
View File
@@ -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,
) )
+4 -3
View File
@@ -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);
+4
View File
@@ -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.