test(http): replay one fallback route table over both listeners

The point of the feature is that a URL means the same thing over either
protocol, so the test states it that way: one route map plus one
fallback, registered with ListenerHTTP1 and ListenerHTTP, asked the same
eight questions, asserting identical answers.

Covers exact routes beating the fallback, query strings still routing to
the bare path, the fallback seeing the full target including the query,
the fallback choosing its own status (404 and 303), a throwing fallback
becoming a 500, and an unset fallback still producing the listener's own
synthetic 404.

Verified against a broken build both ways: dropping the fallback lookup
fails 9 checks, dropping the query-strip fails 2.
This commit is contained in:
catbot 2026-07-28 19:42:16 +00:00
commit fc0adddbe9
2 changed files with 158 additions and 0 deletions

View file

@ -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/<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);
}