From b94822b9fbf426f494fb50da8cdef688daa10309 Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Sat, 7 Nov 2026 08:48:45 -1000 Subject: Test the preview server's request handling Only split_cgi_output was ever run. The handler, which builds the CGI environment, guards the asset directory, caps the body, times cgit out, turns a failed run into a 500 and answers HEAD, ran in no test at all. The server listens on a port the system picks, so parallel runs cannot collide. --- tests/serve-handler-check.py | 179 +++++++++++++++++++++++++++++++++++++++++++ tests/t0401-serve.sh | 52 ++++++++++++- 2 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 tests/serve-handler-check.py (limited to 'tests') diff --git a/tests/serve-handler-check.py b/tests/serve-handler-check.py new file mode 100644 index 0000000..cc45b45 --- /dev/null +++ b/tests/serve-handler-check.py @@ -0,0 +1,179 @@ +"""Check how tools/serve.py hands a request to the CGI and the answer back. + +The server is started on a free loopback port with a shell script in place of +cgit, which writes back the CGI environment it was given, or fails or stalls +when the query asks it to. Each case sends one request and checks one part of +the way through. Run with the repository root, a scratch directory and the +name of one case. +""" + +import http.client +import importlib.util +import socket +import sys +import threading +from pathlib import Path + +# Loading serve.py would otherwise leave a __pycache__ directory behind in +# tools/, compiled bytecode the repository has no use for. +sys.dont_write_bytecode = True + +STUB = """#!/bin/sh +case "$QUERY_STRING" in +fail) exit 3 ;; +stall) exec sleep 5 ;; +esac +printf 'Status: 200 OK\\nContent-Type: text/plain\\nX-Stub: yes\\n\\n' +printf 'method=%s\\n' "$REQUEST_METHOD" +printf 'script=[%s]\\n' "$SCRIPT_NAME" +printf 'path=%s\\n' "$PATH_INFO" +printf 'query=%s\\n' "$QUERY_STRING" +printf 'cookie=%s\\n' "$HTTP_COOKIE" +printf 'length=%s\\n' "$CONTENT_LENGTH" +printf 'config=%s\\n' "$CGIT_CONFIG" +printf 'body=' +cat +""" + + +def load_serve(root): + path = root + "/tools/serve.py" + spec = importlib.util.spec_from_file_location("serve", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def environment(request, scratch, port): + status, headers, body = request( + "GET", "/foo%2bbar/log/?h=main", headers={"Cookie": "a=b"} + ) + lines = body.decode().splitlines() + return ( + status == 200 + and headers.get("X-Stub") == "yes" + and "method=GET" in lines + and "script=[]" in lines + and "path=/foo+bar/log/" in lines + and "query=h=main" in lines + and "cookie=a=b" in lines + and "config=%s" % (scratch / "cgitrc") in lines + ) + + +def post_body(request, scratch, port): + status, _, body = request("POST", "/p/?login", body=b"user=me") + lines = body.decode().splitlines() + return status == 200 and "length=7" in lines and "body=user=me" in lines + + +def asset(request, scratch, port): + status, headers, body = request("GET", "/cgit.css") + return ( + status == 200 + and headers.get("Content-Type") == "text/css" + and body == b"div#cgit {}\n" + ) + + +def repository_file(request, scratch, port): + status, headers, body = request("GET", "/foo/tree/cgit.css") + return status == 200 and b"path=/foo/tree/cgit.css" in body + + +def asset_link_outside(request, scratch, port): + status, headers, body = request("GET", "/outside.css") + return status == 200 and b"path=/outside.css" in body + + +def failing_cgi(request, scratch, port): + status, _, _ = request("GET", "/?fail") + return status == 500 + + +def stalled_cgi(request, scratch, port): + status, _, _ = request("GET", "/?stall") + return status == 504 + + +def oversized_body(request, scratch, port): + status, _, _ = request("POST", "/", body=b"x" * 17) + return status == 413 + + +# A client drops whatever follows the headers of an answer to HEAD, so this +# one reads the raw answer, which the server ends by closing the connection. +def head(request, scratch, port): + with socket.create_connection(("127.0.0.1", port), timeout=30) as raw: + raw.sendall(b"HEAD /foo/ HTTP/1.0\r\n\r\n") + answer = b"" + while chunk := raw.recv(4096): + answer += chunk + headers, _, body = answer.partition(b"\r\n\r\n") + return ( + headers.startswith(b"HTTP/1.0 200") + and b"X-Stub: yes" in headers + and b"Content-Length: 0" not in headers + and body == b"" + ) + + +CASES = { + "environment": environment, + "post-body": post_body, + "asset": asset, + "repository-file": repository_file, + "asset-link-outside": asset_link_outside, + "failing-cgi": failing_cgi, + "stalled-cgi": stalled_cgi, + "oversized-body": oversized_body, + "head": head, +} + + +def main(): + root, scratch, name = sys.argv[1:] + check = CASES[name] + serve = load_serve(root) + # Small bounds, so the timeout and the body cap are reached quickly, and + # the type main() pins for the stylesheet. + serve.CGI_TIMEOUT = 1 + serve.MAX_BODY_BYTES = 16 + serve.mimetypes.add_type("text/css", ".css") + + scratch = Path(scratch).resolve() + data = scratch / "assets" + data.mkdir() + (data / "cgit.css").write_text("div#cgit {}\n") + (scratch / "outside.css").write_text("not an asset\n") + (data / "outside.css").symlink_to(scratch / "outside.css") + (scratch / "cgitrc").write_text("") + stub = scratch / "cgit" + stub.write_text(STUB) + stub.chmod(0o755) + + server = serve.CgitServer(("127.0.0.1", 0), scratch / "cgitrc", stub, data) + thread = threading.Thread(target=server.serve_forever) + thread.start() + + def request(method, path, body=None, headers={}): + connection = http.client.HTTPConnection( + "127.0.0.1", server.server_port, timeout=30 + ) + try: + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + try: + ok = check(request, scratch, server.server_port) + finally: + server.shutdown() + server.server_close() + thread.join() + return 0 if ok else 1 + + +sys.exit(main()) diff --git a/tests/t0401-serve.sh b/tests/t0401-serve.sh index ca15e1c..23b8697 100755 --- a/tests/t0401-serve.sh +++ b/tests/t0401-serve.sh @@ -1,9 +1,11 @@ #!/bin/sh -# Checks how tools/serve.py, the preview server that runs the built binary and -# hands its output back over HTTP, tells the CGI headers from the body. The -# cases live in serve-split-check.py beside this file, and nothing here touches -# a repository, so the fixtures stay unbuilt. +# Checks tools/serve.py, the preview server that runs the built binary and +# hands its output back over HTTP. The cases live beside this file, in +# serve-split-check.py for how the CGI headers are told from the body and in +# serve-handler-check.py for a request served on a free loopback port with a +# stub in place of cgit. Nothing here touches a repository, so the fixtures +# stay unbuilt. test_description='Check the preview server splits CGI output correctly' CGIT_TEST_NO_CREATE_REPOS=YesPlease @@ -51,4 +53,46 @@ test_expect_success 'output with no blank line at all is all headers' ' split_check no-blank-line ' +handler_check() { + mkdir "$1" && + "$PYTHON" -I "$TEST_OUTPUT_DIRECTORY/serve-handler-check.py" \ + "$TEST_OUTPUT_DIRECTORY/.." "$PWD/$1" "$1" +} + +test_expect_success 'a request reaches the CGI with the environment it needs' ' + handler_check environment +' + +test_expect_success 'a request body reaches the CGI with its length' ' + handler_check post-body +' + +test_expect_success 'a root-level asset is served from the data directory' ' + handler_check asset +' + +test_expect_success 'a file of that name inside a repository goes to the CGI' ' + handler_check repository-file +' + +test_expect_success 'a link out of the data directory is no asset' ' + handler_check asset-link-outside +' + +test_expect_success 'a CGI that fails is answered with 500' ' + handler_check failing-cgi +' + +test_expect_success 'a CGI that stalls is answered with 504' ' + handler_check stalled-cgi +' + +test_expect_success 'a body over the cap is refused with 413' ' + handler_check oversized-body +' + +test_expect_success 'a HEAD request gets the headers alone' ' + handler_check head +' + test_done -- cgit 2.12.1