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.
273 lines
11 KiB
C++
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");
|
|
}
|