//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // The HTTP/1.1 round-trip of ShouldSendRecieveHTTP1, over TLS. The point is // that nothing above the transport changed: the same routes, the same // keep-alive reuse, the same 404/500 behaviour, now with libssl underneath. // // Also covers what only exists under TLS: ALPN, `scheme` reported as https, // certificate verification against a private trust anchor, and the two ways // verification is supposed to fail. import Crafter.Network; import std; using namespace Crafter; namespace { int failures = 0; void Check(bool condition, std::string_view what) { if (!condition) { std::println("FAIL: {}", what); ++failures; } } std::unordered_map> Routes() { std::unordered_map> routes; routes["/"] = [](const HTTPRequest&) { return CreateResponseHTTP("200", "Hello World!"); }; routes["/echo"] = [](const HTTPRequest& request) { return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, request.body); }; routes["/scheme"] = [](const HTTPRequest& request) { return CreateResponseHTTP("200", request.scheme); }; routes["/query"] = [](const HTTPRequest& request) { return CreateResponseHTTP("200", request.path); }; routes["/boom"] = [](const HTTPRequest&) -> HTTPResponse { throw std::runtime_error("handler exploded"); }; return routes; } } int main() { try { // The listener mints an ephemeral certificate; the client is handed // that same certificate as a trust anchor, so this exercises real // chain *and* hostname verification rather than skipping both. ListenerAsyncHTTP1 listener(8095, Routes(), TLSServerCredentials{ .selfSigned = true }); Check(listener.listener.Secure(), "the listener reports itself as https"); TLSClientCredentials credentials; credentials.caPem = GetSelfSignedCertificatePem().certificate; ClientHTTP1 client("localhost", 8095, credentials); Check(client.Secure(), "the client reports itself as https"); HTTPResponse hello = client.Send(CreateRequestHTTP("GET", "/", "localhost")); Check(hello.status == "200", "GET / status"); Check(hello.body == "Hello World!", "GET / body"); Check(hello.headers.contains("date"), "the server stamps a Date header"); Check(hello.headers.at("content-length") == "12", "content-length matches the body"); // ALPN is the whole reason a TLS server can tell HTTP/1.1 from h2 // before reading a byte, so assert it actually got negotiated. Check(client.Protocol() == "http/1.1", "ALPN negotiated http/1.1"); HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", std::string("ping pong"))); Check(echoed.status == "200", "POST /echo status"); Check(echoed.body == "ping pong", "POST /echo returns the request body"); Check(echoed.headers.at("content-type") == "text/plain", "handler headers survive"); // Origin-form targets carry no scheme; the transport has to supply it. HTTPResponse scheme = client.Send(CreateRequestHTTP("GET", "/scheme", "localhost")); Check(scheme.body == "https", "the handler sees scheme=https over TLS"); HTTPResponse query = client.Send(CreateRequestHTTP("GET", "/query?a=1&b=2", "localhost")); Check(query.status == "200", "query-string request routes to the bare path"); Check(query.body == "/query?a=1&b=2", "the handler sees the full target"); HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nope", "localhost")); Check(missing.status == "404", "unknown route is a 404"); HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/", "localhost")); Check(head.status == "200", "HEAD status"); Check(head.body.empty(), "HEAD has no body"); Check(head.headers.at("content-length") == "12", "HEAD still advertises the length"); HTTPResponse boom = client.Send(CreateRequestHTTP("GET", "/boom", "localhost")); Check(boom.status == "500", "a throwing handler yields 500"); Check(boom.body.find("handler exploded") != std::string::npos, "500 carries the reason"); // A body big enough to span many TLS records, to catch a Write() that // mishandles a partial SSL_write. const std::string large(512 * 1024, 'z'); HTTPResponse bulk = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", large)); Check(bulk.status == "200", "large POST status"); Check(bulk.body == large, "a body spanning many TLS records survives intact"); // Every exchange above shared one TLS session — no rehandshaking per // request, which is what makes keep-alive worth having here. Check(listener.listener.AcceptedCount() == 1, "the whole test used a single connection"); Check(client.Connected(), "the connection is still pooled"); Check(listener.listener.HandshakeFailureCount() == 0, "no handshake failed"); // ── Verification has to actually fail when it should ────────────── // Default credentials: system trust store only, so a self-signed // certificate must be rejected rather than quietly accepted. { ClientHTTP1 strict("localhost", 8095, TLSClientCredentials{}); bool rejected = false; try { strict.Send(CreateRequestHTTP("GET", "/", "localhost")); } catch (const TLSException&) { rejected = true; } Check(rejected, "an untrusted self-signed certificate is rejected"); Check(!strict.Connected(), "a rejected connection is not left pooled"); } // Right certificate, wrong name: the chain checks out but the SANs say // localhost, so the name check has to catch it. Verifying the chain // without the name is the classic way TLS gets deployed insecurely. { TLSClientCredentials mismatched; mismatched.caPem = GetSelfSignedCertificatePem().certificate; mismatched.serverName = "not-localhost.invalid"; ClientHTTP1 wrongName("localhost", 8095, mismatched); bool rejected = false; try { wrongName.Send(CreateRequestHTTP("GET", "/", "localhost")); } catch (const TLSException&) { rejected = true; } Check(rejected, "a certificate for the wrong name is rejected"); } // insecureNoServerValidation is the dev escape hatch; it has to work, // because the alternative is people shipping their own worse one. { ClientHTTP1 insecure("localhost", 8095, TLSClientCredentials{ .insecureNoServerValidation = true }); HTTPResponse response = insecure.Send(CreateRequestHTTP("GET", "/", "localhost")); Check(response.body == "Hello World!", "insecureNoServerValidation talks to the same server"); } // A plaintext client against a TLS listener: its request line is not a // TLS record, so the handshake fails and the server counts it. This is // what a port scanner or a misconfigured caller looks like, and it // must not disturb anything else. { const std::uint64_t before = listener.listener.HandshakeFailureCount(); try { ClientHTTP1 plaintext("localhost", 8095); plaintext.Send(CreateRequestHTTP("GET", "/", "localhost")); } catch (const std::exception&) { // Expected: the listener drops it without answering. } // The handshake is rejected on the connection thread, so give it a // moment to record the failure before reading the counter. for (int wait = 0; wait < 100; ++wait) { if (listener.listener.HandshakeFailureCount() > before) break; std::this_thread::sleep_for(std::chrono::milliseconds(20)); } Check(listener.listener.HandshakeFailureCount() == before + 1, "a plaintext peer is counted as a handshake failure"); } // And the TLS listener still serves after all that. HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost")); Check(after.body == "Hello World!", "the listener still serves after a bad peer"); listener.Stop(); } catch (const std::exception& error) { std::println("threw: {}", error.what()); return 1; } if (failures != 0) { std::println("{} check(s) failed", failures); return 1; } return 0; }