A refunded license was revoked on the server but kept working in the app: the edition is resolved offline from the key alone, and the flag added by #347 lasted the session only and never touched the edition. Rust - license.check, next to the key, holds what the license server last said about it: {key_sha256, attempted_at, revoked}. Never the key itself. A missing, unreadable or invalid file, or a state about another key, reads as "no state": not revoked, check due. - entitlements_from_dir resolves a revoked key to ("free", []), which also locks auto-update. read_license and store_license report the state. - check_license_revocation: POST /v1/licenses/verify with the product and the key only. Nothing is sent without a stored key that validates, nor within 24 h of the last attempt, which is written before the request. Fail-open: only "valid" lifts the state, only "revoked" sets it. - Activation, machine list and deactivation record a license_revoked answer under the key they sent. - fetch_price reads error.code of a 403 and names license_revoked instead of premium_required. It records nothing: the price route judges the license of the activation token, which the client cannot tie to the stored key, and 0.15.0 left the token of a previous key next to a new one (#367). Front - LicenseContext takes the revoked state from Rust (info.revoked); the session flag and markRevoked are gone. The check starts once per app start, writes its outcome to the in-app log, and reloads the context only when the state changed. A revoked key entered again stays Free. - UpsellGate and the License card name the revocation. Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and its bounds), architecture, user guide, price contract annex, changelogs. Refs #365 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
19 KiB
TypeScript
518 lines
19 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,
|
|
shouldLoadOnOpen,
|
|
performActivate,
|
|
performDeactivate,
|
|
loadMachines,
|
|
loadActivationStatus,
|
|
useLicenseMachines,
|
|
type LicenseMachinesAction,
|
|
type LicenseMachinesDeps,
|
|
type LicenseMachinesState,
|
|
} from "./useLicenseMachines";
|
|
import {
|
|
parseLicenseError,
|
|
type ActivationStatus,
|
|
type LicenseError,
|
|
type LicenseErrorCode,
|
|
type LicenseResult,
|
|
type MachineInfo,
|
|
} from "../services/licenseService";
|
|
|
|
// 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() }));
|
|
|
|
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,
|
|
});
|
|
|
|
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<MachineInfo[]>> => ({ ok: true, value: [] }),
|
|
),
|
|
getActivationStatus: vi.fn(async () => ({ is_activated: true })),
|
|
refresh: vi.fn(async () => {}),
|
|
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: [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: [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 () => {
|
|
const getActivationStatus = vi
|
|
.fn<() => Promise<ActivationStatus>>()
|
|
.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: [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(2);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|