7f7868f7cf
---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---
242 lines
9.7 KiB
Python
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() |