Follow-ups of the three /pr-review passes on #386, #387 and #388, applied on the stack head before the fast-forward merge: - #386: pin the relay of the revoked state by revocationCheckLauncher (a launcher passing false instead of wasRevoked passed the whole suite), and say in the rate budget comment that two checks in one minute bring the machine-limit path to 6 requests. - #387 S1: the fake `t` of the removal tests and of the License card tests marks what it translates, so a string that skipped `t` fails its test. - #387 S2: pin that storing another key stops on Storage while the previous token cannot be dropped, the previous key staying. - #387 S3: reload the license context after a failed removal too. Rust deletes the key first, so a token that resisted left the key gone while the card kept showing it under "the key was not removed". - #388: pin that an unreadable token stays (the directory case could not tell, its deletion fails anyway). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
17 KiB
TypeScript
449 lines
17 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";
|
|
// The fake `t` marks what it translates, `t(key[options])`: the assertions written in that form
|
|
// fail on a string that skipped `t` (#387 review). Substring assertions on a bare key still read
|
|
// the key inside the mark.
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (k: string, o?: Record<string, unknown>) =>
|
|
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${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,
|
|
selectCanRemoveKey,
|
|
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(),
|
|
removeKey: vi.fn(),
|
|
showEmptyState: selectShowEmptyState(state),
|
|
canDeactivate: selectCanDeactivate(state),
|
|
canActivate: selectCanActivate(state, revoked),
|
|
canRemoveKey: selectCanRemoveKey(state),
|
|
};
|
|
}
|
|
|
|
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=""[^>]*>t\(license\.enterKey\)<\/button>/;
|
|
const activateDisabled = /<button[^>]*disabled=""[^>]*>t\(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");
|
|
});
|
|
});
|
|
|
|
// "Remove the key" shows for any stored key, valid or revoked (#370): `hasLicense` (a paid
|
|
// edition) would hide it for a revoked key, whose daily check is what the removal stops.
|
|
describe("LicenseCard key removal (#370)", () => {
|
|
const REMOVE = "t(license.removeKey.button)";
|
|
const removeDisabled = /<button[^>]*disabled=""[^>]*>t\(license\.removeKey\.button\)<\/button>/;
|
|
const enterKeyDisabled = /<button[^>]*disabled=""[^>]*>t\(license\.enterKey\)<\/button>/;
|
|
const activateDisabled = /<button[^>]*disabled=""[^>]*>t\(license\.activate\)<\/button>/;
|
|
const deactivateDisabled = /<button[^>]*disabled=""[^>]*>t\(license\.machines\.deactivate\)<\/button>/;
|
|
|
|
it("is offered for a valid key", () => {
|
|
const html = render();
|
|
expect(html).toContain(REMOVE);
|
|
expect(html).not.toMatch(removeDisabled);
|
|
});
|
|
|
|
it("is offered for a revoked key in the Free edition", () => {
|
|
context = {
|
|
...context,
|
|
edition: "free",
|
|
revoked: true,
|
|
info: { ...(context.info as LicenseInfo), revoked: true },
|
|
};
|
|
machines = hookView({}, true);
|
|
const html = render();
|
|
expect(html).toContain(REMOVE);
|
|
expect(html).not.toMatch(removeDisabled);
|
|
});
|
|
|
|
it("is not offered without a stored key, account Premium included", () => {
|
|
context = { ...context, edition: "free", info: null };
|
|
expect(render()).not.toContain("license.removeKey");
|
|
|
|
context = { ...context, edition: "premium", info: null };
|
|
expect(render()).not.toContain("license.removeKey");
|
|
});
|
|
|
|
it("waits for a key being stored, an activation or a deactivation", () => {
|
|
context = { ...context, validating: true };
|
|
expect(render()).toMatch(removeDisabled);
|
|
|
|
context = { ...context, validating: false };
|
|
machines = hookView({ activation: notActivated, activating: true });
|
|
expect(render()).toMatch(removeDisabled);
|
|
|
|
machines = hookView({ ...loadedList([machine("a")]), deactivatingId: "a" });
|
|
expect(render()).toMatch(removeDisabled);
|
|
});
|
|
|
|
it("while it runs, says so, and key entry, Activate and Deactivate wait", () => {
|
|
machines = hookView({ activation: notActivated, removing: true });
|
|
let html = render();
|
|
expect(html).toContain("t(license.removeKey.removing)");
|
|
expect(html).not.toContain(REMOVE);
|
|
expect(html).toMatch(enterKeyDisabled);
|
|
expect(html).toMatch(activateDisabled);
|
|
|
|
machines = hookView({ ...loadedList([machine("a")]), removing: true });
|
|
html = render();
|
|
expect(html).toMatch(deactivateDisabled);
|
|
|
|
// Idle again: nothing waits on it.
|
|
machines = hookView({ ...loadedList([machine("a")]), activation: notActivated });
|
|
html = render();
|
|
expect(html).not.toMatch(enterKeyDisabled);
|
|
expect(html).not.toMatch(activateDisabled);
|
|
expect(html).not.toMatch(deactivateDisabled);
|
|
});
|
|
|
|
it("names a failed removal with its reason", () => {
|
|
machines = hookView({ removeError: parseLicenseError('{"code":"storage"}') });
|
|
const html = render();
|
|
expect(html).toContain("t(license.removeKey.failed[reason=t(license.errors.storage)])");
|
|
expect(html.split("license.removeKey.failed")).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|