Conformité : forme d'error.data et ordre token puis majeure (protocole 1.3)

Deux fonctions, par la connexion de sonde :
- testAnInvalidContextIsRefusedWithItsProblems : un GetPlan de contexte invalide
  ({"timestamp": "valeur-sonde:7Qz9"}) → -32602 à son id, connexion gardée ; la session de sonde
  jugée par le validateur publié contre le contrat EMBARQUÉ (--responses-only) : forme exigée
  d'un optimiseur qui s'annonce 1.3 ou plus, AVERTISSEMENT seulement pour un 1.2 ; le contrôle
  doit avoir eu lieu (GetPlan.error.data compté) ; garde indépendante : la valeur reçue n'est
  pas dans error.data, quelle que soit la version.
- testTheTokenIsCheckedBeforeTheMajor : token faux et autre majeure → -32001 puis fermeture ;
  -32602 d'un optimiseur 1.2 averti ; SKIP sans token attendu.

Éprouvé contre l'optimiseur de référence : 1.3 conforme (11 verts + 1 SKIP sans token, 3/3 avec
token) ; old-data annoncé 1.3 refusé, annoncé 1.2 averti ; echo-value refusé en 1.3 comme en 1.2 ;
major-first refusé en 1.3, averti en 1.2 ; référence annoncée 1.2 verte sans avertissement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-23 11:15:10 +02:00
parent d961b73ed7
commit 651181f84e
2 changed files with 163 additions and 10 deletions

View File

