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:
parent
b793e07787
commit
2ce74892d3
@ -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;
|
||||
|
||||
@ -1150,45 +1150,44 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms)
|
||||
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms)
|
||||
{
|
||||
// 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)}});
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user