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
10 changed files with 132 additions and 47 deletions
Showing only changes of commit a051b96c87 - Show all commits

View file

@ -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). - 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). - 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). - 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). - 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é ### Sécurité

View file

@ -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). - 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). - 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). - 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). - 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 ### Security

View file

@ -493,7 +493,7 @@ async fn activate_inner(
} }
/// Deactivation call (frees the slot of `machine_id`). Returns `NoActiveActivation` as /// 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( async fn deactivate_inner(
api_base: &str, api_base: &str,
license_key: &str, license_key: &str,
@ -545,10 +545,15 @@ fn machines_from_verify(body: &serde_json::Value) -> Result<Vec<MachineInfo>, Li
} }
} }
/// Deactivation is idempotent: `no_active_activation` means the slot is already free, /// Deactivation as the command performs it: idempotent, since `no_active_activation`
/// which is the state the user asked for. /// means the slot is already free, which is the state the user asked for. Kept apart
fn deactivation_outcome(result: Result<(), LicenseError>) -> Result<(), LicenseError> { /// from the Tauri command, without `AppHandle`, so tests run it against a mock server.
match result { 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(()), Err(LicenseError::NoActiveActivation) => Ok(()),
other => other, other => other,
} }
@ -621,7 +626,7 @@ pub async fn deactivate_machine(
async fn deactivate_machine_impl(app: &tauri::AppHandle, machine_id: &str) -> Result<(), LicenseError> { async fn deactivate_machine_impl(app: &tauri::AppHandle, machine_id: &str) -> Result<(), LicenseError> {
let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?; 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 // If this machine was deactivated, remove the local activation token
let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?; let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?;
@ -1025,6 +1030,10 @@ mod license_server_tests {
json!({"code": "machine_limit_reached"}), json!({"code": "machine_limit_reached"}),
), ),
(404, envelope("no_active_activation"), json!({"code": "no_active_activation"})), (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, 400,
json!({"error": {"code": "invalid_input", "message": "Invalid input"}, "details": {"fieldErrors": {}}}), 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")); assert_eq!(listed[0].machine_name.as_deref(), Some("laptop"));
} }
#[test] #[tokio::test]
fn deactivation_treats_an_already_free_slot_as_done() { async fn deactivation_treats_an_already_free_slot_as_done() {
assert_eq!(deactivation_outcome(Err(LicenseError::NoActiveActivation)), Ok(())); let mut server = mockito::Server::new_async().await;
assert_eq!(deactivation_outcome(Ok(())), Ok(())); 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!( assert_eq!(
deactivation_outcome(Err(LicenseError::LicenseRevoked)), deactivate_idempotent(&revoked.url(), "SR-BASE-k", "machine-2").await,
Err(LicenseError::LicenseRevoked) Err(LicenseError::LicenseRevoked)
); );
} }

View file

@ -21,6 +21,7 @@ export default function LicenseCard() {
deactivate, deactivate,
toggleList, toggleList,
showEmptyState, showEmptyState,
canDeactivate,
} = useLicenseMachines(hasLicense); } = useLicenseMachines(hasLicense);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -227,7 +228,7 @@ export default function LicenseCard() {
<button <button
type="button" type="button"
onClick={() => void deactivate(m.machine_id)} onClick={() => void deactivate(m.machine_id)}
disabled={machines.deactivatingId !== null} disabled={!canDeactivate}
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50" className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
> >
{machines.deactivatingId === m.machine_id && ( {machines.deactivatingId === m.machine_id && (

View file

@ -3,6 +3,7 @@ import {
licenseMachinesReducer, licenseMachinesReducer,
initialLicenseMachinesState, initialLicenseMachinesState,
selectShowEmptyState, selectShowEmptyState,
selectCanDeactivate,
shouldLoadOnOpen, shouldLoadOnOpen,
performActivate, performActivate,
performDeactivate, performDeactivate,
@ -187,6 +188,36 @@ describe("selectShowEmptyState", () => {
}); });
}); });
describe("selectCanDeactivate", () => {
const loaded = reduce([
{ type: "LIST_LOAD_START" },
{ type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] },
]);
it("allows a deactivation on an idle, loaded list", () => {
expect(selectCanDeactivate(loaded)).toBe(true);
});
it("blocks every button while one deactivation is running", () => {
expect(selectCanDeactivate(reduce([{ type: "DEACTIVATE_START", machineId: "a" }], loaded))).toBe(false);
});
it("blocks the buttons while a 409 reloads an already loaded list", () => {
// The reload answers with the list as of its request: a machine deactivated
// before it lands would reappear.
const reloading = reduce(
[
{ type: "ACTIVATE_START" },
{ type: "ACTIVATE_ERROR", error: err("machine_limit_reached") },
{ type: "LIST_LOAD_START" },
],
loaded,
);
expect(reloading.machines).toHaveLength(2);
expect(selectCanDeactivate(reloading)).toBe(false);
});
});
describe("shouldLoadOnOpen", () => { describe("shouldLoadOnOpen", () => {
const loaded = reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }]); const loaded = reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }]);
@ -328,7 +359,7 @@ describe("performDeactivate", () => {
await performDeactivate(deps, "a"); await performDeactivate(deps, "a");
const s = getState(); const s = getState();
expect(s.listError?.code).toBe("rate_limit"); expect(s.listError?.code).toBe("rate_limit");
expect(s.listError?.params).toEqual({ seconds: 25 }); expect(s.listError?.params).toEqual({ count: 25 });
expect(s.deactivatingId).toBeNull(); expect(s.deactivatingId).toBeNull();
expect(deps.refresh).not.toHaveBeenCalled(); expect(deps.refresh).not.toHaveBeenCalled();
}); });

