Files
fgl-aircon/tests/ayla/test_httpd.cpp
Petr Polezhaev b44004627b core(M0): монорепо-каркас — CMake (posix+esp-idf), платслой, лог, мини-httpd
- CMakeLists в корне: ветвление ESP_PLATFORM (idf_component_register,
  lwip/esp_timer/esp_hw_support/pthread) / POSIX (статическая библиотека
  fgl-aircon, C++20, -fno-exceptions -fno-rtti, -Werror).
- src/ayla/platform: сокеты/потоки/CSPRNG/время; posix (getrandom, poll,
  pthread_join) и esp-idf (lwip_select, esp_fill_random, pthread-слой IDF);
  tcp_shutdown/tcp_local_port/thread_join для управляемой остановки.
- src/ayla: log (sink, без printf); мини-httpd/1.1 (keep-alive, Content-Length,
  лимиты заголовков/тела, ephemeral-порт, жизненный цикл с гарантией
  завершения потока: shutdown(active)→join→close).
- tests/ayla: platform (join, loopback+shutdown) и httpd (404, keep-alive,
  обработчик/парсинг, oversize-400, stop при живом соединении, стрим
  заголовков). doctest через FetchContent.
- scripts/ci.sh: сборка+ctest. ESP-IDF v5.5.5 esp32: смоук-сборка с ядром
  как компонентом — Project build complete.
Ревью под-агентом: 3 круга, все блокеры (жизненный цикл httpd) закрыты, APPROVED.
2026-09-22 00:27:33 +03:00

