diff --git a/components/esp_http_server/test_apps/main/CMakeLists.txt b/components/esp_http_server/test_apps/main/CMakeLists.txt index a97994f3498..81af38a9c73 100644 --- a/components/esp_http_server/test_apps/main/CMakeLists.txt +++ b/components/esp_http_server/test_apps/main/CMakeLists.txt @@ -1,3 +1,3 @@ -idf_component_register(SRC_DIRS "." - PRIV_INCLUDE_DIRS "." "../../src" "../../src/port/esp32" +idf_component_register(SRC_DIRS "." "../mock_client" + PRIV_INCLUDE_DIRS "." "../../src" "../../src/port/esp32" "../mock_client" PRIV_REQUIRES esp_http_server test_utils unity esp_timer) diff --git a/components/esp_http_server/test_apps/main/test_http_server.c b/components/esp_http_server/test_apps/main/test_http_server.c index 22fb55d7b88..aa72a297390 100644 --- a/components/esp_http_server/test_apps/main/test_http_server.c +++ b/components/esp_http_server/test_apps/main/test_http_server.c @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include "unity.h" #include "test_utils.h" +#include "mock_http_server_client.h" int pre_start_mem, post_stop_mem, post_stop_min_mem; bool basic_sanity = true; @@ -31,6 +33,30 @@ esp_err_t null_func(httpd_req_t *req) return ESP_OK; } +#ifdef CONFIG_HTTPD_WS_SUPPORT +static httpd_handle_t start_test_ws_server(uint16_t server_port, uint16_t ctrl_port) +{ + httpd_handle_t hd = NULL; + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = server_port; + config.ctrl_port = ctrl_port; + + httpd_uri_t ws_uri = { + .uri = "/ws", + .method = HTTP_GET, + .handler = null_func, + .user_ctx = NULL, + .is_websocket = true, + .handle_ws_control_frames = false, + .supported_subprotocol = NULL, + }; + + TEST_ASSERT_EQUAL(ESP_OK, httpd_start(&hd, &config)); + TEST_ASSERT_EQUAL(ESP_OK, httpd_register_uri_handler(hd, &ws_uri)); + return hd; +} +#endif /* CONFIG_HTTPD_WS_SUPPORT */ + httpd_uri_t handler_limit_uri (char* path) { httpd_uri_t uri = { diff --git a/components/esp_http_server/test_apps/mock_client/mock_http_server_client.c b/components/esp_http_server/test_apps/mock_client/mock_http_server_client.c new file mode 100644 index 00000000000..d97f9c8bf80 --- /dev/null +++ b/components/esp_http_server/test_apps/mock_client/mock_http_server_client.c @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "unity.h" + +#include "mock_http_server_client.h" + +static const char *TAG = "mock_srv_client"; + +/* ----------------------------------------------------------------------- + * Internal helpers + * --------------------------------------------------------------------- */ + +/** + * Parse the HTTP status code from the beginning of a raw response buffer. + * Expects "HTTP/1.x NNN ..." format. Returns -1 on any parse failure. + */ +static int parse_status_code(const char *data, size_t len) +{ + /* Minimum: "HTTP/1.1 200 " = 13 chars */ + if (len < 13) { + return -1; + } + /* Find the first space (end of "HTTP/1.x") */ + const char *p = memchr(data, ' ', len < 16 ? len : 16); + if (!p) { + return -1; + } + p++; /* point at status code digits */ + if ((size_t)(p - data) + 3 > len) { + return -1; + } + int code = 0; + for (int i = 0; i < 3; i++) { + if (p[i] < '0' || p[i] > '9') { + return -1; + } + code = code * 10 + (p[i] - '0'); + } + /* Sanity: valid HTTP status codes are 100–599 */ + if (code < 100 || code > 599) { + return -1; + } + return code; +} + +/* ----------------------------------------------------------------------- + * Public API + * --------------------------------------------------------------------- */ + +mock_server_response_t *mock_server_send_request(uint16_t port, + const mock_server_request_t *req) +{ + mock_server_response_t *resp = calloc(1, sizeof(*resp)); + if (!resp) { + ESP_LOGE(TAG, "calloc() failed"); + return NULL; + } + resp->status_code = -1; + + /* ---- Create socket and connect ---- */ + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + ESP_LOGE(TAG, "socket() failed: errno=%d", errno); + free(resp); + return NULL; + } + + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_port = htons(port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + ESP_LOGE(TAG, "connect() to 127.0.0.1:%u failed: errno=%d", port, errno); + close(fd); + free(resp); + return NULL; + } + ESP_LOGD(TAG, "connected to 127.0.0.1:%u", port); + + /* ---- Send the scripted request ---- */ + size_t total_len = req->len ? req->len : strlen(req->data); + size_t sent = 0; + + /* How many bytes to send in each write() call */ + int max_per_write = (req->max_bytes_per_write > 0) + ? req->max_bytes_per_write + : (int)total_len; + + /* After how many sent bytes to stop writing. + * 0 or negative = send everything (zero is the zero-init default for + * unset struct fields, so it must mean "no early close"). */ + int close_after = req->close_after_bytes; + + while (sent < total_len) { + /* Honour early-close limit */ + if (close_after > 0 && (int)sent >= close_after) { + break; + } + + int chunk = max_per_write; + /* Don't exceed the early-close byte count */ + if (close_after > 0 && (int)(sent + chunk) > close_after) { + chunk = close_after - (int)sent; + } + /* Don't exceed total length */ + if ((size_t)chunk > total_len - sent) { + chunk = (int)(total_len - sent); + } + + int n = send(fd, req->data + sent, (size_t)chunk, 0); + if (n <= 0) { + ESP_LOGE(TAG, "send() failed after %zu bytes: errno=%d", sent, errno); + close(fd); + free(resp); + return NULL; + } + sent += (size_t)n; + ESP_LOGD(TAG, "sent %d bytes (%zu/%zu total)", n, sent, total_len); + + if (req->write_delay_ms > 0 && sent < total_len) { + vTaskDelay(pdMS_TO_TICKS(req->write_delay_ms)); + } + } + + /* Signal end-of-request to the server if we stopped sending early */ + if (close_after > 0) { + shutdown(fd, SHUT_WR); + ESP_LOGD(TAG, "shutdown(SHUT_WR) after %zu bytes", sent); + } + + /* ---- Read the response ---- */ + int timeout_ms = (req->recv_timeout_ms > 0) ? req->recv_timeout_ms : 2000; + + /* Record absolute deadline so that EINTR retries don't reset the clock. */ + struct timeval deadline; + gettimeofday(&deadline, NULL); + deadline.tv_sec += timeout_ms / 1000; + deadline.tv_usec += (timeout_ms % 1000) * 1000; + if (deadline.tv_usec >= 1000000) { + deadline.tv_sec++; + deadline.tv_usec -= 1000000; + } + + size_t received = 0; + while (received < sizeof(resp->data) - 1) { + /* Calculate remaining time until deadline. */ + struct timeval now, remaining; + gettimeofday(&now, NULL); + remaining.tv_sec = deadline.tv_sec - now.tv_sec; + remaining.tv_usec = deadline.tv_usec - now.tv_usec; + if (remaining.tv_usec < 0) { + remaining.tv_sec--; + remaining.tv_usec += 1000000; + } + if (remaining.tv_sec < 0) { + ESP_LOGD(TAG, "recv deadline elapsed after %zu bytes", received); + break; + } + + /* Wait for data (or deadline) — handles EINTR safely. */ + fd_set read_fds; + FD_ZERO(&read_fds); + FD_SET(fd, &read_fds); + int ready = select(fd + 1, &read_fds, NULL, NULL, &remaining); + if (ready < 0) { + if (errno == EINTR) { + continue; /* signal interrupted select — recalculate remaining time */ + } + ESP_LOGE(TAG, "select() error: errno=%d", errno); + break; + } + if (ready == 0) { + ESP_LOGD(TAG, "recv deadline elapsed after %zu bytes", received); + break; + } + + int n = recv(fd, resp->data + received, sizeof(resp->data) - 1 - received, 0); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) { + continue; + } + ESP_LOGE(TAG, "recv() error after %zu bytes: errno=%d", received, errno); + break; + } + if (n == 0) { + resp->server_closed = true; + ESP_LOGD(TAG, "server closed connection after %zu bytes", received); + break; + } + received += (size_t)n; + } + + resp->len = received; + resp->data[received] = '\0'; /* NUL-terminate for easy string inspection */ + + if (received > 0) { + resp->status_code = parse_status_code(resp->data, received); + ESP_LOGD(TAG, "response: status=%d, len=%zu", resp->status_code, received); + } else { + ESP_LOGW(TAG, "no response received from server"); + } + + close(fd); + return resp; +} + +void mock_server_response_free(mock_server_response_t *resp) +{ + free(resp); +} + +void mock_server_assert_status(const mock_server_response_t *resp, int expected_status) +{ + if (resp->len == 0) { + TEST_FAIL_MESSAGE("mock_server: no response received from server"); + return; + } + if (resp->status_code == -1) { + /* Print the raw response to help diagnose the issue */ + ESP_LOGE(TAG, "unparsable response (%zu bytes): %.80s", resp->len, resp->data); + TEST_FAIL_MESSAGE("mock_server: server response is not a valid HTTP status line"); + return; + } + TEST_ASSERT_EQUAL_MESSAGE(expected_status, resp->status_code, + "HTTP status code mismatch"); +} diff --git a/components/esp_http_server/test_apps/mock_client/mock_http_server_client.h b/components/esp_http_server/test_apps/mock_client/mock_http_server_client.h new file mode 100644 index 00000000000..a62fe177bc4 --- /dev/null +++ b/components/esp_http_server/test_apps/mock_client/mock_http_server_client.h @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file mock_http_server_client.h + * @brief Scripted TCP client for black-box testing of esp_http_server. + * + * Allows tests to send arbitrary (including malformed or fragmented) byte + * sequences to a running httpd instance over loopback and capture the raw + * response. The server under test is started/stopped by the test itself using + * the normal httpd_start() / httpd_stop() APIs; this module only provides the + * "client" side. + * + * Typical usage: + * @code + * // 1. Start the server + * httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + * cfg.server_port = 8099; + * httpd_handle_t server; + * httpd_start(&server, &cfg); + * httpd_register_uri_handler(server, &my_handler); + * + * // 2. Send a scripted request and capture the response + * mock_server_request_t req = { + * .data = "GET /path HTTP/1.1\r\nHost: localhost\r\n\r\n", + * }; + * mock_server_response_t resp = {0}; + * mock_server_send_request(8099, &req, &resp); + * TEST_ASSERT_EQUAL(200, resp.status_code); + * + * // 3. Stop the server + * httpd_stop(server); + * @endcode + */ + +#pragma once + +#include +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Maximum number of response bytes the mock client will buffer. */ +#define MOCK_SERVER_CLIENT_RESP_BUF_SIZE 4096 + +/** + * @brief Describes a single scripted request the mock client sends. + * + * Only @p data is required. All other fields have safe defaults when zero. + */ +typedef struct { + const char *data; /**< Raw bytes to send (must not be NULL) */ + size_t len; /**< Length of @p data; 0 → strlen(data) */ + + /** Maximum bytes per write() call. -1 (or 0) = send all at once. + * Set to a small value to simulate a slow/trickled sender. */ + int max_bytes_per_write; + + /** Delay in milliseconds between fragmented writes. 0 = no delay. */ + int write_delay_ms; + + /** Close the TCP write-side after sending this many bytes. + * 0 (default) or negative = send everything, keep write-side open. + * Positive N = close after N bytes (useful for partial-request / abrupt-close tests). */ + int close_after_bytes; + + /** How long (ms) to wait for a response before giving up. 0 → 2000 ms. */ + int recv_timeout_ms; +} mock_server_request_t; + +/** + * @brief Result captured from the server after sending a scripted request. + */ +typedef struct { + char data[MOCK_SERVER_CLIENT_RESP_BUF_SIZE]; /**< Raw response bytes */ + size_t len; /**< Bytes actually received */ + int status_code; /**< HTTP status code parsed from the status line, + * or -1 if the response is not a valid HTTP response. */ + bool server_closed; /**< True if the server closed the connection (recv returned 0). */ +} mock_server_response_t; + +/** + * @brief Connect to a running httpd on 127.0.0.1:@p port, send the scripted + * request and capture the response. + * + * The response struct is heap-allocated; the caller must free it with + * mock_server_response_free() when done. The TCP connection is always + * closed before returning. + * + * @param[in] port TCP port the httpd server is listening on. + * @param[in] req Scripted request to send. + * + * @return Heap-allocated response, or NULL if a TCP-level error occurred. + */ +mock_server_response_t *mock_server_send_request(uint16_t port, + const mock_server_request_t *req); + +/** + * @brief Free a response returned by mock_server_send_request(). + */ +void mock_server_response_free(mock_server_response_t *resp); + +/** + * @brief Assert that @p resp contains a valid HTTP response with the expected + * status code. Calls TEST_FAIL() (Unity) if the assertion does not hold. + */ +void mock_server_assert_status(const mock_server_response_t *resp, int expected_status); + +#ifdef __cplusplus +} +#endif