aboutsummaryrefslogtreecommitdiff
path: root/src/zenhttp/httpclient_test.cpp
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-10 17:27:26 +0100
committerGitHub Enterprise <[email protected]>2026-03-10 17:27:26 +0100
commitd0a07e555577dcd4a8f55f1b45d9e8e4e6366ab7 (patch)
tree2dfe1e3e0b620043d358e0b7f8bdf8320d985491 /src/zenhttp/httpclient_test.cpp
parentchangelog entry which was inadvertently omitted from PR merge (diff)
downloadzen-d0a07e555577dcd4a8f55f1b45d9e8e4e6366ab7.tar.xz
zen-d0a07e555577dcd4a8f55f1b45d9e8e4e6366ab7.zip
HttpClient using libcurl, Unix Sockets for HTTP. HTTPS support (#770)
The main goal of this change is to eliminate the cpr back-end altogether and replace it with the curl implementation. I would expect to drop cpr as soon as we feel happy with the libcurl back-end. That would leave us with a direct dependency on libcurl only, and cpr can be eliminated as a dependency. ### HttpClient Backend Overhaul - Implemented a new **libcurl-based HttpClient** backend (`httpclientcurl.cpp`, ~2000 lines) as an alternative to the cpr-based one - Made HttpClient backend **configurable at runtime** via constructor arguments and `-httpclient=...` CLI option (for zen, zenserver, and tests) - Extended HttpClient test suite to cover multipart/content-range scenarios ### Unix Domain Socket Support - Added Unix domain socket support to **httpasio** (server side) - Added Unix domain socket support to **HttpClient** - Added Unix domain socket support to **HttpWsClient** (WebSocket client) - Templatized `HttpServerConnectionT<SocketType>` and `WsAsioConnectionT<SocketType>` to handle TCP, Unix, and SSL sockets uniformly via `if constexpr` dispatch ### HTTPS Support - Added **preliminary HTTPS support to httpasio** (for Mac/Linux via OpenSSL) - Added **basic HTTPS support for http.sys** (Windows) - Implemented HTTPS test for httpasio - Split `InitializeServer` into smaller sub-functions for http.sys ### Other Notable Changes - Improved **zenhttp-test stability** with dynamic port allocation - Enhanced port retry logic in http.sys (handles ERROR_ACCESS_DENIED) - Fatal signal/exception handlers for backtrace generation in tests - Added `zen bench http` subcommand to exercise network + HTTP client/server communication stack
Diffstat (limited to 'src/zenhttp/httpclient_test.cpp')
-rw-r--r--src/zenhttp/httpclient_test.cpp299
1 files changed, 295 insertions, 4 deletions
diff --git a/src/zenhttp/httpclient_test.cpp b/src/zenhttp/httpclient_test.cpp
index 52bf149a7..2d949c546 100644
--- a/src/zenhttp/httpclient_test.cpp
+++ b/src/zenhttp/httpclient_test.cpp
@@ -8,6 +8,7 @@
# include <zencore/compactbinarybuilder.h>
# include <zencore/compactbinaryutil.h>
# include <zencore/compositebuffer.h>
+# include <zencore/filesystem.h>
# include <zencore/iobuffer.h>
# include <zencore/logging.h>
# include <zencore/scopeguard.h>
@@ -232,7 +233,7 @@ struct TestServerFixture
TestServerFixture()
{
Server = CreateHttpAsioServer(AsioConfig{});
- Port = Server->Initialize(7600, TmpDir.Path());
+ Port = Server->Initialize(0, TmpDir.Path());
ZEN_ASSERT(Port != -1);
Server->RegisterService(TestService);
ServerThread = std::thread([this]() { Server->Run(false); });
@@ -1044,13 +1045,22 @@ struct FaultTcpServer
{
m_Port = m_Acceptor.local_endpoint().port();
StartAccept();
- m_Thread = std::thread([this]() { m_IoContext.run(); });
+ m_Thread = std::thread([this]() {
+ try
+ {
+ m_IoContext.run();
+ }
+ catch (...)
+ {
+ }
+ });
}
~FaultTcpServer()
{
- std::error_code Ec;
- m_Acceptor.close(Ec);
+ // io_context::stop() is thread-safe; do NOT call m_Acceptor.close() from this
+ // thread — ASIO I/O objects are not safe for concurrent access and the io_context
+ // thread may be touching the acceptor in StartAccept().
m_IoContext.stop();
if (m_Thread.joinable())
{
@@ -1081,6 +1091,105 @@ struct FaultTcpServer
}
};
+TEST_CASE("httpclient.range-response")
+{
+ ScopedTemporaryDirectory DownloadDir;
+
+ SUBCASE("single range 206 response populates Ranges")
+ {
+ std::string RangeBody(100, 'A');
+
+ FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Response = fmt::format(
+ "HTTP/1.1 206 Partial Content\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Range: bytes 200-299/1000\r\n"
+ "Content-Length: {}\r\n"
+ "\r\n"
+ "{}",
+ RangeBody.size(),
+ RangeBody);
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ });
+
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path());
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::PartialContent);
+ REQUIRE(Resp.Ranges.size() == 1);
+ CHECK_EQ(Resp.Ranges[0].RangeOffset, 200);
+ CHECK_EQ(Resp.Ranges[0].RangeLength, 100);
+ }
+
+ SUBCASE("multipart byteranges 206 response populates Ranges")
+ {
+ std::string Part1Data(16, 'X');
+ std::string Part2Data(12, 'Y');
+ std::string Boundary = "testboundary123";
+
+ std::string MultipartBody = fmt::format(
+ "\r\n--{}\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Range: bytes 100-115/1000\r\n"
+ "\r\n"
+ "{}"
+ "\r\n--{}\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Range: bytes 500-511/1000\r\n"
+ "\r\n"
+ "{}"
+ "\r\n--{}--",
+ Boundary,
+ Part1Data,
+ Boundary,
+ Part2Data,
+ Boundary);
+
+ FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Response = fmt::format(
+ "HTTP/1.1 206 Partial Content\r\n"
+ "Content-Type: multipart/byteranges; boundary={}\r\n"
+ "Content-Length: {}\r\n"
+ "\r\n"
+ "{}",
+ Boundary,
+ MultipartBody.size(),
+ MultipartBody);
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ });
+
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path());
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::PartialContent);
+ REQUIRE(Resp.Ranges.size() == 2);
+ // Ranges should be sorted by RangeOffset
+ CHECK_EQ(Resp.Ranges[0].RangeOffset, 100);
+ CHECK_EQ(Resp.Ranges[0].RangeLength, 16);
+ CHECK_EQ(Resp.Ranges[1].RangeOffset, 500);
+ CHECK_EQ(Resp.Ranges[1].RangeLength, 12);
+ }
+
+ SUBCASE("non-range 200 response has empty Ranges")
+ {
+ FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Response = MakeRawHttpResponse(200, "full content");
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ });
+
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path());
+ CHECK(Resp.IsSuccess());
+ CHECK(Resp.Ranges.empty());
+ }
+}
+
TEST_CASE("httpclient.transport-faults" * doctest::skip())
{
SUBCASE("connection reset before response")
@@ -1354,6 +1463,188 @@ TEST_CASE("httpclient.transport-faults-post" * doctest::skip())
}
}
+TEST_CASE("httpclient.unixsocket")
+{
+ ScopedTemporaryDirectory TmpDir;
+ std::string SocketPath = (TmpDir.Path() / "zen.sock").string();
+
+ HttpClientTestService TestService;
+
+ Ref<HttpServer> Server = CreateHttpAsioServer(AsioConfig{.UnixSocketPath = SocketPath});
+
+ int Port = Server->Initialize(0, TmpDir.Path());
+ REQUIRE(Port != -1);
+
+ Server->RegisterService(TestService);
+
+ std::thread ServerThread([&]() { Server->Run(false); });
+
+ auto _ = MakeGuard([&]() {
+ Server->RequestExit();
+ if (ServerThread.joinable())
+ {
+ ServerThread.join();
+ }
+ Server->Close();
+ });
+
+ HttpClientSettings Settings;
+ Settings.UnixSocketPath = SocketPath;
+
+ HttpClient Client("localhost", Settings, /*CheckIfAbortFunction*/ {});
+
+ SUBCASE("GET over unix socket")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ SUBCASE("POST echo over unix socket")
+ {
+ const char* Payload = "unix socket payload";
+ IoBuffer Body(IoBuffer::Clone, Payload, strlen(Payload));
+ Body.SetContentType(ZenContentType::kText);
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Body);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "unix socket payload");
+ }
+}
+
+# if ZEN_USE_OPENSSL
+
+TEST_CASE("httpclient.https")
+{
+ // Self-signed test certificate for localhost/127.0.0.1, valid until 2036
+ static constexpr std::string_view TestCertPem =
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIIDJTCCAg2gAwIBAgIUEtJYMSUmJmvJ157We/qXNVJ7W8gwDQYJKoZIhvcNAQEL\n"
+ "BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDMwOTIwMjU1M1oXDTM2MDMw\n"
+ "NjIwMjU1M1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\n"
+ "AAOCAQ8AMIIBCgKCAQEAv9YvZ6WeBz3z/Zuxi6OIivWksDxDZZ5oAXKVwlUXaa7v\n"
+ "iDkm9P5ZsEhN+M5vZMe2Yb9i3cnTUaE6Avs1ddOwTAYNGrE/B5DmibrRWc23R0cv\n"
+ "gdnYQJ+gjsAeMvUWYLK58xW4YoMR5bmfpj1ruqobUNkG/oJYnAUcjgo4J149irW+\n"
+ "4n9uLJvxL+5fI/b/AIkv+4TMe70/d/BPmnixWrrzxUT6S5ghE2Mq7+XLScfpY2Sp\n"
+ "GQ/Xbnj9/ELYLpQnNLuVZwWZDpXj+FLbF1zxgjYdw1cCjbRcOIEW2/GJeJvGXQ6Y\n"
+ "Vld5pCBm9uKPPLWoFCoakK5YvP00h+8X+HghGVSscQIDAQABo28wbTAdBgNVHQ4E\n"
+ "FgQUgM6hjymi6g2EBUg2ENu0nIK8yhMwHwYDVR0jBBgwFoAUgM6hjymi6g2EBUg2\n"
+ "ENu0nIK8yhMwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh\n"
+ "bGhvc3QwDQYJKoZIhvcNAQELBQADggEBABY1oaaWwL4RaK/epKvk/IrmVT2mlAai\n"
+ "uvGLfjhc6FGvXaxPGTSUPrVbFornaWZAg7bOWCexWnEm2sWd75V/usvZAPN4aIiD\n"
+ "H66YQipq3OD4F9Gowp01IU4AcGh7MerFpYPk76+wp2ANq71x8axtlZjVn3hSFMmN\n"
+ "i6m9S/eyCl9WjYBT5ZEC4fJV0nOSmNe/+gCAm11/js9zNfXKmUchJtuZpubY3A0k\n"
+ "X2II6qYWf1PH+JJkefNZtt2c66CrEN5eAg4/rGEgsp43zcd4ZHVkpBKFLDEls1ev\n"
+ "drQ45zc4Ht77pHfnHu7YsLcRZ9Wq3COMNZYx5lItqnomX2qBm1pkwjI=\n"
+ "-----END CERTIFICATE-----\n";
+
+ static constexpr std::string_view TestKeyPem =
+ "-----BEGIN PRIVATE KEY-----\n"
+ "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/1i9npZ4HPfP9\n"
+ "m7GLo4iK9aSwPENlnmgBcpXCVRdpru+IOSb0/lmwSE34zm9kx7Zhv2LdydNRoToC\n"
+ "+zV107BMBg0asT8HkOaJutFZzbdHRy+B2dhAn6COwB4y9RZgsrnzFbhigxHluZ+m\n"
+ "PWu6qhtQ2Qb+glicBRyOCjgnXj2Ktb7if24sm/Ev7l8j9v8AiS/7hMx7vT938E+a\n"
+ "eLFauvPFRPpLmCETYyrv5ctJx+ljZKkZD9dueP38QtgulCc0u5VnBZkOleP4UtsX\n"
+ "XPGCNh3DVwKNtFw4gRbb8Yl4m8ZdDphWV3mkIGb24o88tagUKhqQrli8/TSH7xf4\n"
+ "eCEZVKxxAgMBAAECggEAILd9pDaZqfCF8SWhdQgx3Ekiii/s6qLGaCDLq7XpZUvB\n"
+ "bEEbBMNwNmFOcvV6B/0LfMYwLVUjZhOSGjoPlwXAVmbdy0SZVEgBGVI0LBWqgUyB\n"
+ "rKqjd/oBXvci71vfMiSpE+0LYjmqTryGnspw2gfy2qn4yGUgiZNRmGPjycsHweUL\n"
+ "V3FHm3cf0dyE4sJ0mjVqZzRT/unw2QOCE6FlY7M1XxZL88IWfn6G4lckdJTwoOP5\n"
+ "VPR2J3XbyhvCeXeDRCHKRXojWWR2HovWnDXQc95GRgCd0vYdHuIUM6RXVPZQvy3X\n"
+ "l0GwQKHNcVr1uwtYDgGKw0tNCUDvxdfQaWilTFuicQKBgQDvEYp+vL1hnF+AVdu3\n"
+ "elsYsHpFgExkTI8wnUMvGZrFiIQyCyVDU3jkG3kcKacI1bfwopXopaQCjrYk9epm\n"
+ "liOVm3/Xtr6e2ENa7w8TQbdK65PciQNOMxml6g8clRRBl0cwj+aI3nW/Kop1cdrR\n"
+ "A9Vo+8iPTO5gDcxTiIb45a6E3QKBgQDNbE009P6ewx9PU7Llkhb9VBgsb7oQN3EV\n"
+ "TCYd4taiN6FPnTuL/cdijAA8y04hiVT+Efo9TUN9NCl9HdHXQcjj7/n/eFLH0Pkw\n"
+ "OIK3QN49OfR88wivLMtwWxIog0tJjc9+7dR4bR4o1jTlIrasEIvUTuDJQ8MKGc9v\n"
+ "pBITua+SpQKBgE4raSKZqj7hd6Sp7kbnHiRLiB9znQbqtaNKuK4M7DuMsNUAKfYC\n"
+ "tDO5+/bGc9SCtTtcnjHM/3zKlyossrFKhGYlyz6IhXnA8v0nz8EXKsy3jMh+kHMg\n"
+ "aFGE394TrOTphyCM3O+B9fRE/7L5QHg5ja1fLqwUlpkXyejCaoe16kONAoGAYIz9\n"
+ "wN1B67cEOVG6rOI8QfdLoV8mEcctNHhlFfjvLrF89SGOwl6WX0A0QF7CK0sUEpK6\n"
+ "jiOJjAh/U5o3bbgyxsedNjEEn3weE0cMUTuA+UALJMtKEqO4PuffIgGL2ld35k28\n"
+ "ZpnK6iC8HdJyD297eV9VkeNygYXeFLgF8xV8ay0CgYEAh4fmVZt9YhgVByYny2kF\n"
+ "ZUIkGF5h9wxzVOPpQwpizIGFFb3i/ZdGQcuLTfIBVRKf50sT3IwJe65ATv6+Lz0f\n"
+ "wg/pMvosi0/F5KGbVRVdzBMQy58WyyGti4tNl+8EXGvo8+DCmjlTYwfjRoZGg/qJ\n"
+ "EMP3/hTN7dHDRxPK8E0Fh0Y=\n"
+ "-----END PRIVATE KEY-----\n";
+
+ ScopedTemporaryDirectory TmpDir;
+
+ // Write cert and key to temp files
+ const auto CertPath = TmpDir.Path() / "test.crt";
+ const auto KeyPath = TmpDir.Path() / "test.key";
+ WriteFile(CertPath, IoBuffer(IoBuffer::Clone, TestCertPem.data(), TestCertPem.size()));
+ WriteFile(KeyPath, IoBuffer(IoBuffer::Clone, TestKeyPem.data(), TestKeyPem.size()));
+
+ HttpClientTestService TestService;
+
+ AsioConfig Config;
+ Config.CertFile = CertPath.string();
+ Config.KeyFile = KeyPath.string();
+
+ Ref<HttpServer> Server = CreateHttpAsioServer(Config);
+
+ int Port = Server->Initialize(0, TmpDir.Path());
+ REQUIRE(Port != -1);
+
+ Server->RegisterService(TestService);
+
+ std::thread ServerThread([&]() { Server->Run(false); });
+
+ auto _ = MakeGuard([&]() {
+ Server->RequestExit();
+ if (ServerThread.joinable())
+ {
+ ServerThread.join();
+ }
+ Server->Close();
+ });
+
+ int HttpsPort = Server->GetEffectiveHttpsPort();
+ REQUIRE(HttpsPort > 0);
+
+ HttpClientSettings Settings;
+ Settings.InsecureSsl = true;
+
+ HttpClient Client(fmt::format("https://127.0.0.1:{}", HttpsPort), Settings, /*CheckIfAbortFunction*/ {});
+
+ SUBCASE("GET over HTTPS")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ SUBCASE("POST echo over HTTPS")
+ {
+ const char* Payload = "https payload";
+ IoBuffer Body(IoBuffer::Clone, Payload, strlen(Payload));
+ Body.SetContentType(ZenContentType::kText);
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Body);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "https payload");
+ }
+
+ SUBCASE("GET JSON over HTTPS")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/json");
+ CHECK(Resp.IsSuccess());
+ CbObject Obj = Resp.AsObject();
+ CHECK_EQ(Obj["ok"].AsBool(), true);
+ CHECK_EQ(Obj["message"].AsString(), "test");
+ }
+
+ SUBCASE("Large payload over HTTPS")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/large");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u);
+ }
+}
+
+# endif // ZEN_USE_OPENSSL
+
TEST_SUITE_END();
void