diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c87679b08..fb94a4384 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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} $ @@ -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 diff --git a/test/base-configuration-fixture.hpp b/test/base-configuration-fixture.hpp new file mode 100644 index 000000000..6523e0ccb --- /dev/null +++ b/test/base-configuration-fixture.hpp @@ -0,0 +1,50 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#ifndef CONFIGURATION_FIXTURE_H +#define CONFIGURATION_FIXTURE_H + +#include "base/configuration.hpp" +#include +#include + +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 diff --git a/test/base-testloggerfixture.hpp b/test/base-testloggerfixture.hpp new file mode 100644 index 000000000..0b926efcf --- /dev/null +++ b/test/base-testloggerfixture.hpp @@ -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 +#include +#include +#include + +namespace icinga { + +class TestLogger : public Logger +{ +public: + DECLARE_PTR_TYPEDEFS(TestLogger); + + struct Expect + { + std::string pattern; + std::promise prom; + std::shared_future 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(); + 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 m_Expects; + std::vector 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 diff --git a/test/base-tlsstream-fixture.hpp b/test/base-tlsstream-fixture.hpp new file mode 100644 index 000000000..7a2b95153 --- /dev/null +++ b/test/base-tlsstream-fixture.hpp @@ -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 +#include + +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::Make(IoEngine::Get().GetIoContext(), *clientSslContext); + + serverSslContext = SetupSslContext(serverCert.crtFile, serverCert.keyFile, + m_CaCrtFile.string(), "", DEFAULT_TLS_CIPHERS, DEFAULT_TLS_PROTOCOLMIN, DebugInfo()); + server = Shared::Make(serverIoContext, *serverSslContext); + + std::promise 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::Ptr& stream, std::optional 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::Ptr clientSslContext; + Shared::Ptr client; + + Shared::Ptr serverSslContext; + Shared::Ptr server; +}; + +} // namespace icinga + +#endif // TLSSTREAM_FIXTURE_H diff --git a/test/icingaapplication-fixture.cpp b/test/icingaapplication-fixture.cpp index 80fa4bfd8..260131c68 100644 --- a/test/icingaapplication-fixture.cpp +++ b/test/icingaapplication-fixture.cpp @@ -26,6 +26,8 @@ void IcingaApplicationFixture::InitIcingaApplication() IcingaApplicationFixture::~IcingaApplicationFixture() { + BOOST_TEST_MESSAGE("Uninitializing Application..."); + Application::UninitializeBase(); IcingaApplication::GetInstance().reset(); } diff --git a/test/remote-httpmessage.cpp b/test/remote-httpmessage.cpp new file mode 100644 index 000000000..d12b6a950 --- /dev/null +++ b/test/remote-httpmessage.cpp @@ -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 + +using namespace icinga; +using namespace boost::beast; + +BOOST_FIXTURE_TEST_SUITE(remote_httpmessage, TlsStreamFixture) + +BOOST_AUTO_TEST_CASE(request_parse) +{ + http::request 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()); + BOOST_REQUIRE(request.Params()->Get("test2").IsBoolean()); + BOOST_REQUIRE(request.Params()->Get("test2").ToBool()); + BOOST_REQUIRE(request.Params()->Get("test1").IsObjectType()); + BOOST_REQUIRE(request.GetLastParameter("test1")); + BOOST_REQUIRE(request.Params()->Get("test3").IsObjectType()); + 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::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 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 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 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 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 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 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 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() diff --git a/test/remote-httpserverconnection.cpp b/test/remote-httpserverconnection.cpp new file mode 100644 index 000000000..48a36cdc1 --- /dev/null +++ b/test/remote-httpserverconnection.cpp @@ -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 +#include +#include +#include +#include + +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 + bool AssertServerDisconnected(const std::chrono::duration& timeout) + { + auto iterations = timeout / std::chrono::milliseconds(50); + for(std::size_t i=0; iDisconnected(); i++){ + Utility::Sleep(std::chrono::duration(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 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 sr(request); + http::write_header(*client, sr); + client->flush(); + + flat_buffer buf; + http::response 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 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 response; + http::read(*client, buf, response); + + BOOST_REQUIRE_EQUAL(response.result(), http::status::bad_request); + BOOST_REQUIRE_NE(response.body().find("

Bad Request

"), std::string::npos); + + BOOST_REQUIRE(Shutdown(client)); +} + +BOOST_AUTO_TEST_CASE(error_access_control) +{ + CreateTestUsers(); + CreateApiListener(); + SetupHttpServerConnection(true); + + http::request 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 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 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 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 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(), "

Accept header is missing or not set to 'application/json'.

\r\n"); + + BOOST_REQUIRE(Shutdown(client)); +} + +BOOST_AUTO_TEST_CASE(error_authenticate_cn) +{ + CreateTestUsers(); + SetupHttpServerConnection(true); + + http::request 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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() diff --git a/test/remote-sslcert-fixture.cpp b/test/remote-sslcert-fixture.cpp new file mode 100644 index 000000000..f5f5b185b --- /dev/null +++ b/test/remote-sslcert-fixture.cpp @@ -0,0 +1,26 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#include "remote-sslcert-fixture.hpp" +#include + +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() diff --git a/test/remote-sslcert-fixture.hpp b/test/remote-sslcert-fixture.hpp new file mode 100644 index 000000000..61fe4c617 --- /dev/null +++ b/test/remote-sslcert-fixture.hpp @@ -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 + +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