- 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
136 lines
6.4 KiB
Python
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()
|