Simpl-Resultat/src/hooks/useLicenseMachines.test.ts
le king fu 69ef92e65f test(i18n): add the shared marked fake t and use it in useLicenseMachines
src/test/i18nMock.ts holds the one fake t of the suite: markedT renders
t(key) or t(key[a=1,b=2]), and i18nMock({ language?, fixture? }) builds
the react-i18next module for vi.mock. The local echo of
useLicenseMachines.test.ts is replaced by markedT.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:49:44 -04:00

933 lines
37 KiB
TypeScript

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<string, unknown> = {}): 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<LicenseMachinesDeps> = {}) {
let state = initialLicenseMachinesState;
const deps: LicenseMachinesDeps = {
activateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
deactivateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
listActivatedMachines: vi.fn(
async (): Promise<LicenseResult<MachineList>> => ({ 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<LicenseResult<void>>>()
.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<ActivationStatus>>()
.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<MachineList>, 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<typeof useLicenseMachines> {
let view: ReturnType<typeof useLicenseMachines> | 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<void> = { ok: true, value: undefined };
const failure = (code: LicenseErrorCode, extra: Record<string, unknown> = {}): LicenseResult<void> => ({
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<void>[]) {
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, unknown>, 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");
});
});