Files
broker/tests/test_router.py
T
coreci 7f7868f7cf docs(milestone): complete coreci-broker
---ci---
phase: 5
milestone: v0.1
status: complete
requirements:
  covered: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20]
  partial: []
---/ci---
2026-10-10 15:30:14 +00:00

242 lines
9.7 KiB
Python

import contextlib
import io
import json
import os
import sys
import tempfile
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)))
import coreci_broker
def _echo(request):
request.send_json(
200,
{"body": request.body, "query": request.query, "params": request.path_params},
)
def _unauthorized(request):
request.send_error_json(401, "unauthorized")
def _forbidden(request):
request.send_error_json(403, "forbidden")
def _conflict(request):
request.send_error_json(409, "conflict")
def _explode(request):
raise RuntimeError("boom")
class RouterTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = tmp.name
self.ui_root = os.path.join(self.tmp, "ui")
self.static_root = os.path.join(self.tmp, "static")
os.makedirs(self.ui_root)
os.makedirs(self.static_root)
env = {
"BROKER_ENV_PATH": os.path.join(self.tmp, ".env"),
"BROKER_DB_PATH": os.path.join(self.tmp, "broker.db"),
"BROKER_AUDIT_LOG_PATH": os.path.join(self.tmp, "broker.audit.log"),
"BROKER_UI_ROOT": self.ui_root,
"BROKER_STATIC_ROOT": self.static_root,
}
with contextlib.redirect_stdout(io.StringIO()):
self.broker = coreci_broker.boot(env)
self.addCleanup(self.broker.audit.close)
self.addCleanup(self.broker.store.close)
self.broker.router.add("POST", "/test/echo/<name>", _echo)
self.broker.router.add("GET", "/test/401", _unauthorized)
self.broker.router.add("GET", "/test/403", _forbidden)
self.broker.router.add("GET", "/test/409", _conflict)
self.broker.router.add("GET", "/test/500", _explode)
self.server = coreci_broker.serve("127.0.0.1", 0, self.broker)
self.addCleanup(self.server.server_close)
self.addCleanup(self.server.shutdown)
self.base = "http://127.0.0.1:%d" % self.server.server_address[1]
def _request(self, method, path, data=None):
request = urllib.request.Request(self.base + path, data=data, method=method)
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, response.read(), response.headers
except urllib.error.HTTPError as error:
return error.code, error.read(), error.headers
def _request_json(self, method, path, payload=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
status, body, headers = self._request(method, path, data=data)
return status, json.loads(body.decode("utf-8")), headers
def _write(self, directory, name, content):
with open(os.path.join(directory, name), "w", encoding="utf-8") as handle:
handle.write(content)
def test_serve_returns_started_threading_daemon_server(self):
self.assertIsInstance(self.server, ThreadingHTTPServer)
self.assertTrue(self.server.daemon_threads)
self.assertEqual(self.broker, self.server.broker)
def test_healthz(self):
status, payload, headers = self._request_json("GET", "/healthz")
self.assertEqual(200, status)
self.assertEqual({"ok": True}, payload)
self.assertEqual("application/json; charset=utf-8", headers.get("Content-Type"))
def test_error_contract_for_every_code(self):
cases = [
("GET", "/test/401", None, 401),
("GET", "/test/403", None, 403),
("GET", "/test/409", None, 409),
("GET", "/test/500", None, 500),
("GET", "/no/such/route", None, 404),
("POST", "/test/echo/alice", b"{not json", 400),
]
for method, path, data, code in cases:
with self.subTest(code=code):
status, body, headers = self._request(method, path, data=data)
self.assertEqual(code, status)
self.assertEqual(
"application/json; charset=utf-8", headers.get("Content-Type")
)
payload = json.loads(body.decode("utf-8"))
self.assertEqual({"error", "message"}, set(payload))
self.assertEqual(code, payload["error"])
self.assertIsInstance(payload["message"], str)
self.assertTrue(payload["message"])
def test_json_body_query_and_params_roundtrip(self):
payload = {"k": [1, 2, {"z": True}], "s": "værdi", "n": None}
status, body, _ = self._request_json(
"POST", "/test/echo/alice?x=1&tag=a&tag=b", payload
)
self.assertEqual(200, status)
self.assertEqual(
{
"body": payload,
"query": {"x": "1", "tag": "a"},
"params": {"name": "alice"},
},
body,
)
def test_post_with_empty_body_yields_null_body(self):
status, body, _ = self._request_json("POST", "/test/echo/alice")
self.assertEqual(200, status)
self.assertIsNone(body["body"])
def test_unknown_route_is_404_even_with_valid_json_body(self):
status, body, _ = self._request_json("POST", "/api/broker/nope", {"a": 1})
self.assertEqual(404, status)
self.assertEqual({"error", "message"}, set(body))
self.assertEqual(404, body["error"])
def test_method_mismatch_is_404(self):
status, body, _ = self._request_json("DELETE", "/healthz")
self.assertEqual(404, status)
self.assertEqual(404, body["error"])
def test_static_content_types(self):
fixtures = {
"index.html": ("<html><body>ok</body></html>", "text/html; charset=utf-8"),
"app.css": ("body{margin:0}", "text/css; charset=utf-8"),
"app.js": ("console.log(1)", "application/javascript; charset=utf-8"),
"mod.py": ("import os\n", "text/x-python; charset=utf-8"),
"notes.txt": ("hello world", "text/plain; charset=utf-8"),
"data.json": ("{\"k\": 1}", "application/json; charset=utf-8"),
}
for name, (content, _) in fixtures.items():
self._write(self.ui_root, name, content)
for name, (content, content_type) in fixtures.items():
with self.subTest(name=name):
status, body, headers = self._request("GET", "/ui/" + name)
self.assertEqual(200, status)
self.assertEqual(content.encode("utf-8"), body)
self.assertEqual(content_type, headers.get("Content-Type"))
def test_static_serves_client_module(self):
self._write(
self.static_root, "coreci_broker_client.py", "import urllib.request\n"
)
status, body, headers = self._request("GET", "/static/coreci_broker_client.py")
self.assertEqual(200, status)
self.assertEqual(b"import urllib.request\n", body)
self.assertEqual("text/x-python; charset=utf-8", headers.get("Content-Type"))
def test_ui_index_and_extensionless_page(self):
self._write(self.ui_root, "index.html", "<html>index</html>")
self._write(self.ui_root, "login.html", "<html>login</html>")
status, body, _ = self._request("GET", "/ui/")
self.assertEqual(200, status)
self.assertEqual(b"<html>index</html>", body)
status, body, _ = self._request("GET", "/ui/login")
self.assertEqual(200, status)
self.assertEqual(b"<html>login</html>", body)
def test_static_missing_file_is_404_contract(self):
status, body, headers = self._request("GET", "/ui/absent.html")
self.assertEqual(404, status)
self.assertEqual("application/json; charset=utf-8", headers.get("Content-Type"))
self.assertEqual(
{"error": 404, "message": "not found"}, json.loads(body.decode("utf-8"))
)
def test_static_prefix_rejects_non_get(self):
status, body, _ = self._request("POST", "/ui/index.html", data=b"{}")
self.assertEqual(404, status)
self.assertEqual(404, json.loads(body.decode("utf-8"))["error"])
def test_ui_root_attribute_is_configurable(self):
alt = os.path.join(self.tmp, "ui_alt")
os.makedirs(alt)
self._write(alt, "x.html", "alt root")
self.broker.ui_root = alt
status, body, _ = self._request("GET", "/ui/x.html")
self.assertEqual(200, status)
self.assertEqual(b"alt root", body)
def test_traversal_attempts_rejected(self):
attempts = [
"/ui/../etc/passwd",
"/static/%2e%2e/etc/passwd",
"/static/..%2fetc%2fpasswd",
"/static/..%5cetc%5cpasswd",
"/ui/..%5c..%5cetc%5cpasswd",
"/static/%00etc/passwd",
"/static//etc/passwd",
"/ui/./.././../etc/passwd",
]
for path in attempts:
with self.subTest(path=path):
status, body, _ = self._request("GET", path)
self.assertIn(status, (400, 404))
payload = json.loads(body.decode("utf-8"))
self.assertEqual({"error", "message"}, set(payload))
self.assertEqual(status, payload["error"])
def test_symlink_escape_rejected(self):
outside = os.path.join(self.tmp, "outside")
os.makedirs(outside)
self._write(outside, "secret.txt", "top secret")
os.symlink(outside, os.path.join(self.ui_root, "alias"))
status, body, _ = self._request("GET", "/ui/alias/secret.txt")
self.assertEqual(404, status)
self.assertEqual(
{"error": 404, "message": "not found"}, json.loads(body.decode("utf-8"))
)
if __name__ == "__main__":
unittest.main()