Files
tavolo/server/tests/test_routes_me.py
T
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

136 lines
6.4 KiB
Python

"""Tests for the whoami endpoint and the static SPA host."""
from __future__ import annotations
import dataclasses
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tavolo.config import settings
from tests.helpers import async_test, make_principal, oidc_user
class MeRouteTest(unittest.TestCase):
@async_test
async def test_me_authenticated(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice", email="alice@example.com"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
# The email claim the IdP returns is neither stored nor exposed;
# no picture claim in this request.
self.assertEqual(
{"sub": make_principal("alice"), "name": "alice", "picture": None},
response.json(),
)
@async_test
async def test_me_includes_the_profile_picture(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice", picture="https://cdn.example.com/alice.png"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual(
"https://cdn.example.com/alice.png",
response.json()["picture"],
)
@async_test
async def test_me_rejects_non_http_picture_claims(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice", picture="javascript:alert(1)"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertIsNone(response.json()["picture"])
@async_test
async def test_me_unauthenticated(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me")
self.assertEqual(401, response.status_code)
class StaticRouteTest(unittest.TestCase):
"""The app only serves the SPA shell; asset files under /static are
served by Granian and are not reachable through the ASGI transport."""
@async_test
async def test_serves_shell_and_spa_fallback(self) -> None:
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
index = await client.get("/")
self.assertEqual(200, index.status_code)
self.assertEqual("text/html; charset=utf-8", index.headers["content-type"])
self.assertIn(b"spa", index.content)
# Unknown client-side route falls back to the app shell.
fallback = await client.get("/game/some-id")
self.assertEqual(200, fallback.status_code)
self.assertIn(b"spa", fallback.content)
@async_test
async def test_serves_pwa_manifest_and_worker(self) -> None:
"""The manifest and the service worker are served from the site
root, with the content types browsers require, instead of falling
through to the app shell."""
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
(Path(dist) / "manifest.webmanifest").write_text('{"name": "Tavolo"}')
(Path(dist) / "sw.js").write_text("self.addEventListener('fetch', () => {});")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
manifest = await client.get("/manifest.webmanifest")
self.assertEqual(200, manifest.status_code)
self.assertEqual("application/manifest+json", manifest.headers["content-type"])
self.assertEqual('{"name": "Tavolo"}', manifest.text)
worker = await client.get("/sw.js")
self.assertEqual(200, worker.status_code)
self.assertEqual("text/javascript; charset=utf-8", worker.headers["content-type"])
# The worker script itself must never be cached stale.
self.assertEqual("no-cache", worker.headers["cache-control"])
self.assertIn(b"addEventListener", worker.content)
@async_test
async def test_missing_pwa_files_return_404(self) -> None:
"""A build without the PWA files must 404 those exact paths rather
than answer them with the shell (the wrong content type)."""
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
self.assertEqual(404, (await client.get("/manifest.webmanifest")).status_code)
self.assertEqual(404, (await client.get("/sw.js")).status_code)
@async_test
async def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/")
self.assertEqual(404, response.status_code)
if __name__ == "__main__":
unittest.main()