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>
933 lines
37 KiB
TypeScript
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");
|
|
});
|
|
});
|