Skip to content

Commit 24f3f9c

Browse files
etrclaude
andcommitted
TASK-053 step 1: add v2_dispatch_contract_test safety net
Pin the end-to-end observable invariants the dispatch path must satisfy before swapping finalize_answer over from the v1 maps (resolve_resource_for_request) to lookup_v2 (v2 3-tier table). Four invariants asserted via real HTTP traffic: 1. parameterized capture replay (/users/{id} -> get_arg("id")=="42"), 2. prefix routes set route_resolved_ctx.matched->is_prefix == true, 3. exact routes set matched->is_prefix == false, 4. method mismatch returns 405 AND the hook ctx still carries a non-null resource pointer (resolution succeeded; only the method check failed afterward). All four currently pass against the v1 dispatch path; they MUST keep passing after the cutover. Wired into check_PROGRAMS in test/Makefile.am. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 00d57e2 commit 24f3f9c

2 files changed

Lines changed: 286 additions & 1 deletion

File tree

test/Makefile.am

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ LDADD += -lcurl
2626

2727
AM_CPPFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/httpserver/ -DHTTPSERVER_COMPILATION
2828
METASOURCES = AUTO
29-
check_PROGRAMS = basic file_upload http_utils threaded nodelay string_utilities http_endpoint ban_system ws_start_stop authentication deferred http_resource http_response create_webserver create_webserver_explicit new_response_types daemon_info uri_log feature_unavailable header_hygiene_iovec header_hygiene iovec_entry http_method constants body http_response_sbo http_response_factories http_response_move_sanitizer webserver_pimpl http_request_pimpl create_test_request http_request_arena http_request_const_getters http_request_tls_accessors webserver_register_smartptr webserver_register_path_prefix webserver_on_methods webserver_route route_table lookup_pipeline route_table_concurrency routing_regression threadsafety_stress webserver_features webserver_ws_unavailable webserver_register_ws_smartptr webserver_dauth_unavailable consumer_fixture header_hygiene_hooks hook_api_shape hooks_no_firing hooks_accept_ctx_shape hooks_connection_lifecycle hooks_accept_decision_banned hooks_accept_decision_throwing hooks_body_chunk_ctx_shape hooks_request_received_short_circuit hooks_body_chunk_observes_progress hooks_body_chunk_short_circuit_no_leak hooks_before_handler_ctx_shape hooks_route_resolved_miss_and_hit hooks_before_handler_short_circuit hooks_alias_count hooks_alias_functional hooks_handler_exception_chain hooks_handler_exception_user_handler_throws_continues_chain hooks_handler_exception_fallback_to_hardcoded_500 hooks_handler_exception_slot hooks_response_sent_ctx_shape hooks_request_completed_ctx_shape hooks_after_handler_replaces_response hooks_after_handler_mutates_response_in_place hooks_response_sent_carries_status_bytes_timing hooks_request_completed_fires_on_early_failure hooks_log_access_alias_slot hooks_per_route_invalid_phase_throws hooks_per_route_order hooks_per_route_early_413_per_endpoint hooks_per_route_resource_destroyed_first hooks_per_route_concurrent_registration
29+
check_PROGRAMS = basic file_upload http_utils threaded nodelay string_utilities http_endpoint ban_system ws_start_stop authentication deferred http_resource http_response create_webserver create_webserver_explicit new_response_types daemon_info uri_log feature_unavailable header_hygiene_iovec header_hygiene iovec_entry http_method constants body http_response_sbo http_response_factories http_response_move_sanitizer webserver_pimpl http_request_pimpl create_test_request http_request_arena http_request_const_getters http_request_tls_accessors webserver_register_smartptr webserver_register_path_prefix webserver_on_methods webserver_route route_table lookup_pipeline route_table_concurrency routing_regression v2_dispatch_contract threadsafety_stress webserver_features webserver_ws_unavailable webserver_register_ws_smartptr webserver_dauth_unavailable consumer_fixture header_hygiene_hooks hook_api_shape hooks_no_firing hooks_accept_ctx_shape hooks_connection_lifecycle hooks_accept_decision_banned hooks_accept_decision_throwing hooks_body_chunk_ctx_shape hooks_request_received_short_circuit hooks_body_chunk_observes_progress hooks_body_chunk_short_circuit_no_leak hooks_before_handler_ctx_shape hooks_route_resolved_miss_and_hit hooks_before_handler_short_circuit hooks_alias_count hooks_alias_functional hooks_handler_exception_chain hooks_handler_exception_user_handler_throws_continues_chain hooks_handler_exception_fallback_to_hardcoded_500 hooks_handler_exception_slot hooks_response_sent_ctx_shape hooks_request_completed_ctx_shape hooks_after_handler_replaces_response hooks_after_handler_mutates_response_in_place hooks_response_sent_carries_status_bytes_timing hooks_request_completed_fires_on_early_failure hooks_log_access_alias_slot hooks_per_route_invalid_phase_throws hooks_per_route_order hooks_per_route_early_413_per_endpoint hooks_per_route_resource_destroyed_first hooks_per_route_concurrent_registration
3030

