Loading test/CMakeLists.txt +12 −0 Changes for test/CMakeLists.txt: 12 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -105,3 +105,15 @@ else() endif() add_test(http3_checksum_test http3_checksum_test) add_executable(http3_control_streams_test http3_control_streams_test.cpp ) if(WIN32) target_link_libraries (http3_control_streams_test httppp-static ws2_32) else() target_link_libraries (http3_control_streams_test httppp-static) endif() add_test(http3_control_streams_test http3_control_streams_test) test/http3_control_streams_test.cpp 0 → 100644 +250 −0 Changes for test/http3_control_streams_test.cpp: 250 added lines, 0 removed lines. Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ // Unit test for libhttppp::Http3QuicSocket::sendControlStreams() -- the // override that now owns the HTTP/3 mandatory-control-stream logic // (RFC 9114 6.2.1) moved out of netplus::quic::sendControlStreams(), which // is a no-op base left purely as a transport-layer extension point. // // A real loopback QUIC (TLS 1.3) handshake is used because openStream()/ // sendStreamData() need real peer transport parameters (stream limits) to // avoid throwing -- there is no lighter-weight mock for this transport. // The client side is the Http3QuicSocket under test (constructing the // client directly, rather than going through the server-side accept() // path, is what makes calling sendControlStreams() from the test possible // at all: netplus::quic::accept() always mints plain netplus::quic child // connections for accepted peers, never the listener's dynamic type -- see // quic.cpp's accept(), which hardcodes std::make_shared<quic>()). The // server side is a plain netplus::quic listener that captures whatever // unidirectional streams arrive via setStreamCallback(), the same hook // production code (HttpD) registers on the server side. #include <atomic> #include <chrono> #include <cstdint> #include <iostream> #include <map> #include <mutex> #include <thread> #include <vector> #include <netplus/connection.h> #include <netplus/eventapi.h> #include <netplus/exception.h> #include <netplus/socket.h> #include "httpd.h" #include "https_ca_cert.h" #include "https_certs.h" using namespace netplus; namespace { int g_passed = 0, g_failed = 0; void check(bool ok, const std::string &name) { if (ok) { std::cout << " PASS: " << name << std::endl; ++g_passed; } else { std::cout << " FAIL: " << name << std::endl; ++g_failed; } } // Minimal event sink: only needed to drive the server-side QUIC state // machine (accept()/handshake/stream dispatch) via runEventloop(). None of // the assertions happen here -- they happen via the stream callback below, // which fires for every accepted child connection (quic::accept() copies // the listener's _stream_callback onto each child it creates). class NullServer : public event { public: explicit NullServer(std::vector<netplus::socket *> socks, int timeout = 500) : event(socks, timeout) {} void RequestEvent(con &, const int, ULONG_PTR) override {} void ResponseEvent(con &, const int, ULONG_PTR) override {} void ConnectEvent(con &, const int, ULONG_PTR) override {} void DisconnectEvent(con &, const int, ULONG_PTR) override {} void CreateConnection(std::shared_ptr<con> &res) override { res = std::make_shared<con>(this); } }; std::atomic<bool> g_serverReady(false); void waitReady() { while (!g_serverReady.load()) std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::this_thread::sleep_for(std::chrono::milliseconds(200)); } std::mutex g_capturedMutex; std::map<uint64_t, std::vector<uint8_t>> g_captured; void runServerThread(std::map<std::string, ssl::CertificateBundle> certs, int port) { try { quic serverSock(certs, "127.0.0.1", port, 64, -1); serverSock.setStreamCallback([](netplus::socket *, uint64_t stream_id, const std::vector<uint8_t> &data, bool) { std::lock_guard<std::mutex> lk(g_capturedMutex); auto &buf = g_captured[stream_id]; buf.insert(buf.end(), data.begin(), data.end()); }); NullServer srv({&serverSock}); g_serverReady.store(true); srv.runEventloop(); } catch (NetException &e) { std::cerr << "[server] NetException: " << e.what() << std::endl; g_serverReady.store(true); } catch (std::exception &e) { std::cerr << "[server] Exception: " << e.what() << std::endl; g_serverReady.store(true); } } } // namespace int main() { std::cout << "=== HTTP/3 Control Streams Unit Test ===" << std::endl; const int kPort = 19644; int rc = 0; try { x509cert cert; if (!cert.loadFromBuffer(test_cert_der)) throw std::runtime_error("failed to load embedded test certificate"); ssl::CertificateBundle bundle; bundle.cert = cert; bundle.privateKeyDer = std::vector<uint8_t>(test_key_der.begin(), test_key_der.end()); bundle.rsa_key = rsa(bundle.privateKeyDer); bundle.chain.push_back(std::vector<uint8_t>( MKCERT_ROOT_CA_DER, MKCERT_ROOT_CA_DER + MKCERT_ROOT_CA_DER_LEN)); std::map<std::string, ssl::CertificateBundle> certs; certs["localhost"] = bundle; certs["127.0.0.1"] = bundle; std::thread serverThread(runServerThread, certs, kPort); struct JoinGuard { std::thread *t; ~JoinGuard() { if (t && t->joinable()) { event::Running = false; t->join(); } } } joinGuard{&serverThread}; waitReady(); // ---- Drive a real client-side handshake (mirrors HttpClient:: // tryHttp3First()'s pattern in http.cpp) ---- libhttppp::Http3QuicSocket client; client.connect("127.0.0.1", kPort, true /*nonblock*/); client.setNonBlock(); auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); while (!client.getHandshakeDone()) { if (std::chrono::steady_clock::now() >= deadline) throw std::runtime_error("QUIC handshake did not complete in time"); try { client.pumpNetwork(MSG_DONTWAIT); } catch (NetException &e) { if (e.getErrorType() != NetException::Note) throw; } std::this_thread::sleep_for(std::chrono::milliseconds(1)); } check(true, "client/server QUIC handshake completes"); // ---- The unit under test: exercise it directly, like a call from // sendHandshakeDone() would, without needing ALPN "h3" negotiation // wired up end to end. ---- client.sendControlStreams(); // Idempotent: a second call must not open additional streams. client.sendControlStreams(); // Give the server's event loop a moment to dispatch the datagrams. auto pumpDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); size_t seen = 0; while (std::chrono::steady_clock::now() < pumpDeadline) { { std::lock_guard<std::mutex> lk(g_capturedMutex); seen = g_captured.size(); } if (seen >= 3) break; std::this_thread::sleep_for(std::chrono::milliseconds(20)); } std::map<uint64_t, std::vector<uint8_t>> captured; { std::lock_guard<std::mutex> lk(g_capturedMutex); captured = g_captured; } check(captured.size() == 3, "exactly 3 unidirectional streams observed (no duplicate on 2nd call)"); // Streams are opened in program order (control, encoder, decoder) // and openStream() hands out monotonically increasing IDs, so // sorting by stream_id recovers that order. std::vector<std::vector<uint8_t>> byOrder; for (auto &kv : captured) byOrder.push_back(kv.second); if (byOrder.size() == 3) { const std::vector<uint8_t> &ctrl = byOrder[0]; const std::vector<uint8_t> &enc = byOrder[1]; const std::vector<uint8_t> &dec = byOrder[2]; check(ctrl.size() == 3 && ctrl[0] == 0x00 && ctrl[1] == 0x04 && ctrl[2] == 0x00, "control stream: type 0x00 + empty SETTINGS frame (0x04 0x00)"); check(enc.size() == 1 && enc[0] == 0x02, "QPACK encoder stream: type 0x02"); check(dec.size() == 1 && dec[0] == 0x03, "QPACK decoder stream: type 0x03"); // RFC 9000 6.1: unidirectional client-initiated stream IDs are // 2, 6, 10, ... (id % 4 == 2). bool idsOk = true; for (auto &kv : captured) if (kv.first % 4 != 2) idsOk = false; check(idsOk, "all 3 streams use client-initiated unidirectional IDs (id % 4 == 2)"); } else { check(false, "control stream: type 0x00 + empty SETTINGS frame (0x04 0x00)"); check(false, "QPACK encoder stream: type 0x02"); check(false, "QPACK decoder stream: type 0x03"); check(false, "all 3 streams use client-initiated unidirectional IDs (id % 4 == 2)"); } } catch (NetException &e) { std::cerr << "NetException: " << e.what() << std::endl; rc = 1; } catch (std::exception &e) { std::cerr << "Exception: " << e.what() << std::endl; rc = 1; } std::cout << "==============================" << std::endl; std::cout << "Results: " << g_passed << " passed, " << g_failed << " failed" << std::endl; std::cout << "==============================" << std::endl; return (rc != 0 || g_failed > 0) ? 1 : 0; } Loading
test/CMakeLists.txt +12 −0 Changes for test/CMakeLists.txt: 12 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -105,3 +105,15 @@ else() endif() add_test(http3_checksum_test http3_checksum_test) add_executable(http3_control_streams_test http3_control_streams_test.cpp ) if(WIN32) target_link_libraries (http3_control_streams_test httppp-static ws2_32) else() target_link_libraries (http3_control_streams_test httppp-static) endif() add_test(http3_control_streams_test http3_control_streams_test)
test/http3_control_streams_test.cpp 0 → 100644 +250 −0 Changes for test/http3_control_streams_test.cpp: 250 added lines, 0 removed lines. Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ // Unit test for libhttppp::Http3QuicSocket::sendControlStreams() -- the // override that now owns the HTTP/3 mandatory-control-stream logic // (RFC 9114 6.2.1) moved out of netplus::quic::sendControlStreams(), which // is a no-op base left purely as a transport-layer extension point. // // A real loopback QUIC (TLS 1.3) handshake is used because openStream()/ // sendStreamData() need real peer transport parameters (stream limits) to // avoid throwing -- there is no lighter-weight mock for this transport. // The client side is the Http3QuicSocket under test (constructing the // client directly, rather than going through the server-side accept() // path, is what makes calling sendControlStreams() from the test possible // at all: netplus::quic::accept() always mints plain netplus::quic child // connections for accepted peers, never the listener's dynamic type -- see // quic.cpp's accept(), which hardcodes std::make_shared<quic>()). The // server side is a plain netplus::quic listener that captures whatever // unidirectional streams arrive via setStreamCallback(), the same hook // production code (HttpD) registers on the server side. #include <atomic> #include <chrono> #include <cstdint> #include <iostream> #include <map> #include <mutex> #include <thread> #include <vector> #include <netplus/connection.h> #include <netplus/eventapi.h> #include <netplus/exception.h> #include <netplus/socket.h> #include "httpd.h" #include "https_ca_cert.h" #include "https_certs.h" using namespace netplus; namespace { int g_passed = 0, g_failed = 0; void check(bool ok, const std::string &name) { if (ok) { std::cout << " PASS: " << name << std::endl; ++g_passed; } else { std::cout << " FAIL: " << name << std::endl; ++g_failed; } } // Minimal event sink: only needed to drive the server-side QUIC state // machine (accept()/handshake/stream dispatch) via runEventloop(). None of // the assertions happen here -- they happen via the stream callback below, // which fires for every accepted child connection (quic::accept() copies // the listener's _stream_callback onto each child it creates). class NullServer : public event { public: explicit NullServer(std::vector<netplus::socket *> socks, int timeout = 500) : event(socks, timeout) {} void RequestEvent(con &, const int, ULONG_PTR) override {} void ResponseEvent(con &, const int, ULONG_PTR) override {} void ConnectEvent(con &, const int, ULONG_PTR) override {} void DisconnectEvent(con &, const int, ULONG_PTR) override {} void CreateConnection(std::shared_ptr<con> &res) override { res = std::make_shared<con>(this); } }; std::atomic<bool> g_serverReady(false); void waitReady() { while (!g_serverReady.load()) std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::this_thread::sleep_for(std::chrono::milliseconds(200)); } std::mutex g_capturedMutex; std::map<uint64_t, std::vector<uint8_t>> g_captured; void runServerThread(std::map<std::string, ssl::CertificateBundle> certs, int port) { try { quic serverSock(certs, "127.0.0.1", port, 64, -1); serverSock.setStreamCallback([](netplus::socket *, uint64_t stream_id, const std::vector<uint8_t> &data, bool) { std::lock_guard<std::mutex> lk(g_capturedMutex); auto &buf = g_captured[stream_id]; buf.insert(buf.end(), data.begin(), data.end()); }); NullServer srv({&serverSock}); g_serverReady.store(true); srv.runEventloop(); } catch (NetException &e) { std::cerr << "[server] NetException: " << e.what() << std::endl; g_serverReady.store(true); } catch (std::exception &e) { std::cerr << "[server] Exception: " << e.what() << std::endl; g_serverReady.store(true); } } } // namespace int main() { std::cout << "=== HTTP/3 Control Streams Unit Test ===" << std::endl; const int kPort = 19644; int rc = 0; try { x509cert cert; if (!cert.loadFromBuffer(test_cert_der)) throw std::runtime_error("failed to load embedded test certificate"); ssl::CertificateBundle bundle; bundle.cert = cert; bundle.privateKeyDer = std::vector<uint8_t>(test_key_der.begin(), test_key_der.end()); bundle.rsa_key = rsa(bundle.privateKeyDer); bundle.chain.push_back(std::vector<uint8_t>( MKCERT_ROOT_CA_DER, MKCERT_ROOT_CA_DER + MKCERT_ROOT_CA_DER_LEN)); std::map<std::string, ssl::CertificateBundle> certs; certs["localhost"] = bundle; certs["127.0.0.1"] = bundle; std::thread serverThread(runServerThread, certs, kPort); struct JoinGuard { std::thread *t; ~JoinGuard() { if (t && t->joinable()) { event::Running = false; t->join(); } } } joinGuard{&serverThread}; waitReady(); // ---- Drive a real client-side handshake (mirrors HttpClient:: // tryHttp3First()'s pattern in http.cpp) ---- libhttppp::Http3QuicSocket client; client.connect("127.0.0.1", kPort, true /*nonblock*/); client.setNonBlock(); auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); while (!client.getHandshakeDone()) { if (std::chrono::steady_clock::now() >= deadline) throw std::runtime_error("QUIC handshake did not complete in time"); try { client.pumpNetwork(MSG_DONTWAIT); } catch (NetException &e) { if (e.getErrorType() != NetException::Note) throw; } std::this_thread::sleep_for(std::chrono::milliseconds(1)); } check(true, "client/server QUIC handshake completes"); // ---- The unit under test: exercise it directly, like a call from // sendHandshakeDone() would, without needing ALPN "h3" negotiation // wired up end to end. ---- client.sendControlStreams(); // Idempotent: a second call must not open additional streams. client.sendControlStreams(); // Give the server's event loop a moment to dispatch the datagrams. auto pumpDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); size_t seen = 0; while (std::chrono::steady_clock::now() < pumpDeadline) { { std::lock_guard<std::mutex> lk(g_capturedMutex); seen = g_captured.size(); } if (seen >= 3) break; std::this_thread::sleep_for(std::chrono::milliseconds(20)); } std::map<uint64_t, std::vector<uint8_t>> captured; { std::lock_guard<std::mutex> lk(g_capturedMutex); captured = g_captured; } check(captured.size() == 3, "exactly 3 unidirectional streams observed (no duplicate on 2nd call)"); // Streams are opened in program order (control, encoder, decoder) // and openStream() hands out monotonically increasing IDs, so // sorting by stream_id recovers that order. std::vector<std::vector<uint8_t>> byOrder; for (auto &kv : captured) byOrder.push_back(kv.second); if (byOrder.size() == 3) { const std::vector<uint8_t> &ctrl = byOrder[0]; const std::vector<uint8_t> &enc = byOrder[1]; const std::vector<uint8_t> &dec = byOrder[2]; check(ctrl.size() == 3 && ctrl[0] == 0x00 && ctrl[1] == 0x04 && ctrl[2] == 0x00, "control stream: type 0x00 + empty SETTINGS frame (0x04 0x00)"); check(enc.size() == 1 && enc[0] == 0x02, "QPACK encoder stream: type 0x02"); check(dec.size() == 1 && dec[0] == 0x03, "QPACK decoder stream: type 0x03"); // RFC 9000 6.1: unidirectional client-initiated stream IDs are // 2, 6, 10, ... (id % 4 == 2). bool idsOk = true; for (auto &kv : captured) if (kv.first % 4 != 2) idsOk = false; check(idsOk, "all 3 streams use client-initiated unidirectional IDs (id % 4 == 2)"); } else { check(false, "control stream: type 0x00 + empty SETTINGS frame (0x04 0x00)"); check(false, "QPACK encoder stream: type 0x02"); check(false, "QPACK decoder stream: type 0x03"); check(false, "all 3 streams use client-initiated unidirectional IDs (id % 4 == 2)"); } } catch (NetException &e) { std::cerr << "NetException: " << e.what() << std::endl; rc = 1; } catch (std::exception &e) { std::cerr << "Exception: " << e.what() << std::endl; rc = 1; } std::cout << "==============================" << std::endl; std::cout << "Results: " << g_passed << " passed, " << g_failed << " failed" << std::endl; std::cout << "==============================" << std::endl; return (rc != 0 || g_failed > 0) ? 1 : 0; }