From cffda52ba6e695f322e35a1fbd3cbdd2e2260fcb Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Thu, 24 Oct 2024 16:50:26 +0100 Subject: [PATCH] refactor: Coroutine based webserver (#1699) Code of new coroutine-based web server. The new server is not connected to Clio and not ready to use yet. For #919. --- .github/workflows/build.yml | 12 - docs/examples/config/example-config.json | 9 +- src/util/CMakeLists.txt | 1 + src/util/CoroutineGroup.cpp | 76 +++ src/util/CoroutineGroup.hpp | 88 ++++ src/util/WithTimeout.hpp | 71 +++ src/util/requests/impl/WsConnectionImpl.hpp | 43 +- src/web/CMakeLists.txt | 8 +- src/web/Server.hpp | 13 +- src/web/impl/AdminVerificationStrategy.cpp | 17 + src/web/impl/AdminVerificationStrategy.hpp | 6 + .../web/ng/Connection.cpp | 39 +- src/web/ng/Connection.hpp | 148 ++++++ src/web/ng/Error.hpp | 31 ++ src/web/ng/MessageHandler.hpp | 37 ++ src/web/ng/Request.cpp | 131 +++++ src/web/ng/Request.hpp | 145 ++++++ src/web/ng/Response.cpp | 116 +++++ src/web/ng/Response.hpp | 106 ++++ src/web/ng/Server.cpp | 322 +++++++++++++ src/web/ng/Server.hpp | 147 ++++++ src/web/ng/impl/Concepts.hpp | 35 ++ src/web/ng/impl/ConnectionHandler.cpp | 285 +++++++++++ src/web/ng/impl/ConnectionHandler.hpp | 130 +++++ src/web/ng/impl/HttpConnection.hpp | 219 +++++++++ src/web/{ => ng}/impl/ServerSslContext.cpp | 45 +- src/web/{ => ng}/impl/ServerSslContext.hpp | 12 +- src/web/ng/impl/WsConnection.cpp | 77 +++ src/web/ng/impl/WsConnection.hpp | 178 +++++++ tests/common/CMakeLists.txt | 11 +- .../{WithTimeout.cpp => CallWithTimeout.cpp} | 4 +- .../{WithTimeout.hpp => CallWithTimeout.hpp} | 2 +- tests/common/util/TestHttpClient.cpp | 250 ++++++++++ tests/common/util/TestHttpClient.hpp | 99 ++++ tests/common/util/TestHttpServer.cpp | 19 +- tests/common/util/TestHttpServer.hpp | 11 + tests/common/util/TestHttpSyncClient.hpp | 270 ----------- tests/common/util/TestWebSocketClient.cpp | 225 +++++++++ tests/common/util/TestWebSocketClient.hpp | 94 ++++ tests/common/util/TmpFile.hpp | 22 +- tests/common/web/ng/MockConnection.hpp | 62 +++ tests/unit/CMakeLists.txt | 19 +- tests/unit/test_data/SslCert.cpp | 105 ++++ .../unit/test_data/SslCert.hpp | 34 +- tests/unit/test_data/cert.pem | 22 - tests/unit/test_data/key.pem | 27 -- tests/unit/util/CoroutineGroupTests.cpp | 167 +++++++ tests/unit/util/RepeatTests.cpp | 4 +- tests/unit/util/WithTimeout.cpp | 77 +++ tests/unit/web/ServerTests.cpp | 46 +- .../web/{ => impl}/AdminVerificationTests.cpp | 90 +++- tests/unit/web/ng/RequestTests.cpp | 224 +++++++++ tests/unit/web/ng/ResponseTests.cpp | 126 +++++ tests/unit/web/ng/ServerTests.cpp | 332 +++++++++++++ .../web/ng/impl/ConnectionHandlerTests.cpp | 453 ++++++++++++++++++ .../unit/web/ng/impl/HttpConnectionTests.cpp | 296 ++++++++++++ .../web/ng/impl/ServerSslContextTests.cpp | 181 +++++++ tests/unit/web/ng/impl/WsConnectionTests.cpp | 250 ++++++++++ 58 files changed, 5581 insertions(+), 488 deletions(-) create mode 100644 src/util/CoroutineGroup.cpp create mode 100644 src/util/CoroutineGroup.hpp create mode 100644 src/util/WithTimeout.hpp rename tests/unit/web/impl/ServerSslContextTests.cpp => src/web/ng/Connection.cpp (56%) create mode 100644 src/web/ng/Connection.hpp create mode 100644 src/web/ng/Error.hpp create mode 100644 src/web/ng/MessageHandler.hpp create mode 100644 src/web/ng/Request.cpp create mode 100644 src/web/ng/Request.hpp create mode 100644 src/web/ng/Response.cpp create mode 100644 src/web/ng/Response.hpp create mode 100644 src/web/ng/impl/Concepts.hpp create mode 100644 src/web/ng/impl/ConnectionHandler.cpp create mode 100644 src/web/ng/impl/ConnectionHandler.hpp create mode 100644 src/web/ng/impl/HttpConnection.hpp rename src/web/{ => ng}/impl/ServerSslContext.cpp (58%) rename src/web/{ => ng}/impl/ServerSslContext.hpp (79%) create mode 100644 src/web/ng/impl/WsConnection.cpp create mode 100644 src/web/ng/impl/WsConnection.hpp rename tests/common/util/{WithTimeout.cpp => CallWithTimeout.cpp} (92%) rename tests/common/util/{WithTimeout.hpp => CallWithTimeout.hpp} (93%) create mode 100644 tests/common/util/TestHttpClient.cpp create mode 100644 tests/common/util/TestHttpClient.hpp delete mode 100644 tests/common/util/TestHttpSyncClient.hpp create mode 100644 tests/common/util/TestWebSocketClient.cpp create mode 100644 tests/common/util/TestWebSocketClient.hpp create mode 100644 tests/common/web/ng/MockConnection.hpp create mode 100644 tests/unit/test_data/SslCert.cpp rename src/web/Server.cpp => tests/unit/test_data/SslCert.hpp (54%) delete mode 100644 tests/unit/test_data/cert.pem delete mode 100644 tests/unit/test_data/key.pem create mode 100644 tests/unit/util/CoroutineGroupTests.cpp create mode 100644 tests/unit/util/WithTimeout.cpp rename tests/unit/web/{ => impl}/AdminVerificationTests.cpp (60%) create mode 100644 tests/unit/web/ng/RequestTests.cpp create mode 100644 tests/unit/web/ng/ResponseTests.cpp create mode 100644 tests/unit/web/ng/ServerTests.cpp create mode 100644 tests/unit/web/ng/impl/ConnectionHandlerTests.cpp create mode 100644 tests/unit/web/ng/impl/HttpConnectionTests.cpp create mode 100644 tests/unit/web/ng/impl/ServerSslContextTests.cpp create mode 100644 tests/unit/web/ng/impl/WsConnectionTests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8342fd03..4471bf5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -149,13 +149,6 @@ jobs: name: clio_tests_${{ runner.os }}_${{ matrix.build_type }}_${{ steps.conan.outputs.conan_profile }} path: build/clio_*tests - - name: Upload test data - if: ${{ !matrix.code_coverage }} - uses: actions/upload-artifact@v4 - with: - name: clio_test_data_${{ runner.os }}_${{ matrix.build_type }}_${{ steps.conan.outputs.conan_profile }} - path: build/tests/unit/test_data - - name: Save cache uses: ./.github/actions/save_cache with: @@ -219,11 +212,6 @@ jobs: with: name: clio_tests_${{ runner.os }}_${{ matrix.build_type }}_${{ matrix.conan_profile }} - - uses: actions/download-artifact@v4 - with: - name: clio_test_data_${{ runner.os }}_${{ matrix.build_type }}_${{ matrix.conan_profile }} - path: tests/unit/test_data - - name: Run clio_tests run: | chmod +x ./clio_tests diff --git a/docs/examples/config/example-config.json b/docs/examples/config/example-config.json index 41e123ae..6f3ff0c9 100644 --- a/docs/examples/config/example-config.json +++ b/docs/examples/config/example-config.json @@ -70,7 +70,14 @@ "admin_password": "xrp", // If local_admin is true, Clio will consider requests come from 127.0.0.1 as admin requests // It's true by default unless admin_password is set,'local_admin' : true and 'admin_password' can not be set at the same time - "local_admin": false + "local_admin": false, + "processing_policy": "parallel", // Could be "sequent" or "parallel". + // For sequent policy request from one client connection will be processed one by one and the next one will not be read before + // the previous one is processed. For parallel policy Clio will take all requests and process them in parallel and + // send a reply for each request whenever it is ready. + "parallel_requests_limit": 10 // Optional parameter, used only if "processing_strategy" is "parallel". + It limits the number of requests for one client connection processed in parallel. Infinite if not specified. + }, // Time in seconds for graceful shutdown. Defaults to 10 seconds. Not fully implemented yet. "graceful_period": 10.0, diff --git a/src/util/CMakeLists.txt b/src/util/CMakeLists.txt index 910542ce..8c28fdf9 100644 --- a/src/util/CMakeLists.txt +++ b/src/util/CMakeLists.txt @@ -4,6 +4,7 @@ target_sources( clio_util PRIVATE build/Build.cpp config/Config.cpp + CoroutineGroup.cpp log/Logger.cpp prometheus/Http.cpp prometheus/Label.cpp diff --git a/src/util/CoroutineGroup.cpp b/src/util/CoroutineGroup.cpp new file mode 100644 index 00000000..27148607 --- /dev/null +++ b/src/util/CoroutineGroup.cpp @@ -0,0 +1,76 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/CoroutineGroup.hpp" + +#include "util/Assert.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace util { + +CoroutineGroup::CoroutineGroup(boost::asio::yield_context yield, std::optional maxChildren) + : timer_{yield.get_executor(), boost::asio::steady_timer::duration::max()}, maxChildren_{maxChildren} +{ +} + +CoroutineGroup::~CoroutineGroup() +{ + ASSERT(childrenCounter_ == 0, "CoroutineGroup is destroyed without waiting for child coroutines to finish"); +} + +bool +CoroutineGroup::spawn(boost::asio::yield_context yield, std::function fn) +{ + if (maxChildren_.has_value() && childrenCounter_ >= *maxChildren_) + return false; + + ++childrenCounter_; + boost::asio::spawn(yield, [this, fn = std::move(fn)](boost::asio::yield_context yield) { + fn(yield); + --childrenCounter_; + if (childrenCounter_ == 0) + timer_.cancel(); + }); + return true; +} + +void +CoroutineGroup::asyncWait(boost::asio::yield_context yield) +{ + if (childrenCounter_ == 0) + return; + + boost::system::error_code error; + timer_.async_wait(yield[error]); +} + +size_t +CoroutineGroup::size() const +{ + return childrenCounter_; +} + +} // namespace util diff --git a/src/util/CoroutineGroup.hpp b/src/util/CoroutineGroup.hpp new file mode 100644 index 00000000..7fc5aa07 --- /dev/null +++ b/src/util/CoroutineGroup.hpp @@ -0,0 +1,88 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include + +#include +#include +#include + +namespace util { + +/** + * @brief CoroutineGroup is a helper class to manage a group of coroutines. It allows to spawn multiple coroutines and + * wait for all of them to finish. + */ +class CoroutineGroup { + boost::asio::steady_timer timer_; + std::optional maxChildren_; + int childrenCounter_{0}; + +public: + /** + * @brief Construct a new Coroutine Group object + * + * @param yield The yield context to use for the internal timer + * @param maxChildren The maximum number of coroutines that can be spawned at the same time. If not provided, there + * is no limit + */ + CoroutineGroup(boost::asio::yield_context yield, std::optional maxChildren = std::nullopt); + + /** + * @brief Destroy the Coroutine Group object + * + * @note asyncWait() must be called before the object is destroyed + */ + ~CoroutineGroup(); + + /** + * @brief Spawn a new coroutine in the group + * + * @param yield The yield context to use for the coroutine (it should be the same as the one used in the + * constructor) + * @param fn The function to execute + * @return true If the coroutine was spawned successfully. false if the maximum number of coroutines has been + * reached + */ + bool + spawn(boost::asio::yield_context yield, std::function fn); + + /** + * @brief Wait for all the coroutines in the group to finish + * + * @note This method must be called before the object is destroyed + * + * @param yield The yield context to use for the internal timer + */ + void + asyncWait(boost::asio::yield_context yield); + + /** + * @brief Get the number of coroutines in the group + * + * @return size_t The number of coroutines in the group + */ + size_t + size() const; +}; + +} // namespace util diff --git a/src/util/WithTimeout.hpp b/src/util/WithTimeout.hpp new file mode 100644 index 00000000..0a1b5b86 --- /dev/null +++ b/src/util/WithTimeout.hpp @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace util { + +/** + * @brief Perform a coroutine operation with a timeout. + * + * @tparam Operation The operation type to perform. Must be a callable accepting yield context with bound cancellation + * token. + * @param operation The operation to perform. + * @param yield The yield context. + * @param timeout The timeout duration. + * @return The error code of the operation. + */ +template +boost::system::error_code +withTimeout(Operation&& operation, boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout) +{ + boost::system::error_code error; + auto operationCompleted = std::make_shared(false); + boost::asio::cancellation_signal cancellationSignal; + auto cyield = boost::asio::bind_cancellation_slot(cancellationSignal.slot(), yield[error]); + + boost::asio::steady_timer timer{boost::asio::get_associated_executor(cyield), timeout}; + timer.async_wait([&cancellationSignal, operationCompleted](boost::system::error_code errorCode) { + if (!errorCode and !*operationCompleted) + cancellationSignal.emit(boost::asio::cancellation_type::terminal); + }); + operation(cyield); + *operationCompleted = true; + + // Map error code to timeout + if (error == boost::system::errc::operation_canceled) { + return boost::system::errc::make_error_code(boost::system::errc::timed_out); + } + return error; +} + +} // namespace util diff --git a/src/util/requests/impl/WsConnectionImpl.hpp b/src/util/requests/impl/WsConnectionImpl.hpp index 926fe91b..b588e5d0 100644 --- a/src/util/requests/impl/WsConnectionImpl.hpp +++ b/src/util/requests/impl/WsConnectionImpl.hpp @@ -19,6 +19,7 @@ #pragma once +#include "util/WithTimeout.hpp" #include "util/requests/Types.hpp" #include "util/requests/WsConnection.hpp" @@ -67,15 +68,13 @@ public: auto operation = [&](auto&& token) { ws_.async_read(buffer, token); }; if (timeout) { - withTimeout(operation, yield[errorCode], *timeout); + errorCode = util::withTimeout(operation, yield[errorCode], *timeout); } else { operation(yield[errorCode]); } - if (errorCode) { - errorCode = mapError(errorCode); + if (errorCode) return std::unexpected{RequestError{"Read error", errorCode}}; - } return boost::beast::buffers_to_string(std::move(buffer).data()); } @@ -90,15 +89,13 @@ public: boost::beast::error_code errorCode; auto operation = [&](auto&& token) { ws_.async_write(boost::asio::buffer(message), token); }; if (timeout) { - withTimeout(operation, yield[errorCode], *timeout); + errorCode = util::withTimeout(operation, yield, *timeout); } else { operation(yield[errorCode]); } - if (errorCode) { - errorCode = mapError(errorCode); + if (errorCode) return RequestError{"Write error", errorCode}; - } return std::nullopt; } @@ -119,36 +116,6 @@ public: return RequestError{"Close error", errorCode}; return std::nullopt; } - -private: - template - static void - withTimeout(Operation&& operation, boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout) - { - auto isCompleted = std::make_shared(false); - boost::asio::cancellation_signal cancellationSignal; - auto cyield = boost::asio::bind_cancellation_slot(cancellationSignal.slot(), yield); - - boost::asio::steady_timer timer{boost::asio::get_associated_executor(cyield), timeout}; - - // The timer below can be called with no error code even if the operation is completed before the timeout, so we - // need an additional flag here - timer.async_wait([&cancellationSignal, isCompleted](boost::system::error_code errorCode) { - if (!errorCode and !*isCompleted) - cancellationSignal.emit(boost::asio::cancellation_type::terminal); - }); - operation(cyield); - *isCompleted = true; - } - - static boost::system::error_code - mapError(boost::system::error_code const ec) - { - if (ec == boost::system::errc::operation_canceled) { - return boost::system::errc::make_error_code(boost::system::errc::timed_out); - } - return ec; - } }; using PlainWsConnection = WsConnectionImpl>; diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt index cdf882ec..b5b0ae9f 100644 --- a/src/web/CMakeLists.txt +++ b/src/web/CMakeLists.txt @@ -3,13 +3,17 @@ add_library(clio_web) target_sources( clio_web PRIVATE Resolver.cpp - Server.cpp dosguard/DOSGuard.cpp dosguard/IntervalSweepHandler.cpp dosguard/WhitelistHandler.cpp impl/AdminVerificationStrategy.cpp - impl/ServerSslContext.cpp + ng/Connection.cpp + ng/impl/ConnectionHandler.cpp + ng/impl/ServerSslContext.cpp + ng/impl/WsConnection.cpp ng/Server.cpp + ng/Request.cpp + ng/Response.cpp ) target_link_libraries(clio_web PUBLIC clio_util) diff --git a/src/web/Server.hpp b/src/web/Server.hpp index 8cbf4ae2..beb27ac6 100644 --- a/src/web/Server.hpp +++ b/src/web/Server.hpp @@ -24,8 +24,8 @@ #include "web/HttpSession.hpp" #include "web/SslHttpSession.hpp" #include "web/dosguard/DOSGuardInterface.hpp" -#include "web/impl/ServerSslContext.hpp" #include "web/interface/Concepts.hpp" +#include "web/ng/impl/ServerSslContext.hpp" #include #include @@ -59,15 +59,6 @@ */ namespace web { -/** - * @brief A helper function to create a server SSL context. - * - * @param config The config to create the context - * @return Optional SSL context or error message if any - */ -std::expected, std::string> -makeServerSslContext(util::Config const& config); - /** * @brief The Detector class to detect if the connection is a ssl or not. * @@ -329,7 +320,7 @@ make_HttpServer( { static util::Logger const log{"WebServer"}; - auto expectedSslContext = makeServerSslContext(config); + auto expectedSslContext = ng::impl::makeServerSslContext(config); if (not expectedSslContext) { LOG(log.error()) << "Failed to create SSL context: " << expectedSslContext.error(); return nullptr; diff --git a/src/web/impl/AdminVerificationStrategy.cpp b/src/web/impl/AdminVerificationStrategy.cpp index 33332741..4295279a 100644 --- a/src/web/impl/AdminVerificationStrategy.cpp +++ b/src/web/impl/AdminVerificationStrategy.cpp @@ -20,6 +20,7 @@ #include "web/impl/AdminVerificationStrategy.hpp" #include "util/JsonUtils.hpp" +#include "util/config/Config.hpp" #include #include @@ -79,4 +80,20 @@ make_AdminVerificationStrategy(std::optional password) return std::make_shared(); } +std::expected, std::string> +make_AdminVerificationStrategy(util::Config const& serverConfig) +{ + auto adminPassword = serverConfig.maybeValue("admin_password"); + auto const localAdmin = serverConfig.maybeValue("local_admin"); + bool const localAdminEnabled = localAdmin && localAdmin.value(); + + if (localAdminEnabled == adminPassword.has_value()) { + if (adminPassword.has_value()) + return std::unexpected{"Admin config error, local_admin and admin_password can not be set together."}; + return std::unexpected{"Admin config error, either local_admin and admin_password must be specified."}; + } + + return make_AdminVerificationStrategy(std::move(adminPassword)); +} + } // namespace web::impl diff --git a/src/web/impl/AdminVerificationStrategy.hpp b/src/web/impl/AdminVerificationStrategy.hpp index 0a2d8a19..fd772612 100644 --- a/src/web/impl/AdminVerificationStrategy.hpp +++ b/src/web/impl/AdminVerificationStrategy.hpp @@ -19,10 +19,13 @@ #pragma once +#include "util/config/Config.hpp" + #include #include #include +#include #include #include #include @@ -82,4 +85,7 @@ public: std::shared_ptr make_AdminVerificationStrategy(std::optional password); +std::expected, std::string> +make_AdminVerificationStrategy(util::Config const& serverConfig); + } // namespace web::impl diff --git a/tests/unit/web/impl/ServerSslContextTests.cpp b/src/web/ng/Connection.cpp similarity index 56% rename from tests/unit/web/impl/ServerSslContextTests.cpp rename to src/web/ng/Connection.cpp index 3febd5dc..4bfb4bc2 100644 --- a/tests/unit/web/impl/ServerSslContextTests.cpp +++ b/src/web/ng/Connection.cpp @@ -17,32 +17,41 @@ */ //============================================================================== -#include "web/impl/ServerSslContext.hpp" +#include "web/ng/Connection.hpp" -#include +#include "util/Taggable.hpp" -using namespace web::impl; +#include -TEST(ServerSslContext, makeServerSslContext) +#include +#include +#include + +namespace web::ng { + +Connection::Connection( + std::string ip, + boost::beast::flat_buffer buffer, + util::TagDecoratorFactory const& tagDecoratorFactory +) + : util::Taggable(tagDecoratorFactory), ip_{std::move(ip)}, buffer_{std::move(buffer)} { - auto const sslContext = makeServerSslContext(TEST_DATA_SSL_CERT_PATH, TEST_DATA_SSL_KEY_PATH); - ASSERT_TRUE(sslContext); } -TEST(ServerSslContext, makeServerSslContext_WrongCertPath) +ConnectionContext +Connection::context() const { - auto const sslContext = makeServerSslContext("wrong_path", TEST_DATA_SSL_KEY_PATH); - ASSERT_FALSE(sslContext); + return ConnectionContext{*this}; } -TEST(ServerSslContext, makeServerSslContext_WrongKeyPath) +std::string const& +Connection::ip() const { - auto const sslContext = makeServerSslContext(TEST_DATA_SSL_CERT_PATH, "wrong_path"); - ASSERT_FALSE(sslContext); + return ip_; } -TEST(ServerSslContext, makeServerSslContext_CertKeyMismatch) +ConnectionContext::ConnectionContext(Connection const& connection) : connection_{connection} { - auto const sslContext = makeServerSslContext(TEST_DATA_SSL_KEY_PATH, TEST_DATA_SSL_CERT_PATH); - ASSERT_FALSE(sslContext); } + +} // namespace web::ng diff --git a/src/web/ng/Connection.hpp b/src/web/ng/Connection.hpp new file mode 100644 index 00000000..45b20052 --- /dev/null +++ b/src/web/ng/Connection.hpp @@ -0,0 +1,148 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "util/Taggable.hpp" +#include "web/ng/Error.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace web::ng { + +/** + * @brief A forward declaration of ConnectionContext. + */ +class ConnectionContext; + +/** + *@brief A class representing a connection to a client. + */ +class Connection : public util::Taggable { +protected: + std::string ip_; // client ip + boost::beast::flat_buffer buffer_; + +public: + /** + * @brief The default timeout for send, receive, and close operations. + */ + static constexpr std::chrono::steady_clock::duration DEFAULT_TIMEOUT = std::chrono::seconds{30}; + + /** + * @brief Construct a new Connection object + * + * @param ip The client ip. + * @param buffer The buffer to use for reading and writing. + * @param tagDecoratorFactory The factory for creating tag decorators. + */ + Connection(std::string ip, boost::beast::flat_buffer buffer, util::TagDecoratorFactory const& tagDecoratorFactory); + + /** + * @brief Whether the connection was upgraded. Upgraded connections are websocket connections. + * + * @return true if the connection was upgraded. + */ + virtual bool + wasUpgraded() const = 0; + + /** + * @brief Send a response to the client. + * + * @param response The response to send. + * @param yield The yield context. + * @param timeout The timeout for the operation. + * @return An error if the operation failed or nullopt if it succeeded. + */ + + virtual std::optional + send( + Response response, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT + ) = 0; + + /** + * @brief Receive a request from the client. + * + * @param yield The yield context. + * @param timeout The timeout for the operation. + * @return The request if it was received or an error if the operation failed. + */ + virtual std::expected + receive(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT) = 0; + + /** + * @brief Gracefully close the connection. + * + * @param yield The yield context. + * @param timeout The timeout for the operation. + */ + virtual void + close(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT) = 0; + + /** + * @brief Get the connection context. + * + * @return The connection context. + */ + ConnectionContext + context() const; + + /** + * @brief Get the ip of the client. + * + * @return The ip of the client. + */ + std::string const& + ip() const; +}; + +/** + * @brief A pointer to a connection. + */ +using ConnectionPtr = std::unique_ptr; + +/** + * @brief A class representing the context of a connection. + */ +class ConnectionContext { + std::reference_wrapper connection_; + +public: + /** + * @brief Construct a new ConnectionContext object. + * + * @param connection The connection. + */ + explicit ConnectionContext(Connection const& connection); +}; + +} // namespace web::ng diff --git a/src/web/ng/Error.hpp b/src/web/ng/Error.hpp new file mode 100644 index 00000000..93f35646 --- /dev/null +++ b/src/web/ng/Error.hpp @@ -0,0 +1,31 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include + +namespace web::ng { + +/** + * @brief Error of any async operation. + */ +using Error = boost::system::error_code; + +} // namespace web::ng diff --git a/src/web/ng/MessageHandler.hpp b/src/web/ng/MessageHandler.hpp new file mode 100644 index 00000000..f518238f --- /dev/null +++ b/src/web/ng/MessageHandler.hpp @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "web/ng/Connection.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" + +#include + +#include + +namespace web::ng { + +/** + * @brief Handler for messages. + */ +using MessageHandler = std::function; + +} // namespace web::ng diff --git a/src/web/ng/Request.cpp b/src/web/ng/Request.cpp new file mode 100644 index 00000000..1a60736b --- /dev/null +++ b/src/web/ng/Request.cpp @@ -0,0 +1,131 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "web/ng/Request.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace web::ng { + +namespace { + +template +std::optional +getHeaderValue(HeadersType const& headers, HeaderNameType const& headerName) +{ + auto const it = headers.find(headerName); + if (it == headers.end()) + return std::nullopt; + return it->value(); +} + +} // namespace + +Request::Request(boost::beast::http::request request) : data_{std::move(request)} +{ +} + +Request::Request(std::string request, HttpHeaders const& headers) + : data_{WsData{.request = std::move(request), .headers = headers}} +{ +} + +Request::Method +Request::method() const +{ + if (not isHttp()) + return Method::Websocket; + + switch (httpRequest().method()) { + case boost::beast::http::verb::get: + return Method::Get; + case boost::beast::http::verb::post: + return Method::Post; + default: + return Method::Unsupported; + } +} + +bool +Request::isHttp() const +{ + return std::holds_alternative(data_); +} + +std::optional const>> +Request::asHttpRequest() const +{ + if (not isHttp()) + return std::nullopt; + + return httpRequest(); +} + +std::string_view +Request::message() const +{ + if (not isHttp()) + return std::get(data_).request; + return httpRequest().body(); +} + +std::optional +Request::target() const +{ + if (not isHttp()) + return std::nullopt; + + return httpRequest().target(); +} + +std::optional +Request::headerValue(boost::beast::http::field headerName) const +{ + if (not isHttp()) + return getHeaderValue(std::get(data_).headers.get(), headerName); + + return getHeaderValue(httpRequest(), headerName); +} + +std::optional +Request::headerValue(std::string const& headerName) const +{ + if (not isHttp()) + return getHeaderValue(std::get(data_).headers.get(), headerName); + + return getHeaderValue(httpRequest(), headerName); +} + +Request::HttpRequest const& +Request::httpRequest() const +{ + return std::get(data_); +} + +} // namespace web::ng diff --git a/src/web/ng/Request.hpp b/src/web/ng/Request.hpp new file mode 100644 index 00000000..06018156 --- /dev/null +++ b/src/web/ng/Request.hpp @@ -0,0 +1,145 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace web::ng { + +/** + * @brief Represents an HTTP or WebSocket request. + */ +class Request { +public: + /** + * @brief The headers of an HTTP request. + */ + using HttpHeaders = boost::beast::http::request::header_type; + +private: + struct WsData { + std::string request; + std::reference_wrapper headers; + }; + + using HttpRequest = boost::beast::http::request; + std::variant data_; + +public: + /** + * @brief Construct from an HTTP request. + * + * @param request The HTTP request. + */ + explicit Request(boost::beast::http::request request); + + /** + * @brief Construct from a WebSocket request. + * + * @param request The WebSocket request. + * @param headers The headers of the HTTP request initiated the WebSocket connection + */ + Request(std::string request, HttpHeaders const& headers); + + /** + * @brief Method of the request. + * @note Websocket is not a real method, it is used to distinguish WebSocket requests from HTTP requests. + */ + enum class Method { Get, Post, Websocket, Unsupported }; + + /** + * @brief Get the method of the request. + * + * @return The method of the request. + */ + Method + method() const; + + /** + * @brief Check if the request is an HTTP request. + * + * @return true if the request is an HTTP request, false otherwise. + */ + bool + isHttp() const; + + /** + * @brief Get the HTTP request. + * + * @return The HTTP request or std::nullopt if the request is a WebSocket request. + */ + std::optional const>> + asHttpRequest() const; + + /** + * @brief Get the body (in case of an HTTP request) or the message (in case of a WebSocket request). + * + * @return The message of the request. + */ + std::string_view + message() const; + + /** + * @brief Get the target of the request. + * + * @return The target of the request or std::nullopt if the request is a WebSocket request. + */ + std::optional + target() const; + + /** + * @brief Get the value of a header. + * + * @param headerName The name of the header. + * @return The value of the header or std::nullopt if the header does not exist. + */ + std::optional + headerValue(boost::beast::http::field headerName) const; + + /** + * @brief Get the value of a header. + * + * @param headerName The name of the header. + * @return The value of the header or std::nullopt if the header does not exist. + */ + std::optional + headerValue(std::string const& headerName) const; + +private: + /** + * @brief Get the HTTP request. + * @note This function assumes that the request is an HTTP request. So if data_ is not an HTTP request, + * the behavior is undefined. + * + * @return The HTTP request. + */ + HttpRequest const& + httpRequest() const; +}; + +} // namespace web::ng diff --git a/src/web/ng/Response.cpp b/src/web/ng/Response.cpp new file mode 100644 index 00000000..70c91506 --- /dev/null +++ b/src/web/ng/Response.cpp @@ -0,0 +1,116 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "web/ng/Response.hpp" + +#include "util/Assert.hpp" +#include "util/build/Build.hpp" +#include "web/ng/Request.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace http = boost::beast::http; +namespace web::ng { + +namespace { + +std::string_view +asString(Response::HttpData::ContentType type) +{ + switch (type) { + case Response::HttpData::ContentType::TextHtml: + return "text/html"; + case Response::HttpData::ContentType::ApplicationJson: + return "application/json"; + } + ASSERT(false, "Unknown content type"); + std::unreachable(); +} + +template +std::optional +makeHttpData(http::status status, Request const& request) +{ + if (request.isHttp()) { + auto const& httpRequest = request.asHttpRequest()->get(); + auto constexpr contentType = std::is_same_v, std::string> + ? Response::HttpData::ContentType::TextHtml + : Response::HttpData::ContentType::ApplicationJson; + return Response::HttpData{ + .status = status, + .contentType = contentType, + .keepAlive = httpRequest.keep_alive(), + .version = httpRequest.version() + }; + } + return std::nullopt; +} +} // namespace + +Response::Response(boost::beast::http::status status, std::string message, Request const& request) + : message_(std::move(message)), httpData_{makeHttpData(status, request)} +{ +} + +Response::Response(boost::beast::http::status status, boost::json::object const& message, Request const& request) + : message_(boost::json::serialize(message)), httpData_{makeHttpData(status, request)} +{ +} + +std::string const& +Response::message() const +{ + return message_; +} + +http::response +Response::intoHttpResponse() && +{ + ASSERT(httpData_.has_value(), "Response must have http data to be converted into http response"); + + http::response result{httpData_->status, httpData_->version}; + result.set(http::field::server, fmt::format("clio-server-{}", util::build::getClioVersionString())); + result.set(http::field::content_type, asString(httpData_->contentType)); + result.keep_alive(httpData_->keepAlive); + result.body() = std::move(message_); + result.prepare_payload(); + return result; +} + +boost::asio::const_buffer +Response::asConstBuffer() const& +{ + ASSERT(not httpData_.has_value(), "Losing existing http data"); + return boost::asio::buffer(message_.data(), message_.size()); +} + +} // namespace web::ng diff --git a/src/web/ng/Response.hpp b/src/web/ng/Response.hpp new file mode 100644 index 00000000..33348e5a --- /dev/null +++ b/src/web/ng/Response.hpp @@ -0,0 +1,106 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "web/ng/Request.hpp" + +#include +#include +#include +#include +#include + +#include +#include +namespace web::ng { + +/** + * @brief Represents an HTTP or Websocket response. + */ +class Response { +public: + /** + * @brief The data for an HTTP response. + */ + struct HttpData { + /** + * @brief The content type of the response. + */ + enum class ContentType { ApplicationJson, TextHtml }; + + boost::beast::http::status status; ///< The HTTP status. + ContentType contentType; ///< The content type. + bool keepAlive; ///< Whether the connection should be kept alive. + unsigned int version; ///< The HTTP version. + }; + +private: + std::string message_; + std::optional httpData_; + +public: + /** + * @brief Construct a Response from string. Content type will be text/html. + * + * @param status The HTTP status. + * @param message The message to send. + * @param request The request that triggered this response. Used to determine whether the response should contain + * HTTP or WebSocket data. + */ + Response(boost::beast::http::status status, std::string message, Request const& request); + + /** + * @brief Construct a Response from JSON object. Content type will be application/json. + * + * @param status The HTTP status. + * @param message The message to send. + * @param request The request that triggered this response. Used to determine whether the response should contain + * HTTP or WebSocket + */ + Response(boost::beast::http::status status, boost::json::object const& message, Request const& request); + + /** + * @brief Get the message of the response. + * + * @return The message of the response. + */ + std::string const& + message() const; + + /** + * @brief Convert the Response to an HTTP response. + * @note The Response must be constructed with an HTTP request. + * + * @return The HTTP response. + */ + boost::beast::http::response + intoHttpResponse() &&; + + /** + * @brief Get the message of the response as a const buffer. + * @note The response must be constructed with a WebSocket request. + * + * @return The message of the response as a const buffer. + */ + boost::asio::const_buffer + asConstBuffer() const&; +}; + +} // namespace web::ng diff --git a/src/web/ng/Server.cpp b/src/web/ng/Server.cpp index e69de29b..07045de1 100644 --- a/src/web/ng/Server.cpp +++ b/src/web/ng/Server.cpp @@ -0,0 +1,322 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "web/ng/Server.hpp" + +#include "util/Assert.hpp" +#include "util/Taggable.hpp" +#include "util/config/Config.hpp" +#include "util/log/Logger.hpp" +#include "web/ng/Connection.hpp" +#include "web/ng/MessageHandler.hpp" +#include "web/ng/impl/HttpConnection.hpp" +#include "web/ng/impl/ServerSslContext.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace web::ng { + +namespace { + +std::expected +makeEndpoint(util::Config const& serverConfig) +{ + auto const ip = serverConfig.maybeValue("ip"); + if (not ip.has_value()) + return std::unexpected{"Missing 'ip` in server config."}; + + boost::system::error_code error; + auto const address = boost::asio::ip::make_address(*ip, error); + if (error) + return std::unexpected{fmt::format("Error parsing provided IP: {}", error.message())}; + + auto const port = serverConfig.maybeValue("port"); + if (not port.has_value()) + return std::unexpected{"Missing 'port` in server config."}; + + return boost::asio::ip::tcp::endpoint{address, *port}; +} + +std::expected +makeAcceptor(boost::asio::io_context& context, boost::asio::ip::tcp::endpoint const& endpoint) +{ + boost::asio::ip::tcp::acceptor acceptor{context}; + try { + acceptor.open(endpoint.protocol()); + acceptor.set_option(boost::asio::socket_base::reuse_address(true)); + acceptor.bind(endpoint); + acceptor.listen(boost::asio::socket_base::max_listen_connections); + } catch (boost::system::system_error const& error) { + return std::unexpected{fmt::format("Error creating TCP acceptor: {}", error.what())}; + } + return acceptor; +} + +std::expected +extractIp(boost::asio::ip::tcp::socket const& socket) +{ + std::string ip; + try { + ip = socket.remote_endpoint().address().to_string(); + } catch (boost::system::system_error const& error) { + return std::unexpected{error}; + } + return ip; +} + +struct SslDetectionResult { + boost::asio::ip::tcp::socket socket; + bool isSsl; + boost::beast::flat_buffer buffer; +}; + +std::expected, std::string> +detectSsl(boost::asio::ip::tcp::socket socket, boost::asio::yield_context yield) +{ + boost::beast::tcp_stream tcpStream{std::move(socket)}; + boost::beast::flat_buffer buffer; + boost::beast::error_code errorCode; + bool const isSsl = boost::beast::async_detect_ssl(tcpStream, buffer, yield[errorCode]); + + if (errorCode == boost::asio::ssl::error::stream_truncated) + return std::nullopt; + + if (errorCode) + return std::unexpected{fmt::format("Detector failed (detect): {}", errorCode.message())}; + + return SslDetectionResult{.socket = tcpStream.release_socket(), .isSsl = isSsl, .buffer = std::move(buffer)}; +} + +std::expected +makeConnection( + SslDetectionResult sslDetectionResult, + std::optional& sslContext, + std::string ip, + util::TagDecoratorFactory& tagDecoratorFactory, + boost::asio::yield_context yield +) +{ + impl::UpgradableConnectionPtr connection; + if (sslDetectionResult.isSsl) { + if (not sslContext.has_value()) + return std::unexpected{"SSL is not supported by this server"}; + + connection = std::make_unique( + std::move(sslDetectionResult.socket), + std::move(ip), + std::move(sslDetectionResult.buffer), + *sslContext, + tagDecoratorFactory + ); + } else { + connection = std::make_unique( + std::move(sslDetectionResult.socket), + std::move(ip), + std::move(sslDetectionResult.buffer), + tagDecoratorFactory + ); + } + + auto const expectedIsUpgrade = connection->isUpgradeRequested(yield); + if (not expectedIsUpgrade.has_value()) { + return std::unexpected{ + fmt::format("Error checking whether upgrade requested: {}", expectedIsUpgrade.error().message()) + }; + } + + if (*expectedIsUpgrade) { + auto expectedUpgradedConnection = connection->upgrade(sslContext, tagDecoratorFactory, yield); + if (expectedUpgradedConnection.has_value()) + return std::move(expectedUpgradedConnection).value(); + + return std::unexpected{fmt::format("Error upgrading connection: {}", expectedUpgradedConnection.error().what()) + }; + } + + return connection; +} + +} // namespace + +Server::Server( + boost::asio::io_context& ctx, + boost::asio::ip::tcp::endpoint endpoint, + std::optional sslContext, + impl::ConnectionHandler connectionHandler, + util::TagDecoratorFactory tagDecoratorFactory +) + : ctx_{ctx} + , sslContext_{std::move(sslContext)} + , connectionHandler_{std::move(connectionHandler)} + , endpoint_{std::move(endpoint)} + , tagDecoratorFactory_{tagDecoratorFactory} +{ +} + +void +Server::onGet(std::string const& target, MessageHandler handler) +{ + ASSERT(not running_, "Adding a GET handler is not allowed when Server is running."); + connectionHandler_.onGet(target, std::move(handler)); +} + +void +Server::onPost(std::string const& target, MessageHandler handler) +{ + ASSERT(not running_, "Adding a POST handler is not allowed when Server is running."); + connectionHandler_.onPost(target, std::move(handler)); +} + +void +Server::onWs(MessageHandler handler) +{ + ASSERT(not running_, "Adding a Websocket handler is not allowed when Server is running."); + connectionHandler_.onWs(std::move(handler)); +} + +std::optional +Server::run() +{ + auto acceptor = makeAcceptor(ctx_.get(), endpoint_); + if (not acceptor.has_value()) + return std::move(acceptor).error(); + + running_ = true; + boost::asio::spawn( + ctx_.get(), + [this, acceptor = std::move(acceptor).value()](boost::asio::yield_context yield) mutable { + while (true) { + boost::beast::error_code errorCode; + boost::asio::ip::tcp::socket socket{ctx_.get().get_executor()}; + + acceptor.async_accept(socket, yield[errorCode]); + if (errorCode) { + LOG(log_.debug()) << "Error accepting a connection: " << errorCode.what(); + continue; + } + boost::asio::spawn( + ctx_.get(), + [this, socket = std::move(socket)](boost::asio::yield_context yield) mutable { + handleConnection(std::move(socket), yield); + }, + boost::asio::detached + ); + } + } + ); + return std::nullopt; +} + +void +Server::stop() +{ +} + +void +Server::handleConnection(boost::asio::ip::tcp::socket socket, boost::asio::yield_context yield) +{ + auto sslDetectionResultExpected = detectSsl(std::move(socket), yield); + if (not sslDetectionResultExpected) { + LOG(log_.info()) << sslDetectionResultExpected.error(); + return; + } + auto sslDetectionResult = std::move(sslDetectionResultExpected).value(); + if (not sslDetectionResult) + return; // stream truncated, probably user disconnected + + auto ip = extractIp(sslDetectionResult->socket); + if (not ip.has_value()) { + LOG(log_.info()) << "Cannot get remote endpoint: " << ip.error().what(); + return; + } + + // TODO(kuznetsss): check ip with dosguard here + + auto connectionExpected = makeConnection( + std::move(sslDetectionResult).value(), sslContext_, std::move(ip).value(), tagDecoratorFactory_, yield + ); + if (not connectionExpected.has_value()) { + LOG(log_.info()) << "Error creating a connection: " << connectionExpected.error(); + return; + } + + boost::asio::spawn( + ctx_.get(), + [this, connection = std::move(connectionExpected).value()](boost::asio::yield_context yield) mutable { + connectionHandler_.processConnection(std::move(connection), yield); + } + ); +} + +std::expected +make_Server(util::Config const& config, boost::asio::io_context& context) +{ + auto const serverConfig = config.section("server"); + + auto endpoint = makeEndpoint(serverConfig); + if (not endpoint.has_value()) + return std::unexpected{std::move(endpoint).error()}; + + auto expectedSslContext = impl::makeServerSslContext(config); + if (not expectedSslContext) + return std::unexpected{std::move(expectedSslContext).error()}; + + impl::ConnectionHandler::ProcessingPolicy processingPolicy{impl::ConnectionHandler::ProcessingPolicy::Parallel}; + std::optional parallelRequestLimit; + + auto const processingStrategyStr = serverConfig.valueOr("processing_policy", "parallel"); + if (processingStrategyStr == "sequent") { + processingPolicy = impl::ConnectionHandler::ProcessingPolicy::Sequential; + } else if (processingStrategyStr == "parallel") { + parallelRequestLimit = serverConfig.maybeValue("parallel_requests_limit"); + } else { + return std::unexpected{fmt::format("Invalid 'server.processing_strategy': {}", processingStrategyStr)}; + } + + return Server{ + context, + std::move(endpoint).value(), + std::move(expectedSslContext).value(), + impl::ConnectionHandler{processingPolicy, parallelRequestLimit}, + util::TagDecoratorFactory(config) + }; +} + +} // namespace web::ng diff --git a/src/web/ng/Server.hpp b/src/web/ng/Server.hpp index e69de29b..dd68ad0b 100644 --- a/src/web/ng/Server.hpp +++ b/src/web/ng/Server.hpp @@ -0,0 +1,147 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "util/Taggable.hpp" +#include "util/config/Config.hpp" +#include "util/log/Logger.hpp" +#include "web/impl/AdminVerificationStrategy.hpp" +#include "web/ng/MessageHandler.hpp" +#include "web/ng/impl/ConnectionHandler.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace web::ng { + +/** + * @brief Web server class. + */ +class Server { + util::Logger log_{"WebServer"}; + util::Logger perfLog_{"Performance"}; + std::reference_wrapper ctx_; + + std::optional sslContext_; + + impl::ConnectionHandler connectionHandler_; + + boost::asio::ip::tcp::endpoint endpoint_; + + util::TagDecoratorFactory tagDecoratorFactory_; + + bool running_{false}; + +public: + /** + * @brief Construct a new Server object. + * + * @param ctx The boost::asio::io_context to use. + * @param endpoint The endpoint to listen on. + * @param sslContext The SSL context to use (optional). + * @param connectionHandler The connection handler. + * @param tagDecoratorFactory The tag decorator factory. + */ + Server( + boost::asio::io_context& ctx, + boost::asio::ip::tcp::endpoint endpoint, + std::optional sslContext, + impl::ConnectionHandler connectionHandler, + util::TagDecoratorFactory tagDecoratorFactory + ); + + /** + * @brief Copy constructor is deleted. The Server couldn't be copied. + */ + Server(Server const&) = delete; + + /** + * @brief Move constructor is defaulted. + */ + Server(Server&&) = default; + + /** + * @brief Set handler for GET requests. + * @note This method can't be called after run() is called. + * + * @param target The target of the request. + * @param handler The handler to set. + */ + void + onGet(std::string const& target, MessageHandler handler); + + /** + * @brief Set handler for POST requests. + * @note This method can't be called after run() is called. + * + * @param target The target of the request. + * @param handler The handler to set. + */ + void + onPost(std::string const& target, MessageHandler handler); + + /** + * @brief Set handler for WebSocket requests. + * @note This method can't be called after run() is called. + * + * @param handler The handler to set. + */ + void + onWs(MessageHandler handler); + + /** + * @brief Run the server. + * + * @return std::nullopt if the server started successfully, otherwise an error message. + */ + std::optional + run(); + + /** + * @brief Stop the server. + ** @note Stopping the server cause graceful shutdown of all connections. And rejecting new connections. + */ + void + stop(); + +private: + void + handleConnection(boost::asio::ip::tcp::socket socket, boost::asio::yield_context yield); +}; + +/** + * @brief Create a new Server. + * + * @param config The configuration. + * @param context The boost::asio::io_context to use. + * + * @return The Server or an error message. + */ +std::expected +make_Server(util::Config const& config, boost::asio::io_context& context); + +} // namespace web::ng diff --git a/src/web/ng/impl/Concepts.hpp b/src/web/ng/impl/Concepts.hpp new file mode 100644 index 00000000..7c0985d4 --- /dev/null +++ b/src/web/ng/impl/Concepts.hpp @@ -0,0 +1,35 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include + +#include + +namespace web::ng::impl { + +template +concept IsTcpStream = std::is_same_v, boost::beast::tcp_stream>; + +template +concept IsSslTcpStream = std::is_same_v, boost::asio::ssl::stream>; + +} // namespace web::ng::impl diff --git a/src/web/ng/impl/ConnectionHandler.cpp b/src/web/ng/impl/ConnectionHandler.cpp new file mode 100644 index 00000000..20b32158 --- /dev/null +++ b/src/web/ng/impl/ConnectionHandler.cpp @@ -0,0 +1,285 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "web/ng/impl/ConnectionHandler.hpp" + +#include "util/Assert.hpp" +#include "util/CoroutineGroup.hpp" +#include "util/log/Logger.hpp" +#include "web/ng/Connection.hpp" +#include "web/ng/Error.hpp" +#include "web/ng/MessageHandler.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace web::ng::impl { + +namespace { + +Response +handleHttpRequest( + ConnectionContext const& connectionContext, + ConnectionHandler::TargetToHandlerMap const& handlers, + Request const& request, + boost::asio::yield_context yield +) +{ + ASSERT(request.target().has_value(), "Got not a HTTP request"); + auto it = handlers.find(*request.target()); + if (it == handlers.end()) { + return Response{boost::beast::http::status::bad_request, "Bad target", request}; + } + return it->second(request, connectionContext, yield); +} + +Response +handleWsRequest( + ConnectionContext connectionContext, + std::optional const& handler, + Request const& request, + boost::asio::yield_context yield +) +{ + if (not handler.has_value()) { + return Response{boost::beast::http::status::bad_request, "WebSocket is not supported by this server", request}; + } + return handler->operator()(request, connectionContext, yield); +} + +} // namespace + +size_t +ConnectionHandler::StringHash::operator()(char const* str) const +{ + return hash_type{}(str); +} + +size_t +ConnectionHandler::StringHash::operator()(std::string_view str) const +{ + return hash_type{}(str); +} + +size_t +ConnectionHandler::StringHash::operator()(std::string const& str) const +{ + return hash_type{}(str); +} + +ConnectionHandler::ConnectionHandler(ProcessingPolicy processingPolicy, std::optional maxParallelRequests) + : processingPolicy_{processingPolicy}, maxParallelRequests_{maxParallelRequests} +{ +} + +void +ConnectionHandler::onGet(std::string const& target, MessageHandler handler) +{ + getHandlers_[target] = std::move(handler); +} + +void +ConnectionHandler::onPost(std::string const& target, MessageHandler handler) +{ + postHandlers_[target] = std::move(handler); +} + +void +ConnectionHandler::onWs(MessageHandler handler) +{ + wsHandler_ = std::move(handler); +} + +void +ConnectionHandler::processConnection(ConnectionPtr connectionPtr, boost::asio::yield_context yield) +{ + auto& connectionRef = *connectionPtr; + auto signalConnection = onStop_.connect([&connectionRef, yield]() { connectionRef.close(yield); }); + + bool shouldCloseGracefully = false; + + switch (processingPolicy_) { + case ProcessingPolicy::Sequential: + shouldCloseGracefully = sequentRequestResponseLoop(connectionRef, yield); + break; + case ProcessingPolicy::Parallel: + shouldCloseGracefully = parallelRequestResponseLoop(connectionRef, yield); + break; + } + if (shouldCloseGracefully) + connectionRef.close(yield); + + signalConnection.disconnect(); +} + +void +ConnectionHandler::stop() +{ + onStop_(); +} + +bool +ConnectionHandler::handleError(Error const& error, Connection const& connection) const +{ + // ssl::error::stream_truncated, also known as an SSL "short read", + // indicates the peer closed the connection without performing the + // required closing handshake (for example, Google does this to + // improve performance). Generally this can be a security issue, + // but if your communication protocol is self-terminated (as + // it is with both HTTP and WebSocket) then you may simply + // ignore the lack of close_notify. + // + // https://github.com/boostorg/beast/issues/38 + // + // https://security.stackexchange.com/questions/91435/how-to-handle-a-malicious-ssl-tls-shutdown + // + // When a short read would cut off the end of an HTTP message, + // Beast returns the error boost::beast::http::error::partial_message. + // Therefore, if we see a short read here, it has occurred + // after the message has been completed, so it is safe to ignore it. + if (error == boost::beast::http::error::end_of_stream || error == boost::asio::ssl::error::stream_truncated) + return false; + + // WebSocket connection was gracefully closed + if (error == boost::beast::websocket::error::closed) + return false; + + if (error != boost::asio::error::operation_aborted) { + LOG(log_.error()) << connection.tag() << ": " << error.message() << ": " << error.value(); + } + return true; +} + +bool +ConnectionHandler::sequentRequestResponseLoop(Connection& connection, boost::asio::yield_context yield) +{ + // The loop here is infinite because: + // - For websocket connection is persistent so Clio will try to read and respond infinite unless client + // disconnected. + // - When client disconnected connection.send() or connection.receive() will return an error. + // - For http it is still a loop to reuse the connection if keep alive is set. Otherwise client will disconnect and + // an error appears. + // - When server is shutting down it will cancel all operations on the connection so an error appears. + + while (true) { + auto expectedRequest = connection.receive(yield); + if (not expectedRequest) + return handleError(expectedRequest.error(), connection); + + LOG(log_.info()) << connection.tag() << "Received request from ip = " << connection.ip(); + + auto maybeReturnValue = processRequest(connection, std::move(expectedRequest).value(), yield); + if (maybeReturnValue.has_value()) + return maybeReturnValue.value(); + } +} + +bool +ConnectionHandler::parallelRequestResponseLoop(Connection& connection, boost::asio::yield_context yield) +{ + // atomic_bool is not needed here because everything happening on coroutine's strand + bool stop = false; + bool closeConnectionGracefully = true; + util::CoroutineGroup tasksGroup{yield, maxParallelRequests_}; + + while (not stop) { + auto expectedRequest = connection.receive(yield); + if (not expectedRequest) { + auto const closeGracefully = handleError(expectedRequest.error(), connection); + stop = true; + closeConnectionGracefully &= closeGracefully; + break; + } + + bool const spawnSuccess = tasksGroup.spawn( + yield, // spawn on the same strand + [this, &stop, &closeConnectionGracefully, &connection, request = std::move(expectedRequest).value()]( + boost::asio::yield_context innerYield + ) mutable { + auto maybeCloseConnectionGracefully = processRequest(connection, request, innerYield); + if (maybeCloseConnectionGracefully.has_value()) { + stop = true; + closeConnectionGracefully &= maybeCloseConnectionGracefully.value(); + } + } + ); + + if (not spawnSuccess) { + connection.send( + Response{ + boost::beast::http::status::too_many_requests, + "Too many requests for one connection", + expectedRequest.value() + }, + yield + ); + } + } + tasksGroup.asyncWait(yield); + return closeConnectionGracefully; +} + +std::optional +ConnectionHandler::processRequest(Connection& connection, Request const& request, boost::asio::yield_context yield) +{ + auto response = handleRequest(connection.context(), request, yield); + + auto const maybeError = connection.send(std::move(response), yield); + if (maybeError.has_value()) { + return handleError(maybeError.value(), connection); + } + return std::nullopt; +} + +Response +ConnectionHandler::handleRequest( + ConnectionContext const& connectionContext, + Request const& request, + boost::asio::yield_context yield +) +{ + switch (request.method()) { + case Request::Method::Get: + return handleHttpRequest(connectionContext, getHandlers_, request, yield); + case Request::Method::Post: + return handleHttpRequest(connectionContext, postHandlers_, request, yield); + case Request::Method::Websocket: + return handleWsRequest(connectionContext, wsHandler_, request, yield); + default: + return Response{boost::beast::http::status::bad_request, "Unsupported http method", request}; + } +} + +} // namespace web::ng::impl diff --git a/src/web/ng/impl/ConnectionHandler.hpp b/src/web/ng/impl/ConnectionHandler.hpp new file mode 100644 index 00000000..b5f8a9a0 --- /dev/null +++ b/src/web/ng/impl/ConnectionHandler.hpp @@ -0,0 +1,130 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "util/log/Logger.hpp" +#include "web/ng/Connection.hpp" +#include "web/ng/Error.hpp" +#include "web/ng/MessageHandler.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace web::ng::impl { + +class ConnectionHandler { +public: + enum class ProcessingPolicy { Sequential, Parallel }; + + struct StringHash { + using hash_type = std::hash; + using is_transparent = void; + + std::size_t + operator()(char const* str) const; + std::size_t + operator()(std::string_view str) const; + std::size_t + operator()(std::string const& str) const; + }; + + using TargetToHandlerMap = std::unordered_map>; + +private: + util::Logger log_{"WebServer"}; + util::Logger perfLog_{"Performance"}; + + ProcessingPolicy processingPolicy_; + std::optional maxParallelRequests_; + + TargetToHandlerMap getHandlers_; + TargetToHandlerMap postHandlers_; + std::optional wsHandler_; + + boost::signals2::signal onStop_; + +public: + ConnectionHandler(ProcessingPolicy processingPolicy, std::optional maxParallelRequests); + + void + onGet(std::string const& target, MessageHandler handler); + + void + onPost(std::string const& target, MessageHandler handler); + + void + onWs(MessageHandler handler); + + void + processConnection(ConnectionPtr connection, boost::asio::yield_context yield); + + void + stop(); + +private: + /** + * @brief Handle an error. + * + * @param error The error to handle. + * @param connection The connection that caused the error. + * @return True if the connection should be gracefully closed, false otherwise. + */ + bool + handleError(Error const& error, Connection const& connection) const; + + /** + * @brief The request-response loop. + * + * @param connection The connection to handle. + * @param yield The yield context. + * @return True if the connection should be gracefully closed, false otherwise. + */ + bool + sequentRequestResponseLoop(Connection& connection, boost::asio::yield_context yield); + + bool + parallelRequestResponseLoop(Connection& connection, boost::asio::yield_context yield); + + std::optional + processRequest(Connection& connection, Request const& request, boost::asio::yield_context yield); + + /** + * @brief Handle a request. + * + * @param connectionContext The connection context. + * @param request The request to handle. + * @param yield The yield context. + * @return The response to send. + */ + Response + handleRequest(ConnectionContext const& connectionContext, Request const& request, boost::asio::yield_context yield); +}; + +} // namespace web::ng::impl diff --git a/src/web/ng/impl/HttpConnection.hpp b/src/web/ng/impl/HttpConnection.hpp new file mode 100644 index 00000000..3a598c38 --- /dev/null +++ b/src/web/ng/impl/HttpConnection.hpp @@ -0,0 +1,219 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "util/Assert.hpp" +#include "util/Taggable.hpp" +#include "web/ng/Connection.hpp" +#include "web/ng/Error.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" +#include "web/ng/impl/Concepts.hpp" +#include "web/ng/impl/WsConnection.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace web::ng::impl { + +class UpgradableConnection : public Connection { +public: + using Connection::Connection; + + virtual std::expected + isUpgradeRequested( + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT + ) = 0; + + virtual std::expected + upgrade( + std::optional& sslContext, + util::TagDecoratorFactory const& tagDecoratorFactory, + boost::asio::yield_context yield + ) = 0; +}; + +using UpgradableConnectionPtr = std::unique_ptr; + +template +class HttpConnection : public UpgradableConnection { + StreamType stream_; + std::optional> request_; + +public: + HttpConnection( + boost::asio::ip::tcp::socket socket, + std::string ip, + boost::beast::flat_buffer buffer, + util::TagDecoratorFactory const& tagDecoratorFactory + ) + requires IsTcpStream + : UpgradableConnection(std::move(ip), std::move(buffer), tagDecoratorFactory), stream_{std::move(socket)} + { + } + + HttpConnection( + boost::asio::ip::tcp::socket socket, + std::string ip, + boost::beast::flat_buffer buffer, + boost::asio::ssl::context& sslCtx, + util::TagDecoratorFactory const& tagDecoratorFactory + ) + requires IsSslTcpStream + : UpgradableConnection(std::move(ip), std::move(buffer), tagDecoratorFactory) + , stream_{std::move(socket), sslCtx} + { + } + + bool + wasUpgraded() const override + { + return false; + } + + std::optional + send( + Response response, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT + ) override + { + auto const httpResponse = std::move(response).intoHttpResponse(); + boost::system::error_code error; + boost::beast::get_lowest_layer(stream_).expires_after(timeout); + boost::beast::http::async_write(stream_, httpResponse, yield[error]); + if (error) + return error; + return std::nullopt; + } + + std::expected + receive(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT) override + { + if (request_.has_value()) { + Request result{std::move(request_).value()}; + request_.reset(); + return result; + } + auto expectedRequest = fetch(yield, timeout); + if (expectedRequest.has_value()) + return Request{std::move(expectedRequest).value()}; + + return std::unexpected{std::move(expectedRequest).error()}; + } + + void + close(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT) override + { + [[maybe_unused]] boost::system::error_code error; + if constexpr (IsSslTcpStream) { + boost::beast::get_lowest_layer(stream_).expires_after(timeout); + stream_.async_shutdown(yield[error]); + } + if constexpr (IsTcpStream) { + stream_.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_type::shutdown_both, error); + } else { + boost::beast::get_lowest_layer(stream_).socket().shutdown( + boost::asio::ip::tcp::socket::shutdown_type::shutdown_both, error + ); + } + } + + std::expected + isUpgradeRequested(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT) + override + { + auto expectedRequest = fetch(yield, timeout); + if (not expectedRequest.has_value()) + return std::unexpected{std::move(expectedRequest).error()}; + + request_ = std::move(expectedRequest).value(); + + return boost::beast::websocket::is_upgrade(request_.value()); + } + + std::expected + upgrade( + [[maybe_unused]] std::optional& sslContext, + util::TagDecoratorFactory const& tagDecoratorFactory, + boost::asio::yield_context yield + ) override + { + ASSERT(request_.has_value(), "Request must be present to upgrade the connection"); + + if constexpr (IsSslTcpStream) { + ASSERT(sslContext.has_value(), "SSL context must be present to upgrade the connection"); + return make_SslWsConnection( + boost::beast::get_lowest_layer(stream_).release_socket(), + std::move(ip_), + std::move(buffer_), + std::move(request_).value(), + sslContext.value(), + tagDecoratorFactory, + yield + ); + } else { + return make_PlainWsConnection( + stream_.release_socket(), + std::move(ip_), + std::move(buffer_), + std::move(request_).value(), + tagDecoratorFactory, + yield + ); + } + } + +private: + std::expected, Error> + fetch(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout) + { + boost::beast::http::request request{}; + boost::system::error_code error; + boost::beast::get_lowest_layer(stream_).expires_after(timeout); + boost::beast::http::async_read(stream_, buffer_, request, yield[error]); + if (error) + return std::unexpected{error}; + return request; + } +}; + +using PlainHttpConnection = HttpConnection; + +using SslHttpConnection = HttpConnection>; + +} // namespace web::ng::impl diff --git a/src/web/impl/ServerSslContext.cpp b/src/web/ng/impl/ServerSslContext.cpp similarity index 58% rename from src/web/impl/ServerSslContext.cpp rename to src/web/ng/impl/ServerSslContext.cpp index 06460f4f..46e9fb2a 100644 --- a/src/web/impl/ServerSslContext.cpp +++ b/src/web/ng/impl/ServerSslContext.cpp @@ -17,7 +17,9 @@ */ //============================================================================== -#include "web/impl/ServerSslContext.hpp" +#include "web/ng/impl/ServerSslContext.hpp" + +#include "util/config/Config.hpp" #include #include @@ -31,7 +33,7 @@ #include #include -namespace web::impl { +namespace web::ng::impl { namespace { @@ -49,32 +51,47 @@ readFile(std::string const& path) } // namespace -std::expected -makeServerSslContext(std::string const& certFilePath, std::string const& keyFilePath) +std::expected, std::string> +makeServerSslContext(util::Config const& config) { - auto const certContent = readFile(certFilePath); + bool const configHasCertFile = config.contains("ssl_cert_file"); + bool const configHasKeyFile = config.contains("ssl_key_file"); + + if (configHasCertFile != configHasKeyFile) + return std::unexpected{"Config entries 'ssl_cert_file' and 'ssl_key_file' must be set or unset together."}; + + if (not configHasCertFile) + return std::nullopt; + + auto const certFilename = config.value("ssl_cert_file"); + auto const certContent = readFile(certFilename); if (!certContent) - return std::unexpected{"Can't read SSL certificate: " + certFilePath}; + return std::unexpected{"Can't read SSL certificate: " + certFilename}; - auto const keyContent = readFile(keyFilePath); + auto const keyFilename = config.value("ssl_key_file"); + auto const keyContent = readFile(keyFilename); if (!keyContent) - return std::unexpected{"Can't read SSL key: " + keyFilePath}; + return std::unexpected{"Can't read SSL key: " + keyFilename}; + return impl::makeServerSslContext(*certContent, *keyContent); +} + +std::expected +makeServerSslContext(std::string const& certData, std::string const& keyData) +{ using namespace boost::asio; ssl::context ctx{ssl::context::tls_server}; ctx.set_options(ssl::context::default_workarounds | ssl::context::no_sslv2); try { - ctx.use_certificate_chain(buffer(certContent->data(), certContent->size())); - ctx.use_private_key(buffer(keyContent->data(), keyContent->size()), ssl::context::file_format::pem); + ctx.use_certificate_chain(buffer(certData.data(), certData.size())); + ctx.use_private_key(buffer(keyData.data(), keyData.size()), ssl::context::file_format::pem); } catch (...) { - return std::unexpected{ - fmt::format("Error loading SSL certificate ({}) or SSL key ({}).", certFilePath, keyFilePath) - }; + return std::unexpected{fmt::format("Error loading SSL certificate or SSL key.")}; } return ctx; } -} // namespace web::impl +} // namespace web::ng::impl diff --git a/src/web/impl/ServerSslContext.hpp b/src/web/ng/impl/ServerSslContext.hpp similarity index 79% rename from src/web/impl/ServerSslContext.hpp rename to src/web/ng/impl/ServerSslContext.hpp index 06698f73..151f8b06 100644 --- a/src/web/impl/ServerSslContext.hpp +++ b/src/web/ng/impl/ServerSslContext.hpp @@ -19,14 +19,20 @@ #pragma once +#include "util/config/Config.hpp" + #include #include +#include #include -namespace web::impl { +namespace web::ng::impl { + +std::expected, std::string> +makeServerSslContext(util::Config const& config); std::expected -makeServerSslContext(std::string const& certFilePath, std::string const& keyFilePath); +makeServerSslContext(std::string const& certData, std::string const& keyData); -} // namespace web::impl +} // namespace web::ng::impl diff --git a/src/web/ng/impl/WsConnection.cpp b/src/web/ng/impl/WsConnection.cpp new file mode 100644 index 00000000..5ea6a976 --- /dev/null +++ b/src/web/ng/impl/WsConnection.cpp @@ -0,0 +1,77 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "web/ng/impl/WsConnection.hpp" + +#include "util/Taggable.hpp" +#include "web/ng/Error.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace web::ng::impl { + +std::expected, Error> +make_PlainWsConnection( + boost::asio::ip::tcp::socket socket, + std::string ip, + boost::beast::flat_buffer buffer, + boost::beast::http::request request, + util::TagDecoratorFactory const& tagDecoratorFactory, + boost::asio::yield_context yield +) +{ + auto connection = std::make_unique( + std::move(socket), std::move(ip), std::move(buffer), std::move(request), tagDecoratorFactory + ); + auto maybeError = connection->performHandshake(yield); + if (maybeError.has_value()) + return std::unexpected{maybeError.value()}; + return connection; +} + +std::expected, Error> +make_SslWsConnection( + boost::asio::ip::tcp::socket socket, + std::string ip, + boost::beast::flat_buffer buffer, + boost::beast::http::request request, + boost::asio::ssl::context& sslContext, + util::TagDecoratorFactory const& tagDecoratorFactory, + boost::asio::yield_context yield +) +{ + auto connection = std::make_unique( + std::move(socket), std::move(ip), std::move(buffer), sslContext, std::move(request), tagDecoratorFactory + ); + auto maybeError = connection->performHandshake(yield); + if (maybeError.has_value()) + return std::unexpected{maybeError.value()}; + return connection; +} + +} // namespace web::ng::impl diff --git a/src/web/ng/impl/WsConnection.hpp b/src/web/ng/impl/WsConnection.hpp new file mode 100644 index 00000000..956f1538 --- /dev/null +++ b/src/web/ng/impl/WsConnection.hpp @@ -0,0 +1,178 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "util/Taggable.hpp" +#include "util/WithTimeout.hpp" +#include "util/build/Build.hpp" +#include "web/ng/Connection.hpp" +#include "web/ng/Error.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" +#include "web/ng/impl/Concepts.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace web::ng::impl { + +template +class WsConnection : public Connection { + boost::beast::websocket::stream stream_; + boost::beast::http::request initialRequest_; + +public: + WsConnection( + boost::asio::ip::tcp::socket socket, + std::string ip, + boost::beast::flat_buffer buffer, + boost::beast::http::request initialRequest, + util::TagDecoratorFactory const& tagDecoratorFactory + ) + requires IsTcpStream + : Connection(std::move(ip), std::move(buffer), tagDecoratorFactory) + , stream_(std::move(socket)) + , initialRequest_(std::move(initialRequest)) + { + } + + WsConnection( + boost::asio::ip::tcp::socket socket, + std::string ip, + boost::beast::flat_buffer buffer, + boost::asio::ssl::context& sslContext, + boost::beast::http::request initialRequest, + util::TagDecoratorFactory const& tagDecoratorFactory + ) + requires IsSslTcpStream + : Connection(std::move(ip), std::move(buffer), tagDecoratorFactory) + , stream_(std::move(socket), sslContext) + , initialRequest_(std::move(initialRequest)) + { + // Disable the timeout. The websocket::stream uses its own timeout settings. + boost::beast::get_lowest_layer(stream_).expires_never(); + stream_.set_option(boost::beast::websocket::stream_base::timeout::suggested(boost::beast::role_type::server)); + stream_.set_option( + boost::beast::websocket::stream_base::decorator([](boost::beast::websocket::response_type& res) { + res.set(boost::beast::http::field::server, util::build::getClioFullVersionString()); + }) + ); + } + + std::optional + performHandshake(boost::asio::yield_context yield) + { + Error error; + stream_.async_accept(initialRequest_, yield[error]); + if (error) + return error; + return std::nullopt; + } + + bool + wasUpgraded() const override + { + return true; + } + + std::optional + send( + Response response, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT + ) override + { + auto error = util::withTimeout( + [this, &response](auto&& yield) { stream_.async_write(response.asConstBuffer(), yield); }, yield, timeout + ); + if (error) + return error; + return std::nullopt; + } + + std::expected + receive(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT) override + { + auto error = util::withTimeout([this](auto&& yield) { stream_.async_read(buffer_, yield); }, yield, timeout); + if (error) + return std::unexpected{error}; + + auto request = boost::beast::buffers_to_string(buffer_.data()); + buffer_.consume(buffer_.size()); + + return Request{std::move(request), initialRequest_}; + } + + void + close(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout = DEFAULT_TIMEOUT) override + { + boost::beast::websocket::stream_base::timeout wsTimeout{}; + stream_.get_option(wsTimeout); + wsTimeout.handshake_timeout = timeout; + stream_.set_option(wsTimeout); + + stream_.async_close(boost::beast::websocket::close_code::normal, yield); + } +}; + +using PlainWsConnection = WsConnection; +using SslWsConnection = WsConnection>; + +std::expected, Error> +make_PlainWsConnection( + boost::asio::ip::tcp::socket socket, + std::string ip, + boost::beast::flat_buffer buffer, + boost::beast::http::request request, + util::TagDecoratorFactory const& tagDecoratorFactory, + boost::asio::yield_context yield +); + +std::expected, Error> +make_SslWsConnection( + boost::asio::ip::tcp::socket socket, + std::string ip, + boost::beast::flat_buffer buffer, + boost::beast::http::request request, + boost::asio::ssl::context& sslContext, + util::TagDecoratorFactory const& tagDecoratorFactory, + boost::asio::yield_context yield +); + +} // namespace web::ng::impl diff --git a/tests/common/CMakeLists.txt b/tests/common/CMakeLists.txt index 10d5f525..56629598 100644 --- a/tests/common/CMakeLists.txt +++ b/tests/common/CMakeLists.txt @@ -1,8 +1,15 @@ add_library(clio_testing_common) target_sources( - clio_testing_common PRIVATE util/StringUtils.cpp util/TestHttpServer.cpp util/TestWsServer.cpp util/TestObject.cpp - util/AssignRandomPort.cpp util/WithTimeout.cpp + clio_testing_common + PRIVATE util/AssignRandomPort.cpp + util/CallWithTimeout.cpp + util/StringUtils.cpp + util/TestHttpClient.cpp + util/TestHttpServer.cpp + util/TestObject.cpp + util/TestWebSocketClient.cpp + util/TestWsServer.cpp ) include(deps/gtest) diff --git a/tests/common/util/WithTimeout.cpp b/tests/common/util/CallWithTimeout.cpp similarity index 92% rename from tests/common/util/WithTimeout.cpp rename to tests/common/util/CallWithTimeout.cpp index 9604fbde..0f4710f0 100644 --- a/tests/common/util/WithTimeout.cpp +++ b/tests/common/util/CallWithTimeout.cpp @@ -17,7 +17,7 @@ */ //============================================================================== -#include "util/WithTimeout.hpp" +#include "util/CallWithTimeout.hpp" #include @@ -30,7 +30,7 @@ namespace tests::common::util { void -withTimeout(std::chrono::steady_clock::duration timeout, std::function function) +callWithTimeout(std::chrono::steady_clock::duration timeout, std::function function) { std::promise promise; auto future = promise.get_future(); diff --git a/tests/common/util/WithTimeout.hpp b/tests/common/util/CallWithTimeout.hpp similarity index 93% rename from tests/common/util/WithTimeout.hpp rename to tests/common/util/CallWithTimeout.hpp index 3d782139..21289762 100644 --- a/tests/common/util/WithTimeout.hpp +++ b/tests/common/util/CallWithTimeout.hpp @@ -31,6 +31,6 @@ namespace tests::common::util { * @param function The function to run */ void -withTimeout(std::chrono::steady_clock::duration timeout, std::function function); +callWithTimeout(std::chrono::steady_clock::duration timeout, std::function function); } // namespace tests::common::util diff --git a/tests/common/util/TestHttpClient.cpp b/tests/common/util/TestHttpClient.cpp new file mode 100644 index 00000000..fbe975ce --- /dev/null +++ b/tests/common/util/TestHttpClient.cpp @@ -0,0 +1,250 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/TestHttpClient.hpp" + +#include "util/Assert.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace http = boost::beast::http; +namespace net = boost::asio; +namespace ssl = boost::asio::ssl; +using tcp = boost::asio::ip::tcp; + +namespace { + +std::string +syncRequest( + std::string const& host, + std::string const& port, + std::string const& body, + std::vector additionalHeaders, + http::verb method, + std::string target = "/" +) +{ + boost::asio::io_context ioc; + + net::ip::tcp::resolver resolver(ioc); + boost::beast::tcp_stream stream(ioc); + + auto const results = resolver.resolve(host, port); + stream.connect(results); + + http::request req{method, "/", 10}; + req.set(http::field::host, host); + req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); + + for (auto& header : additionalHeaders) { + req.set(header.name, header.value); + } + + req.target(target); + req.body() = std::string(body); + req.prepare_payload(); + http::write(stream, req); + + boost::beast::flat_buffer buffer; + http::response res; + http::read(stream, buffer, res); + + boost::beast::error_code ec; + stream.socket().shutdown(tcp::socket::shutdown_both, ec); + + return res.body(); +} + +} // namespace + +WebHeader::WebHeader(http::field name, std::string value) : name(name), value(std::move(value)) +{ +} + +std::string +HttpSyncClient::post( + std::string const& host, + std::string const& port, + std::string const& body, + std::vector additionalHeaders +) +{ + return syncRequest(host, port, body, std::move(additionalHeaders), http::verb::post); +} + +std::string +HttpSyncClient::get( + std::string const& host, + std::string const& port, + std::string const& body, + std::string const& target, + std::vector additionalHeaders +) +{ + return syncRequest(host, port, body, std::move(additionalHeaders), http::verb::get, target); +} + +bool +HttpsSyncClient::verify_certificate(bool /* preverified */, boost::asio::ssl::verify_context& /* ctx */) +{ + return true; +} + +std::string +HttpsSyncClient::syncPost(std::string const& host, std::string const& port, std::string const& body) +{ + net::io_context ioc; + boost::asio::ssl::context ctx(boost::asio::ssl::context::sslv23); + ctx.set_default_verify_paths(); + ctx.set_verify_mode(ssl::verify_none); + + tcp::resolver resolver(ioc); + boost::beast::ssl_stream stream(ioc, ctx); + +// We can't fix this so have to ignore +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wold-style-cast" + if (!SSL_set_tlsext_host_name(stream.native_handle(), host.c_str())) +#pragma GCC diagnostic pop + { + boost::beast::error_code const ec{static_cast(::ERR_get_error()), net::error::get_ssl_category()}; + throw boost::beast::system_error{ec}; + } + + auto const results = resolver.resolve(host, port); + boost::beast::get_lowest_layer(stream).connect(results); + stream.handshake(ssl::stream_base::client); + + http::request req{http::verb::post, "/", 10}; + req.set(http::field::host, host); + req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); + req.body() = std::string(body); + req.prepare_payload(); + http::write(stream, req); + + boost::beast::flat_buffer buffer; + http::response res; + http::read(stream, buffer, res); + + boost::beast::error_code ec; + stream.shutdown(ec); + + return res.body(); +} + +HttpAsyncClient::HttpAsyncClient(boost::asio::io_context& ioContext) : stream_{ioContext} +{ +} + +std::optional +HttpAsyncClient::connect( + std::string_view host, + std::string_view port, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout +) +{ + boost::system::error_code error; + boost::asio::ip::tcp::resolver resolver{stream_.get_executor()}; + auto const resolverResults = resolver.resolve(host, port, error); + if (error) + return error; + + ASSERT(resolverResults.size() > 0, "No results from resolver"); + + boost::beast::get_lowest_layer(stream_).expires_after(timeout); + stream_.async_connect(resolverResults.begin()->endpoint(), yield[error]); + if (error) + return error; + return std::nullopt; +} + +std::optional +HttpAsyncClient::send( + boost::beast::http::request request, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout +) +{ + request.prepare_payload(); + boost::system::error_code error; + boost::beast::get_lowest_layer(stream_).expires_after(timeout); + http::async_write(stream_, request, yield[error]); + if (error) + return error; + return std::nullopt; +} + +std::expected, boost::system::error_code> +HttpAsyncClient::receive(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout) +{ + boost::system::error_code error; + http::response response; + boost::beast::get_lowest_layer(stream_).expires_after(timeout); + http::async_read(stream_, buffer_, response, yield[error]); + if (error) + return std::unexpected{error}; + return response; +} + +void +HttpAsyncClient::gracefulShutdown() +{ + boost::system::error_code error; + stream_.socket().shutdown(tcp::socket::shutdown_both, error); +} + +void +HttpAsyncClient::disconnect() +{ + boost::system::error_code error; + stream_.socket().close(error); +} diff --git a/tests/common/util/TestHttpClient.hpp b/tests/common/util/TestHttpClient.hpp new file mode 100644 index 00000000..2a5e5d4e --- /dev/null +++ b/tests/common/util/TestHttpClient.hpp @@ -0,0 +1,99 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2023, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +struct WebHeader { + WebHeader(boost::beast::http::field name, std::string value); + + boost::beast::http::field name; + std::string value; +}; + +struct HttpSyncClient { + static std::string + post( + std::string const& host, + std::string const& port, + std::string const& body, + std::vector additionalHeaders = {} + ); + + static std::string + get(std::string const& host, + std::string const& port, + std::string const& body, + std::string const& target, + std::vector additionalHeaders = {}); +}; + +struct HttpsSyncClient { + static bool + verify_certificate(bool /* preverified */, boost::asio::ssl::verify_context& /* ctx */); + + static std::string + syncPost(std::string const& host, std::string const& port, std::string const& body); +}; + +class HttpAsyncClient { + boost::beast::tcp_stream stream_; + boost::beast::flat_buffer buffer_; + +public: + HttpAsyncClient(boost::asio::io_context& ioContext); + + std::optional + connect( + std::string_view host, + std::string_view port, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout + ); + + std::optional + send( + boost::beast::http::request request, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout + ); + + std::expected, boost::system::error_code> + receive(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout); + + void + gracefulShutdown(); + void + disconnect(); +}; diff --git a/tests/common/util/TestHttpServer.cpp b/tests/common/util/TestHttpServer.cpp index 904978fa..60884011 100644 --- a/tests/common/util/TestHttpServer.cpp +++ b/tests/common/util/TestHttpServer.cpp @@ -19,6 +19,8 @@ #include "util/TestHttpServer.hpp" +#include "util/Assert.hpp" + #include #include #include @@ -36,6 +38,7 @@ #include #include +#include #include #include @@ -107,13 +110,27 @@ doSession( TestHttpServer::TestHttpServer(boost::asio::io_context& context, std::string host) : acceptor_(context) { - boost::asio::ip::tcp::endpoint const endpoint(boost::asio::ip::make_address(host), 0); + boost::asio::ip::tcp::resolver resolver{context}; + auto const results = resolver.resolve(host, "0"); + ASSERT(!results.empty(), "Failed to resolve host"); + boost::asio::ip::tcp::endpoint const& endpoint = results.begin()->endpoint(); acceptor_.open(endpoint.protocol()); acceptor_.set_option(asio::socket_base::reuse_address(true)); acceptor_.bind(endpoint); acceptor_.listen(asio::socket_base::max_listen_connections); } +std::expected +TestHttpServer::accept(boost::asio::yield_context yield) +{ + boost::beast::error_code errorCode; + tcp::socket socket(this->acceptor_.get_executor()); + acceptor_.async_accept(socket, yield[errorCode]); + if (errorCode) + return std::unexpected{errorCode}; + return socket; +} + void TestHttpServer::handleRequest(TestHttpServer::RequestHandler handler, bool const allowToFail) { diff --git a/tests/common/util/TestHttpServer.hpp b/tests/common/util/TestHttpServer.hpp index 052581e2..bb057489 100644 --- a/tests/common/util/TestHttpServer.hpp +++ b/tests/common/util/TestHttpServer.hpp @@ -21,9 +21,11 @@ #include #include +#include #include #include +#include #include #include #include @@ -44,6 +46,15 @@ public: */ TestHttpServer(boost::asio::io_context& context, std::string host); + /** + * @brief Accept a new connection + * + * @param yield boost::asio::yield_context to use for networking + * @return Either a socket with the new connection or an error code + */ + std::expected + accept(boost::asio::yield_context yield); + /** * @brief Start the server * diff --git a/tests/common/util/TestHttpSyncClient.hpp b/tests/common/util/TestHttpSyncClient.hpp deleted file mode 100644 index 37173c6f..00000000 --- a/tests/common/util/TestHttpSyncClient.hpp +++ /dev/null @@ -1,270 +0,0 @@ -//------------------------------------------------------------------------------ -/* - This file is part of clio: https://github.com/XRPLF/clio - Copyright (c) 2023, the clio developers. - - Permission to use, copy, modify, and distribute this software for any - purpose with or without fee is hereby granted, provided that the above - copyright notice and this permission notice appear in all copies. - - THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -*/ -//============================================================================== - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace http = boost::beast::http; -namespace net = boost::asio; -namespace ssl = boost::asio::ssl; -using tcp = boost::asio::ip::tcp; - -struct WebHeader { - WebHeader(http::field name, std::string value) : name(name), value(std::move(value)) - { - } - http::field name; - std::string value; -}; - -struct HttpSyncClient { - static std::string - syncPost( - std::string const& host, - std::string const& port, - std::string const& body, - std::vector additionalHeaders = {} - ) - { - return syncRequest(host, port, body, std::move(additionalHeaders), http::verb::post); - } - - static std::string - syncGet( - std::string const& host, - std::string const& port, - std::string const& body, - std::string const& target, - std::vector additionalHeaders = {} - ) - { - return syncRequest(host, port, body, std::move(additionalHeaders), http::verb::get, target); - } - -private: - static std::string - syncRequest( - std::string const& host, - std::string const& port, - std::string const& body, - std::vector additionalHeaders, - http::verb method, - std::string target = "/" - ) - { - boost::asio::io_context ioc; - - net::ip::tcp::resolver resolver(ioc); - boost::beast::tcp_stream stream(ioc); - - auto const results = resolver.resolve(host, port); - stream.connect(results); - - http::request req{method, "/", 10}; - req.set(http::field::host, host); - req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); - - for (auto& header : additionalHeaders) { - req.set(header.name, header.value); - } - - req.target(target); - req.body() = std::string(body); - req.prepare_payload(); - http::write(stream, req); - - boost::beast::flat_buffer buffer; - http::response res; - http::read(stream, buffer, res); - - boost::beast::error_code ec; - stream.socket().shutdown(tcp::socket::shutdown_both, ec); - - return res.body(); - } -}; - -class WebSocketSyncClient { - net::io_context ioc_; - tcp::resolver resolver_{ioc_}; - boost::beast::websocket::stream ws_{ioc_}; - -public: - void - connect(std::string const& host, std::string const& port, std::vector additionalHeaders = {}) - { - auto const results = resolver_.resolve(host, port); - auto const ep = net::connect(ws_.next_layer(), results); - - // Update the host_ string. This will provide the value of the - // Host HTTP header during the WebSocket handshake. - // See https://tools.ietf.org/html/rfc7230#section-5.4 - auto const hostPort = host + ':' + std::to_string(ep.port()); - - ws_.set_option(boost::beast::websocket::stream_base::decorator([additionalHeaders = std::move(additionalHeaders - )](boost::beast::websocket::request_type& req) { - req.set(http::field::user_agent, std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-coro"); - for (auto const& header : additionalHeaders) { - req.set(header.name, header.value); - } - })); - - ws_.handshake(hostPort, "/"); - } - - void - disconnect() - { - ws_.close(boost::beast::websocket::close_code::normal); - } - - std::string - syncPost(std::string const& body) - { - boost::beast::flat_buffer buffer; - - ws_.write(net::buffer(std::string(body))); - ws_.read(buffer); - - return boost::beast::buffers_to_string(buffer.data()); - } -}; - -struct HttpsSyncClient { - static bool - verify_certificate(bool /* preverified */, boost::asio::ssl::verify_context& /* ctx */) - { - return true; - } - - static std::string - syncPost(std::string const& host, std::string const& port, std::string const& body) - { - net::io_context ioc; - boost::asio::ssl::context ctx(boost::asio::ssl::context::sslv23); - ctx.set_default_verify_paths(); - ctx.set_verify_mode(ssl::verify_none); - - tcp::resolver resolver(ioc); - boost::beast::ssl_stream stream(ioc, ctx); - -// We can't fix this so have to ignore -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wold-style-cast" - if (!SSL_set_tlsext_host_name(stream.native_handle(), host.c_str())) -#pragma GCC diagnostic pop - { - boost::beast::error_code const ec{static_cast(::ERR_get_error()), net::error::get_ssl_category()}; - throw boost::beast::system_error{ec}; - } - - auto const results = resolver.resolve(host, port); - boost::beast::get_lowest_layer(stream).connect(results); - stream.handshake(ssl::stream_base::client); - - http::request req{http::verb::post, "/", 10}; - req.set(http::field::host, host); - req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); - req.body() = std::string(body); - req.prepare_payload(); - http::write(stream, req); - - boost::beast::flat_buffer buffer; - http::response res; - http::read(stream, buffer, res); - - boost::beast::error_code ec; - stream.shutdown(ec); - - return res.body(); - } -}; - -class WebServerSslSyncClient { - net::io_context ioc_; - std::optional>> ws_; - -public: - void - connect(std::string const& host, std::string const& port) - { - boost::asio::ssl::context ctx(boost::asio::ssl::context::sslv23); - ctx.set_default_verify_paths(); - ctx.set_verify_mode(ssl::verify_none); - - tcp::resolver resolver{ioc_}; - ws_.emplace(ioc_, ctx); - - auto const results = resolver.resolve(host, port); - net::connect(ws_->next_layer().next_layer(), results.begin(), results.end()); - ws_->next_layer().handshake(ssl::stream_base::client); - - ws_->set_option(boost::beast::websocket::stream_base::decorator([](boost::beast::websocket::request_type& req) { - req.set(http::field::user_agent, std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-coro"); - })); - - ws_->handshake(host, "/"); - } - - void - disconnect() - { - ws_->close(boost::beast::websocket::close_code::normal); - } - - std::string - syncPost(std::string const& body) - { - boost::beast::flat_buffer buffer; - ws_->write(net::buffer(std::string(body))); - ws_->read(buffer); - - return boost::beast::buffers_to_string(buffer.data()); - } -}; diff --git a/tests/common/util/TestWebSocketClient.cpp b/tests/common/util/TestWebSocketClient.cpp new file mode 100644 index 00000000..f34cffbe --- /dev/null +++ b/tests/common/util/TestWebSocketClient.cpp @@ -0,0 +1,225 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/TestWebSocketClient.hpp" + +#include "util/Assert.hpp" +#include "util/TestHttpClient.hpp" +#include "util/WithTimeout.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace http = boost::beast::http; +namespace net = boost::asio; +namespace ssl = boost::asio::ssl; +using tcp = boost::asio::ip::tcp; + +void +WebSocketSyncClient::connect(std::string const& host, std::string const& port, std::vector additionalHeaders) +{ + auto const results = resolver_.resolve(host, port); + auto const ep = net::connect(ws_.next_layer(), results); + + // Update the host_ string. This will provide the value of the + // Host HTTP header during the WebSocket handshake. + // See https://tools.ietf.org/html/rfc7230#section-5.4 + auto const hostPort = host + ':' + std::to_string(ep.port()); + + ws_.set_option(boost::beast::websocket::stream_base::decorator([additionalHeaders = std::move(additionalHeaders + )](boost::beast::websocket::request_type& req) { + req.set(http::field::user_agent, std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-coro"); + for (auto const& header : additionalHeaders) { + req.set(header.name, header.value); + } + })); + + ws_.handshake(hostPort, "/"); +} + +void +WebSocketSyncClient::disconnect() +{ + ws_.close(boost::beast::websocket::close_code::normal); +} + +std::string +WebSocketSyncClient::syncPost(std::string const& body) +{ + boost::beast::flat_buffer buffer; + + ws_.write(net::buffer(std::string(body))); + ws_.read(buffer); + + return boost::beast::buffers_to_string(buffer.data()); +} + +void +WebServerSslSyncClient::connect(std::string const& host, std::string const& port) +{ + boost::asio::ssl::context ctx(boost::asio::ssl::context::sslv23); + ctx.set_default_verify_paths(); + ctx.set_verify_mode(ssl::verify_none); + + tcp::resolver resolver{ioc_}; + ws_.emplace(ioc_, ctx); + + auto const results = resolver.resolve(host, port); + net::connect(ws_->next_layer().next_layer(), results.begin(), results.end()); + ws_->next_layer().handshake(ssl::stream_base::client); + + ws_->set_option(boost::beast::websocket::stream_base::decorator([](boost::beast::websocket::request_type& req) { + req.set(http::field::user_agent, std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-coro"); + })); + + ws_->handshake(host, "/"); +} + +void +WebServerSslSyncClient::disconnect() +{ + ws_->close(boost::beast::websocket::close_code::normal); +} + +std::string +WebServerSslSyncClient::syncPost(std::string const& body) +{ + boost::beast::flat_buffer buffer; + ws_->write(net::buffer(std::string(body))); + ws_->read(buffer); + + return boost::beast::buffers_to_string(buffer.data()); +} + +WebSocketAsyncClient::WebSocketAsyncClient(boost::asio::io_context& ioContext) : stream_{ioContext} +{ +} + +std::optional +WebSocketAsyncClient::connect( + std::string const& host, + std::string const& port, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout, + std::vector additionalHeaders +) +{ + auto const results = boost::asio::ip::tcp::resolver{yield.get_executor()}.resolve(host, port); + ASSERT(not results.empty(), "Could not resolve {}:{}", host, port); + + boost::system::error_code error; + boost::beast::get_lowest_layer(stream_).expires_after(timeout); + stream_.next_layer().async_connect(results, yield[error]); + if (error) + return error; + + boost::beast::websocket::stream_base::timeout wsTimeout{}; + stream_.get_option(wsTimeout); + wsTimeout.handshake_timeout = timeout; + stream_.set_option(wsTimeout); + boost::beast::get_lowest_layer(stream_).expires_never(); + + stream_.set_option(boost::beast::websocket::stream_base::decorator([additionalHeaders = std::move(additionalHeaders + )](boost::beast::websocket::request_type& req) { + for (auto const& header : additionalHeaders) { + req.set(header.name, header.value); + } + })); + stream_.async_handshake(fmt::format("{}:{}", host, port), "/", yield[error]); + + if (error) + return error; + + return std::nullopt; +} + +std::optional +WebSocketAsyncClient::send( + boost::asio::yield_context yield, + std::string_view message, + std::chrono::steady_clock::duration timeout +) +{ + auto const error = util::withTimeout( + [this, &message](auto&& cyield) { stream_.async_write(net::buffer(message), cyield); }, yield, timeout + ); + + if (error) + return error; + return std::nullopt; +} + +std::expected +WebSocketAsyncClient::receive(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout) +{ + boost::beast::flat_buffer buffer{}; + auto error = + util::withTimeout([this, &buffer](auto&& cyield) { stream_.async_read(buffer, cyield); }, yield, timeout); + if (error) + return std::unexpected{error}; + return boost::beast::buffers_to_string(buffer.data()); +} + +void +WebSocketAsyncClient::gracefulClose(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout) +{ + boost::beast::websocket::stream_base::timeout wsTimeout{}; + stream_.get_option(wsTimeout); + wsTimeout.handshake_timeout = timeout; + stream_.set_option(wsTimeout); + stream_.async_close(boost::beast::websocket::close_code::normal, yield); +} + +void +WebSocketAsyncClient::close() +{ + boost::beast::get_lowest_layer(stream_).close(); +} diff --git a/tests/common/util/TestWebSocketClient.hpp b/tests/common/util/TestWebSocketClient.hpp new file mode 100644 index 00000000..c8ba064a --- /dev/null +++ b/tests/common/util/TestWebSocketClient.hpp @@ -0,0 +1,94 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2023, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "util/TestHttpClient.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class WebSocketSyncClient { + boost::asio::io_context ioc_; + boost::asio::ip::tcp::resolver resolver_{ioc_}; + boost::beast::websocket::stream ws_{ioc_}; + +public: + void + connect(std::string const& host, std::string const& port, std::vector additionalHeaders = {}); + + void + disconnect(); + + std::string + syncPost(std::string const& body); +}; + +class WebSocketAsyncClient { + boost::beast::websocket::stream stream_; + +public: + WebSocketAsyncClient(boost::asio::io_context& ioContext); + + std::optional + connect( + std::string const& host, + std::string const& port, + boost::asio::yield_context yield, + std::chrono::steady_clock::duration timeout, + std::vector additionalHeaders = {} + ); + + std::optional + send(boost::asio::yield_context yield, std::string_view message, std::chrono::steady_clock::duration timeout); + + std::expected + receive(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout); + + void + gracefulClose(boost::asio::yield_context yield, std::chrono::steady_clock::duration timeout); + + void + close(); +}; + +class WebServerSslSyncClient { + boost::asio::io_context ioc_; + std::optional>> ws_; + +public: + void + connect(std::string const& host, std::string const& port); + + void + disconnect(); + + std::string + syncPost(std::string const& body); +}; diff --git a/tests/common/util/TmpFile.hpp b/tests/common/util/TmpFile.hpp index 71292ed3..f0964703 100644 --- a/tests/common/util/TmpFile.hpp +++ b/tests/common/util/TmpFile.hpp @@ -25,9 +25,10 @@ #include #include #include +#include struct TmpFile { - std::string const path; + std::string path; TmpFile(std::string_view content) : path{std::tmpnam(nullptr)} { @@ -36,8 +37,25 @@ struct TmpFile { ofs << content; } + TmpFile(TmpFile const&) = delete; + TmpFile(TmpFile&& other) : path{std::move(other.path)} + { + other.path.clear(); + } + TmpFile& + operator=(TmpFile const&) = delete; + TmpFile& + + operator=(TmpFile&& other) + { + if (this != &other) + *this = std::move(other); + return *this; + } + ~TmpFile() { - std::filesystem::remove(path); + if (not path.empty()) + std::filesystem::remove(path); } }; diff --git a/tests/common/web/ng/MockConnection.hpp b/tests/common/web/ng/MockConnection.hpp new file mode 100644 index 00000000..35caad6a --- /dev/null +++ b/tests/common/web/ng/MockConnection.hpp @@ -0,0 +1,62 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include "web/ng/Connection.hpp" +#include "web/ng/Error.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" + +#include +#include + +#include +#include +#include + +struct MockConnectionImpl : web::ng::Connection { + using web::ng::Connection::Connection; + + MOCK_METHOD(bool, wasUpgraded, (), (const, override)); + + using SendReturnType = std::optional; + MOCK_METHOD( + SendReturnType, + send, + (web::ng::Response, boost::asio::yield_context, std::chrono::steady_clock::duration), + (override) + ); + + using ReceiveReturnType = std::expected; + MOCK_METHOD( + ReceiveReturnType, + receive, + (boost::asio::yield_context, std::chrono::steady_clock::duration), + (override) + ); + + MOCK_METHOD(void, close, (boost::asio::yield_context, std::chrono::steady_clock::duration)); +}; + +using MockConnection = testing::NiceMock; +using MockConnectionPtr = std::unique_ptr>; + +using StrictMockConnection = testing::StrictMock; +using StrictMockConnectionPtr = std::unique_ptr>; diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 524a0cef..09b8fd70 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -94,6 +94,7 @@ target_sources( rpc/RPCEngineTests.cpp rpc/RPCHelpersTests.cpp rpc/WorkQueueTests.cpp + test_data/SslCert.cpp util/AccountUtilsTests.cpp util/AssertTests.cpp # Async framework @@ -103,6 +104,7 @@ target_sources( util/async/AnyStrandTests.cpp util/async/AsyncExecutionContextTests.cpp util/BatchingTests.cpp + util/CoroutineGroupTests.cpp util/LedgerUtilsTests.cpp # Prometheus support util/prometheus/BoolTests.cpp @@ -125,12 +127,19 @@ target_sources( util/SignalsHandlerTests.cpp util/TimeUtilsTests.cpp util/TxUtilTests.cpp + util/WithTimeout.cpp # Webserver - web/AdminVerificationTests.cpp web/dosguard/DOSGuardTests.cpp web/dosguard/IntervalSweepHandlerTests.cpp web/dosguard/WhitelistHandlerTests.cpp - web/impl/ServerSslContextTests.cpp + web/impl/AdminVerificationTests.cpp + web/ng/ResponseTests.cpp + web/ng/RequestTests.cpp + web/ng/ServerTests.cpp + web/ng/impl/ConnectionHandlerTests.cpp + web/ng/impl/HttpConnectionTests.cpp + web/ng/impl/ServerSslContextTests.cpp + web/ng/impl/WsConnectionTests.cpp web/RPCServerHandlerTests.cpp web/ServerTests.cpp # New Config @@ -143,12 +152,6 @@ target_sources( util/newconfig/ValueViewTests.cpp ) -configure_file(test_data/cert.pem ${CMAKE_BINARY_DIR}/tests/unit/test_data/cert.pem COPYONLY) -target_compile_definitions(clio_tests PRIVATE TEST_DATA_SSL_CERT_PATH="tests/unit/test_data/cert.pem") - -configure_file(test_data/key.pem ${CMAKE_BINARY_DIR}/tests/unit/test_data/key.pem COPYONLY) -target_compile_definitions(clio_tests PRIVATE TEST_DATA_SSL_KEY_PATH="tests/unit/test_data/key.pem") - # See https://github.com/google/googletest/issues/3475 gtest_discover_tests(clio_tests DISCOVERY_TIMEOUT 90) diff --git a/tests/unit/test_data/SslCert.cpp b/tests/unit/test_data/SslCert.cpp new file mode 100644 index 00000000..ad4bee30 --- /dev/null +++ b/tests/unit/test_data/SslCert.cpp @@ -0,0 +1,105 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/TmpFile.hpp" + +#include + +#include + +namespace tests { + +std::string_view +sslCert() +{ + static auto constexpr CERT = R"( +-----BEGIN CERTIFICATE----- +MIIDrjCCApagAwIBAgIJAOE4Hv/P8CO3MA0GCSqGSIb3DQEBCwUAMDkxEjAQBgNV +BAMMCTEyNy4wLjAuMTELMAkGA1UEBhMCVVMxFjAUBgNVBAcMDVNhbiBGcmFuc2lz +Y28wHhcNMjMwNTE4MTUwMzEwWhcNMjQwNTE3MTUwMzEwWjBrMQswCQYDVQQGEwJV +UzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5zaXNjbzEN +MAsGA1UECgwEVGVzdDEMMAoGA1UECwwDRGV2MRIwEAYDVQQDDAkxMjcuMC4wLjEw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCo/crhYMiGTrfNvFKg3y0m +pFkPdbQhYUzAKW5lyFTCwc/EQLjfaw+TnxiifKdjmca1N5IaF51KocPSAUEtxT+y +7h1KyP6SAaAnAqaI+ahCJOnMSZ2DYqquevDpACKXKHIyCOjqVg6IKwtTap2ddw3w +A5oAP3C2o11ygUVAkP29T24oDzF6/AgXs6ClTIRGWePkgtMaXDM6vUihyGnEbTwk +PbYL1mVIsHYNMZtbjHw692hsC0K0pT7H2FFuBoA3+OAfN74Ks3cGrjxFjZLnU979 +WsOdMBagMn9VUW+/zPieIALl1gKgB0Hpm63XVtROymqnwxa3eDMSndnVwqzzd+1p +AgMBAAGjgYYwgYMwUwYDVR0jBEwwSqE9pDswOTESMBAGA1UEAwwJMTI3LjAuMC4x +MQswCQYDVQQGEwJVUzEWMBQGA1UEBwwNU2FuIEZyYW5zaXNjb4IJAKu2wr50Pfbq +MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMBQGA1UdEQQNMAuCCTEyNy4wLjAuMTAN +BgkqhkiG9w0BAQsFAAOCAQEArEjC1DmJ6q0735PxGkOmjWNsfnw8c2Zl1Z4idKfn +svEFtegNLU7tCu4aKunxlCHWiFVpunr4X67qH1JiE93W0JADnRrPxvywiqR6nUcO +p6HII/kzOizUXk59QMc1GLIIR6LDlNEeDlUbIc2DH8DPrRFBuIMYy4lf18qyfiUb +8Jt8nLeAzbhA21wI6BVhEt8G/cgIi88mPifXq+YVHrJE01jUREHRwl/MMildqxgp +LLuOOuPuy2d+HqjKE7z00j28Uf7gZK29bGx1rK+xH6veAr4plKBavBr8WWpAoUG+ +PAMNb1i80cMsjK98xXDdr+7Uvy5M4COMwA5XHmMZDEW8Jw== +-----END CERTIFICATE----- +)"; + return CERT; +} + +TmpFile +sslCertFile() +{ + return TmpFile{sslCert()}; +} + +std::string_view +sslKey() +{ + static auto constexpr KEY = R"( +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAqP3K4WDIhk63zbxSoN8tJqRZD3W0IWFMwCluZchUwsHPxEC4 +32sPk58YonynY5nGtTeSGhedSqHD0gFBLcU/su4dSsj+kgGgJwKmiPmoQiTpzEmd +g2Kqrnrw6QAilyhyMgjo6lYOiCsLU2qdnXcN8AOaAD9wtqNdcoFFQJD9vU9uKA8x +evwIF7OgpUyERlnj5ILTGlwzOr1IochpxG08JD22C9ZlSLB2DTGbW4x8OvdobAtC +tKU+x9hRbgaAN/jgHze+CrN3Bq48RY2S51Pe/VrDnTAWoDJ/VVFvv8z4niAC5dYC +oAdB6Zut11bUTspqp8MWt3gzEp3Z1cKs83ftaQIDAQABAoIBAGXZH48Zz4DyrGA4 +YexG1WV2o55np/p+M82Uqs55IGyIdnmnMESmt6qWtjgnvJKQuWu6ZDmJhejW+bf1 +vZyiRrPGQq0x2guRIz6foFLpdHj42lee/mmS659gxRUIWdCUNc7mA8pHt1Zl6tuJ +ZBjlCedfpE8F7R6F8unx8xTozaRr4ZbOVnqB8YWjyuIDUnujsxKdKFASZJAEzRjh ++lScXAdEYTaswgTWFFGKzwTjH/Yfv4y3LwE0RmR/1e+eQmQ7Z4C0HhjYe3EYXAvk +naH2QFZaYVhu7x/+oLPetIzFJOZn61iDhUtGYdvQVvF8qQCPqeuKeLcS9X5my9aK +nfLUryECgYEA3ZZGffe6Me6m0ZX/zwT5NbZpZCJgeALGLZPg9qulDVf8zHbDRsdn +K6Mf/Xhy3DCfSwdwcuAKz/r+4tPFyNUJR+Y2ltXaVl72iY3uJRdriNrEbZ47Ez4z +dhtEmDrD7C+7AusErEgjas+AKXkp1tovXrXUiVfRytBtoKqrym4IjJUCgYEAwzxz +fTuE2nrIwFkvg0p9PtrCwkw8dnzhBeNnzFdPOVAiHCfnNcaSOWWTkGHIkGLoORqs +fqfZCD9VkqRwsPDaSSL7vhX3oHuerDipdxOjaXVjYa7YjM6gByzo62hnG6BcQHC7 +zrj7iqjnMdyNLtXcPu6zm/j5iIOLWXMevK/OVIUCgYAey4e4cfk6f0RH1GTczIAl +6tfyxqRJiXkpVGfrYCdsF1JWyBqTd5rrAZysiVTNLSS2NK54CJL4HJXXyD6wjorf +pyrnA4l4f3Ib49G47exP9Ldf1KG5JufX/iomTeR0qp1+5lKb7tqdOYFCQkiCR4hV +zUdgXwgU+6qArbd6RpiBkQKBgQCSen5jjQ5GJS0NM1y0cmS5jcPlpvEOLO9fTZiI +9VCZPYf5++46qHr42T73aoXh3nNAtMSKWkA5MdtwJDPwbSQ5Dyg1G6IoI9eOewya +LH/EFbC0j0wliLkD6SvvwurpDU1pg6tElAEVrVeYT1MVupp+FPVopkoBpEAeooKD +KpvxSQKBgQDP9fNJIpuX3kaudb0pI1OvuqBYTrTExMx+JMR+Sqf0HUwavpeCn4du +O2R4tGOOkGAX/0/actRXptFk23ucHnSIwcW6HYgDM3tDBP7n3GYdu5CSE1eiR5k7 +Zl3fuvbMYcmYKgutFcRj+8NvzRWT2suzGU2x4PiPX+fh5kpvmMdvLA== +-----END RSA PRIVATE KEY----- +)"; + return KEY; +} + +TmpFile +sslKeyFile() +{ + return TmpFile{sslKey()}; +} + +} // namespace tests diff --git a/src/web/Server.cpp b/tests/unit/test_data/SslCert.hpp similarity index 54% rename from src/web/Server.cpp rename to tests/unit/test_data/SslCert.hpp index a20d72ee..86c5342e 100644 --- a/src/web/Server.cpp +++ b/tests/unit/test_data/SslCert.hpp @@ -17,32 +17,24 @@ */ //============================================================================== -#include "web/Server.hpp" +#pragma once -#include "util/config/Config.hpp" +#include "util/TmpFile.hpp" -#include +#include -#include -#include +namespace tests { -namespace web { +std::string_view +sslCert(); -std::expected, std::string> -makeServerSslContext(util::Config const& config) -{ - bool const configHasCertFile = config.contains("ssl_cert_file"); - bool const configHasKeyFile = config.contains("ssl_key_file"); +TmpFile +sslCertFile(); - if (configHasCertFile != configHasKeyFile) - return std::unexpected{"Config entries 'ssl_cert_file' and 'ssl_key_file' must be set or unset together."}; +std::string_view +sslKey(); - if (not configHasCertFile) - return std::nullopt; +TmpFile +sslKeyFile(); - auto const certFilename = config.value("ssl_cert_file"); - auto const keyFilename = config.value("ssl_key_file"); - - return impl::makeServerSslContext(certFilename, keyFilename); -} -} // namespace web +} // namespace tests diff --git a/tests/unit/test_data/cert.pem b/tests/unit/test_data/cert.pem deleted file mode 100644 index 7ef61709..00000000 --- a/tests/unit/test_data/cert.pem +++ /dev/null @@ -1,22 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDrjCCApagAwIBAgIJAOE4Hv/P8CO3MA0GCSqGSIb3DQEBCwUAMDkxEjAQBgNV -BAMMCTEyNy4wLjAuMTELMAkGA1UEBhMCVVMxFjAUBgNVBAcMDVNhbiBGcmFuc2lz -Y28wHhcNMjMwNTE4MTUwMzEwWhcNMjQwNTE3MTUwMzEwWjBrMQswCQYDVQQGEwJV -UzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5zaXNjbzEN -MAsGA1UECgwEVGVzdDEMMAoGA1UECwwDRGV2MRIwEAYDVQQDDAkxMjcuMC4wLjEw -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCo/crhYMiGTrfNvFKg3y0m -pFkPdbQhYUzAKW5lyFTCwc/EQLjfaw+TnxiifKdjmca1N5IaF51KocPSAUEtxT+y -7h1KyP6SAaAnAqaI+ahCJOnMSZ2DYqquevDpACKXKHIyCOjqVg6IKwtTap2ddw3w -A5oAP3C2o11ygUVAkP29T24oDzF6/AgXs6ClTIRGWePkgtMaXDM6vUihyGnEbTwk -PbYL1mVIsHYNMZtbjHw692hsC0K0pT7H2FFuBoA3+OAfN74Ks3cGrjxFjZLnU979 -WsOdMBagMn9VUW+/zPieIALl1gKgB0Hpm63XVtROymqnwxa3eDMSndnVwqzzd+1p -AgMBAAGjgYYwgYMwUwYDVR0jBEwwSqE9pDswOTESMBAGA1UEAwwJMTI3LjAuMC4x -MQswCQYDVQQGEwJVUzEWMBQGA1UEBwwNU2FuIEZyYW5zaXNjb4IJAKu2wr50Pfbq -MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMBQGA1UdEQQNMAuCCTEyNy4wLjAuMTAN -BgkqhkiG9w0BAQsFAAOCAQEArEjC1DmJ6q0735PxGkOmjWNsfnw8c2Zl1Z4idKfn -svEFtegNLU7tCu4aKunxlCHWiFVpunr4X67qH1JiE93W0JADnRrPxvywiqR6nUcO -p6HII/kzOizUXk59QMc1GLIIR6LDlNEeDlUbIc2DH8DPrRFBuIMYy4lf18qyfiUb -8Jt8nLeAzbhA21wI6BVhEt8G/cgIi88mPifXq+YVHrJE01jUREHRwl/MMildqxgp -LLuOOuPuy2d+HqjKE7z00j28Uf7gZK29bGx1rK+xH6veAr4plKBavBr8WWpAoUG+ -PAMNb1i80cMsjK98xXDdr+7Uvy5M4COMwA5XHmMZDEW8Jw== ------END CERTIFICATE----- diff --git a/tests/unit/test_data/key.pem b/tests/unit/test_data/key.pem deleted file mode 100644 index ff714e73..00000000 --- a/tests/unit/test_data/key.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAqP3K4WDIhk63zbxSoN8tJqRZD3W0IWFMwCluZchUwsHPxEC4 -32sPk58YonynY5nGtTeSGhedSqHD0gFBLcU/su4dSsj+kgGgJwKmiPmoQiTpzEmd -g2Kqrnrw6QAilyhyMgjo6lYOiCsLU2qdnXcN8AOaAD9wtqNdcoFFQJD9vU9uKA8x -evwIF7OgpUyERlnj5ILTGlwzOr1IochpxG08JD22C9ZlSLB2DTGbW4x8OvdobAtC -tKU+x9hRbgaAN/jgHze+CrN3Bq48RY2S51Pe/VrDnTAWoDJ/VVFvv8z4niAC5dYC -oAdB6Zut11bUTspqp8MWt3gzEp3Z1cKs83ftaQIDAQABAoIBAGXZH48Zz4DyrGA4 -YexG1WV2o55np/p+M82Uqs55IGyIdnmnMESmt6qWtjgnvJKQuWu6ZDmJhejW+bf1 -vZyiRrPGQq0x2guRIz6foFLpdHj42lee/mmS659gxRUIWdCUNc7mA8pHt1Zl6tuJ -ZBjlCedfpE8F7R6F8unx8xTozaRr4ZbOVnqB8YWjyuIDUnujsxKdKFASZJAEzRjh -+lScXAdEYTaswgTWFFGKzwTjH/Yfv4y3LwE0RmR/1e+eQmQ7Z4C0HhjYe3EYXAvk -naH2QFZaYVhu7x/+oLPetIzFJOZn61iDhUtGYdvQVvF8qQCPqeuKeLcS9X5my9aK -nfLUryECgYEA3ZZGffe6Me6m0ZX/zwT5NbZpZCJgeALGLZPg9qulDVf8zHbDRsdn -K6Mf/Xhy3DCfSwdwcuAKz/r+4tPFyNUJR+Y2ltXaVl72iY3uJRdriNrEbZ47Ez4z -dhtEmDrD7C+7AusErEgjas+AKXkp1tovXrXUiVfRytBtoKqrym4IjJUCgYEAwzxz -fTuE2nrIwFkvg0p9PtrCwkw8dnzhBeNnzFdPOVAiHCfnNcaSOWWTkGHIkGLoORqs -fqfZCD9VkqRwsPDaSSL7vhX3oHuerDipdxOjaXVjYa7YjM6gByzo62hnG6BcQHC7 -zrj7iqjnMdyNLtXcPu6zm/j5iIOLWXMevK/OVIUCgYAey4e4cfk6f0RH1GTczIAl -6tfyxqRJiXkpVGfrYCdsF1JWyBqTd5rrAZysiVTNLSS2NK54CJL4HJXXyD6wjorf -pyrnA4l4f3Ib49G47exP9Ldf1KG5JufX/iomTeR0qp1+5lKb7tqdOYFCQkiCR4hV -zUdgXwgU+6qArbd6RpiBkQKBgQCSen5jjQ5GJS0NM1y0cmS5jcPlpvEOLO9fTZiI -9VCZPYf5++46qHr42T73aoXh3nNAtMSKWkA5MdtwJDPwbSQ5Dyg1G6IoI9eOewya -LH/EFbC0j0wliLkD6SvvwurpDU1pg6tElAEVrVeYT1MVupp+FPVopkoBpEAeooKD -KpvxSQKBgQDP9fNJIpuX3kaudb0pI1OvuqBYTrTExMx+JMR+Sqf0HUwavpeCn4du -O2R4tGOOkGAX/0/actRXptFk23ucHnSIwcW6HYgDM3tDBP7n3GYdu5CSE1eiR5k7 -Zl3fuvbMYcmYKgutFcRj+8NvzRWT2suzGU2x4PiPX+fh5kpvmMdvLA== ------END RSA PRIVATE KEY----- diff --git a/tests/unit/util/CoroutineGroupTests.cpp b/tests/unit/util/CoroutineGroupTests.cpp new file mode 100644 index 00000000..ba9c6e38 --- /dev/null +++ b/tests/unit/util/CoroutineGroupTests.cpp @@ -0,0 +1,167 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/AsioContextTestFixture.hpp" +#include "util/CoroutineGroup.hpp" + +#include +#include +#include +#include + +#include + +using namespace util; + +struct CoroutineGroupTests : SyncAsioContextTest { + testing::StrictMock> callback1_; + testing::StrictMock> callback2_; + testing::StrictMock> callback3_; +}; + +TEST_F(CoroutineGroupTests, SpawnWait) +{ + testing::Sequence sequence; + EXPECT_CALL(callback1_, Call).InSequence(sequence); + EXPECT_CALL(callback2_, Call).InSequence(sequence); + EXPECT_CALL(callback3_, Call).InSequence(sequence); + + runSpawn([this](boost::asio::yield_context yield) { + CoroutineGroup group{yield, 2}; + + group.spawn(yield, [&](boost::asio::yield_context yield) { + boost::asio::steady_timer timer{yield.get_executor(), std::chrono::milliseconds{1}}; + timer.async_wait(yield); + callback1_.Call(); + }); + EXPECT_EQ(group.size(), 1); + + group.spawn(yield, [&](boost::asio::yield_context yield) { + boost::asio::steady_timer timer{yield.get_executor(), std::chrono::milliseconds{2}}; + timer.async_wait(yield); + callback2_.Call(); + }); + EXPECT_EQ(group.size(), 2); + + group.asyncWait(yield); + EXPECT_EQ(group.size(), 0); + + callback3_.Call(); + }); +} + +TEST_F(CoroutineGroupTests, SpawnWaitSpawnWait) +{ + testing::Sequence sequence; + EXPECT_CALL(callback1_, Call).InSequence(sequence); + EXPECT_CALL(callback2_, Call).InSequence(sequence); + EXPECT_CALL(callback3_, Call).InSequence(sequence); + + runSpawn([this](boost::asio::yield_context yield) { + CoroutineGroup group{yield, 2}; + + group.spawn(yield, [&](boost::asio::yield_context yield) { + boost::asio::steady_timer timer{yield.get_executor(), std::chrono::milliseconds{1}}; + timer.async_wait(yield); + callback1_.Call(); + }); + EXPECT_EQ(group.size(), 1); + + group.asyncWait(yield); + EXPECT_EQ(group.size(), 0); + + group.spawn(yield, [&](boost::asio::yield_context yield) { + boost::asio::steady_timer timer{yield.get_executor(), std::chrono::milliseconds{1}}; + timer.async_wait(yield); + callback2_.Call(); + }); + EXPECT_EQ(group.size(), 1); + + group.asyncWait(yield); + EXPECT_EQ(group.size(), 0); + + callback3_.Call(); + }); +} + +TEST_F(CoroutineGroupTests, ChildCoroutinesFinishBeforeWait) +{ + testing::Sequence sequence; + EXPECT_CALL(callback2_, Call).InSequence(sequence); + EXPECT_CALL(callback1_, Call).InSequence(sequence); + EXPECT_CALL(callback3_, Call).InSequence(sequence); + + runSpawn([this](boost::asio::yield_context yield) { + CoroutineGroup group{yield, 2}; + group.spawn(yield, [&](boost::asio::yield_context yield) { + boost::asio::steady_timer timer{yield.get_executor(), std::chrono::milliseconds{2}}; + timer.async_wait(yield); + callback1_.Call(); + }); + group.spawn(yield, [&](boost::asio::yield_context yield) { + boost::asio::steady_timer timer{yield.get_executor(), std::chrono::milliseconds{1}}; + timer.async_wait(yield); + callback2_.Call(); + }); + + boost::asio::steady_timer timer{yield.get_executor(), std::chrono::milliseconds{3}}; + timer.async_wait(yield); + + group.asyncWait(yield); + callback3_.Call(); + }); +} + +TEST_F(CoroutineGroupTests, EmptyGroup) +{ + EXPECT_CALL(callback1_, Call); + + runSpawn([this](boost::asio::yield_context yield) { + CoroutineGroup group{yield}; + group.asyncWait(yield); + callback1_.Call(); + }); +} + +TEST_F(CoroutineGroupTests, TooManyCoroutines) +{ + EXPECT_CALL(callback1_, Call); + EXPECT_CALL(callback2_, Call); + EXPECT_CALL(callback3_, Call); + + runSpawn([this](boost::asio::yield_context yield) { + CoroutineGroup group{yield, 1}; + + EXPECT_TRUE(group.spawn(yield, [this](boost::asio::yield_context innerYield) { + boost::asio::steady_timer timer{innerYield.get_executor(), std::chrono::milliseconds{1}}; + timer.async_wait(innerYield); + callback1_.Call(); + })); + + EXPECT_FALSE(group.spawn(yield, [this](boost::asio::yield_context) { callback2_.Call(); })); + + boost::asio::steady_timer timer{yield.get_executor(), std::chrono::milliseconds{2}}; + timer.async_wait(yield); + + EXPECT_TRUE(group.spawn(yield, [this](boost::asio::yield_context) { callback2_.Call(); })); + + group.asyncWait(yield); + callback3_.Call(); + }); +} diff --git a/tests/unit/util/RepeatTests.cpp b/tests/unit/util/RepeatTests.cpp index f0d5b7cd..69a6bf8c 100644 --- a/tests/unit/util/RepeatTests.cpp +++ b/tests/unit/util/RepeatTests.cpp @@ -18,8 +18,8 @@ //============================================================================== #include "util/AsioContextTestFixture.hpp" +#include "util/CallWithTimeout.hpp" #include "util/Repeat.hpp" -#include "util/WithTimeout.hpp" #include #include @@ -41,7 +41,7 @@ struct RepeatTests : SyncAsioContextTest { void withRunningContext(std::function func) { - tests::common::util::withTimeout(std::chrono::seconds{1000}, [this, func = std::move(func)]() { + tests::common::util::callWithTimeout(std::chrono::seconds{1}, [this, func = std::move(func)]() { auto workGuard = boost::asio::make_work_guard(ctx); std::thread thread{[this]() { ctx.run(); }}; func(); diff --git a/tests/unit/util/WithTimeout.cpp b/tests/unit/util/WithTimeout.cpp new file mode 100644 index 00000000..6dcb9c93 --- /dev/null +++ b/tests/unit/util/WithTimeout.cpp @@ -0,0 +1,77 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/WithTimeout.hpp" + +#include "util/AsioContextTestFixture.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +struct WithTimeoutTests : SyncAsioContextTest { + using CYieldType = boost::asio::cancellation_slot_binder< + boost::asio::basic_yield_context, + boost::asio::cancellation_slot>; + + testing::StrictMock> operationMock; +}; + +TEST_F(WithTimeoutTests, CallsOperation) +{ + EXPECT_CALL(operationMock, Call); + runSpawn([&](boost::asio::yield_context yield) { + auto const error = util::withTimeout(operationMock.AsStdFunction(), yield, std::chrono::seconds{1}); + EXPECT_EQ(error, boost::system::error_code{}); + }); +} + +TEST_F(WithTimeoutTests, TimesOut) +{ + EXPECT_CALL(operationMock, Call).WillOnce([](auto cyield) { + boost::asio::steady_timer timer{boost::asio::get_associated_executor(cyield)}; + timer.expires_after(std::chrono::milliseconds{10}); + timer.async_wait(cyield); + }); + runSpawn([&](boost::asio::yield_context yield) { + auto error = util::withTimeout(operationMock.AsStdFunction(), yield, std::chrono::milliseconds{1}); + EXPECT_EQ(error.value(), boost::system::errc::timed_out); + }); +} + +TEST_F(WithTimeoutTests, OperationFailed) +{ + EXPECT_CALL(operationMock, Call).WillOnce([](auto cyield) { + boost::asio::ip::tcp::socket socket{boost::asio::get_associated_executor(cyield)}; + socket.async_send(boost::asio::buffer("test"), cyield); + }); + runSpawn([&](boost::asio::yield_context yield) { + auto error = util::withTimeout(operationMock.AsStdFunction(), yield, std::chrono::seconds{1}); + EXPECT_EQ(error.value(), boost::system::errc::bad_file_descriptor); + }); +} diff --git a/tests/unit/web/ServerTests.cpp b/tests/unit/web/ServerTests.cpp index b43469ee..8cdf10e3 100644 --- a/tests/unit/web/ServerTests.cpp +++ b/tests/unit/web/ServerTests.cpp @@ -20,7 +20,9 @@ #include "util/AssignRandomPort.hpp" #include "util/LoggerFixtures.hpp" #include "util/MockPrometheus.hpp" -#include "util/TestHttpSyncClient.hpp" +#include "util/TestHttpClient.hpp" +#include "util/TestWebSocketClient.hpp" +#include "util/TmpFile.hpp" #include "util/config/Config.hpp" #include "util/prometheus/Gauge.hpp" #include "util/prometheus/Label.hpp" @@ -45,6 +47,7 @@ #include #include #include +#include #include #include @@ -103,14 +106,6 @@ generateJSONDataOverload(std::string_view port) )); } -boost::json::value -addSslConfig(boost::json::value config) -{ - config.as_object()["ssl_key_file"] = TEST_DATA_SSL_KEY_PATH; - config.as_object()["ssl_cert_file"] = TEST_DATA_SSL_CERT_PATH; - return config; -} - struct WebServerTest : NoLoggerFixture { ~WebServerTest() override { @@ -126,6 +121,14 @@ struct WebServerTest : NoLoggerFixture { runner.emplace([this] { ctx.run(); }); } + boost::json::value + addSslConfig(boost::json::value config) const + { + config.as_object()["ssl_key_file"] = sslKeyFile.path; + config.as_object()["ssl_cert_file"] = sslCertFile.path; + return config; + } + // this ctx is for dos timer boost::asio::io_context ctxSync; std::string const port = std::to_string(tests::util::generateFreePort()); @@ -141,6 +144,9 @@ struct WebServerTest : NoLoggerFixture { // this ctx is for http server boost::asio::io_context ctx; + TmpFile sslCertFile{tests::sslCertFile()}; + TmpFile sslKeyFile{tests::sslKeyFile()}; + private: std::optional work; std::optional runner; @@ -212,7 +218,7 @@ TEST_F(WebServerTestsWithMockPrometheus, Http) { auto e = std::make_shared(); auto const server = makeServerSync(cfg, ctx, dosGuard, e); - auto const res = HttpSyncClient::syncPost("localhost", port, R"({"Hello":1})"); + auto const res = HttpSyncClient::post("localhost", port, R"({"Hello":1})"); EXPECT_EQ(res, R"({"Hello":1})"); } @@ -236,7 +242,7 @@ TEST_F(WebServerTestsWithMockPrometheus, HttpInternalError) { auto e = std::make_shared(); auto const server = makeServerSync(cfg, ctx, dosGuard, e); - auto const res = HttpSyncClient::syncPost("localhost", port, R"({})"); + auto const res = HttpSyncClient::post("localhost", port, R"({})"); EXPECT_EQ( res, R"({"error":"internal","error_code":73,"error_message":"Internal error.","status":"error","type":"response"})" @@ -286,7 +292,7 @@ TEST_F(WebServerTestsWithMockPrometheus, IncompleteSslConfig) auto e = std::make_shared(); auto jsonConfig = generateJSONWithDynamicPort(port); - jsonConfig.as_object()["ssl_key_file"] = TEST_DATA_SSL_KEY_PATH; + jsonConfig.as_object()["ssl_key_file"] = sslKeyFile.path; auto const server = makeServerSync(Config{jsonConfig}, ctx, dosGuard, e); EXPECT_EQ(server, nullptr); @@ -297,7 +303,7 @@ TEST_F(WebServerTestsWithMockPrometheus, WrongSslConfig) auto e = std::make_shared(); auto jsonConfig = generateJSONWithDynamicPort(port); - jsonConfig.as_object()["ssl_key_file"] = TEST_DATA_SSL_KEY_PATH; + jsonConfig.as_object()["ssl_key_file"] = sslKeyFile.path; jsonConfig.as_object()["ssl_cert_file"] = "wrong_path"; auto const server = makeServerSync(Config{jsonConfig}, ctx, dosGuard, e); @@ -334,9 +340,9 @@ TEST_F(WebServerTestsWithMockPrometheus, HttpRequestOverload) { auto e = std::make_shared(); auto const server = makeServerSync(cfg, ctx, dosGuardOverload, e); - auto res = HttpSyncClient::syncPost("localhost", port, R"({})"); + auto res = HttpSyncClient::post("localhost", port, R"({})"); EXPECT_EQ(res, "{}"); - res = HttpSyncClient::syncPost("localhost", port, R"({})"); + res = HttpSyncClient::post("localhost", port, R"({})"); EXPECT_EQ( res, R"({"error":"slowDown","error_code":10,"error_message":"You are placing too much load on the server.","status":"error","type":"response"})" @@ -372,7 +378,7 @@ TEST_F(WebServerTestsWithMockPrometheus, HttpPayloadOverload) std::string const s100(100, 'a'); auto e = std::make_shared(); auto server = makeServerSync(cfg, ctx, dosGuardOverload, e); - auto const res = HttpSyncClient::syncPost("localhost", port, fmt::format(R"({{"payload":"{}"}})", s100)); + auto const res = HttpSyncClient::post("localhost", port, fmt::format(R"({{"payload":"{}"}})", s100)); EXPECT_EQ( res, R"({"payload":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","warning":"load","warnings":[{"id":2003,"message":"You are about to be rate limited"}]})" @@ -535,7 +541,7 @@ TEST_P(WebServerAdminTest, HttpAdminCheck) auto server = makeServerSync(serverConfig, ctx, dosGuardOverload, e); std::string const request = "Why hello"; uint32_t const webServerPort = serverConfig.value("server.port"); - auto const res = HttpSyncClient::syncPost("localhost", std::to_string(webServerPort), request, GetParam().headers); + auto const res = HttpSyncClient::post("localhost", std::to_string(webServerPort), request, GetParam().headers); EXPECT_EQ(res, fmt::format("{} {}", request, GetParam().expectedResponse)); } @@ -653,7 +659,7 @@ TEST_F(WebServerPrometheusTest, rejectedWithoutAdminPassword) uint32_t const webServerPort = tests::util::generateFreePort(); Config const serverConfig{boost::json::parse(JSONServerConfigWithAdminPassword(webServerPort))}; auto server = makeServerSync(serverConfig, ctx, dosGuard, e); - auto const res = HttpSyncClient::syncGet("localhost", std::to_string(webServerPort), "", "/metrics"); + auto const res = HttpSyncClient::get("localhost", std::to_string(webServerPort), "", "/metrics"); EXPECT_EQ(res, "Only admin is allowed to collect metrics"); } @@ -676,7 +682,7 @@ TEST_F(WebServerPrometheusTest, rejectedIfPrometheusIsDisabled) Config const serverConfig{boost::json::parse(JSONServerConfigWithDisabledPrometheus)}; PrometheusService::init(serverConfig); auto server = makeServerSync(serverConfig, ctx, dosGuard, e); - auto const res = HttpSyncClient::syncGet( + auto const res = HttpSyncClient::get( "localhost", std::to_string(webServerPort), "", @@ -697,7 +703,7 @@ TEST_F(WebServerPrometheusTest, validResponse) auto e = std::make_shared(); Config const serverConfig{boost::json::parse(JSONServerConfigWithAdminPassword(webServerPort))}; auto server = makeServerSync(serverConfig, ctx, dosGuard, e); - auto const res = HttpSyncClient::syncGet( + auto const res = HttpSyncClient::get( "localhost", std::to_string(webServerPort), "", diff --git a/tests/unit/web/AdminVerificationTests.cpp b/tests/unit/web/impl/AdminVerificationTests.cpp similarity index 60% rename from tests/unit/web/AdminVerificationTests.cpp rename to tests/unit/web/impl/AdminVerificationTests.cpp index 942ecfe1..8b6f610e 100644 --- a/tests/unit/web/AdminVerificationTests.cpp +++ b/tests/unit/web/impl/AdminVerificationTests.cpp @@ -18,16 +18,17 @@ //============================================================================== #include "util/LoggerFixtures.hpp" +#include "util/config/Config.hpp" #include "web/impl/AdminVerificationStrategy.hpp" #include #include #include +#include #include #include #include -#include namespace http = boost::beast::http; @@ -81,16 +82,7 @@ TEST_F(PasswordAdminVerificationStrategyTest, IsAdminReturnsTrueOnlyForValidPass } struct MakeAdminVerificationStrategyTestParams { - MakeAdminVerificationStrategyTestParams( - std::optional passwordOpt, - bool expectIpStrategy, - bool expectPasswordStrategy - ) - : passwordOpt(std::move(passwordOpt)) - , expectIpStrategy(expectIpStrategy) - , expectPasswordStrategy(expectPasswordStrategy) - { - } + std::string testName; std::optional passwordOpt; bool expectIpStrategy; bool expectPasswordStrategy; @@ -111,8 +103,78 @@ INSTANTIATE_TEST_CASE_P( MakeAdminVerificationStrategyTest, MakeAdminVerificationStrategyTest, testing::Values( - MakeAdminVerificationStrategyTestParams(std::nullopt, true, false), - MakeAdminVerificationStrategyTestParams("p", false, true), - MakeAdminVerificationStrategyTestParams("", false, true) + MakeAdminVerificationStrategyTestParams{ + .testName = "NoPassword", + .passwordOpt = std::nullopt, + .expectIpStrategy = true, + .expectPasswordStrategy = false + }, + MakeAdminVerificationStrategyTestParams{ + .testName = "HasPassword", + .passwordOpt = "p", + .expectIpStrategy = false, + .expectPasswordStrategy = true + }, + MakeAdminVerificationStrategyTestParams{ + .testName = "EmptyPassword", + .passwordOpt = "", + .expectIpStrategy = false, + .expectPasswordStrategy = true + } + ) +); + +struct MakeAdminVerificationStrategyFromConfigTestParams { + std::string testName; + std::string config; + bool expectedError; +}; + +struct MakeAdminVerificationStrategyFromConfigTest + : public testing::TestWithParam {}; + +TEST_P(MakeAdminVerificationStrategyFromConfigTest, ChecksConfig) +{ + util::Config serverConfig{boost::json::parse(GetParam().config)}; + auto const result = web::impl::make_AdminVerificationStrategy(serverConfig); + if (GetParam().expectedError) { + EXPECT_FALSE(result.has_value()); + } +} + +INSTANTIATE_TEST_SUITE_P( + MakeAdminVerificationStrategyFromConfigTest, + MakeAdminVerificationStrategyFromConfigTest, + testing::Values( + MakeAdminVerificationStrategyFromConfigTestParams{ + .testName = "NoPasswordNoLocalAdmin", + .config = "{}", + .expectedError = true + }, + MakeAdminVerificationStrategyFromConfigTestParams{ + .testName = "OnlyPassword", + .config = R"({"admin_password": "password"})", + .expectedError = false + }, + MakeAdminVerificationStrategyFromConfigTestParams{ + .testName = "OnlyLocalAdmin", + .config = R"({"local_admin": true})", + .expectedError = false + }, + MakeAdminVerificationStrategyFromConfigTestParams{ + .testName = "OnlyLocalAdminDisabled", + .config = R"({"local_admin": false})", + .expectedError = true + }, + MakeAdminVerificationStrategyFromConfigTestParams{ + .testName = "LocalAdminAndPassword", + .config = R"({"local_admin": true, "admin_password": "password"})", + .expectedError = true + }, + MakeAdminVerificationStrategyFromConfigTestParams{ + .testName = "LocalAdminDisabledAndPassword", + .config = R"({"local_admin": false, "admin_password": "password"})", + .expectedError = false + } ) ); diff --git a/tests/unit/web/ng/RequestTests.cpp b/tests/unit/web/ng/RequestTests.cpp new file mode 100644 index 00000000..3478061f --- /dev/null +++ b/tests/unit/web/ng/RequestTests.cpp @@ -0,0 +1,224 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/NameGenerator.hpp" +#include "web/ng/Request.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +using namespace web::ng; +namespace http = boost::beast::http; + +struct RequestTest : public ::testing::Test {}; + +struct RequestMethodTestBundle { + std::string testName; + Request request; + Request::Method expectedMethod; +}; + +struct RequestMethodTest : RequestTest, ::testing::WithParamInterface {}; + +TEST_P(RequestMethodTest, method) +{ + EXPECT_EQ(GetParam().request.method(), GetParam().expectedMethod); +} + +INSTANTIATE_TEST_SUITE_P( + RequestMethodTest, + RequestMethodTest, + testing::Values( + RequestMethodTestBundle{ + .testName = "HttpGet", + .request = Request{http::request{http::verb::get, "/", 11}}, + .expectedMethod = Request::Method::Get, + }, + RequestMethodTestBundle{ + .testName = "HttpPost", + .request = Request{http::request{http::verb::post, "/", 11}}, + .expectedMethod = Request::Method::Post, + }, + RequestMethodTestBundle{ + .testName = "WebSocket", + .request = Request{"websocket message", Request::HttpHeaders{}}, + .expectedMethod = Request::Method::Websocket, + }, + RequestMethodTestBundle{ + .testName = "Unsupported", + .request = Request{http::request{http::verb::acl, "/", 11}}, + .expectedMethod = Request::Method::Unsupported, + } + ), + tests::util::NameGenerator +); + +struct RequestIsHttpTestBundle { + std::string testName; + Request request; + bool expectedIsHttp; +}; + +struct RequestIsHttpTest : RequestTest, testing::WithParamInterface {}; + +TEST_P(RequestIsHttpTest, isHttp) +{ + EXPECT_EQ(GetParam().request.isHttp(), GetParam().expectedIsHttp); +} + +INSTANTIATE_TEST_SUITE_P( + RequestIsHttpTest, + RequestIsHttpTest, + testing::Values( + RequestIsHttpTestBundle{ + .testName = "HttpRequest", + .request = Request{http::request{http::verb::get, "/", 11}}, + .expectedIsHttp = true, + }, + RequestIsHttpTestBundle{ + .testName = "WebSocketRequest", + .request = Request{"websocket message", Request::HttpHeaders{}}, + .expectedIsHttp = false, + } + ), + tests::util::NameGenerator +); + +struct RequestAsHttpRequestTest : RequestTest {}; + +TEST_F(RequestAsHttpRequestTest, HttpRequest) +{ + http::request const httpRequest{http::verb::get, "/some", 11}; + Request const request{httpRequest}; + auto const maybeHttpRequest = request.asHttpRequest(); + ASSERT_TRUE(maybeHttpRequest.has_value()); + auto const& actualHttpRequest = maybeHttpRequest->get(); + EXPECT_EQ(actualHttpRequest.method(), httpRequest.method()); + EXPECT_EQ(actualHttpRequest.target(), httpRequest.target()); + EXPECT_EQ(actualHttpRequest.version(), httpRequest.version()); +} + +TEST_F(RequestAsHttpRequestTest, WebSocketRequest) +{ + Request const request{"websocket message", Request::HttpHeaders{}}; + auto const maybeHttpRequest = request.asHttpRequest(); + EXPECT_FALSE(maybeHttpRequest.has_value()); +} + +struct RequestMessageTest : RequestTest {}; + +TEST_F(RequestMessageTest, HttpRequest) +{ + std::string const body = "some body"; + http::request const httpRequest{http::verb::post, "/some", 11, body}; + Request const request{httpRequest}; + EXPECT_EQ(request.message(), httpRequest.body()); +} + +TEST_F(RequestMessageTest, WebSocketRequest) +{ + std::string const message = "websocket message"; + Request const request{message, Request::HttpHeaders{}}; + EXPECT_EQ(request.message(), message); +} + +struct RequestTargetTestBundle { + std::string testName; + Request request; + std::optional expectedTarget; +}; + +struct RequestTargetTest : RequestTest, ::testing::WithParamInterface {}; + +TEST_P(RequestTargetTest, target) +{ + auto const maybeTarget = GetParam().request.target(); + EXPECT_EQ(maybeTarget, GetParam().expectedTarget); +} + +INSTANTIATE_TEST_SUITE_P( + RequestTargetTest, + RequestTargetTest, + testing::Values( + RequestTargetTestBundle{ + .testName = "HttpRequest", + .request = Request{http::request{http::verb::get, "/some", 11}}, + .expectedTarget = "/some", + }, + RequestTargetTestBundle{ + .testName = "WebSocketRequest", + .request = Request{"websocket message", Request::HttpHeaders{}}, + .expectedTarget = std::nullopt, + } + ), + tests::util::NameGenerator +); + +struct RequestHeaderValueTest : RequestTest {}; + +TEST_F(RequestHeaderValueTest, headerValue) +{ + http::request httpRequest{http::verb::get, "/some", 11}; + http::field const headerName = http::field::user_agent; + std::string const headerValue = "clio"; + httpRequest.set(headerName, headerValue); + + Request const request{httpRequest}; + auto const maybeHeaderValue = request.headerValue(headerName); + ASSERT_TRUE(maybeHeaderValue.has_value()); + EXPECT_EQ(maybeHeaderValue.value(), headerValue); +} + +TEST_F(RequestHeaderValueTest, headerValueString) +{ + http::request httpRequest{http::verb::get, "/some", 11}; + std::string const headerName = "Custom"; + std::string const headerValue = "some value"; + httpRequest.set(headerName, headerValue); + Request const request{httpRequest}; + auto const maybeHeaderValue = request.headerValue(headerName); + ASSERT_TRUE(maybeHeaderValue.has_value()); + EXPECT_EQ(maybeHeaderValue.value(), headerValue); +} + +TEST_F(RequestHeaderValueTest, headerValueNotFound) +{ + http::request httpRequest{http::verb::get, "/some", 11}; + Request const request{httpRequest}; + auto const maybeHeaderValue = request.headerValue(http::field::user_agent); + EXPECT_FALSE(maybeHeaderValue.has_value()); +} + +TEST_F(RequestHeaderValueTest, headerValueWebsocketRequest) +{ + Request::HttpHeaders headers; + http::field const headerName = http::field::user_agent; + std::string const headerValue = "clio"; + headers.set(headerName, headerValue); + Request const request{"websocket message", headers}; + auto const maybeHeaderValue = request.headerValue(headerName); + ASSERT_TRUE(maybeHeaderValue.has_value()); + EXPECT_EQ(maybeHeaderValue.value(), headerValue); +} diff --git a/tests/unit/web/ng/ResponseTests.cpp b/tests/unit/web/ng/ResponseTests.cpp new file mode 100644 index 00000000..f1a2cea0 --- /dev/null +++ b/tests/unit/web/ng/ResponseTests.cpp @@ -0,0 +1,126 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/build/Build.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace web::ng; +namespace http = boost::beast::http; + +struct ResponseDeathTest : testing::Test {}; + +TEST_F(ResponseDeathTest, intoHttpResponseWithoutHttpData) +{ + Request const request{"some messsage", Request::HttpHeaders{}}; + web::ng::Response response{boost::beast::http::status::ok, "message", request}; + EXPECT_DEATH(std::move(response).intoHttpResponse(), ""); +} + +TEST_F(ResponseDeathTest, asConstBufferWithHttpData) +{ + Request const request{http::request{http::verb::get, "/", 11}}; + web::ng::Response response{boost::beast::http::status::ok, "message", request}; + EXPECT_DEATH(response.asConstBuffer(), ""); +} + +struct ResponseTest : testing::Test { + int const httpVersion_ = 11; + http::status const responseStatus_ = http::status::ok; +}; + +TEST_F(ResponseTest, intoHttpResponse) +{ + Request const request{http::request{http::verb::post, "/", httpVersion_, "some message"}}; + std::string const responseMessage = "response message"; + + web::ng::Response response{responseStatus_, responseMessage, request}; + + auto const httpResponse = std::move(response).intoHttpResponse(); + EXPECT_EQ(httpResponse.result(), responseStatus_); + EXPECT_EQ(httpResponse.body(), responseMessage); + EXPECT_EQ(httpResponse.version(), httpVersion_); + EXPECT_EQ(httpResponse.keep_alive(), request.asHttpRequest()->get().keep_alive()); + + ASSERT_GT(httpResponse.count(http::field::content_type), 0); + EXPECT_EQ(httpResponse[http::field::content_type], "text/html"); + + ASSERT_GT(httpResponse.count(http::field::content_type), 0); + EXPECT_EQ(httpResponse[http::field::server], fmt::format("clio-server-{}", util::build::getClioVersionString())); +} + +TEST_F(ResponseTest, intoHttpResponseJson) +{ + Request const request{http::request{http::verb::post, "/", httpVersion_, "some message"}}; + boost::json::object const responseMessage{{"key", "value"}}; + + web::ng::Response response{responseStatus_, responseMessage, request}; + + auto const httpResponse = std::move(response).intoHttpResponse(); + EXPECT_EQ(httpResponse.result(), responseStatus_); + EXPECT_EQ(httpResponse.body(), boost::json::serialize(responseMessage)); + EXPECT_EQ(httpResponse.version(), httpVersion_); + EXPECT_EQ(httpResponse.keep_alive(), request.asHttpRequest()->get().keep_alive()); + + ASSERT_GT(httpResponse.count(http::field::content_type), 0); + EXPECT_EQ(httpResponse[http::field::content_type], "application/json"); + + ASSERT_GT(httpResponse.count(http::field::content_type), 0); + EXPECT_EQ(httpResponse[http::field::server], fmt::format("clio-server-{}", util::build::getClioVersionString())); +} + +TEST_F(ResponseTest, asConstBuffer) +{ + Request const request("some request", Request::HttpHeaders{}); + std::string const responseMessage = "response message"; + web::ng::Response response{responseStatus_, responseMessage, request}; + + auto const buffer = response.asConstBuffer(); + EXPECT_EQ(buffer.size(), responseMessage.size()); + + std::string const messageFromBuffer{static_cast(buffer.data()), buffer.size()}; + EXPECT_EQ(messageFromBuffer, responseMessage); +} + +TEST_F(ResponseTest, asConstBufferJson) +{ + Request const request("some request", Request::HttpHeaders{}); + boost::json::object const responseMessage{{"key", "value"}}; + web::ng::Response response{responseStatus_, responseMessage, request}; + + auto const buffer = response.asConstBuffer(); + EXPECT_EQ(buffer.size(), boost::json::serialize(responseMessage).size()); + + std::string const messageFromBuffer{static_cast(buffer.data()), buffer.size()}; + EXPECT_EQ(messageFromBuffer, boost::json::serialize(responseMessage)); +} diff --git a/tests/unit/web/ng/ServerTests.cpp b/tests/unit/web/ng/ServerTests.cpp new file mode 100644 index 00000000..459f11ca --- /dev/null +++ b/tests/unit/web/ng/ServerTests.cpp @@ -0,0 +1,332 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/AsioContextTestFixture.hpp" +#include "util/AssignRandomPort.hpp" +#include "util/LoggerFixtures.hpp" +#include "util/NameGenerator.hpp" +#include "util/Taggable.hpp" +#include "util/TestHttpClient.hpp" +#include "util/TestWebSocketClient.hpp" +#include "util/config/Config.hpp" +#include "web/ng/Connection.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" +#include "web/ng/Server.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace web::ng; + +namespace http = boost::beast::http; + +struct MakeServerTestBundle { + std::string testName; + std::string configJson; + bool expectSuccess; +}; + +struct MakeServerTest : NoLoggerFixture, testing::WithParamInterface { + boost::asio::io_context ioContext_; +}; + +TEST_P(MakeServerTest, Make) +{ + util::Config const config{boost::json::parse(GetParam().configJson)}; + auto const expectedServer = make_Server(config, ioContext_); + EXPECT_EQ(expectedServer.has_value(), GetParam().expectSuccess); +} + +INSTANTIATE_TEST_CASE_P( + MakeServerTests, + MakeServerTest, + testing::Values( + MakeServerTestBundle{ + "NoIp", + R"json( + { + "server": {"port": 12345} + } + )json", + false + }, + MakeServerTestBundle{ + "BadEndpoint", + R"json( + { + "server": {"ip": "wrong", "port": 12345} + } + )json", + false + }, + MakeServerTestBundle{ + "PortMissing", + R"json( + { + "server": {"ip": "127.0.0.1"} + } + )json", + false + }, + MakeServerTestBundle{ + "BadSslConfig", + R"json( + { + "server": {"ip": "127.0.0.1", "port": 12345}, + "ssl_cert_file": "somг_file" + } + )json", + false + }, + MakeServerTestBundle{ + "BadProcessingPolicy", + R"json( + { + "server": {"ip": "127.0.0.1", "port": 12345, "processing_policy": "wrong"} + } + )json", + false + }, + MakeServerTestBundle{ + "CorrectConfig_ParallelPolicy", + R"json( + { + "server": {"ip": "127.0.0.1", "port": 12345, "processing_policy": "parallel"} + } + )json", + true + }, + MakeServerTestBundle{ + "CorrectConfig_SequentPolicy", + R"json( + { + "server": {"ip": "127.0.0.1", "port": 12345, "processing_policy": "sequent"} + } + )json", + true + } + ), + tests::util::NameGenerator +); + +struct ServerTest : SyncAsioContextTest { + ServerTest() + { + [&]() { ASSERT_TRUE(server_.has_value()); }(); + server_->onGet("/", getHandler_.AsStdFunction()); + server_->onPost("/", postHandler_.AsStdFunction()); + server_->onWs(wsHandler_.AsStdFunction()); + } + + uint32_t const serverPort_ = tests::util::generateFreePort(); + + util::Config const config_{ + boost::json::object{{"server", boost::json::object{{"ip", "127.0.0.1"}, {"port", serverPort_}}}} + }; + + std::expected server_ = make_Server(config_, ctx); + + std::string requestMessage_ = "some request"; + std::string const headerName_ = "Some-header"; + std::string const headerValue_ = "some value"; + + testing::StrictMock> + getHandler_; + testing::StrictMock> + postHandler_; + testing::StrictMock> + wsHandler_; +}; + +TEST_F(ServerTest, BadEndpoint) +{ + boost::asio::ip::tcp::endpoint endpoint{boost::asio::ip::address_v4::from_string("1.2.3.4"), 0}; + impl::ConnectionHandler connectionHandler{impl::ConnectionHandler::ProcessingPolicy::Sequential, std::nullopt}; + util::TagDecoratorFactory tagDecoratorFactory{util::Config{boost::json::value{}}}; + Server server{ctx, endpoint, std::nullopt, std::move(connectionHandler), tagDecoratorFactory}; + auto maybeError = server.run(); + ASSERT_TRUE(maybeError.has_value()); + EXPECT_THAT(*maybeError, testing::HasSubstr("Error creating TCP acceptor")); +} + +struct ServerHttpTestBundle { + std::string testName; + http::verb method; + + Request::Method + expectedMethod() const + { + switch (method) { + case http::verb::get: + return Request::Method::Get; + case http::verb::post: + return Request::Method::Post; + default: + return Request::Method::Unsupported; + } + } +}; + +struct ServerHttpTest : ServerTest, testing::WithParamInterface {}; + +TEST_F(ServerHttpTest, ClientDisconnects) +{ + HttpAsyncClient client{ctx}; + boost::asio::spawn(ctx, [&](boost::asio::yield_context yield) { + auto maybeError = + client.connect("127.0.0.1", std::to_string(serverPort_), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + client.disconnect(); + ctx.stop(); + }); + + server_->run(); + runContext(); +} + +TEST_P(ServerHttpTest, RequestResponse) +{ + HttpAsyncClient client{ctx}; + + http::request request{GetParam().method, "/", 11, requestMessage_}; + request.set(headerName_, headerValue_); + + Response const response{http::status::ok, "some response", Request{request}}; + + boost::asio::spawn(ctx, [&](boost::asio::yield_context yield) { + auto maybeError = + client.connect("127.0.0.1", std::to_string(serverPort_), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + for ([[maybe_unused]] auto _i : std::ranges::iota_view{0, 3}) { + maybeError = client.send(request, yield, std::chrono::milliseconds{100}); + EXPECT_FALSE(maybeError.has_value()) << maybeError->message(); + + auto const expectedResponse = client.receive(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(expectedResponse.has_value()) << expectedResponse.error().message(); }(); + EXPECT_EQ(expectedResponse->result(), http::status::ok); + EXPECT_EQ(expectedResponse->body(), response.message()); + } + + client.gracefulShutdown(); + ctx.stop(); + }); + + auto& handler = GetParam().method == http::verb::get ? getHandler_ : postHandler_; + + EXPECT_CALL(handler, Call) + .Times(3) + .WillRepeatedly([&, response = response](Request const& receivedRequest, auto&&, auto&&) { + EXPECT_TRUE(receivedRequest.isHttp()); + EXPECT_EQ(receivedRequest.method(), GetParam().expectedMethod()); + EXPECT_EQ(receivedRequest.message(), request.body()); + EXPECT_EQ(receivedRequest.target(), request.target()); + EXPECT_EQ(receivedRequest.headerValue(headerName_), request.at(headerName_)); + + return response; + }); + + server_->run(); + + runContext(); +} + +INSTANTIATE_TEST_SUITE_P( + ServerHttpTests, + ServerHttpTest, + testing::Values(ServerHttpTestBundle{"GET", http::verb::get}, ServerHttpTestBundle{"POST", http::verb::post}), + tests::util::NameGenerator +); + +TEST_F(ServerTest, WsClientDisconnects) +{ + WebSocketAsyncClient client{ctx}; + + boost::asio::spawn(ctx, [&](boost::asio::yield_context yield) { + auto maybeError = + client.connect("127.0.0.1", std::to_string(serverPort_), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + client.close(); + ctx.stop(); + }); + + server_->run(); + + runContext(); +} + +TEST_F(ServerTest, WsRequestResponse) +{ + WebSocketAsyncClient client{ctx}; + + Response const response{http::status::ok, "some response", Request{requestMessage_, Request::HttpHeaders{}}}; + + boost::asio::spawn(ctx, [&](boost::asio::yield_context yield) { + auto maybeError = + client.connect("127.0.0.1", std::to_string(serverPort_), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + for ([[maybe_unused]] auto _i : std::ranges::iota_view{0, 3}) { + maybeError = client.send(yield, requestMessage_, std::chrono::milliseconds{100}); + EXPECT_FALSE(maybeError.has_value()) << maybeError->message(); + + auto const expectedResponse = client.receive(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(expectedResponse.has_value()) << expectedResponse.error().message(); }(); + EXPECT_EQ(expectedResponse.value(), response.message()); + } + + client.gracefulClose(yield, std::chrono::milliseconds{100}); + ctx.stop(); + }); + + EXPECT_CALL(wsHandler_, Call) + .Times(3) + .WillRepeatedly([&, response = response](Request const& receivedRequest, auto&&, auto&&) { + EXPECT_FALSE(receivedRequest.isHttp()); + EXPECT_EQ(receivedRequest.method(), Request::Method::Websocket); + EXPECT_EQ(receivedRequest.message(), requestMessage_); + EXPECT_EQ(receivedRequest.target(), std::nullopt); + + return response; + }); + + server_->run(); + + runContext(); +} diff --git a/tests/unit/web/ng/impl/ConnectionHandlerTests.cpp b/tests/unit/web/ng/impl/ConnectionHandlerTests.cpp new file mode 100644 index 00000000..c12c8d4a --- /dev/null +++ b/tests/unit/web/ng/impl/ConnectionHandlerTests.cpp @@ -0,0 +1,453 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/AsioContextTestFixture.hpp" +#include "util/Taggable.hpp" +#include "util/UnsupportedType.hpp" +#include "util/config/Config.hpp" +#include "web/ng/Connection.hpp" +#include "web/ng/Error.hpp" +#include "web/ng/MockConnection.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" +#include "web/ng/impl/ConnectionHandler.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace web::ng::impl; +using namespace web::ng; +using testing::Return; +namespace beast = boost::beast; +namespace http = boost::beast::http; +namespace websocket = boost::beast::websocket; + +struct ConnectionHandlerTest : SyncAsioContextTest { + ConnectionHandlerTest(ConnectionHandler::ProcessingPolicy policy, std::optional maxParallelConnections) + : connectionHandler_{policy, maxParallelConnections} + { + } + + template + static std::unexpected + makeError(BoostErrorType error) + { + if constexpr (std::same_as) { + return std::unexpected{http::make_error_code(error)}; + } else if constexpr (std::same_as) { + return std::unexpected{websocket::make_error_code(error)}; + } else if constexpr (std::same_as || + std::same_as || + std::same_as || + std::same_as) { + return std::unexpected{boost::asio::error::make_error_code(error)}; + } else { + static_assert(util::Unsupported, "Wrong error type"); + } + } + + template + static std::expected + makeRequest(Args&&... args) + { + return Request{std::forward(args)...}; + } + + ConnectionHandler connectionHandler_; + + util::TagDecoratorFactory tagDecoratorFactory_{util::Config(boost::json::object{{"log_tag_style", "uint"}})}; + StrictMockConnectionPtr mockConnection_ = + std::make_unique("1.2.3.4", beast::flat_buffer{}, tagDecoratorFactory_); +}; + +struct ConnectionHandlerSequentialProcessingTest : ConnectionHandlerTest { + ConnectionHandlerSequentialProcessingTest() + : ConnectionHandlerTest(ConnectionHandler::ProcessingPolicy::Sequential, std::nullopt) + { + } +}; + +TEST_F(ConnectionHandlerSequentialProcessingTest, ReceiveError) +{ + EXPECT_CALL(*mockConnection_, receive).WillOnce(Return(makeError(http::error::end_of_stream))); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerSequentialProcessingTest, ReceiveError_CloseConnection) +{ + EXPECT_CALL(*mockConnection_, receive).WillOnce(Return(makeError(boost::asio::error::timed_out))); + EXPECT_CALL(*mockConnection_, close); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerSequentialProcessingTest, Receive_Handle_NoHandler_Send) +{ + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(Return(makeRequest("some_request", Request::HttpHeaders{}))) + .WillOnce(Return(makeError(websocket::error::closed))); + + EXPECT_CALL(*mockConnection_, send).WillOnce([](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), "WebSocket is not supported by this server"); + return std::nullopt; + }); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerSequentialProcessingTest, Receive_Handle_BadTarget_Send) +{ + std::string const target = "/some/target"; + + std::string const requestMessage = "some message"; + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(Return(makeRequest(http::request{http::verb::get, target, 11, requestMessage}))) + .WillOnce(Return(makeError(http::error::end_of_stream))); + + EXPECT_CALL(*mockConnection_, send).WillOnce([](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), "Bad target"); + auto const httpResponse = std::move(response).intoHttpResponse(); + EXPECT_EQ(httpResponse.result(), http::status::bad_request); + EXPECT_EQ(httpResponse.version(), 11); + return std::nullopt; + }); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerSequentialProcessingTest, Receive_Handle_BadMethod_Send) +{ + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(Return(makeRequest(http::request{http::verb::acl, "/", 11}))) + .WillOnce(Return(makeError(http::error::end_of_stream))); + + EXPECT_CALL(*mockConnection_, send).WillOnce([](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), "Unsupported http method"); + return std::nullopt; + }); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerSequentialProcessingTest, Receive_Handle_Send) +{ + testing::StrictMock> + wsHandlerMock; + connectionHandler_.onWs(wsHandlerMock.AsStdFunction()); + + std::string const requestMessage = "some message"; + std::string const responseMessage = "some response"; + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(Return(makeRequest(requestMessage, Request::HttpHeaders{}))) + .WillOnce(Return(makeError(websocket::error::closed))); + + EXPECT_CALL(wsHandlerMock, Call).WillOnce([&](Request const& request, auto&&, auto&&) { + EXPECT_EQ(request.message(), requestMessage); + return Response(http::status::ok, responseMessage, request); + }); + + EXPECT_CALL(*mockConnection_, send).WillOnce([&responseMessage](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), responseMessage); + return std::nullopt; + }); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerSequentialProcessingTest, Receive_Handle_Send_Loop) +{ + std::string const target = "/some/target"; + testing::StrictMock> + postHandlerMock; + connectionHandler_.onPost(target, postHandlerMock.AsStdFunction()); + + std::string const requestMessage = "some message"; + std::string const responseMessage = "some response"; + + auto const returnRequest = + Return(makeRequest(http::request{http::verb::post, target, 11, requestMessage})); + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(returnRequest) + .WillOnce(returnRequest) + .WillOnce(returnRequest) + .WillOnce(Return(makeError(http::error::partial_message))); + + EXPECT_CALL(postHandlerMock, Call).Times(3).WillRepeatedly([&](Request const& request, auto&&, auto&&) { + EXPECT_EQ(request.message(), requestMessage); + return Response(http::status::ok, responseMessage, request); + }); + + EXPECT_CALL(*mockConnection_, send).Times(3).WillRepeatedly([&responseMessage](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), responseMessage); + return std::nullopt; + }); + + EXPECT_CALL(*mockConnection_, close); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerSequentialProcessingTest, Receive_Handle_SendError) +{ + std::string const target = "/some/target"; + testing::StrictMock> + getHandlerMock; + + std::string const requestMessage = "some message"; + std::string const responseMessage = "some response"; + + connectionHandler_.onGet(target, getHandlerMock.AsStdFunction()); + + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(Return(makeRequest(http::request{http::verb::get, target, 11, requestMessage}))); + + EXPECT_CALL(getHandlerMock, Call).WillOnce([&](Request const& request, auto&&, auto&&) { + EXPECT_EQ(request.message(), requestMessage); + return Response(http::status::ok, responseMessage, request); + }); + + EXPECT_CALL(*mockConnection_, send).WillOnce([&responseMessage](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), responseMessage); + return makeError(http::error::end_of_stream).error(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerSequentialProcessingTest, Stop) +{ + testing::StrictMock> + wsHandlerMock; + connectionHandler_.onWs(wsHandlerMock.AsStdFunction()); + + std::string const requestMessage = "some message"; + std::string const responseMessage = "some response"; + bool connectionClosed = false; + EXPECT_CALL(*mockConnection_, receive) + .Times(4) + .WillRepeatedly([&](auto&&, auto&&) -> std::expected { + if (connectionClosed) { + return makeError(websocket::error::closed); + } + return makeRequest(requestMessage, Request::HttpHeaders{}); + }); + + EXPECT_CALL(wsHandlerMock, Call).Times(3).WillRepeatedly([&](Request const& request, auto&&, auto&&) { + EXPECT_EQ(request.message(), requestMessage); + return Response(http::status::ok, responseMessage, request); + }); + + size_t numCalls = 0; + EXPECT_CALL(*mockConnection_, send).Times(3).WillRepeatedly([&](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), responseMessage); + + ++numCalls; + if (numCalls == 3) + connectionHandler_.stop(); + + return std::nullopt; + }); + + EXPECT_CALL(*mockConnection_, close).WillOnce([&connectionClosed]() { connectionClosed = true; }); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +struct ConnectionHandlerParallelProcessingTest : ConnectionHandlerTest { + static size_t constexpr maxParallelRequests = 3; + + ConnectionHandlerParallelProcessingTest() + : ConnectionHandlerTest( + ConnectionHandler::ProcessingPolicy::Parallel, + ConnectionHandlerParallelProcessingTest::maxParallelRequests + ) + { + } + + static void + asyncSleep(boost::asio::yield_context yield, std::chrono::steady_clock::duration duration) + { + boost::asio::steady_timer timer{yield.get_executor()}; + timer.expires_after(duration); + timer.async_wait(yield); + } +}; + +TEST_F(ConnectionHandlerParallelProcessingTest, ReceiveError) +{ + EXPECT_CALL(*mockConnection_, receive).WillOnce(Return(makeError(http::error::end_of_stream))); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerParallelProcessingTest, Receive_Handle_Send) +{ + testing::StrictMock> + wsHandlerMock; + connectionHandler_.onWs(wsHandlerMock.AsStdFunction()); + + std::string const requestMessage = "some message"; + std::string const responseMessage = "some response"; + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(Return(makeRequest(requestMessage, Request::HttpHeaders{}))) + .WillOnce(Return(makeError(websocket::error::closed))); + + EXPECT_CALL(wsHandlerMock, Call).WillOnce([&](Request const& request, auto&&, auto&&) { + EXPECT_EQ(request.message(), requestMessage); + return Response(http::status::ok, responseMessage, request); + }); + + EXPECT_CALL(*mockConnection_, send).WillOnce([&responseMessage](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), responseMessage); + return std::nullopt; + }); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerParallelProcessingTest, Receive_Handle_Send_Loop) +{ + testing::StrictMock> + wsHandlerMock; + connectionHandler_.onWs(wsHandlerMock.AsStdFunction()); + + std::string const requestMessage = "some message"; + std::string const responseMessage = "some response"; + + auto const returnRequest = [&](auto&&, auto&&) { return makeRequest(requestMessage, Request::HttpHeaders{}); }; + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(returnRequest) + .WillOnce(returnRequest) + .WillOnce(Return(makeError(websocket::error::closed))); + + EXPECT_CALL(wsHandlerMock, Call).Times(2).WillRepeatedly([&](Request const& request, auto&&, auto&&) { + EXPECT_EQ(request.message(), requestMessage); + return Response(http::status::ok, responseMessage, request); + }); + + EXPECT_CALL(*mockConnection_, send).Times(2).WillRepeatedly([&responseMessage](Response response, auto&&, auto&&) { + EXPECT_EQ(response.message(), responseMessage); + return std::nullopt; + }); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} + +TEST_F(ConnectionHandlerParallelProcessingTest, Receive_Handle_Send_Loop_TooManyRequest) +{ + testing::StrictMock> + wsHandlerMock; + connectionHandler_.onWs(wsHandlerMock.AsStdFunction()); + + std::string const requestMessage = "some message"; + std::string const responseMessage = "some response"; + + auto const returnRequest = [&](auto&&, auto&&) { return makeRequest(requestMessage, Request::HttpHeaders{}); }; + testing::Sequence sequence; + EXPECT_CALL(*mockConnection_, receive) + .WillOnce(returnRequest) + .WillOnce(returnRequest) + .WillOnce(returnRequest) + .WillOnce(returnRequest) + .WillOnce(returnRequest) + .WillOnce(Return(makeError(websocket::error::closed))); + + EXPECT_CALL(wsHandlerMock, Call) + .Times(3) + .WillRepeatedly([&](Request const& request, auto&&, boost::asio::yield_context yield) { + EXPECT_EQ(request.message(), requestMessage); + asyncSleep(yield, std::chrono::milliseconds{3}); + return Response(http::status::ok, responseMessage, request); + }); + + EXPECT_CALL( + *mockConnection_, + send( + testing::ResultOf([](Response response) { return response.message(); }, responseMessage), + testing::_, + testing::_ + ) + ) + .Times(3) + .WillRepeatedly(Return(std::nullopt)); + + EXPECT_CALL( + *mockConnection_, + send( + testing::ResultOf( + [](Response response) { return response.message(); }, "Too many requests for one connection" + ), + testing::_, + testing::_ + ) + ) + .Times(2) + .WillRepeatedly(Return(std::nullopt)); + + runSpawn([this](boost::asio::yield_context yield) { + connectionHandler_.processConnection(std::move(mockConnection_), yield); + }); +} diff --git a/tests/unit/web/ng/impl/HttpConnectionTests.cpp b/tests/unit/web/ng/impl/HttpConnectionTests.cpp new file mode 100644 index 00000000..4a31c370 --- /dev/null +++ b/tests/unit/web/ng/impl/HttpConnectionTests.cpp @@ -0,0 +1,296 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/AsioContextTestFixture.hpp" +#include "util/Taggable.hpp" +#include "util/TestHttpClient.hpp" +#include "util/TestHttpServer.hpp" +#include "util/TestWebSocketClient.hpp" +#include "util/config/Config.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" +#include "web/ng/impl/HttpConnection.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace web::ng::impl; +using namespace web::ng; +namespace http = boost::beast::http; + +struct HttpConnectionTests : SyncAsioContextTest { + util::TagDecoratorFactory tagDecoratorFactory_{util::Config{boost::json::object{{"log_tag_style", "int"}}}}; + TestHttpServer httpServer_{ctx, "localhost"}; + HttpAsyncClient httpClient_{ctx}; + http::request request_{http::verb::post, "/some_target", 11, "some data"}; + + PlainHttpConnection + acceptConnection(boost::asio::yield_context yield) + { + auto expectedSocket = httpServer_.accept(yield); + [&]() { ASSERT_TRUE(expectedSocket.has_value()) << expectedSocket.error().message(); }(); + auto ip = expectedSocket->remote_endpoint().address().to_string(); + return PlainHttpConnection{ + std::move(expectedSocket).value(), std::move(ip), boost::beast::flat_buffer{}, tagDecoratorFactory_ + }; + } +}; + +TEST_F(HttpConnectionTests, wasUpgraded) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + EXPECT_FALSE(connection.wasUpgraded()); + }); +} + +TEST_F(HttpConnectionTests, Receive) +{ + request_.set(boost::beast::http::field::user_agent, "test_client"); + + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + maybeError = httpClient_.send(request_, yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + EXPECT_TRUE(connection.ip() == "127.0.0.1" or connection.ip() == "::1") << connection.ip(); + + auto expectedRequest = connection.receive(yield, std::chrono::milliseconds{100}); + ASSERT_TRUE(expectedRequest.has_value()) << expectedRequest.error().message(); + ASSERT_TRUE(expectedRequest->isHttp()); + + auto const& receivedRequest = expectedRequest.value().asHttpRequest()->get(); + EXPECT_EQ(receivedRequest.method(), request_.method()); + EXPECT_EQ(receivedRequest.target(), request_.target()); + EXPECT_EQ(receivedRequest.body(), request_.body()); + EXPECT_EQ( + receivedRequest.at(boost::beast::http::field::user_agent), + request_.at(boost::beast::http::field::user_agent) + ); + }); +} + +TEST_F(HttpConnectionTests, ReceiveTimeout) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{1}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + auto expectedRequest = connection.receive(yield, std::chrono::milliseconds{1}); + EXPECT_FALSE(expectedRequest.has_value()); + }); +} + +TEST_F(HttpConnectionTests, ReceiveClientDisconnected) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{1}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + httpClient_.disconnect(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + auto expectedRequest = connection.receive(yield, std::chrono::milliseconds{1}); + EXPECT_FALSE(expectedRequest.has_value()); + }); +} + +TEST_F(HttpConnectionTests, Send) +{ + Request const request{request_}; + Response const response{http::status::ok, "some response data", request}; + + boost::asio::spawn(ctx, [this, response = response](boost::asio::yield_context yield) mutable { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + auto const expectedResponse = httpClient_.receive(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(expectedResponse.has_value()) << maybeError->message(); }(); + + auto const receivedResponse = expectedResponse.value(); + auto const sentResponse = std::move(response).intoHttpResponse(); + EXPECT_EQ(receivedResponse.result(), sentResponse.result()); + EXPECT_EQ(receivedResponse.body(), sentResponse.body()); + EXPECT_EQ(receivedResponse.version(), request_.version()); + EXPECT_TRUE(receivedResponse.keep_alive()); + }); + + runSpawn([this, &response](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + auto maybeError = connection.send(response, yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + }); +} + +TEST_F(HttpConnectionTests, SendMultipleTimes) +{ + Request const request{request_}; + Response const response{http::status::ok, "some response data", request}; + + boost::asio::spawn(ctx, [this, response = response](boost::asio::yield_context yield) mutable { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + for ([[maybe_unused]] auto _i : std::ranges::iota_view{0, 3}) { + auto const expectedResponse = httpClient_.receive(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(expectedResponse.has_value()) << maybeError->message(); }(); + + auto const receivedResponse = expectedResponse.value(); + auto const sentResponse = Response{response}.intoHttpResponse(); + EXPECT_EQ(receivedResponse.result(), sentResponse.result()); + EXPECT_EQ(receivedResponse.body(), sentResponse.body()); + EXPECT_EQ(receivedResponse.version(), request_.version()); + EXPECT_TRUE(receivedResponse.keep_alive()); + } + }); + + runSpawn([this, &response](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + + for ([[maybe_unused]] auto _i : std::ranges::iota_view{0, 3}) { + auto maybeError = connection.send(response, yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + } + }); +} + +TEST_F(HttpConnectionTests, SendClientDisconnected) +{ + Response const response{http::status::ok, "some response data", Request{request_}}; + boost::asio::spawn(ctx, [this, response = response](boost::asio::yield_context yield) mutable { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{1}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + httpClient_.disconnect(); + }); + runSpawn([this, &response](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + auto maybeError = connection.send(response, yield, std::chrono::milliseconds{1}); + size_t counter{1}; + while (not maybeError.has_value() and counter < 100) { + ++counter; + maybeError = connection.send(response, yield, std::chrono::milliseconds{1}); + } + EXPECT_TRUE(maybeError.has_value()); + EXPECT_LT(counter, 100); + }); +} + +TEST_F(HttpConnectionTests, Close) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + size_t counter{0}; + while (not maybeError.has_value() and counter < 100) { + ++counter; + maybeError = httpClient_.send(request_, yield, std::chrono::milliseconds{1}); + } + EXPECT_TRUE(maybeError.has_value()); + EXPECT_LT(counter, 100); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + connection.close(yield, std::chrono::milliseconds{1}); + }); +} + +TEST_F(HttpConnectionTests, IsUpgradeRequested_GotHttpRequest) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + + maybeError = httpClient_.send(request_, yield, std::chrono::milliseconds{1}); + EXPECT_FALSE(maybeError.has_value()) << maybeError->message(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + auto result = connection.isUpgradeRequested(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(result.has_value()) << result.error().message(); }(); + EXPECT_FALSE(result.value()); + }); +} + +TEST_F(HttpConnectionTests, IsUpgradeRequested_FailedToFetch) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = httpClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + auto result = connection.isUpgradeRequested(yield, std::chrono::milliseconds{1}); + EXPECT_FALSE(result.has_value()); + }); +} + +TEST_F(HttpConnectionTests, Upgrade) +{ + WebSocketAsyncClient wsClient_{ctx}; + + boost::asio::spawn(ctx, [this, &wsClient_](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError->message(); }(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto connection = acceptConnection(yield); + auto const expectedResult = connection.isUpgradeRequested(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(expectedResult.has_value()) << expectedResult.error().message(); }(); + [&]() { ASSERT_TRUE(expectedResult.value()); }(); + + std::optional sslContext; + auto expectedWsConnection = connection.upgrade(sslContext, tagDecoratorFactory_, yield); + [&]() { ASSERT_TRUE(expectedWsConnection.has_value()) << expectedWsConnection.error().message(); }(); + }); +} diff --git a/tests/unit/web/ng/impl/ServerSslContextTests.cpp b/tests/unit/web/ng/impl/ServerSslContextTests.cpp new file mode 100644 index 00000000..6a98bb3c --- /dev/null +++ b/tests/unit/web/ng/impl/ServerSslContextTests.cpp @@ -0,0 +1,181 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/NameGenerator.hpp" +#include "util/TmpFile.hpp" +#include "util/config/Config.hpp" +#include "web/ng/impl/ServerSslContext.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace web::ng::impl; + +struct MakeServerSslContextFromConfigTestBundle { + std::string testName; + std::optional certFile; + std::optional keyFile; + std::optional expectedError; + bool expectContext; + + boost::json::value + configJson() const + { + boost::json::object result; + if (certFile.has_value()) { + result["ssl_cert_file"] = *certFile; + } + + if (keyFile.has_value()) { + result["ssl_key_file"] = *keyFile; + } + return result; + } +}; + +struct MakeServerSslContextFromConfigTest : testing::TestWithParam {}; + +TEST_P(MakeServerSslContextFromConfigTest, makeFromConfig) +{ + auto const config = util::Config{GetParam().configJson()}; + auto const expectedServerSslContext = makeServerSslContext(config); + if (GetParam().expectedError.has_value()) { + ASSERT_FALSE(expectedServerSslContext.has_value()); + EXPECT_THAT(expectedServerSslContext.error(), testing::HasSubstr(*GetParam().expectedError)); + } else { + EXPECT_EQ(expectedServerSslContext.value().has_value(), GetParam().expectContext); + } +} + +INSTANTIATE_TEST_SUITE_P( + MakeServerSslContextFromConfigTest, + MakeServerSslContextFromConfigTest, + testing::ValuesIn( + {MakeServerSslContextFromConfigTestBundle{ + .testName = "NoCertNoKey", + .certFile = std::nullopt, + .keyFile = std::nullopt, + .expectedError = std::nullopt, + .expectContext = false + }, + MakeServerSslContextFromConfigTestBundle{ + .testName = "CertOnly", + .certFile = "some_path", + .keyFile = std::nullopt, + .expectedError = "Config entries 'ssl_cert_file' and 'ssl_key_file' must be set or unset together.", + .expectContext = false + }, + MakeServerSslContextFromConfigTestBundle{ + .testName = "KeyOnly", + .certFile = std::nullopt, + .keyFile = "some_path", + .expectedError = "Config entries 'ssl_cert_file' and 'ssl_key_file' must be set or unset together.", + .expectContext = false + }, + MakeServerSslContextFromConfigTestBundle{ + .testName = "BothKeyAndCert", + .certFile = "some_path", + .keyFile = "some_other_path", + .expectedError = "Can't read SSL certificate", + .expectContext = false + }} + ), + tests::util::NameGenerator +); + +struct MakeServerSslContextFromConfigRealFilesTest : testing::Test {}; + +TEST_F(MakeServerSslContextFromConfigRealFilesTest, WrongKeyFile) +{ + auto const certFile = tests::sslCertFile(); + boost::json::object configJson = {{"ssl_cert_file", certFile.path}, {"ssl_key_file", "some_path"}}; + + util::Config const config{configJson}; + auto const expectedServerSslContext = makeServerSslContext(config); + ASSERT_FALSE(expectedServerSslContext.has_value()); + EXPECT_THAT(expectedServerSslContext.error(), testing::HasSubstr("Can't read SSL key")); +} + +TEST_F(MakeServerSslContextFromConfigRealFilesTest, BothFilesValid) +{ + auto const certFile = tests::sslCertFile(); + auto const keyFile = tests::sslKeyFile(); + boost::json::object configJson = {{"ssl_cert_file", certFile.path}, {"ssl_key_file", keyFile.path}}; + + util::Config const config{configJson}; + auto const expectedServerSslContext = makeServerSslContext(config); + EXPECT_TRUE(expectedServerSslContext.has_value()); +} + +struct MakeServerSslContextFromDataTestBundle { + std::string testName; + std::string certData; + std::string keyData; + bool expectedSuccess; +}; + +struct MakeServerSslContextFromDataTest : testing::TestWithParam {}; + +TEST_P(MakeServerSslContextFromDataTest, makeFromData) +{ + auto const& data = GetParam(); + auto const expectedServerSslContext = makeServerSslContext(data.certData, data.keyData); + EXPECT_EQ(expectedServerSslContext.has_value(), data.expectedSuccess); +} + +INSTANTIATE_TEST_SUITE_P( + MakeServerSslContextFromDataTest, + MakeServerSslContextFromDataTest, + testing::ValuesIn( + {MakeServerSslContextFromDataTestBundle{ + .testName = "EmptyData", + .certData = "", + .keyData = "", + .expectedSuccess = false + }, + MakeServerSslContextFromDataTestBundle{ + .testName = "CertOnly", + .certData = std::string{tests::sslCert()}, + .keyData = "", + .expectedSuccess = false + }, + MakeServerSslContextFromDataTestBundle{ + .testName = "KeyOnly", + .certData = "", + .keyData = std::string{tests::sslKey()}, + .expectedSuccess = false + }, + MakeServerSslContextFromDataTestBundle{ + .testName = "BothKeyAndCert", + .certData = std::string{tests::sslCert()}, + .keyData = std::string{tests::sslKey()}, + .expectedSuccess = true + }} + ), + tests::util::NameGenerator +); diff --git a/tests/unit/web/ng/impl/WsConnectionTests.cpp b/tests/unit/web/ng/impl/WsConnectionTests.cpp new file mode 100644 index 00000000..3ea2782f --- /dev/null +++ b/tests/unit/web/ng/impl/WsConnectionTests.cpp @@ -0,0 +1,250 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2024, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/AsioContextTestFixture.hpp" +#include "util/Taggable.hpp" +#include "util/TestHttpServer.hpp" +#include "util/TestWebSocketClient.hpp" +#include "util/config/Config.hpp" +#include "web/ng/Error.hpp" +#include "web/ng/Request.hpp" +#include "web/ng/Response.hpp" +#include "web/ng/impl/HttpConnection.hpp" +#include "web/ng/impl/WsConnection.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace web::ng::impl; +using namespace web::ng; + +struct web_WsConnectionTests : SyncAsioContextTest { + util::TagDecoratorFactory tagDecoratorFactory_{util::Config{boost::json::object{{"log_tag_style", "int"}}}}; + TestHttpServer httpServer_{ctx, "localhost"}; + WebSocketAsyncClient wsClient_{ctx}; + Request request_{"some request", Request::HttpHeaders{}}; + + std::unique_ptr + acceptConnection(boost::asio::yield_context yield) + { + auto expectedSocket = httpServer_.accept(yield); + [&]() { ASSERT_TRUE(expectedSocket.has_value()) << expectedSocket.error().message(); }(); + auto ip = expectedSocket->remote_endpoint().address().to_string(); + + PlainHttpConnection httpConnection{ + std::move(expectedSocket).value(), std::move(ip), boost::beast::flat_buffer{}, tagDecoratorFactory_ + }; + + auto expectedTrue = httpConnection.isUpgradeRequested(yield); + [&]() { + ASSERT_TRUE(expectedTrue.has_value()) << expectedTrue.error().message(); + ASSERT_TRUE(expectedTrue.value()) << "Expected upgrade request"; + }(); + + std::optional sslContext; + auto expectedWsConnection = httpConnection.upgrade(sslContext, tagDecoratorFactory_, yield); + [&]() { ASSERT_TRUE(expectedWsConnection.has_value()) << expectedWsConnection.error().message(); }(); + auto connection = std::move(expectedWsConnection).value(); + auto wsConnectionPtr = dynamic_cast(connection.release()); + [&]() { ASSERT_NE(wsConnectionPtr, nullptr) << "Expected PlainWsConnection"; }(); + return std::unique_ptr{wsConnectionPtr}; + } +}; + +TEST_F(web_WsConnectionTests, WasUpgraded) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + }); + runSpawn([this](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + EXPECT_TRUE(wsConnection->wasUpgraded()); + }); +} + +TEST_F(web_WsConnectionTests, Send) +{ + Response const response{boost::beast::http::status::ok, "some response", request_}; + + boost::asio::spawn(ctx, [this, &response](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + auto const expectedMessage = wsClient_.receive(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(expectedMessage.has_value()) << expectedMessage.error().message(); }(); + EXPECT_EQ(expectedMessage.value(), response.message()); + }); + + runSpawn([this, &response](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + auto maybeError = wsConnection->send(response, yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + }); +} + +TEST_F(web_WsConnectionTests, MultipleSend) +{ + Response const response{boost::beast::http::status::ok, "some response", request_}; + + boost::asio::spawn(ctx, [this, &response](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + + for ([[maybe_unused]] auto _i : std::ranges::iota_view{0, 3}) { + auto const expectedMessage = wsClient_.receive(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(expectedMessage.has_value()) << expectedMessage.error().message(); }(); + EXPECT_EQ(expectedMessage.value(), response.message()); + } + }); + + runSpawn([this, &response](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + + for ([[maybe_unused]] auto _i : std::ranges::iota_view{0, 3}) { + auto maybeError = wsConnection->send(response, yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + } + }); +} + +TEST_F(web_WsConnectionTests, SendFailed) +{ + Response const response{boost::beast::http::status::ok, "some response", request_}; + + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + wsClient_.close(); + }); + + runSpawn([this, &response](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + std::optional maybeError; + size_t counter = 0; + while (not maybeError.has_value() and counter < 100) { + maybeError = wsConnection->send(response, yield, std::chrono::milliseconds{1}); + ++counter; + } + EXPECT_TRUE(maybeError.has_value()); + EXPECT_LT(counter, 100); + }); +} + +TEST_F(web_WsConnectionTests, Receive) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + + maybeError = wsClient_.send(yield, request_.message(), std::chrono::milliseconds{100}); + EXPECT_FALSE(maybeError.has_value()) << maybeError->message(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + + auto maybeRequest = wsConnection->receive(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(maybeRequest.has_value()) << maybeRequest.error().message(); }(); + EXPECT_EQ(maybeRequest->message(), request_.message()); + }); +} + +TEST_F(web_WsConnectionTests, MultipleReceive) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + + for ([[maybe_unused]] auto _i : std::ranges::iota_view{0, 3}) { + maybeError = wsClient_.send(yield, request_.message(), std::chrono::milliseconds{100}); + EXPECT_FALSE(maybeError.has_value()) << maybeError->message(); + } + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + + for ([[maybe_unused]] auto _i : std::ranges::iota_view{0, 3}) { + auto maybeRequest = wsConnection->receive(yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_TRUE(maybeRequest.has_value()) << maybeRequest.error().message(); }(); + EXPECT_EQ(maybeRequest->message(), request_.message()); + } + }); +} + +TEST_F(web_WsConnectionTests, ReceiveTimeout) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + auto maybeRequest = wsConnection->receive(yield, std::chrono::milliseconds{1}); + EXPECT_FALSE(maybeRequest.has_value()); + EXPECT_EQ(maybeRequest.error().value(), boost::asio::error::timed_out); + }); +} + +TEST_F(web_WsConnectionTests, ReceiveFailed) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + wsClient_.close(); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + auto maybeRequest = wsConnection->receive(yield, std::chrono::milliseconds{100}); + EXPECT_FALSE(maybeRequest.has_value()); + EXPECT_EQ(maybeRequest.error().value(), boost::asio::error::eof); + }); +} + +TEST_F(web_WsConnectionTests, Close) +{ + boost::asio::spawn(ctx, [this](boost::asio::yield_context yield) { + auto maybeError = wsClient_.connect("localhost", httpServer_.port(), yield, std::chrono::milliseconds{100}); + [&]() { ASSERT_FALSE(maybeError.has_value()) << maybeError.value().message(); }(); + auto const maybeMessage = wsClient_.receive(yield, std::chrono::milliseconds{100}); + EXPECT_FALSE(maybeMessage.has_value()); + EXPECT_THAT(maybeMessage.error().message(), testing::HasSubstr("was gracefully closed")); + }); + + runSpawn([this](boost::asio::yield_context yield) { + auto wsConnection = acceptConnection(yield); + wsConnection->close(yield, std::chrono::milliseconds{100}); + }); +}