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", *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"): connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3) try: connection.request(method, path) 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_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()