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>
603 lines
23 KiB
TypeScript
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();
|
|
});
|
|
});
|