225 lines
8.4 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Тесты мини-httpd: парсинг запросов, keep-alive, 404 по умолчанию,
// корректный stop при живом keep-alive соединении.
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "ayla/httpd.hpp"
#include "ayla/platform/platform.hpp"
namespace {
// Простой блокирующий HTTP-клиент для тестов (ephemeral-порт сервера).
std::string build_request(const char* method, const char* target,
const char* body, bool keep_alive) {
char head[512];
int n = snprintf(head, sizeof(head),
"%s %s HTTP/1.1\r\n"
"Content-Length: %u\r\n"
"Connection: %s\r\n"
"\r\n",
method, target,
body != nullptr ? static_cast<unsigned>(strlen(body)) : 0u,
keep_alive ? "keep-alive" : "close");
std::string req(head, head + n);
if (body != nullptr) req += body;
return req;
}
struct RawResponse {
int status = 0;
std::string body;
};
// Один запрос на новом соединении.
RawResponse http_request(uint16_t port, const char* method, const char* target,
const char* body = nullptr, bool keep_alive = true) {
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
REQUIRE(fd >= 0);
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
std::string req = build_request(method, target, body, keep_alive);
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
static_cast<long>(req.size()));
std::string raw;
char buf[1024];
for (;;) {
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
if (r <= 0) break;
raw.append(buf, buf + r);
size_t hdr_end = raw.find("\r\n\r\n");
if (hdr_end != std::string::npos) {
unsigned clen = 0;
size_t cl = raw.find("Content-Length:");
if (cl != std::string::npos) {
clen = static_cast<unsigned>(atoi(raw.c_str() + cl + 15));
}
if (raw.size() >= hdr_end + 4 + clen) break;
}
}
fgl::plat::tcp_close(fd);
RawResponse out;
out.status = atoi(raw.c_str() + 9); // "HTTP/1.1 NNN"
size_t hdr_end = raw.find("\r\n\r\n");
if (hdr_end != std::string::npos) out.body = raw.substr(hdr_end + 4);
return out;
}
} // namespace
TEST_CASE("httpd: ephemeral порт и 404 по умолчанию") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
const uint16_t port = srv.port();
REQUIRE(port != 0);
auto resp = http_request(port, "GET", "/local_lan/commands.json");
CHECK(resp.status == 404);
CHECK(resp.body.empty());
srv.stop();
CHECK_FALSE(srv.is_running());
}
TEST_CASE("httpd: keep-alive — два запроса на одном соединении") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
const uint16_t port = srv.port();
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
REQUIRE(fd >= 0);
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
for (int i = 0; i < 2; i++) {
std::string req = build_request("GET", "/local_lan/commands.json", nullptr,
true);
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
static_cast<long>(req.size()));
std::string raw;
char buf[512];
while (raw.find("\r\n\r\n") == std::string::npos) {
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
if (r <= 0) break;
raw.append(buf, buf + r);
}
CHECK(atoi(raw.c_str() + 9) == 404);
}
fgl::plat::tcp_close(fd);
srv.stop();
}
namespace {
struct HandlerCtx {
fgl::ayla::HttpRequest last_req;
int calls = 0;
};
bool capture_handler(const fgl::ayla::HttpRequest& req,
fgl::ayla::HttpResponse& resp, void* ctx) {
auto* h = static_cast<HandlerCtx*>(ctx);
h->last_req = req;
h->calls++;
resp.status = 200;
static const char kBody[] = "{\"ok\":true}";
resp.body = reinterpret_cast<const uint8_t*>(kBody);
resp.body_len = sizeof(kBody) - 1;
return true;
}
} // namespace
TEST_CASE("httpd: обработчик, парсинг метода/пути/query/тела/пира") {
fgl::ayla::HttpServer srv;
HandlerCtx h;
REQUIRE(srv.start(0, capture_handler, &h));
auto resp = http_request(srv.port(), "POST",
"/local_lan/property/datapoint.json?cmd_id=5&status=200",
"{\"enc\":\"abc\"}");
CHECK(resp.status == 200);
CHECK(resp.body == std::string("{\"ok\":true}"));
CHECK(h.calls == 1);
CHECK(std::string(h.last_req.method) == "POST");
CHECK(std::string(h.last_req.target) == "/local_lan/property/datapoint.json");
CHECK(std::string(h.last_req.query) == "cmd_id=5&status=200");
CHECK(h.last_req.body_len == 13);
CHECK(memcmp(h.last_req.body, "{\"enc\":\"abc\"}", 13) == 0);
CHECK(h.last_req.peer_ip == 0x7f000001);
srv.stop();
}
TEST_CASE("httpd: oversized body -> 400 + Connection: close") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
std::string big(fgl::ayla::kHttpdMaxBody + 100, 'x');
int fd = fgl::plat::tcp_connect("127.0.0.1", srv.port(), 2000);
REQUIRE(fd >= 0);
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
std::string req = build_request("POST", "/local_lan/property/datapoint.json",
big.c_str(), false);
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
static_cast<long>(req.size()));
std::string raw;
char buf[1024];
for (;;) {
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
if (r <= 0) break;
raw.append(buf, buf + r);
}
fgl::plat::tcp_close(fd);
CHECK(atoi(raw.c_str() + 9) == 400);
CHECK(raw.find("Connection: close") != std::string::npos);
srv.stop();
}
TEST_CASE("httpd: stop() при открытом keep-alive соединении (нет UAF/зависания)") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
const uint16_t port = srv.port();
// Соединение без запроса: поток сервера сидит в recv с 30с таймаутом.
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
REQUIRE(fd >= 0);
fgl::plat::sleep_ms(200); // даём серверу принять и уйти в ожидание
uint64_t t0 = fgl::plat::now_ms();
srv.stop(); // должен прервать соединение shutdown'ом и join'нуть поток
uint64_t elapsed = fgl::plat::now_ms() - t0;
CHECK(srv.port() == port);
CHECK(elapsed < 2000); // не ждём rx-таймаут
fgl::plat::tcp_close(fd);
}
TEST_CASE("httpd: stop() сразу после start()") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
uint64_t t0 = fgl::plat::now_ms();
srv.stop(); // поток мог не дойти до poll — join всё равно быстрый
CHECK(fgl::plat::now_ms() - t0 < 2000);
// Сервер можно перезапустить после stop.
REQUIRE(srv.start(0, nullptr, nullptr));
auto resp = http_request(srv.port(), "GET", "/");
CHECK(resp.status == 404);
srv.stop();
}
TEST_CASE("httpd: бесконечный стрим заголовков завершается (лимит 1КБ)") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
int fd = fgl::plat::tcp_connect("127.0.0.1", srv.port(), 2000);
REQUIRE(fd >= 0);
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
// Отправляем request line и бессрочный поток заголовков малыми кусками.
std::string req = "GET / HTTP/1.1\r\n";
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
static_cast<long>(req.size()));
const char* filler = "X-Pad: 0123456789012345678901234567890123456789\r\n";
size_t flen = strlen(filler);
for (int i = 0; i < 80; i++) { // ~4КБ — больше лимита
REQUIRE(fgl::plat::tcp_send(fd, filler, flen) == static_cast<long>(flen));
}
// Сервер должен перестать читать и закрыть соединение: recv завершается
// (EOF или RST после close с непрочитанными данными), а не висит вечно.
fgl::plat::tcp_set_timeout(fd, 3000, 3000);
char buf[64];
long r;
while ((r = fgl::plat::tcp_recv(fd, buf, sizeof(buf))) > 0) {
}
CHECK(r <= 0); // соединение закрыто сервером
fgl::plat::tcp_close(fd);
srv.stop();
}