SetLoadConfig : le journal du refus ne recopie plus la saisie du client (relecture q7-suite, I-1)

Le refus écrivait, en noquote(), la phrase détaillée du refus : elle cite mode, domaine,
rankOrigin, adaptateur, relays[].thingId, échéance et libellé tels que saisis. Un saut de ligne
dans l'un d'eux fabriquait une fausse ligne de journal (etm72 l'échappait par `<< why`).

Le journal ne porte plus que la phrase rendue depuis le motif (renderFr), comme SetTariff :
cause et endroit, un identifiant seulement s'il est bien formé. Échapper le détail aurait
suffi contre l'injection, pas contre le volume : relays[].thingId n'est pas borné.

La cause système d'un WRITE_FAILED, que seul le détail portait, est désormais écrite par
LoadConfigStore::commit() dans une ligne à part (chemin et erreur du fichier, aucune saisie).

Test : testSetLoadConfigRefusalIsPublished envoie dix refus dont un champ vaut
"x\n[NymeaEnergy] FAUX" et vérifie, message par message (JournalCapture::messages()), que le
refus est journalisé et qu'aucun message n'ajoute de ligne commençant par [NymeaEnergy].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-22 23:22:32 +02:00
parent b793e07787
commit 2ce74892d3
3 changed files with 67 additions and 18 deletions

View File

@ -430,8 +430,14 @@ bool LoadConfigStore::commit(const LoadConfigs &configs, int premierBorne, QStri
const LoadConfigs previous = m_configs;
m_configs = configs;
if (!save(error)) {
QString echec;
if (!save(&echec)) {
m_configs = previous; // rollback mémoire si l'écriture échoue
// La cause système est dite ICI : le refus de SetLoadConfig ne journalise que son motif,
// et WRITE_FAILED ne dit pas pourquoi le disque a refusé. Aucune saisie n'y entre.
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Écriture de la configuration échouée :"
<< filePath() << echec;
if (error) *error = echec;
if (fault)
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("WRITE_FAILED")}};
return false;

View File

@ -1150,45 +1150,44 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap &params)
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap &params)
{
// Même forme que SetTariff : le code d'erreur amont, inchangé, et à côté le MOTIF publiable —
// une cause et un endroit, jamais une phrase (règle 12). Le journal garde la phrase
// française rendue depuis ce motif, suivie du détail du refus.
auto refuser = [this](const QVariantMap &faultParams, const QString &detail) {
// une cause et un endroit, jamais une phrase (règle 12). Le journal ne porte que la phrase
// française rendue depuis ce motif, comme pour SetTariff : la phrase détaillée du refus
// recopie ce que le client a saisi (mode, domaine, relais, libellé…), et un saut de ligne y
// fabriquerait une fausse ligne de journal (relecture q7-suite, I-1).
auto refuser = [this](const QVariantMap &faultParams) {
// 7-d — le refus au moins aussi haut que l'enregistrement.
qCWarning(dcNymeaEnergy()).noquote()
<< QStringLiteral("[NymeaEnergy] SetLoadConfig rejetée : %1 (motif %2)%3")
<< QStringLiteral("[NymeaEnergy] SetLoadConfig rejetée : %1 (motif %2)")
.arg(renderFr({DecisionCode::LoadConfigRefused, faultParams}),
QLatin1String(DecisionCode::LoadConfigRefused),
detail.isEmpty() ? QString() : QStringLiteral(" — ") + detail);
QLatin1String(DecisionCode::LoadConfigRefused));
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
{"fault", QLatin1String(DecisionCode::LoadConfigRefused)},
{"faultParams", faultParams}});
};
if (!m_loadConfigStore)
return refuser({{QStringLiteral("cause"), QStringLiteral("STORE_UNAVAILABLE")}},
QStringLiteral("aucun store configuré"));
return refuser({{QStringLiteral("cause"), QStringLiteral("STORE_UNAVAILABLE")}});
LoadConfigs configs;
foreach (const QVariant &item, params.value("loadConfigs").toList())
configs.append(LoadConfig::fromMap(item.toMap()));
QString why;
QVariantMap faultParams;
// Q7 — les bornes de saisie AVANT tout le reste, réconciliation comprise : ses motifs citent
// l'identifiant et le libellé, qui doivent donc être bornés avant d'entrer au journal.
if (!LoadConfigStore::validateBounds(configs, &why, &faultParams))
return refuser(faultParams, why);
// Q7 — les bornes de saisie AVANT tout le reste, réconciliation comprise : le motif ne cite
// un identifiant que bien formé.
if (!LoadConfigStore::validateBounds(configs, nullptr, &faultParams))
return refuser(faultParams);
// LM-1209-c — la marque de rang se réconcilie ICI, à la frontière CLIENTE, et nulle part
// ailleurs : c'est la seule couche qui sache qu'une écriture vient du dehors. Le moteur,
// lui, pose légitimement "auto" en créant une entrée d'office.
LoadConfigs reconciliees;
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why, &faultParams))
return refuser(faultParams, why);
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, nullptr, &faultParams))
return refuser(faultParams);
configs = reconciliees;
if (!m_loadConfigStore->setConfigs(configs, &why, &faultParams))
return refuser(faultParams, why);
if (!m_loadConfigStore->setConfigs(configs, nullptr, &faultParams))
return refuser(faultParams);
// store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part.
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}

