Simpl-Resultat/src/components/settings/LicenseCard.render.test.tsx
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

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("{&quot;code&quot;");
});
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);
});
});