Simpl-Resultat/src/hooks/useLicenseMachines.test.ts
le king fu e24c30fe6a feat(license): online revocation check — a revoked key falls back to Free (#365)
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>
2026-10-02 14:45:34 -04:00

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);
});
});