CI / Build and push docker image (push) Successful in 1m54s
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.
136 lines
6.3 KiB
Python
136 lines
6.3 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, 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": "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()
|