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.
This commit is contained in:
24
tests/CMakeLists.txt
Normal file
24
tests/CMakeLists.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# Тесты ядра. Структура зеркалит src: tests/ayla — протоколная часть,
|
||||
# tests/aircon — конверсии/шаблоны (появятся в M3).
|
||||
#
|
||||
# Тесты собираются с исключениями (фреймворк), сама библиотека — без.
|
||||
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
doctest
|
||||
GIT_REPOSITORY https://github.com/doctest/doctest.git
|
||||
GIT_TAG v2.4.12
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(doctest)
|
||||
|
||||
function(fgl_add_test name)
|
||||
add_executable(test_${name} ${ARGN})
|
||||
target_compile_features(test_${name} PRIVATE cxx_std_20)
|
||||
target_link_libraries(test_${name} PRIVATE fgl-aircon doctest_with_main)
|
||||
target_include_directories(test_${name} PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
||||
add_test(NAME ${name} COMMAND test_${name})
|
||||
endfunction()
|
||||
|
||||
fgl_add_test(ayla_platform ayla/test_platform.cpp)
|
||||
fgl_add_test(ayla_httpd ayla/test_httpd.cpp)
|
||||
224
tests/ayla/test_httpd.cpp
Normal file
224
tests/ayla/test_httpd.cpp
Normal file
@@ -0,0 +1,224 @@
|
||||
// Тесты мини-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();
|
||||
}
|
||||
108
tests/ayla/test_platform.cpp
Normal file
108
tests/ayla/test_platform.cpp
Normal file
@@ -0,0 +1,108 @@
|
||||
// Тесты платформенного слоя (POSIX-путь; на ESP-IDF тесты не запускаются —
|
||||
// верификация IDF-слоя сборкой и on-device приёмкой).
|
||||
#include "doctest/doctest.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "ayla/platform/platform.hpp"
|
||||
|
||||
TEST_CASE("now_ms монотонен") {
|
||||
uint64_t a = fgl::plat::now_ms();
|
||||
fgl::plat::sleep_ms(30);
|
||||
uint64_t b = fgl::plat::now_ms();
|
||||
CHECK(b >= a + 25);
|
||||
CHECK(b - a < 1000);
|
||||
}
|
||||
|
||||
TEST_CASE("random заполняет и не повторяется тривиально") {
|
||||
uint8_t a[16] = {}, b[16] = {};
|
||||
REQUIRE(fgl::plat::random(a, sizeof(a)));
|
||||
REQUIRE(fgl::plat::random(b, sizeof(b)));
|
||||
CHECK_FALSE(memcmp(a, b, sizeof(a)) == 0);
|
||||
bool all_zero = true;
|
||||
for (uint8_t x : a) all_zero = all_zero && (x == 0);
|
||||
CHECK_FALSE(all_zero);
|
||||
}
|
||||
|
||||
TEST_CASE("thread_create + thread_join: поток завершён после join") {
|
||||
struct Ctx {
|
||||
std::atomic<bool> ran{false};
|
||||
std::atomic<bool> finished{false};
|
||||
};
|
||||
Ctx c;
|
||||
fgl::plat::ThreadId tid = nullptr;
|
||||
REQUIRE(fgl::plat::thread_create(
|
||||
[](void* p) {
|
||||
auto* x = static_cast<Ctx*>(p);
|
||||
x->ran.store(true);
|
||||
fgl::plat::sleep_ms(100);
|
||||
x->finished.store(true);
|
||||
},
|
||||
&c, "t_join", 0, &tid));
|
||||
REQUIRE(tid != nullptr);
|
||||
CHECK_FALSE(c.finished.load());
|
||||
fgl::plat::thread_join(tid); // вернётся после завершения потока
|
||||
CHECK(c.ran.load());
|
||||
CHECK(c.finished.load());
|
||||
}
|
||||
|
||||
TEST_CASE("tcp loopback: ephemeral-порт, connect/send/recv/echo, shutdown") {
|
||||
int listen_fd = fgl::plat::tcp_listen(0);
|
||||
REQUIRE(listen_fd >= 0);
|
||||
const uint16_t port = fgl::plat::tcp_local_port(listen_fd);
|
||||
REQUIRE(port != 0);
|
||||
|
||||
struct SrvCtx {
|
||||
int lfd;
|
||||
int cfd = -1;
|
||||
std::atomic<bool> accepted{false};
|
||||
std::atomic<bool> echoed{false};
|
||||
std::atomic<uint32_t> peer_ip{0};
|
||||
};
|
||||
SrvCtx srv;
|
||||
srv.lfd = listen_fd;
|
||||
fgl::plat::ThreadId tid = nullptr;
|
||||
REQUIRE(fgl::plat::thread_create(
|
||||
[](void* p) {
|
||||
auto* s = static_cast<SrvCtx*>(p);
|
||||
uint32_t ip = 0;
|
||||
uint16_t peer_port = 0;
|
||||
int cfd = fgl::plat::tcp_accept(s->lfd, &ip, &peer_port);
|
||||
if (cfd < 0) return;
|
||||
s->cfd = cfd;
|
||||
s->accepted.store(true);
|
||||
s->peer_ip.store(ip);
|
||||
fgl::plat::tcp_set_timeout(cfd, 5000, 5000);
|
||||
char buf[16];
|
||||
long n = fgl::plat::tcp_recv(cfd, buf, sizeof(buf));
|
||||
if (n > 0) {
|
||||
fgl::plat::tcp_send(cfd, buf, static_cast<size_t>(n));
|
||||
s->echoed.store(true);
|
||||
// ждём возможного второго запроса (для проверки shutdown ниже)
|
||||
char buf2[16];
|
||||
fgl::plat::tcp_recv(cfd, buf2, sizeof(buf2));
|
||||
}
|
||||
fgl::plat::tcp_close(cfd);
|
||||
},
|
||||
&srv, "t_srv", 0, &tid));
|
||||
|
||||
int cfd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
|
||||
REQUIRE(cfd >= 0);
|
||||
REQUIRE(fgl::plat::tcp_send(cfd, "ping", 4) == 4);
|
||||
fgl::plat::tcp_set_timeout(cfd, 2000, 2000);
|
||||
char buf[16] = {};
|
||||
REQUIRE(fgl::plat::tcp_recv(cfd, buf, sizeof(buf)) == 4);
|
||||
CHECK(std::string(buf, 4) == "ping");
|
||||
fgl::plat::tcp_close(cfd);
|
||||
|
||||
REQUIRE(srv.accepted.load());
|
||||
CHECK(srv.peer_ip.load() == 0x7f000001); // 127.0.0.1 в host byte order
|
||||
REQUIRE(srv.echoed.load());
|
||||
|
||||
// shutdown прерывает блокированный recv сервера → поток завершается
|
||||
fgl::plat::tcp_shutdown(srv.cfd);
|
||||
fgl::plat::thread_join(tid);
|
||||
fgl::plat::tcp_close(listen_fd);
|
||||
}
|
||||
Reference in New Issue
Block a user