Files
SEQUA-Engine/studio/host/web/tests/test_ui_server.cpp
T
Ange-Marie MAURIN - A2M Systèmes 99b0a0f008 feat(studio/host): brique web, service de l'interface et ouverture du navigateur
UiServer (ix::HttpServer) : GET/HEAD, / -> index.html, MIME, en-têtes no-store/nosniff, vérification exclusive du port sous Windows. Chemins : décodage unique, refus de .., ., fichiers cachés, antislash, octet nul, : (lecteur ou flux NTFS), puis confinement vérifié par weakly_canonical. build_ui_url, build_core_ws_url, open_browser (http/https uniquement). 14 cas de test dont 12 tentatives d'évasion. Écrit par un subagent (DeepSeek V4.1 Flash), relu ; refus de : étendu aux flux NTFS.
2026-11-06 22:41:21 +01:00

303 lines
8.9 KiB
C++

// SPDX-License-Identifier: MPL-2.0
//
// Tests d'intégration : un vrai client HTTP (ix::HttpClient) contre UiServer, en boucle
// locale, avec des tentatives d'évasion de la racine qui doivent toutes échouer.
#include "sequa/studio/host/ui_server.hpp"
#include <doctest/doctest.h>
#include <ixwebsocket/IXHttp.h>
#include <ixwebsocket/IXHttpClient.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <string>
#include <string_view>
#include <system_error>
#include <vector>
using namespace sequa::studio::host;
namespace fs = std::filesystem;
namespace {
constexpr std::string_view secret_content = "SECRET-NE-DOIT-JAMAIS-SORTIR";
// Dossier temporaire imposé par la tâche.
fs::path opencode_temp_dir()
{
#ifdef _WIN32
const fs::path fixed("C:/Users/a-mma/AppData/Local/Temp/opencode");
if (fs::is_directory(fixed)) {
return fixed;
}
#endif
return fs::temp_directory_path();
}
fs::path make_unique_dir()
{
static std::atomic<unsigned> counter{0};
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
for (unsigned attempt = 0; attempt < 100; ++attempt) {
const fs::path dir = opencode_temp_dir() /
("sequa-host-web-" + std::to_string(stamp) + "-" +
std::to_string(counter.fetch_add(1)));
std::error_code ec;
if (fs::create_directories(dir, ec)) {
return dir;
}
}
return {};
}
void write_file(const fs::path& path, std::string_view content)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(content.data(), static_cast<std::streamsize>(content.size()));
}
// Racine temporaire + serveur lancé sur un port libre de 19400-19499.
class TestEnv
{
public:
TestEnv() { setup(); }
~TestEnv()
{
server_.reset(); // arrête le serveur avant de supprimer les fichiers
std::error_code ec;
fs::remove_all(base_, ec);
}
TestEnv(const TestEnv&) = delete;
TestEnv& operator=(const TestEnv&) = delete;
bool started() const { return static_cast<bool>(server_); }
std::uint16_t port() const { return port_; }
const fs::path& root() const { return root_; }
const fs::path& base() const { return base_; }
std::vector<std::string> logs() const
{
const std::lock_guard<std::mutex> lock(mutex_);
return logs_;
}
private:
void setup()
{
base_ = make_unique_dir();
if (base_.empty()) {
return;
}
root_ = base_ / "root";
fs::create_directories(root_ / "assets");
write_file(root_ / "index.html", "<!doctype html><title>SEQUA</title>\n");
write_file(root_ / "assets" / "app.js", "console.log('ok');\n");
write_file(root_ / "assets" / "style.css", "body { color: red; }\n");
write_file(root_ / ".hidden", "cache\n");
write_file(base_ / "secret.txt", secret_content); // HORS de la racine
UiServerOptions options;
options.host = "127.0.0.1";
options.root = root_;
options.log = [this](std::string_view line) {
const std::lock_guard<std::mutex> lock(mutex_);
logs_.emplace_back(line);
};
for (int candidate = 19400; candidate <= 19499; ++candidate) {
options.port = static_cast<std::uint16_t>(candidate);
server_ = std::make_unique<UiServer>(options);
if (server_->start().empty()) {
port_ = static_cast<std::uint16_t>(candidate);
return;
}
server_.reset();
}
}
fs::path base_;
fs::path root_;
std::uint16_t port_ = 0;
mutable std::mutex mutex_;
std::vector<std::string> logs_;
std::unique_ptr<UiServer> server_; // détruit en premier : le journal reste valide
};
ix::HttpResponsePtr http_request(std::uint16_t port, const std::string& verb, const std::string& path)
{
ix::HttpClient client;
ix::HttpRequestArgsPtr args = client.createRequest();
args->followRedirects = false;
const std::string url = "http://127.0.0.1:" + std::to_string(port) + path;
if (verb == "GET") {
return client.get(url, args);
}
if (verb == "HEAD") {
return client.head(url, args);
}
return client.post(url, std::string("x"), args);
}
} // namespace
TEST_CASE("Web : GET / sert index.html avec les bons en-tetes")
{
TestEnv env;
REQUIRE(env.started());
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/");
REQUIRE(response);
CHECK(response->statusCode == 200);
CHECK(response->body.find("SEQUA") != std::string::npos);
CHECK(response->headers["Content-Type"] == "text/html; charset=utf-8");
CHECK(response->headers["Cache-Control"] == "no-store");
CHECK(response->headers["X-Content-Type-Options"] == "nosniff");
CHECK(response->headers["Referrer-Policy"] == "no-referrer");
}
TEST_CASE("Web : la query est ignoree pour trouver le fichier")
{
TestEnv env;
REQUIRE(env.started());
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/?core=ws%3A%2F%2Fx");
REQUIRE(response);
CHECK(response->statusCode == 200);
CHECK(response->body.find("SEQUA") != std::string::npos);
}
TEST_CASE("Web : types MIME des assets")
{
TestEnv env;
REQUIRE(env.started());
const ix::HttpResponsePtr js = http_request(env.port(), "GET", "/assets/app.js");
REQUIRE(js);
CHECK(js->statusCode == 200);
CHECK(js->headers["Content-Type"] == "text/javascript; charset=utf-8");
const ix::HttpResponsePtr css = http_request(env.port(), "GET", "/assets/style.css");
REQUIRE(css);
CHECK(css->statusCode == 200);
CHECK(css->headers["Content-Type"] == "text/css; charset=utf-8");
}
TEST_CASE("Web : HEAD renvoie les en-tetes sans corps")
{
TestEnv env;
REQUIRE(env.started());
const ix::HttpResponsePtr response = http_request(env.port(), "HEAD", "/");
REQUIRE(response);
CHECK(response->statusCode == 200);
CHECK(response->headers["Content-Type"] == "text/html; charset=utf-8");
CHECK(response->headers["Content-Length"] == "0");
}
TEST_CASE("Web : POST -> 405 avec Allow")
{
TestEnv env;
REQUIRE(env.started());
const ix::HttpResponsePtr response = http_request(env.port(), "POST", "/");
REQUIRE(response);
CHECK(response->statusCode == 405);
CHECK(response->headers["Allow"] == "GET, HEAD");
}
TEST_CASE("Web : fichier absent -> 404")
{
TestEnv env;
REQUIRE(env.started());
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/absent.js");
REQUIRE(response);
CHECK(response->statusCode == 404);
}
TEST_CASE("Web : tentatives d'evasion refusees sans jamais livrer le secret")
{
TestEnv env;
REQUIRE(env.started());
const std::vector<std::string> evasions = {
"/../secret.txt",
"/%2e%2e/secret.txt",
"/%2E%2E%2Fsecret.txt",
"/..%5csecret.txt",
"/assets/../../secret.txt",
"/%252e%252e/secret.txt",
"/C:/Windows/win.ini",
"//etc/passwd",
"/.hidden",
"/%00",
"/index.html:secret",
"/index.html::$DATA",
};
for (const std::string& path : evasions) {
CAPTURE(path);
const ix::HttpResponsePtr response = http_request(env.port(), "GET", path);
REQUIRE(response);
CHECK(response->statusCode == 404);
CHECK(response->body.find("SECRET") == std::string::npos);
}
}
TEST_CASE("Web : journal une ligne par requete")
{
TestEnv env;
REQUIRE(env.started());
(void)http_request(env.port(), "GET", "/");
(void)http_request(env.port(), "GET", "/absent.js");
(void)http_request(env.port(), "POST", "/");
const std::vector<std::string> logs = env.logs();
auto contains = [&logs](const std::string& needle) {
for (const std::string& line : logs) {
if (line.find(needle) != std::string::npos) {
return true;
}
}
return false;
};
CHECK(contains("GET / 200"));
CHECK(contains("GET /absent.js 404"));
CHECK(contains("POST / 405"));
}
TEST_CASE("Web : port deja occupe -> start() echoue")
{
TestEnv env;
REQUIRE(env.started());
UiServerOptions options;
options.host = "127.0.0.1";
options.port = env.port();
options.root = env.root();
UiServer second(options);
CHECK_FALSE(second.start().empty());
CHECK_FALSE(second.running());
}
TEST_CASE("Web : racine absente -> erreur claire")
{
const fs::path missing =
opencode_temp_dir() / "sequa-host-web-inexistant-doit-echouer";
UiServerOptions options;
options.host = "127.0.0.1";
options.port = 19499;
options.root = missing;
UiServer server(options);
const std::string error = server.start();
CHECK(error.find("studio/ui/dist introuvable") != std::string::npos);
CHECK_FALSE(server.running());
}