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
3 changed files with 64 additions and 12 deletions
Showing only changes of commit 0c9b8f8f1a - Show all commits

View file

@ -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<String, LicenseError> {
/// 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<String, LicenseError> {
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("<html>OK</html>")
.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;

View file

@ -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.

View file

@ -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. */