View file

@ -126,6 +126,15 @@ export function selectShowEmptyState(state: LicenseMachinesState): boolean {
); );
} }
/**
* Deactivate buttons are live only when no deactivation and no list reload is running. A
* reload (e.g. the one a 409 triggers over an already loaded list) returns the server's
* list as of its request: a machine deactivated meanwhile would reappear in it.
*/
export function selectCanDeactivate(state: LicenseMachinesState): boolean {
return state.deactivatingId === null && !state.listLoading;
}
/** Whether opening the list (from `state`, before the toggle) should load it. */ /** Whether opening the list (from `state`, before the toggle) should load it. */
export function shouldLoadOnOpen(state: LicenseMachinesState): boolean { export function shouldLoadOnOpen(state: LicenseMachinesState): boolean {
return !state.listOpen && !state.listLoading && (!state.listLoaded || state.listError !== null); return !state.listOpen && !state.listLoading && (!state.listLoaded || state.listError !== null);
@ -229,5 +238,6 @@ export function useLicenseMachines(enabled: boolean) {
deactivate, deactivate,
toggleList, toggleList,
showEmptyState: selectShowEmptyState(state), showEmptyState: selectShowEmptyState(state),
canDeactivate: selectCanDeactivate(state),
}; };
} }

View file

@ -1210,7 +1210,8 @@
"machineLimitReached": "This license has reached its maximum number of machines. Deactivate a machine in the list to free a slot.", "machineLimitReached": "This license has reached its maximum number of machines. Deactivate a machine in the list to free a slot.",
"noActiveActivation": "This machine is not activated for this license.", "noActiveActivation": "This machine is not activated for this license.",
"invalidInput": "The request sent to the license server is invalid.", "invalidInput": "The request sent to the license server is invalid.",
"rateLimit": "Too many attempts. Try again in {{seconds}} seconds.", "rateLimit_one": "Too many attempts. Try again in {{count}} second.",
"rateLimit_other": "Too many attempts. Try again in {{count}} seconds.",
"network": "Cannot reach the license server. Check your internet connection.", "network": "Cannot reach the license server. Check your internet connection.",
"invalidResponse": "The license server returned an unexpected response.", "invalidResponse": "The license server returned an unexpected response.",
"server": "The license server encountered an error. Try again later.", "server": "The license server encountered an error. Try again later.",

View file

