diff --git a/ee/packages/license/__tests__/emitter.spec.ts b/ee/packages/license/__tests__/emitter.spec.ts index 6147d12623bc..5682715d6d2b 100644 --- a/ee/packages/license/__tests__/emitter.spec.ts +++ b/ee/packages/license/__tests__/emitter.spec.ts @@ -116,4 +116,180 @@ describe('Event License behaviors', () => { await expect(fn).toBeCalledWith(undefined); }); }); + + /** + * this is only called when the prevent_action behavior is triggered for the first time + * it will not be called again until the behavior is toggled + */ + describe('Toggled behaviors', () => { + it('should emit `behaviorToggled:prevent_action` event when the limit is reached once but `behavior:prevent_action` twice', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const toggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', fn); + + licenseManager.onBehaviorToggled('prevent_action', toggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(2); + await expect(toggleFn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `behaviorToggled:allow_action` event when the limit is not reached once but `behavior:allow_action` twice', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const toggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('allow_action', fn); + + licenseManager.onBehaviorToggled('allow_action', toggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 9); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + + await expect(fn).toBeCalledTimes(2); + await expect(toggleFn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `behaviorToggled:prevent_action` and `behaviorToggled:allow_action` events when the shouldPreventAction function changes the result', async () => { + const licenseManager = await getReadyLicenseManager(); + const preventFn = jest.fn(); + const preventToggleFn = jest.fn(); + const allowFn = jest.fn(); + const allowToggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', preventFn); + licenseManager.onBehaviorToggled('prevent_action', preventToggleFn); + licenseManager.onBehaviorTriggered('allow_action', allowFn); + licenseManager.onBehaviorToggled('allow_action', allowToggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(1); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(0); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventFn).toBeCalledTimes(1); + expect(preventToggleFn).toBeCalledTimes(1); + expect(allowFn).toBeCalledTimes(0); + expect(allowToggleFn).toBeCalledTimes(0); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventFn).toBeCalledTimes(1); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(0); + expect(allowToggleFn).toBeCalledTimes(0); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(1); + }); + }); + + describe('Allow actions', () => { + it('should emit `behavior:allow_action` event when the limit is not reached', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const preventFn = jest.fn(); + + licenseManager.onBehaviorTriggered('allow_action', fn); + licenseManager.onBehaviorTriggered('prevent_action', preventFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 9); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + + await expect(fn).toBeCalledTimes(1); + await expect(preventFn).toBeCalledTimes(0); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + }); }); diff --git a/ee/packages/license/src/definition/LicenseBehavior.ts b/ee/packages/license/src/definition/LicenseBehavior.ts index 8b5af5f3c481..ac2249233ab5 100644 --- a/ee/packages/license/src/definition/LicenseBehavior.ts +++ b/ee/packages/license/src/definition/LicenseBehavior.ts @@ -1,7 +1,13 @@ import type { LicenseLimitKind } from './ILicenseV3'; import type { LicenseModule } from './LicenseModule'; -export type LicenseBehavior = 'invalidate_license' | 'start_fair_policy' | 'prevent_action' | 'prevent_installation' | 'disable_modules'; +export type LicenseBehavior = + | 'invalidate_license' + | 'start_fair_policy' + | 'prevent_action' + | 'allow_action' + | 'prevent_installation' + | 'disable_modules'; export type BehaviorWithContext = | { diff --git a/ee/packages/license/src/definition/LicenseInfo.ts b/ee/packages/license/src/definition/LicenseInfo.ts index 4c4e34d30528..019d1b9e1ca0 100644 --- a/ee/packages/license/src/definition/LicenseInfo.ts +++ b/ee/packages/license/src/definition/LicenseInfo.ts @@ -5,6 +5,7 @@ import type { LicenseModule } from './LicenseModule'; export type LicenseInfo = { license?: ILicenseV3; activeModules: LicenseModule[]; + preventedActions: Record; limits: Record; tags: ILicenseTag[]; trial: boolean; diff --git a/ee/packages/license/src/definition/events.ts b/ee/packages/license/src/definition/events.ts index 53f3afe846db..ad1114738cce 100644 --- a/ee/packages/license/src/definition/events.ts +++ b/ee/packages/license/src/definition/events.ts @@ -4,9 +4,15 @@ import type { LicenseModule } from './LicenseModule'; type ModuleValidation = Record<`${'invalid' | 'valid'}:${LicenseModule}`, undefined>; type BehaviorTriggered = Record<`behavior:${LicenseBehavior}`, { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }>; +type BehaviorTriggeredToggled = Record< + `behaviorToggled:${LicenseBehavior}`, + { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind } +>; + type LimitReached = Record<`limitReached:${LicenseLimitKind}`, undefined>; export type LicenseEvents = ModuleValidation & + BehaviorTriggeredToggled & BehaviorTriggered & LimitReached & { validate: undefined; diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index 9256bcafe5f7..51f3282a9742 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -33,7 +33,7 @@ export function behaviorTriggered(this: LicenseManager, options: BehaviorWithCon logger.error({ msg: 'Error running behavior triggered event', error }); } - if (behavior !== 'prevent_action') { + if (!['prevent_action'].includes(behavior)) { return; } @@ -48,6 +48,19 @@ export function behaviorTriggered(this: LicenseManager, options: BehaviorWithCon } } +export function behaviorTriggeredToggled(this: LicenseManager, options: BehaviorWithContext) { + const { behavior, reason, modules: _, ...rest } = options; + + try { + this.emit(`behaviorToggled:${behavior}`, { + reason, + ...rest, + }); + } catch (error) { + logger.error({ msg: 'Error running behavior triggered event', error }); + } +} + export function licenseValidated(this: LicenseManager) { try { this.emit('validate'); diff --git a/ee/packages/license/src/events/listeners.ts b/ee/packages/license/src/events/listeners.ts index ecabecb28c0f..6c80867b7ac8 100644 --- a/ee/packages/license/src/events/listeners.ts +++ b/ee/packages/license/src/events/listeners.ts @@ -79,6 +79,14 @@ export function onBehaviorTriggered( this.on(`behavior:${behavior}`, cb); } +export function onBehaviorToggled( + this: LicenseManager, + behavior: Exclude, + cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void, +) { + this.on(`behaviorToggled:${behavior}`, cb); +} + export function onLimitReached(this: LicenseManager, limitKind: LicenseLimitKind, cb: () => void) { this.on(`limitReached:${limitKind}`, cb); } diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index 9707a41d96ab..92b30c4af40d 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -4,6 +4,7 @@ import type { LimitContext } from './definition/LimitContext'; import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated'; import { onLicense } from './events/deprecated'; import { + onBehaviorToggled, onBehaviorTriggered, onInvalidFeature, onInvalidateLicense, @@ -97,6 +98,8 @@ export class LicenseImp extends LicenseManager implements License { onBehaviorTriggered = onBehaviorTriggered; + onBehaviorToggled = onBehaviorToggled; + // Deprecated: onLicense = onLicense; diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 8212a4a0da27..5987065bd697 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -12,7 +12,7 @@ import type { LicenseEvents } from './definition/events'; import { DuplicatedLicenseError } from './errors/DuplicatedLicenseError'; import { InvalidLicenseError } from './errors/InvalidLicenseError'; import { NotReadyForValidation } from './errors/NotReadyForValidation'; -import { behaviorTriggered, licenseInvalidated, licenseValidated } from './events/emitter'; +import { behaviorTriggered, behaviorTriggeredToggled, licenseInvalidated, licenseValidated } from './events/emitter'; import { logger } from './logger'; import { getModules, invalidateAll, replaceModules } from './modules'; import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense'; @@ -49,6 +49,8 @@ export class LicenseManager extends Emitter { private _lockedLicense: string | undefined; + public shouldPreventActionResults = new Map(); + constructor() { super(); @@ -106,6 +108,8 @@ export class LicenseManager extends Emitter { this._unmodifiedLicense = undefined; this._valid = false; this._lockedLicense = undefined; + + this.shouldPreventActionResults.clear(); clearPendingLicense.call(this); } @@ -243,6 +247,12 @@ export class LicenseManager extends Emitter { } } + private triggerBehaviorEventsToggled(validationResult: BehaviorWithContext[]): void { + for (const { ...options } of validationResult) { + behaviorTriggeredToggled.call(this, { ...options }); + } + } + public hasValidLicense(): boolean { return Boolean(this.getLicense()); } @@ -279,18 +289,37 @@ export class LicenseManager extends Emitter { const validationResult = await runValidation.call(this, license, options); + const shouldPreventAction = isBehaviorsInResult(validationResult, ['prevent_action']); + // extra values should not call events since they are not actually reaching the limit just checking if they would if (extraCount) { - return isBehaviorsInResult(validationResult, ['prevent_action']); + return shouldPreventAction; } if (isBehaviorsInResult(validationResult, ['invalidate_license', 'disable_modules', 'start_fair_policy'])) { await this.revalidateLicense(); } - this.triggerBehaviorEvents(filterBehaviorsResult(validationResult, ['prevent_action'])); + const eventsToEmit = shouldPreventAction + ? filterBehaviorsResult(validationResult, ['prevent_action']) + : [ + { + behavior: 'allow_action', + modules: [], + reason: 'limit', + limit: action, + } as BehaviorWithContext, + ]; + + if (this.shouldPreventActionResults.get(action) !== shouldPreventAction) { + this.shouldPreventActionResults.set(action, shouldPreventAction); + + this.triggerBehaviorEventsToggled(eventsToEmit); + } + + this.triggerBehaviorEvents(eventsToEmit); - return isBehaviorsInResult(validationResult, ['prevent_action']); + return shouldPreventAction; } public async getInfo({ @@ -331,6 +360,7 @@ export class LicenseManager extends Emitter { return { license: (includeLicense && license) || undefined, activeModules, + preventedActions: Object.fromEntries(this.shouldPreventActionResults.entries()) as Record, limits: limits as Record, tags: license?.information.tags || [], trial: Boolean(license?.information.trial),