// 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 #include #include #include #include #include #include #include #include #include #include #include #include #include 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 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(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(server_); } std::uint16_t port() const { return port_; } const fs::path& root() const { return root_; } const fs::path& base() const { return base_; } std::vector logs() const { const std::lock_guard 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", "SEQUA\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 lock(mutex_); logs_.emplace_back(line); }; for (int candidate = 19400; candidate <= 19499; ++candidate) { options.port = static_cast(candidate); server_ = std::make_unique(options); if (server_->start().empty()) { port_ = static_cast(candidate); return; } server_.reset(); } } fs::path base_; fs::path root_; std::uint16_t port_ = 0; mutable std::mutex mutex_; std::vector logs_; std::unique_ptr 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 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 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()); }