Patrick Schurig 5768c36953 fix(optimiseur): token d'au moins 16 caractères (correctifs brief 4, point 4)
Question 4 de T7 : le masquage à la réception remplace TOUTE occurrence du
token dans ce que l'optimiseur renvoie ; un token court, présent par hasard
dans un identifiant de charge, corromprait le plan reçu (unknown-load).

- SetOptimizerToken (writeToken) refuse un token non vide de moins de
  16 caractères (points de code UTF-8) : EnergyErrorInvalidParameter, fault
  `token-refused` comme les autres refus ; rien n'est modifié ; le motif est
  journalisé, jamais la valeur. Vide = retrait, inchangé.
- Le client relit le fichier au début de chaque tentative ; plus court que
  16 caractères, il ne se connecte PAS (ni avec ce token, ni sans token : les
  mesures du site ne partent pas vers un optimiseur que rien n'authentifie).
  lastErrorCode `token-too-short`, dit une fois par série, nouvelle tentative
  au backoff jusqu'au remplacement du fichier.

INTERFACE.md (SetOptimizerToken, lastErrorCode) et protocole §3 (E-T2-8,
forme du token). Tests : 15 refusé, 16 accepté, 15 « é » refusés (caractères,
pas octets), vide = retrait, fichier court sans connexion puis token conforme
envoyé ; tokens des suites portés à 16 au moins. Contre-épreuves M4a (refus
retiré) et M4b (fichier court envoyé) : mordent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:44:19 +02:00

835 lines
36 KiB
C++

// 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 "../common/etm-powersync-fakeoptimizer.h"
#include <QCoreApplication>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocalServer>
#include <QLocalSocket>
#include <QLoggingCategory>
#include <QPointer>
#include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTemporaryDir>
#include <QTest>
#include <sys/stat.h>
// 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;
}
} // 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;
};
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();
void testAShortTokenFileOpensNoConnection();
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<QString>("text");
QTest::addColumn<int>("kind");
QTest::addColumn<QString>("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<int>("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>(), 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<QByteArray>("reply");
QTest::addColumn<QString>("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-7f3a9"));
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-7f3a9"));
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<quint64>(), 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<quint64>(), 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<qint64> 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<quint64>(), 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<QVariant> &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-16-car", &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-16-car"));
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-1234"));
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-1234"));
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')));
// Trop court : moins de 16 caractères (le masquage remplace toute occurrence du token). Comptés
// en caractères : 15 « é » font 30 octets et restent refusés.
QVERIFY(!OptimizerClient::writeToken(path, "quinze-car-1234", &error));
QVERIFY2(error.contains("moins de 16 caractères"), qUtf8Printable(error));
QVERIFY(!error.contains("quinze"));
QVERIFY(!OptimizerClient::writeToken(path, QString(15, QChar(0xE9)).toUtf8()));
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(f.readAll(), QByteArray("secret-deux-1234")); // 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"});
// 16 caractères : accepté, exactement à la borne.
QVERIFY2(OptimizerClient::writeToken(path, "seize-car-123456", &error), qPrintable(error));
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(f.readAll(), QByteArray("seize-car-123456"));
f.close();
// 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));
}
void TestOptimizerClient::testAShortTokenFileOpensNoConnection()
{
// Un fichier de token de moins de 16 caractères (posé à la main : la RPC le refuse) : le client
// ne se connecte PAS — ni avec ce token, ni sans token. Dit une fois, sans la valeur. Relu à
// chaque tentative : un token conforme posé ensuite sert à la suivante.
const QString tokenPath = m_dir.filePath("energy-optimizer.token");
const QByteArray court = "court-4f2a-9c1e"; // 15 caractères
{
QFile f(tokenPath);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate));
QVERIFY(f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner));
QCOMPARE(f.write(court + '\n'), qint64(court.size() + 1));
}
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), tokenPath, "9.9", &clock);
int attempts = 0;
connect(&client, &OptimizerClient::stateChanged, this, [&](OptimizerClient::State s) {
if (s == OptimizerClient::Connecting)
++attempts;
});
client.start();
settle();
QCOMPARE(client.state(), OptimizerClient::Waiting);
QCOMPARE(client.lastErrorCode(), QStringLiteral("token-too-short"));
QCOMPARE(server.connections, 0);
// Les tentatives suivantes relisent le fichier, échouent pareil, et ne le redisent pas.
clock.advance(1000);
settle();
clock.advance(2000);
settle();
QCOMPARE(attempts, 3);
QCOMPARE(server.connections, 0);
QCOMPARE(countLog("(token-too-short) :"), 1);
QCOMPARE(countLog("moins de 16 caractères"), 1);
for (const QString &line : std::as_const(s_log))
QVERIFY2(!line.contains(QString::fromUtf8(court)), qUtf8Printable(line));
// Remplacé par un token conforme : la tentative suivante se connecte et l'envoie.
QVERIFY(OptimizerClient::writeToken(tokenPath, "seize-car-123456"));
clock.advance(4000);
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("seize-car-123456"));
QCOMPARE(server.connections, 1);
}
QTEST_GUILESS_MAIN(TestOptimizerClient)
#include "testoptimizerclient.moc"