// Main unit test compilation unit // boost mandates that exactly one compilation unit contains the following two lines: #define BOOST_TEST_MODULE webserver_test #include // Support both boost in Debian unstable (BOOST_LATEST) and in stable (boost 1.67) #if BOOST_VERSION >= 107100 #define BOOST_LATEST #endif #include #include #include #include #include #include #ifdef BOOST_LATEST #include #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "webserver.h" using namespace std::string_literals; namespace fs = std::filesystem; namespace pt = boost::property_tree; using namespace boost::unit_test; using namespace Reichwein; class WebserverProcess { public: const fs::path testConfigFilename{"./webserver.conf"}; WebserverProcess(): m_pid{} { File::setFile(testConfigFilename, R"CONFIG( www-data www-data 10 ../plugins localhost ip6-localhost localhost 127.0.0.1 [::1] static-files . ../fullchain.pem ../privkey.pem
127.0.0.1
8080 http localhost
::1
8080 http localhost
127.0.0.1
8081 https localhost
::1
8081 https localhost
)CONFIG"); start(); } ~WebserverProcess() { stop(); fs::remove(testConfigFilename); } void start() { if (m_pid != 0) throw std::runtime_error("Process already running, so it can't be started"); // connect stdout of new child process to stream of parent, via pipe int filedes[2]; if (pipe(filedes) == -1) throw std::runtime_error("Pipe error"); m_pid = fork(); if (m_pid < 0) throw std::runtime_error("Fork unsuccessful."); if (m_pid == 0) { // child process branch // if (close(filedes[0]) == -1) throw std::runtime_error("Child can't close read end of pipe"); // Replace stdout of child with pipe input (next 2 commands) if (close(1) == -1) throw std::runtime_error("Child can't close stdout"); if (dup(filedes[1]) == -1) throw std::runtime_error("Child replace stdout w/ pipe input"); char* argv[] = {(char*)"webserver", (char*)"-c", (char*)"./webserver.conf"}; webserver(sizeof(argv) / sizeof(char*), argv); exit(0); } if (close(filedes[1]) == -1) throw std::runtime_error("Parent can't close read end of pipe"); m_filebuf = std::make_shared<__gnu_cxx::stdio_filebuf>(filedes[0], std::ios::in); m_is = std::make_shared(&(*m_filebuf)); // wait for server to start up std::this_thread::sleep_for(std::chrono::milliseconds(100)); } void stop() { if (m_pid == 0) throw std::runtime_error("Process not running, so it can't be stopped"); if (kill(m_pid, SIGTERM) != 0) throw std::runtime_error("Unable to kill process"); if (int result = waitpid(m_pid, NULL, 0); result != m_pid) throw std::runtime_error("waitpid returned "s + std::to_string(result)); m_pid = 0; m_is = 0; m_filebuf = 0; } bool isRunning() { if (m_pid == 0) return false; fs::path pid_file{fmt::format("/proc/{}/stat", m_pid)}; if (!fs::exists(pid_file)) return false; std::string s{File::getFile(pid_file)}; auto pos0{s.find(' ', 0)}; pos0 = s.find(' ', pos0 + 1); pos0++; auto pos1{s.find(' ', pos0 + 1)}; std::string state{s.substr(pos0, pos1 - pos0)}; return state == "R" || state == "S"; } std::string output() { if (!isRunning()) throw std::runtime_error("No output/stdout available from webserver since it is not running"); if (!m_is) throw std::runtime_error("Webserver stdout stream not initialized."); std::stringstream result; std::string buffer(static_cast(1024), '\0'); int size{}; while ((size = m_is->readsome(buffer.data(), buffer.size())) > 0) result << buffer.substr(0, size); return result.str(); } private: pid_t m_pid; // child stdout std::shared_ptr<__gnu_cxx::stdio_filebuf> m_filebuf; std::shared_ptr m_is; }; std::pair HTTPGet(const std::string& target, bool ipv6 = true, bool HTTP11 = true) { auto const host = ipv6 ? "::1" : "127.0.0.1"; auto const port = "8080"; int version = HTTP11 ? 11 : 10; // The io_context is required for all I/O boost::asio::io_context ioc; // These objects perform our I/O boost::asio::ip::tcp::resolver resolver(ioc); boost::beast::tcp_stream stream(ioc); // Look up the domain name auto const results = resolver.resolve(host, port); // Make the connection on the IP address we get from a lookup stream.connect(results); // Set up an HTTP GET request message boost::beast::http::request req{boost::beast::http::verb::get, target, version}; req.set(boost::beast::http::field::host, ipv6 && host == "::1"s ? "["s + host + "]"s : host); req.set(boost::beast::http::field::user_agent, "Webserver Testsuite"); // Send the HTTP request to the remote host boost::beast::http::write(stream, req); // This buffer is used for reading and must be persisted boost::beast::flat_buffer buffer; // Declare a container to hold the response boost::beast::http::response res; // Receive the HTTP response boost::beast::http::read(stream, buffer, res); // Return value std::ostringstream header_stream; header_stream << res.base(); std::ostringstream body_stream; body_stream << boost::beast::buffers_to_string(res.body().data()); // Gracefully close the socket boost::beast::error_code ec; stream.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); // not_connected happens sometimes // so don't bother reporting it. // if (ec && ec != boost::beast::errc::not_connected) throw boost::beast::system_error{ec}; return {header_stream.str(), body_stream.str()}; } std::pair HTTPSGet(const std::string& target, bool ipv6 = true, bool HTTP11 = true) { auto const host = ipv6 ? "::1" : "127.0.0.1"; auto const port = "8081"; int version = HTTP11 ? 11 : 10; // The io_context is required for all I/O boost::asio::io_context ioc; // The SSL context is required, and holds certificates boost::asio::ssl::context ctx( #ifdef BOOST_LATEST boost::asio::ssl::context::tlsv13_client #else boost::asio::ssl::context::tlsv12_client #endif ); // This holds the root certificate used for verification //load_root_certificates(ctx); // Verify the remote server's certificate ctx.set_verify_mode(boost::asio::ssl::verify_none); // TODO: ssl::verify_peer w/ load_root_certificates() (above) // These objects perform our I/O boost::asio::ip::tcp::resolver resolver(ioc); boost::beast::ssl_stream stream(ioc, ctx); // Set SNI Hostname (many hosts need this to handshake successfully) if (!SSL_set_tlsext_host_name(stream.native_handle(), host)) { boost::beast::error_code ec{static_cast(::ERR_get_error()), boost::asio::error::get_ssl_category()}; throw boost::beast::system_error{ec}; } // Look up the domain name auto const results = resolver.resolve(host, port); // Make the connection on the IP address we get from a lookup boost::beast::get_lowest_layer(stream).connect(results); // Perform the SSL handshake stream.handshake(boost::asio::ssl::stream_base::client); // Set up an HTTP GET request message boost::beast::http::request req{boost::beast::http::verb::get, target, version}; req.set(boost::beast::http::field::host, ipv6 && host == "::1"s ? "["s + host + "]"s : host); req.set(boost::beast::http::field::user_agent, "Webserver Testsuite"); // Send the HTTP request to the remote host boost::beast::http::write(stream, req); // This buffer is used for reading and must be persisted boost::beast::flat_buffer buffer; // Declare a container to hold the response boost::beast::http::response res; // Receive the HTTP response boost::beast::http::read(stream, buffer, res); // Return value std::ostringstream header_stream; header_stream << res.base(); std::ostringstream body_stream; body_stream << boost::beast::buffers_to_string(res.body().data()); // Gracefully close the stream boost::beast::error_code ec; stream.shutdown(ec); if (ec == boost::asio::error::eof) { // Rationale: // http://stackoverflow.com/questions/25587403/boost-asio-ssl-async-shutdown-always-finishes-with-an-error ec = {}; } if (ec) throw boost::beast::system_error{ec}; return {header_stream.str(), body_stream.str()}; } class Fixture { public: Fixture(){} ~Fixture(){} }; BOOST_DATA_TEST_CASE_F(Fixture, http_get, data::make({false, true}) * data::make({false, true}) * data::make({false, true}), ipv6, http11, https) { WebserverProcess serverProcess; BOOST_REQUIRE(serverProcess.isRunning()); auto response{(https ? HTTPSGet("/webserver.conf") : HTTPGet("/webserver.conf"))}; BOOST_REQUIRE(serverProcess.isRunning()); BOOST_REQUIRE_EQUAL(response.first, "HTTP/1.1 200 OK\r\nServer: Reichwein.IT Webserver " VERSION "\r\nContent-Type: application/text\r\nContent-Length: 1021\r\n\r\n"); BOOST_REQUIRE_EQUAL(response.second, File::getFile(serverProcess.testConfigFilename)); auto output{serverProcess.output()}; BOOST_REQUIRE_MESSAGE(boost::algorithm::contains(output, "Serving"), "Bad output: "s + output); } BOOST_DATA_TEST_CASE_F(Fixture, http_get_file_not_found, data::make({false, true}) * data::make({false, true}) * data::make({false, true}), ipv6, http11, https) { WebserverProcess serverProcess; BOOST_REQUIRE(serverProcess.isRunning()); BOOST_REQUIRE(!fs::exists("./webserver.confSUFFIX")); auto response{(https ? HTTPSGet("/webserver.confSUFFIX") : HTTPGet("/webserver.confSUFFIX"))}; BOOST_REQUIRE(serverProcess.isRunning()); BOOST_REQUIRE_EQUAL(response.first, "HTTP/1.1 404 Not Found\r\nServer: Reichwein.IT Webserver " VERSION "\r\nContent-Type: text/html\r\nContent-Length: 36\r\n\r\n"); BOOST_REQUIRE_EQUAL(response.second, "404 Not found: /webserver.confSUFFIX"); auto output{serverProcess.output()}; BOOST_REQUIRE_MESSAGE(boost::algorithm::contains(output, "Serving"), "Bad output: "s + output); }