// SPDX-License-Identifier: MPL-2.0 // // Tests d'intégration : un vrai client WebSocket (IXWebSocket) contre le CoreServer et un // vrai CoreRuntime, en boucle locale (spec 0.2 §3 à §5). #include "sequa/core/server/protocol.hpp" #include "sequa/core/server/runtime.hpp" #include "sequa/core/server/server.hpp" #include #include #include #include #include #include #include #include #include #include #include using namespace sequa::core::server; using nlohmann::json; using namespace std::chrono_literals; namespace { constexpr auto timeout = std::chrono::seconds(5); struct Fixture { explicit Fixture(ServerOptions options = {}, CoreRuntimeOptions runtime_options = make_runtime_options()) : runtime(std::make_unique(runtime_options)) { // IXWebSocket ne rapporte pas le port choisi pour le port 0 : on essaie une plage. for (int port = 19100; port < 19200; ++port) { options.port = port; auto candidate = std::make_unique(options, *runtime); if (candidate->start().empty()) { server = std::move(candidate); return; } } FAIL("aucun port libre entre 19100 et 19199"); } static CoreRuntimeOptions make_runtime_options() { CoreRuntimeOptions options; options.core_version = "9.9.9-test"; options.real_time = false; options.lease_grace = 100ms; options.attach_deadline = 5s; return options; } [[nodiscard]] std::string url(const std::string& query = {}) const { return "ws://127.0.0.1:" + std::to_string(server->options().port) + "/" + query; } std::unique_ptr runtime; // survit au serveur (déclaré avant) std::unique_ptr server; }; class TestClient { public: explicit TestClient(const std::string& url, const ix::WebSocketHttpHeaders& headers = {}) { ix::initNetSystem(); socket_.setUrl(url); socket_.setExtraHeaders(headers); socket_.disableAutomaticReconnection(); socket_.setOnMessageCallback([this](const ix::WebSocketMessagePtr& message) { const std::lock_guard lock(mutex_); switch (message->type) { case ix::WebSocketMessageType::Open: open_ = true; break; case ix::WebSocketMessageType::Message: messages_.push_back(message->str); break; case ix::WebSocketMessageType::Close: closed_ = true; close_code_ = message->closeInfo.code; break; case ix::WebSocketMessageType::Error: closed_ = true; break; default: break; } changed_.notify_all(); }); socket_.start(); } ~TestClient() { socket_.stop(); ix::uninitNetSystem(); } TestClient(const TestClient&) = delete; TestClient& operator=(const TestClient&) = delete; bool wait_open() { return wait([this] { return open_ || closed_; }) && open_; } void send(const std::string& text) { socket_.sendText(text); } void stop() { socket_.stop(); } std::vector wait_messages(std::size_t count) { wait([this, count] { return messages_.size() >= count || closed_; }); const std::lock_guard lock(mutex_); return messages_; } // Envoie une requête et attend sa réponse (messages reçus jusqu'ici ignorés). json call(const std::string& type, json payload = json::object()) { std::size_t before = 0; { const std::lock_guard lock(mutex_); before = messages_.size(); } send(json{{"v", 1}, {"type", type}, {"id", type}, {"payload", std::move(payload)}}.dump()); const auto messages = wait_messages(before + 1); REQUIRE(messages.size() > before); return json::parse(messages[before]); } bool wait_closed() { return wait([this] { return closed_; }); } std::uint16_t close_code() { const std::lock_guard lock(mutex_); return close_code_; } private: template bool wait(Predicate predicate) { std::unique_lock lock(mutex_); return changed_.wait_for(lock, timeout, predicate); } ix::WebSocket socket_; std::mutex mutex_; std::condition_variable changed_; bool open_ = false; bool closed_ = false; std::uint16_t close_code_ = 0; std::vector messages_; }; json handshake(TestClient& client, const char* who = "sequa-studio-host") { REQUIRE(client.wait_open()); client.send(encode_hello(protocol_version, who)); const auto messages = client.wait_messages(1); REQUIRE(messages.size() == 1); INFO("reponse : " << messages.front()); const json reply = json::parse(messages.front()); REQUIRE(reply["type"] == "welcome"); return reply; } } // namespace TEST_CASE("Serveur : poignee de main 0.3 sur un vrai socket") { Fixture fixture; TestClient client(fixture.url()); const json welcome = handshake(client); CHECK(welcome["type"] == "welcome"); CHECK(welcome["payload"]["coreVersion"] == "9.9.9-test"); CHECK(welcome["payload"]["protocolVersion"] == "0.3.0"); CHECK(welcome["payload"]["instanceId"] == fixture.runtime->core_status().instance_id); } TEST_CASE("Serveur : jeton exige") { ServerOptions options; options.token = "0123456789abcdef"; Fixture fixture(options); SUBCASE("absent") { TestClient client(fixture.url()); const auto messages = client.wait_messages(1); REQUIRE_FALSE(messages.empty()); CHECK(json::parse(messages.front())["payload"]["code"] == "unauthorized"); REQUIRE(client.wait_closed()); CHECK(client.close_code() == close_code::policy_violation); } SUBCASE("faux") { TestClient client(fixture.url("?token=0123456789abcdeX")); REQUIRE(client.wait_closed()); CHECK(client.close_code() == close_code::policy_violation); } SUBCASE("correct, parmi d'autres parametres") { TestClient client(fixture.url("?x=1&token=0123456789abcdef")); CHECK(handshake(client)["type"] == "welcome"); } } TEST_CASE("Serveur : origine verifiee, origine absente acceptee") { SUBCASE("origine locale par defaut") { Fixture fixture; TestClient local(fixture.url(), {{"Origin", "http://localhost:5173"}}); CHECK(handshake(local, "sequa-studio")["type"] == "welcome"); } SUBCASE("site etranger refuse") { Fixture fixture; TestClient foreign(fixture.url(), {{"Origin", "https://exemple.com"}}); const auto messages = foreign.wait_messages(1); REQUIRE_FALSE(messages.empty()); CHECK(json::parse(messages.front())["payload"]["code"] == "forbidden-origin"); REQUIRE(foreign.wait_closed()); CHECK(foreign.close_code() == close_code::policy_violation); } SUBCASE("liste explicite : seule l'origine exacte passe") { ServerOptions options; options.allowed_origins = {"http://127.0.0.1:9180"}; Fixture fixture(options); TestClient other_port(fixture.url(), {{"Origin", "http://127.0.0.1:5173"}}); REQUIRE(other_port.wait_closed()); TestClient exact(fixture.url(), {{"Origin", "http://127.0.0.1:9180"}}); CHECK(handshake(exact, "sequa-studio")["type"] == "welcome"); } SUBCASE("client natif ws:// accepte meme avec une liste explicite") { ServerOptions options; options.allowed_origins = {"http://127.0.0.1:9180"}; Fixture fixture(options); TestClient native(fixture.url(), {{"Origin", "ws://127.0.0.1:1234"}}); CHECK(handshake(native)["type"] == "welcome"); } SUBCASE("piege de prefixe") { Fixture fixture; TestClient trap(fixture.url(), {{"Origin", "http://localhost.exemple.com"}}); REQUIRE(trap.wait_closed()); CHECK(trap.close_code() == close_code::policy_violation); } } TEST_CASE("Serveur : export puis import d'une session par le protocole") { Fixture fixture; TestClient client(fixture.url()); handshake(client); CHECK(client.call("session/start", {{"seed", "2026"}})["payload"]["tick"] == 0); CHECK(client.call("session/input", {{"buttons", 1}, {"moveX", 32767}, {"moveY", 0}})["payload"]["tick"] == 0); // Pas de temps réel ici : la session n'avance pas toute seule, l'export est à tick 0. const json exported = client.call("session/export", {{"includeHashes", true}})["payload"]; CHECK(exported["tick"] == 0); const json imported = client.call("session/import", {{"recording", exported["recording"]}, {"resume", false}})["payload"]; CHECK(imported["running"] == false); CHECK(imported["stateHash"] == exported["stateHash"]); CHECK(client.call("session/import", {{"recording", "@@@@"}, {"resume", true}})["payload"]["code"] == "invalid-recording"); } TEST_CASE("Serveur : bail - la deconnexion du controleur declenche l'arret") { auto runtime_options = Fixture::make_runtime_options(); runtime_options.linked = true; Fixture fixture({}, runtime_options); { TestClient controller(fixture.url()); handshake(controller); CHECK(controller.call("core/attach")["payload"]["linked"] == true); CHECK(fixture.runtime->core_status().controller_attached); controller.stop(); } std::this_thread::sleep_for(300ms); // grâce de 100 ms CHECK(fixture.runtime->exit_reason() == CoreRuntime::ExitReason::lease_expired); } TEST_CASE("Serveur : bail leve par core/detach - pas d'arret") { auto runtime_options = Fixture::make_runtime_options(); runtime_options.linked = true; Fixture fixture({}, runtime_options); { TestClient controller(fixture.url()); handshake(controller); CHECK(controller.call("core/attach")["payload"]["linked"] == true); CHECK(controller.call("core/detach")["payload"]["linked"] == false); controller.stop(); } std::this_thread::sleep_for(300ms); CHECK(fixture.runtime->exit_reason() == CoreRuntime::ExitReason::none); } TEST_CASE("Serveur : core/shutdown demande l'arret") { Fixture fixture; TestClient client(fixture.url()); handshake(client); CHECK(client.call("core/shutdown")["type"] == "core/shutdown"); std::this_thread::sleep_for(400ms); CHECK(fixture.runtime->exit_reason() == CoreRuntime::ExitReason::shutdown); } TEST_CASE("Serveur : port deja occupe -> start() renvoie une erreur") { Fixture first; ServerOptions options; options.port = first.server->options().port; CoreServer second(options, *first.runtime); CHECK_FALSE(second.start().empty()); CHECK_FALSE(second.running()); }