Simpl-Resultat/docs/qa-update-cycle.md
le king fu d2ded515fd
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m11s
fix(license): name key-entry errors, keep the edition off the activation token, flag revoked keys
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
  UnknownEdition. validate_with_key returns it (ExpiredSignature maps
  to LicenseExpired), and store_license, read_license and
  validate_license_key reject with the same JSON as the network
  commands. store_license_in and read_license_in take a directory, so
  the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
  resolve_license_entitlements no longer takes the activation token,
  and current_entitlements reads only license.key
  (entitlements_from_dir). A token that had expired (2-year server
  lifetime) or came from another machine (restored data) used to drop
  a valid key to free, while a key copied without its token kept its
  edition. get_activation_status and persist_activation_token keep the
  strict token validation, and the key keeps its strict validation
  (CWE-613).

Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
  static render reaches it) names a refused key, through
  parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
  whole session, because the card unmounts when the user leaves
  Settings > Users. Only a different key clears it. Card-level
  message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
  deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
  beyond 50 years (licenseExpiry.ts, for the 100-year keys of
  maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
  the price authFailed message points to activating the machine.

Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).

Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.

Fixes #347
Fixes #360
Refs #363

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:39:01 -04:00

10 KiB

QA — Cycle de mise à jour automatique

Checklist manuelle pour valider qu'une release est réellement installable par la mise à jour automatique, et pas seulement téléchargeable. À dérouler après chaque release qui touche tauri-plugin-updater, ses dépendances (reqwest/rustls-webpki, tar), release.yml, ou la configuration updater de tauri.conf.json.

Elle existe parce que la CI ne peut rien prouver ici : cargo check et cargo test prouvent que le chemin de mise à jour compile, jamais qu'il fonctionne. Une régression s'y manifeste chez l'utilisateur, au moment de la mise à jour — et la mise à jour automatique étant une fonctionnalité Base+, ce sont des utilisateurs payants.

Cette checklist tourne après que la release est publiée. release.yml a déjà poussé latest.json vers le registre de paquets, qui est l'endpoint configuré dans tauri.conf.json. Tout utilisateur Base+ qui clique sur « Vérifier les mises à jour » reçoit donc déjà la nouvelle version pendant que vous déroulez ceci. En cas d'échec, voir « Si ça casse » en bas — et lire cette section avant de commencer, pas pendant l'incident.


