(WIP) Add unit-tests for HttpServerConnection (and later others)

This commit is contained in:
Johannes Schmidt 2025-07-11 10:30:30 +02:00
parent fcad7f77cc
commit 42cee50cc0
6 changed files with 716 additions and 0 deletions

View File

@ -88,6 +88,8 @@ set(base_test_SOURCES
icinga-perfdata.cpp
methods-pluginnotificationtask.cpp
remote-configpackageutility.cpp
remote-httpserverconnection.cpp
remote-httpmessage.cpp
remote-url.cpp
${base_OBJS}
$<TARGET_OBJECTS:config>
@ -271,6 +273,20 @@ add_boost_test(base
icinga_perfdata/parse_edgecases
icinga_perfdata/empty_warn_crit_min_max
methods_pluginnotificationtask/truncate_long_output
remote_httpmessage/request_parse
# remote_httpmessage/request_params
remote_httpserverconnection/setup_certs
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_and_wg_abort
remote_httpserverconnection/liveness_disconnect
remote_httpserverconnection/cleanup_certs
remote_configpackageutility/ValidateName
remote_url/id_and_path
remote_url/parameters
@ -279,6 +295,32 @@ add_boost_test(base
remote_url/illegal_legal_strings
)
if(BUILD_TESTING)
set_tests_properties(
base-remote_httpmessage/request_parse
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_and_wg_abort
base-remote_httpserverconnection/liveness_disconnect
PROPERTIES FIXTURES_REQUIRED ssl_certs)
set_tests_properties(
base-remote_httpserverconnection/setup_certs
PROPERTIES FIXTURES_SETUP ssl_certs
)
set_tests_properties(
base-remote_httpserverconnection/cleanup_certs
PROPERTIES FIXTURES_CLEANUP ssl_certs
)
endif()
if(ICINGA2_WITH_LIVESTATUS)
set(livestatus_test_SOURCES
icingaapplication-fixture.cpp

View File

@ -0,0 +1,53 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#ifndef CONFIGURATION_FIXTURE_H
#define CONFIGURATION_FIXTURE_H
#include "base/configuration.hpp"
#include "base/utility.hpp"
#include <boost/filesystem.hpp>
#include <BoostTestTargetConfig.h>
namespace icinga {
struct ConfigurationDataDirFixture
{
ConfigurationDataDirFixture()
{
Utility::MkDir(tmpDir, 0700);
}
String tmpDir = boost::filesystem::temp_directory_path().append("icinga2").string();
String previousDataDir = std::exchange(Configuration::DataDir, tmpDir);
};
struct ConfigurationDataDirCleanupFixture: ConfigurationDataDirFixture
{
~ConfigurationDataDirCleanupFixture()
{
Utility::RemoveDirRecursive(std::exchange(Configuration::DataDir, previousDataDir));
}
};
struct ConfigurationCacheDirFixture
{
ConfigurationCacheDirFixture()
{
Utility::MkDir(tmpCacheDir, 0700);
}
String tmpCacheDir = boost::filesystem::temp_directory_path().append("icinga2").append("cache").string();
String previousCacheDir = std::exchange(Configuration::CacheDir, tmpCacheDir);
};
struct ConfigurationCacheDirCleanupFixture: ConfigurationCacheDirFixture
{
~ConfigurationCacheDirCleanupFixture()
{
Utility::RemoveDirRecursive(std::exchange(Configuration::CacheDir, previousCacheDir));
}
};
} // namespace icinga
#endif // CONFIGURATION_FIXTURE_H

View File

@ -0,0 +1,47 @@
/* 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 SslCertificateFixture: ConfigurationDataDirFixture
{
SslCertificateFixture()
{
caDir = ApiListener::GetCaDir();
Utility::MkDir(caDir, 0700);
certsDir = ApiListener::GetCertsDir();
Utility::MkDir(certsDir, 0700);
PkiUtility::NewCa();
if (!Utility::PathExists(certsDir+"ca.crt")) {
Utility::CopyFile(caDir + "ca.crt", certsDir + "ca.crt");
}
}
~SslCertificateFixture() {}
void EnsureCertFor(const String& name)
{
auto certKeyFileName = certsDir + name + ".crt";
if (!Utility::PathExists(certKeyFileName)) {
PkiUtility::NewCert(name, certsDir + name + ".key", certsDir + name + ".csr",
certsDir + name + ".crt");
PkiUtility::SignCsr(certsDir + name + ".csr", certsDir + name + ".crt");
}
}
String caDir;
String certsDir;
};
} // namespace icinga
#endif // SSLCERT_FIXTURE_H

View File

@ -0,0 +1,91 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#ifndef TLSSTREAM_FIXTURE_H
#define TLSSTREAM_FIXTURE_H
#include "test/base-sslcert-fixture.hpp"
#include "base/tlsstream.hpp"
#include "base/io-engine.hpp"
#include <BoostTestTargetConfig.h>
namespace icinga {
/**
* Creates a pair of TLS Streams on a random unused port.
*/
struct TlsStreamFixture: SslCertificateFixture
{
TlsStreamFixture()
{
using namespace boost::asio::ip;
using handshake_type = boost::asio::ssl::stream_base::handshake_type;
EnsureCertFor("remote");
EnsureCertFor("local");
auto& localContext = IoEngine::Get().GetIoContext();
remoteSslCtx = SetupSslContext(certsDir + "remote.crt", certsDir + "remote.key", caDir+"ca.crt", "", DEFAULT_TLS_CIPHERS,
DEFAULT_TLS_PROTOCOLMIN, DebugInfo());
client = Shared<AsioTlsStream>::Make(IoEngine::Get().GetIoContext(), *remoteSslCtx);
localSslCtx = SetupSslContext(certsDir + "local.crt", certsDir + "local.key", caDir+"ca.crt",
"", DEFAULT_TLS_CIPHERS, DEFAULT_TLS_PROTOCOLMIN, DebugInfo());
server = Shared<AsioTlsStream>::Make(localContext, *localSslCtx);
std::mutex handshakeMutex;
std::condition_variable handshakeCv;
bool handshakeDone = false;
tcp::acceptor acceptor{localContext, tcp::endpoint{make_address_v4("127.0.0.1"), 0}};
acceptor.listen();
acceptor.async_accept(server->lowest_layer(), [&, this](const boost::system::error_code& ec) {
if (ec) {
std::cout << "Local Accept Error: " << ec.message() << std::endl;
return;
}
server->next_layer().async_handshake(handshake_type::server, [&, this](const boost::system::error_code& ec) {
if (ec) {
std::cout << "Local Handshake Error: " << ec.message() << std::endl;
return;
}
handshakeMutex.lock();
handshakeDone = true;
handshakeMutex.unlock();
handshakeCv.notify_all();
});
});
boost::system::error_code ec;
if (client->lowest_layer().connect(acceptor.local_endpoint(), ec)) {
std::cout << "Client Connect error: " << ec.message() << std::endl;
}
if (client->next_layer().handshake(handshake_type::client, ec)) {
std::cout << "Client Handshake error: " << ec.message() << std::endl;
}
if (!client->next_layer().IsVerifyOK()) {
std::cout << "Verify failed for connection" << std::endl;
throw;
}
std::unique_lock lock{handshakeMutex};
handshakeCv.wait(lock, [&](){return handshakeDone;});
}
~TlsStreamFixture()
{
std::cout << "TlsStreamFixture done" << std::endl;
}
Shared<boost::asio::ssl::context>::Ptr remoteSslCtx;
Shared<AsioTlsStream>::Ptr client;
Shared<boost::asio::ssl::context>::Ptr localSslCtx;
Shared<AsioTlsStream>::Ptr server;
};
} // namespace icinga
#endif // HTTPSERVERCONNECTION_FIXTURE_H

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

@ -0,0 +1,103 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#include "test/base-tlsstream-fixture.hpp"
#include "test/icingaapplication-fixture.hpp"
#include "base/base64.hpp"
#include "base/json.hpp"
#include "config/configitem.hpp"
#include "config/configcompiler.hpp"
#include "remote/httphandler.hpp"
#include <boost/beast/http.hpp>
#include <boost/algorithm/string.hpp>
#include <set>
#include <BoostTestTargetConfig.h>
using namespace icinga;
using namespace boost::beast;
struct HttpMessageFixture: TlsStreamFixture
{
};
BOOST_FIXTURE_TEST_SUITE(remote_httpmessage, HttpMessageFixture)
BOOST_AUTO_TEST_CASE(request_parse)
{
std::atomic<bool> done = false;
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);
server->next_layer().async_shutdown(yc);
});
http::write(*client, requestOut);
client->flush();
BOOST_REQUIRE_NO_THROW(client->next_layer().shutdown());
}
BOOST_AUTO_TEST_CASE(request_params)
{
HttpRequest request(client);
request.body() = JsonEncode(new Dictionary{{"test1", false}, {"test2", true}});
std::cout << request.body();
request.target("https://localhost:1234/v1/test?test1&test3&test3");
request.DecodeParams();
BOOST_REQUIRE_EQUAL(request.Params()->Get("test2"), "true");
BOOST_REQUIRE(request.Params()->Get("test1").IsObjectType<Array>());
BOOST_REQUIRE_EQUAL(request.GetLastParameter("test1"), "true");
BOOST_REQUIRE(request.Params()->Get("test3").IsObjectType<Array>());
BOOST_REQUIRE_EQUAL(request.GetLastParameter("test3"), "true");
}
BOOST_AUTO_TEST_CASE(response_stream_operator)
{
}
BOOST_AUTO_TEST_CASE(response_body_reader)
{
}
BOOST_AUTO_TEST_CASE(response_body_writer)
{
}
BOOST_AUTO_TEST_CASE(response_write_fixed)
{
}
BOOST_AUTO_TEST_CASE(response_write_chunked)
{
}
BOOST_AUTO_TEST_CASE(response_sendjsonbody)
{
}
BOOST_AUTO_TEST_SUITE_END()

