Add unit-tests for HttpServerConnection and HTTP message classes

This commit is contained in:
Johannes Schmidt 2025-07-11 10:30:30 +02:00
parent 87b89a7175
commit 151d8a8969
9 changed files with 1149 additions and 0 deletions

View File

@ -87,7 +87,10 @@ set(base_test_SOURCES
icinga-notification.cpp
icinga-perfdata.cpp
methods-pluginnotificationtask.cpp
remote-sslcert-fixture.cpp
remote-configpackageutility.cpp
remote-httpserverconnection.cpp
remote-httpmessage.cpp
remote-url.cpp
${base_OBJS}
$<TARGET_OBJECTS:config>
@ -271,6 +274,30 @@ add_boost_test(base
icinga_perfdata/parse_edgecases
icinga_perfdata/empty_warn_crit_min_max
methods_pluginnotificationtask/truncate_long_output
remote_certs/setup_certs
remote_certs/cleanup_certs
remote_httpmessage/request_parse
remote_httpmessage/request_params
remote_httpmessage/response_flush_nothrow
remote_httpmessage/response_body_reader
remote_httpmessage/response_write_empty
remote_httpmessage/response_write_fixed
remote_httpmessage/response_write_chunked
remote_httpmessage/response_sendjsonbody
remote_httpmessage/response_sendjsonerror
remote_httpserverconnection/expect_100_continue
remote_httpserverconnection/bad_request
remote_httpserverconnection/error_access_control
remote_httpserverconnection/error_accept_header
remote_httpserverconnection/error_authenticate_cn
remote_httpserverconnection/error_authenticate_passwd
remote_httpserverconnection/error_authenticate_wronguser
remote_httpserverconnection/error_authenticate_wrongpasswd
remote_httpserverconnection/reuse_connection
remote_httpserverconnection/wg_abort
remote_httpserverconnection/client_shutdown
remote_httpserverconnection/handler_throw
remote_httpserverconnection/liveness_disconnect
remote_configpackageutility/ValidateName
remote_url/id_and_path
remote_url/parameters
@ -279,6 +306,43 @@ add_boost_test(base
remote_url/illegal_legal_strings
)
if(BUILD_TESTING)
set_tests_properties(
base-remote_httpmessage/request_parse
base-remote_httpmessage/request_params
base-remote_httpmessage/response_flush_nothrow
base-remote_httpmessage/response_body_reader
base-remote_httpmessage/response_write_empty
base-remote_httpmessage/response_write_fixed
base-remote_httpmessage/response_write_chunked
base-remote_httpmessage/response_sendjsonbody
base-remote_httpmessage/response_sendjsonerror
base-remote_httpserverconnection/expect_100_continue
base-remote_httpserverconnection/bad_request
base-remote_httpserverconnection/error_access_control
base-remote_httpserverconnection/error_accept_header
base-remote_httpserverconnection/error_authenticate_cn
base-remote_httpserverconnection/error_authenticate_passwd
base-remote_httpserverconnection/error_authenticate_wronguser
base-remote_httpserverconnection/error_authenticate_wrongpasswd
base-remote_httpserverconnection/reuse_connection
base-remote_httpserverconnection/wg_abort
base-remote_httpserverconnection/client_shutdown
base-remote_httpserverconnection/handler_throw
base-remote_httpserverconnection/liveness_disconnect
PROPERTIES FIXTURES_REQUIRED ssl_certs)
set_tests_properties(
base-remote_certs/setup_certs
PROPERTIES FIXTURES_SETUP ssl_certs
)
set_tests_properties(
base-remote_certs/cleanup_certs
PROPERTIES FIXTURES_CLEANUP ssl_certs
)
endif()
if(ICINGA2_WITH_LIVESTATUS)
set(livestatus_test_SOURCES
icingaapplication-fixture.cpp

View File

@ -0,0 +1,50 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#ifndef CONFIGURATION_FIXTURE_H
#define CONFIGURATION_FIXTURE_H
#include "base/configuration.hpp"
#include <boost/filesystem.hpp>
#include <BoostTestTargetConfig.h>
namespace icinga {
struct ConfigurationDataDirFixture
{
ConfigurationDataDirFixture() : m_DataDir(boost::filesystem::current_path() / "data"), m_PrevDataDir(Configuration::DataDir.GetData())
{
Configuration::DataDir = m_DataDir.string();
boost::filesystem::create_directories(m_DataDir);
}
~ConfigurationDataDirFixture()
{
boost::filesystem::remove_all(m_DataDir);
Configuration::DataDir = m_PrevDataDir.string();
}
boost::filesystem::path m_DataDir;
boost::filesystem::path m_PrevDataDir;
};
struct ConfigurationCacheDirFixture
{
ConfigurationCacheDirFixture() : m_CacheDir(boost::filesystem::current_path() / "data"), m_PrevCacheDir(Configuration::CacheDir.GetData())
{
Configuration::CacheDir = m_CacheDir.string();
boost::filesystem::create_directories(m_CacheDir);
}
~ConfigurationCacheDirFixture()
{
boost::filesystem::remove_all(m_CacheDir);
Configuration::CacheDir = m_PrevCacheDir.string();
}
boost::filesystem::path m_CacheDir;
boost::filesystem::path m_PrevCacheDir;
};
} // namespace icinga
#endif // CONFIGURATION_FIXTURE_H

View File

@ -0,0 +1,91 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#ifndef TEST_LOGGER_FIXTURE_H
#define TEST_LOGGER_FIXTURE_H
#include "base/logger.hpp"
#include <boost/test/test_tools.hpp>
#include <boost/regex.hpp>
#include <future>
#include <BoostTestTargetConfig.h>
namespace icinga {
class TestLogger : public Logger
{
public:
DECLARE_PTR_TYPEDEFS(TestLogger);
struct Expect
{
std::string pattern;
std::promise<bool> prom;
std::shared_future<bool> crutch;
};
auto ExpectLogPattern(const std::string& pattern, const std::chrono::milliseconds& timeout = std::chrono::seconds(0))
{
std::unique_lock lock(m_Mutex);
for (const auto & logEntry : m_LogEntries) {
if (boost::regex_match(logEntry.Message.GetData(), boost::regex(pattern))) {
return boost::test_tools::assertion_result{true};
}
}
if (timeout == std::chrono::seconds(0)) {
return boost::test_tools::assertion_result{false};
}
auto prom = std::promise<bool>();
auto fut = prom.get_future().share();
m_Expects.emplace_back(Expect{pattern, std::move(prom), fut});
lock.unlock();
auto status = fut.wait_for(timeout);
boost::test_tools::assertion_result ret{status == std::future_status::ready && fut.get()};
ret.message() << "Pattern \"" << pattern << "\" in log within " << timeout.count() << "ms";
return ret;
}
private:
void ProcessLogEntry(const LogEntry& entry) override
{
std::unique_lock lock(m_Mutex);
m_LogEntries.push_back(entry);
m_Expects.erase(std::remove_if(m_Expects.begin(), m_Expects.end(), [&entry](Expect& expect) {
if (boost::regex_match(entry.Message.GetData(), boost::regex(expect.pattern))) {
expect.prom.set_value(true);
return true;
}
return false;
}), m_Expects.end());
}
void Flush() override {}
std::mutex m_Mutex;
std::vector<Expect> m_Expects;
std::vector<LogEntry> m_LogEntries;
};
struct TestLoggerFixture
{
TestLoggerFixture()
{
testLogger->SetSeverity(testLogger->SeverityToString(LogDebug));
testLogger->Activate(true);
testLogger->SetActive(true);
}
auto ExpectLogPattern(const std::string& pattern, const std::chrono::milliseconds& timeout = std::chrono::seconds(0))
{
return testLogger->ExpectLogPattern(pattern, timeout);
}
TestLogger::Ptr testLogger = new TestLogger;
};
} // namespace icinga
#endif // TEST_LOGGER_FIXTURE_H

View File

@ -0,0 +1,105 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#ifndef TLSSTREAM_FIXTURE_H
#define TLSSTREAM_FIXTURE_H
#include "remote-sslcert-fixture.hpp"
#include "base/tlsstream.hpp"
#include "base/io-engine.hpp"
#include <future>
#include <BoostTestTargetConfig.h>
namespace icinga {
/**
* Creates a pair of TLS Streams on a random unused port.
*/
struct TlsStreamFixture: CertificateFixture
{
TlsStreamFixture()
{
using namespace boost::asio::ip;
using handshake_type = boost::asio::ssl::stream_base::handshake_type;
auto serverCert = EnsureCertFor("server");
auto clientCert = EnsureCertFor("client");
auto& serverIoContext = IoEngine::Get().GetIoContext();
clientSslContext = SetupSslContext(clientCert.crtFile, clientCert.keyFile,
m_CaCrtFile.string(), "", DEFAULT_TLS_CIPHERS, DEFAULT_TLS_PROTOCOLMIN, DebugInfo());
client = Shared<AsioTlsStream>::Make(IoEngine::Get().GetIoContext(), *clientSslContext);
serverSslContext = SetupSslContext(serverCert.crtFile, serverCert.keyFile,
m_CaCrtFile.string(), "", DEFAULT_TLS_CIPHERS, DEFAULT_TLS_PROTOCOLMIN, DebugInfo());
server = Shared<AsioTlsStream>::Make(serverIoContext, *serverSslContext);
std::promise<void> p;
tcp::acceptor acceptor{serverIoContext, tcp::endpoint{address_v4::loopback(), 0}};
acceptor.listen();
acceptor.async_accept(server->lowest_layer(), [&, this](const boost::system::error_code& ec) {
if (ec) {
BOOST_TEST_MESSAGE("Server Accept Error: " + ec.message());
return;
}
server->next_layer().async_handshake(handshake_type::server, [&, this](const boost::system::error_code& ec) {
if (ec) {
BOOST_TEST_MESSAGE("Server Handshake Error: " + ec.message());
return;
}
p.set_value();
});
});
boost::system::error_code ec;
if (client->lowest_layer().connect(acceptor.local_endpoint(), ec)) {
BOOST_TEST_MESSAGE("Client Connect error: " + ec.message());
}
if (client->next_layer().handshake(handshake_type::client, ec)) {
BOOST_TEST_MESSAGE("Client Handshake error: " + ec.message());
}
if (!client->next_layer().IsVerifyOK()) {
BOOST_TEST_MESSAGE("Verify failed for connection");
throw;
}
p.get_future().wait();
}
auto Shutdown(const Shared<AsioTlsStream>::Ptr& stream, std::optional<boost::asio::yield_context> yc = {})
{
boost::system::error_code ec;
if (yc) {
stream->next_layer().async_shutdown((*yc)[ec]);
}
else{
stream->next_layer().shutdown(ec);
}
#if BOOST_VERSION < 107000
/* On boost versions < 1.70, the end-of-file condition was propagated as an error,
* even in case of a successful shutdown. This is information can be found in the
* changelog for the boost Asio 1.14.0 / Boost 1.70 release.
*/
if (ec == boost::asio::error::eof) {
BOOST_TEST_MESSAGE("Shutdown completed successfully with 'boost::asio::error::eof'.");
return boost::test_tools::assertion_result{true};
}
#endif
boost::test_tools::assertion_result ret{!ec};
ret.message() << "Error: " << ec.message();
return ret;
}
Shared<boost::asio::ssl::context>::Ptr clientSslContext;
Shared<AsioTlsStream>::Ptr client;
Shared<boost::asio::ssl::context>::Ptr serverSslContext;
Shared<AsioTlsStream>::Ptr server;
};
} // namespace icinga
#endif // TLSSTREAM_FIXTURE_H

View File

@ -26,6 +26,8 @@ void IcingaApplicationFixture::InitIcingaApplication()
IcingaApplicationFixture::~IcingaApplicationFixture()
{
BOOST_TEST_MESSAGE("Uninitializing Application...");
Application::UninitializeBase();
IcingaApplication::GetInstance().reset();
}

258
test/remote-httpmessage.cpp Normal file
View File

@ -0,0 +1,258 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#include "test/base-tlsstream-fixture.hpp"
#include "remote/httpmessage.hpp"
#include "base/base64.hpp"
#include "base/json.hpp"
#include <BoostTestTargetConfig.h>
using namespace icinga;
using namespace boost::beast;
BOOST_FIXTURE_TEST_SUITE(remote_httpmessage, TlsStreamFixture)
BOOST_AUTO_TEST_CASE(request_parse)
{
http::request<boost::beast::http::string_body> requestOut;
requestOut.method(http::verb::get);
requestOut.target("https://localhost:5665/v1/test");
requestOut.set(http::field::authorization, "Basic " + Base64::Encode("invalid:invalid"));
requestOut.set(http::field::accept, "application/json");
requestOut.set(http::field::connection, "close");
requestOut.content_length(0);
auto & io = IoEngine::Get();
io.SpawnCoroutine(io.GetIoContext(), [&, this](boost::asio::yield_context yc) {
boost::beast::flat_buffer buf;
HttpRequest request(server);
server->async_fill(yc);
BOOST_REQUIRE_NO_THROW(request.ParseHeader(buf, yc));
for (auto & field : requestOut.base()) {
BOOST_REQUIRE(request.count(field.name()));
}
request.ParseBody(buf, yc);
BOOST_REQUIRE(Shutdown(server, yc));
});
http::write(*client, requestOut);
client->flush();
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(request_params)
{
HttpRequest request(client);
request.body() = JsonEncode(new Dictionary{{"test1", false}, {"test2", true}});
request.target("https://localhost:1234/v1/test?test1=1&test3=0&test3=1");
request.DecodeParams();
BOOST_REQUIRE(!request.Params()->Get("test2").IsObjectType<Array>());
BOOST_REQUIRE(request.Params()->Get("test2").IsBoolean());
BOOST_REQUIRE(request.Params()->Get("test2").ToBool());
BOOST_REQUIRE(request.Params()->Get("test1").IsObjectType<Array>());
BOOST_REQUIRE(request.GetLastParameter("test1"));
BOOST_REQUIRE(request.Params()->Get("test3").IsObjectType<Array>());
BOOST_REQUIRE(request.GetLastParameter("test3"));
}
BOOST_AUTO_TEST_CASE(response_body_reader)
{
auto & io = IoEngine::Get();
io.SpawnCoroutine(io.GetIoContext(), [&, this](boost::asio::yield_context yc) {
HttpResponse response(server);
response.result(http::status::ok);
response.StartStreaming();
SerializableBody<boost::beast::multi_buffer>::reader rd{response.base(), response.body()};
boost::system::error_code ec;
rd.init(3, ec);
BOOST_REQUIRE(!ec);
boost::asio::const_buffer seq1("test1", 5);
rd.put(seq1, ec);
BOOST_REQUIRE(!ec);
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
boost::asio::const_buffer seq2("test2", 5);
rd.put(seq2, ec);
BOOST_REQUIRE(!ec);
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
rd.finish(ec);
BOOST_REQUIRE(!ec);
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
BOOST_REQUIRE(Shutdown(server, yc));
});
http::response_parser<http::string_body> parser;
flat_buffer buf;
http::read(*client, buf, parser);
BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok);
BOOST_REQUIRE_EQUAL(parser.get().chunked(), true);
BOOST_REQUIRE_EQUAL(parser.get().body(), "test1test2");
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(response_flush_nothrow)
{
auto& io = IoEngine::Get();
std::promise<void> done;
io.SpawnCoroutine(io.GetIoContext(), [&, this](boost::asio::yield_context yc) {
HttpResponse response(server);
response.result(http::status::ok);
server->lowest_layer().close();
boost::beast::error_code ec;
BOOST_REQUIRE_NO_THROW(response.Flush(yc[ec]));
BOOST_REQUIRE_EQUAL(ec, boost::system::errc::bad_file_descriptor);
done.set_value();
});
auto status = done.get_future().wait_for(std::chrono::seconds(1));
BOOST_REQUIRE(status == std::future_status::ready);
}
BOOST_AUTO_TEST_CASE(response_write_empty)
{
auto & io = IoEngine::Get();
io.SpawnCoroutine(io.GetIoContext(), [&, this](boost::asio::yield_context yc) {
HttpResponse response(server);
response.result(http::status::ok);
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
BOOST_REQUIRE(Shutdown(server, yc));
});
http::response_parser<http::string_body> parser;
flat_buffer buf;
http::read(*client, buf, parser);
BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok);
BOOST_REQUIRE_EQUAL(parser.get().chunked(), false);
BOOST_REQUIRE_EQUAL(parser.get().body(), "");
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(response_write_fixed)
{
auto & io = IoEngine::Get();
io.SpawnCoroutine(io.GetIoContext(), [&, this](boost::asio::yield_context yc) {
HttpResponse response(server);
response.result(http::status::ok);
response.body() << "test";
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
BOOST_REQUIRE(Shutdown(server, yc));
});
http::response_parser<http::string_body> parser;
flat_buffer buf;
http::read(*client, buf, parser);
BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok);
BOOST_REQUIRE_EQUAL(parser.get().chunked(), false);
BOOST_REQUIRE_EQUAL(parser.get().body(), "test");
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(response_write_chunked)
{
auto & io = IoEngine::Get();
io.SpawnCoroutine(io.GetIoContext(), [&, this](boost::asio::yield_context yc) {
HttpResponse response(server);
response.result(http::status::ok);
response.StartStreaming();
response.body() << "test" << 1;
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
response.body() << "test" << 2;
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
response.body() << "test" << 3;
response.body().Finish();
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
BOOST_REQUIRE(Shutdown(server, yc));
});
http::response_parser<http::string_body> parser;
flat_buffer buf;
http::read(*client, buf, parser);
BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok);
BOOST_REQUIRE_EQUAL(parser.get().chunked(), true);
BOOST_REQUIRE_EQUAL(parser.get().body(), "test1test2test3");
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(response_sendjsonbody)
{
auto & io = IoEngine::Get();
io.SpawnCoroutine(io.GetIoContext(), [&, this](boost::asio::yield_context yc) {
HttpResponse response(server);
response.result(http::status::ok);
response.SendJsonBody(new Dictionary{{"test", 1}});
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
BOOST_REQUIRE(Shutdown(server, yc));
});
http::response_parser<http::string_body> parser;
flat_buffer buf;
http::read(*client, buf, parser);
BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok);
BOOST_REQUIRE_EQUAL(parser.get().chunked(), false);
Dictionary::Ptr body = JsonDecode(parser.get().body());
BOOST_REQUIRE_EQUAL(body->Get("test"), 1);
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(response_sendjsonerror)
{
auto & io = IoEngine::Get();
io.SpawnCoroutine(io.GetIoContext(), [&, this](boost::asio::yield_context yc) {
HttpResponse response(server);
// This has to be overwritten in SendJsonError.
response.result(http::status::ok);
response.SendJsonError(404, "Not found.");
BOOST_REQUIRE_NO_THROW(response.Flush(yc));
BOOST_REQUIRE(Shutdown(server, yc));
});
http::response_parser<http::string_body> parser;
flat_buffer buf;
http::read(*client, buf, parser);
BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::not_found);
BOOST_REQUIRE_EQUAL(parser.get().chunked(), false);
Dictionary::Ptr body = JsonDecode(parser.get().body());
BOOST_REQUIRE_EQUAL(body->Get("error"), 404);
BOOST_REQUIRE_EQUAL(body->Get("status"), "Not found.");
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_SUITE_END()

View File

@ -0,0 +1,482 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#include "base-tlsstream-fixture.hpp"
#include "icingaapplication-fixture.hpp"
#include "base-testloggerfixture.hpp"
#include "base/base64.hpp"
#include "base/json.hpp"
#include "remote/httphandler.hpp"
#include <boost/beast/http.hpp>
#include <boost/algorithm/string.hpp>
#include <boost/container/flat_set.hpp>
#include <boost/version.hpp>
#include <BoostTestTargetConfig.h>
using namespace icinga;
using namespace boost::beast;
// using namespace boost::unit_test;
using namespace boost::unit_test_framework;
struct HttpServerConnectionFixture : TlsStreamFixture,
IcingaApplicationFixture,
ConfigurationCacheDirFixture,
TestLoggerFixture
{
HttpServerConnection::Ptr conn;
StoppableWaitGroup::Ptr wg;
HttpServerConnectionFixture()
: wg(new StoppableWaitGroup)
{
Logger::SetConsoleLogSeverity(icinga::LogDebug);
}
static void CreateApiListener()
{
ScriptGlobal::Set("NodeName", "server");
ApiListener::Ptr listener = new ApiListener;
listener->OnConfigLoaded();
listener->SetAccessControlAllowOrigin(new Array{"127.0.0.1"});
}
static void CreateTestUsers()
{
ApiUser::Ptr user = new ApiUser;
user->SetName("client");
user->SetClientCN("client");
user->SetPermissions(new Array{"*"});
user->Register();
user = new ApiUser;
user->SetName("test");
user->SetPassword("test");
user->SetPermissions(new Array{"*"});
user->Register();
}
void SetupHttpServerConnection(bool authenticated)
{
String identity = authenticated ? "client" : "invalid";
conn = new HttpServerConnection(wg, identity, authenticated, server);
conn->Start();
}
template <class Rep, class Period>
bool AssertServerDisconnected(const std::chrono::duration<Rep, Period>& timeout)
{
auto iterations = timeout / std::chrono::milliseconds(50);
for(std::size_t i=0; i<iterations && !conn->Disconnected(); i++){
Utility::Sleep(std::chrono::duration<double>(timeout).count()/iterations);
}
return conn->Disconnected();
}
};
class UnitTestHandler final: public HttpHandler
{
bool HandleRequest(const WaitGroup::Ptr& waitGroup, const HttpRequest& request, HttpResponse& response,
boost::asio::yield_context& yc) override
{
response.result(boost::beast::http::status::ok);
if (request.GetLastParameter("stream")) {
response.StartStreaming();
response.Flush(yc);
for (;;) {
response.body() << "test";
response.Flush(yc);
}
return true;
}
if (request.GetLastParameter("throw")) {
response.StartStreaming();
response.body() << "test";
response.Flush(yc);
throw std::runtime_error{"The front fell off"};
}
response.body() << "test";
return true;
}
};
REGISTER_URLHANDLER("/v1/test", UnitTestHandler);
BOOST_FIXTURE_TEST_SUITE(remote_httpserverconnection, HttpServerConnectionFixture)
BOOST_AUTO_TEST_CASE(expect_100_continue)
{
CreateTestUsers();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.version(11);
request.target("https://localhost:5665/v1/test");
request.set(http::field::expect, "100-continue");
request.set(http::field::accept, "application/json");
request.set(http::field::connection, "close");
request.content_length(0);
http::request_serializer<http::string_body> sr(request);
http::write_header(*client, sr);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::continue_);
http::write(*client, sr);
client->flush();
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::ok);
BOOST_REQUIRE_EQUAL(response.body(), "test");
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(bad_request)
{
CreateTestUsers();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.version(12);
request.target("https://localhost:5665/v1/test");
request.set(http::field::accept, "application/json");
request.set(http::field::connection, "close");
request.content_length(0);
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.result(), http::status::bad_request);
BOOST_REQUIRE_NE(response.body().find("<h1>Bad Request</h1>"), std::string::npos);
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(error_access_control)
{
CreateTestUsers();
CreateApiListener();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::options);
request.target("https://localhost:5665/v1/test");
request.set(http::field::origin, "127.0.0.1");
request.set(http::field::access_control_request_method, "GET");
request.set(http::field::connection, "close");
request.content_length(0);
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::ok);
BOOST_REQUIRE_EQUAL(response.body(), "Preflight OK");
boost::container::flat_set<std::string> sv;
boost::container::flat_set options{"GET", "POST", "PUT", "DELETE", "PUSH"};
BOOST_REQUIRE_NE(response[http::field::access_control_allow_methods], "");
BOOST_REQUIRE_NE(response[http::field::access_control_allow_headers], "");
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(error_accept_header)
{
CreateTestUsers();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::post);
request.target("https://localhost:5665/v1/test");
request.set(http::field::accept, "text/html");
request.set(http::field::connection, "close");
request.content_length(0);
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::bad_request);
BOOST_REQUIRE_EQUAL(response.body(), "<h1>Accept header is missing or not set to 'application/json'.</h1>\r\n");
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(error_authenticate_cn)
{
CreateTestUsers();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.target("https://localhost:5665/v1/test");
request.set(http::field::accept, "application/json");
request.set(http::field::connection, "close");
request.content_length(0);
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::ok);
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(error_authenticate_passwd)
{
CreateTestUsers();
SetupHttpServerConnection(false);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.target("https://localhost:5665/v1/test");
request.set(http::field::authorization, "Basic " + Base64::Encode("test:test"));
request.set(http::field::accept, "application/json");
request.set(http::field::connection, "close");
request.content_length(0);
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::ok);
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(error_authenticate_wronguser)
{
CreateTestUsers();
SetupHttpServerConnection(false);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.target("https://localhost:5665/v1/test");
request.set(http::field::authorization, "Basic " + Base64::Encode("invalid:invalid"));
request.set(http::field::accept, "application/json");
request.set(http::field::connection, "close");
request.content_length(0);
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::unauthorized);
Dictionary::Ptr body = JsonDecode(response.body());
BOOST_REQUIRE(body);
BOOST_REQUIRE_EQUAL(body->Get("error"), 401);
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(error_authenticate_wrongpasswd)
{
CreateTestUsers();
SetupHttpServerConnection(false);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.target("https://localhost:5665/v1/test");
request.set(http::field::authorization, "Basic " + Base64::Encode("test:invalid"));
request.set(http::field::accept, "application/json");
request.set(http::field::connection, "close");
request.content_length(0);
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::unauthorized);
Dictionary::Ptr body = JsonDecode(response.body());
BOOST_REQUIRE(body);
BOOST_REQUIRE_EQUAL(body->Get("error"), 401);
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_CASE(reuse_connection)
{
CreateTestUsers();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.target("https://localhost:5665/v1/test");
request.set(http::field::accept, "application/json");
request.keep_alive(true);
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::ok);
BOOST_REQUIRE_EQUAL(response.body(), "test");
request.keep_alive(false);
http::write(*client, request);
client->flush();
response.body() = "";
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.result(), http::status::ok);
BOOST_REQUIRE_EQUAL(response.body(), "test");
BOOST_REQUIRE(AssertServerDisconnected(std::chrono::seconds(5)));
BOOST_REQUIRE(Shutdown(client));
BOOST_REQUIRE(ExpectLogPattern("HTTP client disconnected .*", std::chrono::seconds(5)));
}
BOOST_AUTO_TEST_CASE(wg_abort)
{
CreateTestUsers();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.target("https://localhost:5665/v1/test");
request.set(http::field::accept, "application/json");
request.keep_alive(true);
http::write(*client, request);
client->flush();
wg->Join();
flat_buffer buf;
http::response<http::string_body> response;
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.version(), 11);
BOOST_REQUIRE_EQUAL(response.result(), http::status::ok);
BOOST_REQUIRE_EQUAL(response.body(), "test");
BOOST_REQUIRE(AssertServerDisconnected(std::chrono::seconds(5)));
BOOST_REQUIRE(Shutdown(client));
BOOST_REQUIRE(ExpectLogPattern("HTTP client disconnected .*", std::chrono::seconds(5)));
}
BOOST_AUTO_TEST_CASE(client_shutdown)
{
CreateTestUsers();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.target("https://localhost:5665/v1/test");
request.set(http::field::accept, "application/json");
request.keep_alive(true);
/* Instruct the test HttpHandler defined above to stream an endless response.
*/
request.body() = JsonEncode(new Dictionary{{"stream", true}});
request.prepare_payload();
http::write(*client, request);
client->flush();
/* Unlike the other test cases we don't require success here, because with the request
* above, UnitTestHandler simulates a HttpHandler that is constantly writing.
* That will cause the shutdown to fail on the client-side with "application data after
* close notify", but the important part is that HttpServerConnection actually closes
* the connection on its own side, which we check with the BOOST_REQUIRE() below.
*/
BOOST_WARN(Shutdown(client));
BOOST_REQUIRE(AssertServerDisconnected(std::chrono::seconds(5)));
BOOST_REQUIRE(ExpectLogPattern("HTTP client disconnected.*", std::chrono::seconds(5)));
BOOST_REQUIRE(ExpectLogPattern("Detected shutdown from client: .*"));
}
BOOST_AUTO_TEST_CASE(handler_throw)
{
CreateTestUsers();
SetupHttpServerConnection(true);
http::request<boost::beast::http::string_body> request;
request.method(http::verb::get);
request.target("https://localhost:5665/v1/test");
request.set(http::field::accept, "application/json");
request.keep_alive(true);
/* Instruct the test TestHandler to throw an exception while streaming, which the
* HttpHandler base should propagate up to HttpServerConnection.
* The correct response is to shutdown the connection instead of trying to send a JSON
* error message.
*/
request.body() = JsonEncode(new Dictionary{{"throw", true}});
request.prepare_payload();
http::write(*client, request);
client->flush();
flat_buffer buf;
http::response_parser<http::string_body> parser;
boost::system::error_code ec;
http::read(*client, buf, parser, ec);
/* Since the handler threw in the middle of sending the message we shouldn't be able
* to read a complete message here.
* Somehow boost::beast error codes can not be tested with BOOST_REQUIRE_EQUAL so we
* compare the string instead.
*/
BOOST_REQUIRE_EQUAL(ec.message(), "partial message");
/* The body should only contain the single "test" the handler has written, without any
* attempts made to additionally write some json error message.
*/
BOOST_REQUIRE_EQUAL(parser.get().body(), "test");
/* We then expect the server to initiate a shutdown, which we then complete below.
*/
BOOST_REQUIRE(AssertServerDisconnected(std::chrono::seconds(5)));
BOOST_REQUIRE(Shutdown(client));
BOOST_REQUIRE(ExpectLogPattern("HTTP client disconnected.*", std::chrono::seconds(5)));
BOOST_REQUIRE(ExpectLogPattern("Exception while processing HTTP request from .+?: The front fell off"));
}
BOOST_AUTO_TEST_CASE(liveness_disconnect)
{
SetupHttpServerConnection(false);
BOOST_REQUIRE(AssertServerDisconnected(std::chrono::seconds(11)));
BOOST_REQUIRE(ExpectLogPattern("HTTP client disconnected .*"));
BOOST_REQUIRE(Shutdown(client));
}
BOOST_AUTO_TEST_SUITE_END()

