|
|
@@ -3247,6 +3247,53 @@ TEST(DigestAuthTest, ChallengeMissingRealmDoesNotCrash) {
|
|
|
run_digest_challenge_missing_field_test("Digest nonce=\"n\", qop=\"auth\"");
|
|
|
}
|
|
|
|
|
|
+// A hostile server can put a '"' in realm/nonce/opaque, or a non-token
|
|
|
+// algorithm, in its challenge. parse_www_authenticate() de-escapes quoted-pairs
|
|
|
+// when storing the values, so the header builder has to re-escape them (and
|
|
|
+// keep algorithm a bare token); otherwise the value breaks out of its
|
|
|
+// quoted-string and injects extra auth-params into the client's Authorization.
|
|
|
+TEST(DigestAuthTest, EscapesInjectedAuthParams) {
|
|
|
+ std::atomic<int> hits{0};
|
|
|
+ std::string authorization;
|
|
|
+
|
|
|
+ Server svr;
|
|
|
+ svr.Get("/x", [&](const Request &req, Response &res) {
|
|
|
+ if (++hits == 1) {
|
|
|
+ res.status = StatusCode::Unauthorized_401;
|
|
|
+ // On the wire the quotes embedded in the values are backslash-escaped.
|
|
|
+ res.set_header("WWW-Authenticate",
|
|
|
+ "Digest realm=\"testrealm\", "
|
|
|
+ "nonce=\"n\\\"; injected=\\\"x\", "
|
|
|
+ "algorithm=\"MD5, injected2=\\\"y\\\"\", qop=\"auth\"");
|
|
|
+ } else {
|
|
|
+ authorization = req.get_header_value("Authorization");
|
|
|
+ res.set_content("ok", "text/plain");
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ auto port = svr.bind_to_any_port(HOST);
|
|
|
+ std::thread t([&]() { svr.listen_after_bind(); });
|
|
|
+ auto se = detail::scope_exit([&] {
|
|
|
+ svr.stop();
|
|
|
+ t.join();
|
|
|
+ });
|
|
|
+ svr.wait_until_ready();
|
|
|
+
|
|
|
+ Client cli(HOST, port);
|
|
|
+ cli.set_digest_auth("hello", "world");
|
|
|
+ auto res = cli.Get("/x");
|
|
|
+ ASSERT_TRUE(res) << "Error: " << to_string(res.error());
|
|
|
+ EXPECT_EQ(2, hits.load());
|
|
|
+
|
|
|
+ EXPECT_EQ(0u, authorization.rfind("Digest ", 0));
|
|
|
+ // The nonce (de-escaped to n"; injected="x ) must be re-escaped so it stays
|
|
|
+ // inside its quoted-string rather than starting an "injected" auth-param.
|
|
|
+ EXPECT_NE(std::string::npos,
|
|
|
+ authorization.find("nonce=\"n\\\"; injected=\\\"x\""));
|
|
|
+ // A non-token algorithm falls back to a bare MD5 token, dropping the payload.
|
|
|
+ EXPECT_EQ(std::string::npos, authorization.find("injected2"));
|
|
|
+}
|
|
|
+
|
|
|
#endif
|
|
|
|
|
|
TEST(SpecifyServerIPAddressTest, AnotherHostname_Online) {
|
|
|
@@ -3754,6 +3801,34 @@ TEST(RedirectToDifferentPort, OverflowPortNumber) {
|
|
|
EXPECT_FALSE(res);
|
|
|
}
|
|
|
|
|
|
+TEST(RedirectToDifferentPort, TrailingCharactersInPort) {
|
|
|
+ Server svr;
|
|
|
+ auto port = svr.bind_to_any_port(HOST);
|
|
|
+ svr.Get("/redir", [&](const Request & /*req*/, Response &res) {
|
|
|
+ // The server's own port followed by junk must not be followed
|
|
|
+ res.set_redirect("http://" + std::string(HOST) + ":" +
|
|
|
+ std::to_string(port) + "junk/target");
|
|
|
+ });
|
|
|
+ svr.Get("/target", [&](const Request & /*req*/, Response &res) {
|
|
|
+ res.set_content("target", "text/plain");
|
|
|
+ });
|
|
|
+
|
|
|
+ auto thread = std::thread([&]() { svr.listen_after_bind(); });
|
|
|
+ auto se = detail::scope_exit([&] {
|
|
|
+ svr.stop();
|
|
|
+ thread.join();
|
|
|
+ ASSERT_FALSE(svr.is_running());
|
|
|
+ });
|
|
|
+
|
|
|
+ svr.wait_until_ready();
|
|
|
+
|
|
|
+ Client cli(HOST, port);
|
|
|
+ cli.set_follow_location(true);
|
|
|
+
|
|
|
+ auto res = cli.Get("/redir");
|
|
|
+ EXPECT_FALSE(res);
|
|
|
+}
|
|
|
+
|
|
|
TEST(RedirectFromPageWithContent, Redirect) {
|
|
|
Server svr;
|
|
|
|
|
|
@@ -11406,6 +11481,134 @@ TEST(KeepAliveTest, MaxCount) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// A client may pipeline its requests (RFC 9112 9.3.2), so reading one request
|
|
|
+// can pull the next one into the server's buffer. The server must serve it
|
|
|
+// without waiting for the socket to become readable again. The keep-alive
|
|
|
+// timeout outlasts the client's read timeout, so a request left waiting for it
|
|
|
+// shows up as a missing response.
|
|
|
+static void serve_pipelining_routes(Server &svr,
|
|
|
+ const std::function<void(int)> &client) {
|
|
|
+ svr.set_keep_alive_timeout(5);
|
|
|
+ svr.Get("/hi/(\\d+)", [](const Request &req, Response &res) {
|
|
|
+ res.set_content("hi " + req.matches[1].str(), "text/plain");
|
|
|
+ });
|
|
|
+ svr.Post("/echo", [](const Request &req, Response &res) {
|
|
|
+ res.set_content("echo " + req.body, "text/plain");
|
|
|
+ });
|
|
|
+
|
|
|
+ auto port = svr.bind_to_any_port(HOST);
|
|
|
+ thread t = thread([&] { svr.listen_after_bind(); });
|
|
|
+ auto se = detail::scope_exit([&] {
|
|
|
+ svr.stop();
|
|
|
+ t.join();
|
|
|
+ });
|
|
|
+ svr.wait_until_ready();
|
|
|
+
|
|
|
+ client(port);
|
|
|
+}
|
|
|
+
|
|
|
+static std::string
|
|
|
+send_pipelined_requests(const std::vector<std::string> &parts) {
|
|
|
+ Server svr;
|
|
|
+ std::string res;
|
|
|
+ serve_pipelining_routes(svr, [&](int port) {
|
|
|
+ EXPECT_TRUE(send_request_in_parts(2, parts, &res, port));
|
|
|
+ });
|
|
|
+ return res;
|
|
|
+}
|
|
|
+
|
|
|
+static void expect_in_order(const std::string &res,
|
|
|
+ const std::vector<std::string> &bodies) {
|
|
|
+ size_t pos = 0;
|
|
|
+ for (const auto &body : bodies) {
|
|
|
+ pos = res.find(body, pos);
|
|
|
+ ASSERT_NE(std::string::npos, pos) << "missing or out of order: " << body;
|
|
|
+ pos += body.size();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+TEST(KeepAliveTest, PipelinedRequests) {
|
|
|
+ auto res = send_pipelined_requests(
|
|
|
+ {"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
|
|
+ "POST /echo HTTP/1.1\r\nHost: localhost\r\n"
|
|
|
+ "Content-Length: 4\r\n\r\nbody"
|
|
|
+ "GET /hi/3 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"});
|
|
|
+ expect_in_order(res, {"hi 1", "echo body", "hi 3"});
|
|
|
+}
|
|
|
+
|
|
|
+// The second request starts in the server's buffer and ends on the socket.
|
|
|
+TEST(KeepAliveTest, PipelinedRequestSplitAcrossReads) {
|
|
|
+ auto res =
|
|
|
+ send_pipelined_requests({"GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
|
|
+ "GET /hi/2 HTTP/1.1\r\nHo",
|
|
|
+ "st: localhost\r\nConnection: close\r\n\r\n"});
|
|
|
+ expect_in_order(res, {"hi 1", "hi 2"});
|
|
|
+}
|
|
|
+
|
|
|
+// RFC 9112 2.2: an empty line before the request-line is ignored, so an extra
|
|
|
+// CRLF after a body does not turn into a 400 for the next request.
|
|
|
+TEST(KeepAliveTest, EmptyLineBeforeRequestLineIsIgnored) {
|
|
|
+ auto res = send_pipelined_requests(
|
|
|
+ {"POST /echo HTTP/1.1\r\nHost: localhost\r\n"
|
|
|
+ "Content-Length: 4\r\n\r\nbody\r\n"
|
|
|
+ "GET /hi/2 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"});
|
|
|
+ EXPECT_EQ(std::string::npos, res.find("400 Bad Request"));
|
|
|
+ expect_in_order(res, {"echo body", "hi 2"});
|
|
|
+}
|
|
|
+
|
|
|
+// Where an unparsable request ends is unknown, so what follows it in the buffer
|
|
|
+// must not be served as the next request.
|
|
|
+TEST(KeepAliveTest, PipelinedRequestAfterInvalidRequestIsNotServed) {
|
|
|
+ auto res = send_pipelined_requests(
|
|
|
+ {"INVALID REQUEST LINE\r\n\r\n"
|
|
|
+ "GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"});
|
|
|
+ EXPECT_EQ("HTTP/1.1 400 Bad Request", res.substr(0, 24));
|
|
|
+ EXPECT_EQ(std::string::npos, res.find("hi 1"));
|
|
|
+}
|
|
|
+
|
|
|
+#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
|
|
|
+// Over TLS, the next request is held by the TLS library as already decrypted
|
|
|
+// data rather than on the socket.
|
|
|
+TEST(KeepAliveTest, SSLPipelinedRequests) {
|
|
|
+ SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
|
|
|
+ std::string res;
|
|
|
+ serve_pipelining_routes(svr, [&](int port) {
|
|
|
+ auto error = Error::Success;
|
|
|
+ auto sock = detail::create_client_socket(
|
|
|
+ HOST, "", port, AF_UNSPEC, false, false, nullptr,
|
|
|
+ /*connection_timeout_sec=*/5, 0,
|
|
|
+ /*read_timeout_sec=*/2, 0,
|
|
|
+ /*write_timeout_sec=*/5, 0, std::string(), error);
|
|
|
+ ASSERT_NE(INVALID_SOCKET, sock);
|
|
|
+ auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); });
|
|
|
+
|
|
|
+ auto ctx = SSL_CTX_new(TLS_client_method());
|
|
|
+ auto ssl = SSL_new(ctx);
|
|
|
+ auto ssl_se = detail::scope_exit([&] {
|
|
|
+ SSL_free(ssl);
|
|
|
+ SSL_CTX_free(ctx);
|
|
|
+ });
|
|
|
+ SSL_set_fd(ssl, static_cast<int>(sock));
|
|
|
+ ASSERT_EQ(1, SSL_connect(ssl));
|
|
|
+
|
|
|
+ // One write, so all three requests arrive in one TLS record
|
|
|
+ const std::string req =
|
|
|
+ "GET /hi/1 HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
|
|
+ "GET /hi/2 HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
|
|
+ "GET /hi/3 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
|
|
|
+ ASSERT_EQ(static_cast<int>(req.size()),
|
|
|
+ SSL_write(ssl, req.data(), static_cast<int>(req.size())));
|
|
|
+
|
|
|
+ char buf[512];
|
|
|
+ int n;
|
|
|
+ while ((n = SSL_read(ssl, buf, sizeof(buf))) > 0) {
|
|
|
+ res.append(buf, static_cast<size_t>(n));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ expect_in_order(res, {"hi 1", "hi 2", "hi 3"});
|
|
|
+}
|
|
|
+#endif
|
|
|
+
|
|
|
TEST(KeepAliveTest, Issue1041) {
|
|
|
Server svr;
|
|
|
svr.set_keep_alive_timeout(3);
|
|
|
@@ -13848,6 +14051,11 @@ TEST(HostAndPortPropertiesTest, PortOutOfRange) {
|
|
|
ASSERT_FALSE(cli.is_valid());
|
|
|
}
|
|
|
|
|
|
+TEST(HostAndPortPropertiesTest, TrailingCharactersInPort) {
|
|
|
+ httplib::Client cli("http://www.google.com:80abc");
|
|
|
+ ASSERT_FALSE(cli.is_valid());
|
|
|
+}
|
|
|
+
|
|
|
#ifdef CPPHTTPLIB_SSL_ENABLED
|
|
|
TEST(HostAndPortPropertiesTest, SSL) {
|
|
|
httplib::SSLClient cli("www.google.com");
|
|
|
@@ -13958,6 +14166,41 @@ TEST(SSLClientTest, WindowsCertificateVerification_Disabled) {
|
|
|
auto res = cli.Get("/");
|
|
|
if (res) { EXPECT_NE(StatusCode::InternalServerError_500, res->status); }
|
|
|
}
|
|
|
+
|
|
|
+// The server sends an intermediate cross-signed by a root Windows trusts,
|
|
|
+// while the leaf's AIA URL leads to one under a root Windows does not trust.
|
|
|
+TEST(SSLClientTest, WindowsCertificateVerification_ServerIntermediates_Online) {
|
|
|
+ SSLClient cli("accounts.spotify.com", 443);
|
|
|
+ auto res = cli.Get("/");
|
|
|
+ ASSERT_TRUE(res) << "Error: " << to_string(res.error())
|
|
|
+ << " ssl_backend_error=" << res.ssl_backend_error();
|
|
|
+}
|
|
|
+
|
|
|
+// Windows, not the backend, decides on the chain: the error carries a
|
|
|
+// CryptoAPI trust status, which no backend error for a self-signed
|
|
|
+// certificate has.
|
|
|
+TEST(SSLClientTest, WindowsCertificateVerification_RejectsUntrustedRoot) {
|
|
|
+ SSLServer svr(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE);
|
|
|
+ ASSERT_TRUE(svr.is_valid());
|
|
|
+
|
|
|
+ thread t = thread([&]() { ASSERT_TRUE(svr.listen("127.0.0.1", PORT)); });
|
|
|
+ auto se = detail::scope_exit([&] {
|
|
|
+ svr.stop();
|
|
|
+ t.join();
|
|
|
+ ASSERT_FALSE(svr.is_running());
|
|
|
+ });
|
|
|
+
|
|
|
+ svr.wait_until_ready();
|
|
|
+
|
|
|
+ SSLClient cli("127.0.0.1", PORT);
|
|
|
+ cli.set_connection_timeout(30);
|
|
|
+
|
|
|
+ auto res = cli.Get("/");
|
|
|
+ ASSERT_FALSE(res);
|
|
|
+ EXPECT_EQ(Error::SSLServerVerification, res.error());
|
|
|
+ EXPECT_NE(0u, res.ssl_backend_error() & CERT_TRUST_IS_UNTRUSTED_ROOT)
|
|
|
+ << "ssl_backend_error=" << res.ssl_backend_error();
|
|
|
+}
|
|
|
#endif
|
|
|
|
|
|
TEST(SSLClientTest, ServerCertificateVerification1_Online) {
|
|
|
@@ -14326,6 +14569,9 @@ TEST(SSLClientServerTest, ClientCertMissing) {
|
|
|
|
|
|
SSLClient cli(HOST, PORT);
|
|
|
cli.set_connection_timeout(30);
|
|
|
+ // cert.pem does not match HOST. Skip the hostname check, which runs before
|
|
|
+ // the chain check on Windows, so the result does not depend on the order.
|
|
|
+ cli.enable_server_hostname_verification(false);
|
|
|
|
|
|
auto res = cli.Get("/test");
|
|
|
ASSERT_TRUE(!res);
|
|
|
@@ -21973,231 +22219,6 @@ TEST(ETagTest, NegativeFileModificationTime) {
|
|
|
std::remove(fname);
|
|
|
}
|
|
|
|
|
|
-//==============================================================================
|
|
|
-// SSE Parsing Tests
|
|
|
-//==============================================================================
|
|
|
-
|
|
|
-class SSEParsingTest : public ::testing::Test {
|
|
|
-protected:
|
|
|
- // Test helper that mimics SSE parsing behavior
|
|
|
- static bool parse_sse_line(const std::string &line, sse::SSEMessage &msg,
|
|
|
- int &retry_ms) {
|
|
|
- // Blank line signals end of event
|
|
|
- if (line.empty() || line == "\r") { return true; }
|
|
|
-
|
|
|
- // Lines starting with ':' are comments (ignored)
|
|
|
- if (!line.empty() && line[0] == ':') { return false; }
|
|
|
-
|
|
|
- // Find the colon separator
|
|
|
- auto colon_pos = line.find(':');
|
|
|
- if (colon_pos == std::string::npos) {
|
|
|
- // Line with no colon is treated as field name with empty value
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- std::string field = line.substr(0, colon_pos);
|
|
|
- std::string value;
|
|
|
-
|
|
|
- // Value starts after colon, skip optional single space
|
|
|
- if (colon_pos + 1 < line.size()) {
|
|
|
- size_t value_start = colon_pos + 1;
|
|
|
- if (line[value_start] == ' ') { value_start++; }
|
|
|
- value = line.substr(value_start);
|
|
|
- // Remove trailing \r if present
|
|
|
- if (!value.empty() && value.back() == '\r') { value.pop_back(); }
|
|
|
- }
|
|
|
-
|
|
|
- // Handle known fields
|
|
|
- if (field == "event") {
|
|
|
- msg.event = value;
|
|
|
- } else if (field == "data") {
|
|
|
- // Multiple data lines are concatenated with newlines
|
|
|
- if (!msg.data.empty()) { msg.data += "\n"; }
|
|
|
- msg.data += value;
|
|
|
- } else if (field == "id") {
|
|
|
- // Empty id is valid (clears the last event ID)
|
|
|
- msg.id = value;
|
|
|
- } else if (field == "retry") {
|
|
|
- // Parse retry interval in milliseconds
|
|
|
- {
|
|
|
- int v = 0;
|
|
|
- auto res =
|
|
|
- detail::from_chars(value.data(), value.data() + value.size(), v);
|
|
|
- if (res.ec == std::errc{}) { retry_ms = v; }
|
|
|
- }
|
|
|
- }
|
|
|
- // Unknown fields are ignored per SSE spec
|
|
|
-
|
|
|
- return false;
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// Test: Single-line data
|
|
|
-TEST_F(SSEParsingTest, SingleLineData) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("data: hello", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.data, "hello");
|
|
|
- EXPECT_EQ(msg.event, "message");
|
|
|
-
|
|
|
- // Blank line ends event
|
|
|
- EXPECT_TRUE(parse_sse_line("", msg, retry_ms));
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Multi-line data
|
|
|
-TEST_F(SSEParsingTest, MultiLineData) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("data: line1", msg, retry_ms));
|
|
|
- EXPECT_FALSE(parse_sse_line("data: line2", msg, retry_ms));
|
|
|
- EXPECT_FALSE(parse_sse_line("data: line3", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.data, "line1\nline2\nline3");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Custom event types
|
|
|
-TEST_F(SSEParsingTest, CustomEventType) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("event: update", msg, retry_ms));
|
|
|
- EXPECT_FALSE(parse_sse_line("data: payload", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.event, "update");
|
|
|
- EXPECT_EQ(msg.data, "payload");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Event ID handling
|
|
|
-TEST_F(SSEParsingTest, EventIdHandling) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("id: 12345", msg, retry_ms));
|
|
|
- EXPECT_FALSE(parse_sse_line("data: test", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.id, "12345");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Empty event ID (clears last event ID)
|
|
|
-TEST_F(SSEParsingTest, EmptyEventId) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- msg.id = "previous";
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("id:", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.id, "");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Retry field parsing
|
|
|
-TEST_F(SSEParsingTest, RetryFieldParsing) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("retry: 5000", msg, retry_ms));
|
|
|
- EXPECT_EQ(retry_ms, 5000);
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Invalid retry value
|
|
|
-TEST_F(SSEParsingTest, InvalidRetryValue) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("retry: invalid", msg, retry_ms));
|
|
|
- EXPECT_EQ(retry_ms, 3000); // Unchanged
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Comments (lines starting with :)
|
|
|
-TEST_F(SSEParsingTest, CommentsIgnored) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line(": this is a comment", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.data, "");
|
|
|
- EXPECT_EQ(msg.event, "message");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Colon in value
|
|
|
-TEST_F(SSEParsingTest, ColonInValue) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("data: hello:world:test", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.data, "hello:world:test");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Line with no colon (field name only)
|
|
|
-TEST_F(SSEParsingTest, FieldNameOnly) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- // According to SSE spec, this is treated as field name with empty value
|
|
|
- EXPECT_FALSE(parse_sse_line("data", msg, retry_ms));
|
|
|
- // Since we don't recognize "data" without colon, data should be empty
|
|
|
- EXPECT_EQ(msg.data, "");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Trailing \r handling
|
|
|
-TEST_F(SSEParsingTest, TrailingCarriageReturn) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("data: hello\r", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.data, "hello");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Unknown fields ignored
|
|
|
-TEST_F(SSEParsingTest, UnknownFieldsIgnored) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("unknown: value", msg, retry_ms));
|
|
|
- EXPECT_EQ(msg.data, "");
|
|
|
- EXPECT_EQ(msg.event, "message");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Space after colon is optional
|
|
|
-TEST_F(SSEParsingTest, SpaceAfterColonOptional) {
|
|
|
- sse::SSEMessage msg1, msg2;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("data: hello", msg1, retry_ms));
|
|
|
- EXPECT_FALSE(parse_sse_line("data:hello", msg2, retry_ms));
|
|
|
- EXPECT_EQ(msg1.data, "hello");
|
|
|
- EXPECT_EQ(msg2.data, "hello");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: SSEMessage clear
|
|
|
-TEST_F(SSEParsingTest, MessageClear) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- msg.event = "custom";
|
|
|
- msg.data = "some data";
|
|
|
- msg.id = "123";
|
|
|
-
|
|
|
- msg.clear();
|
|
|
-
|
|
|
- EXPECT_EQ(msg.event, "message");
|
|
|
- EXPECT_EQ(msg.data, "");
|
|
|
- EXPECT_EQ(msg.id, "");
|
|
|
-}
|
|
|
-
|
|
|
-// Test: Complete event parsing
|
|
|
-TEST_F(SSEParsingTest, CompleteEventParsing) {
|
|
|
- sse::SSEMessage msg;
|
|
|
- int retry_ms = 3000;
|
|
|
-
|
|
|
- EXPECT_FALSE(parse_sse_line("event: notification", msg, retry_ms));
|
|
|
- EXPECT_FALSE(parse_sse_line("id: evt-42", msg, retry_ms));
|
|
|
- EXPECT_FALSE(parse_sse_line("data: {\"type\":\"alert\"}", msg, retry_ms));
|
|
|
- EXPECT_FALSE(parse_sse_line("retry: 1000", msg, retry_ms));
|
|
|
-
|
|
|
- // Blank line ends event
|
|
|
- EXPECT_TRUE(parse_sse_line("", msg, retry_ms));
|
|
|
-
|
|
|
- EXPECT_EQ(msg.event, "notification");
|
|
|
- EXPECT_EQ(msg.id, "evt-42");
|
|
|
- EXPECT_EQ(msg.data, "{\"type\":\"alert\"}");
|
|
|
- EXPECT_EQ(retry_ms, 1000);
|
|
|
-}
|
|
|
-
|
|
|
//==============================================================================
|
|
|
// Integration Tests with Server
|
|
|
//==============================================================================
|
|
|
@@ -22292,6 +22313,189 @@ protected:
|
|
|
int port_ = 0;
|
|
|
};
|
|
|
|
|
|
+//==============================================================================
|
|
|
+// SSE Parsing Tests
|
|
|
+//==============================================================================
|
|
|
+
|
|
|
+class SSEParsingTest : public SSEIntegrationTest {
|
|
|
+protected:
|
|
|
+ // Feeds a raw event stream to SSEClient and returns the dispatched messages.
|
|
|
+ // A trailing sentinel event tells when the whole stream has been parsed.
|
|
|
+ std::vector<sse::SSEMessage> parse(const std::string &stream) {
|
|
|
+ auto body = stream + "event: end\ndata: end\n\n";
|
|
|
+ server_->Get("/parse", [body](const Request &, Response &res) {
|
|
|
+ res.set_content(body, "text/event-stream");
|
|
|
+ });
|
|
|
+ return collect("/parse");
|
|
|
+ }
|
|
|
+
|
|
|
+ // Runs SSEClient against path until an "end" event arrives and returns the
|
|
|
+ // messages dispatched before it
|
|
|
+ std::vector<sse::SSEMessage> collect(const std::string &path) {
|
|
|
+ Client client(HOST, get_port());
|
|
|
+ sse::SSEClient sse(client, path);
|
|
|
+ std::mutex mutex;
|
|
|
+ std::condition_variable cv;
|
|
|
+ std::vector<sse::SSEMessage> messages;
|
|
|
+ auto done = false;
|
|
|
+
|
|
|
+ sse.on_message([&](const sse::SSEMessage &msg) {
|
|
|
+ std::lock_guard<std::mutex> lock(mutex);
|
|
|
+ // Ignore a replay from a reconnect that races with stop()
|
|
|
+ if (!done) { messages.push_back(msg); }
|
|
|
+ });
|
|
|
+ sse.on_event("end", [&](const sse::SSEMessage &) {
|
|
|
+ std::lock_guard<std::mutex> lock(mutex);
|
|
|
+ done = true;
|
|
|
+ cv.notify_all();
|
|
|
+ });
|
|
|
+ sse.set_reconnect_interval(100);
|
|
|
+ sse.start_async();
|
|
|
+
|
|
|
+ {
|
|
|
+ std::unique_lock<std::mutex> lock(mutex);
|
|
|
+ cv.wait_for(lock, std::chrono::seconds(5), [&] { return done; });
|
|
|
+ }
|
|
|
+ sse.stop();
|
|
|
+
|
|
|
+ EXPECT_TRUE(done);
|
|
|
+ return messages;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, SingleLineData) {
|
|
|
+ auto msgs = parse("data: hello\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "hello");
|
|
|
+ EXPECT_EQ(msgs[0].event, "message");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, MultiLineData) {
|
|
|
+ auto msgs = parse("data: line1\ndata: line2\ndata: line3\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "line1\nline2\nline3");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, CustomEventType) {
|
|
|
+ auto msgs = parse("event: update\ndata: payload\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].event, "update");
|
|
|
+ EXPECT_EQ(msgs[0].data, "payload");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, EventIdHandling) {
|
|
|
+ auto msgs = parse("id: 12345\ndata: test\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].id, "12345");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, EmptyEventId) {
|
|
|
+ auto msgs = parse("id:\ndata: test\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].id, "");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, CommentsIgnored) {
|
|
|
+ auto msgs = parse(": this is a comment\ndata: hello\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "hello");
|
|
|
+ EXPECT_EQ(msgs[0].event, "message");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, ColonInValue) {
|
|
|
+ auto msgs = parse("data: hello:world:test\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "hello:world:test");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, FieldNameOnly) {
|
|
|
+ // A line without a colon is a field name with an empty value
|
|
|
+ auto msgs = parse("data\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, TrailingCarriageReturn) {
|
|
|
+ auto msgs = parse("event: update\r\ndata: hello\r\n\r\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].event, "update");
|
|
|
+ EXPECT_EQ(msgs[0].data, "hello");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, FieldNameOnlyWithCarriageReturn) {
|
|
|
+ auto msgs = parse("data\r\n\r\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, UnknownFieldsIgnored) {
|
|
|
+ auto msgs = parse("unknown: value\ndata: hello\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "hello");
|
|
|
+ EXPECT_EQ(msgs[0].event, "message");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, SpaceAfterColonOptional) {
|
|
|
+ auto msgs = parse("data: hello\n\ndata:hello\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 2u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "hello");
|
|
|
+ EXPECT_EQ(msgs[1].data, "hello");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, EventWithoutDataResetsEventType) {
|
|
|
+ // An event without data is not dispatched, and its type must not leak
|
|
|
+ // into the next event
|
|
|
+ auto msgs = parse("event: update\n\ndata: hello\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].event, "message");
|
|
|
+ EXPECT_EQ(msgs[0].data, "hello");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, EventWithoutDataUpdatesLastEventId) {
|
|
|
+ // The first connection sends only an id; the second echoes back the
|
|
|
+ // Last-Event-ID it was reconnected with
|
|
|
+ std::atomic<int> connection_count{0};
|
|
|
+ server_->Get("/id-only", [&](const Request &req, Response &res) {
|
|
|
+ if (connection_count++ == 0) {
|
|
|
+ res.set_content("id: 42\n\n", "text/event-stream");
|
|
|
+ } else {
|
|
|
+ res.set_content("data: " + req.get_header_value("Last-Event-ID") +
|
|
|
+ "\n\nevent: end\ndata: end\n\n",
|
|
|
+ "text/event-stream");
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ auto msgs = collect("/id-only");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].data, "42");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(SSEParsingTest, CompleteEventParsing) {
|
|
|
+ auto msgs = parse("event: notification\nid: evt-42\n"
|
|
|
+ "data: {\"type\":\"alert\"}\nretry: 1000\n\n");
|
|
|
+ ASSERT_EQ(msgs.size(), 1u);
|
|
|
+ EXPECT_EQ(msgs[0].event, "notification");
|
|
|
+ EXPECT_EQ(msgs[0].id, "evt-42");
|
|
|
+ EXPECT_EQ(msgs[0].data, "{\"type\":\"alert\"}");
|
|
|
+}
|
|
|
+
|
|
|
+TEST(SSEMessageTest, Clear) {
|
|
|
+ sse::SSEMessage msg;
|
|
|
+ msg.event = "custom";
|
|
|
+ msg.data = "some data";
|
|
|
+ msg.id = "123";
|
|
|
+
|
|
|
+ msg.clear();
|
|
|
+
|
|
|
+ EXPECT_EQ(msg.event, "message");
|
|
|
+ EXPECT_EQ(msg.data, "");
|
|
|
+ EXPECT_EQ(msg.id, "");
|
|
|
+}
|
|
|
+
|
|
|
+//==============================================================================
|
|
|
+// SSE Integration Tests
|
|
|
+//==============================================================================
|
|
|
+
|
|
|
// Test: Successful connection and on_open callback
|
|
|
TEST_F(SSEIntegrationTest, SuccessfulConnection) {
|
|
|
// Add a simple endpoint that sends one event and closes
|
|
|
@@ -22607,6 +22811,45 @@ TEST_F(SSEIntegrationTest, MultiLineDataIntegration) {
|
|
|
EXPECT_EQ(received_data, "line1\nline2\nline3");
|
|
|
}
|
|
|
|
|
|
+TEST_F(SSEIntegrationTest, EmptyDataLines) {
|
|
|
+ server_->Get("/empty-data-lines", [](const Request &, Response &res) {
|
|
|
+ res.set_chunked_content_provider("text/event-stream", [](size_t offset,
|
|
|
+ DataSink &sink) {
|
|
|
+ if (offset == 0) {
|
|
|
+ const std::string events = "data:\n\ndata:\ndata: hello\n\ndata\n\n";
|
|
|
+ sink.write(events.data(), events.size());
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ Client client("localhost", get_port());
|
|
|
+ sse::SSEClient sse(client, "/empty-data-lines");
|
|
|
+ std::mutex mutex;
|
|
|
+ std::condition_variable cv;
|
|
|
+ std::vector<std::string> received;
|
|
|
+
|
|
|
+ sse.on_message([&](const sse::SSEMessage &msg) {
|
|
|
+ std::lock_guard<std::mutex> lock(mutex);
|
|
|
+ received.push_back(msg.data);
|
|
|
+ cv.notify_all();
|
|
|
+ });
|
|
|
+ sse.set_max_reconnect_attempts(1);
|
|
|
+ sse.start_async();
|
|
|
+
|
|
|
+ {
|
|
|
+ std::unique_lock<std::mutex> lock(mutex);
|
|
|
+ cv.wait_for(lock, std::chrono::seconds(2),
|
|
|
+ [&] { return received.size() >= 3; });
|
|
|
+ }
|
|
|
+ sse.stop();
|
|
|
+
|
|
|
+ ASSERT_GE(received.size(), 3u);
|
|
|
+ EXPECT_EQ(received[0], "");
|
|
|
+ EXPECT_EQ(received[1], "\nhello");
|
|
|
+ EXPECT_EQ(received[2], "");
|
|
|
+}
|
|
|
+
|
|
|
// Test: Auto-reconnect after server disconnection
|
|
|
TEST_F(SSEIntegrationTest, AutoReconnectAfterDisconnect) {
|
|
|
std::atomic<int> connection_count{0};
|