Simpl-Resultat/src/services/entitlements.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

213 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
// Subject lives in src/shared/entitlements.ts; test co-located here per the
// issue's file plan (services/).
import {
isEntitled,
requiredTierFor,
ENTITLEMENTS,
type FeatureKey,
} from "../shared/entitlements";
import type { Edition } from "./licenseService";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
const EDITIONS: Edition[] = ["free", "base", "premium"];
const FEATURES = Object.keys(ENTITLEMENTS) as FeatureKey[];
describe("isEntitled — matrix", () => {
it("denies every feature in Free (fail-closed)", () => {
FEATURES.forEach((f) => {
expect(isEntitled(f, "free", [])).toBe(false);
});
});
it("Base unlocks budget/adjustments/reports-advanced/multi-profile, not balance", () => {
expect(isEntitled("budget", "base", [])).toBe(true);
expect(isEntitled("adjustments", "base", [])).toBe(true);
expect(isEntitled("reports-advanced", "base", [])).toBe(true);
expect(isEntitled("multi-profile", "base", [])).toBe(true);
expect(isEntitled("balance", "base", [])).toBe(false);
});
it("Premium unlocks everything including balance", () => {
FEATURES.forEach((f) => {
expect(isEntitled(f, "premium", [])).toBe(true);
});
});
it("matches the ENTITLEMENTS table for every feature × edition", () => {
FEATURES.forEach((f) => {
EDITIONS.forEach((ed) => {
const expected = ed !== "free" && ENTITLEMENTS[f].includes(ed);
expect(isEntitled(f, ed, [])).toBe(expected);
});
});
});
});
describe("isEntitled — features[] override", () => {
it("grants a higher-tier feature when the signed override lists it", () => {
// balance is Premium-only; a Base license carrying balance in features[] gets it.
expect(isEntitled("balance", "base", ["balance"])).toBe(true);
});
it("IGNORES the override when edition is free (CWE-863)", () => {
// A key that resolves to free (invalid or expired) still lists its signed
// features[]. Those must not be re-granted.
expect(isEntitled("balance", "free", ["balance"])).toBe(false);
expect(isEntitled("budget", "free", ["budget"])).toBe(false);
});
it("does not grant unrelated features via the override", () => {
expect(isEntitled("balance", "base", ["budget"])).toBe(false);
});
});
describe("isEntitled — unknown feature", () => {
it("denies an unknown feature key (deny-all)", () => {
expect(isEntitled("web-sync" as FeatureKey, "premium", [])).toBe(false);
expect(isEntitled("web-sync" as FeatureKey, "base", [])).toBe(false);
});
it("still honours a signed override for an unknown key when non-free", () => {
expect(isEntitled("web-sync" as FeatureKey, "premium", ["web-sync"])).toBe(true);
});
it("keeps an unknown feature denied in Free even with an override", () => {
expect(isEntitled("web-sync" as FeatureKey, "free", ["web-sync"])).toBe(false);
});
});
describe("requiredTierFor — upsell tier derivation", () => {
it("maps every Base-tier feature to base, and balance to premium", () => {
expect(requiredTierFor("budget")).toBe("base");
expect(requiredTierFor("adjustments")).toBe("base");
expect(requiredTierFor("reports-advanced")).toBe("base");
expect(requiredTierFor("multi-profile")).toBe("base");
expect(requiredTierFor("balance")).toBe("premium");
});
it("returns the MINIMUM tier satisfying isEntitled, never free", () => {
FEATURES.forEach((f) => {
const tier = requiredTierFor(f);
expect(tier).not.toBe("free");
// The returned tier does unlock the feature…
expect(isEntitled(f, tier, [])).toBe(true);
// …and is minimal: premium only when base does not unlock it.
if (tier === "premium") {
expect(isEntitled(f, "base", [])).toBe(false);
}
});
});
});
describe("upsell i18n coverage (fr + en)", () => {
// UpsellGate renders t(`upsell.features.${feature}`) — every FeatureKey must
// resolve in BOTH locales or a locked screen shows a raw key to the user.
// Structural sub-type only: full typeof-identity between the two JSON files
// is not this test's concern.
interface UpsellMessages {
nav: { locked: string };
license: { editions: { base: string; premium: string } };
upsell: {
title: string;
features: Record<string, string>;
ctaGet: string;
premiumNotForSale: string;
ctaHaveKey: string;
revoked: string;
};
}
const locales: Record<string, UpsellMessages> = { fr, en };
it("has a non-empty upsell description for every FeatureKey in both locales", () => {
Object.entries(locales).forEach(([lng, messages]) => {
FEATURES.forEach((f) => {
expect(
messages.upsell.features[f],
`missing upsell.features.${f} in ${lng}`,
).toBeTruthy();
});
});
});
it("has no stale upsell.features key outside the ENTITLEMENTS matrix", () => {
Object.entries(locales).forEach(([lng, messages]) => {
Object.keys(messages.upsell.features).forEach((k) => {
expect(FEATURES, `stale upsell.features.${k} in ${lng}`).toContain(k);
});
});
});
it("exposes the title, CTA keys and nav.locked in both locales", () => {
Object.values(locales).forEach((messages) => {
expect(messages.upsell.title).toBeTruthy();
expect(messages.upsell.ctaGet).toBeTruthy();
expect(messages.upsell.premiumNotForSale).toBeTruthy();
expect(messages.upsell.ctaHaveKey).toBeTruthy();
expect(messages.upsell.revoked).toBeTruthy();
expect(messages.nav.locked).toBeTruthy();
});
});
// Pinning the whole key set catches a stale CTA key coming back in either
// locale (the Premium note replaced the "coming soon" note in #346).
it("has exactly the upsell keys the locked screen uses, in both locales", () => {
Object.entries(locales).forEach(([lng, messages]) => {
expect(Object.keys(messages.upsell).sort(), lng).toEqual([
"ctaGet",
"ctaHaveKey",
"features",
"premiumNotForSale",
"revoked",
"title",
]);
});
});
it("keeps the license.editions.<tier> label keys required by the CTA", () => {
Object.values(locales).forEach((messages) => {
expect(messages.license.editions.base).toBeTruthy();
expect(messages.license.editions.premium).toBeTruthy();
});
});
});
// Texts decided in planning (#346): the Premium note (D3), the Compte Maximus
// card (D4) and the download link of the free edition (D11), pinned verbatim.
describe("purchase path texts (fr + en)", () => {
interface PurchaseTexts {
upsell: { premiumNotForSale: string };
account: { description: string };
settings: { updates: { downloadLatest: string; notEntitled: string } };
}
const texts = { fr, en } as unknown as Record<"fr" | "en", PurchaseTexts>;
it("says Premium is not sold online yet", () => {
expect(texts.fr.upsell.premiumNotForSale).toBe("Premium n'est pas encore offert en ligne.");
expect(texts.en.upsell.premiumNotForSale).toBe("Premium isn't sold online yet.");
});
it("describes what the account is for, without promising Premium", () => {
expect(texts.fr.account.description).toBe(
"Le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions. Les éditions Base et Premium viennent de la clé de licence, pas du compte.",
);
expect(texts.en.account.description).toBe(
"Your account lets you find your licenses on lacompagniemaximus.com and identifies you in the feedback you send. Base and Premium editions come from your license key, not from the account.",
);
});
it("labels the download link of the free edition", () => {
expect(texts.fr.settings.updates.downloadLatest).toBe("Télécharger la dernière version");
expect(texts.en.settings.updates.downloadLatest).toBe("Download the latest version");
});
it("tells the free edition that automatic updates come with Base", () => {
expect(texts.fr.settings.updates.notEntitled).toBe(
"Les mises à jour automatiques sont incluses dans l'édition Base.",
);
expect(texts.en.settings.updates.notEntitled).toBe(
"Automatic updates are included in the Base edition.",
);
});
});