import { describe, it, expect, vi, beforeEach } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { invoke } from "@tauri-apps/api/core"; import { licenseMachinesReducer, initialLicenseMachinesState, selectShowEmptyState, selectCanDeactivate, selectCanActivate, selectCanRemoveKey, selectRemoveLicenseState, releasesSlot, removeLicenseConfirmKey, shouldLoadOnOpen, performActivate, performDeactivate, performRemoveLicense, loadMachines, loadActivationStatus, useLicenseMachines, type LicenseMachinesAction, type LicenseMachinesDeps, type LicenseMachinesState, type RemoveLicenseDeps, type RemoveLicenseState, } from "./useLicenseMachines"; import { parseLicenseError, type ActivationStatus, type LicenseError, type LicenseErrorCode, type LicenseResult, type MachineInfo, type MachineList, type TokenDropped, } from "../services/licenseService"; import { markedT } from "../test/i18nMock"; // The hook itself reads the LicenseContext; the pure functions below never do. const context = { refresh: vi.fn(async () => {}), revoked: false }; vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: () => context })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); // What the hook writes to the in-app log (the removal of the key, #370). const log = vi.hoisted(() => ({ logInfo: vi.fn(), logWarn: vi.fn() })); vi.mock("../services/logService", () => log); const err = (code: LicenseErrorCode, extra: Record = {}): LicenseError => parseLicenseError(JSON.stringify({ code, ...extra })); const machine = (id: string): MachineInfo => ({ machine_id: id, machine_name: `host-${id}`, activated_at: "2026-09-01T00:00:00Z", last_seen_at: "2026-09-20T00:00:00Z", is_this_machine: false, }); /** What `list_activated_machines` answers: the machines, and a token removed or not (#367). */ const listed = (machines: MachineInfo[], tokenDropped: TokenDropped | null = null): MachineList => ({ machines, token_dropped: tokenDropped, }); const reduce = ( actions: LicenseMachinesAction[], from: LicenseMachinesState = initialLicenseMachinesState, ): LicenseMachinesState => actions.reduce(licenseMachinesReducer, from); const limitHit = reduce([ { type: "ACTIVATE_START" }, { type: "ACTIVATE_ERROR", error: err("machine_limit_reached") }, ]); describe("licenseMachinesReducer — machine limit (D2)", () => { it("opens the list and keeps the message on machine_limit_reached", () => { expect(limitHit.activationError?.code).toBe("machine_limit_reached"); expect(limitHit.listOpen).toBe(true); expect(limitHit.activating).toBe(false); }); it("does not open the list for other activation refusals", () => { const s = reduce([ { type: "ACTIVATE_START" }, { type: "ACTIVATE_ERROR", error: err("license_revoked") }, ]); expect(s.activationError?.code).toBe("license_revoked"); expect(s.listOpen).toBe(false); }); it("keeps the activation error through the list load it triggers", () => { const loading = reduce([{ type: "LIST_LOAD_START" }], limitHit); expect(loading.activationError?.code).toBe("machine_limit_reached"); const loaded = reduce( [{ type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b"), machine("c")] }], loading, ); expect(loaded.activationError?.code).toBe("machine_limit_reached"); expect(loaded.machines).toHaveLength(3); expect(loaded.listLoaded).toBe(true); }); it("keeps the activation error when the list load itself fails (e.g. a 429 right after the 409)", () => { const s = reduce( [{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("rate_limit", { retry_after_s: 30 }) }], limitHit, ); expect(s.activationError?.code).toBe("machine_limit_reached"); expect(s.listError?.code).toBe("rate_limit"); expect(s.listLoaded).toBe(false); }); it("clears only a machine-limit activation error once a slot is freed", () => { const withList = reduce( [{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] }], limitHit, ); const freed = reduce( [{ type: "DEACTIVATE_START", machineId: "a" }, { type: "DEACTIVATE_DONE", machineId: "a" }], withList, ); expect(freed.activationError).toBeNull(); expect(freed.machines.map((m) => m.machine_id)).toEqual(["b"]); expect(freed.deactivatingId).toBeNull(); const networkFailure = reduce([ { type: "ACTIVATE_START" }, { type: "ACTIVATE_ERROR", error: err("network") }, { type: "DEACTIVATE_START", machineId: "a" }, { type: "DEACTIVATE_DONE", machineId: "a" }, ]); expect(networkFailure.activationError?.code).toBe("network"); }); }); describe("licenseMachinesReducer — who may clear the activation error", () => { // Only ACTIVATE_START and a DEACTIVATE_DONE after a machine-limit refusal // clear it. Everything else (list loads in particular) must leave it alone. const preserving: LicenseMachinesAction[] = [ { type: "ACTIVATION_STATUS", activation: { is_activated: false } }, { type: "TOGGLE_LIST" }, { type: "TOGGLE_LIST" }, { type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }, { type: "LIST_LOAD_ERROR", error: err("server", { status: 502 }) }, { type: "DEACTIVATE_START", machineId: "a" }, { type: "DEACTIVATE_ERROR", error: err("network") }, { type: "ACTIVATE_DONE" }, ]; // ACTIVATE_DONE is listed too: it is not a clearing path of its own, since // ACTIVATE_START always clears the error before a successful activation. it.each(preserving.map((a) => [a.type, a] as const))("%s keeps it", (_type, action) => { expect(licenseMachinesReducer(limitHit, action).activationError?.code).toBe( "machine_limit_reached", ); }); it("ACTIVATE_START clears it", () => { expect(licenseMachinesReducer(limitHit, { type: "ACTIVATE_START" }).activationError).toBeNull(); }); }); describe("licenseMachinesReducer — list lifecycle", () => { it("DEACTIVATE_START clears a stale list error", () => { const s = reduce([ { type: "DEACTIVATE_START", machineId: "a" }, { type: "DEACTIVATE_ERROR", error: err("rate_limit", { retry_after_s: 20 }) }, { type: "DEACTIVATE_START", machineId: "a" }, ]); expect(s.listError).toBeNull(); expect(s.deactivatingId).toBe("a"); }); it("closes and invalidates the list after a successful activation", () => { const s = reduce( [ { type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a")] }, { type: "ACTIVATE_START" }, { type: "ACTIVATE_DONE" }, ], { ...initialLicenseMachinesState, listOpen: true }, ); expect(s.listOpen).toBe(false); expect(s.listLoaded).toBe(false); expect(s.activating).toBe(false); }); }); describe("selectShowEmptyState", () => { it("is false before the first load", () => { expect(selectShowEmptyState(initialLicenseMachinesState)).toBe(false); }); it("is true once a load returns an empty list without error", () => { expect(selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }]))).toBe(true); }); it("is false while loading, on a list error, or with machines", () => { expect(selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }]))).toBe(false); expect( selectShowEmptyState( reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("license_revoked") }]), ), ).toBe(false); expect( selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a")] }])), ).toBe(false); }); it("is false when a deactivation fails on a list emptied locally", () => { const s = reduce([ { type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }, { type: "DEACTIVATE_START", machineId: "a" }, { type: "DEACTIVATE_ERROR", error: err("network") }, ]); expect(selectShowEmptyState(s)).toBe(false); }); }); describe("selectCanDeactivate", () => { const loaded = reduce([ { type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] }, ]); it("allows a deactivation on an idle, loaded list", () => { expect(selectCanDeactivate(loaded)).toBe(true); }); it("blocks every button while one deactivation is running", () => { expect(selectCanDeactivate(reduce([{ type: "DEACTIVATE_START", machineId: "a" }], loaded))).toBe(false); }); it("blocks the buttons while a 409 reloads an already loaded list", () => { // The reload answers with the list as of its request: a machine deactivated // before it lands would reappear. const reloading = reduce( [ { type: "ACTIVATE_START" }, { type: "ACTIVATE_ERROR", error: err("machine_limit_reached") }, { type: "LIST_LOAD_START" }, ], loaded, ); expect(reloading.machines).toHaveLength(2); expect(selectCanDeactivate(reloading)).toBe(false); }); }); describe("selectCanActivate", () => { it("allows an activation when nothing is running", () => { expect(selectCanActivate(initialLicenseMachinesState, false)).toBe(true); }); it("blocks the button while an activation is running", () => { expect(selectCanActivate(reduce([{ type: "ACTIVATE_START" }]), false)).toBe(false); }); it("blocks the button while a deactivation is running", () => { // An activation racing the deactivation after a 409 could reach the server // first: the machine-limit message would vanish with 5 calls already spent. const s = reduce( [ { type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] }, { type: "DEACTIVATE_START", machineId: "a" }, ], limitHit, ); expect(selectCanActivate(s, false)).toBe(false); }); it("blocks the button while the list loads", () => { // Otherwise a verify answer landing after a successful activation would // mark as fresh a list that no longer includes this machine. const loading = reduce([{ type: "TOGGLE_LIST" }, { type: "LIST_LOAD_START" }]); expect(selectCanActivate(loading, false)).toBe(false); }); it("blocks the button for a key revoked during the session", () => { // Every attempt would get a 403 and spend the shared rate budget (#347). expect(selectCanActivate(initialLicenseMachinesState, true)).toBe(false); }); }); describe("shouldLoadOnOpen", () => { const loaded = reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }]); it("loads the first time the list opens", () => { expect(shouldLoadOnOpen(initialLicenseMachinesState)).toBe(true); }); it("does not reload a list already loaded without error", () => { expect(shouldLoadOnOpen(loaded)).toBe(false); }); it("retries after a list error", () => { expect( shouldLoadOnOpen(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("network") }])), ).toBe(true); }); it("never loads when closing the list or while a load is running", () => { expect(shouldLoadOnOpen({ ...initialLicenseMachinesState, listOpen: true })).toBe(false); expect(shouldLoadOnOpen(reduce([{ type: "LIST_LOAD_START" }]))).toBe(false); }); }); // Orchestration: the effects are plain functions with injected dependencies, // so the D2 path can be replayed without React or Tauri. Every dispatched // action is folded through the real reducer. function harness(overrides: Partial = {}) { let state = initialLicenseMachinesState; const deps: LicenseMachinesDeps = { activateMachine: vi.fn(async (): Promise> => ({ ok: true, value: undefined })), deactivateMachine: vi.fn(async (): Promise> => ({ ok: true, value: undefined })), listActivatedMachines: vi.fn( async (): Promise> => ({ ok: true, value: listed([]) }), ), getActivationStatus: vi.fn(async () => ({ is_activated: true })), refresh: vi.fn(async () => {}), logInfo: vi.fn(), dispatch: (action) => { state = licenseMachinesReducer(state, action); }, ...overrides, }; const networkCalls = () => vi.mocked(deps.activateMachine).mock.calls.length + vi.mocked(deps.deactivateMachine).mock.calls.length + vi.mocked(deps.listActivatedMachines).mock.calls.length; return { deps, getState: () => state, networkCalls }; } describe("machine-limit path (D2) end to end", () => { it("fits in 4 license-server calls and keeps the message until a slot is freed", async () => { const activateMachine = vi .fn<() => Promise>>() .mockResolvedValueOnce({ ok: false, error: err("machine_limit_reached") }) .mockResolvedValueOnce({ ok: true, value: undefined }); const { deps, getState, networkCalls } = harness({ activateMachine, listActivatedMachines: vi.fn(async () => ({ ok: true as const, value: listed([machine("a"), machine("b"), machine("c")]), })), }); await performActivate(deps); let s = getState(); expect(s.activationError?.code).toBe("machine_limit_reached"); expect(s.listOpen).toBe(true); expect(s.listLoaded).toBe(true); expect(s.machines).toHaveLength(3); expect(deps.refresh).not.toHaveBeenCalled(); await performDeactivate(deps, "b"); s = getState(); expect(s.machines.map((m) => m.machine_id)).toEqual(["a", "c"]); expect(s.activationError).toBeNull(); await performActivate(deps); s = getState(); expect(s.activationError).toBeNull(); // activate (409) + verify + deactivate + activate: under the server's // 5 requests per minute and per IP. expect(networkCalls()).toBe(4); expect(deps.listActivatedMachines).toHaveBeenCalledTimes(1); expect(deps.refresh).toHaveBeenCalledTimes(2); }); }); describe("performActivate", () => { it("reloads the license context after a successful activation, without reloading the list", async () => { const { deps, getState } = harness(); await performActivate(deps); expect(deps.refresh).toHaveBeenCalledTimes(1); expect(deps.getActivationStatus).toHaveBeenCalledTimes(1); expect(deps.listActivatedMachines).not.toHaveBeenCalled(); expect(getState().activation?.is_activated).toBe(true); }); it("still refreshes when the local activation status cannot be read", async () => { const { deps } = harness({ getActivationStatus: vi.fn(async () => { throw new Error("machine id unreadable"); }), }); await expect(performActivate(deps)).resolves.toBeUndefined(); expect(deps.refresh).toHaveBeenCalledTimes(1); }); it("neither loads the list nor refreshes on other refusals", async () => { const { deps, getState } = harness({ activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_expired") })), }); await performActivate(deps); expect(getState().activationError?.code).toBe("license_expired"); expect(deps.listActivatedMachines).not.toHaveBeenCalled(); expect(deps.refresh).not.toHaveBeenCalled(); }); }); describe("performDeactivate", () => { it("removes the machine locally and refreshes, without calling verify", async () => { const { deps, getState } = harness(); await loadMachines({ ...deps, listActivatedMachines: vi.fn(async () => ({ ok: true as const, value: listed([machine("a"), machine("b")]), })), }); await performDeactivate(deps, "a"); expect(getState().machines.map((m) => m.machine_id)).toEqual(["b"]); expect(deps.listActivatedMachines).not.toHaveBeenCalled(); expect(deps.refresh).toHaveBeenCalledTimes(1); }); it("re-reads the local status, so deactivating this machine brings back Activate", async () => { // Read when the card mounts, again once the list has loaded (#367), then after the // deactivation. const getActivationStatus = vi .fn<() => Promise>() .mockResolvedValueOnce({ is_activated: true }) .mockResolvedValueOnce({ is_activated: true }) .mockResolvedValueOnce({ is_activated: false }); const { deps, getState } = harness({ getActivationStatus }); await loadActivationStatus(deps); await loadMachines({ ...deps, listActivatedMachines: vi.fn(async () => ({ ok: true as const, value: listed([machine("this"), machine("b")]), })), }); expect(getState().activation?.is_activated).toBe(true); // Rust deletes this machine's activation token on success; only the // re-read status turns the card back from "Activated" to "Activate". await performDeactivate(deps, "this"); expect(getState().activation?.is_activated).toBe(false); expect(getActivationStatus).toHaveBeenCalledTimes(3); }); it("shows a refusal on the list and releases the busy state", async () => { const { deps, getState } = harness({ deactivateMachine: vi.fn(async () => ({ ok: false as const, error: err("rate_limit", { retry_after_s: 25 }), })), }); await performDeactivate(deps, "a"); const s = getState(); expect(s.listError?.code).toBe("rate_limit"); expect(s.listError?.params).toEqual({ count: 25 }); expect(s.deactivatingId).toBeNull(); expect(deps.refresh).not.toHaveBeenCalled(); }); }); // The Rust command that receives `license_revoked` persists it (#365): each of the three // server calls reloads the LicenseContext, so the edition (Free) and the card follow. describe("a revocation reloads the license context", () => { const revokedResult = { ok: false as const, error: err("license_revoked") }; it("from an activation, without loading the list", async () => { const { deps, getState } = harness({ activateMachine: vi.fn(async () => revokedResult) }); await performActivate(deps); expect(deps.refresh).toHaveBeenCalledTimes(1); expect(deps.listActivatedMachines).not.toHaveBeenCalled(); expect(getState().activationError?.code).toBe("license_revoked"); }); it("from the machine list", async () => { const { deps, getState } = harness({ listActivatedMachines: vi.fn(async () => revokedResult) }); await loadMachines(deps); expect(deps.refresh).toHaveBeenCalledTimes(1); expect(getState().listError?.code).toBe("license_revoked"); }); it("from a deactivation", async () => { const { deps } = harness({ deactivateMachine: vi.fn(async () => revokedResult) }); await performDeactivate(deps, "a"); expect(deps.refresh).toHaveBeenCalledTimes(1); }); it("never from another refusal", async () => { const { deps } = harness({ activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_expired") })), listActivatedMachines: vi.fn(async () => ({ ok: false as const, error: err("network") })), deactivateMachine: vi.fn(async () => ({ ok: false as const, error: err("server", { status: 502 }) })), }); await performActivate(deps); await loadMachines(deps); await performDeactivate(deps, "a"); expect(deps.refresh).not.toHaveBeenCalled(); }); }); // Loading the list may remove this machine's activation token, when the list names no // activation of this machine (#367). The fixed log line is written out here: compared to the // constant, a line swapped for another would pass. const NOT_LISTED_LINE = "License: this machine's activation token was removed, as the license server lists no activation of this machine for the stored key"; describe("a list that removed this machine's token (#367)", () => { /** Records the list call, the status reads, the log lines and the actions, in order. */ function listHarness(answer: LicenseResult, statuses: boolean[]) { const events: string[] = []; const reads = [...statuses]; const { deps, getState } = harness({ listActivatedMachines: vi.fn(async () => { events.push("list"); return answer; }), getActivationStatus: vi.fn(async () => { events.push("status"); return { is_activated: reads.shift() ?? false }; }), logInfo: vi.fn((...args: unknown[]) => { events.push(`log ${args.map(String).join(" ")}`); }), }); const fold = deps.dispatch; deps.dispatch = (action) => { events.push(action.type); fold(action); }; return { deps, getState, events }; } it("reads the activation status again once the list has loaded, so Activate comes back", async () => { const { deps, getState, events } = listHarness( { ok: true, value: listed([machine("other")], "not_listed") }, [true, false], ); await loadActivationStatus(deps); expect(getState().activation?.is_activated).toBe(true); await loadMachines(deps); expect(getState().activation?.is_activated).toBe(false); expect(getState().machines.map((m) => m.machine_id)).toEqual(["other"]); expect(events).toEqual([ "status", "ACTIVATION_STATUS", "LIST_LOAD_START", "list", "LIST_LOAD_DONE", `log ${NOT_LISTED_LINE}`, "status", "ACTIVATION_STATUS", ]); }); it("reads it again after any list, and logs only a token removed", async () => { // A local read, no request: the card follows whatever Rust did. const { deps, events } = listHarness({ ok: true, value: listed([machine("this")]) }, [true]); await loadMachines(deps); expect(events).toEqual(["LIST_LOAD_START", "list", "LIST_LOAD_DONE", "status", "ACTIVATION_STATUS"]); expect(deps.logInfo).not.toHaveBeenCalled(); }); it("neither logs nor reads the status again after a list that failed", async () => { // Rust removes nothing when the list fails. const { deps, events } = listHarness({ ok: false, error: err("network") }, [true]); await loadMachines(deps); expect(events).toEqual(["LIST_LOAD_START", "list", "LIST_LOAD_ERROR"]); expect(deps.logInfo).not.toHaveBeenCalled(); }); }); // The hook glues the pure functions to the LicenseContext: rendered once on the // server, with the context faked, to check both directions of that glue. describe("useLicenseMachines wiring to the LicenseContext", () => { function renderHook(): ReturnType { let view: ReturnType | undefined; function Probe() { view = useLicenseMachines(true); return null; } renderToStaticMarkup(createElement(Probe)); if (!view) throw new Error("the probe did not render"); return view; } beforeEach(() => { context.revoked = false; context.refresh.mockClear(); vi.mocked(invoke).mockReset(); }); it("reads the revocation from the context and blocks Activate", () => { context.revoked = true; const view = renderHook(); expect(view.revoked).toBe(true); expect(view.canActivate).toBe(false); }); it("allows Activate without a revocation", () => { const view = renderHook(); expect(view.revoked).toBe(false); expect(view.canActivate).toBe(true); }); it("hands the context's refresh to the server calls", async () => { vi.mocked(invoke).mockRejectedValue('{"code":"license_revoked"}'); const view = renderHook(); await view.activate(); expect(context.refresh).toHaveBeenCalledTimes(1); }); it("writes a token the list removed to the in-app log, then reads the status (#367)", async () => { // The list loaded by a machine-limit refusal, the one load a test can await here. log.logInfo.mockClear(); vi.mocked(invoke).mockImplementation(async (command: string) => { if (command === "activate_machine") throw '{"code":"machine_limit_reached"}'; if (command === "list_activated_machines") return listed([], "not_listed"); if (command === "get_activation_status") return { is_activated: false }; return null; }); const view = renderHook(); await view.activate(); expect(log.logInfo).toHaveBeenCalledWith(NOT_LISTED_LINE); expect(invoke).toHaveBeenLastCalledWith("get_activation_status"); }); it("removes the key through remove_license, with the context's revocation and refresh", async () => { // The activation status is never loaded here (no effects): not activated, so no release. vi.mocked(invoke).mockResolvedValue(null); log.logInfo.mockClear(); context.revoked = true; const confirm = vi.fn(() => true); const view = renderHook(); expect(view.canRemoveKey).toBe(true); await view.removeKey({ confirm, t: markedT }); expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmRevoked)"); expect(invoke).toHaveBeenCalledWith("remove_license", { releaseSlot: false }); expect(context.refresh).toHaveBeenCalledTimes(1); expect(log.logInfo).toHaveBeenCalledWith("License: the stored key was removed"); }); it("takes a key that is not revoked as such", async () => { vi.mocked(invoke).mockResolvedValue(null); const confirm = vi.fn(() => false); const view = renderHook(); await view.removeKey({ confirm, t: markedT }); expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmValid)"); expect(invoke).not.toHaveBeenCalled(); }); }); // The removal of the stored key (#370). `markedT` marks what it translates, `t(key[options])`, // so each assertion names the message the user would read, and a string that skipped `t` (a raw // key) fails it (#387 review). describe("licenseMachinesReducer — removal of the key", () => { it("marks the removal running, clearing the previous failure", () => { const failed = reduce([{ type: "REMOVE_START" }, { type: "REMOVE_ERROR", error: err("storage") }]); expect(failed.removing).toBe(false); expect(failed.removeError?.code).toBe("storage"); const again = reduce([{ type: "REMOVE_START" }], failed); expect(again.removing).toBe(true); expect(again.removeError).toBeNull(); }); it("ends without an error", () => { const ended = reduce([{ type: "REMOVE_START" }, { type: "REMOVE_END" }]); expect(ended.removing).toBe(false); expect(ended.removeError).toBeNull(); }); }); describe("selectors — removal of the key", () => { const removing = reduce([{ type: "REMOVE_START" }]); it("lets the removal start only when nothing else runs", () => { expect(selectCanRemoveKey(initialLicenseMachinesState)).toBe(true); expect(selectCanRemoveKey(removing)).toBe(false); expect(selectCanRemoveKey(reduce([{ type: "ACTIVATE_START" }]))).toBe(false); expect(selectCanRemoveKey(reduce([{ type: "DEACTIVATE_START", machineId: "a" }]))).toBe(false); }); it("makes Activate and Deactivate wait for a removal", () => { // An activation would write a token next to no key; a deactivation may race the release. expect(selectCanActivate(removing, false)).toBe(false); expect(selectCanDeactivate(removing)).toBe(false); expect(selectCanActivate(reduce([{ type: "REMOVE_END" }], removing), false)).toBe(true); expect(selectCanDeactivate(reduce([{ type: "REMOVE_END" }], removing))).toBe(true); }); it("reads the activation loaded last, none before it is loaded", () => { const activated = (is_activated: boolean) => reduce([{ type: "ACTIVATION_STATUS", activation: { is_activated } }]); expect(selectRemoveLicenseState(initialLicenseMachinesState, false)).toEqual({ isActivated: false, revoked: false, }); expect(selectRemoveLicenseState(activated(false), false)).toEqual({ isActivated: false, revoked: false }); expect(selectRemoveLicenseState(activated(true), false)).toEqual({ isActivated: true, revoked: false }); expect(selectRemoveLicenseState(activated(true), true)).toEqual({ isActivated: true, revoked: true }); }); }); describe("what a removal asks for, by state (D2, D3)", () => { // (state, confirmation, slot released first) const rows: [RemoveLicenseState, string, boolean][] = [ [{ isActivated: false, revoked: false }, "license.removeKey.confirmValid", false], [{ isActivated: true, revoked: false }, "license.removeKey.confirmActivated", true], [{ isActivated: false, revoked: true }, "license.removeKey.confirmRevoked", false], // Under an account Premium, a revoked key next to a token of this machine: the server // freed its slots when it revoked it. [{ isActivated: true, revoked: true }, "license.removeKey.confirmRevoked", false], ]; it.each(rows)("%o asks %s", (state, confirmation, release) => { expect(removeLicenseConfirmKey(state)).toBe(confirmation); expect(releasesSlot(state)).toBe(release); }); }); describe("performRemoveLicense", () => { const removed: LicenseResult = { ok: true, value: undefined }; const failure = (code: LicenseErrorCode, extra: Record = {}): LicenseResult => ({ ok: false, error: err(code, extra), }); /** Answers the confirmations in order, then the calls of remove_license in order. */ function removalHarness(answers: boolean[], results: LicenseResult[]) { let state = initialLicenseMachinesState; // Confirmations, calls and reloads in the order they happen. const events: string[] = []; const confirmations = [...answers]; const outcomes = [...results]; const deps: RemoveLicenseDeps = { confirm: vi.fn((message: string) => { events.push(`confirm ${message}`); return confirmations.shift() ?? false; }), t: markedT, removeLicense: vi.fn(async (releaseSlot: boolean) => { events.push(`remove(${releaseSlot})`); return outcomes.shift() ?? removed; }), refresh: vi.fn(async () => { events.push("refresh"); }), logInfo: vi.fn(), logWarn: vi.fn(), dispatch: (action) => { state = licenseMachinesReducer(state, action); events.push(action.type); }, }; return { deps, events, getState: () => state }; } const valid: RemoveLicenseState = { isActivated: false, revoked: false }; const activated: RemoveLicenseState = { isActivated: true, revoked: false }; const revoked: RemoveLicenseState = { isActivated: false, revoked: true }; it("calls nothing when the confirmation is refused", async () => { const { deps, events, getState } = removalHarness([false], []); await performRemoveLicense(deps, activated); expect(events).toEqual(["confirm t(license.removeKey.confirmActivated)"]); expect(deps.removeLicense).not.toHaveBeenCalled(); expect(deps.refresh).not.toHaveBeenCalled(); expect(deps.logInfo).not.toHaveBeenCalled(); expect(deps.logWarn).not.toHaveBeenCalled(); expect(getState()).toEqual(initialLicenseMachinesState); }); it("removes a valid key, logs it, then reloads the license context", async () => { const { deps, events, getState } = removalHarness([true], [removed]); await performRemoveLicense(deps, valid); expect(events).toEqual([ "confirm t(license.removeKey.confirmValid)", "REMOVE_START", "remove(false)", "refresh", "REMOVE_END", ]); expect(deps.logInfo).toHaveBeenCalledTimes(1); expect(deps.logInfo).toHaveBeenCalledWith("License: the stored key was removed"); expect(deps.logWarn).not.toHaveBeenCalled(); expect(getState().removing).toBe(false); expect(getState().removeError).toBeNull(); }); it("frees the slot of an activated machine first", async () => { const { deps, events } = removalHarness([true], [removed]); await performRemoveLicense(deps, activated); expect(events).toEqual([ "confirm t(license.removeKey.confirmActivated)", "REMOVE_START", "remove(true)", "refresh", "REMOVE_END", ]); expect(deps.logInfo).toHaveBeenCalledWith( "License: the stored key was removed, after this machine's slot was freed on the license server", ); }); it("removes a revoked key without a release", async () => { const { deps, events } = removalHarness([true], [removed]); await performRemoveLicense(deps, revoked); expect(events.slice(0, 3)).toEqual([ "confirm t(license.removeKey.confirmRevoked)", "REMOVE_START", "remove(false)", ]); }); // Error table, second row: the server could not be reached or answered badly. Rust removed // nothing; the second confirmation names the reason and warns that the slot stays occupied. const retryable: [LicenseErrorCode, Record, string][] = [ ["network", {}, "t(license.errors.network)"], ["server", { status: 502 }, "t(license.errors.server)"], ["rate_limit", { retry_after_s: 42 }, "t(license.errors.rateLimit[count=42])"], ["invalid_response", {}, "t(license.errors.invalidResponse)"], ]; it.each(retryable)( "after a release that failed with %s, asks again and removes the key without it", async (code, extra, reason) => { const { deps, events, getState } = removalHarness([true, true], [failure(code, extra), removed]); await performRemoveLicense(deps, activated); expect(events).toEqual([ "confirm t(license.removeKey.confirmActivated)", "REMOVE_START", "remove(true)", `confirm t(license.removeKey.confirmWithoutRelease[reason=${reason}])`, "remove(false)", "refresh", "REMOVE_END", ]); expect(deps.logWarn).toHaveBeenCalledTimes(1); expect(deps.logWarn).toHaveBeenCalledWith( "License: this machine's slot could not be freed on the license server", code, ); expect(deps.logInfo).toHaveBeenCalledTimes(1); expect(deps.logInfo).toHaveBeenCalledWith( "License: the stored key was removed; this machine's slot stays occupied on the license server", ); expect(getState().removeError).toBeNull(); }, ); it.each(retryable)("after a release that failed with %s, keeps the key when asked to", async (code, extra) => { const { deps, events, getState } = removalHarness([true, false], [failure(code, extra)]); await performRemoveLicense(deps, activated); expect(events.slice(-2)).toEqual([expect.stringContaining("confirmWithoutRelease"), "REMOVE_END"]); expect(deps.removeLicense).toHaveBeenCalledTimes(1); expect(deps.refresh).not.toHaveBeenCalled(); expect(deps.logInfo).not.toHaveBeenCalled(); expect(getState().removing).toBe(false); expect(getState().removeError).toBeNull(); }); // Every other failure is named on the card, with no "remove anyway": nothing to drop. const named: LicenseErrorCode[] = [ "storage", "invalid_input", "license_expired", "machine_limit_reached", "no_license_stored", "internal", "unknown", ]; it.each(named)("names a failure with %s, without asking again", async (code) => { const { deps, events, getState } = removalHarness([true], [failure(code)]); await performRemoveLicense(deps, activated); expect(events).toEqual([ "confirm t(license.removeKey.confirmActivated)", "REMOVE_START", "remove(true)", "refresh", "REMOVE_ERROR", ]); expect(getState().removeError?.code).toBe(code); expect(getState().removing).toBe(false); expect(deps.refresh).toHaveBeenCalledTimes(1); expect(deps.logInfo).not.toHaveBeenCalled(); expect(deps.logWarn).toHaveBeenCalledWith("License: the stored key could not be removed", code); }); it("never asks again when no release was asked", async () => { // Without a release Rust sends nothing; a network code then means nothing to drop. const { deps, events, getState } = removalHarness([true, true], [failure("network")]); await performRemoveLicense(deps, valid); expect(events).toEqual([ "confirm t(license.removeKey.confirmValid)", "REMOVE_START", "remove(false)", "refresh", "REMOVE_ERROR", ]); expect(getState().removeError?.code).toBe("network"); }); it("names a removal that fails after the slot was given up", async () => { const { deps, events, getState } = removalHarness([true, true], [failure("network"), failure("storage")]); await performRemoveLicense(deps, activated); expect(events.slice(-3)).toEqual(["remove(false)", "refresh", "REMOVE_ERROR"]); expect(getState().removeError?.code).toBe("storage"); expect(deps.refresh).toHaveBeenCalledTimes(1); expect(deps.logWarn).toHaveBeenLastCalledWith("License: the stored key could not be removed", "storage"); }); it("reloads the license context after a failure, before naming it", async () => { // Rust deletes the key first: a token that resisted leaves the key gone, and the card must // follow the disk rather than keep the key under "not removed" (#387 review). const { deps, events, getState } = removalHarness([true], [failure("storage")]); await performRemoveLicense(deps, valid); expect(events.slice(-2)).toEqual(["refresh", "REMOVE_ERROR"]); expect(deps.refresh).toHaveBeenCalledTimes(1); expect(getState().removeError?.code).toBe("storage"); }); });