From ca6db31a8d4e54c9e302a9c535dff2b313ae519b Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 20 Jun 2026 22:37:52 -0700 Subject: [PATCH 1/9] chore: add Agents.md to .gitignore to prevent tracking of agent documentation --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4c457311..bc88a7e2 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ services/vroom/data/ Claude.md .claude/ .cursor/ +Agents.md From aaef73511a4b641aff7125c0bdf5caaea7700dcf Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 27 Jun 2026 20:08:51 -0700 Subject: [PATCH 2/9] feat(api): add WhatsApp send route endpoint Co-authored-by: Cursor --- app/api/CMakeLists.txt | 2 + .../api/endpoints/whatsapp_endpoint.hpp | 11 ++ app/api/src/api_server.cpp | 2 + app/api/src/endpoints/whatsapp_endpoint.cpp | 145 +++++++++++++++++ tests/CMakeLists.txt | 60 +++++++ ...pp_send_route_forwards_to_upstream_test.sh | 154 ++++++++++++++++++ ...send_route_rejects_invalid_request_test.sh | 51 ++++++ ..._send_route_requires_configuration_test.sh | 32 ++++ ...e_returns_502_for_upstream_failure_test.sh | 109 +++++++++++++ 9 files changed, 566 insertions(+) create mode 100644 app/api/include/deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp create mode 100644 app/api/src/endpoints/whatsapp_endpoint.cpp create mode 100644 tests/integration/http_server/whatsapp_send_route_forwards_to_upstream_test.sh create mode 100644 tests/integration/http_server/whatsapp_send_route_rejects_invalid_request_test.sh create mode 100644 tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh create mode 100644 tests/integration/http_server/whatsapp_send_route_returns_502_for_upstream_failure_test.sh diff --git a/app/api/CMakeLists.txt b/app/api/CMakeLists.txt index 58311100..825ffca4 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 00000000..f92d3c60 --- /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 7a4c8715..285233db 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 00000000..35db2be1 --- /dev/null +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -0,0 +1,145 @@ +#include "deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp" + +#include "env_utils.hpp" + +#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 char kWhatsAppAccessTokenEnv[] = "WHATSAPP_ACCESS_TOKEN"; +constexpr char kWhatsAppPhoneNumberIdEnv[] = "WHATSAPP_PHONE_NUMBER_ID"; +constexpr char kSendRoutePath[] = "/api/whatsapp/send-route"; +constexpr char kToField[] = "to"; +constexpr char kMessageField[] = "message"; + +[[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()) { + 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 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 = ResolveEnvOrDefault(kWhatsAppAccessTokenEnv, ""); + const auto phone_number_id = ResolveEnvOrDefault(kWhatsAppPhoneNumberIdEnv, ""); + if (access_token.empty() || phone_number_id.empty()) { + std::move(callback)( + BuildErrorResponse(drogon::k503ServiceUnavailable, "WhatsApp is not configured.")); + return; + } + + auto upstream_request = drogon::HttpRequest::newHttpRequest(); + upstream_request->setMethod(drogon::Post); + upstream_request->setPath("/v18.0/" + 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 adf46101..786fc00e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -268,6 +268,66 @@ 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 +) + +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 00000000..4efe234f --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_forwards_to_upstream_test.sh @@ -0,0 +1,154 @@ +#!/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 + +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" +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" \ + --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}" != "/v18.0/phone-123/messages" ]]; then + echo "expected upstream path /v18.0/phone-123/messages, got ${recorded_path}" >&2 + exit 1 +fi + +recorded_auth="$(cat "${request_auth_file}")" +if [[ "${recorded_auth}" != "Bearer test-token" ]]; then + echo "expected bearer auth header to be forwarded" >&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 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 00000000..e72628dc --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_rejects_invalid_request_test.sh @@ -0,0 +1,51 @@ +#!/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" + +http_server_start WHATSAPP_ACCESS_TOKEN="" WHATSAPP_PHONE_NUMBER_ID="" +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" \ + --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" \ + --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 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 00000000..2c93402c --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh @@ -0,0 +1,32 @@ +#!/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" + +http_server_start WHATSAPP_ACCESS_TOKEN="" WHATSAPP_PHONE_NUMBER_ID="" +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" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" + +if [[ "${http_code}" != "503" ]]; then + echo "expected HTTP 503 when WhatsApp credentials are missing, 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 00000000..a249fb9d --- /dev/null +++ b/tests/integration/http_server/whatsapp_send_route_returns_502_for_upstream_failure_test.sh @@ -0,0 +1,109 @@ +#!/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 + +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" +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" \ + --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 From 76444f3aad390a75005e6536675682ab129cb7f3 Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 27 Jun 2026 20:14:05 -0700 Subject: [PATCH 3/9] style(api): format WhatsApp endpoint Co-authored-by: Cursor --- app/api/src/endpoints/whatsapp_endpoint.cpp | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/api/src/endpoints/whatsapp_endpoint.cpp b/app/api/src/endpoints/whatsapp_endpoint.cpp index 35db2be1..470b41be 100644 --- a/app/api/src/endpoints/whatsapp_endpoint.cpp +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -3,8 +3,8 @@ #include "env_utils.hpp" #include -#include #include +#include #include #include #include @@ -63,8 +63,8 @@ constexpr char kMessageField[] = "message"; return payload; } -[[nodiscard]] drogon::HttpResponsePtr BuildUpstreamFailureResponse( - const drogon::HttpResponsePtr& upstream_response) { +[[nodiscard]] drogon::HttpResponsePtr +BuildUpstreamFailureResponse(const drogon::HttpResponsePtr& upstream_response) { Json::Value body{Json::objectValue}; body["error"] = "WhatsApp upstream request failed."; if (upstream_response != nullptr) { @@ -84,10 +84,9 @@ void RegisterWhatsAppEndpoint(drogon::HttpAppFramework& app) { app.registerHandler( kSendRoutePath, - [whatsapp_client = - std::move(whatsapp_client)](const drogon::HttpRequestPtr& request, - std::function&& - callback) mutable { + [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)( From 217532fb887ffb161eb9ed6a9c99d1bbeae5f8b9 Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 27 Jun 2026 23:14:13 -0700 Subject: [PATCH 4/9] fix(api): reject whitespace-only whatsapp fields --- app/api/src/endpoints/whatsapp_endpoint.cpp | 6 ++- ...send_route_rejects_invalid_request_test.sh | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/api/src/endpoints/whatsapp_endpoint.cpp b/app/api/src/endpoints/whatsapp_endpoint.cpp index 470b41be..7346264d 100644 --- a/app/api/src/endpoints/whatsapp_endpoint.cpp +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -2,6 +2,8 @@ #include "env_utils.hpp" +#include +#include #include #include #include @@ -41,7 +43,9 @@ constexpr char kMessageField[] = "message"; } auto value = body[field_name].asString(); - if (value.empty()) { + if (value.empty() || + std::all_of(value.begin(), value.end(), + [](const unsigned char ch) { return std::isspace(ch) != 0; })) { return std::nullopt; } 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 index e72628dc..17bd3f8b 100644 --- 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 @@ -49,3 +49,41 @@ if ! grep -Eq '"error"[[:space:]]*:[[:space:]]*"Request body must include messag 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" \ + --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" \ + --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 From ebf90e7cfe4f0b3ad07fafe90c28665bb6f52e47 Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 27 Jun 2026 23:20:35 -0700 Subject: [PATCH 5/9] style(api): format whatsapp endpoint --- app/api/src/endpoints/whatsapp_endpoint.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/api/src/endpoints/whatsapp_endpoint.cpp b/app/api/src/endpoints/whatsapp_endpoint.cpp index 7346264d..235a0de9 100644 --- a/app/api/src/endpoints/whatsapp_endpoint.cpp +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -43,9 +43,8 @@ constexpr char kMessageField[] = "message"; } 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; })) { + if (value.empty() || std::all_of(value.begin(), value.end(), + [](const unsigned char ch) { return std::isspace(ch) != 0; })) { return std::nullopt; } From f1361ecc421c63523f1f147b6e47e3558763e394 Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Thu, 2 Jul 2026 23:46:10 -0700 Subject: [PATCH 6/9] fix(api): address whatsapp review findings --- app/api/src/endpoints/whatsapp_endpoint.cpp | 43 ++++++- ...pp_send_route_forwards_to_upstream_test.sh | 115 +++++++++++------- ..._send_route_requires_configuration_test.sh | 4 +- 3 files changed, 112 insertions(+), 50 deletions(-) diff --git a/app/api/src/endpoints/whatsapp_endpoint.cpp b/app/api/src/endpoints/whatsapp_endpoint.cpp index 235a0de9..1b846a85 100644 --- a/app/api/src/endpoints/whatsapp_endpoint.cpp +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -16,12 +16,45 @@ 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 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(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); @@ -111,17 +144,21 @@ void RegisterWhatsAppEndpoint(drogon::HttpAppFramework& app) { return; } - const auto access_token = ResolveEnvOrDefault(kWhatsAppAccessTokenEnv, ""); - const auto phone_number_id = ResolveEnvOrDefault(kWhatsAppPhoneNumberIdEnv, ""); + 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("/v18.0/" + phone_number_id + "/messages"); + upstream_request->setPath("/" + api_version + "/" + phone_number_id + "/messages"); upstream_request->addHeader("Authorization", "Bearer " + access_token); upstream_request->setContentTypeCode(drogon::CT_APPLICATION_JSON); 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 index 4efe234f..95b2c3d5 100644 --- 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 @@ -102,53 +102,78 @@ if [[ "${stub_ready}" != "true" ]]; then exit 1 fi -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" -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" \ - --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 +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 +} -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 +send_route_and_assert_upstream_request() { + local expected_path="$1" + local expected_auth="$2" -recorded_path="$(cat "${request_path_file}")" -if [[ "${recorded_path}" != "/v18.0/phone-123/messages" ]]; then - echo "expected upstream path /v18.0/phone-123/messages, got ${recorded_path}" >&2 - exit 1 -fi + rm -f "${request_path_file}" "${request_auth_file}" "${request_content_type_file}" \ + "${request_body_file}" "${response_file}" -recorded_auth="$(cat "${request_auth_file}")" -if [[ "${recorded_auth}" != "Bearer test-token" ]]; then - echo "expected bearer auth header to be forwarded" >&2 - exit 1 -fi + http_server_wait_until_responding "/health" "${response_file}" -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 + 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" \ + --data-binary "@${payload_file}" \ + "$(http_server_url /api/whatsapp/send-route)")" -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 + 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' +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' +send_route_and_assert_upstream_request "/v22.0/phone-123/messages" "Bearer test-token" 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 index 2c93402c..c29cac66 100644 --- a/tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh +++ b/tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh @@ -9,7 +9,7 @@ http_server_init 38500 "$@" response_file="${work_dir}/response.json" payload_file="${work_dir}/payload.json" -http_server_start WHATSAPP_ACCESS_TOKEN="" WHATSAPP_PHONE_NUMBER_ID="" +http_server_start WHATSAPP_ACCESS_TOKEN=$' \t\n' WHATSAPP_PHONE_NUMBER_ID=$'\n ' http_server_wait_until_responding "/health" "${response_file}" printf '{"to":"14155551234","message":"Your route for today: Stop 1"}' >"${payload_file}" @@ -20,7 +20,7 @@ http_code="$("${curl_bin}" -sS -o "${response_file}" -w "%{http_code}" \ "$(http_server_url /api/whatsapp/send-route)")" if [[ "${http_code}" != "503" ]]; then - echo "expected HTTP 503 when WhatsApp credentials are missing, got ${http_code}" >&2 + echo "expected HTTP 503 when WhatsApp credentials are blank, got ${http_code}" >&2 cat "${response_file}" >&2 || true exit 1 fi From 9f9bfd3df92605655d849aa9ce421c9fcd0b5eee Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Thu, 2 Jul 2026 23:53:36 -0700 Subject: [PATCH 7/9] style(api): satisfy whatsapp clang-format --- app/api/src/endpoints/whatsapp_endpoint.cpp | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/api/src/endpoints/whatsapp_endpoint.cpp b/app/api/src/endpoints/whatsapp_endpoint.cpp index 1b846a85..69737d60 100644 --- a/app/api/src/endpoints/whatsapp_endpoint.cpp +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -26,14 +26,12 @@ 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) { + 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) { + while (last > first && std::isspace(static_cast(value[last - 1])) != 0) { --last; } @@ -45,8 +43,8 @@ constexpr char kMessageField[] = "message"; return TrimWhitespace(ResolveEnvOrDefault(key, default_value)); } -[[nodiscard]] std::string ResolveTrimmedEnvOrDefaultWhenBlank( - const char* key, const std::string_view 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}; @@ -153,8 +151,7 @@ void RegisterWhatsAppEndpoint(drogon::HttpAppFramework& app) { } const auto api_version = - ResolveTrimmedEnvOrDefaultWhenBlank(kWhatsAppApiVersionEnv, - kDefaultWhatsAppApiVersion); + ResolveTrimmedEnvOrDefaultWhenBlank(kWhatsAppApiVersionEnv, kDefaultWhatsAppApiVersion); auto upstream_request = drogon::HttpRequest::newHttpRequest(); upstream_request->setMethod(drogon::Post); From 19289ca8932201510cdbfa2a85fbb785a70c4822 Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Fri, 3 Jul 2026 10:28:32 -0700 Subject: [PATCH 8/9] fix(api): qualify whatsapp env helper lookup --- app/api/src/endpoints/whatsapp_endpoint.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/src/endpoints/whatsapp_endpoint.cpp b/app/api/src/endpoints/whatsapp_endpoint.cpp index 69737d60..bd67eb48 100644 --- a/app/api/src/endpoints/whatsapp_endpoint.cpp +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -40,7 +40,7 @@ constexpr char kMessageField[] = "message"; [[nodiscard]] std::string ResolveTrimmedEnvOrDefault(const char* key, const std::string_view default_value) { - return TrimWhitespace(ResolveEnvOrDefault(key, default_value)); + return TrimWhitespace(deliveryoptimizer::api::ResolveEnvOrDefault(key, default_value)); } [[nodiscard]] std::string From 66bc90b8ea91fbc996f007f491df6514c87377c7 Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sun, 12 Jul 2026 00:55:06 -0700 Subject: [PATCH 9/9] fix(api): require shared secret on whatsapp send route Add a WHATSAPP_SEND_ROUTE_SECRET check on the send-route endpoint so it fails closed (503) when unconfigured and rejects requests (401) missing a matching X-WhatsApp-Send-Secret header, since the Cloud Run service is publicly invokable. Update existing integration tests to supply the secret and add a dedicated test for the new auth behavior. Co-authored-by: Cursor --- app/api/src/endpoints/whatsapp_endpoint.cpp | 15 +++ tests/CMakeLists.txt | 11 ++ ...pp_send_route_forwards_to_upstream_test.sh | 7 +- ...send_route_rejects_invalid_request_test.sh | 8 +- ...route_rejects_unauthorized_request_test.sh | 108 ++++++++++++++++++ ..._send_route_requires_configuration_test.sh | 5 +- ...e_returns_502_for_upstream_failure_test.sh | 5 +- 7 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 tests/integration/http_server/whatsapp_send_route_rejects_unauthorized_request_test.sh diff --git a/app/api/src/endpoints/whatsapp_endpoint.cpp b/app/api/src/endpoints/whatsapp_endpoint.cpp index bd67eb48..522bcd1b 100644 --- a/app/api/src/endpoints/whatsapp_endpoint.cpp +++ b/app/api/src/endpoints/whatsapp_endpoint.cpp @@ -20,6 +20,8 @@ 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"; @@ -128,6 +130,19 @@ void RegisterWhatsAppEndpoint(drogon::HttpAppFramework& app) { 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)( diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 786fc00e..7816b190 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -290,6 +290,17 @@ deliveryoptimizer_set_local_http_test_port( 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}" 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 index 95b2c3d5..9c09dace 100644 --- 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 @@ -110,6 +110,8 @@ stop_http_server_for_restart() { fi } +send_route_secret="test-secret" + send_route_and_assert_upstream_request() { local expected_path="$1" local expected_auth="$2" @@ -123,6 +125,7 @@ send_route_and_assert_upstream_request() { 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)")" @@ -169,11 +172,11 @@ send_route_and_assert_upstream_request() { 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_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_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 index 17bd3f8b..1333764a 100644 --- 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 @@ -9,13 +9,16 @@ http_server_init 38400 "$@" response_file="${work_dir}/response.json" payload_file="${work_dir}/payload.json" -http_server_start WHATSAPP_ACCESS_TOKEN="" WHATSAPP_PHONE_NUMBER_ID="" +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)")" @@ -35,6 +38,7 @@ 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)")" @@ -54,6 +58,7 @@ 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)")" @@ -73,6 +78,7 @@ 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)")" 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 00000000..623d773e --- /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 index c29cac66..638f600f 100644 --- a/tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh +++ b/tests/integration/http_server/whatsapp_send_route_requires_configuration_test.sh @@ -9,13 +9,16 @@ http_server_init 38500 "$@" response_file="${work_dir}/response.json" payload_file="${work_dir}/payload.json" -http_server_start WHATSAPP_ACCESS_TOKEN=$' \t\n' WHATSAPP_PHONE_NUMBER_ID=$'\n ' +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)")" 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 index a249fb9d..c14f37da 100644 --- 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 @@ -78,14 +78,17 @@ if [[ "${stub_ready}" != "true" ]]; then 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_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)")"