Prérequis

Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode d'échec où la checklist se coche « OK » sans avoir rien vérifié.

  • Une machine Windows (hôte ou VM) et une machine Linux de bureau. Les deux flux sont réellement différents, ils ne se substituent pas l'un à l'autre.

  • Sur la machine Linux, un agent polkit qui tourne. L'installation du .deb passe par pkexec ; sans agent, l'app retombe en cascade sur zenity/kdialog puis sur un sudo dans un terminal inexistant, et paraît figée au lieu d'échouer — sur l'écran de téléchargement, pas sur readyToInstall (voir §2, c'est le piège qui produit le mauvais rapport de bug). Noter plus bas quelle invite est réellement apparue : si ce n'est pas polkit, c'est un chemin de repli qui a été validé, pas le chemin nominal.

  • La version précédente installée sur chaque machine, depuis les artefacts de la release précédente (.deb et -setup.exe attachés à la release Forgejo). Ne pas tester sur la machine qui vient de builder la nouvelle version.

    Sans ça, check() compare release.version > version_courante, trouve faux, et l'UI affiche « à jour ». La checklist se coche alors comme « rien à mettre à jour » — soit exactement le saut silencieux qu'elle est censée éliminer, avec en prime une trace écrite affirmant qu'elle est passée.

  • Une clé de licence Base ou Premium valide sur chaque machine. La mise à jour automatique est verrouillée par édition : useUpdater appelle check_entitlement("auto-update") et bascule en notEntitled avant même d'appeler check(). Une installation Gratuite ne télécharge jamais rien, et les builds de release n'ont pas de dev-override.

  • Sur chaque machine, supprimer tout activation.token provenant d'une autre machine (VM clonée, snapshot restauré) dans le répertoire de données de l'app. Une license.key seule fonctionne — l'absence de token est un état de pré-activation toléré. Dans les versions antérieures à #347 (v0.15.0 et avant), un token émis pour un autre machine_id fait retomber l'édition en Gratuite, et le testeur voit une carte « non éligible » anodine plutôt qu'un échec ; depuis #347, il ne touche plus l'édition et la carte Licence affiche seulement « Non activée ». La suppression reste nécessaire tant que la version de départ du cycle est antérieure à #347.


Ce que ce test prouve — et ce qu'il ne prouve pas

À écrire explicitement, parce que la tentation est de croire qu'un cycle réussi couvre tout le chemin de mise à jour.

Prouvé sur les deux cibles : le téléchargement TLS (donc rustls-webpki), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.

Non prouvé : l'extraction d'archive, donc tar. Dans tauri-plugin-updater, tar n'est atteint que par install_appimage et la branche macOS .app.tar.gz. Aucun de nos bundles ne passe par là — le .deb fait pkexec dpkg -i, le NSIS écrit le .exe dans un répertoire temporaire et le lance. Tant qu'on ne livre pas d'AppImage, le chemin tar reste mort et aucune checklist manuelle ne le couvrira.


1. Windows (NSIS)

Point d'entrée : Paramètres → Systèmes → carte Mises à jour → « Vérifier les mises à jour ». Il n'y a aucune vérification automatique dans l'app : les quatre déclencheurs sont manuels — ce bouton, l'icône rafraîchir de l'état upToDate, le « réessayer » de l'état error, et celui de la page d'erreur, qui détecte seulement et n'offre aucun chemin de téléchargement.

  • La carte passe en checking, puis affiche available avec le numéro de la nouvelle version et les notes extraites du CHANGELOG.

  • Cliquer sur télécharger → état downloading, la progression avance (elle vient de contentLength, donc un serveur qui ne le renvoie pas se voit ici).

  • L'installeur NSIS s'ouvre et l'application se ferme d'elle-même.

    Attendu, et propre à Windows : downloadAndInstall ne rend jamais la main — le processus appelle exit(0) et laisse l'installeur prendre le relais. Les états readyToInstall et installing ne s'affichent jamais sur cette cible. Ne pas les attendre, ne pas les cocher.

  • Terminer l'installation, relancer l'app, vérifier la version dans Paramètres → Systèmes.

  • Re-cliquer sur « Vérifier les mises à jour » → upToDate.

2. Linux (.deb)

  • Même point d'entrée, mêmes états jusqu'à downloading.

  • Pendant que la carte affiche encore « Téléchargement en cours… », une invite d'authentification apparaît. Noter laquelle : polkit (nominal), zenity/kdialog, ou rien du tout (repli sudo → l'app reste figée sur l'écran de téléchargement).

    Invite observée : ________________

    C'est bien le moment nominal, et c'est contre-intuitif : downloadAndInstall télécharge et installe dans le même appel (updater.rs:723-729), et l'événement Finished est un no-op côté UI (useUpdater.ts:119-121). L'écran reste donc sur downloading pendant tout le dpkg -i. Un testeur qui guette l'invite après readyToInstall ne la verra jamais arriver au bon moment : il rapportera « le téléchargement bloque » là où le vrai diagnostic est « pas d'agent polkit ».

  • Authentifier → l'app reste vivante et passe en readyToInstall (« Mise à jour prête à installer » + bouton « Installer et redémarrer »).

    À ce stade le .deb est déjà installé sur le disque : installAndRestart ne fait que relaunch(). Le libellé du bouton ment sur cette cible — qui s'arrête là croit que rien n'a été installé, et qui voit la version changer après le clic attribue l'installation au clic.

  • Cliquer → état installing, l'app redémarre.

  • Vérifier la version, re-vérifier → upToDate.

3. RPM — cassé, connu

  • Ne pas tester : ce chemin est cassé, pas seulement non vérifié.

latest.json ne porte qu'une entrée linux-x86_64, construite à partir du .deb (release.yml:104-122). Une installation rpm reçoit donc des octets deb, que Installer::Rpm rejette après vérification de la signature magique du payload. Les artefacts .rpm continuent pourtant d'être publiés comme assets de release. Suivi en #320.


Si ça casse

L'ordre compte, et la première action ne répare pas ce qu'on croit.

  1. Republier le latest.json précédent sur generic/simpl-resultat/latest (DELETE puis PUT, avec le PACKAGE_TOKEN qu'utilise release.yml). Le corps est récupérable depuis les assets de la release précédente — sa copie dans le registre a été détruite par le DELETE que release.yml fait avant chaque envoi.

    Les deux verbes ne visent pas le même préfixe d'API, et release.yml:217-219 porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :

    DELETE  {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest
    PUT     {serveur}/api/packages/{owner}/generic/simpl-resultat/latest
    

    Rejouer les deux sur la même URL donne un 404 sur l'un des deux, au moment où on peut le moins se permettre de le débugger.

  2. Comprendre ce que ça arrête, et ce que ça n'arrête pas. check() compare strictement release.version > version_courante. Republier vN-1 stoppe la propagation vers ceux qui n'ont pas encore cliqué. Les utilisateurs déjà passés à la vN cassée ne se verront jamais proposer vN-1 : ils sont bloqués dessus.

  3. Le vrai correctif est donc une vN+1, pas un retour en arrière. Le republication de vN-1 n'achète que du temps — et si elle reste en place, elle prive aussi les utilisateurs restés en vN-1 de tout chemin de mise à jour.


Trace

  • Consigner le résultat dans le corps de la release Forgejo, en section « QA — cycle de mise à jour automatique » ajoutée sous les notes : cibles déroulées, versions de départ et d'arrivée, invite d'authentification observée sous Linux. Forgejo n'a pas de commentaires sur les releases, et éditer le corps ne touche pas latest.json, déjà publié dans le registre de paquets. Le corps est remplacé en entier par l'API — relire, concaténer, puis :

    # id : GET /releases/tags/vX.Y.Z → .id ; payload.json = {"body": "<notes existantes + section QA>"}
    curl -X PATCH -H "Authorization: token $(cat ~/.forgejo-token)" -H "Content-Type: application/json" \
      --data-binary @payload.json "https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat/releases/{id}"
    
  • Si la checklist n'a pas été déroulée, l'écrire explicitement avec la raison, plutôt que de laisser le silence. Une étape non tracée est indiscernable d'une étape passée — c'est le mode d'échec que cette page combat.