Simpl-Resultat/src/contexts/LicenseContext.test.ts
le king fu d2ded515fd
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m11s
fix(license): name key-entry errors, keep the edition off the activation token, flag revoked keys
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
  UnknownEdition. validate_with_key returns it (ExpiredSignature maps
  to LicenseExpired), and store_license, read_license and
  validate_license_key reject with the same JSON as the network
  commands. store_license_in and read_license_in take a directory, so
  the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
  resolve_license_entitlements no longer takes the activation token,
  and current_entitlements reads only license.key
  (entitlements_from_dir). A token that had expired (2-year server
  lifetime) or came from another machine (restored data) used to drop
  a valid key to free, while a key copied without its token kept its
  edition. get_activation_status and persist_activation_token keep the
  strict token validation, and the key keeps its strict validation
  (CWE-613).

Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
  static render reaches it) names a refused key, through
  parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
  whole session, because the card unmounts when the user leaves
  Settings > Users. Only a different key clears it. Card-level
  message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
  deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
  beyond 50 years (licenseExpiry.ts, for the 100-year keys of
  maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
  the price authFailed message points to activating the machine.

Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).

Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.

Fixes #347
Fixes #360
Refs #363

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:39:01 -04:00

154 lines
5.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { licenseReducer, initialLicenseState } from "./LicenseContext";
import type { Edition, LicenseInfo } from "../services/licenseService";
const makeInfo = (edition: Exclude<Edition, "free">): LicenseInfo => ({
edition,
email: "max@example.com",
features: [],
machine_limit: 3,
issued_at: 1,
expires_at: 0,
});
// The CWE-703 retry loop arms exclusively on `status === "error"`, and its
// LOAD_START clears `error`. A rejected key must therefore never re-enter the
// load lifecycle: it would arm the auto refresh, which would wipe the very
// message the user is reading (~1s after submitting an invalid key).
const premiumReady = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
);
describe("licenseReducer — load lifecycle (CWE-703)", () => {
it("LOAD_ERROR preserves the last-known edition and info", () => {
const errored = licenseReducer(premiumReady, { type: "LOAD_ERROR", error: "ipc down" });
expect(errored.status).toBe("error");
expect(errored.error).toBe("ipc down");
expect(errored.edition).toBe("premium");
expect(errored.info).toEqual(makeInfo("premium"));
});
it("LOAD_START clears the load error but not a pending validation error", () => {
const rejected = licenseReducer(
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
const refreshed = licenseReducer(rejected, { type: "LOAD_START" });
expect(refreshed.status).toBe("loading");
expect(refreshed.error).toBeNull();
expect(refreshed.validationError).toBe("invalid key");
});
});
describe("licenseReducer — key validation is orthogonal to the load lifecycle", () => {
it("VALIDATE_ERROR on a ready license keeps status ready (retry never arms)", () => {
const validating = licenseReducer(premiumReady, { type: "VALIDATE_START" });
expect(validating.status).toBe("ready");
expect(validating.validating).toBe(true);
const rejected = licenseReducer(validating, { type: "VALIDATE_ERROR", error: "invalid key" });
expect(rejected.status).toBe("ready");
expect(rejected.validating).toBe(false);
expect(rejected.validationError).toBe("invalid key");
expect(rejected.edition).toBe("premium");
expect(rejected.info).toEqual(makeInfo("premium"));
});
it("VALIDATE_ERROR during a boot error keeps status error (load retry keeps running)", () => {
const bootError = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_ERROR", error: "boot fail" },
);
const rejected = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
expect(rejected.status).toBe("error");
expect(rejected.error).toBe("boot fail");
expect(rejected.validationError).toBe("invalid key");
expect(rejected.edition).toBe("free");
});
it("VALIDATE_START clears the previous validation error", () => {
const rejected = licenseReducer(
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
const resubmit = licenseReducer(rejected, { type: "VALIDATE_START" });
expect(resubmit.validating).toBe(true);
expect(resubmit.validationError).toBeNull();
});
it("VALIDATE_DONE resets the full state, recovering from a boot error", () => {
const bootError = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_ERROR", error: "boot fail" },
);
const done = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
{ type: "VALIDATE_DONE", info: makeInfo("base") },
);
expect(done).toEqual({
status: "ready",
edition: "base",
info: makeInfo("base"),
error: null,
validating: false,
validationError: null,
revoked: false,
});
});
});
// A `license_revoked` answer stays known for the whole session (#347, D13): the card
// that receives it unmounts when the user leaves Settings → Users or switches profile,
// this state does not. Only a different key clears it.
describe("licenseReducer — revocation lasts the session", () => {
const revoked = licenseReducer(premiumReady, { type: "LICENSE_REVOKED" });
it("LICENSE_REVOKED flags the stored key without touching the edition", () => {
expect(revoked.revoked).toBe(true);
expect(revoked.edition).toBe("premium");
expect(revoked.status).toBe("ready");
});
it("survives a reload of the license and a load error", () => {
const reloaded = licenseReducer(
licenseReducer(revoked, { type: "LOAD_START" }),
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
);
expect(reloaded.revoked).toBe(true);
const failed = licenseReducer(licenseReducer(revoked, { type: "LOAD_START" }), {
type: "LOAD_ERROR",
error: "boot fail",
});
expect(failed.revoked).toBe(true);
});
it("survives a refused key", () => {
const refused = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
type: "VALIDATE_ERROR",
error: '{"code":"invalid_prefix"}',
});
expect(refused.revoked).toBe(true);
});
it("stays when the same key is entered again", () => {
const same = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
type: "VALIDATE_DONE",
info: makeInfo("premium"),
});
expect(same.revoked).toBe(true);
});
it("clears when a different key is stored", () => {
const other: LicenseInfo = { ...makeInfo("premium"), issued_at: 2 };
const replaced = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
type: "VALIDATE_DONE",
info: other,
});
expect(replaced.revoked).toBe(false);
});
});