// Copyright Epic Games, Inc. All Rights Reserved. #include #include #if ZEN_WITH_TESTS # include # include # include # include # include # include # include # include # include # include "servers/httpasio.h" # include # include ZEN_THIRD_PARTY_INCLUDES_START # include ZEN_THIRD_PARTY_INCLUDES_END namespace zen { using namespace std::literals; ////////////////////////////////////////////////////////////////////////// // Test service class HttpClientTestService : public HttpService { public: HttpClientTestService() { m_Router.AddMatcher("statuscode", [](std::string_view Str) -> bool { for (char C : Str) { if (C < '0' || C > '9') { return false; } } return !Str.empty(); }); m_Router.RegisterRoute( "hello", [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "hello world"); }, HttpVerb::kGet); m_Router.RegisterRoute( "echo", [](HttpRouterRequest& Req) { HttpServerRequest& HttpReq = Req.ServerRequest(); IoBuffer Body = HttpReq.ReadPayload(); HttpContentType CT = HttpReq.RequestContentType(); HttpReq.WriteResponse(HttpResponseCode::OK, CT, Body); }, HttpVerb::kPost | HttpVerb::kPut); m_Router.RegisterRoute( "echo/headers", [](HttpRouterRequest& Req) { HttpServerRequest& HttpReq = Req.ServerRequest(); std::string_view Auth = HttpReq.GetAuthorizationHeader(); CbObjectWriter Writer; if (!Auth.empty()) { Writer.AddString("Authorization", Auth); } HttpReq.WriteResponse(HttpResponseCode::OK, Writer.Save()); }, HttpVerb::kGet | HttpVerb::kPost); m_Router.RegisterRoute( "echo/method", [](HttpRouterRequest& Req) { HttpServerRequest& HttpReq = Req.ServerRequest(); std::string_view Method = ToString(HttpReq.RequestVerb()); HttpReq.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, Method); }, HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete | HttpVerb::kHead); m_Router.RegisterRoute( "json", [](HttpRouterRequest& Req) { CbObjectWriter Obj; Obj.AddBool("ok", true); Obj.AddString("message", "test"); Req.ServerRequest().WriteResponse(HttpResponseCode::OK, Obj.Save()); }, HttpVerb::kGet); m_Router.RegisterRoute( "nocontent", [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::NoContent); }, HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete); m_Router.RegisterRoute( "created", [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::Created, HttpContentType::kText, "resource created"); }, HttpVerb::kPost | HttpVerb::kPut); m_Router.RegisterRoute( "content-type/text", [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "plain text"); }, HttpVerb::kGet); m_Router.RegisterRoute( "content-type/json", [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kJSON, "{\"key\":\"value\"}"); }, HttpVerb::kGet); m_Router.RegisterRoute( "content-type/binary", [](HttpRouterRequest& Req) { uint8_t Data[] = {0xDE, 0xAD, 0xBE, 0xEF}; IoBuffer Buf(IoBuffer::Clone, Data, sizeof(Data)); Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kBinary, Buf); }, HttpVerb::kGet); m_Router.RegisterRoute( "content-type/cbobject", [](HttpRouterRequest& Req) { CbObjectWriter Obj; Obj.AddString("type", "cbobject"); Req.ServerRequest().WriteResponse(HttpResponseCode::OK, Obj.Save()); }, HttpVerb::kGet); m_Router.RegisterRoute( "auth/bearer", [](HttpRouterRequest& Req) { HttpServerRequest& HttpReq = Req.ServerRequest(); std::string_view Auth = HttpReq.GetAuthorizationHeader(); if (Auth.starts_with("Bearer ") && Auth.size() > 7) { HttpReq.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "authenticated"); } else { HttpReq.WriteResponse(HttpResponseCode::Unauthorized, HttpContentType::kText, "unauthorized"); } }, HttpVerb::kGet); m_Router.RegisterRoute( "slow", [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponseAsync([](HttpServerRequest& Request) { Sleep(2000); Request.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "slow response"); }); }, HttpVerb::kGet); m_Router.RegisterRoute( "large", [](HttpRouterRequest& Req) { constexpr size_t Size = 64 * 1024; IoBuffer Buf(Size); uint8_t* Ptr = static_cast(Buf.MutableData()); for (size_t i = 0; i < Size; ++i) { Ptr[i] = static_cast(i & 0xFF); } Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kBinary, Buf); }, HttpVerb::kGet); m_Router.RegisterRoute( "status/{statuscode}", [](HttpRouterRequest& Req) { std::string_view CodeStr = Req.GetCapture(1); int Code = std::stoi(std::string{CodeStr}); const HttpResponseCode ResponseCode = static_cast(Code); Req.ServerRequest().WriteResponse(ResponseCode); }, HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete | HttpVerb::kHead); m_Router.RegisterRoute( "attempt-counter", [this](HttpRouterRequest& Req) { uint32_t Count = m_AttemptCounter.fetch_add(1); if (Count < m_FailCount) { Req.ServerRequest().WriteResponse(HttpResponseCode::ServiceUnavailable); } else { Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "success after retries"); } }, HttpVerb::kGet); } virtual const char* BaseUri() const override { return "/api/test/"; } virtual void HandleRequest(HttpServerRequest& Request) override { m_Router.HandleRequest(Request); } void ResetAttemptCounter(uint32_t FailCount) { m_AttemptCounter.store(0); m_FailCount = FailCount; } private: HttpRequestRouter m_Router; std::atomic m_AttemptCounter{0}; uint32_t m_FailCount = 2; }; ////////////////////////////////////////////////////////////////////////// // Test server fixture struct TestServerFixture { HttpClientTestService TestService; ScopedTemporaryDirectory TmpDir; Ref Server; std::thread ServerThread; int Port = -1; TestServerFixture() { Server = CreateHttpAsioServer(AsioConfig{}); Port = Server->Initialize(7600, TmpDir.Path()); ZEN_ASSERT(Port != -1); Server->RegisterService(TestService); ServerThread = std::thread([this]() { Server->Run(false); }); } ~TestServerFixture() { Server->RequestExit(); if (ServerThread.joinable()) { ServerThread.join(); } Server->Close(); } HttpClient MakeClient(HttpClientSettings Settings = {}) { return HttpClient(fmt::format("127.0.0.1:{}", Port), Settings, /*CheckIfAbortFunction*/ {}); } }; ////////////////////////////////////////////////////////////////////////// // Tests TEST_CASE("httpclient.verbs") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("GET returns 200 with expected body") { HttpClient::Response Resp = Client.Get("/api/test/echo/method"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "GET"); } SUBCASE("POST dispatches correctly") { HttpClient::Response Resp = Client.Post("/api/test/echo/method"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "POST"); } SUBCASE("PUT dispatches correctly") { HttpClient::Response Resp = Client.Put("/api/test/echo/method"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "PUT"); } SUBCASE("DELETE dispatches correctly") { HttpClient::Response Resp = Client.Delete("/api/test/echo/method"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "DELETE"); } SUBCASE("HEAD returns 200 with empty body") { HttpClient::Response Resp = Client.Head("/api/test/echo/method"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), ""sv); } } TEST_CASE("httpclient.get") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("simple GET with text response") { HttpClient::Response Resp = Client.Get("/api/test/hello"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.StatusCode, HttpResponseCode::OK); CHECK_EQ(Resp.AsText(), "hello world"); } SUBCASE("GET with auth header via echo") { HttpClient::Response Resp = Client.Get("/api/test/echo/headers", std::pair("Authorization", "Bearer test-token-123")); CHECK(Resp.IsSuccess()); CbObject Obj = Resp.AsObject(); CHECK_EQ(Obj["Authorization"].AsString(), "Bearer test-token-123"); } SUBCASE("GET returning CbObject") { HttpClient::Response Resp = Client.Get("/api/test/json"); CHECK(Resp.IsSuccess()); CbObject Obj = Resp.AsObject(); CHECK(Obj["ok"].AsBool() == true); CHECK_EQ(Obj["message"].AsString(), "test"); } SUBCASE("GET large payload") { HttpClient::Response Resp = Client.Get("/api/test/large"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u); const uint8_t* Data = static_cast(Resp.ResponsePayload.GetData()); bool Valid = true; for (size_t i = 0; i < 64 * 1024; ++i) { if (Data[i] != static_cast(i & 0xFF)) { Valid = false; break; } } CHECK(Valid); } } TEST_CASE("httpclient.post") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("POST with IoBuffer payload echo round-trip") { const char* Payload = "test payload data"; IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload)); Buf.SetContentType(ZenContentType::kText); HttpClient::Response Resp = Client.Post("/api/test/echo", Buf); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "test payload data"); } SUBCASE("POST with IoBuffer and explicit content type") { const char* Payload = "{\"key\":\"value\"}"; IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload)); HttpClient::Response Resp = Client.Post("/api/test/echo", Buf, ZenContentType::kJSON); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "{\"key\":\"value\"}"); } SUBCASE("POST with CbObject payload round-trip") { CbObjectWriter Writer; Writer.AddBool("enabled", true); Writer.AddString("name", "testobj"); CbObject Obj = Writer.Save(); HttpClient::Response Resp = Client.Post("/api/test/echo", Obj); CHECK(Resp.IsSuccess()); CbObject RoundTripped = Resp.AsObject(); CHECK(RoundTripped["enabled"].AsBool() == true); CHECK_EQ(RoundTripped["name"].AsString(), "testobj"); } SUBCASE("POST with CompositeBuffer payload") { const char* Part1 = "hello "; const char* Part2 = "composite"; IoBuffer Buf1(IoBuffer::Clone, Part1, strlen(Part1)); IoBuffer Buf2(IoBuffer::Clone, Part2, strlen(Part2)); SharedBuffer Seg1{Buf1}; SharedBuffer Seg2{Buf2}; CompositeBuffer Composite{std::move(Seg1), std::move(Seg2)}; HttpClient::Response Resp = Client.Post("/api/test/echo", Composite, ZenContentType::kText); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "hello composite"); } SUBCASE("POST with custom headers") { HttpClient::Response Resp = Client.Post("/api/test/echo/headers", HttpClient::KeyValueMap{}, HttpClient::KeyValueMap{}); CHECK(Resp.IsSuccess()); } SUBCASE("POST with empty body to nocontent endpoint") { HttpClient::Response Resp = Client.Post("/api/test/nocontent"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.StatusCode, HttpResponseCode::NoContent); } } TEST_CASE("httpclient.put") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("PUT with IoBuffer payload echo round-trip") { const char* Payload = "put payload data"; IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload)); Buf.SetContentType(ZenContentType::kText); HttpClient::Response Resp = Client.Put("/api/test/echo", Buf); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "put payload data"); } SUBCASE("PUT with parameters only") { HttpClient::Response Resp = Client.Put("/api/test/nocontent"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.StatusCode, HttpResponseCode::NoContent); } SUBCASE("PUT to created endpoint") { const char* Payload = "new resource"; IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload)); Buf.SetContentType(ZenContentType::kText); HttpClient::Response Resp = Client.Put("/api/test/created", Buf); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.StatusCode, HttpResponseCode::Created); CHECK_EQ(Resp.AsText(), "resource created"); } } TEST_CASE("httpclient.upload") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("Upload IoBuffer") { constexpr size_t Size = 128 * 1024; IoBuffer Blob = CreateSemiRandomBlob(Size); HttpClient::Response Resp = Client.Upload("/api/test/echo", Blob); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.ResponsePayload.GetSize(), Size); } SUBCASE("Upload CompositeBuffer") { IoBuffer Buf1 = CreateSemiRandomBlob(32 * 1024); IoBuffer Buf2 = CreateSemiRandomBlob(32 * 1024); SharedBuffer Seg1{Buf1}; SharedBuffer Seg2{Buf2}; CompositeBuffer Composite{std::move(Seg1), std::move(Seg2)}; HttpClient::Response Resp = Client.Upload("/api/test/echo", Composite, ZenContentType::kBinary); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u); } } TEST_CASE("httpclient.download") { TestServerFixture Fixture; ScopedTemporaryDirectory DownloadDir; SUBCASE("Download small payload stays in memory") { HttpClient Client = Fixture.MakeClient(); HttpClient::Response Resp = Client.Download("/api/test/hello", DownloadDir.Path()); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "hello world"); } SUBCASE("Download with reduced MaximumInMemoryDownloadSize forces file spill") { HttpClientSettings Settings; Settings.MaximumInMemoryDownloadSize = 4; HttpClient Client = Fixture.MakeClient(Settings); HttpClient::Response Resp = Client.Download("/api/test/large", DownloadDir.Path()); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u); } } TEST_CASE("httpclient.status-codes") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("2xx are success") { CHECK(Client.Get("/api/test/status/200").IsSuccess()); CHECK(Client.Get("/api/test/status/201").IsSuccess()); CHECK(Client.Get("/api/test/status/204").IsSuccess()); } SUBCASE("4xx are not success") { CHECK(!Client.Get("/api/test/status/400").IsSuccess()); CHECK(!Client.Get("/api/test/status/401").IsSuccess()); CHECK(!Client.Get("/api/test/status/403").IsSuccess()); CHECK(!Client.Get("/api/test/status/404").IsSuccess()); CHECK(!Client.Get("/api/test/status/409").IsSuccess()); } SUBCASE("5xx are not success") { CHECK(!Client.Get("/api/test/status/500").IsSuccess()); CHECK(!Client.Get("/api/test/status/502").IsSuccess()); CHECK(!Client.Get("/api/test/status/503").IsSuccess()); } SUBCASE("status code values match") { CHECK_EQ(Client.Get("/api/test/status/200").StatusCode, HttpResponseCode::OK); CHECK_EQ(Client.Get("/api/test/status/201").StatusCode, HttpResponseCode::Created); CHECK_EQ(Client.Get("/api/test/status/204").StatusCode, HttpResponseCode::NoContent); CHECK_EQ(Client.Get("/api/test/status/400").StatusCode, HttpResponseCode::BadRequest); CHECK_EQ(Client.Get("/api/test/status/401").StatusCode, HttpResponseCode::Unauthorized); CHECK_EQ(Client.Get("/api/test/status/403").StatusCode, HttpResponseCode::Forbidden); CHECK_EQ(Client.Get("/api/test/status/404").StatusCode, HttpResponseCode::NotFound); CHECK_EQ(Client.Get("/api/test/status/409").StatusCode, HttpResponseCode::Conflict); CHECK_EQ(Client.Get("/api/test/status/500").StatusCode, HttpResponseCode::InternalServerError); CHECK_EQ(Client.Get("/api/test/status/502").StatusCode, HttpResponseCode::BadGateway); CHECK_EQ(Client.Get("/api/test/status/503").StatusCode, HttpResponseCode::ServiceUnavailable); } } TEST_CASE("httpclient.response") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("IsSuccess and operator bool for success") { HttpClient::Response Resp = Client.Get("/api/test/hello"); CHECK(Resp.IsSuccess()); CHECK(static_cast(Resp)); } SUBCASE("IsSuccess and operator bool for failure") { HttpClient::Response Resp = Client.Get("/api/test/status/404"); CHECK(!Resp.IsSuccess()); CHECK(!static_cast(Resp)); } SUBCASE("AsText returns body") { HttpClient::Response Resp = Client.Get("/api/test/hello"); CHECK_EQ(Resp.AsText(), "hello world"); } SUBCASE("AsText returns empty for no-content") { HttpClient::Response Resp = Client.Get("/api/test/nocontent"); CHECK(Resp.AsText().empty()); } SUBCASE("AsObject parses CbObject") { HttpClient::Response Resp = Client.Get("/api/test/json"); CbObject Obj = Resp.AsObject(); CHECK(Obj["ok"].AsBool() == true); CHECK_EQ(Obj["message"].AsString(), "test"); } SUBCASE("AsObject returns empty for non-CB content") { HttpClient::Response Resp = Client.Get("/api/test/hello"); CbObject Obj = Resp.AsObject(); CHECK(!Obj); } SUBCASE("ToText for text content") { HttpClient::Response Resp = Client.Get("/api/test/content-type/text"); CHECK_EQ(Resp.ToText(), "plain text"); } SUBCASE("ToText for CbObject content") { HttpClient::Response Resp = Client.Get("/api/test/json"); std::string Text = Resp.ToText(); CHECK(!Text.empty()); // ToText for CbObject converts to JSON string representation CHECK(Text.find("ok") != std::string::npos); CHECK(Text.find("test") != std::string::npos); } SUBCASE("ErrorMessage includes status code on failure") { HttpClient::Response Resp = Client.Get("/api/test/status/404"); std::string Msg = Resp.ErrorMessage("test-prefix"); CHECK(Msg.find("test-prefix") != std::string::npos); CHECK(Msg.find("404") != std::string::npos); } SUBCASE("ThrowError throws on failure") { HttpClient::Response Resp = Client.Get("/api/test/status/500"); CHECK_THROWS_AS(Resp.ThrowError("test"), HttpClientError); } SUBCASE("ThrowError does not throw on success") { HttpClient::Response Resp = Client.Get("/api/test/hello"); CHECK_NOTHROW(Resp.ThrowError("test")); } SUBCASE("HttpClientError carries response code") { HttpClient::Response Resp = Client.Get("/api/test/status/403"); try { Resp.ThrowError("test"); CHECK(false); // should not reach } catch (const HttpClientError& Err) { CHECK_EQ(Err.GetHttpResponseCode(), HttpResponseCode::Forbidden); } } } TEST_CASE("httpclient.error-handling") { SUBCASE("Connection refused") { HttpClient Client("127.0.0.1:19999", HttpClientSettings{}, /*CheckIfAbortFunction*/ {}); HttpClient::Response Resp = Client.Get("/api/test/hello"); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); } SUBCASE("Request timeout") { TestServerFixture Fixture; HttpClientSettings Settings; Settings.Timeout = std::chrono::milliseconds(500); HttpClient Client = Fixture.MakeClient(Settings); HttpClient::Response Resp = Client.Get("/api/test/slow"); CHECK(!Resp.IsSuccess()); } SUBCASE("Nonexistent endpoint returns failure") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); HttpClient::Response Resp = Client.Get("/api/test/does-not-exist"); CHECK(!Resp.IsSuccess()); } } TEST_CASE("httpclient.session") { TestServerFixture Fixture; SUBCASE("Default session ID is non-empty") { HttpClient Client = Fixture.MakeClient(); CHECK(!Client.GetSessionId().empty()); } SUBCASE("SetSessionId changes ID") { HttpClient Client = Fixture.MakeClient(); Oid NewId = Oid::NewOid(); std::string OldId = std::string(Client.GetSessionId()); Client.SetSessionId(NewId); CHECK_EQ(Client.GetSessionId(), NewId.ToString()); CHECK_NE(Client.GetSessionId(), OldId); } SUBCASE("SetSessionId with Zero resets") { HttpClient Client = Fixture.MakeClient(); Oid NewId = Oid::NewOid(); Client.SetSessionId(NewId); CHECK_EQ(Client.GetSessionId(), NewId.ToString()); Client.SetSessionId(Oid::Zero); // After resetting, should get a session string (not empty, not the custom one) CHECK(!Client.GetSessionId().empty()); CHECK_NE(Client.GetSessionId(), NewId.ToString()); } } TEST_CASE("httpclient.authentication") { TestServerFixture Fixture; SUBCASE("Authenticate returns false without provider") { HttpClient Client = Fixture.MakeClient(); CHECK(!Client.Authenticate()); } SUBCASE("Authenticate returns true with valid token") { HttpClientSettings Settings; Settings.AccessTokenProvider = []() -> HttpClientAccessToken { return HttpClientAccessToken{ .Value = "valid-token", .ExpireTime = HttpClientAccessToken::Clock::now() + std::chrono::hours(1), }; }; HttpClient Client = Fixture.MakeClient(Settings); CHECK(Client.Authenticate()); } SUBCASE("Authenticate returns false with expired token") { HttpClientSettings Settings; Settings.AccessTokenProvider = []() -> HttpClientAccessToken { return HttpClientAccessToken{ .Value = "expired-token", .ExpireTime = HttpClientAccessToken::Clock::now() - std::chrono::hours(1), }; }; HttpClient Client = Fixture.MakeClient(Settings); CHECK(!Client.Authenticate()); } SUBCASE("Bearer token verified by auth endpoint") { HttpClient Client = Fixture.MakeClient(); HttpClient::Response AuthResp = Client.Get("/api/test/auth/bearer", std::pair("Authorization", "Bearer my-secret-token")); CHECK(AuthResp.IsSuccess()); CHECK_EQ(AuthResp.AsText(), "authenticated"); } SUBCASE("Request without token to auth endpoint gets 401") { HttpClient Client = Fixture.MakeClient(); HttpClient::Response Resp = Client.Get("/api/test/auth/bearer"); CHECK(!Resp.IsSuccess()); CHECK_EQ(Resp.StatusCode, HttpResponseCode::Unauthorized); } } TEST_CASE("httpclient.content-types") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("text content type") { HttpClient::Response Resp = Client.Get("/api/test/content-type/text"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.ResponsePayload.GetContentType(), ZenContentType::kText); } SUBCASE("JSON content type") { HttpClient::Response Resp = Client.Get("/api/test/content-type/json"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.ResponsePayload.GetContentType(), ZenContentType::kJSON); } SUBCASE("binary content type") { HttpClient::Response Resp = Client.Get("/api/test/content-type/binary"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.ResponsePayload.GetContentType(), ZenContentType::kBinary); } SUBCASE("CbObject content type") { HttpClient::Response Resp = Client.Get("/api/test/content-type/cbobject"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.ResponsePayload.GetContentType(), ZenContentType::kCbObject); } } TEST_CASE("httpclient.metadata") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); SUBCASE("ElapsedSeconds is positive") { HttpClient::Response Resp = Client.Get("/api/test/hello"); CHECK(Resp.IsSuccess()); CHECK(Resp.ElapsedSeconds > 0.0); } SUBCASE("DownloadedBytes populated for GET") { HttpClient::Response Resp = Client.Get("/api/test/hello"); CHECK(Resp.IsSuccess()); CHECK(Resp.DownloadedBytes > 0); } SUBCASE("UploadedBytes populated for POST with payload") { const char* Payload = "some upload data"; IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload)); Buf.SetContentType(ZenContentType::kText); HttpClient::Response Resp = Client.Post("/api/test/echo", Buf); CHECK(Resp.IsSuccess()); CHECK(Resp.UploadedBytes > 0); } } TEST_CASE("httpclient.retry") { TestServerFixture Fixture; SUBCASE("Retry succeeds after transient failures") { Fixture.TestService.ResetAttemptCounter(2); HttpClientSettings Settings; Settings.RetryCount = 3; HttpClient Client = Fixture.MakeClient(Settings); HttpClient::Response Resp = Client.Get("/api/test/attempt-counter"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "success after retries"); } SUBCASE("No retry returns 503 immediately") { Fixture.TestService.ResetAttemptCounter(2); HttpClientSettings Settings; Settings.RetryCount = 0; HttpClient Client = Fixture.MakeClient(Settings); HttpClient::Response Resp = Client.Get("/api/test/attempt-counter"); CHECK(!Resp.IsSuccess()); CHECK_EQ(Resp.StatusCode, HttpResponseCode::ServiceUnavailable); } } TEST_CASE("httpclient.measurelatency") { SUBCASE("Successful measurement against live server") { TestServerFixture Fixture; HttpClient Client = Fixture.MakeClient(); LatencyTestResult Result = MeasureLatency(Client, "/api/test/hello"); CHECK(Result.Success); CHECK(Result.LatencySeconds > 0.0); } SUBCASE("Failed measurement against unreachable port") { HttpClient Client("127.0.0.1:19999", HttpClientSettings{}, /*CheckIfAbortFunction*/ {}); LatencyTestResult Result = MeasureLatency(Client, "/api/test/hello"); CHECK(!Result.Success); CHECK(!Result.FailureReason.empty()); } } TEST_CASE("httpclient.keyvaluemap") { SUBCASE("Default construction is empty") { HttpClient::KeyValueMap Map; CHECK(Map->empty()); } SUBCASE("Construction from pair") { HttpClient::KeyValueMap Map(std::pair("key", "value")); CHECK_EQ(Map->size(), 1u); CHECK_EQ(Map->at("key"), "value"); } SUBCASE("Construction from string_view pair") { HttpClient::KeyValueMap Map(std::pair("key"sv, "value"sv)); CHECK_EQ(Map->size(), 1u); CHECK_EQ(Map->at("key"), "value"); } SUBCASE("Construction from initializer list") { HttpClient::KeyValueMap Map({{"a"sv, "1"sv}, {"b"sv, "2"sv}}); CHECK_EQ(Map->size(), 2u); CHECK_EQ(Map->at("a"), "1"); CHECK_EQ(Map->at("b"), "2"); } } ////////////////////////////////////////////////////////////////////////// // Transport fault testing static std::string MakeRawHttpResponse(int StatusCode, std::string_view Body) { return fmt::format( "HTTP/1.1 {} OK\r\n" "Content-Type: text/plain\r\n" "Content-Length: {}\r\n" "\r\n" "{}", StatusCode, Body.size(), Body); } static std::string MakeRawHttpHeaders(int StatusCode, size_t ContentLength) { return fmt::format( "HTTP/1.1 {} OK\r\n" "Content-Type: application/octet-stream\r\n" "Content-Length: {}\r\n" "\r\n", StatusCode, ContentLength); } static void DrainHttpRequest(asio::ip::tcp::socket& Socket) { asio::streambuf Buf; std::error_code Ec; asio::read_until(Socket, Buf, "\r\n\r\n", Ec); } static void DrainFullHttpRequest(asio::ip::tcp::socket& Socket) { // Read until end of headers asio::streambuf Buf; std::error_code Ec; asio::read_until(Socket, Buf, "\r\n\r\n", Ec); if (Ec) { return; } // Extract headers to find Content-Length std::string Headers(asio::buffers_begin(Buf.data()), asio::buffers_end(Buf.data())); size_t ContentLength = 0; auto Pos = Headers.find("Content-Length: "); if (Pos == std::string::npos) { Pos = Headers.find("content-length: "); } if (Pos != std::string::npos) { size_t ValStart = Pos + 16; // length of "Content-Length: " size_t ValEnd = Headers.find("\r\n", ValStart); if (ValEnd != std::string::npos) { ContentLength = std::stoull(Headers.substr(ValStart, ValEnd - ValStart)); } } // Calculate how many body bytes were already read past the header boundary. // asio::read_until may read past the delimiter, so Buf.data() contains everything read. size_t HeaderEnd = Headers.find("\r\n\r\n") + 4; size_t BodyBytesInBuf = Headers.size() > HeaderEnd ? Headers.size() - HeaderEnd : 0; size_t Remaining = ContentLength > BodyBytesInBuf ? ContentLength - BodyBytesInBuf : 0; if (Remaining > 0) { std::vector BodyBuf(Remaining); asio::read(Socket, asio::buffer(BodyBuf), Ec); } } static void DrainPartialBody(asio::ip::tcp::socket& Socket, size_t BytesToRead) { // Read headers first asio::streambuf Buf; std::error_code Ec; asio::read_until(Socket, Buf, "\r\n\r\n", Ec); if (Ec) { return; } // Determine how many body bytes were already buffered past headers std::string All(asio::buffers_begin(Buf.data()), asio::buffers_end(Buf.data())); size_t HeaderEnd = All.find("\r\n\r\n") + 4; size_t BodyBytesInBuf = All.size() > HeaderEnd ? All.size() - HeaderEnd : 0; if (BodyBytesInBuf < BytesToRead) { size_t Remaining = BytesToRead - BodyBytesInBuf; std::vector BodyBuf(Remaining); asio::read(Socket, asio::buffer(BodyBuf), Ec); } } struct FaultTcpServer { using FaultHandler = std::function; asio::io_context m_IoContext; asio::ip::tcp::acceptor m_Acceptor; FaultHandler m_Handler; std::thread m_Thread; int m_Port; explicit FaultTcpServer(FaultHandler Handler) : m_Acceptor(m_IoContext, asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), 0)) , m_Handler(std::move(Handler)) { m_Port = m_Acceptor.local_endpoint().port(); StartAccept(); m_Thread = std::thread([this]() { m_IoContext.run(); }); } ~FaultTcpServer() { std::error_code Ec; m_Acceptor.close(Ec); m_IoContext.stop(); if (m_Thread.joinable()) { m_Thread.join(); } } FaultTcpServer(const FaultTcpServer&) = delete; FaultTcpServer& operator=(const FaultTcpServer&) = delete; void StartAccept() { m_Acceptor.async_accept([this](std::error_code Ec, asio::ip::tcp::socket Socket) { if (!Ec) { m_Handler(Socket); } if (m_Acceptor.is_open()) { StartAccept(); } }); } HttpClient MakeClient(HttpClientSettings Settings = {}) { return HttpClient(fmt::format("127.0.0.1:{}", m_Port), Settings, /*CheckIfAbortFunction*/ {}); } }; TEST_CASE("httpclient.transport-faults") { SUBCASE("connection reset before response") { FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::error_code Ec; Socket.set_option(asio::socket_base::linger(true, 0), Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); HttpClient::Response Resp = Client.Get("/test"); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); } SUBCASE("connection closed before response") { FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::error_code Ec; Socket.shutdown(asio::ip::tcp::socket::shutdown_both, Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); HttpClient::Response Resp = Client.Get("/test"); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); } SUBCASE("partial headers then close") { // libcurl parses the status line (200 OK) and accepts the response even though // headers are truncated mid-field. It reports success with an empty body instead // of an error. Ideally this should be detected as a transport failure. FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::string Partial = "HTTP/1.1 200 OK\r\nContent-"; std::error_code Ec; asio::write(Socket, asio::buffer(Partial), Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); HttpClient::Response Resp = Client.Get("/test"); WARN(!Resp.IsSuccess()); WARN(Resp.Error.has_value()); } SUBCASE("truncated body") { FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::string Headers = MakeRawHttpHeaders(200, 1000); std::error_code Ec; asio::write(Socket, asio::buffer(Headers), Ec); std::string PartialBody(100, 'x'); asio::write(Socket, asio::buffer(PartialBody), Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); HttpClient::Response Resp = Client.Get("/test"); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); } SUBCASE("connection reset mid-body") { FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::string Headers = MakeRawHttpHeaders(200, 10000); std::error_code Ec; asio::write(Socket, asio::buffer(Headers), Ec); std::string PartialBody(1000, 'x'); asio::write(Socket, asio::buffer(PartialBody), Ec); Socket.set_option(asio::socket_base::linger(true, 0), Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); HttpClient::Response Resp = Client.Get("/test"); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); } SUBCASE("stalled response triggers timeout") { std::atomic StallActive{true}; FaultTcpServer Server([&StallActive](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::string Headers = MakeRawHttpHeaders(200, 1000); std::error_code Ec; asio::write(Socket, asio::buffer(Headers), Ec); while (StallActive.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); } }); HttpClientSettings Settings; Settings.Timeout = std::chrono::milliseconds(500); HttpClient Client = Server.MakeClient(Settings); HttpClient::Response Resp = Client.Get("/test"); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); StallActive.store(false); } SUBCASE("retry succeeds after transient failures") { std::atomic ConnCount{0}; FaultTcpServer Server([&ConnCount](asio::ip::tcp::socket& Socket) { int N = ConnCount.fetch_add(1); DrainHttpRequest(Socket); if (N < 2) { // Connection reset produces NETWORK_SEND_FAILURE which is retryable std::error_code Ec; Socket.set_option(asio::socket_base::linger(true, 0), Ec); Socket.close(Ec); } else { std::string Response = MakeRawHttpResponse(200, "recovered"); std::error_code Ec; asio::write(Socket, asio::buffer(Response), Ec); } }); HttpClientSettings Settings; Settings.RetryCount = 3; HttpClient Client = Server.MakeClient(Settings); HttpClient::Response Resp = Client.Get("/test"); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "recovered"); } } TEST_CASE("httpclient.transport-faults-post") { constexpr size_t kPostBodySize = 256 * 1024; auto MakePostBody = []() -> IoBuffer { IoBuffer Buf(kPostBodySize); uint8_t* Ptr = static_cast(Buf.MutableData()); for (size_t i = 0; i < kPostBodySize; ++i) { Ptr[i] = static_cast(i & 0xFF); } Buf.SetContentType(ZenContentType::kBinary); return Buf; }; SUBCASE("POST: server resets before consuming body") { FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::error_code Ec; Socket.set_option(asio::socket_base::linger(true, 0), Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); IoBuffer Body = MakePostBody(); HttpClient::Response Resp = Client.Post("/test", Body); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); } SUBCASE("POST: server closes before consuming body") { FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::error_code Ec; Socket.shutdown(asio::ip::tcp::socket::shutdown_both, Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); IoBuffer Body = MakePostBody(); HttpClient::Response Resp = Client.Post("/test", Body); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); } SUBCASE("POST: server resets mid-body") { FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainPartialBody(Socket, 8 * 1024); std::error_code Ec; Socket.set_option(asio::socket_base::linger(true, 0), Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); IoBuffer Body = MakePostBody(); HttpClient::Response Resp = Client.Post("/test", Body); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); } SUBCASE("POST: early error response before consuming body") { FaultTcpServer Server([](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); std::string Response = MakeRawHttpResponse(503, "service busy"); std::error_code Ec; asio::write(Socket, asio::buffer(Response), Ec); Socket.shutdown(asio::ip::tcp::socket::shutdown_both, Ec); Socket.close(Ec); }); HttpClient Client = Server.MakeClient(); IoBuffer Body = MakePostBody(); HttpClient::Response Resp = Client.Post("/test", Body); CHECK(!Resp.IsSuccess()); // With a large upload body, the server may RST the connection before the client // reads the 503 response. Either outcome is valid: the client sees the HTTP 503 // status, or it sees a transport-level error from the RST. CHECK((Resp.StatusCode == HttpResponseCode::ServiceUnavailable || Resp.Error.has_value())); } SUBCASE("POST: stalled upload triggers timeout") { std::atomic StallActive{true}; FaultTcpServer Server([&StallActive](asio::ip::tcp::socket& Socket) { DrainHttpRequest(Socket); // Stop reading body — TCP window will fill and client send will stall while (StallActive.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); } }); HttpClientSettings Settings; Settings.Timeout = std::chrono::milliseconds(2000); HttpClient Client = Server.MakeClient(Settings); IoBuffer Body = MakePostBody(); HttpClient::Response Resp = Client.Post("/test", Body); CHECK(!Resp.IsSuccess()); CHECK(Resp.Error.has_value()); StallActive.store(false); } SUBCASE("POST: retry with large body after transient failure") { std::atomic ConnCount{0}; FaultTcpServer Server([&ConnCount](asio::ip::tcp::socket& Socket) { int N = ConnCount.fetch_add(1); if (N < 2) { DrainHttpRequest(Socket); std::error_code Ec; Socket.set_option(asio::socket_base::linger(true, 0), Ec); Socket.close(Ec); } else { DrainFullHttpRequest(Socket); std::string Response = MakeRawHttpResponse(200, "upload-ok"); std::error_code Ec; asio::write(Socket, asio::buffer(Response), Ec); } }); HttpClientSettings Settings; Settings.RetryCount = 3; HttpClient Client = Server.MakeClient(Settings); IoBuffer Body = MakePostBody(); HttpClient::Response Resp = Client.Post("/test", Body); CHECK(Resp.IsSuccess()); CHECK_EQ(Resp.AsText(), "upload-ok"); } } void httpclient_test_forcelink() { } } // namespace zen #endif