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()