Merge commit '92046785d1fea5f9efe5a770d636792ea6cab78b' into copilot/fix-f350b804-905b-4a06-ab84-d0f12e5b0dd1

This commit is contained in:
Mayukha Vadari
2026-02-03 00:07:02 -05:00
5 changed files with 338 additions and 127 deletions

View File

@@ -62,12 +62,36 @@ jobs:
REMOTE_PASSWORD: ${{ secrets.remote_password }}
run: conan remote login "${REMOTE_NAME}" "${REMOTE_USERNAME}" --password "${REMOTE_PASSWORD}"
- name: Upload Conan recipe
- name: Upload Conan recipe (version)
env:
REMOTE_NAME: ${{ inputs.remote_name }}
run: |
conan export . --version=${{ steps.version.outputs.version }}
conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.version.outputs.version }}
- name: Upload Conan recipe (develop)
if: ${{ github.ref == 'refs/heads/develop' }}
env:
REMOTE_NAME: ${{ inputs.remote_name }}
run: |
conan export . --version=develop
conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/develop
- name: Upload Conan recipe (rc)
if: ${{ startsWith(github.ref, 'refs/heads/release') }}
env:
REMOTE_NAME: ${{ inputs.remote_name }}
run: |
conan export . --version=rc
conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/rc
- name: Upload Conan recipe (release)
if: ${{ github.event_name == 'tag' }}
env:
REMOTE_NAME: ${{ inputs.remote_name }}
run: |
conan export . --version=release
conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/release
outputs:
ref: xrpl/${{ steps.version.outputs.version }}

View File

