test(charging): table des droits NymeaEnergy.* figée, et droits mordant sur transport authentifié

testNymeaEnergyPermissionScopes compare le permissionScope PUBLIÉ par JSONRPC.Introspect (la
table que nymea consulte) à une table écrite dans le test : méthode ajoutée, retirée ou droit
changé → échec, avec tous les écarts listés.

testNymeaEnergyPermissionsEnforced crée de vrais comptes sur le MockTcpServer du harnais, dont
l'authentification est active (Hello : authenticationRequired vérifié) :
- sans droit : GetLoadTelemetry, GetTariff, GetBatteryLevelConsideration répondent ;
  SetTariff {zones: []} et SetLoadDerogation → « Permission denied. », tarif inchangé ;
- ControlThings : SetLoadDerogation posée puis levée ; SetLoadConfig refusé, config inchangée ;
- ConfigureThings : SetTariff passe ; SetPhasePowerLimit et GetOptimizerTrace refusés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-19 08:26:24 +02:00
parent 8a055eee9c
commit 6c68d17edc
2 changed files with 188 additions and 0 deletions

View File

@ -25,6 +25,9 @@
#include "testcharging.h"
#include <hardware/electricity.h>
#include <usermanager/usermanager.h>
#include <QScopeGuard>
TestCharging::TestCharging(QObject *parent):
EnergyTestBase(parent)
@ -2171,3 +2174,181 @@ void TestCharging::getChargingInfosFiltersOnCharger()
// une entrée, donc une liste vide ne peut vouloir dire qu'une chose — « pas cette borne ».
QCOMPARE(lire(QUuid::createUuid()).count(), 0);
}
// ============================================================================================
// [ETM] Droits de NymeaEnergy.* — lire, commander, configurer, administrer (2026-10-19)
// ============================================================================================
void TestCharging::testNymeaEnergyPermissionScopes()
{
// LA TABLE, écrite à la main et non relue dans le code : c'est elle qui dit ce qui a été
// DÉCIDÉ. Une méthode nouvelle doit y entrer avec son droit, sinon ce test échoue — c'est ce
// qui aurait attrapé GetBatteryLevelConsideration, une lecture tombée en Admin par la valeur
// par défaut de registerMethod.
const QString none = "PermissionScopeNone";
const QString control = "PermissionScopeControlThings";
const QString configure = "PermissionScopeConfigureThings";
const QString admin = "PermissionScopeAdmin";
const QMap<QString, QString> attendu = {
// Lire : tout compte AUTHENTIFIÉ.
{"GetPhasePowerLimit", none},
{"GetAcquisitionTolerance", none},
{"GetBatteryLevelConsideration", none},
{"GetLockOnUnplug", none},
{"GetChargingInfos", none},
{"GetChargingSchedules", none},
{"GetAvailableSpotMarketProviders", none},
{"GetSpotMarketConfiguration", none},
{"GetSpotMarketScoreEntries", none},
{"GetLoadConfig", none},
{"GetLoadTelemetry", none},
{"GetEnergyRatios", none},
{"GetPvForecast", none},
{"GetForecastSite", none},
{"GetForecastSnapshot", none},
{"GetTariff", none},
{"GetOptimizerStatus", none},
{"GetPlan", none},
{"GetOptimizerProtocol", none},
// Commander : les gestes de l'usager.
{"SetChargingInfo", control},
{"SetLoadDerogation", control},
{"ClearLoadDerogation", control},
{"ClearLoadFault", control},
{"SetLockOnUnplug", control},
// Configurer : décrire l'installation.
{"SetLoadConfig", configure},
{"SetTariff", configure},
{"SetForecastSite", configure},
{"SetAcquisitionTolerance", configure},
{"SetSpotMarketConfiguration", configure},
// Administrer : raccordement, secret, chemin système.
{"SetPhasePowerLimit", admin},
{"SetBatteryLevelConsideration", admin},
{"SetOptimizerEndpoint", admin},
{"SetOptimizerToken", admin},
{"GetOptimizerTrace", admin},
{"SetOptimizerTrace", admin},
};
// Le droit PUBLIÉ, celui que nymea vérifie à chaque appel : JSONRPC.Introspect rend la table
// même que le serveur consulte, pas une relecture du handler.
const QString prefixe = "NymeaEnergy.";
const QVariantMap methodes = injectAndWait("JSONRPC.Introspect").toMap()
.value("params").toMap().value("methods").toMap();
QMap<QString, QString> publie;
for (auto it = methodes.cbegin(); it != methodes.cend(); ++it) {
if (it.key().startsWith(prefixe))
publie.insert(it.key().mid(prefixe.size()), it.value().toMap().value("permissionScope").toString());
}
QVERIFY2(!publie.isEmpty(), "aucune méthode NymeaEnergy.* dans JSONRPC.Introspect");
// Tous les écarts d'un coup, pas le premier seul : une table se corrige en une passe.
QStringList noms = attendu.keys() + publie.keys();
noms.removeDuplicates();
noms.sort();
QStringList ecarts;
for (const QString &m : noms) {
if (attendu.value(m) != publie.value(m))
ecarts << QString("%1 : attendu %2, publié %3")
.arg(m, attendu.value(m, "(absente de la table)"), publie.value(m, "(non enregistrée)"));
}
QVERIFY2(ecarts.isEmpty(), qPrintable("\n" + ecarts.join('\n')));
}
void TestCharging::testNymeaEnergyPermissionsEnforced()
{
UserManager *users = NymeaCore::instance()->userManager();
const QString motDePasse = "Droits-RPC-1";
// UN CLIENT PAR COMPTE : nymea lie le jeton à la connexion au JSONRPC.Hello, et coupe un
// client qui en change sans refaire la poignée de main.
struct Compte { QString nom; QUuid client; QByteArray jeton; bool authentificationExigee = false; };
QList<Compte> ouverts;
const auto nettoyer = qScopeGuard([&]() {
for (const Compte &c : std::as_const(ouverts)) {
emit m_mockTcpServer->clientDisconnected(c.client);
users->removeUser(c.nom);
}
injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", QVariantList()}});
injectAndWait("NymeaEnergy.SetTariff", {{"zones", QVariantList()}});
});
auto ouvrir = [&](const QString &nom, Types::PermissionScopes droits) {
Compte c{nom, QUuid::createUuid(), QByteArray()};
users->removeUser(nom);
if (users->createUser(nom, motDePasse, nom + "@etm.test", nom, droits) == UserManager::UserErrorNoError)
c.jeton = users->authenticate(nom, motDePasse, "droits-rpc");
emit m_mockTcpServer->clientConnected(c.client);
c.authentificationExigee = injectAndWait("JSONRPC.Hello", {}, c.client, c.jeton).toMap()
.value("params").toMap().value("authenticationRequired").toBool();
ouverts << c;
return c;
};
auto appeler = [&](const Compte &c, const QString &methode, const QVariantMap &p = QVariantMap()) {
return injectAndWait("NymeaEnergy." + methode, p, c.client, c.jeton).toMap();
};
auto reussi = [](const QVariantMap &r) { return r.value("status").toString() == QLatin1String("success"); };
auto refuse = [](const QVariantMap &r) {
return r.value("status").toString() == QLatin1String("error")
&& r.value("error").toString() == QLatin1String("Permission denied.");
};
auto erreur = [](const QVariantMap &r) { return r.value("params").toMap().value("energyError").toString(); };
auto lireAdmin = [&](const QString &methode) { return injectAndWait("NymeaEnergy." + methode).toMap().value("params"); };
// ---- Un état CONNU, posé par l'admin du harnais : un tarif, une charge -----------------
const QVariantList hphc{
QVariantMap{{"label", "HC"}, {"priceCtkWh", 15.0},
{"ranges", QVariantList{QVariantMap{{"from", "22:00"}, {"to", "06:00"}}}}},
QVariantMap{{"label", "HP"}, {"priceCtkWh", 25.0},
{"ranges", QVariantList{QVariantMap{{"from", "06:00"}, {"to", "22:00"}}}}}};
QCOMPARE(erreur(injectAndWait("NymeaEnergy.SetTariff", {{"zones", hphc}}).toMap()), QString("EnergyErrorNoError"));
const QUuid relais = addPowerSwitch(1000);
QVERIFY(!relais.isNull());
const QVariantList charges{QVariantMap{
{"id", "droits-relais"}, {"label", "Relais"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true},
{"relays", QVariantList{QVariantMap{{"thingId", relais.toString()}, {"powerW", 1000}}}},
{"minOnS", 60}, {"minOffS", 60}}};
QCOMPARE(erreur(injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", charges}}).toMap()),
QString("EnergyErrorNoError"));
const QVariant tarifAvant = lireAdmin("GetTariff");
const QVariant chargesAvant = lireAdmin("GetLoadConfig");
QVERIFY(tarifAvant.toMap().contains("zones"));
QCOMPARE(chargesAvant.toMap().value("loadConfigs").toList().count(), 1);
// ---- Compte SANS DROIT (scopes []) : il lit, il n'écrit rien ----------------------------
const Compte lecteur = ouvrir("droits-lecteur", Types::PermissionScopeNone);
QVERIFY(!lecteur.jeton.isEmpty());
// Le transport du harnais EXIGE l'authentification. Sans elle, nymea ne vérifie aucun droit
// et rien de ce qui suit ne prouverait quoi que ce soit.
QVERIFY(lecteur.authentificationExigee);
for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetTariff"),
QStringLiteral("GetBatteryLevelConsideration")}) {
const QVariantMap r = appeler(lecteur, m);
QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact)));
}
QCOMPARE(appeler(lecteur, "GetTariff").value("params"), tarifAvant); // la vraie valeur, pas une coquille
QVERIFY(refuse(appeler(lecteur, "SetTariff", {{"zones", QVariantList()}})));
QCOMPARE(lireAdmin("GetTariff"), tarifAvant);
QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}})));
// ---- Compte ControlThings : il commande, il ne configure pas -----------------------------
const Compte usager = ouvrir("droits-usager", Types::PermissionScopeControlThings);
QVERIFY(!usager.jeton.isEmpty());
QVariantMap r = appeler(usager, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}});
QVERIFY2(reussi(r), qPrintable(QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact)));
QCOMPARE(erreur(r), QString("EnergyErrorNoError"));
QCOMPARE(erreur(appeler(usager, "ClearLoadDerogation", {{"loadId", "droits-relais"}})), QString("EnergyErrorNoError"));
QVERIFY(refuse(appeler(usager, "SetLoadConfig", {{"loadConfigs", QVariantList()}})));
QCOMPARE(lireAdmin("GetLoadConfig"), chargesAvant);
// ---- Compte ConfigureThings : il configure, il n'administre pas --------------------------
// nymea n'accepte ConfigureThings qu'avec AccessAllThings (UserManager::validateScopes).
const Compte installateur = ouvrir("droits-installateur",
Types::PermissionScopes(Types::PermissionScopeConfigureThings)
| Types::PermissionScopeAccessAllThings);
QVERIFY(!installateur.jeton.isEmpty());
QCOMPARE(erreur(appeler(installateur, "SetTariff", {{"zones", hphc}})), QString("EnergyErrorNoError"));
QVERIFY(refuse(appeler(installateur, "SetPhasePowerLimit", {{"phasePowerLimit", 16}})));
QVERIFY(refuse(appeler(installateur, "GetOptimizerTrace")));
}

View File

@ -63,6 +63,13 @@ private slots:
//! « for all or a single EV charger », et Q_UNUSED : accepté, sans effet, sans erreur.
void getChargingInfosFiltersOnCharger();
//! [ETM] Le droit PUBLIÉ de chaque méthode NymeaEnergy.* (JSONRPC.Introspect) est comparé à une
//! table écrite ici. Une méthode ajoutée, retirée ou dont le droit change le fait échouer.
void testNymeaEnergyPermissionScopes();
//! [ETM] Les droits MORDENT : comptes réels à droits réduits, sur le transport authentifié du
//! harnais. Lire passe sans droit ; écrire est refusé sans changer l'état.
void testNymeaEnergyPermissionsEnforced();
// Test individual logic
void testEcoMode_data();
void testEcoMode();