@ -15,11 +15,13 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio
| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil | | `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil |
| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée | | `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée |
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue | | `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
| `testAnInvalidContextIsRefusedWithItsProblems` | protocole 1.3, §7 : un `GetPlan` dont le contexte est invalide (`{"timestamp": "valeur-sonde:7Qz9"}`) → `-32602` à son `id`, connexion **gardée** ; `error.data` jugé par le validateur publié contre le contrat embarqué (`--responses-only`) : forme `{"problems":[{path, problem}]}` **exigée** d'un optimiseur qui s'annonce 1.3 ou plus, **avertie** (`AVERTISSEMENT`, test vert) d'un optimiseur 1.2 ; la valeur reçue recopiée dans `error.data` est **refusée** quelle que soit la version (règle de 1.2) |
| `testTheTokenIsCheckedBeforeTheMajor` | protocole 1.3, §3 : token faux **et** autre majeure dans le même handshake → `-32001` (le token d'abord), puis l'optimiseur ferme. Exigé à partir de 1.3 ; `-32602` d'un optimiseur 1.2 est averti. **SKIP** sans `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` : sans token attendu, l'ordre ne s'éprouve pas |
| `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) | | `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) |
Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**. Les lignes `testEveryPlanIsAccepted` et `testTheSafetyLoopStaysOnTop` valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce Les fonctions 6 à 9, 11 et 12 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce
que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles
utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` → utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` →
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité. `-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
@ -42,7 +44,8 @@ export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {end
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \ for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \ testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \ testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \
testTheSessionMatchesTheContractOffUtc; do testTheSessionMatchesTheContractOffUtc testAnInvalidContextIsRefusedWithItsProblems \
testTheTokenIsCheckedBeforeTheMajor; do
systemd-run --user --scope -q -p MemoryMax=2G \ systemd-run --user --scope -q -p MemoryMax=2G \
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \ bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn" --bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
@ -61,6 +64,19 @@ commande vit dans le même réseau privé (la boucle locale y fonctionne, `tcp:/
| `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence | | `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence |
| `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer | | `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer |
**Un optimiseur 1.2 face aux règles 1.3.** Une mineure ajoute des règles (protocole §9) : la suite
lit la version que l'optimiseur annonce au handshake et n'exige la forme d'`error.data` et l'ordre
token puis majeure qu'à partir de 1.3. En deçà, l'écart est écrit en `AVERTISSEMENT` et la fonction
reste verte — l'optimiseur fonctionne avec ce moteur, il s'aligne avant de s'annoncer 1.3. Pour
éprouver l'ordre, donner un token (`ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE`, et le même fichier à
l'optimiseur) :
```sh
printf 'jeton-de-conformite-0123456789' > /chemin/token
export ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE=/chemin/token
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint} --token-file /chemin/token'
```
L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme
la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé), la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé),
cycles non appliqués avec leur `fallbackCode`, latences. cycles non appliqués avec leur `fallbackCode`, latences.
@ -95,7 +111,7 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa
forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ; forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ;
le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan` le moteur n'envoie de token que si l'installateur en a posé un), l'exhaustivité de la validation du
invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut contexte par l'optimiseur (un seul contexte invalide est envoyé ; il en suffit d'un problème pour
de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` juger la forme), le comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G). (prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).

View File

@ -22,7 +22,11 @@
// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot // autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot
// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ; // traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ;
// 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure // 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure
// locale comme en production, à travers le passage à l'heure d'hiver. // locale comme en production, à travers le passage à l'heure d'hiver ;
// 11 et 12 (protocole 1.3), par la sonde : un contexte GetPlan invalide → -32602 dont `error.data`
// a la forme normée, jugée par le validateur publié — exigée d'un optimiseur qui s'annonce 1.3
// ou plus, avertie pour un 1.2 ; la valeur reçue n'y est jamais recopiée. Token faux ET autre
// majeure dans le même handshake → -32001 (le token d'abord), puis fermeture.
// //
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur // Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque // qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
@ -420,6 +424,8 @@ private slots:
void testABatchIsRefused(); void testABatchIsRefused();
void testALineOverTheLimitIsRefused(); void testALineOverTheLimitIsRefused();
void testTheSessionMatchesTheContractOffUtc(); void testTheSessionMatchesTheContractOffUtc();
void testAnInvalidContextIsRefusedWithItsProblems();
void testTheTokenIsCheckedBeforeTheMajor();
private: private:
void monter(); void monter();
@ -431,8 +437,8 @@ private:
void sonder(std::unique_ptr<Sonde> &sonde); void sonder(std::unique_ptr<Sonde> &sonde);
//! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide. //! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide.
QByteArray handshake(int id, const QString &version, const QString &token) const; QByteArray handshake(int id, const QString &version, const QString &token) const;
//! Handshake accepté sur \p sonde, ou échec du test. //! Handshake accepté sur \p sonde, ou échec du test ; \p reponse : le `result` reçu, si demandé.
void ouvrirSession(Sonde &sonde, int id); void ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse = nullptr);
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais). //! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
void brancher(bool parLeRelais); void brancher(bool parLeRelais);
void cycle(const QDateTime &t); void cycle(const QDateTime &t);
@ -846,7 +852,7 @@ QByteArray TestOptimizerConformance::handshake(int id, const QString &version, c
.toJson(QJsonDocument::Compact); .toJson(QJsonDocument::Compact);
} }
void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id) void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse)
{ {
sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token)); sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token));
QByteArray ligne; QByteArray ligne;
@ -854,8 +860,33 @@ void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id)
const QJsonObject m = QJsonDocument::fromJson(ligne).object(); const QJsonObject m = QJsonDocument::fromJson(ligne).object();
QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(), QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(),
qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300)))); qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300))));
if (reponse)
*reponse = m;
} }
namespace {
//! « 1.3 » → 103 ; illisible → -1. Seules majeure et mineure comptent (§9).
int versionComparable(const QString &v)
{
bool okMajeure = false, okMineure = true;
const int majeure = v.section('.', 0, 0).toInt(&okMajeure);
const int mineure = v.contains('.') ? v.section('.', 1, 1).toInt(&okMineure) : 0;
return okMajeure && okMineure ? majeure * 100 + mineure : -1;
}
//! Version à partir de laquelle les règles 1.3 sont EXIGÉES ; en deçà, un écart est averti.
constexpr int kExigeDepuis = 103;
//! Vrai si \p annoncee est LISIBLE et antérieure à 1.3 — une version illisible est jugée 1.3.
bool avantRegles13(const QString &annoncee)
{
const int v = versionComparable(annoncee);
return v >= 0 && v < kExigeDepuis;
}
} // namespace
void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused() void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused()
{ {
// §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le // §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le
@ -983,5 +1014,111 @@ void TestOptimizerConformance::testTheSessionMatchesTheContractOffUtc()
jugerLaSession(); jugerLaSession();
} }
// --- 11 et 12 : protocole 1.3, par une connexion de sonde --------------------------------------------
void TestOptimizerConformance::testAnInvalidContextIsRefusedWithItsProblems()
{
// §7 (1.2) : un contexte GetPlan invalide → -32602, connexion gardée. §7 (1.3) : `error.data`
// = {"problems":[{path, problem}]}, jamais la valeur reçue. La forme est jugée par le VALIDATEUR
// PUBLIÉ, sur la session de sonde (--responses-only : la requête est fautive à dessein) et
// contre le contrat EMBARQUÉ ; c'est lui qui exige ou avertit selon la version que
// l'optimiseur a annoncée. Le contexte porte une valeur qu'aucun chemin conforme ne peut
// contenir : si elle revient dans `error.data`, c'est une recopie.
static const QString marqueur = QStringLiteral("valeur-sonde:7Qz9");
std::unique_ptr<Sonde> sonde;
sonder(sonde);
if (QTest::currentTestFailed())
return;
QJsonObject hsReponse;
ouvrirSession(*sonde, 1, &hsReponse);
if (QTest::currentTestFailed())
return;
const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString();
const QJsonObject getPlan{{"jsonrpc", "2.0"}, {"id", 2}, {"method", "GetPlan"},
{"params", QJsonObject{{"timestamp", marqueur}}}};
sonde->envoyer(QJsonDocument(getPlan).toJson(QJsonDocument::Compact));
QByteArray ligne;
QVERIFY2(sonde->recevoir(&ligne), "contexte invalide : aucune réponse (ou connexion fermée)");
const QString e = verifierErreur(ligne, 2, -32602);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
const QJsonObject reponse = QJsonDocument::fromJson(ligne).object();
// Garde indépendante du validateur : la valeur reçue n'est nulle part dans `error.data`.
const QJsonValue data = reponse.value("error").toObject().value("data");
const QByteArray dataTexte = QJsonDocument(QJsonArray{data}).toJson(QJsonDocument::Compact);
QVERIFY2(!dataTexte.contains(marqueur.toUtf8()),
"error.data recopie la valeur reçue (params.timestamp) — jamais la valeur reçue (§7)");
// La session de sonde, jugée par le validateur publié. Le token y devient des astérisques.
QJsonObject hsParams = QJsonDocument::fromJson(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion),
m_token)).object();
if (!m_token.isEmpty()) {
QJsonObject p = hsParams.value("params").toObject();
p.insert("token", QString(m_token.size(), QLatin1Char('*')));
hsParams.insert("params", p);
}
QTemporaryDir dir;
QVERIFY(dir.isValid());
const Contrat::Verdict v = Contrat::juger({hsParams, hsReponse, getPlan, reponse},
Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()),
{QStringLiteral("--responses-only")});
QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("optimiseur en %1 — error.data NON CONFORME au contrat :\n")
.arg(annoncee.left(16)) + v.sortie.right(4000)));
// Le contrôle a bien eu lieu : un validateur qui ne trouverait plus la forme dans le contrat
// passerait au vert sans rien juger.
QVERIFY2(v.compte("GetPlan.error.data") == 1, qUtf8Printable(v.sortie.right(1000)));
const int avertissements = v.resume.value("avertissements").toInt();
for (const QString &l : v.sortie.split('\n'))
if (l.startsWith(QStringLiteral("AVERTISSEMENT")))
qWarning().noquote() << "[Conformité]" << l;
// Un avertissement n'est permis qu'à un optimiseur d'avant 1.3 : au-delà, c'est un écart.
QVERIFY2(avertissements == 0 || avantRegles13(annoncee), qUtf8Printable(v.sortie.right(1000)));
qInfo().noquote() << "[Conformité] contexte invalide : -32602, optimiseur en" << annoncee
<< (avertissements ? QStringLiteral("— forme d'error.data AVERTIE (%1), à aligner avant 1.3").arg(avertissements)
: QStringLiteral("— forme d'error.data conforme"));
// Connexion gardée : la sonde peut encore parler.
sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})");
QVERIFY2(sonde->recevoir(&ligne), "après le contexte invalide : aucune réponse (ou connexion fermée)");
const QString e2 = verifierErreur(ligne, 3, -32601);
QVERIFY2(e2.isEmpty(), qUtf8Printable(e2));
}
void TestOptimizerConformance::testTheTokenIsCheckedBeforeTheMajor()
{
// §3 (1.3) : token faux ET autre majeure dans le même handshake → -32001, le token d'abord,
// puis l'optimiseur ferme. Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2
// qui juge la majeure d'abord (-32602) est averti. Sans token attendu, rien à éprouver.
std::unique_ptr<Sonde> sonde;
sonder(sonde);
if (QTest::currentTestFailed())
return;
if (m_token.isEmpty())
QSKIP("aucun token attendu (ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE) : ordre token puis majeure non éprouvé");
QJsonObject hsReponse;
ouvrirSession(*sonde, 1, &hsReponse); // la version annoncée, sur une session acceptée
if (QTest::currentTestFailed())
return;
const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString();
const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0";
Sonde faux(endpoint());
QVERIFY(faux.ouverte());
faux.envoyer(handshake(1, autreMajeure, m_token + QStringLiteral("-faux")));
QByteArray ligne;
QVERIFY2(faux.recevoir(&ligne), "token faux et autre majeure : aucune réponse avant la fermeture");
const QString e = verifierErreur(ligne, 1, -32001);
if (!e.isEmpty()) {
// La majeure jugée d'abord : -32602 (règle 1.2), un écart d'ordre seulement.
const bool majeureDabord = verifierErreur(ligne, 1, -32602).isEmpty();
QVERIFY2(majeureDabord && avantRegles13(annoncee),
qUtf8Printable(QStringLiteral("optimiseur en %1 : ").arg(annoncee.left(16)) + e));
qWarning().noquote() << "[Conformité] AVERTISSEMENT optimiseur en" << annoncee.left(16)
<< ": token faux et autre majeure → -32602, la majeure jugée avant le token —"
" -32001 exigé à partir de 1.3, à aligner avant de s'annoncer 1.3";
} else {
qInfo().noquote() << "[Conformité] token faux et autre majeure : -32001, le token d'abord (optimiseur en"
<< annoncee.left(16) + ")";
}
QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé : l'optimiseur n'a pas fermé la connexion en 5 s");
}
QTEST_MAIN(TestOptimizerConformance) QTEST_MAIN(TestOptimizerConformance)
#include "testconformance.moc" #include "testconformance.moc"