Simpl-Resultat/src-tauri/src/commands/license_commands.rs
le king fu 2cbc886653
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m47s
PR Check — Rust / rust (pull_request) Successful in 3m40s
fix(license): apply the review follow-ups on the license stack head
Follow-ups of the three /pr-review passes on #386, #387 and #388, applied
on the stack head before the fast-forward merge:

- #386: pin the relay of the revoked state by revocationCheckLauncher
  (a launcher passing false instead of wasRevoked passed the whole suite),
  and say in the rate budget comment that two checks in one minute bring
  the machine-limit path to 6 requests.
- #387 S1: the fake `t` of the removal tests and of the License card tests
  marks what it translates, so a string that skipped `t` fails its test.
- #387 S2: pin that storing another key stops on Storage while the
  previous token cannot be dropped, the previous key staying.
- #387 S3: reload the license context after a failed removal too. Rust
  deletes the key first, so a token that resisted left the key gone while
  the card kept showing it under "the key was not removed".
- #388: pin that an unreadable token stays (the directory case could not
  tell, its deletion fails anyway).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:13:36 -04:00

4383 lines
192 KiB
Rust

// License validation, storage and reading for the Base/Premium editions.
//
// Architecture:
// - License key = "SR-BASE-<JWT>" or "SR-PREMIUM-<JWT>", JWT signed Ed25519 by the server
// - Activation token = separate JWT, also signed by the server, that binds the license to a
// machine ON THE SERVER (machine_id claim, machine limit). Issued by `/v1/licenses/activate`
// (Issue #49); the market-price route requires it.
// - Both files live in app_data_dir/ — license.key and activation.token
// - The edition comes from the license key alone: get_edition() returns "free" unless the
// license JWT is valid (signature + exp). The activation token plays no part (#347, D12).
// Until #347 a token that did not validate for this machine dropped the edition to free:
// that protected nothing (a key copied without its token kept its edition) and locked out
// users whose 2-year token had expired or came from a restored backup (#360). The token now
// only drives the "Activated" state of the License card and the price calls, which the
// server verifies (ADR 0017, amendment for #347).
// - The machine is named to the server by an identifier DERIVED from the operating system's
// machine id (HMAC-SHA256 under a fixed application key), never by that id itself (#350,
// ADR 0020). A token or a server row from before the derivation carries the raw id: both
// forms are recognized locally, only the derived one is ever sent by an activation.
// - A key the license server revoked resolves to free (#365). The server is asked at app
// start and after a key is entered (#373), one check at a time, at most once per 24 h and
// only when a key is stored (`check_license_revocation`); its answer is kept in
// license.check, next to the key, as a digest of the key, the time of the last attempt and
// the verdict. Fail-open: an unreachable server changes nothing.
// - 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,
// 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.
use hmac::{Hmac, Mac};
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::fs;
use std::path::{Path, PathBuf};
use tauri::Manager;
use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
// Ed25519 public key for license verification.
//
// Production key generated 2026-04-25 alongside the maximus-api scaffold.
// The matching private key lives ONLY on the license server as env var
// ED25519_PRIVATE_KEY_PEM (see maximus-api/.env on Coolify).
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAmUTcl7xjt01uc2FhPgvP0at0I/Pie0JLh73AApNy+o8=\n\
-----END PUBLIC KEY-----\n";
const LICENSE_FILE: &str = "license.key";
/// 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).
const CHECK_FILE: &str = "license.check";
/// Shortest time between two automatic attempts of the revocation check (#365, D1).
const CHECK_INTERVAL_SECS: i64 = 86_400;
const KEY_PREFIX_BASE: &str = "SR-BASE-";
const KEY_PREFIX_PREMIUM: &str = "SR-PREMIUM-";
/// Decoded license metadata exposed to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseInfo {
pub edition: String,
pub email: String,
pub features: Vec<String>,
pub machine_limit: u32,
pub issued_at: i64,
pub expires_at: i64,
/// The license server answered "revoked" for this key and nothing lifted it since
/// (#365). Read from the state file of the data directory: always `false` out of a
/// validation that reads no file.
#[serde(default)]
pub revoked: bool,
}
/// Claims embedded in the license JWT (signed by the license server).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LicenseClaims {
sub: String, // email
iss: String,
iat: i64,
exp: i64, // mandatory — see CWE-613
edition: String,
#[serde(default)]
features: Vec<String>,
machine_limit: u32,
}
/// Claims embedded in the activation token JWT (server-signed, machine-bound).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ActivationClaims {
sub: String, // license id or hash
iat: i64,
exp: i64,
machine_id: String,
}
fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))
}
fn license_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app_data_dir(app)?.join(LICENSE_FILE))
}
/// Strip the human-readable prefix and return the bare JWT.
fn strip_prefix(key: &str) -> Result<&str, LicenseError> {
let trimmed = key.trim();
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) {
return Ok(jwt);
}
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) {
return Ok(jwt);
}
Err(LicenseError::InvalidPrefix)
}
/// Build a `Validation` with `exp` and `iat` mandatory. Assertions are explicit so a future
/// config change cannot silently disable expiry checking (CWE-613).
fn strict_validation() -> Validation {
let mut validation = Validation::new(Algorithm::EdDSA);
validation.validate_exp = true;
validation.leeway = 0;
validation.set_required_spec_claims(&["exp", "iat"]);
validation
}
/// Build the production `DecodingKey` from the embedded PEM constant.
fn embedded_decoding_key() -> Result<DecodingKey, LicenseError> {
DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes()).map_err(|_| LicenseError::Internal)
}
/// Name a `jsonwebtoken` failure for the user. An expired key and a key signed by another
/// key get their own message; anything else (unreadable JWT, missing claim, unexpected
/// algorithm) reads as a malformed key.
fn license_error_from_jwt(err: &jsonwebtoken::errors::Error) -> LicenseError {
match err.kind() {
ErrorKind::ExpiredSignature => LicenseError::LicenseExpired,
ErrorKind::InvalidSignature => LicenseError::InvalidSignature,
_ => LicenseError::Malformed,
}
}
/// Pure validation: decode the JWT, verify signature with the provided key, ensure the
/// edition claim is one we recognize. Returns `LicenseInfo` on success.
///
/// Separated from the Tauri command so tests can pass their own key.
fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInfo, LicenseError> {
let jwt = strip_prefix(key)?;
let validation = strict_validation();
let data = decode::<LicenseClaims>(jwt, decoding_key, &validation)
.map_err(|e| license_error_from_jwt(&e))?;
let claims = data.claims;
if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM {
return Err(LicenseError::UnknownEdition);
}
Ok(LicenseInfo {
edition: claims.edition,
email: claims.sub,
features: claims.features,
machine_limit: claims.machine_limit,
issued_at: claims.iat,
expires_at: claims.exp,
revoked: false,
})
}
/// Machine identifier an activation token was issued for, once the token is known to be
/// signed by the license server and unexpired.
fn activation_token_machine_id(token: &str, decoding_key: &DecodingKey) -> Result<String, String> {
let validation = strict_validation();
let data = decode::<ActivationClaims>(token.trim(), decoding_key, &validation)
.map_err(|e| format!("Invalid activation token: {}", e))?;
Ok(data.claims.machine_id)
}
/// Validate an activation token against the local machine. The token must be signed by the
/// license server, unexpired, and issued for this machine: under its derived identifier, or
/// under the raw one for an activation made before the derivation (ADR 0020, D1).
fn validate_activation_with_key(
token: &str,
ids: &MachineIds,
decoding_key: &DecodingKey,
) -> Result<(), String> {
let machine_id = activation_token_machine_id(token, decoding_key)?;
if !ids.names_this_machine(&machine_id) {
return Err("Activation token belongs to a different machine".to_string());
}
Ok(())
}
// === Tauri commands ===========================================================================
/// Validate a license key without persisting it. Errors cross the boundary as a serialized
/// [`LicenseError`].
#[tauri::command]
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
embedded_decoding_key()
.and_then(|decoding_key| validate_with_key(&key, &decoding_key))
.map_err(|e| license_error_to_string(&e))
}
/// Validate then persist a license key. The activation token (machine binding on the server)
/// is stored separately by [`store_activation_token`] once the server has issued one. Errors
/// cross the boundary as a serialized [`LicenseError`].
#[tauri::command]
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
store_license_impl(&app, &key).map_err(|e| license_error_to_string(&e))
}
fn store_license_impl(app: &tauri::AppHandle, key: &str) -> Result<LicenseInfo, LicenseError> {
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
let decoding_key = embedded_decoding_key()?;
store_license_in(&dir, key, &decoding_key)
}
/// Validate `key`, then write it to `dir/license.key`. A refused key writes nothing. A
/// different key drops the activation token of the previous one. The info returned says
/// whether the key is already known as revoked (#365): entering a revoked key again does
/// not bring its edition back. Kept apart from the Tauri command, without `AppHandle`, so
/// tests run it in a temporary directory.
fn store_license_in(
dir: &Path,
key: &str,
decoding_key: &DecodingKey,
) -> Result<LicenseInfo, LicenseError> {
let info = validate_with_key(key, decoding_key)?;
let key = key.trim();
fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?;
// A token belongs to the key it was issued for, and the client cannot tell which: its
// `sub` is the license id, the key carries the email. Kept across a key change, it would
// show this machine as activated while the price server authenticates the previous
// license (#347 review). The same key entered again keeps its token.
let previous = fs::read_to_string(dir.join(LICENSE_FILE)).ok();
if previous.as_deref().map(str::trim) != Some(key) {
remove_activation_token_in(dir)?;
}
fs::write(dir.join(LICENSE_FILE), key).map_err(|_| LicenseError::Storage)?;
Ok(LicenseInfo {
revoked: is_revoked_in(dir, key),
..info
})
}
/// 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
/// 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> {
match fs::remove_file(dir.join(ACTIVATION_FILE)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(LicenseError::Storage),
}
}
/// 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
/// caller — it must have been issued for this machine's derived identifier to be stored.
/// Errors cross the boundary as a serialized [`LicenseError`].
#[tauri::command]
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
persist_activation_token(&app, &token).map_err(|e| license_error_to_string(&e))
}
/// [`store_activation_token_in`] for the app data directory and this machine.
fn persist_activation_token(app: &tauri::AppHandle, token: &str) -> Result<(), LicenseError> {
let ids = local_machine_ids()?;
let decoding_key = embedded_decoding_key()?;
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
store_activation_token_in(&dir, token, &ids, &decoding_key)
}
/// Validate then write the activation token the server has just issued. It must carry the
/// identifier this version sends, the derived one: the raw one is only accepted from a token
/// already on disk (ADR 0020). A token that fails validation came from the server, hence
/// `InvalidResponse`; a failed write is `Storage`. Kept apart from `AppHandle` so tests run
/// it in a temporary directory.
fn store_activation_token_in(
dir: &Path,
token: &str,
ids: &MachineIds,
decoding_key: &DecodingKey,
) -> Result<(), LicenseError> {
match activation_token_machine_id(token, decoding_key) {
Ok(machine_id) if machine_id == ids.derived => {}
_ => return Err(LicenseError::InvalidResponse),
}
fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?;
fs::write(dir.join(ACTIVATION_FILE), token.trim()).map_err(|_| LicenseError::Storage)
}
/// Read and validate the stored license. Returns `None` when no license is stored or when the
/// stored key no longer validates (the edition then resolves to free). Errors cross the
/// boundary as a serialized [`LicenseError`].
#[tauri::command]
pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String> {
let dir = app_data_dir(&app).map_err(|_| license_error_to_string(&LicenseError::Storage))?;
let Ok(decoding_key) = embedded_decoding_key() else {
return Ok(None);
};
read_license_in(&dir, &decoding_key).map_err(|e| license_error_to_string(&e))
}
/// Read `dir/license.key`: `None` when the file is missing or the key does not validate,
/// `Storage` when the file exists but cannot be read. The info says whether the license
/// server is known to have revoked the key (#365).
fn read_license_in(dir: &Path, decoding_key: &DecodingKey) -> Result<Option<LicenseInfo>, LicenseError> {
let path = dir.join(LICENSE_FILE);
if !path.exists() {
return Ok(None);
}
let key = fs::read_to_string(&path).map_err(|_| LicenseError::Storage)?;
Ok(validate_with_key(&key, decoding_key)
.ok()
.map(|info| LicenseInfo {
revoked: is_revoked_in(dir, &key),
..info
}))
}
/// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
///
/// Returns "free" when no license is stored, or when the stored license JWT is invalid or
/// expired. The activation token plays no part (#347, D12): it binds the key to a machine on
/// the server, and only the price calls require it.
#[tauri::command]
pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
Ok(current_edition(&app))
}
/// Dev-only edition override, compiled in ONLY under the `dev-override` Cargo
/// feature (off by default and absent from any release feature set). Gating on
/// `debug_assertions` instead would be CWE-489: a custom release build could
/// flip it on and the env var would become a Premium backdoor. When compiled
/// in, `SR_DEV_EDITION` forces the resolved edition (free|base|premium) so the
/// three tiers can be tested without real licenses; unrecognized values are
/// ignored and resolution falls through to the normal path.
fn dev_override_edition() -> Option<String> {
#[cfg(feature = "dev-override")]
{
if let Ok(edition) = std::env::var("SR_DEV_EDITION") {
if edition == EDITION_FREE || edition == EDITION_BASE || edition == EDITION_PREMIUM {
return Some(edition);
}
}
}
None
}
/// Pure resolution of `(edition, signed features)` from the license key alone.
///
/// Single choke point of the local gating: a valid, unexpired key gives its edition and its
/// signed `features[]`; any validation failure gives `("free", [])`, features included, so a
/// key that no longer validates never keeps its overrides (CWE-863). The activation token is
/// deliberately not an input (#347, D12): it used to downgrade a valid key whenever it did not
/// validate for this machine, which protected nothing (a key copied without its token kept its
/// edition) and locked out users whose token had expired or came from a restored backup.
fn resolve_license_entitlements(license_key: &str, decoding_key: &DecodingKey) -> (String, Vec<String>) {
match validate_with_key(license_key, decoding_key) {
Ok(info) => (info.edition, info.features),
Err(_) => (EDITION_FREE.to_string(), Vec::new()),
}
}
/// Resolve the entitlements from the files of `dir` (the app data directory in production).
/// Reads `license.key` and the revocation state of that key: `activation.token` is never
/// read here, whatever it contains. A missing or unreadable key file resolves to
/// `("free", [])`, and so does a key the license server revoked (#365), signed features
/// included. Kept apart from `AppHandle` so tests exercise the decision on real files in a
/// temporary directory.
fn entitlements_from_dir(dir: &Path, decoding_key: &DecodingKey) -> (String, Vec<String>) {
let Ok(key) = fs::read_to_string(dir.join(LICENSE_FILE)) else {
return (EDITION_FREE.to_string(), Vec::new());
};
if is_revoked_in(dir, &key) {
return (EDITION_FREE.to_string(), Vec::new());
}
resolve_license_entitlements(&key, decoding_key)
}
/// Internal helper used by `entitlements::check_entitlement`. Resolves the effective edition
/// AND the signed per-license feature overrides together. Never returns an error — any failure
/// resolves to `("free", [])` so feature gates fail closed, features included (CWE-863).
///
/// Priority: dev override (dev-override builds only) > Premium (via Compte Maximus with active
/// subscription) > the license key (offline), unless the license server revoked it (#365) >
/// Free. The activation token is not consulted (#347, D12).
pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec<String>) {
// Dev-only tier testing — compiled out of normal builds (see the helper).
if let Some(edition) = dev_override_edition() {
return (edition, Vec::new());
}
// Check Compte Maximus subscription first — Premium overrides Base. This
// path never reads the license JWT, so it carries no signed features.
if let Some(edition) = check_account_edition(app) {
if edition == EDITION_PREMIUM {
return (edition, Vec::new());
}
}
let free = || (EDITION_FREE.to_string(), Vec::new());
let Ok(dir) = app_data_dir(app) else {
return free();
};
let Ok(decoding_key) = embedded_decoding_key() else {
return free();
};
entitlements_from_dir(&dir, &decoding_key)
}
/// Edition-only view of [`current_entitlements`], used by `get_edition` and any
/// caller that does not need the feature overrides.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
current_entitlements(app).0
}
/// Read the HMAC-verified account cache to check for an active Premium
/// subscription. Legacy unsigned caches (from v0.7.x) and tampered
/// payloads return None — Premium features stay locked until the user
/// re-authenticates or the next token refresh re-signs the cache.
///
/// This is intentional: before HMAC signing, any local process could
/// write `{"subscription_status": "active"}` to account.json and
/// bypass the paywall. Fail-closed is the correct posture here.
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
let account = super::account_cache::load_verified(app).ok().flatten()?;
match account.subscription_status.as_deref() {
Some("active") => Some(EDITION_PREMIUM.to_string()),
_ => None,
}
}
// === Online revocation check (#365, ADR 0017 amendment) ========================================
/// What the license server last said about a key, and when it was last asked. Stored as
/// JSON in `license.check`, next to the key it is about.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct LicenseCheck {
/// SHA-256 of the key the state is about, in hex. Never the key itself: this file must
/// not become a second copy of it.
key_sha256: String,
/// Unix time of the last attempt to reach the server, successful or not.
attempted_at: i64,
/// The server answered "revoked" for this key, and no "valid" answer lifted it since.
revoked: bool,
}
/// SHA-256 of the trimmed key, as 64 lowercase hex characters.
fn key_digest(key: &str) -> String {
use sha2::Digest;
Sha256::digest(key.trim().as_bytes())
.iter()
.map(|b| format!("{:02x}", b))
.collect()
}
/// State recorded for `key`, if any. A missing or unreadable file, invalid JSON, or a state
/// about another key all read as "no state": the key is not known as revoked, and a check
/// is due. A corrupt file must never downgrade a paying user.
fn read_license_check(dir: &Path, key: &str) -> Option<LicenseCheck> {
let raw = fs::read_to_string(dir.join(CHECK_FILE)).ok()?;
let check: LicenseCheck = serde_json::from_str(&raw).ok()?;
(check.key_sha256 == key_digest(key)).then_some(check)
}
fn write_license_check(dir: &Path, check: &LicenseCheck) -> Result<(), LicenseError> {
let raw = serde_json::to_string(check).map_err(|_| LicenseError::Storage)?;
fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?;
fs::write(dir.join(CHECK_FILE), raw).map_err(|_| LicenseError::Storage)
}
/// Whether the license server is known to have revoked `key`.
fn is_revoked_in(dir: &Path, key: &str) -> bool {
read_license_check(dir, key).is_some_and(|check| check.revoked)
}
/// Record a "revoked" answer for the key a command has just sent (D4): whichever command
/// learns it, the state is the same one the check writes. Any other result passes through
/// untouched. The key is the one that was sent, never one read again afterwards.
fn note_revocation<T>(
dir: &Path,
license_key: &str,
result: Result<T, LicenseError>,
now: i64,
) -> Result<T, LicenseError> {
if matches!(result, Err(LicenseError::LicenseRevoked)) {
// Best effort: the refusal itself still reaches the user.
let _ = write_license_check(
dir,
&LicenseCheck {
key_sha256: key_digest(license_key),
attempted_at: now,
revoked: true,
},
);
}
result
}
/// How a revocation check ended. The front writes it to the in-app log.
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RevocationOutcome {
/// No key is stored, or the stored key does not validate locally: nothing was sent.
NoLicense,
/// The last attempt is less than 24 h old: nothing was sent.
NotDue,
/// The server knows the key as valid.
Valid,
/// The server answered "revoked".
Revoked,
/// The server could not be reached, or answered with an error: nothing changed.
Unreachable,
/// The server answered something the client does not act on (`not_found`, `expired`).
Unknown,
/// The attempt could not be written, so nothing was sent; or the server's verdict could
/// not be, so nothing changed.
Storage,
}
/// Result of [`check_license_revocation`]: how the check ended, and whether the stored key
/// is known as revoked afterwards.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RevocationCheck {
pub outcome: RevocationOutcome,
pub revoked: bool,
}
/// What a verify answer says about the key itself.
enum VerifyVerdict {
Valid,
Revoked,
Other,
}
/// Read `valid` and `reason` only: the check must not depend on the machine list, so a
/// valid answer lifts a revocation even without `machines`.
fn verdict_from_verify(body: &serde_json::Value) -> VerifyVerdict {
match body.get("valid").and_then(|v| v.as_bool()) {
Some(true) => VerifyVerdict::Valid,
Some(false) if body.get("reason").and_then(|v| v.as_str()) == Some("revoked") => {
VerifyVerdict::Revoked
}
_ => VerifyVerdict::Other,
}
}
/// Whether an automatic attempt is due: no state for the key, a last attempt at least 24 h
/// old, or one dated in the future (clock moved back). Checked arithmetic: a corrupt
/// `attempted_at` must neither panic nor block the check for ever.
fn revocation_check_due(state: Option<&LicenseCheck>, now: i64) -> bool {
match state {
None => true,
Some(check) => now
.checked_sub(check.attempted_at)
.map_or(true, |age| !(0..CHECK_INTERVAL_SECS).contains(&age)),
}
}
/// The revocation check, without `AppHandle` so tests run it in a temporary directory and
/// against a mock server (#365). The command runs it through [`check_revocation_in_turn`],
/// one check at a time (#373).
///
/// - Nothing is sent without a stored key that validates locally, nor when the last attempt
/// is less than 24 h old (D1).
/// - The attempt is written before the request leaves: a second call, or a failed one, still
/// counts, so "at most one attempt per 24 h" holds whatever happens next.
/// - Fail-open (D3): only a "valid" answer lifts the state and only a "revoked" answer sets
/// it. Any other answer, and any error, leaves it as it was.
async fn check_revocation_in(
dir: &Path,
api_base: &str,
decoding_key: &DecodingKey,
now: i64,
) -> RevocationCheck {
let no_license = RevocationCheck {
outcome: RevocationOutcome::NoLicense,
revoked: false,
};
let Ok(key) = fs::read_to_string(dir.join(LICENSE_FILE)) else {
return no_license;
};
if validate_with_key(&key, decoding_key).is_err() {
return no_license;
}
let state = read_license_check(dir, &key);
let was_revoked = state.as_ref().is_some_and(|check| check.revoked);
let unchanged = |outcome| RevocationCheck {
outcome,
revoked: was_revoked,
};
if !revocation_check_due(state.as_ref(), now) {
return unchanged(RevocationOutcome::NotDue);
}
let mut check = LicenseCheck {
key_sha256: key_digest(&key),
attempted_at: now,
revoked: was_revoked,
};
if write_license_check(dir, &check).is_err() {
return unchanged(RevocationOutcome::Storage);
}
let outcome = match verify_body(api_base, &key).await {
Ok(body) => match verdict_from_verify(&body) {
VerifyVerdict::Valid => RevocationOutcome::Valid,
VerifyVerdict::Revoked => RevocationOutcome::Revoked,
VerifyVerdict::Other => RevocationOutcome::Unknown,
},
// The same verdict, should the server ever say it through the error envelope.
Err(LicenseError::LicenseRevoked) => RevocationOutcome::Revoked,
Err(LicenseError::LicenseNotFound | LicenseError::LicenseExpired) => {
RevocationOutcome::Unknown
}
Err(_) => RevocationOutcome::Unreachable,
};
check.revoked = match outcome {
RevocationOutcome::Valid => false,
RevocationOutcome::Revoked => true,
_ => return unchanged(outcome),
};
if check.revoked != was_revoked && write_license_check(dir, &check).is_err() {
return unchanged(RevocationOutcome::Storage);
}
RevocationCheck {
outcome,
revoked: check.revoked,
}
}
/// One revocation check at a time (#373). Since a key entered in session is checked too, two
/// checks can overlap: the one at start and the one of a key entered before it ran, or the
/// checks of two keys entered in a row. The 24 h guard reads the state then writes the
/// attempt, and a verdict rewrites `license.check`, which holds one key: side by side, both
/// checks could send, or one could overwrite what the other recorded. The command takes this
/// turn over the whole check, request included, so a check that waited reads the state the
/// previous one left.
static REVOCATION_CHECK_TURN: tauri::async_runtime::Mutex<()> =
tauri::async_runtime::Mutex::const_new(());
/// [`check_revocation_in`] once `turn` is free, held until the check is over (#373): the
/// command passes [`REVOCATION_CHECK_TURN`]. The time is read when the turn comes, not when the
/// check is called: read before the wait, it could date the attempt of a check that ran
/// meanwhile in the future, and a check is due then. Kept apart from `AppHandle`, and from the
/// static, so tests run checks side by side against a mock server, each test with its own turn.
async fn check_revocation_in_turn(
turn: &tauri::async_runtime::Mutex<()>,
dir: &Path,
api_base: &str,
decoding_key: &DecodingKey,
now: impl FnOnce() -> i64,
) -> RevocationCheck {
let _turn = turn.lock().await;
check_revocation_in(dir, api_base, decoding_key, now()).await
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs() as i64)
.unwrap_or(0)
}
// === Machine identifier (#350, ADR 0020) ======================================================
type HmacSha256 = Hmac<Sha256>;
/// Fixed, application-specific key of the machine identifier derivation. Not a secret (this
/// repository is public): it makes the identifier specific to this application, as
/// machine-id(5) asks. NEVER change it, nor the derivation: the license server records every
/// activation under an identifier derived with this key, and another key would orphan them
/// all. Two reference vectors pin both in the tests.
const MACHINE_ID_KEY: &[u8] = b"com.simpl.resultat/license-machine-id/v1";
/// Derive the identifier sent to the license server from the operating system's machine id:
/// HMAC-SHA256 under [`MACHINE_ID_KEY`], as 64 lowercase hex characters. The OS id is
/// confidential and must not be exposed on the network (machine-id(5)); it cannot be
/// recovered from the derived one.
///
/// An empty id is refused, never hashed: every machine without a machine id would otherwise
/// share one derived identifier, hence one slot of a license.
fn derive_machine_id(raw: &str) -> Result<String, LicenseError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(LicenseError::Internal);
}
let mut mac = HmacSha256::new_from_slice(MACHINE_ID_KEY).map_err(|_| LicenseError::Internal)?;
mac.update(raw.as_bytes());
Ok(mac
.finalize()
.into_bytes()
.iter()
.map(|b| format!("{:02x}", b))
.collect())
}
/// The two identifiers the license server may know this machine under. No `Debug` on
/// purpose: the raw one must not end up in a log.
struct MachineIds {
/// Derived identifier: the only one this version sends.
derived: String,
/// Operating system's machine id, which versions up to 0.15.0 sent as is. Never sent
/// again: kept to recognize an activation made by one of those versions (ADR 0020, D1).
raw: String,
}
impl MachineIds {
/// Both identifiers of the machine whose OS id is `raw`. `Internal` for an empty id.
fn from_raw(raw: &str) -> Result<Self, LicenseError> {
let raw = raw.trim();
Ok(Self {
derived: derive_machine_id(raw)?,
raw: raw.to_string(),
})
}
/// Whether `machine_id`, as a server row or a token carries it, names this machine. Both
/// forms count (ADR 0020, D3): a row or a token from before the derivation carries the
/// raw one, and the server may rehash its rows some day.
fn names_this_machine(&self, machine_id: &str) -> bool {
machine_id == self.derived || machine_id == self.raw
}
}
/// Operating system's machine id (`/etc/machine-id` on Linux, `MachineGuid` on Windows).
/// Stable across reboots; it changes after an OS reinstall or a hardware migration. The
/// edition does not depend on it (#347, D12): after such a change the License card shows
/// this machine as not activated, and "Activate" binds it again on the server, which the
/// price calls require. It never leaves this module as is: see [`derive_machine_id`].
fn raw_machine_id() -> Result<String, LicenseError> {
machine_uid::get().map_err(|_| LicenseError::Internal)
}
/// Identifiers of this machine. `Internal` when the OS id is unreadable or empty.
fn local_machine_ids() -> Result<MachineIds, LicenseError> {
MachineIds::from_raw(&raw_machine_id()?)
}
// License server API base URL. Overridable via MAXIMUS_API_URL for development.
fn api_base_url() -> String {
std::env::var("MAXIMUS_API_URL")
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}
/// Product sent with every license server call. The `/v1/licenses/*` routes accept it
/// as optional and otherwise infer it from the key prefix (maximus-api#48).
const PRODUCT: &str = "simpl-resultat";
/// Wait assumed when a 429 carries no usable `retry_after` (the server window is 60 s).
const DEFAULT_RETRY_AFTER_S: u64 = 60;
/// Typed error of the license commands, serialized as JSON to cross the Tauri boundary —
/// same pattern as `FetchPriceError` in `balance_commands.rs`: `{"code":"license_revoked"}`,
/// `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. The front
/// parses it with `parseLicenseError` (`licenseService.ts`) and maps each code to a
/// `license.errors.*` message. Issue #270 (server-side refusals); #347 (local key-entry
/// errors, which reuse `LicenseExpired` and `Storage`).
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum LicenseError {
LicenseNotFound,
LicenseRevoked,
/// Refused by the server, or the key's own `exp` has passed.
LicenseExpired,
MachineLimitReached,
NoActiveActivation,
InvalidInput,
/// The key does not start with `SR-BASE-` or `SR-PREMIUM-`.
InvalidPrefix,
/// The key was not signed by the license server.
InvalidSignature,
/// The key's JWT cannot be read: bad encoding, missing claim, unexpected algorithm.
Malformed,
/// The key names an edition this version does not know.
UnknownEdition,
RateLimit { retry_after_s: u64 },
/// The request could not be sent or timed out.
Network,
/// Unreadable 2xx body, missing `activation_token`, or a token refused locally.
InvalidResponse,
/// Any other non-2xx answer, or an unknown code.
Server { status: u16 },
NoLicenseStored,
/// Reading or writing the license / activation files.
Storage,
/// Unreadable machine id or invalid embedded public key.
Internal,
}
/// Serialize a `LicenseError` to the stable JSON string returned across the Tauri boundary.
fn license_error_to_string(err: &LicenseError) -> String {
serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string())
}
/// Map a non-2xx answer to a `LicenseError` from the `/v1` envelope
/// `{ "error": { "code", "message" } }`. Only `error.code` decides: a 404 is
/// `license_not_found` or `no_active_activation`, never "missing route". The status is
/// used for 429 and for bodies without a known code (e.g. an HTML 502 page).
fn error_from_response(status: u16, body: &serde_json::Value) -> LicenseError {
let code = body.pointer("/error/code").and_then(|v| v.as_str());
match code {
Some("license_not_found") => LicenseError::LicenseNotFound,
Some("license_revoked") => LicenseError::LicenseRevoked,
Some("license_expired") => LicenseError::LicenseExpired,
Some("machine_limit_reached") => LicenseError::MachineLimitReached,
Some("no_active_activation") => LicenseError::NoActiveActivation,
Some("invalid_input") => LicenseError::InvalidInput,
_ if status == 429 || code == Some("rate_limit_exceeded") => {
let retry_after_s = body
.pointer("/error/retry_after")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_RETRY_AFTER_S);
LicenseError::RateLimit { retry_after_s }
}
_ => LicenseError::Server { status },
}
}
/// POST a JSON body to the license server. The body of a non-2xx answer is read
/// leniently (it may be an HTML error page) before the status decides.
async fn post_license_api(
api_base: &str,
path: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, LicenseError> {
let resp = reqwest::Client::new()
.post(format!("{}{}", api_base, path))
.json(body)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|_| LicenseError::Network)?;
let status = resp.status();
if status.is_success() {
resp.json::<serde_json::Value>()
.await
.map_err(|_| LicenseError::InvalidResponse)
} else {
let body = resp
.json::<serde_json::Value>()
.await
.unwrap_or(serde_json::Value::Null);
Err(error_from_response(status.as_u16(), &body))
}
}
/// Body of an activation request. `machine_name` is omitted, never sent as `null`, when
/// the host name is unreadable: the server's schema (`z.string().optional()`) answers
/// `400 invalid_input` to a null.
fn activate_body(license_key: &str, machine_id: &str, machine_name: Option<&str>) -> serde_json::Value {
let mut body = serde_json::json!({
"product": PRODUCT,
"license_key": license_key.trim(),
"machine_id": machine_id,
});
if let Some(name) = machine_name {
body["machine_name"] = serde_json::Value::String(name.to_string());
}
body
}
/// Activation call, without `AppHandle` nor disk access so tests can point it at a mock
/// server. Returns the activation token issued for `machine_id`.
async fn activate_inner(
api_base: &str,
license_key: &str,
machine_id: &str,
machine_name: Option<&str>,
) -> Result<String, LicenseError> {
let body = activate_body(license_key, machine_id, machine_name);
let resp = post_license_api(api_base, "/v1/licenses/activate", &body).await?;
resp["activation_token"]
.as_str()
.map(str::to_string)
.ok_or(LicenseError::InvalidResponse)
}
/// Activation of the machine whose OS id is `raw_machine_id`, as the command performs it:
/// the identifier sent is the derived one, never the raw one (ADR 0020), and the token the
/// server issues for it is stored. The derivation sits here, next to the call, so that an
/// empty id stops before any request. Kept apart from `AppHandle` so tests run it against
/// a mock server and a temporary directory.
async fn activate_and_store_in(
dir: &Path,
api_base: &str,
license_key: &str,
raw_machine_id: &str,
machine_name: Option<&str>,
decoding_key: &DecodingKey,
now: i64,
) -> Result<(), LicenseError> {
let ids = MachineIds::from_raw(raw_machine_id)?;
let answer = activate_inner(api_base, license_key, &ids.derived, machine_name).await;
let token = note_revocation(dir, license_key, answer, now)?;
store_activation_token_in(dir, &token, &ids, decoding_key)
}
/// Deactivation call (frees the slot of `machine_id`). Returns `NoActiveActivation` as
/// the server says it; [`deactivate_idempotent`] decides what that means for the user.
async fn deactivate_inner(
api_base: &str,
license_key: &str,
machine_id: &str,
) -> Result<(), LicenseError> {
let body = serde_json::json!({
"product": PRODUCT,
"license_key": license_key.trim(),
"machine_id": machine_id,
});
post_license_api(api_base, "/v1/licenses/deactivate", &body)
.await
.map(|_| ())
}
/// Verify call, raw answer. The request carries the product and the key, nothing else: it
/// is also what the revocation check sends on its own, at app start (#365) and after a key
/// is entered (#373).
async fn verify_body(api_base: &str, license_key: &str) -> Result<serde_json::Value, LicenseError> {
let body = serde_json::json!({
"product": PRODUCT,
"license_key": license_key.trim(),
});
post_license_api(api_base, "/v1/licenses/verify", &body).await
}
/// Verify call: the machines activated for the key.
async fn verify_inner(api_base: &str, license_key: &str) -> Result<ListedMachines, LicenseError> {
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:
/// `{valid:false, reason}` for an unknown, revoked or expired key, `{valid:true,
/// 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". A row that does not
/// 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()) {
Some(false) => Err(match body.get("reason").and_then(|v| v.as_str()) {
Some("not_found") => LicenseError::LicenseNotFound,
Some("revoked") => LicenseError::LicenseRevoked,
Some("expired") => LicenseError::LicenseExpired,
_ => LicenseError::Server { status: 200 },
}),
Some(true) => {
let rows = body
.get("machines")
.and_then(|v| v.as_array())
.ok_or(LicenseError::InvalidResponse)?;
let machines: Vec<MachineInfo> = rows
.iter()
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
.collect();
Ok(ListedMachines {
skipped: rows.len() - machines.len(),
machines,
})
}
None => Err(LicenseError::InvalidResponse),
}
}
/// Deactivation as the command performs it: idempotent, since `no_active_activation`
/// means the slot is already free, which is the state the user asked for. Kept apart
/// from the Tauri command, without `AppHandle`, so tests run it against a mock server.
async fn deactivate_idempotent(
api_base: &str,
license_key: &str,
machine_id: &str,
) -> Result<(), LicenseError> {
match deactivate_inner(api_base, license_key, machine_id).await {
Err(LicenseError::NoActiveActivation) => Ok(()),
other => other,
}
}
/// Stored license key for a server call, `None` when no key is stored.
fn read_stored_key(app: &tauri::AppHandle) -> Result<Option<String>, LicenseError> {
let path = license_path(app).map_err(|_| LicenseError::Storage)?;
if !path.exists() {
return Ok(None);
}
fs::read_to_string(&path)
.map(Some)
.map_err(|_| LicenseError::Storage)
}
/// Machine info returned by the license server, plus the local `is_this_machine` flag.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineInfo {
pub machine_id: String,
pub machine_name: Option<String>,
pub activated_at: String,
pub last_seen_at: String,
/// Whether the row names this machine. Set locally by [`flag_this_machine`], never read
/// from the server's answer.
#[serde(skip_deserializing)]
pub is_this_machine: bool,
}
/// Activation status for display in the UI. It carries no machine identifier: the webview
/// gets the flagged machine list instead (ADR 0020).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivationStatus {
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
/// `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> {
for machine in &mut machines {
machine.is_this_machine =
ids.is_some_and(|ids| ids.names_this_machine(&machine.machine_id));
}
machines
}
/// Machines activated for the key, as the command returns them: the server's list with
/// this machine flagged. Kept apart from `AppHandle` so tests run it against a mock server.
async fn list_machines_inner(
api_base: &str,
license_key: &str,
ids: Option<&MachineIds>,
) -> Result<ListedMachines, LicenseError> {
let listed = verify_inner(api_base, license_key).await?;
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
/// 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(
dir: &Path,
api_base: &str,
license_key: &str,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
now: i64,
) -> 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 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
/// "revoked" answer recorded for the key that was sent (#365, D4), then the local token
/// dropped if it stood for the freed slot.
async fn deactivate_in(
dir: &Path,
api_base: &str,
license_key: &str,
machine_id: &str,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
now: i64,
) -> Result<(), LicenseError> {
let answer = deactivate_idempotent(api_base, license_key, machine_id).await;
note_revocation(dir, license_key, answer, now)?;
drop_token_of_deactivated_machine_in(dir, machine_id, ids, decoding_key);
Ok(())
}
/// Activate this machine with the license server. Reads the stored license key, sends
/// the derived machine identifier to the API, and stores the returned activation token.
/// Errors cross the boundary as a serialized [`LicenseError`].
#[tauri::command]
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
activate_machine_impl(&app)
.await
.map_err(|e| license_error_to_string(&e))
}
async fn activate_machine_impl(app: &tauri::AppHandle) -> Result<(), LicenseError> {
let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?;
let os_machine_id = raw_machine_id()?;
let machine_name = hostname::get().ok().and_then(|h| h.into_string().ok());
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
let decoding_key = embedded_decoding_key()?;
activate_and_store_in(
&dir,
&api_base_url(),
&license_key,
&os_machine_id,
machine_name.as_deref(),
&decoding_key,
unix_now(),
)
.await
}
/// Deactivate a machine on the license server, freeing a slot. Idempotent: a machine the
/// server no longer counts as active is treated as deactivated. Errors cross the
/// boundary as a serialized [`LicenseError`].
#[tauri::command]
pub async fn deactivate_machine(
app: tauri::AppHandle,
machine_id: String,
) -> Result<(), String> {
deactivate_machine_impl(&app, &machine_id)
.await
.map_err(|e| license_error_to_string(&e))
}
async fn deactivate_machine_impl(app: &tauri::AppHandle, machine_id: &str) -> Result<(), LicenseError> {
let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?;
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
let decoding_key = embedded_decoding_key()?;
// Best effort on the OS id: unreadable, the token only goes with a row it was issued for.
let ids = local_machine_ids().ok();
deactivate_in(
&dir,
&api_base_url(),
&license_key,
machine_id,
ids.as_ref(),
&decoding_key,
unix_now(),
)
.await
}
/// Identifier the stored activation token was issued for, when that token is signed by the
/// license server and unexpired. `None` for a missing, unreadable, expired or forged token.
fn stored_token_machine_id(dir: &Path, decoding_key: &DecodingKey) -> Option<String> {
let token = fs::read_to_string(dir.join(ACTIVATION_FILE)).ok()?;
activation_token_machine_id(&token, decoding_key).ok()
}
/// After the server has freed the slot of `deactivated`, drop the local activation token if
/// it stood for that slot (ADR 0020). `ids` is `None` when the OS id is unreadable. Kept
/// apart from `AppHandle` so tests run it in a temporary directory.
fn drop_token_of_deactivated_machine_in(
dir: &Path,
deactivated: &str,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
) {
let token_id = stored_token_machine_id(dir, decoding_key);
let stood_for_it = match ids {
// A row of this machine: the token goes with it. One exception: the old raw row is
// deactivated while the token stands for the derived one, which stays active.
Some(ids) if ids.names_this_machine(deactivated) => {
!(deactivated == ids.raw && token_id.as_deref() == Some(ids.derived.as_str()))
}
// Another machine's row, or an unreadable OS id: only a token issued for that row.
_ => token_id.as_deref() == Some(deactivated),
};
if stood_for_it {
let _ = remove_activation_token_in(dir);
}
}
/// List all machines currently activated for the stored license (empty when no key is
/// 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]
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<MachineList, String> {
list_activated_machines_impl(&app)
.await
.map_err(|e| license_error_to_string(&e))
}
async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result<MachineList, LicenseError> {
let Some(license_key) = read_stored_key(app)? else {
return Ok(MachineList {
machines: vec![],
token_dropped: None,
});
};
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
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();
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
/// expired token, or one issued for another machine, reads as not activated, so the License
/// card offers "Activate" (the price calls need a token the server accepts). A machine whose
/// OS id is unreadable cannot be matched to a token and reads as not activated too. The
/// edition does not depend on this status (#347, D12).
#[tauri::command]
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
let dir = app_data_dir(&app)?;
let is_activated = match (local_machine_ids(), embedded_decoding_key()) {
(Ok(ids), Ok(decoding_key)) => activation_status_in(&dir, &ids, &decoding_key),
_ => false,
};
Ok(ActivationStatus { is_activated })
}
/// Whether `dir/activation.token` activates this machine: signed by the license server,
/// not expired, and issued for one of this machine's identifiers (the raw one for a token
/// from before the derivation, ADR 0020). A missing, unreadable, expired or foreign token
/// reads as not activated, which is what brings back "Activate" (#360). Kept apart from
/// `AppHandle` so tests run it in a temporary directory.
fn activation_status_in(dir: &Path, ids: &MachineIds, decoding_key: &DecodingKey) -> bool {
let Ok(token) = fs::read_to_string(dir.join(ACTIVATION_FILE)) else {
return false;
};
validate_activation_with_key(&token, ids, decoding_key).is_ok()
}
// === Removal of the stored key (#370) ==========================================================
/// Remove the stored license key from this computer (#370). `license.key` goes first, which is
/// what stops the revocation check from sending it, then the activation token; `license.check`
/// stays. With `release_slot`, this machine's slot is freed on the license server first (D2).
/// Errors cross the boundary as a serialized [`LicenseError`]: a release that failed removes
/// nothing, and when the server could not be reached or answered badly the front asks whether
/// to remove the key anyway, then calls again without `release_slot`.
#[tauri::command]
pub async fn remove_license(app: tauri::AppHandle, release_slot: bool) -> Result<(), String> {
remove_license_impl(&app, release_slot)
.await
.map_err(|e| license_error_to_string(&e))
}
async fn remove_license_impl(app: &tauri::AppHandle, release_slot: bool) -> Result<(), LicenseError> {
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
let decoding_key = embedded_decoding_key()?;
// Best effort on the OS id: unreadable, no token can stand for this machine, and the
// removal stays local (the card reads "Not activated" then, too).
let ids = local_machine_ids().ok();
remove_license_in(
&dir,
&api_base_url(),
release_slot,
ids.as_ref(),
&decoding_key,
unix_now(),
)
.await
}
/// The removal, without `AppHandle` so tests run it in a temporary directory and against a
/// mock server.
///
/// - With `release_slot`, [`release_this_machine_slot`] runs first. Its error is returned as
/// is and nothing is removed.
/// - `license.key`, then `activation.token`, each through a deletion that tolerates a missing
/// file. The first failure stops there with `Storage`: the token never goes while the key
/// stays.
/// - `license.check` stays. It is indexed by the key's digest, so it weighs on no other key,
/// and dropping it would give a revoked key, removed then entered again, its edition back
/// until the next check (decision 1 of #370).
/// - Without a key nothing is sent, nothing is written, and the call succeeds: a second
/// removal is a no-op, apart from a token a first one could not delete.
async fn remove_license_in(
dir: &Path,
api_base: &str,
release_slot: bool,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
now: i64,
) -> Result<(), LicenseError> {
if release_slot {
release_this_machine_slot(dir, api_base, ids, decoding_key, now).await?;
}
match fs::remove_file(dir.join(LICENSE_FILE)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return Err(LicenseError::Storage),
}
remove_activation_token_in(dir)
}
/// Free this machine's slot on the license server before its key is removed (#370, D2).
///
/// Nothing is sent without a stored key, for a key known as revoked (the server freed its
/// slots when it revoked it), nor without a token that activates this machine
/// ([`this_machine_token_id`]): as far as the client knows, there is no slot of this machine
/// to free then. The deactivation carries the token's own identifier, the raw one for an
/// activation made before #350, and the stored key.
///
/// `no_active_activation`, `license_not_found` and `license_revoked` (recorded for the key
/// sent, #365 D4) mean the slot is free: the removal goes on. Any other error is returned,
/// `network`, `server`, `rate_limit` and `invalid_response` among them. A key file that exists
/// but cannot be read is `Storage`: the release was asked and cannot be made.
async fn release_this_machine_slot(
dir: &Path,
api_base: &str,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
now: i64,
) -> Result<(), LicenseError> {
let key = match fs::read_to_string(dir.join(LICENSE_FILE)) {
Ok(key) => key,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(_) => return Err(LicenseError::Storage),
};
if is_revoked_in(dir, &key) {
return Ok(());
}
let Some(machine_id) = this_machine_token_id(dir, ids, decoding_key) else {
return Ok(());
};
// Not `deactivate_in`: it drops the token as soon as the slot is freed, and a key that
// then failed to go would stay without its token.
let answer = deactivate_idempotent(api_base, &key, &machine_id).await;
match note_revocation(dir, &key, answer, now) {
Ok(()) | Err(LicenseError::LicenseNotFound | LicenseError::LicenseRevoked) => Ok(()),
Err(e) => Err(e),
}
}
/// Identifier of the stored activation token when that token activates this machine: signed
/// by the license server, unexpired, and issued under one of this machine's identifiers, the
/// rule of [`activation_status_in`] that gives the card its "Activated" state. `None` for a
/// missing, unreadable, expired or forged token, for another machine's token, and without a
/// readable OS id.
fn this_machine_token_id(
dir: &Path,
ids: Option<&MachineIds>,
decoding_key: &DecodingKey,
) -> Option<String> {
let ids = ids?;
stored_token_machine_id(dir, decoding_key).filter(|id| ids.names_this_machine(id))
}
/// Ask the license server whether the stored key was revoked (#365). The front calls it at
/// app start and after a key is entered (#373); the checks run one at a time
/// ([`check_revocation_in_turn`]), and the 24 h guard and the fail-open rules live in
/// [`check_revocation_in`]. It is the one request the app makes on its own: nothing leaves
/// without a stored key, and the request carries the product and the key only. Never an
/// error for the user: the outcome says what happened, for the in-app log.
#[tauri::command]
pub async fn check_license_revocation(app: tauri::AppHandle) -> Result<RevocationCheck, String> {
let dir = app_data_dir(&app)?;
let decoding_key = embedded_decoding_key().map_err(|e| license_error_to_string(&e))?;
Ok(check_revocation_in_turn(
&REVOCATION_CHECK_TURN,
&dir,
&api_base_url(),
&decoding_key,
unix_now,
)
.await)
}
// === Tests ====================================================================================
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use jsonwebtoken::{encode, EncodingKey, Header};
// === Manual DER encoder for the Ed25519 private key =======================================
// We avoid the `pem` feature on `ed25519-dalek` because the `LineEnding` re-export path
// varies across `pkcs8`/`spki`/`der` versions. The Ed25519 PKCS#8 v1 byte layout is fixed
// and trivial: 16-byte prefix + 32-byte raw seed.
//
// Note the asymmetry in jsonwebtoken's API:
// - `EncodingKey::from_ed_der` expects a PKCS#8-wrapped private key (passed to ring's
// `Ed25519KeyPair::from_pkcs8`).
// - `DecodingKey::from_ed_der` expects the *raw* 32-byte public key (passed to ring's
// `UnparsedPublicKey::new` which takes raw bytes, not a SubjectPublicKeyInfo).
/// Wrap a 32-byte Ed25519 seed in a PKCS#8 v1 PrivateKeyInfo DER blob.
fn ed25519_pkcs8_private_der(seed: &[u8; 32]) -> Vec<u8> {
// SEQUENCE(46) {
// INTEGER(1) 0 // version v1
// SEQUENCE(5) {
// OID(3) 1.3.101.112 // Ed25519
// }
// OCTET STRING(34) {
// OCTET STRING(32) <32 bytes>
// }
// }
let mut der = vec![
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
0x04, 0x20,
];
der.extend_from_slice(seed);
der
}
/// Build a deterministic test keypair so signed tokens are reproducible across runs.
fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) {
let signing_key = SigningKey::from_bytes(&seed);
let pubkey_bytes = signing_key.verifying_key().to_bytes();
let priv_der = ed25519_pkcs8_private_der(&seed);
let encoding_key = EncodingKey::from_ed_der(&priv_der);
// Raw 32-byte public key (NOT SubjectPublicKeyInfo) — see note above.
let decoding_key = DecodingKey::from_ed_der(&pubkey_bytes);
(encoding_key, decoding_key)
}
fn default_keys() -> (EncodingKey, DecodingKey) {
test_keys([42u8; 32])
}
fn make_token<T: serde::Serialize>(encoding_key: &EncodingKey, claims: &T) -> String {
encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap()
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[test]
fn rejects_key_without_prefix() {
let (_enc, dec) = default_keys();
let result = validate_with_key("nonsense", &dec);
assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix);
}
#[test]
fn accepts_well_formed_base_license() {
let (enc, dec) = default_keys();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features: vec!["auto-update".to_string()],
machine_limit: 3,
};
let jwt = make_token(&enc, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let info = validate_with_key(&key, &dec).unwrap();
assert_eq!(info.edition, EDITION_BASE);
assert_eq!(info.email, "user@example.com");
assert_eq!(info.machine_limit, 3);
}
#[test]
fn accepts_hundred_year_license() {
// Keys issued since maximus-api#63 last 36525 days: their `exp` crosses 2^32 (#363).
let (enc, dec) = default_keys();
let exp = now() + 36525 * 86400;
assert!(exp > u32::MAX as i64, "a 100-year key must cross 2^32 to test the boundary");
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp,
edition: EDITION_BASE.to_string(),
features: vec![],
machine_limit: 3,
};
let key = format!("{}{}", KEY_PREFIX_BASE, make_token(&enc, &claims));
let info = validate_with_key(&key, &dec).unwrap();
assert_eq!(info.expires_at, exp);
}
#[test]
fn rejects_expired_license() {
let (enc, dec) = default_keys();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now() - 1000,
exp: now() - 100,
edition: EDITION_BASE.to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&enc, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, &dec);
assert_eq!(result.unwrap_err(), LicenseError::LicenseExpired, "expired license must be rejected");
}
#[test]
fn rejects_license_without_exp() {
// CWE-613: a key without `exp` never validates, however well it is signed.
#[derive(Serialize)]
struct ClaimsWithoutExp {
sub: String,
iss: String,
iat: i64,
edition: String,
features: Vec<String>,
machine_limit: u32,
}
let (enc, dec) = default_keys();
let claims = ClaimsWithoutExp {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
edition: EDITION_BASE.to_string(),
features: vec![],
machine_limit: 3,
};
let key = format!("{}{}", KEY_PREFIX_BASE, make_token(&enc, &claims));
assert_eq!(validate_with_key(&key, &dec).unwrap_err(), LicenseError::Malformed);
}
#[test]
fn rejects_license_signed_with_wrong_key() {
let (enc_signer, _dec_signer) = default_keys();
let (_enc_other, dec_other) = test_keys([7u8; 32]);
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&enc_signer, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, &dec_other);
assert_eq!(
result.unwrap_err(),
LicenseError::InvalidSignature,
"wrong-key signature must be rejected"
);
}
#[test]
fn rejects_corrupted_jwt() {
let (_enc, dec) = default_keys();
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
let result = validate_with_key(&key, &dec);
assert_eq!(result.unwrap_err(), LicenseError::Malformed);
}
#[test]
fn a_key_damaged_while_copying_can_read_as_invalid_signature() {
// jsonwebtoken checks the signature over `header.payload` before it decodes the
// payload: a space slipped into the payload reads as a signature failure. The
// `invalidSignature` message therefore sends the user back to the copy first.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let payload_start = key.find('.').unwrap() + 1;
let damaged = format!("{} {}", &key[..payload_start + 4], &key[payload_start + 4..]);
assert_eq!(validate_with_key(&damaged, &dec).unwrap_err(), LicenseError::InvalidSignature);
}
#[test]
fn rejects_unknown_edition() {
let (enc, dec) = default_keys();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: "enterprise".to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&enc, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, &dec);
assert_eq!(result.unwrap_err(), LicenseError::UnknownEdition);
}
// The machine the tests run "on", and another one: an OS id and the identifier derived
// from it. These pairs are the two reference vectors of ADR 0020, written out as literals
// so that no test can pass by comparing the derivation to itself.
const THIS_RAW: &str = "0123456789abcdef0123456789abcdef";
const THIS_DERIVED: &str = "4f8817b37072a3a1e6ef014f34c46835ba715b6f2f97afee1fc680d7fc170db1";
const OTHER_RAW: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const OTHER_DERIVED: &str = "3b9579ec36b3daf37a2aaab0abe2315fa4a9f07200ae568c5aaa614663037d9d";
/// A fixed clock for the functions that take the time as an argument.
const NOW: i64 = 1_800_000_000;
fn this_machine() -> MachineIds {
MachineIds::from_raw(THIS_RAW).unwrap()
}
#[test]
fn activation_token_matches_machine() {
let (enc, dec) = default_keys();
let token = activation_for(&enc, THIS_DERIVED);
assert!(validate_activation_with_key(&token, &this_machine(), &dec).is_ok());
}
#[test]
fn activation_token_from_before_the_derivation_still_matches() {
// ADR 0020, D1: versions up to 0.15.0 sent the OS id as is, and their token carries
// it. A machine activated by one of them stays activated.
let (enc, dec) = default_keys();
let token = activation_for(&enc, THIS_RAW);
assert!(validate_activation_with_key(&token, &this_machine(), &dec).is_ok());
}
#[test]
fn activation_token_rejects_other_machine() {
let (enc, dec) = default_keys();
for other in [OTHER_RAW, OTHER_DERIVED] {
let token = activation_for(&enc, other);
let result = validate_activation_with_key(&token, &this_machine(), &dec);
assert!(result.is_err(), "copied activation token must be rejected ({})", other);
}
}
#[test]
fn activation_token_rejects_expired() {
// The token stays strictly validated where it decides something: the "Activated"
// state of the card and the token written after an activation (#347, D12). The raw
// form gets no leniency.
let (enc, dec) = default_keys();
for id in [THIS_DERIVED, THIS_RAW] {
let token = expired_activation_for(&enc, id);
assert!(
validate_activation_with_key(&token, &this_machine(), &dec).is_err(),
"an expired token must not read as activated ({})",
id
);
}
}
#[test]
fn embedded_public_key_pem_parses() {
// Sanity check that the production PEM constant is well-formed.
assert!(embedded_decoding_key().is_ok());
}
// === Entitlements resolution: the license key alone (#347, D12) ============================
fn base_license_with_features(enc: &EncodingKey, features: Vec<String>) -> String {
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features,
machine_limit: 3,
};
let jwt = make_token(enc, &claims);
format!("{}{}", KEY_PREFIX_BASE, jwt)
}
fn expired_base_license(enc: &EncodingKey) -> String {
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now() - 1000,
exp: now() - 100,
edition: EDITION_BASE.to_string(),
features: vec!["balance".to_string()],
machine_limit: 3,
};
format!("{}{}", KEY_PREFIX_BASE, make_token(enc, &claims))
}
fn activation_for(enc: &EncodingKey, machine_id: &str) -> String {
let claims = ActivationClaims {
sub: "license-id".to_string(),
iat: now(),
exp: now() + 86400,
machine_id: machine_id.to_string(),
};
make_token(enc, &claims)
}
/// A token as the server signs it (2 years), issued long enough ago to have expired.
fn expired_activation_for(enc: &EncodingKey, machine_id: &str) -> String {
let claims = ActivationClaims {
sub: "license-id".to_string(),
iat: now() - 3 * 365 * 86400,
exp: now() - 100,
machine_id: machine_id.to_string(),
};
make_token(enc, &claims)
}
#[test]
fn invalid_license_resolves_free_without_features() {
let (_enc, dec) = default_keys();
let (edition, features) = resolve_license_entitlements("SR-BASE-not.a.jwt", &dec);
assert_eq!(edition, EDITION_FREE);
assert!(features.is_empty());
}
#[test]
fn expired_license_resolves_free_without_features() {
let (enc, dec) = default_keys();
let (edition, features) = resolve_license_entitlements(&expired_base_license(&enc), &dec);
assert_eq!(edition, EDITION_FREE);
assert!(features.is_empty(), "signed features must not survive an expired key");
}
/// 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(PathBuf);
impl TestDir {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"simpl-resultat-license-test-{}-{}",
name,
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn write(&self, file: &str, contents: impl AsRef<[u8]>) {
fs::write(self.0.join(file), contents).unwrap();
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
/// Bytes that are not UTF-8: `read_to_string` fails on them.
const NOT_UTF8: [u8; 4] = [0xff, 0xfe, 0x00, 0x80];
#[test]
fn key_alone_keeps_edition_and_features() {
let (enc, dec) = default_keys();
let dir = TestDir::new("key_alone");
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
}
#[test]
fn expired_token_of_this_machine_keeps_edition_and_features() {
// #347: the server signs the token for 2 years, the key lasts longer.
let (enc, dec) = default_keys();
let dir = TestDir::new("expired_token");
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
dir.write(ACTIVATION_FILE, expired_activation_for(&enc, "this-machine"));
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
}
#[test]
fn token_of_another_machine_keeps_edition_and_features() {
// #360: data restored after a reinstall, or copied to a new computer.
let (enc, dec) = default_keys();
let dir = TestDir::new("other_machine_token");
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
dir.write(ACTIVATION_FILE, activation_for(&enc, "another-machine"));
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
}
#[test]
fn unreadable_token_keeps_edition_and_features() {
let (enc, dec) = default_keys();
let dir = TestDir::new("unreadable_token");
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
dir.write(ACTIVATION_FILE, NOT_UTF8);
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
}
#[test]
fn expired_key_resolves_free_even_with_a_valid_token() {
// A token never upgrades an edition: the key alone decides.
let (enc, dec) = default_keys();
let dir = TestDir::new("expired_key_valid_token");
dir.write(LICENSE_FILE, expired_base_license(&enc));
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_FREE);
assert!(features.is_empty());
}
#[test]
fn missing_key_file_resolves_free() {
let (enc, dec) = default_keys();
let dir = TestDir::new("missing_key");
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_FREE);
assert!(features.is_empty());
}
// === Key storage and reading: errors named at the source (#347) =============================
#[test]
fn store_refuses_a_bad_prefix_without_writing() {
let (_enc, dec) = default_keys();
let dir = TestDir::new("store_bad_prefix");
let result = store_license_in(&dir.0, "BASE-not-a-key", &dec);
assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix);
assert!(!dir.0.join(LICENSE_FILE).exists(), "a refused key must not be written");
}
#[test]
fn store_writes_the_trimmed_key() {
let (enc, dec) = default_keys();
let dir = TestDir::new("store_trimmed");
let key = base_license_with_features(&enc, vec![]);
let info = store_license_in(&dir.0, &format!(" {}\n", key), &dec).unwrap();
assert_eq!(info.edition, EDITION_BASE);
assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), key);
}
#[test]
fn store_reports_storage_when_the_directory_cannot_be_created() {
let (enc, dec) = default_keys();
let dir = TestDir::new("store_blocked");
dir.write("blocker", "a file where the data directory should go");
let target = dir.0.join("blocker").join("app-data");
let result = store_license_in(&target, &base_license_with_features(&enc, vec![]), &dec);
assert_eq!(result.unwrap_err(), LicenseError::Storage);
}
#[test]
fn read_reports_storage_for_an_unreadable_key_file() {
let (_enc, dec) = default_keys();
let dir = TestDir::new("read_unreadable");
dir.write(LICENSE_FILE, NOT_UTF8);
assert_eq!(read_license_in(&dir.0, &dec).unwrap_err(), LicenseError::Storage);
}
#[test]
fn read_returns_none_for_a_missing_or_invalid_key() {
let (_enc, dec) = default_keys();
let dir = TestDir::new("read_none");
assert!(read_license_in(&dir.0, &dec).unwrap().is_none(), "no file, no license");
dir.write(LICENSE_FILE, "SR-BASE-not.a.jwt");
assert!(read_license_in(&dir.0, &dec).unwrap().is_none(), "an invalid key reads as none");
}
#[test]
fn store_drops_the_token_of_a_previous_key() {
// Rebuy after a refund, reissue after a revocation, Base to Premium: the old token
// would name the previous license to the price server (#347 review).
let (enc, dec) = default_keys();
let dir = TestDir::new("store_new_key");
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()]));
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
let new_key = base_license_with_features(&enc, vec![]);
store_license_in(&dir.0, &new_key, &dec).unwrap();
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "the previous key's token must go");
assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), new_key);
}
#[test]
fn store_keeps_the_token_when_the_same_key_is_entered_again() {
let (enc, dec) = default_keys();
let dir = TestDir::new("store_same_key");
let key = base_license_with_features(&enc, vec![]);
dir.write(LICENSE_FILE, &key);
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
store_license_in(&dir.0, &format!(" {}\n", key), &dec).unwrap();
assert!(dir.0.join(ACTIVATION_FILE).exists(), "the same key keeps its activation");
}
#[test]
fn store_refuses_another_key_while_the_previous_token_cannot_be_dropped() {
// Kept next to the new key, that token would name the previous license to the price
// server (#347 review, the folder of #367): the key change stops on `Storage` instead,
// and the previous key stays (#387 review).
let (enc, dec) = default_keys();
let dir = TestDir::new("store_token_not_deletable");
let old_key = base_license_with_features(&enc, vec!["balance".to_string()]);
dir.write(LICENSE_FILE, &old_key);
// A directory in place of the token: removing it as a file fails.
fs::create_dir(dir.0.join(ACTIVATION_FILE)).unwrap();
let new_key = base_license_with_features(&enc, vec![]);
assert_eq!(store_license_in(&dir.0, &new_key, &dec).unwrap_err(), LicenseError::Storage);
assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), old_key);
}
#[test]
fn store_drops_a_token_left_without_a_key() {
let (enc, dec) = default_keys();
let dir = TestDir::new("store_orphan_token");
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
store_license_in(&dir.0, &base_license_with_features(&enc, vec![]), &dec).unwrap();
assert!(!dir.0.join(ACTIVATION_FILE).exists());
}
#[test]
fn a_refused_key_leaves_the_stored_key_and_token_alone() {
let (enc, dec) = default_keys();
let dir = TestDir::new("store_refused_keeps_all");
let key = base_license_with_features(&enc, vec![]);
dir.write(LICENSE_FILE, &key);
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
let result = store_license_in(&dir.0, "BASE-not-a-key", &dec);
assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix);
assert!(dir.0.join(ACTIVATION_FILE).exists());
assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), key);
}
// === Activation status: the decision behind "Not activated" / "Activate" (#347, #360) ======
#[test]
fn activation_status_accepts_a_token_of_this_machine() {
let (enc, dec) = default_keys();
let dir = TestDir::new("status_this_machine");
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED));
assert!(activation_status_in(&dir.0, &this_machine(), &dec));
}
#[test]
fn activation_status_accepts_a_token_from_before_the_derivation() {
// ADR 0020, D1: updating the app must not turn "Activated" into "Activate".
let (enc, dec) = default_keys();
let dir = TestDir::new("status_raw_token");
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_RAW));
assert!(activation_status_in(&dir.0, &this_machine(), &dec));
}
#[test]
fn activation_status_refuses_a_missing_token() {
let (_enc, dec) = default_keys();
let dir = TestDir::new("status_missing");
assert!(!activation_status_in(&dir.0, &this_machine(), &dec));
}
#[test]
fn activation_status_refuses_an_expired_token() {
let (enc, dec) = default_keys();
for id in [THIS_DERIVED, THIS_RAW] {
let dir = TestDir::new("status_expired");
dir.write(ACTIVATION_FILE, expired_activation_for(&enc, id));
assert!(!activation_status_in(&dir.0, &this_machine(), &dec), "claim {}", id);
}
}
#[test]
fn activation_status_refuses_a_token_of_another_machine() {
let (enc, dec) = default_keys();
for other in [OTHER_RAW, OTHER_DERIVED] {
let dir = TestDir::new("status_other_machine");
dir.write(ACTIVATION_FILE, activation_for(&enc, other));
assert!(!activation_status_in(&dir.0, &this_machine(), &dec), "claim {}", other);
}
}
#[test]
fn activation_status_refuses_a_token_signed_by_another_key() {
let (_enc, dec) = default_keys();
let (other_enc, _other_dec) = test_keys([7u8; 32]);
let dir = TestDir::new("status_other_key");
dir.write(ACTIVATION_FILE, activation_for(&other_enc, THIS_DERIVED));
assert!(!activation_status_in(&dir.0, &this_machine(), &dec));
}
#[test]
fn activation_status_refuses_an_unreadable_token() {
let (_enc, dec) = default_keys();
let dir = TestDir::new("status_unreadable");
dir.write(ACTIVATION_FILE, NOT_UTF8);
assert!(!activation_status_in(&dir.0, &this_machine(), &dec));
}
// === Machine identifier: derivation and transition (#350, ADR 0020) ========================
#[test]
fn derivation_matches_the_reference_vectors() {
// Frozen: the license server records every activation under these identifiers, and
// maximus-api reuses the same vectors. A red test here means the key or the formula
// moved, and every existing activation with it.
assert_eq!(derive_machine_id(THIS_RAW), Ok(THIS_DERIVED.to_string()));
assert_eq!(derive_machine_id(OTHER_RAW), Ok(OTHER_DERIVED.to_string()));
}
#[test]
fn derived_identifier_is_64_lowercase_hex_and_ignores_surrounding_whitespace() {
let derived = derive_machine_id(" 0123456789abcdef0123456789abcdef\n").unwrap();
assert_eq!(derived, THIS_DERIVED, "the OS id is trimmed before the derivation");
assert_eq!(derived.len(), 64);
assert!(derived.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
}
#[test]
fn an_empty_os_id_is_refused_not_hashed() {
// Hashed, it would give every machine without a machine id the same identifier.
for raw in ["", " ", "\n"] {
assert_eq!(derive_machine_id(raw), Err(LicenseError::Internal), "raw {:?}", raw);
assert_eq!(
MachineIds::from_raw(raw).err(),
Some(LicenseError::Internal),
"raw {:?}",
raw
);
}
}
#[test]
fn machine_ids_hold_both_forms_and_name_only_this_machine() {
let ids = MachineIds::from_raw(&format!(" {}\n", THIS_RAW)).unwrap();
assert_eq!(ids.derived, THIS_DERIVED);
assert_eq!(ids.raw, THIS_RAW);
assert!(ids.names_this_machine(THIS_DERIVED));
assert!(ids.names_this_machine(THIS_RAW));
assert!(!ids.names_this_machine(OTHER_RAW));
assert!(!ids.names_this_machine(OTHER_DERIVED));
}
#[test]
fn a_fresh_token_must_carry_the_derived_identifier() {
let (enc, dec) = default_keys();
let dir = TestDir::new("fresh_token_derived");
let token = activation_for(&enc, THIS_DERIVED);
store_activation_token_in(&dir.0, &format!(" {}\n", token), &this_machine(), &dec).unwrap();
assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token);
}
#[test]
fn a_fresh_token_for_any_other_identifier_is_refused_and_not_written() {
// The raw id is accepted from a token already on disk only: the server answers a
// request that carried the derived one.
let (enc, dec) = default_keys();
for id in [THIS_RAW, OTHER_RAW, OTHER_DERIVED] {
let dir = TestDir::new("fresh_token_refused");
let result =
store_activation_token_in(&dir.0, &activation_for(&enc, id), &this_machine(), &dec);
assert_eq!(result, Err(LicenseError::InvalidResponse), "claim {}", id);
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "claim {}", id);
}
let dir = TestDir::new("fresh_token_expired");
let expired = expired_activation_for(&enc, THIS_DERIVED);
assert_eq!(
store_activation_token_in(&dir.0, &expired, &this_machine(), &dec),
Err(LicenseError::InvalidResponse)
);
}
/// Body of an activation answer carrying `token`.
fn activation_answer(token: &str) -> String {
serde_json::json!({ "activation_token": token }).to_string()
}
#[tokio::test]
async fn activation_sends_the_derived_identifier_never_the_raw_one() {
// The decision point of #350. The only mock declared wants the derived identifier in
// `machine_id` and refuses any body where the OS id appears, in whatever field: a
// request carrying it gets mockito's 501 and this test fails.
let (enc, dec) = default_keys();
let dir = TestDir::new("activate_sends_derived");
let token = activation_for(&enc, THIS_DERIVED);
let mut server = mockito::Server::new_async().await;
let derived_only = server
.mock("POST", "/v1/licenses/activate")
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"product": "simpl-resultat",
"license_key": "SR-BASE-k",
"machine_id": THIS_DERIVED,
"machine_name": "laptop",
})))
.match_request(|request| {
!request.utf8_lossy_body().map(|body| body.contains(THIS_RAW)).unwrap_or(true)
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(activation_answer(&token))
.create_async()
.await;
activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", THIS_RAW, Some("laptop"), &dec, NOW)
.await
.expect("activation");
derived_only.assert_async().await;
assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token);
}
#[tokio::test]
async fn an_empty_os_id_stops_the_activation_before_any_request() {
let (_enc, dec) = default_keys();
for raw in ["", " \n"] {
let dir = TestDir::new("activate_empty_id");
let mut server = mockito::Server::new_async().await;
let any_request = server
.mock("POST", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
let result =
activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", raw, None, &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Internal), "raw {:?}", raw);
any_request.assert_async().await;
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "raw {:?}", raw);
}
}
#[tokio::test]
async fn activation_refuses_a_token_issued_for_another_identifier() {
// Here the OS id echoed back, as a server answering for a version up to 0.15.0 would.
let (enc, dec) = default_keys();
let dir = TestDir::new("activate_wrong_token");
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/activate")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(activation_answer(&activation_for(&enc, THIS_RAW)))
.create_async()
.await;
let result =
activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", THIS_RAW, None, &dec, NOW).await;
assert_eq!(result, Err(LicenseError::InvalidResponse));
assert!(!dir.0.join(ACTIVATION_FILE).exists());
}
/// A row of the machine list as the server sends it.
fn listed_machine(machine_id: &str) -> serde_json::Value {
serde_json::json!({
"machine_id": machine_id,
"machine_name": "laptop",
"activated_at": "2026-09-01T00:00:00.000Z",
"last_seen_at": "2026-09-20T00:00:00.000Z",
})
}
#[tokio::test]
async fn the_machine_list_flags_this_machine_under_either_identifier() {
// ADR 0020, D3: a row activated before the derivation carries the OS id, and the
// server may rehash its rows some day. The flag is never read from the answer: the
// foreign row below claims it and loses it.
let mut foreign = listed_machine(OTHER_RAW);
foreign["is_this_machine"] = serde_json::Value::Bool(true);
let answer = serde_json::json!({
"valid": true,
"machine_limit": 3,
"machines": [listed_machine(THIS_DERIVED), listed_machine(THIS_RAW), foreign,
listed_machine(OTHER_DERIVED)],
});
let mut server = mockito::Server::new_async().await;
let listing = server
.mock("POST", "/v1/licenses/verify")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(answer.to_string())
.expect(2)
.create_async()
.await;
let flags = |machines: Vec<MachineInfo>| -> Vec<(String, bool)> {
machines.into_iter().map(|m| (m.machine_id, m.is_this_machine)).collect()
};
let expected = |this: bool| {
vec![
(THIS_DERIVED.to_string(), this),
(THIS_RAW.to_string(), this),
(OTHER_RAW.to_string(), false),
(OTHER_DERIVED.to_string(), false),
]
};
let ids = this_machine();
let listed = list_machines_inner(&server.url(), "SR-BASE-k", Some(&ids)).await.unwrap();
assert_eq!(flags(listed.machines), expected(true));
// 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();
assert_eq!(flags(unflagged.machines), expected(false));
listing.assert_async().await;
}
#[test]
fn the_webview_gets_the_flag_and_no_identifier_of_its_own() {
// The wire shape `licenseService.ts` reads: a flagged row carries `is_this_machine`,
// and the activation status is the single field `is_activated`.
let row: MachineInfo = serde_json::from_value(listed_machine(THIS_RAW)).unwrap();
let flagged = flag_this_machine(vec![row], Some(&this_machine()));
assert_eq!(
serde_json::to_value(&flagged[0]).unwrap(),
serde_json::json!({
"machine_id": THIS_RAW,
"machine_name": "laptop",
"activated_at": "2026-09-01T00:00:00.000Z",
"last_seen_at": "2026-09-20T00:00:00.000Z",
"is_this_machine": true,
})
);
assert_eq!(
serde_json::to_value(ActivationStatus { is_activated: true }).unwrap(),
serde_json::json!({ "is_activated": true })
);
}
#[test]
fn deactivation_drops_the_token_that_stood_for_the_freed_slot() {
let (enc, dec) = default_keys();
let ids = this_machine();
// (identifier of the stored token, identifier deactivated, token kept, case)
let cases = [
(THIS_RAW, THIS_RAW, false, "raw token, raw row of this machine"),
(THIS_DERIVED, THIS_DERIVED, false, "derived token, derived row of this machine"),
(THIS_DERIVED, THIS_RAW, true, "derived token, old raw row of this machine"),
(THIS_RAW, THIS_DERIVED, false, "raw token, row rehashed by the server"),
(THIS_RAW, OTHER_RAW, true, "raw token, row of another machine"),
(THIS_DERIVED, OTHER_DERIVED, true, "derived token, row of another machine"),
(THIS_DERIVED, OTHER_RAW, true, "derived token, raw row of another machine"),
(OTHER_RAW, OTHER_RAW, false, "token of another machine, that machine's row"),
(OTHER_RAW, OTHER_DERIVED, true, "token of another machine, a different row"),
// Data restored from another computer (#360): the foreign token goes with any
// row of this machine, as it did before the derivation.
(OTHER_RAW, THIS_RAW, false, "token of another machine, raw row of this machine"),
(OTHER_DERIVED, THIS_DERIVED, false, "token of another machine, derived row of this machine"),
];
for (token_id, deactivated, kept, case) in cases {
let dir = TestDir::new("deactivation_token");
dir.write(ACTIVATION_FILE, activation_for(&enc, token_id));
drop_token_of_deactivated_machine_in(&dir.0, deactivated, Some(&ids), &dec);
assert_eq!(dir.0.join(ACTIVATION_FILE).exists(), kept, "{}", case);
}
}
#[test]
fn deactivating_this_machine_drops_a_token_that_no_longer_validates() {
// Expired, forged or unreadable, the file stands for no other activation: it goes
// with this machine's row, as it did before the derivation.
let (enc, dec) = default_keys();
let (other_enc, _other_dec) = test_keys([7u8; 32]);
let ids = this_machine();
let stale: Vec<(Vec<u8>, &str)> = vec![
(expired_activation_for(&enc, THIS_RAW).into_bytes(), "expired raw token"),
(expired_activation_for(&enc, THIS_DERIVED).into_bytes(), "expired derived token"),
(activation_for(&other_enc, THIS_DERIVED).into_bytes(), "token signed by another key"),
(NOT_UTF8.to_vec(), "unreadable file"),
];
for (contents, case) in stale {
for deactivated in [THIS_RAW, THIS_DERIVED] {
let dir = TestDir::new("deactivation_stale_token");
dir.write(ACTIVATION_FILE, &contents);
drop_token_of_deactivated_machine_in(&dir.0, deactivated, Some(&ids), &dec);
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{}, row {}", case, deactivated);
}
// Another machine's row never touches it.
let dir = TestDir::new("deactivation_stale_token");
dir.write(ACTIVATION_FILE, &contents);
drop_token_of_deactivated_machine_in(&dir.0, OTHER_RAW, Some(&ids), &dec);
assert!(dir.0.join(ACTIVATION_FILE).exists(), "{}, row of another machine", case);
}
}
#[test]
fn without_a_readable_os_id_only_the_token_s_own_identifier_decides() {
let (enc, dec) = default_keys();
let cases = [
(THIS_RAW, THIS_RAW, false),
(THIS_DERIVED, THIS_DERIVED, false),
(THIS_RAW, THIS_DERIVED, true),
(THIS_DERIVED, OTHER_RAW, true),
];
for (token_id, deactivated, kept) in cases {
let dir = TestDir::new("deactivation_no_os_id");
dir.write(ACTIVATION_FILE, activation_for(&enc, token_id));
drop_token_of_deactivated_machine_in(&dir.0, deactivated, None, &dec);
assert_eq!(
dir.0.join(ACTIVATION_FILE).exists(),
kept,
"token {}, row {}",
token_id,
deactivated
);
}
}
#[test]
fn deactivation_without_a_stored_token_writes_nothing() {
let (_enc, dec) = default_keys();
let dir = TestDir::new("deactivation_no_token");
drop_token_of_deactivated_machine_in(&dir.0, THIS_DERIVED, Some(&this_machine()), &dec);
assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 0);
}
// === Online revocation check (#365) ========================================================
/// A literal key and its SHA-256, computed outside this code: they pin the state format.
const LITERAL_KEY: &str = "SR-BASE-header.payload.signature";
const LITERAL_KEY_SHA256: &str =
"f4753f0b6f2031a066ec9d8da6aaa3eeeda05258614372d7b4c772a9bdb489c9";
/// Content of a state file about `key`.
fn check_state(key: &str, attempted_at: i64, revoked: bool) -> String {
serde_json::json!({
"key_sha256": key_digest(key),
"attempted_at": attempted_at,
"revoked": revoked,
})
.to_string()
}
/// The state file as stored, if it is readable JSON.
fn stored_state(dir: &TestDir) -> Option<serde_json::Value> {
let raw = fs::read_to_string(dir.0.join(CHECK_FILE)).ok()?;
serde_json::from_str(&raw).ok()
}
/// A verify route answering `status` with `body`, expecting `hits` requests.
async fn verify_route(
server: &mut mockito::ServerGuard,
status: usize,
body: &str,
hits: usize,
) -> mockito::Mock {
server
.mock("POST", "/v1/licenses/verify")
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.expect(hits)
.create_async()
.await
}
const REVOKED_ENVELOPE: &str =
r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#;
#[test]
fn the_state_names_the_key_by_its_sha256_never_by_itself() {
assert_eq!(key_digest(LITERAL_KEY), LITERAL_KEY_SHA256);
assert_eq!(key_digest(&format!(" {}\n", LITERAL_KEY)), LITERAL_KEY_SHA256);
let dir = TestDir::new("state_format");
write_license_check(
&dir.0,
&LicenseCheck {
key_sha256: key_digest(LITERAL_KEY),
attempted_at: NOW,
revoked: true,
},
)
.unwrap();
assert_eq!(
stored_state(&dir),
Some(serde_json::json!({
"key_sha256": LITERAL_KEY_SHA256,
"attempted_at": NOW,
"revoked": true,
}))
);
let raw = fs::read_to_string(dir.0.join(CHECK_FILE)).unwrap();
assert!(!raw.contains("header.payload"), "the key must not be in the state file");
}
#[test]
fn a_revoked_key_resolves_free_without_features() {
let (enc, dec) = default_keys();
let dir = TestDir::new("revoked_resolves_free");
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
dir.write(LICENSE_FILE, &key);
// Not revoked: the key's edition, its features, and automatic updates.
dir.write(CHECK_FILE, check_state(&key, NOW, false));
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
assert!(crate::commands::entitlements::is_entitled("auto-update", &edition, &features));
// Revoked: Free, signed features dropped, automatic updates refused.
dir.write(CHECK_FILE, check_state(&key, NOW, true));
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_FREE);
assert!(features.is_empty(), "signed features must not survive a revocation");
assert!(!crate::commands::entitlements::is_entitled("auto-update", &edition, &features));
assert!(!crate::commands::entitlements::is_entitled("balance", &edition, &features));
}
#[test]
fn a_state_that_is_not_about_this_key_changes_nothing() {
// Another key bought after a refund, or a damaged file: a paying user stays paying.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let other_key = base_license_with_features(&enc, vec![]);
let states: Vec<(&str, Vec<u8>)> = vec![
("revoked state of another key", check_state(&other_key, NOW, true).into_bytes()),
("unreadable file", NOT_UTF8.to_vec()),
("invalid JSON", b"{not json".to_vec()),
("JSON of another shape", br#"{"revoked":true}"#.to_vec()),
];
for (case, contents) in states {
let dir = TestDir::new("state_not_about_this_key");
dir.write(LICENSE_FILE, &key);
dir.write(CHECK_FILE, &contents);
let (edition, features) = entitlements_from_dir(&dir.0, &dec);
assert_eq!(edition, EDITION_BASE, "{}", case);
assert_eq!(features, vec!["balance".to_string()], "{}", case);
assert!(!read_license_in(&dir.0, &dec).unwrap().unwrap().revoked, "{}", case);
}
}
#[test]
fn reading_and_storing_a_key_report_its_revoked_state() {
let (enc, dec) = default_keys();
let dir = TestDir::new("info_revoked");
let key = base_license_with_features(&enc, vec![]);
dir.write(LICENSE_FILE, &key);
assert!(!read_license_in(&dir.0, &dec).unwrap().unwrap().revoked, "no state, not revoked");
dir.write(CHECK_FILE, check_state(&key, NOW, true));
let info = read_license_in(&dir.0, &dec).unwrap().unwrap();
assert!(info.revoked);
assert_eq!(info.edition, EDITION_BASE, "the info still describes the key itself");
assert_eq!(serde_json::to_value(&info).unwrap()["revoked"], true);
// The same key entered again does not come back clean.
assert!(store_license_in(&dir.0, &key, &dec).unwrap().revoked);
assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_FREE);
// Another key does: the state is not about it.
let other_key = base_license_with_features(&enc, vec!["balance".to_string()]);
assert!(!store_license_in(&dir.0, &other_key, &dec).unwrap().revoked);
assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE);
}
#[test]
fn the_check_result_serializes_as_the_front_reads_it() {
let rows = [
(RevocationOutcome::NoLicense, "no_license"),
(RevocationOutcome::NotDue, "not_due"),
(RevocationOutcome::Valid, "valid"),
(RevocationOutcome::Revoked, "revoked"),
(RevocationOutcome::Unreachable, "unreachable"),
(RevocationOutcome::Unknown, "unknown"),
(RevocationOutcome::Storage, "storage"),
];
for (outcome, wire) in rows {
assert_eq!(
serde_json::to_value(RevocationCheck { outcome, revoked: true }).unwrap(),
serde_json::json!({ "outcome": wire, "revoked": true })
);
}
}
#[tokio::test]
async fn the_check_moves_the_state_on_a_verdict_and_on_nothing_else() {
// D3, fail-open: only "valid" lifts and only "revoked" sets. Every other answer and
// every error leaves a clean key clean and a revoked key revoked.
use RevocationOutcome::{Revoked, Unknown, Unreachable, Valid};
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
// (status, body, outcome, revoked after a clean start, revoked after a revoked start)
let rows: Vec<(usize, &str, RevocationOutcome, bool, bool)> = vec![
(200, r#"{"valid":false,"reason":"revoked"}"#, Revoked, true, true),
(403, REVOKED_ENVELOPE, Revoked, true, true),
(200, r#"{"valid":true,"machines":[]}"#, Valid, false, false),
(200, r#"{"valid":true}"#, Valid, false, false),
(200, r#"{"valid":false,"reason":"not_found"}"#, Unknown, false, true),
(200, r#"{"valid":false,"reason":"expired"}"#, Unknown, false, true),
(200, r#"{"valid":false,"reason":"suspended"}"#, Unknown, false, true),
(200, r#"{"machines":[]}"#, Unknown, false, true),
(404, r#"{"error":{"code":"license_not_found","message":"x"}}"#, Unknown, false, true),
(400, r#"{"error":{"code":"invalid_input","message":"x"}}"#, Unreachable, false, true),
(
429,
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
Unreachable,
false,
true,
),
(503, "<html>Service Unavailable</html>", Unreachable, false, true),
(200, "<html>OK</html>", Unreachable, false, true),
];
for (status, body, outcome, after_clean, after_revoked) in rows {
for (was_revoked, expected) in [(false, after_clean), (true, after_revoked)] {
let case = format!("{} {} from revoked={}", status, body, was_revoked);
let dir = TestDir::new("check_outcomes");
dir.write(LICENSE_FILE, &key);
dir.write(CHECK_FILE, check_state(&key, NOW - CHECK_INTERVAL_SECS, was_revoked));
let mut server = mockito::Server::new_async().await;
let route = verify_route(&mut server, status, body, 1).await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(result, RevocationCheck { outcome, revoked: expected }, "{}", case);
route.assert_async().await;
assert_eq!(
stored_state(&dir),
Some(serde_json::json!({
"key_sha256": key_digest(&key),
"attempted_at": NOW,
"revoked": expected,
})),
"{}",
case
);
let edition = entitlements_from_dir(&dir.0, &dec).0;
assert_eq!(edition, if expected { EDITION_FREE } else { EDITION_BASE }, "{}", case);
}
}
}
#[tokio::test]
async fn an_unreachable_server_changes_nothing() {
// Port 1 on loopback: nothing listens. Offline, a legitimate user is never
// downgraded, and a key already known as revoked stays revoked.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
for was_revoked in [false, true] {
let dir = TestDir::new("check_offline");
dir.write(LICENSE_FILE, &key);
dir.write(CHECK_FILE, check_state(&key, NOW - CHECK_INTERVAL_SECS, was_revoked));
let result = check_revocation_in(&dir.0, "http://127.0.0.1:1", &dec, NOW).await;
assert_eq!(
result,
RevocationCheck { outcome: RevocationOutcome::Unreachable, revoked: was_revoked }
);
assert_eq!(is_revoked_in(&dir.0, &key), was_revoked);
}
}
#[tokio::test]
async fn nothing_is_sent_without_a_valid_stored_key_nor_before_24_hours() {
use RevocationOutcome::{NoLicense, NotDue};
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
// (case, key file, state file, outcome, revoked)
let cases: Vec<(&str, Option<String>, Option<String>, RevocationOutcome, bool)> = vec![
("no key stored", None, None, NoLicense, false),
("a stored key that does not validate", Some("SR-BASE-not.a.jwt".into()), None, NoLicense, false),
("an expired stored key", Some(expired_base_license(&enc)), None, NoLicense, false),
("an attempt in this very second", Some(key.clone()), Some(check_state(&key, NOW, false)), NotDue, false),
// 24 h written out, here and in the next test: rows built from the constant
// would follow it wherever it goes.
(
"an attempt 24 h minus one second ago, key revoked",
Some(key.clone()),
Some(check_state(&key, NOW - 86_399, true)),
NotDue,
true,
),
];
for (case, key_file, state_file, outcome, revoked) in cases {
let dir = TestDir::new("check_not_sent");
if let Some(contents) = &key_file {
dir.write(LICENSE_FILE, contents);
}
if let Some(contents) = &state_file {
dir.write(CHECK_FILE, contents);
}
let mut server = mockito::Server::new_async().await;
let any_request = server
.mock("POST", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(result, RevocationCheck { outcome, revoked }, "{}", case);
any_request.assert_async().await;
assert_eq!(
fs::read_to_string(dir.0.join(CHECK_FILE)).ok(),
state_file,
"{}: the state file must be left as it was",
case
);
}
}
#[tokio::test]
async fn an_attempt_is_due_without_state_after_24_hours_or_with_an_unusable_date() {
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let other_key = base_license_with_features(&enc, vec!["balance".to_string()]);
let states: Vec<(&str, Option<String>)> = vec![
("no state", None),
("an attempt exactly 24 h ago", Some(check_state(&key, NOW - 86_400, false))),
("an attempt ten days ago", Some(check_state(&key, NOW - 10 * CHECK_INTERVAL_SECS, false))),
("a fresh state about another key", Some(check_state(&other_key, NOW, true))),
("an attempt dated in the future", Some(check_state(&key, NOW + 1, false))),
("an attempt dated at i64::MAX", Some(check_state(&key, i64::MAX, false))),
("an attempt dated at i64::MIN", Some(check_state(&key, i64::MIN, false))),
("invalid JSON", Some("{not json".to_string())),
];
for (case, state_file) in states {
let dir = TestDir::new("check_due");
dir.write(LICENSE_FILE, &key);
if let Some(contents) = &state_file {
dir.write(CHECK_FILE, contents);
}
let mut server = mockito::Server::new_async().await;
let route = verify_route(&mut server, 200, r#"{"valid":true,"machines":[]}"#, 1).await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(
result,
RevocationCheck { outcome: RevocationOutcome::Valid, revoked: false },
"{}",
case
);
route.assert_async().await;
assert_eq!(
stored_state(&dir),
Some(serde_json::json!({
"key_sha256": key_digest(&key),
"attempted_at": NOW,
"revoked": false,
})),
"{}",
case
);
}
}
#[tokio::test]
async fn a_failed_attempt_still_counts_for_24_hours() {
// AC4: one attempt per 24 h whatever its result. The attempt is written before the
// request leaves, so the second call below sends nothing; a day later one is due.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_failed_attempt");
dir.write(LICENSE_FILE, &key);
let mut server = mockito::Server::new_async().await;
let failing = verify_route(&mut server, 503, "<html>Service Unavailable</html>", 1).await;
let first = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(first.outcome, RevocationOutcome::Unreachable);
assert_eq!(stored_state(&dir).unwrap()["attempted_at"], NOW);
let second = check_revocation_in(&dir.0, &server.url(), &dec, NOW + 60).await;
assert_eq!(second.outcome, RevocationOutcome::NotDue);
failing.assert_async().await;
let mut next_day = mockito::Server::new_async().await;
let route = verify_route(&mut next_day, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await;
let third =
check_revocation_in(&dir.0, &next_day.url(), &dec, NOW + CHECK_INTERVAL_SECS).await;
assert_eq!(third, RevocationCheck { outcome: RevocationOutcome::Revoked, revoked: true });
route.assert_async().await;
}
#[tokio::test]
async fn an_attempt_that_cannot_be_written_sends_nothing() {
// Bound (2) holds in a data directory where the state cannot be written: the attempt
// is not recorded, so nothing is sent, or every start would send a request (review
// of #368).
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_unwritable");
dir.write(LICENSE_FILE, &key);
// A directory in place of the state file: it reads as "no state" and cannot be written.
fs::create_dir(dir.0.join(CHECK_FILE)).unwrap();
let mut server = mockito::Server::new_async().await;
let any_request = server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Storage, revoked: false });
any_request.assert_async().await;
}
#[tokio::test]
async fn a_verdict_that_cannot_be_written_is_not_reported_as_applied() {
// The other `storage` outcome: the request left and the server answered "revoked",
// but the verdict could not be written. Nothing on disk says the key is revoked, so
// the check does not say it either.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_verdict_unwritable");
dir.write(LICENSE_FILE, &key);
let state_path = dir.0.join(CHECK_FILE);
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/verify")
.match_request(move |_request| {
// The attempt is on disk by now: a directory in its place makes the verdict
// that follows unwritable.
let _ = fs::remove_file(&state_path);
let _ = fs::create_dir(&state_path);
true
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"valid":false,"reason":"revoked"}"#)
.expect(1)
.create_async()
.await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Storage, revoked: false });
route.assert_async().await;
assert!(!is_revoked_in(&dir.0, &key));
assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE);
}
#[tokio::test]
async fn the_attempt_is_on_disk_before_the_request_reaches_the_server() {
// D1: written before the request leaves, so a call started meanwhile sends nothing
// and an app closed mid-request has still used its attempt. Read from the server's
// side: a write made after the answer would pass every other test.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_attempt_first");
dir.write(LICENSE_FILE, &key);
dir.write(CHECK_FILE, check_state(&key, NOW - 86_400, true));
let state_path = dir.0.join(CHECK_FILE);
let seen = std::sync::Arc::new(std::sync::Mutex::new(None::<String>));
let seen_by_server = seen.clone();
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/verify")
.match_request(move |_request| {
*seen_by_server.lock().unwrap() = fs::read_to_string(&state_path).ok();
true
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"valid":true,"machines":[]}"#)
.expect(1)
.create_async()
.await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Valid, revoked: false });
route.assert_async().await;
// While the request was in flight: this call's attempt, and the verdict not moved yet.
let during = seen.lock().unwrap().clone().expect("a state file during the request");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&during).unwrap(),
serde_json::json!({
"key_sha256": key_digest(&key),
"attempted_at": NOW,
"revoked": true,
})
);
assert!(!is_revoked_in(&dir.0, &key), "the valid answer lifts it afterwards");
}
#[tokio::test]
async fn the_check_sends_the_product_and_the_key_and_nothing_else() {
// The one request the app makes on its own: no machine identifier, no host name, no
// usage data. A body with any other field matches no mock and gets mockito's 501.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_request_body");
dir.write(LICENSE_FILE, format!(" {}\n", key));
let sent_key = key.clone();
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/verify")
.match_request(move |request| {
let Ok(raw) = request.body() else {
return false;
};
let Ok(serde_json::Value::Object(fields)) =
serde_json::from_slice::<serde_json::Value>(raw)
else {
return false;
};
let mut names: Vec<&str> = fields.keys().map(String::as_str).collect();
names.sort_unstable();
names == ["license_key", "product"]
&& fields["product"] == "simpl-resultat"
&& fields["license_key"] == sent_key.as_str()
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"valid":true,"machines":[]}"#)
.expect(1)
.create_async()
.await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(result.outcome, RevocationOutcome::Valid);
route.assert_async().await;
let raw_state = fs::read_to_string(dir.0.join(CHECK_FILE)).unwrap();
assert!(!raw_state.contains(&key), "the key must not be in the state file");
}
// === One check at a time (#373) ===========================================================
//
// Each test has its own turn, standing for `REVOCATION_CHECK_TURN`: tests run on parallel
// threads, and a turn shared between them would decide which check goes first. `join!` is
// `biased`: its first branch is polled first, every time, so it is the check that starts
// first.
/// A turn of its own for one test.
fn new_turn() -> tauri::async_runtime::Mutex<()> {
tauri::async_runtime::Mutex::new(())
}
#[tokio::test]
async fn two_checks_side_by_side_send_one_request() {
// A key entered before the first ready state starts the check twice, from the entry
// and from the start. The second waits for the first one's verdict and sends nothing.
// Without the turn it would run during the first one's request and read its attempt
// only: `revoked: false`. That is what this test sees of a missing turn on one thread;
// the double send itself needs two threads between the read and the write of the
// state, a window without an await point that no test here can hold open.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_side_by_side");
dir.write(LICENSE_FILE, &key);
let mut server = mockito::Server::new_async().await;
let route = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await;
let url = server.url();
let turn = new_turn();
let (first, second) = tokio::join!(
biased;
check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW),
check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW),
);
assert_eq!(first, RevocationCheck { outcome: RevocationOutcome::Revoked, revoked: true });
assert_eq!(second, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: true });
route.assert_async().await;
}
#[tokio::test]
async fn the_check_of_a_key_entered_meanwhile_waits_for_the_verdict_in_flight() {
// The check of the stored key is in flight when another key is entered, and that
// key's check starts at once. `license.check` holds one key: side by side, the first
// verdict would land on the second key's attempt, and the second key would be checked
// again at the next start, within 24 h.
let (enc, dec) = default_keys();
let refunded_key = base_license_with_features(&enc, vec![]);
let new_key = base_license_with_features(&enc, vec!["balance".to_string()]);
let dir = TestDir::new("check_key_entered_meanwhile");
dir.write(LICENSE_FILE, &refunded_key);
let revoked_key = refunded_key.clone();
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/verify")
.with_status(200)
.with_header("content-type", "application/json")
.with_body_from_request(move |request| {
let sent = request
.body()
.ok()
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(raw).ok())
.and_then(|body| body["license_key"].as_str().map(str::to_string));
if sent.as_deref() == Some(revoked_key.as_str()) {
br#"{"valid":false,"reason":"revoked"}"#.to_vec()
} else {
br#"{"valid":true,"machines":[]}"#.to_vec()
}
})
.expect(2)
.create_async()
.await;
let url = server.url();
let turn = new_turn();
let (first, second) = tokio::join!(
biased;
check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW),
async {
store_license_in(&dir.0, &new_key, &dec).unwrap();
check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW).await
},
);
assert_eq!(first, RevocationCheck { outcome: RevocationOutcome::Revoked, revoked: true });
assert_eq!(second, RevocationCheck { outcome: RevocationOutcome::Valid, revoked: false });
assert_eq!(
stored_state(&dir),
Some(serde_json::json!({
"key_sha256": key_digest(&new_key),
"attempted_at": NOW,
"revoked": false,
}))
);
// The next start, the same day: the new key's attempt counts, nothing leaves.
let next_start = check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW + 60).await;
assert_eq!(next_start, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: false });
route.assert_async().await;
}
#[tokio::test]
async fn a_check_reads_the_time_once_its_turn_has_come() {
// A check that waited for the one running finds that one's attempt on disk, written a
// second after this check was called. Read before the wait, the time would date that
// attempt in the future, a check would be due, and a second request would leave.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_time_after_turn");
dir.write(LICENSE_FILE, &key);
let clock = std::sync::atomic::AtomicI64::new(NOW);
let mut server = mockito::Server::new_async().await;
let any_request = server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await;
let url = server.url();
let turn = new_turn();
let running = turn.lock().await;
let (dir_ref, clock_ref, key_ref) = (&dir, &clock, &key);
let (result, ()) = tokio::join!(
biased;
check_revocation_in_turn(&turn, &dir.0, &url, &dec, || {
clock.load(std::sync::atomic::Ordering::SeqCst)
}),
async move {
// What the running check leaves before it ends: its attempt, a second later.
dir_ref.write(CHECK_FILE, check_state(key_ref, NOW + 1, false));
clock_ref.store(NOW + 1, std::sync::atomic::Ordering::SeqCst);
drop(running);
},
);
assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: false });
any_request.assert_async().await;
}
#[tokio::test]
async fn a_revoked_answer_to_any_license_command_is_recorded_for_the_key_it_sent() {
// D4: activation, list and deactivation all learn it the same way the check does.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let ids = this_machine();
let recorded = |dir: &TestDir| {
assert!(is_revoked_in(&dir.0, &key));
assert_eq!(stored_state(dir).unwrap()["attempted_at"], NOW);
assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_FREE);
};
let dir = TestDir::new("revoked_by_activation");
dir.write(LICENSE_FILE, &key);
let mut server = mockito::Server::new_async().await;
let _activate = server
.mock("POST", "/v1/licenses/activate")
.with_status(403)
.with_header("content-type", "application/json")
.with_body(REVOKED_ENVELOPE)
.create_async()
.await;
let result =
activate_and_store_in(&dir.0, &server.url(), &key, THIS_RAW, None, &dec, NOW).await;
assert_eq!(result, Err(LicenseError::LicenseRevoked));
recorded(&dir);
let dir = TestDir::new("revoked_by_list");
dir.write(LICENSE_FILE, &key);
let mut server = mockito::Server::new_async().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), &dec, NOW).await;
assert_eq!(result.map(|list| list.machines.len()), Err(LicenseError::LicenseRevoked));
recorded(&dir);
let dir = TestDir::new("revoked_by_deactivation");
dir.write(LICENSE_FILE, &key);
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED));
let mut server = mockito::Server::new_async().await;
let _deactivate = server
.mock("POST", "/v1/licenses/deactivate")
.with_status(403)
.with_header("content-type", "application/json")
.with_body(REVOKED_ENVELOPE)
.create_async()
.await;
let result =
deactivate_in(&dir.0, &server.url(), &key, THIS_DERIVED, Some(&ids), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::LicenseRevoked));
recorded(&dir);
assert!(dir.0.join(ACTIVATION_FILE).exists(), "a refused deactivation frees no slot");
}
#[tokio::test]
async fn the_revocation_is_recorded_for_the_key_that_was_sent_not_the_one_on_disk() {
// A key replaced while the request was in flight: the answer is about the old one.
let (enc, dec) = default_keys();
let sent_key = base_license_with_features(&enc, vec![]);
let stored_key = base_license_with_features(&enc, vec!["balance".to_string()]);
let dir = TestDir::new("revoked_for_the_sent_key");
dir.write(LICENSE_FILE, &stored_key);
let mut server = mockito::Server::new_async().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, &dec, NOW).await;
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, &stored_key));
assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE);
}
#[tokio::test]
async fn only_a_revoked_answer_is_recorded_by_the_license_commands() {
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let refusals: Vec<(usize, &str, LicenseError)> = vec![
(
404,
r#"{"error":{"code":"license_not_found","message":"x"}}"#,
LicenseError::LicenseNotFound,
),
(
403,
r#"{"error":{"code":"license_expired","message":"x"}}"#,
LicenseError::LicenseExpired,
),
(
409,
r#"{"error":{"code":"machine_limit_reached","message":"x"},"machines":[]}"#,
LicenseError::MachineLimitReached,
),
(502, "<html>Bad Gateway</html>", LicenseError::Server { status: 502 }),
];
for (status, body, expected) in refusals {
let dir = TestDir::new("not_recorded");
dir.write(LICENSE_FILE, &key);
let mut server = mockito::Server::new_async().await;
let _activate = server
.mock("POST", "/v1/licenses/activate")
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
let result =
activate_and_store_in(&dir.0, &server.url(), &key, THIS_RAW, None, &dec, NOW).await;
assert_eq!(result, Err(expected), "status {}", status);
assert!(!dir.0.join(CHECK_FILE).exists(), "status {}: nothing to record", status);
}
// A list that loads and a deactivation that succeeds record nothing either: only the
// check writes a "valid" verdict.
let dir = TestDir::new("not_recorded_on_success");
dir.write(LICENSE_FILE, &key);
let mut server = mockito::Server::new_async().await;
let _verify = verify_route(&mut server, 200, r#"{"valid":true,"machines":[]}"#, 1).await;
let _deactivate = server
.mock("POST", "/v1/licenses/deactivate")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.create_async()
.await;
assert_eq!(
list_machines_in(&dir.0, &server.url(), &key, None, &dec, NOW)
.await
.map(|list| list.machines.len()),
Ok(0)
);
assert_eq!(
deactivate_in(&dir.0, &server.url(), &key, OTHER_RAW, None, &dec, NOW).await,
Ok(())
);
assert!(!dir.0.join(CHECK_FILE).exists());
}
// === Removal of the stored key (#370) ======================================================
/// A data directory as a removal finds it: a stored key, this machine's activation token,
/// a state file about the key, and a file that is none of them.
fn removal_dir(name: &str, enc: &EncodingKey, key: &str) -> TestDir {
let dir = TestDir::new(name);
dir.write(LICENSE_FILE, key);
dir.write(ACTIVATION_FILE, activation_for(enc, THIS_DERIVED));
dir.write(CHECK_FILE, check_state(key, NOW - 60, false));
dir.write("profiles.json", "{}");
dir
}
/// Every entry of the directory with its contents (empty for a sub-directory), by name.
fn snapshot(dir: &TestDir) -> Vec<(String, Vec<u8>)> {
let mut entries: Vec<(String, Vec<u8>)> = fs::read_dir(&dir.0)
.unwrap()
.map(|entry| {
let entry = entry.unwrap();
let contents = fs::read(entry.path()).unwrap_or_default();
(entry.file_name().into_string().unwrap(), contents)
})
.collect();
entries.sort();
entries
}
/// A server that answers nothing: any request it receives fails the test.
async fn no_request_expected(server: &mut mockito::ServerGuard) -> mockito::Mock {
server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await
}
/// A deactivation route answering `status` with `body`, expecting one request.
async fn deactivate_route(server: &mut mockito::ServerGuard, status: usize, body: &str) -> mockito::Mock {
server
.mock("POST", "/v1/licenses/deactivate")
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await
}
#[test]
fn the_token_removal_tolerates_a_missing_token_and_names_any_other_failure() {
let dir = TestDir::new("remove_token_helper");
assert_eq!(remove_activation_token_in(&dir.0), Ok(()), "no token to remove");
assert_eq!(remove_activation_token_in(&dir.0.join("missing")), Ok(()), "no directory");
dir.write(ACTIVATION_FILE, "a token");
assert_eq!(remove_activation_token_in(&dir.0), Ok(()));
assert!(!dir.0.join(ACTIVATION_FILE).exists());
// A directory in place of the token: it cannot be deleted as a file.
fs::create_dir(dir.0.join(ACTIVATION_FILE)).unwrap();
assert_eq!(remove_activation_token_in(&dir.0), Err(LicenseError::Storage));
}
#[tokio::test]
async fn a_removal_drops_the_key_and_its_token_and_leaves_everything_else() {
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let dir = removal_dir("remove_files", &enc, &key);
let state = fs::read(dir.0.join(CHECK_FILE)).unwrap();
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()));
any_request.assert_async().await;
assert_eq!(
snapshot(&dir),
vec![(CHECK_FILE.to_string(), state), ("profiles.json".to_string(), b"{}".to_vec())]
);
}
#[tokio::test]
async fn after_a_removal_the_edition_is_free_and_the_check_sends_nothing() {
// The last attempt is a day old: a key left on disk would be sent right away.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let dir = removal_dir("remove_then_check", &enc, &key);
dir.write(CHECK_FILE, check_state(&key, NOW - 86_400, false));
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW)
.await
.unwrap();
assert_eq!(entitlements_from_dir(&dir.0, &dec), (EDITION_FREE.to_string(), Vec::new()));
assert_eq!(
check_revocation_in(&dir.0, &server.url(), &dec, NOW).await,
RevocationCheck { outcome: RevocationOutcome::NoLicense, revoked: false }
);
any_request.assert_async().await;
}
#[tokio::test]
async fn a_revoked_key_removed_then_entered_again_stays_free() {
// Decision 1: `license.check` stays. Dropped, it would give the key its edition back
// until the next check, a way around the revocation open to anyone through a button.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let dir = removal_dir("remove_revoked_then_enter", &enc, &key);
dir.write(CHECK_FILE, check_state(&key, NOW, true));
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW)
.await
.unwrap();
assert!(store_license_in(&dir.0, &key, &dec).unwrap().revoked);
assert_eq!(entitlements_from_dir(&dir.0, &dec), (EDITION_FREE.to_string(), Vec::new()));
any_request.assert_async().await;
}
#[tokio::test]
async fn a_removal_without_a_key_succeeds_and_writes_nothing() {
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
for release_slot in [false, true] {
// The state of a key removed earlier, and nothing else.
let dir = TestDir::new("remove_no_key");
dir.write(CHECK_FILE, check_state(&key, NOW, true));
let before = snapshot(&dir);
let result =
remove_license_in(&dir.0, &server.url(), release_slot, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()), "release_slot {}", release_slot);
assert_eq!(snapshot(&dir), before, "release_slot {}", release_slot);
// A data directory that does not exist is not created.
let missing = dir.0.join("app-data");
let result =
remove_license_in(&missing, &server.url(), release_slot, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()), "release_slot {}", release_slot);
assert!(!missing.exists(), "release_slot {}", release_slot);
}
any_request.assert_async().await;
}
#[tokio::test]
async fn a_removal_without_a_key_still_drops_a_token_left_behind() {
// What a removal whose token could not be deleted leaves: the next one completes it.
let (enc, dec) = default_keys();
let dir = TestDir::new("remove_orphan_token");
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED));
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()));
assert!(!dir.0.join(ACTIVATION_FILE).exists());
any_request.assert_async().await;
}
#[tokio::test]
async fn a_key_that_cannot_be_deleted_is_storage_and_its_token_stays() {
// Never the token gone while the key stays: the card would read "Not activated" next
// to a key the user still has.
let (enc, dec) = default_keys();
let dir = TestDir::new("remove_key_blocked");
// A directory in place of the key file: it cannot be deleted as a file.
fs::create_dir(dir.0.join(LICENSE_FILE)).unwrap();
let token = activation_for(&enc, THIS_DERIVED);
dir.write(ACTIVATION_FILE, &token);
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Storage));
assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token);
any_request.assert_async().await;
}
#[tokio::test]
async fn a_token_that_cannot_be_deleted_is_storage_once_the_key_is_gone() {
// The key goes first: it is what the revocation check sends.
let (enc, dec) = default_keys();
let dir = TestDir::new("remove_token_blocked");
dir.write(LICENSE_FILE, base_license_with_features(&enc, vec![]));
fs::create_dir(dir.0.join(ACTIVATION_FILE)).unwrap();
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Storage));
assert!(!dir.0.join(LICENSE_FILE).exists());
any_request.assert_async().await;
}
#[tokio::test]
async fn the_release_carries_the_token_s_identifier_before_anything_is_removed() {
// D2: this machine's slot is freed first, under the identifier its token was issued
// for, the raw one for an activation made before #350. Read from the server's side,
// while the request is in flight: the key and the token are still on disk.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
for token_id in [THIS_DERIVED, THIS_RAW] {
let dir = removal_dir("remove_release", &enc, &key);
dir.write(ACTIVATION_FILE, activation_for(&enc, token_id));
let (key_path, token_path) = (dir.0.join(LICENSE_FILE), dir.0.join(ACTIVATION_FILE));
let seen = std::sync::Arc::new(std::sync::Mutex::new(None::<(bool, bool)>));
let seen_by_server = seen.clone();
let sent_key = key.clone();
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/deactivate")
.match_request(move |request| {
*seen_by_server.lock().unwrap() = Some((key_path.exists(), token_path.exists()));
let Ok(raw) = request.body() else {
return false;
};
serde_json::from_slice::<serde_json::Value>(raw).ok()
== Some(serde_json::json!({
"product": "simpl-resultat",
"license_key": sent_key,
"machine_id": token_id,
}))
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.expect(1)
.create_async()
.await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()), "token {}", token_id);
route.assert_async().await;
assert_eq!(*seen.lock().unwrap(), Some((true, true)), "token {}", token_id);
assert!(!dir.0.join(LICENSE_FILE).exists(), "token {}", token_id);
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "token {}", token_id);
}
}
#[tokio::test]
async fn a_key_that_cannot_be_deleted_after_the_release_keeps_its_token() {
// The release must not drop the token itself, as a deactivation from the machine list
// does: the key that then fails to go would stay without it.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = removal_dir("remove_released_key_blocked", &enc, &key);
let token = fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap();
let key_path = dir.0.join(LICENSE_FILE);
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/deactivate")
.match_request(move |_request| {
// The key has been read by now: a directory in its place cannot be deleted.
let _ = fs::remove_file(&key_path);
let _ = fs::create_dir(&key_path);
true
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.expect(1)
.create_async()
.await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Storage));
route.assert_async().await;
assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token);
}
#[tokio::test]
async fn a_slot_already_free_lets_the_removal_go_on() {
// Error table, first row: the server no longer counts this machine, whatever the
// reason, and a revocation is recorded for the key that was sent (#365, D4).
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let rows: Vec<(usize, &str, bool)> = vec![
(404, r#"{"error":{"code":"no_active_activation","message":"x"}}"#, false),
(404, r#"{"error":{"code":"license_not_found","message":"x"}}"#, false),
(403, REVOKED_ENVELOPE, true),
];
for (status, body, revoked) in rows {
let dir = removal_dir("remove_slot_free", &enc, &key);
let mut server = mockito::Server::new_async().await;
let route = deactivate_route(&mut server, status, body).await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Ok(()), "{} {}", status, body);
route.assert_async().await;
assert!(!dir.0.join(LICENSE_FILE).exists(), "{} {}", status, body);
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{} {}", status, body);
let expected_state = if revoked {
serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW, "revoked": true })
} else {
serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW - 60, "revoked": false })
};
assert_eq!(stored_state(&dir), Some(expected_state), "{} {}", status, body);
}
}
#[tokio::test]
async fn a_release_that_fails_removes_nothing() {
// Error table, second row: the front asks whether to remove the key anyway. Any code
// outside the table is returned the same way (decision log of #370).
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let rows: Vec<(usize, &str, LicenseError)> = vec![
(502, "<html>Bad Gateway</html>", LicenseError::Server { status: 502 }),
(
429,
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
LicenseError::RateLimit { retry_after_s: 42 },
),
(200, "<html>OK</html>", LicenseError::InvalidResponse),
(400, r#"{"error":{"code":"invalid_input","message":"x"}}"#, LicenseError::InvalidInput),
];
for (status, body, expected) in rows {
let dir = removal_dir("remove_release_failed", &enc, &key);
let before = snapshot(&dir);
let mut server = mockito::Server::new_async().await;
let route = deactivate_route(&mut server, status, body).await;
let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(expected), "{} {}", status, body);
route.assert_async().await;
assert_eq!(snapshot(&dir), before, "{} {}", status, body);
}
// Offline. Port 1 on loopback: nothing listens.
let dir = removal_dir("remove_release_offline", &enc, &key);
let before = snapshot(&dir);
let result = remove_license_in(&dir.0, "http://127.0.0.1:1", true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(result, Err(LicenseError::Network));
assert_eq!(snapshot(&dir), before);
}
#[tokio::test]
async fn the_release_sends_nothing_without_a_slot_of_this_machine_to_free() {
// Error table, third row: `release_slot` is ignored, the removal stays local.
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();
// (case, release_slot, token file, key known as revoked, OS id readable)
let cases: Vec<(&str, bool, Option<String>, bool, bool)> = vec![
("release not asked", false, Some(activation_for(&enc, THIS_DERIVED)), false, true),
("no token", true, None, false, true),
("expired token of this machine", true, Some(expired_activation_for(&enc, THIS_DERIVED)), false, true),
("token of another machine", true, Some(activation_for(&enc, OTHER_DERIVED)), false, true),
("token signed by another key", true, Some(activation_for(&other_enc, THIS_DERIVED)), false, true),
("unreadable OS id", true, Some(activation_for(&enc, THIS_DERIVED)), false, false),
("key known as revoked", true, Some(activation_for(&enc, THIS_DERIVED)), true, true),
];
for (case, release_slot, token, revoked, readable_os_id) in cases {
let dir = TestDir::new("remove_nothing_sent");
dir.write(LICENSE_FILE, &key);
if let Some(token) = &token {
dir.write(ACTIVATION_FILE, token);
}
dir.write(CHECK_FILE, check_state(&key, NOW - 60, revoked));
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let ids = readable_os_id.then_some(&machine);
let result = remove_license_in(&dir.0, &server.url(), release_slot, ids, &dec, NOW).await;
assert_eq!(result, Ok(()), "{}", case);
any_request.assert_async().await;
assert!(!dir.0.join(LICENSE_FILE).exists(), "{}", case);
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{}", case);
}
}
#[tokio::test]
async fn a_release_with_an_unreadable_key_is_storage_and_removes_nothing() {
// The release was asked and cannot be made: nothing goes. Without it, the key is not
// read at all, and it goes.
let (enc, dec) = default_keys();
let dir = TestDir::new("remove_unreadable_key");
dir.write(LICENSE_FILE, NOT_UTF8);
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED));
let before = snapshot(&dir);
let mut server = mockito::Server::new_async().await;
let any_request = no_request_expected(&mut server).await;
let released = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await;
assert_eq!(released, Err(LicenseError::Storage));
assert_eq!(snapshot(&dir), before);
let local = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await;
assert_eq!(local, Ok(()));
assert_eq!(snapshot(&dir), Vec::new());
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 token that does not read as text: nothing says it is the one the answer is about,
// so it stays (#388 review; the directory below cannot tell, its deletion fails anyway).
dir.write(ACTIVATION_FILE, NOT_UTF8);
assert_eq!(
remove_activation_token_if_unchanged_in(&dir.0, "token-a"),
Err(LicenseError::Storage)
);
assert!(dir.0.join(ACTIVATION_FILE).exists(), "an unreadable token stays");
fs::remove_file(dir.0.join(ACTIVATION_FILE)).unwrap();
// 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 =========================================================================
/// The dev override must be dead code in normal builds: even with
/// SR_DEV_EDITION set, nothing reads it when the `dev-override` Cargo
/// feature is off (CWE-489 — a release binary must never honor the env
/// var). Env-var note: under this feature-off build NO code path reads
/// SR_DEV_EDITION, so setting it here cannot race with parallel tests.
#[cfg(not(feature = "dev-override"))]
#[test]
fn sr_dev_edition_has_no_effect_when_feature_off() {
std::env::set_var("SR_DEV_EDITION", "premium");
assert_eq!(dev_override_edition(), None);
std::env::remove_var("SR_DEV_EDITION");
}
// Companion coverage for `cargo test --features dev-override` (not part of
// the normal CI run). SR_DEV_EDITION is process-global and cargo test runs
// tests on parallel threads, so every env manipulation serializes on a lock.
#[cfg(feature = "dev-override")]
mod dev_override_on {
use super::super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn sr_dev_edition_forces_edition() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("SR_DEV_EDITION", "premium");
assert_eq!(dev_override_edition(), Some("premium".to_string()));
std::env::remove_var("SR_DEV_EDITION");
}
#[test]
fn sr_dev_edition_unknown_value_ignored() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("SR_DEV_EDITION", "enterprise");
assert_eq!(dev_override_edition(), None);
std::env::remove_var("SR_DEV_EDITION");
}
}
}
// === License server calls (issue #270) =========================================================
//
// Pure mapping functions are tested with tables; the HTTP layer with an in-process mockito
// server passed as `api_base` (same strategy as `fetch_price_inner`). A request that matches
// no declared mock gets mockito's 501, so a client still calling a legacy `/licenses/*` path
// fails these tests.
#[cfg(test)]
mod license_server_tests {
use super::*;
use mockito::Matcher;
use serde_json::json;
/// JSON the front receives for an error.
fn wire(err: &LicenseError) -> serde_json::Value {
serde_json::from_str(&license_error_to_string(err)).unwrap()
}
#[test]
fn error_codes_serialize_like_fetch_price_error() {
assert_eq!(wire(&LicenseError::LicenseNotFound), json!({"code": "license_not_found"}));
assert_eq!(
wire(&LicenseError::RateLimit { retry_after_s: 42 }),
json!({"code": "rate_limit", "retry_after_s": 42})
);
assert_eq!(
wire(&LicenseError::Server { status: 502 }),
json!({"code": "server", "status": 502})
);
assert_eq!(wire(&LicenseError::NoLicenseStored), json!({"code": "no_license_stored"}));
// Local key-entry errors (#347), read by the same `parseLicenseError`.
assert_eq!(wire(&LicenseError::InvalidPrefix), json!({"code": "invalid_prefix"}));
assert_eq!(wire(&LicenseError::InvalidSignature), json!({"code": "invalid_signature"}));
assert_eq!(wire(&LicenseError::Malformed), json!({"code": "malformed"}));
assert_eq!(wire(&LicenseError::UnknownEdition), json!({"code": "unknown_edition"}));
}
#[test]
fn every_envelope_refusal_maps_to_its_code() {
let envelope = |code: &str| json!({"error": {"code": code, "message": "x"}});
let rows: Vec<(u16, serde_json::Value, serde_json::Value)> = vec![
(404, envelope("license_not_found"), json!({"code": "license_not_found"})),
(403, envelope("license_revoked"), json!({"code": "license_revoked"})),
(403, envelope("license_expired"), json!({"code": "license_expired"})),
(
409,
json!({"error": {"code": "machine_limit_reached", "message": "Machine limit reached (3)"},
"machines": [{"machine_id": "a", "machine_name": null,
"activated_at": "2026-09-01T00:00:00Z", "last_seen_at": "2026-09-01T00:00:00Z"}]}),
json!({"code": "machine_limit_reached"}),
),
(404, envelope("no_active_activation"), json!({"code": "no_active_activation"})),
// A missing route (maximus-api answers `{"error":{"code":"not_found"}}`) or the
// proxy's 404 page during a redeploy: a server error, never "key not found".
(404, envelope("not_found"), json!({"code": "server", "status": 404})),
(404, serde_json::Value::Null, json!({"code": "server", "status": 404})),
(
400,
json!({"error": {"code": "invalid_input", "message": "Invalid input"}, "details": {"fieldErrors": {}}}),
json!({"code": "invalid_input"}),
),
(
429,
json!({"error": {"code": "rate_limit_exceeded", "message": "slow down", "retry_after": 42}}),
json!({"code": "rate_limit", "retry_after_s": 42}),
),
(
429,
json!({"error": {"code": "rate_limit_exceeded", "message": "slow down"}}),
json!({"code": "rate_limit", "retry_after_s": 60}),
),
(418, envelope("not_a_known_code"), json!({"code": "server", "status": 418})),
(502, serde_json::Value::Null, json!({"code": "server", "status": 502})),
];
for (status, body, expected) in rows {
assert_eq!(
wire(&error_from_response(status, &body)),
expected,
"status {} body {}",
status,
body
);
}
}
#[test]
fn verify_answers_map_to_machines_or_codes() {
let machines = json!([{"machine_id": "a", "machine_name": "laptop",
"activated_at": "2026-09-01T00:00:00Z", "last_seen_at": "2026-09-20T00:00:00Z"}]);
let refusals = vec![
(json!({"valid": false, "reason": "not_found"}), json!({"code": "license_not_found"})),
(json!({"valid": false, "reason": "revoked"}), json!({"code": "license_revoked"})),
(json!({"valid": false, "reason": "expired"}), json!({"code": "license_expired"})),
(json!({"valid": false, "reason": "suspended"}), json!({"code": "server", "status": 200})),
(json!({"machines": machines}), json!({"code": "invalid_response"})),
(json!({"valid": true}), json!({"code": "invalid_response"})),
];
for (body, expected) in refusals {
let err = machines_from_verify(&body).expect_err(&body.to_string());
assert_eq!(wire(&err), expected, "body {}", body);
}
let listed = machines_from_verify(&json!({"valid": true, "machine_limit": 3, "machines": machines}))
.expect("valid answer");
assert_eq!(listed.machines.len(), 1);
assert_eq!(listed.machines[0].machine_id, "a");
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]
async fn deactivation_treats_an_already_free_slot_as_done() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/deactivate")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"no_active_activation","message":"No active activation found for this machine"}}"#)
.create_async()
.await;
assert_eq!(deactivate_idempotent(&server.url(), "SR-BASE-k", "machine-2").await, Ok(()));
// The route's other 404: an unknown key must stay an error, never "deactivated"
// (the command would also delete this machine's token). Only error.code decides.
let mut unknown = mockito::Server::new_async().await;
let _u = unknown
.mock("POST", "/v1/licenses/deactivate")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"license_not_found","message":"License not found"}}"#)
.create_async()
.await;
assert_eq!(
deactivate_idempotent(&unknown.url(), "SR-BASE-k", "machine-2").await,
Err(LicenseError::LicenseNotFound)
);
}
#[test]
fn activate_body_omits_an_unknown_machine_name() {
let body = activate_body("SR-BASE-k", "machine-1", None);
assert!(body.get("machine_name").is_none(), "body {}", body);
assert_eq!(body["product"], "simpl-resultat");
assert_eq!(
activate_body("SR-BASE-k", "machine-1", Some("laptop"))["machine_name"],
"laptop"
);
}
#[tokio::test]
async fn deactivate_with_an_unreadable_success_body_is_an_invalid_response() {
// A 2xx the client cannot read proves nothing: counting it as a success would
// make the command delete this machine's local token for nothing.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/deactivate")
.with_status(200)
.with_header("content-type", "text/html")
.with_body("<html>OK</html>")
.create_async()
.await;
assert_eq!(
deactivate_inner(&server.url(), "SR-BASE-k", "machine-2").await,
Err(LicenseError::InvalidResponse)
);
}
#[tokio::test]
async fn activate_posts_the_trimmed_key_and_product_to_v1() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/v1/licenses/activate")
.match_body(Matcher::PartialJson(json!({
"product": "simpl-resultat",
"license_key": "SR-BASE-abc.def.ghi",
"machine_id": "machine-1",
"machine_name": "laptop",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"activation_token":"tok-123"}"#)
.create_async()
.await;
let token = activate_inner(&server.url(), " SR-BASE-abc.def.ghi \n", "machine-1", Some("laptop"))
.await
.expect("activation token");
assert_eq!(token, "tok-123");
m.assert_async().await;
}
#[tokio::test]
async fn activate_reads_the_v1_error_envelope() {
let cases = vec![
(404, r#"{"error":{"code":"license_not_found","message":"License not found"}}"#, LicenseError::LicenseNotFound),
(403, r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#, LicenseError::LicenseRevoked),
(
409,
r#"{"error":{"code":"machine_limit_reached","message":"Machine limit reached (3)"},"machines":[]}"#,
LicenseError::MachineLimitReached,
),
];
for (status, body, expected) in cases {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/activate")
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
let err = activate_inner(&server.url(), "SR-BASE-k", "m", None)
.await
.expect_err("refusal");
assert_eq!(err, expected, "status {}", status);
}
}
#[tokio::test]
async fn activate_html_error_page_is_a_server_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/activate")
.with_status(502)
.with_header("content-type", "text/html")
.with_body("<html><body><h1>502 Bad Gateway</h1></body></html>")
.create_async()
.await;
let err = activate_inner(&server.url(), "SR-BASE-k", "m", None)
.await
.expect_err("502");
assert_eq!(err, LicenseError::Server { status: 502 });
}
#[tokio::test]
async fn activate_without_a_token_is_an_invalid_response() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/activate")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.create_async()
.await;
let err = activate_inner(&server.url(), "SR-BASE-k", "m", None)
.await
.expect_err("no token");
assert_eq!(err, LicenseError::InvalidResponse);
}
#[tokio::test]
async fn deactivate_posts_key_machine_and_product_to_v1() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/v1/licenses/deactivate")
.match_body(Matcher::PartialJson(json!({
"product": "simpl-resultat",
"license_key": "SR-BASE-k",
"machine_id": "machine-2",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.create_async()
.await;
deactivate_inner(&server.url(), "SR-BASE-k\n", "machine-2")
.await
.expect("deactivated");
m.assert_async().await;
}
#[tokio::test]
async fn deactivate_reports_no_active_activation() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/deactivate")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"no_active_activation","message":"No active activation found for this machine"}}"#)
.create_async()
.await;
let err = deactivate_inner(&server.url(), "SR-BASE-k", "machine-2")
.await
.expect_err("already free");
assert_eq!(err, LicenseError::NoActiveActivation);
}
#[tokio::test]
async fn verify_posts_product_to_v1_and_lists_machines() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/v1/licenses/verify")
.match_body(Matcher::PartialJson(json!({
"product": "simpl-resultat",
"license_key": "SR-BASE-k",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"valid":true,"edition":"base","email":"x@example.com","machine_limit":3,"active_machines":1,
"machines":[{"machine_id":"a","machine_name":null,"activated_at":"2026-09-01T00:00:00.000Z","last_seen_at":"2026-09-20T00:00:00.000Z"}],
"expires_at":"2036-09-09T00:00:00.000Z"}"#,
)
.create_async()
.await;
let listed = verify_inner(&server.url(), "SR-BASE-k").await.expect("machines");
assert_eq!(listed.machines.len(), 1);
assert_eq!(listed.machines[0].machine_id, "a");
m.assert_async().await;
}
#[tokio::test]
async fn verify_revoked_key_is_a_refusal_not_an_empty_list() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/verify")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"valid":false,"reason":"revoked"}"#)
.create_async()
.await;
let err = verify_inner(&server.url(), "SR-BASE-k").await.expect_err("revoked");
assert_eq!(err, LicenseError::LicenseRevoked);
}
#[tokio::test]
async fn verify_rate_limit_and_html_outage() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/v1/licenses/verify")
.with_status(429)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Too many requests","retry_after":42}}"#)
.create_async()
.await;
assert_eq!(
verify_inner(&server.url(), "SR-BASE-k").await.expect_err("429"),
LicenseError::RateLimit { retry_after_s: 42 }
);
let mut outage = mockito::Server::new_async().await;
let _o = outage
.mock("POST", "/v1/licenses/verify")
.with_status(503)
.with_header("content-type", "text/html")
.with_body("<html>Service Unavailable</html>")
.create_async()
.await;
assert_eq!(
verify_inner(&outage.url(), "SR-BASE-k").await.expect_err("503"),
LicenseError::Server { status: 503 }
);
}
/// Regression for #270: a server that only knows the `/v1` routes. Before the fix the
/// client called `/licenses/*`; here that would get mockito's 501 and fail.
#[tokio::test]
async fn all_three_calls_work_against_v1_only_routes() {
let mut server = mockito::Server::new_async().await;
let a = server
.mock("POST", "/v1/licenses/activate")
.match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"activation_token":"t"}"#)
.create_async()
.await;
let v = server
.mock("POST", "/v1/licenses/verify")
.match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"valid":true,"machines":[]}"#)
.create_async()
.await;
let d = server
.mock("POST", "/v1/licenses/deactivate")
.match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.create_async()
.await;
let base = server.url();
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.machines.len()), Ok(0));
assert_eq!(deactivate_inner(&base, "SR-BASE-k", "m").await, Ok(()));
a.assert_async().await;
v.assert_async().await;
d.assert_async().await;
}
#[tokio::test]
async fn unreachable_server_is_a_network_error() {
// Port 1 on loopback: nothing listens, the connection is refused at once.
assert_eq!(
verify_inner("http://127.0.0.1:1", "SR-BASE-k").await.expect_err("refused"),
LicenseError::Network
);
}
}