diff --git a/.gitignore b/.gitignore index 4c4573117..bc88a7e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ services/vroom/data/ Claude.md .claude/ .cursor/ +Agents.md diff --git a/app/api/CMakeLists.txt b/app/api/CMakeLists.txt index 58311100c..825ffca48 100644 --- a/app/api/CMakeLists.txt +++ b/app/api/CMakeLists.txt @@ -11,6 +11,7 @@ target_sources( src/endpoints/optimization_jobs_endpoint.cpp src/endpoints/optimize_endpoint.cpp src/endpoints/osrm_proxy_endpoint.cpp + src/endpoints/whatsapp_endpoint.cpp src/optimization_job_runtime.cpp src/optimization_job_store.cpp src/observability.cpp @@ -34,6 +35,7 @@ target_sources( include/deliveryoptimizer/api/endpoints/optimization_jobs_endpoint.hpp include/deliveryoptimizer/api/endpoints/optimize_endpoint.hpp include/deliveryoptimizer/api/endpoints/osrm_proxy_endpoint.hpp + include/deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp include/deliveryoptimizer/api/forecast_optimizer.hpp include/deliveryoptimizer/api/internal/json_utils.hpp include/deliveryoptimizer/api/optimization_job_runtime.hpp diff --git a/app/api/include/deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp b/app/api/include/deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp new file mode 100644 index 000000000..f92d3c607 --- /dev/null +++ b/app/api/include/deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace drogon { +class HttpAppFramework; +} + +namespace deliveryoptimizer::api { + +void RegisterWhatsAppEndpoint(drogon::HttpAppFramework& app); + +} // namespace deliveryoptimizer::api diff --git a/app/api/src/api_server.cpp b/app/api/src/api_server.cpp index 7a4c87156..285233db8 100644 --- a/app/api/src/api_server.cpp +++ b/app/api/src/api_server.cpp @@ -6,6 +6,7 @@ #include "deliveryoptimizer/api/endpoints/optimization_jobs_endpoint.hpp" #include "deliveryoptimizer/api/endpoints/optimize_endpoint.hpp" #include "deliveryoptimizer/api/endpoints/osrm_proxy_endpoint.hpp" +#include "deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp" #include "deliveryoptimizer/api/observability.hpp" #include "deliveryoptimizer/api/optimization_job_runtime.hpp" #include "deliveryoptimizer/api/optimization_job_store.hpp" @@ -147,6 +148,7 @@ int RunApiServer() { RegisterDeliveriesOptimizeEndpoint(app, options.solve_admission, observability); } RegisterOsrmProxyEndpoint(app); + RegisterWhatsAppEndpoint(app); app.addListener("0.0.0.0", options.listen_port); // Parser-generated 413 responses bypass request/response advices, so we keep a diff --git a/app/api/src/endpoints/whatsapp_endpoint.cpp b/app/api/src/endpoints/whatsapp_endpoint.cpp new file mode 100644 index 000000000..522bcd1b9 --- /dev/null +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -0,0 +1,196 @@ +#include "deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp" + +#include "env_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr std::string_view kDefaultWhatsAppBaseUrl = "https://graph.facebook.com"; +constexpr char kWhatsAppBaseUrlEnv[] = "WHATSAPP_API_BASE_URL"; +constexpr std::string_view kDefaultWhatsAppApiVersion = "v23.0"; +constexpr char kWhatsAppApiVersionEnv[] = "WHATSAPP_API_VERSION"; +constexpr char kWhatsAppAccessTokenEnv[] = "WHATSAPP_ACCESS_TOKEN"; +constexpr char kWhatsAppPhoneNumberIdEnv[] = "WHATSAPP_PHONE_NUMBER_ID"; +constexpr char kWhatsAppSendSecretEnv[] = "WHATSAPP_SEND_ROUTE_SECRET"; +constexpr char kSendSecretHeader[] = "X-WhatsApp-Send-Secret"; +constexpr char kSendRoutePath[] = "/api/whatsapp/send-route"; +constexpr char kToField[] = "to"; +constexpr char kMessageField[] = "message"; + +[[nodiscard]] std::string TrimWhitespace(const std::string_view value) { + std::size_t first = 0; + while (first < value.size() && std::isspace(static_cast(value[first])) != 0) { + ++first; + } + + std::size_t last = value.size(); + while (last > first && std::isspace(static_cast(value[last - 1])) != 0) { + --last; + } + + return std::string{value.substr(first, last - first)}; +} + +[[nodiscard]] std::string ResolveTrimmedEnvOrDefault(const char* key, + const std::string_view default_value) { + return TrimWhitespace(deliveryoptimizer::api::ResolveEnvOrDefault(key, default_value)); +} + +[[nodiscard]] std::string +ResolveTrimmedEnvOrDefaultWhenBlank(const char* key, const std::string_view default_value) { + auto value = ResolveTrimmedEnvOrDefault(key, default_value); + if (value.empty()) { + return std::string{default_value}; + } + + return value; +} + +[[nodiscard]] drogon::HttpResponsePtr BuildJsonResponse(const Json::Value& body, + const drogon::HttpStatusCode code) { + auto response = drogon::HttpResponse::newHttpJsonResponse(body); + response->setStatusCode(code); + return response; +} + +[[nodiscard]] drogon::HttpResponsePtr BuildErrorResponse(const drogon::HttpStatusCode code, + const std::string_view error_message) { + Json::Value body{Json::objectValue}; + body["error"] = std::string{error_message}; + return BuildJsonResponse(body, code); +} + +[[nodiscard]] std::optional ReadNonEmptyStringMember(const Json::Value& body, + const char* field_name) { + if (!body.isMember(field_name) || !body[field_name].isString()) { + return std::nullopt; + } + + auto value = body[field_name].asString(); + if (value.empty() || std::all_of(value.begin(), value.end(), + [](const unsigned char ch) { return std::isspace(ch) != 0; })) { + return std::nullopt; + } + + return value; +} + +[[nodiscard]] bool IsSuccessStatus(const drogon::HttpStatusCode status_code) { + const auto status = static_cast(status_code); + return status >= 200 && status < 300; +} + +[[nodiscard]] Json::Value BuildWhatsAppPayload(const std::string& to_number, + const std::string& message) { + Json::Value payload{Json::objectValue}; + payload["messaging_product"] = "whatsapp"; + payload["to"] = to_number; + payload["type"] = "text"; + payload["text"]["body"] = message; + return payload; +} + +[[nodiscard]] drogon::HttpResponsePtr +BuildUpstreamFailureResponse(const drogon::HttpResponsePtr& upstream_response) { + Json::Value body{Json::objectValue}; + body["error"] = "WhatsApp upstream request failed."; + if (upstream_response != nullptr) { + body["upstream_status"] = static_cast(upstream_response->getStatusCode()); + } + return BuildJsonResponse(body, drogon::k502BadGateway); +} + +} // namespace + +namespace deliveryoptimizer::api { + +void RegisterWhatsAppEndpoint(drogon::HttpAppFramework& app) { + const auto whatsapp_base_url = + ResolveNormalizedUrlEnvOrDefault(kWhatsAppBaseUrlEnv, kDefaultWhatsAppBaseUrl); + auto whatsapp_client = drogon::HttpClient::newHttpClient(whatsapp_base_url); + + app.registerHandler( + kSendRoutePath, + [whatsapp_client = std::move(whatsapp_client)]( + const drogon::HttpRequestPtr& request, + std::function&& callback) mutable { + const auto request_body = request->getJsonObject(); + if (request_body == nullptr) { + std::move(callback)( + BuildErrorResponse(drogon::k400BadRequest, "Request body must be valid JSON.")); + return; + } + + const auto configured_secret = ResolveTrimmedEnvOrDefault(kWhatsAppSendSecretEnv, ""); + if (configured_secret.empty()) { + std::move(callback)( + BuildErrorResponse(drogon::k503ServiceUnavailable, "WhatsApp is not configured.")); + return; + } + + const auto provided_secret = request->getHeader(kSendSecretHeader); + if (provided_secret != configured_secret) { + std::move(callback)(BuildErrorResponse(drogon::k401Unauthorized, "Unauthorized.")); + return; + } + + const auto to_number = ReadNonEmptyStringMember(*request_body, kToField); + if (!to_number.has_value()) { + std::move(callback)( + BuildErrorResponse(drogon::k400BadRequest, "Request body must include to.")); + return; + } + + const auto message = ReadNonEmptyStringMember(*request_body, kMessageField); + if (!message.has_value()) { + std::move(callback)( + BuildErrorResponse(drogon::k400BadRequest, "Request body must include message.")); + return; + } + + const auto access_token = ResolveTrimmedEnvOrDefault(kWhatsAppAccessTokenEnv, ""); + const auto phone_number_id = ResolveTrimmedEnvOrDefault(kWhatsAppPhoneNumberIdEnv, ""); + if (access_token.empty() || phone_number_id.empty()) { + std::move(callback)( + BuildErrorResponse(drogon::k503ServiceUnavailable, "WhatsApp is not configured.")); + return; + } + + const auto api_version = + ResolveTrimmedEnvOrDefaultWhenBlank(kWhatsAppApiVersionEnv, kDefaultWhatsAppApiVersion); + + auto upstream_request = drogon::HttpRequest::newHttpRequest(); + upstream_request->setMethod(drogon::Post); + upstream_request->setPath("/" + api_version + "/" + phone_number_id + "/messages"); + upstream_request->addHeader("Authorization", "Bearer " + access_token); + upstream_request->setContentTypeCode(drogon::CT_APPLICATION_JSON); + + const auto payload = BuildWhatsAppPayload(*to_number, *message); + upstream_request->setBody(payload.toStyledString()); + + whatsapp_client->sendRequest( + upstream_request, + [callback = std::move(callback)](const drogon::ReqResult result, + const drogon::HttpResponsePtr& response) mutable { + if (result == drogon::ReqResult::Ok && response != nullptr && + IsSuccessStatus(response->getStatusCode())) { + std::move(callback)(response); + return; + } + + std::move(callback)(BuildUpstreamFailureResponse(response)); + }); + }, + {drogon::Post}); +} + +} // namespace deliveryoptimizer::api diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index adf46101b..7816b1904 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -268,6 +268,77 @@ deliveryoptimizer_set_local_http_test_environment( "DELIVERYOPTIMIZER_TEST_PORT=${osrm_proxy_test_port};DELIVERYOPTIMIZER_OSRM_STUB_PORT=${osrm_stub_test_port}" ) +deliveryoptimizer_add_local_http_test( + DeliveryOptimizerApiValidationTest.WhatsappSendRouteRejectsInvalidRequest + whatsapp_send_route_rejects_invalid_request_test.sh + 30 + "${DELIVERYOPTIMIZER_LOCAL_API_LABELS}" +) +deliveryoptimizer_set_local_http_test_port( + DeliveryOptimizerApiValidationTest.WhatsappSendRouteRejectsInvalidRequest + 38400 +) + +deliveryoptimizer_add_local_http_test( + DeliveryOptimizerApiValidationTest.WhatsappSendRouteRequiresConfiguration + whatsapp_send_route_requires_configuration_test.sh + 30 + "${DELIVERYOPTIMIZER_LOCAL_API_LABELS}" +) +deliveryoptimizer_set_local_http_test_port( + DeliveryOptimizerApiValidationTest.WhatsappSendRouteRequiresConfiguration + 38500 +) + +deliveryoptimizer_add_local_http_test( + DeliveryOptimizerApiValidationTest.WhatsappSendRouteRejectsUnauthorizedRequest + whatsapp_send_route_rejects_unauthorized_request_test.sh + 30 + "${DELIVERYOPTIMIZER_LOCAL_API_LABELS}" +) +deliveryoptimizer_set_local_http_test_port( + DeliveryOptimizerApiValidationTest.WhatsappSendRouteRejectsUnauthorizedRequest + 38800 +) + +add_test( + NAME DeliveryOptimizerApiContractTest.WhatsappSendRouteForwardsToUpstream + COMMAND "${BASH_PROGRAM}" + "${CMAKE_CURRENT_SOURCE_DIR}/integration/http_server/whatsapp_send_route_forwards_to_upstream_test.sh" + "$" "${PYTHON3_PROGRAM}" "${CURL_PROGRAM}" +) +set_tests_properties( + DeliveryOptimizerApiContractTest.WhatsappSendRouteForwardsToUpstream + PROPERTIES + TIMEOUT 30 + LABELS "${DELIVERYOPTIMIZER_LOCAL_API_LABELS}" +) +deliveryoptimizer_resolve_local_http_test_port(whatsapp_send_route_test_port 38600) +deliveryoptimizer_resolve_local_http_test_port(whatsapp_send_route_stub_port 52200) +deliveryoptimizer_set_local_http_test_environment( + DeliveryOptimizerApiContractTest.WhatsappSendRouteForwardsToUpstream + "DELIVERYOPTIMIZER_TEST_PORT=${whatsapp_send_route_test_port};DELIVERYOPTIMIZER_WHATSAPP_STUB_PORT=${whatsapp_send_route_stub_port}" +) + +add_test( + NAME DeliveryOptimizerApiContractTest.WhatsappSendRouteReturns502ForUpstreamFailure + COMMAND "${BASH_PROGRAM}" + "${CMAKE_CURRENT_SOURCE_DIR}/integration/http_server/whatsapp_send_route_returns_502_for_upstream_failure_test.sh" + "$" "${PYTHON3_PROGRAM}" "${CURL_PROGRAM}" +) +set_tests_properties( + DeliveryOptimizerApiContractTest.WhatsappSendRouteReturns502ForUpstreamFailure + PROPERTIES + TIMEOUT 30 + LABELS "${DELIVERYOPTIMIZER_LOCAL_API_LABELS}" +) +deliveryoptimizer_resolve_local_http_test_port(whatsapp_send_route_failure_test_port 38700) +deliveryoptimizer_resolve_local_http_test_port(whatsapp_send_route_failure_stub_port 52300) +deliveryoptimizer_set_local_http_test_environment( + DeliveryOptimizerApiContractTest.WhatsappSendRouteReturns502ForUpstreamFailure + "DELIVERYOPTIMIZER_TEST_PORT=${whatsapp_send_route_failure_test_port};DELIVERYOPTIMIZER_WHATSAPP_FAILURE_STUB_PORT=${whatsapp_send_route_failure_stub_port}" +) + deliveryoptimizer_add_local_http_test( DeliveryOptimizerApiContractTest.OsrmProxyRejectsDisallowedService osrm_proxy_rejects_disallowed_service_test.sh diff --git a/tests/integration/http_server/whatsapp_send_route_forwards_to_upstream_test.sh b/tests/integration/http_server/whatsapp_send_route_forwards_to_upstream_test.sh new file mode 100644 index 000000000..9c09dacee --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_forwards_to_upstream_test.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/integration/http_server/http_server_helpers.sh +source "${script_dir}/http_server_helpers.sh" + +if [[ $# -lt 2 ]]; then + echo "usage: $0 [curl-binary]" >&2 + exit 2 +fi + +python_bin="$2" +curl_bin="${3:-curl}" +stub_default_port="$((52200 + ($$ % 10000)))" +stub_port="${DELIVERYOPTIMIZER_WHATSAPP_STUB_PORT:-${stub_default_port}}" + +http_server_init 38600 "$1" "${curl_bin}" +response_file="${work_dir}/response.json" +payload_file="${work_dir}/payload.json" +request_path_file="${work_dir}/request-path.txt" +request_auth_file="${work_dir}/request-auth.txt" +request_content_type_file="${work_dir}/request-content-type.txt" +request_body_file="${work_dir}/request-body.json" +ready_file="${work_dir}/stub-ready.txt" +stub_log_file="${work_dir}/stub.log" +rm -f "${request_path_file}" "${request_auth_file}" "${request_content_type_file}" \ + "${request_body_file}" "${ready_file}" + +http_server_cleanup_with_stub() { + if [[ -n "${stub_pid:-}" ]]; then + kill "${stub_pid}" >/dev/null 2>&1 || true + wait "${stub_pid}" >/dev/null 2>&1 || true + fi + http_server_cleanup +} +trap http_server_cleanup_with_stub EXIT + +env STUB_PORT="${stub_port}" REQUEST_PATH_FILE="${request_path_file}" \ + REQUEST_AUTH_FILE="${request_auth_file}" \ + REQUEST_CONTENT_TYPE_FILE="${request_content_type_file}" \ + REQUEST_BODY_FILE="${request_body_file}" READY_FILE="${ready_file}" \ + "${python_bin}" - >"${stub_log_file}" 2>&1 <<'PY' & +import os +from http.server import BaseHTTPRequestHandler, HTTPServer + +port = int(os.environ["STUB_PORT"]) +request_path_file = os.environ["REQUEST_PATH_FILE"] +request_auth_file = os.environ["REQUEST_AUTH_FILE"] +request_content_type_file = os.environ["REQUEST_CONTENT_TYPE_FILE"] +request_body_file = os.environ["REQUEST_BODY_FILE"] +ready_file = os.environ["READY_FILE"] + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) + + with open(request_path_file, "w", encoding="utf-8") as path_file: + path_file.write(self.path) + with open(request_auth_file, "w", encoding="utf-8") as auth_file: + auth_file.write(self.headers.get("Authorization", "")) + with open(request_content_type_file, "w", encoding="utf-8") as content_type_file: + content_type_file.write(self.headers.get("Content-Type", "")) + with open(request_body_file, "wb") as body_file: + body_file.write(body) + + payload = b'{"messages":[{"id":"wamid.test-message"}]}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): + return + + +server = HTTPServer(("127.0.0.1", port), Handler) +with open(ready_file, "w", encoding="utf-8") as ready: + ready.write("ready") +server.serve_forever() +PY +stub_pid=$! + +stub_ready=false +for _ in $(seq 1 50); do + if [[ -f "${ready_file}" ]]; then + stub_ready=true + break + fi + if ! kill -0 "${stub_pid}" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done + +if [[ "${stub_ready}" != "true" ]]; then + echo "WhatsApp stub failed to start on port ${stub_port}" >&2 + cat "${stub_log_file}" >&2 || true + exit 1 +fi + +stop_http_server_for_restart() { + if [[ -n "${server_pid:-}" ]]; then + kill "${server_pid}" >/dev/null 2>&1 || true + wait "${server_pid}" >/dev/null 2>&1 || true + server_pid="" + fi +} + +send_route_secret="test-secret" + +send_route_and_assert_upstream_request() { + local expected_path="$1" + local expected_auth="$2" + + rm -f "${request_path_file}" "${request_auth_file}" "${request_content_type_file}" \ + "${request_body_file}" "${response_file}" + + http_server_wait_until_responding "/health" "${response_file}" + + printf '{"to":"14155551234","message":"Your route for today: Stop 1"}' >"${payload_file}" + http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: ${send_route_secret}" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + + if [[ "${http_code}" != "200" ]]; then + echo "expected HTTP 200 from WhatsApp send route, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + cat "${stub_log_file}" >&2 || true + exit 1 + fi + + if ! grep -Eq '"id"[[:space:]]*:[[:space:]]*"wamid.test-message"' "${response_file}"; then + echo "expected WhatsApp message id in response" >&2 + cat "${response_file}" >&2 || true + exit 1 + fi + + recorded_path="$(cat "${request_path_file}")" + if [[ "${recorded_path}" != "${expected_path}" ]]; then + echo "expected upstream path ${expected_path}, got ${recorded_path}" >&2 + exit 1 + fi + + recorded_auth="$(cat "${request_auth_file}")" + if [[ "${recorded_auth}" != "${expected_auth}" ]]; then + echo "expected bearer auth header ${expected_auth}, got ${recorded_auth}" >&2 + exit 1 + fi + + recorded_content_type="$(cat "${request_content_type_file}")" + if [[ "${recorded_content_type}" != application/json* ]]; then + echo "expected JSON content type, got ${recorded_content_type}" >&2 + exit 1 + fi + + if ! grep -Eq '"messaging_product"[[:space:]]*:[[:space:]]*"whatsapp"' "${request_body_file}" || + ! grep -Eq '"to"[[:space:]]*:[[:space:]]*"14155551234"' "${request_body_file}" || + ! grep -Eq '"type"[[:space:]]*:[[:space:]]*"text"' "${request_body_file}" || + ! grep -Eq '"body"[[:space:]]*:[[:space:]]*"Your route for today: Stop 1"' "${request_body_file}"; then + echo "expected forwarded WhatsApp JSON payload" >&2 + cat "${request_body_file}" >&2 || true + exit 1 + fi +} + +http_server_start WHATSAPP_API_BASE_URL="http://127.0.0.1:${stub_port}" \ + WHATSAPP_ACCESS_TOKEN=$' test-token\n' WHATSAPP_PHONE_NUMBER_ID=$'\tphone-123 ' \ + WHATSAPP_API_VERSION=$' \t\n' WHATSAPP_SEND_ROUTE_SECRET="${send_route_secret}" +send_route_and_assert_upstream_request "/v23.0/phone-123/messages" "Bearer test-token" + +stop_http_server_for_restart +http_server_start WHATSAPP_API_BASE_URL="http://127.0.0.1:${stub_port}" \ + WHATSAPP_ACCESS_TOKEN="test-token" WHATSAPP_PHONE_NUMBER_ID="phone-123" \ + WHATSAPP_API_VERSION=$' v22.0\t' WHATSAPP_SEND_ROUTE_SECRET="${send_route_secret}" +send_route_and_assert_upstream_request "/v22.0/phone-123/messages" "Bearer test-token" diff --git a/tests/integration/http_server/whatsapp_send_route_rejects_invalid_request_test.sh b/tests/integration/http_server/whatsapp_send_route_rejects_invalid_request_test.sh new file mode 100644 index 000000000..1333764af --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_rejects_invalid_request_test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/integration/http_server/http_server_helpers.sh +source "${script_dir}/http_server_helpers.sh" + +http_server_init 38400 "$@" +response_file="${work_dir}/response.json" +payload_file="${work_dir}/payload.json" + +send_route_secret="test-secret" +http_server_start WHATSAPP_ACCESS_TOKEN="" WHATSAPP_PHONE_NUMBER_ID="" \ + WHATSAPP_SEND_ROUTE_SECRET="${send_route_secret}" +http_server_wait_until_responding "/health" "${response_file}" + +printf '{"message":"Your route for today: Stop 1"}' >"${payload_file}" +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: ${send_route_secret}" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "400" ]]; then + echo "expected HTTP 400 when to is missing, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"Request body must include to."' "${response_file}"; then + echo "expected missing to error response" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +printf '{"to":"14155551234"}' >"${payload_file}" +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: ${send_route_secret}" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "400" ]]; then + echo "expected HTTP 400 when message is missing, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"Request body must include message."' "${response_file}"; then + echo "expected missing message error response" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +printf '{"to":" ","message":"Your route for today: Stop 1"}' >"${payload_file}" +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: ${send_route_secret}" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "400" ]]; then + echo "expected HTTP 400 when to is whitespace-only, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"Request body must include to."' "${response_file}"; then + echo "expected whitespace-only to error response" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +printf '{"to":"14155551234","message":"\t \n"}' >"${payload_file}" +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: ${send_route_secret}" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "400" ]]; then + echo "expected HTTP 400 when message is whitespace-only, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"Request body must include message."' "${response_file}"; then + echo "expected whitespace-only message error response" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi diff --git a/tests/integration/http_server/whatsapp_send_route_rejects_unauthorized_request_test.sh b/tests/integration/http_server/whatsapp_send_route_rejects_unauthorized_request_test.sh new file mode 100644 index 000000000..623d773ec --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_rejects_unauthorized_request_test.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/integration/http_server/http_server_helpers.sh +source "${script_dir}/http_server_helpers.sh" + +http_server_init 38800 "$@" +response_file="${work_dir}/response.json" +payload_file="${work_dir}/payload.json" + +stop_http_server_for_restart() { + if [[ -n "${server_pid:-}" ]]; then + kill "${server_pid}" >/dev/null 2>&1 || true + wait "${server_pid}" >/dev/null 2>&1 || true + server_pid="" + fi +} + +printf '{"to":"14155551234","message":"Your route for today: Stop 1"}' >"${payload_file}" + +http_server_start WHATSAPP_ACCESS_TOKEN="test-token" WHATSAPP_PHONE_NUMBER_ID="phone-123" \ + WHATSAPP_SEND_ROUTE_SECRET=$' \t\n' +http_server_wait_until_responding "/health" "${response_file}" + +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: anything" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "503" ]]; then + echo "expected HTTP 503 when send route secret is blank, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"WhatsApp is not configured."' "${response_file}"; then + echo "expected WhatsApp configuration error response for blank secret" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +stop_http_server_for_restart + +send_route_secret="correct-secret" +http_server_start WHATSAPP_ACCESS_TOKEN="test-token" WHATSAPP_PHONE_NUMBER_ID="phone-123" \ + WHATSAPP_SEND_ROUTE_SECRET="${send_route_secret}" +http_server_wait_until_responding "/health" "${response_file}" + +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "401" ]]; then + echo "expected HTTP 401 when secret header is missing, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"Unauthorized."' "${response_file}"; then + echo "expected unauthorized error response for missing secret header" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: wrong-secret" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "401" ]]; then + echo "expected HTTP 401 when secret header does not match, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"Unauthorized."' "${response_file}"; then + echo "expected unauthorized error response for mismatched secret header" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +empty_payload_file="${work_dir}/empty-payload.json" +printf '{"message":"Your route for today: Stop 1"}' >"${empty_payload_file}" +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: ${send_route_secret}" \ + --data-binary "@${empty_payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "400" ]]; then + echo "expected HTTP 400 past the secret check when to is missing, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"Request body must include to."' "${response_file}"; then + echo "expected request to pass the secret check and reach field validation" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi diff --git a/tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh b/tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh new file mode 100644 index 000000000..638f600ff --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/integration/http_server/http_server_helpers.sh +source "${script_dir}/http_server_helpers.sh" + +http_server_init 38500 "$@" +response_file="${work_dir}/response.json" +payload_file="${work_dir}/payload.json" + +send_route_secret="test-secret" +http_server_start WHATSAPP_ACCESS_TOKEN=$' \t\n' WHATSAPP_PHONE_NUMBER_ID=$'\n ' \ + WHATSAPP_SEND_ROUTE_SECRET="${send_route_secret}" +http_server_wait_until_responding "/health" "${response_file}" + +printf '{"to":"14155551234","message":"Your route for today: Stop 1"}' >"${payload_file}" +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: ${send_route_secret}" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "503" ]]; then + echo "expected HTTP 503 when WhatsApp credentials are blank, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"WhatsApp is not configured."' "${response_file}"; then + echo "expected WhatsApp configuration error response" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi diff --git a/tests/integration/http_server/whatsapp_send_route_returns_502_for_upstream_failure_test.sh b/tests/integration/http_server/whatsapp_send_route_returns_502_for_upstream_failure_test.sh new file mode 100644 index 000000000..c14f37da3 --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_returns_502_for_upstream_failure_test.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/integration/http_server/http_server_helpers.sh +source "${script_dir}/http_server_helpers.sh" + +if [[ $# -lt 2 ]]; then + echo "usage: $0 [curl-binary]" >&2 + exit 2 +fi + +python_bin="$2" +curl_bin="${3:-curl}" +stub_default_port="$((52300 + ($$ % 10000)))" +stub_port="${DELIVERYOPTIMIZER_WHATSAPP_FAILURE_STUB_PORT:-${stub_default_port}}" + +http_server_init 38700 "$1" "${curl_bin}" +response_file="${work_dir}/response.json" +payload_file="${work_dir}/payload.json" +ready_file="${work_dir}/stub-ready.txt" +stub_log_file="${work_dir}/stub.log" +rm -f "${ready_file}" + +http_server_cleanup_with_stub() { + if [[ -n "${stub_pid:-}" ]]; then + kill "${stub_pid}" >/dev/null 2>&1 || true + wait "${stub_pid}" >/dev/null 2>&1 || true + fi + http_server_cleanup +} +trap http_server_cleanup_with_stub EXIT + +env STUB_PORT="${stub_port}" READY_FILE="${ready_file}" \ + "${python_bin}" - >"${stub_log_file}" 2>&1 <<'PY' & +import os +from http.server import BaseHTTPRequestHandler, HTTPServer + +port = int(os.environ["STUB_PORT"]) +ready_file = os.environ["READY_FILE"] + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + payload = b'{"error":{"message":"upstream failed"}}' + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): + return + + +server = HTTPServer(("127.0.0.1", port), Handler) +with open(ready_file, "w", encoding="utf-8") as ready: + ready.write("ready") +server.serve_forever() +PY +stub_pid=$! + +stub_ready=false +for _ in $(seq 1 50); do + if [[ -f "${ready_file}" ]]; then + stub_ready=true + break + fi + if ! kill -0 "${stub_pid}" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done + +if [[ "${stub_ready}" != "true" ]]; then + echo "WhatsApp failure stub failed to start on port ${stub_port}" >&2 + cat "${stub_log_file}" >&2 || true + exit 1 +fi + +send_route_secret="test-secret" +http_server_start WHATSAPP_API_BASE_URL="http://127.0.0.1:${stub_port}" \ + WHATSAPP_ACCESS_TOKEN="test-token" WHATSAPP_PHONE_NUMBER_ID="phone-123" \ + WHATSAPP_SEND_ROUTE_SECRET="${send_route_secret}" +http_server_wait_until_responding "/health" "${response_file}" + +printf '{"to":"14155551234","message":"Your route for today: Stop 1"}' >"${payload_file}" +http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-WhatsApp-Send-Secret: ${send_route_secret}" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "502" ]]; then + echo "expected HTTP 502 when WhatsApp upstream fails, got ${http_code}" >&2 + cat "${response_file}" >&2 || true + cat "${stub_log_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"WhatsApp upstream request failed."' "${response_file}"; then + echo "expected WhatsApp upstream failure error response" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi + +if ! grep -Eq '"upstream_status"[[:space:]]*:[[:space:]]*500' "${response_file}"; then + echo "expected upstream status in failure response" >&2 + cat "${response_file}" >&2 || true + exit 1 +fi