Simpl-Resultat/src-tauri/src/commands/license_commands.rs
le king fu e2f0fb86da
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m26s
feat(license): remove the stored license key from the License card
"Remove the key" on the License card removes license.key, then the
activation token, and keeps license.check, so a revoked key removed then
entered again stays Free. Removing the key stops the revocation check,
which until now only storing another key could stop.

When this machine is activated with a valid key, its slot is freed on
the license server first (remove_license with release_slot). A release
that fails removes nothing; on network, server, rate_limit or
invalid_response the card asks whether to remove the key anyway, the
slot then staying occupied. no_active_activation, license_not_found and
license_revoked (recorded for the key sent) mean the slot is free.

- Rust: remove_license / remove_license_in, release_this_machine_slot,
  this_machine_token_id, and the shared remove_activation_token_in
  helper, now also used by store_license_in and the deactivation.
- Front: removeLicense service; performRemoveLicense in
  useLicenseMachines (confirmations by state, error table, in-app log
  lines); removing and removeError in the machines reducer, which lock
  key entry, Activate and Deactivate; the button for any stored key,
  valid or revoked.
- Docs: user guide, in-app tips, architecture, ADR 0017 amendment,
  changelogs.

Resolves #370

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

3864 lines
168 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.
//
// 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";
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); meant for the stale token of #367 next.
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),
}
}
/// 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<Vec<MachineInfo>, LicenseError> {
machines_from_verify(&verify_body(api_base, license_key).await?)
}
/// 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".
fn machines_from_verify(body: &serde_json::Value) -> Result<Vec<MachineInfo>, 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 machines = body
.get("machines")
.and_then(|v| v.as_array())
.ok_or(LicenseError::InvalidResponse)?;
Ok(machines
.iter()
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
.collect())
}
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,
}
/// 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<Vec<MachineInfo>, LicenseError> {
let machines = verify_inner(api_base, license_key).await?;
Ok(flag_this_machine(machines, ids))
}
/// [`list_machines_inner`] as the command performs it: a "revoked" answer is recorded for
/// the key that was sent (#365, D4).
async fn list_machines_in(
dir: &Path,
api_base: &str,
license_key: &str,
ids: Option<&MachineIds>,
now: i64,
) -> Result<Vec<MachineInfo>, LicenseError> {
let answer = list_machines_inner(api_base, license_key, ids).await;
note_revocation(dir, license_key, answer, now)
}
/// 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). Errors cross the boundary as a serialized [`LicenseError`].
#[tauri::command]
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, 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<Vec<MachineInfo>, LicenseError> {
let Some(license_key) = read_stored_key(app)? else {
return Ok(vec![]);
};
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
// Best effort on the OS id: unreadable, it leaves every row unflagged.
let ids = local_machine_ids().ok();
list_machines_in(&dir, &api_base_url(), &license_key, ids.as_ref(), 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_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), 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), 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), NOW).await;
assert_eq!(result.map(|machines| 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, NOW).await;
assert_eq!(result.map(|machines| 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, NOW).await.map(|m| m.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;
}
// === 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.len(), 1);
assert_eq!(listed[0].machine_id, "a");
assert_eq!(listed[0].machine_name.as_deref(), Some("laptop"));
}
#[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 machines = verify_inner(&server.url(), "SR-BASE-k").await.expect("machines");
assert_eq!(machines.len(), 1);
assert_eq!(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.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
);
}
}