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>
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
// Render tests for the License card (#347). No jsdom needed: react-dom/server
|
|
// renders in vitest's node environment, and the license context and machine
|
|
// hook are replaced by fakes so each test pins one visible state. The fake
|
|
// hook computes its flags with the real selectors.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { ReactElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
|
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
|
|
|
|
let language = "fr";
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (k: string, o?: Record<string, unknown>) =>
|
|
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
|
i18n: { language },
|
|
}),
|
|
}));
|
|
|
|
let context: Record<string, unknown>;
|
|
vi.mock("../../contexts/LicenseContext", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../../contexts/LicenseContext")>()),
|
|
useLicenseContext: () => context,
|
|
}));
|
|
|
|
let machines: Record<string, unknown>;
|
|
vi.mock("../../hooks/useLicenseMachines", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../../hooks/useLicenseMachines")>()),
|
|
useLicenseMachines: () => machines,
|
|
}));
|
|
|
|
import LicenseCard from "./LicenseCard";
|
|
import { licenseIdentity } from "../../contexts/LicenseContext";
|
|
import {
|
|
initialLicenseMachinesState,
|
|
selectCanActivate,
|
|
selectCanDeactivate,
|
|
selectShowEmptyState,
|
|
type LicenseMachinesState,
|
|
} from "../../hooks/useLicenseMachines";
|
|
import { parseLicenseError, type LicenseInfo, type MachineInfo } from "../../services/licenseService";
|
|
|
|
const DAY_S = 86400;
|
|
const nowS = () => Math.floor(Date.now() / 1000);
|
|
// Noon UTC, so the calendar day is the same in UTC (CI) and America/Toronto.
|
|
const TEN_YEAR_EXPIRY_S = Date.UTC(2036, 8, 26, 12) / 1000;
|
|
|
|
const machine = (id: string, isThisMachine = false): MachineInfo => ({
|
|
machine_id: id,
|
|
machine_name: `host-${id}`,
|
|
activated_at: "2026-09-01T12:00:00Z",
|
|
last_seen_at: "2026-09-20T12:00:00Z",
|
|
is_this_machine: isThisMachine,
|
|
});
|
|
|
|
/** What the real hook returns for `state`, with its real selectors. */
|
|
function hookView(partial: Partial<LicenseMachinesState>, revoked = false) {
|
|
const state: LicenseMachinesState = { ...initialLicenseMachinesState, ...partial };
|
|
return {
|
|
state,
|
|
revoked,
|
|
activate: vi.fn(),
|
|
deactivate: vi.fn(),
|
|
toggleList: vi.fn(),
|
|
showEmptyState: selectShowEmptyState(state),
|
|
canDeactivate: selectCanDeactivate(state),
|
|
canActivate: selectCanActivate(state, revoked),
|
|
};
|
|
}
|
|
|
|
const notActivated = { is_activated: false };
|
|
const loadedList = (list: MachineInfo[]) => ({
|
|
listOpen: true,
|
|
listLoaded: true,
|
|
machines: list,
|
|
});
|
|
|
|
const render = () => renderToStaticMarkup(<LicenseCard />);
|
|
|
|
beforeEach(() => {
|
|
language = "fr";
|
|
context = {
|
|
status: "ready",
|
|
edition: "base",
|
|
features: [],
|
|
info: {
|
|
edition: "base",
|
|
email: "user@example.com",
|
|
features: [],
|
|
machine_limit: 3,
|
|
issued_at: nowS(),
|
|
expires_at: TEN_YEAR_EXPIRY_S,
|
|
revoked: false,
|
|
},
|
|
error: null,
|
|
validating: false,
|
|
validationError: null,
|
|
revoked: false,
|
|
refresh: vi.fn(),
|
|
submitKey: vi.fn(),
|
|
};
|
|
machines = hookView({});
|
|
});
|
|
|
|
describe("LicenseCard load error", () => {
|
|
it("names a serialized LicenseError instead of printing it", () => {
|
|
context = { ...context, status: "error", error: '{"code":"storage"}' };
|
|
const html = render();
|
|
expect(html).toContain("license.errors.storage");
|
|
expect(html).not.toContain("{"code"");
|
|
});
|
|
|
|
it("never prints a raw backend string, even one that is not a LicenseError", () => {
|
|
context = { ...context, status: "error", error: "Cannot read license file: permission denied" };
|
|
const html = render();
|
|
expect(html).toContain("license.errors.unknown");
|
|
expect(html).not.toContain("Cannot read license file");
|
|
});
|
|
});
|
|
|
|
// The main case since #365: Rust persists the revocation and resolves the edition of a
|
|
// revoked key to Free. The card says why, and offers a way out.
|
|
describe("LicenseCard revoked key (Free edition)", () => {
|
|
beforeEach(() => {
|
|
context = {
|
|
...context,
|
|
edition: "free",
|
|
revoked: true,
|
|
info: { ...(context.info as LicenseInfo), revoked: true },
|
|
};
|
|
machines = hookView({}, true);
|
|
});
|
|
|
|
it("shows the Free edition, names the revocation once, and offers to buy or enter a key", () => {
|
|
const html = render();
|
|
expect(html).toContain("license.editions.free");
|
|
expect(html.split("license.errors.licenseRevoked")).toHaveLength(2);
|
|
expect(html).toContain("license.purchase");
|
|
expect(html).toContain("license.enterKey");
|
|
});
|
|
|
|
it("shows no expiry date: it would read as a date until which the key works", () => {
|
|
const html = render();
|
|
expect(html).not.toContain("license.expiresAt");
|
|
expect(html).not.toContain("license.noExpiry");
|
|
expect(html).not.toContain("2036-09-26");
|
|
});
|
|
|
|
it("shows neither the Machines section nor the edition's green check", () => {
|
|
const html = render();
|
|
expect(html).not.toContain("license.machines.title");
|
|
expect(html).not.toContain("license.activate");
|
|
expect(html).not.toContain("lucide-circle-check-big");
|
|
});
|
|
|
|
it("keeps the expiry date of a key that is not revoked", () => {
|
|
context = {
|
|
...context,
|
|
edition: "base",
|
|
revoked: false,
|
|
info: { ...(context.info as LicenseInfo), revoked: false },
|
|
};
|
|
machines = hookView({});
|
|
const html = render();
|
|
expect(html).toContain("license.expiresAt");
|
|
expect(html).toContain("2036-09-26");
|
|
expect(html).not.toContain("license.errors.licenseRevoked");
|
|
});
|
|
});
|
|
|
|
// The corner that keeps a revoked key under a paid edition: Premium coming from the
|
|
// Maximus account while a revoked key is stored. The Machines section is then still shown.
|
|
describe("LicenseCard revoked key under an account Premium", () => {
|
|
beforeEach(() => {
|
|
context = {
|
|
...context,
|
|
edition: "premium",
|
|
revoked: true,
|
|
info: { ...(context.info as LicenseInfo), revoked: true },
|
|
};
|
|
});
|
|
|
|
it("names the revocation at card level with the list closed, and blocks Activate", () => {
|
|
machines = hookView({ activation: notActivated }, true);
|
|
const html = render();
|
|
expect(html).toContain("license.errors.licenseRevoked");
|
|
expect(html).toContain("license.activate");
|
|
expect(html).toContain('disabled=""');
|
|
});
|
|
|
|
it("keeps Activate live without a revocation", () => {
|
|
machines = hookView({ activation: notActivated });
|
|
expect(render()).not.toContain('disabled=""');
|
|
});
|
|
|
|
it("swaps the edition's green check for an alert", () => {
|
|
// Machine not activated: the "Activated" badge carries the same check icon.
|
|
machines = hookView({ activation: notActivated }, true);
|
|
expect(render()).not.toContain("lucide-circle-check-big");
|
|
|
|
machines = hookView({ activation: notActivated });
|
|
expect(render()).toContain("lucide-circle-check-big");
|
|
});
|
|
|
|
it("hides the Activated badge: the server deactivated every machine of a revoked key", () => {
|
|
const activated = { is_activated: true };
|
|
machines = hookView({ activation: activated }, true);
|
|
expect(render()).not.toContain("license.machines.activated");
|
|
|
|
machines = hookView({ activation: activated });
|
|
expect(render()).toContain("license.machines.activated");
|
|
});
|
|
|
|
it("does not repeat the revocation under Machines", () => {
|
|
const revokedError = parseLicenseError('{"code":"license_revoked"}');
|
|
machines = hookView(
|
|
{ activation: notActivated, activationError: revokedError, listOpen: true, listError: revokedError },
|
|
true,
|
|
);
|
|
expect(render().split("license.errors.licenseRevoked")).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("LicenseCard machines", () => {
|
|
it("keeps the other errors visible under Machines", () => {
|
|
// Only the revocation moves to card level: the machine-limit message of the D2 path
|
|
// (#270) and a failed list load still show where they happen.
|
|
machines = hookView({
|
|
activation: notActivated,
|
|
activationError: parseLicenseError('{"code":"machine_limit_reached"}'),
|
|
});
|
|
expect(render()).toContain("license.errors.machineLimitReached");
|
|
|
|
machines = hookView({ listOpen: true, listError: parseLicenseError('{"code":"network"}') });
|
|
expect(render()).toContain("license.errors.network");
|
|
});
|
|
|
|
it("counts the activated machines against the key's limit", () => {
|
|
machines = hookView(loadedList([machine("a"), machine("b")]));
|
|
expect(render()).toContain("license.machines.count[count=2,limit=3]");
|
|
});
|
|
|
|
it("shows no count without a stored key (account Premium)", () => {
|
|
context = { ...context, edition: "premium", info: null };
|
|
machines = hookView(loadedList([machine("a")]));
|
|
expect(render()).not.toContain("license.machines.count");
|
|
});
|
|
|
|
// The backend decides which rows name this machine (#350): it knows both forms of the
|
|
// identifier, the webview knows neither.
|
|
const THIS_MACHINE = "(license.machines.thisMachine)";
|
|
/** The row of `id`: from its host name up to its Deactivate button. */
|
|
const rowOf = (html: string, id: string) => {
|
|
const start = html.indexOf(`host-${id}`);
|
|
expect(start).toBeGreaterThan(-1);
|
|
return html.slice(start, html.indexOf("license.machines.deactivate", start));
|
|
};
|
|
|
|
it("marks the row the backend flags as this machine, and only that row", () => {
|
|
machines = hookView(loadedList([machine("a"), machine("b", true), machine("c")]));
|
|
const html = render();
|
|
expect(rowOf(html, "b")).toContain(THIS_MACHINE);
|
|
expect(rowOf(html, "a")).not.toContain(THIS_MACHINE);
|
|
expect(rowOf(html, "c")).not.toContain(THIS_MACHINE);
|
|
expect(html.split(THIS_MACHINE)).toHaveLength(2);
|
|
});
|
|
|
|
it("marks every flagged row: an old activation of this machine next to its new one", () => {
|
|
machines = hookView(loadedList([machine("old", true), machine("new", true), machine("c")]));
|
|
expect(render().split(THIS_MACHINE)).toHaveLength(3);
|
|
});
|
|
|
|
it("marks no row when none is flagged, whatever the activation status", () => {
|
|
for (const activation of [null, notActivated, { is_activated: true }]) {
|
|
machines = hookView({ ...loadedList([machine("a"), machine("b")]), activation });
|
|
expect(render()).not.toContain(THIS_MACHINE);
|
|
}
|
|
// And the other way round: a flagged row is marked without any activation status.
|
|
machines = hookView({ ...loadedList([machine("a", true)]), activation: null });
|
|
expect(render()).toContain(THIS_MACHINE);
|
|
});
|
|
|
|
it("lets an empty list say so on its own line", () => {
|
|
machines = hookView(loadedList([]));
|
|
const html = render();
|
|
expect(html).toContain("license.machines.noMachines");
|
|
expect(html).not.toContain("license.machines.count");
|
|
});
|
|
|
|
it("marks this machine as not activated next to Activate", () => {
|
|
machines = hookView({ activation: notActivated });
|
|
const html = render();
|
|
expect(html).toContain("license.machines.notActivated");
|
|
expect(html).toContain("license.activate");
|
|
});
|
|
|
|
it("locks key entry while an activation runs, and Activate while a key is stored", () => {
|
|
// Either order would leave the previous key's token next to the new key (#364 review).
|
|
const enterKeyDisabled = /<button[^>]*disabled=""[^>]*>license\.enterKey<\/button>/;
|
|
const activateDisabled = /<button[^>]*disabled=""[^>]*>license\.activate<\/button>/;
|
|
|
|
machines = hookView({ activation: notActivated, activating: true });
|
|
expect(render()).toMatch(enterKeyDisabled);
|
|
|
|
machines = hookView({ activation: notActivated });
|
|
context = { ...context, validating: true };
|
|
expect(render()).toMatch(activateDisabled);
|
|
|
|
context = { ...context, validating: false };
|
|
const idle = render();
|
|
expect(idle).not.toMatch(enterKeyDisabled);
|
|
expect(idle).not.toMatch(activateDisabled);
|
|
});
|
|
|
|
it("labels the buttons while an activation or a deactivation runs", () => {
|
|
machines = hookView({ activation: notActivated, activating: true });
|
|
expect(render()).toContain("license.machines.activating");
|
|
|
|
machines = hookView({ ...loadedList([machine("a")]), deactivatingId: "a" });
|
|
expect(render()).toContain("license.machines.deactivating");
|
|
});
|
|
});
|
|
|
|
describe("LicenseCard dates", () => {
|
|
it("shows no expiry date for a 100-year key", () => {
|
|
context = {
|
|
...context,
|
|
info: { ...(context.info as object), expires_at: nowS() + 36525 * DAY_S },
|
|
};
|
|
const html = render();
|
|
expect(html).toContain("license.noExpiry");
|
|
expect(html).not.toContain("license.expiresAt");
|
|
});
|
|
|
|
it.each([
|
|
["fr", "fr-CA"],
|
|
["en", "en-CA"],
|
|
])("formats every date for the app language (%s → %s)", (lng, locale) => {
|
|
language = lng;
|
|
machines = hookView(loadedList([machine("a")]));
|
|
const spy = vi.spyOn(Date.prototype, "toLocaleDateString");
|
|
try {
|
|
const html = render();
|
|
const locales = spy.mock.calls.map((call) => call[0]);
|
|
// The expiry date and the machine's activation date.
|
|
expect(locales).toHaveLength(2);
|
|
expect(locales).toEqual([locale, locale]);
|
|
expect(html).toContain("license.expiresAt");
|
|
expect(html).toContain("2036-09-26");
|
|
expect(html).toContain("2026-09-01");
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("LicenseCard key change", () => {
|
|
it("remounts for another key, so the previous key's activation and machines are dropped", () => {
|
|
const first = LicenseCard() as ReactElement;
|
|
expect(first.key).toBe(licenseIdentity(context.info as LicenseInfo));
|
|
|
|
context = { ...context, info: { ...(context.info as LicenseInfo), issued_at: 1 } };
|
|
const second = LicenseCard() as ReactElement;
|
|
expect(second.key).not.toBe(first.key);
|
|
});
|
|
});
|