// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync // // Transport client vers l'optimiseur (brief 4 T2). Faux optimiseurs dans le processus // (QLocalServer / QTcpServer), temps SIMULÉ : la boucle d'événements ne sert qu'aux E/S réelles // des sockets, jamais à laisser s'écouler un délai du client. #include "etm/scheduler/optimizerclient.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // Le moteur la définit dans smartchargingmanager.cpp ; cette cible légère ne le lie pas. Q_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy") namespace { // Journal capturé : TOUS les niveaux, debug compris (règle posée en initTestCase). QStringList s_log; QtMessageHandler s_previousHandler = nullptr; void captureMessage(QtMsgType type, const QMessageLogContext &context, const QString &message) { s_log.append(message); if (s_previousHandler) s_previousHandler(type, context, message); } int countLog(const QString &needle) { int n = 0; for (const QString &line : std::as_const(s_log)) n += line.contains(needle) ? 1 : 0; return n; } // Attente d'une condition d'E/S. L'horloge murale ne borne QUE l'attente du test, jamais un // délai du client. template bool waitFor(F condition, int timeoutMs = 5000) { QElapsedTimer t; t.start(); while (!condition()) { if (t.elapsed() > timeoutMs) return false; QCoreApplication::processEvents(QEventLoop::AllEvents, 10); } return true; } // Laisse passer les événements en attente sans rien attendre de précis : sert à constater // qu'il NE se passe rien. void settle(int ms = 50) { QElapsedTimer t; t.start(); while (t.elapsed() < ms) QCoreApplication::processEvents(QEventLoop::AllEvents, 5); } } // namespace /*! Horloge simulée : le temps n'avance que par advance(), et les réveils échus sonnent dans * l'ordre, chacun à son instant exact. */ class SimClock : public OptimizerClientClock { Q_OBJECT public: qint64 nowMs() const override { return m_now; } void armWakeUp(qint64 delayMs) override { m_deadline = m_now + delayMs; } void disarmWakeUp() override { m_deadline = -1; } bool armed() const { return m_deadline >= 0; } qint64 remaining() const { return m_deadline - m_now; } void advance(qint64 ms) { const qint64 target = m_now + ms; while (m_deadline >= 0 && m_deadline <= target) { m_now = m_deadline; m_deadline = -1; emit wakeUp(); } m_now = target; } private: qint64 m_now = 1000000; // origine arbitraire non nulle qint64 m_deadline = -1; }; /*! Faux optimiseur : accepte une connexion à la fois, découpe ce qu'il reçoit en lignes JSON, * et répond ce que le test lui dicte. */ class FakeOptimizer : public QObject { Q_OBJECT public: enum Transport { Unix, Tcp }; FakeOptimizer(Transport transport, const QString &dir) : m_transport(transport), m_dir(dir) {} bool listen() { if (m_transport == Unix) { m_local = new QLocalServer(this); QLocalServer::removeServer(socketPath()); connect(m_local, &QLocalServer::newConnection, this, [this] { while (QLocalSocket *s = m_local->nextPendingConnection()) adopt(s); }); return m_local->listen(socketPath()); } m_tcp = new QTcpServer(this); connect(m_tcp, &QTcpServer::newConnection, this, [this] { while (QTcpSocket *s = m_tcp->nextPendingConnection()) adopt(s); }); // Port déjà choisi lors d'une écoute précédente : on le reprend (reconnexion). return m_tcp->listen(QHostAddress::LocalHost, m_port) && (m_port = m_tcp->serverPort()) != 0; } QString socketPath() const { return m_dir + QStringLiteral("/optimizer.sock"); } QString endpoint() const { if (m_transport == Unix) return QStringLiteral("unix://") + socketPath(); return QStringLiteral("tcp://127.0.0.1:%1").arg(m_port); } bool waitMessage(QJsonObject *out, int timeoutMs = 5000) { if (!waitFor([this] { return !m_messages.isEmpty(); }, timeoutMs)) return false; *out = m_messages.takeFirst(); return true; } void sendRaw(const QByteArray &bytes) { QVERIFY(m_client); m_client->write(bytes); if (auto *tcp = qobject_cast(m_client.data())) tcp->flush(); else if (auto *local = qobject_cast(m_client.data())) local->flush(); } void sendJson(const QJsonObject &o) { sendRaw(QJsonDocument(o).toJson(QJsonDocument::Compact) + '\n'); } void replyResult(qint64 id, const QJsonObject &result) { sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"result", result}}); } void abortClient() { if (auto *tcp = qobject_cast(m_client.data())) tcp->abort(); else if (auto *local = qobject_cast(m_client.data())) local->abort(); } int connections = 0; int disconnections = 0; private: void adopt(QIODevice *s) { ++connections; m_client = s; m_buffer.clear(); connect(s, &QIODevice::readyRead, this, [this, s] { m_buffer.append(s->readAll()); int nl; while ((nl = m_buffer.indexOf('\n')) >= 0) { m_messages.append(QJsonDocument::fromJson(m_buffer.left(nl)).object()); m_buffer.remove(0, nl + 1); } }); if (auto *tcp = qobject_cast(s)) connect(tcp, &QTcpSocket::disconnected, this, [this] { ++disconnections; }); else if (auto *local = qobject_cast(s)) connect(local, &QLocalSocket::disconnected, this, [this] { ++disconnections; }); } Transport m_transport; QString m_dir; quint16 m_port = 0; QLocalServer *m_local = nullptr; QTcpServer *m_tcp = nullptr; QPointer m_client; QByteArray m_buffer; QList m_messages; }; static QJsonObject handshakeResult(const QString &version = QStringLiteral("1.1"), bool withCapabilities = true) { QJsonObject r{{"protocolVersion", version}, {"name", "faux-optimiseur"}, {"planning", true}}; if (withCapabilities) r.insert("capabilities", QJsonObject{{"maxHorizonH", 48}, {"kinds", QJsonArray{"setpoint", "state"}}}); return r; } class TestOptimizerClient : public QObject { Q_OBJECT private slots: void initTestCase(); void init(); void testEndpointParsing_data(); void testEndpointParsing(); void testNotStartedOpensNothing(); void testInvalidEndpointIsNotStarted(); void testHandshakeAndPlan_data(); void testHandshakeAndPlan(); void testWrongMajorIsRefused(); void testInvalidHandshakeIsRefused_data(); void testInvalidHandshakeIsRefused(); void testWrongTokenThenFixedToken(); void testHandshakeTimeout(); void testPlanTimeoutKeepsConnection(); void testAbruptCloseThenReconnect(); void testBackoffDoublesToSixtySeconds(); void testStaleResponseIsIgnored(); void testLineLengthLimit(); void testBadLinesKeepConnection(); void testWriteToken(); void testTokenNeverInAnyLog(); private: // Démarre le client et mène le handshake jusqu'à Ready. void bringUp(OptimizerClient &client, FakeOptimizer &server, const QJsonObject &result = handshakeResult()); QTemporaryDir m_dir; }; void TestOptimizerClient::initTestCase() { QLoggingCategory::setFilterRules(QStringLiteral("NymeaEnergy.debug=true")); s_previousHandler = qInstallMessageHandler(captureMessage); } void TestOptimizerClient::init() { s_log.clear(); QVERIFY(m_dir.isValid()); } void TestOptimizerClient::bringUp(OptimizerClient &client, FakeOptimizer &server, const QJsonObject &result) { client.start(); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake")); server.replyResult(hs.value("id").toInteger(), result); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); } void TestOptimizerClient::testEndpointParsing_data() { QTest::addColumn("text"); QTest::addColumn("kind"); QTest::addColumn("canonical"); QTest::newRow("unix") << "unix:///run/powersync/optimizer.sock" << int(OptimizerEndpoint::Unix) << "unix:///run/powersync/optimizer.sock"; QTest::newRow("tcp ipv4") << "tcp://192.168.1.10:7400" << int(OptimizerEndpoint::Tcp) << "tcp://192.168.1.10:7400"; QTest::newRow("tcp nom") << "tcp://nas.local:7400" << int(OptimizerEndpoint::Tcp) << "tcp://nas.local:7400"; QTest::newRow("tcp ipv6") << "tcp://[fd00::1]:7400" << int(OptimizerEndpoint::Tcp) << "tcp://[fd00::1]:7400"; QTest::newRow("unix relatif") << "unix://run/x.sock" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("unix vide") << "unix://" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("tcp sans port") << "tcp://192.168.1.10" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("tcp port 0") << "tcp://h:0" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("tcp port trop grand") << "tcp://h:65536" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("tcp port signé") << "tcp://h:+80" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("tcp reste") << "tcp://h:80/x" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("tcp ipv6 nu") << "tcp://fd00::1:7400" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("tcp hôte vide") << "tcp://:7400" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("http") << "http://h:80" << int(OptimizerEndpoint::Invalid) << ""; QTest::newRow("vide") << "" << int(OptimizerEndpoint::Invalid) << ""; } void TestOptimizerClient::testEndpointParsing() { QFETCH(QString, text); QFETCH(int, kind); QFETCH(QString, canonical); const OptimizerEndpoint e = OptimizerEndpoint::parse(text); QCOMPARE(int(e.kind), kind); if (e.isValid()) QCOMPARE(e.toString(), canonical); } void TestOptimizerClient::testNotStartedOpensNothing() { // Le moteur n'instancie pas encore le client ; le jour où il le fera, la construction seule // ne doit rien ouvrir, rien armer, rien écrire au journal. FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); QVERIFY(server.listen()); SimClock clock; { OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); settle(); clock.advance(120000); settle(); QCOMPARE(client.state(), OptimizerClient::Stopped); QCOMPARE(client.requestPlan({}), quint64(0)); } QCOMPARE(server.connections, 0); QVERIFY(!clock.armed()); QCOMPARE(s_log, QStringList()); } void TestOptimizerClient::testInvalidEndpointIsNotStarted() { SimClock clock; OptimizerClient client(OptimizerEndpoint::parse("tcp://nulle-part"), QString(), "9.9", &clock); client.start(); QCOMPARE(client.state(), OptimizerClient::Stopped); QCOMPARE(client.lastErrorCode(), QStringLiteral("invalid-endpoint")); QVERIFY(!clock.armed()); QCOMPARE(countLog("[Optimiseur] adresse invalide"), 1); } void TestOptimizerClient::testHandshakeAndPlan_data() { QTest::addColumn("transport"); QTest::newRow("unix") << int(FakeOptimizer::Unix); QTest::newRow("tcp") << int(FakeOptimizer::Tcp); } void TestOptimizerClient::testHandshakeAndPlan() { QFETCH(int, transport); FakeOptimizer server(FakeOptimizer::Transport(transport), m_dir.path()); QVERIFY(server.listen()); SimClock clock; // Fichier de token ABSENT : pas de clé token du tout (ni vide, ni nulle). OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), m_dir.filePath("absent.token"), "9.9", &clock); QSignalSpy readySpy(&client, &OptimizerClient::ready); QSignalSpy planSpy(&client, &OptimizerClient::planReceived); client.start(); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); QCOMPARE(client.state(), OptimizerClient::Handshaking); QCOMPARE(hs.value("jsonrpc").toString(), QStringLiteral("2.0")); QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake")); QCOMPARE(hs.value("id").toInteger(), 1); const QJsonObject params = hs.value("params").toObject(); QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.1")); QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9")); QVERIFY(!params.contains("token")); // Seule l'échéance du handshake est armée, à 5 s depuis le début de la tentative. QVERIFY(clock.armed()); QCOMPARE(clock.remaining(), qint64(5000)); server.replyResult(1, handshakeResult()); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); QCOMPARE(readySpy.count(), 1); QVERIFY(client.hasCapabilities()); QCOMPARE(client.capabilities().value("maxHorizonH").toInt(), 48); QCOMPARE(client.capabilities().value("kinds").toStringList(), (QStringList{"setpoint", "state"})); QCOMPARE(client.serverName(), QStringLiteral("faux-optimiseur")); QCOMPARE(client.serverProtocolVersion(), QStringLiteral("1.1")); QVERIFY(client.serverPlanning()); QVERIFY(!clock.armed()); // prêt et rien en vol : aucun réveil QCOMPARE(countLog("[Optimiseur] connecté à " + server.endpoint()), 1); // GetPlan : params transmis tels quels, identifiants croissants. const QVariantMap context{{"timestamp", "2026-10-16T10:00:00Z"}, {"loads", QVariantList{}}}; const quint64 id = client.requestPlan(context); QCOMPARE(id, quint64(2)); QCOMPARE(clock.remaining(), qint64(10000)); QJsonObject req; QVERIFY(server.waitMessage(&req)); QCOMPARE(req.value("method").toString(), QStringLiteral("GetPlan")); QCOMPARE(req.value("id").toInteger(), 2); QCOMPARE(req.value("params").toObject().toVariantMap(), context); server.replyResult(2, QJsonObject{{"planId", "p1"}, {"slots", QJsonArray{}}}); QVERIFY(waitFor([&] { return planSpy.count() == 1; })); QCOMPARE(planSpy.at(0).at(0).value(), quint64(2)); QCOMPARE(planSpy.at(0).at(1).toMap().value("planId").toString(), QStringLiteral("p1")); QCOMPARE(client.pendingRequestId(), quint64(0)); QVERIFY(!clock.armed()); QCOMPARE(client.requestPlan(context), quint64(3)); // Sans capabilities : mémorisé comme ABSENT, pas comme vide (§3, seul le présent restreint). client.stop(); QCOMPARE(client.state(), OptimizerClient::Stopped); bringUp(client, server, handshakeResult("1.4", false)); QVERIFY(!client.hasCapabilities()); QCOMPARE(client.serverProtocolVersion(), QStringLiteral("1.4")); // même majeure : accepté } void TestOptimizerClient::testWrongMajorIsRefused() { FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); QSignalSpy readySpy(&client, &OptimizerClient::ready); client.start(); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); server.replyResult(1, handshakeResult("2.0")); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); QCOMPARE(client.lastErrorCode(), QStringLiteral("major-mismatch")); QCOMPARE(readySpy.count(), 0); QCOMPARE(client.requestPlan({}), quint64(0)); QVERIFY(waitFor([&] { return server.disconnections == 1; })); QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.1 — majeures différentes, refusé"), 1); // Nouvelle tentative au backoff (§3), pas avant. clock.advance(999); settle(); QCOMPARE(server.connections, 1); clock.advance(1); QVERIFY(server.waitMessage(&hs)); QCOMPARE(server.connections, 2); QCOMPARE(hs.value("id").toInteger(), 2); // les identifiants traversent les reconnexions server.replyResult(2, handshakeResult("1.0")); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); } void TestOptimizerClient::testInvalidHandshakeIsRefused_data() { QTest::addColumn("reply"); QTest::addColumn("code"); QTest::newRow("protocolVersion absent") << QByteArray(R"({"jsonrpc":"2.0","id":1,"result":{"name":"x"}})") << "invalid-handshake"; QTest::newRow("protocolVersion non chaîne") << QByteArray(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1.1}})") << "invalid-handshake"; QTest::newRow("result non objet") << QByteArray(R"({"jsonrpc":"2.0","id":1,"result":"1.1"})") << "invalid-handshake"; QTest::newRow("capabilities non objet") << QByteArray(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","capabilities":["setpoint"]}})") << "invalid-handshake"; QTest::newRow("erreur JSON-RPC") << QByteArray(R"({"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"inconnu"}})") << "handshake-refused"; } void TestOptimizerClient::testInvalidHandshakeIsRefused() { QFETCH(QByteArray, reply); QFETCH(QString, code); FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); QSignalSpy readySpy(&client, &OptimizerClient::ready); client.start(); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); server.sendRaw(reply + '\n'); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); QCOMPARE(client.lastErrorCode(), code); QCOMPARE(readySpy.count(), 0); QCOMPARE(clock.remaining(), qint64(1000)); QCOMPARE(countLog("(" + code + ") :"), 1); } void TestOptimizerClient::testWrongTokenThenFixedToken() { const QString tokenPath = m_dir.filePath("energy-optimizer.token"); QVERIFY(OptimizerClient::writeToken(tokenPath, "jeton-faux-7f3a")); FakeOptimizer server(FakeOptimizer::Tcp, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), tokenPath, "9.9", &clock); // 1. Mauvais token : le serveur ferme la connexion (§3). client.start(); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("jeton-faux-7f3a")); server.abortClient(); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); QCOMPARE(client.lastErrorCode(), QStringLiteral("closed-during-handshake")); QCOMPARE(countLog("un token a été envoyé"), 1); // 2. Le serveur refuse par une erreur JSON-RPC : même issue, autre code. clock.advance(1000); QVERIFY(server.waitMessage(&hs)); server.sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", hs.value("id")}, {"error", QJsonObject{{"code", -32001}, {"message", "token refusé"}}}}); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); QCOMPARE(client.lastErrorCode(), QStringLiteral("handshake-refused")); // 3. Token corrigé sur disque, sans toucher au client : relu à la connexion suivante. QVERIFY(OptimizerClient::writeToken(tokenPath, "jeton-juste-91c2")); clock.advance(1999); settle(); QCOMPARE(server.connections, 2); clock.advance(1); QVERIFY(server.waitMessage(&hs)); QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("jeton-juste-91c2")); server.replyResult(hs.value("id").toInteger(), handshakeResult()); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); QCOMPARE(countLog("(après 2 échec(s))"), 1); // 4. Token retiré : la connexion suivante part SANS clé token. QVERIFY(OptimizerClient::writeToken(tokenPath, QByteArray())); QVERIFY(!QFile::exists(tokenPath)); server.abortClient(); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); clock.advance(1000); QVERIFY(server.waitMessage(&hs)); QVERIFY(!hs.value("params").toObject().contains("token")); } void TestOptimizerClient::testHandshakeTimeout() { FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); client.start(); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); // reçu, jamais répondu clock.advance(4999); settle(); QCOMPARE(client.state(), OptimizerClient::Handshaking); clock.advance(1); QCOMPARE(client.state(), OptimizerClient::Waiting); QCOMPARE(client.lastErrorCode(), QStringLiteral("handshake-timeout")); QVERIFY(waitFor([&] { return server.disconnections == 1; })); QCOMPARE(clock.remaining(), qint64(1000)); // prochaine tentative au backoff // Une réponse tardive au handshake abandonné ne ressuscite rien. clock.advance(1000); QVERIFY(server.waitMessage(&hs)); QCOMPARE(hs.value("id").toInteger(), 2); server.replyResult(1, handshakeResult()); settle(); QCOMPARE(client.state(), OptimizerClient::Handshaking); QCOMPARE(client.staleResponses(), 1); server.replyResult(2, handshakeResult()); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); } void TestOptimizerClient::testPlanTimeoutKeepsConnection() { FakeOptimizer server(FakeOptimizer::Tcp, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); bringUp(client, server); QSignalSpy failSpy(&client, &OptimizerClient::planFailed); QSignalSpy planSpy(&client, &OptimizerClient::planReceived); const quint64 id = client.requestPlan({{"n", 1}}); QJsonObject req; QVERIFY(server.waitMessage(&req)); clock.advance(9999); settle(); QCOMPARE(failSpy.count(), 0); clock.advance(1); QCOMPARE(failSpy.count(), 1); QCOMPARE(failSpy.at(0).at(0).value(), id); QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("timeout")); QCOMPARE(client.state(), OptimizerClient::Ready); // connexion conservée QCOMPARE(server.disconnections, 0); QVERIFY(!clock.armed()); // La réponse tardive est reconnue par son id et ne commande rien. server.replyResult(qint64(id), QJsonObject{{"planId", "tardif"}}); QVERIFY(waitFor([&] { return client.staleResponses() == 1; })); QCOMPARE(planSpy.count(), 0); const quint64 next = client.requestPlan({{"n", 2}}); QVERIFY(server.waitMessage(&req)); server.replyResult(qint64(next), QJsonObject{{"planId", "frais"}}); QVERIFY(waitFor([&] { return planSpy.count() == 1; })); QCOMPARE(planSpy.at(0).at(1).toMap().value("planId").toString(), QStringLiteral("frais")); } void TestOptimizerClient::testAbruptCloseThenReconnect() { FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); bringUp(client, server); QSignalSpy failSpy(&client, &OptimizerClient::planFailed); const quint64 id = client.requestPlan({}); QJsonObject req; QVERIFY(server.waitMessage(&req)); server.abortClient(); QVERIFY(waitFor([&] { return failSpy.count() == 1; })); QCOMPARE(failSpy.at(0).at(0).value(), id); QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("disconnected")); QCOMPARE(client.state(), OptimizerClient::Waiting); QCOMPARE(client.lastErrorCode(), QStringLiteral("connection-lost")); QCOMPARE(client.requestPlan({}), quint64(0)); clock.advance(999); settle(); QCOMPARE(server.connections, 1); clock.advance(1); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); server.replyResult(hs.value("id").toInteger(), handshakeResult()); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); QCOMPARE(client.nextBackoffMs(), qint64(1000)); // remis à 1 s après succès } void TestOptimizerClient::testBackoffDoublesToSixtySeconds() { FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); // n'écoute pas encore SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); int attempts = 0; connect(&client, &OptimizerClient::stateChanged, this, [&](OptimizerClient::State s) { if (s == OptimizerClient::Connecting) ++attempts; }); client.start(); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); QCOMPARE(attempts, 1); QCOMPARE(client.lastErrorCode(), QStringLiteral("connect-failed")); const QList delays{1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000}; for (const qint64 d : delays) { const int before = attempts; QCOMPARE(clock.remaining(), d); clock.advance(d - 1); settle(10); QCOMPARE(attempts, before); clock.advance(1); QVERIFY(waitFor([&] { return attempts == before + 1 && client.state() == OptimizerClient::Waiting; })); } // Une série d'échecs identiques : annoncée UNE fois, puis comptée. QCOMPARE(countLog("(connect-failed) :"), 1); QVERIFY(server.listen()); clock.advance(60000); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); server.replyResult(hs.value("id").toInteger(), handshakeResult()); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); QCOMPARE(countLog("(après 9 échec(s))"), 1); QCOMPARE(client.nextBackoffMs(), qint64(1000)); } void TestOptimizerClient::testStaleResponseIsIgnored() { FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); bringUp(client, server); QSignalSpy planSpy(&client, &OptimizerClient::planReceived); QSignalSpy failSpy(&client, &OptimizerClient::planFailed); const quint64 first = client.requestPlan({{"trigger", "cycle"}}); clock.advance(3000); const quint64 second = client.requestPlan({{"trigger", "load-config"}}); QCOMPARE(second, first + 1); QCOMPARE(client.pendingRequestId(), second); QCOMPARE(clock.remaining(), qint64(10000)); // l'attente repart pour la seconde QJsonObject a, b; QVERIFY(server.waitMessage(&a)); QVERIFY(server.waitMessage(&b)); server.replyResult(qint64(first), QJsonObject{{"planId", "ancien"}}); QVERIFY(waitFor([&] { return client.staleResponses() == 1; })); QCOMPARE(planSpy.count(), 0); QCOMPARE(client.pendingRequestId(), second); server.replyResult(qint64(second), QJsonObject{{"planId", "récent"}}); QVERIFY(waitFor([&] { return planSpy.count() == 1; })); QCOMPARE(planSpy.at(0).at(0).value(), second); QCOMPARE(planSpy.at(0).at(1).toMap().value("planId").toString(), QStringLiteral("récent")); QCOMPARE(failSpy.count(), 0); // le remplacement est silencieux QCOMPARE(client.protocolErrors(), 0); } void TestOptimizerClient::testLineLengthLimit() { FakeOptimizer server(FakeOptimizer::Tcp, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); bringUp(client, server); QSignalSpy planSpy(&client, &OptimizerClient::planReceived); QSignalSpy errorSpy(&client, &OptimizerClient::protocolError); QJsonObject req; // Exactement kMaxLineBytes : acceptée (des blancs complètent un JSON valide). const quint64 first = client.requestPlan({}); QVERIFY(server.waitMessage(&req)); QByteArray atLimit = QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(first)}, {"result", QJsonObject{{"planId", "limite"}}}}) .toJson(QJsonDocument::Compact); atLimit.append(QByteArray(OptimizerClient::kMaxLineBytes - atLimit.size(), ' ')); QCOMPARE(atLimit.size(), OptimizerClient::kMaxLineBytes); server.sendRaw(atLimit + '\n'); QVERIFY(waitFor([&] { return planSpy.count() == 1; }, 10000)); QCOMPARE(client.protocolErrors(), 0); // Un octet de plus : écartée, comptée, connexion conservée, requête toujours attendue. const quint64 second = client.requestPlan({}); QVERIFY(server.waitMessage(&req)); QByteArray over = QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(second)}, {"result", QJsonObject{{"planId", "trop-long"}}}}) .toJson(QJsonDocument::Compact); over.append(QByteArray(OptimizerClient::kMaxLineBytes + 1 - over.size(), ' ')); server.sendRaw(over + '\n'); QVERIFY(waitFor([&] { return errorSpy.count() == 1; }, 10000)); QCOMPARE(errorSpy.at(0).at(0).toString(), QStringLiteral("line-too-long")); settle(); QCOMPARE(planSpy.count(), 1); QCOMPARE(client.state(), OptimizerClient::Ready); QCOMPARE(client.pendingRequestId(), second); // 3 Mio sans aucune fin de ligne : écartés AVANT la fin (la mémoire reste bornée), une seule // erreur ; tout jusqu'au '\n' est jeté, et la ligne d'APRÈS est lue normalement. server.sendRaw(QByteArray(3 * OptimizerClient::kMaxLineBytes, 'x')); QVERIFY(waitFor([&] { return errorSpy.count() == 2; }, 10000)); server.sendRaw("yyy\n"); // fin de la MÊME ligne trop longue : jetée avec elle server.replyResult(qint64(second), QJsonObject{{"planId", "après"}}); QVERIFY(waitFor([&] { return planSpy.count() == 2; }, 10000)); QCOMPARE(planSpy.at(1).at(1).toMap().value("planId").toString(), QStringLiteral("après")); QCOMPARE(client.protocolErrors(), 2); QCOMPARE(server.disconnections, 0); } void TestOptimizerClient::testBadLinesKeepConnection() { FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); bringUp(client, server); QSignalSpy planSpy(&client, &OptimizerClient::planReceived); QSignalSpy errorSpy(&client, &OptimizerClient::protocolError); QSignalSpy notifSpy(&client, &OptimizerClient::notificationReceived); const quint64 id = client.requestPlan({}); QJsonObject req; QVERIFY(server.waitMessage(&req)); server.sendRaw("ceci n'est pas du JSON\n"); server.sendRaw("[1,2,3]\n"); server.sendRaw("{\"jsonrpc\":\"2.0\",\"result\":{}}\n"); server.sendRaw("{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700}}\n"); server.sendRaw("{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{}}\n"); server.sendRaw("{\"jsonrpc\":\"2.0\",\"id\":\"abc\",\"result\":{}}\n"); server.sendRaw("{\"jsonrpc\":\"2.0\",\"method\":\"UpdateLearned\",\"params\":{\"confidence\":0.4}}\n"); server.sendRaw("\n"); QVERIFY(waitFor([&] { return errorSpy.count() == 6 && notifSpy.count() == 1; })); QStringList codes; for (const QList &e : std::as_const(errorSpy)) codes << e.at(0).toString(); QCOMPARE(codes, (QStringList{"invalid-json", "invalid-json", "missing-id", "missing-id", "unknown-id", "unknown-id"})); QCOMPARE(notifSpy.at(0).at(0).toString(), QStringLiteral("UpdateLearned")); QCOMPARE(client.state(), OptimizerClient::Ready); QCOMPARE(client.pendingRequestId(), id); // Une ligne de journal par NATURE d'erreur et par connexion, pas une par ligne. QCOMPARE(countLog("ligne écartée (invalid-json) :"), 1); QCOMPARE(countLog("ligne écartée (missing-id) :"), 1); QCOMPARE(countLog("ligne écartée (unknown-id) :"), 1); server.replyResult(qint64(id), QJsonObject{{"planId", "ok"}}); QVERIFY(waitFor([&] { return planSpy.count() == 1; })); QCOMPARE(client.protocolErrors(), 6); QCOMPARE(server.disconnections, 0); } void TestOptimizerClient::testWriteToken() { const QString path = m_dir.filePath("tok/energy-optimizer.token"); QVERIFY(QDir().mkpath(m_dir.filePath("tok"))); const mode_t previousUmask = ::umask(022); struct stat st; // Nouveau fichier : 0600 malgré un umask permissif. QString error; QVERIFY2(OptimizerClient::writeToken(path, "secret-un", &error), qPrintable(error)); QCOMPARE(::stat(QFile::encodeName(path).constData(), &st), 0); QCOMPARE(int(st.st_mode & 0777), 0600); QFile f(path); QVERIFY(f.open(QIODevice::ReadOnly)); QCOMPARE(f.readAll(), QByteArray("secret-un")); f.close(); // Fichier préexistant en 0644 (posé à la main) : remplacé ET resserré. QVERIFY(QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadGroup | QFileDevice::ReadOther)); QVERIFY(OptimizerClient::writeToken(path, "secret-deux")); QCOMPARE(::stat(QFile::encodeName(path).constData(), &st), 0); QCOMPARE(int(st.st_mode & 0777), 0600); QVERIFY(f.open(QIODevice::ReadOnly)); QCOMPARE(f.readAll(), QByteArray("secret-deux")); f.close(); // Refus : blanc ou contrôle (le trimmed() de la relecture ne les rendrait pas), trop long. QVERIFY(!OptimizerClient::writeToken(path, "avec\nretour", &error)); QVERIFY(!error.contains("avec")); QVERIFY(!OptimizerClient::writeToken(path, " bord")); QVERIFY(!OptimizerClient::writeToken(path, QByteArray(4097, 'a'))); QVERIFY(f.open(QIODevice::ReadOnly)); QCOMPARE(f.readAll(), QByteArray("secret-deux")); // intact f.close(); // Seul ce fichier est écrit à côté : aucun temporaire laissé, rien d'autre (energy.conf). QCOMPARE(QDir(m_dir.filePath("tok")).entryList(QDir::Files | QDir::Hidden), QStringList{"energy-optimizer.token"}); // Vide : retrait (plus de token configuré) ; retirer un absent n'est pas une erreur. QVERIFY(OptimizerClient::writeToken(path, QByteArray())); QVERIFY(!QFile::exists(path)); QVERIFY(OptimizerClient::writeToken(path, QByteArray())); QVERIFY(OptimizerClient::defaultTokenPath().endsWith("/energy-optimizer.token")); ::umask(previousUmask); QCOMPARE(s_log, QStringList()); // l'écriture ne journalise rien } void TestOptimizerClient::testTokenNeverInAnyLog() { // Décision Q2 : le token n'apparaît dans AUCUNE ligne de journal, à aucun niveau. Le journal // est capturé debug compris, et chaque chemin qui manipule le token est parcouru : envoi, // refus par fermeture, refus par erreur qui RECOPIE le token, droits trop larges, succès, // GetPlan, perte de connexion. const QByteArray token = "Zq81-secret-de-test-4e7b"; const QString tokenPath = m_dir.filePath("energy-optimizer.token"); QVERIFY(OptimizerClient::writeToken(tokenPath, token)); FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); QVERIFY(server.listen()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), tokenPath, "9.9", &clock); client.start(); QJsonObject hs; QVERIFY(server.waitMessage(&hs)); // Le secret a bien transité : sans cela, son absence du journal ne prouverait rien. QCOMPARE(hs.value("params").toObject().value("token").toString().toUtf8(), token); server.abortClient(); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); clock.advance(1000); QVERIFY(server.waitMessage(&hs)); server.sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", hs.value("id")}, {"error", QJsonObject{{"code", -32001}, {"message", "token " + QString::fromUtf8(token) + " refusé"}}}}); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); QVERIFY(QFile::setPermissions(tokenPath, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadOther)); clock.advance(2000); QVERIFY(server.waitMessage(&hs)); server.replyResult(hs.value("id").toInteger(), handshakeResult()); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); const quint64 id = client.requestPlan({{"n", 1}}); QJsonObject req; QVERIFY(server.waitMessage(&req)); server.replyResult(qint64(id), QJsonObject{{"planId", "p"}}); QVERIFY(waitFor([&] { return client.pendingRequestId() == 0; })); server.abortClient(); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); client.stop(); // Le journal a bien coulé, à tous les niveaux, sur chaque chemin parcouru — une absence ne // s'affirme que dans un flux dont on a prouvé qu'il coule. QVERIFY(countLog("handshake envoyé") >= 3); // debug QCOMPARE(countLog("(closed-during-handshake)"), 1); // avertissement QCOMPARE(countLog("(handshake-refused)"), 1); QCOMPARE(countLog("erreur JSON-RPC -32001"), 1); QCOMPARE(countLog("lisible au-delà de son propriétaire"), 1); QCOMPARE(countLog("[Optimiseur] connecté à"), 1); // info QCOMPARE(countLog("(connection-lost)"), 1); QVERIFY(s_log.size() >= 8); const QString secret = QString::fromUtf8(token); for (const QString &line : std::as_const(s_log)) QVERIFY2(!line.contains(secret) && !line.contains("Zq81"), qPrintable("token dans le journal : " + line)); } QTEST_GUILESS_MAIN(TestOptimizerClient) #include "testoptimizerclient.moc"