//------------------------------------------------------------------------------ /* 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. */ //============================================================================== #include "util/AssignRandomPort.hpp" #include "util/LoggerFixtures.hpp" #include "util/MockPrometheus.hpp" #include "util/TestHttpClient.hpp" #include "util/TestWebSocketClient.hpp" #include "util/TmpFile.hpp" #include "util/config/Array.hpp" #include "util/config/ConfigDefinition.hpp" #include "util/config/ConfigFileJson.hpp" #include "util/config/ConfigValue.hpp" #include "util/config/Types.hpp" #include "util/prometheus/Label.hpp" #include "util/prometheus/Prometheus.hpp" #include "web/AdminVerificationStrategy.hpp" #include "web/Server.hpp" #include "web/dosguard/DOSGuard.hpp" #include "web/dosguard/DOSGuardInterface.hpp" #include "web/dosguard/IntervalSweepHandler.hpp" #include "web/dosguard/Weights.hpp" #include "web/dosguard/WhitelistHandler.hpp" #include "web/interface/ConnectionBase.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 using namespace util; using namespace util::config; using namespace web::impl; using namespace web; static boost::json::value generateJSONWithDynamicPort(std::string_view port) { return boost::json::parse( fmt::format( R"JSON({{ "server": {{ "ip": "0.0.0.0", "port": {} }}, "dos_guard": {{ "max_fetches": 100, "sweep_interval": 1000, "max_connections": 2, "max_requests": 3, "whitelist": ["127.0.0.1"] }} }})JSON", port ) ); } static boost::json::value generateJSONDataOverload(std::string_view port) { return boost::json::parse( fmt::format( R"JSON({{ "server": {{ "ip": "0.0.0.0", "port": {} }}, "dos_guard": {{ "max_fetches": 100, "sweep_interval": 1000, "max_connections": 2, "max_requests": 1 }} }})JSON", port ) ); } inline static ClioConfigDefinition getParseServerConfig(boost::json::value val) { ConfigFileJson const jsonVal{val.as_object()}; auto config = ClioConfigDefinition{ {"server.ip", ConfigValue{ConfigType::String}}, {"server.port", ConfigValue{ConfigType::Integer}}, {"server.admin_password", ConfigValue{ConfigType::String}.optional()}, {"server.local_admin", ConfigValue{ConfigType::Boolean}.optional()}, {"server.ws_max_sending_queue_size", ConfigValue{ConfigType::Integer}.defaultValue(1500)}, {"log.tag_style", ConfigValue{ConfigType::String}.defaultValue("uint")}, {"dos_guard.max_fetches", ConfigValue{ConfigType::Integer}}, {"dos_guard.sweep_interval", ConfigValue{ConfigType::Integer}}, {"dos_guard.max_connections", ConfigValue{ConfigType::Integer}}, {"dos_guard.max_requests", ConfigValue{ConfigType::Integer}}, {"dos_guard.whitelist.[]", Array{ConfigValue{ConfigType::String}.optional()}}, {"ssl_key_file", ConfigValue{ConfigType::String}.optional()}, {"ssl_cert_file", ConfigValue{ConfigType::String}.optional()}, }; auto const errors = config.parse(jsonVal); [&]() { ASSERT_FALSE(errors.has_value()); }(); return config; }; struct WebServerTest : NoLoggerFixture { ~WebServerTest() override { work_.reset(); ctx.stop(); if (runner_->joinable()) runner_->join(); } WebServerTest() { work_.emplace(boost::asio::make_work_guard(ctx)); // make sure ctx does not stop on its own 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()); ClioConfigDefinition cfg{getParseServerConfig(generateJSONWithDynamicPort(port))}; dosguard::WhitelistHandler whitelistHandler{cfg}; dosguard::Weights dosguardWeights{1, {}}; dosguard::DOSGuard dosGuard{cfg, whitelistHandler, dosguardWeights}; dosguard::IntervalSweepHandler sweepHandler{cfg, ctxSync, dosGuard}; ClioConfigDefinition cfgOverload{getParseServerConfig(generateJSONDataOverload(port))}; dosguard::WhitelistHandler whitelistHandlerOverload{cfgOverload}; dosguard::DOSGuard dosGuardOverload{cfgOverload, whitelistHandlerOverload, dosguardWeights}; dosguard::IntervalSweepHandler sweepHandlerOverload{cfgOverload, ctxSync, dosGuardOverload}; // 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_; }; class EchoExecutor { public: void operator()(std::string const& reqStr, std::shared_ptr const& ws) { ws->send(std::string(reqStr), http::status::ok); } void operator()(boost::beast::error_code /* ec */, std::shared_ptr const& /* ws */) { } }; class ExceptionExecutor { public: void operator()(std::string const& /* req */, std::shared_ptr const& /* ws */) { throw std::runtime_error("MyError"); } void operator()(boost::beast::error_code /* ec */, std::shared_ptr const& /* ws */) { } }; namespace { template std::shared_ptr> makeServerSync( util::config::ClioConfigDefinition const& config, boost::asio::io_context& ioc, web::dosguard::DOSGuardInterface& dosGuard, std::shared_ptr const& handler ) { auto server = std::shared_ptr>(); std::mutex m; std::condition_variable cv; bool ready = false; boost::asio::dispatch(ioc.get_executor(), [&]() mutable { server = web::makeHttpServer(config, ioc, dosGuard, handler); { std::lock_guard const lk(m); ready = true; } cv.notify_one(); }); { std::unique_lock lk(m); cv.wait(lk, [&] { return ready; }); } return server; } } // namespace TEST_F(WebServerTest, Http) { auto const e = std::make_shared(); auto const server = makeServerSync(cfg, ctx, dosGuard, e); auto const [status, res] = HttpSyncClient::post("localhost", port, R"JSON({"Hello":1})JSON"); EXPECT_EQ(res, R"JSON({"Hello":1})JSON"); EXPECT_EQ(status, boost::beast::http::status::ok); } TEST_F(WebServerTest, Ws) { auto e = std::make_shared(); auto const server = makeServerSync(cfg, ctx, dosGuard, e); WebSocketSyncClient wsClient; wsClient.connect("localhost", port); auto const res = wsClient.syncPost(R"JSON({"Hello":1})JSON"); EXPECT_EQ(res, R"JSON({"Hello":1})JSON"); wsClient.disconnect(); } TEST_F(WebServerTest, HttpInternalError) { auto const e = std::make_shared(); auto const server = makeServerSync(cfg, ctx, dosGuard, e); auto const [status, res] = HttpSyncClient::post("localhost", port, R"JSON({})JSON"); EXPECT_EQ( res, R"JSON({"error":"internal","error_code":73,"error_message":"Internal error.","status":"error","type":"response"})JSON" ); EXPECT_EQ(status, boost::beast::http::status::internal_server_error); } TEST_F(WebServerTest, WsInternalError) { auto e = std::make_shared(); auto const server = makeServerSync(cfg, ctx, dosGuard, e); WebSocketSyncClient wsClient; wsClient.connect("localhost", port); auto const res = wsClient.syncPost(R"JSON({"id":"id1"})JSON"); wsClient.disconnect(); EXPECT_EQ( res, R"JSON({"error":"internal","error_code":73,"error_message":"Internal error.","status":"error","type":"response","id":"id1","request":{"id":"id1"}})JSON" ); } TEST_F(WebServerTest, WsInternalErrorNotJson) { auto e = std::make_shared(); auto const server = makeServerSync(cfg, ctx, dosGuard, e); WebSocketSyncClient wsClient; wsClient.connect("localhost", port); auto const res = wsClient.syncPost("not json"); wsClient.disconnect(); EXPECT_EQ( res, R"JSON({"error":"internal","error_code":73,"error_message":"Internal error.","status":"error","type":"response","request":"not json"})JSON" ); } TEST_F(WebServerTest, IncompleteSslConfig) { auto const e = std::make_shared(); auto jsonConfig = generateJSONWithDynamicPort(port); jsonConfig.as_object()["ssl_key_file"] = sslKeyFile.path; auto const server = makeServerSync(getParseServerConfig(jsonConfig), ctx, dosGuard, e); EXPECT_EQ(server, nullptr); } TEST_F(WebServerTest, WrongSslConfig) { auto const e = std::make_shared(); auto jsonConfig = generateJSONWithDynamicPort(port); jsonConfig.as_object()["ssl_key_file"] = sslKeyFile.path; jsonConfig.as_object()["ssl_cert_file"] = "wrong_path"; auto const server = makeServerSync(getParseServerConfig(jsonConfig), ctx, dosGuard, e); EXPECT_EQ(server, nullptr); } TEST_F(WebServerTest, Https) { auto const e = std::make_shared(); cfg = getParseServerConfig(addSslConfig(generateJSONWithDynamicPort(port))); auto const server = makeServerSync(cfg, ctx, dosGuard, e); auto const res = HttpsSyncClient::syncPost("localhost", port, R"JSON({"Hello":1})JSON"); EXPECT_EQ(res, R"JSON({"Hello":1})JSON"); } TEST_F(WebServerTest, Wss) { auto e = std::make_shared(); cfg = getParseServerConfig(addSslConfig(generateJSONWithDynamicPort(port))); auto server = makeServerSync(cfg, ctx, dosGuard, e); WebServerSslSyncClient wsClient; wsClient.connect("localhost", port); auto const res = wsClient.syncPost(R"JSON({"Hello":1})JSON"); EXPECT_EQ(res, R"JSON({"Hello":1})JSON"); wsClient.disconnect(); } TEST_F(WebServerTest, HttpPayloadOverload) { std::string const s100(100, 'a'); auto const e = std::make_shared(); auto server = makeServerSync(cfg, ctx, dosGuardOverload, e); auto const [status, res] = HttpSyncClient::post("localhost", port, fmt::format(R"JSON({{"payload":"{}"}})JSON", s100)); EXPECT_EQ( res, R"JSON({"payload":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","warning":"load","warnings":[{"id":2003,"message":"You are about to be rate limited"}]})JSON" ); EXPECT_EQ(status, boost::beast::http::status::ok); } TEST_F(WebServerTest, WsPayloadOverload) { std::string const s100(100, 'a'); auto const e = std::make_shared(); auto server = makeServerSync(cfg, ctx, dosGuardOverload, e); WebSocketSyncClient wsClient; wsClient.connect("localhost", port); auto const res = wsClient.syncPost(fmt::format(R"JSON({{"payload":"{}"}})JSON", s100)); wsClient.disconnect(); EXPECT_EQ( res, R"JSON({"payload":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","warning":"load","warnings":[{"id":2003,"message":"You are about to be rate limited"}]})JSON" ); } TEST_F(WebServerTest, WsTooManyConnection) { auto const e = std::make_shared(); auto server = makeServerSync(cfg, ctx, dosGuardOverload, e); // max connection is 2, exception should happen when the third connection is made WebSocketSyncClient wsClient1; wsClient1.connect("localhost", port); WebSocketSyncClient wsClient2; wsClient2.connect("localhost", port); bool exceptionThrown = false; try { WebSocketSyncClient wsClient3; wsClient3.connect("localhost", port); } catch (boost::system::system_error const& ex) { exceptionThrown = true; EXPECT_EQ(ex.code(), boost::beast::websocket::error::upgrade_declined); } wsClient1.disconnect(); wsClient2.disconnect(); EXPECT_TRUE(exceptionThrown); } TEST_F(WebServerTest, HealthCheck) { auto e = std::make_shared(); // request handled before we get to executor auto const server = makeServerSync(cfg, ctx, dosGuard, e); auto const [status, res] = HttpSyncClient::get("localhost", port, "", "/health"); EXPECT_FALSE(res.empty()); EXPECT_EQ(status, boost::beast::http::status::ok); } TEST_F(WebServerTest, GetOtherThanHealthCheck) { auto e = std::make_shared(); // request handled before we get to executor auto const server = makeServerSync(cfg, ctx, dosGuard, e); auto const [status, res] = HttpSyncClient::get("localhost", port, "", "/"); EXPECT_FALSE(res.empty()); EXPECT_EQ(status, boost::beast::http::status::bad_request); } namespace { std::string jsonServerConfigWithAdminPassword(uint32_t const port) { return fmt::format( R"JSON({{ "server": {{ "ip": "0.0.0.0", "port": {}, "admin_password": "secret" }} }})JSON", port ); } std::string jsonServerConfigWithLocalAdmin(uint32_t const port) { return fmt::format( R"JSON({{ "server": {{ "ip": "0.0.0.0", "port": {}, "local_admin": true }} }})JSON", port ); } std::string jsonServerConfigWithBothAdminPasswordAndLocalAdminFalse(uint32_t const port) { return fmt::format( R"JSON({{ "server": {{ "ip": "0.0.0.0", "port": {}, "admin_password": "secret", "local_admin": false }} }})JSON", port ); } std::string jsonServerConfigWithNoSpecifiedAdmin(uint32_t const port) { return fmt::format( R"JSON({{ "server": {{ "ip": "0.0.0.0", "port": {} }} }})JSON", port ); } // get this value from online sha256 generator constexpr auto kSECRET_SHA256 = "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"; } // namespace class AdminCheckExecutor { public: void operator()(std::string const& reqStr, std::shared_ptr const& ws) { auto response = fmt::format("{} {}", reqStr, ws->isAdmin() ? "admin" : "user"); ws->send(std::move(response), http::status::ok); } void operator()(boost::beast::error_code /* ec */, std::shared_ptr const& /* ws */) { } }; struct WebServerAdminTestParams { std::string config; std::vector headers; std::string expectedResponse; }; inline static ClioConfigDefinition getParseAdminServerConfig(boost::json::value val) { ConfigFileJson const jsonVal{val.as_object()}; auto config = ClioConfigDefinition{ {"server.ip", ConfigValue{ConfigType::String}}, {"server.port", ConfigValue{ConfigType::Integer}}, {"server.admin_password", ConfigValue{ConfigType::String}.optional()}, {"server.local_admin", ConfigValue{ConfigType::Boolean}.optional()}, {"server.processing_policy", ConfigValue{ConfigType::String}.defaultValue("parallel")}, {"server.parallel_requests_limit", ConfigValue{ConfigType::Integer}.optional()}, {"server.ws_max_sending_queue_size", ConfigValue{ConfigType::Integer}.defaultValue(1500)}, {"ssl_cert_file", ConfigValue{ConfigType::String}.optional()}, {"ssl_key_file", ConfigValue{ConfigType::String}.optional()}, {"prometheus.enabled", ConfigValue{ConfigType::Boolean}.defaultValue(true)}, {"prometheus.compress_reply", ConfigValue{ConfigType::Boolean}.defaultValue(true)}, {"log.tag_style", ConfigValue{ConfigType::String}.defaultValue("uint")} }; auto const errors = config.parse(jsonVal); [&]() { ASSERT_FALSE(errors.has_value()); }(); return config; }; class WebServerAdminTest : public WebServerTest, public ::testing::WithParamInterface {}; TEST_P(WebServerAdminTest, WsAdminCheck) { auto e = std::make_shared(); ClioConfigDefinition const serverConfig{getParseAdminServerConfig(boost::json::parse(GetParam().config))}; auto server = makeServerSync(serverConfig, ctx, dosGuardOverload, e); WebSocketSyncClient wsClient; uint32_t const webServerPort = serverConfig.get("server.port"); wsClient.connect("localhost", std::to_string(webServerPort), GetParam().headers); std::string const request = "Why hello"; auto const res = wsClient.syncPost(request); wsClient.disconnect(); EXPECT_EQ(res, fmt::format("{} {}", request, GetParam().expectedResponse)); } TEST_P(WebServerAdminTest, HttpAdminCheck) { auto const e = std::make_shared(); ClioConfigDefinition const serverConfig{getParseAdminServerConfig(boost::json::parse(GetParam().config))}; auto server = makeServerSync(serverConfig, ctx, dosGuardOverload, e); std::string const request = "Why hello"; uint32_t const webServerPort = serverConfig.get("server.port"); auto const [status, res] = HttpSyncClient::post("localhost", std::to_string(webServerPort), request, GetParam().headers); EXPECT_EQ(res, fmt::format("{} {}", request, GetParam().expectedResponse)); EXPECT_EQ(status, boost::beast::http::status::ok); } INSTANTIATE_TEST_CASE_P( WebServerAdminTestsSuit, WebServerAdminTest, ::testing::Values( WebServerAdminTestParams{ .config = jsonServerConfigWithAdminPassword(tests::util::generateFreePort()), .headers = {}, .expectedResponse = "user" }, WebServerAdminTestParams{ .config = jsonServerConfigWithAdminPassword(tests::util::generateFreePort()), .headers = {WebHeader(http::field::authorization, "")}, .expectedResponse = "user" }, WebServerAdminTestParams{ .config = jsonServerConfigWithAdminPassword(tests::util::generateFreePort()), .headers = {WebHeader(http::field::authorization, "s")}, .expectedResponse = "user" }, WebServerAdminTestParams{ .config = jsonServerConfigWithAdminPassword(tests::util::generateFreePort()), .headers = {WebHeader(http::field::authorization, kSECRET_SHA256)}, .expectedResponse = "user" }, WebServerAdminTestParams{ .config = jsonServerConfigWithAdminPassword(tests::util::generateFreePort()), .headers = {WebHeader( http::field::authorization, fmt::format("{}{}", PasswordAdminVerificationStrategy::kPASSWORD_PREFIX, kSECRET_SHA256) )}, .expectedResponse = "admin" }, WebServerAdminTestParams{ .config = jsonServerConfigWithBothAdminPasswordAndLocalAdminFalse(tests::util::generateFreePort()), .headers = {WebHeader(http::field::authorization, kSECRET_SHA256)}, .expectedResponse = "user" }, WebServerAdminTestParams{ .config = jsonServerConfigWithBothAdminPasswordAndLocalAdminFalse(tests::util::generateFreePort()), .headers = {WebHeader( http::field::authorization, fmt::format("{}{}", PasswordAdminVerificationStrategy::kPASSWORD_PREFIX, kSECRET_SHA256) )}, .expectedResponse = "admin" }, WebServerAdminTestParams{ .config = jsonServerConfigWithAdminPassword(tests::util::generateFreePort()), .headers = {WebHeader( http::field::authentication_info, fmt::format("{}{}", PasswordAdminVerificationStrategy::kPASSWORD_PREFIX, kSECRET_SHA256) )}, .expectedResponse = "user" }, WebServerAdminTestParams{ .config = jsonServerConfigWithLocalAdmin(tests::util::generateFreePort()), .headers = {}, .expectedResponse = "admin" }, WebServerAdminTestParams{ .config = jsonServerConfigWithNoSpecifiedAdmin(tests::util::generateFreePort()), .headers = {}, .expectedResponse = "admin" } ) ); TEST_F(WebServerTest, AdminErrorCfgTestBothAdminPasswordAndLocalAdminSet) { uint32_t webServerPort = tests::util::generateFreePort(); std::string const jsonServerConfigWithBothAdminPasswordAndLocalAdmin = fmt::format( R"JSON({{ "server":{{ "ip": "0.0.0.0", "port": {}, "admin_password": "secret", "local_admin": true }} }})JSON", webServerPort ); auto const e = std::make_shared(); ClioConfigDefinition const serverConfig{ getParseAdminServerConfig(boost::json::parse(jsonServerConfigWithBothAdminPasswordAndLocalAdmin)) }; EXPECT_THROW(web::makeHttpServer(serverConfig, ctx, dosGuardOverload, e), std::logic_error); } TEST_F(WebServerTest, AdminErrorCfgTestBothAdminPasswordAndLocalAdminFalse) { uint32_t webServerPort = tests::util::generateFreePort(); std::string const jsonServerConfigWithNoAdminPasswordAndLocalAdminFalse = fmt::format( R"JSON({{ "server": {{ "ip": "0.0.0.0", "port": {}, "local_admin": false }} }})JSON", webServerPort ); auto const e = std::make_shared(); ClioConfigDefinition const serverConfig{ getParseAdminServerConfig(boost::json::parse(jsonServerConfigWithNoAdminPasswordAndLocalAdminFalse)) }; EXPECT_THROW(web::makeHttpServer(serverConfig, ctx, dosGuardOverload, e), std::logic_error); } struct WebServerPrometheusTest : util::prometheus::WithPrometheus, WebServerTest {}; TEST_F(WebServerPrometheusTest, rejectedWithoutAdminPassword) { auto const e = std::make_shared(); uint32_t const webServerPort = tests::util::generateFreePort(); ClioConfigDefinition const serverConfig{ getParseAdminServerConfig(boost::json::parse(jsonServerConfigWithAdminPassword(webServerPort))) }; auto server = makeServerSync(serverConfig, ctx, dosGuard, e); auto const [status, res] = HttpSyncClient::get("localhost", std::to_string(webServerPort), "", "/metrics"); EXPECT_EQ(res, "Only admin is allowed to collect metrics"); EXPECT_EQ(status, boost::beast::http::status::unauthorized); } struct WebServerPrometheusDisabledTest : util::prometheus::WithPrometheusDisabled, WebServerTest {}; TEST_F(WebServerPrometheusDisabledTest, rejectedIfPrometheusIsDisabled) { uint32_t webServerPort = tests::util::generateFreePort(); std::string const jsonServerConfigWithDisabledPrometheus = fmt::format( R"JSON({{ "server":{{ "ip": "0.0.0.0", "port": {}, "admin_password": "secret", "ws_max_sending_queue_size": 1500 }} }})JSON", webServerPort ); auto const e = std::make_shared(); ClioConfigDefinition const serverConfig{ getParseAdminServerConfig(boost::json::parse(jsonServerConfigWithDisabledPrometheus)) }; auto server = makeServerSync(serverConfig, ctx, dosGuard, e); auto const [status, res] = HttpSyncClient::get( "localhost", std::to_string(webServerPort), "", "/metrics", {WebHeader( http::field::authorization, fmt::format("{}{}", PasswordAdminVerificationStrategy::kPASSWORD_PREFIX, kSECRET_SHA256) )} ); EXPECT_EQ(res, "Prometheus is disabled in clio config"); EXPECT_EQ(status, boost::beast::http::status::forbidden); } TEST_F(WebServerPrometheusTest, validResponse) { uint32_t const webServerPort = tests::util::generateFreePort(); auto& testCounter = PrometheusService::counterInt("test_counter", util::prometheus::Labels()); ++testCounter; auto const e = std::make_shared(); ClioConfigDefinition const serverConfig{ getParseAdminServerConfig(boost::json::parse(jsonServerConfigWithAdminPassword(webServerPort))) }; auto server = makeServerSync(serverConfig, ctx, dosGuard, e); auto const [status, res] = HttpSyncClient::get( "localhost", std::to_string(webServerPort), "", "/metrics", {WebHeader( http::field::authorization, fmt::format("{}{}", PasswordAdminVerificationStrategy::kPASSWORD_PREFIX, kSECRET_SHA256) )} ); EXPECT_EQ(res, "# TYPE test_counter counter\ntest_counter 1\n\n"); EXPECT_EQ(status, boost::beast::http::status::ok); }