From 651181f84e5a95fe846c1eae4ec00a4c0bd07754 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 23 Oct 2026 11:15:10 +0200 Subject: [PATCH] =?UTF-8?q?Conformit=C3=A9=20:=20forme=20d'error.data=20et?= =?UTF-8?q?=20ordre=20token=20puis=20majeure=20(protocole=201.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/auto/conformance/README.md | 28 +++- tests/auto/conformance/testconformance.cpp | 145 ++++++++++++++++++++- 2 files changed, 163 insertions(+), 10 deletions(-) diff --git a/tests/auto/conformance/README.md b/tests/auto/conformance/README.md index 58ee026..e38cf6f 100644 --- a/tests/auto/conformance/README.md +++ b/tests/auto/conformance/README.md @@ -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 | | `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 | +| `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) | -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 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é. @@ -42,7 +44,8 @@ export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {end for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \ testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \ testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \ - testTheSessionMatchesTheContractOffUtc; do + testTheSessionMatchesTheContractOffUtc testAnInvalidContextIsRefusedWithItsProblems \ + testTheTokenIsCheckedBeforeTheMajor; do systemd-run --user --scope -q -p MemoryMax=2G \ bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \ --bind --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_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 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. @@ -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 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é ; -le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan` -invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut -de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` +le moteur n'envoie de token que si l'installateur en a posé un), l'exhaustivité de la validation du +contexte par l'optimiseur (un seul contexte invalide est envoyé ; il en suffit d'un problème pour +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). diff --git a/tests/auto/conformance/testconformance.cpp b/tests/auto/conformance/testconformance.cpp index 92403de..c829d02 100644 --- a/tests/auto/conformance/testconformance.cpp +++ b/tests/auto/conformance/testconformance.cpp @@ -22,7 +22,11 @@ // 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) ; // 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 // 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 testALineOverTheLimitIsRefused(); void testTheSessionMatchesTheContractOffUtc(); + void testAnInvalidContextIsRefusedWithItsProblems(); + void testTheTokenIsCheckedBeforeTheMajor(); private: void monter(); @@ -431,8 +437,8 @@ private: void sonder(std::unique_ptr &sonde); //! 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; - //! Handshake accepté sur \p sonde, ou échec du test. - void ouvrirSession(Sonde &sonde, int id); + //! Handshake accepté sur \p sonde, ou échec du test ; \p reponse : le `result` reçu, si demandé. + void ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse = nullptr); //! Lance l'optimiseur et y branche le moteur (directement, ou par le relais). void brancher(bool parLeRelais); void cycle(const QDateTime &t); @@ -846,7 +852,7 @@ QByteArray TestOptimizerConformance::handshake(int id, const QString &version, c .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)); QByteArray ligne; @@ -854,8 +860,33 @@ void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id) const QJsonObject m = QJsonDocument::fromJson(ligne).object(); QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(), 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() { // §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(); } +// --- 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; + 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; + 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) #include "testconformance.moc"