From a051b96c879719825234725d2fc7ff8eb4ef0429 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 26 Sep 2026 20:27:43 -0400 Subject: [PATCH] fix(license): apply the review of #359 - 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) --- CHANGELOG.fr.md | 1 - CHANGELOG.md | 1 - src-tauri/src/commands/license_commands.rs | 47 +++++++++++---- src/components/settings/LicenseCard.tsx | 3 +- src/hooks/useLicenseMachines.test.ts | 33 ++++++++++- src/hooks/useLicenseMachines.ts | 10 ++++ src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- src/services/licenseService.test.ts | 69 +++++++++++++--------- src/services/licenseService.ts | 9 ++- 10 files changed, 132 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index c3e7be1..821c7cb 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -7,7 +7,6 @@ - L'activation, la désactivation et la liste des machines passent désormais par les points d'accès `/v1` du serveur de licences et nomment le produit, au lieu de passer par une redirection à chaque appel (#270). - Un refus du serveur de licences est désormais expliqué dans votre langue : clé révoquée, expirée ou introuvable, nombre maximal de machines atteint, trop de tentatives (avec le délai à attendre), serveur injoignable. Il s'affichait auparavant en anglais sous un « Activation failed » générique, et une clé révoquée apparaissait comme « Aucune machine activée » (#270). - Quand le nombre maximal de machines est atteint, la liste des machines s'ouvre d'elle-même pour en désactiver une, et le message reste affiché pendant son chargement. Désactiver une machine ne réinterroge plus le serveur, et désactiver une machine que le serveur considère déjà libérée compte désormais comme réussi (#270). -- L'édition affichée se met désormais à jour dès l'activation ou la désactivation d'une machine, sans redémarrer l'application (#270). - Le bouton « Acheter » de la carte Licence ouvrait une page qui n'existe pas ; il ouvre désormais la page produit dans la langue de l'application (#270). ### Sécurité diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b9d10..51ec66f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ - Machine activation, deactivation and the machine list now call the license server's current `/v1` endpoints and name the product explicitly, instead of going through a redirect on every call (#270). - A refusal from the license server is now explained in your language: revoked, expired or unknown key, machine limit reached, too many attempts (with the wait time), server unreachable. It used to show a generic "Activation failed" in English, and a revoked key showed up as "No machines activated" (#270). - When the machine limit is reached, the machine list opens by itself so you can deactivate one, and the message stays visible while it loads. Deactivating a machine no longer queries the server a second time, and deactivating a machine the server already considers free now counts as done (#270). -- The edition shown now updates right after a machine activation or deactivation, without restarting the app (#270). - The "Buy" button of the License card opened a page that does not exist; it now opens the product page in the app's language (#270). ### Security diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 14fbbfe..b0c603b 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -493,7 +493,7 @@ async fn activate_inner( } /// Deactivation call (frees the slot of `machine_id`). Returns `NoActiveActivation` as -/// the server says it; [`deactivation_outcome`] decides what that means for the user. +/// the server says it; [`deactivate_idempotent`] decides what that means for the user. async fn deactivate_inner( api_base: &str, license_key: &str, @@ -545,10 +545,15 @@ fn machines_from_verify(body: &serde_json::Value) -> Result, Li } } -/// Deactivation is idempotent: `no_active_activation` means the slot is already free, -/// which is the state the user asked for. -fn deactivation_outcome(result: Result<(), LicenseError>) -> Result<(), LicenseError> { - match result { +/// Deactivation as the command performs it: idempotent, since `no_active_activation` +/// means the slot is already free, which is the state the user asked for. Kept apart +/// from the Tauri command, without `AppHandle`, so tests run it against a mock server. +async fn deactivate_idempotent( + api_base: &str, + license_key: &str, + machine_id: &str, +) -> Result<(), LicenseError> { + match deactivate_inner(api_base, license_key, machine_id).await { Err(LicenseError::NoActiveActivation) => Ok(()), other => other, } @@ -621,7 +626,7 @@ pub async fn deactivate_machine( async fn deactivate_machine_impl(app: &tauri::AppHandle, machine_id: &str) -> Result<(), LicenseError> { let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?; - deactivation_outcome(deactivate_inner(&api_base_url(), &license_key, machine_id).await)?; + deactivate_idempotent(&api_base_url(), &license_key, machine_id).await?; // If this machine was deactivated, remove the local activation token let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?; @@ -1025,6 +1030,10 @@ mod license_server_tests { json!({"code": "machine_limit_reached"}), ), (404, envelope("no_active_activation"), json!({"code": "no_active_activation"})), + // A missing route (maximus-api answers `{"error":{"code":"not_found"}}`) or the + // proxy's 404 page during a redeploy: a server error, never "key not found". + (404, envelope("not_found"), json!({"code": "server", "status": 404})), + (404, serde_json::Value::Null, json!({"code": "server", "status": 404})), ( 400, json!({"error": {"code": "invalid_input", "message": "Invalid input"}, "details": {"fieldErrors": {}}}), @@ -1078,12 +1087,28 @@ mod license_server_tests { assert_eq!(listed[0].machine_name.as_deref(), Some("laptop")); } - #[test] - fn deactivation_treats_an_already_free_slot_as_done() { - assert_eq!(deactivation_outcome(Err(LicenseError::NoActiveActivation)), Ok(())); - assert_eq!(deactivation_outcome(Ok(())), Ok(())); + #[tokio::test] + async fn deactivation_treats_an_already_free_slot_as_done() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/v1/licenses/deactivate") + .with_status(404) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"no_active_activation","message":"No active activation found for this machine"}}"#) + .create_async() + .await; + assert_eq!(deactivate_idempotent(&server.url(), "SR-BASE-k", "machine-2").await, Ok(())); + + let mut revoked = mockito::Server::new_async().await; + let _r = revoked + .mock("POST", "/v1/licenses/deactivate") + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#) + .create_async() + .await; assert_eq!( - deactivation_outcome(Err(LicenseError::LicenseRevoked)), + deactivate_idempotent(&revoked.url(), "SR-BASE-k", "machine-2").await, Err(LicenseError::LicenseRevoked) ); } diff --git a/src/components/settings/LicenseCard.tsx b/src/components/settings/LicenseCard.tsx index 21a88d3..49fa87e 100644 --- a/src/components/settings/LicenseCard.tsx +++ b/src/components/settings/LicenseCard.tsx @@ -21,6 +21,7 @@ export default function LicenseCard() { deactivate, toggleList, showEmptyState, + canDeactivate, } = useLicenseMachines(hasLicense); const handleSubmit = async (e: React.FormEvent) => { @@ -227,7 +228,7 @@ export default function LicenseCard() {