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>
213 lines
8.2 KiB
TypeScript
213 lines
8.2 KiB
TypeScript
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.",
|
||
);
|
||
});
|
||
});
|