Follow-ups of the three /pr-review passes on #386, #387 and #388, applied on the stack head before the fast-forward merge: - #386: pin the relay of the revoked state by revocationCheckLauncher (a launcher passing false instead of wasRevoked passed the whole suite), and say in the rate budget comment that two checks in one minute bring the machine-limit path to 6 requests. - #387 S1: the fake `t` of the removal tests and of the License card tests marks what it translates, so a string that skipped `t` fails its test. - #387 S2: pin that storing another key stops on Storage while the previous token cannot be dropped, the previous key staying. - #387 S3: reload the license context after a failed removal too. Rust deletes the key first, so a token that resisted left the key gone while the card kept showing it under "the key was not removed". - #388: pin that an unreadable token stays (the directory case could not tell, its deletion fails anyway). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
633 lines
24 KiB
TypeScript
633 lines
24 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();
|
|
});
|
|
});
|
|
|
|
// The launcher relays the revoked state the provider read: a reload follows a change only.
|
|
// Every other test of the launcher starts from a key that is not revoked (#386 review).
|
|
describe("revocationCheckLauncher", () => {
|
|
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
function launcherHarness(result: RevocationCheck) {
|
|
const deps: RevocationCheckDeps = {
|
|
checkLicenseRevocation: vi.fn(async (): Promise<RevocationCheck> => result),
|
|
refresh: vi.fn(async () => {}),
|
|
logInfo: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
};
|
|
return deps;
|
|
}
|
|
|
|
it("reloads when a key known as revoked is confirmed valid again", async () => {
|
|
const deps = launcherHarness({ outcome: "valid", revoked: false });
|
|
revocationCheckLauncher(deps)(true);
|
|
await settle();
|
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not reload a key known as revoked that is still revoked", async () => {
|
|
const deps = launcherHarness({ outcome: "revoked", revoked: true });
|
|
revocationCheckLauncher(deps)(true);
|
|
await settle();
|
|
expect(deps.checkLicenseRevocation).toHaveBeenCalledTimes(1);
|
|
expect(deps.refresh).not.toHaveBeenCalled();
|
|
});
|
|
});
|