View File

@ -0,0 +1,380 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */
#include "test/base-tlsstream-fixture.hpp"
#include "test/icingaapplication-fixture.hpp"
#include "base/base64.hpp"
#include "base/json.hpp"
#include "config/configitem.hpp"
#include "config/configcompiler.hpp"
#include "remote/httphandler.hpp"
#include <boost/beast/http.hpp>
#include <boost/algorithm/string.hpp>
#include <boost/container/flat_set.hpp>
#include <set>
#include <BoostTestTargetConfig.h>
using namespace icinga;
using namespace boost::beast;
using namespace boost::unit_test_framework;
struct HttpServerConnectionFixture :
TlsStreamFixture, IcingaApplicationFixture, ConfigurationCacheDirCleanupFixture
{
HttpServerConnectionFixture()
: wg(new StoppableWaitGroup)
{
Logger::SetConsoleLogSeverity(icinga::LogDebug);
}
~HttpServerConnectionFixture() {};
static void CreateApiListener(){
ConfigItem::RunWithActivationContext(new Function("CreateTestUser", []() {
String config = R"CONFIG(
const NodeName = "local"
object Endpoint "local" {}
object Zone "local" {
endpoints = [ "local" ]
}
object ApiListener "api" {
accept_config = false
accept_commands = false
bind_host = "localhost"
bind_port = 123456
ticket_salt = "test"
access_control_allow_origin = ["127.0.0.1"]
}
)CONFIG";
std::unique_ptr<Expression> expr = ConfigCompiler::CompileText("<test>", config);
expr->Evaluate(*ScriptFrame::GetCurrentFrame());
}));
}
static void CreateTestUsers()
{
ApiUser::Ptr user = new ApiUser;
user->SetName("remote");
user->SetClientCN("remote");
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 ? "remote" : "invalid";
conn = new HttpServerConnection(wg, identity, authenticated, server);
conn->Start();
}
void ShutdownTlsConn()
{
boost::system::error_code ec;
if (client->next_layer().shutdown(ec)) {
BOOST_TEST_MESSAGE(ec.message());
throw;
}
}
boost::asio::deadline_timer timeout{IoEngine::Get().GetIoContext()};
HttpServerConnection::Ptr conn;
StoppableWaitGroup::Ptr wg;
};
class UnitTestHandler final: public HttpHandler
{
bool HandleRequest(const WaitGroup::Ptr& waitGroup, HttpRequest& request, HttpResponse& response,
boost::asio::yield_context& yc) override
{
response.result(boost::beast::http::status::ok);
response.body() << "test";
return true;
}
};
REGISTER_URLHANDLER("/v1/test", UnitTestHandler);
BOOST_FIXTURE_TEST_SUITE(remote_httpserverconnection, HttpServerConnectionFixture)
BOOST_FIXTURE_TEST_CASE(setup_certs, SslCertificateFixture)
{
EnsureCertFor("local");
EnsureCertFor("remote");
}
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_NO_THROW(ShutdownTlsConn());
}
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_NO_THROW(ShutdownTlsConn());
}
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_NO_THROW(ShutdownTlsConn());
}
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_NO_THROW(ShutdownTlsConn());
}
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_NO_THROW(ShutdownTlsConn());
}
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_NO_THROW(ShutdownTlsConn());
}
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_NO_THROW(ShutdownTlsConn());
}
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_NO_THROW(ShutdownTlsConn());
}
BOOST_AUTO_TEST_CASE(reuse_connection_and_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();
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");
http::write(*client, request);
client->flush();
wg->Join();
response.body() = "";
http::read(*client, buf, response);
BOOST_REQUIRE_EQUAL(response.result(), http::status::ok);
BOOST_REQUIRE_EQUAL(response.body(), "test");
BOOST_REQUIRE_NO_THROW(ShutdownTlsConn());
}
BOOST_AUTO_TEST_CASE(liveness_disconnect)
{
SetupHttpServerConnection(false);
Utility::Sleep(11);
BOOST_REQUIRE(conn->Disconnected());
BOOST_REQUIRE_NO_THROW(ShutdownTlsConn());
}
BOOST_FIXTURE_TEST_CASE(cleanup_certs, ConfigurationDataDirCleanupFixture) {}
BOOST_AUTO_TEST_SUITE_END()