Repo is now a monorepo:
- server/: the kaya backend, unchanged in behaviour, plus:
- GET /api/me for SPA session detection
- last_move recorded on every play and broadcast in the game state, so
clients can show who played which card the moment they play it
- legal_moves per hand card for the player on turn (rules stay
server-side)
- static catch-all route serving the compiled SPA with index.html
fallback; Tortoise context now bound only for /api/* requests
- configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
lobby (create match / join by code), live game page over websocket with
card images (CC0 woodcut napoletane deck), capture picker, move banner,
game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
app image serves the SPA; compose builds from the repo root with
overridable ports/OIDC env
Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
86 lines
3.5 KiB
Python
86 lines
3.5 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 pwo import async_test
|
|
|
|
from scopa.app import app
|
|
from scopa.config import settings
|
|
from tests.helpers import 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"):
|
|
response = await client.get("/api/me")
|
|
self.assertEqual(200, response.status_code)
|
|
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
|
|
|
@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):
|
|
@async_test
|
|
async def test_serves_files_and_spa_fallback(self) -> None:
|
|
with tempfile.TemporaryDirectory() as dist:
|
|
root = Path(dist)
|
|
(root / "index.html").write_text("<html>spa</html>")
|
|
(root / "app.js").write_text("console.log(1)")
|
|
cards = root / "assets" / "cards"
|
|
cards.mkdir(parents=True)
|
|
(cards / "07D.svg").write_text("<svg/>")
|
|
|
|
patched = dataclasses.replace(settings, static_dir=dist)
|
|
with mock.patch("scopa.routes.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.assertIn(b"spa", index.content)
|
|
|
|
js = await client.get("/app.js")
|
|
self.assertEqual(200, js.status_code)
|
|
self.assertEqual("text/javascript; charset=utf-8", js.headers["content-type"])
|
|
|
|
svg = await client.get("/assets/cards/07D.svg")
|
|
self.assertEqual(200, svg.status_code)
|
|
self.assertEqual("image/svg+xml", svg.headers["content-type"])
|
|
|
|
# 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)
|
|
|
|
# Traversal attempts never escape the dist directory.
|
|
traversal = await client.get("/..%2F..%2Fetc%2Fpasswd")
|
|
self.assertIn(traversal.status_code, (200, 404))
|
|
if traversal.status_code == 200:
|
|
self.assertIn(b"spa", traversal.content)
|
|
|
|
@async_test
|
|
async def test_missing_dist_returns_404(self) -> None:
|
|
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
|
with mock.patch("scopa.routes.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()
|