@@ -6,9 +6,19 @@ find_package(GTest REQUIRED)
# Custom target for all tests defined in this file
add_custom_target(xrpl.tests)
# Test helpers
add_library(xrpl.helpers.test STATIC)
target_sources(xrpl.helpers.test PRIVATE
helpers/TestSink.cpp
)
target_include_directories(xrpl.helpers.test PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(xrpl.helpers.test PRIVATE xrpl.libxrpl)
# Common library dependencies for the rest of the tests.
add_library(xrpl.imports.test INTERFACE)
target_link_libraries(xrpl.imports.test INTERFACE gtest::gtest xrpl.libxrpl)
target_link_libraries(xrpl.imports.test INTERFACE gtest::gtest xrpl.libxrpl xrpl.helpers.test)
# One test for each module.
xrpl_add_test(basics)

View File

@@ -0,0 +1,127 @@
#include <boost/predef.h>
#include <helpers/TestSink.h>
#include <cstdlib> // for getenv
#if BOOST_OS_WINDOWS
#include <io.h> // for _isatty, _fileno
#include <stdio.h> // for stdout
#else
#include <unistd.h> // for isatty, STDOUT_FILENO
#endif
#include <iostream>
namespace xrpl {
TestSink::TestSink(beast::severities::Severity threshold)
: Sink(threshold, false)
{
}
void
TestSink::write(beast::severities::Severity level, std::string const& text)
{
if (level < threshold())
return;
writeAlways(level, text);
}
void
TestSink::writeAlways(
beast::severities::Severity level,
std::string const& text)
{
auto supportsColor = [] {
// 1. Check for "NO_COLOR" environment variable (Standard convention)
if (std::getenv("NO_COLOR") != nullptr)
{
return false;
}
// 2. Check for "CLICOLOR_FORCE" (Force color)
if (std::getenv("CLICOLOR_FORCE") != nullptr)
{
return true;
}
// 3. Platform-specific check to see if stdout is a terminal
#if BOOST_OS_WINDOWS
// Windows: Check if the output handle is a character device
// _fileno(stdout) is usually 1
// _isatty returns non-zero if the handle is a character device, 0
// otherwise.
return _isatty(_fileno(stdout)) != 0;
#else
// Linux/macOS: Check if file descriptor 1 (stdout) is a TTY
// STDOUT_FILENO is 1
// isatty returns 1 if the file descriptor is a TTY, 0 otherwise.
return isatty(STDOUT_FILENO) != 0;
#endif
}();
auto color = [level]() {
switch (level)
{
case beast::severities::kTrace:
return "\033[34m"; // blue
case beast::severities::kDebug:
return "\033[32m"; // green
case beast::severities::kInfo:
return "\033[36m"; // cyan
case beast::severities::kWarning:
return "\033[33m"; // yellow
case beast::severities::kError:
return "\033[31m"; // red
case beast::severities::kFatal:
default:
break;
}
return "\033[31m"; // red
}();
auto prefix = [level]() {
switch (level)
{
case beast::severities::kTrace:
return "TRC:";
case beast::severities::kDebug:
return "DBG:";
case beast::severities::kInfo:
return "INF:";
case beast::severities::kWarning:
return "WRN:";
case beast::severities::kError:
return "ERR:";
case beast::severities::kFatal:
default:
break;
}
return "FTL:";
}();
auto& stream = [level]() -> std::ostream& {
switch (level)
{
case beast::severities::kError:
case beast::severities::kFatal:
return std::cerr;
default:
return std::cout;
}
}();
constexpr auto reset = "\033[0m";
if (supportsColor)
{
stream << color << prefix << " " << text << reset << std::endl;
}
else
{
stream << prefix << " " << text << std::endl;
}
}
} // namespace xrpl

View File

@@ -0,0 +1,27 @@
#ifndef XRPL_DEBUGSINK_H
#define XRPL_DEBUGSINK_H
#include <xrpl/beast/utility/Journal.h>
namespace xrpl {
class TestSink : public beast::Journal::Sink
{
public:
static TestSink&
instance()
{
static TestSink sink{};
return sink;
}
TestSink(beast::severities::Severity threshold = beast::severities::kDebug);
void
write(beast::severities::Severity level, std::string const& text) override;
void
writeAlways(beast::severities::Severity level, std::string const& text)
override;
};
} // namespace xrpl
#endif // XRPL_DEBUGSINK_H

View File

@@ -2,15 +2,22 @@
#include <xrpl/net/HTTPClient.h>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/version.hpp>
#include <gtest/gtest.h>
#include <helpers/TestSink.h>
#include <atomic>
#include <map>
#include <memory>
#include <semaphore>
#include <thread>
using namespace xrpl;
@@ -24,16 +31,19 @@ private:
boost::asio::io_context ioc_;
boost::asio::ip::tcp::acceptor acceptor_;
boost::asio::ip::tcp::endpoint endpoint_;
std::atomic<bool> running_{true};
bool running_{true};
bool finished_{false};
unsigned short port_;
// Custom headers to return
std::map<std::string, std::string> custom_headers_;
std::string response_body_;
unsigned int status_code_{200};
std::map<std::string, std::string> customHeaders_;
std::string responseBody_;
unsigned int statusCode_{200};
beast::Journal j_;
public:
TestHTTPServer() : acceptor_(ioc_), port_(0)
TestHTTPServer() : acceptor_(ioc_), port_(0), j_(TestSink::instance())
{
// Bind to any available port
endpoint_ = {boost::asio::ip::tcp::v4(), 0};
@@ -45,12 +55,19 @@ public:
// Get the actual port that was assigned
port_ = acceptor_.local_endpoint().port();
accept();
// Start the accept coroutine
boost::asio::co_spawn(ioc_, accept(), boost::asio::detached);
}
TestHTTPServer(TestHTTPServer&&) = delete;
TestHTTPServer&
operator=(TestHTTPServer&&) = delete;
~TestHTTPServer()
{
stop();
XRPL_ASSERT(
finished(),
"xrpl::TestHTTPServer::~TestHTTPServer : accept future ready");
}
boost::asio::io_context&
@@ -68,22 +85,21 @@ public:
void
setHeader(std::string const& name, std::string const& value)
{
custom_headers_[name] = value;
customHeaders_[name] = value;
}
void
setResponseBody(std::string const& body)
{
response_body_ = body;
responseBody_ = body;
}
void
setStatusCode(unsigned int code)
{
status_code_ = code;
statusCode_ = code;
}
private:
void
stop()
{
@@ -91,78 +107,84 @@ private:
acceptor_.close();
}
void
accept()
bool
finished() const
{
if (!running_)
return;
acceptor_.async_accept(
ioc_,
endpoint_,
[&](boost::system::error_code const& error,
boost::asio::ip::tcp::socket peer) {
if (!running_)
return;
if (!error)
{
handleConnection(std::move(peer));
}
});
return finished_;
}
void
private:
boost::asio::awaitable<void>
accept()
{
while (running_)
{
try
{
auto socket =
co_await acceptor_.async_accept(boost::asio::use_awaitable);
if (!running_)
break;
// Handle this connection
co_await handleConnection(std::move(socket));
}
catch (std::exception const& e)
{
// Accept or handle failed, stop accepting
JLOG(j_.debug()) << "Error: " << e.what();
break;
}
}
finished_ = true;
}
boost::asio::awaitable<void>
handleConnection(boost::asio::ip::tcp::socket socket)
{
try
{
// Read the HTTP request
boost::beast::flat_buffer buffer;
boost::beast::http::request<boost::beast::http::string_body> req;
boost::beast::http::read(socket, buffer, req);
// Read the HTTP request asynchronously
co_await boost::beast::http::async_read(
socket, buffer, req, boost::asio::use_awaitable);
// Create response
boost::beast::http::response<boost::beast::http::string_body> res;
res.version(req.version());
res.result(status_code_);
res.result(statusCode_);
res.set(boost::beast::http::field::server, "TestServer");
// Add custom headers
for (auto const& [name, value] : custom_headers_)
// Set body and prepare payload first
res.body() = responseBody_;
res.prepare_payload();
// Override Content-Length with custom headers after
// prepare_payload. This allows us to test case-insensitive
// header parsing.
for (auto const& [name, value] : customHeaders_)
{
res.set(name, value);
}
// Set body and prepare payload first
res.body() = response_body_;
res.prepare_payload();
// Override Content-Length with custom headers after prepare_payload
// This allows us to test case-insensitive header parsing
for (auto const& [name, value] : custom_headers_)
{
if (boost::iequals(name, "Content-Length"))
{
res.erase(boost::beast::http::field::content_length);
res.set(name, value);
}
}
// Send response
boost::beast::http::write(socket, res);
// Send response asynchronously
co_await boost::beast::http::async_write(
socket, res, boost::asio::use_awaitable);
// Shutdown socket gracefully
boost::system::error_code ec;
socket.shutdown(boost::asio::ip::tcp::socket::shutdown_send, ec);
boost::system::error_code shutdownEc;
socket.shutdown(
boost::asio::ip::tcp::socket::shutdown_send, shutdownEc);
}
catch (std::exception const&)
catch (std::exception const& e)
{
// Connection handling errors are expected
// Error reading or writing, just close the connection
JLOG(j_.debug()) << "Connection error: " << e.what();
}
if (running_)
accept();
}
};
@@ -171,13 +193,13 @@ bool
runHTTPTest(
TestHTTPServer& server,
std::string const& path,
std::atomic<bool>& completed,
std::atomic<int>& result_status,
std::string& result_data,
boost::system::error_code& result_error)
bool& completed,
int& resultStatus,
std::string& resultData,
boost::system::error_code& resultError)
{
// Create a null journal for testing
beast::Journal j{beast::Journal::getNullSink()};
beast::Journal j{TestSink::instance()};
// Initialize HTTPClient SSL context
HTTPClient::initializeSSLContext("", "", false, j);
@@ -193,9 +215,9 @@ runHTTPTest(
[&](boost::system::error_code const& ec,
int status,
std::string const& data) -> bool {
result_error = ec;
result_status = status;
result_data = data;
resultError = ec;
resultStatus = status;
resultData = data;
completed = true;
return false; // don't retry
},
@@ -203,13 +225,19 @@ runHTTPTest(
// Run the IO context until completion
auto start = std::chrono::steady_clock::now();
while (!completed &&
std::chrono::steady_clock::now() - start < std::chrono::seconds(10))
while (server.ioc().run_one() != 0)
{
if (server.ioc().run_one() == 0)
if (std::chrono::steady_clock::now() - start >=
std::chrono::seconds(10) ||
server.finished())
{
break;
}
if (completed)
{
server.stop();
}
}
return completed;
@@ -220,7 +248,7 @@ runHTTPTest(
TEST(HTTPClient, case_insensitive_content_length)
{
// Test different cases of Content-Length header
std::vector<std::string> header_cases = {
std::vector<std::string> headerCases = {
"Content-Length", // Standard case
"content-length", // Lowercase - this tests the regex icase fix
"CONTENT-LENGTH", // Uppercase
@@ -228,53 +256,48 @@ TEST(HTTPClient, case_insensitive_content_length)
"content-Length" // Mixed case 2
};
for (auto const& header_name : header_cases)
for (auto const& headerName : headerCases)
{
TestHTTPServer server;
std::string test_body = "Hello World!";
server.setResponseBody(test_body);
server.setHeader(header_name, std::to_string(test_body.size()));
std::string testBody = "Hello World!";
server.setResponseBody(testBody);
server.setHeader(headerName, std::to_string(testBody.size()));
std::atomic<bool> completed{false};
std::atomic<int> result_status{0};
std::string result_data;
boost::system::error_code result_error;
bool completed{false};
int resultStatus{0};
std::string resultData;
boost::system::error_code resultError;
bool test_completed = runHTTPTest(
server,
"/test",
completed,
result_status,
result_data,
result_error);
bool testCompleted = runHTTPTest(
server, "/test", completed, resultStatus, resultData, resultError);
// Verify results
EXPECT_TRUE(test_completed);
EXPECT_FALSE(result_error);
EXPECT_EQ(result_status, 200);
EXPECT_EQ(result_data, test_body);
EXPECT_TRUE(testCompleted);
EXPECT_FALSE(resultError);
EXPECT_EQ(resultStatus, 200);
EXPECT_EQ(resultData, testBody);
}
}
TEST(HTTPClient, basic_http_request)
{
TestHTTPServer server;
std::string test_body = "Test response body";
server.setResponseBody(test_body);
std::string testBody = "Test response body";
server.setResponseBody(testBody);
server.setHeader("Content-Type", "text/plain");
std::atomic<bool> completed{false};
std::atomic<int> result_status{0};
std::string result_data;
boost::system::error_code result_error;
bool completed{false};
int resultStatus{0};
std::string resultData;
boost::system::error_code resultError;
bool test_completed = runHTTPTest(
server, "/basic", completed, result_status, result_data, result_error);
bool testCompleted = runHTTPTest(
server, "/basic", completed, resultStatus, resultData, resultError);
EXPECT_TRUE(test_completed);
EXPECT_FALSE(result_error);
EXPECT_EQ(result_status, 200);
EXPECT_EQ(result_data, test_body);
EXPECT_TRUE(testCompleted);
EXPECT_FALSE(resultError);
EXPECT_EQ(resultStatus, 200);
EXPECT_EQ(resultData, testBody);
}
TEST(HTTPClient, empty_response)
@@ -283,45 +306,45 @@ TEST(HTTPClient, empty_response)
server.setResponseBody(""); // Empty body
server.setHeader("Content-Length", "0");
std::atomic<bool> completed{false};
std::atomic<int> result_status{0};
std::string result_data;
boost::system::error_code result_error;
bool completed{false};
int resultStatus{0};
std::string resultData;
boost::system::error_code resultError;
bool test_completed = runHTTPTest(
server, "/empty", completed, result_status, result_data, result_error);
bool testCompleted = runHTTPTest(
server, "/empty", completed, resultStatus, resultData, resultError);
EXPECT_TRUE(test_completed);
EXPECT_FALSE(result_error);
EXPECT_EQ(result_status, 200);
EXPECT_TRUE(result_data.empty());
EXPECT_TRUE(testCompleted);
EXPECT_FALSE(resultError);
EXPECT_EQ(resultStatus, 200);
EXPECT_TRUE(resultData.empty());
}
TEST(HTTPClient, different_status_codes)
{
std::vector<unsigned int> status_codes = {200, 404, 500};
std::vector<unsigned int> statusCodes = {200, 404, 500};
for (auto status : status_codes)
for (auto status : statusCodes)
{
TestHTTPServer server;
server.setStatusCode(status);
server.setResponseBody("Status " + std::to_string(status));
std::atomic<bool> completed{false};
std::atomic<int> result_status{0};
std::string result_data;
boost::system::error_code result_error;
bool completed{false};
int resultStatus{0};
std::string resultData;
boost::system::error_code resultError;
bool test_completed = runHTTPTest(
bool testCompleted = runHTTPTest(
server,
"/status",
completed,
result_status,
result_data,
result_error);
resultStatus,
resultData,
resultError);
EXPECT_TRUE(test_completed);
EXPECT_FALSE(result_error);
EXPECT_EQ(result_status, static_cast<int>(status));
EXPECT_TRUE(testCompleted);
EXPECT_FALSE(resultError);
EXPECT_EQ(resultStatus, static_cast<int>(status));
}
}