diff --git a/project.cpp b/project.cpp index cddc2cb..c8180e2 100644 --- a/project.cpp +++ b/project.cpp @@ -119,6 +119,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // crafter-network static lib via .Dependencies({ &cfg }). if (cfg.target == "x86_64-pc-linux-gnu") { cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg }); + cfg.AddTest("ShouldFallbackUnknownRoutes").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg }); cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg }); diff --git a/tests/ShouldFallbackUnknownRoutes/main.cpp b/tests/ShouldFallbackUnknownRoutes/main.cpp new file mode 100644 index 0000000..89e37b8 --- /dev/null +++ b/tests/ShouldFallbackUnknownRoutes/main.cpp @@ -0,0 +1,157 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A route table whose paths cannot be enumerated up front — `/order/`, +// `/shop/` — is served through the listeners' `fallback` handler. The +// same table is registered with ListenerHTTP1 and ListenerHTTP and asked the +// same questions, because the point of the feature is that a URL means the +// same thing over either protocol. + +import Crafter.Network; +import Crafter.Thread; +import std; +using namespace Crafter; + +namespace { + constexpr std::uint16_t kPortHTTP1 = 8096; + constexpr std::uint16_t kPortHTTP1Plain = 8097; + constexpr std::uint16_t kPortHTTP3 = 8098; + + int failures = 0; + + void Check(bool condition, std::string_view what) { + if (!condition) { + std::println("FAIL: {}", what); + ++failures; + } + } + + // Every exchange the test performs, phrased so it can be replayed against + // either protocol. `body` is matched exactly; an empty expectation means + // "don't care". + struct Exchange { + std::string_view what; + std::string_view path; + std::string_view status; + std::string_view body; + }; + + constexpr std::array kExchanges = {{ + // A registered route still wins: fallback only sees what routes miss. + {"an exact route beats the fallback", "/", "200", "root"}, + {"query strings still route to the path", "/?utm=1", "200", "root"}, + // ...and everything else reaches the fallback with the target intact. + {"an unknown path reaches the fallback", "/shop/blue-mug", "200", "slug:blue-mug"}, + {"a second segment value reaches it too", "/shop/red-mug", "200", "slug:red-mug"}, + {"the fallback sees the full target", "/shop/mug?ref=x", "200", "slug:mug?ref=x"}, + {"the fallback may answer 404 itself", "/shop/", "404", "no such product"}, + {"the fallback may answer non-404", "/order/deadbeef", "303", ""}, + // A throwing fallback must be contained exactly like a throwing route. + {"a throwing fallback becomes a 500", "/boom", "500", ""}, + }}; + + std::unordered_map> MakeRoutes() { + return { + {"/", [](const HTTPRequest&) { return CreateResponseHTTP("200", "root"); }}, + }; + } + + // Stands in for an application router: the set of valid slugs and order + // tokens is only known at runtime, so none of these paths could have been + // registered in `routes`. + HTTPResponse Fallback(const HTTPRequest& request) { + const std::string_view path = PathWithoutQueryHTTP(request.path); + if (path == "/boom") throw std::runtime_error("fallback exploded"); + if (path.starts_with("/order/")) { + return CreateResponseHTTP("303", {{"location", "/"}}, ""); + } + if (path.starts_with("/shop/")) { + if (path.size() == std::string_view("/shop/").size()) { + return CreateResponseHTTP("404", "no such product"); + } + // The full target, query string and all, reached the handler. + return CreateResponseHTTP("200", std::format("slug:{}", + request.path.substr(std::string_view("/shop/").size()))); + } + return CreateResponseHTTP("404", "unrouted"); + } + + void Replay(std::string_view protocol, + const std::function& send) { + for (const Exchange& exchange : kExchanges) { + HTTPResponse response = send(exchange.path); + Check(response.status == exchange.status, + std::format("{}: {} (status {}, wanted {})", + protocol, exchange.what, response.status, exchange.status)); + if (!exchange.body.empty()) { + Check(response.body == exchange.body, + std::format("{}: {} (body '{}', wanted '{}')", + protocol, exchange.what, response.body, exchange.body)); + } + } + } +} + +int main() { + ThreadPool::Start(); + + // A hung read would otherwise stall the whole suite. + std::thread watchdog([] { + std::this_thread::sleep_for(std::chrono::seconds(30)); + std::println("timed out"); + std::cout.flush(); + std::_Exit(1); + }); + watchdog.detach(); + + try { + // ── HTTP/1.1 over TCP ──────────────────────────────────────────── + { + ListenerAsyncHTTP1 listener(kPortHTTP1, MakeRoutes(), Fallback); + ClientHTTP1 client("localhost", kPortHTTP1); + Replay("http1", [&](std::string_view path) { + return client.Send(CreateRequestHTTP("GET", std::string(path), "localhost")); + }); + + // Without a fallback the listener keeps synthesising its own 404, + // so nothing about the default behaviour moved. + ListenerAsyncHTTP1 plain(kPortHTTP1Plain, MakeRoutes()); + ClientHTTP1 plainClient("localhost", kPortHTTP1Plain); + HTTPResponse missing = plainClient.Send( + CreateRequestHTTP("GET", "/shop/blue-mug", "localhost")); + Check(missing.status == "404", "http1: no fallback still means a synthetic 404"); + Check(missing.body == "Not Found", "http1: ...with the listener's own body"); + plain.Stop(); + + listener.Stop(); + } + + // ── HTTP/3 over QUIC, same routes and same fallback ────────────── + { + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + ListenerAsyncHTTP listener(kPortHTTP3, serverCreds, MakeRoutes(), Fallback); + + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + ClientHTTP client("localhost", kPortHTTP3, clientCreds); + Replay("http3", [&](std::string_view path) { + return client.Send(CreateRequestHTTP("GET", std::string(path), "localhost")); + }); + } + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + std::cout.flush(); + std::_Exit(1); + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + std::cout.flush(); + std::_Exit(1); + } + // See ShouldSendRecieveQUICStream: msquic's RegistrationClose blocks on + // outstanding connections, so skip graceful teardown once we are done. + std::cout.flush(); + std::_Exit(0); +}