#include #include #include #include "api_url_parser.hpp" // Helper to convert string to mutable buffer for testing std::string make_mutable_copy(const std::string &url) { return url; // Return copy that can be modified } TEST_CASE("ApiUrlParser routing") { SUBCASE("Static GET routes") { auto url = make_mutable_copy("/v1/version"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetVersion); url = make_mutable_copy("/v1/subscribe"); result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetSubscribe); url = make_mutable_copy("/metrics"); result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetMetrics); url = make_mutable_copy("/ok"); result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetOk); } SUBCASE("Static POST routes") { auto url = make_mutable_copy("/v1/commit"); RouteMatch match; auto result = ApiUrlParser::parse("POST", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::PostCommit); } SUBCASE("Not found") { auto url = make_mutable_copy("/unknown/route"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::NotFound); url = make_mutable_copy("/v1/version"); result = ApiUrlParser::parse("DELETE", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::NotFound); } } TEST_CASE("ApiUrlParser with query strings") { SUBCASE("Simple query string") { auto url = make_mutable_copy("/v1/status?request_id=123"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "123"); } SUBCASE("Multiple query parameters") { auto url = make_mutable_copy("/v1/status?request_id=abc&min_version=42"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "abc"); REQUIRE(match.params[static_cast(ApiParameterKey::MinVersion)] .has_value()); CHECK(match.params[static_cast(ApiParameterKey::MinVersion)].value() == "42"); } SUBCASE("Unknown parameters are ignored") { auto url = make_mutable_copy("/v1/version?foo=bar&baz=quux"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetVersion); CHECK_FALSE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); } } TEST_CASE("ApiUrlParser with URL parameters") { SUBCASE("PUT retention policy") { auto url = make_mutable_copy("/v1/retention/my-policy"); RouteMatch match; auto result = ApiUrlParser::parse("PUT", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::PutRetention); REQUIRE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::PolicyId)].value() == "my-policy"); } SUBCASE("DELETE retention policy") { auto url = make_mutable_copy("/v1/retention/another-policy"); RouteMatch match; auto result = ApiUrlParser::parse("DELETE", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::DeleteRetention); REQUIRE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::PolicyId)].value() == "another-policy"); } SUBCASE("GET retention policy") { auto url = make_mutable_copy("/v1/retention/get-this"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetRetention); REQUIRE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::PolicyId)].value() == "get-this"); } SUBCASE("GET all retention policies (no ID)") { auto url = make_mutable_copy("/v1/retention"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetRetention); CHECK_FALSE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); } } TEST_CASE("ApiUrlParser with URL and query parameters") { auto url = make_mutable_copy("/v1/retention/p1?request_id=abc123"); RouteMatch match; auto result = ApiUrlParser::parse("DELETE", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::DeleteRetention); REQUIRE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::PolicyId)].value() == "p1"); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "abc123"); } TEST_CASE("ApiUrlParser URL decoding") { SUBCASE("Path segment percent-decoding") { auto url = make_mutable_copy("/v1/retention/my%2Dpolicy"); RouteMatch match; auto result = ApiUrlParser::parse("PUT", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::PutRetention); REQUIRE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::PolicyId)].value() == "my-policy"); } SUBCASE("Query parameter form decoding (+ to space)") { auto url = make_mutable_copy("/v1/status?request_id=hello+world"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "hello world"); } SUBCASE("Query parameter percent-decoding") { auto url = make_mutable_copy("/v1/status?request_id=hello%20world"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "hello world"); } SUBCASE("Base64-like sequences in query parameters") { auto url = make_mutable_copy("/v1/status?request_id=YWJj%3D"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "YWJj="); } SUBCASE("Mixed encoding in path and query") { auto url = make_mutable_copy( "/v1/retention/my%2Dpolicy?request_id=hello+world%21"); RouteMatch match; auto result = ApiUrlParser::parse("DELETE", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::DeleteRetention); REQUIRE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::PolicyId)].value() == "my-policy"); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "hello world!"); } } TEST_CASE("ApiUrlParser malformed encoding") { SUBCASE("Incomplete percent sequence in path") { auto url = make_mutable_copy("/v1/retention/bad%2"); RouteMatch match; auto result = ApiUrlParser::parse("PUT", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::MalformedEncoding); } SUBCASE("Invalid hex digits in path") { auto url = make_mutable_copy("/v1/retention/bad%ZZ"); RouteMatch match; auto result = ApiUrlParser::parse("PUT", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::MalformedEncoding); } SUBCASE("Incomplete percent sequence in query") { auto url = make_mutable_copy("/v1/status?request_id=bad%2"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::MalformedEncoding); } SUBCASE("Invalid hex digits in query") { auto url = make_mutable_copy("/v1/status?request_id=bad%GG"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::MalformedEncoding); } SUBCASE("Percent at end of path") { auto url = make_mutable_copy("/v1/retention/bad%"); RouteMatch match; auto result = ApiUrlParser::parse("PUT", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::MalformedEncoding); } SUBCASE("Percent at end of query") { auto url = make_mutable_copy("/v1/status?request_id=bad%"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::MalformedEncoding); } } TEST_CASE("ApiUrlParser edge cases and bugs") { SUBCASE("Bug: Path boundary error - /v1/retention/ with trailing slash") { // BUG: Code checks length > 13 but substrings at 14, causing off-by-one auto url = make_mutable_copy( "/v1/retention/"); // length 14, exactly the boundary case RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetRetention); // This should NOT set PolicyId since it's empty, but current code might // have issues CHECK_FALSE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); } SUBCASE("Bug: Empty URL handling") { auto url = make_mutable_copy(""); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::NotFound); } SUBCASE("Bug: Query-only URL") { auto url = make_mutable_copy("?request_id=123"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::NotFound); // Should still parse query parameters REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "123"); } SUBCASE("Bug: Consecutive delimiters in query string") { auto url = make_mutable_copy("/v1/status?&&request_id=123&&min_version=42&&"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "123"); REQUIRE(match.params[static_cast(ApiParameterKey::MinVersion)] .has_value()); CHECK(match.params[static_cast(ApiParameterKey::MinVersion)].value() == "42"); } SUBCASE("Bug: Parameter without value (should be skipped)") { auto url = make_mutable_copy("/v1/status?debug&request_id=123"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); // debug parameter should be ignored since it has no value REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "123"); } SUBCASE("Bug: Empty parameter value") { auto url = make_mutable_copy("/v1/status?request_id="); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == ""); } SUBCASE("Edge: Exact length boundary for retention path") { // Test the exact boundary condition: "/v1/retention" is 13 chars auto url = make_mutable_copy("/v1/retention"); // exactly 13 characters RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetRetention); CHECK_FALSE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); } SUBCASE("Edge: Minimum valid policy ID") { // Test one character after the boundary auto url = make_mutable_copy("/v1/retention/a"); // 15 chars total, policy_id = "a" RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetRetention); REQUIRE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::PolicyId)].value() == "a"); } } TEST_CASE("ApiUrlParser specific bug reproduction") { SUBCASE("Reproduction: Path boundary math error") { // The bug: code checks length > 13 but substrings at 14 // "/v1/retention" = 13 chars, "/v1/retention/" = 14 chars // This test should demonstrate undefined behavior or wrong results auto url = make_mutable_copy("/v1/retention/"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); // Expected behavior: should match GetRetention but NOT set PolicyId CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetRetention); // The bug: may incorrectly extract empty string or cause buffer read error // Let's see what actually happens if (match.params[static_cast(ApiParameterKey::PolicyId)].has_value()) { auto policy_id = match.params[static_cast(ApiParameterKey::PolicyId)].value(); CHECK(policy_id.empty()); // Should be empty if set at all } } SUBCASE("Reproduction: Query parsing with edge cases") { // Test parameter parsing with multiple edge conditions auto url = make_mutable_copy( "/v1/status?=empty_key&no_value&request_id=&min_version=42&="); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); // Should handle empty values correctly REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == ""); REQUIRE(match.params[static_cast(ApiParameterKey::MinVersion)] .has_value()); CHECK(match.params[static_cast(ApiParameterKey::MinVersion)].value() == "42"); } SUBCASE("Reproduction: Very long input stress test") { // Test potential integer overflow or performance issues std::string long_policy_id(1000, 'x'); // 1000 character policy ID auto url = make_mutable_copy("/v1/retention/" + long_policy_id + "?request_id=" + std::string(500, 'y')); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetRetention); REQUIRE( match.params[static_cast(ApiParameterKey::PolicyId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::PolicyId)].value() == long_policy_id); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == std::string(500, 'y')); } SUBCASE("Reproduction: Zero-length edge case") { char empty_buffer[1] = {0}; RouteMatch match; auto result = ApiUrlParser::parse("GET", empty_buffer, 0, match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::NotFound); } SUBCASE("Reproduction: Null buffer edge case") { // This might cause undefined behavior if not handled properly RouteMatch match; char single_char = '/'; auto result = ApiUrlParser::parse("GET", &single_char, 1, match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::NotFound); } SUBCASE("BUG: Query parser pos increment overflow") { // BUG: pos += pair_end + 1 can go beyond buffer bounds // When pair_end == query_length - pos (no & found), pos becomes // query_length + 1 auto url = make_mutable_copy("/v1/status?no_ampersand_at_end"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); // This should not crash or have undefined behavior CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetStatus); // Parameter should be ignored since it's not a known key } SUBCASE("BUG: String view with potentially negative length cast") { // BUG: decoded_value_length is int but gets cast to size_t for string_view // If decode function returned negative (which it can), this could wrap // around // We can't easily trigger the decode function to return -1 through normal // parsing since that's caught earlier, but this tests the edge case // handling auto url = make_mutable_copy("/v1/status?request_id=normal_value"); RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); REQUIRE( match.params[static_cast(ApiParameterKey::RequestId)].has_value()); CHECK(match.params[static_cast(ApiParameterKey::RequestId)].value() == "normal_value"); } SUBCASE("BUG: Array bounds - PolicyId path extraction edge case") { // Test the boundary condition more precisely // "/v1/retention" = 13 chars, checking length > 13, substr(14) auto url = make_mutable_copy("/v1/retention/"); // exactly 14 chars RouteMatch match; auto result = ApiUrlParser::parse("GET", url.data(), static_cast(url.size()), match); CHECK(result == ParseResult::Success); CHECK(match.route == HttpRoute::GetRetention); // path.length() = 14, so > 13 is true // path.substr(14) should return empty string_view // The bug would be if this crashes or returns invalid data if (match.params[static_cast(ApiParameterKey::PolicyId)].has_value()) { CHECK(match.params[static_cast(ApiParameterKey::PolicyId)] .value() .empty()); } } }