@ -1210,7 +1210,8 @@
"machineLimitReached": "Le nombre maximal de machines pour cette licence est atteint. Désactivez une machine dans la liste pour libérer une place.", "machineLimitReached": "Le nombre maximal de machines pour cette licence est atteint. Désactivez une machine dans la liste pour libérer une place.",
"noActiveActivation": "Cette machine n'est pas activée pour cette licence.", "noActiveActivation": "Cette machine n'est pas activée pour cette licence.",
"invalidInput": "La requête envoyée au serveur de licences est invalide.", "invalidInput": "La requête envoyée au serveur de licences est invalide.",
"rateLimit": "Trop de tentatives. Réessayez dans {{seconds}} secondes.", "rateLimit_one": "Trop de tentatives. Réessayez dans {{count}} seconde.",
"rateLimit_other": "Trop de tentatives. Réessayez dans {{count}} secondes.",
"network": "Impossible de joindre le serveur de licences. Vérifiez votre connexion Internet.", "network": "Impossible de joindre le serveur de licences. Vérifiez votre connexion Internet.",
"invalidResponse": "Le serveur de licences a renvoyé une réponse inattendue.", "invalidResponse": "Le serveur de licences a renvoyé une réponse inattendue.",
"server": "Le serveur de licences a rencontré une erreur. Réessayez plus tard.", "server": "Le serveur de licences a rencontré une erreur. Réessayez plus tard.",

View file

@ -17,45 +17,50 @@ vi.mock("@tauri-apps/api/core", () => ({
const mockInvoke = vi.mocked(invoke); const mockInvoke = vi.mocked(invoke);
// Codes the Rust `LicenseError` enum serializes (license_commands.rs). Keep // Codes the Rust `LicenseError` enum serializes (license_commands.rs), each
// this list in step with the enum: a code missing here and in the map would // with the message key written out literally. Comparing against
// fall back to `unknown` and hide the real reason from the user. // LICENSE_ERROR_I18N_MAP itself would be circular: swapping two entries of the
const RUST_CODES: Exclude<LicenseErrorCode, "unknown" | "rate_limit">[] = [ // map (an expired key shown as "revoked") would still pass.
"license_not_found", const EXPECTED_KEYS: [Exclude<LicenseErrorCode, "unknown" | "rate_limit">, string][] = [
"license_revoked", ["license_not_found", "license.errors.licenseNotFound"],
"license_expired", ["license_revoked", "license.errors.licenseRevoked"],
"machine_limit_reached", ["license_expired", "license.errors.licenseExpired"],
"no_active_activation", ["machine_limit_reached", "license.errors.machineLimitReached"],
"invalid_input", ["no_active_activation", "license.errors.noActiveActivation"],
"network", ["invalid_input", "license.errors.invalidInput"],
"invalid_response", ["network", "license.errors.network"],
"server", ["invalid_response", "license.errors.invalidResponse"],
"no_license_stored", ["server", "license.errors.server"],
"storage", ["no_license_stored", "license.errors.noLicenseStored"],
"internal", ["storage", "license.errors.storage"],
["internal", "license.errors.internal"],
]; ];
describe("parseLicenseError", () => { describe("parseLicenseError", () => {
it.each(RUST_CODES)("maps %s to its own license.errors key", (code) => { it.each(EXPECTED_KEYS)("maps %s to %s", (code, key) => {
const err = parseLicenseError(JSON.stringify({ code })); const err = parseLicenseError(JSON.stringify({ code }));
expect(err.code).toBe(code); expect(err.code).toBe(code);
expect(err.i18nKey).toBe(LICENSE_ERROR_I18N_MAP[code]); expect(err.i18nKey).toBe(key);
expect(err.i18nKey).not.toBe(LICENSE_ERROR_I18N_MAP.unknown);
expect(err.params).toBeUndefined(); expect(err.params).toBeUndefined();
}); });
it("gives every code its own message key", () => {
const keys = Object.values(LICENSE_ERROR_I18N_MAP);
expect(new Set(keys).size).toBe(keys.length);
});
it("keeps the server status payload without breaking the mapping", () => { it("keeps the server status payload without breaking the mapping", () => {
const err = parseLicenseError('{"code":"server","status":502}'); const err = parseLicenseError('{"code":"server","status":502}');
expect(err.code).toBe("server"); expect(err.code).toBe("server");
expect(err.i18nKey).toBe("license.errors.server"); expect(err.i18nKey).toBe("license.errors.server");
}); });
it("carries the retry delay of a rate limit into the message params", () => { it("carries the retry delay of a rate limit as the plural count", () => {
const err = parseLicenseError('{"code":"rate_limit","retry_after_s":42}'); const err = parseLicenseError('{"code":"rate_limit","retry_after_s":42}');
expect(err).toEqual({ expect(err).toEqual({
code: "rate_limit", code: "rate_limit",
i18nKey: "license.errors.rateLimit", i18nKey: "license.errors.rateLimit",
params: { seconds: 42 }, params: { count: 42 },
}); });
}); });
@ -65,7 +70,7 @@ describe("parseLicenseError", () => {
'{"code":"rate_limit","retry_after_s":0}', '{"code":"rate_limit","retry_after_s":0}',
'{"code":"rate_limit","retry_after_s":"soon"}', '{"code":"rate_limit","retry_after_s":"soon"}',
]) { ]) {
expect(parseLicenseError(body).params).toEqual({ seconds: 60 }); expect(parseLicenseError(body).params).toEqual({ count: 60 });
} }
}); });
@ -137,19 +142,29 @@ describe("license.errors i18n coverage (fr + en)", () => {
messages, messages,
); );
// i18next resolves a key used with `count` through its plural forms.
const PLURAL_KEYS = new Set(["license.errors.rateLimit"]);
it("resolves every mapped key to a non-empty string in both locales", () => { it("resolves every mapped key to a non-empty string in both locales", () => {
for (const [lng, messages] of Object.entries(locales)) { for (const [lng, messages] of Object.entries(locales)) {
for (const key of Object.values(LICENSE_ERROR_I18N_MAP)) { for (const key of Object.values(LICENSE_ERROR_I18N_MAP)) {
const value = resolve(messages, key); const variants = PLURAL_KEYS.has(key) ? [`${key}_one`, `${key}_other`] : [key];
expect(typeof value, `${key} in ${lng}`).toBe("string"); for (const variant of variants) {
expect((value as string).trim(), `${key} in ${lng}`).not.toBe(""); const value = resolve(messages, variant);
expect(typeof value, `${variant} in ${lng}`).toBe("string");
expect((value as string).trim(), `${variant} in ${lng}`).not.toBe("");
}
} }
} }
}); });
it("keeps the {{seconds}} placeholder in the rate limit message", () => { it("puts the {{count}} placeholder in both plural forms of the rate limit message", () => {
for (const [lng, messages] of Object.entries(locales)) { for (const [lng, messages] of Object.entries(locales)) {
expect(resolve(messages, "license.errors.rateLimit"), lng).toContain("{{seconds}}"); for (const form of ["one", "other"]) {
expect(resolve(messages, `license.errors.rateLimit_${form}`), `${form} in ${lng}`).toContain(
"{{count}}",
);
}
} }
}); });
}); });

