Crafter.Network/tests/ShouldFallbackUnknownRoutes/main.cpp

157 lines
6.7 KiB
C++
Raw Normal View History

//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A route table whose paths cannot be enumerated up front — `/order/<token>`,
// `/shop/<slug>` — 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<Exchange, 8> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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<HTTPResponse(std::string_view)>& 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);
}