diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 67cb668..051d5ca 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -469,14 +469,10 @@ async fn post_license_api( } } -/// Activation call, without `AppHandle` nor disk access so tests can point it at a mock -/// server. Returns the activation token issued for `machine_id`. -async fn activate_inner( - api_base: &str, - license_key: &str, - machine_id: &str, - machine_name: Option<&str>, -) -> Result { +/// Body of an activation request. `machine_name` is omitted, never sent as `null`, when +/// the host name is unreadable: the server's schema (`z.string().optional()`) answers +/// `400 invalid_input` to a null. +fn activate_body(license_key: &str, machine_id: &str, machine_name: Option<&str>) -> serde_json::Value { let mut body = serde_json::json!({ "product": PRODUCT, "license_key": license_key.trim(), @@ -485,6 +481,18 @@ async fn activate_inner( if let Some(name) = machine_name { body["machine_name"] = serde_json::Value::String(name.to_string()); } + body +} + +/// Activation call, without `AppHandle` nor disk access so tests can point it at a mock +/// server. Returns the activation token issued for `machine_id`. +async fn activate_inner( + api_base: &str, + license_key: &str, + machine_id: &str, + machine_name: Option<&str>, +) -> Result { + let body = activate_body(license_key, machine_id, machine_name); let resp = post_license_api(api_base, "/v1/licenses/activate", &body).await?; resp["activation_token"] .as_str() @@ -1115,6 +1123,35 @@ mod license_server_tests { ); } + #[test] + fn activate_body_omits_an_unknown_machine_name() { + let body = activate_body("SR-BASE-k", "machine-1", None); + assert!(body.get("machine_name").is_none(), "body {}", body); + assert_eq!(body["product"], "simpl-resultat"); + assert_eq!( + activate_body("SR-BASE-k", "machine-1", Some("laptop"))["machine_name"], + "laptop" + ); + } + + #[tokio::test] + async fn deactivate_with_an_unreadable_success_body_is_an_invalid_response() { + // A 2xx the client cannot read proves nothing: counting it as a success would + // make the command delete this machine's local token for nothing. + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/v1/licenses/deactivate") + .with_status(200) + .with_header("content-type", "text/html") + .with_body("OK") + .create_async() + .await; + assert_eq!( + deactivate_inner(&server.url(), "SR-BASE-k", "machine-2").await, + Err(LicenseError::InvalidResponse) + ); + } + #[tokio::test] async fn activate_posts_the_trimmed_key_and_product_to_v1() { let mut server = mockito::Server::new_async().await; diff --git a/src/hooks/useLicenseMachines.test.ts b/src/hooks/useLicenseMachines.test.ts index 47f53a9..1afbd06 100644 --- a/src/hooks/useLicenseMachines.test.ts +++ b/src/hooks/useLicenseMachines.test.ts @@ -230,6 +230,20 @@ describe("selectCanActivate", () => { expect(selectCanActivate(reduce([{ type: "ACTIVATE_START" }]))).toBe(false); }); + it("blocks the button while a deactivation is running", () => { + // An activation racing the deactivation after a 409 could reach the server + // first: the machine-limit message would vanish with 5 calls already spent. + const s = reduce( + [ + { type: "LIST_LOAD_START" }, + { type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] }, + { type: "DEACTIVATE_START", machineId: "a" }, + ], + limitHit, + ); + expect(selectCanActivate(s)).toBe(false); + }); + it("blocks the button while the list loads", () => { // Otherwise a verify answer landing after a successful activation would // mark as fresh a list that no longer includes this machine. diff --git a/src/hooks/useLicenseMachines.ts b/src/hooks/useLicenseMachines.ts index 6e33991..e90b817 100644 --- a/src/hooks/useLicenseMachines.ts +++ b/src/hooks/useLicenseMachines.ts @@ -136,12 +136,13 @@ export function selectCanDeactivate(state: LicenseMachinesState): boolean { } /** - * The Activate button is live only when no activation and no list reload is running. - * Same reason as for deactivation: a verify answer landing after a successful - * activation would mark as fresh a list that no longer includes this machine. + * The Activate button is live only when no activation, no list reload and no + * deactivation is running. A verify answer landing after a successful activation would + * mark as fresh a list that no longer includes this machine; an activation racing a + * deactivation could clear the machine-limit message and spend the rate budget. */ export function selectCanActivate(state: LicenseMachinesState): boolean { - return !state.activating && !state.listLoading; + return !state.activating && !state.listLoading && state.deactivatingId === null; } /** Whether opening the list (from `state`, before the toggle) should load it. */