diff --git a/tests/auto/charging/testcharging.cpp b/tests/auto/charging/testcharging.cpp index c610c14..33e1611 100644 --- a/tests/auto/charging/testcharging.cpp +++ b/tests/auto/charging/testcharging.cpp @@ -25,6 +25,9 @@ #include "testcharging.h" #include +#include + +#include 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 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 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 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"))); +} diff --git a/tests/auto/charging/testcharging.h b/tests/auto/charging/testcharging.h index c7d9d64..7a2c19c 100644 --- a/tests/auto/charging/testcharging.h +++ b/tests/auto/charging/testcharging.h @@ -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();