Simpl-Resultat/src/hooks/useIsPremium.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

61 lines
1.5 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { useIsPremium } from "./useIsPremium";
// useIsPremium now reads LicenseContext (was: useLicense). Mock the context hook.
vi.mock("../contexts/LicenseContext", () => ({
useLicenseContext: vi.fn(),
}));
import { useLicenseContext } from "../contexts/LicenseContext";
const mockUseLicenseContext = vi.mocked(useLicenseContext);
describe("useIsPremium", () => {
it('returns true when edition is "premium"', () => {
mockUseLicenseContext.mockReturnValue({
status: "ready",
edition: "premium",
features: [],
info: null,
error: null,
validating: false,
validationError: null,
revoked: false,
refresh: vi.fn(),
submitKey: vi.fn(),
});
expect(useIsPremium()).toBe(true);
});
it('returns false when edition is "base"', () => {
mockUseLicenseContext.mockReturnValue({
status: "ready",
edition: "base",
features: [],
info: null,
error: null,
validating: false,
validationError: null,
revoked: false,
refresh: vi.fn(),
submitKey: vi.fn(),
});
expect(useIsPremium()).toBe(false);
});
it('returns false when edition is "free"', () => {
mockUseLicenseContext.mockReturnValue({
status: "ready",
edition: "free",
features: [],
info: null,
error: null,
validating: false,
validationError: null,
revoked: false,
refresh: vi.fn(),
submitKey: vi.fn(),
});
expect(useIsPremium()).toBe(false);
});
});