View file

@ -77,8 +77,11 @@ export type LicenseErrorCode =
export interface LicenseError { export interface LicenseError {
code: LicenseErrorCode; code: LicenseErrorCode;
i18nKey: string; i18nKey: string;
/** Interpolation values for `i18nKey` — only `rate_limit` carries one. */ /**
params?: { seconds: number }; * Interpolation values for `i18nKey` — only `rate_limit` carries one. `count` selects
* the i18next plural form (`rateLimit_one` / `rateLimit_other`).
*/
params?: { count: number };
} }
export const LICENSE_ERROR_I18N_MAP: Record<LicenseErrorCode, string> = { export const LICENSE_ERROR_I18N_MAP: Record<LicenseErrorCode, string> = {
@ -121,7 +124,7 @@ export function parseLicenseError(e: unknown): LicenseError {
typeof raw === "number" && Number.isFinite(raw) && raw >= 1 typeof raw === "number" && Number.isFinite(raw) && raw >= 1
? Math.ceil(raw) ? Math.ceil(raw)
: DEFAULT_RETRY_AFTER_S; : DEFAULT_RETRY_AFTER_S;
return { code, i18nKey: LICENSE_ERROR_I18N_MAP.rate_limit, params: { seconds } }; return { code, i18nKey: LICENSE_ERROR_I18N_MAP.rate_limit, params: { count: seconds } };
} }
return { code, i18nKey: LICENSE_ERROR_I18N_MAP[code] }; return { code, i18nKey: LICENSE_ERROR_I18N_MAP[code] };
} }