Files
SEQUA-Engine/core/tests/server/test_protocol.cpp
T
Ange-Marie MAURIN - A2M Systèmes a185b3414a feat(core): protocole Studio - Core 0.2, session en direct, bail et sécurité
CoreServices / CoreRuntime / LiveSession (conception reprise d'un subagent interrompu, implémentation complétée) : session à 60 Hz avec entrée maintenue, export de l'enregistrement et des empreintes par tick, import par rejeu (sim::rebuild_session) puis poursuite dans le même enregistrement ; bail réversible (--linked, core/attach, core/detach, délai de grâce, code de sortie 3) ; core/shutdown ; jeton en temps constant et contrôle de l'Origin (origines ws:// des clients natifs acceptées, spec mise à jour). Base64 strict corrigé (rejetait des entrées valides). 56 cas de test, ASan vert sur les tests serveur. build.ps1 : -Target accepte a,b.
2026-11-06 23:00:53 +01:00

273 lines
11 KiB
C++

// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/server/protocol.hpp"
#include "sequa/core/server/services.hpp"
#include <doctest/doctest.h>
#include <nlohmann/json.hpp>
#include <string>
using namespace sequa::core::server;
using nlohmann::json;
namespace {
// Services factices : mémorisent les appels, réponses prévisibles.
class FakeServices final : public CoreServices {
public:
CoreStatus core_status() const override
{
CoreStatus status;
status.core_version = "1.2.3";
status.protocol_version = std::string(protocol_version);
status.instance_id = "0123456789abcdef0123456789abcdef";
status.pid = 4242;
status.linked = linked;
status.controller_attached = controller.has_value();
return status;
}
AttachResult attach(ConnectionId id, bool force) override
{
if (controller && *controller != id && !force) {
return AttachResult::controller_exists;
}
controller = id;
linked = true;
return AttachResult::attached;
}
DetachResult detach(ConnectionId id) override
{
if (controller != id) {
return DetachResult::not_controller;
}
linked = false;
return DetachResult::detached;
}
void request_shutdown() override { shutdown = true; }
SessionStateView session_start(std::uint64_t new_seed) override
{
state.seed = new_seed;
state.tick = 0;
return state;
}
SessionStateView session_pause() override
{
state.running = false;
return state;
}
SessionStateView session_resume() override
{
state.running = true;
return state;
}
SessionStateView session_status() const override { return state; }
std::uint64_t session_input(std::uint32_t b, std::int32_t x, std::int32_t y) override
{
buttons = b;
move_x = x;
move_y = y;
return state.tick;
}
SessionExportData session_export() const override
{
SessionExportData data;
data.recording = {std::byte{'S'}, std::byte{'Q'}};
data.tick = 2;
data.state_hash = 0xABCDEFull;
data.hashes = {1, 0x0102030405060708ull};
return data;
}
std::optional<SessionImportData> session_import(std::span<const std::byte> recording, bool resume) override
{
if (recording.size() != 2) {
return std::nullopt;
}
SessionImportData data;
data.state = state;
data.state.running = resume;
data.replay_ms = 1.5;
return data;
}
void on_client_connected() override {}
void on_client_disconnected(ConnectionId) override {}
SessionStateView state{0x2Aull, 60, 10, 0xFFull, true, 3};
std::optional<ConnectionId> controller;
bool linked = false;
bool shutdown = false;
std::uint32_t buttons = 0;
std::int32_t move_x = 0;
std::int32_t move_y = 0;
};
const std::string hello = encode_hello(protocol_version, "sequa-studio-host");
json single(const Reply& reply)
{
REQUIRE(reply.messages.size() == 1);
return json::parse(reply.messages.front());
}
json request(ProtocolSession& session, const std::string& type, json payload = json::object(), std::string id = "r1")
{
const json message{{"v", 1}, {"type", type}, {"id", id}, {"payload", std::move(payload)}};
const Reply reply = session.on_text(message.dump());
CHECK_FALSE(reply.close);
return single(reply);
}
ProtocolSession ready_session(FakeServices& services, ConnectionId id = 1)
{
ProtocolSession session(services, id);
const json welcome = single(session.on_text(hello));
REQUIRE(welcome["type"] == "welcome");
return session;
}
void check_rejected(ProtocolSession& session, const std::string& text, const std::string& expected_code)
{
const Reply reply = session.on_text(text);
CHECK(single(reply)["payload"]["code"] == expected_code);
CHECK(reply.close);
CHECK(reply.close_reason.size() <= 123);
CHECK(session.state() == ProtocolSession::State::closed);
}
} // namespace
TEST_CASE("Protocole : compatibilite des versions 0.2")
{
CHECK(is_compatible_protocol_version("0.2.0"));
CHECK(is_compatible_protocol_version("0.2.7-rc.1"));
CHECK_FALSE(is_compatible_protocol_version("0.1.0"));
CHECK_FALSE(is_compatible_protocol_version("0.3.0"));
CHECK_FALSE(is_compatible_protocol_version("1.2.0"));
CHECK_FALSE(is_compatible_protocol_version("0.2"));
CHECK_FALSE(is_compatible_protocol_version("0.2.0x"));
}
TEST_CASE("Protocole : welcome porte instanceId, pid et linked")
{
FakeServices services;
ProtocolSession session(services, 1);
const json welcome = single(session.on_text(encode_hello(protocol_version, "sequa-studio")));
CHECK(welcome["payload"]["protocolVersion"] == "0.2.0");
CHECK(welcome["payload"]["coreVersion"] == "1.2.3");
CHECK(welcome["payload"]["instanceId"] == "0123456789abcdef0123456789abcdef");
CHECK(welcome["payload"]["pid"] == 4242);
CHECK(welcome["payload"]["linked"] == false);
}
TEST_CASE("Protocole : poignee de main refusee")
{
FakeServices services;
ProtocolSession session(services, 1);
SUBCASE("JSON invalide") { check_rejected(session, "{pas du json", "invalid-json"); }
SUBCASE("autre message avant hello") { check_rejected(session, R"({"v":1,"type":"core/ping","payload":{}})", "handshake-required"); }
SUBCASE("client inconnu") { check_rejected(session, encode_hello(protocol_version, "autre"), "invalid-payload"); }
SUBCASE("version 0.1 incompatible") { check_rejected(session, encode_hello("0.1.0", "sequa-studio"), "incompatible-protocol"); }
SUBCASE("id non textuel") { check_rejected(session, R"({"v":1,"type":"hello","id":5,"payload":{}})", "invalid-envelope"); }
}
TEST_CASE("Protocole : id recopie, reponse du meme type")
{
FakeServices services;
auto session = ready_session(services);
const json pong = request(session, "core/ping", json::object(), "abc-17");
CHECK(pong["type"] == "core/ping");
CHECK(pong["id"] == "abc-17");
// Sans id : réponse sans id.
const json no_id = single(session.on_text(R"({"v":1,"type":"core/ping","payload":{}})"));
CHECK_FALSE(no_id.contains("id"));
}
TEST_CASE("Protocole : erreurs apres la poignee de main sans fermeture")
{
FakeServices services;
auto session = ready_session(services);
const json unknown = request(session, "scene/get");
CHECK(unknown["type"] == "error");
CHECK(unknown["id"] == "r1");
CHECK(unknown["payload"]["code"] == "unknown-type");
CHECK(request(session, "session/start", {{"seed", "xyz"}})["payload"]["code"] == "invalid-payload");
CHECK(request(session, "session/start", {{"seed", "12345678901234567"}})["payload"]["code"] == "invalid-payload");
const Reply garbage = session.on_text("pas du json");
CHECK_FALSE(garbage.close);
CHECK(single(garbage)["payload"]["code"] == "invalid-json");
CHECK(session.state() == ProtocolSession::State::ready);
}
TEST_CASE("Protocole : core/status et core/shutdown")
{
FakeServices services;
auto session = ready_session(services);
const json status = request(session, "core/status")["payload"];
CHECK(status["instanceId"] == "0123456789abcdef0123456789abcdef");
CHECK(status["controllerAttached"] == false);
CHECK(request(session, "core/shutdown")["type"] == "core/shutdown");
CHECK(services.shutdown);
}
TEST_CASE("Protocole : controleur, attach force et detach")
{
FakeServices services;
auto first = ready_session(services, 1);
auto second = ready_session(services, 2);
CHECK(request(second, "core/detach")["payload"]["code"] == "not-controller");
CHECK(request(first, "core/attach")["payload"]["linked"] == true);
CHECK(request(second, "core/attach")["payload"]["code"] == "controller-exists");
CHECK(request(second, "core/attach", {{"force", "oui"}})["payload"]["code"] == "invalid-payload");
CHECK(request(second, "core/attach", {{"force", true}})["payload"]["linked"] == true);
CHECK(services.controller == 2u);
CHECK(request(first, "core/detach")["payload"]["code"] == "not-controller");
CHECK(request(second, "core/detach")["payload"]["linked"] == false);
}
TEST_CASE("Protocole : etat de session en hexadecimal")
{
FakeServices services;
auto session = ready_session(services);
const json state = request(session, "session/status")["payload"];
CHECK(state["seed"] == "000000000000002a");
CHECK(state["stateHash"] == "00000000000000ff");
CHECK(state["tickRateHz"] == 60);
CHECK(state["tick"] == 10);
CHECK(state["inputCount"] == 3);
CHECK(request(session, "session/start", {{"seed", "FF"}})["payload"]["seed"] == "00000000000000ff");
CHECK(request(session, "session/pause")["payload"]["running"] == false);
CHECK(request(session, "session/resume")["payload"]["running"] == true);
}
TEST_CASE("Protocole : session/input et ses bornes")
{
FakeServices services;
auto session = ready_session(services);
CHECK(request(session, "session/input", {{"buttons", 4294967295u}, {"moveX", -32767}, {"moveY", 32767}})["payload"]["tick"] == 10);
CHECK(services.buttons == 4294967295u);
CHECK(services.move_x == -32767);
CHECK(request(session, "session/input", {{"buttons", 1}, {"moveX", 32768}, {"moveY", 0}})["payload"]["code"] == "invalid-payload");
CHECK(request(session, "session/input", {{"buttons", -1}, {"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload");
CHECK(request(session, "session/input", {{"buttons", 4294967296u}, {"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload");
CHECK(request(session, "session/input", {{"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload");
}
TEST_CASE("Protocole : session/export et session/import en base64")
{
FakeServices services;
auto session = ready_session(services);
const json exported = request(session, "session/export", {{"includeHashes", true}})["payload"];
CHECK(exported["recording"] == "U1E=");
CHECK(exported["tick"] == 2);
CHECK(exported["stateHash"] == "0000000000abcdef");
CHECK(exported["hashes"] == "AQAAAAAAAAAIBwYFBAMCAQ=="); // 1 puis 0x0102030405060708, petit-boutiste
CHECK_FALSE(request(session, "session/export")["payload"].contains("hashes"));
const json imported = request(session, "session/import", {{"recording", "U1E="}, {"resume", false}})["payload"];
CHECK(imported["running"] == false);
CHECK(imported["replayMs"] == 1.5);
CHECK(request(session, "session/import", {{"recording", "U1E"}, {"resume", true}})["payload"]["code"] == "invalid-recording");
CHECK(request(session, "session/import", {{"recording", "AAAA"}, {"resume", true}})["payload"]["code"] == "invalid-recording");
CHECK(request(session, "session/import", {{"recording", "U1E="}})["payload"]["code"] == "invalid-payload");
}