View File

@ -147,6 +147,8 @@ public:
bool contains(const QString &fragment) const { return m_texte.contains(fragment); }
bool contient(QtMsgType type) const { return m_types.contains(type); }
QString texte() const { return m_texte; }
//! Les messages un par un : une fausse ligne de journal ne se voit qu'À L'INTÉRIEUR d'un message.
QStringList messages() const { return m_messages; }
private:
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
@ -154,12 +156,14 @@ private:
// Toutes les captures vivantes reçoivent le message — c'est ce que le chaînage visait.
for (JournalCapture *c : s_pile) {
c->m_texte += msg + QLatin1Char('\n');
c->m_messages.append(msg);
c->m_types.insert(type);
}
if (s_origine)
s_origine(type, ctx, msg); // le message reste visible dans la sortie de test
}
QString m_texte;
QStringList m_messages;
QSet<QtMsgType> m_types;
static QList<JournalCapture *> s_pile;
static QtMessageHandler s_origine;
@ -6247,6 +6251,46 @@ void Simulation::testSetLoadConfigRefusalIsPublished()
QVERIFY2(lire() == avantGet, c.quoi);
QVERIFY2(fichier() == avantDisque, c.quoi);
}
// [relecture q7-suite, I-1] Le journal d'un refus ne recopie RIEN de la saisie. La phrase
// détaillée y partait sans échappement : un saut de ligne glissé dans un champ fabriquait une
// fausse ligne de journal. Chaque champ qu'elle citait, refusé tour à tour.
const QString forge = QStringLiteral("x\n[NymeaEnergy] FAUX");
QVariantMap ballonF = ballon;
ballonF.insert("relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 1000}}});
QVariantMap ballonCF = ballonC;
ballonCF.insert("relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 500}}});
struct Forge { const char *quoi; QVariantList charges; const char *cause; };
const QList<Forge> forges{
{"mode", avec(0, {{"mode", forge}}), "MODE"},
{"domain", avec(0, {{"domain", forge}}), "DOMAIN"},
{"rankOrigin", avec(0, {{"rankOrigin", forge}}), "RANK_ORIGIN"},
{"adapter", avec(0, {{"adapter", forge}}), "ADAPTER"},
{"relays[].thingId", avec(0, {{"relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 1000}},
QVariantMap{{"thingId", forge}, {"powerW", 500}}}}}),
"FIELD_DUPLICATE"},
{"dailyDeadline", avec(0, {{"needs", QVariantMap{{"dailyDeadline", forge}}}}), "DEADLINE"},
{"label (Config … invalide)", avec(0, {{"label", forge}, {"mode", "variable"}}), "MODE"},
{"label (rang en double)", avec(1, {{"label", forge}, {"priority", 1}}), "RANK_DUPLICATE"},
{"label (auto fabriqué)", avec(1, {{"label", forge}, {"rankOrigin", "auto"}}), "RANK_ORIGIN_AUTO"},
{"relais partagé", QVariantList{ballonF, routeurPv, ballonCF}, "THING_SHARED"},
};
for (const Forge &f : forges) {
JournalCapture journal;
const QVariantMap p = poser(f.charges).value("params").toMap();
QVERIFY2(p.value("faultParams").toMap().value("cause").toString() == QLatin1String(f.cause),
qPrintable(QStringLiteral("%1 : %2").arg(f.quoi, QString::fromUtf8(json(p.value("faultParams"))))));
// Non vide : le refus EST journalisé…
QVERIFY2(journal.contains(QStringLiteral("SetLoadConfig rejetée")), f.quoi);
// …et aucun message n'y ajoute une ligne qui se fasse passer pour un message du moteur.
for (const QString &m : journal.messages()) {
const QStringList lignes = m.split(QLatin1Char('\n'));
for (int i = 1; i < lignes.count(); ++i)
QVERIFY2(!lignes.at(i).startsWith(QLatin1String("[NymeaEnergy]"), Qt::CaseInsensitive),
qPrintable(QStringLiteral("%1 : fausse ligne de journal « %2 »").arg(f.quoi, lignes.at(i))));
}
QVERIFY2(lire() == avantGet, f.quoi);
}
#endif
}