From 26e2a8772a05c34d5cc633d25c8f40caecba6df0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 7 Feb 2026 20:21:00 -0800 Subject: [PATCH 1/5] Tidy up file placement --- include/boost/http/server/router.hpp | 9 ++ .../router_base.cpp} | 31 ++-- .../{any_router.hpp => router_base.hpp} | 4 +- src/server/http_worker.cpp | 14 +- .../router_base.cpp} | 147 ++++++++++++++---- test/unit/server/router.cpp | 18 ++- 6 files changed, 174 insertions(+), 49 deletions(-) rename src/server/{any_router.cpp => detail/router_base.cpp} (95%) rename src/server/detail/{any_router.hpp => router_base.hpp} (96%) rename test/unit/server/{any_router.cpp => detail/router_base.cpp} (90%) diff --git a/include/boost/http/server/router.hpp b/include/boost/http/server/router.hpp index e5102344..b0b461c0 100644 --- a/include/boost/http/server/router.hpp +++ b/include/boost/http/server/router.hpp @@ -13,7 +13,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -598,6 +600,13 @@ class router : public detail::router_base router_options options = {}) : detail::router_base(options.v_) { + set_options_handler( + [](P& rp, std::string_view allow) -> route_task { + rp.status(status::no_content); + rp.res.set(field::allow, allow); + (void)(co_await rp.send()); + co_return route_done; + }); } /** Construct a router from another router with compatible types. diff --git a/src/server/any_router.cpp b/src/server/detail/router_base.cpp similarity index 95% rename from src/server/any_router.cpp rename to src/server/detail/router_base.cpp index 32d320df..9de7ca6f 100644 --- a/src/server/any_router.cpp +++ b/src/server/detail/router_base.cpp @@ -7,10 +7,12 @@ // Official repository: https://github.com/cppalliance/http // -#include "src/server/detail/any_router.hpp" +#include "src/server/detail/router_base.hpp" #include #include #include +#include +#include #include #include #include "src/server/detail/pct_decode.hpp" @@ -206,8 +208,8 @@ dispatch_loop(route_params& p, bool is_options) const std::size_t last_matched = SIZE_MAX; std::uint32_t current_depth = 0; - std::uint64_t options_methods = 0; - std::vector options_custom_verbs; + std::uint64_t matched_methods = 0; + std::vector matched_custom_verbs; std::size_t path_stack[router_base::max_path_depth]; path_stack[0] = 0; @@ -286,12 +288,12 @@ dispatch_loop(route_params& p, bool is_options) const if(!ancestors_ok) continue; - // Collect methods from matching end-route matchers for OPTIONS - if(is_options && m.end_) + // Collect methods from matching end-route matchers + if(m.end_) { - options_methods |= m.allowed_methods_; + matched_methods |= m.allowed_methods_; for(auto const& v : m.custom_verbs_) - options_custom_verbs.push_back(v); + matched_custom_verbs.push_back(v); } if(m.end_ && !e.match_method( @@ -364,12 +366,23 @@ dispatch_loop(route_params& p, bool is_options) const co_return route_error(pv.ec_); // OPTIONS fallback - if(is_options && options_methods != 0 && options_handler_) + if(is_options && matched_methods != 0 && options_handler_) { - std::string allow = build_allow_header(options_methods, options_custom_verbs); + std::string allow = build_allow_header(matched_methods, matched_custom_verbs); co_return co_await options_handler_->invoke(p, allow); } + // 405 fallback: path matched but method didn't + if(!is_options && + (matched_methods != 0 || !matched_custom_verbs.empty())) + { + std::string allow = build_allow_header(matched_methods, matched_custom_verbs); + p.res.set(field::allow, allow); + p.res.set_status(status::method_not_allowed); + (void)(co_await p.send()); + co_return route_done; + } + co_return route_next; } diff --git a/src/server/detail/any_router.hpp b/src/server/detail/router_base.hpp similarity index 96% rename from src/server/detail/any_router.hpp rename to src/server/detail/router_base.hpp index dcb4dcc9..50d551c6 100644 --- a/src/server/detail/any_router.hpp +++ b/src/server/detail/router_base.hpp @@ -7,8 +7,8 @@ // Official repository: https://github.com/cppalliance/http // -#ifndef BOOST_HTTP_SRC_SERVER_DETAIL_ANY_ROUTER_HPP -#define BOOST_HTTP_SRC_SERVER_DETAIL_ANY_ROUTER_HPP +#ifndef BOOST_HTTP_SRC_SERVER_DETAIL_ROUTER_BASE_HPP +#define BOOST_HTTP_SRC_SERVER_DETAIL_ROUTER_BASE_HPP #include #include diff --git a/src/server/http_worker.cpp b/src/server/http_worker.cpp index 5c5ee79e..e9ae7850 100644 --- a/src/server/http_worker.cpp +++ b/src/server/http_worker.cpp @@ -74,10 +74,20 @@ do_http_session() { auto rv = co_await fr.dispatch(rp.req.method(), rp.url, rp); + + if(rv.what() == route_what::close) + break; + + if(rv.what() == route_what::next) + { + rp.status(http::status::not_found); + (void)(co_await rp.send()); + } + if(rv.failed()) { - // VFALCO log rv.error() - break; + rp.status(http::status::internal_server_error); + (void)(co_await rp.send()); } if(! rp.res.keep_alive()) diff --git a/test/unit/server/any_router.cpp b/test/unit/server/detail/router_base.cpp similarity index 90% rename from test/unit/server/any_router.cpp rename to test/unit/server/detail/router_base.cpp index 0458d82d..f194189a 100644 --- a/test/unit/server/any_router.cpp +++ b/test/unit/server/detail/router_base.cpp @@ -11,16 +11,23 @@ #include #include +#include #include "test_suite.hpp" namespace boost { namespace http { -struct any_router_test +struct router_base_test { using params = route_params; using test_router = router; + static void init_sink(params& p) + { + p.res_body = capy::any_buffer_sink( + capy::test::buffer_sink{}); + } + void testCopyConstruction() { auto counter = std::make_shared(0); @@ -35,6 +42,7 @@ struct any_router_test router<> ar2(ar1); params req; + init_sink(req); capy::test::run_blocking()(ar1.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST_EQ(*counter, 1); @@ -66,6 +74,7 @@ struct any_router_test ar2 = ar1; params req; + init_sink(req); capy::test::run_blocking()(ar1.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST_EQ(*counter, 1); @@ -90,6 +99,7 @@ struct any_router_test ar1 = ar2; // assign to default-constructed params req; + init_sink(req); capy::test::run_blocking()(ar1.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST_EQ(*counter, 1); @@ -97,7 +107,6 @@ struct any_router_test void testOptionsHandler() { - std::string captured_allow; test_router r; r.add(http::method::get, "/api/users", [](params&) -> route_task { @@ -107,18 +116,14 @@ struct any_router_test { co_return route_done; }); - r.set_options_handler( - [&captured_allow](params&, std::string_view allow) -> route_task - { - captured_allow = allow; - co_return route_done; - }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::options, urls::url_view("/api/users"), req)); - BOOST_TEST(captured_allow.find("GET") != std::string::npos); - BOOST_TEST(captured_allow.find("POST") != std::string::npos); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string::npos); + BOOST_TEST(allow.find("POST") != std::string::npos); } void testExplicitOptionsPriority() @@ -147,6 +152,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::options, urls::url_view("/test"), req)); BOOST_TEST(explicit_called); @@ -155,7 +161,6 @@ struct any_router_test void testAllMethodsHandler() { - std::string captured_allow; test_router r; // Use route().all() but have handler return route_next // so OPTIONS fallback can run @@ -163,25 +168,20 @@ struct any_router_test { co_return route_next; }); - r.set_options_handler( - [&captured_allow](params&, std::string_view allow) -> route_task - { - captured_allow = allow; - co_return route_done; - }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::options, urls::url_view("/wildcard"), req)); // .all() should produce a full Allow header - BOOST_TEST(captured_allow.find("GET") != std::string::npos); - BOOST_TEST(captured_allow.find("POST") != std::string::npos); - BOOST_TEST(captured_allow.find("DELETE") != std::string::npos); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string::npos); + BOOST_TEST(allow.find("POST") != std::string::npos); + BOOST_TEST(allow.find("DELETE") != std::string::npos); } void testOptionsStarGlobal() { - std::string captured_allow; test_router r; r.add(http::method::get, "/a", [](params&) -> route_task { @@ -195,20 +195,54 @@ struct any_router_test { co_return route_done; }); - r.set_options_handler( - [&captured_allow](params&, std::string_view allow) -> route_task - { - captured_allow = allow; - co_return route_done; - }); params req; + init_sink(req); + capy::test::run_blocking()(r.dispatch( + http::method::options, urls::url_view("*"), req)); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string::npos); + BOOST_TEST(allow.find("POST") != std::string::npos); + BOOST_TEST(allow.find("PUT") != std::string::npos); + } + + void testMethodNotAllowed() + { + test_router r; + r.add(http::method::get, "/foo", [](params&) -> route_task + { + co_return route_done; + }); + r.add(http::method::post, "/foo", [](params&) -> route_task + { + co_return route_done; + }); + + params req; + init_sink(req); + capy::test::run_blocking()(r.dispatch( + http::method::put, urls::url_view("/foo"), req)); + BOOST_TEST(req.res.status() == status::method_not_allowed); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string::npos); + BOOST_TEST(allow.find("POST") != std::string::npos); + } + + void testOptionsStarDefault() + { + test_router r; + r.add(http::method::get, "/x", [](params&) -> route_task + { + co_return route_done; + }); + + params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::options, urls::url_view("*"), req)); - // Should contain all registered methods - BOOST_TEST(captured_allow.find("GET") != std::string::npos); - BOOST_TEST(captured_allow.find("POST") != std::string::npos); - BOOST_TEST(captured_allow.find("PUT") != std::string::npos); + BOOST_TEST(req.res.status() == status::no_content); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string::npos); } //-------------------------------------------- @@ -243,6 +277,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/users/123"), req)); @@ -266,6 +301,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/users/42/posts/99"), req)); @@ -287,6 +323,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/files/a/b/c.txt"), req)); @@ -310,6 +347,7 @@ struct any_router_test // With optional group { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v2"), req)); BOOST_TEST_EQ(call_count, 1); @@ -319,6 +357,7 @@ struct any_router_test // Without optional group { params req; + init_sink(req); captured_version.clear(); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api"), req)); @@ -341,6 +380,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/items/abc-def-123"), req)); @@ -362,6 +402,7 @@ struct any_router_test // Wrong path { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/posts/123"), req)); BOOST_TEST(!handler_called); @@ -384,6 +425,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/john/myrepo/blob/main/src/index.js"), @@ -413,6 +455,7 @@ struct any_router_test // With extension { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/file.txt"), req)); BOOST_TEST_EQ(call_count, 1); @@ -422,6 +465,7 @@ struct any_router_test // Without extension { params req; + init_sink(req); captured_ext.clear(); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/file"), req)); @@ -439,6 +483,7 @@ struct any_router_test [](params&) -> route_task { co_return route_done; }); params req; + init_sink(req); // First request captures param capy::test::run_blocking()(r.dispatch( @@ -464,6 +509,7 @@ struct any_router_test }); params req; + init_sink(req); // %20 = space, path is decoded before matching capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/users/john%20doe"), req)); @@ -490,6 +536,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v1/users"), req)); @@ -516,6 +563,7 @@ struct any_router_test }()); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v1/users"), req)); @@ -546,6 +594,7 @@ struct any_router_test }()); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v1/users/123"), req)); @@ -580,6 +629,7 @@ struct any_router_test }()); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a/b/c/d/e/f"), req)); @@ -610,6 +660,7 @@ struct any_router_test }()); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v1/users/42"), req)); @@ -643,6 +694,7 @@ struct any_router_test }()); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/very/long/path/prefix/that/exceeds/small/string/optimization/tail"), @@ -741,6 +793,7 @@ struct any_router_test // Exact case - should match { params req; + init_sink(req); handler_called = false; capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/Api"), req)); @@ -750,6 +803,7 @@ struct any_router_test // Wrong case - should not match { params req; + init_sink(req); handler_called = false; capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api"), req)); @@ -772,6 +826,7 @@ struct any_router_test // Different case - should match { params req; + init_sink(req); handler_called = false; capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api"), req)); @@ -781,6 +836,7 @@ struct any_router_test // Upper case - should match { params req; + init_sink(req); handler_called = false; capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/API"), req)); @@ -803,6 +859,7 @@ struct any_router_test // Without trailing slash - should match { params req; + init_sink(req); handler_called = false; capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/users"), req)); @@ -812,6 +869,7 @@ struct any_router_test // With trailing slash - should not match in strict mode { params req; + init_sink(req); handler_called = false; capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/users/"), req)); @@ -834,6 +892,7 @@ struct any_router_test // Without trailing slash - should match { params req; + init_sink(req); handler_called = false; capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/users"), req)); @@ -843,6 +902,7 @@ struct any_router_test // With trailing slash - should also match in non-strict mode { params req; + init_sink(req); handler_called = false; capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/users/"), req)); @@ -867,6 +927,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/path:literal"), req)); BOOST_TEST(handler_called); @@ -886,6 +947,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/path*star"), req)); BOOST_TEST(handler_called); @@ -905,6 +967,7 @@ struct any_router_test // Use percent-encoded braces: { = %7B, } = %7D params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/path%7Bbrace%7D"), req)); BOOST_TEST(handler_called); @@ -923,6 +986,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/path%5Cslash"), req)); BOOST_TEST(handler_called); @@ -945,6 +1009,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/items/123"), req)); BOOST_TEST_EQ(captured_value, "123"); @@ -963,6 +1028,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/files/a/b/c.txt"), req)); BOOST_TEST_EQ(captured_value, "a/b/c.txt"); @@ -986,6 +1052,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/LAX-JFK"), req)); BOOST_TEST_EQ(from_val, "LAX"); @@ -1003,6 +1070,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/1/2/3/4/5/6/7/8/9/10"), req)); BOOST_TEST_EQ(req.params.size(), 10u); @@ -1023,6 +1091,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a//b"), req)); BOOST_TEST(handler_called); @@ -1041,6 +1110,7 @@ struct any_router_test }); params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST(handler_called); @@ -1061,6 +1131,7 @@ struct any_router_test // All levels { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a/b/c"), req)); BOOST_TEST_EQ(call_count, 1); @@ -1069,6 +1140,7 @@ struct any_router_test // Two levels { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a/b"), req)); BOOST_TEST_EQ(call_count, 2); @@ -1077,6 +1149,7 @@ struct any_router_test // One level { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a"), req)); BOOST_TEST_EQ(call_count, 3); @@ -1098,6 +1171,7 @@ struct any_router_test // Both groups { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a/b"), req)); BOOST_TEST_EQ(call_count, 1); @@ -1106,6 +1180,7 @@ struct any_router_test // First only { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a"), req)); BOOST_TEST_EQ(call_count, 2); @@ -1114,6 +1189,7 @@ struct any_router_test // Second only { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/b"), req)); BOOST_TEST_EQ(call_count, 3); @@ -1122,6 +1198,7 @@ struct any_router_test // Neither { params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view(""), req)); BOOST_TEST_EQ(call_count, 4); @@ -1142,6 +1219,7 @@ struct any_router_test // Empty param value - should not match params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/users//posts"), req)); BOOST_TEST(!handler_called); @@ -1161,6 +1239,7 @@ struct any_router_test // Empty wildcard value - should not match params req; + init_sink(req); capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/files/"), req)); BOOST_TEST(!handler_called); @@ -1175,6 +1254,8 @@ struct any_router_test testExplicitOptionsPriority(); testAllMethodsHandler(); testOptionsStarGlobal(); + testMethodNotAllowed(); + testOptionsStarDefault(); // Route pattern integration tests testParamCapture(); @@ -1235,8 +1316,8 @@ struct any_router_test }; TEST_SUITE( - any_router_test, - "boost.http.server.any_router"); + router_base_test, + "boost.http.server.detail.router_base"); } // http } // boost diff --git a/test/unit/server/router.cpp b/test/unit/server/router.cpp index ecccea2e..c1d1a26a 100644 --- a/test/unit/server/router.cpp +++ b/test/unit/server/router.cpp @@ -12,6 +12,7 @@ #include #include +#include #include "test_suite.hpp" namespace boost { @@ -22,6 +23,12 @@ struct router_test using params = route_params; using test_router = router; + static void init_sink(params& p) + { + p.res_body = capy::any_buffer_sink( + capy::test::buffer_sink{}); + } + struct derived_params : params {}; struct my_transform @@ -125,6 +132,7 @@ struct router_test route_result rv0 = route_done) { params req; + init_sink(req); route_result rv; capy::test::run_blocking([&](route_result res) { rv = res; })( r.dispatch(http::method::get, urls::url_view(url), req)); @@ -138,6 +146,7 @@ struct router_test route_result rv0 = route_done) { params req; + init_sink(req); route_result rv; capy::test::run_blocking([&](route_result res) { rv = res; })( r.dispatch(verb, urls::url_view(url), req)); @@ -151,6 +160,7 @@ struct router_test route_result rv0 = route_done) { params req; + init_sink(req); route_result rv; capy::test::run_blocking([&](route_result res) { rv = res; })( r.dispatch(verb, urls::url_view(url), req)); @@ -192,16 +202,16 @@ struct router_test // basic routing { test_router r; r.add(GET, "/", h_send); check(r, GET, "/"); } - { test_router r; r.add(GET, "/", h_next); check(r, POST, "/", route_next); } + { test_router r; r.add(GET, "/", h_next); check(r, POST, "/"); } { test_router r; r.add(POST, "/", h_send); check(r, POST, "/"); } // verb matching - case sensitive { test_router r; r.add(GET, "/", h_send); check(r, "GET", "/"); } - { test_router r; r.add(GET, "/", h_next); check(r, "get", "/", route_next); } + { test_router r; r.add(GET, "/", h_next); check(r, "get", "/"); } // custom verb { test_router r; r.add("CUSTOM", "/", h_send); check(r, "CUSTOM", "/"); } - { test_router r; r.add("CUSTOM", "/", h_next); check(r, "custom", "/", route_next); } + { test_router r; r.add("CUSTOM", "/", h_next); check(r, "custom", "/"); } // path matching { test_router r; r.add(GET, "/x", h_next); r.add(GET, "/y", h_send); check(r, GET, "/y"); } @@ -426,6 +436,7 @@ struct router_test test_router r; r.use(h_next); params req; + init_sink(req); BOOST_TEST_THROWS( capy::test::run_blocking()(r.dispatch( http::method::unknown, urls::url_view("/"), req)), @@ -437,6 +448,7 @@ struct router_test test_router r; r.use(h_next); params req; + init_sink(req); BOOST_TEST_THROWS( capy::test::run_blocking()(r.dispatch( "", urls::url_view("/"), req)), From 3ee2f01d2715b8753d428597d20e5d459c16c99b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 8 Feb 2026 05:45:41 -0800 Subject: [PATCH 2/5] Add test_worker --- test/unit/server/http_worker.cpp | 61 +++++- test/unit/server/test_worker.hpp | 351 +++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 test/unit/server/test_worker.hpp diff --git a/test/unit/server/http_worker.cpp b/test/unit/server/http_worker.cpp index 7864d0d0..dbef45ae 100644 --- a/test/unit/server/http_worker.cpp +++ b/test/unit/server/http_worker.cpp @@ -10,16 +10,75 @@ // Test that header file is self-contained. #include -#include "test_helpers.hpp" +#include "test_worker.hpp" namespace boost { namespace http { struct http_worker_test + : test_worker { + void + testBasicRoute() + { + // Route returns 200 with body + check(GET, "/hello", + h_text("world"), + status::ok, "world"); + + // Unmatched path returns 404 + { + test_router r; + r.add(GET, "/hello", h_text("x")); + check(r, GET, "/missing", + status::not_found); + } + + // Wrong method returns 405 + { + test_router r; + r.add(GET, "/hello", h_text("x")); + check(r, POST, "/hello", + status::method_not_allowed); + } + + // Custom status code + check(GET, "/nc", + h_stat(status::no_content), + status::no_content); + + // Multiple routes, correct dispatch + { + test_router r; + r.add(GET, "/a", h_text("A")); + r.add(GET, "/b", h_text("B")); + check(r, GET, "/b", + status::ok, "B"); + } + + // HTML body sniffs content-type + { + test_router r; + r.add(GET, "/page", + h_text("

hi

")); + auto res = exchange(r, GET, "/page"); + BOOST_TEST(res.status() == status::ok); + BOOST_TEST(res.body == "

hi

"); + BOOST_TEST(res.res.exists( + field::content_type)); + } + + // Custom content-type + check(GET, "/json", + h_typed("application/json", + R"({"ok":true})"), + status::ok, R"({"ok":true})"); + } + void run() { + testBasicRoute(); } }; diff --git a/test/unit/server/test_worker.hpp b/test/unit/server/test_worker.hpp new file mode 100644 index 00000000..b6cf1a6c --- /dev/null +++ b/test/unit/server/test_worker.hpp @@ -0,0 +1,351 @@ +// +// Copyright (c) 2026 Vinnie Falco (vinnie.falco@gmail.com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_TEST_WORKER_HPP +#define BOOST_HTTP_TEST_WORKER_HPP + +#include +#include +#include + +#include "test_helpers.hpp" + +#include + +namespace boost { +namespace http { + +/** Shared test utilities for http_worker round-trips. + + All members are static. Use as a namespace: + @code + using tw = test_worker; + tw::check(tw::GET, "/hello", tw::h_text("world"), + status::ok, "world"); + @endcode +*/ +struct test_worker +{ + using test_router = router; + + static constexpr auto GET = method::get; + static constexpr auto POST = method::post; + static constexpr auto PUT = method::put; + static constexpr auto HEAD = method::head; + static constexpr auto DELETE_ = method::delete_; + static constexpr auto PATCH = method::patch; + + //-------------------------------------------- + // Cached configs + //-------------------------------------------- + + static shared_parser_config const& + pcfg() + { + static auto const cfg = + make_parser_config(parser_config{true}); + return cfg; + } + + static shared_serializer_config const& + scfg() + { + static auto const cfg = + make_serializer_config( + serializer_config{}); + return cfg; + } + + //-------------------------------------------- + // Handler helpers + //-------------------------------------------- + + /** Handler that sends 200 with text body. + + Captured string is safe: the router stores + the handler in stable heap storage. + */ + static auto + h_text(std::string_view body) + { + return [body = std::string(body)]( + route_params& rp) -> route_task + { + auto [ec] = co_await rp.send(body); + if(ec) + co_return route_error(ec); + co_return route_done; + }; + } + + /** Handler that sends the given status with + no body. + */ + static auto + h_stat(http::status s) + { + return [s](route_params& rp) -> route_task + { + auto [ec] = co_await rp.status(s).send(); + if(ec) + co_return route_error(ec); + co_return route_done; + }; + } + + /** Handler that sends 200 with text body and + a custom Content-Type. + */ + static auto + h_typed( + std::string_view content_type, + std::string_view body) + { + return [ + ct = std::string(content_type), + body = std::string(body)]( + route_params& rp) -> route_task + { + rp.res.set(field::content_type, ct); + auto [ec] = co_await rp.send(body); + if(ec) + co_return route_error(ec); + co_return route_done; + }; + } + + //-------------------------------------------- + // Parsed response + //-------------------------------------------- + + struct result + { + http::response res; + std::string body; + + http::status + status() const noexcept + { + return res.status(); + } + }; + + //-------------------------------------------- + // Build minimal HTTP/1.1 request + //-------------------------------------------- + + static std::string + make_request( + http::method m, + std::string_view path, + std::string_view extra_headers = {}) + { + std::string s; + auto ms = to_string(m); + s.append(ms.data(), ms.size()); + s += ' '; + s += path; + s += + " HTTP/1.1\r\n" + "Host: localhost\r\n"; + if(! extra_headers.empty()) + s += extra_headers; + s += + "Connection: close\r\n" + "\r\n"; + return s; + } + + //-------------------------------------------- + // Split raw wire data into headers + body + //-------------------------------------------- + + static result + parse_response(std::string_view raw) + { + result r; + auto pos = raw.find("\r\n\r\n"); + if(! BOOST_TEST( + pos != std::string_view::npos)) + return r; + try + { + r.res = http::response( + raw.substr(0, pos + 4)); + } + catch(std::exception const& e) + { + BOOST_TEST_FAIL(); + return r; + } + r.body.assign(raw.substr(pos + 4)); + return r; + } + + //-------------------------------------------- + // Run one request/response exchange + //-------------------------------------------- + + /** Run a raw request through a worker and + return the parsed response. + */ + static result + exchange( + test_router const& r, + std::string_view request) + { + auto [client, server] = + capy::test::make_stream_pair(); + + http_worker w( + server, + test_router(r), + pcfg(), + scfg()); + + client.provide(request); + client.close(); + + capy::test::run_blocking()( + w.do_http_session()); + + return parse_response(client.data()); + } + + /** Build the request from method + path, then + run it. + */ + static result + exchange( + test_router const& r, + http::method m, + std::string_view path) + { + return exchange(r, make_request(m, path)); + } + + //-------------------------------------------- + // Fuse-driven exchange + //-------------------------------------------- + + /** Run a request/response exchange under a fuse. + + The caller provides a lambda that receives + the client stream and the http_worker. The + fuse re-runs the lambda at successive error + injection points. + + @par Example + @code + test_router r; + r.add(GET, "/hello", h_text("world")); + auto fr = fused(r, GET, "/hello", + [](auto& client, auto& w) + { + capy::test::run_blocking()( + w.do_http_session()); + }); + BOOST_TEST(fr.success); + @endcode + */ + template + static capy::test::fuse::result + fused( + test_router const& r, + http::method m, + std::string_view path, + F&& fn) + { + auto req = make_request(m, path); + capy::test::fuse f; + return f.armed([&](capy::test::fuse& f) + { + auto [client, server] = + capy::test::make_stream_pair(f); + + http_worker w( + server, + test_router(r), + pcfg(), + scfg()); + + client.provide(req); + client.close(); + + fn(client, w); + }); + } + + //-------------------------------------------- + // Check: router + method + path + //-------------------------------------------- + + static void + check( + test_router const& r, + http::method m, + std::string_view path, + http::status expected) + { + auto res = exchange(r, m, path); + BOOST_TEST(res.status() == expected); + } + + static void + check( + test_router const& r, + http::method m, + std::string_view path, + http::status expected, + std::string_view expected_body) + { + auto res = exchange(r, m, path); + BOOST_TEST(res.status() == expected); + BOOST_TEST(res.body == expected_body); + } + + //-------------------------------------------- + // Check: single route (one-liner) + //-------------------------------------------- + + template + static void + check( + http::method m, + std::string_view path, + H&& handler, + http::status expected) + { + test_router r; + r.add(m, path, + std::forward(handler)); + check(r, m, path, expected); + } + + template + static void + check( + http::method m, + std::string_view path, + H&& handler, + http::status expected, + std::string_view expected_body) + { + test_router r; + r.add(m, path, + std::forward(handler)); + check(r, m, path, + expected, expected_body); + } +}; + +} // http +} // boost + +#endif From 705e5f7aa19e26e4841f0c6b129aff7f1457bdcd Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 8 Feb 2026 05:48:57 -0800 Subject: [PATCH 3/5] more http_worker tests for unhandled routes --- test/unit/server/http_worker.cpp | 62 ++++++++++++++++++++++++++++++++ test/unit/server/test_worker.hpp | 59 ++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/test/unit/server/http_worker.cpp b/test/unit/server/http_worker.cpp index dbef45ae..d8b73a78 100644 --- a/test/unit/server/http_worker.cpp +++ b/test/unit/server/http_worker.cpp @@ -75,10 +75,72 @@ struct http_worker_test status::ok, R"({"ok":true})"); } + void + testRouteOutcomes() + { + // route_next: handler declines, path has + // a route so router returns 405 + check(GET, "/x", h_next, + status::method_not_allowed); + + // route_next_route: skip route, path has + // a route so router returns 405 + check(GET, "/x", h_next_route, + status::method_not_allowed); + + // route_next via middleware -> 404 + { + test_router r; + r.use(h_next); + check(r, GET, "/x", + status::not_found); + } + + // route_close: session ends, no response + { + test_router r; + r.add(GET, "/bye", h_close); + auto raw = exchange_raw(r, GET, "/bye"); + BOOST_TEST(raw.empty()); + } + + // route_error: handler fails -> 500 + check(GET, "/err", + h_error(http::error::bad_content_length), + status::internal_server_error); + + // Empty router -> 404 + { + test_router r; + check(r, GET, "/anything", + status::not_found); + } + + // route_next falls through to second handler + { + test_router r; + r.route("/x") + .add(GET, h_next) + .add(GET, h_text("ok")); + check(r, GET, "/x", + status::ok, "ok"); + } + + // route_next_route skips entire first route + { + test_router r; + r.add(GET, "/x", h_next_route); + r.add(GET, "/x", h_text("second")); + check(r, GET, "/x", + status::ok, "second"); + } + } + void run() { testBasicRoute(); + testRouteOutcomes(); } }; diff --git a/test/unit/server/test_worker.hpp b/test/unit/server/test_worker.hpp index b6cf1a6c..892a3db7 100644 --- a/test/unit/server/test_worker.hpp +++ b/test/unit/server/test_worker.hpp @@ -99,6 +99,29 @@ struct test_worker }; } + /** Handler that returns route_next (decline). */ + static auto + h_next(route_params&) -> route_task + { co_return route_next; } + + /** Handler that returns route_next_route. */ + static auto + h_next_route(route_params&) -> route_task + { co_return route_next_route; } + + /** Handler that returns route_close. */ + static auto + h_close(route_params&) -> route_task + { co_return route_close; } + + /** Handler that returns route_error. */ + static auto + h_error(system::error_code ec) + { + return [ec](route_params&) -> route_task + { co_return route_error(ec); }; + } + /** Handler that sends 200 with text body and a custom Content-Type. */ @@ -192,6 +215,42 @@ struct test_worker // Run one request/response exchange //-------------------------------------------- + /** Run a raw request and return the raw + bytes written by the worker. + */ + static std::string + exchange_raw( + test_router const& r, + std::string_view request) + { + auto [client, server] = + capy::test::make_stream_pair(); + + http_worker w( + server, + test_router(r), + pcfg(), + scfg()); + + client.provide(request); + client.close(); + + capy::test::run_blocking()( + w.do_http_session()); + + return std::string(client.data()); + } + + static std::string + exchange_raw( + test_router const& r, + http::method m, + std::string_view path) + { + return exchange_raw(r, + make_request(m, path)); + } + /** Run a raw request through a worker and return the parsed response. */ From 1a19de15b6a08dba73d0e28d68504b5778987c0b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 8 Feb 2026 05:59:18 -0800 Subject: [PATCH 4/5] cors tests --- test/unit/server/cors.cpp | 620 +++++++++++++++++++++++++------ test/unit/server/test_worker.hpp | 1 + 2 files changed, 509 insertions(+), 112 deletions(-) diff --git a/test/unit/server/cors.cpp b/test/unit/server/cors.cpp index cbe1f5f1..3943d62f 100644 --- a/test/unit/server/cors.cpp +++ b/test/unit/server/cors.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Vinnie Falco (vinnie.falco@gmail.com) // // Distributed under the Boost Software License, Version 1.0. (See accompanying // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) @@ -9,153 +9,549 @@ // Test that header file is self-contained. #include -#include -#include "src/rfc/detail/rules.hpp" -#include -#include -#include -#include -#include +#include "test_worker.hpp" -#include "test_suite.hpp" +#include namespace boost { namespace http { -#if 0 -// DO NOT REMOVE THIS -class field_item +struct cors_test + : test_worker { -public: - field_item( - core::string_view s) - : s_(s) + // Shorthand to build a router with cors + handler + static test_router + make_cors_router(cors_options opts) { - grammar::parse(s_, - detail::field_name_rule).value(); + test_router r; + r.use(cors(std::move(opts))); + r.use(h_text("ok")); + return r; } - field_item( - field f) noexcept - : s_(to_string(f)) + // Preflight request with optional extra headers + static std::string + preflight( + std::string_view path = "/", + std::string_view extra = {}) { + return make_request( + OPTIONS, path, extra); } - operator core::string_view() const noexcept + //-------------------------------------------- + // Preflight defaults + //-------------------------------------------- + + void + testPreflightDefaults() { - return s_; + auto r = make_cors_router({}); + auto res = exchange(r, preflight()); + + // Default status is 204 No Content + BOOST_TEST(res.status() == + status::no_content); + + // Wildcard origin + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "*"); + + // Default methods + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_methods, ""), + "GET,HEAD,PUT,PATCH,POST,DELETE"); + + // No credentials by default + BOOST_TEST_NOT(res.res.exists( + field::access_control_allow_credentials)); + + // No max-age by default + BOOST_TEST_NOT(res.res.exists( + field::access_control_max_age)); + + // No exposed headers by default + BOOST_TEST_NOT(res.res.exists( + field::access_control_expose_headers)); } -private: - core::string_view s_; -}; + //-------------------------------------------- + // Preflight with specific origin + //-------------------------------------------- -template -struct list -{ - struct item + void + testPreflightOrigin() { - core::string_view s; + // Explicit wildcard + { + cors_options o; + o.origin = "*"; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "*"); + // No Vary for wildcard + BOOST_TEST_NOT(res.res.exists( + field::vary)); + } - template< - class T, - class = typename std::enable_if< - std::is_constructible< - Element, T>::value>::type> - item(T&& t) - : s(Element(std::forward(t))) + // Specific origin adds Vary { + cors_options o; + o.origin = "https://example.com"; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "https://example.com"); + BOOST_TEST(res.res.exists(field::vary)); } - }; + } -public: - list(std::initializer_list init) + //-------------------------------------------- + // Preflight methods + //-------------------------------------------- + + void + testPreflightMethods() { - if(init.size() == 0) - return; - auto it = init.begin(); - s_ = it->s; - while(++it != init.end()) + cors_options o; + o.methods = "GET,POST"; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_methods, ""), + "GET,POST"); + } + + //-------------------------------------------- + // Preflight credentials + //-------------------------------------------- + + void + testPreflightCredentials() + { + cors_options o; + o.credentials = true; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_credentials, + ""), + "true"); + } + + //-------------------------------------------- + // Preflight allowed headers + //-------------------------------------------- + + void + testPreflightAllowedHeaders() + { + // Explicit allowed headers + { + cors_options o; + o.allowedHeaders = "X-Custom, Authorization"; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_headers, + ""), + "X-Custom, Authorization"); + } + + // Echo from request header + { + cors_options o; + auto r = make_cors_router(o); + auto res = exchange(r, preflight("/", + "Access-Control-Request-Headers: " + "X-Foo, X-Bar\r\n")); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_headers, + ""), + "X-Foo, X-Bar"); + } + + // No request header, no echoed header { - s_.push_back(','); - s_.append(it->s.data(), - it->s.size()); + cors_options o; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST_NOT(res.res.exists( + field::access_control_allow_headers)); } } - core::string_view get() const noexcept + //-------------------------------------------- + // Preflight max-age + //-------------------------------------------- + + void + testPreflightMaxAge() { - return s_; + cors_options o; + o.max_age = std::chrono::seconds(3600); + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_max_age, ""), + "3600"); } -private: - std::string s_; -}; -#endif + //-------------------------------------------- + // Preflight exposed headers + //-------------------------------------------- + + void + testPreflightExposedHeaders() + { + cors_options o; + o.exposedHeaders = "X-Request-Id"; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_expose_headers, ""), + "X-Request-Id"); + } + + //-------------------------------------------- + // Preflight custom status + //-------------------------------------------- + + void + testPreflightStatus() + { + cors_options o; + o.result = status::ok; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + BOOST_TEST(res.status() == status::ok); + } + + //-------------------------------------------- + // preFlightContinue passes to next handler + //-------------------------------------------- + + void + testPreflightContinue() + { + cors_options o; + o.preFlightContinue = true; + auto r = make_cors_router(o); + auto res = exchange(r, preflight()); + + // Next handler sends "ok" with 200 + BOOST_TEST(res.status() == status::ok); + BOOST_TEST(res.body == "ok"); + + // CORS headers still present + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "*"); + BOOST_TEST(res.res.exists( + field::access_control_allow_methods)); + } + + //-------------------------------------------- + // Normal (non-OPTIONS) request gets headers + //-------------------------------------------- + + void + testNormalRequest() + { + // Default options + { + auto r = make_cors_router({}); + auto res = exchange(r, GET, "/"); + BOOST_TEST(res.status() == status::ok); + BOOST_TEST(res.body == "ok"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, + ""), + "*"); + // No methods header on normal response + BOOST_TEST_NOT(res.res.exists( + field::access_control_allow_methods)); + // No max-age on normal response + BOOST_TEST_NOT(res.res.exists( + field::access_control_max_age)); + // No allowed-headers on normal response + BOOST_TEST_NOT(res.res.exists( + field::access_control_allow_headers)); + } + + // Credentials on normal response + { + cors_options o; + o.credentials = true; + auto r = make_cors_router(o); + auto res = exchange(r, GET, "/"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_credentials, + ""), + "true"); + } + + // Exposed headers on normal response + { + cors_options o; + o.exposedHeaders = "X-RateLimit"; + auto r = make_cors_router(o); + auto res = exchange(r, GET, "/"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_expose_headers, + ""), + "X-RateLimit"); + } + + // Specific origin on normal response + { + cors_options o; + o.origin = "https://app.example.com"; + auto r = make_cors_router(o); + auto res = exchange(r, GET, "/"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, + ""), + "https://app.example.com"); + BOOST_TEST(res.res.exists(field::vary)); + } + } + + //-------------------------------------------- + // Full options combined + //-------------------------------------------- -struct cors_test -{ void - testPreflight() + testFullOptions() { - http::cors_options opts; - opts.preFlightContinue = true; + cors_options o; + o.origin = "https://example.com"; + o.methods = "GET,POST,DELETE"; + o.allowedHeaders = "Authorization, Content-Type"; + o.exposedHeaders = "X-Request-Id, X-RateLimit"; + o.max_age = std::chrono::seconds(86400); + o.credentials = true; + + auto r = make_cors_router(o); - http::router r; - r.use(http::cors(opts)); - r.use([](http::route_params& rp) -> http::route_task { - auto [ec] = co_await rp.send("ok"); - if(ec) - co_return http::route_error(ec); - co_return http::route_done; + // Preflight + { + auto res = exchange(r, preflight()); + BOOST_TEST(res.status() == + status::no_content); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "https://example.com"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_methods, ""), + "GET,POST,DELETE"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_headers, ""), + "Authorization, Content-Type"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_expose_headers, ""), + "X-Request-Id, X-RateLimit"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_max_age, ""), + "86400"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_credentials, + ""), + "true"); + } + + // Normal GET + { + auto res = exchange(r, GET, "/"); + BOOST_TEST(res.status() == status::ok); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "https://example.com"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_credentials, + ""), + "true"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_expose_headers, ""), + "X-Request-Id, X-RateLimit"); + // Preflight-only headers absent + BOOST_TEST_NOT(res.res.exists( + field::access_control_allow_methods)); + BOOST_TEST_NOT(res.res.exists( + field::access_control_max_age)); + BOOST_TEST_NOT(res.res.exists( + field::access_control_allow_headers)); + } + } + + //-------------------------------------------- + // CORS with route-level handler + //-------------------------------------------- + + void + testWithRoutes() + { + test_router r; + r.use(cors(cors_options{})); + r.add(GET, "/api", h_text("data")); + r.add(POST, "/api", h_text("created")); + + // GET /api + { + auto res = exchange(r, GET, "/api"); + BOOST_TEST(res.status() == status::ok); + BOOST_TEST(res.body == "data"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "*"); + } + + // POST /api + { + auto res = exchange(r, POST, "/api"); + BOOST_TEST(res.status() == status::ok); + BOOST_TEST(res.body == "created"); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "*"); + } + + // OPTIONS /api (preflight) + { + auto res = exchange(r, preflight("/api")); + BOOST_TEST(res.status() == + status::no_content); + BOOST_TEST(res.res.exists( + field::access_control_allow_origin)); + } + + // Unmatched path still gets CORS on 404 + { + auto res = exchange(r, GET, "/missing"); + BOOST_TEST(res.status() == + status::not_found); + BOOST_TEST_EQ(res.res.value_or( + field::access_control_allow_origin, ""), + "*"); + } + } + + //-------------------------------------------- + // Fuse: preflight error injection + //-------------------------------------------- + + void + testFusePreflight() + { + auto r = make_cors_router({}); + auto fr = fused(r, OPTIONS, "/", + [](auto&, auto& w) + { + capy::test::run_blocking()( + w.do_http_session()); + }); + BOOST_TEST(fr.success); + } + + //-------------------------------------------- + // Fuse: normal request error injection + //-------------------------------------------- + + void + testFuseNormal() + { + auto r = make_cors_router({}); + auto fr = fused(r, GET, "/", + [](auto&, auto& w) + { + capy::test::run_blocking()( + w.do_http_session()); }); - auto parser_cfg = http::make_parser_config( - http::parser_config{true}); - auto serializer_cfg = http::make_serializer_config( - http::serializer_config{}); - - capy::test::fuse f; - auto [client, server] = - capy::test::make_stream_pair(f); - - http::http_worker worker( - server, std::move(r), - parser_cfg, serializer_cfg); - - client.provide( - "OPTIONS /test HTTP/1.1\r\n" - "Host: localhost\r\n" - "Connection: close\r\n" - "\r\n"); - client.close(); - - capy::test::run_blocking()( - worker.do_http_session()); - - auto response = client.data(); - BOOST_TEST(response.find("200") != - std::string_view::npos); - BOOST_TEST(response.find( - "Access-Control-Allow-Origin: *") != - std::string_view::npos); - BOOST_TEST(response.find( - "Access-Control-Allow-Methods: " - "GET,HEAD,PUT,PATCH,POST,DELETE") != - std::string_view::npos); - } - - void run() - { - testPreflight(); - -#if 0 - list v({ - field::access_control_allow_origin, - "example.com", - "example.org" + BOOST_TEST(fr.success); + } + + //-------------------------------------------- + // Fuse: full options error injection + //-------------------------------------------- + + void + testFuseFullOptions() + { + cors_options o; + o.origin = "https://example.com"; + o.methods = "GET,POST"; + o.allowedHeaders = "Authorization"; + o.exposedHeaders = "X-Request-Id"; + o.max_age = std::chrono::seconds(3600); + o.credentials = true; + auto r = make_cors_router(o); + + // Preflight under fuse + { + auto fr = fused(r, OPTIONS, "/", + [](auto&, auto& w) + { + capy::test::run_blocking()( + w.do_http_session()); }); -#endif + BOOST_TEST(fr.success); + } + + // Normal request under fuse + { + auto fr = fused(r, GET, "/", + [](auto&, auto& w) + { + capy::test::run_blocking()( + w.do_http_session()); + }); + BOOST_TEST(fr.success); + } + } + + //-------------------------------------------- + // Fuse: preFlightContinue error injection + //-------------------------------------------- + + void + testFusePreflightContinue() + { + cors_options o; + o.preFlightContinue = true; + auto r = make_cors_router(o); + auto fr = fused(r, OPTIONS, "/", + [](auto&, auto& w) + { + capy::test::run_blocking()( + w.do_http_session()); + }); + BOOST_TEST(fr.success); + } + + void + run() + { + testPreflightDefaults(); + testPreflightOrigin(); + testPreflightMethods(); + testPreflightCredentials(); + testPreflightAllowedHeaders(); + testPreflightMaxAge(); + testPreflightExposedHeaders(); + testPreflightStatus(); + testPreflightContinue(); + testNormalRequest(); + testFullOptions(); + testWithRoutes(); + testFusePreflight(); + testFuseNormal(); + testFuseFullOptions(); + testFusePreflightContinue(); } }; diff --git a/test/unit/server/test_worker.hpp b/test/unit/server/test_worker.hpp index 892a3db7..65c47c70 100644 --- a/test/unit/server/test_worker.hpp +++ b/test/unit/server/test_worker.hpp @@ -40,6 +40,7 @@ struct test_worker static constexpr auto HEAD = method::head; static constexpr auto DELETE_ = method::delete_; static constexpr auto PATCH = method::patch; + static constexpr auto OPTIONS = method::options; //-------------------------------------------- // Cached configs From b4433814ebaadd9a2650c0b149a2b2b402421f0c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 8 Feb 2026 17:27:05 -0800 Subject: [PATCH 5/5] additional router OPTIONS tests --- test/unit/server/router.cpp | 222 ++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/test/unit/server/router.cpp b/test/unit/server/router.cpp index c1d1a26a..58d0cb05 100644 --- a/test/unit/server/router.cpp +++ b/test/unit/server/router.cpp @@ -570,6 +570,226 @@ struct router_test } } + void testOptionsMethod() + { + static auto const GET = http::method::get; + static auto const POST = http::method::post; + static auto const PUT = http::method::put; + static auto const DELETE_ = http::method::delete_; + static auto const OPTIONS = http::method::options; + + // OPTIONS on single-verb route + { test_router r; r.add(GET, "/x", h_send); check(r, OPTIONS, "/x"); } + + // OPTIONS on multi-verb route + { test_router r; r.route("/x").add(GET, h_send).add(POST, h_send); check(r, OPTIONS, "/x"); } + + // OPTIONS on unmatched path -> route_next + { test_router r; r.add(GET, "/x", h_send); check(r, OPTIONS, "/y", route_next); } + + // 405 sets Allow header (non-OPTIONS wrong method) + { + test_router r; + r.add(GET, "/x", h_send); + params req; + init_sink(req); + route_result rv; + capy::test::run_blocking([&](route_result res) { rv = res; })( + r.dispatch(POST, urls::url_view("/x"), req)); + BOOST_TEST(rv.what() == route_what::done); + BOOST_TEST(req.res.exists(field::allow)); + BOOST_TEST(req.res.status() == status::method_not_allowed); + } + + // Allow header lists all registered verbs + { + test_router r; + r.route("/api") + .add(GET, h_send) + .add(POST, h_send) + .add(DELETE_, h_send); + params req; + init_sink(req); + route_result rv; + capy::test::run_blocking([&](route_result res) { rv = res; })( + r.dispatch(PUT, urls::url_view("/api"), req)); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string_view::npos); + BOOST_TEST(allow.find("POST") != std::string_view::npos); + BOOST_TEST(allow.find("DELETE") != std::string_view::npos); + } + + // OPTIONS with custom verb + { + test_router r; + r.add(GET, "/x", h_send); + r.add("CUSTOM", "/x", h_send); + params req; + init_sink(req); + route_result rv; + capy::test::run_blocking([&](route_result res) { rv = res; })( + r.dispatch(PUT, urls::url_view("/x"), req)); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("CUSTOM") != std::string_view::npos); + BOOST_TEST(allow.find("GET") != std::string_view::npos); + } + + // all() produces full method list on 405 + { + test_router r; + r.all("/x", h_next); + params req; + init_sink(req); + route_result rv; + capy::test::run_blocking([&](route_result res) { rv = res; })( + r.dispatch(GET, urls::url_view("/x"), req)); + // all() handler returned route_next, so + // the router sees path matched and falls + // through to 405 with the full set + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string_view::npos); + BOOST_TEST(allow.find("POST") != std::string_view::npos); + BOOST_TEST(allow.find("PUT") != std::string_view::npos); + BOOST_TEST(allow.find("DELETE") != std::string_view::npos); + } + + // set_options_handler customizes OPTIONS response + { + test_router r; + r.add(GET, "/x", h_send); + r.add(POST, "/x", h_send); + r.set_options_handler( + [](params& rp, std::string_view allow) -> route_task + { + rp.res.set(field::allow, allow); + rp.res.set(field::access_control_allow_methods, allow); + co_return route_done; + }); + params req; + init_sink(req); + route_result rv; + capy::test::run_blocking([&](route_result res) { rv = res; })( + r.dispatch(OPTIONS, urls::url_view("/x"), req)); + BOOST_TEST(rv.what() == route_what::done); + BOOST_TEST(req.res.exists(field::allow)); + BOOST_TEST(req.res.exists( + field::access_control_allow_methods)); + } + } + + void testMultiLevelVerbs() + { + static auto const GET = http::method::get; + static auto const POST = http::method::post; + static auto const PUT = http::method::put; + static auto const DELETE_ = http::method::delete_; + static auto const OPTIONS = http::method::options; + + // Multi-level route tree: /api/users and /api/posts + // with different verbs at each level + { + test_router r; + r.use("/api", []{ + test_router r2; + r2.add(GET, "/users", h_send); + r2.add(POST, "/users", h_send); + r2.add(GET, "/posts", h_send); + r2.add(PUT, "/posts", h_send); + r2.add(DELETE_, "/posts", h_send); + return r2; + }()); + + // Direct verb matches + check(r, GET, "/api/users"); + check(r, POST, "/api/users"); + check(r, GET, "/api/posts"); + check(r, PUT, "/api/posts"); + check(r, DELETE_, "/api/posts"); + + // OPTIONS on each sub-path + check(r, OPTIONS, "/api/users"); + check(r, OPTIONS, "/api/posts"); + + // Wrong verb -> 405 + { + params req; + init_sink(req); + route_result rv; + capy::test::run_blocking([&](route_result res) { rv = res; })( + r.dispatch(DELETE_, urls::url_view("/api/users"), req)); + BOOST_TEST(req.res.status() == status::method_not_allowed); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string_view::npos); + BOOST_TEST(allow.find("POST") != std::string_view::npos); + // DELETE not allowed on /users + BOOST_TEST(allow.find("DELETE") == std::string_view::npos); + } + + // Wrong verb on /posts + { + params req; + init_sink(req); + route_result rv; + capy::test::run_blocking([&](route_result res) { rv = res; })( + r.dispatch(POST, urls::url_view("/api/posts"), req)); + BOOST_TEST(req.res.status() == status::method_not_allowed); + auto allow = req.res.value_or(field::allow, ""); + BOOST_TEST(allow.find("GET") != std::string_view::npos); + BOOST_TEST(allow.find("PUT") != std::string_view::npos); + BOOST_TEST(allow.find("DELETE") != std::string_view::npos); + } + } + + // Deeply nested: /v1/admin/settings + { + test_router r; + r.use("/v1", []{ + test_router r2; + r2.use("/admin", []{ + test_router r3; + r3.add(GET, "/settings", h_send); + r3.add(PUT, "/settings", h_send); + return r3; + }()); + return r2; + }()); + + check(r, GET, "/v1/admin/settings"); + check(r, PUT, "/v1/admin/settings"); + check(r, OPTIONS, "/v1/admin/settings"); + + // Wrong verb + { + params req; + init_sink(req); + route_result rv; + capy::test::run_blocking([&](route_result res) { rv = res; })( + r.dispatch(POST, urls::url_view("/v1/admin/settings"), req)); + BOOST_TEST(req.res.status() == status::method_not_allowed); + } + } + + // Sibling sub-routers with non-overlapping verbs + { + test_router r; + r.use("/svc", []{ + test_router r2; + r2.add(GET, "/health", h_send); + return r2; + }()); + r.use("/svc", []{ + test_router r2; + r2.add(POST, "/rpc", h_send); + return r2; + }()); + + check(r, GET, "/svc/health"); + check(r, POST, "/svc/rpc"); + check(r, OPTIONS, "/svc/health"); + check(r, OPTIONS, "/svc/rpc"); + } + } + void run() { testUse(); @@ -582,6 +802,8 @@ struct router_test testPathDecoding(); testCrossTypeConstruction(); testDynamicTransform(); + testOptionsMethod(); + testMultiLevelVerbs(); } };