import contextlib import http.client import json import pathlib import re import signal import socket import subprocess import sys import tempfile import time import unittest SERVER = pathlib.Path(sys.argv.pop(1)).resolve() @contextlib.contextmanager def running_server(*arguments, stop_signal=signal.SIGTERM): with tempfile.TemporaryDirectory(prefix="fpol_test_") as directory: log_path = pathlib.Path(directory) / "server.log" with log_path.open("w") as log: process = subprocess.Popen([str(SERVER), "--port", "0", "--database", str(pathlib.Path(directory) / "league.sqlite"), *map(str, arguments)], stdout=log, stderr=log) try: deadline = time.monotonic() + 10 while time.monotonic() < deadline: output = log_path.read_text() match = re.search(r"Listening on http://127\.0\.0\.1:(\d+)", output) if match and process.poll() is None: yield int(match.group(1)) break if process.poll() is not None: raise AssertionError(f"Server exited during startup:\n{output}") time.sleep(0.02) else: raise AssertionError(f"Server did not start:\n{log_path.read_text()}") finally: if process.poll() is None: process.send_signal(stop_signal) try: return_code = process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait() raise AssertionError("Server did not shut down within five seconds") from None if return_code != 0: raise AssertionError(f"Server exited with {return_code}:\n{log_path.read_text()}") def request(port, path, method="GET", body=None): connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3) try: connection.request(method, path, body=json.dumps(body) if body is not None else None, headers={"Content-Type": "application/json"}) response = connection.getresponse() return response.status, response.getheader("Content-Type"), response.read() finally: connection.close() class server_tests(unittest.TestCase): def test_health_and_api_only_routes(self): with running_server() as port: status, content_type, body = request(port, "/api/health") self.assertEqual(status, 200) self.assertEqual(content_type, "application/json") self.assertEqual(json.loads(body), {"ok": True, "payload": {"status": "ok"}}) for path in ("/", "/api/unknown", "/missing.js"): with self.subTest(path=path): status, _, body = request(port, path) self.assertEqual(status, 404) self.assertEqual(json.loads(body)["code"], "not_found") self.assertEqual(request(port, "/api/health", "POST")[0], 404) def test_catalog_rules_and_validation_routes(self): with running_server("--allow-demo") as port: self.assertEqual(json.loads(request(port, "/api/catalog")[2])["payload"], {"documents": []}) self.assertEqual(len(json.loads(request(port, "/api/rules")[2])["payload"]["slots"]), 12) status, _, body = request(port, "/api/capacity", "POST", {"catalog_id": "missing", "players": 12}) self.assertEqual(status, 400) self.assertEqual(json.loads(body)["code"], "invalid_argument") self.assertEqual(request(port, "/api/score-preview", "POST", {})[0], 400) self.assertEqual(request(port, "/api/capacity")[0], 404) def test_seeded_catalog_capacity_and_scoring(self): demo = pathlib.Path(__file__).resolve().parents[1] / "data" / "demo" with tempfile.TemporaryDirectory(prefix="fpol_api_") as directory: database = pathlib.Path(directory) / "league.sqlite" result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "import", str(demo / "catalog.json"), str(demo / "ratings.json"), str(demo / "results-3.json")], capture_output=True, text=True, timeout=10) self.assertEqual(result.returncode, 0, result.stderr) with running_server("--database", database, "--allow-demo") as port: catalog = json.loads(request(port, "/api/catalog")[2])["payload"] self.assertEqual(len(catalog["documents"]), 3) status, _, body = request(port, "/api/capacity", "POST", json.loads((demo / "capacity.json").read_text())) self.assertEqual(status, 200) self.assertTrue(json.loads(body)["payload"]["sufficient_candidates"]) status, _, body = request(port, "/api/score-preview", "POST", json.loads((demo / "preview.json").read_text())) self.assertEqual(status, 200) self.assertEqual(json.loads(body)["payload"]["total"], 528.24) def test_authenticated_cookie_headers_origin_and_query(self): with tempfile.TemporaryDirectory(prefix="fpol_auth_http_") as directory: database = pathlib.Path(directory) / "league.sqlite" result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "create-user", "alice"], input="a strong test password\n", capture_output=True, text=True, timeout=10) self.assertEqual(result.returncode, 0, result.stderr) with running_server("--database", database, "--origin", "https://game.example", "--secure-cookies") as port: def call(path, method="GET", body=None, headers=None): connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) try: connection.request(method, path, body=json.dumps(body) if body is not None else None, headers={"Content-Type": "application/json", **(headers or {})}) response = connection.getresponse() return response.status, dict(response.getheaders()), json.loads(response.read()) finally: connection.close() login = {"username": "alice", "password": "a strong test password"} self.assertEqual(call("/api/auth/login", "POST", login)[0], 403) headers = {"Origin": "https://game.example", "X-Fpol-Request": "1"} status, response, body = call("/api/auth/login", "POST", login, headers) self.assertEqual(status, 200) cookie = response["Set-Cookie"] self.assertIn("HttpOnly", cookie) self.assertIn("SameSite=Lax", cookie) self.assertIn("Secure", cookie) self.assertEqual(response["Cache-Control"], "no-store") self.assertNotIn("session_token", body) headers["Cookie"] = cookie.split(";", 1)[0] self.assertEqual(call("/api/auth/me", headers=headers)[2]["payload"]["username"], "alice") self.assertEqual(call("/api/candidates?search=two+words&limit=1", headers=headers)[0], 200) self.assertEqual(call("/api/candidates?search=%ZZ", headers=headers)[0], 400) self.assertEqual(call("/api/candidates?limit=1&limit=2", headers=headers)[0], 400) self.assertEqual(call("/api/auth/logout", "POST", {}, headers)[0], 403) headers["X-CSRF-Token"] = body["payload"]["csrf"] self.assertEqual(call("/api/auth/logout", "POST", {}, headers)[0], 200) self.assertEqual(call("/api/auth/me", headers=headers)[0], 401) self.assertEqual(call("/api/catalog", headers=headers)[0], 404) def test_static_assets_and_route_boundaries(self): with tempfile.TemporaryDirectory(prefix="fpol_web_") as directory: root = pathlib.Path(directory) (root / "assets").mkdir() (root / "api").mkdir() (root / "index.html").write_text("Fantasy Politics") (root / "assets" / "app.js").write_text("console.log('ready');") (root / "assets" / "app.css").write_text("body { color: green; }") (root / "assets" / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00\xff") (root / ".env").write_text("not a public asset") (root / ".hidden").mkdir() (root / ".hidden" / "data.txt").write_text("not a public asset") (root / "api" / "health").write_text("must not replace the health route") (root / "linked.html").symlink_to(root / "index.html") (root / "linked_assets").symlink_to(root / "assets", target_is_directory=True) with running_server("--web-root", root) as port: for route, filename, content_type in ( ("/", "index.html", "text/html; charset=utf-8"), ("/index.html", "index.html", "text/html; charset=utf-8"), ("/assets/app.js", "assets/app.js", "text/javascript; charset=utf-8"), ("/assets/app.css", "assets/app.css", "text/css; charset=utf-8"), ("/assets/image.png", "assets/image.png", "image/png"), ): with self.subTest(route=route): self.assertEqual(request(port, route), (200, content_type, (root / filename).read_bytes())) for route in ("/linked.html", "/linked_assets/app.js", "/.env", "/.hidden/data.txt", "/../index.html", "/%2e%2e/index.html", "/missing.js", "/api/missing"): with self.subTest(route=route): self.assertEqual(request(port, route)[0], 404) self.assertEqual(json.loads(request(port, "/api/health")[2])["payload"], {"status": "ok"}) def test_invalid_startup(self): with tempfile.TemporaryDirectory(prefix="fpol_invalid_") as directory: root = pathlib.Path(directory) file = root / "file.txt" file.write_text("not a web root") for arguments, diagnostic in ( (["--unknown"], "Unknown option"), (["--port"], "Missing value"), (["--port", "abc"], "Port must be an integer"), (["--port", "65536"], "Port must be an integer"), (["--port", "-1"], "Port must be an integer"), (["--port", "80x"], "Port must be an integer"), (["--host", ""], "Missing value"), (["--web-root", root / "missing"], "Web root must be a directory"), (["--web-root", file], "Web root must be a directory"), (["--web-root", root], "Web root must contain index.html"), ): with self.subTest(arguments=arguments): result = subprocess.run([str(SERVER), *map(str, arguments)], capture_output=True, text=True, timeout=5) self.assertNotEqual(result.returncode, 0) self.assertIn("[error]", result.stderr) self.assertIn(diagnostic, result.stderr) (root / "index.html").symlink_to(file) result = subprocess.run([str(SERVER), "--web-root", str(root)], capture_output=True, text=True, timeout=5) self.assertNotEqual(result.returncode, 0) self.assertIn("Web root must contain index.html", result.stderr) def test_occupied_port(self): with socket.socket() as listener: listener.bind(("127.0.0.1", 0)) listener.listen() result = subprocess.run([str(SERVER), "--port", str(listener.getsockname()[1])], capture_output=True, text=True, timeout=5) self.assertNotEqual(result.returncode, 0) self.assertIn("[error]", result.stderr) def test_help(self): result = subprocess.run([str(SERVER), "--help"], capture_output=True, text=True, timeout=5) self.assertEqual(result.returncode, 0) self.assertIn("--web-root", result.stdout) def test_interrupt_shutdown(self): with running_server(stop_signal=signal.SIGINT) as port: self.assertEqual(request(port, "/api/health")[0], 200) if __name__ == "__main__": unittest.main()