fix(license): drop an activation token that no longer stands for this machine
A data directory written by v0.15.0 or earlier can hold a valid key next
to the activation token of another license, typically a refunded hence
revoked one. The License card read "Activated", market prices were
refused, and "Activate" was never offered: the folder could not recover
without a manual file removal.
- Prices: a 403 license_revoked drops the refused token, and only while
the file still holds it, so an activation that lands while the refused
request is in flight keeps its new token (CWE-367). The key's revoked
state is never written from this route. The token is read through
ACTIVATION_FILE by the directory-based read_stored_activation_token_in,
in fetch_price_in, testable without AppHandle.
- Machine list: list_activated_machines returns { machines,
token_dropped }. A complete list that names this machine under neither
identifier drops a token that activates this machine, when the OS id is
readable and the token is the one on disk when the list was asked for.
An error, a revoked key or a rate limit drops nothing.
machines_from_verify counts the rows it skips, which the list still
tolerates. This also gives "Activate" back to a machine deactivated from
another computer.
- Front: the activation status is read again after every list load; a
fixed log line (ACTIVATION_TOKEN_LOG) is written on token_dropped and on
a price refused as revoked; the revoked-license price message invites to
activate the machine again.
- Docs: architecture, ADR 0017 (#365 amendment), ADR 0020 (rule 4), user
guide and in-app tips, changelogs.
Resolves #367
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e2f0fb86da
commit
ed35570121
16 changed files with 1007 additions and 93 deletions
|
|
@ -18,6 +18,7 @@
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|
||||||
- Une clé de licence révoquée par le serveur de licences et saisie en cours de session fait désormais repasser l'application en édition Gratuite sans attendre : la clé est vérifiée dès son enregistrement, en arrière-plan, au lieu du démarrage suivant. Elle gardait auparavant son édition jusqu'au redémarrage de l'application, ou jusqu'à une tentative d'activation de la machine. La saisie d'une clé rend toujours la main tout de suite, en ligne comme hors ligne, et une clé révoquée affiche son édition une à deux secondes avant de basculer. La vérification part toujours au plus une fois par 24 heures pour une même clé, et chacune est inscrite dans les journaux (Paramètres → Système) (#373).
|
- Une clé de licence révoquée par le serveur de licences et saisie en cours de session fait désormais repasser l'application en édition Gratuite sans attendre : la clé est vérifiée dès son enregistrement, en arrière-plan, au lieu du démarrage suivant. Elle gardait auparavant son édition jusqu'au redémarrage de l'application, ou jusqu'à une tentative d'activation de la machine. La saisie d'une clé rend toujours la main tout de suite, en ligne comme hors ligne, et une clé révoquée affiche son édition une à deux secondes avant de basculer. La vérification part toujours au plus une fois par 24 heures pour une même clé, et chacune est inscrite dans les journaux (Paramètres → Système) (#373).
|
||||||
|
- Une machine dont l'activation ne sert plus retrouve « Activer » dans la carte Licence (Paramètres → Utilisateurs), sans manipulation de fichier. C'était le cas d'un ordinateur où une version jusqu'à la 0.15.0 avait laissé l'activation d'une licence précédente à côté d'une autre clé : la carte affichait « Activée », les cours du marché étaient refusés parce que cette licence était révoquée, et « Activer » n'était jamais proposé. L'activation est désormais retirée quand le serveur des cours la refuse parce qu'elle appartient à une licence révoquée (le message du refus invite désormais à activer de nouveau la machine), et quand la liste des machines, une fois ouverte, ne compte pas cette machine, ce qui couvre aussi une machine désactivée depuis un autre ordinateur. La clé enregistrée et son édition ne changent pas, et chaque retrait est inscrit dans les journaux (Paramètres → Système) (#367).
|
||||||
|
|
||||||
## [0.16.0] - 2026-10-03
|
## [0.16.0] - 2026-10-03
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- A license key revoked by the license server and entered during a session now puts the app back in the Free edition right away: the key is checked as soon as it is stored, in the background, instead of at the next start. It used to keep its edition until the app restarted, or until you tried to activate the machine. Entering a key still answers at once, online or offline, and a revoked key shows its edition for a second or two before the switch. The check still goes out at most once every 24 hours for the same key, and each check is written to the logs (Settings → System) (#373).
|
- A license key revoked by the license server and entered during a session now puts the app back in the Free edition right away: the key is checked as soon as it is stored, in the background, instead of at the next start. It used to keep its edition until the app restarted, or until you tried to activate the machine. Entering a key still answers at once, online or offline, and a revoked key shows its edition for a second or two before the switch. The check still goes out at most once every 24 hours for the same key, and each check is written to the logs (Settings → System) (#373).
|
||||||
|
- A machine whose activation no longer serves gets "Activate" back on the License card (Settings → Users), without touching any file. This happened to a computer where a version up to 0.15.0 had left the activation of a previous license next to another key: the card read "Activated", market prices were refused because that license was revoked, and "Activate" was never offered. The activation is now removed when the price server refuses it as belonging to a revoked license (the refusal message now invites you to activate the machine again), and when the machine list, once opened, does not count this machine, which also covers a machine deactivated from another computer. The stored key and its edition are untouched, and each removal is written to the logs (Settings → System) (#367).
|
||||||
|
|
||||||
## [0.16.0] - 2026-10-03
|
## [0.16.0] - 2026-10-03
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
>
|
>
|
||||||
> **Le retrait de la clé (#370, 2026-10-04).** Jusque-là, aucune interface ne retirait une clé enregistrée, et seul l'enregistrement d'une autre clé arrêtait cet envoi. « Retirer la clé », dans la carte Licence, l'arrête aussi : `remove_license` supprime `license.key` d'abord, puis le jeton d'activation, et une suppression qui échoue rend `storage` sans jamais laisser la clé sans son jeton. `license.check` est gardé : indexé par le condensé de la clé, il ne pèse sur aucune autre, et le supprimer rendrait son édition à une clé révoquée retirée puis ressaisie jusqu'au contrôle suivant, un contournement mis à la portée de tous par un bouton. Une telle clé reste donc en Gratuite, par cet état si aucune autre clé n'a été contrôlée entre-temps, sinon par le contrôle à la saisie (#373) ; hors ligne après le contrôle d'une autre clé, elle garde son édition jusqu'au contrôle suivant (fail-open, ci-dessus). Sur une machine activée avec une clé valide, le retrait libère d'abord sa place sur le serveur de licences (décision de Max du 2026-10-03) ; si le serveur ne répond pas, rien n'est supprimé et l'app demande s'il faut retirer la clé quand même, la place restant occupée.
|
> **Le retrait de la clé (#370, 2026-10-04).** Jusque-là, aucune interface ne retirait une clé enregistrée, et seul l'enregistrement d'une autre clé arrêtait cet envoi. « Retirer la clé », dans la carte Licence, l'arrête aussi : `remove_license` supprime `license.key` d'abord, puis le jeton d'activation, et une suppression qui échoue rend `storage` sans jamais laisser la clé sans son jeton. `license.check` est gardé : indexé par le condensé de la clé, il ne pèse sur aucune autre, et le supprimer rendrait son édition à une clé révoquée retirée puis ressaisie jusqu'au contrôle suivant, un contournement mis à la portée de tous par un bouton. Une telle clé reste donc en Gratuite, par cet état si aucune autre clé n'a été contrôlée entre-temps, sinon par le contrôle à la saisie (#373) ; hors ligne après le contrôle d'une autre clé, elle garde son édition jusqu'au contrôle suivant (fail-open, ci-dessus). Sur une machine activée avec une clé valide, le retrait libère d'abord sa place sur le serveur de licences (décision de Max du 2026-10-03) ; si le serveur ne répond pas, rien n'est supprimé et l'app demande s'il faut retirer la clé quand même, la place restant occupée.
|
||||||
>
|
>
|
||||||
> **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis », et le jeton périmé est suivi en #367.
|
> **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis ». Depuis #367 (2026-10-04), ce refus supprime aussi le jeton refusé, toujours sans écrire l'état de la clé (décision de Max du 2026-10-03) : ce jeton ne sert plus à rien, quelle que soit la clé enregistrée, et la carte Licence repropose « Activer », qui répare une clé valide ; si la clé enregistrée est elle-même révoquée, « Activer » le dit et l'enregistre sous cette clé. La suppression n'a lieu que si le fichier vaut encore le jeton présenté : une réactivation arrivée pendant la requête refusée garde son jeton neuf. La liste des machines supprime de même un jeton qu'aucune de ses lignes ne représente (règle 4 de l'[ADR 0020](0020-identifiant-machine-derive.md)). Chacune de ces suppressions écrit une ligne au Journal, à code fixe.
|
||||||
>
|
>
|
||||||
> **Côté interface.** `LicenseContext` lit l'état depuis Rust (`info.revoked`) et ne décide plus rien ; la carte Licence, chaque écran verrouillé (`UpsellGate`) et la carte Mises à jour en Gratuite nomment la révocation, sans bandeau global ; la page d'erreur, page de repli après un plantage, reste sans cette ligne (revue de la PR #368). Une clé révoquée ressaisie reste en Gratuite.
|
> **Côté interface.** `LicenseContext` lit l'état depuis Rust (`info.revoked`) et ne décide plus rien ; la carte Licence, chaque écran verrouillé (`UpsellGate`) et la carte Mises à jour en Gratuite nomment la révocation, sans bandeau global ; la page d'erreur, page de repli après un plantage, reste sans cette ligne (revue de la PR #368). Une clé révoquée ressaisie reste en Gratuite.
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
- Status: **Accepted**
|
- Status: **Accepted**
|
||||||
- Date: 2026-10-01
|
- Date: 2026-10-01
|
||||||
- Issues: #350 (cette décision), maximus/maximus-api#82 (identifiants bruts déjà stockés par le serveur), maximus/la-compagnie-maximus#184 (données d'activation absentes de la politique de confidentialité)
|
- Issues: #350 (cette décision), maximus/maximus-api#82 (identifiants bruts déjà stockés par le serveur), maximus/la-compagnie-maximus#184 (données d'activation absentes de la politique de confidentialité), #367 (jeton qui ne représente plus aucune activation, règle 4)
|
||||||
- S'appuie sur l'[ADR 0017](0017-feature-gating-par-tier.md), amendement #347 (l'édition suit la clé seule), et sur l'[ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) (cours du marché via maximus-api)
|
- S'appuie sur l'[ADR 0017](0017-feature-gating-par-tier.md), amendement #347 (l'édition suit la clé seule), et sur l'[ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) (cours du marché via maximus-api)
|
||||||
|
|
||||||
## Contexte
|
## Contexte
|
||||||
|
|
@ -62,6 +62,7 @@ Un jeton valide dont le claim `machine_id` vaut l'identifiant **brut** de cette
|
||||||
| de cette machine | ligne d'une autre machine | gardé |
|
| de cette machine | ligne d'une autre machine | gardé |
|
||||||
|
|
||||||
- **Un identifiant du système illisible ne bloque ni la liste ni la désactivation.** Aucune ligne n'est alors marquée, et le jeton ne part que s'il a été émis pour la ligne désactivée. L'état d'activation se lit « Non activée », et l'activation échoue avant tout appel.
|
- **Un identifiant du système illisible ne bloque ni la liste ni la désactivation.** Aucune ligne n'est alors marquée, et le jeton ne part que s'il a été émis pour la ligne désactivée. L'état d'activation se lit « Non activée », et l'activation échoue avant tout appel.
|
||||||
|
- **Un jeton qui ne représente plus aucune activation part aussi (#367, 2026-10-04).** À l'ouverture de la liste des machines, `drop_token_of_unlisted_machine_in` supprime le jeton quand aucune ligne de la clé enregistrée ne nomme ce poste, sous l'une ou l'autre forme : jeton d'une autre licence laissé par la v0.15.0, place libérée depuis un autre poste. Rien ne part sans identifiant du système lisible, sur une liste incomplète (une ligne illisible pourrait être celle de ce poste), pour un jeton qui n'active pas ce poste, ni quand le jeton a changé pendant la requête ; une erreur, une clé révoquée ou un 429 ne suppriment rien. Le refus `license_revoked` de la route des cours supprime le jeton qu'il a refusé, à la même condition. Une ligne que le serveur rehacherait (maximus/maximus-api#82) n'est reconnue que si elle vaut exactement le dérivé de la règle 1 : sous une autre forme, une machine activée en brut perdrait son jeton à l'ouverture de la liste, et sa réactivation prendrait une seconde place. Un test l'épingle côté client avec le vecteur de référence.
|
||||||
|
|
||||||
### 5. Ce que la transition laisse en place (D2)
|
### 5. Ce que la transition laisse en place (D2)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
||||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||||
| `onboardingService.ts` | Liste de démarrage (#351) : `getOnboardingCounters` lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import et la préférence `onboarding_checklist_hidden` ; `hideChecklist` / `showChecklist` posent et retirent la préférence. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » |
|
| `onboardingService.ts` | Liste de démarrage (#351) : `getOnboardingCounters` lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import et la préférence `onboarding_checklist_hidden` ; `hideChecklist` / `showChecklist` posent et retirent la préférence. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » |
|
||||||
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
||||||
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé |
|
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine |
|
||||||
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
|
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
|
||||||
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
|
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
|
||||||
|
|
||||||
|
|
@ -230,7 +230,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
|
| `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
|
||||||
| `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext` — `ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
|
| `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext` — `ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
|
||||||
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
|
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
|
||||||
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait |
|
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait. Après chaque chargement réussi de la liste, `loadMachines` relit l'état d'activation (lecture locale, aucune requête) : Rust a pu supprimer un jeton qu'aucune ligne ne représente, et « Activer » revient sans recharger la page ; une ligne au Journal à code fixe le dit quand `token_dropped` vaut `not_listed` (#367) |
|
||||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||||
|
|
||||||
### Hook transverse — `useCollapsibleGroups`
|
### Hook transverse — `useCollapsibleGroups`
|
||||||
|
|
@ -380,17 +380,17 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
|
||||||
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347) et son état de révocation (#365)
|
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347) et son état de révocation (#365)
|
||||||
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270). Envoie l'identifiant machine **dérivé**, jamais celui du système (#350)
|
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270). Envoie l'identifiant machine **dérivé**, jamais celui du système (#350)
|
||||||
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès. Supprime le jeton local quand il représentait la place libérée
|
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès. Supprime le jeton local quand il représentait la place libérée
|
||||||
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porte `is_this_machine`, calculé côté Rust
|
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porte `is_this_machine`, calculé côté Rust. Rend `{ machines, token_dropped }` depuis #367 : une liste complète qui ne nomme pas ce poste supprime le jeton d'activation de ce poste (`token_dropped: "not_listed"`, sinon `null`)
|
||||||
- `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
|
- `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
|
||||||
- `check_license_revocation` — Contrôle de révocation, au démarrage (`POST /v1/licenses/verify`, #365) et à la saisie d'une clé (#373), un à la fois. Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé
|
- `check_license_revocation` — Contrôle de révocation, au démarrage (`POST /v1/licenses/verify`, #365) et à la saisie d'une clé (#373), un à la fois. Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé
|
||||||
|
|
||||||
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ». Le retrait de la clé (#370) vit dans `remove_license_in`, sans `AppHandle` : la libération préalable (`release_this_machine_slot`) passe par `deactivate_idempotent` puis `note_revocation`, et non par `deactivate_in`, qui supprimerait le jeton avant la clé. Toute suppression du jeton passe par `remove_activation_token_in`, qui tolère un jeton absent et rend `storage` sinon ; `store_license_in`, la désactivation et, ensuite, #367 l'emploient aussi.
|
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ». Le retrait de la clé (#370) vit dans `remove_license_in`, sans `AppHandle` : la libération préalable (`release_this_machine_slot`) passe par `deactivate_idempotent` puis `note_revocation`, et non par `deactivate_in`, qui supprimerait le jeton avant la clé. Toute suppression du jeton passe par `remove_activation_token_in`, qui tolère un jeton absent et rend `storage` sinon ; `store_license_in` et la désactivation l'emploient aussi. Les deux suppressions de #367, décidées par la réponse d'un serveur, passent par `remove_activation_token_if_unchanged_in`, qui ne supprime le fichier que s'il vaut encore le jeton dont parle la réponse : une activation arrivée pendant la requête écrit un jeton neuf, qui reste (CWE-367).
|
||||||
|
|
||||||
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`, sauf si `license.check` la dit révoquée (#365, paragraphe suivant). Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`, sauf si `license.check` la dit révoquée (#365, paragraphe suivant). Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
||||||
|
|
||||||
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03), tant qu'elle est enregistrée : `remove_license` (#370) la retire et garde `license.check`, si bien qu'une clé révoquée retirée puis ressaisie reste en Gratuite. La requête porte `product` et `license_key`, rien d'autre. Le front la lance au démarrage et à la saisie d'une clé (#373), donc deux contrôles peuvent se chevaucher : la commande passe par `check_revocation_in_turn`, qui prend le verrou statique `REVOCATION_CHECK_TURN` (`tauri::async_runtime::Mutex`) avant de lire l'état et le garde jusqu'au verdict, et ne lit l'heure qu'une fois son tour venu. Un second contrôle de la même clé lit la tentative du premier et n'envoie rien ; celui d'une clé saisie pendant le contrôle d'une autre attend ce verdict au lieu de l'écraser dans `license.check`. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, mais n'écrit rien, parce que le serveur y juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (#367). Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03), tant qu'elle est enregistrée : `remove_license` (#370) la retire et garde `license.check`, si bien qu'une clé révoquée retirée puis ressaisie reste en Gratuite. La requête porte `product` et `license_key`, rien d'autre. Le front la lance au démarrage et à la saisie d'une clé (#373), donc deux contrôles peuvent se chevaucher : la commande passe par `check_revocation_in_turn`, qui prend le verrou statique `REVOCATION_CHECK_TURN` (`tauri::async_runtime::Mutex`) avant de lire l'état et le garde jusqu'au verdict, et ne lit l'heure qu'une fois son tour venu. Un second contrôle de la même clé lit la tentative du premier et n'envoie rien ; celui d'une clé saisie pendant le contrôle d'une autre attend ce verdict au lieu de l'écraser dans `license.check`. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, et `fetch_price_in` supprime alors le jeton d'activation refusé, s'il n'a pas changé pendant la requête, mais n'écrit pas l'état de la clé : le serveur y juge la licence du jeton, que le client ne sait pas relier à la clé enregistrée, et ce jeton ne sert plus à rien quelle que soit la clé (#367). `fetch_price_in` lit le jeton dans le dossier de données (`read_stored_activation_token_in`, constante `ACTIVATION_FILE`) et se teste dans un dossier temporaire. Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
||||||
|
|
||||||
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.
|
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. `drop_token_of_unlisted_machine_in` (#367), appelée par `list_machines_in`, supprime le jeton quand la liste de la clé enregistrée ne nomme ce poste sous aucune des deux formes, si l'identifiant du système est lisible, si la liste est complète (`machines_from_verify` compte les lignes écartées, que l'affichage tolère toujours), si le jeton active ce poste et s'il n'a pas changé pendant la requête ; une erreur, une clé révoquée ou un 429 ne suppriment rien. Elle suppose que le serveur, s'il rehache un jour ses lignes brutes (maximus/maximus-api#82), produit exactement le dérivé ci-dessus, ce qu'un test épingle. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.
|
||||||
|
|
||||||
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
|
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -633,7 +633,7 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
|
||||||
|
|
||||||
- **Édition et échéance** — l'édition de votre clé et sa date d'échéance, dans la langue de l'application. Une clé valide pour plus de 50 ans affiche « Sans échéance »
|
- **Édition et échéance** — l'édition de votre clé et sa date d'échéance, dans la langue de l'application. Une clé valide pour plus de 50 ans affiche « Sans échéance »
|
||||||
- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible ou n'a pas pu être vérifiée (le plus souvent parce qu'elle a été copiée en partie ou coupée par un retour à la ligne ; une clé complète qui reste refusée n'a pas été émise par le serveur de licences), elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas
|
- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible ou n'a pas pu être vérifiée (le plus souvent parce qu'elle a été copiée en partie ou coupée par un retour à la ligne ; une clé complète qui reste refusée n'a pas été émise par le serveur de licences), elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas
|
||||||
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours
|
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours. L'application retire aussi d'elle-même une activation qui ne sert plus, et la carte affiche alors « Non activée » : quand la récupération d'un cours est refusée parce que l'activation de cette machine appartient à une licence révoquée (par exemple celle d'une clé remboursée, restée à côté de votre clé actuelle depuis une version antérieure), et quand la liste des machines ne compte pas cette machine (désactivée depuis un autre ordinateur, par exemple). Chacun de ces retraits est inscrit dans les journaux (Paramètres → Système)
|
||||||
- **Ce que l'activation envoie** — votre clé de licence, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système. Le nom sert à reconnaître vos machines dans la liste. Une machine activée avec une version antérieure garde l'identifiant de cette activation jusqu'à ce que vous la désactiviez puis l'activiez de nouveau
|
- **Ce que l'activation envoie** — votre clé de licence, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système. Le nom sert à reconnaître vos machines dans la liste. Une machine activée avec une version antérieure garde l'identifiant de cette activation jusqu'à ce que vous la désactiviez puis l'activiez de nouveau
|
||||||
- **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place
|
- **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place
|
||||||
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition
|
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,10 @@
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::commands::license_commands::{remove_activation_token_if_unchanged_in, ACTIVATION_FILE};
|
||||||
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
|
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -57,7 +59,8 @@ pub enum FetchPriceError {
|
||||||
/// 403 `license_revoked`: the license the activation token stands for was revoked.
|
/// 403 `license_revoked`: the license the activation token stands for was revoked.
|
||||||
/// Named for the user, never recorded as the state of the stored key: the token names
|
/// Named for the user, never recorded as the state of the stored key: the token names
|
||||||
/// a license the client cannot tie to that key, and a data directory written by 0.15.0
|
/// a license the client cannot tie to that key, and a data directory written by 0.15.0
|
||||||
/// or earlier can hold a valid key next to the token of a previous license (#365).
|
/// or earlier can hold a valid key next to the token of a previous license (#365). The
|
||||||
|
/// refused token is dropped instead (#367, [`fetch_price_in`]).
|
||||||
LicenseRevoked,
|
LicenseRevoked,
|
||||||
SymbolNotFound,
|
SymbolNotFound,
|
||||||
RateLimit { retry_after_s: u64 },
|
RateLimit { retry_after_s: u64 },
|
||||||
|
|
@ -97,34 +100,43 @@ fn base_url() -> String {
|
||||||
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the stored activation token from disk (raw JWT string).
|
/// Read the activation token stored in `dir` (raw JWT string, trimmed).
|
||||||
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable.
|
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable: nothing is sent
|
||||||
fn read_stored_activation_token(app: &tauri::AppHandle) -> Result<String, FetchPriceError> {
|
/// then, and the message invites the user to activate this machine.
|
||||||
let app_dir = app
|
fn read_stored_activation_token_in(dir: &Path) -> Result<String, FetchPriceError> {
|
||||||
.path()
|
std::fs::read_to_string(dir.join(ACTIVATION_FILE))
|
||||||
.app_data_dir()
|
|
||||||
.map_err(|_| FetchPriceError::Auth)?;
|
|
||||||
let token_path = app_dir.join("activation.token");
|
|
||||||
std::fs::read_to_string(&token_path)
|
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.map_err(|_| FetchPriceError::Auth)
|
.map_err(|_| FetchPriceError::Auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Core implementation — separated from the Tauri command so tests can inject
|
/// The price call as the command performs it, without `AppHandle` so tests run it in a
|
||||||
/// an arbitrary token string and base URL without touching the file system.
|
/// temporary directory and against a mock server: the stored activation token is read, then
|
||||||
|
/// sent to `api_base`.
|
||||||
///
|
///
|
||||||
/// Design note (MEDIUM decision in decisions-log.md): the public `fetch_price`
|
/// A 403 `license_revoked` condemns that token (#367): the license it was issued for is
|
||||||
/// command is a thin wrapper that loads the token then delegates here. Tests
|
/// revoked, so it will never fetch a price again, whatever key is stored. It is dropped and
|
||||||
/// call this inner function directly with an explicit `api_base` to avoid
|
/// the License card offers "Activate" again, but only while the file still holds it: an
|
||||||
/// env-var races between concurrent test threads.
|
/// activation that lands while the refused request is in flight writes a new token, which
|
||||||
async fn fetch_price_with_token(
|
/// stays (CWE-367). Best effort: the refusal reaches the user whatever happens to the file.
|
||||||
token: &str,
|
/// The revoked state of the stored key is never written from here: this route judges the
|
||||||
|
/// license of the token, which the client cannot tie to the key (#365).
|
||||||
|
pub(crate) async fn fetch_price_in(
|
||||||
|
dir: &Path,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
date: &str,
|
date: &str,
|
||||||
|
api_base: &str,
|
||||||
) -> Result<PriceResponse, FetchPriceError> {
|
) -> Result<PriceResponse, FetchPriceError> {
|
||||||
fetch_price_inner(token, symbol, date, &base_url()).await
|
let token = read_stored_activation_token_in(dir)?;
|
||||||
|
let result = fetch_price_inner(&token, symbol, date, api_base).await;
|
||||||
|
if matches!(result, Err(FetchPriceError::LicenseRevoked)) {
|
||||||
|
let _ = remove_activation_token_if_unchanged_in(dir, &token);
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The HTTP call alone, separated so tests can inject an arbitrary token string and base URL
|
||||||
|
/// without touching the file system. Tests pass an explicit `api_base` to avoid env-var races
|
||||||
|
/// between concurrent test threads.
|
||||||
async fn fetch_price_inner(
|
async fn fetch_price_inner(
|
||||||
token: &str,
|
token: &str,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
|
|
@ -201,9 +213,10 @@ async fn fetch_price_inner(
|
||||||
|
|
||||||
/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api.
|
/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api.
|
||||||
///
|
///
|
||||||
/// Reads the stored activation token, then calls `GET /v1/prices`. Returns a
|
/// Reads the stored activation token, then calls `GET /v1/prices`; a token refused as issued
|
||||||
/// serialized `FetchPriceError` JSON string on error so the JS layer can
|
/// for a revoked license is dropped ([`fetch_price_in`], #367). Returns a serialized
|
||||||
/// `JSON.parse` and branch on `code`.
|
/// `FetchPriceError` JSON string on error so the JS layer can `JSON.parse` and branch on
|
||||||
|
/// `code`.
|
||||||
///
|
///
|
||||||
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
|
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
|
||||||
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
|
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
|
||||||
|
|
@ -214,8 +227,11 @@ pub async fn fetch_price(
|
||||||
symbol: String,
|
symbol: String,
|
||||||
date: String,
|
date: String,
|
||||||
) -> Result<PriceResponse, String> {
|
) -> Result<PriceResponse, String> {
|
||||||
let token = read_stored_activation_token(&app).map_err(|e| price_error_to_string(&e))?;
|
let dir = app
|
||||||
fetch_price_with_token(&token, &symbol, &date)
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|_| price_error_to_string(&FetchPriceError::Auth))?;
|
||||||
|
fetch_price_in(&dir, &symbol, &date, &base_url())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| price_error_to_string(&e))
|
.map_err(|e| price_error_to_string(&e))
|
||||||
}
|
}
|
||||||
|
|
@ -590,4 +606,172 @@ mod tests {
|
||||||
// JSON parse would fail. A successful 200 parse confirms the privacy contract.
|
// JSON parse would fail. A successful 200 parse confirms the privacy contract.
|
||||||
assert_eq!(result.unwrap().symbol, "BTC");
|
assert_eq!(result.unwrap().symbol, "BTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === The stored token, and a token refused as issued for a revoked license (#367) ==========
|
||||||
|
|
||||||
|
/// A data directory of its own for each test (test name + pid: cargo runs tests on parallel
|
||||||
|
/// threads), removed when the test ends, failed assertion included.
|
||||||
|
struct TestDir(std::path::PathBuf);
|
||||||
|
|
||||||
|
impl TestDir {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"simpl-resultat-price-test-{}-{}",
|
||||||
|
name,
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
TestDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_token(&self, contents: &str) {
|
||||||
|
std::fs::write(self.0.join(ACTIVATION_FILE), contents).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token(&self) -> Option<String> {
|
||||||
|
std::fs::read_to_string(self.0.join(ACTIVATION_FILE)).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVOKED: &str = r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#;
|
||||||
|
|
||||||
|
/// `ok`, or the code the front reads from the error.
|
||||||
|
fn outcome(result: &Result<PriceResponse, FetchPriceError>) -> String {
|
||||||
|
match result {
|
||||||
|
Ok(_) => "ok".to_string(),
|
||||||
|
Err(e) => serde_json::from_str::<serde_json::Value>(&price_error_to_string(e)).unwrap()
|
||||||
|
["code"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_revoked_license_drops_the_token_it_refused() {
|
||||||
|
// The license of this token is revoked: it will never fetch a price again, and the card
|
||||||
|
// must offer "Activate". Written with a newline, as an older version may have left it,
|
||||||
|
// read from `ACTIVATION_FILE` and sent trimmed. Nothing is written in its place: the
|
||||||
|
// key's state is not this route's to record (#365).
|
||||||
|
let dir = TestDir::new("drops_revoked_token");
|
||||||
|
dir.write_token("token-of-a-revoked-license\n");
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.match_header("authorization", "Bearer token-of-a-revoked-license")
|
||||||
|
.with_status(403)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(REVOKED)
|
||||||
|
.expect(1)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
|
||||||
|
assert_eq!(outcome(&result), "license_revoked");
|
||||||
|
route.assert_async().await;
|
||||||
|
assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 0, "the token gone, nothing written");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_token_written_while_the_refused_request_was_in_flight_stays() {
|
||||||
|
// An activation lands while the refused request is in flight (CWE-367): the refusal is
|
||||||
|
// about the token that was sent, not the one written meanwhile.
|
||||||
|
let dir = TestDir::new("token_replaced_in_flight");
|
||||||
|
dir.write_token("refused-token");
|
||||||
|
let token_path = dir.0.join(ACTIVATION_FILE);
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.match_request(move |_request| {
|
||||||
|
std::fs::write(&token_path, "fresh-token").unwrap();
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.with_status(403)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(REVOKED)
|
||||||
|
.expect(1)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
|
||||||
|
assert_eq!(outcome(&result), "license_revoked");
|
||||||
|
route.assert_async().await;
|
||||||
|
assert_eq!(dir.token().as_deref(), Some("fresh-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_other_answer_drops_the_token() {
|
||||||
|
// Only a revoked license condemns the token: a Base license, a token the server does
|
||||||
|
// not accept (the user is asked to activate), a missing symbol and every outage leave
|
||||||
|
// it as it was.
|
||||||
|
let rows: Vec<(usize, &str, &str)> = vec![
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#,
|
||||||
|
"ok",
|
||||||
|
),
|
||||||
|
(401, r#"{"error":{"code":"invalid_token","message":"x"}}"#, "auth"),
|
||||||
|
(403, r#"{"error":{"code":"premium_required","message":"x"}}"#, "premium_required"),
|
||||||
|
(403, "<html>Forbidden</html>", "premium_required"),
|
||||||
|
(404, r#"{"error":{"code":"symbol_not_found","message":"x"}}"#, "symbol_not_found"),
|
||||||
|
(
|
||||||
|
429,
|
||||||
|
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
|
||||||
|
"rate_limit",
|
||||||
|
),
|
||||||
|
(502, r#"{"error":{"code":"provider_unavailable","message":"x"}}"#, "provider_unavailable"),
|
||||||
|
];
|
||||||
|
for (status, body, expected) in rows {
|
||||||
|
let dir = TestDir::new("keeps_token");
|
||||||
|
dir.write_token("token");
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.with_status(status)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(body)
|
||||||
|
.expect(1)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
|
||||||
|
assert_eq!(outcome(&result), expected, "{} {}", status, body);
|
||||||
|
route.assert_async().await;
|
||||||
|
assert_eq!(dir.token().as_deref(), Some("token"), "{} {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline. Port 1 on loopback: nothing listens.
|
||||||
|
let dir = TestDir::new("keeps_token_offline");
|
||||||
|
dir.write_token("token");
|
||||||
|
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", "http://127.0.0.1:1").await;
|
||||||
|
assert_eq!(outcome(&result), "network");
|
||||||
|
assert_eq!(dir.token().as_deref(), Some("token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn without_a_token_nothing_is_sent_and_the_answer_is_auth() {
|
||||||
|
// The rest of a batch once the token is gone: the message invites to activate.
|
||||||
|
let dir = TestDir::new("no_token");
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let any_request = server
|
||||||
|
.mock("GET", mockito::Matcher::Any)
|
||||||
|
.expect(0)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
|
||||||
|
assert_eq!(outcome(&result), "auth");
|
||||||
|
any_request.assert_async().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@
|
||||||
// - The stored key can be removed (#370, `remove_license`): license.key, then the activation
|
// - The stored key can be removed (#370, `remove_license`): license.key, then the activation
|
||||||
// token, after this machine's slot is freed on the server when asked. license.check stays,
|
// token, after this machine's slot is freed on the server when asked. license.check stays,
|
||||||
// so a revoked key removed then entered again stays revoked.
|
// so a revoked key removed then entered again stays revoked.
|
||||||
|
// - A token that no longer stands for an activation of this machine is dropped (#367), so the
|
||||||
|
// License card offers "Activate" again: when the price route refuses it as issued for a
|
||||||
|
// revoked license (`balance_commands.rs`), and when the machine list of the stored key names
|
||||||
|
// no activation of this machine. Either way only while the file still holds the token the
|
||||||
|
// answer is about: an activation that lands meanwhile writes a token that must stay.
|
||||||
//
|
//
|
||||||
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
|
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
|
||||||
|
|
||||||
|
|
@ -49,7 +54,8 @@ MCowBQYDK2VwAyEAmUTcl7xjt01uc2FhPgvP0at0I/Pie0JLh73AApNy+o8=\n\
|
||||||
-----END PUBLIC KEY-----\n";
|
-----END PUBLIC KEY-----\n";
|
||||||
|
|
||||||
const LICENSE_FILE: &str = "license.key";
|
const LICENSE_FILE: &str = "license.key";
|
||||||
const ACTIVATION_FILE: &str = "activation.token";
|
/// Activation token of this machine. The price route reads it too (`balance_commands.rs`).
|
||||||
|
pub(crate) const ACTIVATION_FILE: &str = "activation.token";
|
||||||
/// State of the online revocation check for the stored key (#365).
|
/// State of the online revocation check for the stored key (#365).
|
||||||
const CHECK_FILE: &str = "license.check";
|
const CHECK_FILE: &str = "license.check";
|
||||||
|
|
||||||
|
|
@ -253,7 +259,8 @@ fn store_license_in(
|
||||||
|
|
||||||
/// Delete `dir/activation.token`. A missing token is not an error; any other failure is
|
/// Delete `dir/activation.token`. A missing token is not an error; any other failure is
|
||||||
/// `Storage`. Shared by the entry of another key, a deactivation that freed this machine's
|
/// `Storage`. Shared by the entry of another key, a deactivation that freed this machine's
|
||||||
/// slot and the removal of the key (#370); meant for the stale token of #367 next.
|
/// slot and the removal of the key (#370); the stale token of #367 goes through
|
||||||
|
/// [`remove_activation_token_if_unchanged_in`].
|
||||||
pub(crate) fn remove_activation_token_in(dir: &Path) -> Result<(), LicenseError> {
|
pub(crate) fn remove_activation_token_in(dir: &Path) -> Result<(), LicenseError> {
|
||||||
match fs::remove_file(dir.join(ACTIVATION_FILE)) {
|
match fs::remove_file(dir.join(ACTIVATION_FILE)) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
|
|
@ -262,6 +269,21 @@ pub(crate) fn remove_activation_token_in(dir: &Path) -> Result<(), LicenseError>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete `dir/activation.token` only while it still holds `token`, the one an answer from a
|
||||||
|
/// server is about (#367). That answer can land after an activation replaced the token, and the
|
||||||
|
/// new one must stay (CWE-367). `Ok(true)` once the token is gone, `Ok(false)` when the file is
|
||||||
|
/// missing or holds another token, `Storage` when it cannot be read or deleted. Both sides are
|
||||||
|
/// trimmed, as the token is when it is written and when it is sent.
|
||||||
|
pub(crate) fn remove_activation_token_if_unchanged_in(dir: &Path, token: &str) -> Result<bool, LicenseError> {
|
||||||
|
match fs::read_to_string(dir.join(ACTIVATION_FILE)) {
|
||||||
|
Ok(stored) if stored.trim() == token.trim() => {}
|
||||||
|
Ok(_) => return Ok(false),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||||
|
Err(_) => return Err(LicenseError::Storage),
|
||||||
|
}
|
||||||
|
remove_activation_token_in(dir).map(|()| true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist a server-issued activation token (machine binding). The token is opaque to the
|
/// Persist a server-issued activation token (machine binding). The token is opaque to the
|
||||||
/// caller — it must have been issued for this machine's derived identifier to be stored.
|
/// caller — it must have been issued for this machine's derived identifier to be stored.
|
||||||
/// Errors cross the boundary as a serialized [`LicenseError`].
|
/// Errors cross the boundary as a serialized [`LicenseError`].
|
||||||
|
|
@ -937,15 +959,26 @@ async fn verify_body(api_base: &str, license_key: &str) -> Result<serde_json::Va
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify call: the machines activated for the key.
|
/// Verify call: the machines activated for the key.
|
||||||
async fn verify_inner(api_base: &str, license_key: &str) -> Result<Vec<MachineInfo>, LicenseError> {
|
async fn verify_inner(api_base: &str, license_key: &str) -> Result<ListedMachines, LicenseError> {
|
||||||
machines_from_verify(&verify_body(api_base, license_key).await?)
|
machines_from_verify(&verify_body(api_base, license_key).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The machines of a verify answer, and how many of its rows could not be read.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ListedMachines {
|
||||||
|
machines: Vec<MachineInfo>,
|
||||||
|
/// Rows left out of `machines` because they did not deserialize (a `null` date is
|
||||||
|
/// enough). The list still shows the others; only the reconciliation of the activation
|
||||||
|
/// token reads this count (#367): a row left out may be this machine's.
|
||||||
|
skipped: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a verify answer. The server answers 200 in every business case:
|
/// Read a verify answer. The server answers 200 in every business case:
|
||||||
/// `{valid:false, reason}` for an unknown, revoked or expired key, `{valid:true,
|
/// `{valid:false, reason}` for an unknown, revoked or expired key, `{valid:true,
|
||||||
/// machines, ...}` otherwise. Before #270 a `valid:false` answer had no `machines` and
|
/// machines, ...}` otherwise. Before #270 a `valid:false` answer had no `machines` and
|
||||||
/// read as an empty list, so a revoked key showed "no machine activated".
|
/// read as an empty list, so a revoked key showed "no machine activated". A row that does not
|
||||||
fn machines_from_verify(body: &serde_json::Value) -> Result<Vec<MachineInfo>, LicenseError> {
|
/// read stays out of the list without failing it, and is counted.
|
||||||
|
fn machines_from_verify(body: &serde_json::Value) -> Result<ListedMachines, LicenseError> {
|
||||||
match body.get("valid").and_then(|v| v.as_bool()) {
|
match body.get("valid").and_then(|v| v.as_bool()) {
|
||||||
Some(false) => Err(match body.get("reason").and_then(|v| v.as_str()) {
|
Some(false) => Err(match body.get("reason").and_then(|v| v.as_str()) {
|
||||||
Some("not_found") => LicenseError::LicenseNotFound,
|
Some("not_found") => LicenseError::LicenseNotFound,
|
||||||
|
|
@ -954,14 +987,18 @@ fn machines_from_verify(body: &serde_json::Value) -> Result<Vec<MachineInfo>, Li
|
||||||
_ => LicenseError::Server { status: 200 },
|
_ => LicenseError::Server { status: 200 },
|
||||||
}),
|
}),
|
||||||
Some(true) => {
|
Some(true) => {
|
||||||
let machines = body
|
let rows = body
|
||||||
.get("machines")
|
.get("machines")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.ok_or(LicenseError::InvalidResponse)?;
|
.ok_or(LicenseError::InvalidResponse)?;
|
||||||
Ok(machines
|
let machines: Vec<MachineInfo> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
|
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
|
||||||
.collect())
|
.collect();
|
||||||
|
Ok(ListedMachines {
|
||||||
|
skipped: rows.len() - machines.len(),
|
||||||
|
machines,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
None => Err(LicenseError::InvalidResponse),
|
None => Err(LicenseError::InvalidResponse),
|
||||||
}
|
}
|
||||||
|
|
@ -1012,6 +1049,26 @@ pub struct ActivationStatus {
|
||||||
pub is_activated: bool,
|
pub is_activated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `list_activated_machines` returns (#367): the flagged list, and whether loading it
|
||||||
|
/// removed the local activation token, so the front can write it to the in-app log and read
|
||||||
|
/// the activation status again.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MachineList {
|
||||||
|
pub machines: Vec<MachineInfo>,
|
||||||
|
/// `None` when the token was not removed.
|
||||||
|
pub token_dropped: Option<TokenDropped>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the activation token was removed while the machine list loaded.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TokenDropped {
|
||||||
|
/// The license server lists no activation of this machine for the stored key: the token
|
||||||
|
/// stood for none (another license's token left by 0.15.0, a slot freed from another
|
||||||
|
/// computer).
|
||||||
|
NotListed,
|
||||||
|
}
|
||||||
|
|
||||||
/// Flag the rows that name this machine, under either identifier (ADR 0020, D3). `ids` is
|
/// Flag the rows that name this machine, under either identifier (ADR 0020, D3). `ids` is
|
||||||
/// `None` when the OS id is unreadable: no row is flagged then, and the list still loads.
|
/// `None` when the OS id is unreadable: no row is flagged then, and the list still loads.
|
||||||
fn flag_this_machine(mut machines: Vec<MachineInfo>, ids: Option<&MachineIds>) -> Vec<MachineInfo> {
|
fn flag_this_machine(mut machines: Vec<MachineInfo>, ids: Option<&MachineIds>) -> Vec<MachineInfo> {
|
||||||
|
|
@ -1028,22 +1085,80 @@ async fn list_machines_inner(
|
||||||
api_base: &str,
|
api_base: &str,
|
||||||
license_key: &str,
|
license_key: &str,
|
||||||
ids: Option<&MachineIds>,
|
ids: Option<&MachineIds>,
|
||||||
) -> Result<Vec<MachineInfo>, LicenseError> {
|
) -> Result<ListedMachines, LicenseError> {
|
||||||
let machines = verify_inner(api_base, license_key).await?;
|
let listed = verify_inner(api_base, license_key).await?;
|
||||||
Ok(flag_this_machine(machines, ids))
|
Ok(ListedMachines {
|
||||||
|
machines: flag_this_machine(listed.machines, ids),
|
||||||
|
skipped: listed.skipped,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`list_machines_inner`] as the command performs it: a "revoked" answer is recorded for
|
/// [`list_machines_inner`] as the command performs it: a "revoked" answer is recorded for
|
||||||
/// the key that was sent (#365, D4).
|
/// the key that was sent (#365, D4), and a list that names no activation of this machine
|
||||||
|
/// drops the local token ([`drop_token_of_unlisted_machine_in`], #367). Any error returns
|
||||||
|
/// before that: nothing is removed on a failed list.
|
||||||
async fn list_machines_in(
|
async fn list_machines_in(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
api_base: &str,
|
api_base: &str,
|
||||||
license_key: &str,
|
license_key: &str,
|
||||||
ids: Option<&MachineIds>,
|
ids: Option<&MachineIds>,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
now: i64,
|
now: i64,
|
||||||
) -> Result<Vec<MachineInfo>, LicenseError> {
|
) -> Result<MachineList, LicenseError> {
|
||||||
|
// The token as the list is asked for: the answer cannot speak for one an activation writes
|
||||||
|
// while the request is in flight.
|
||||||
|
let token = fs::read_to_string(dir.join(ACTIVATION_FILE)).ok();
|
||||||
let answer = list_machines_inner(api_base, license_key, ids).await;
|
let answer = list_machines_inner(api_base, license_key, ids).await;
|
||||||
note_revocation(dir, license_key, answer, now)
|
let listed = note_revocation(dir, license_key, answer, now)?;
|
||||||
|
let token_dropped =
|
||||||
|
drop_token_of_unlisted_machine_in(dir, token.as_deref(), &listed, ids, decoding_key);
|
||||||
|
Ok(MachineList {
|
||||||
|
machines: listed.machines,
|
||||||
|
token_dropped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the machine list of the stored key loaded, drop the local activation token when no
|
||||||
|
/// row names this machine, under either identifier (ADR 0020): the token stands for no
|
||||||
|
/// activation of this machine under that key, so the card must offer "Activate" again (#367).
|
||||||
|
/// That is the token of another license left by 0.15.0 next to the key, or the token of a slot
|
||||||
|
/// freed from another computer. `token` is the file as it was when the list was asked for.
|
||||||
|
///
|
||||||
|
/// Nothing is removed unless every guard holds:
|
||||||
|
/// - the OS id is readable: without it no row can name this machine;
|
||||||
|
/// - the list is complete: a row left out because it did not read may be this machine's;
|
||||||
|
/// - the token activates this machine (signed, unexpired, issued for one of its identifiers):
|
||||||
|
/// any other token already reads as not activated;
|
||||||
|
/// - the file still holds that token: an activation that landed while the list was in flight
|
||||||
|
/// wrote a token the list cannot speak for.
|
||||||
|
///
|
||||||
|
/// It assumes a server that rehashes its raw rows (maximus/maximus-api#82) gives exactly the
|
||||||
|
/// derived identifier of ADR 0020: any other form would read as "this machine not listed".
|
||||||
|
/// Kept apart from `AppHandle` so tests run it in a temporary directory.
|
||||||
|
fn drop_token_of_unlisted_machine_in(
|
||||||
|
dir: &Path,
|
||||||
|
token: Option<&str>,
|
||||||
|
listed: &ListedMachines,
|
||||||
|
ids: Option<&MachineIds>,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
|
) -> Option<TokenDropped> {
|
||||||
|
let ids = ids?;
|
||||||
|
if listed.skipped > 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = token?;
|
||||||
|
validate_activation_with_key(token, ids, decoding_key).ok()?;
|
||||||
|
if listed
|
||||||
|
.machines
|
||||||
|
.iter()
|
||||||
|
.any(|machine| ids.names_this_machine(&machine.machine_id))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match remove_activation_token_if_unchanged_in(dir, token) {
|
||||||
|
Ok(true) => Some(TokenDropped::NotListed),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deactivation as the command performs it, without `AppHandle`: the idempotent call, a
|
/// Deactivation as the command performs it, without `AppHandle`: the idempotent call, a
|
||||||
|
|
@ -1155,22 +1270,35 @@ fn drop_token_of_deactivated_machine_in(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all machines currently activated for the stored license (empty when no key is
|
/// List all machines currently activated for the stored license (empty when no key is
|
||||||
/// stored). Errors cross the boundary as a serialized [`LicenseError`].
|
/// stored), and say whether the local activation token was removed because none of them is
|
||||||
|
/// this machine (#367). Errors cross the boundary as a serialized [`LicenseError`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
|
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<MachineList, String> {
|
||||||
list_activated_machines_impl(&app)
|
list_activated_machines_impl(&app)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| license_error_to_string(&e))
|
.map_err(|e| license_error_to_string(&e))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result<Vec<MachineInfo>, LicenseError> {
|
async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result<MachineList, LicenseError> {
|
||||||
let Some(license_key) = read_stored_key(app)? else {
|
let Some(license_key) = read_stored_key(app)? else {
|
||||||
return Ok(vec![]);
|
return Ok(MachineList {
|
||||||
|
machines: vec![],
|
||||||
|
token_dropped: None,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
|
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
|
||||||
// Best effort on the OS id: unreadable, it leaves every row unflagged.
|
let decoding_key = embedded_decoding_key()?;
|
||||||
|
// Best effort on the OS id: unreadable, it leaves every row unflagged and the token alone.
|
||||||
let ids = local_machine_ids().ok();
|
let ids = local_machine_ids().ok();
|
||||||
list_machines_in(&dir, &api_base_url(), &license_key, ids.as_ref(), unix_now()).await
|
list_machines_in(
|
||||||
|
&dir,
|
||||||
|
&api_base_url(),
|
||||||
|
&license_key,
|
||||||
|
ids.as_ref(),
|
||||||
|
&decoding_key,
|
||||||
|
unix_now(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the local activation status without contacting the server. Strict on purpose: an
|
/// Check the local activation status without contacting the server. Strict on purpose: an
|
||||||
|
|
@ -2132,11 +2260,11 @@ mod tests {
|
||||||
|
|
||||||
let ids = this_machine();
|
let ids = this_machine();
|
||||||
let listed = list_machines_inner(&server.url(), "SR-BASE-k", Some(&ids)).await.unwrap();
|
let listed = list_machines_inner(&server.url(), "SR-BASE-k", Some(&ids)).await.unwrap();
|
||||||
assert_eq!(flags(listed), expected(true));
|
assert_eq!(flags(listed.machines), expected(true));
|
||||||
|
|
||||||
// Unreadable OS id: the list still loads, and no row is flagged.
|
// Unreadable OS id: the list still loads, and no row is flagged.
|
||||||
let unflagged = list_machines_inner(&server.url(), "SR-BASE-k", None).await.unwrap();
|
let unflagged = list_machines_inner(&server.url(), "SR-BASE-k", None).await.unwrap();
|
||||||
assert_eq!(flags(unflagged), expected(false));
|
assert_eq!(flags(unflagged.machines), expected(false));
|
||||||
listing.assert_async().await;
|
listing.assert_async().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2913,8 +3041,8 @@ mod tests {
|
||||||
dir.write(LICENSE_FILE, &key);
|
dir.write(LICENSE_FILE, &key);
|
||||||
let mut server = mockito::Server::new_async().await;
|
let mut server = mockito::Server::new_async().await;
|
||||||
let _verify = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await;
|
let _verify = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await;
|
||||||
let result = list_machines_in(&dir.0, &server.url(), &key, Some(&ids), NOW).await;
|
let result = list_machines_in(&dir.0, &server.url(), &key, Some(&ids), &dec, NOW).await;
|
||||||
assert_eq!(result.map(|machines| machines.len()), Err(LicenseError::LicenseRevoked));
|
assert_eq!(result.map(|list| list.machines.len()), Err(LicenseError::LicenseRevoked));
|
||||||
recorded(&dir);
|
recorded(&dir);
|
||||||
|
|
||||||
let dir = TestDir::new("revoked_by_deactivation");
|
let dir = TestDir::new("revoked_by_deactivation");
|
||||||
|
|
@ -2946,9 +3074,9 @@ mod tests {
|
||||||
let mut server = mockito::Server::new_async().await;
|
let mut server = mockito::Server::new_async().await;
|
||||||
let _verify = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await;
|
let _verify = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await;
|
||||||
|
|
||||||
let result = list_machines_in(&dir.0, &server.url(), &sent_key, None, NOW).await;
|
let result = list_machines_in(&dir.0, &server.url(), &sent_key, None, &dec, NOW).await;
|
||||||
|
|
||||||
assert_eq!(result.map(|machines| machines.len()), Err(LicenseError::LicenseRevoked));
|
assert_eq!(result.map(|list| list.machines.len()), Err(LicenseError::LicenseRevoked));
|
||||||
assert!(is_revoked_in(&dir.0, &sent_key));
|
assert!(is_revoked_in(&dir.0, &sent_key));
|
||||||
assert!(!is_revoked_in(&dir.0, &stored_key));
|
assert!(!is_revoked_in(&dir.0, &stored_key));
|
||||||
assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE);
|
assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE);
|
||||||
|
|
@ -3007,7 +3135,9 @@ mod tests {
|
||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
list_machines_in(&dir.0, &server.url(), &key, None, NOW).await.map(|m| m.len()),
|
list_machines_in(&dir.0, &server.url(), &key, None, &dec, NOW)
|
||||||
|
.await
|
||||||
|
.map(|list| list.machines.len()),
|
||||||
Ok(0)
|
Ok(0)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -3419,6 +3549,350 @@ mod tests {
|
||||||
any_request.assert_async().await;
|
any_request.assert_async().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === A token that stands for no activation of this machine (#367) ==========================
|
||||||
|
|
||||||
|
/// A verify answer that lists `rows` for a valid key.
|
||||||
|
fn verify_answer(rows: Vec<serde_json::Value>) -> String {
|
||||||
|
serde_json::json!({ "valid": true, "machine_limit": 3, "machines": rows }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The token file, when it reads.
|
||||||
|
fn stored_token(dir: &TestDir) -> Option<String> {
|
||||||
|
fs::read_to_string(dir.0.join(ACTIVATION_FILE)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_conditional_removal_drops_only_the_token_it_is_given() {
|
||||||
|
let dir = TestDir::new("remove_token_if_unchanged");
|
||||||
|
assert_eq!(remove_activation_token_if_unchanged_in(&dir.0, "token-a"), Ok(false), "no token");
|
||||||
|
|
||||||
|
dir.write(ACTIVATION_FILE, "token-b");
|
||||||
|
assert_eq!(remove_activation_token_if_unchanged_in(&dir.0, "token-a"), Ok(false), "another token");
|
||||||
|
assert_eq!(stored_token(&dir).as_deref(), Some("token-b"));
|
||||||
|
|
||||||
|
// Whitespace aside: a file left with a newline, a token sent trimmed.
|
||||||
|
dir.write(ACTIVATION_FILE, "token-a\n");
|
||||||
|
assert_eq!(remove_activation_token_if_unchanged_in(&dir.0, " token-a"), Ok(true));
|
||||||
|
assert!(!dir.0.join(ACTIVATION_FILE).exists());
|
||||||
|
|
||||||
|
// A directory in place of the token: it cannot be read as a file.
|
||||||
|
fs::create_dir(dir.0.join(ACTIVATION_FILE)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
remove_activation_token_if_unchanged_in(&dir.0, "token-a"),
|
||||||
|
Err(LicenseError::Storage)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_v0_15_0_folder_gets_activate_back_once_the_price_route_refuses_its_token() {
|
||||||
|
// The folder of #367: v0.15.0 stored a key without touching the token, so key A sits
|
||||||
|
// next to the token of license B, signed for this machine, B refunded hence revoked.
|
||||||
|
// The card reads "Activated", the price route refuses that token, and nothing offered
|
||||||
|
// "Activate". The refusal is about B: A keeps its edition, and no state is written.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let key_a = base_license_with_features(&enc, vec!["balance".to_string()]);
|
||||||
|
let ids = this_machine();
|
||||||
|
let dir = TestDir::new("v0_15_0_stale_token");
|
||||||
|
dir.write(LICENSE_FILE, &key_a);
|
||||||
|
let token_b = activation_for(&enc, THIS_DERIVED);
|
||||||
|
dir.write(ACTIVATION_FILE, &token_b);
|
||||||
|
assert!(activation_status_in(&dir.0, &ids, &dec), "the card reads Activated");
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.match_header("authorization", mockito::Matcher::Exact(format!("Bearer {}", token_b)))
|
||||||
|
.with_status(403)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(REVOKED_ENVELOPE)
|
||||||
|
.expect(1)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = crate::commands::balance_commands::fetch_price_in(
|
||||||
|
&dir.0,
|
||||||
|
"AAPL",
|
||||||
|
"2026-04-25",
|
||||||
|
&server.url(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(crate::commands::balance_commands::FetchPriceError::LicenseRevoked)),
|
||||||
|
"{:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
route.assert_async().await;
|
||||||
|
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "the refused token is gone");
|
||||||
|
assert!(!activation_status_in(&dir.0, &ids, &dec), "the card offers Activate again");
|
||||||
|
assert!(!dir.0.join(CHECK_FILE).exists(), "the price route never writes the key's state");
|
||||||
|
assert_eq!(
|
||||||
|
entitlements_from_dir(&dir.0, &dec),
|
||||||
|
(EDITION_BASE.to_string(), vec!["balance".to_string()]),
|
||||||
|
"key A keeps its edition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_complete_list_without_this_machine_drops_its_token() {
|
||||||
|
// The same folder seen from the machine list: key A has no activation of this machine,
|
||||||
|
// under either identifier. So does a machine whose slot was freed from another
|
||||||
|
// computer, which keeps its token until then.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let key = base_license_with_features(&enc, vec![]);
|
||||||
|
let ids = this_machine();
|
||||||
|
let lists: Vec<(&str, Vec<serde_json::Value>)> = vec![
|
||||||
|
("no machine", vec![]),
|
||||||
|
("other machines only", vec![listed_machine(OTHER_RAW), listed_machine(OTHER_DERIVED)]),
|
||||||
|
];
|
||||||
|
for (list_case, rows) in lists {
|
||||||
|
for token_id in [THIS_DERIVED, THIS_RAW] {
|
||||||
|
let case = format!("{}, token {}", list_case, token_id);
|
||||||
|
let dir = TestDir::new("list_drops_token");
|
||||||
|
dir.write(LICENSE_FILE, &key);
|
||||||
|
dir.write(ACTIVATION_FILE, activation_for(&enc, token_id));
|
||||||
|
assert!(activation_status_in(&dir.0, &ids, &dec), "{}: Activated before", case);
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = verify_route(&mut server, 200, &verify_answer(rows.clone()), 1).await;
|
||||||
|
|
||||||
|
let list = list_machines_in(&dir.0, &server.url(), &key, Some(&ids), &dec, NOW)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
route.assert_async().await;
|
||||||
|
assert_eq!(list.token_dropped, Some(TokenDropped::NotListed), "{}", case);
|
||||||
|
assert_eq!(list.machines.len(), rows.len(), "{}: the list itself is unchanged", case);
|
||||||
|
assert_eq!(stored_token(&dir), None, "{}", case);
|
||||||
|
assert!(!activation_status_in(&dir.0, &ids, &dec), "{}: Activate is offered again", case);
|
||||||
|
assert!(!dir.0.join(CHECK_FILE).exists(), "{}: nothing else is written", case);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of `key` answering `rows`, loaded on this machine next to an activation token
|
||||||
|
/// issued for `token_id`, in a directory named after the calling test: whether the token was
|
||||||
|
/// dropped, the token, and the token file afterwards.
|
||||||
|
async fn list_next_to_a_token(
|
||||||
|
test: &str,
|
||||||
|
key: &str,
|
||||||
|
token_id: &str,
|
||||||
|
rows: Vec<serde_json::Value>,
|
||||||
|
) -> (Option<TokenDropped>, String, Option<String>) {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new(test);
|
||||||
|
dir.write(LICENSE_FILE, key);
|
||||||
|
let token = activation_for(&enc, token_id);
|
||||||
|
dir.write(ACTIVATION_FILE, &token);
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = verify_route(&mut server, 200, &verify_answer(rows), 1).await;
|
||||||
|
|
||||||
|
let list = list_machines_in(&dir.0, &server.url(), key, Some(&this_machine()), &dec, NOW)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
route.assert_async().await;
|
||||||
|
(list.token_dropped, token, stored_token(&dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_row_of_this_machine_keeps_the_token_under_either_identifier() {
|
||||||
|
// ADR 0020, D3: the row may carry either identifier, whatever the token carries.
|
||||||
|
let (enc, _dec) = default_keys();
|
||||||
|
let key = base_license_with_features(&enc, vec![]);
|
||||||
|
// (token claim, identifier of this machine's row)
|
||||||
|
let cases = [(THIS_DERIVED, THIS_DERIVED), (THIS_RAW, THIS_RAW), (THIS_DERIVED, THIS_RAW)];
|
||||||
|
for (token_id, row) in cases {
|
||||||
|
let rows = vec![listed_machine(OTHER_DERIVED), listed_machine(row)];
|
||||||
|
let (dropped, token, left) =
|
||||||
|
list_next_to_a_token("list_keeps_token", &key, token_id, rows).await;
|
||||||
|
assert_eq!(dropped, None, "token {}, row {}", token_id, row);
|
||||||
|
assert_eq!(left, Some(token), "token {}, row {}", token_id, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_raw_token_stays_next_to_its_row_rehashed_as_adr_0020_derives_it() {
|
||||||
|
// What maximus/maximus-api#82 must give a raw row it rehashes: exactly the derived
|
||||||
|
// identifier of ADR 0020, HMAC-SHA256 of the OS id under
|
||||||
|
// `com.simpl.resultat/license-machine-id/v1`, here its first reference vector written
|
||||||
|
// out. Under any other form the row would read as "this machine not listed", and a
|
||||||
|
// machine activated before the derivation would lose its token at the list's opening.
|
||||||
|
let (enc, _dec) = default_keys();
|
||||||
|
let key = base_license_with_features(&enc, vec![]);
|
||||||
|
let rehashed = "4f8817b37072a3a1e6ef014f34c46835ba715b6f2f97afee1fc680d7fc170db1";
|
||||||
|
let (dropped, token, left) =
|
||||||
|
list_next_to_a_token("list_keeps_raw_token", &key, THIS_RAW, vec![listed_machine(rehashed)])
|
||||||
|
.await;
|
||||||
|
assert_eq!(dropped, None);
|
||||||
|
assert_eq!(left, Some(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_token_stays_unless_the_list_can_speak_for_it() {
|
||||||
|
// Each guard on a list that names no activation of this machine, which would drop a
|
||||||
|
// token of this machine otherwise.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let (other_enc, _other_dec) = test_keys([7u8; 32]);
|
||||||
|
let key = base_license_with_features(&enc, vec![]);
|
||||||
|
let machine = this_machine();
|
||||||
|
let valid_token = activation_for(&enc, THIS_DERIVED).into_bytes();
|
||||||
|
// This machine's row, left out of the list: its date does not read.
|
||||||
|
let mut unreadable_row = listed_machine(THIS_DERIVED);
|
||||||
|
unreadable_row["activated_at"] = serde_json::Value::Null;
|
||||||
|
// (case, rows, token file, OS id readable)
|
||||||
|
let cases: Vec<(&str, Vec<serde_json::Value>, Option<Vec<u8>>, bool)> = vec![
|
||||||
|
(
|
||||||
|
"a row that does not read",
|
||||||
|
vec![listed_machine(OTHER_DERIVED), unreadable_row],
|
||||||
|
Some(valid_token.clone()),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("an unreadable OS id", vec![], Some(valid_token.clone()), false),
|
||||||
|
(
|
||||||
|
"an expired token of this machine",
|
||||||
|
vec![],
|
||||||
|
Some(expired_activation_for(&enc, THIS_DERIVED).into_bytes()),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a token of another machine",
|
||||||
|
vec![],
|
||||||
|
Some(activation_for(&enc, OTHER_DERIVED).into_bytes()),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"a token signed by another key",
|
||||||
|
vec![],
|
||||||
|
Some(activation_for(&other_enc, THIS_DERIVED).into_bytes()),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("an unreadable token", vec![], Some(NOT_UTF8.to_vec()), true),
|
||||||
|
("no token", vec![], None, true),
|
||||||
|
];
|
||||||
|
for (case, rows, token, readable_os_id) in cases {
|
||||||
|
let dir = TestDir::new("list_token_guards");
|
||||||
|
dir.write(LICENSE_FILE, &key);
|
||||||
|
if let Some(contents) = &token {
|
||||||
|
dir.write(ACTIVATION_FILE, contents);
|
||||||
|
}
|
||||||
|
let before = snapshot(&dir);
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = verify_route(&mut server, 200, &verify_answer(rows), 1).await;
|
||||||
|
let ids = readable_os_id.then_some(&machine);
|
||||||
|
|
||||||
|
let list = list_machines_in(&dir.0, &server.url(), &key, ids, &dec, NOW)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
route.assert_async().await;
|
||||||
|
assert_eq!(list.token_dropped, None, "{}", case);
|
||||||
|
assert_eq!(snapshot(&dir), before, "{}", case);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_list_that_fails_drops_nothing() {
|
||||||
|
// No list, no reconciliation: an error, a revoked key, a rate limit or an outage say
|
||||||
|
// nothing about this machine's activation.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let key = base_license_with_features(&enc, vec![]);
|
||||||
|
let ids = this_machine();
|
||||||
|
let token = activation_for(&enc, THIS_DERIVED);
|
||||||
|
let failures: Vec<(usize, &str, LicenseError)> = vec![
|
||||||
|
(200, r#"{"valid":false,"reason":"revoked"}"#, LicenseError::LicenseRevoked),
|
||||||
|
(403, REVOKED_ENVELOPE, LicenseError::LicenseRevoked),
|
||||||
|
(
|
||||||
|
429,
|
||||||
|
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
|
||||||
|
LicenseError::RateLimit { retry_after_s: 42 },
|
||||||
|
),
|
||||||
|
(200, r#"{"valid":false,"reason":"not_found"}"#, LicenseError::LicenseNotFound),
|
||||||
|
(200, r#"{"valid":true}"#, LicenseError::InvalidResponse),
|
||||||
|
(503, "<html>Service Unavailable</html>", LicenseError::Server { status: 503 }),
|
||||||
|
];
|
||||||
|
for (status, body, expected) in failures {
|
||||||
|
let dir = TestDir::new("list_fails_keeps_token");
|
||||||
|
dir.write(LICENSE_FILE, &key);
|
||||||
|
dir.write(ACTIVATION_FILE, &token);
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = verify_route(&mut server, status, body, 1).await;
|
||||||
|
|
||||||
|
let result = list_machines_in(&dir.0, &server.url(), &key, Some(&ids), &dec, NOW).await;
|
||||||
|
|
||||||
|
assert_eq!(result.map(|list| list.machines.len()), Err(expected), "{} {}", status, body);
|
||||||
|
route.assert_async().await;
|
||||||
|
assert_eq!(stored_token(&dir).as_deref(), Some(token.as_str()), "{} {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline. Port 1 on loopback: nothing listens.
|
||||||
|
let dir = TestDir::new("list_offline_keeps_token");
|
||||||
|
dir.write(LICENSE_FILE, &key);
|
||||||
|
dir.write(ACTIVATION_FILE, &token);
|
||||||
|
let result = list_machines_in(&dir.0, "http://127.0.0.1:1", &key, Some(&ids), &dec, NOW).await;
|
||||||
|
assert_eq!(result.map(|list| list.machines.len()), Err(LicenseError::Network));
|
||||||
|
assert_eq!(stored_token(&dir), Some(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_token_written_while_the_list_was_in_flight_stays() {
|
||||||
|
// An activation lands while the list request is in flight (CWE-367): the answer, which
|
||||||
|
// names no activation of this machine, cannot speak for the token it wrote.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let key = base_license_with_features(&enc, vec![]);
|
||||||
|
let dir = TestDir::new("list_token_replaced");
|
||||||
|
dir.write(LICENSE_FILE, &key);
|
||||||
|
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_RAW));
|
||||||
|
let fresh = activation_for(&enc, THIS_DERIVED);
|
||||||
|
let (token_path, written) = (dir.0.join(ACTIVATION_FILE), fresh.clone());
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let route = server
|
||||||
|
.mock("POST", "/v1/licenses/verify")
|
||||||
|
.match_request(move |_request| {
|
||||||
|
fs::write(&token_path, &written).unwrap();
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(verify_answer(vec![]))
|
||||||
|
.expect(1)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let list = list_machines_in(&dir.0, &server.url(), &key, Some(&this_machine()), &dec, NOW)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
route.assert_async().await;
|
||||||
|
assert_eq!(list.token_dropped, None);
|
||||||
|
assert_eq!(stored_token(&dir), Some(fresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_webview_learns_whether_the_list_dropped_the_token() {
|
||||||
|
// The wire shape `licenseService.ts` reads (`MachineList`).
|
||||||
|
let row: MachineInfo = serde_json::from_value(listed_machine(OTHER_DERIVED)).unwrap();
|
||||||
|
let wire = |token_dropped| {
|
||||||
|
serde_json::to_value(MachineList {
|
||||||
|
machines: vec![row.clone()],
|
||||||
|
token_dropped,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
wire(Some(TokenDropped::NotListed)),
|
||||||
|
serde_json::json!({
|
||||||
|
"machines": [{
|
||||||
|
"machine_id": OTHER_DERIVED,
|
||||||
|
"machine_name": "laptop",
|
||||||
|
"activated_at": "2026-09-01T00:00:00.000Z",
|
||||||
|
"last_seen_at": "2026-09-20T00:00:00.000Z",
|
||||||
|
"is_this_machine": false,
|
||||||
|
}],
|
||||||
|
"token_dropped": "not_listed",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(wire(None)["token_dropped"], serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
// === Dev override =========================================================================
|
// === Dev override =========================================================================
|
||||||
|
|
||||||
/// The dev override must be dead code in normal builds: even with
|
/// The dev override must be dead code in normal builds: even with
|
||||||
|
|
@ -3565,9 +4039,28 @@ mod license_server_tests {
|
||||||
|
|
||||||
let listed = machines_from_verify(&json!({"valid": true, "machine_limit": 3, "machines": machines}))
|
let listed = machines_from_verify(&json!({"valid": true, "machine_limit": 3, "machines": machines}))
|
||||||
.expect("valid answer");
|
.expect("valid answer");
|
||||||
assert_eq!(listed.len(), 1);
|
assert_eq!(listed.machines.len(), 1);
|
||||||
assert_eq!(listed[0].machine_id, "a");
|
assert_eq!(listed.machines[0].machine_id, "a");
|
||||||
assert_eq!(listed[0].machine_name.as_deref(), Some("laptop"));
|
assert_eq!(listed.machines[0].machine_name.as_deref(), Some("laptop"));
|
||||||
|
assert_eq!(listed.skipped, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_row_that_does_not_read_is_left_out_and_counted() {
|
||||||
|
// The list still shows the other rows; the count is what tells the reconciliation of
|
||||||
|
// the activation token that the list is incomplete (#367).
|
||||||
|
let body = json!({"valid": true, "machines": [
|
||||||
|
{"machine_id": "a", "machine_name": null,
|
||||||
|
"activated_at": "2026-09-01T00:00:00Z", "last_seen_at": "2026-09-20T00:00:00Z"},
|
||||||
|
{"machine_id": "b", "machine_name": "laptop",
|
||||||
|
"activated_at": null, "last_seen_at": "2026-09-20T00:00:00Z"},
|
||||||
|
{"machine_name": "desktop",
|
||||||
|
"activated_at": "2026-09-01T00:00:00Z", "last_seen_at": "2026-09-20T00:00:00Z"},
|
||||||
|
]});
|
||||||
|
let listed = machines_from_verify(&body).expect("valid answer");
|
||||||
|
let ids: Vec<&str> = listed.machines.iter().map(|m| m.machine_id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["a"]);
|
||||||
|
assert_eq!(listed.skipped, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -3765,9 +4258,9 @@ mod license_server_tests {
|
||||||
)
|
)
|
||||||
.create_async()
|
.create_async()
|
||||||
.await;
|
.await;
|
||||||
let machines = verify_inner(&server.url(), "SR-BASE-k").await.expect("machines");
|
let listed = verify_inner(&server.url(), "SR-BASE-k").await.expect("machines");
|
||||||
assert_eq!(machines.len(), 1);
|
assert_eq!(listed.machines.len(), 1);
|
||||||
assert_eq!(machines[0].machine_id, "a");
|
assert_eq!(listed.machines[0].machine_id, "a");
|
||||||
m.assert_async().await;
|
m.assert_async().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3846,7 +4339,7 @@ mod license_server_tests {
|
||||||
|
|
||||||
let base = server.url();
|
let base = server.url();
|
||||||
assert_eq!(activate_inner(&base, "SR-BASE-k", "m", None).await, Ok("t".to_string()));
|
assert_eq!(activate_inner(&base, "SR-BASE-k", "m", None).await, Ok("t".to_string()));
|
||||||
assert_eq!(verify_inner(&base, "SR-BASE-k").await.map(|m| m.len()), Ok(0));
|
assert_eq!(verify_inner(&base, "SR-BASE-k").await.map(|m| m.machines.len()), Ok(0));
|
||||||
assert_eq!(deactivate_inner(&base, "SR-BASE-k", "m").await, Ok(()));
|
assert_eq!(deactivate_inner(&base, "SR-BASE-k", "m").await, Ok(()));
|
||||||
a.assert_async().await;
|
a.assert_async().await;
|
||||||
v.assert_async().await;
|
v.assert_async().await;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import {
|
||||||
type LicenseErrorCode,
|
type LicenseErrorCode,
|
||||||
type LicenseResult,
|
type LicenseResult,
|
||||||
type MachineInfo,
|
type MachineInfo,
|
||||||
|
type MachineList,
|
||||||
|
type TokenDropped,
|
||||||
} from "../services/licenseService";
|
} from "../services/licenseService";
|
||||||
|
|
||||||
// The hook itself reads the LicenseContext; the pure functions below never do.
|
// The hook itself reads the LicenseContext; the pure functions below never do.
|
||||||
|
|
@ -53,6 +55,12 @@ const machine = (id: string): MachineInfo => ({
|
||||||
is_this_machine: false,
|
is_this_machine: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** What `list_activated_machines` answers: the machines, and a token removed or not (#367). */
|
||||||
|
const listed = (machines: MachineInfo[], tokenDropped: TokenDropped | null = null): MachineList => ({
|
||||||
|
machines,
|
||||||
|
token_dropped: tokenDropped,
|
||||||
|
});
|
||||||
|
|
||||||
const reduce = (
|
const reduce = (
|
||||||
actions: LicenseMachinesAction[],
|
actions: LicenseMachinesAction[],
|
||||||
from: LicenseMachinesState = initialLicenseMachinesState,
|
from: LicenseMachinesState = initialLicenseMachinesState,
|
||||||
|
|
@ -309,10 +317,11 @@ function harness(overrides: Partial<LicenseMachinesDeps> = {}) {
|
||||||
activateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
|
activateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
|
||||||
deactivateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
|
deactivateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
|
||||||
listActivatedMachines: vi.fn(
|
listActivatedMachines: vi.fn(
|
||||||
async (): Promise<LicenseResult<MachineInfo[]>> => ({ ok: true, value: [] }),
|
async (): Promise<LicenseResult<MachineList>> => ({ ok: true, value: listed([]) }),
|
||||||
),
|
),
|
||||||
getActivationStatus: vi.fn(async () => ({ is_activated: true })),
|
getActivationStatus: vi.fn(async () => ({ is_activated: true })),
|
||||||
refresh: vi.fn(async () => {}),
|
refresh: vi.fn(async () => {}),
|
||||||
|
logInfo: vi.fn(),
|
||||||
dispatch: (action) => {
|
dispatch: (action) => {
|
||||||
state = licenseMachinesReducer(state, action);
|
state = licenseMachinesReducer(state, action);
|
||||||
},
|
},
|
||||||
|
|
@ -335,7 +344,7 @@ describe("machine-limit path (D2) end to end", () => {
|
||||||
activateMachine,
|
activateMachine,
|
||||||
listActivatedMachines: vi.fn(async () => ({
|
listActivatedMachines: vi.fn(async () => ({
|
||||||
ok: true as const,
|
ok: true as const,
|
||||||
value: [machine("a"), machine("b"), machine("c")],
|
value: listed([machine("a"), machine("b"), machine("c")]),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -400,7 +409,10 @@ describe("performDeactivate", () => {
|
||||||
const { deps, getState } = harness();
|
const { deps, getState } = harness();
|
||||||
await loadMachines({
|
await loadMachines({
|
||||||
...deps,
|
...deps,
|
||||||
listActivatedMachines: vi.fn(async () => ({ ok: true as const, value: [machine("a"), machine("b")] })),
|
listActivatedMachines: vi.fn(async () => ({
|
||||||
|
ok: true as const,
|
||||||
|
value: listed([machine("a"), machine("b")]),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
await performDeactivate(deps, "a");
|
await performDeactivate(deps, "a");
|
||||||
expect(getState().machines.map((m) => m.machine_id)).toEqual(["b"]);
|
expect(getState().machines.map((m) => m.machine_id)).toEqual(["b"]);
|
||||||
|
|
@ -409,9 +421,12 @@ describe("performDeactivate", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-reads the local status, so deactivating this machine brings back Activate", async () => {
|
it("re-reads the local status, so deactivating this machine brings back Activate", async () => {
|
||||||
|
// Read when the card mounts, again once the list has loaded (#367), then after the
|
||||||
|
// deactivation.
|
||||||
const getActivationStatus = vi
|
const getActivationStatus = vi
|
||||||
.fn<() => Promise<ActivationStatus>>()
|
.fn<() => Promise<ActivationStatus>>()
|
||||||
.mockResolvedValueOnce({ is_activated: true })
|
.mockResolvedValueOnce({ is_activated: true })
|
||||||
|
.mockResolvedValueOnce({ is_activated: true })
|
||||||
.mockResolvedValueOnce({ is_activated: false });
|
.mockResolvedValueOnce({ is_activated: false });
|
||||||
const { deps, getState } = harness({ getActivationStatus });
|
const { deps, getState } = harness({ getActivationStatus });
|
||||||
await loadActivationStatus(deps);
|
await loadActivationStatus(deps);
|
||||||
|
|
@ -419,7 +434,7 @@ describe("performDeactivate", () => {
|
||||||
...deps,
|
...deps,
|
||||||
listActivatedMachines: vi.fn(async () => ({
|
listActivatedMachines: vi.fn(async () => ({
|
||||||
ok: true as const,
|
ok: true as const,
|
||||||
value: [machine("this"), machine("b")],
|
value: listed([machine("this"), machine("b")]),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
expect(getState().activation?.is_activated).toBe(true);
|
expect(getState().activation?.is_activated).toBe(true);
|
||||||
|
|
@ -428,7 +443,7 @@ describe("performDeactivate", () => {
|
||||||
// re-read status turns the card back from "Activated" to "Activate".
|
// re-read status turns the card back from "Activated" to "Activate".
|
||||||
await performDeactivate(deps, "this");
|
await performDeactivate(deps, "this");
|
||||||
expect(getState().activation?.is_activated).toBe(false);
|
expect(getState().activation?.is_activated).toBe(false);
|
||||||
expect(getActivationStatus).toHaveBeenCalledTimes(2);
|
expect(getActivationStatus).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a refusal on the list and releases the busy state", async () => {
|
it("shows a refusal on the list and releases the busy state", async () => {
|
||||||
|
|
@ -486,6 +501,79 @@ describe("a revocation reloads the license context", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Loading the list may remove this machine's activation token, when the list names no
|
||||||
|
// activation of this machine (#367). The fixed log line is written out here: compared to the
|
||||||
|
// constant, a line swapped for another would pass.
|
||||||
|
const NOT_LISTED_LINE =
|
||||||
|
"License: this machine's activation token was removed, as the license server lists no activation of this machine for the stored key";
|
||||||
|
|
||||||
|
describe("a list that removed this machine's token (#367)", () => {
|
||||||
|
/** Records the list call, the status reads, the log lines and the actions, in order. */
|
||||||
|
function listHarness(answer: LicenseResult<MachineList>, statuses: boolean[]) {
|
||||||
|
const events: string[] = [];
|
||||||
|
const reads = [...statuses];
|
||||||
|
const { deps, getState } = harness({
|
||||||
|
listActivatedMachines: vi.fn(async () => {
|
||||||
|
events.push("list");
|
||||||
|
return answer;
|
||||||
|
}),
|
||||||
|
getActivationStatus: vi.fn(async () => {
|
||||||
|
events.push("status");
|
||||||
|
return { is_activated: reads.shift() ?? false };
|
||||||
|
}),
|
||||||
|
logInfo: vi.fn((...args: unknown[]) => {
|
||||||
|
events.push(`log ${args.map(String).join(" ")}`);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const fold = deps.dispatch;
|
||||||
|
deps.dispatch = (action) => {
|
||||||
|
events.push(action.type);
|
||||||
|
fold(action);
|
||||||
|
};
|
||||||
|
return { deps, getState, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reads the activation status again once the list has loaded, so Activate comes back", async () => {
|
||||||
|
const { deps, getState, events } = listHarness(
|
||||||
|
{ ok: true, value: listed([machine("other")], "not_listed") },
|
||||||
|
[true, false],
|
||||||
|
);
|
||||||
|
await loadActivationStatus(deps);
|
||||||
|
expect(getState().activation?.is_activated).toBe(true);
|
||||||
|
|
||||||
|
await loadMachines(deps);
|
||||||
|
|
||||||
|
expect(getState().activation?.is_activated).toBe(false);
|
||||||
|
expect(getState().machines.map((m) => m.machine_id)).toEqual(["other"]);
|
||||||
|
expect(events).toEqual([
|
||||||
|
"status",
|
||||||
|
"ACTIVATION_STATUS",
|
||||||
|
"LIST_LOAD_START",
|
||||||
|
"list",
|
||||||
|
"LIST_LOAD_DONE",
|
||||||
|
`log ${NOT_LISTED_LINE}`,
|
||||||
|
"status",
|
||||||
|
"ACTIVATION_STATUS",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads it again after any list, and logs only a token removed", async () => {
|
||||||
|
// A local read, no request: the card follows whatever Rust did.
|
||||||
|
const { deps, events } = listHarness({ ok: true, value: listed([machine("this")]) }, [true]);
|
||||||
|
await loadMachines(deps);
|
||||||
|
expect(events).toEqual(["LIST_LOAD_START", "list", "LIST_LOAD_DONE", "status", "ACTIVATION_STATUS"]);
|
||||||
|
expect(deps.logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neither logs nor reads the status again after a list that failed", async () => {
|
||||||
|
// Rust removes nothing when the list fails.
|
||||||
|
const { deps, events } = listHarness({ ok: false, error: err("network") }, [true]);
|
||||||
|
await loadMachines(deps);
|
||||||
|
expect(events).toEqual(["LIST_LOAD_START", "list", "LIST_LOAD_ERROR"]);
|
||||||
|
expect(deps.logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The hook glues the pure functions to the LicenseContext: rendered once on the
|
// The hook glues the pure functions to the LicenseContext: rendered once on the
|
||||||
// server, with the context faked, to check both directions of that glue.
|
// server, with the context faked, to check both directions of that glue.
|
||||||
describe("useLicenseMachines wiring to the LicenseContext", () => {
|
describe("useLicenseMachines wiring to the LicenseContext", () => {
|
||||||
|
|
@ -526,6 +614,23 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
|
||||||
expect(context.refresh).toHaveBeenCalledTimes(1);
|
expect(context.refresh).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes a token the list removed to the in-app log, then reads the status (#367)", async () => {
|
||||||
|
// The list loaded by a machine-limit refusal, the one load a test can await here.
|
||||||
|
log.logInfo.mockClear();
|
||||||
|
vi.mocked(invoke).mockImplementation(async (command: string) => {
|
||||||
|
if (command === "activate_machine") throw '{"code":"machine_limit_reached"}';
|
||||||
|
if (command === "list_activated_machines") return listed([], "not_listed");
|
||||||
|
if (command === "get_activation_status") return { is_activated: false };
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const view = renderHook();
|
||||||
|
|
||||||
|
await view.activate();
|
||||||
|
|
||||||
|
expect(log.logInfo).toHaveBeenCalledWith(NOT_LISTED_LINE);
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith("get_activation_status");
|
||||||
|
});
|
||||||
|
|
||||||
it("removes the key through remove_license, with the context's revocation and refresh", async () => {
|
it("removes the key through remove_license, with the context's revocation and refresh", async () => {
|
||||||
// The activation status is never loaded here (no effects): not activated, so no release.
|
// The activation status is never loaded here (no effects): not activated, so no release.
|
||||||
vi.mocked(invoke).mockResolvedValue(null);
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||||
import {
|
import {
|
||||||
|
ACTIVATION_TOKEN_LOG,
|
||||||
activateMachine,
|
activateMachine,
|
||||||
deactivateMachine,
|
deactivateMachine,
|
||||||
listActivatedMachines,
|
listActivatedMachines,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
type LicenseErrorCode,
|
type LicenseErrorCode,
|
||||||
type LicenseResult,
|
type LicenseResult,
|
||||||
type MachineInfo,
|
type MachineInfo,
|
||||||
|
type MachineList,
|
||||||
} from "../services/licenseService";
|
} from "../services/licenseService";
|
||||||
import { logInfo, logWarn } from "../services/logService";
|
import { logInfo, logWarn } from "../services/logService";
|
||||||
import { useLicenseContext } from "../contexts/LicenseContext";
|
import { useLicenseContext } from "../contexts/LicenseContext";
|
||||||
|
|
@ -30,6 +32,10 @@ import { useLicenseContext } from "../contexts/LicenseContext";
|
||||||
// a key is entered (#373), shares that budget: with it, the machine-limit path reaches
|
// a key is entered (#373), shares that budget: with it, the machine-limit path reaches
|
||||||
// the 5 requests of the first minute. Removing the key of an activated machine spends one
|
// the 5 requests of the first minute. Removing the key of an activated machine spends one
|
||||||
// deactivate call (#370).
|
// deactivate call (#370).
|
||||||
|
//
|
||||||
|
// Loading the list may remove this machine's activation token, when the list names no
|
||||||
|
// activation of this machine (#367): the activation status is read again after every list
|
||||||
|
// load, a local read, so "Activate" comes back without a reload. No request is added.
|
||||||
|
|
||||||
export interface LicenseMachinesState {
|
export interface LicenseMachinesState {
|
||||||
activation: ActivationStatus | null;
|
activation: ActivationStatus | null;
|
||||||
|
|
@ -200,9 +206,11 @@ export function shouldLoadOnOpen(state: LicenseMachinesState): boolean {
|
||||||
export interface LicenseMachinesDeps {
|
export interface LicenseMachinesDeps {
|
||||||
activateMachine: () => Promise<LicenseResult<void>>;
|
activateMachine: () => Promise<LicenseResult<void>>;
|
||||||
deactivateMachine: (machineId: string) => Promise<LicenseResult<void>>;
|
deactivateMachine: (machineId: string) => Promise<LicenseResult<void>>;
|
||||||
listActivatedMachines: () => Promise<LicenseResult<MachineInfo[]>>;
|
listActivatedMachines: () => Promise<LicenseResult<MachineList>>;
|
||||||
getActivationStatus: () => Promise<ActivationStatus>;
|
getActivationStatus: () => Promise<ActivationStatus>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
/** Writes to the in-app log: a token the list load removed (#367). */
|
||||||
|
logInfo: (...args: unknown[]) => void;
|
||||||
dispatch: (action: LicenseMachinesAction) => void;
|
dispatch: (action: LicenseMachinesAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +240,13 @@ export async function loadMachines(deps: LicenseMachinesDeps): Promise<void> {
|
||||||
await noteRevocation(deps, result.error);
|
await noteRevocation(deps, result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deps.dispatch({ type: "LIST_LOAD_DONE", machines: result.value });
|
deps.dispatch({ type: "LIST_LOAD_DONE", machines: result.value.machines });
|
||||||
|
if (result.value.token_dropped === "not_listed") {
|
||||||
|
deps.logInfo(ACTIVATION_TOKEN_LOG.not_listed_for_this_machine);
|
||||||
|
}
|
||||||
|
// Rust may have just removed a token that stood for no activation of this machine (#367):
|
||||||
|
// read the status again, so the card offers "Activate" without a reload.
|
||||||
|
await loadActivationStatus(deps);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function performActivate(deps: LicenseMachinesDeps): Promise<void> {
|
export async function performActivate(deps: LicenseMachinesDeps): Promise<void> {
|
||||||
|
|
@ -402,6 +416,7 @@ export function useLicenseMachines(enabled: boolean) {
|
||||||
listActivatedMachines,
|
listActivatedMachines,
|
||||||
getActivationStatus,
|
getActivationStatus,
|
||||||
refresh,
|
refresh,
|
||||||
|
logInfo,
|
||||||
dispatch,
|
dispatch,
|
||||||
}),
|
}),
|
||||||
[refresh],
|
[refresh],
|
||||||
|
|
|
||||||
|
|
@ -1256,7 +1256,7 @@
|
||||||
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer",
|
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer",
|
||||||
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
|
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
|
||||||
"The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"",
|
"The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"",
|
||||||
"Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer, a different key or an expired activation, the card shows \"Not activated\": click \"Activate\"",
|
"Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer, a different key or an expired activation, the card shows \"Not activated\": click \"Activate\". The app also removes an activation that no longer serves, and the card then shows \"Not activated\": when a price fetch is refused because this machine's activation belongs to a revoked license (a refunded key left next to your current key by an earlier version, for example), and when the machine list does not count this machine (deactivated from another computer, for example). Each such removal is written to the logs (Settings → System)",
|
||||||
"Activation sends the license server your key, this computer's name and a derived identifier, specific to Simpl'Résultat, in place of the system's machine identifier",
|
"Activation sends the license server your key, this computer's name and a derived identifier, specific to Simpl'Résultat, in place of the system's machine identifier",
|
||||||
"The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot",
|
"The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot",
|
||||||
"If the license server reports that your key was revoked, for instance after a refund, the app goes back to the Free edition and stays there from one session to the next: the License card, the locked screens and the Updates card say so. Your data is kept, and another valid key gives its edition back",
|
"If the license server reports that your key was revoked, for instance after a refund, the app goes back to the Free edition and stays there from one session to the next: the License card, the locked screens and the Updates card say so. Your data is kept, and another valid key gives its edition back",
|
||||||
|
|
@ -2242,7 +2242,7 @@
|
||||||
"missingParam": "Missing parameter",
|
"missingParam": "Missing parameter",
|
||||||
"authFailed": "Authentication failed — activate this machine in Settings → Users",
|
"authFailed": "Authentication failed — activate this machine in Settings → Users",
|
||||||
"premiumRequired": "This feature requires a premium subscription",
|
"premiumRequired": "This feature requires a premium subscription",
|
||||||
"licenseRevoked": "This machine's activation belongs to a revoked license",
|
"licenseRevoked": "This machine's activation belongs to a revoked license — activate this machine again in Settings → Users",
|
||||||
"symbolNotFound": "Symbol not found",
|
"symbolNotFound": "Symbol not found",
|
||||||
"rateLimit": "Too many requests — retry in {{seconds}}s",
|
"rateLimit": "Too many requests — retry in {{seconds}}s",
|
||||||
"serverUnavailable": "Server unavailable — try again later",
|
"serverUnavailable": "Server unavailable — try again later",
|
||||||
|
|
|
||||||
|
|
@ -1256,7 +1256,7 @@
|
||||||
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste",
|
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste",
|
||||||
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
|
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
|
||||||
"La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »",
|
"La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »",
|
||||||
"Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer »",
|
"Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer ». L'application retire aussi d'elle-même une activation qui ne sert plus, et la carte affiche alors « Non activée » : quand la récupération d'un cours est refusée parce que l'activation de cette machine appartient à une licence révoquée (par exemple celle d'une clé remboursée, restée à côté de votre clé actuelle depuis une version antérieure), et quand la liste des machines ne compte pas cette machine (désactivée depuis un autre ordinateur, par exemple). Chacun de ces retraits est inscrit dans les journaux (Paramètres → Système)",
|
||||||
"L'activation envoie au serveur de licences votre clé, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système",
|
"L'activation envoie au serveur de licences votre clé, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système",
|
||||||
"La liste des machines indique combien sont activées sur le nombre permis par votre clé ; désactivez-en une pour libérer une place",
|
"La liste des machines indique combien sont activées sur le nombre permis par votre clé ; désactivez-en une pour libérer une place",
|
||||||
"Si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition",
|
"Si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition",
|
||||||
|
|
@ -2242,7 +2242,7 @@
|
||||||
"missingParam": "Paramètre manquant",
|
"missingParam": "Paramètre manquant",
|
||||||
"authFailed": "Échec d'authentification — activez cette machine dans Paramètres → Utilisateurs",
|
"authFailed": "Échec d'authentification — activez cette machine dans Paramètres → Utilisateurs",
|
||||||
"premiumRequired": "Cette fonction nécessite un abonnement premium",
|
"premiumRequired": "Cette fonction nécessite un abonnement premium",
|
||||||
"licenseRevoked": "L'activation de cette machine est liée à une licence révoquée",
|
"licenseRevoked": "L'activation de cette machine est liée à une licence révoquée — activez de nouveau cette machine dans Paramètres → Utilisateurs",
|
||||||
"symbolNotFound": "Symbole introuvable",
|
"symbolNotFound": "Symbole introuvable",
|
||||||
"rateLimit": "Trop de requêtes — réessaie dans {{seconds}} s",
|
"rateLimit": "Trop de requêtes — réessaie dans {{seconds}} s",
|
||||||
"serverUnavailable": "Serveur indisponible — réessaie plus tard",
|
"serverUnavailable": "Serveur indisponible — réessaie plus tard",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,17 @@ vi.mock("./profileService", () => ({
|
||||||
loadProfiles: vi.fn(),
|
loadProfiles: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The in-app log: a price refused as revoked writes a line (#367).
|
||||||
|
vi.mock("./logService", () => ({
|
||||||
|
logInfo: vi.fn(),
|
||||||
|
logWarn: vi.fn(),
|
||||||
|
logError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { loadProfiles } from "./profileService";
|
import { loadProfiles } from "./profileService";
|
||||||
|
import { logInfo } from "./logService";
|
||||||
import fr from "../i18n/locales/fr.json";
|
import fr from "../i18n/locales/fr.json";
|
||||||
import en from "../i18n/locales/en.json";
|
import en from "../i18n/locales/en.json";
|
||||||
import {
|
import {
|
||||||
|
|
@ -2041,6 +2049,7 @@ describe("balance.service.prices", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.mocked(invoke).mockReset();
|
vi.mocked(invoke).mockReset();
|
||||||
|
vi.mocked(logInfo).mockClear();
|
||||||
prices.__resetForTests();
|
prices.__resetForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2136,6 +2145,54 @@ describe("balance.service.prices", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rust removes the token it refused as revoked (#367): the message sends the user to
|
||||||
|
// "Activate". So does the auth message, which the rest of a batch gets once the token is
|
||||||
|
// gone.
|
||||||
|
it("invites to activate this machine after a refusal, in both locales (#367)", () => {
|
||||||
|
const where = { fr: "Paramètres → Utilisateurs", en: "Settings → Users" };
|
||||||
|
const verb = { fr: /activez/, en: /activate/ };
|
||||||
|
for (const lng of ["fr", "en"] as const) {
|
||||||
|
const errors = { fr, en }[lng].balance.priceFetching.errors;
|
||||||
|
for (const key of ["licenseRevoked", "authFailed"] as const) {
|
||||||
|
expect(errors[key], `${key} in ${lng}`).toContain(where[lng]);
|
||||||
|
expect(errors[key], `${key} in ${lng}`).toMatch(verb[lng]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3c. A refusal as revoked writes one fixed line to the in-app log (#367): Rust has just
|
||||||
|
// removed the token it refused. Written out, not read from the constant.
|
||||||
|
it("fetchPrice logs a token refused as revoked, once", async () => {
|
||||||
|
vi.mocked(invoke).mockRejectedValueOnce('{"code":"license_revoked"}');
|
||||||
|
|
||||||
|
const promise = prices.fetchPrice("AAPL", "2026-04-25");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logInfo).toHaveBeenCalledWith(
|
||||||
|
"License: the price server refused this machine's activation token as issued for a revoked license; that token is removed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetchPrice logs nothing on any other answer", async () => {
|
||||||
|
vi.mocked(invoke)
|
||||||
|
.mockResolvedValueOnce(FAKE_PRICE_RESPONSE)
|
||||||
|
.mockRejectedValueOnce('{"code":"auth"}')
|
||||||
|
.mockRejectedValueOnce('{"code":"premium_required"}')
|
||||||
|
.mockRejectedValueOnce('{"code":"symbol_not_found"}')
|
||||||
|
.mockRejectedValueOnce('{"code":"rate_limit","retry_after_s":30}');
|
||||||
|
|
||||||
|
for (const symbol of ["OK", "AUTH", "BASE", "NONE", "SLOW"]) {
|
||||||
|
const promise = prices.fetchPrice(symbol, "2026-04-25");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledTimes(5);
|
||||||
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// 4. 404 symbol_not_found — no retry
|
// 4. 404 symbol_not_found — no retry
|
||||||
it("fetchPrice symbol_not_found returns immediately without retry", async () => {
|
it("fetchPrice symbol_not_found returns immediately without retry", async () => {
|
||||||
vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');
|
vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getDb, withTransaction } from "./db";
|
import { getDb, withTransaction } from "./db";
|
||||||
import { loadProfiles } from "./profileService";
|
import { loadProfiles } from "./profileService";
|
||||||
|
import { ACTIVATION_TOKEN_LOG } from "./licenseService";
|
||||||
|
import { logInfo } from "./logService";
|
||||||
import type {
|
import type {
|
||||||
AccountReturn,
|
AccountReturn,
|
||||||
BalanceAccount,
|
BalanceAccount,
|
||||||
|
|
@ -2671,6 +2673,7 @@ const PRICE_ERROR_I18N_MAP: Record<Exclude<PriceErrorCode, "rate_limit">, string
|
||||||
premium_required: "balance.priceFetching.errors.premiumRequired",
|
premium_required: "balance.priceFetching.errors.premiumRequired",
|
||||||
// The license of this machine's activation was revoked (#365). Named, never taken
|
// The license of this machine's activation was revoked (#365). Named, never taken
|
||||||
// as the state of the stored key: Rust cannot tie the activation token to that key.
|
// as the state of the stored key: Rust cannot tie the activation token to that key.
|
||||||
|
// Rust drops that token instead (#367): the message sends the user to "Activate".
|
||||||
license_revoked: "balance.priceFetching.errors.licenseRevoked",
|
license_revoked: "balance.priceFetching.errors.licenseRevoked",
|
||||||
symbol_not_found: "balance.priceFetching.errors.symbolNotFound",
|
symbol_not_found: "balance.priceFetching.errors.symbolNotFound",
|
||||||
provider_unavailable: "balance.priceFetching.errors.serverUnavailable",
|
provider_unavailable: "balance.priceFetching.errors.serverUnavailable",
|
||||||
|
|
@ -2742,7 +2745,10 @@ async function _enforceRateLimit(): Promise<void> {
|
||||||
_lastFiredAt = Date.now();
|
_lastFiredAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single attempt: rate-limit, then invoke once. */
|
/**
|
||||||
|
* Single attempt: rate-limit, then invoke once. A `license_revoked` refusal is written to
|
||||||
|
* the in-app log: Rust has just removed the activation token it refused (#367).
|
||||||
|
*/
|
||||||
async function _doFetchOnce(
|
async function _doFetchOnce(
|
||||||
symbol: string,
|
symbol: string,
|
||||||
date: string
|
date: string
|
||||||
|
|
@ -2752,7 +2758,11 @@ async function _doFetchOnce(
|
||||||
const raw = await invoke<RawPriceResponse>("fetch_price", { symbol, date });
|
const raw = await invoke<RawPriceResponse>("fetch_price", { symbol, date });
|
||||||
return { ok: true, ...raw };
|
return { ok: true, ...raw };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: parseRustError(e) };
|
const error = parseRustError(e);
|
||||||
|
if (error.code === "license_revoked") {
|
||||||
|
logInfo(ACTIVATION_TOKEN_LOG.price_refused_revoked);
|
||||||
|
}
|
||||||
|
return { ok: false, error };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
parseLicenseError,
|
parseLicenseError,
|
||||||
LICENSE_ERROR_I18N_MAP,
|
LICENSE_ERROR_I18N_MAP,
|
||||||
|
ACTIVATION_TOKEN_LOG,
|
||||||
activateMachine,
|
activateMachine,
|
||||||
checkLicenseRevocation,
|
checkLicenseRevocation,
|
||||||
deactivateMachine,
|
deactivateMachine,
|
||||||
|
|
@ -104,17 +105,21 @@ describe("license command wrappers", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns ok with the value when the command resolves", async () => {
|
it("returns ok with the value when the command resolves", async () => {
|
||||||
const machines = [
|
// The list and whether loading it removed this machine's activation token (#367).
|
||||||
{
|
const list = {
|
||||||
machine_id: "m1",
|
machines: [
|
||||||
machine_name: "laptop",
|
{
|
||||||
activated_at: "2026-09-01T00:00:00Z",
|
machine_id: "m1",
|
||||||
last_seen_at: "2026-09-20T00:00:00Z",
|
machine_name: "laptop",
|
||||||
is_this_machine: true,
|
activated_at: "2026-09-01T00:00:00Z",
|
||||||
},
|
last_seen_at: "2026-09-20T00:00:00Z",
|
||||||
];
|
is_this_machine: true,
|
||||||
mockInvoke.mockResolvedValueOnce(machines);
|
},
|
||||||
await expect(listActivatedMachines()).resolves.toEqual({ ok: true, value: machines });
|
],
|
||||||
|
token_dropped: "not_listed",
|
||||||
|
};
|
||||||
|
mockInvoke.mockResolvedValueOnce(list);
|
||||||
|
await expect(listActivatedMachines()).resolves.toEqual({ ok: true, value: list });
|
||||||
expect(mockInvoke).toHaveBeenCalledWith("list_activated_machines", undefined);
|
expect(mockInvoke).toHaveBeenCalledWith("list_activated_machines", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,6 +164,17 @@ describe("license command wrappers", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("log lines of a removed activation token (#367)", () => {
|
||||||
|
it("has one fixed line per code fixed at planning", () => {
|
||||||
|
// On the model of REVOCATION_LOG: constants, so no token, license id or machine id can
|
||||||
|
// reach the in-app log through them (ADR 0020, CWE-532). The callers' tests pin each line.
|
||||||
|
expect(Object.keys(ACTIVATION_TOKEN_LOG).sort()).toEqual([
|
||||||
|
"not_listed_for_this_machine",
|
||||||
|
"price_refused_revoked",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The render tests echo i18n keys instead of translating them: a key missing
|
// The render tests echo i18n keys instead of translating them: a key missing
|
||||||
// from a locale would show up as a raw "license.errors.x" string with the
|
// from a locale would show up as a raw "license.errors.x" string with the
|
||||||
// suite green. Only these checks read the real locale files.
|
// suite green. Only these checks read the real locale files.
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,33 @@ export interface ActivationStatus {
|
||||||
is_activated: boolean;
|
is_activated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why Rust removed the activation token while the machine list loaded (#367). `not_listed`:
|
||||||
|
* the license server lists no activation of this machine for the stored key, so the token
|
||||||
|
* stood for none (another license's token left by 0.15.0, a slot freed from another computer).
|
||||||
|
*/
|
||||||
|
export type TokenDropped = "not_listed";
|
||||||
|
|
||||||
|
/** What `list_activated_machines` returns (#367). */
|
||||||
|
export interface MachineList {
|
||||||
|
machines: MachineInfo[];
|
||||||
|
/** `null` when the token was not removed. */
|
||||||
|
token_dropped: TokenDropped | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-app log lines of an activation token Rust removed (#367), by fixed code, on the
|
||||||
|
* model of `REVOCATION_LOG`: no token, license id nor machine id ever goes in them (ADR 0020,
|
||||||
|
* CWE-532). The machine list writes the first, on `token_dropped`; the price fetch writes the
|
||||||
|
* second, on a `license_revoked` refusal.
|
||||||
|
*/
|
||||||
|
export const ACTIVATION_TOKEN_LOG = {
|
||||||
|
not_listed_for_this_machine:
|
||||||
|
"License: this machine's activation token was removed, as the license server lists no activation of this machine for the stored key",
|
||||||
|
price_refused_revoked:
|
||||||
|
"License: the price server refused this machine's activation token as issued for a revoked license; that token is removed",
|
||||||
|
} as const;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// License errors (issues #270 and #347)
|
// License errors (issues #270 and #347)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
@ -218,8 +245,12 @@ export function deactivateMachine(machineId: string): Promise<LicenseResult<void
|
||||||
return callLicenseCommand<void>("deactivate_machine", { machineId });
|
return callLicenseCommand<void>("deactivate_machine", { machineId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listActivatedMachines(): Promise<LicenseResult<MachineInfo[]>> {
|
/**
|
||||||
return callLicenseCommand<MachineInfo[]>("list_activated_machines");
|
* The machines activated for the stored key, and whether loading them removed this machine's
|
||||||
|
* activation token (#367): read the activation status again afterwards.
|
||||||
|
*/
|
||||||
|
export function listActivatedMachines(): Promise<LicenseResult<MachineList>> {
|
||||||
|
return callLicenseCommand<MachineList>("list_activated_machines");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActivationStatus(): Promise<ActivationStatus> {
|
export async function getActivationStatus(): Promise<ActivationStatus> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue