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, 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 = {}) { 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 => "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 => "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 => "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 => "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 => { 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 => { 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((resolve) => setTimeout(resolve, 0)); const freeReady = licenseReducer(initialLicenseState, { type: "LOAD_DONE", edition: "free", info: null, }); function entryHarness(answer: () => Promise) { 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 => "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((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, start: ReturnType, launch: (wasRevoked: boolean) => void, ): SubmitKeyDeps { let state = initial; return { storeLicense: async () => clean, getEdition: async (): Promise => "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((resolve) => setTimeout(resolve, 0)); function launcherHarness(result: RevocationCheck) { const deps: RevocationCheckDeps = { checkLicenseRevocation: vi.fn(async (): Promise => 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(); }); });