diff --git a/include/boost/redis/config.hpp b/include/boost/redis/config.hpp index b564bdfc..0a2d666f 100644 --- a/include/boost/redis/config.hpp +++ b/include/boost/redis/config.hpp @@ -7,6 +7,8 @@ #ifndef BOOST_REDIS_CONFIG_HPP #define BOOST_REDIS_CONFIG_HPP +#include + #include #include #include @@ -40,21 +42,45 @@ struct config { */ std::string unix_socket; - /** @brief Username passed to the `HELLO` command. - * If left empty `HELLO` will be sent without authentication parameters. + /** @brief Username used for authentication during connection establishment. + * + * If @ref use_setup is false (the default), during connection establishment, + * authentication is performed by sending a `HELLO` command. + * This field contains the username to employ. + * + * If the username equals the literal `"default"` (the default) + * and no password is specified, the `HELLO` command is sent + * without authentication parameters. */ std::string username = "default"; - /** @brief Password passed to the - * `HELLO` command. If left - * empty `HELLO` will be sent without authentication parameters. + /** @brief Password used for authentication during connection establishment. + * + * If @ref use_setup is false (the default), during connection establishment, + * authentication is performed by sending a `HELLO` command. + * This field contains the password to employ. + * + * If the username equals the literal `"default"` (the default) + * and no password is specified, the `HELLO` command is sent + * without authentication parameters. */ std::string password; - /// Client name parameter of the `HELLO` command. + /** @brief Client name parameter to use during connection establishment. + * + * If @ref use_setup is false (the default), during connection establishment, + * a `HELLO` command is sent. If this field is not empty, the `HELLO` command + * will contain a `SETNAME` subcommand containing this value. + */ std::string clientname = "Boost.Redis"; - /// Database that will be passed to the `SELECT` command. + /** @brief Database index to pass to the `SELECT` command during connection establishment. + * + * If @ref use_setup is false (the default), and this field is set to a + * non-empty optional, and its value is different than zero, + * a `SELECT` command will be issued during connection establishment to set the logical + * database index. By default, no `SELECT` command is sent. + */ std::optional database_index = 0; /// Message used by the health-checker in @ref boost::redis::basic_connection::async_run. @@ -95,7 +121,7 @@ struct config { */ std::size_t max_read_size = (std::numeric_limits::max)(); - /** @brief read_buffer_append_size + /** @brief Grow size of the read buffer. * * The size by which the read buffer grows when more space is * needed. This can help avoiding some memory allocations. Once the @@ -103,6 +129,39 @@ struct config { * since the buffer is reused. */ std::size_t read_buffer_append_size = 4096; + + /** @brief Enables using a custom requests during connection establishment. + * + * If set to true, the @ref setup member will be sent to the server immediately after + * connection establishment. Every time a reconnection happens, the setup + * request will be executed before any other request. + * It can be used to perform authentication, + * subscribe to channels or select a database index. + * + * When set to true, *the custom setup request replaces the built-in HELLO + * request generated by the library*. The @ref username, @ref password, + * @ref clientname and @ref database_index fields *will be ignored*. + * + * By default, @ref setup contains a `"HELLO 3"` command, which upgrades the + * protocol to RESP3. You might modify this request as you like, + * but you should ensure that the resulting connection uses RESP3. + * + * To prevent sending any setup request at all, set this field to true + * and @ref setup to an empty request. This can be used to interface with + * systems that don't support `HELLO`. + * + * By default, this field is false, and @ref setup will not be used. + */ + bool use_setup = false; + + /** @brief Request to be executed after connection establishment. + * + * This member is only used if @ref use_setup is `true`. Please consult + * @ref use_setup docs for more info. + * + * By default, `setup` contains a `"HELLO 3"` command. + */ + request setup = detail::make_hello_request(); }; } // namespace boost::redis diff --git a/include/boost/redis/connection.hpp b/include/boost/redis/connection.hpp index 47e7c5b6..7babf95d 100644 --- a/include/boost/redis/connection.hpp +++ b/include/boost/redis/connection.hpp @@ -13,11 +13,11 @@ #include #include #include -#include #include #include #include #include +#include #include #include #include @@ -86,8 +86,7 @@ struct connection_impl { multiplexer mpx_; connection_logger logger_; read_buffer read_buffer_; - request hello_req_; - generic_response hello_resp_; + generic_response setup_resp_; using executor_type = Executor; @@ -324,10 +323,12 @@ class run_op { using order_t = std::array; - static system::error_code on_hello(connection_impl& conn, system::error_code ec) + static system::error_code on_setup_finished( + connection_impl& conn, + system::error_code ec) { - ec = check_hello_response(ec, conn.hello_resp_); - conn.logger_.on_hello(ec, conn.hello_resp_); + ec = check_setup_response(ec, conn.setup_resp_); + conn.logger_.on_setup(ec, conn.setup_resp_); if (ec) { conn.cancel(operation::run); } @@ -335,14 +336,24 @@ class run_op { } template - auto handshaker(CompletionToken&& token) + auto send_setup(CompletionToken&& token) { - return conn_->async_exec( - conn_->hello_req_, - any_adapter(conn_->hello_resp_), - asio::deferred([&conn = *this->conn_](system::error_code hello_ec, std::size_t) { - return asio::deferred.values(on_hello(conn, hello_ec)); - }))(std::forward(token)); + // clang-format off + // Skip sending the setup request if it's empty + return asio::deferred_t::when(conn_->cfg_.setup.get_commands() != 0u) + .then( + conn_->async_exec( + conn_->cfg_.setup, + any_adapter(conn_->setup_resp_), + asio::deferred([&conn = *this->conn_](system::error_code ec, std::size_t) { + return asio::deferred.values(on_setup_finished(conn, ec)); + }) + ) + ) + .otherwise(asio::deferred.values(system::error_code())) + (std::forward(token)) + ; + // clang-format on } template @@ -382,7 +393,7 @@ class run_op { system::error_code final_ec; if (order[0] == 0 && !!ec0) { - // The hello op finished first and with an error + // The setup op finished first and with an error final_ec = ec0; } else if (order[0] == 2 && ec2 == error::pong_timeout) { // The check ping timeout finished first. Use the ping error code @@ -411,8 +422,8 @@ class run_op { return; } - // Set up the hello request, as it only depends on the config - setup_hello_request(conn_->cfg_, conn_->hello_req_); + // Compose the setup request. This only depends on the config, so it can be done just once + compose_setup_request(conn_->cfg_); for (;;) { // Try to connect @@ -423,15 +434,16 @@ class run_op { if (!ec) { conn_->read_buffer_.clear(); conn_->mpx_.reset(); - clear_response(conn_->hello_resp_); + clear_response(conn_->setup_resp_); // Note: Order is important here because the writer might - // trigger an async_write before the async_hello thereby - // causing an authentication problem. + // trigger an async_write before the setup request is sent, + // causing other requests to be sent before the setup request, + // violating the setup request contract. BOOST_ASIO_CORO_YIELD asio::experimental::make_parallel_group( [this](auto token) { - return this->handshaker(token); + return this->send_setup(token); }, [this](auto token) { return conn_->health_checker_.async_ping(*conn_, token); @@ -526,10 +538,11 @@ class basic_connection { executor_type ex, asio::ssl::context ctx = asio::ssl::context{asio::ssl::context::tlsv12_client}, logger lgr = {}) - : impl_(std::make_unique>( - std::move(ex), - std::move(ctx), - std::move(lgr))) + : impl_( + std::make_unique>( + std::move(ex), + std::move(ctx), + std::move(lgr))) { } /** @brief Constructor from an executor and a logger. @@ -593,8 +606,9 @@ class basic_connection { * connects to one of the endpoints obtained during name resolution. * For UNIX domain socket connections, it connects to @ref boost::redis::config::unix_socket. * @li If @ref boost::redis::config::use_ssl is `true`, performs the TLS handshake. - * @li Sends a `HELLO` command where - * each of its parameters are read from `cfg`. + * @li Executes the setup request, as defined by the passed @ref config object. + * By default, this is a `HELLO` command, but it can contain any other arbitrary + * commands. See the @ref config docs for more info. * @li Starts a health-check operation where ping commands are sent * at intervals specified by * @ref boost::redis::config::health_check_interval. @@ -786,10 +800,7 @@ class basic_connection { class CompletionToken = asio::default_completion_token_t> auto async_exec(request const& req, Response& resp = ignore, CompletionToken&& token = {}) { - return this->async_exec( - req, - any_adapter{resp}, - std::forward(token)); + return this->async_exec(req, any_adapter{resp}, std::forward(token)); } /** @brief Executes commands on the Redis server asynchronously. @@ -1093,10 +1104,7 @@ class connection { template auto async_exec(request const& req, Response& resp = ignore, CompletionToken&& token = {}) { - return async_exec( - req, - any_adapter{resp}, - std::forward(token)); + return async_exec(req, any_adapter{resp}, std::forward(token)); } /** diff --git a/include/boost/redis/detail/connection_logger.hpp b/include/boost/redis/detail/connection_logger.hpp index 34d6a4b7..18637d73 100644 --- a/include/boost/redis/detail/connection_logger.hpp +++ b/include/boost/redis/detail/connection_logger.hpp @@ -39,7 +39,7 @@ class connection_logger { void on_ssl_handshake(system::error_code const& ec); void on_write(system::error_code const& ec, std::size_t n); void on_fsm_resume(reader_fsm::action const& action); - void on_hello(system::error_code const& ec, generic_response const& resp); + void on_setup(system::error_code const& ec, generic_response const& resp); void log(logger::level lvl, std::string_view msg); void log(logger::level lvl, std::string_view op, system::error_code const& ec); void trace(std::string_view message) { log(logger::level::debug, message); } diff --git a/include/boost/redis/detail/hello_utils.hpp b/include/boost/redis/detail/setup_request_utils.hpp similarity index 54% rename from include/boost/redis/detail/hello_utils.hpp rename to include/boost/redis/detail/setup_request_utils.hpp index aa313249..bd8a3bea 100644 --- a/include/boost/redis/detail/hello_utils.hpp +++ b/include/boost/redis/detail/setup_request_utils.hpp @@ -4,8 +4,8 @@ * accompanying file LICENSE.txt) */ -#ifndef BOOST_REDIS_HELLO_UTILS_HPP -#define BOOST_REDIS_HELLO_UTILS_HPP +#ifndef BOOST_REDIS_SETUP_REQUEST_UTILS_HPP +#define BOOST_REDIS_SETUP_REQUEST_UTILS_HPP #include #include @@ -13,9 +13,15 @@ namespace boost::redis::detail { -void setup_hello_request(config const& cfg, request& req); +// Modifies config::setup to make a request suitable to be sent +// to the server using async_exec +void compose_setup_request(config& cfg); + +// Completely clears a response void clear_response(generic_response& res); -system::error_code check_hello_response(system::error_code io_ec, const generic_response&); + +// Checks that the response to the setup request was successful +system::error_code check_setup_response(system::error_code io_ec, const generic_response&); } // namespace boost::redis::detail diff --git a/include/boost/redis/error.hpp b/include/boost/redis/error.hpp index a2ae878c..a252789f 100644 --- a/include/boost/redis/error.hpp +++ b/include/boost/redis/error.hpp @@ -80,7 +80,7 @@ enum class error /// Incompatible node depth. incompatible_node_depth, - /// Resp3 hello command error + /// The setup request sent during connection establishment failed (the name is historical). resp3_hello, /// The configuration specified a UNIX socket address, but UNIX sockets are not supported by the system. diff --git a/include/boost/redis/impl/connection_logger.ipp b/include/boost/redis/impl/connection_logger.ipp index 54f8b33f..effa6b9d 100644 --- a/include/boost/redis/impl/connection_logger.ipp +++ b/include/boost/redis/impl/connection_logger.ipp @@ -5,6 +5,7 @@ */ #include +#include #include #include @@ -174,12 +175,12 @@ void connection_logger::on_fsm_resume(reader_fsm::action const& action) logger_.fn(logger::level::debug, msg); } -void connection_logger::on_hello(system::error_code const& ec, generic_response const& resp) +void connection_logger::on_setup(system::error_code const& ec, generic_response const& resp) { if (logger_.lvl < logger::level::info) return; - msg_ = "hello_op: "; + msg_ = "Setup request execution: "; if (ec) { format_error_code(ec, msg_); if (resp.has_error()) { diff --git a/include/boost/redis/impl/error.ipp b/include/boost/redis/impl/error.ipp index 696aa26c..5133feb1 100644 --- a/include/boost/redis/impl/error.ipp +++ b/include/boost/redis/impl/error.ipp @@ -44,7 +44,8 @@ struct error_category_impl : system::error_category { case error::sync_receive_push_failed: return "Can't receive server push synchronously without blocking."; case error::incompatible_node_depth: return "Incompatible node depth."; - case error::resp3_hello: return "RESP3 handshake error (hello command)."; + case error::resp3_hello: + return "The setup request sent during connection establishment failed."; case error::unix_sockets_unsupported: return "The configuration specified a UNIX socket address, but UNIX sockets are not " "supported by the system."; diff --git a/include/boost/redis/impl/hello_utils.ipp b/include/boost/redis/impl/hello_utils.ipp deleted file mode 100644 index b1376b14..00000000 --- a/include/boost/redis/impl/hello_utils.ipp +++ /dev/null @@ -1,53 +0,0 @@ -/* Copyright (c) 2018-2024 Marcelo Zimbres Silva (mzimbres@gmail.com) - * - * Distributed under the Boost Software License, Version 1.0. (See - * accompanying file LICENSE.txt) - */ - -#include - -namespace boost::redis::detail { - -void setup_hello_request(config const& cfg, request& req) -{ - // Which parts of the command should we send? - // Don't send AUTH if the user is the default and the password is empty. - // Other users may have empty passwords. - // Note that this is just an optimization. - bool send_auth = !(cfg.username.empty() || (cfg.username == "default" && cfg.password.empty())); - bool send_setname = !cfg.clientname.empty(); - - req.clear(); - if (send_auth && send_setname) - req.push("HELLO", "3", "AUTH", cfg.username, cfg.password, "SETNAME", cfg.clientname); - else if (send_auth) - req.push("HELLO", "3", "AUTH", cfg.username, cfg.password); - else if (send_setname) - req.push("HELLO", "3", "SETNAME", cfg.clientname); - else - req.push("HELLO", "3"); - - if (cfg.database_index && cfg.database_index.value() != 0) - req.push("SELECT", cfg.database_index.value()); -} - -void clear_response(generic_response& res) -{ - if (res.has_value()) - res->clear(); - else - res.emplace(); -} - -system::error_code check_hello_response(system::error_code io_ec, const generic_response& resp) -{ - if (io_ec) - return io_ec; - - if (resp.has_error()) - return error::resp3_hello; - - return system::error_code(); -} - -} // namespace boost::redis::detail diff --git a/include/boost/redis/impl/request.ipp b/include/boost/redis/impl/request.ipp index ada4adb8..0e15b19a 100644 --- a/include/boost/redis/impl/request.ipp +++ b/include/boost/redis/impl/request.ipp @@ -21,4 +21,11 @@ auto has_response(std::string_view cmd) -> bool return false; } +request make_hello_request() +{ + request req; + req.push("HELLO", "3"); + return req; +} + } // namespace boost::redis::detail diff --git a/include/boost/redis/impl/setup_request_utils.ipp b/include/boost/redis/impl/setup_request_utils.ipp new file mode 100644 index 00000000..e1402ec2 --- /dev/null +++ b/include/boost/redis/impl/setup_request_utils.ipp @@ -0,0 +1,68 @@ +/* Copyright (c) 2018-2024 Marcelo Zimbres Silva (mzimbres@gmail.com) + * + * Distributed under the Boost Software License, Version 1.0. (See + * accompanying file LICENSE.txt) + */ + +#include +#include +#include + +namespace boost::redis::detail { + +void compose_setup_request(config& cfg) +{ + if (!cfg.use_setup) { + // We're not using the setup request as-is, but should compose one based on + // the values passed by the user + auto& req = cfg.setup; + req.clear(); + + // Which parts of the command should we send? + // Don't send AUTH if the user is the default and the password is empty. + // Other users may have empty passwords. + // Note that this is just an optimization. + bool send_auth = !( + cfg.username.empty() || (cfg.username == "default" && cfg.password.empty())); + bool send_setname = !cfg.clientname.empty(); + + // Gather everything we can in a HELLO command + if (send_auth && send_setname) + req.push("HELLO", "3", "AUTH", cfg.username, cfg.password, "SETNAME", cfg.clientname); + else if (send_auth) + req.push("HELLO", "3", "AUTH", cfg.username, cfg.password); + else if (send_setname) + req.push("HELLO", "3", "SETNAME", cfg.clientname); + else + req.push("HELLO", "3"); + + // SELECT is independent of HELLO + if (cfg.database_index && cfg.database_index.value() != 0) + req.push("SELECT", cfg.database_index.value()); + } + + // In any case, the setup request should have the priority + // flag set so it's executed before any other request + request_access::set_priority(cfg.setup, true); +} + +void clear_response(generic_response& res) +{ + if (res.has_value()) + res->clear(); + else + res.emplace(); +} + +system::error_code check_setup_response(system::error_code io_ec, const generic_response& resp) +{ + if (io_ec) + return io_ec; + + if (resp.has_error()) + return error::resp3_hello; + + return system::error_code(); +} + +} // namespace boost::redis::detail diff --git a/include/boost/redis/request.hpp b/include/boost/redis/request.hpp index e5fa81dd..2cb041f4 100644 --- a/include/boost/redis/request.hpp +++ b/include/boost/redis/request.hpp @@ -21,7 +21,8 @@ namespace boost::redis { namespace detail { auto has_response(std::string_view cmd) -> bool; -} +struct request_access; +} // namespace detail /** @brief Represents a Redis request. * @@ -332,8 +333,21 @@ class request { std::size_t commands_ = 0; std::size_t expected_responses_ = 0; bool has_hello_priority_ = false; + + friend struct detail::request_access; }; +namespace detail { + +struct request_access { + inline static void set_priority(request& r, bool value) { r.has_hello_priority_ = value; } +}; + +// Creates a HELLO 3 request +request make_hello_request(); + +} // namespace detail + } // namespace boost::redis #endif // BOOST_REDIS_REQUEST_HPP diff --git a/include/boost/redis/src.hpp b/include/boost/redis/src.hpp index 31069f8f..3e57518e 100644 --- a/include/boost/redis/src.hpp +++ b/include/boost/redis/src.hpp @@ -14,7 +14,7 @@ #include #include #include -#include +#include #include #include #include diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1c7f7c3a..d6e04feb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -41,7 +41,7 @@ make_test(test_exec_fsm) make_test(test_log_to_file) make_test(test_conn_logging) make_test(test_reader_fsm) -make_test(test_hello_utils) +make_test(test_setup_request_utils) # Tests that require a real Redis server make_test(test_conn_quit) @@ -56,7 +56,7 @@ make_test(test_conn_exec_cancel) make_test(test_conn_exec_cancel2) make_test(test_conn_echo_stress) make_test(test_conn_move) -make_test(test_conn_auth) +make_test(test_conn_setup) make_test(test_issue_50) make_test(test_issue_181) make_test(test_conversions) diff --git a/test/Jamfile b/test/Jamfile index 68f3fa79..b3f24452 100644 --- a/test/Jamfile +++ b/test/Jamfile @@ -57,7 +57,7 @@ local tests = test_log_to_file test_conn_logging test_reader_fsm - test_hello_utils + test_setup_request_utils ; # Build and run the tests diff --git a/test/test_conn_auth.cpp b/test/test_conn_auth.cpp deleted file mode 100644 index d305e284..00000000 --- a/test/test_conn_auth.cpp +++ /dev/null @@ -1,143 +0,0 @@ -// -// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), -// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// - -#include -#include -#include -#include - -#include -#include -#include - -#include "common.hpp" - -#include -#include -#include - -namespace asio = boost::asio; -namespace redis = boost::redis; -using namespace std::chrono_literals; -using boost::system::error_code; - -namespace { - -// Creates a user with a known password. Harmless if the user already exists -void setup_password() -{ - // Setup - asio::io_context ioc; - redis::connection conn{ioc}; - - // Enable the user and grant them permissions on everything - redis::request req; - req.push("ACL", "SETUSER", "myuser", "on", ">mypass", "~*", "&*", "+@all"); - redis::generic_response resp; - - bool run_finished = false, exec_finished = false; - conn.async_run(make_test_config(), [&](error_code ec) { - run_finished = true; - BOOST_TEST_EQ(ec, asio::error::operation_aborted); - }); - - conn.async_exec(req, resp, [&](error_code ec, std::size_t) { - exec_finished = true; - BOOST_TEST_EQ(ec, error_code()); - conn.cancel(); - }); - - ioc.run_for(test_timeout); - - BOOST_TEST(run_finished); - BOOST_TEST(exec_finished); - BOOST_TEST(resp.has_value()); -} - -void test_auth_success() -{ - // Setup - asio::io_context ioc; - redis::connection conn{ioc}; - - // This request should return the username we're logged in as - redis::request req; - req.push("ACL", "WHOAMI"); - redis::response resp; - - // These credentials are set up in main, before tests are run - auto cfg = make_test_config(); - cfg.username = "myuser"; - cfg.password = "mypass"; - - bool exec_finished = false, run_finished = false; - - conn.async_exec(req, resp, [&](error_code ec, std::size_t) { - exec_finished = true; - BOOST_TEST_EQ(ec, error_code()); - conn.cancel(); - }); - - conn.async_run(cfg, [&](error_code ec) { - run_finished = true; - BOOST_TEST_EQ(ec, asio::error::operation_aborted); - }); - - ioc.run_for(test_timeout); - - BOOST_TEST(exec_finished); - BOOST_TEST(run_finished); - BOOST_TEST_EQ(std::get<0>(resp).value(), "myuser"); -} - -void test_auth_failure() -{ - // Verify that we log appropriately (see https://github.com/boostorg/redis/issues/297) - std::ostringstream oss; - redis::logger lgr(redis::logger::level::info, [&](redis::logger::level, std::string_view msg) { - oss << msg << '\n'; - }); - - // Setup - asio::io_context ioc; - redis::connection conn{ioc, std::move(lgr)}; - - // Disable reconnection so the hello error causes the connection to exit - auto cfg = make_test_config(); - cfg.username = "myuser"; - cfg.password = "wrongpass"; // wrong - cfg.reconnect_wait_interval = 0s; - - bool run_finished = false; - - conn.async_run(cfg, [&](error_code ec) { - run_finished = true; - BOOST_TEST_EQ(ec, redis::error::resp3_hello); - }); - - ioc.run_for(test_timeout); - - BOOST_TEST(run_finished); - - // Check the log - auto log = oss.str(); - if (!BOOST_TEST_NE(log.find("WRONGPASS"), std::string::npos)) { - std::cerr << "Log was: " << log << std::endl; - } -} - -} // namespace - -int main() -{ - setup_password(); - test_auth_success(); - test_auth_failure(); - - return boost::report_errors(); -} diff --git a/test/test_conn_exec.cpp b/test/test_conn_exec.cpp index 4c69cdda..dd9efc34 100644 --- a/test/test_conn_exec.cpp +++ b/test/test_conn_exec.cpp @@ -141,49 +141,6 @@ BOOST_AUTO_TEST_CASE(cancel_request_if_not_connected) BOOST_TEST(finished); } -BOOST_AUTO_TEST_CASE(correct_database) -{ - auto cfg = make_test_config(); - cfg.database_index = 2; - - net::io_context ioc; - - auto conn = std::make_shared(ioc); - - request req; - req.push("CLIENT", "LIST"); - - generic_response resp; - - bool exec_finished = false, run_finished = false; - - conn->async_exec(req, resp, [&](error_code ec, std::size_t n) { - BOOST_TEST(ec == error_code()); - std::clog << "async_exec has completed: " << n << std::endl; - conn->cancel(); - exec_finished = true; - }); - - conn->async_run(cfg, {}, [&run_finished](error_code) { - std::clog << "async_run has exited." << std::endl; - run_finished = true; - }); - - ioc.run_for(test_timeout); - BOOST_TEST_REQUIRE(exec_finished); - BOOST_TEST_REQUIRE(run_finished); - - BOOST_TEST_REQUIRE(!resp.value().empty()); - auto const& value = resp.value().front().value; - auto const pos = value.find("db="); - auto const index_str = value.substr(pos + 3, 1); - auto const index = std::stoi(index_str); - - // This check might fail if more than one client is connected to - // redis when the CLIENT LIST command is run. - BOOST_CHECK_EQUAL(cfg.database_index.value(), index); -} - BOOST_AUTO_TEST_CASE(large_number_of_concurrent_requests_issue_170) { // See https://github.com/boostorg/redis/issues/170 diff --git a/test/test_conn_setup.cpp b/test/test_conn_setup.cpp new file mode 100644 index 00000000..3c66cfeb --- /dev/null +++ b/test/test_conn_setup.cpp @@ -0,0 +1,344 @@ +// +// Copyright (c) 2025 Marcelo Zimbres Silva (mzimbres@gmail.com), +// Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#include +#include + +#include +#include +#include + +#include "common.hpp" + +#include +#include +#include +#include + +namespace asio = boost::asio; +namespace redis = boost::redis; +using namespace std::chrono_literals; +using boost::system::error_code; + +namespace { + +// Finds a value in the output of the CLIENT INFO command +// format: key1=value1 key2=value2 +std::string_view find_client_info(std::string_view client_info, std::string_view key) +{ + std::string prefix{key}; + prefix += '='; + + auto const pos = client_info.find(prefix); + if (pos == std::string_view::npos) + return {}; + auto const pos_begin = pos + prefix.size(); + auto const pos_end = client_info.find(' ', pos_begin); + return client_info.substr(pos_begin, pos_end - pos_begin); +} + +// Creates a user with a known password. Harmless if the user already exists +void setup_password() +{ + // Setup + asio::io_context ioc; + redis::connection conn{ioc}; + + // Enable the user and grant them permissions on everything + redis::request req; + req.push("ACL", "SETUSER", "myuser", "on", ">mypass", "~*", "&*", "+@all"); + redis::generic_response resp; + + bool run_finished = false, exec_finished = false; + conn.async_run(make_test_config(), [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, asio::error::operation_aborted); + }); + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST_EQ(ec, error_code()); + conn.cancel(); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(run_finished); + BOOST_TEST(exec_finished); + BOOST_TEST(resp.has_value()); +} + +void test_auth_success() +{ + // Setup + asio::io_context ioc; + redis::connection conn{ioc}; + + // This request should return the username we're logged in as + redis::request req; + req.push("ACL", "WHOAMI"); + redis::response resp; + + // These credentials are set up in main, before tests are run + auto cfg = make_test_config(); + cfg.username = "myuser"; + cfg.password = "mypass"; + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + exec_finished = true; + BOOST_TEST_EQ(ec, error_code()); + conn.cancel(); + }); + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, asio::error::operation_aborted); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); + BOOST_TEST_EQ(std::get<0>(resp).value(), "myuser"); +} + +void test_auth_failure() +{ + // Verify that we log appropriately (see https://github.com/boostorg/redis/issues/297) + std::ostringstream oss; + redis::logger lgr(redis::logger::level::info, [&](redis::logger::level, std::string_view msg) { + oss << msg << '\n'; + }); + + // Setup + asio::io_context ioc; + redis::connection conn{ioc, std::move(lgr)}; + + // Disable reconnection so the hello error causes the connection to exit + auto cfg = make_test_config(); + cfg.username = "myuser"; + cfg.password = "wrongpass"; // wrong + cfg.reconnect_wait_interval = 0s; + + bool run_finished = false; + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, redis::error::resp3_hello); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(run_finished); + + // Check the log + auto log = oss.str(); + if (!BOOST_TEST_NE(log.find("WRONGPASS"), std::string::npos)) { + std::cerr << "Log was: " << log << std::endl; + } +} + +void test_database_index() +{ + // Setup + asio::io_context ioc; + redis::connection conn(ioc); + + // Use a non-default database index + auto cfg = make_test_config(); + cfg.database_index = 2; + + redis::request req; + req.push("CLIENT", "INFO"); + + redis::response resp; + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t n) { + BOOST_TEST_EQ(ec, error_code()); + std::clog << "async_exec has completed: " << n << std::endl; + conn.cancel(); + exec_finished = true; + }); + + conn.async_run(cfg, {}, [&run_finished](error_code) { + std::clog << "async_run has exited." << std::endl; + run_finished = true; + }); + + ioc.run_for(test_timeout); + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); + BOOST_TEST_EQ(find_client_info(std::get<0>(resp).value(), "db"), "2"); +} + +// The user configured an empty setup request. No request should be sent +void test_setup_empty() +{ + // Setup + asio::io_context ioc; + redis::connection conn(ioc); + + auto cfg = make_test_config(); + cfg.use_setup = true; + cfg.setup.clear(); + + redis::request req; + req.push("CLIENT", "INFO"); + + redis::response resp; + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + BOOST_TEST_EQ(ec, error_code()); + conn.cancel(); + exec_finished = true; + }); + + conn.async_run(cfg, {}, [&run_finished](error_code) { + run_finished = true; + }); + + ioc.run_for(test_timeout); + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); + BOOST_TEST_EQ(find_client_info(std::get<0>(resp).value(), "resp"), "2"); // using RESP2 +} + +// We can use the setup member to run commands at startup +void test_setup_hello() +{ + // Setup + asio::io_context ioc; + redis::connection conn(ioc); + + auto cfg = make_test_config(); + cfg.use_setup = true; + cfg.setup.clear(); + cfg.setup.push("HELLO", "3", "AUTH", "myuser", "mypass"); + cfg.setup.push("SELECT", 8); + + redis::request req; + req.push("CLIENT", "INFO"); + + redis::response resp; + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + BOOST_TEST_EQ(ec, error_code()); + conn.cancel(); + exec_finished = true; + }); + + conn.async_run(cfg, {}, [&run_finished](error_code) { + run_finished = true; + }); + + ioc.run_for(test_timeout); + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); + BOOST_TEST_EQ(find_client_info(std::get<0>(resp).value(), "resp"), "3"); // using RESP3 + BOOST_TEST_EQ(find_client_info(std::get<0>(resp).value(), "user"), "myuser"); + BOOST_TEST_EQ(find_client_info(std::get<0>(resp).value(), "db"), "8"); +} + +// Running a pipeline without a HELLO is okay (regression check: we set the priority flag) +void test_setup_no_hello() +{ + // Setup + asio::io_context ioc; + redis::connection conn(ioc); + + auto cfg = make_test_config(); + cfg.use_setup = true; + cfg.setup.clear(); + cfg.setup.push("SELECT", 8); + + redis::request req; + req.push("CLIENT", "INFO"); + + redis::response resp; + + bool exec_finished = false, run_finished = false; + + conn.async_exec(req, resp, [&](error_code ec, std::size_t) { + BOOST_TEST_EQ(ec, error_code()); + conn.cancel(); + exec_finished = true; + }); + + conn.async_run(cfg, {}, [&run_finished](error_code) { + run_finished = true; + }); + + ioc.run_for(test_timeout); + BOOST_TEST(exec_finished); + BOOST_TEST(run_finished); + BOOST_TEST_EQ(find_client_info(std::get<0>(resp).value(), "resp"), "2"); // using RESP3 + BOOST_TEST_EQ(find_client_info(std::get<0>(resp).value(), "db"), "8"); +} + +void test_setup_failure() +{ + // Verify that we log appropriately (see https://github.com/boostorg/redis/issues/297) + std::ostringstream oss; + redis::logger lgr(redis::logger::level::info, [&](redis::logger::level, std::string_view msg) { + oss << msg << '\n'; + }); + + // Setup + asio::io_context ioc; + redis::connection conn{ioc, std::move(lgr)}; + + // Disable reconnection so the hello error causes the connection to exit + auto cfg = make_test_config(); + cfg.use_setup = true; + cfg.setup.clear(); + cfg.setup.push("GET", "two", "args"); // GET only accepts one arg, so this will fail + cfg.reconnect_wait_interval = 0s; + + bool run_finished = false; + + conn.async_run(cfg, [&](error_code ec) { + run_finished = true; + BOOST_TEST_EQ(ec, redis::error::resp3_hello); + }); + + ioc.run_for(test_timeout); + + BOOST_TEST(run_finished); + + // Check the log + auto log = oss.str(); + if (!BOOST_TEST_NE(log.find("wrong number of arguments"), std::string::npos)) { + std::cerr << "Log was: " << log << std::endl; + } +} + +} // namespace + +int main() +{ + setup_password(); + test_auth_success(); + test_auth_failure(); + test_database_index(); + test_setup_empty(); + test_setup_hello(); + test_setup_no_hello(); + test_setup_failure(); + + return boost::report_errors(); +} diff --git a/test/test_hello_utils.cpp b/test/test_setup_request_utils.cpp similarity index 54% rename from test/test_hello_utils.cpp rename to test/test_setup_request_utils.cpp index 11abd1b3..b9aa1c70 100644 --- a/test/test_hello_utils.cpp +++ b/test/test_setup_request_utils.cpp @@ -8,7 +8,7 @@ #include #include -#include +#include #include #include #include @@ -20,95 +20,129 @@ namespace asio = boost::asio; namespace redis = boost::redis; -using redis::detail::setup_hello_request; +using redis::detail::compose_setup_request; using redis::detail::clear_response; -using redis::detail::check_hello_response; +using redis::detail::check_setup_response; using boost::system::error_code; namespace { -void test_setup_hello_request() +void test_compose_setup() { redis::config cfg; cfg.clientname = ""; - redis::request req; - setup_hello_request(cfg, req); + compose_setup_request(cfg); std::string_view const expected = "*2\r\n$5\r\nHELLO\r\n$1\r\n3\r\n"; - BOOST_TEST_EQ(req.payload(), expected); + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); } -void test_setup_hello_request_select() +void test_compose_setup_select() { redis::config cfg; cfg.clientname = ""; cfg.database_index = 10; - redis::request req; - setup_hello_request(cfg, req); + compose_setup_request(cfg); std::string_view const expected = "*2\r\n$5\r\nHELLO\r\n$1\r\n3\r\n" "*2\r\n$6\r\nSELECT\r\n$2\r\n10\r\n"; - BOOST_TEST_EQ(req.payload(), expected); + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); } -void test_setup_hello_request_clientname() +void test_compose_setup_clientname() { redis::config cfg; - redis::request req; - setup_hello_request(cfg, req); + compose_setup_request(cfg); std::string_view const expected = "*4\r\n$5\r\nHELLO\r\n$1\r\n3\r\n$7\r\nSETNAME\r\n$11\r\nBoost.Redis\r\n"; - BOOST_TEST_EQ(req.payload(), expected); + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); } -void test_setup_hello_request_auth() +void test_compose_setup_auth() { redis::config cfg; cfg.clientname = ""; cfg.username = "foo"; cfg.password = "bar"; - redis::request req; - setup_hello_request(cfg, req); + compose_setup_request(cfg); std::string_view const expected = "*5\r\n$5\r\nHELLO\r\n$1\r\n3\r\n$4\r\nAUTH\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"; - BOOST_TEST_EQ(req.payload(), expected); + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); } -void test_setup_hello_request_auth_empty_password() +void test_compose_setup_auth_empty_password() { redis::config cfg; cfg.clientname = ""; cfg.username = "foo"; - redis::request req; - setup_hello_request(cfg, req); + compose_setup_request(cfg); std::string_view const expected = "*5\r\n$5\r\nHELLO\r\n$1\r\n3\r\n$4\r\nAUTH\r\n$3\r\nfoo\r\n$0\r\n\r\n"; - BOOST_TEST_EQ(req.payload(), expected); + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); } -void test_setup_hello_request_auth_setname() +void test_compose_setup_auth_setname() { redis::config cfg; cfg.clientname = "mytest"; cfg.username = "foo"; cfg.password = "bar"; - redis::request req; - setup_hello_request(cfg, req); + compose_setup_request(cfg); std::string_view const expected = "*7\r\n$5\r\nHELLO\r\n$1\r\n3\r\n$4\r\nAUTH\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$7\r\nSETNAME\r\n$" "6\r\nmytest\r\n"; - BOOST_TEST_EQ(req.payload(), expected); + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); +} + +void test_compose_setup_use_setup() +{ + redis::config cfg; + cfg.clientname = "mytest"; + cfg.username = "foo"; + cfg.password = "bar"; + cfg.database_index = 4; + cfg.use_setup = true; + cfg.setup.push("SELECT", 8); + + compose_setup_request(cfg); + + std::string_view const expected = + "*2\r\n$5\r\nHELLO\r\n$1\r\n3\r\n" + "*2\r\n$6\r\nSELECT\r\n$1\r\n8\r\n"; + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); +} + +// Regression check: we set the priority flag +void test_compose_setup_use_setup_no_hello() +{ + redis::config cfg; + cfg.use_setup = true; + cfg.setup.clear(); + cfg.setup.push("SELECT", 8); + + compose_setup_request(cfg); + + std::string_view const expected = "*2\r\n$6\r\nSELECT\r\n$1\r\n8\r\n"; + BOOST_TEST_EQ(cfg.setup.payload(), expected); + BOOST_TEST(cfg.setup.has_hello_priority()); } // clear response @@ -141,28 +175,28 @@ void test_clear_response_error() } // check response -void test_check_hello_response_success() +void test_check_response_success() { redis::generic_response resp; resp->push_back({}); - auto ec = check_hello_response(error_code(), resp); + auto ec = check_setup_response(error_code(), resp); BOOST_TEST_EQ(ec, error_code()); } -void test_check_hello_response_io_error() +void test_check_response_io_error() { redis::generic_response resp; - auto ec = check_hello_response(asio::error::already_open, resp); + auto ec = check_setup_response(asio::error::already_open, resp); BOOST_TEST_EQ(ec, asio::error::already_open); } -void test_check_hello_response_server_error() +void test_check_response_server_error() { redis::generic_response resp{ boost::system::in_place_error, redis::adapter::error{redis::resp3::type::simple_error, "wrong password"} }; - auto ec = check_hello_response(error_code(), resp); + auto ec = check_setup_response(error_code(), resp); BOOST_TEST_EQ(ec, redis::error::resp3_hello); } @@ -170,20 +204,22 @@ void test_check_hello_response_server_error() int main() { - test_setup_hello_request(); - test_setup_hello_request_select(); - test_setup_hello_request_clientname(); - test_setup_hello_request_auth(); - test_setup_hello_request_auth_empty_password(); - test_setup_hello_request_auth_setname(); + test_compose_setup(); + test_compose_setup_select(); + test_compose_setup_clientname(); + test_compose_setup_auth(); + test_compose_setup_auth_empty_password(); + test_compose_setup_auth_setname(); + test_compose_setup_use_setup(); + test_compose_setup_use_setup_no_hello(); test_clear_response_empty(); test_clear_response_nonempty(); test_clear_response_error(); - test_check_hello_response_success(); - test_check_hello_response_io_error(); - test_check_hello_response_server_error(); + test_check_response_success(); + test_check_response_io_error(); + test_check_response_server_error(); return boost::report_errors(); } \ No newline at end of file