fix(license): call /v1 routes with product, name server refusals, localize purchase URL #359
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#359
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-270-licence-v1-errors"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #270
Les trois commandes réseau de licence appelaient les chemins historiques
/licenses/*(un 308 à chaque appel, produit deviné du préfixeSR-) 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 pileplanned-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.LicenseError, sérialisé en JSON{code}dans leErrcommeFetchPriceError(13 variantes,rate_limit+retry_after_s,server+status). Seulerror.codede l'enveloppe décide : un 404 estlicense_not_foundouno_active_activation, jamais une route absente.server, pasinvalid_response.{valid:false, reason}devientlicense_not_found/license_revoked/license_expiredau lieu d'une liste vide ; une réponse sansvalidbooléen ou sansmachinesestinvalid_response.activate_inner,deactivate_inner,verify_innersansAppHandle; les commandes Tauri gardentResult<_, String>. Les étapes locales ont leur code : clé absenteno_license_stored, fichierstorage,machine_idou clé publiqueinternal, jeton refusé localementinvalid_response.TS
licenseService.ts:parseLicenseError(calqué surparseRustError, repli uniqueunknown, 60 s par défaut pourrate_limit),LICENSE_ERROR_I18N_MAP, wrappers typés{ ok, value } | { ok, error }.useLicenseMachinesà reducer exporté : erreur d'activation distincte de l'erreur de liste, aucun chargement de liste ne l'efface ;machine_limit_reachedouvre la liste (D2) ; une désactivation retire la machine localement sans relancer verify ;refresh()duLicenseContextsuit chaque activation ou désactivation réussie ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet (StrictMode).LicenseCard.tsxconsomme 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.selectCanDeactivate, depuisa051b96) ; le bouton Activer l'est pendant une activation, un rechargement ou une désactivation (selectCanActivate, depuisb8e67c3et0c9b8f8).src/shared/purchaseUrl.ts:/{fr|en}/produits/simpl-resultat, avec un commentaire de contrat qui renvoie à maximus/la-compagnie-maximus#153.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(commandeslicense_commands.rs+ contrat d'erreur, tableau des hooks, lignelicenseService.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_innerrenvoie toujours le code (testé) ; la décision vit dansdeactivate_idempotent, sansAppHandle, testée contre mockito (depuisa051b96). Seule la ligne dedeactivate_machine_implqui l'appelle reste hors test, car elle dépend d'AppHandle. La clélicense.errors.noActiveActivationreste dans la table. Relevé par le vérificateur de plan, consigné au CHANGELOG et dans l'architecture.Tests
cargo testlicense_server_tests)licenseService22,useLicenseMachines39,purchaseUrl4)npm run buildCouverture des refus : table pure de
error_from_response(les 6 codes de l'enveloppe, 429 avec et sansretry_after, code inconnu, 502 sans corps), table demachines_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
rateLimitgarde{{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) :
/licenses/activateproducterror.as_str())valid:falselu comme une liste videactivate_innerLIST_LOAD_STARTefface l'erreur d'activationrefresh()après une activationHors périmètre
Réactiver une machine déjà désactivée répond 500 côté serveur (contrainte
uq_license_machinesans condition suris_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
UpsellGatedearchitecture.mdqui cite encore #270 y est réécrite).Hachage de l'identifiant machine → #350, avant le go-live.
🤖 Generated with Claude Code
- 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>CI verte, vérifiée dans les logs et pas seulement par le statut du job :
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.check-frontend) : build OK, 63 fichiers, 1237 tests vitest OK.Mêmes comptes qu'en local (126 Rust, 1237 vitest).
Revue adversariale — PR #359 (#270)
Verdict : APPROVE
Résumé. Le passage aux routes
/v1avecproduct, le contratLicenseErroret le reducer du parcours D2 sont corrects. Je les ai recoupés avec la source de maximus-api, et la CI est verte au head7d667f7. 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é
maximus-api(routes/licenses.ts,services/licenseService.ts,middleware/rateLimit.ts,index.ts) plutôt que repris du corps de l'issue :reasonquand la clé n'est pas valide ;retry_afterest un entier ≥ 1 placé danserror;productest optionnel, etsimpl-resultatest la seule valeur admise ;404 {"error":{"code":"not_found"}}, que le client classe bien enserver, pas enlicense_not_found.{ ok: false }.useLicenseMachinesest leur seul consommateur, donc aucun appelant ne tombe dans un succès silencieux.store_activation_tokenn'a aucun appelant TS : le nouveau format de son erreur ne casse rien./v1/licenses/*, etrefresh()commeget_activation_statusrestent en IPC locale. Le parcours D2 tient en 4 appels, épinglé par le test de bout en bout.refreshest stable (useCallback([])) et ne rejette jamais :void activate()ne peut pas produire de rejet non géré.126 passed, et le garde-fou apparaît dans le log (Suppression guard: 2 checks, exit 0) ;git archivehors dépôt : 15/15license_server_tests, 56/56 tests vitest pour les trois nouveaux fichiers./fr/produits/simpl-resultatet/en/produits/simpl-resultatrépondent 200. L'ancienne/simpl-resultatredirige puis tombe en 404.Fixes #270dans 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é.
DEACTIVATE_DONEne retire plus la machineperformDeactivatesansrefresh()ACTIVATE_ERRORouvre la liste pour tout refusmachine_limit_reached→license.errors.serverlicense_expiredlu commeLicenseRevokedvalid:truesansmachines→ liste videmachine_nameLicenseNotFoundlicense_expired→license.errors.licenseRevokeddeactivate_machine_implsansdeactivation_outcomeshouldLoadOnOpensans|| listError !== null,selectShowEmptyStatesans!listLoading, verify sanstrim(), 429 sans codeSuggestions (non bloquantes)
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_entitlementsrend la même édition avec un jeton d'activation valide et sans jeton. C'est ce que prouvent les tests existantsmachine_match_keeps_edition_and_featuresetmissing_activation_token_keeps_edition_and_features.get_editionne 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.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.Couvrir le 404 dans l'autre sens. Le code de
error_from_responseest correct, mais aucun test ne le protège :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 ;{"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 ;(404, envelope("not_found"), server 404)et(404, Null, server 404).Le test « maps %s to its own license.errors key » est tautologique (
licenseService.test.ts:39).err.i18nKeyàLICENSE_ERROR_I18N_MAP[code], donc la table à elle-même.license_expiredsur la clélicenseRevokedlaisse les 56 tests verts, et un utilisateur dont la clé a expiré lirait « révoquée ».Le câblage de la désactivation idempotente n'est pas couvert.
deactivation_outcomeest testée, mais retirer son appel dansdeactivate_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.Course mineure sur D2.
LIST_LOAD_DONEpeut voir la machine réapparaître : la réponse de verify l'inclut encore.disabled={machines.deactivatingId !== null || machines.listLoading}(LicenseCard.tsx:230).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.- 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>Les six suggestions de la revue sont appliquées dans
a051b96.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.every_envelope_refusal_maps_to_its_code:404 {"error":{"code":"not_found"}}et404sans corps, attendusserver.deactivate_idempotent(sansAppHandle), testé contre mockito :404 no_active_activation→ fait,403 license_revoked→ erreur.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 ».rateLimit_one/rateLimit_otheraveccount; 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.
LicenseNotFound(survivait)license_expired→ clélicenseRevoked(survivait)deactivate_idempotentsans l'idempotence (survivait)params {seconds}au lieu de{count}Reste non couvert : la ligne de
deactivate_machine_implqui appelledeactivate_idempotent. Elle dépend d'AppHandle: la remplacer par un appel direct àdeactivate_innerlaisserait la suite verte.CI au head
a051b96Vérifiée dans les logs :
check-rust) :Suppression guard: 2 checks, exit 0,deactivation_treats_an_already_free_slot_as_done ... ok, 126 passed ;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.Revue adversariale — PR #359 (#270), re-revue au head
a051b96Verdict : 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é
7d667f7..a051b96) :deactivate_idempotentremplacedeactivation_outcomeet passe par mockito ;not_found, corps vide) ;selectCanDeactivateest câblé enLicenseCard.tsx:231;rateLimit_one/rateLimit_other;maximus-apiau commitebec4bb(identique aumaindistant) plutôt que repris de l'issue :license_not_found, 403license_revokedoulicense_expired, 409machine_limit_reached(avecmachines), 200{activation_token}. Une machine déjà active reçoit un nouveau jeton en 200, jamais un 409.license_not_found, 404no_active_activation, 200{ok:true}. La route ne contrôle ni la révocation ni l'expiration (src/services/licenseService.ts:271-307).reasonvaut exactementnot_found,revokedouexpired, les trois chaînes que litmachines_from_verify.productestz.enum(["simpl-resultat"]).optional(), et les objets ne sont pas stricts.machine_nameestz.string().optional(), sans longueur maximale. Le client omet ce champ au lieu d'envoyernull, que zod refuserait.licenses.use("/*")limite à 5 requêtes/min par IP (x-forwarded-for), dans une fenêtreminuteque partagent activate, verify et deactivate./v1/pricescompte sur la clélic:<id>, donc dans un autre seau.refresh()(get_edition+read_license) etget_activation_statusrestent locaux : le parcours D2 tient en 4 appels.404 {"error":{"code":"not_found"}}donneserver, désormais épinglé.a051b96, posé directement surmain7b852c9(inchangé) : l'arbre testé est donc l'arbre après merge.Suppression guard: 2 checks, exit 0, les 15license_server_testspassent,126 passed. Aucun avertissement rustc, seulement les 12 avertissements autorisés de cargo-audit.git archivehors 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 ».useLicenseMachinesest le seul consommateur des trois wrappers.store_activation_tokenn'a aucun appelant TS (seulement enregistré enlib.rs:456) : son nouveau format d'erreur ne casse rien.refresh()fait passer le contexte par l'étatloading.UsersSettingsPagen'est sous aucunRequireFeature: la carte n'est ni démontée ni remise à zéro. Seul leNavLockde la barre latérale masque son cadenas le temps de l'IPC.Fixes #270dans le message de7d667f7. Aucun.onlyni.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é./licenses/activateLicenseNotFoundproductlicense_expired→ clélicenseRevokedparams: { seconds }au lieu de{ count }performDeactivatesansrefresh()deactivate_idempotentavale aussiLicenseNotFoundperformDeactivatesansloadActivationStatusSuggestions (non bloquantes)
Épingler l'autre 404 de deactivate (
license_commands.rs:1091).deactivation_treats_an_already_free_slot_as_doneest un 403license_revoked, que la route deactivate ne renvoie jamais (voir le contrat ci-dessus).Err(NoActiveActivation) | Err(LicenseNotFound) => Ok(())laisse les 15 tests verts.404 {"error":{"code":"license_not_found"}}, avecErr(LicenseError::LicenseNotFound)attendu.Épingler la relecture du statut d'activation après une désactivation (
useLicenseMachines.ts:197).getActivationStatusdu harnais renvoie toujoursis_activated: true(useLicenseMachines.test.ts:255), et aucun test ne désactive"this".{ is_activated: false, machine_id: "this" };"this";getState().activation?.is_activatedvautfalse.CHANGELOG — la puce « Acheter » n'est plus exacte (
CHANGELOG.md:10,CHANGELOG.fr.md:10)./simpl-resultatré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/….purchaseUrl.test.ts:4(« a path the site never served »).(Faible) Course symétrique sur le bouton Activer (
LicenseCard.tsx:159).activatingdé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_DONErevalide une liste queACTIVATE_DONEvenait d'invalider.TOGGLE_LIST,LIST_LOAD_START,ACTIVATE_START,ACTIVATE_DONE,LIST_LOAD_DONE([a, b])donne{ listOpen: false, listLoaded: true, machines: [a, b] }, etshouldLoadOnOpenvautfalse.disabled={machines.activating || machines.listLoading}, sur le modèle deselectCanDeactivate.Les quatre suggestions de la re-revue (commentaire 16079) sont appliquées dans
b8e67c3.deactivation_treats_an_already_free_slot_as_donepasse du 403license_revoked(que la route ne renvoie jamais) au404 license_not_found, attendu en erreur : une clé inconnue n'est jamais « désactivée » et ne supprime jamais le jeton local."this"fait repasseractivation.is_activatedàfalse, et donc revenir le bouton Activer./simpl-resultat→ 308 →/produits/simpl-resultat→ 200 en FR comme en EN. Le commentaire depurchaseUrl.test.tsest passé au passé.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.
license_not_found(survivait)CI au head
b8e67c3Vérifiée dans les logs, les deux jobs sur
HEAD is now at b8e67c3:check-rust) :Suppression guard: 2 checks, exit 0, 126 passed ;check-frontend) : build OK, 1245 tests vitest.Code modifié depuis l'approbation : #270 repasse en
status:review.Revue adversariale — PR #359 (#270), 3e passe au head
b8e67c3Verdict : APPROVE
Résumé. Les quatre retouches de
b8e67c3sont 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_activationsporteCONSTRAINT "uq_license_machine" UNIQUE("license_id","machine_id"), sans condition suris_active(drizzle/0000_ambitious_lyja.sql:9) ;deactivateLicensene supprime pas la ligne, il poseis_active = false(src/services/licenseService.ts:285-295) ;activateLicensene cherche que la ligne active de la machine (l.188-199), puis fait unINSERTsimple (l.255-260). Pour une machine déjà désactivée une fois, l'insert viole la contrainte, etonErrorrépond500 {"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é depuise8fe0ba(2026-04-29).machine-Amachine-A{"ok":true}machine-Ainternal_error; Postgres :duplicate key value violates unique constraint "uq_license_machine"active_machines: 0Effet 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 :
b8e67c3(useLicenseMachines.test.ts:372) : le bouton revient, mais il mène à ce 500 ;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(), ouINSERT … ON CONFLICT (license_id, machine_id) DO UPDATE), avec le test d'intégration activate → deactivate → activate = 200.Ce que j'ai vérifié
b8e67c3:404 license_not_found, attendu en erreur ;"this"et vérifieis_activated: falseaprès la relecture du statut ;purchaseUrl.test.tsest passé au passé. Sondé ce matin :/fr/produits/simpl-resultatet/en/produits/simpl-resultatrépondent 200 sans redirection,/simpl-resultataboutit en 200 après deux redirections. La puce est exacte ;selectCanActivateest câblé enLicenseCard.tsx:160.routes/licenses.ts,services/licenseService.ts,types/index.ts, migration0000) :reasonquand la clé n'est pas valide ;productoptionnel,simpl-resultatseule valeur admise ;activated_atetlast_seen_atsontNOT NULL: aucune machine ne peut disparaître en silence dans lefilter_mapdemachines_from_verify.check-rust) :HEAD is now at b8e67c3,Suppression guard: 2 checks, exit 0, les 15license_server_testspassent,126 passed, aucun avertissement rustc ;check-frontend) : même head, build OK, 63 fichiers, 1245 tests vitest.git archivehors dépôt : 126/126 tests Rust, 64/64 tests vitest pour les trois nouveaux fichiers.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 ».useLicenseMachinesest le seul consommateur des trois wrappers.refresh()ne rejette jamais, etLOAD_STARTconserve l'édition : la section Machines ne disparaît pas pendant le rafraîchissement.Fixes #270dans le message de7d667f7, aucun.onlyni.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 unCARGO_TARGET_DIRséparé : aucun artefact muté n'atterrit danssrc-tauri/target.selectCanActivatesans!state.listLoading(retouche 4)performDeactivatesansloadActivationStatus(retouche 2)license_not_found(retouche 1)Nullau lieu d'InvalidResponse(post_license_api)"machine_name": nullenvoyé quand le nom d'hôte est illisibleSuggestions (non bloquantes)
useLicenseMachines.ts:143-145).selectCanActivatene regarde queactivatingetlistLoading. Or, dans le parcours D2, le clic qui suit une désactivation est « Activer ».canActivatevauttrue. Si le serveur traite l'activation avant la désactivation (deux connexions distinctes, rien ne fixe l'ordre), l'activation reçoit un 409, puisDEACTIVATE_DONEefface ce message de limite.&& state.deactivatingId === nulldansselectCanActivate(et&& !state.activatingdansselectCanDeactivate, par symétrie), avec un test sur le modèle de ceux deb8e67c3.license_commands.rs:459-462). Pour activate et verify, un corps illisible échoue plus loin (jeton ouvalidabsent). 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 → 200text/html→InvalidResponse.machine_namequand le nom d'hôte est illisible (l.485-487). Sondé dans la même reproduction :"machine_name": nullreçoit400 invalid_input(Expected string, received null). Test :Matcher::Jsonexact, et nonPartialJson, sur un appelactivate_inner(…, None).Les deux suggestions de la 3e passe (commentaire 16179) sont appliquées dans
0c9b8f8, et son constat principal est ouvert côté serveur.selectCanActivateexige aussideactivatingId === null. Testé sur la séquence 409 → liste chargée → désactivation en cours.invalid_response, et le jeton local n'est jamais supprimé pour rien.machine_name: nullsur un nom d'hôte illisibleactivate_body, fonction pure testée : le champ est omis, jamais envoyé ànull.Preuve du rouge sur l'arbre corrigé
machine_nameenvoyé ànull(survivait)Constat serveur
Réactiver une machine déjà désactivée répond 500, à cause de la contrainte
uq_license_machinesans condition suris_active. Vérifié dans la source de maximus-api au commitef50dec: ouvert en maximus/maximus-api#70 (upsertonConflictDoUpdaterecommandé, 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
0c9b8f8Vérifiée dans les logs, les deux jobs sur
HEAD is now at 0c9b8f8:check-rust) :Suppression guard: 2 checks, exit 0, 128 passed ;check-frontend) : build OK, 1246 tests vitest.Code modifié depuis l'approbation : #270 repasse en
status:review.