Fix OIDC subject collisions by introducing canonical provider-plus-subject principals

- Add canonical_principal(provider, sub) => 'oidc:v1:<provider>:<sub>'
- Preserve provider identity across HTTP sessions and WebSockets
- Apply principals throughout: game creation/joining/seating, WebSocket views/actions,
  Redis engine state, match history, leaderboard focus, ratings, /api/me, OpenAPI,
  and telemetry
- Widen identity columns to 1024 chars with schema-only aerich migration (5_20261024221522_canonical_principals.py)
- Document provider names as stable security identifiers
- Add regression tests: same subject at different providers cannot share games, private hands,
  join codes, moves, history, or ratings; distinct seat and rating ownership verified
- All platform, integration, game, and frontend tests pass; mypy clean
This commit is contained in:
2026-10-24 22:25:44 +00:00
parent 4d940add1c
commit 71493f720b
30 changed files with 591 additions and 228 deletions
@@ -43,7 +43,11 @@ from tavolo.platform import (
PlayerResult,
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.openapi import SCHEMAS
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})
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
def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user for the
first configured provider."""
def oidc_user(
oidc: OIDCProviders,
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)
provider = next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
selected = provider or next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
patcher.start()
try:
yield user
@@ -341,13 +364,20 @@ def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iter
@contextlib.contextmanager
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
one per websocket connection, in order. Once exhausted it keeps
returning the last user."""
def ws_users(
users: Sequence[OIDCUser | AuthenticatedUser], provider: str = "default"
) -> Iterator[None]:
"""Patch ``auth.get_ws_user`` to hand out identities in sequence."""
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
def _next(_ws):
@@ -16,6 +16,7 @@ from kaya.session import InMemorySessionStore, Session, SessionMixin
from helpers import async_test
from tavolo.platform.auth import (
canonical_principal,
current_user,
display_name,
get_ws_user,
@@ -53,12 +54,16 @@ class SessionUserTest(unittest.TestCase):
def test_finds_default_provider_user(self) -> None:
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
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:
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
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:
session = Session(
@@ -79,6 +84,12 @@ class SessionUserTest(unittest.TestCase):
session = Session("sid", {"oidc:default:user": "alice"})
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):
def test_returns_user_from_provider_namespaced_session(self) -> None:
@@ -88,12 +99,33 @@ class GetWsUserTest(unittest.TestCase):
)
user = get_ws_user(_ws(session))
assert user is not None
self.assertEqual("alice", user.sub)
self.assertEqual("alice", user.claims.sub)
def test_missing_session_returns_none(self) -> None:
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):
"""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"}}),
)
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:
# Single-identity sessions never hold two users; this only pins the
@@ -164,7 +198,8 @@ class CurrentUserTest(unittest.TestCase):
),
)
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):
@@ -198,7 +233,7 @@ class RequireAuthTest(unittest.TestCase):
async def handler(request_ctx: Any) -> None:
user = current_user(oidc, request_ctx)
assert user is not None
handled.append(user.sub)
handled.append(user.principal)
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 helpers import async_test, make_platform, oidc_user
from helpers import async_test, make_platform, make_principal, oidc_user
class GamesRouteTest(unittest.TestCase):
@@ -118,7 +118,7 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual("lobby", body["phase"])
self.assertEqual(5, body["target"])
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.
with oidc_user(platform.oidc, "mallory"):
@@ -219,7 +219,7 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(1, entry["seats_open"])
self.assertEqual(0, entry["your_seat"])
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.
with oidc_user(platform.oidc, "bob"):
@@ -246,6 +246,57 @@ class GamesRouteTest(unittest.TestCase):
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):
@async_test
async def test_lists_available_game_types(self) -> None:
@@ -311,7 +362,7 @@ class MeRouteTest(unittest.TestCase):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual(
{"sub": "alice", "name": "alice", "picture": None},
{"sub": make_principal("alice"), "name": "alice", "picture": None},
response.json(),
)
@@ -12,7 +12,10 @@ from httpx import ASGITransport, AsyncClient
from tavolo.platform.elo import INITIAL_RATING
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
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):
@@ -24,12 +27,12 @@ def _finished_session(target: int = 2):
id="stats-game",
game_type=engine.id,
join_code="STATS1",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
creator_sub=ALICE,
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
)
engine.create(session, {"target": target})
engine.join(session, "bob", "bob")
engine.handle_action(session, "alice", "play", {})
engine.join(session, BOB, "bob")
engine.handle_action(session, ALICE, "play", {})
return engine, session
@@ -39,7 +42,7 @@ class SaveMatchResultTest(unittest.TestCase):
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
engine.handle_action(session, ALICE, "play", {})
self.assertTrue(engine.is_finished(session))
with ctx:
@@ -53,19 +56,19 @@ class SaveMatchResultTest(unittest.TestCase):
# The game type travels from the session onto the row; the
# engine's summary is stored verbatim as the result.
self.assertEqual("dummy", match.game_type)
self.assertEqual("alice", match.result["winner"])
self.assertEqual(ALICE, match.result["winner"])
self.assertEqual(2, match.result["plays"])
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()}
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
self.assertEqual({ALICE: 2.0, BOB: 0.0}, scores)
@async_test
async def test_finished_match_updates_elo_ratings(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
engine.handle_action(session, ALICE, "play", {})
with ctx:
await save_match_result(session, engine)
@@ -75,29 +78,59 @@ class SaveMatchResultTest(unittest.TestCase):
}
self.assertEqual(2, len(ratings))
# Two players at 1500: winner gains K/2, loser loses it.
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
self.assertEqual(1, ratings["alice"].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
self.assertEqual(1, ratings["bob"].matches_played)
self.assertEqual(INITIAL_RATING + 16, ratings[ALICE].rating)
self.assertEqual(1, ratings[ALICE].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings[BOB].rating)
self.assertEqual(1, ratings[BOB].matches_played)
# The leaderboard aggregates ride along in the same transaction.
self.assertEqual(1, ratings["alice"].wins)
self.assertEqual(0, ratings["bob"].wins)
self.assertEqual(2.0, ratings["alice"].points)
self.assertEqual(0.0, ratings["bob"].points)
self.assertEqual("alice", ratings["alice"].display_name)
self.assertEqual(1, ratings[ALICE].wins)
self.assertEqual(0, ratings[BOB].wins)
self.assertEqual(2.0, ratings[ALICE].points)
self.assertEqual(0.0, ratings[BOB].points)
self.assertEqual("alice", ratings[ALICE].display_name)
# The per-match delta is recorded on each participation row.
deltas = {
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 def test_elo_ratings_accumulate_across_matches(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
engine.handle_action(session, ALICE, "play", {})
from tavolo.platform import GameSession, Seat
@@ -105,14 +138,14 @@ class SaveMatchResultTest(unittest.TestCase):
id="stats-game-2",
game_type="dummy",
join_code="STATS2",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
creator_sub=ALICE,
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
)
engine2.create(session2, {"target": 2})
engine2.join(session2, "bob", "bob")
engine2.join(session2, BOB, "bob")
# 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:
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
# is now the favourite (1516 vs 1484), so losing costs 17.
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
bob = await PlayerRating.get(user_sub="bob")
self.assertEqual(INITIAL_RATING - 1, ratings[ALICE])
self.assertEqual(INITIAL_RATING + 1, ratings[BOB])
bob = await PlayerRating.get(user_sub=BOB)
self.assertEqual(2, bob.matches_played)
@async_test
@@ -160,11 +193,12 @@ async def _seed_two_matches(
result={"winner": winner, "plays": 3 + index},
)
for seat, sub in enumerate(("alice", "bob")):
principal = make_principal(sub)
score = 2.0 + index if sub == winner else 1.0
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=sub,
user_sub=principal,
display_name=sub,
seat=seat,
team="A" if seat == 0 else "B",
@@ -172,22 +206,22 @@ async def _seed_two_matches(
score=score,
)
aggregate = aggregates.setdefault(
(sub, game_types[index]),
{"matches": 0, "wins": 0, "points": 0.0},
(principal, game_types[index]),
{"matches": 0, "wins": 0, "points": 0.0, "display_name": sub},
)
aggregate["matches"] += 1
aggregate["wins"] += 1 if sub == winner else 0
aggregate["points"] += score
for (sub, game_type), aggregate in aggregates.items():
for (principal, game_type), aggregate in aggregates.items():
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
user_sub=principal,
game_type=game_type,
rating=INITIAL_RATING,
matches_played=aggregate["matches"],
wins=aggregate["wins"],
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")
self.assertEqual(200, response.status_code)
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
self.assertEqual(2, by_sub["alice"]["matches"])
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(1, by_sub["bob"]["wins"]) # bob won match 2
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
self.assertEqual(2, by_sub[ALICE]["matches"])
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(1, by_sub[BOB]["wins"]) # bob won match 2
self.assertEqual(4.0, by_sub[BOB]["points"]) # 1.0 + 3.0
# 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 def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
@@ -282,7 +316,7 @@ class StatsRouteTest(unittest.TestCase):
with ctx:
# Alice outranks everyone despite Bob leading on points.
await PlayerRating.filter(
user_sub="alice", game_type="dummy"
user_sub=ALICE, game_type="dummy"
).update(rating=1600)
transport = ASGITransport(app=app)
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)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["alice"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
self.assertEqual(1600, by_sub[ALICE]["elo"])
self.assertEqual(INITIAL_RATING, by_sub[BOB]["elo"])
# Elo outranks wins/points.
self.assertEqual("alice", results[0]["user_sub"])
self.assertEqual(ALICE, results[0]["user_sub"])
@async_test
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)
with ctx:
await PlayerRating.filter(
user_sub="bob", game_type="dummy"
user_sub=BOB, game_type="dummy"
).update(rating=1516)
# Bob's rating in another game must not leak into the
# dummy leaderboard.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
user_sub=BOB,
game_type="second",
rating=1800,
matches_played=1,
@@ -321,8 +355,8 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1516, by_sub["bob"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
self.assertEqual(1516, by_sub[BOB]["elo"])
self.assertEqual(INITIAL_RATING, by_sub[ALICE]["elo"])
self.assertEqual("second", platform.registry.all()[1].id)
@async_test
@@ -334,7 +368,7 @@ class StatsRouteTest(unittest.TestCase):
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
user_sub=make_principal(sub),
game_type="dummy",
rating=1600 + index,
matches_played=1,
@@ -354,20 +388,21 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(6, body["total"])
self.assertEqual(2, len(body["results"]))
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(
["dave", "carol"],
[make_principal("dave"), make_principal("carol")],
[row["user_sub"] for row in second.json()["results"]],
)
self.assertEqual(
["bob", "alice"],
[BOB, ALICE],
[row["user_sub"] for row in third.json()["results"]],
)
# Out-of-range pages clamp to the last page.
self.assertEqual(3, beyond.json()["page"])
self.assertEqual(
["bob", "alice"],
[BOB, ALICE],
[row["user_sub"] for row in beyond.json()["results"]],
)
@@ -380,7 +415,7 @@ class StatsRouteTest(unittest.TestCase):
for index, sub in enumerate(("carol", "dave")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
user_sub=make_principal(sub),
game_type="dummy",
rating=1600 + index,
matches_played=1,
@@ -398,7 +433,7 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(4, body["page"])
self.assertEqual(4, body["your_rank"])
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()
self.assertEqual(1, anonymous_body["page"])
self.assertIsNone(anonymous_body["your_rank"])
@@ -421,7 +456,7 @@ class StatsRouteTest(unittest.TestCase):
app, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
engine.handle_action(session, ALICE, "play", {})
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app)
@@ -433,10 +468,37 @@ class StatsRouteTest(unittest.TestCase):
p["user_sub"]: p
for p in response.json()["results"][0]["players"]
}
self.assertEqual(16, players["alice"]["elo_delta"])
self.assertEqual(-16, players["bob"]["elo_delta"])
self.assertEqual(16, players[ALICE]["elo_delta"])
self.assertEqual(-16, players[BOB]["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 def test_my_ratings_requires_auth(self) -> None:
app, _, _ = make_platform()
@@ -452,14 +514,14 @@ class StatsRouteTest(unittest.TestCase):
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
user_sub=ALICE,
game_type="dummy",
rating=1516,
matches_played=1,
)
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
user_sub=BOB,
game_type="dummy",
rating=1484,
matches_played=1,
@@ -516,9 +578,9 @@ class GameTypeFilterTest(unittest.TestCase):
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, alice won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(1, by_sub[ALICE]["matches"])
self.assertEqual(1, by_sub[ALICE]["wins"])
self.assertEqual(0, by_sub[BOB]["wins"])
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 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):
@@ -288,6 +288,27 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual("error", error["type"])
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 def test_anonymous_user_watches_as_spectator(self) -> None:
app, platform, _ = make_platform()