3131
MOSTLYCLEANFILES = *.gcda *.gcno *.gcov
3232

@@ -272,6 +272,17 @@ threadsafety_stress_SOURCES = integ/threadsafety_stress.cpp
272272
routing_regression_SOURCES = unit/routing_regression_test.cpp
273273
routing_regression_LDADD = $(LDADD) -lmicrohttpd
274274

275+
# v2_dispatch_contract: TASK-053. End-to-end safety net pinning the
276+
# observable invariants the dispatch path must satisfy AFTER finalize_answer
277+
# is cut over from resolve_resource_for_request (v1 maps) to
278+
# resolve_resource_for_request_v2 (lookup_v2-backed). Drives real HTTP
279+
# requests against the public webserver surface and asserts on response
280+
# body / status / hook context (parameterized capture replay, prefix vs
281+
# exact is_prefix flag, 405 method-mismatch still resolves the resource).
282+
# Default LDADD plus -lcurl is sufficient (already in LDADD); no PIMPL
283+
# friend access needed because the gate is the public observable contract.
284+
v2_dispatch_contract_SOURCES = unit/v2_dispatch_contract_test.cpp
285+
275286
# webserver_features: TASK-034. Pins the public contract of
276287
# webserver::features() (PRD-FLG-REQ-003): nested `struct features` with
277288
# four bool fields in documented order; the static call is noexcept; the
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
/*
2+
This file is part of libhttpserver
3+
Copyright (C) 2011-2026 Sebastiano Merlino
4+
5+
This library is free software; you can redistribute it and/or
6+
modify it under the terms of the GNU Lesser General Public
7+
License as published by the Free Software Foundation; either
8+
version 2.1 of the License, or (at your option) any later version.
9+
10+
This library is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
Lesser General Public License for more details.
14+
15+
You should have received a copy of the GNU Lesser General Public
16+
License along with this library; if not, write to the Free Software
17+
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
18+
USA
19+
*/
20+
21+
// TASK-053: v2 dispatch contract gate.
22+
//
23+
// This TU pins the *end-to-end* observable invariants the dispatch path
24+
// must satisfy through the public webserver surface, BEFORE we cut over
25+
// finalize_answer() from resolve_resource_for_request (v1) to
26+
// resolve_resource_for_request_v2 (lookup_v2-backed). Each test fires a
27+
// real HTTP request and asserts on response body / status / hook context.
28+
//
29+
// The four pinned invariants:
30+
// 1. Parameterized routes: `/users/{id}` matched against `/users/42`
31+
// populates `req.get_arg("id") == "42"`.
32+
// 2. Prefix routes: `/static` matched against `/static/foo/bar` hits
33+
// the registered resource and `ctx.matched->is_prefix == true`.
34+
// 3. Exact routes: `/exact` returns `ctx.matched->is_prefix == false`.
35+
// 4. Method mismatch: POST to a GET-only route still returns 405 and
36+
// the route_resolved hook ctx still carries a non-null resource
37+
// pointer (the resolve step ran; only the method check failed).
38+
//
39+
// All four currently pass against the v1 dispatch path. They MUST keep
40+
// passing after the v2 cutover. Anchoring them HERE — pre-cutover — is
41+
// the "safety net first" pattern that lets each subsequent step land
42+
// without regression risk.
43+
44+
#include <curl/curl.h>
45+
46+
#include <atomic>
47+
#include <chrono>
48+
#include <cstddef>
49+
#include <functional>
50+
#include <memory>
51+
#include <string>
52+
#include <thread>
53+
54+
#include "./httpserver.hpp"
55+
#include "./littletest.hpp"
56+
57+
using httpserver::create_webserver;
58+
using httpserver::hook_phase;
59+
using httpserver::http_request;
60+
using httpserver::http_response;
61+
using httpserver::route_resolved_ctx;
62+
using httpserver::webserver;
63+
64+
#define PORT 8231
65+
66+
namespace {
67+
68+
size_t writefunc(void* ptr, size_t size, size_t nmemb, std::string* s) {
69+
s->append(reinterpret_cast<char*>(ptr), size * nmemb);
70+
return size * nmemb;
71+
}
72+
73+
// Echo the `id` URL parameter back in the response body so the test
74+
// can read it via the body stream. Used by the parameterized-route test.
75+
class echo_id_resource : public httpserver::http_resource {
76+
public:
77+
http_response render_get(const http_request& req) override {
78+
return http_response::string(std::string(req.get_arg("id")));
79+
}
80+
};
81+
82+
class hello_resource : public httpserver::http_resource {
83+
public:
84+
http_response render_get(const http_request&) override {
85+
return http_response::string("OK");
86+
}
87+
};
88+
89+
// Performs a GET and returns body + status code.
90+
struct response_capture {
91+
long status = 0;
92+
std::string body;
93+
};
94+
95+
response_capture do_get(const std::string& path) {
96+
response_capture out;
97+
CURL* curl = curl_easy_init();
98+
std::string url = "http://127.0.0.1:" + std::to_string(PORT) + path;
99+
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
100+
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
101+
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out.body);
102+
curl_easy_perform(curl);
103+
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out.status);
104+
curl_easy_cleanup(curl);
105+
return out;
106+
}
107+
108+
// Performs a POST with no body and returns the status code.
109+
long do_post_status(const std::string& path) {
110+
long status = 0;
111+
CURL* curl = curl_easy_init();
112+
std::string url = "http://127.0.0.1:" + std::to_string(PORT) + path;
113+
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
114+
curl_easy_setopt(curl, CURLOPT_POST, 1L);
115+
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "");
116+
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L);
117+
std::string sink;
118+
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
119+
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
120+
curl_easy_perform(curl);
121+
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
122+
curl_easy_cleanup(curl);
123+
return status;
124+
}
125+
126+
// Probe state for the route_resolved hook ctx. Captures the most-recent
127+
// invocation's matched flags and resource pointer so we can assert them
128+
// in the test bodies.
129+
struct hook_probe {
130+
std::atomic<std::size_t> calls{0};
131+
std::atomic<bool> last_matched_engaged{false};
132+
std::atomic<bool> last_is_prefix{false};
133+
std::atomic<bool> last_resource_non_null{false};
134+
};
135+
136+
} // namespace
137+
138+
LT_BEGIN_SUITE(v2_dispatch_contract_suite)
139+
void set_up() {}
140+
void tear_down() {}
141+
LT_END_SUITE(v2_dispatch_contract_suite)
142+
143+
// Invariant 1: parameterized route — `/users/{id}` against `/users/42`
144+
// must populate `req.get_arg("id") == "42"` (the v2 equivalent of
145+
// apply_extracted_params).
146+
LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, parameterized_route_extracts_capture)
147+
webserver ws{create_webserver(PORT)};
148+
auto resource = std::make_shared<echo_id_resource>();
149+
ws.register_path("/users/{id}", resource);
150+
ws.start(false);
151+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
152+
153+
response_capture r = do_get("/users/42");
154+
ws.stop();
155+
156+
LT_CHECK_EQ(r.status, 200L);
157+
LT_CHECK_EQ(r.body, std::string("42"));
158+
LT_END_AUTO_TEST(parameterized_route_extracts_capture)
159+
160+
// Invariant 2: prefix route — `/static` matched against `/static/foo/bar`
161+
// must hit and route_resolved ctx must carry is_prefix=true.
162+
LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, prefix_route_marks_is_prefix_true)
163+
hook_probe probe;
164+
webserver ws{create_webserver(PORT)};
165+
166+
auto h = ws.add_hook(hook_phase::route_resolved,
167+
std::function<void(const route_resolved_ctx&)>(
168+
[&probe](const route_resolved_ctx& ctx) {
169+
probe.calls.fetch_add(1, std::memory_order_relaxed);
170+
if (ctx.matched.has_value()) {
171+
probe.last_matched_engaged.store(true,
172+
std::memory_order_relaxed);
173+
probe.last_is_prefix.store(ctx.matched->is_prefix,
174+
std::memory_order_relaxed);
175+
probe.last_resource_non_null.store(
176+
ctx.resource != nullptr,
177+
std::memory_order_relaxed);
178+
}
179+
}));
180+
(void)h;
181+
182+
auto resource = std::make_shared<hello_resource>();
183+
ws.register_prefix("/static", resource);
184+
ws.start(false);
185+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
186+
187+
response_capture r = do_get("/static/foo/bar");
188+
ws.stop();
189+
190+
LT_CHECK_EQ(r.status, 200L);
191+
LT_CHECK(probe.last_matched_engaged.load());
192+
LT_CHECK_EQ(probe.last_is_prefix.load(), true);
193+
LT_CHECK(probe.last_resource_non_null.load());
194+
LT_END_AUTO_TEST(prefix_route_marks_is_prefix_true)
195+
196+
// Invariant 3: exact route — `/exact` hit must carry is_prefix=false.
197+
LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, exact_route_marks_is_prefix_false)
198+
hook_probe probe;
199+
webserver ws{create_webserver(PORT)};
200+
201+
auto h = ws.add_hook(hook_phase::route_resolved,
202+
std::function<void(const route_resolved_ctx&)>(
203+
[&probe](const route_resolved_ctx& ctx) {
204+
probe.calls.fetch_add(1, std::memory_order_relaxed);
205+
if (ctx.matched.has_value()) {
206+
probe.last_matched_engaged.store(true,
207+
std::memory_order_relaxed);
208+
probe.last_is_prefix.store(ctx.matched->is_prefix,
209+
std::memory_order_relaxed);
210+
probe.last_resource_non_null.store(
211+
ctx.resource != nullptr,
212+
std::memory_order_relaxed);
213+
}
214+
}));
215+
(void)h;
216+
217+
auto resource = std::make_shared<hello_resource>();
218+
ws.register_path("/exact", resource);
219+
ws.start(false);
220+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
221+
222+
response_capture r = do_get("/exact");
223+
ws.stop();
224+
225+
LT_CHECK_EQ(r.status, 200L);
226+
LT_CHECK(probe.last_matched_engaged.load());
227+
LT_CHECK_EQ(probe.last_is_prefix.load(), false);
228+
LT_CHECK(probe.last_resource_non_null.load());
229+
LT_END_AUTO_TEST(exact_route_marks_is_prefix_false)
230+
231+
// Invariant 4: method mismatch returns 405; the route_resolved hook ctx
232+
// MUST still carry a non-null resource pointer because route resolution
233+
// succeeded — the method check that produces 405 runs AFTER the lookup
234+
// and uses the resolved resource's get_allowed_methods().
235+
LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, method_mismatch_still_resolves_route)
236+
hook_probe probe;
237+
webserver ws{create_webserver(PORT)};
238+
239+
auto h = ws.add_hook(hook_phase::route_resolved,
240+
std::function<void(const route_resolved_ctx&)>(
241+
[&probe](const route_resolved_ctx& ctx) {
242+
probe.calls.fetch_add(1, std::memory_order_relaxed);
243+
if (ctx.matched.has_value()) {
244+
probe.last_matched_engaged.store(true,
245+
std::memory_order_relaxed);
246+
probe.last_resource_non_null.store(
247+
ctx.resource != nullptr,
248+
std::memory_order_relaxed);
249+
}
250+
}));
251+
(void)h;
252+
253+
auto resource = std::make_shared<hello_resource>();
254+
// Constrain the resource to GET-only so a POST against /get_only
255+
// exercises the dispatch path's 405 branch — the lookup MUST resolve
256+
// the resource (so the hook ctx carries it) and the method check
257+
// that runs AFTER the lookup returns 405.
258+
resource->disallow_all();
259+
resource->set_allowing(httpserver::http_method::get, true);
260+
ws.register_path("/get_only", resource);
261+
ws.start(false);
262+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
263+
264+
long post_status = do_post_status("/get_only");
265+
ws.stop();
266+
267+
LT_CHECK_EQ(post_status, 405L);
268+
LT_CHECK(probe.last_matched_engaged.load());
269+
LT_CHECK(probe.last_resource_non_null.load());
270+
LT_END_AUTO_TEST(method_mismatch_still_resolves_route)
271+
272+
LT_BEGIN_AUTO_TEST_ENV()
273+
AUTORUN_TESTS()
274+
LT_END_AUTO_TEST_ENV()

0 commit comments

Comments
 (0)