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:
parent
8a055eee9c
commit
6c68d17edc
@ -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")));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user