Simpl-Resultat/src/contexts/LicenseContext.test.ts
le king fu ab78aa9ddc
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
PR Check — Rust / rust (pull_request) Successful in 3m25s
fix(license): check a revoked key as soon as it is entered
A license key entered during a session was only checked against the
license server at the next start: a revoked key kept its edition until
the app restarted, or until a machine activation was refused (#365
checked at start only).

Front: performSubmitKey launches the revocation check after
VALIDATE_DONE, without waiting for it, and never after a refused key.
SubmitKeyDeps gets a single launcher, startRevocationCheck(wasRevoked),
built once by the provider with revocationCheckLauncher and shared with
the start check, whose starter is unchanged.

Rust: the command runs the checks one at a time. A static
tauri::async_runtime::Mutex (REVOCATION_CHECK_TURN) is held by
check_revocation_in_turn over the whole check, request and verdict
included, and the time is read once the turn has come. A key entered
before the first ready state, checked by the entry and by the start,
sends one request; the check of a key entered while another one is in
flight waits for its verdict instead of overwriting license.check.
check_revocation_in is unchanged, and so are Cargo.toml and Cargo.lock.

Docs: ADR 0017 (amendment #365, point 5), user guide, in-app docs
(docs.settings.tips, docs.editions.tips), architecture, and both
changelogs under [Unreleased] (Fixed / Corrigé).

Resolves #373

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:26:10 -04:00

603 lines
23 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
licenseReducer,
initialLicenseState,
licenseContextValue,
performSubmitKey,
createRevocationCheckStarter,
performRevocationCheck,
revocationCheckLauncher,
REVOCATION_LOG,
type LicenseAction,
type RevocationCheckDeps,
type SubmitKeyDeps,
} from "./LicenseContext";
import type {
Edition,
LicenseInfo,
RevocationCheck,
RevocationOutcome,
} from "../services/licenseService";
const makeInfo = (edition: Exclude<Edition, "free">, revoked = false): LicenseInfo => ({
edition,
email: "max@example.com",
features: [],
machine_limit: 3,
issued_at: 1,
expires_at: 0,
revoked,
});
// The CWE-703 retry loop arms exclusively on `status === "error"`, and its
// LOAD_START clears `error`. A rejected key must therefore never re-enter the
// load lifecycle: it would arm the auto refresh, which would wipe the very
// message the user is reading (~1s after submitting an invalid key).
const premiumReady = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
);
describe("licenseReducer — load lifecycle (CWE-703)", () => {
it("LOAD_ERROR preserves the last-known edition and info", () => {
const errored = licenseReducer(premiumReady, { type: "LOAD_ERROR", error: "ipc down" });
expect(errored.status).toBe("error");
expect(errored.error).toBe("ipc down");
expect(errored.edition).toBe("premium");
expect(errored.info).toEqual(makeInfo("premium"));
});
it("LOAD_START clears the load error but not a pending validation error", () => {
const rejected = licenseReducer(
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
const refreshed = licenseReducer(rejected, { type: "LOAD_START" });
expect(refreshed.status).toBe("loading");
expect(refreshed.error).toBeNull();
expect(refreshed.validationError).toBe("invalid key");
});
});
describe("licenseReducer — key validation is orthogonal to the load lifecycle", () => {
it("VALIDATE_ERROR on a ready license keeps status ready (retry never arms)", () => {
const validating = licenseReducer(premiumReady, { type: "VALIDATE_START" });
expect(validating.status).toBe("ready");
expect(validating.validating).toBe(true);
const rejected = licenseReducer(validating, { type: "VALIDATE_ERROR", error: "invalid key" });
expect(rejected.status).toBe("ready");
expect(rejected.validating).toBe(false);
expect(rejected.validationError).toBe("invalid key");
expect(rejected.edition).toBe("premium");
expect(rejected.info).toEqual(makeInfo("premium"));
});
it("VALIDATE_ERROR during a boot error keeps status error (load retry keeps running)", () => {
const bootError = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_ERROR", error: "boot fail" },
);
const rejected = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
expect(rejected.status).toBe("error");
expect(rejected.error).toBe("boot fail");
expect(rejected.validationError).toBe("invalid key");
expect(rejected.edition).toBe("free");
});
it("VALIDATE_START clears the previous validation error", () => {
const rejected = licenseReducer(
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
const resubmit = licenseReducer(rejected, { type: "VALIDATE_START" });
expect(resubmit.validating).toBe(true);
expect(resubmit.validationError).toBeNull();
});
it("VALIDATE_DONE resets the full state, recovering from a boot error", () => {
const bootError = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_ERROR", error: "boot fail" },
);
const done = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
{ type: "VALIDATE_DONE", info: makeInfo("base"), edition: "base" },
);
expect(done).toEqual({
status: "ready",
edition: "base",
info: makeInfo("base"),
error: null,
validating: false,
validationError: null,
});
});
});
// The revoked state is persisted by Rust and reaches the context inside the license
// info (#365): nothing in the reducer decides it, so nothing in the session can lose it.
describe("licenseReducer — the revoked state comes from Rust", () => {
const revokedReady = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_DONE", edition: "free", info: makeInfo("base", true) },
);
it("a loaded revoked key is Free, and its info says why", () => {
expect(revokedReady.edition).toBe("free");
expect(revokedReady.info?.revoked).toBe(true);
expect(revokedReady.info?.edition).toBe("base");
});
it("survives a reload, a load error and a refused key", () => {
const reloaded = licenseReducer(
licenseReducer(revokedReady, { type: "LOAD_START" }),
{ type: "LOAD_DONE", edition: "free", info: makeInfo("base", true) },
);
expect(reloaded.info?.revoked).toBe(true);
const failed = licenseReducer(licenseReducer(revokedReady, { type: "LOAD_START" }), {
type: "LOAD_ERROR",
error: "boot fail",
});
expect(failed.info?.revoked).toBe(true);
expect(failed.edition).toBe("free");
const refused = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
type: "VALIDATE_ERROR",
error: '{"code":"invalid_prefix"}',
});
expect(refused.info?.revoked).toBe(true);
});
it("VALIDATE_DONE takes the edition it is given, not the one the key claims", () => {
const same = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
type: "VALIDATE_DONE",
info: makeInfo("base", true),
edition: "free",
});
expect(same.edition).toBe("free");
expect(same.info?.revoked).toBe(true);
});
it("a different, valid key starts clean", () => {
const other: LicenseInfo = { ...makeInfo("premium"), issued_at: 2 };
const replaced = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
type: "VALIDATE_DONE",
info: other,
edition: "premium",
});
expect(replaced.edition).toBe("premium");
expect(replaced.info?.revoked).toBe(false);
});
});
// Storing a key: the edition shown right after is the one Rust resolves (#365). Before,
// VALIDATE_DONE took the key's own edition, so a revoked key entered again read as Base
// until the next reload. A stored key is then checked online at once (#373).
describe("performSubmitKey", () => {
function submitHarness(overrides: Partial<SubmitKeyDeps> = {}) {
let state = premiumReady;
const actions: LicenseAction[] = [];
// Dispatched actions and launched checks, in the order they happen.
const events: string[] = [];
const deps: SubmitKeyDeps = {
storeLicense: vi.fn(async () => makeInfo("base")),
getEdition: vi.fn(async (): Promise<Edition> => "base"),
dispatch: (action) => {
actions.push(action);
events.push(action.type);
state = licenseReducer(state, action);
},
startRevocationCheck: vi.fn((wasRevoked: boolean) => {
events.push(`check(${wasRevoked})`);
}),
...overrides,
};
return { deps, actions, events, getState: () => state };
}
it("launches the revocation check once, after VALIDATE_DONE, for the key just stored", async () => {
const { deps, events } = submitHarness();
await performSubmitKey(deps, "SR-BASE-k");
expect(events).toEqual(["VALIDATE_START", "VALIDATE_DONE", "check(false)"]);
expect(deps.startRevocationCheck).toHaveBeenCalledTimes(1);
});
it("launches the check with the revoked state Rust gave the key", async () => {
// A key already known here as revoked: the check compares its answer with that state,
// so a "revoked" answer reloads nothing.
const { deps, events } = submitHarness({
storeLicense: vi.fn(async () => makeInfo("base", true)),
getEdition: vi.fn(async (): Promise<Edition> => "free"),
});
await performSubmitKey(deps, "SR-BASE-k");
expect(events).toEqual(["VALIDATE_START", "VALIDATE_DONE", "check(true)"]);
});
it("takes the edition Rust resolves: Free for a revoked key entered again", async () => {
const { deps, getState } = submitHarness({
storeLicense: vi.fn(async () => makeInfo("base", true)),
getEdition: vi.fn(async (): Promise<Edition> => "free"),
});
const result = await performSubmitKey(deps, "SR-BASE-k");
expect(result).toEqual({ ok: true, info: makeInfo("base", true) });
expect(getState().edition).toBe("free");
expect(getState().info?.revoked).toBe(true);
expect(deps.storeLicense).toHaveBeenCalledWith("SR-BASE-k");
});
it("keeps an account Premium over the stored key's own edition", async () => {
const { deps, getState } = submitHarness({
getEdition: vi.fn(async (): Promise<Edition> => "premium"),
});
await performSubmitKey(deps, "SR-BASE-k");
expect(getState().edition).toBe("premium");
expect(getState().info?.edition).toBe("base");
});
it("falls back to Free when the edition cannot be read and the key came back revoked", async () => {
const { deps, getState } = submitHarness({
storeLicense: vi.fn(async () => makeInfo("base", true)),
getEdition: vi.fn(async (): Promise<Edition> => {
throw new Error("ipc down");
}),
});
const result = await performSubmitKey(deps, "SR-BASE-k");
expect(result.ok).toBe(true);
expect(getState().edition).toBe("free");
expect(getState().status).toBe("ready");
// The key is stored: it is checked, whatever became of the edition read.
expect(deps.startRevocationCheck).toHaveBeenCalledWith(true);
});
it("falls back to the key's edition when the edition cannot be read and the key is clean", async () => {
const { deps, getState } = submitHarness({
getEdition: vi.fn(async (): Promise<Edition> => {
throw new Error("ipc down");
}),
});
await performSubmitKey(deps, "SR-BASE-k");
expect(getState().edition).toBe("base");
});
it("reports a refused key without reading the edition nor launching the check", async () => {
const { deps, actions, getState } = submitHarness({
storeLicense: vi.fn(async () => {
throw '{"code":"invalid_prefix"}';
}),
});
const result = await performSubmitKey(deps, "nonsense");
expect(result).toEqual({ ok: false, error: '{"code":"invalid_prefix"}' });
expect(deps.getEdition).not.toHaveBeenCalled();
expect(deps.startRevocationCheck).not.toHaveBeenCalled();
expect(actions.map((a) => a.type)).toEqual(["VALIDATE_START", "VALIDATE_ERROR"]);
expect(getState().edition).toBe("premium");
expect(getState().validationError).toBe('{"code":"invalid_prefix"}');
});
});
// The entry and its check together, wired as the provider wires them: the launcher is the
// real one, only the Tauri commands and the log are stubs (#373).
describe("a key entered in session is checked right away", () => {
/** Lets the check launched in the background run to its end. */
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const freeReady = licenseReducer(initialLicenseState, {
type: "LOAD_DONE",
edition: "free",
info: null,
});
function entryHarness(answer: () => Promise<RevocationCheck>) {
let state = freeReady;
const dispatch = (action: LicenseAction) => {
state = licenseReducer(state, action);
};
const check: RevocationCheckDeps = {
checkLicenseRevocation: vi.fn(answer),
// A reload reads what Rust resolves once the revocation is recorded.
refresh: vi.fn(async () => {
dispatch({ type: "LOAD_START" });
dispatch({ type: "LOAD_DONE", edition: "free", info: makeInfo("base", true) });
}),
logInfo: vi.fn(),
logWarn: vi.fn(),
};
const deps: SubmitKeyDeps = {
storeLicense: vi.fn(async () => makeInfo("base")),
getEdition: vi.fn(async (): Promise<Edition> => "base"),
dispatch,
startRevocationCheck: revocationCheckLauncher(check),
};
return { deps, check, getState: () => state };
}
it("answers the entry first, then takes a revoked key to Free without a restart", async () => {
let answer: (result: RevocationCheck) => void = () => {};
const { deps, check, getState } = entryHarness(
() =>
new Promise<RevocationCheck>((resolve) => {
answer = resolve;
}),
);
const entry = performSubmitKey(deps, "SR-BASE-k");
// The entry is over while the license server has not answered yet.
const first = await Promise.race([
entry.then(() => "entry"),
settle().then(() => "nothing yet"),
]);
expect(first).toBe("entry");
expect(await entry).toEqual({ ok: true, info: makeInfo("base") });
expect(getState().edition).toBe("base");
expect(check.checkLicenseRevocation).toHaveBeenCalledTimes(1);
expect(check.logInfo).not.toHaveBeenCalled();
answer({ outcome: "revoked", revoked: true });
await settle();
expect(check.logInfo).toHaveBeenCalledWith(
"License check: the license server says the key was revoked",
);
expect(check.refresh).toHaveBeenCalledTimes(1);
expect(getState().edition).toBe("free");
expect(getState().info?.revoked).toBe(true);
});
it("keeps the key's edition when the check cannot run, and logs it", async () => {
const failure = new Error("ipc down");
const { deps, check, getState } = entryHarness(async () => {
throw failure;
});
await performSubmitKey(deps, "SR-BASE-k");
await settle();
expect(check.logWarn).toHaveBeenCalledTimes(1);
expect(check.logWarn).toHaveBeenCalledWith(
"License check: the check could not run, nothing changed",
failure,
);
expect(check.refresh).not.toHaveBeenCalled();
expect(getState().edition).toBe("base");
});
it("keeps the key's edition when the server is unreachable, and logs it", async () => {
const { deps, check, getState } = entryHarness(async () => ({
outcome: "unreachable",
revoked: false,
}));
await performSubmitKey(deps, "SR-BASE-k");
await settle();
expect(check.logInfo).toHaveBeenCalledTimes(1);
expect(check.logInfo).toHaveBeenCalledWith(
"License check: the license server could not be reached or answered with an error, nothing changed",
);
expect(check.refresh).not.toHaveBeenCalled();
expect(getState().edition).toBe("base");
});
});
// The provider's value is derived by a plain function: its state never loads under
// renderToStaticMarkup, so this is where "the context says revoked" is decided.
describe("licenseContextValue", () => {
const refresh = async () => {};
const submitKey = async () => ({ ok: false as const, error: "unused" });
const loaded = (info: LicenseInfo | null, edition: Edition) =>
licenseContextValue(
licenseReducer(initialLicenseState, { type: "LOAD_DONE", edition, info }),
refresh,
submitKey,
);
it("says revoked for a revoked key, and only then", () => {
expect(loaded(makeInfo("base", true), "free").revoked).toBe(true);
expect(loaded(makeInfo("base"), "base").revoked).toBe(false);
expect(loaded(null, "free").revoked).toBe(false);
expect(licenseContextValue(initialLicenseState, refresh, submitKey).revoked).toBe(false);
});
it("exposes the state, the signed features and the two actions", () => {
const info: LicenseInfo = { ...makeInfo("base"), features: ["balance"] };
expect(loaded(info, "base")).toEqual({
status: "ready",
edition: "base",
features: ["balance"],
info,
error: null,
validating: false,
validationError: null,
revoked: false,
refresh,
submitKey,
});
expect(loaded(null, "free").features).toEqual([]);
});
});
// D1: the check is a startup check. The starter is what makes "once" true: effects do
// not run under renderToStaticMarkup, so the decision lives here, outside the provider.
describe("createRevocationCheckStarter", () => {
const clean = makeInfo("base");
it("does nothing until the license is ready", () => {
const run = vi.fn();
const start = createRevocationCheckStarter(run);
start("idle", null);
start("loading", null);
start("error", clean);
expect(run).not.toHaveBeenCalled();
});
it("runs once at the first ready state with a stored key", () => {
const run = vi.fn();
const start = createRevocationCheckStarter(run);
start("loading", null);
start("ready", clean);
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(false);
});
it("never runs again: not after a reload, nor for another key", () => {
const run = vi.fn();
const start = createRevocationCheckStarter(run);
start("ready", clean);
start("loading", clean);
start("ready", clean);
start("ready", { ...makeInfo("premium"), issued_at: 2 });
expect(run).toHaveBeenCalledTimes(1);
});
it("passes the revoked state the key was loaded with", () => {
const run = vi.fn();
createRevocationCheckStarter(run)("ready", makeInfo("base", true));
expect(run).toHaveBeenCalledWith(true);
});
/** Entry deps whose dispatch also runs the starter, as the provider's effect does. */
function entryWithStarter(
initial: ReturnType<typeof licenseReducer>,
start: ReturnType<typeof createRevocationCheckStarter>,
launch: (wasRevoked: boolean) => void,
): SubmitKeyDeps {
let state = initial;
return {
storeLicense: async () => clean,
getEdition: async (): Promise<Edition> => "base",
dispatch: (action) => {
state = licenseReducer(state, action);
start(state.status, state.info);
},
startRevocationCheck: launch,
};
}
it("sends nothing without a stored key, and leaves a key entered later to the entry", async () => {
// Before #373 a key entered later waited for the next start. The starter is still
// consumed at boot; the entry checks the key itself, once.
const launch = vi.fn();
const start = createRevocationCheckStarter(launch);
const booted = licenseReducer(initialLicenseState, {
type: "LOAD_DONE",
edition: "free",
info: null,
});
start(booted.status, booted.info);
expect(launch).not.toHaveBeenCalled();
await performSubmitKey(entryWithStarter(booted, start, launch), "SR-BASE-k");
expect(launch).toHaveBeenCalledTimes(1);
expect(launch).toHaveBeenCalledWith(false);
});
it("launches twice for a key entered before the first ready state, which Rust sends once", async () => {
// The entry ends in the first ready state, so the starter launches too. Rust runs the
// checks one at a time and the second reads the first one's attempt
// (`two_checks_side_by_side_send_one_request` in license_commands.rs).
const launch = vi.fn();
const start = createRevocationCheckStarter(launch);
const loading = licenseReducer(initialLicenseState, { type: "LOAD_START" });
start(loading.status, loading.info);
await performSubmitKey(entryWithStarter(loading, start, launch), "SR-BASE-k");
expect(launch).toHaveBeenCalledTimes(2);
});
it("is consumed by the first ready state after a boot error too", () => {
const run = vi.fn();
const start = createRevocationCheckStarter(run);
start("error", null);
start("loading", null);
start("ready", clean);
start("ready", clean);
expect(run).toHaveBeenCalledTimes(1);
});
});
describe("performRevocationCheck", () => {
function checkHarness(result: RevocationCheck | Error) {
const deps: RevocationCheckDeps = {
checkLicenseRevocation: vi.fn(async () => {
if (result instanceof Error) throw result;
return result;
}),
refresh: vi.fn(async () => {}),
logInfo: vi.fn(),
logWarn: vi.fn(),
};
return deps;
}
// Each message written out: comparing against REVOCATION_LOG itself would pass with
// two messages swapped.
const EXPECTED_LOG: [RevocationOutcome, string][] = [
["no_license", "License check: no valid key stored, nothing sent"],
["not_due", "License check: last attempt less than 24 h ago, nothing sent"],
["valid", "License check: the license server confirms the key"],
["revoked", "License check: the license server says the key was revoked"],
["unreachable", "License check: the license server could not be reached or answered with an error, nothing changed"],
["unknown", "License check: the license server gave no usable answer, nothing changed"],
["storage", "License check: the attempt or the server's answer could not be recorded, nothing changed"],
];
it("has a log message for every outcome, and no other", () => {
expect(Object.keys(REVOCATION_LOG).sort()).toEqual(EXPECTED_LOG.map(([o]) => o).sort());
});
it.each(EXPECTED_LOG)("writes one log line for %s", async (outcome, message) => {
const deps = checkHarness({ outcome, revoked: false });
await performRevocationCheck(deps, false);
expect(deps.logInfo).toHaveBeenCalledTimes(1);
expect(deps.logInfo).toHaveBeenCalledWith(message);
expect(deps.logWarn).not.toHaveBeenCalled();
});
it("reloads the license when the key turns out revoked", async () => {
const deps = checkHarness({ outcome: "revoked", revoked: true });
await performRevocationCheck(deps, false);
expect(deps.refresh).toHaveBeenCalledTimes(1);
});
it("reloads the license when a revocation is lifted", async () => {
const deps = checkHarness({ outcome: "valid", revoked: false });
await performRevocationCheck(deps, true);
expect(deps.refresh).toHaveBeenCalledTimes(1);
});
it("does not reload when nothing changed", async () => {
const unchanged: [RevocationCheck, boolean][] = [
[{ outcome: "valid", revoked: false }, false],
[{ outcome: "revoked", revoked: true }, true],
[{ outcome: "not_due", revoked: true }, true],
[{ outcome: "unreachable", revoked: false }, false],
[{ outcome: "unreachable", revoked: true }, true],
[{ outcome: "no_license", revoked: false }, false],
];
for (const [result, wasRevoked] of unchanged) {
const deps = checkHarness(result);
await performRevocationCheck(deps, wasRevoked);
expect(deps.refresh, `${result.outcome} from ${wasRevoked}`).not.toHaveBeenCalled();
}
});
it("logs a command that could not run and changes nothing", async () => {
const failure = new Error("ipc down");
const deps = checkHarness(failure);
await expect(performRevocationCheck(deps, false)).resolves.toBeUndefined();
expect(deps.logWarn).toHaveBeenCalledWith(
"License check: the check could not run, nothing changed",
failure,
);
expect(deps.logInfo).not.toHaveBeenCalled();
expect(deps.refresh).not.toHaveBeenCalled();
});
});