fix(license): call /v1 routes with product, name server refusals, localize purchase URL #359

Merged
maximus merged 4 commits from issue-270-licence-v1-errors into main 2026-09-27 13:16:02 +00:00
Owner

Fixes #270

Les trois commandes réseau de licence appelaient les chemins historiques /licenses/* (un 308 à chaque appel, produit deviné du préfixe SR-) et lisaient mal l'enveloppe d'erreur /v1 : tout refus s'affichait « Activation failed », une clé révoquée apparaissait comme « Aucune machine activée », et le bouton « Acheter » menait à un 404. Première PR de la pile planned-2026-09-24-paiements (#270 → #346 → #347).

Ce qui change

Rust (license_commands.rs)

  • POST /v1/licenses/{activate,deactivate,verify} avec "product":"simpl-resultat" ; la clé est rognée dans les fonctions *_inner, pas seulement dans la commande.
  • Enum LicenseError, sérialisé en JSON {code} dans le Err comme FetchPriceError (13 variantes, rate_limit + retry_after_s, server + status). Seul error.code de l'enveloppe décide : un 404 est license_not_found ou no_active_activation, jamais une route absente.
  • Le corps d'un non-2xx est lu sans exiger de JSON : une page HTML 502 donne server, pas invalid_response.
  • verify répond 200 dans tous les cas métier : {valid:false, reason} devient license_not_found / license_revoked / license_expired au lieu d'une liste vide ; une réponse sans valid booléen ou sans machines est invalid_response.
  • activate_inner, deactivate_inner, verify_inner sans AppHandle ; les commandes Tauri gardent Result<_, String>. Les étapes locales ont leur code : clé absente no_license_stored, fichier storage, machine_id ou clé publique internal, jeton refusé localement invalid_response.

TS

  • licenseService.ts : parseLicenseError (calqué sur parseRustError, repli unique unknown, 60 s par défaut pour rate_limit), LICENSE_ERROR_I18N_MAP, wrappers typés { ok, value } | { ok, error }.
  • Nouveau hook useLicenseMachines à reducer exporté : erreur d'activation distincte de l'erreur de liste, aucun chargement de liste ne l'efface ; machine_limit_reached ouvre la liste (D2) ; une désactivation retire la machine localement sans relancer verify ; refresh() du LicenseContext suit chaque activation ou désactivation réussie ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet (StrictMode).
  • LicenseCard.tsx consomme le hook ; « Aucune machine activée » seulement pour une liste chargée, vide et sans erreur ; tous les boutons Désactiver sont désactivés pendant une désactivation.
  • Tous les boutons Désactiver sont inactifs pendant une désactivation ou un rechargement de la liste (selectCanDeactivate, depuis a051b96) ; le bouton Activer l'est pendant une activation, un rechargement ou une désactivation (selectCanActivate, depuis b8e67c3 et 0c9b8f8).
  • src/shared/purchaseUrl.ts : /{fr|en}/produits/simpl-resultat, avec un commentaire de contrat qui renvoie à maximus/la-compagnie-maximus#153.
  • 14 messages license.errors.* FR/EN au vouvoiement ; le délai d'attente utilise les formes plurielles d'i18next (rateLimit_one / rateLimit_other).

Docs : CHANGELOG FR/EN (Fixed / Corrigé), docs/architecture.md (commandes license_commands.rs + contrat d'erreur, tableau des hooks, ligne licenseService.ts).

Écart assumé par rapport à l'issue

Désactivation idempotente : no_active_activation (place déjà libre côté serveur) vaut succès dans la commande, qui retire aussi le jeton local s'il s'agit de cette machine. deactivate_inner renvoie toujours le code (testé) ; la décision vit dans deactivate_idempotent, sans AppHandle, testée contre mockito (depuis a051b96). Seule la ligne de deactivate_machine_impl qui l'appelle reste hors test, car elle dépend d'AppHandle. La clé license.errors.noActiveActivation reste dans la table. Relevé par le vérificateur de plan, consigné au CHANGELOG et dans l'architecture.

Tests

Suite Avant Après
cargo test 111 128 (+17 license_server_tests)
vitest 1181 1246 (+65 : licenseService 22, useLicenseMachines 39, purchaseUrl 4)
npm run build vert vert

Couverture des refus : table pure de error_from_response (les 6 codes de l'enveloppe, 429 avec et sans retry_after, code inconnu, 502 sans corps), table de machines_from_verify (3 raisons, raison inconnue, réponses malformées), puis mockito au niveau HTTP : chemin + product + clé rognée, 404/403/409, 502 et 503 à corps HTML, 429, verify révoqué, régression sur un serveur qui ne déclare que les routes /v1, serveur injoignable.

Test de couverture i18n : chaque valeur de la table résout vers une chaîne non vide en FR et en EN, et rateLimit garde {{seconds}} (sans jsdom, rien d'autre ne verrait une clé manquante).

Preuve du rouge

Chaque défaut d'origine, réintroduit sur l'arbre final puis restauré depuis une copie (diffstat identique avant et après) :

Défaut réintroduit Tests rouges
chemin legacy /licenses/activate 5 / 15
verify sans product 2 / 15
enveloppe lue comme une chaîne (error.as_str()) 3 / 15
verify valid:false lu comme une liste vide 2 / 15
lecture stricte du corps d'un non-2xx 2 / 15
clé non rognée dans activate_inner 1 / 15
LIST_LOAD_START efface l'erreur d'activation 4 / 31
verify relancé après une désactivation 2 / 31
état vide malgré une erreur de liste 1 / 31
pas de refresh() après une activation 3 / 31
ancienne URL d'achat 4 / 4

Hors périmètre

  • Réactiver une machine déjà désactivée répond 500 côté serveur (contrainte uq_license_machine sans condition sur is_active) → maximus/maximus-api#70, prérequis du go-live. Ce n'est pas une régression : l'ancien client recevait le même 500.

  • Erreurs de saisie de clé, clé révoquée persistante à l'écran, compteur de machines, dates, jeton d'activation expiré → #347.

  • CTA « Obtenir Base », carte Mises à jour, page d'erreur, texte du compte → #346 (la ligne UpsellGate de architecture.md qui cite encore #270 y est réécrite).

  • Hachage de l'identifiant machine → #350, avant le go-live.

🤖 Generated with Claude Code

Fixes #270 Les trois commandes réseau de licence appelaient les chemins historiques `/licenses/*` (un 308 à chaque appel, produit deviné du préfixe `SR-`) et lisaient mal l'enveloppe d'erreur `/v1` : tout refus s'affichait « Activation failed », une clé révoquée apparaissait comme « Aucune machine activée », et le bouton « Acheter » menait à un 404. Première PR de la pile `planned-2026-09-24-paiements` (#270 → #346 → #347). ## Ce qui change **Rust (`license_commands.rs`)** - `POST /v1/licenses/{activate,deactivate,verify}` avec `"product":"simpl-resultat"` ; la clé est rognée dans les fonctions `*_inner`, pas seulement dans la commande. - Enum `LicenseError`, sérialisé en JSON `{code}` dans le `Err` comme `FetchPriceError` (13 variantes, `rate_limit` + `retry_after_s`, `server` + `status`). Seul `error.code` de l'enveloppe décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. - Le corps d'un non-2xx est lu sans exiger de JSON : une page HTML 502 donne `server`, pas `invalid_response`. - verify répond 200 dans tous les cas métier : `{valid:false, reason}` devient `license_not_found` / `license_revoked` / `license_expired` au lieu d'une liste vide ; une réponse sans `valid` booléen ou sans `machines` est `invalid_response`. - `activate_inner`, `deactivate_inner`, `verify_inner` sans `AppHandle` ; les commandes Tauri gardent `Result<_, String>`. Les étapes locales ont leur code : clé absente `no_license_stored`, fichier `storage`, `machine_id` ou clé publique `internal`, jeton refusé localement `invalid_response`. **TS** - `licenseService.ts` : `parseLicenseError` (calqué sur `parseRustError`, repli unique `unknown`, 60 s par défaut pour `rate_limit`), `LICENSE_ERROR_I18N_MAP`, wrappers typés `{ ok, value } | { ok, error }`. - Nouveau hook `useLicenseMachines` à reducer exporté : erreur d'activation distincte de l'erreur de liste, aucun chargement de liste ne l'efface ; `machine_limit_reached` ouvre la liste (D2) ; une désactivation retire la machine localement sans relancer verify ; `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet (StrictMode). - `LicenseCard.tsx` consomme le hook ; « Aucune machine activée » seulement pour une liste chargée, vide et sans erreur ; tous les boutons Désactiver sont désactivés pendant une désactivation. - Tous les boutons Désactiver sont inactifs pendant une désactivation **ou un rechargement de la liste** (`selectCanDeactivate`, depuis `a051b96`) ; le bouton Activer l'est pendant une activation, un rechargement ou une désactivation (`selectCanActivate`, depuis `b8e67c3` et `0c9b8f8`). - `src/shared/purchaseUrl.ts` : `/{fr|en}/produits/simpl-resultat`, avec un commentaire de contrat qui renvoie à maximus/la-compagnie-maximus#153. - 14 messages `license.errors.*` FR/EN au vouvoiement ; le délai d'attente utilise les formes plurielles d'i18next (`rateLimit_one` / `rateLimit_other`). **Docs** : CHANGELOG FR/EN (Fixed / Corrigé), `docs/architecture.md` (commandes `license_commands.rs` + contrat d'erreur, tableau des hooks, ligne `licenseService.ts`). ## Écart assumé par rapport à l'issue **Désactivation idempotente** : `no_active_activation` (place déjà libre côté serveur) vaut succès dans la commande, qui retire aussi le jeton local s'il s'agit de cette machine. `deactivate_inner` renvoie toujours le code (testé) ; la décision vit dans `deactivate_idempotent`, sans `AppHandle`, testée contre mockito (depuis `a051b96`). Seule la ligne de `deactivate_machine_impl` qui l'appelle reste hors test, car elle dépend d'`AppHandle`. La clé `license.errors.noActiveActivation` reste dans la table. Relevé par le vérificateur de plan, consigné au CHANGELOG et dans l'architecture. ## Tests | Suite | Avant | Après | |---|---|---| | `cargo test` | 111 | **128** (+17 `license_server_tests`) | | vitest | 1181 | **1246** (+65 : `licenseService` 22, `useLicenseMachines` 39, `purchaseUrl` 4) | | `npm run build` | vert | vert | Couverture des refus : table pure de `error_from_response` (les 6 codes de l'enveloppe, 429 avec et sans `retry_after`, code inconnu, 502 sans corps), table de `machines_from_verify` (3 raisons, raison inconnue, réponses malformées), puis mockito au niveau HTTP : chemin + `product` + clé rognée, 404/403/409, 502 et 503 à corps HTML, 429, verify révoqué, régression sur un serveur qui ne déclare que les routes `/v1`, serveur injoignable. Test de couverture i18n : chaque valeur de la table résout vers une chaîne non vide en FR et en EN, et `rateLimit` garde `{{seconds}}` (sans jsdom, rien d'autre ne verrait une clé manquante). ## Preuve du rouge Chaque défaut d'origine, réintroduit sur l'arbre final puis restauré depuis une copie (diffstat identique avant et après) : | Défaut réintroduit | Tests rouges | |---|---| | chemin legacy `/licenses/activate` | 5 / 15 | | verify sans `product` | 2 / 15 | | enveloppe lue comme une chaîne (`error.as_str()`) | 3 / 15 | | verify `valid:false` lu comme une liste vide | 2 / 15 | | lecture stricte du corps d'un non-2xx | 2 / 15 | | clé non rognée dans `activate_inner` | 1 / 15 | | `LIST_LOAD_START` efface l'erreur d'activation | 4 / 31 | | verify relancé après une désactivation | 2 / 31 | | état vide malgré une erreur de liste | 1 / 31 | | pas de `refresh()` après une activation | 3 / 31 | | ancienne URL d'achat | 4 / 4 | ## Hors périmètre - Réactiver une machine déjà désactivée répond 500 côté serveur (contrainte `uq_license_machine` sans condition sur `is_active`) → maximus/maximus-api#70, prérequis du go-live. Ce n'est pas une régression : l'ancien client recevait le même 500. - Erreurs de saisie de clé, clé révoquée persistante à l'écran, compteur de machines, dates, jeton d'activation expiré → #347. - CTA « Obtenir Base », carte Mises à jour, page d'erreur, texte du compte → #346 (la ligne `UpsellGate` de `architecture.md` qui cite encore #270 y est réécrite). - Hachage de l'identifiant machine → #350, avant le go-live. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-09-26 13:16:33 +00:00
fix(license): call /v1 routes with product, name server refusals, localize purchase URL
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m40s
PR Check — Rust / rust (pull_request) Successful in 8m52s
7d667f7ca3
- Activation, deactivation and verify POST to /v1/licenses/* with
  "product":"simpl-resultat" instead of the legacy /licenses/* paths,
  which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
  read from error.code of the /v1 envelope. A 200 {valid:false} from
  verify is now a coded refusal instead of an empty machine list, and an
  HTML error page on a non-2xx maps to server instead of
  invalid_response. Deactivation is idempotent: no_active_activation
  counts as done.
- The three calls live in *_inner functions without AppHandle, tested
  against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
  survives the list load it triggers (machine_limit_reached opens the
  list), a deactivation removes the machine locally without re-running
  verify (4 requests on the machine-limit path, under the server's
  5/min/IP limit), and refresh() follows every successful activation or
  deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
  /{fr|en}/produits/simpl-resultat.

test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.

Fixes #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

CI verte, vérifiée dans les logs et pas seulement par le statut du job :

  • run 421 (check-rust) : garde-fou d'audit exécuté (Suppression guard: 2 checks, exit 0), license_server_tests::all_three_calls_work_against_v1_only_routes ... ok, test result: ok. 126 passed.
  • run 420 (check-frontend) : build OK, 63 fichiers, 1237 tests vitest OK.

Mêmes comptes qu'en local (126 Rust, 1237 vitest).

CI verte, vérifiée dans les logs et pas seulement par le statut du job : - run 421 (`check-rust`) : garde-fou d'audit exécuté (`Suppression guard: 2 checks, exit 0`), `license_server_tests::all_three_calls_work_against_v1_only_routes ... ok`, `test result: ok. 126 passed`. - run 420 (`check-frontend`) : build OK, 63 fichiers, **1237** tests vitest OK. Mêmes comptes qu'en local (126 Rust, 1237 vitest).
Author
Owner

Revue adversariale — PR #359 (#270)

Verdict : APPROVE

Résumé. Le passage aux routes /v1 avec product, le contrat LicenseError et le reducer du parcours D2 sont corrects. Je les ai recoupés avec la source de maximus-api, et la CI est verte au head 7d667f7. Rien de bloquant. Une puce du CHANGELOG annonce une correction que l'utilisateur ne peut pas observer, et ma passe de mutations trouve trois trous de test à combler.

Ce que j'ai vérifié

  • Contrat serveur, relu dans maximus-api (routes/licenses.ts, services/licenseService.ts, middleware/rateLimit.ts, index.ts) plutôt que repris du corps de l'issue :
    • codes et statuts d'activate et de deactivate ;
    • verify répond toujours 200, avec reason quand la clé n'est pas valide ;
    • retry_after est un entier ≥ 1 placé dans error ;
    • product est optionnel, et simpl-resultat est la seule valeur admise ;
    • une route absente répond 404 {"error":{"code":"not_found"}}, que le client classe bien en server, pas en license_not_found.
  • Appelants : les trois wrappers ne rejettent plus, ils résolvent { ok: false }. useLicenseMachines est leur seul consommateur, donc aucun appelant ne tombe dans un succès silencieux. store_activation_token n'a aucun appelant TS : le nouveau format de son erreur ne casse rien.
  • Budget de 5 requêtes/min/IP : seules les trois commandes touchent /v1/licenses/*, et refresh() comme get_activation_status restent en IPC locale. Le parcours D2 tient en 4 appels, épinglé par le test de bout en bout.
  • refresh est stable (useCallback([])) et ne rejette jamais : void activate() ne peut pas produire de rejet non géré.
  • CI au head :
    • run 421 : 126 passed, et le garde-fou apparaît dans le log (Suppression guard: 2 checks, exit 0) ;
    • run 420 : build vert, 1237 tests vitest ;
    • en local, sur une copie git archive hors dépôt : 15/15 license_server_tests, 56/56 tests vitest pour les trois nouveaux fichiers.
  • URL d'achat : /fr/produits/simpl-resultat et /en/produits/simpl-resultat répondent 200. L'ancienne /simpl-resultat redirige puis tombe en 404.
  • i18n : 14 clés FR/EN, aucune clé dupliquée, vouvoiement respecté.
  • Divers : commit conventionnel, avec Fixes #270 dans le message de commit. Aucun secret, aucune migration, aucune nouvelle dépendance.

Mutations du relecteur

Ces mutations s'ajoutent à la table de l'auteur. Chacune est appliquée sur la copie hors dépôt puis annulée, et l'arbre de travail du dépôt n'a pas bougé.

Mutation Résultat
DEACTIVATE_DONE ne retire plus la machine 3 rouges
performDeactivate sans refresh() 2 rouges
ACTIVATE_ERROR ouvre la liste pour tout refus 1 rouge
machine_limit_reached → license.errors.server 1 rouge
Rust : license_expired lu comme LicenseRevoked 1 rouge
Rust : verify valid:true sans machines → liste vide 1 rouge
Rust : activate sans machine_name 1 rouge
Rust : un 404 sans code connu → LicenseNotFound 15/15 verts
TS : license_expired → license.errors.licenseRevoked 56/56 verts
Rust : deactivate_machine_impl sans deactivation_outcome 15/15 verts
Branches mineures : shouldLoadOnOpen sans || listError !== null, selectShowEmptyState sans !listLoading, verify sans trim(), 429 sans code verts

Suggestions (non bloquantes)

  1. CHANGELOG — une puce décrit un changement invisible. Il s'agit de « L'édition affichée se met désormais à jour dès l'activation ou la désactivation » (CHANGELOG.md:10, CHANGELOG.fr.md:10).

    • resolve_license_entitlements rend la même édition avec un jeton d'activation valide et sans jeton. C'est ce que prouvent les tests existants machine_match_keeps_edition_and_features et missing_activation_token_keeps_edition_and_features.
    • Activer (pas de jeton → jeton valide) ou désactiver cette machine (jeton valide → jeton supprimé) ne change donc ni l'édition ni les features.
    • get_edition ne change que dans un cas : un jeton invalide ou expiré remplacé par un valide. L'édition affichée ne bouge pas non plus dans ce cas : en Gratuite, la carte masque la section Machines (hasLicense) ; via le compte Premium, l'édition reste Premium.
    • À faire : garder l'appel à refresh(), inoffensif et utile quand #347 traitera le jeton expiré, mais retirer la puce des deux fichiers avant le tag, dans ce PR ou un suivant de la pile. Le critère d'acceptation correspondant de #270 reposait sur la même prémisse.
  2. Couvrir le 404 dans l'autre sens. Le code de error_from_response est correct, mais aucun test ne le protège :

    • la table de every_envelope_refusal_maps_to_its_code (license_commands.rs:1014) n'a aucune ligne 404 sans code connu, donc une réécriture « tout 404 → LicenseNotFound » reste verte ;
    • c'est le cas d'une route absente ({"error":{"code":"not_found"}} de maximus-api) ou de la page 404 de Traefik pendant un redéploiement ; l'utilisateur lirait alors « Cette clé de licence est introuvable » avec une clé payée ;
    • deux lignes suffisent : (404, envelope("not_found"), server 404) et (404, Null, server 404).
  3. Le test « maps %s to its own license.errors key » est tautologique (licenseService.test.ts:39).

    • Il compare err.i18nKey à LICENSE_ERROR_I18N_MAP[code], donc la table à elle-même.
    • Faire pointer license_expired sur la clé licenseRevoked laisse les 56 tests verts, et un utilisateur dont la clé a expiré lirait « révoquée ».
    • À faire : vérifier, pour chaque code, la clé littérale attendue ; à défaut, vérifier que les valeurs de la table sont toutes distinctes.
  4. Le câblage de la désactivation idempotente n'est pas couvert. deactivation_outcome est testée, mais retirer son appel dans deactivate_machine_impl (license_commands.rs:624) laisse les 15 tests verts. C'est une limite connue du code qui dépend d'AppHandle ; la mentionner dans le corps de PR pour qu'elle ne passe pas pour couverte.

  5. Course mineure sur D2.

    • Si la liste était déjà chargée avant le 409, elle reste affichée pendant le rechargement, avec ses boutons Désactiver actifs.
    • Une désactivation qui aboutit avant LIST_LOAD_DONE peut voir la machine réapparaître : la réponse de verify l'inclut encore.
    • Grâce à l'idempotence, un second clic ne fait aucun dégât. Pour supprimer la course : disabled={machines.deactivatingId !== null || machines.listLoading} (LicenseCard.tsx:230).
  6. Pluriel du délai. Le serveur peut envoyer retry_after = 1, ce qui donne « Réessayez dans 1 secondes » / « Try again in 1 seconds ». Les formes plurielles d'i18next (count) règlent le cas.

## Revue adversariale — PR #359 (#270) **Verdict : APPROVE** **Résumé.** Le passage aux routes `/v1` avec `product`, le contrat `LicenseError` et le reducer du parcours D2 sont corrects. Je les ai recoupés avec la source de maximus-api, et la CI est verte au head `7d667f7`. Rien de bloquant. Une puce du CHANGELOG annonce une correction que l'utilisateur ne peut pas observer, et ma passe de mutations trouve trois trous de test à combler. ### Ce que j'ai vérifié - **Contrat serveur**, relu dans `maximus-api` (`routes/licenses.ts`, `services/licenseService.ts`, `middleware/rateLimit.ts`, `index.ts`) plutôt que repris du corps de l'issue : - codes et statuts d'activate et de deactivate ; - verify répond toujours 200, avec `reason` quand la clé n'est pas valide ; - `retry_after` est un entier ≥ 1 placé **dans** `error` ; - `product` est optionnel, et `simpl-resultat` est la seule valeur admise ; - une route absente répond `404 {"error":{"code":"not_found"}}`, que le client classe bien en `server`, pas en `license_not_found`. - **Appelants** : les trois wrappers ne rejettent plus, ils résolvent `{ ok: false }`. `useLicenseMachines` est leur seul consommateur, donc aucun appelant ne tombe dans un succès silencieux. `store_activation_token` n'a aucun appelant TS : le nouveau format de son erreur ne casse rien. - **Budget de 5 requêtes/min/IP** : seules les trois commandes touchent `/v1/licenses/*`, et `refresh()` comme `get_activation_status` restent en IPC locale. Le parcours D2 tient en 4 appels, épinglé par le test de bout en bout. - **`refresh`** est stable (`useCallback([])`) et ne rejette jamais : `void activate()` ne peut pas produire de rejet non géré. - **CI au head** : - run 421 : `126 passed`, et le garde-fou apparaît dans le log (`Suppression guard: 2 checks, exit 0`) ; - run 420 : build vert, 1237 tests vitest ; - en local, sur une copie `git archive` hors dépôt : 15/15 `license_server_tests`, 56/56 tests vitest pour les trois nouveaux fichiers. - **URL d'achat** : `/fr/produits/simpl-resultat` et `/en/produits/simpl-resultat` répondent 200. L'ancienne `/simpl-resultat` redirige puis tombe en 404. - **i18n** : 14 clés FR/EN, aucune clé dupliquée, vouvoiement respecté. - **Divers** : commit conventionnel, avec `Fixes #270` dans le message de commit. Aucun secret, aucune migration, aucune nouvelle dépendance. ### Mutations du relecteur Ces mutations s'ajoutent à la table de l'auteur. Chacune est appliquée sur la copie hors dépôt puis annulée, et l'arbre de travail du dépôt n'a pas bougé. | Mutation | Résultat | |---|---| | `DEACTIVATE_DONE` ne retire plus la machine | 3 rouges | | `performDeactivate` sans `refresh()` | 2 rouges | | `ACTIVATE_ERROR` ouvre la liste pour tout refus | 1 rouge | | `machine_limit_reached` → `license.errors.server` | 1 rouge | | Rust : `license_expired` lu comme `LicenseRevoked` | 1 rouge | | Rust : verify `valid:true` sans `machines` → liste vide | 1 rouge | | Rust : activate sans `machine_name` | 1 rouge | | **Rust : un 404 sans code connu → `LicenseNotFound`** | **15/15 verts** | | **TS : `license_expired` → `license.errors.licenseRevoked`** | **56/56 verts** | | **Rust : `deactivate_machine_impl` sans `deactivation_outcome`** | **15/15 verts** | | Branches mineures : `shouldLoadOnOpen` sans `\|\| listError !== null`, `selectShowEmptyState` sans `!listLoading`, verify sans `trim()`, 429 sans code | verts | ### Suggestions (non bloquantes) 1. **CHANGELOG — une puce décrit un changement invisible.** Il s'agit de « L'édition affichée se met désormais à jour dès l'activation ou la désactivation » (`CHANGELOG.md:10`, `CHANGELOG.fr.md:10`). - `resolve_license_entitlements` rend la même édition avec un jeton d'activation valide et sans jeton. C'est ce que prouvent les tests existants `machine_match_keeps_edition_and_features` et `missing_activation_token_keeps_edition_and_features`. - Activer (pas de jeton → jeton valide) ou désactiver cette machine (jeton valide → jeton supprimé) ne change donc ni l'édition ni les features. - `get_edition` ne change que dans un cas : un jeton invalide ou expiré remplacé par un valide. L'édition affichée ne bouge pas non plus dans ce cas : en Gratuite, la carte masque la section Machines (`hasLicense`) ; via le compte Premium, l'édition reste Premium. - À faire : garder l'appel à `refresh()`, inoffensif et utile quand #347 traitera le jeton expiré, mais retirer la puce des deux fichiers avant le tag, dans ce PR ou un suivant de la pile. Le critère d'acceptation correspondant de #270 reposait sur la même prémisse. 2. **Couvrir le 404 dans l'autre sens.** Le code de `error_from_response` est correct, mais aucun test ne le protège : - la table de `every_envelope_refusal_maps_to_its_code` (`license_commands.rs:1014`) n'a aucune ligne 404 sans code connu, donc une réécriture « tout 404 → `LicenseNotFound` » reste verte ; - c'est le cas d'une route absente (`{"error":{"code":"not_found"}}` de maximus-api) ou de la page 404 de Traefik pendant un redéploiement ; l'utilisateur lirait alors « Cette clé de licence est introuvable » avec une clé payée ; - deux lignes suffisent : `(404, envelope("not_found"), server 404)` et `(404, Null, server 404)`. 3. **Le test « maps %s to its own license.errors key » est tautologique** (`licenseService.test.ts:39`). - Il compare `err.i18nKey` à `LICENSE_ERROR_I18N_MAP[code]`, donc la table à elle-même. - Faire pointer `license_expired` sur la clé `licenseRevoked` laisse les 56 tests verts, et un utilisateur dont la clé a expiré lirait « révoquée ». - À faire : vérifier, pour chaque code, la clé littérale attendue ; à défaut, vérifier que les valeurs de la table sont toutes distinctes. 4. **Le câblage de la désactivation idempotente n'est pas couvert.** `deactivation_outcome` est testée, mais retirer son appel dans `deactivate_machine_impl` (`license_commands.rs:624`) laisse les 15 tests verts. C'est une limite connue du code qui dépend d'`AppHandle` ; la mentionner dans le corps de PR pour qu'elle ne passe pas pour couverte. 5. **Course mineure sur D2.** - Si la liste était déjà chargée avant le 409, elle reste affichée pendant le rechargement, avec ses boutons Désactiver actifs. - Une désactivation qui aboutit avant `LIST_LOAD_DONE` peut voir la machine réapparaître : la réponse de verify l'inclut encore. - Grâce à l'idempotence, un second clic ne fait aucun dégât. Pour supprimer la course : `disabled={machines.deactivatingId !== null || machines.listLoading}` (`LicenseCard.tsx:230`). 6. **Pluriel du délai.** Le serveur peut envoyer `retry_after` = 1, ce qui donne « Réessayez dans 1 secondes » / « Try again in 1 seconds ». Les formes plurielles d'i18next (`count`) règlent le cas.
maximus added 1 commit 2026-09-27 00:27:46 +00:00
fix(license): apply the review of #359
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
PR Check — Rust / rust (pull_request) Successful in 8m48s
a051b96c87
- A 404 without a known code (missing route, proxy error page) is now
  pinned as a server error by two table rows, so "key not found" can
  never be shown for it.
- The message-key test spells out the expected key of every code
  instead of comparing the map with itself, and checks the keys are
  distinct: swapping two entries now fails.
- Idempotent deactivation moves into deactivate_idempotent, without
  AppHandle, tested against a mock server (404 no_active_activation ->
  done, 403 revoked -> error).
- Deactivate buttons stay disabled while the list reloads
  (selectCanDeactivate), so a machine deactivated during a reload
  cannot reappear.
- The rate-limit message uses i18next plural forms (rateLimit_one /
  rateLimit_other with count): no more "1 seconds".
- CHANGELOG: drop the bullet about the edition updating after an
  activation. The edition does not change with or without an
  activation token, so nothing is observable; refresh() stays.

Proven red on the fixed tree: 404 without a known code read as
LicenseNotFound 1, license_expired mapped to the revoked key 2,
idempotence removed 1, buttons live during a reload 1, {seconds}
instead of {count} 2.

Refs #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

Les six suggestions de la revue sont appliquées dans a051b96.

# Suggestion Correctif
1 Puce CHANGELOG « l'édition se met à jour » invisible Retirée des deux fichiers ; refresh() reste (sans effet visible aujourd'hui, utile quand #347 traitera le jeton expiré). Le critère d'acceptation de #270 qui la portait reposait sur la même prémisse.
2 Un 404 sans code connu n'était pas protégé Deux lignes dans every_envelope_refusal_maps_to_its_code : 404 {"error":{"code":"not_found"}} et 404 sans corps, attendus server.
3 Test des clés de message tautologique Clé attendue écrite en toutes lettres pour chaque code, plus un contrôle que toutes les clés sont distinctes.
4 Branchement de la désactivation idempotente non couvert Sorti dans deactivate_idempotent (sans AppHandle), testé contre mockito : 404 no_active_activation → fait, 403 license_revoked → erreur.
5 Course pendant le rechargement de la liste selectCanDeactivate : boutons Désactiver inactifs pendant une désactivation et pendant un rechargement ; testé sur le cas « 409 sur une liste déjà chargée ».
6 « 1 secondes » rateLimit_one / rateLimit_other avec count ; la couverture i18n vérifie les deux formes en FR et en EN.

Preuve du rouge sur l'arbre corrigé

Chaque mutation est appliquée depuis une copie de sauvegarde puis restaurée ; le diffstat est identique avant et après.

Mutation Avant Mutation Après
un 404 sans code connu lu comme LicenseNotFound (survivait) 15/15 verts 1 rouge 15/15 verts
license_expired → clé licenseRevoked (survivait) 22/22 verts 2 rouges 22/22 verts
deactivate_idempotent sans l'idempotence (survivait) 15/15 verts 1 rouge 15/15 verts
boutons actifs pendant un rechargement 34/34 verts 1 rouge 34/34 verts
params {seconds} au lieu de {count} 22/22 verts 2 rouges 22/22 verts

Reste non couvert : la ligne de deactivate_machine_impl qui appelle deactivate_idempotent. Elle dépend d'AppHandle : la remplacer par un appel direct à deactivate_inner laisserait la suite verte.

CI au head a051b96

Vérifiée dans les logs :

  • run 423 (check-rust) : Suppression guard: 2 checks, exit 0, deactivation_treats_an_already_free_slot_as_done ... ok, 126 passed ;
  • run 422 (check-frontend) : build OK, 1241 tests vitest.

Hors PR

Le jeton d'une autre machine (réinstallation, migration de poste) bloque la licence en Gratuite sans issue dans l'interface : ouvert en #360, pistes à trancher.

Code modifié depuis l'approbation : #270 repasse en status:review.

Les six suggestions de la revue sont appliquées dans `a051b96`. | # | Suggestion | Correctif | |---|---|---| | 1 | Puce CHANGELOG « l'édition se met à jour » invisible | Retirée des deux fichiers ; `refresh()` reste (sans effet visible aujourd'hui, utile quand #347 traitera le jeton expiré). Le critère d'acceptation de #270 qui la portait reposait sur la même prémisse. | | 2 | Un 404 sans code connu n'était pas protégé | Deux lignes dans `every_envelope_refusal_maps_to_its_code` : `404 {"error":{"code":"not_found"}}` et `404` sans corps, attendus `server`. | | 3 | Test des clés de message tautologique | Clé attendue écrite en toutes lettres pour chaque code, plus un contrôle que toutes les clés sont distinctes. | | 4 | Branchement de la désactivation idempotente non couvert | Sorti dans `deactivate_idempotent` (sans `AppHandle`), testé contre mockito : `404 no_active_activation` → fait, `403 license_revoked` → erreur. | | 5 | Course pendant le rechargement de la liste | `selectCanDeactivate` : boutons Désactiver inactifs pendant une désactivation **et** pendant un rechargement ; testé sur le cas « 409 sur une liste déjà chargée ». | | 6 | « 1 secondes » | `rateLimit_one` / `rateLimit_other` avec `count` ; la couverture i18n vérifie les deux formes en FR et en EN. | ## Preuve du rouge sur l'arbre corrigé Chaque mutation est appliquée depuis une copie de sauvegarde puis restaurée ; le diffstat est identique avant et après. | Mutation | Avant | Mutation | Après | |---|---|---|---| | un 404 sans code connu lu comme `LicenseNotFound` (survivait) | 15/15 verts | **1 rouge** | 15/15 verts | | `license_expired` → clé `licenseRevoked` (survivait) | 22/22 verts | **2 rouges** | 22/22 verts | | `deactivate_idempotent` sans l'idempotence (survivait) | 15/15 verts | **1 rouge** | 15/15 verts | | boutons actifs pendant un rechargement | 34/34 verts | **1 rouge** | 34/34 verts | | `params {seconds}` au lieu de `{count}` | 22/22 verts | **2 rouges** | 22/22 verts | **Reste non couvert** : la ligne de `deactivate_machine_impl` qui appelle `deactivate_idempotent`. Elle dépend d'`AppHandle` : la remplacer par un appel direct à `deactivate_inner` laisserait la suite verte. ## CI au head `a051b96` Vérifiée dans les logs : - run 423 (`check-rust`) : `Suppression guard: 2 checks, exit 0`, `deactivation_treats_an_already_free_slot_as_done ... ok`, **126 passed** ; - run 422 (`check-frontend`) : build OK, **1241** tests vitest. ## Hors PR Le jeton d'une autre machine (réinstallation, migration de poste) bloque la licence en Gratuite sans issue dans l'interface : ouvert en #360, pistes à trancher. Code modifié depuis l'approbation : #270 repasse en `status:review`.
Author
Owner

Revue adversariale — PR #359 (#270), re-revue au head a051b96

Verdict : APPROVE

Résumé. Les six suggestions de la revue précédente sont appliquées proprement dans a051b96, et le reste de la PR tient face au contrat serveur, relu dans la source de maximus-api. Rien de bloquant. Ma passe de mutations trouve deux comportements corrects mais non gardés par les tests. Par ailleurs, maximus/la-compagnie-maximus#153 a été fermée entre-temps, et une puce du CHANGELOG n'est plus exacte.

Ce que j'ai vérifié

  • Commit de correction (7d667f7..a051b96) :
    • deactivate_idempotent remplace deactivation_outcome et passe par mockito ;
    • la table porte deux lignes 404 (not_found, corps vide) ;
    • les clés i18n attendues sont écrites en toutes lettres, avec un contrôle d'unicité ;
    • selectCanDeactivate est câblé en LicenseCard.tsx:231 ;
    • le délai d'attente passe aux pluriels rateLimit_one / rateLimit_other ;
    • la puce « édition » est retirée des deux CHANGELOG.
  • Contrat serveur, relu dans maximus-api au commit ebec4bb (identique au main distant) plutôt que repris de l'issue :
    • activate : 404 license_not_found, 403 license_revoked ou license_expired, 409 machine_limit_reached (avec machines), 200 {activation_token}. Une machine déjà active reçoit un nouveau jeton en 200, jamais un 409.
    • deactivate : 404 license_not_found, 404 no_active_activation, 200 {ok:true}. La route ne contrôle ni la révocation ni l'expiration (src/services/licenseService.ts:271-307).
    • verify : toujours 200. reason vaut exactement not_found, revoked ou expired, les trois chaînes que lit machines_from_verify.
    • Schémas zod : product est z.enum(["simpl-resultat"]).optional(), et les objets ne sont pas stricts. machine_name est z.string().optional(), sans longueur maximale. Le client omet ce champ au lieu d'envoyer null, que zod refuserait.
    • Débit : licenses.use("/*") limite à 5 requêtes/min par IP (x-forwarded-for), dans une fenêtre minute que partagent activate, verify et deactivate. /v1/prices compte sur la clé lic:<id>, donc dans un autre seau. refresh() (get_edition + read_license) et get_activation_status restent locaux : le parcours D2 tient en 4 appels.
    • Route absente : 404 {"error":{"code":"not_found"}} donne server, désormais épinglé.
  • CI au head, vérifiée dans les logs et pas seulement au statut. Les deux runs extraient a051b96, posé directement sur main 7b852c9 (inchangé) : l'arbre testé est donc l'arbre après merge.
    • run 423 : Suppression guard: 2 checks, exit 0, les 15 license_server_tests passent, 126 passed. Aucun avertissement rustc, seulement les 12 avertissements autorisés de cargo-audit.
    • run 422 : build OK, 63 fichiers, 1241 tests (34, 22 et 4 pour les trois nouveaux fichiers).
  • En local, sur une copie git archive hors dépôt : 15/15 tests Rust, 60/60 tests vitest pour les trois fichiers. Rendu par i18next 25.8.4 avec les fichiers de langue de la PR : « Réessayez dans 1 seconde » / « 42 secondes », « Try again in 1 second » / « 42 seconds ».
  • Appelants : useLicenseMachines est le seul consommateur des trois wrappers. store_activation_token n'a aucun appelant TS (seulement enregistré en lib.rs:456) : son nouveau format d'erreur ne casse rien.
  • refresh() fait passer le contexte par l'état loading. UsersSettingsPage n'est sous aucun RequireFeature : la carte n'est ni démontée ni remise à zéro. Seul le NavLock de la barre latérale masque son cadenas le temps de l'IPC.
  • Divers : commits conventionnels, avec Fixes #270 dans le message de 7d667f7. Aucun .only ni .skip, aucune sortie de débogage, aucun secret, aucune migration.

Mutations du relecteur

Chaque mutation est appliquée sur la copie hors dépôt, puis restaurée ; les fichiers sont ensuite comparés octet par octet à a051b96. L'arbre du dépôt n'a pas bougé.

Mutation Résultat
Rust : activate repasse sur /licenses/activate 5 / 15 rouges (conforme à la table de l'auteur)
Rust : un 404 sans code connu → LicenseNotFound 1 rouge (conforme)
Rust : verify sans product 2 rouges (conforme)
TS : license_expired → clé licenseRevoked 2 rouges (conforme)
TS : params: { seconds } au lieu de { count } 3 rouges sur les trois fichiers
Contrôle : performDeactivate sans refresh() 2 rouges
Rust : deactivate_idempotent avale aussi LicenseNotFound 15/15 verts
TS : performDeactivate sans loadActivationStatus 60/60 verts

Suggestions (non bloquantes)

  1. Épingler l'autre 404 de deactivate (license_commands.rs:1091).

    • Le cas négatif de deactivation_treats_an_already_free_slot_as_done est un 403 license_revoked, que la route deactivate ne renvoie jamais (voir le contrat ci-dessus).
    • Le cas qui compte est l'autre 404 : Err(NoActiveActivation) | Err(LicenseNotFound) => Ok(()) laisse les 15 tests verts.
    • C'est le piège que nomme l'issue : « ne jamais décider sur le statut HTTP seul ». Une clé inconnue serait affichée comme désactivée, et le jeton local serait supprimé s'il s'agit de cette machine.
    • À faire : remplacer (ou compléter) le mock 403 par 404 {"error":{"code":"license_not_found"}}, avec Err(LicenseError::LicenseNotFound) attendu.
  2. Épingler la relecture du statut d'activation après une désactivation (useLicenseMachines.ts:197).

    • Quand on désactive cette machine, Rust supprime le jeton. C'est cette ligne qui fait alors repasser la carte de « Activée » au bouton « Activer ».
    • La retirer laisse les 60 tests verts. Le getActivationStatus du harnais renvoie toujours is_activated: true (useLicenseMachines.test.ts:255), et aucun test ne désactive "this".
    • À faire, dans « removes the machine locally and refreshes » :
      • faire répondre { is_activated: false, machine_id: "this" } ;
      • désactiver "this" ;
      • vérifier que getState().activation?.is_activated vaut false.
  3. CHANGELOG — la puce « Acheter » n'est plus exacte (CHANGELOG.md:10, CHANGELOG.fr.md:10).

    • maximus/la-compagnie-maximus#153 est fermée depuis le 2026-09-27 à 00:24 UTC. Sondé ce soir : /simpl-resultat répond 308 vers /produits/simpl-resultat, qui répond 307 vers /fr/produits/simpl-resultat (200). Avec un navigateur en anglais, on arrive sur /en/….
    • La formule « ouvrait une page qui n'existe pas » sera donc fausse au moment du tag : le bouton de v0.15.0 aboutit sur la page produit, dans la langue du navigateur.
    • Ce qui change pour l'utilisateur : la page s'ouvre directement, dans la langue de l'application, sans dépendre d'une redirection. Reformuler la puce en ce sens.
    • Mettre aussi au passé le commentaire de purchaseUrl.test.ts:4 (« a path the site never served »).
  4. (Faible) Course symétrique sur le bouton Activer (LicenseCard.tsx:159).

    • Seul activating désactive ce bouton. Si l'utilisateur ouvre la liste puis active, et que l'activation répond avant le verify parti plus tôt, LIST_LOAD_DONE revalide une liste que ACTIVATE_DONE venait d'invalider.
    • Rejoué sur le reducer : la suite TOGGLE_LIST, LIST_LOAD_START, ACTIVATE_START, ACTIVATE_DONE, LIST_LOAD_DONE([a, b]) donne { listOpen: false, listLoaded: true, machines: [a, b] }, et shouldLoadOnOpen vaut false.
    • La liste rouverte omet alors la machine qui vient d'être activée, jusqu'au prochain montage de la carte.
    • Le cas est improbable, car verify doit répondre après un appel parti après lui, et l'effet est cosmétique. Correctif : disabled={machines.activating || machines.listLoading}, sur le modèle de selectCanDeactivate.
## Revue adversariale — PR #359 (#270), re-revue au head `a051b96` **Verdict : APPROVE** **Résumé.** Les six suggestions de la revue précédente sont appliquées proprement dans `a051b96`, et le reste de la PR tient face au contrat serveur, relu dans la source de maximus-api. Rien de bloquant. Ma passe de mutations trouve deux comportements corrects mais non gardés par les tests. Par ailleurs, maximus/la-compagnie-maximus#153 a été fermée entre-temps, et une puce du CHANGELOG n'est plus exacte. ### Ce que j'ai vérifié - **Commit de correction** (`7d667f7..a051b96`) : - `deactivate_idempotent` remplace `deactivation_outcome` et passe par mockito ; - la table porte deux lignes 404 (`not_found`, corps vide) ; - les clés i18n attendues sont écrites en toutes lettres, avec un contrôle d'unicité ; - `selectCanDeactivate` est câblé en `LicenseCard.tsx:231` ; - le délai d'attente passe aux pluriels `rateLimit_one` / `rateLimit_other` ; - la puce « édition » est retirée des deux CHANGELOG. - **Contrat serveur**, relu dans `maximus-api` au commit `ebec4bb` (identique au `main` distant) plutôt que repris de l'issue : - **activate** : 404 `license_not_found`, 403 `license_revoked` ou `license_expired`, 409 `machine_limit_reached` (avec `machines`), 200 `{activation_token}`. Une machine déjà active reçoit un nouveau jeton en 200, jamais un 409. - **deactivate** : 404 `license_not_found`, 404 `no_active_activation`, 200 `{ok:true}`. La route ne contrôle ni la révocation ni l'expiration (`src/services/licenseService.ts:271-307`). - **verify** : toujours 200. `reason` vaut exactement `not_found`, `revoked` ou `expired`, les trois chaînes que lit `machines_from_verify`. - **Schémas zod** : `product` est `z.enum(["simpl-resultat"]).optional()`, et les objets ne sont pas stricts. `machine_name` est `z.string().optional()`, sans longueur maximale. Le client omet ce champ au lieu d'envoyer `null`, que zod refuserait. - **Débit** : `licenses.use("/*")` limite à 5 requêtes/min par IP (`x-forwarded-for`), dans une fenêtre `minute` que partagent activate, verify et deactivate. `/v1/prices` compte sur la clé `lic:<id>`, donc dans un autre seau. `refresh()` (`get_edition` + `read_license`) et `get_activation_status` restent locaux : le parcours D2 tient en 4 appels. - **Route absente** : `404 {"error":{"code":"not_found"}}` donne `server`, désormais épinglé. - **CI au head**, vérifiée dans les logs et pas seulement au statut. Les deux runs extraient `a051b96`, posé directement sur `main` `7b852c9` (inchangé) : l'arbre testé est donc l'arbre après merge. - run 423 : `Suppression guard: 2 checks, exit 0`, les 15 `license_server_tests` passent, `126 passed`. Aucun avertissement rustc, seulement les 12 avertissements autorisés de cargo-audit. - run 422 : build OK, 63 fichiers, **1241** tests (34, 22 et 4 pour les trois nouveaux fichiers). - **En local**, sur une copie `git archive` hors dépôt : 15/15 tests Rust, 60/60 tests vitest pour les trois fichiers. Rendu par i18next 25.8.4 avec les fichiers de langue de la PR : « Réessayez dans 1 seconde » / « 42 secondes », « Try again in 1 second » / « 42 seconds ». - **Appelants** : `useLicenseMachines` est le seul consommateur des trois wrappers. `store_activation_token` n'a aucun appelant TS (seulement enregistré en `lib.rs:456`) : son nouveau format d'erreur ne casse rien. - **`refresh()`** fait passer le contexte par l'état `loading`. `UsersSettingsPage` n'est sous aucun `RequireFeature` : la carte n'est ni démontée ni remise à zéro. Seul le `NavLock` de la barre latérale masque son cadenas le temps de l'IPC. - **Divers** : commits conventionnels, avec `Fixes #270` dans le message de `7d667f7`. Aucun `.only` ni `.skip`, aucune sortie de débogage, aucun secret, aucune migration. ### Mutations du relecteur Chaque mutation est appliquée sur la copie hors dépôt, puis restaurée ; les fichiers sont ensuite comparés octet par octet à `a051b96`. L'arbre du dépôt n'a pas bougé. | Mutation | Résultat | |---|---| | Rust : activate repasse sur `/licenses/activate` | 5 / 15 rouges (conforme à la table de l'auteur) | | Rust : un 404 sans code connu → `LicenseNotFound` | 1 rouge (conforme) | | Rust : verify sans `product` | 2 rouges (conforme) | | TS : `license_expired` → clé `licenseRevoked` | 2 rouges (conforme) | | TS : `params: { seconds }` au lieu de `{ count }` | 3 rouges sur les trois fichiers | | Contrôle : `performDeactivate` sans `refresh()` | 2 rouges | | **Rust : `deactivate_idempotent` avale aussi `LicenseNotFound`** | **15/15 verts** | | **TS : `performDeactivate` sans `loadActivationStatus`** | **60/60 verts** | ### Suggestions (non bloquantes) 1. **Épingler l'autre 404 de deactivate** (`license_commands.rs:1091`). - Le cas négatif de `deactivation_treats_an_already_free_slot_as_done` est un 403 `license_revoked`, que la route deactivate ne renvoie jamais (voir le contrat ci-dessus). - Le cas qui compte est l'autre 404 : `Err(NoActiveActivation) | Err(LicenseNotFound) => Ok(())` laisse les 15 tests verts. - C'est le piège que nomme l'issue : « ne jamais décider sur le statut HTTP seul ». Une clé inconnue serait affichée comme désactivée, et le jeton local serait supprimé s'il s'agit de cette machine. - À faire : remplacer (ou compléter) le mock 403 par `404 {"error":{"code":"license_not_found"}}`, avec `Err(LicenseError::LicenseNotFound)` attendu. 2. **Épingler la relecture du statut d'activation après une désactivation** (`useLicenseMachines.ts:197`). - Quand on désactive cette machine, Rust supprime le jeton. C'est cette ligne qui fait alors repasser la carte de « Activée » au bouton « Activer ». - La retirer laisse les 60 tests verts. Le `getActivationStatus` du harnais renvoie toujours `is_activated: true` (`useLicenseMachines.test.ts:255`), et aucun test ne désactive `"this"`. - À faire, dans « removes the machine locally and refreshes » : - faire répondre `{ is_activated: false, machine_id: "this" }` ; - désactiver `"this"` ; - vérifier que `getState().activation?.is_activated` vaut `false`. 3. **CHANGELOG — la puce « Acheter » n'est plus exacte** (`CHANGELOG.md:10`, `CHANGELOG.fr.md:10`). - maximus/la-compagnie-maximus#153 est fermée depuis le 2026-09-27 à 00:24 UTC. Sondé ce soir : `/simpl-resultat` répond 308 vers `/produits/simpl-resultat`, qui répond 307 vers `/fr/produits/simpl-resultat` (200). Avec un navigateur en anglais, on arrive sur `/en/…`. - La formule « ouvrait une page qui n'existe pas » sera donc fausse au moment du tag : le bouton de v0.15.0 aboutit sur la page produit, dans la langue du navigateur. - Ce qui change pour l'utilisateur : la page s'ouvre directement, dans la langue de l'application, sans dépendre d'une redirection. Reformuler la puce en ce sens. - Mettre aussi au passé le commentaire de `purchaseUrl.test.ts:4` (« a path the site never served »). 4. **(Faible) Course symétrique sur le bouton Activer** (`LicenseCard.tsx:159`). - Seul `activating` désactive ce bouton. Si l'utilisateur ouvre la liste puis active, et que l'activation répond avant le verify parti plus tôt, `LIST_LOAD_DONE` revalide une liste que `ACTIVATE_DONE` venait d'invalider. - Rejoué sur le reducer : la suite `TOGGLE_LIST`, `LIST_LOAD_START`, `ACTIVATE_START`, `ACTIVATE_DONE`, `LIST_LOAD_DONE([a, b])` donne `{ listOpen: false, listLoaded: true, machines: [a, b] }`, et `shouldLoadOnOpen` vaut `false`. - La liste rouverte omet alors la machine qui vient d'être activée, jusqu'au prochain montage de la carte. - Le cas est improbable, car verify doit répondre après un appel parti après lui, et l'effet est cosmétique. Correctif : `disabled={machines.activating || machines.listLoading}`, sur le modèle de `selectCanDeactivate`.
maximus added 1 commit 2026-09-27 12:09:05 +00:00
fix(license): apply the second review of #359
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 8m54s
b8e67c3d4f
- The idempotent-deactivation test now pins the route's other 404:
  license_not_found stays an error. The previous negative case was a
  403 that the deactivate route never returns, so swallowing
  license_not_found (an unknown key shown as deactivated, the local
  token deleted) went unnoticed.
- A test deactivates this machine and checks that the re-read activation
  status brings the Activate button back. Removing that re-read used to
  leave the suite green.
- The Activate button stays disabled while the list loads
  (selectCanActivate), the counterpart of selectCanDeactivate: a verify
  answer landing after a successful activation can no longer mark as
  fresh a list that misses this machine.
- CHANGELOG: the old /simpl-resultat address is redirected by the site
  since la-compagnie-maximus#153, so the "Buy" bullet now says the
  button opens the product page directly, in the app's language. The
  test comment is moved to the past tense.

Proven red on the fixed tree: idempotence swallowing license_not_found
1, no status re-read after deactivation 1, Activate live during a list
load 1.

Refs #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

Les quatre suggestions de la re-revue (commentaire 16079) sont appliquées dans b8e67c3.

# Suggestion Correctif
1 L'autre 404 de deactivate n'était pas épinglé Le cas négatif de deactivation_treats_an_already_free_slot_as_done passe du 403 license_revoked (que la route ne renvoie jamais) au 404 license_not_found, attendu en erreur : une clé inconnue n'est jamais « désactivée » et ne supprime jamais le jeton local.
2 La relecture du statut après une désactivation n'était pas gardée Nouveau test : désactiver "this" fait repasser activation.is_activated à false, et donc revenir le bouton Activer.
3 La puce « Acheter » n'était plus exacte depuis la-compagnie-maximus#153 Reformulée en FR et en EN : la page produit s'ouvre directement, dans la langue de l'app, au lieu d'une ancienne adresse qui ne fonctionne que grâce à la redirection du site. Redirection vérifiée : /simpl-resultat → 308 → /produits/simpl-resultat → 200 en FR comme en EN. Le commentaire de purchaseUrl.test.ts est passé au passé.
4 Course symétrique sur le bouton Activer selectCanActivate : bouton inactif pendant une activation et pendant un rechargement de la liste ; testé sur la séquence ouverture de la liste → chargement.

Preuve du rouge sur l'arbre corrigé

Même protocole : copie de sauvegarde, puis mutation, puis restauration ; le diffstat est identique avant et après.

Mutation Avant Mutation Après
l'idempotence avale aussi license_not_found (survivait) 15/15 verts 1 rouge 15/15 verts
pas de relecture du statut après désactivation (survivait) 38/38 verts 1 rouge 38/38 verts
bouton Activer actif pendant un rechargement 38/38 verts 1 rouge 38/38 verts

CI au head b8e67c3

Vérifiée dans les logs, les deux jobs sur HEAD is now at b8e67c3 :

  • run 426 (check-rust) : Suppression guard: 2 checks, exit 0, 126 passed ;
  • run 425 (check-frontend) : build OK, 1245 tests vitest.

Code modifié depuis l'approbation : #270 repasse en status:review.

Les quatre suggestions de la re-revue (commentaire 16079) sont appliquées dans `b8e67c3`. | # | Suggestion | Correctif | |---|---|---| | 1 | L'autre 404 de deactivate n'était pas épinglé | Le cas négatif de `deactivation_treats_an_already_free_slot_as_done` passe du 403 `license_revoked` (que la route ne renvoie jamais) au `404 license_not_found`, attendu en erreur : une clé inconnue n'est jamais « désactivée » et ne supprime jamais le jeton local. | | 2 | La relecture du statut après une désactivation n'était pas gardée | Nouveau test : désactiver `"this"` fait repasser `activation.is_activated` à `false`, et donc revenir le bouton Activer. | | 3 | La puce « Acheter » n'était plus exacte depuis la-compagnie-maximus#153 | Reformulée en FR et en EN : la page produit s'ouvre directement, dans la langue de l'app, au lieu d'une ancienne adresse qui ne fonctionne que grâce à la redirection du site. Redirection vérifiée : `/simpl-resultat` → 308 → `/produits/simpl-resultat` → 200 en FR comme en EN. Le commentaire de `purchaseUrl.test.ts` est passé au passé. | | 4 | Course symétrique sur le bouton Activer | `selectCanActivate` : bouton inactif pendant une activation **et** pendant un rechargement de la liste ; testé sur la séquence ouverture de la liste → chargement. | ## Preuve du rouge sur l'arbre corrigé Même protocole : copie de sauvegarde, puis mutation, puis restauration ; le diffstat est identique avant et après. | Mutation | Avant | Mutation | Après | |---|---|---|---| | l'idempotence avale aussi `license_not_found` (survivait) | 15/15 verts | **1 rouge** | 15/15 verts | | pas de relecture du statut après désactivation (survivait) | 38/38 verts | **1 rouge** | 38/38 verts | | bouton Activer actif pendant un rechargement | 38/38 verts | **1 rouge** | 38/38 verts | ## CI au head `b8e67c3` Vérifiée dans les logs, les deux jobs sur `HEAD is now at b8e67c3` : - run 426 (`check-rust`) : `Suppression guard: 2 checks, exit 0`, **126 passed** ; - run 425 (`check-frontend`) : build OK, **1245** tests vitest. Code modifié depuis l'approbation : #270 repasse en `status:review`.
Author
Owner

Revue adversariale — PR #359 (#270), 3e passe au head b8e67c3

Verdict : APPROVE

Résumé. Les quatre retouches de b8e67c3 sont correctes, et chacune fait tomber un test quand je la défais. Le reste de la PR tient face au contrat relu dans maximus-api, et la CI est verte au head (vérifiée dans les logs). Rien de bloquant dans cette PR. En rejouant le parcours complet contre le vrai serveur, j'ai en revanche trouvé un défaut dans maximus-api : une machine désactivée ne peut plus jamais être réactivée, le serveur répond 500. C'est hors du périmètre de la PR, mais c'est le chemin qu'elle met en avant, et il faut le corriger avant le go-live.

Hors PR — une machine désactivée ne peut plus être réactivée (maximus-api)

  • Mécanisme, lu dans maximus-api :

    • license_activations porte CONSTRAINT "uq_license_machine" UNIQUE("license_id","machine_id"), sans condition sur is_active (drizzle/0000_ambitious_lyja.sql:9) ;
    • deactivateLicense ne supprime pas la ligne, il pose is_active = false (src/services/licenseService.ts:285-295) ;
    • activateLicense ne cherche que la ligne active de la machine (l.188-199), puis fait un INSERT simple (l.255-260). Pour une machine déjà désactivée une fois, l'insert viole la contrainte, et onError répond 500 {"error":{"code":"internal_error"}} (src/index.ts:55-61).
  • Reproduit, pas seulement déduit : test d'intégration jetable sur une copie hors dépôt de maximus-api au commit 4af25fb, avec l'infrastructure de test du dépôt (vraies routes Hono, Postgres 16 embarqué, migrations appliquées). La logique d'activation n'a pas bougé depuis e8fe0ba (2026-04-29).

    Appel Réponse
    activate machine-A 200, jeton
    deactivate machine-A 200 {"ok":true}
    activate machine-A 500 internal_error ; Postgres : duplicate key value violates unique constraint "uq_license_machine"
    verify 200, active_machines: 0
  • Effet côté client : cette PR classe ce 500 en server, et la carte affiche « Le serveur de licences a rencontré une erreur. Réessayez plus tard. » Réessayer ne change rien, et chaque essai consomme le budget partagé de 5 requêtes par minute. L'ancien client recevait le même 500, affiché « Activation failed » : ce n'est pas une régression de la PR, et le client n'a aucun moyen de le contourner.

  • Parcours touchés :

    • désactiver cette machine, puis cliquer « Activer ». C'est le chemin qu'épingle le nouveau test de b8e67c3 (useLicenseMachines.test.ts:372) : le bouton revient, mais il mène à ce 500 ;
    • revenir sur un poste libéré depuis un autre : le parcours D2 (409 → liste → désactivation → activation) finit en 500 dès que la machine qui s'active a déjà été désactivée une fois.
  • Le défaut existe depuis la première migration, et aucune issue de maximus-api ne le couvre (recherche : uq_license_machine, réactiv, deactivat).

  • À faire : ouvrir une issue dans maximus-api, prérequis du go-live (maximus/maximus-api#62). Piste : après le contrôle de limite, réactiver la ligne existante (UPDATE … SET is_active = true, machine_name = …, last_seen_at = now(), ou INSERT … ON CONFLICT (license_id, machine_id) DO UPDATE), avec le test d'intégration activate → deactivate → activate = 200.

Ce que j'ai vérifié

  • Retouches de b8e67c3 :
    1. le cas négatif de l'idempotence est un 404 license_not_found, attendu en erreur ;
    2. le nouveau test désactive "this" et vérifie is_activated: false après la relecture du statut ;
    3. la puce « Acheter » est reformulée en FR et en EN, et le commentaire de purchaseUrl.test.ts est passé au passé. Sondé ce matin : /fr/produits/simpl-resultat et /en/produits/simpl-resultat répondent 200 sans redirection, /simpl-resultat aboutit en 200 après deux redirections. La puce est exacte ;
    4. selectCanActivate est câblé en LicenseCard.tsx:160.
  • Contrat serveur, relu dans maximus-api (routes/licenses.ts, services/licenseService.ts, types/index.ts, migration 0000) :
    • codes et statuts d'activate et de deactivate ; verify toujours 200, avec reason quand la clé n'est pas valide ;
    • product optionnel, simpl-resultat seule valeur admise ;
    • activated_at et last_seen_at sont NOT NULL : aucune machine ne peut disparaître en silence dans le filter_map de machines_from_verify.
  • CI au head, dans les logs :
    • run 426 (check-rust) : HEAD is now at b8e67c3, Suppression guard: 2 checks, exit 0, les 15 license_server_tests passent, 126 passed, aucun avertissement rustc ;
    • run 425 (check-frontend) : même head, build OK, 63 fichiers, 1245 tests vitest.
  • En local, sur une copie git archive hors dépôt : 126/126 tests Rust, 64/64 tests vitest pour les trois nouveaux fichiers.
  • i18n rendu par la vraie instance (src/i18n/config.ts), et pas seulement lu dans les JSON : les 14 codes produisent une phrase en FR et en EN, sans clé brute ; « Réessayez dans 1 seconde » / « 2 secondes », « Try again in 1 second » / « 2 seconds ».
  • Appelants : useLicenseMachines est le seul consommateur des trois wrappers. refresh() ne rejette jamais, et LOAD_START conserve l'édition : la section Machines ne disparaît pas pendant le rafraîchissement.
  • Divers : commits conventionnels, Fixes #270 dans le message de 7d667f7, aucun .only ni .skip, aucune sortie de débogage, aucun secret, aucune migration. Le guide utilisateur ne décrit ni la liste des machines ni les messages d'erreur : rien à y reporter.

Mutations du relecteur

Chaque mutation est appliquée sur la copie hors dépôt, puis annulée. La copie finale est identique octet pour octet à b8e67c3 (cmp), et l'arbre du dépôt n'a pas bougé. Les builds Rust mutés utilisent un CARGO_TARGET_DIR séparé : aucun artefact muté n'atterrit dans src-tauri/target.

Mutation Résultat
TS : selectCanActivate sans !state.listLoading (retouche 4) 1 rouge sur 38 (conforme)
TS : performDeactivate sans loadActivationStatus (retouche 2) 1 rouge sur 38 (conforme)
Rust : l'idempotence avale aussi license_not_found (retouche 1) 1 rouge sur 29 (conforme)
Rust : un 2xx illisible lu comme Null au lieu d'InvalidResponse (post_license_api) 29/29 verts
Rust : "machine_name": null envoyé quand le nom d'hôte est illisible 29/29 verts

Suggestions (non bloquantes)

  1. Le bouton Activer reste actif pendant une désactivation (useLicenseMachines.ts:143-145).
    • selectCanActivate ne regarde que activating et listLoading. Or, dans le parcours D2, le clic qui suit une désactivation est « Activer ».
    • Rejoué sur le vrai reducer avec des promesses différées. Pendant la désactivation, canActivate vaut true. Si le serveur traite l'activation avant la désactivation (deux connexions distinctes, rien ne fixe l'ordre), l'activation reçoit un 409, puis DEACTIVATE_DONE efface ce message de limite.
    • État final : aucune erreur affichée, machine non activée, et 5 appels déjà consommés. Le clic suivant sur « Activer » est le 6e appel : il reçoit un 429 tant que la fenêtre d'une minute n'est pas écoulée.
    • Le cas est improbable, mais c'est le budget que la PR protège. Correctif : && state.deactivatingId === null dans selectCanActivate (et && !state.activating dans selectCanDeactivate, par symétrie), avec un test sur le modèle de ceux de b8e67c3.
  2. Deux comportements Rust corrects mais non épinglés (les deux mutations survivantes).
    • Lecture stricte d'un 2xx (license_commands.rs:459-462). Pour activate et verify, un corps illisible échoue plus loin (jeton ou valid absent). Pour deactivate, il deviendrait un succès : un 200 non JSON (page de blocage d'un proxy d'entreprise) « désactiverait » la machine, et supprimerait le jeton local s'il s'agit de celle-ci, sans que le serveur ait rien reçu. Test : deactivate → 200 text/html → InvalidResponse.
    • Omission de machine_name quand le nom d'hôte est illisible (l.485-487). Sondé dans la même reproduction : "machine_name": null reçoit 400 invalid_input (Expected string, received null). Test : Matcher::Json exact, et non PartialJson, sur un appel activate_inner(…, None).
## Revue adversariale — PR #359 (#270), 3e passe au head `b8e67c3` **Verdict : APPROVE** **Résumé.** Les quatre retouches de `b8e67c3` sont correctes, et chacune fait tomber un test quand je la défais. Le reste de la PR tient face au contrat relu dans maximus-api, et la CI est verte au head (vérifiée dans les logs). Rien de bloquant dans cette PR. En rejouant le parcours complet contre le vrai serveur, j'ai en revanche trouvé un défaut **dans maximus-api** : une machine désactivée ne peut plus jamais être réactivée, le serveur répond 500. C'est hors du périmètre de la PR, mais c'est le chemin qu'elle met en avant, et il faut le corriger avant le go-live. ### Hors PR — une machine désactivée ne peut plus être réactivée (maximus-api) - **Mécanisme**, lu dans maximus-api : - `license_activations` porte `CONSTRAINT "uq_license_machine" UNIQUE("license_id","machine_id")`, sans condition sur `is_active` (`drizzle/0000_ambitious_lyja.sql:9`) ; - `deactivateLicense` ne supprime pas la ligne, il pose `is_active = false` (`src/services/licenseService.ts:285-295`) ; - `activateLicense` ne cherche que la ligne **active** de la machine (l.188-199), puis fait un `INSERT` simple (l.255-260). Pour une machine déjà désactivée une fois, l'insert viole la contrainte, et `onError` répond `500 {"error":{"code":"internal_error"}}` (`src/index.ts:55-61`). - **Reproduit**, pas seulement déduit : test d'intégration jetable sur une copie hors dépôt de maximus-api au commit `4af25fb`, avec l'infrastructure de test du dépôt (vraies routes Hono, Postgres 16 embarqué, migrations appliquées). La logique d'activation n'a pas bougé depuis `e8fe0ba` (2026-04-29). | Appel | Réponse | |---|---| | activate `machine-A` | 200, jeton | | deactivate `machine-A` | 200 `{"ok":true}` | | activate `machine-A` | **500** `internal_error` ; Postgres : `duplicate key value violates unique constraint "uq_license_machine"` | | verify | 200, `active_machines: 0` | - **Effet côté client** : cette PR classe ce 500 en `server`, et la carte affiche « Le serveur de licences a rencontré une erreur. Réessayez plus tard. » Réessayer ne change rien, et chaque essai consomme le budget partagé de 5 requêtes par minute. L'ancien client recevait le même 500, affiché « Activation failed » : ce n'est pas une régression de la PR, et le client n'a aucun moyen de le contourner. - **Parcours touchés** : - désactiver cette machine, puis cliquer « Activer ». C'est le chemin qu'épingle le nouveau test de `b8e67c3` (`useLicenseMachines.test.ts:372`) : le bouton revient, mais il mène à ce 500 ; - revenir sur un poste libéré depuis un autre : le parcours D2 (409 → liste → désactivation → activation) finit en 500 dès que la machine qui s'active a déjà été désactivée une fois. - Le défaut existe depuis la première migration, et aucune issue de maximus-api ne le couvre (recherche : `uq_license_machine`, `réactiv`, `deactivat`). - **À faire** : ouvrir une issue dans maximus-api, prérequis du go-live (maximus/maximus-api#62). Piste : après le contrôle de limite, réactiver la ligne existante (`UPDATE … SET is_active = true, machine_name = …, last_seen_at = now()`, ou `INSERT … ON CONFLICT (license_id, machine_id) DO UPDATE`), avec le test d'intégration activate → deactivate → activate = 200. ### Ce que j'ai vérifié - **Retouches de `b8e67c3`** : 1. le cas négatif de l'idempotence est un `404 license_not_found`, attendu en erreur ; 2. le nouveau test désactive `"this"` et vérifie `is_activated: false` après la relecture du statut ; 3. la puce « Acheter » est reformulée en FR et en EN, et le commentaire de `purchaseUrl.test.ts` est passé au passé. Sondé ce matin : `/fr/produits/simpl-resultat` et `/en/produits/simpl-resultat` répondent 200 sans redirection, `/simpl-resultat` aboutit en 200 après deux redirections. La puce est exacte ; 4. `selectCanActivate` est câblé en `LicenseCard.tsx:160`. - **Contrat serveur**, relu dans maximus-api (`routes/licenses.ts`, `services/licenseService.ts`, `types/index.ts`, migration `0000`) : - codes et statuts d'activate et de deactivate ; verify toujours 200, avec `reason` quand la clé n'est pas valide ; - `product` optionnel, `simpl-resultat` seule valeur admise ; - `activated_at` et `last_seen_at` sont `NOT NULL` : aucune machine ne peut disparaître en silence dans le `filter_map` de `machines_from_verify`. - **CI au head**, dans les logs : - run 426 (`check-rust`) : `HEAD is now at b8e67c3`, `Suppression guard: 2 checks, exit 0`, les 15 `license_server_tests` passent, `126 passed`, aucun avertissement rustc ; - run 425 (`check-frontend`) : même head, build OK, 63 fichiers, **1245** tests vitest. - **En local**, sur une copie `git archive` hors dépôt : 126/126 tests Rust, 64/64 tests vitest pour les trois nouveaux fichiers. - **i18n rendu par la vraie instance** (`src/i18n/config.ts`), et pas seulement lu dans les JSON : les 14 codes produisent une phrase en FR et en EN, sans clé brute ; « Réessayez dans 1 seconde » / « 2 secondes », « Try again in 1 second » / « 2 seconds ». - **Appelants** : `useLicenseMachines` est le seul consommateur des trois wrappers. `refresh()` ne rejette jamais, et `LOAD_START` conserve l'édition : la section Machines ne disparaît pas pendant le rafraîchissement. - **Divers** : commits conventionnels, `Fixes #270` dans le message de `7d667f7`, aucun `.only` ni `.skip`, aucune sortie de débogage, aucun secret, aucune migration. Le guide utilisateur ne décrit ni la liste des machines ni les messages d'erreur : rien à y reporter. ### Mutations du relecteur Chaque mutation est appliquée sur la copie hors dépôt, puis annulée. La copie finale est identique octet pour octet à `b8e67c3` (`cmp`), et l'arbre du dépôt n'a pas bougé. Les builds Rust mutés utilisent un `CARGO_TARGET_DIR` séparé : aucun artefact muté n'atterrit dans `src-tauri/target`. | Mutation | Résultat | |---|---| | TS : `selectCanActivate` sans `!state.listLoading` (retouche 4) | 1 rouge sur 38 (conforme) | | TS : `performDeactivate` sans `loadActivationStatus` (retouche 2) | 1 rouge sur 38 (conforme) | | Rust : l'idempotence avale aussi `license_not_found` (retouche 1) | 1 rouge sur 29 (conforme) | | **Rust : un 2xx illisible lu comme `Null` au lieu d'`InvalidResponse`** (`post_license_api`) | **29/29 verts** | | **Rust : `"machine_name": null` envoyé quand le nom d'hôte est illisible** | **29/29 verts** | ### Suggestions (non bloquantes) 1. **Le bouton Activer reste actif pendant une désactivation** (`useLicenseMachines.ts:143-145`). - `selectCanActivate` ne regarde que `activating` et `listLoading`. Or, dans le parcours D2, le clic qui suit une désactivation est « Activer ». - Rejoué sur le vrai reducer avec des promesses différées. Pendant la désactivation, `canActivate` vaut `true`. Si le serveur traite l'activation avant la désactivation (deux connexions distinctes, rien ne fixe l'ordre), l'activation reçoit un 409, puis `DEACTIVATE_DONE` efface ce message de limite. - État final : aucune erreur affichée, machine non activée, et 5 appels déjà consommés. Le clic suivant sur « Activer » est le 6e appel : il reçoit un 429 tant que la fenêtre d'une minute n'est pas écoulée. - Le cas est improbable, mais c'est le budget que la PR protège. Correctif : `&& state.deactivatingId === null` dans `selectCanActivate` (et `&& !state.activating` dans `selectCanDeactivate`, par symétrie), avec un test sur le modèle de ceux de `b8e67c3`. 2. **Deux comportements Rust corrects mais non épinglés** (les deux mutations survivantes). - **Lecture stricte d'un 2xx** (`license_commands.rs:459-462`). Pour activate et verify, un corps illisible échoue plus loin (jeton ou `valid` absent). Pour deactivate, il deviendrait un succès : un 200 non JSON (page de blocage d'un proxy d'entreprise) « désactiverait » la machine, et supprimerait le jeton local s'il s'agit de celle-ci, sans que le serveur ait rien reçu. Test : deactivate → 200 `text/html` → `InvalidResponse`. - **Omission de `machine_name`** quand le nom d'hôte est illisible (l.485-487). Sondé dans la même reproduction : `"machine_name": null` reçoit `400 invalid_input` (`Expected string, received null`). Test : `Matcher::Json` exact, et non `PartialJson`, sur un appel `activate_inner(…, None)`.
maximus added 1 commit 2026-09-27 13:02:11 +00:00
fix(license): apply the third review of #359
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m11s
0c9b8f8f1a
- The Activate button also stays disabled while a deactivation runs
  (selectCanActivate): an activation racing the deactivation after a
  409 could reach the server first, clear the machine-limit message and
  leave the next click to a 429.
- activate_body builds the activation request and omits machine_name
  when the host name is unreadable; the server answers 400 invalid_input
  to a null. A pure test pins it.
- A deactivation whose 2xx body cannot be read stays an invalid_response.
  Counting it as a success would make the command delete this machine's
  local token for nothing. A mock-server test pins it.

Proven red on the fixed tree: Activate live during a deactivation 1,
machine_name sent as null 1, unreadable 2xx body read as empty 1.

The same review found that maximus-api answers 500 to the reactivation
of a machine deactivated before (unique constraint without is_active):
tracked in maximus/maximus-api#70, outside this PR.

Refs #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

Les deux suggestions de la 3e passe (commentaire 16179) sont appliquées dans 0c9b8f8, et son constat principal est ouvert côté serveur.

Suggestion Correctif
Bouton Activer actif pendant une désactivation selectCanActivate exige aussi deactivatingId === null. Testé sur la séquence 409 → liste chargée → désactivation en cours.
Réponse 2xx illisible lue comme vide Test mockito : une désactivation qui répond 200 avec un corps HTML reste invalid_response, et le jeton local n'est jamais supprimé pour rien.
machine_name: null sur un nom d'hôte illisible Le corps est construit par activate_body, fonction pure testée : le champ est omis, jamais envoyé à null.

Preuve du rouge sur l'arbre corrigé

Mutation Avant Mutation Après
bouton Activer actif pendant une désactivation 39/39 verts 1 rouge 39/39 verts
machine_name envoyé à null (survivait) 17/17 verts 1 rouge 17/17 verts
corps 2xx illisible lu comme vide (survivait) 17/17 verts 1 rouge 17/17 verts

Constat serveur

Réactiver une machine déjà désactivée répond 500, à cause de la contrainte uq_license_machine sans condition sur is_active. Vérifié dans la source de maximus-api au commit ef50dec : ouvert en maximus/maximus-api#70 (upsert onConflictDoUpdate recommandé, sans migration), et ajouté comme prérequis sur maximus/maximus-api#62. Hors de cette PR : l'ancien client recevait le même 500.

CI au head 0c9b8f8

Vérifiée dans les logs, les deux jobs sur HEAD is now at 0c9b8f8 :

  • run 428 (check-rust) : Suppression guard: 2 checks, exit 0, 128 passed ;
  • run 427 (check-frontend) : build OK, 1246 tests vitest.

Code modifié depuis l'approbation : #270 repasse en status:review.

Les deux suggestions de la 3e passe (commentaire 16179) sont appliquées dans `0c9b8f8`, et son constat principal est ouvert côté serveur. | Suggestion | Correctif | |---|---| | Bouton Activer actif pendant une désactivation | `selectCanActivate` exige aussi `deactivatingId === null`. Testé sur la séquence 409 → liste chargée → désactivation en cours. | | Réponse 2xx illisible lue comme vide | Test mockito : une désactivation qui répond 200 avec un corps HTML reste `invalid_response`, et le jeton local n'est jamais supprimé pour rien. | | `machine_name: null` sur un nom d'hôte illisible | Le corps est construit par `activate_body`, fonction pure testée : le champ est omis, jamais envoyé à `null`. | ## Preuve du rouge sur l'arbre corrigé | Mutation | Avant | Mutation | Après | |---|---|---|---| | bouton Activer actif pendant une désactivation | 39/39 verts | **1 rouge** | 39/39 verts | | `machine_name` envoyé à `null` (survivait) | 17/17 verts | **1 rouge** | 17/17 verts | | corps 2xx illisible lu comme vide (survivait) | 17/17 verts | **1 rouge** | 17/17 verts | ## Constat serveur Réactiver une machine déjà désactivée répond 500, à cause de la contrainte `uq_license_machine` sans condition sur `is_active`. Vérifié dans la source de maximus-api au commit `ef50dec` : ouvert en **maximus/maximus-api#70** (upsert `onConflictDoUpdate` recommandé, sans migration), et ajouté comme prérequis sur maximus/maximus-api#62. Hors de cette PR : l'ancien client recevait le même 500. ## CI au head `0c9b8f8` Vérifiée dans les logs, les deux jobs sur `HEAD is now at 0c9b8f8` : - run 428 (`check-rust`) : `Suppression guard: 2 checks, exit 0`, **128 passed** ; - run 427 (`check-frontend`) : build OK, **1246** tests vitest. Code modifié depuis l'approbation : #270 repasse en `status:review`.
maximus merged commit 0c9b8f8f1a into main 2026-09-27 13:16:02 +00:00
maximus deleted branch issue-270-licence-v1-errors 2026-09-27 13:16:03 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#359
No description provided.