You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dvmhost/tests/network/HTTP_RestAPI_Tests.cpp

655 lines
22 KiB

// 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 <catch2/catch_test_macros.hpp>
#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 <asio.hpp>
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <tuple>
#include <vector>
using namespace restapi::http;
// ---------------------------------------------------------------------------
// Global Functions
// ---------------------------------------------------------------------------
namespace {
// ---------------------------------------------------------------------------
// Structure Declaration
// ---------------------------------------------------------------------------
/**
* @brief Represents the state of an HTTP request for testing purposes.
*/
struct RequestState {
std::atomic<unsigned int> calls { 0U };
std::mutex mutex;
HTTPPayload request;
};
struct ResponseState {
std::atomic<bool> 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<std::string>& 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<char, 4096U> 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<char, 4096U> 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<char, 4096U> 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<ManagedConnection>;
using TestConnectionManager = ServerConnectionManager<ManagedConnectionPtr>;
/**
* @brief Minimal managed connection used to verify manager callback lock scope.
*/
struct ManagedConnection {
std::function<void()> onStart;
std::function<void()> onStop;
std::atomic<unsigned int> starts { 0U };
std::atomic<unsigned int> 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<RequestState>()) { }
/**
* @brief Initializes a new instance of the EchoRequestHandler with the specified request state.
*/
explicit EchoRequestHandler(std::shared_ptr<RequestState> 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<std::mutex> 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<RequestState> 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<ResponseState>()) { }
/**
* @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<ResponseState> 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<std::mutex> lock(m_state->mutex);
m_state->response = response;
}
m_state->received = true;
}
private:
std::shared_ptr<ResponseState> 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<RequestState>()),
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<RequestState> state;
HTTPServer<EchoRequestHandler> 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<ResponseState>();
HTTPClient<ClientResponseHandler> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::string> 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<std::string> fragments { header };
for (size_t offset = 0U; offset < body.size(); offset += 997U)
fragments.push_back(body.substr(offset, std::min<size_t>(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<std::string> 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<bool> 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<restapi::DefaultRequestDispatcher> 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::milliseconds>(
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<ManagedConnection>();
ManagedConnectionPtr second = std::make_shared<ManagedConnection>();
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<ManagedConnection>();
ManagedConnectionPtr replacement = std::make_shared<ManagedConnection>();
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);
}

Powered by TurnKey Linux.