From 7373f36cc5651017acff9bb0f13c8cb4b494b885 Mon Sep 17 00:00:00 2001 From: Johannes Schmidt Date: Tue, 22 Jul 2025 13:59:58 +0200 Subject: [PATCH] Add unit-tests for HttpServerConnection and HTTP message classes --- test/CMakeLists.txt | 70 ++++ test/base-configuration-fixture.hpp | 56 +++ test/base-testloggerfixture.hpp | 127 ++++++ test/base-tlsstream-fixture.hpp | 114 ++++++ test/remote-certificate-fixture.cpp | 42 ++ test/remote-certificate-fixture.hpp | 69 ++++ test/remote-httpmessage.cpp | 351 +++++++++++++++++ test/remote-httpserverconnection.cpp | 558 +++++++++++++++++++++++++++ 8 files changed, 1387 insertions(+) create mode 100644 test/base-configuration-fixture.hpp create mode 100644 test/base-testloggerfixture.hpp create mode 100644 test/base-tlsstream-fixture.hpp create mode 100644 test/remote-certificate-fixture.cpp create mode 100644 test/remote-certificate-fixture.hpp create mode 100644 test/remote-httpmessage.cpp create mode 100644 test/remote-httpserverconnection.cpp diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c87679b08..5498a6d83 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-certificate-fixture.cpp remote-configpackageutility.cpp + remote-httpserverconnection.cpp + remote-httpmessage.cpp remote-url.cpp ${base_OBJS} $ @@ -271,6 +274,33 @@ add_boost_test(base icinga_perfdata/parse_edgecases icinga_perfdata/empty_warn_crit_min_max methods_pluginnotificationtask/truncate_long_output + remote_certs_fixture/prepare_directory + remote_certs_fixture/cleanup_certs + remote_httpmessage/request_parse + remote_httpmessage/request_params + remote_httpmessage/response_clear + remote_httpmessage/response_flush_nothrow + remote_httpmessage/response_flush_throw + remote_httpmessage/response_write_empty + remote_httpmessage/response_write_fixed + remote_httpmessage/response_write_chunked + remote_httpmessage/response_sendjsonbody + remote_httpmessage/response_sendjsonerror + remote_httpmessage/response_sendfile + remote_httpserverconnection/expect_100_continue + remote_httpserverconnection/bad_request + remote_httpserverconnection/error_access_control + remote_httpserverconnection/error_accept_header + remote_httpserverconnection/authenticate_cn + remote_httpserverconnection/authenticate_passwd + remote_httpserverconnection/authenticate_error_wronguser + remote_httpserverconnection/authenticate_error_wrongpasswd + remote_httpserverconnection/reuse_connection + remote_httpserverconnection/wg_abort + remote_httpserverconnection/client_shutdown + remote_httpserverconnection/handler_throw_error + remote_httpserverconnection/handler_throw_streaming + remote_httpserverconnection/liveness_disconnect remote_configpackageutility/ValidateName remote_url/id_and_path remote_url/parameters @@ -279,6 +309,46 @@ 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_clear + base-remote_httpmessage/response_flush_nothrow + base-remote_httpmessage/response_flush_throw + 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_httpmessage/response_sendfile + 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/authenticate_cn + base-remote_httpserverconnection/authenticate_passwd + base-remote_httpserverconnection/authenticate_error_wronguser + base-remote_httpserverconnection/authenticate_error_wrongpasswd + base-remote_httpserverconnection/reuse_connection + base-remote_httpserverconnection/wg_abort + base-remote_httpserverconnection/client_shutdown + base-remote_httpserverconnection/handler_throw_error + base-remote_httpserverconnection/handler_throw_streaming + base-remote_httpserverconnection/liveness_disconnect + PROPERTIES FIXTURES_REQUIRED ssl_certs) + + set_tests_properties( + base-remote_certs_fixture/prepare_directory + PROPERTIES FIXTURES_SETUP ssl_certs + ) + + set_tests_properties( + base-remote_certs_fixture/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..2639eb3ff --- /dev/null +++ b/test/base-configuration-fixture.hpp @@ -0,0 +1,56 @@ +/* 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()) + { + boost::filesystem::create_directories(m_DataDir); + Configuration::DataDir = m_DataDir.string(); + } + + ~ConfigurationDataDirFixture() + { + boost::filesystem::remove_all(m_DataDir); + Configuration::DataDir = m_PrevDataDir.string(); + } + + boost::filesystem::path m_DataDir; + +private: + boost::filesystem::path m_PrevDataDir; +}; + +struct ConfigurationCacheDirFixture +{ + ConfigurationCacheDirFixture() + : m_CacheDir(boost::filesystem::current_path() / "cache"), m_PrevCacheDir(Configuration::CacheDir.GetData()) + { + boost::filesystem::create_directories(m_CacheDir); + Configuration::CacheDir = m_CacheDir.string(); + } + + ~ConfigurationCacheDirFixture() + { + boost::filesystem::remove_all(m_CacheDir); + Configuration::CacheDir = m_PrevCacheDir.string(); + } + + boost::filesystem::path m_CacheDir; + +private: + 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..69c073b02 --- /dev/null +++ b/test/base-testloggerfixture.hpp @@ -0,0 +1,127 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#ifndef TEST_LOGGER_FIXTURE_H +#define TEST_LOGGER_FIXTURE_H + +#include +#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; + }; + + 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 expect = std::make_shared(Expect{pattern, std::promise()}); + m_Expects.emplace_back(expect); + lock.unlock(); + + auto future = expect->prom.get_future(); + auto status = future.wait_for(timeout); + boost::test_tools::assertion_result ret{status == std::future_status::ready && future.get()}; + ret.message() << "Pattern \"" << pattern << "\" in log within " << timeout.count() << "ms"; + + lock.lock(); + m_Expects.erase(boost::range::remove(m_Expects, expect), m_Expects.end()); + + return ret; + } + +private: + void ProcessLogEntry(const LogEntry& entry) override + { + std::unique_lock lock(m_Mutex); + m_LogEntries.push_back(entry); + + auto it = boost::range::remove_if(m_Expects, [&entry](const std::shared_ptr& expect) { + if (boost::regex_match(entry.Message.GetData(), boost::regex(expect->pattern))) { + expect->prom.set_value(true); + return true; + } + return false; + }); + m_Expects.erase(it, m_Expects.end()); + } + + void Flush() override {} + + std::mutex m_Mutex; + std::vector> m_Expects; + std::vector m_LogEntries; +}; + +/** + * A fixture to capture log entries and assert their presence in tests. + * + * Currently, this only supports checking existing entries and waiting for new ones + * using ExpectLogPattern(), but more functionality can easily be added in the future, + * like only asserting on past log messages, only waiting for new ones, asserting log + * entry metadata (severity etc.) and so on. + */ +struct TestLoggerFixture +{ + TestLoggerFixture() + { + testLogger->SetSeverity(testLogger->SeverityToString(LogDebug)); + testLogger->Activate(true); + testLogger->SetActive(true); + } + + ~TestLoggerFixture() + { + testLogger->SetActive(false); + testLogger->Deactivate(true); + } + + /** + * Asserts the presence of a log entry that matches the given regex pattern. + * + * First, the existing log entries are searched for the pattern. If the pattern isn't found, + * until the timeout is reached, the function will wait if a new log message is added that + * matches the pattern. + * + * A boost assertion result object is returned, that evaluates to bool, but contains an + * error message that is printed by the testing framework when the assert failed. + * + * @param pattern The regex pattern the log message needs to match + * @param timeout The maximum amount of time to wait for the log message to arrive + * + * @return A @c boost::test_tools::assertion_result object that can be used in BOOST_REQUIRE + */ + 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..3d1327b63 --- /dev/null +++ b/test/base-tlsstream-fixture.hpp @@ -0,0 +1,114 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#pragma once + +#include "base/io-engine.hpp" +#include "base/tlsstream.hpp" +#include "test/remote-certificate-fixture.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& io = IoEngine::Get().GetIoContext(); + + m_ClientSslContext = SetupSslContext(clientCert.crtFile, clientCert.keyFile, m_CaCrtFile.string(), "", + DEFAULT_TLS_CIPHERS, DEFAULT_TLS_PROTOCOLMIN, DebugInfo()); + client = Shared::Make(io, *m_ClientSslContext); + + m_ServerSslContext = SetupSslContext(serverCert.crtFile, serverCert.keyFile, m_CaCrtFile.string(), "", + DEFAULT_TLS_CIPHERS, DEFAULT_TLS_PROTOCOLMIN, DebugInfo()); + server = Shared::Make(io, *m_ServerSslContext); + + std::promise p; + + tcp::acceptor acceptor{io, tcp::endpoint{address_v4::loopback(), 0}}; + acceptor.listen(); + acceptor.async_accept(server->lowest_layer(), [&](const boost::system::error_code& ec) { + if (ec) { + BOOST_TEST_MESSAGE("Server Accept Error: " + ec.message()); + p.set_exception(std::make_exception_ptr(boost::system::system_error{ec})); + return; + } + server->next_layer().async_handshake(handshake_type::server, [&](const boost::system::error_code& ec) { + if (ec) { + BOOST_TEST_MESSAGE("Server Handshake Error: " + ec.message()); + p.set_exception(std::make_exception_ptr(boost::system::system_error{ec})); + return; + } + + if (!server->next_layer().IsVerifyOK()) { + p.set_exception(std::make_exception_ptr(std::runtime_error{"Verify failed on server-side."})); + } + + p.set_value(); + }); + }); + + auto f = p.get_future(); + boost::system::error_code ec; + if (client->lowest_layer().connect(acceptor.local_endpoint(), ec)) { + BOOST_TEST_MESSAGE("Client Connect error: " + ec.message()); + f.get(); + BOOST_THROW_EXCEPTION(boost::system::system_error{ec}); + } + + if (client->next_layer().handshake(handshake_type::client, ec)) { + BOOST_TEST_MESSAGE("Client Handshake error: " + ec.message()); + f.get(); + BOOST_THROW_EXCEPTION(boost::system::system_error{ec}); + } + + if (!client->next_layer().IsVerifyOK()) { + f.get(); + BOOST_THROW_EXCEPTION(std::runtime_error{"Verify failed on client-side."}); + } + + f.get(); + } + + 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 client; + Shared::Ptr server; + +private: + Shared::Ptr m_ClientSslContext; + Shared::Ptr m_ServerSslContext; +}; + +} // namespace icinga diff --git a/test/remote-certificate-fixture.cpp b/test/remote-certificate-fixture.cpp new file mode 100644 index 000000000..adb260740 --- /dev/null +++ b/test/remote-certificate-fixture.cpp @@ -0,0 +1,42 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#include "remote-certificate-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_fixture) + +/** + * Recursively removes the directory that contains the test certificates. + * + * This needs to be done once initially to prepare the directory, in case there are any + * left-overs from previous test runs, and once after all tests using the certificates + * have been completed. + * + * This dependency is expressed as a CTest fixture and not a boost-test one, because that + * is the only way to have persistency between individual test-cases with CTest. + */ +static void CleanupPersistentCertificateDir() +{ + if (boost::filesystem::exists(CertificateFixture::m_PersistentCertsDir)) { + boost::filesystem::remove_all(CertificateFixture::m_PersistentCertsDir); + } +} + +BOOST_FIXTURE_TEST_CASE(prepare_directory, ConfigurationDataDirFixture) +{ + // Remove any existing left-overs of the persistent certificate directory from a previous + // test run. + CleanupPersistentCertificateDir(); +} + +BOOST_FIXTURE_TEST_CASE(cleanup_certs, ConfigurationDataDirFixture) +{ + CleanupPersistentCertificateDir(); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/remote-certificate-fixture.hpp b/test/remote-certificate-fixture.hpp new file mode 100644 index 000000000..1e8ad645a --- /dev/null +++ b/test/remote-certificate-fixture.hpp @@ -0,0 +1,69 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#pragma once + +#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 Cert + { + String crtFile; + String keyFile; + String csrFile; + }; + + Cert 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 diff --git a/test/remote-httpmessage.cpp b/test/remote-httpmessage.cpp new file mode 100644 index 000000000..5c79d1cc2 --- /dev/null +++ b/test/remote-httpmessage.cpp @@ -0,0 +1,351 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#include +#include "base/base64.hpp" +#include "base/json.hpp" +#include "remote/httpmessage.hpp" +#include "remote/httputility.hpp" +#include "test/base-tlsstream-fixture.hpp" +#include +#include + +using namespace icinga; +using namespace boost::beast; + +static std::future SpawnSynchronizedCoroutine(std::function fn) +{ + auto promise = std::make_unique>(); + auto future = promise->get_future(); + auto& io = IoEngine::Get().GetIoContext(); + IoEngine::SpawnCoroutine(io, [promise = std::move(promise), fn = std::move(fn)](boost::asio::yield_context yc) { + try { + fn(std::move(yc)); + } catch (const std::exception&) { + promise->set_exception(std::current_exception()); + return; + } + promise->set_value(); + }); + return future; +} + +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.body() = "test"; + requestOut.prepare_payload(); + + auto future = SpawnSynchronizedCoroutine([this, &requestOut](boost::asio::yield_context yc) { + boost::beast::flat_buffer buf; + HttpRequest request(server); + BOOST_REQUIRE_NO_THROW(request.ParseHeader(buf, yc)); + + for (const auto& field : requestOut.base()) { + BOOST_REQUIRE(request.count(field.name())); + } + + BOOST_REQUIRE_NO_THROW(request.ParseBody(buf, yc)); + BOOST_REQUIRE_EQUAL(request.body(), "test"); + + Shutdown(server, yc); + }); + + http::write(*client, requestOut); + client->flush(); + + Shutdown(client); + future.get(); +} + +BOOST_AUTO_TEST_CASE(request_params) +{ + HttpRequest request(client); + // clang-format off + request.body() = JsonEncode( + new Dictionary{ + {"bool-in-json", true}, + {"bool-in-url-and-json", true}, + {"string-in-json", "json-value"}, + {"string-in-url-and-json", "json-value"} + }); + request.target("https://localhost:1234/v1/test?" + "bool-in-url-and-json=0&" + "bool-in-url=1&" + "string-in-url-and-json=url-value&" + "string-only-in-url=url-value" + ); + // clang-format on + + // Test pointer being valid after decode + request.DecodeParams(); + auto params = request.Params(); + BOOST_REQUIRE(params); + + // Test JSON-only params being parsed as their correct type + BOOST_REQUIRE(params->Get("bool-in-json").IsBoolean()); + BOOST_REQUIRE(params->Get("string-in-json").IsString()); + BOOST_REQUIRE(params->Get("bool-in-url-and-json").IsObjectType()); + BOOST_REQUIRE(params->Get("string-in-url-and-json").IsObjectType()); + + // Test 0/1 string values from URL evaluate to true and false + // These currently get implicitly converted to double and then to bool, but this is an + // implementation we don't need to test for here. + BOOST_REQUIRE_EQUAL(HttpUtility::GetLastParameter(params, "bool-in-url-and-json"), "0"); + BOOST_REQUIRE(!HttpUtility::GetLastParameter(params, "bool-in-url-and-json")); + BOOST_REQUIRE_EQUAL(HttpUtility::GetLastParameter(params, "bool-in-url"), "1"); + BOOST_REQUIRE(HttpUtility::GetLastParameter(params, "bool-in-url")); + + // Test non-existing parameters evaluate to false + BOOST_REQUIRE(HttpUtility::GetLastParameter(params, "does-not-exist").IsEmpty()); + BOOST_REQUIRE(!HttpUtility::GetLastParameter(params, "does-not-exist")); + + // Test precedence of URL params over JSON params + BOOST_REQUIRE_EQUAL(HttpUtility::GetLastParameter(params, "string-in-json"), "json-value"); + BOOST_REQUIRE_EQUAL(HttpUtility::GetLastParameter(params, "string-in-url-and-json"), "url-value"); + BOOST_REQUIRE_EQUAL(HttpUtility::GetLastParameter(params, "string-only-in-url"), "url-value"); +} + +BOOST_AUTO_TEST_CASE(response_clear) +{ + HttpResponse response(server); + response.result(http::status::bad_request); + response.version(10); + response.set(http::field::content_type, "text/html"); + response.body() << "test"; + + response.Clear(); + + BOOST_REQUIRE(response[http::field::content_type].empty()); + BOOST_REQUIRE_EQUAL(response.result(), http::status::ok); + BOOST_REQUIRE_EQUAL(response.version(), 11); + BOOST_REQUIRE_EQUAL(response.body().Size(), 0); +} + +BOOST_AUTO_TEST_CASE(response_flush_nothrow) +{ + auto future = SpawnSynchronizedCoroutine([this](const 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); + }); + + auto status = future.wait_for(std::chrono::seconds(1)); + BOOST_REQUIRE(status == std::future_status::ready); +} + +BOOST_AUTO_TEST_CASE(response_flush_throw) +{ + auto future = SpawnSynchronizedCoroutine([this](const boost::asio::yield_context& yc) { + HttpResponse response(server); + response.result(http::status::ok); + + server->lowest_layer().close(); + + BOOST_REQUIRE_EXCEPTION(response.Flush(yc), std::exception, [](const std::exception& ex) { + auto se = dynamic_cast(&ex); + return se && se->code() == boost::system::errc::bad_file_descriptor; + }); + }); + + auto status = future.wait_for(std::chrono::seconds(1)); + BOOST_REQUIRE(status == std::future_status::ready); +} + +BOOST_AUTO_TEST_CASE(response_write_empty) +{ + auto future = SpawnSynchronizedCoroutine([this](boost::asio::yield_context yc) { + HttpResponse response(server); + response.result(http::status::ok); + + BOOST_REQUIRE_NO_THROW(response.Flush(yc)); + + Shutdown(server, yc); + }); + + http::response_parser parser; + flat_buffer buf; + boost::system::error_code ec; + http::read(*client, buf, parser, ec); + + Shutdown(client); + + future.get(); + + BOOST_REQUIRE(!ec); + BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok); + BOOST_REQUIRE_EQUAL(parser.get().chunked(), false); + BOOST_REQUIRE_EQUAL(parser.get().body(), ""); +} + +BOOST_AUTO_TEST_CASE(response_write_fixed) +{ + auto future = SpawnSynchronizedCoroutine([this](boost::asio::yield_context yc) { + HttpResponse response(server); + response.result(http::status::ok); + response.body() << "test"; + + BOOST_REQUIRE_NO_THROW(response.Flush(yc)); + + Shutdown(server, yc); + }); + + http::response_parser parser; + flat_buffer buf; + boost::system::error_code ec; + http::read(*client, buf, parser, ec); + + Shutdown(client); + + future.get(); + + BOOST_REQUIRE(!ec); + BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok); + BOOST_REQUIRE_EQUAL(parser.get().chunked(), false); + BOOST_REQUIRE_EQUAL(parser.get().body(), "test"); +} + +BOOST_AUTO_TEST_CASE(response_write_chunked) +{ + // NOLINTNEXTLINE(readability-function-cognitive-complexity) + auto future = SpawnSynchronizedCoroutine([this](boost::asio::yield_context yc) { + HttpResponse response(server); + response.result(http::status::ok); + + response.StartStreaming(); + BOOST_REQUIRE_NO_THROW(response.Flush(yc)); + BOOST_REQUIRE(response.HasSerializationStarted()); + + 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)); + + Shutdown(server, yc); + }); + + http::response_parser parser; + flat_buffer buf; + boost::system::error_code ec; + http::read(*client, buf, parser, ec); + + Shutdown(client); + + future.get(); + + BOOST_REQUIRE(!ec); + BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok); + BOOST_REQUIRE_EQUAL(parser.get().chunked(), true); + BOOST_REQUIRE_EQUAL(parser.get().body(), "test1test2test3"); +} + +BOOST_AUTO_TEST_CASE(response_sendjsonbody) +{ + auto future = SpawnSynchronizedCoroutine([this](boost::asio::yield_context yc) { + HttpResponse response(server); + response.result(http::status::ok); + + HttpUtility::SendJsonBody(response, nullptr, new Dictionary{{"test", 1}}); + + BOOST_REQUIRE_NO_THROW(response.Flush(yc)); + + Shutdown(server, yc); + }); + + http::response_parser parser; + flat_buffer buf; + boost::system::error_code ec; + http::read(*client, buf, parser, ec); + + Shutdown(client); + + future.get(); + + BOOST_REQUIRE(!ec); + 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_AUTO_TEST_CASE(response_sendjsonerror) +{ + auto future = SpawnSynchronizedCoroutine([this](boost::asio::yield_context yc) { + HttpResponse response(server); + + // This has to be overwritten in SendJsonError. + response.result(http::status::ok); + + HttpUtility::SendJsonError(response, nullptr, 404, "Not found."); + + BOOST_REQUIRE_NO_THROW(response.Flush(yc)); + + Shutdown(server, yc); + }); + + http::response_parser parser; + flat_buffer buf; + boost::system::error_code ec; + http::read(*client, buf, parser, ec); + + Shutdown(client); + + future.get(); + + BOOST_REQUIRE(!ec); + 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_AUTO_TEST_CASE(response_sendfile) +{ + auto future = SpawnSynchronizedCoroutine([this](boost::asio::yield_context yc) { + HttpResponse response(server); + + response.result(http::status::ok); + BOOST_REQUIRE_NO_THROW(response.SendFile(m_CaCrtFile.string(), yc)); + BOOST_REQUIRE_NO_THROW(response.Flush(yc)); + + Shutdown(server, yc); + }); + + http::response_parser parser; + flat_buffer buf; + boost::system::error_code ec; + http::read(*client, buf, parser, ec); + + Shutdown(client); + + future.get(); + + BOOST_REQUIRE(!ec); + BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok); + BOOST_REQUIRE_EQUAL(parser.get().chunked(), false); + + std::ifstream fp(m_CaCrtFile.string(), std::ifstream::in | std::ifstream::binary); + fp.exceptions(std::ifstream::badbit); + std::stringstream ss; + ss << fp.rdbuf(); + BOOST_REQUIRE_EQUAL(ss.str(), parser.get().body()); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/remote-httpserverconnection.cpp b/test/remote-httpserverconnection.cpp new file mode 100644 index 000000000..e5c202770 --- /dev/null +++ b/test/remote-httpserverconnection.cpp @@ -0,0 +1,558 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#include +#include "base/base64.hpp" +#include "base/json.hpp" +#include "remote/httphandler.hpp" +#include "test/base-testloggerfixture.hpp" +#include "test/base-tlsstream-fixture.hpp" +#include +#include +#include + +using namespace icinga; +using namespace boost::beast; +using namespace boost::unit_test_framework; + +struct HttpServerConnectionFixture : TlsStreamFixture, ConfigurationCacheDirFixture, TestLoggerFixture +{ + HttpServerConnection::Ptr m_Connection; + StoppableWaitGroup::Ptr m_WaitGroup; + + HttpServerConnectionFixture() : m_WaitGroup(new StoppableWaitGroup) {} + + static void CreateApiListener(const String& allowOrigin) + { + ScriptGlobal::Set("NodeName", "server"); + ApiListener::Ptr listener = new ApiListener; + listener->OnConfigLoaded(); + listener->SetAccessControlAllowOrigin(new Array{allowOrigin}); + } + + 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"; + m_Connection = new HttpServerConnection(m_WaitGroup, identity, authenticated, server); + m_Connection->Start(); + } + + template + bool AssertServerDisconnected(const std::chrono::duration& timeout) + { + auto iterations = timeout / std::chrono::milliseconds(50); + for (std::size_t i = 0; i < iterations && !m_Connection->Disconnected(); i++) { + Utility::Sleep(std::chrono::duration(timeout).count() / iterations); + } + return m_Connection->Disconnected(); + } +}; + +class UnitTestHandler final : public HttpHandler +{ +public: + using TestFn = std::function; + + static void RegisterTestFn(std::string handle, TestFn fn) { testFns[std::move(handle)] = std::move(fn); } + +private: + bool HandleRequest(const WaitGroup::Ptr&, const HttpRequest& request, HttpResponse& response, + boost::asio::yield_context& yc) override + { + response.result(boost::beast::http::status::ok); + + auto path = request.Url()->GetPath(); + + if (path.size() == 3) { + if (auto it = testFns.find(path[2].GetData()); it != testFns.end()) { + it->second(response, yc); + return true; + } + } + + response.body() << "test"; + return true; + } + + static inline std::unordered_map testFns; +}; + +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("/v1/test"); + request.set(http::field::expect, "100-continue"); + request.set(http::field::host, "localhost:5665"); + 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; + BOOST_REQUIRE_NO_THROW(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(); + + BOOST_REQUIRE_NO_THROW(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("/v1/test"); + request.set(http::field::host, "localhost:5665"); + 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; + BOOST_REQUIRE_NO_THROW(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("example.org"); + SetupHttpServerConnection(true); + + http::request request; + request.method(http::verb::options); + request.target("/v1/test"); + request.set(http::field::origin, "example.org"); + request.set(http::field::host, "localhost:5665"); + 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; + BOOST_REQUIRE_NO_THROW(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_REQUIRE_EQUAL(response[http::field::access_control_allow_credentials], "true"); + BOOST_REQUIRE_EQUAL(response[http::field::access_control_allow_origin], "example.org"); + 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("/v1/test"); + request.set(http::field::host, "localhost:5665"); + 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; + BOOST_REQUIRE_NO_THROW(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'.

"); + + BOOST_REQUIRE(Shutdown(client)); +} + +BOOST_AUTO_TEST_CASE(authenticate_cn) +{ + CreateTestUsers(); + SetupHttpServerConnection(true); + + http::request request; + request.method(http::verb::get); + request.target("/v1/test"); + request.set(http::field::host, "localhost:5665"); + 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; + BOOST_REQUIRE_NO_THROW(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(authenticate_passwd) +{ + CreateTestUsers(); + SetupHttpServerConnection(false); + + http::request request; + request.method(http::verb::get); + request.target("/v1/test"); + request.set(http::field::authorization, "Basic " + Base64::Encode("test:test")); + request.set(http::field::host, "localhost:5665"); + 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; + BOOST_REQUIRE_NO_THROW(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(authenticate_error_wronguser) +{ + CreateTestUsers(); + SetupHttpServerConnection(false); + + http::request request; + request.method(http::verb::get); + request.target("/v1/test"); + request.set(http::field::authorization, "Basic " + Base64::Encode("invalid:invalid")); + request.set(http::field::host, "localhost:5665"); + 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; + BOOST_REQUIRE_NO_THROW(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(authenticate_error_wrongpasswd) +{ + CreateTestUsers(); + SetupHttpServerConnection(false); + + http::request request; + request.method(http::verb::get); + request.target("/v1/test"); + request.set(http::field::authorization, "Basic " + Base64::Encode("test:invalid")); + request.set(http::field::host, "localhost:5665"); + 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; + BOOST_REQUIRE_NO_THROW(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("/v1/test"); + request.set(http::field::host, "localhost:5665"); + request.set(http::field::accept, "application/json"); + request.keep_alive(true); + http::write(*client, request); + client->flush(); + + flat_buffer buf; + http::response response; + BOOST_REQUIRE_NO_THROW(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(); + + boost::system::error_code ec; + http::response_parser parser; + BOOST_REQUIRE_NO_THROW(http::read(*client, buf, parser)); + + BOOST_REQUIRE(parser.is_header_done()); + BOOST_REQUIRE(parser.is_done()); + BOOST_REQUIRE_EQUAL(parser.get().version(), 11); + BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok); + BOOST_REQUIRE_EQUAL(parser.get().body(), "test"); + + // Second read to get the end of stream error; + http::read(*client, buf, response, ec); + BOOST_REQUIRE_EQUAL(ec, boost::system::error_code{boost::beast::http::error::end_of_stream}); + + 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); + + UnitTestHandler::RegisterTestFn("wgjoin", [this](HttpResponse& response, const boost::asio::yield_context&) { + response.body() << "test"; + m_WaitGroup->Join(); + }); + + http::request request; + request.method(http::verb::get); + request.target("/v1/test/wgjoin"); + request.set(http::field::host, "localhost:5665"); + request.set(http::field::accept, "application/json"); + request.keep_alive(true); + http::write(*client, request); + client->flush(); + + flat_buffer buf; + http::response_parser parser; + BOOST_REQUIRE_NO_THROW(http::read(*client, buf, parser)); + + BOOST_REQUIRE(parser.is_header_done()); + BOOST_REQUIRE(parser.is_done()); + BOOST_REQUIRE_EQUAL(parser.get().version(), 11); + BOOST_REQUIRE_EQUAL(parser.get().result(), http::status::ok); + BOOST_REQUIRE_EQUAL(parser.get().body(), "test"); + + // Second read to get the end of stream error; + http::response response{}; + boost::system::error_code ec; + http::read(*client, buf, response, ec); + BOOST_REQUIRE_EQUAL(ec, boost::system::error_code{boost::beast::http::error::end_of_stream}); + + 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); + + UnitTestHandler::RegisterTestFn("stream", [](HttpResponse& response, const boost::asio::yield_context& yc) { + response.StartStreaming(); + response.Flush(yc); + + boost::asio::deadline_timer dt{IoEngine::Get().GetIoContext()}; + for (;;) { + dt.expires_from_now(boost::posix_time::seconds(1)); + dt.async_wait(yc); + + if (!response.IsClientDisconnected()) { + return; + } + + response.body() << "test"; + response.Flush(yc); + } + }); + + http::request request; + request.method(http::verb::get); + request.target("/v1/test/stream"); + request.set(http::field::host, "localhost:5665"); + request.set(http::field::accept, "application/json"); + request.keep_alive(true); + http::write(*client, request); + client->flush(); + + flat_buffer buf; + http::response_parser parser; + BOOST_REQUIRE_NO_THROW(http::read_header(*client, buf, parser)); + BOOST_REQUIRE(parser.is_header_done()); + + /* 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 may 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_AUTO_TEST_CASE(handler_throw_error) +{ + CreateTestUsers(); + SetupHttpServerConnection(true); + + UnitTestHandler::RegisterTestFn("throw", [](HttpResponse& response, const boost::asio::yield_context&) { + response.StartStreaming(); + response.body() << "test"; + + boost::system::error_code ec{}; + throw boost::system::system_error(ec); + }); + + http::request request; + request.method(http::verb::get); + request.target("/v1/test/throw"); + request.set(http::field::host, "localhost:5665"); + request.set(http::field::accept, "application/json"); + request.keep_alive(false); + http::write(*client, request); + client->flush(); + + flat_buffer buf; + http::response response; + BOOST_REQUIRE_NO_THROW(http::read(*client, buf, response)); + + BOOST_REQUIRE_EQUAL(response.version(), 11); + BOOST_REQUIRE_EQUAL(response.result(), http::status::internal_server_error); + Dictionary::Ptr body = JsonDecode(response.body()); + BOOST_REQUIRE(body); + BOOST_REQUIRE_EQUAL(body->Get("error"), 500); + BOOST_REQUIRE_EQUAL(body->Get("status"), "Unhandled exception"); + + BOOST_REQUIRE(Shutdown(client)); + BOOST_REQUIRE(ExpectLogPattern("HTTP client disconnected .*", std::chrono::seconds(5))); + BOOST_REQUIRE(!ExpectLogPattern("Exception while processing HTTP request.*")); +} + +BOOST_AUTO_TEST_CASE(handler_throw_streaming) +{ + CreateTestUsers(); + SetupHttpServerConnection(true); + + UnitTestHandler::RegisterTestFn("throw", [](HttpResponse& response, const boost::asio::yield_context& yc) { + response.StartStreaming(); + response.body() << "test"; + + response.Flush(yc); + + boost::system::error_code ec{}; + throw boost::system::system_error(ec); + }); + + http::request request; + request.method(http::verb::get); + request.target("/v1/test/throw"); + request.set(http::field::host, "localhost:5665"); + request.set(http::field::accept, "application/json"); + request.keep_alive(true); + + 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. + */ + BOOST_REQUIRE_EQUAL(ec, boost::system::error_code{boost::beast::http::error::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.*")); +} + +BOOST_AUTO_TEST_CASE(liveness_disconnect) +{ + SetupHttpServerConnection(false); + + BOOST_REQUIRE(AssertServerDisconnected(std::chrono::seconds(11))); + BOOST_REQUIRE(ExpectLogPattern("HTTP client disconnected .*")); + BOOST_REQUIRE(ExpectLogPattern("No messages for HTTP connection have been received in the last 10 seconds.")); + BOOST_REQUIRE(Shutdown(client)); +} + +BOOST_AUTO_TEST_SUITE_END()