View File

@ -0,0 +1,26 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#include "remote-sslcert-fixture.hpp"
#include <BoostTestTargetConfig.h>
using namespace icinga;
const boost::filesystem::path CertificateFixture::m_PersistentCertsDir = boost::filesystem::current_path() / "persistent" / "certs";
BOOST_AUTO_TEST_SUITE(remote_certs)
BOOST_FIXTURE_TEST_CASE(setup_certs, ConfigurationDataDirFixture)
{
if (boost::filesystem::exists(CertificateFixture::m_PersistentCertsDir)) {
boost::filesystem::remove_all(CertificateFixture::m_PersistentCertsDir);
}
}
BOOST_FIXTURE_TEST_CASE(cleanup_certs, ConfigurationDataDirFixture)
{
if (boost::filesystem::exists(CertificateFixture::m_PersistentCertsDir)) {
boost::filesystem::remove_all(CertificateFixture::m_PersistentCertsDir);
}
}
BOOST_AUTO_TEST_SUITE_END()

View File

@ -0,0 +1,71 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#ifndef SSLCERT_FIXTURE_H
#define SSLCERT_FIXTURE_H
#include "remote/apilistener.hpp"
#include "remote/pkiutility.hpp"
#include "test/base-configuration-fixture.hpp"
#include <BoostTestTargetConfig.h>
namespace icinga{
struct CertificateFixture: ConfigurationDataDirFixture
{
CertificateFixture()
{
namespace fs = boost::filesystem;
m_CaDir = ApiListener::GetCaDir();
m_CertsDir = ApiListener::GetCertsDir();
m_CaCrtFile = m_CertsDir / "ca.crt";
fs::create_directories(m_PersistentCertsDir / "ca");
fs::create_directories(m_PersistentCertsDir / "certs");
if (fs::exists(m_DataDir / "ca")) {
fs::remove(m_DataDir / "ca");
}
if (fs::exists(m_DataDir / "certs")) {
fs::remove(m_DataDir / "certs");
}
fs::create_directory_symlink(m_PersistentCertsDir / "certs", m_DataDir / "certs");
fs::create_directory_symlink(m_PersistentCertsDir / "ca", m_DataDir / "ca");
if (!fs::exists(m_CaCrtFile)) {
PkiUtility::NewCa();
fs::copy_file(m_CaDir / "ca.crt", m_CaCrtFile);
}
}
auto EnsureCertFor(const std::string& name)
{
struct
{
String crtFile;
String keyFile;
String csrFile;
} cert;
cert.crtFile = (m_CertsDir/(name + ".crt")).string();
cert.keyFile = (m_CertsDir/(name + ".key")).string();
cert.csrFile = (m_CertsDir/(name + ".csr")).string();
if (!Utility::PathExists(cert.crtFile)) {
PkiUtility::NewCert(name, cert.keyFile, cert.csrFile, cert.crtFile);
PkiUtility::SignCsr(cert.csrFile, cert.crtFile);
}
return cert;
}
boost::filesystem::path m_CaDir;
boost::filesystem::path m_CertsDir;
boost::filesystem::path m_CaCrtFile;
static const boost::filesystem::path m_PersistentCertsDir;
};
} // namespace icinga
#endif // SSLCERT_FIXTURE_H