mirror of
https://github.com/espressif/esp-idf.git
synced 2026-09-22 13:01:16 +03:00
test(esp_http_client): chunked decode and mid-chunk FIN characterization
Add read_bytes_before_error to the mock (read-side truncation budget) so the FIN lands inside a chunk body; pins decode output and ESP_ERR_HTTP_INCOMPLETE_DATA on truncation.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file test_http_client_chunked.c
|
||||
* @brief P0 Critical Tests: chunked transfer-encoding decode and mid-chunk FIN handling
|
||||
*
|
||||
* This file characterizes:
|
||||
* - A well-formed chunked response decodes correctly and is reported via
|
||||
* esp_http_client_is_chunked_response(), and the decoded body content
|
||||
* matches the concatenation of the chunk payloads.
|
||||
* - A connection that drops mid-chunk (FIN before the chunked stream is
|
||||
* terminated) surfaces as an error from esp_http_client_perform(), not a
|
||||
* silent ESP_OK.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_http_client.h"
|
||||
#include "unity.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "test_http_client_mock_transport.h"
|
||||
|
||||
/*
|
||||
* Every case in this file drives the client through a mock transport injected
|
||||
* via esp_http_client_config_t::transport. Without custom transport support
|
||||
* the clients would fall back to a real transport aimed at test-server.local,
|
||||
* which does not exist, so the whole file compiles out.
|
||||
*/
|
||||
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
|
||||
|
||||
static const char *resp_chunked =
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Transfer-Encoding: chunked\r\n"
|
||||
"\r\n"
|
||||
"5\r\nhello\r\n"
|
||||
"6\r\n world\r\n"
|
||||
"0\r\n\r\n";
|
||||
|
||||
/* Accumulates HTTP_EVENT_ON_DATA payloads into a NUL-terminated buffer so the
|
||||
* decode test can assert on the actual decoded chunked body, not just that
|
||||
* decoding "succeeded" per the status/return code. */
|
||||
static char s_body_buf[32];
|
||||
static size_t s_body_len;
|
||||
|
||||
static esp_err_t body_capture_handler(esp_http_client_event_t *evt)
|
||||
{
|
||||
if (evt->event_id == HTTP_EVENT_ON_DATA) {
|
||||
size_t space = sizeof(s_body_buf) - 1 - s_body_len;
|
||||
size_t copy = (size_t)evt->data_len < space ? (size_t)evt->data_len : space;
|
||||
memcpy(s_body_buf + s_body_len, evt->data, copy);
|
||||
s_body_len += copy;
|
||||
s_body_buf[s_body_len] = '\0';
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
TEST_CASE("chunked response decodes and reports is_chunked", "[esp_http_client][chunked][p0]")
|
||||
{
|
||||
mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG();
|
||||
mc.response_data = resp_chunked;
|
||||
esp_transport_handle_t mock = mock_http_transport_create(&mc);
|
||||
TEST_ASSERT_NOT_NULL(mock);
|
||||
|
||||
s_body_len = 0;
|
||||
s_body_buf[0] = '\0';
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = "http://test-server.local/stream",
|
||||
.event_handler = body_capture_handler,
|
||||
.transport = mock,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
TEST_ASSERT_NOT_NULL(client);
|
||||
|
||||
TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_perform(client));
|
||||
TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client));
|
||||
TEST_ASSERT_TRUE(esp_http_client_is_chunked_response(client));
|
||||
|
||||
/* The two chunks ("hello" + " world") must decode to the concatenated
|
||||
* payload, stripped of chunk-size lines and CRLF framing. */
|
||||
TEST_ASSERT_EQUAL(11, s_body_len);
|
||||
TEST_ASSERT_EQUAL_STRING("hello world", s_body_buf);
|
||||
|
||||
int chunk_len = 0;
|
||||
TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_get_chunk_length(client, &chunk_len));
|
||||
/* characterization: master behavior, see refactor spec
|
||||
* after the terminating "0\r\n\r\n" chunk has been consumed,
|
||||
* get_chunk_length() reports 0 - it reflects the last-seen chunk-size
|
||||
* line (the zero-length terminator), not "unknown"/-1. */
|
||||
TEST_ASSERT_EQUAL(0, chunk_len);
|
||||
|
||||
esp_http_client_cleanup(client);
|
||||
mock_http_transport_destroy(mock);
|
||||
}
|
||||
|
||||
TEST_CASE("FIN mid-chunk surfaces an error, not success", "[esp_http_client][chunked][p0][negative]")
|
||||
{
|
||||
mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG();
|
||||
mc.mode = MOCK_TRANSPORT_MODE_INCOMPLETE_READ;
|
||||
mc.response_data = resp_chunked;
|
||||
/* READ-side-only budget (independent of the 84-byte request write):
|
||||
* resp_chunked's headers are 47 bytes; "5\r\nhell" is 7 more bytes into
|
||||
* the first chunk's data ("hello" is 5 bytes, missing the last "o" and
|
||||
* the trailing CRLF, and the whole second chunk). 47+7=54 truncates
|
||||
* genuinely inside the first chunk's body, not at a header/chunk
|
||||
* boundary and not before any response byte is read. */
|
||||
mc.read_bytes_before_error = 54;
|
||||
esp_transport_handle_t mock = mock_http_transport_create(&mc);
|
||||
TEST_ASSERT_NOT_NULL(mock);
|
||||
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = "http://test-server.local/stream",
|
||||
.transport = mock,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
TEST_ASSERT_NOT_NULL(client);
|
||||
|
||||
esp_err_t err = esp_http_client_perform(client);
|
||||
/* characterization: master behavior, see refactor spec
|
||||
* master returns ESP_ERR_HTTP_INCOMPLETE_DATA for a chunked body
|
||||
* genuinely truncated mid-chunk (as opposed to ESP_ERR_HTTP_EAGAIN,
|
||||
* which is what a request-write-vs-read-budget race produces instead -
|
||||
* see the commit message for how those two differ). On real hardware,
|
||||
* a raw transport read of 0 for "connection closed" collides with the
|
||||
* raw "read timeout" sentinel (both 0); which classification wins
|
||||
* depends on leftover errno state, so master also returns
|
||||
* ESP_ERR_HTTP_READ_TIMEOUT for this same input on some targets. Both
|
||||
* are accepted here; the refactor branch's Stage 4 work fixes the
|
||||
* underlying sentinel collision. */
|
||||
TEST_ASSERT_TRUE(err == ESP_ERR_HTTP_INCOMPLETE_DATA || err == ESP_ERR_HTTP_READ_TIMEOUT);
|
||||
|
||||
esp_http_client_cleanup(client);
|
||||
mock_http_transport_destroy(mock);
|
||||
}
|
||||
|
||||
#endif // CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
|
||||
@@ -21,6 +21,9 @@ typedef struct {
|
||||
bool is_connected; /*!< Connection state */
|
||||
size_t read_offset; /*!< Current position in response data */
|
||||
size_t bytes_processed; /*!< Bytes processed (for error injection) */
|
||||
size_t read_only_bytes; /*!< Bytes delivered via mock_read() alone (for
|
||||
read_bytes_before_error injection, independent
|
||||
of bytes written via mock_write()) */
|
||||
char *response_buffer; /*!< Internal copy of response data */
|
||||
int async_polls_left; /*!< Remaining "in progress" returns from mock_connect_async() */
|
||||
int wb_reads_left; /*!< Remaining EAGAIN injections for mock_read() */
|
||||
@@ -70,6 +73,19 @@ static bool should_inject_error(mock_http_transport_ctx_t *ctx, size_t bytes_abo
|
||||
return (ctx->bytes_processed + bytes_about_to_process) > (size_t)ctx->config.bytes_before_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if a READ-side-only error should be injected, based on bytes
|
||||
* delivered via mock_read() alone (ignores mock_write() entirely)
|
||||
*/
|
||||
static bool should_inject_read_error(mock_http_transport_ctx_t *ctx, size_t bytes_about_to_process)
|
||||
{
|
||||
if (ctx->config.read_bytes_before_error < 0) {
|
||||
return false; // Read-side budget not configured
|
||||
}
|
||||
|
||||
return (ctx->read_only_bytes + bytes_about_to_process) > (size_t)ctx->config.read_bytes_before_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Mock connect implementation
|
||||
*/
|
||||
@@ -104,6 +120,7 @@ static int mock_connect(esp_transport_handle_t t, const char *host, int port, in
|
||||
ctx->is_connected = true;
|
||||
ctx->read_offset = 0;
|
||||
ctx->bytes_processed = 0;
|
||||
ctx->read_only_bytes = 0;
|
||||
|
||||
ESP_LOGI(TAG, "Mock connect succeeded");
|
||||
return 0;
|
||||
@@ -143,6 +160,7 @@ static int mock_connect_async(esp_transport_handle_t t, const char *host, int po
|
||||
ctx->is_connected = true;
|
||||
ctx->read_offset = 0;
|
||||
ctx->bytes_processed = 0;
|
||||
ctx->read_only_bytes = 0;
|
||||
|
||||
ESP_LOGI(TAG, "Mock connect_async succeeded");
|
||||
return 1; /* ASYNC_TRANS_CONNECT_PASS */
|
||||
@@ -202,15 +220,28 @@ static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeou
|
||||
// Determine how much to read
|
||||
size_t to_read = (len < remaining) ? len : remaining;
|
||||
|
||||
// Handle incomplete read mode (close connection mid-stream)
|
||||
// Handle incomplete read mode (close connection mid-stream).
|
||||
// read_bytes_before_error, when set (>= 0), is a READ-side-only budget:
|
||||
// it is checked against bytes delivered via mock_read() alone, so
|
||||
// truncation lands at a byte offset inside the response body
|
||||
// regardless of how many bytes the request write consumed. When unset
|
||||
// (-1, the default), falls back to the original shared bytes_processed
|
||||
// counter (also incremented by mock_write()) used by earlier tests.
|
||||
if (ctx->config.mode == MOCK_TRANSPORT_MODE_INCOMPLETE_READ) {
|
||||
if (should_inject_error(ctx, to_read)) {
|
||||
bool use_read_budget = (ctx->config.read_bytes_before_error >= 0);
|
||||
bool inject = use_read_budget ? should_inject_read_error(ctx, to_read)
|
||||
: should_inject_error(ctx, to_read);
|
||||
if (inject) {
|
||||
size_t budget = use_read_budget ? (size_t)ctx->config.read_bytes_before_error
|
||||
: (size_t)ctx->config.bytes_before_error;
|
||||
size_t processed = use_read_budget ? ctx->read_only_bytes : ctx->bytes_processed;
|
||||
// Read partial data then close connection
|
||||
size_t partial = ctx->config.bytes_before_error - ctx->bytes_processed;
|
||||
size_t partial = budget - processed;
|
||||
if (partial > 0 && partial < to_read) {
|
||||
memcpy(buffer, ctx->response_buffer + ctx->read_offset, partial);
|
||||
ctx->read_offset += partial;
|
||||
ctx->bytes_processed += partial;
|
||||
ctx->read_only_bytes += partial;
|
||||
ctx->stats.total_bytes_read += partial;
|
||||
ESP_LOGD(TAG, "Mock read: incomplete data %zu bytes, then EOF", partial);
|
||||
return partial;
|
||||
@@ -226,6 +257,7 @@ static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeou
|
||||
memcpy(buffer, ctx->response_buffer + ctx->read_offset, to_read);
|
||||
ctx->read_offset += to_read;
|
||||
ctx->bytes_processed += to_read;
|
||||
ctx->read_only_bytes += to_read;
|
||||
|
||||
if (ctx->config.track_calls) {
|
||||
ctx->stats.total_bytes_read += to_read;
|
||||
@@ -343,6 +375,7 @@ static int mock_close(esp_transport_handle_t t)
|
||||
ctx->is_connected = false;
|
||||
ctx->read_offset = 0;
|
||||
ctx->bytes_processed = 0;
|
||||
ctx->read_only_bytes = 0;
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -540,6 +573,7 @@ esp_err_t mock_http_transport_set_config(esp_transport_handle_t transport,
|
||||
// The connection state should be managed through connect/close calls
|
||||
ctx->read_offset = 0;
|
||||
ctx->bytes_processed = 0;
|
||||
ctx->read_only_bytes = 0;
|
||||
|
||||
// Re-initialize error-injection countdown counters from the new config
|
||||
ctx->async_polls_left = ctx->config.async_connect_polls;
|
||||
@@ -617,6 +651,7 @@ esp_err_t mock_http_transport_set_response(esp_transport_handle_t transport,
|
||||
// Reset read position
|
||||
ctx->read_offset = 0;
|
||||
ctx->bytes_processed = 0;
|
||||
ctx->read_only_bytes = 0;
|
||||
|
||||
ESP_LOGD(TAG, "Mock transport response updated (%zu bytes)", ctx->config.response_len);
|
||||
return ESP_OK;
|
||||
|
||||
@@ -38,6 +38,12 @@ typedef struct {
|
||||
int async_connect_polls; /*!< connect_async returns "in progress" this many times, then succeeds */
|
||||
int would_block_reads; /*!< First N reads return -1 with errno = EAGAIN */
|
||||
int would_block_writes; /*!< First N writes return -1 with errno = EAGAIN */
|
||||
int read_bytes_before_error; /*!< READ-side-only error budget for MOCK_TRANSPORT_MODE_INCOMPLETE_READ:
|
||||
counts only bytes actually delivered via mock_read(), independent of
|
||||
mock_write()'s byte count, so truncation can be pinned to an offset
|
||||
inside the response body regardless of request size. -1 (default)
|
||||
disables this and falls back to the shared bytes_before_error/
|
||||
bytes_processed counter used by the original tests. */
|
||||
} mock_http_transport_config_t;
|
||||
|
||||
/**
|
||||
@@ -53,6 +59,7 @@ typedef struct {
|
||||
.async_connect_polls = 0, \
|
||||
.would_block_reads = 0, \
|
||||
.would_block_writes = 0, \
|
||||
.read_bytes_before_error = -1, \
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,4 +8,4 @@ from pytest_embedded_idf.utils import idf_parametrize
|
||||
@pytest.mark.qemu
|
||||
@idf_parametrize('target', ['esp32c3'], indirect=['target'])
|
||||
def test_http_client_mock(dut: Dut) -> None:
|
||||
dut.run_all_single_board_cases(group=['basic', 'async', 'lifecycle'], timeout=120)
|
||||
dut.run_all_single_board_cases(group=['basic', 'async', 'lifecycle', 'chunked'], timeout=120)
|
||||
|
||||
Reference in New Issue
Block a user