// SPDX-License-Identifier: GPL-2.0-only /* * Digital Voice Modem - Test Suite * GPLv2 Open Source. Use is subject to license terms. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2026 Bryan Biedenkapp, N2PLL * */ #include #include "common/restapi/http/HTTPLexer.h" #include "common/restapi/http/HTTPClient.h" #include "common/restapi/http/HTTPPayload.h" #include "common/restapi/http/HTTPServer.h" #include "common/restapi/http/ServerConnectionManager.h" #include "common/restapi/RequestDispatcher.h" #include #include #include #include #include #include #include #include #include #include #include #include using namespace restapi::http; // --------------------------------------------------------------------------- // Global Functions // --------------------------------------------------------------------------- namespace { // --------------------------------------------------------------------------- // Structure Declaration // --------------------------------------------------------------------------- /** * @brief Represents the state of an HTTP request for testing purposes. */ struct RequestState { std::atomic calls { 0U }; std::mutex mutex; HTTPPayload request; }; struct ResponseState { std::atomic received { false }; std::mutex mutex; HTTPPayload response; }; /** * @brief Sends a raw HTTP request to the specified port using the provided fragments and returns the response. * @param port The port to which the HTTP request should be sent. * @param fragments The fragments of the HTTP request to be sent. * @return The raw HTTP response received from the server. */ std::string sendRawRequest(uint16_t port, const std::vector& fragments) { asio::io_service ioService; asio::ip::tcp::socket socket(ioService); socket.connect(asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), port)); for (const std::string& fragment : fragments) { asio::write(socket, asio::buffer(fragment)); if (fragments.size() > 1U) std::this_thread::sleep_for(std::chrono::milliseconds(1)); } std::string response; std::array buffer {}; asio::error_code ec; do { const size_t length = socket.read_some(asio::buffer(buffer), ec); response.append(buffer.data(), length); } while (!ec); REQUIRE(ec == asio::error::eof); return response; } /** * @brief Sends a partial request, closes the sending direction, and collects any response. * @param port The port to which the partial request is sent. * @param request Partial HTTP request bytes. * @return Raw response bytes received before the server closes the connection. */ std::string sendPartialRequest(uint16_t port, const std::string& request) { asio::io_service ioService; asio::ip::tcp::socket socket(ioService); socket.connect(asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), port)); asio::write(socket, asio::buffer(request)); asio::error_code ec; socket.shutdown(asio::ip::tcp::socket::shutdown_send, ec); REQUIRE_FALSE(ec); std::string response; std::array buffer {}; do { const size_t length = socket.read_some(asio::buffer(buffer), ec); response.append(buffer.data(), length); } while (!ec); REQUIRE(ec == asio::error::eof); return response; } /** * @brief Sends a raw HTTP request without using test assertions in the calling thread. * @param port The port to which the HTTP request should be sent. * @param request Complete HTTP request. * @return The raw HTTP response received from the server. */ std::string sendRawRequestUnchecked(uint16_t port, const std::string& request) { asio::io_service ioService; asio::ip::tcp::socket socket(ioService); asio::error_code ec; socket.connect(asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), port), ec); if (ec) return std::string(); asio::write(socket, asio::buffer(request), ec); if (ec) return std::string(); std::string response; std::array buffer {}; do { const size_t length = socket.read_some(asio::buffer(buffer), ec); response.append(buffer.data(), length); } while (!ec); return response; } std::string responseBody(const std::string& response) { const size_t separator = response.find("\r\n\r\n"); REQUIRE(separator != std::string::npos); return response.substr(separator + 4U); } struct ManagedConnection; using ManagedConnectionPtr = std::shared_ptr; using TestConnectionManager = ServerConnectionManager; /** * @brief Minimal managed connection used to verify manager callback lock scope. */ struct ManagedConnection { std::function onStart; std::function onStop; std::atomic starts { 0U }; std::atomic stops { 0U }; /** @brief Records start and invokes the configured callback. */ void start() { ++starts; if (onStart) onStart(); } /** @brief Records stop and invokes the configured callback. */ void stop() { ++stops; if (onStop) onStop(); } }; } // --------------------------------------------------------------------------- // Class Declaration // --------------------------------------------------------------------------- /** * @brief A request handler that echoes back the received HTTP request content. */ class EchoRequestHandler { public: /** * @brief Initializes a new instance of the EchoRequestHandler. */ EchoRequestHandler() : m_state(std::make_shared()) { } /** * @brief Initializes a new instance of the EchoRequestHandler with the specified request state. */ explicit EchoRequestHandler(std::shared_ptr state) : m_state(std::move(state)) { } /** * @brief Handles an incoming HTTP request and prepares the corresponding reply. * @param request The incoming HTTP request. * @param reply The HTTP reply to be sent back to the client. */ void handleRequest(const HTTPPayload& request, HTTPPayload& reply) { // scope is intentional { std::lock_guard lock(m_state->mutex); m_state->request = request; } ++m_state->calls; std::string content = request.content; reply.payload(content, HTTPPayload::OK, "application/octet-stream"); } private: std::shared_ptr m_state; }; // --------------------------------------------------------------------------- // Class Declaration // --------------------------------------------------------------------------- /** * @brief A request handler that captures the HTTP response from the server. */ class ClientResponseHandler { public: /** * @brief Initializes a new instance of the ClientResponseHandler. */ ClientResponseHandler() : m_state(std::make_shared()) { } /** * @brief Initializes a new instance of the ClientResponseHandler with the specified response state. * @param state The shared response state to be used by the handler. */ explicit ClientResponseHandler(std::shared_ptr state) : m_state(std::move(state)) { } /** * @brief Handles an incoming HTTP response from the server. * @param response The HTTP response received from the server. * @param reply The HTTP reply to be sent back to the server (ignored in this handler). */ void handleRequest(const HTTPPayload& response, HTTPPayload& reply) { (void)reply; { std::lock_guard lock(m_state->mutex); m_state->response = response; } m_state->received = true; } private: std::shared_ptr m_state; }; // --------------------------------------------------------------------------- // Class Declaration // --------------------------------------------------------------------------- /** * @brief A simple loopback HTTP server for testing purposes. */ class LoopbackHTTPServer { public: /** * @brief Initializes an instance of the loopback HTTP server. */ LoopbackHTTPServer() : state(std::make_shared()), server("127.0.0.1", 0U, false) { server.setHandler(EchoRequestHandler(state)); server.open(); port = server.localPort(); thread = std::thread([this]() { server.run(); }); } /** * @brief Finalizes and stops the loopback HTTP server. */ ~LoopbackHTTPServer() { server.stop(); if (thread.joinable()) thread.join(); } LoopbackHTTPServer(const LoopbackHTTPServer&) = delete; LoopbackHTTPServer& operator=(const LoopbackHTTPServer&) = delete; std::shared_ptr state; HTTPServer server; uint16_t port { 0U }; std::thread thread; }; TEST_CASE("HTTPLexer parses a valid HTTP request", "[restapi][http][lexer]") { const std::string request = "GET /api/v1/status HTTP/1.1\r\n" "Host: localhost\r\n" "Content-Length: 0\r\n" "\r\n"; HTTPLexer lexer(false); HTTPPayload payload; auto result = lexer.parse(payload, request.begin(), request.end()); REQUIRE(std::get<0>(result) == HTTPLexer::GOOD); REQUIRE(payload.method == "GET"); REQUIRE(payload.uri == "/api/v1/status"); REQUIRE(payload.httpVersionMajor == 1); REQUIRE(payload.httpVersionMinor == 1); REQUIRE(payload.headers.find("host") == "localhost"); REQUIRE(payload.headers.find("Content-Length") == "0"); } TEST_CASE("HTTPLexer reports incomplete request as indeterminate", "[restapi][http][lexer]") { const std::string partialRequest = "GET /health HTTP/1.1\r\n" "Host: localhost\r\n" "Content-Length: 0\r\n" "\r"; HTTPLexer lexer(false); HTTPPayload payload; auto result = lexer.parse(payload, partialRequest.begin(), partialRequest.end()); REQUIRE(std::get<0>(result) == HTTPLexer::INDETERMINATE); } TEST_CASE("HTTPLexer rejects malformed request", "[restapi][http][lexer][negative]") { const std::string malformedRequest = "GET /bad HTTP/1.x\r\n" "Host localhost\r\n" "\r\n"; HTTPLexer lexer(false); HTTPPayload payload; auto result = lexer.parse(payload, malformedRequest.begin(), malformedRequest.end()); REQUIRE(std::get<0>(result) == HTTPLexer::BAD); } TEST_CASE("HTTPLexer parses a valid HTTP response", "[restapi][http][lexer]") { const std::string response = "HTTP/1.0 200 OK\r\n" "Content-Length: 0\r\n" "\r\n"; HTTPLexer lexer(true); HTTPPayload payload; auto result = lexer.parse(payload, response.begin(), response.end()); REQUIRE(std::get<0>(result) == HTTPLexer::GOOD); REQUIRE(payload.status == HTTPPayload::OK); REQUIRE(payload.httpVersionMajor == 1); REQUIRE(payload.httpVersionMinor == 0); REQUIRE(payload.headers.find("content-length") == "0"); } TEST_CASE("HTTP status payload applies default REST headers", "[restapi][http][payload]") { HTTPPayload payload = HTTPPayload::statusPayload(HTTPPayload::BAD_REQUEST, "application/json"); REQUIRE(payload.isClientPayload == false); REQUIRE(payload.status == HTTPPayload::BAD_REQUEST); REQUIRE(payload.headers.find("Content-Type") == "application/json"); REQUIRE(payload.headers.find("Content-Length") == std::to_string(payload.content.size())); REQUIRE(payload.headers.find("Server").empty() == false); } TEST_CASE("HTTP client frames a GET request carrying JSON", "[restapi][http][client][e2e]") { LoopbackHTTPServer fixture; auto responseState = std::make_shared(); HTTPClient client("127.0.0.1", fixture.port); client.setHandler(ClientResponseHandler(responseState)); REQUIRE(client.open()); json::object body = json::object(); HTTPPayload request = HTTPPayload::requestPayload(HTTP_GET, "/echo"); request.payload(body); REQUIRE(request.content.empty() == false); REQUIRE(request.headers.find("Content-Type") == "application/json"); REQUIRE(request.headers.find("Content-Length") == std::to_string(request.content.size())); REQUIRE(client.request(request)); const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); while (!responseState->received && std::chrono::steady_clock::now() < deadline) std::this_thread::sleep_for(std::chrono::milliseconds(1)); client.close(); REQUIRE(responseState->received); REQUIRE(fixture.state->calls == 1U); { std::lock_guard lock(fixture.state->mutex); REQUIRE(fixture.state->request.method == HTTP_GET); REQUIRE(fixture.state->request.uri == "/echo"); REQUIRE(fixture.state->request.content == request.content); } { std::lock_guard lock(responseState->mutex); REQUIRE(responseState->response.status == HTTPPayload::OK); REQUIRE(responseState->response.content == request.content); } } TEST_CASE("HTTP server handles a complete request over TCP", "[restapi][http][e2e]") { LoopbackHTTPServer fixture; const std::string body("echo\0payload", 12U); const std::string request = "PUT /echo HTTP/1.1\r\n" "Host: localhost\r\n" "Content-Length: " + std::to_string(body.size()) + "\r\n" "\r\n" + body; const std::string response = sendRawRequest(fixture.port, { request }); REQUIRE(response.find("HTTP/1.0 200 OK\r\n") == 0U); REQUIRE(responseBody(response) == body); REQUIRE(fixture.state->calls == 1U); std::lock_guard lock(fixture.state->mutex); REQUIRE(fixture.state->request.method == "PUT"); REQUIRE(fixture.state->request.uri == "/echo"); REQUIRE(fixture.state->request.content == body); } TEST_CASE("HTTP server accumulates a fragmented request body", "[restapi][http][e2e]") { LoopbackHTTPServer fixture; const std::vector fragments { "PUT /fragmented HTTP/1.1\r\nHost: localhost\r\nContent-Length: 11\r\n\r\n", "hello ", "world" }; const std::string response = sendRawRequest(fixture.port, fragments); REQUIRE(response.find("HTTP/1.0 200 OK\r\n") == 0U); REQUIRE(responseBody(response) == "hello world"); REQUIRE(fixture.state->calls == 1U); } TEST_CASE("HTTP server accepts every request header and body split", "[restapi][http][framing][e2e]") { LoopbackHTTPServer fixture; const std::string body("split\0body", 10U); const std::string request = "PUT /split HTTP/1.1\r\n" "Host: localhost\r\n" "Content-Length: " + std::to_string(body.size()) + "\r\n" "\r\n" + body; for (size_t split = 1U; split < request.size(); ++split) { CAPTURE(split); const std::string response = sendRawRequest(fixture.port, { request.substr(0U, split), request.substr(split) }); REQUIRE(response.find("HTTP/1.0 200 OK\r\n") == 0U); REQUIRE(responseBody(response) == body); } REQUIRE(fixture.state->calls == request.size() - 1U); } TEST_CASE("HTTP server preserves fragmented binary bodies", "[restapi][http][framing][e2e]") { LoopbackHTTPServer fixture; std::string body(20000U, 'a'); body[0U] = '\0'; body[8191U] = '\0'; body[19999U] = '\0'; const std::string header = "PUT /binary HTTP/1.1\r\nHost: localhost\r\nContent-Length: " + std::to_string(body.size()) + "\r\n\r\n"; std::vector fragments { header }; for (size_t offset = 0U; offset < body.size(); offset += 997U) fragments.push_back(body.substr(offset, std::min(997U, body.size() - offset))); const std::string response = sendRawRequest(fixture.port, fragments); REQUIRE(response.find("HTTP/1.0 200 OK\r\n") == 0U); REQUIRE(responseBody(response) == body); REQUIRE(fixture.state->calls == 1U); } TEST_CASE("HTTP server rejects malformed Content-Length", "[restapi][http][e2e][negative]") { LoopbackHTTPServer fixture; const std::string request = "PUT /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 12x\r\n\r\n"; const std::string response = sendRawRequest(fixture.port, { request }); REQUIRE(response.find("HTTP/1.0 400 Bad Request\r\n") == 0U); REQUIRE(fixture.state->calls == 0U); } TEST_CASE("HTTP server rejects ambiguous Content-Length syntax", "[restapi][http][framing][e2e][negative]") { LoopbackHTTPServer fixture; const std::vector values { "+1", "-1", "1, 1", "1x", "1844674407370955161600000" }; for (const std::string& value : values) { CAPTURE(value); const std::string request = "PUT /length HTTP/1.1\r\nHost: localhost\r\nContent-Length: " + value + "\r\n\r\n"; const std::string response = sendRawRequest(fixture.port, { request }); REQUIRE(response.find("HTTP/1.0 400 Bad Request\r\n") == 0U); } REQUIRE(fixture.state->calls == 0U); } TEST_CASE("HTTP server never dispatches truncated requests", "[restapi][http][framing][e2e][negative]") { LoopbackHTTPServer fixture; REQUIRE(sendPartialRequest(fixture.port, "PUT /partial HTTP/1.1\r\nHost: localhost\r\nContent-Len").empty()); REQUIRE(sendPartialRequest(fixture.port, "PUT /partial HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhe").empty()); REQUIRE(fixture.state->calls == 0U); } TEST_CASE("HTTP server rejects an oversized declared body", "[restapi][http][e2e][negative]") { LoopbackHTTPServer fixture; const std::string request = "PUT /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1048577\r\n\r\n"; const std::string response = sendRawRequest(fixture.port, { request }); REQUIRE(response.find("HTTP/1.0 400 Bad Request\r\n") == 0U); REQUIRE(fixture.state->calls == 0U); } TEST_CASE("HTTP server rejects bytes beyond the declared body", "[restapi][http][e2e][negative]") { LoopbackHTTPServer fixture; const std::string request = "PUT /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\nextra"; const std::string response = sendRawRequest(fixture.port, { request }); REQUIRE(response.find("HTTP/1.0 400 Bad Request\r\n") == 0U); REQUIRE(fixture.state->calls == 0U); } TEST_CASE("HTTP server services another connection while a REST handler is running", "[restapi][http][dispatcher][e2e]") { std::atomic slowStarted { false }; restapi::DefaultRequestDispatcher dispatcher; dispatcher.match("/slow").get([&slowStarted](const HTTPPayload&, HTTPPayload& reply, const restapi::RequestMatch&) { slowStarted = true; std::this_thread::sleep_for(std::chrono::milliseconds(500)); std::string content = "slow"; reply.payload(content, HTTPPayload::OK, "text/plain"); }); dispatcher.match("/fast").get([](const HTTPPayload&, HTTPPayload& reply, const restapi::RequestMatch&) { std::string content = "fast"; reply.payload(content, HTTPPayload::OK, "text/plain"); }); HTTPServer server("127.0.0.1", 0U, false); server.setHandler(dispatcher); server.open(); const uint16_t port = server.localPort(); std::thread serverThread([&server]() { server.run(); }); const std::string slowRequest = "GET /slow HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; const std::string fastRequest = "GET /fast HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; std::string slowResponse; std::thread slowThread([&]() { slowResponse = sendRawRequestUnchecked(port, slowRequest); }); const auto startDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); while (!slowStarted && std::chrono::steady_clock::now() < startDeadline) std::this_thread::sleep_for(std::chrono::milliseconds(1)); const bool slowObserved = slowStarted; const auto fastStarted = std::chrono::steady_clock::now(); const std::string fastResponse = sendRawRequestUnchecked(port, fastRequest); const auto fastElapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - fastStarted); slowThread.join(); server.stop(); serverThread.join(); REQUIRE(slowObserved); REQUIRE(fastResponse.find("HTTP/1.0 200 OK\r\n") == 0U); REQUIRE(responseBody(fastResponse) == "fast"); REQUIRE(fastElapsed < std::chrono::milliseconds(400)); REQUIRE(responseBody(slowResponse) == "slow"); } TEST_CASE("HTTP connection manager invokes lifecycle callbacks outside its lock", "[restapi][http][connection-manager]") { TestConnectionManager manager; ManagedConnectionPtr first = std::make_shared(); ManagedConnectionPtr second = std::make_shared(); first->onStart = [&manager, second]() { manager.start(second); }; first->onStop = [&manager, second]() { manager.stop(second); }; manager.start(first); REQUIRE(first->starts == 1U); REQUIRE(second->starts == 1U); manager.stop(first); REQUIRE(first->stops == 1U); REQUIRE(second->stops == 1U); } TEST_CASE("HTTP connection manager drains before invoking stop callbacks", "[restapi][http][connection-manager]") { TestConnectionManager manager; ManagedConnectionPtr first = std::make_shared(); ManagedConnectionPtr replacement = std::make_shared(); first->onStop = [&manager, replacement]() { manager.start(replacement); }; manager.start(first); manager.stopAll(); REQUIRE(first->stops == 1U); REQUIRE(replacement->starts == 1U); REQUIRE(replacement->stops == 0U); manager.stopAll(); REQUIRE(replacement->stops == 1U); }