snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
2194
ee/packages/license/CHANGELOG.md
Normal file
2194
ee/packages/license/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
49
ee/packages/license/__tests__/DefaultRestrictions.spec.ts
Normal file
49
ee/packages/license/__tests__/DefaultRestrictions.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { LicenseImp } from '../src';
|
||||
|
||||
describe('Community Restrictions', () => {
|
||||
describe('Apps from marketplace', () => {
|
||||
it('should respect the default if there is no license applied', async () => {
|
||||
const license = new LicenseImp();
|
||||
|
||||
license.setLicenseLimitCounter('marketplaceApps', () => 1);
|
||||
|
||||
await expect(await license.shouldPreventAction('marketplaceApps')).toBe(false);
|
||||
|
||||
license.setLicenseLimitCounter('marketplaceApps', () => 10);
|
||||
|
||||
await expect(await license.shouldPreventAction('marketplaceApps')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Private Apps', () => {
|
||||
it('should respect the default if there is no license applied', async () => {
|
||||
const license = new LicenseImp();
|
||||
|
||||
license.setLicenseLimitCounter('privateApps', () => 0);
|
||||
|
||||
await expect(await license.shouldPreventAction('privateApps')).toBe(true);
|
||||
|
||||
license.setLicenseLimitCounter('privateApps', () => 10);
|
||||
|
||||
await expect(await license.shouldPreventAction('privateApps')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active Users', () => {
|
||||
it('should respect the default if there is no license applied', async () => {
|
||||
const license = new LicenseImp();
|
||||
|
||||
license.setLicenseLimitCounter('activeUsers', () => 1);
|
||||
|
||||
await expect(await license.shouldPreventAction('activeUsers')).toBe(false);
|
||||
|
||||
license.setLicenseLimitCounter('activeUsers', () => 10);
|
||||
|
||||
await expect(await license.shouldPreventAction('activeUsers')).toBe(false);
|
||||
|
||||
license.setLicenseLimitCounter('activeUsers', () => 100000);
|
||||
|
||||
await expect(await license.shouldPreventAction('activeUsers')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
16
ee/packages/license/__tests__/MockedLicenseBuilder.ts
Normal file
16
ee/packages/license/__tests__/MockedLicenseBuilder.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { LicenseImp } from '../src';
|
||||
|
||||
export { MockedLicenseBuilder } from '../src/MockedLicenseBuilder';
|
||||
|
||||
export const getReadyLicenseManager = async () => {
|
||||
const license = new LicenseImp();
|
||||
await license.setWorkspaceUrl('http://localhost:3000');
|
||||
|
||||
license.setLicenseLimitCounter('activeUsers', () => 0);
|
||||
license.setLicenseLimitCounter('guestUsers', () => 0);
|
||||
license.setLicenseLimitCounter('roomsPerGuest', async () => 0);
|
||||
license.setLicenseLimitCounter('privateApps', () => 0);
|
||||
license.setLicenseLimitCounter('marketplaceApps', () => 0);
|
||||
license.setLicenseLimitCounter('monthlyActiveContacts', async () => 0);
|
||||
return license;
|
||||
};
|
||||
87
ee/packages/license/__tests__/applyNewestLicense.spec.ts
Normal file
87
ee/packages/license/__tests__/applyNewestLicense.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from './MockedLicenseBuilder';
|
||||
import { applyNewestLicense } from '../src/applyLicense';
|
||||
|
||||
const OLDER = new Date('2026-01-01T00:00:00.000Z');
|
||||
const NEWER = new Date('2026-06-01T00:00:00.000Z');
|
||||
|
||||
describe('applyNewestLicense', () => {
|
||||
it('should apply the provided license when it is newer than the stored one', async () => {
|
||||
const manager = await getReadyLicenseManager();
|
||||
const stored = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['auditing']).sign();
|
||||
const provided = await new MockedLicenseBuilder().withCreatedAt(NEWER).withGrantedModules(['livechat-enterprise']).sign();
|
||||
|
||||
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
|
||||
|
||||
expect(manager.hasValidLicense()).toBe(true);
|
||||
expect(manager.hasModule('livechat-enterprise')).toBe(true);
|
||||
expect(manager.hasModule('auditing')).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply the dated provided license when the stored one has no issue date', async () => {
|
||||
const manager = await getReadyLicenseManager();
|
||||
|
||||
// V2 licenses carry no signature-verified issue date, so they must sort as the oldest
|
||||
const storedBuilder = new MockedLicenseBuilder().withGrantedModules(['auditing']);
|
||||
storedBuilder.information.createdAt = 'not-a-date';
|
||||
const stored = await storedBuilder.sign();
|
||||
|
||||
const provided = await new MockedLicenseBuilder().withCreatedAt(NEWER).withGrantedModules(['livechat-enterprise']).sign();
|
||||
|
||||
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
|
||||
|
||||
expect(manager.hasModule('livechat-enterprise')).toBe(true);
|
||||
expect(manager.hasModule('auditing')).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply the stored license when it is newer than the provided one', async () => {
|
||||
const manager = await getReadyLicenseManager();
|
||||
const stored = await new MockedLicenseBuilder().withCreatedAt(NEWER).withGrantedModules(['auditing']).sign();
|
||||
const provided = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['livechat-enterprise']).sign();
|
||||
|
||||
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
|
||||
|
||||
expect(manager.hasModule('auditing')).toBe(true);
|
||||
expect(manager.hasModule('livechat-enterprise')).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply the provided license when there is no stored license', async () => {
|
||||
const manager = await getReadyLicenseManager();
|
||||
const provided = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['livechat-enterprise']).sign();
|
||||
|
||||
await expect(applyNewestLicense(manager, '', provided)).resolves.toBe(true);
|
||||
|
||||
expect(manager.hasModule('livechat-enterprise')).toBe(true);
|
||||
});
|
||||
|
||||
it('should fall back to the provided license when the newer stored one fails to apply', async () => {
|
||||
const manager = await getReadyLicenseManager();
|
||||
const stored = await new MockedLicenseBuilder().withCreatedAt(NEWER).withExpiredDate().withGrantedModules(['auditing']).sign();
|
||||
const provided = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['livechat-enterprise']).sign();
|
||||
|
||||
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
|
||||
|
||||
expect(manager.hasModule('livechat-enterprise')).toBe(true);
|
||||
});
|
||||
|
||||
it('should fall back to the stored license when the newer provided one is corrupted', async () => {
|
||||
const manager = await getReadyLicenseManager();
|
||||
const stored = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['auditing']).sign();
|
||||
const provided = `${await new MockedLicenseBuilder().withCreatedAt(NEWER).sign()}corrupted`;
|
||||
|
||||
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
|
||||
|
||||
expect(manager.hasModule('auditing')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when neither license is provided', async () => {
|
||||
const manager = await getReadyLicenseManager();
|
||||
|
||||
await expect(applyNewestLicense(manager, '', '')).resolves.toBe(false);
|
||||
|
||||
expect(manager.hasValidLicense()).toBe(false);
|
||||
});
|
||||
});
|
||||
359
ee/packages/license/__tests__/emitter.spec.ts
Normal file
359
ee/packages/license/__tests__/emitter.spec.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from './MockedLicenseBuilder';
|
||||
|
||||
describe('Event License behaviors', () => {
|
||||
it('should call the module as they are enabled/disabled', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
const validFn = jest.fn();
|
||||
const invalidFn = jest.fn();
|
||||
|
||||
license.onValidFeature('livechat-enterprise', validFn);
|
||||
license.onInvalidFeature('livechat-enterprise', invalidFn);
|
||||
|
||||
const mocked = await new MockedLicenseBuilder();
|
||||
const oldToken = await mocked.sign();
|
||||
|
||||
const newToken = await mocked.withGrantedModules(['livechat-enterprise']).sign();
|
||||
|
||||
// apply license
|
||||
await expect(license.setLicense(oldToken)).resolves.toBe(true);
|
||||
await expect(license.hasValidLicense()).toBe(true);
|
||||
|
||||
await expect(license.hasModule('livechat-enterprise')).toBe(false);
|
||||
|
||||
await expect(validFn).not.toHaveBeenCalled();
|
||||
await expect(invalidFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// apply license containing livechat-enterprise module
|
||||
|
||||
validFn.mockClear();
|
||||
invalidFn.mockClear();
|
||||
|
||||
await expect(license.setLicense(newToken)).resolves.toBe(true);
|
||||
await expect(license.hasValidLicense()).toBe(true);
|
||||
await expect(license.hasModule('livechat-enterprise')).toBe(true);
|
||||
|
||||
await expect(validFn).toHaveBeenCalledTimes(1);
|
||||
await expect(invalidFn).toHaveBeenCalledTimes(0);
|
||||
|
||||
// apply the old license again
|
||||
|
||||
validFn.mockClear();
|
||||
invalidFn.mockClear();
|
||||
await expect(license.setLicense(oldToken)).resolves.toBe(true);
|
||||
await expect(license.hasValidLicense()).toBe(true);
|
||||
await expect(license.hasModule('livechat-enterprise')).toBe(false);
|
||||
await expect(validFn).toHaveBeenCalledTimes(0);
|
||||
await expect(invalidFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call `onValidateLicense` when a valid license is applied', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
const fn = jest.fn();
|
||||
|
||||
license.onValidateLicense(fn);
|
||||
|
||||
const mocked = await new MockedLicenseBuilder();
|
||||
const token = await mocked.sign();
|
||||
|
||||
await expect(license.setLicense(token)).resolves.toBe(true);
|
||||
await expect(license.hasValidLicense()).toBe(true);
|
||||
await expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('behavior:prevent_action event', () => {
|
||||
it('should emit `behavior:prevent_action` event when the limit is reached', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
const fn = jest.fn();
|
||||
|
||||
licenseManager.onBehaviorTriggered('prevent_action', fn);
|
||||
|
||||
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(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(fn).toHaveBeenCalledWith({
|
||||
reason: 'limit',
|
||||
limit: 'activeUsers',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit `limitReached:activeUsers` event when the limit is reached', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
const fn = jest.fn();
|
||||
|
||||
licenseManager.onLimitReached('activeUsers', fn);
|
||||
|
||||
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(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(fn).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This event is used to sync multiple instances of license manager
|
||||
* The sync event is triggered when the license is changed, but if the validation is running due to a previous change, no sync should be triggered, avoiding multiple/loops syncs
|
||||
*/
|
||||
describe('sync event', () => {
|
||||
it('should emit `sync` event when the license is changed', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
const fn = jest.fn();
|
||||
|
||||
licenseManager.onChange(fn);
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
{
|
||||
max: 20,
|
||||
behavior: 'invalidate_license',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 21);
|
||||
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
|
||||
await expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not emit `sync` event when the license validation was triggered by a the sync method', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
const fn = jest.fn();
|
||||
|
||||
licenseManager.onChange(fn);
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
{
|
||||
max: 20,
|
||||
behavior: 'invalidate_license',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 21);
|
||||
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
|
||||
await expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
fn.mockClear();
|
||||
|
||||
await expect(licenseManager.sync()).resolves.toBe(undefined);
|
||||
|
||||
await expect(fn).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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).toHaveBeenCalledTimes(2);
|
||||
await expect(toggleFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(fn).toHaveBeenCalledWith({
|
||||
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).toHaveBeenCalledTimes(2);
|
||||
await expect(toggleFn).toHaveBeenCalledTimes(0);
|
||||
|
||||
await expect(fn).toHaveBeenCalledWith({
|
||||
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).toHaveBeenCalledTimes(0);
|
||||
expect(preventToggleFn).toHaveBeenCalledTimes(0);
|
||||
expect(allowFn).toHaveBeenCalledTimes(1);
|
||||
expect(allowToggleFn).toHaveBeenCalledTimes(0);
|
||||
|
||||
preventFn.mockClear();
|
||||
preventToggleFn.mockClear();
|
||||
allowFn.mockClear();
|
||||
allowToggleFn.mockClear();
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
expect(preventFn).toHaveBeenCalledTimes(0);
|
||||
expect(preventToggleFn).toHaveBeenCalledTimes(0);
|
||||
expect(allowFn).toHaveBeenCalledTimes(1);
|
||||
expect(allowToggleFn).toHaveBeenCalledTimes(0);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
|
||||
|
||||
preventFn.mockClear();
|
||||
preventToggleFn.mockClear();
|
||||
allowFn.mockClear();
|
||||
allowToggleFn.mockClear();
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
expect(preventFn).toHaveBeenCalledTimes(1);
|
||||
expect(preventToggleFn).toHaveBeenCalledTimes(1);
|
||||
expect(allowFn).toHaveBeenCalledTimes(0);
|
||||
expect(allowToggleFn).toHaveBeenCalledTimes(0);
|
||||
|
||||
preventFn.mockClear();
|
||||
preventToggleFn.mockClear();
|
||||
allowFn.mockClear();
|
||||
allowToggleFn.mockClear();
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
expect(preventFn).toHaveBeenCalledTimes(1);
|
||||
expect(preventToggleFn).toHaveBeenCalledTimes(0);
|
||||
expect(allowFn).toHaveBeenCalledTimes(0);
|
||||
expect(allowToggleFn).toHaveBeenCalledTimes(0);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
|
||||
|
||||
preventFn.mockClear();
|
||||
preventToggleFn.mockClear();
|
||||
allowFn.mockClear();
|
||||
allowToggleFn.mockClear();
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
expect(preventFn).toHaveBeenCalledTimes(0);
|
||||
expect(preventToggleFn).toHaveBeenCalledTimes(0);
|
||||
expect(allowFn).toHaveBeenCalledTimes(1);
|
||||
expect(allowToggleFn).toHaveBeenCalledTimes(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).toHaveBeenCalledTimes(1);
|
||||
await expect(preventFn).toHaveBeenCalledTimes(0);
|
||||
|
||||
await expect(fn).toHaveBeenCalledWith({
|
||||
reason: 'limit',
|
||||
limit: 'activeUsers',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
30
ee/packages/license/__tests__/getLicenseCreatedAt.spec.ts
Normal file
30
ee/packages/license/__tests__/getLicenseCreatedAt.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { MockedLicenseBuilder } from './MockedLicenseBuilder';
|
||||
import { getLicenseCreatedAt } from '../src/getLicenseCreatedAt';
|
||||
|
||||
describe('getLicenseCreatedAt', () => {
|
||||
it('should return the createdAt of a signed V3 license', async () => {
|
||||
const createdAt = new Date('2026-01-02T03:04:05.000Z');
|
||||
const encrypted = await new MockedLicenseBuilder().withCreatedAt(createdAt).sign();
|
||||
|
||||
await expect(getLicenseCreatedAt(encrypted)).resolves.toEqual(createdAt);
|
||||
});
|
||||
|
||||
it('should return undefined for an empty license string', async () => {
|
||||
await expect(getLicenseCreatedAt('')).resolves.toBeUndefined();
|
||||
await expect(getLicenseCreatedAt(' ')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for a non-V3 license string', async () => {
|
||||
await expect(getLicenseCreatedAt('not-a-license')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for a V3 license with a broken signature', async () => {
|
||||
const encrypted = await new MockedLicenseBuilder().sign();
|
||||
|
||||
await expect(getLicenseCreatedAt(`${encrypted}corrupted`)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
206
ee/packages/license/__tests__/setLicense.spec.ts
Normal file
206
ee/packages/license/__tests__/setLicense.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { LicenseImp } from '../src';
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from './MockedLicenseBuilder';
|
||||
import { DuplicatedLicenseError } from '../src/errors/DuplicatedLicenseError';
|
||||
import { InvalidLicenseError } from '../src/errors/InvalidLicenseError';
|
||||
import { NotReadyForValidation } from '../src/errors/NotReadyForValidation';
|
||||
|
||||
// Same license used on ci tasks so no I didnt leak it
|
||||
const VALID_LICENSE =
|
||||
process.env.ENTERPRISE_LICENSE ||
|
||||
'MK+bpK5NveUuNlWGaQXGoy+8b74Luet82M3ZGcBB8b5P9Y+m67NEtpW64dc1d5lEWi6d0nFjCjtCMneVD7bKxodz/Cml8URKEo5P7cQb/9wmeT0MzAhYNaRFZlIGkZ3ITF59pDV2u4HZuosEDJikVRwnaJ5ZoU/pOsHSPUPhTyGNIqLeKynODtUpfwDdIKEmHxpf2yVkKjgRiIJmbWjM6A4k+MNNYXWVXHzye7GggqWVg/ZcT7nKU1CCadpLhTJiIrgrrPzil1G5DQ4xnLs3Q2tu2dILSDiW5OYw/ywu2yCMicTjMq4MLL5SXDQJj6WoJzZ54HosbvsDzOXvsdC9gI1CjhPL2uRuvC8XLrzn3vL2UgXnifzD1VrLTtdZ+aSADveqtlzYlRWtqoUFBbNw8o+YVHdhbZGR0beMoAyRbHi5EMpxpad3L+NyztUIT/Uh/IjQ/C2SQZ6jB0GKPBOPxFLN56FNhTGrffLFR++TVoBu0Iquc7kajWkNit3bVbZvbx+oFcVW2PcjQ/+i2jpJjbgtUFUKrTKxGMAXTWoDzIQQ35zNzGAy268IM4Ymp5JmsVEnBOEUkbF9yx6fzkO6xZhpsHf0muklnW0kA+Tlore/TUrBWh1/RwWlQeZlxM5NyWoRM5onQmr/k/4BmObtL1Hpmbk8oMG29z89xtE9y/4=';
|
||||
|
||||
describe('License set license procedures', () => {
|
||||
describe('Invalid formats', () => {
|
||||
it('should have no license by default', async () => {
|
||||
const license = new LicenseImp();
|
||||
|
||||
expect(license.hasValidLicense()).toBe(false);
|
||||
expect(license.getLicense()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error if the license applied is empty', async () => {
|
||||
const license = new LicenseImp();
|
||||
await expect(license.setLicense('')).rejects.toThrow(InvalidLicenseError);
|
||||
});
|
||||
|
||||
it('should throw an error if the license applied is invalid', async () => {
|
||||
const license = new LicenseImp();
|
||||
await expect(license.setLicense('invalid')).rejects.toThrow(InvalidLicenseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid periods', () => {
|
||||
it('should throw an error if the license is expired', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
|
||||
const mocked = new MockedLicenseBuilder();
|
||||
const token = await mocked.withExpiredDate().sign();
|
||||
|
||||
await license.setLicense(token);
|
||||
expect(license.hasValidLicense()).toBe(false);
|
||||
});
|
||||
|
||||
describe('license that is not not started yet is applied', () => {
|
||||
it('should throw an error if the license is not started yet', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
|
||||
const mocked = new MockedLicenseBuilder();
|
||||
const token = await mocked.withNotStartedDate().sign();
|
||||
|
||||
await license.setLicense(token);
|
||||
expect(license.hasValidLicense()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be allowed to set the same license again if the license is not started yet', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
|
||||
const mocked = new MockedLicenseBuilder();
|
||||
const as = mocked.resetValidPeriods().withNotStartedDate();
|
||||
const token = await as.sign();
|
||||
|
||||
await license.setLicense(token);
|
||||
|
||||
expect(license.hasValidLicense()).toBe(false);
|
||||
|
||||
// 5 minutes in the future
|
||||
|
||||
const mockedData = new Date();
|
||||
|
||||
mockedData.setMinutes(mockedData.getMinutes() + 5);
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(mockedData);
|
||||
|
||||
await license.setLicense(token);
|
||||
|
||||
jest.useRealTimers();
|
||||
|
||||
expect(license.hasValidLicense()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if the license is duplicated', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
|
||||
await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true);
|
||||
await expect(license.setLicense(VALID_LICENSE)).rejects.toThrow(DuplicatedLicenseError);
|
||||
});
|
||||
|
||||
it('should keep a valid license if a new invalid formatted license is applied', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
|
||||
await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true);
|
||||
expect(license.hasValidLicense()).toBe(true);
|
||||
|
||||
await expect(license.setLicense('invalid')).rejects.toThrow(InvalidLicenseError);
|
||||
expect(license.hasValidLicense()).toBe(true);
|
||||
});
|
||||
|
||||
describe('Pending cases', () => {
|
||||
it('should return an error if the license is not ready for validation yet - missing workspace url', async () => {
|
||||
const license = new LicenseImp();
|
||||
await expect(license.setLicense(VALID_LICENSE)).rejects.toThrow(NotReadyForValidation);
|
||||
});
|
||||
|
||||
it('should return an error if the license is not ready for validation yet - missing counters', async () => {
|
||||
const license = new LicenseImp();
|
||||
await license.setWorkspaceUrl('http://localhost:3000');
|
||||
|
||||
expect(license.getWorkspaceUrl()).toBe('localhost:3000');
|
||||
|
||||
await expect(license.setLicense(VALID_LICENSE)).rejects.toThrow(NotReadyForValidation);
|
||||
|
||||
expect(license.hasValidLicense()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return a valid license if the license is ready for validation', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
|
||||
await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true);
|
||||
expect(license.hasValidLicense()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('License V3', () => {
|
||||
it('should return a valid license if the license is ready for validation', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
const token = await new MockedLicenseBuilder().sign();
|
||||
|
||||
await expect(license.setLicense(token)).resolves.toBe(true);
|
||||
expect(license.hasValidLicense()).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept new licenses', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
const mocked = new MockedLicenseBuilder();
|
||||
const oldToken = await mocked.sign();
|
||||
|
||||
const newToken = await mocked.withGrantedModules(['livechat-enterprise', 'chat.rocket.test-addon']).sign();
|
||||
|
||||
await expect(license.setLicense(oldToken)).resolves.toBe(true);
|
||||
expect(license.hasValidLicense()).toBe(true);
|
||||
|
||||
expect(license.hasModule('livechat-enterprise')).toBe(false);
|
||||
expect(license.hasModule('chat.rocket.test-addon')).toBe(false);
|
||||
|
||||
await expect(license.setLicense(newToken)).resolves.toBe(true);
|
||||
expect(license.hasValidLicense()).toBe(true);
|
||||
expect(license.hasModule('livechat-enterprise')).toBe(true);
|
||||
expect(license.hasModule('chat.rocket.test-addon')).toBe(true);
|
||||
});
|
||||
|
||||
it('should call a validated event after set a valid license', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
const validateCallback = jest.fn();
|
||||
license.onValidateLicense(validateCallback);
|
||||
await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true);
|
||||
expect(license.hasValidLicense()).toBe(true);
|
||||
expect(validateCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('License limits', () => {
|
||||
describe('invalidate license', () => {
|
||||
it('should trigger an invalidation event when a license with invalid limits is set after a valid one', async () => {
|
||||
const invalidationCallback = jest.fn();
|
||||
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
const mocked = new MockedLicenseBuilder();
|
||||
const oldToken = await mocked
|
||||
.withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'invalidate_license',
|
||||
},
|
||||
])
|
||||
.sign();
|
||||
|
||||
const newToken = await mocked
|
||||
.withLimits('activeUsers', [
|
||||
{
|
||||
max: 1,
|
||||
behavior: 'invalidate_license',
|
||||
},
|
||||
])
|
||||
.sign();
|
||||
|
||||
licenseManager.onInvalidateLicense(invalidationCallback);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
|
||||
|
||||
await expect(licenseManager.setLicense(oldToken)).resolves.toBe(true);
|
||||
expect(licenseManager.hasValidLicense()).toBe(true);
|
||||
|
||||
await expect(licenseManager.setLicense(newToken)).resolves.toBe(true);
|
||||
expect(licenseManager.hasValidLicense()).toBe(false);
|
||||
|
||||
expect(invalidationCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
7
ee/packages/license/jest.config.ts
Normal file
7
ee/packages/license/jest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
modulePathIgnorePatterns: ['<rootDir>/__tests__/MockedLicenseBuilder'],
|
||||
} satisfies Config;
|
||||
39
ee/packages/license/package.json
Normal file
39
ee/packages/license/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@rocket.chat/license",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "jest",
|
||||
"testunit": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/jwt": "workspace:^",
|
||||
"@rocket.chat/logger": "workspace:^",
|
||||
"bcrypt": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/jest-presets": "workspace:~",
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jest": "~30.0.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "~30.2.0",
|
||||
"jest-websocket-mock": "~2.5.0",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
113
ee/packages/license/src/AirGappedRestriction.spec.ts
Normal file
113
ee/packages/license/src/AirGappedRestriction.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { AirGappedRestriction } from './AirGappedRestriction';
|
||||
import { StatsTokenBuilder } from './MockedLicenseBuilder';
|
||||
import { License } from './licenseImp';
|
||||
|
||||
jest.mock('./licenseImp', () => ({
|
||||
License: {
|
||||
hasValidLicense: jest.fn().mockReturnValue(false),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AirGappedRestriction', () => {
|
||||
describe('#computeRestriction()', () => {
|
||||
it('should notify remaining days = 0 (apply restriction) when token is not a string', async () => {
|
||||
const handler = jest.fn();
|
||||
|
||||
AirGappedRestriction.on('remainingDays', handler);
|
||||
await AirGappedRestriction.computeRestriction();
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
days: 0,
|
||||
});
|
||||
expect(AirGappedRestriction.restricted).toBe(true);
|
||||
});
|
||||
it('should notify remaining days = 0 (apply restriction) when it was not possible to decrypt the stats token', async () => {
|
||||
const handler = jest.fn();
|
||||
|
||||
AirGappedRestriction.on('remainingDays', handler);
|
||||
await AirGappedRestriction.computeRestriction('invalid-token');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
days: 0,
|
||||
});
|
||||
expect(AirGappedRestriction.restricted).toBe(true);
|
||||
});
|
||||
it('should notify remaining days (8) within the accepted range (1 - 10) when the last reported stats happened 2 days ago', async () => {
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - 2);
|
||||
const token = await new StatsTokenBuilder().withTimeStamp(now).sign();
|
||||
const handler = jest.fn();
|
||||
|
||||
AirGappedRestriction.on('remainingDays', handler);
|
||||
await AirGappedRestriction.computeRestriction(token);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
days: 8,
|
||||
});
|
||||
expect(AirGappedRestriction.restricted).toBe(false);
|
||||
});
|
||||
it('should notify remaining days = 0 (apply restrictions) when the last reported stats happened more than 10 days ago', async () => {
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - 11);
|
||||
const token = await new StatsTokenBuilder().withTimeStamp(now).sign();
|
||||
const handler = jest.fn();
|
||||
|
||||
AirGappedRestriction.on('remainingDays', handler);
|
||||
await AirGappedRestriction.computeRestriction(token);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
days: 0,
|
||||
});
|
||||
expect(AirGappedRestriction.restricted).toBe(true);
|
||||
});
|
||||
it('should notify remaining days = 0 (apply restrictions) when the last reported stats happened 10 days ago', async () => {
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - 10);
|
||||
const token = await new StatsTokenBuilder().withTimeStamp(now).sign();
|
||||
const handler = jest.fn();
|
||||
|
||||
AirGappedRestriction.on('remainingDays', handler);
|
||||
await AirGappedRestriction.computeRestriction(token);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
days: 0,
|
||||
});
|
||||
expect(AirGappedRestriction.restricted).toBe(true);
|
||||
});
|
||||
it('should notify remaining days = -1 when has valid license', () => {
|
||||
const handler = jest.fn();
|
||||
(License.hasValidLicense as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
AirGappedRestriction.on('remainingDays', handler);
|
||||
AirGappedRestriction.computeRestriction();
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
days: -1,
|
||||
});
|
||||
expect(AirGappedRestriction.restricted).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('#isWarningPeriod', () => {
|
||||
it('should return true if value is between or exactly 0 and 7', async () => {
|
||||
expect(AirGappedRestriction.isWarningPeriod(0)).toBe(true);
|
||||
expect(AirGappedRestriction.isWarningPeriod(1)).toBe(true);
|
||||
expect(AirGappedRestriction.isWarningPeriod(2)).toBe(true);
|
||||
expect(AirGappedRestriction.isWarningPeriod(3)).toBe(true);
|
||||
expect(AirGappedRestriction.isWarningPeriod(4)).toBe(true);
|
||||
expect(AirGappedRestriction.isWarningPeriod(5)).toBe(true);
|
||||
expect(AirGappedRestriction.isWarningPeriod(6)).toBe(true);
|
||||
expect(AirGappedRestriction.isWarningPeriod(7)).toBe(true);
|
||||
});
|
||||
it('should return false if value is lesser than 0 or bigger than 7', async () => {
|
||||
expect(AirGappedRestriction.isWarningPeriod(-1)).toBe(false);
|
||||
expect(AirGappedRestriction.isWarningPeriod(8)).toBe(false);
|
||||
expect(AirGappedRestriction.isWarningPeriod(10)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
ee/packages/license/src/AirGappedRestriction.ts
Normal file
73
ee/packages/license/src/AirGappedRestriction.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import EventEmitter from 'node:events';
|
||||
|
||||
import { License } from '.';
|
||||
import { decryptStatsToken } from './token';
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const NO_ACTION_PERIOD_IN_DAYS = 3;
|
||||
const WARNING_PERIOD_IN_DAYS = 7;
|
||||
|
||||
class AirGappedRestrictionClass extends EventEmitter {
|
||||
#restricted = true;
|
||||
|
||||
public get restricted(): boolean {
|
||||
return this.#restricted;
|
||||
}
|
||||
|
||||
public async computeRestriction(encryptedToken?: string): Promise<void> {
|
||||
if (License.hasValidLicense()) {
|
||||
this.removeRestrictionsUnderLicense();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof encryptedToken !== 'string') {
|
||||
this.applyRestrictions();
|
||||
return;
|
||||
}
|
||||
|
||||
return this.checkRemainingDaysSinceLastStatsReport(encryptedToken);
|
||||
}
|
||||
|
||||
private async checkRemainingDaysSinceLastStatsReport(encryptedToken: string): Promise<void> {
|
||||
try {
|
||||
const { timestamp: lastStatsReportTimestamp } = JSON.parse(await decryptStatsToken(encryptedToken));
|
||||
const now = new Date();
|
||||
const lastStatsReport = new Date(lastStatsReportTimestamp);
|
||||
const nowUTC = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const lastStatsReportUTC = Date.UTC(lastStatsReport.getFullYear(), lastStatsReport.getMonth(), lastStatsReport.getDate());
|
||||
|
||||
const daysSinceLastStatsReport = Math.floor((nowUTC - lastStatsReportUTC) / DAY_IN_MS);
|
||||
|
||||
this.notifyRemainingDaysUntilRestriction(daysSinceLastStatsReport);
|
||||
} catch {
|
||||
this.applyRestrictions();
|
||||
}
|
||||
}
|
||||
|
||||
private applyRestrictions(): void {
|
||||
this.notifyRemainingDaysUntilRestriction(NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS);
|
||||
}
|
||||
|
||||
private removeRestrictionsUnderLicense(): void {
|
||||
this.#restricted = false;
|
||||
this.emit('remainingDays', { days: -1 });
|
||||
}
|
||||
|
||||
public isWarningPeriod(days: number) {
|
||||
if (days < 0) {
|
||||
return false;
|
||||
}
|
||||
return days <= WARNING_PERIOD_IN_DAYS;
|
||||
}
|
||||
|
||||
private notifyRemainingDaysUntilRestriction(daysSinceLastStatsReport: number): void {
|
||||
const remainingDaysUntilRestriction = Math.max(NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS - daysSinceLastStatsReport, 0);
|
||||
|
||||
this.#restricted = remainingDaysUntilRestriction === 0;
|
||||
this.emit('remainingDays', { days: remainingDaysUntilRestriction });
|
||||
}
|
||||
}
|
||||
|
||||
const airGappedRestriction = new AirGappedRestrictionClass();
|
||||
|
||||
export { airGappedRestriction as AirGappedRestriction };
|
||||
280
ee/packages/license/src/MockedLicenseBuilder.ts
Normal file
280
ee/packages/license/src/MockedLicenseBuilder.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { CoreModules } from '@rocket.chat/core-typings';
|
||||
import type {
|
||||
InternalModuleName,
|
||||
GrantedModules,
|
||||
ILicenseTag,
|
||||
ILicenseV3,
|
||||
LicenseLimit,
|
||||
LicenseModule,
|
||||
LicensePeriod,
|
||||
} from '@rocket.chat/core-typings';
|
||||
|
||||
import { encrypt, encryptStatsToken } from './token';
|
||||
|
||||
export class MockedLicenseBuilder {
|
||||
information: {
|
||||
id?: string;
|
||||
autoRenew: boolean;
|
||||
visualExpiration?: string;
|
||||
notifyAdminsAt?: string;
|
||||
notifyUsersAt?: string;
|
||||
trial: boolean;
|
||||
offline: boolean;
|
||||
createdAt: string;
|
||||
grantedBy: {
|
||||
method: 'manual' | 'self-service' | 'sales' | 'support' | 'reseller';
|
||||
seller?: string;
|
||||
};
|
||||
grantedTo?: {
|
||||
name?: string;
|
||||
company?: string;
|
||||
email?: string;
|
||||
};
|
||||
legalText?: string;
|
||||
notes?: string;
|
||||
tags?: ILicenseTag[];
|
||||
};
|
||||
|
||||
validation: {
|
||||
serverUrls: {
|
||||
value: string;
|
||||
type: 'url' | 'regex' | 'hash';
|
||||
}[];
|
||||
|
||||
serverVersions?: {
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
serverUniqueId?: string;
|
||||
cloudWorkspaceId?: string;
|
||||
validPeriods: LicensePeriod[];
|
||||
legalTextAgreement?: {
|
||||
type: 'required' | 'not-required' | 'accepted';
|
||||
acceptedVia?: 'cloud';
|
||||
};
|
||||
|
||||
statisticsReport: {
|
||||
required: boolean;
|
||||
allowedStaleInDays?: number;
|
||||
};
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.information = {
|
||||
autoRenew: true,
|
||||
// expires in 1 year
|
||||
visualExpiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString(),
|
||||
// 15 days before expiration
|
||||
notifyAdminsAt: new Date(new Date().setDate(new Date().getDate() + 15)).toISOString(),
|
||||
// 30 days before expiration
|
||||
notifyUsersAt: new Date(new Date().setDate(new Date().getDate() + 30)).toISOString(),
|
||||
trial: false,
|
||||
offline: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
grantedBy: {
|
||||
method: 'manual',
|
||||
seller: 'Rocket.Cat',
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Test',
|
||||
color: 'blue',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.validation = {
|
||||
serverUrls: [
|
||||
{
|
||||
value: 'localhost:3000',
|
||||
type: 'url',
|
||||
},
|
||||
],
|
||||
serverVersions: [
|
||||
{
|
||||
value: '3.0.0',
|
||||
},
|
||||
],
|
||||
|
||||
serverUniqueId: '1234567890',
|
||||
cloudWorkspaceId: '1234567890',
|
||||
|
||||
validPeriods: [
|
||||
{
|
||||
invalidBehavior: 'disable_modules',
|
||||
modules: ['livechat-enterprise'],
|
||||
validFrom: new Date(new Date().setFullYear(new Date().getFullYear() - 1)).toISOString(),
|
||||
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString(),
|
||||
},
|
||||
],
|
||||
|
||||
statisticsReport: {
|
||||
required: true,
|
||||
allowedStaleInDays: 30,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public withCreatedAt(date: Date): this {
|
||||
this.information.createdAt = date.toISOString();
|
||||
return this;
|
||||
}
|
||||
|
||||
public withExpiredDate(): this {
|
||||
// expired 1 minute ago
|
||||
const date = new Date();
|
||||
date.setMinutes(date.getMinutes() - 1);
|
||||
const expired = date.toISOString();
|
||||
|
||||
const rule = this.validation.validPeriods.find((period) => period.invalidBehavior === 'invalidate_license');
|
||||
|
||||
if (rule) {
|
||||
rule.validUntil = expired;
|
||||
} else {
|
||||
this.validation.validPeriods.push({
|
||||
invalidBehavior: 'invalidate_license',
|
||||
validFrom: new Date(new Date().setFullYear(new Date().getFullYear() - 1)).toISOString(),
|
||||
validUntil: expired,
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public withNotStartedDate(): this {
|
||||
// starts in 1 minute
|
||||
const date = new Date();
|
||||
date.setMinutes(date.getMinutes() + 1);
|
||||
const starts = date.toISOString();
|
||||
|
||||
const rule = this.validation.validPeriods.find((period) => period.invalidBehavior === 'invalidate_license');
|
||||
|
||||
if (rule) {
|
||||
rule.validFrom = starts;
|
||||
} else {
|
||||
this.validation.validPeriods.push({
|
||||
invalidBehavior: 'invalidate_license',
|
||||
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString(),
|
||||
validFrom: starts,
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public resetValidPeriods(): this {
|
||||
this.validation.validPeriods = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
public withValidPeriod(period: LicensePeriod): this {
|
||||
this.validation.validPeriods.push(period);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withGrantedTo(grantedTo: { name?: string; company?: string; email?: string }): this {
|
||||
this.information.grantedTo = grantedTo;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withOffline(offline = true): this {
|
||||
this.information.offline = offline;
|
||||
return this;
|
||||
}
|
||||
|
||||
grantedModules: GrantedModules = [];
|
||||
|
||||
limits: {
|
||||
activeUsers?: LicenseLimit[];
|
||||
guestUsers?: LicenseLimit[];
|
||||
roomsPerGuest?: LicenseLimit<'prevent_action'>[];
|
||||
privateApps?: LicenseLimit[];
|
||||
marketplaceApps?: LicenseLimit[];
|
||||
monthlyActiveContacts?: LicenseLimit[];
|
||||
} = {};
|
||||
|
||||
cloudMeta?: Record<string, any>;
|
||||
|
||||
public withServerUrls(urls: { value: string; type: 'url' | 'regex' | 'hash' }): this {
|
||||
this.validation.serverUrls = this.validation.serverUrls ?? [];
|
||||
this.validation.serverUrls.push(urls);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withServerVersions(versions: { value: string }): this {
|
||||
this.validation.serverVersions = this.validation.serverVersions ?? [];
|
||||
this.validation.serverVersions.push(versions);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withGrantedModules(modules: LicenseModule[]): this {
|
||||
this.grantedModules = this.grantedModules ?? [];
|
||||
this.grantedModules.push(
|
||||
...(modules.map((module) => ({ module, external: !CoreModules.includes(module as InternalModuleName) })) as GrantedModules),
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
withNoGrantedModules(modules: LicenseModule[]): this {
|
||||
this.grantedModules = this.grantedModules ?? [];
|
||||
this.grantedModules = this.grantedModules.filter(({ module }) => !modules.includes(module));
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLimits<K extends keyof ILicenseV3['limits']>(key: K, value: ILicenseV3['limits'][K]): this {
|
||||
this.limits = this.limits ?? {};
|
||||
this.limits[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): ILicenseV3 {
|
||||
return {
|
||||
version: '3.0',
|
||||
information: this.information,
|
||||
validation: this.validation,
|
||||
grantedModules: [...new Set(this.grantedModules)],
|
||||
limits: {
|
||||
activeUsers: [],
|
||||
guestUsers: [],
|
||||
roomsPerGuest: [],
|
||||
privateApps: [],
|
||||
marketplaceApps: [],
|
||||
monthlyActiveContacts: [],
|
||||
...this.limits,
|
||||
},
|
||||
cloudMeta: this.cloudMeta,
|
||||
};
|
||||
}
|
||||
|
||||
public sign(): Promise<string> {
|
||||
return encrypt(this.build());
|
||||
}
|
||||
}
|
||||
|
||||
export class StatsTokenBuilder {
|
||||
private token: Record<string, any>;
|
||||
|
||||
constructor() {
|
||||
this.token = {
|
||||
workspaceId: '123456789',
|
||||
uniqueId: '123456789',
|
||||
recordId: '123456789',
|
||||
timestamp: new Date().toISOString(),
|
||||
info: {},
|
||||
};
|
||||
}
|
||||
|
||||
public withTimeStamp(date: Date): StatsTokenBuilder {
|
||||
this.token.timestamp = date.toISOString();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): Record<string, any> {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
public sign(): Promise<string> {
|
||||
return encryptStatsToken(this.token);
|
||||
}
|
||||
}
|
||||
73
ee/packages/license/src/applyLicense.ts
Normal file
73
ee/packages/license/src/applyLicense.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { getLicenseCreatedAt } from './getLicenseCreatedAt';
|
||||
import type { LicenseImp } from './licenseImp';
|
||||
import { License } from './licenseImp';
|
||||
|
||||
export const applyLicense = async (license: string, isNewLicense: boolean, manager: LicenseImp = License): Promise<boolean> => {
|
||||
const enterpriseLicense = (license ?? '').trim();
|
||||
if (!enterpriseLicense) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enterpriseLicense === manager.encryptedLicense) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return manager.setLicense(enterpriseLicense, isNewLicense);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const applyLicenseOrRemove = async (license: string, isNewLicense: boolean, manager: LicenseImp = License): Promise<boolean> => {
|
||||
const enterpriseLicense = (license ?? '').trim();
|
||||
if (!enterpriseLicense) {
|
||||
manager.remove();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enterpriseLicense === manager.encryptedLicense) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return manager.setLicense(enterpriseLicense, isNewLicense);
|
||||
} catch {
|
||||
manager.remove();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the most recently issued of the given licenses, falling back to the
|
||||
* next one whenever the newest doesn't result in a valid license (note that
|
||||
* `setLicense` reports success even for licenses applied in an invalid/expired
|
||||
* state, so validity — not the apply result — drives the fallback).
|
||||
*
|
||||
* The first license is the one already known to the workspace (applied as not
|
||||
* new); the others are externally supplied, e.g. through an environment
|
||||
* variable (applied as new). Issue dates come from signature-verified payloads
|
||||
* only — licenses without one (V2, invalid or empty strings) sort as oldest,
|
||||
* preserving the given order among themselves.
|
||||
*
|
||||
* Returns whether the workspace ended up with a valid license.
|
||||
*/
|
||||
export const applyNewestLicense = async (manager: LicenseImp = License, ...licenses: Array<string>): Promise<boolean> => {
|
||||
const candidates = licenses
|
||||
.map((license, index) => ({ license: (license ?? '').trim(), isNewLicense: index > 0 }))
|
||||
.filter(({ license }) => license);
|
||||
|
||||
const sortedLicenses = (
|
||||
await Promise.all(candidates.map(async (candidate) => ({ ...candidate, createdAt: await getLicenseCreatedAt(candidate.license) })))
|
||||
).sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0));
|
||||
|
||||
for (const { license, isNewLicense } of sortedLicenses) {
|
||||
await applyLicense(license, isNewLicense, manager);
|
||||
|
||||
if (manager.hasValidLicense()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return manager.hasValidLicense();
|
||||
};
|
||||
39
ee/packages/license/src/deprecated.ts
Normal file
39
ee/packages/license/src/deprecated.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ILicenseV3, LicenseLimitKind } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from './license';
|
||||
import { getModules } from './modules';
|
||||
import { defaultLimits } from './validation/validateDefaultLimits';
|
||||
|
||||
export const getLicenseLimit = (license: ILicenseV3 | undefined, kind: LicenseLimitKind) => {
|
||||
const limitList = license ? license.limits[kind] : defaultLimits[kind as keyof typeof defaultLimits];
|
||||
|
||||
if (!limitList?.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return Math.min(...limitList.map(({ max }) => max));
|
||||
};
|
||||
|
||||
// #TODO: Remove references to those functions
|
||||
|
||||
export function getMaxActiveUsers(this: LicenseManager) {
|
||||
return getLicenseLimit(this.getLicense(), 'activeUsers') ?? 0;
|
||||
}
|
||||
|
||||
export function getAppsConfig(this: LicenseManager) {
|
||||
return {
|
||||
maxPrivateApps: getLicenseLimit(this.getLicense(), 'privateApps'),
|
||||
maxMarketplaceApps: getLicenseLimit(this.getLicense(), 'marketplaceApps'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getUnmodifiedLicenseAndModules(this: LicenseManager) {
|
||||
if (this.valid && this.unmodifiedLicense) {
|
||||
return {
|
||||
license: this.unmodifiedLicense,
|
||||
modules: getModules.call(this),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
6
ee/packages/license/src/errors/DuplicatedLicenseError.ts
Normal file
6
ee/packages/license/src/errors/DuplicatedLicenseError.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class DuplicatedLicenseError extends Error {
|
||||
constructor(message = 'Duplicated license') {
|
||||
super(message);
|
||||
this.name = 'DuplicatedLicense';
|
||||
}
|
||||
}
|
||||
6
ee/packages/license/src/errors/InvalidLicenseError.ts
Normal file
6
ee/packages/license/src/errors/InvalidLicenseError.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InvalidLicenseError extends Error {
|
||||
constructor(message = 'Invalid license') {
|
||||
super(message);
|
||||
this.name = 'InvalidLicenseError';
|
||||
}
|
||||
}
|
||||
6
ee/packages/license/src/errors/NotReadyForValidation.ts
Normal file
6
ee/packages/license/src/errors/NotReadyForValidation.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class NotReadyForValidation extends Error {
|
||||
constructor(message = 'Not ready for validation') {
|
||||
super(message);
|
||||
this.name = 'NotReadyForValidation';
|
||||
}
|
||||
}
|
||||
13
ee/packages/license/src/events/deprecated.ts
Normal file
13
ee/packages/license/src/events/deprecated.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { LicenseModule } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from '../license';
|
||||
import { hasModule } from '../modules';
|
||||
|
||||
// #TODO: Remove this onLicense handler
|
||||
export function onLicense(this: LicenseManager, feature: LicenseModule, cb: (...args: any[]) => void): void | Promise<void> {
|
||||
if (hasModule.call(this, feature)) {
|
||||
return cb();
|
||||
}
|
||||
|
||||
this.once(`valid:${feature}`, cb);
|
||||
}
|
||||
93
ee/packages/license/src/events/emitter.ts
Normal file
93
ee/packages/license/src/events/emitter.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { BehaviorWithContext, LicenseModule } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from '../license';
|
||||
import { logger } from '../logger';
|
||||
import { isInternalModuleName } from '../modules';
|
||||
|
||||
export function moduleValidated(this: LicenseManager, module: LicenseModule) {
|
||||
try {
|
||||
const external = !isInternalModuleName(module);
|
||||
|
||||
this.emit('module', { module, external, valid: true });
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running module (valid: true) event', module, err });
|
||||
}
|
||||
|
||||
try {
|
||||
this.emit(`valid:${module}`);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running module added event', module, err });
|
||||
}
|
||||
}
|
||||
|
||||
export function moduleRemoved(this: LicenseManager, module: LicenseModule) {
|
||||
try {
|
||||
const external = !isInternalModuleName(module);
|
||||
|
||||
this.emit('module', { module, external, valid: false });
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running module (valid: false) event', module, err });
|
||||
}
|
||||
|
||||
try {
|
||||
this.emit(`invalid:${module}`);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running module removed event', module, err });
|
||||
}
|
||||
}
|
||||
|
||||
export function behaviorTriggered(this: LicenseManager, options: BehaviorWithContext) {
|
||||
const { behavior, reason, modules: _, ...rest } = options;
|
||||
|
||||
try {
|
||||
this.emit(`behavior:${behavior}`, {
|
||||
reason,
|
||||
...rest,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running behavior triggered event', behavior, err });
|
||||
}
|
||||
|
||||
if (!['prevent_action'].includes(behavior)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason !== 'limit' || !(`limit` in rest) || !rest.limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.emit(`limitReached:${rest.limit}`);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running limit reached event', limit: rest.limit, err });
|
||||
}
|
||||
}
|
||||
|
||||
export function behaviorTriggeredToggled(this: LicenseManager, options: BehaviorWithContext) {
|
||||
const { behavior, reason, modules: _, ...rest } = options;
|
||||
|
||||
try {
|
||||
this.emit(`behaviorToggled:${behavior}`, {
|
||||
reason,
|
||||
...rest,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running behavior triggered event', behavior, err });
|
||||
}
|
||||
}
|
||||
|
||||
export function licenseValidated(this: LicenseManager) {
|
||||
try {
|
||||
this.emit('validate');
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running license validated event', err });
|
||||
}
|
||||
}
|
||||
|
||||
export function licenseInvalidated(this: LicenseManager) {
|
||||
try {
|
||||
this.emit('invalidate');
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error running license invalidated event', err });
|
||||
}
|
||||
}
|
||||
116
ee/packages/license/src/events/listeners.ts
Normal file
116
ee/packages/license/src/events/listeners.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { LicenseLimitKind, LicenseModule, BehaviorWithContext, LicenseBehavior } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from '../license';
|
||||
import { hasModule } from '../modules';
|
||||
|
||||
/**
|
||||
* Invoked when the license changes some internal state. it's called to sync the license with other instances.
|
||||
*/
|
||||
export function onChange(this: LicenseManager, cb: () => void) {
|
||||
this.on('sync', cb);
|
||||
}
|
||||
|
||||
export function onInstall(this: LicenseManager, cb: () => void) {
|
||||
if (this.hasValidLicense()) {
|
||||
cb();
|
||||
}
|
||||
this.on('installed', cb);
|
||||
}
|
||||
|
||||
export function onRemoveLicense(this: LicenseManager, cb: () => void) {
|
||||
this.on('removed', cb);
|
||||
}
|
||||
|
||||
export function onInvalidate(this: LicenseManager, cb: () => void) {
|
||||
if (!this.hasValidLicense()) {
|
||||
cb();
|
||||
}
|
||||
this.on('invalidate', cb);
|
||||
}
|
||||
|
||||
export function onValidFeature(this: LicenseManager, feature: LicenseModule, cb: () => void) {
|
||||
this.on(`valid:${feature}`, cb);
|
||||
|
||||
if (hasModule.call(this, feature)) {
|
||||
cb();
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
this.off(`valid:${feature}`, cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function onInvalidFeature(this: LicenseManager, feature: LicenseModule, cb: () => void) {
|
||||
this.on(`invalid:${feature}`, cb);
|
||||
|
||||
if (!hasModule.call(this, feature)) {
|
||||
cb();
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
this.off(`invalid:${feature}`, cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function onToggledFeature(
|
||||
this: LicenseManager,
|
||||
feature: LicenseModule,
|
||||
{ up, down }: { up?: () => Promise<void> | void; down?: () => Promise<void> | void },
|
||||
): () => void {
|
||||
let enabled = hasModule.call(this, feature);
|
||||
|
||||
const offValidFeature = onValidFeature.bind(this)(feature, () => {
|
||||
if (!enabled) {
|
||||
void up?.();
|
||||
enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
const offInvalidFeature = onInvalidFeature.bind(this)(feature, () => {
|
||||
if (enabled) {
|
||||
void down?.();
|
||||
enabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
void up?.();
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
offValidFeature();
|
||||
offInvalidFeature();
|
||||
};
|
||||
}
|
||||
|
||||
export function onModule(this: LicenseManager, cb: (data: { module: LicenseModule; external: boolean; valid: boolean }) => void) {
|
||||
this.on('module', cb);
|
||||
}
|
||||
|
||||
export function onValidateLicense(this: LicenseManager, cb: () => void) {
|
||||
this.on('validate', cb);
|
||||
}
|
||||
|
||||
export function onInvalidateLicense(this: LicenseManager, cb: () => void) {
|
||||
this.on('invalidate', cb);
|
||||
}
|
||||
|
||||
export function onBehaviorTriggered(
|
||||
this: LicenseManager,
|
||||
behavior: Exclude<LicenseBehavior, 'prevent_installation'>,
|
||||
cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void,
|
||||
) {
|
||||
this.on(`behavior:${behavior}`, cb);
|
||||
}
|
||||
|
||||
export function onBehaviorToggled(
|
||||
this: LicenseManager,
|
||||
behavior: Exclude<LicenseBehavior, 'prevent_installation'>,
|
||||
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);
|
||||
}
|
||||
27
ee/packages/license/src/events/overwriteClassOnLicense.ts
Normal file
27
ee/packages/license/src/events/overwriteClassOnLicense.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { LicenseModule } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from '../license';
|
||||
import { onLicense } from './deprecated';
|
||||
|
||||
interface IOverrideClassProperties {
|
||||
[key: string]: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
type Class = { new (...args: any[]): any };
|
||||
|
||||
export async function overwriteClassOnLicense(
|
||||
this: LicenseManager,
|
||||
|
||||
license: LicenseModule,
|
||||
original: Class,
|
||||
overwrite: IOverrideClassProperties,
|
||||
): Promise<void> {
|
||||
await onLicense.call(this, license, () => {
|
||||
Object.entries(overwrite).forEach(([key, value]) => {
|
||||
const originalFn = original.prototype[key];
|
||||
original.prototype[key] = function (...args: any[]): any {
|
||||
return value.call(this, originalFn, ...args);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
29
ee/packages/license/src/getLicenseCreatedAt.ts
Normal file
29
ee/packages/license/src/getLicenseCreatedAt.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ILicenseV3 } from '@rocket.chat/core-typings';
|
||||
|
||||
import { decrypt } from './token';
|
||||
|
||||
/**
|
||||
* Returns the signature-verified `information.createdAt` of an encrypted license,
|
||||
* without applying it.
|
||||
*
|
||||
* Returns `undefined` when the string is empty or invalid, or when the license
|
||||
* carries no issue date (V2 licenses) — callers ordering licenses by age should
|
||||
* treat those as the oldest.
|
||||
*/
|
||||
export async function getLicenseCreatedAt(encryptedLicense: string): Promise<Date | undefined> {
|
||||
const license = (encryptedLicense ?? '').trim();
|
||||
|
||||
if (!license.startsWith('RCV3_')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted: ILicenseV3 = JSON.parse(await decrypt(license));
|
||||
|
||||
const createdAt = new Date(decrypted.information.createdAt);
|
||||
|
||||
return Number.isNaN(createdAt.getTime()) ? undefined : createdAt;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
27
ee/packages/license/src/getLicenseLimit.spec.ts
Normal file
27
ee/packages/license/src/getLicenseLimit.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { LicenseImp } from '.';
|
||||
import { getAppsConfig } from './deprecated';
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder';
|
||||
|
||||
describe('Marketplace Restrictions', () => {
|
||||
it('should respect the default if there is no license applied', async () => {
|
||||
const LicenseManager = new LicenseImp();
|
||||
|
||||
expect(getAppsConfig.call(LicenseManager)).toEqual({
|
||||
maxPrivateApps: 0,
|
||||
maxMarketplaceApps: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return unlimited if there is license but no limits', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder();
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
await expect(getAppsConfig.call(licenseManager)).toEqual({
|
||||
maxPrivateApps: -1,
|
||||
maxMarketplaceApps: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
7
ee/packages/license/src/index.ts
Normal file
7
ee/packages/license/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { DuplicatedLicenseError } from './errors/DuplicatedLicenseError';
|
||||
export * from './licenseImp';
|
||||
export * from './license';
|
||||
export * from './MockedLicenseBuilder';
|
||||
export * from './applyLicense';
|
||||
export * from './getLicenseCreatedAt';
|
||||
export * from './AirGappedRestriction';
|
||||
10
ee/packages/license/src/isItemAllowed.ts
Normal file
10
ee/packages/license/src/isItemAllowed.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { LicenseLimitKind, LicenseBehavior, LicenseValidationOptions } from '@rocket.chat/core-typings';
|
||||
|
||||
const isItemAllowed = <T>(item: T, allowList?: T[]): boolean => {
|
||||
return !allowList || allowList.includes(item);
|
||||
};
|
||||
|
||||
export const isLimitAllowed = (item: LicenseLimitKind, options: LicenseValidationOptions): boolean => isItemAllowed(item, options.limits);
|
||||
|
||||
export const isBehaviorAllowed = (item: LicenseBehavior, options: LicenseValidationOptions): boolean =>
|
||||
isItemAllowed(item, options.behaviors) && (options.isNewLicense || item !== 'prevent_installation');
|
||||
537
ee/packages/license/src/license.spec.ts
Normal file
537
ee/packages/license/src/license.spec.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { LicenseImp } from '.';
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder';
|
||||
|
||||
it('should not prevent if there is no license', async () => {
|
||||
const license = await getReadyLicenseManager();
|
||||
const result = await license.shouldPreventAction('activeUsers');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not prevent if the counter is under the limit', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should not prevent actions if there is no limit set in the license', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder();
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
|
||||
licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 5);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should prevent if the counter is equal or over the limit', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
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);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('should not prevent an action if another limit is over the limit', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder()
|
||||
.withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
])
|
||||
.withLimits('monthlyActiveContacts', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
|
||||
licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 2);
|
||||
await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
describe('Validate License Limits', () => {
|
||||
describe('prevent_action behavior', () => {
|
||||
describe('during the licensing apply', () => {
|
||||
it('should not trigger the event even if the counter is over the limit', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const preventActionCallback = jest.fn();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
]);
|
||||
|
||||
licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback);
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('fair usage behavior', () => {
|
||||
it('should change the `prevent_action` flag to true if the counter is equal or over the limit', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const fairUsageCallback = jest.fn();
|
||||
const preventActionCallback = jest.fn();
|
||||
|
||||
licenseManager.onBehaviorTriggered('start_fair_policy', fairUsageCallback);
|
||||
licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback);
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'start_fair_policy',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
preventActionCallback.mockClear();
|
||||
fairUsageCallback.mockClear();
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(1);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
|
||||
preventActionCallback.mockClear();
|
||||
fairUsageCallback.mockClear();
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(4);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('should trigger the toggle event if the counter is under the limit', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const fairUsageCallback = jest.fn();
|
||||
const preventActionCallback = jest.fn();
|
||||
|
||||
licenseManager.onBehaviorToggled('start_fair_policy', fairUsageCallback);
|
||||
licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback);
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: 12,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'start_fair_policy',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 12);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(1);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
preventActionCallback.mockClear();
|
||||
fairUsageCallback.mockClear();
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 12);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
preventActionCallback.mockClear();
|
||||
fairUsageCallback.mockClear();
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(1);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate_license behavior', () => {
|
||||
it('should invalidate the license if the counter is over the limit', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const invalidateCallback = jest.fn();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'invalidate_license',
|
||||
},
|
||||
]);
|
||||
|
||||
licenseManager.on('invalidate', invalidateCallback);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
await expect(licenseManager.hasValidLicense()).toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
await expect(licenseManager.hasValidLicense()).toBe(true);
|
||||
|
||||
await licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
await expect(licenseManager.hasValidLicense()).toBe(true);
|
||||
expect(invalidateCallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
await licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
await expect(licenseManager.hasValidLicense()).toBe(false);
|
||||
expect(invalidateCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prevent action for future limits', () => {
|
||||
it('should prevent if the counter plus the extra value is equal or over the limit', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
]);
|
||||
|
||||
const fairUsageCallback = jest.fn();
|
||||
const preventActionCallback = jest.fn();
|
||||
|
||||
licenseManager.onBehaviorTriggered('start_fair_policy', fairUsageCallback);
|
||||
licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
for await (const extraCount of [1, 2, 3, 4, 5]) {
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers', extraCount)).resolves.toBe(false);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* if we are testing the current count 10 should prevent the action, if we are testing the future count 10 should not prevent the action but 11
|
||||
*/
|
||||
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers', 6)).resolves.toBe(true);
|
||||
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
|
||||
expect(preventActionCallback).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should check roomsPerGuest with per-user context', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
const license = new MockedLicenseBuilder().withLimits('roomsPerGuest', [
|
||||
{
|
||||
max: 3,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('roomsPerGuest', (context) => {
|
||||
switch (context?.userId) {
|
||||
case 'user1':
|
||||
return 2;
|
||||
case 'user2':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
await expect(licenseManager.shouldPreventAction('roomsPerGuest', 1, { userId: 'user1' })).resolves.toBe(false);
|
||||
await expect(licenseManager.shouldPreventAction('roomsPerGuest', 1, { userId: 'user2' })).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('License.getInfo', () => {
|
||||
describe('Marketplace Restrictions', () => {
|
||||
it('should respect the default if there is no license applied', async () => {
|
||||
const licenseManager = new LicenseImp();
|
||||
|
||||
licenseManager.setLicenseLimitCounter('privateApps', () => 0);
|
||||
licenseManager.setLicenseLimitCounter('marketplaceApps', () => 0);
|
||||
|
||||
expect(
|
||||
(
|
||||
await licenseManager.getInfo({
|
||||
limits: true,
|
||||
currentValues: false,
|
||||
license: false,
|
||||
})
|
||||
).limits,
|
||||
).toMatchObject({
|
||||
privateApps: { max: 0 },
|
||||
marketplaceApps: { max: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return unlimited if there is license but no limits', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder();
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
(
|
||||
await licenseManager.getInfo({
|
||||
limits: true,
|
||||
currentValues: false,
|
||||
license: false,
|
||||
})
|
||||
).limits,
|
||||
).toMatchObject({
|
||||
privateApps: { max: -1 },
|
||||
marketplaceApps: { max: -1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('License.setLicense', () => {
|
||||
it('should trigger the validate event even if the module callback throws an error', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const validateCallback = jest.fn();
|
||||
const moduleCallback = jest.fn(() => {
|
||||
throw new Error('Error');
|
||||
});
|
||||
|
||||
const syncCallback = jest.fn();
|
||||
|
||||
licenseManager.on('validate', validateCallback);
|
||||
licenseManager.on('sync', syncCallback);
|
||||
licenseManager.on('module', moduleCallback);
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing']);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
expect(validateCallback).toHaveBeenCalledTimes(1);
|
||||
expect(moduleCallback).toHaveBeenCalledTimes(1);
|
||||
expect(syncCallback).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should trigger the sync event only from the sync method', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const validateCallback = jest.fn();
|
||||
const moduleCallback = jest.fn();
|
||||
const syncCallback = jest.fn();
|
||||
|
||||
licenseManager.on('validate', validateCallback);
|
||||
licenseManager.on('sync', syncCallback);
|
||||
licenseManager.on('module', moduleCallback);
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing']).withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'disable_modules',
|
||||
modules: ['auditing'],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true);
|
||||
|
||||
expect(validateCallback).toHaveBeenCalledTimes(1);
|
||||
expect(moduleCallback).toHaveBeenCalledTimes(1);
|
||||
expect(syncCallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
validateCallback.mockClear();
|
||||
moduleCallback.mockClear();
|
||||
syncCallback.mockClear();
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
|
||||
await licenseManager.revalidateLicense();
|
||||
|
||||
expect(validateCallback).toHaveBeenCalledTimes(1);
|
||||
expect(moduleCallback).toHaveBeenCalledTimes(1);
|
||||
expect(syncCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should trigger the sync event even if the module callback throws an error', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const validateCallback = jest.fn();
|
||||
const moduleCallback = jest.fn(() => {
|
||||
throw new Error('Error');
|
||||
});
|
||||
const syncCallback = jest.fn();
|
||||
|
||||
licenseManager.on('validate', validateCallback);
|
||||
licenseManager.on('sync', syncCallback);
|
||||
licenseManager.on('module', moduleCallback);
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing']).withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'disable_modules',
|
||||
modules: ['auditing'],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true);
|
||||
|
||||
expect(validateCallback).toHaveBeenCalledTimes(1);
|
||||
expect(moduleCallback).toHaveBeenCalledTimes(1);
|
||||
expect(syncCallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
validateCallback.mockClear();
|
||||
moduleCallback.mockClear();
|
||||
syncCallback.mockClear();
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
|
||||
await licenseManager.revalidateLicense();
|
||||
|
||||
expect(validateCallback).toHaveBeenCalledTimes(1);
|
||||
expect(moduleCallback).toHaveBeenCalledTimes(1);
|
||||
expect(syncCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('License.removeLicense', () => {
|
||||
it('should trigger the removed event', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const removeLicense = jest.fn();
|
||||
const moduleCallback = jest.fn();
|
||||
|
||||
licenseManager.on('removed', removeLicense);
|
||||
|
||||
licenseManager.onModule(moduleCallback);
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing', 'chat.rocket.test-addon']).withLimits('activeUsers', [
|
||||
{
|
||||
max: 10,
|
||||
behavior: 'disable_modules',
|
||||
modules: ['auditing'],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true);
|
||||
expect(removeLicense).toHaveBeenCalledTimes(0);
|
||||
expect(moduleCallback).toHaveBeenNthCalledWith(1, {
|
||||
module: 'auditing',
|
||||
valid: true,
|
||||
external: false,
|
||||
});
|
||||
expect(moduleCallback).toHaveBeenNthCalledWith(2, {
|
||||
module: 'chat.rocket.test-addon',
|
||||
valid: true,
|
||||
external: true,
|
||||
});
|
||||
|
||||
removeLicense.mockClear();
|
||||
moduleCallback.mockClear();
|
||||
licenseManager.remove();
|
||||
|
||||
expect(removeLicense).toHaveBeenCalledTimes(1);
|
||||
expect(moduleCallback).toHaveBeenNthCalledWith(1, {
|
||||
module: 'auditing',
|
||||
valid: false,
|
||||
external: false,
|
||||
});
|
||||
expect(moduleCallback).toHaveBeenNthCalledWith(2, {
|
||||
module: 'chat.rocket.test-addon',
|
||||
valid: false,
|
||||
external: true,
|
||||
});
|
||||
|
||||
expect(licenseManager.hasValidLicense()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Offline license', () => {
|
||||
it('should not report an offline license when no license is applied', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
expect(licenseManager.hasOfflineLicense()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not report an offline license for a standard license', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder();
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
expect(licenseManager.hasOfflineLicense()).toBe(false);
|
||||
});
|
||||
|
||||
it('should report an offline license when the license has the offline flag', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withOffline();
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
expect(licenseManager.hasOfflineLicense()).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop reporting an offline license once the license is removed', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withOffline();
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
expect(licenseManager.hasOfflineLicense()).toBe(true);
|
||||
|
||||
licenseManager.remove();
|
||||
expect(licenseManager.hasOfflineLicense()).toBe(false);
|
||||
});
|
||||
});
|
||||
643
ee/packages/license/src/license.ts
Normal file
643
ee/packages/license/src/license.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type {
|
||||
ILicenseTag,
|
||||
LicenseEvents,
|
||||
ILicenseV2,
|
||||
ILicenseV3,
|
||||
LicenseLimitKind,
|
||||
BehaviorWithContext,
|
||||
LicenseBehavior,
|
||||
LicenseInfo,
|
||||
LicenseValidationOptions,
|
||||
LimitContext,
|
||||
LicenseModule,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
|
||||
import { getLicenseLimit } from './deprecated';
|
||||
import type { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated';
|
||||
import { DuplicatedLicenseError } from './errors/DuplicatedLicenseError';
|
||||
import { InvalidLicenseError } from './errors/InvalidLicenseError';
|
||||
import { NotReadyForValidation } from './errors/NotReadyForValidation';
|
||||
import type { onLicense } from './events/deprecated';
|
||||
import { behaviorTriggered, behaviorTriggeredToggled, licenseInvalidated, licenseValidated } from './events/emitter';
|
||||
import type {
|
||||
onBehaviorTriggered,
|
||||
onInvalidFeature,
|
||||
onInvalidateLicense,
|
||||
onLimitReached,
|
||||
onModule,
|
||||
onToggledFeature,
|
||||
onValidFeature,
|
||||
onValidateLicense,
|
||||
} from './events/listeners';
|
||||
import type { overwriteClassOnLicense } from './events/overwriteClassOnLicense';
|
||||
import { logger } from './logger';
|
||||
import type { getModuleDefinition, hasModule } from './modules';
|
||||
import { getExternalModules, getModules, invalidateAll, replaceModules } from './modules';
|
||||
import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense';
|
||||
import type { getTags } from './tags';
|
||||
import { replaceTags } from './tags';
|
||||
import { decrypt } from './token';
|
||||
import { convertToV3 } from './v2/convertToV3';
|
||||
import { filterBehaviorsResult } from './validation/filterBehaviorsResult';
|
||||
import type { setLicenseLimitCounter } from './validation/getCurrentValueForLicenseLimit';
|
||||
import { getCurrentValueForLicenseLimit } from './validation/getCurrentValueForLicenseLimit';
|
||||
import { getModulesToDisable } from './validation/getModulesToDisable';
|
||||
import { isBehaviorsInResult } from './validation/isBehaviorsInResult';
|
||||
import { isReadyForValidation } from './validation/isReadyForValidation';
|
||||
import { runValidation } from './validation/runValidation';
|
||||
import { validateDefaultLimits } from './validation/validateDefaultLimits';
|
||||
import { validateFormat } from './validation/validateFormat';
|
||||
import { validateLicenseLimits } from './validation/validateLicenseLimits';
|
||||
|
||||
const globalLimitKinds: LicenseLimitKind[] = ['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts'];
|
||||
|
||||
// The behaviors that decide whether (and how) a license is installed. Shared between the actual
|
||||
// validation performed on apply and the dry-run preview so both stay in sync if a behavior is added.
|
||||
const licenseValidationBehaviors: LicenseBehavior[] = [
|
||||
'invalidate_license',
|
||||
'start_fair_policy',
|
||||
'prevent_installation',
|
||||
'disable_modules',
|
||||
];
|
||||
|
||||
export abstract class LicenseManager extends Emitter<LicenseEvents> {
|
||||
abstract validateFormat: typeof validateFormat;
|
||||
|
||||
abstract hasModule: typeof hasModule;
|
||||
|
||||
abstract getModules: typeof getModules;
|
||||
|
||||
abstract getModuleDefinition: typeof getModuleDefinition;
|
||||
|
||||
abstract getExternalModules: typeof getExternalModules;
|
||||
|
||||
abstract getTags: typeof getTags;
|
||||
|
||||
abstract overwriteClassOnLicense: typeof overwriteClassOnLicense;
|
||||
|
||||
abstract setLicenseLimitCounter: typeof setLicenseLimitCounter;
|
||||
|
||||
abstract getCurrentValueForLicenseLimit: typeof getCurrentValueForLicenseLimit;
|
||||
|
||||
abstract isLimitReached<T extends LicenseLimitKind>(action: T, context?: Partial<LimitContext<T>>): Promise<boolean>;
|
||||
|
||||
abstract onValidFeature: typeof onValidFeature;
|
||||
|
||||
abstract onInvalidFeature: typeof onInvalidFeature;
|
||||
|
||||
abstract onToggledFeature: typeof onToggledFeature;
|
||||
|
||||
abstract onModule: typeof onModule;
|
||||
|
||||
abstract onValidateLicense: typeof onValidateLicense;
|
||||
|
||||
abstract onInvalidateLicense: typeof onInvalidateLicense;
|
||||
|
||||
abstract onLimitReached: typeof onLimitReached;
|
||||
|
||||
abstract onBehaviorTriggered: typeof onBehaviorTriggered;
|
||||
|
||||
// Deprecated:
|
||||
abstract onLicense: typeof onLicense;
|
||||
|
||||
// Deprecated:
|
||||
abstract getMaxActiveUsers: typeof getMaxActiveUsers;
|
||||
|
||||
// Deprecated:
|
||||
abstract getAppsConfig: typeof getAppsConfig;
|
||||
|
||||
// Deprecated:
|
||||
abstract getUnmodifiedLicenseAndModules: typeof getUnmodifiedLicenseAndModules;
|
||||
|
||||
dataCounters = new Map<LicenseLimitKind, (context?: LimitContext<LicenseLimitKind>) => Promise<number>>();
|
||||
|
||||
pendingLicense = '';
|
||||
|
||||
tags = new Set<ILicenseTag>();
|
||||
|
||||
modules = new Set<LicenseModule>();
|
||||
|
||||
private workspaceUrl: string | undefined;
|
||||
|
||||
protected _license: ILicenseV3 | undefined;
|
||||
|
||||
private _unmodifiedLicense: ILicenseV2 | ILicenseV3 | undefined;
|
||||
|
||||
private _valid: boolean | undefined;
|
||||
|
||||
protected _lockedLicense: string | undefined;
|
||||
|
||||
private states = new Map<LicenseBehavior, Map<LicenseLimitKind, boolean>>();
|
||||
|
||||
public get shouldPreventActionResults() {
|
||||
const state = this.states.get('prevent_action') ?? new Map<LicenseLimitKind, boolean>();
|
||||
|
||||
this.states.set('prevent_action', state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public get license(): ILicenseV3 | undefined {
|
||||
return this._license;
|
||||
}
|
||||
|
||||
public get unmodifiedLicense(): ILicenseV2 | ILicenseV3 | undefined {
|
||||
return this._unmodifiedLicense;
|
||||
}
|
||||
|
||||
public get valid(): boolean | undefined {
|
||||
return this._valid;
|
||||
}
|
||||
|
||||
public get encryptedLicense(): string | undefined {
|
||||
if (!this.hasValidLicense()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this._lockedLicense;
|
||||
}
|
||||
|
||||
public async setWorkspaceUrl(url: string) {
|
||||
this.workspaceUrl = url.replace(/\/$/, '').replace(/^https?:\/\/(.*)$/, '$1');
|
||||
|
||||
if (hasPendingLicense.call(this)) {
|
||||
await applyPendingLicense.call(this);
|
||||
}
|
||||
}
|
||||
|
||||
public getWorkspaceUrl() {
|
||||
return this.workspaceUrl;
|
||||
}
|
||||
|
||||
public hashWorkspaceUrl(url: string) {
|
||||
return crypto.createHash('sha256').update(url).digest('hex');
|
||||
}
|
||||
|
||||
public getHashedWorkspaceUrl() {
|
||||
const workspaceUrl = this.getWorkspaceUrl();
|
||||
|
||||
if (!workspaceUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.hashWorkspaceUrl(workspaceUrl);
|
||||
}
|
||||
|
||||
public async revalidateLicense(options: Omit<LicenseValidationOptions, 'isNewLicense'> = {}): Promise<void> {
|
||||
if (!this.hasValidLicense()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.validateLicense({ ...options, isNewLicense: false, triggerSync: true });
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidLicenseError) {
|
||||
this.invalidateLicense();
|
||||
this.emit('sync');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sync method should be called when a license from a different instance is has changed, so the local instance
|
||||
* needs to be updated. This method will validate the license and update the local instance if the license is valid, but will not trigger the onSync event.
|
||||
*/
|
||||
|
||||
public async sync(options: Omit<LicenseValidationOptions, 'isNewLicense'> = {}): Promise<void> {
|
||||
if (!this.hasValidLicense()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.validateLicense({ ...options, isNewLicense: false, triggerSync: false });
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidLicenseError) {
|
||||
this.invalidateLicense();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearLicenseData(): void {
|
||||
this._license = undefined;
|
||||
this._unmodifiedLicense = undefined;
|
||||
this._valid = false;
|
||||
this._lockedLicense = undefined;
|
||||
|
||||
this.states.clear();
|
||||
clearPendingLicense.call(this);
|
||||
}
|
||||
|
||||
private invalidateLicense(): void {
|
||||
this._valid = false;
|
||||
this.states.clear();
|
||||
invalidateAll.call(this);
|
||||
licenseInvalidated.call(this);
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (!this._license) {
|
||||
return;
|
||||
}
|
||||
this.clearLicenseData();
|
||||
invalidateAll.call(this);
|
||||
this.emit('removed');
|
||||
}
|
||||
|
||||
private async setLicenseV3(
|
||||
newLicense: ILicenseV3,
|
||||
encryptedLicense: string,
|
||||
originalLicense?: ILicenseV2 | ILicenseV3,
|
||||
isNewLicense?: boolean,
|
||||
): Promise<void> {
|
||||
const hadValidLicense = this.hasValidLicense();
|
||||
this.clearLicenseData();
|
||||
|
||||
try {
|
||||
this._unmodifiedLicense = originalLicense || newLicense;
|
||||
this._license = newLicense;
|
||||
|
||||
this._lockedLicense = encryptedLicense;
|
||||
await this.validateLicense({ isNewLicense });
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidLicenseError) {
|
||||
if (hadValidLicense) {
|
||||
this.invalidateLicense();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async setLicenseV2(newLicense: ILicenseV2, encryptedLicense: string, isNewLicense?: boolean): Promise<void> {
|
||||
return this.setLicenseV3(convertToV3(newLicense), encryptedLicense, newLicense, isNewLicense);
|
||||
}
|
||||
|
||||
private isLicenseDuplicated(encryptedLicense: string): boolean {
|
||||
return Boolean(this._lockedLicense && this._lockedLicense === encryptedLicense);
|
||||
}
|
||||
|
||||
private async validateLicense(
|
||||
options: LicenseValidationOptions = {
|
||||
triggerSync: true,
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!this._license) {
|
||||
throw new InvalidLicenseError();
|
||||
}
|
||||
|
||||
if (!isReadyForValidation.call(this)) {
|
||||
throw new NotReadyForValidation();
|
||||
}
|
||||
|
||||
const validationResult = await runValidation.call(this, this._license, {
|
||||
behaviors: licenseValidationBehaviors,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (isBehaviorsInResult(validationResult, ['invalidate_license', 'prevent_installation'])) {
|
||||
throw new InvalidLicenseError();
|
||||
}
|
||||
|
||||
const shouldLogModules = !this._valid || options.isNewLicense;
|
||||
|
||||
this._valid = true;
|
||||
|
||||
if (this._license.information.tags) {
|
||||
replaceTags.call(this, this._license.information.tags);
|
||||
}
|
||||
|
||||
const disabledModules = getModulesToDisable(validationResult);
|
||||
const modulesToEnable = this._license.grantedModules.filter(({ module }) => !disabledModules.includes(module));
|
||||
|
||||
const modulesChanged = replaceModules.call(
|
||||
this,
|
||||
modulesToEnable.map(({ module }) => module),
|
||||
);
|
||||
|
||||
if (shouldLogModules || modulesChanged) {
|
||||
logger.log({ msg: 'License validated', modules: modulesToEnable });
|
||||
}
|
||||
|
||||
if (!options.isNewLicense) {
|
||||
this.triggerBehaviorEvents(validationResult);
|
||||
}
|
||||
|
||||
licenseValidated.call(this);
|
||||
|
||||
// If something changed in the license and the sync option is enabled, trigger a sync
|
||||
if (
|
||||
((!options.isNewLicense &&
|
||||
filterBehaviorsResult(validationResult, ['invalidate_license', 'start_fair_policy', 'prevent_installation'])) ||
|
||||
modulesChanged) &&
|
||||
options.triggerSync
|
||||
) {
|
||||
this.emit('sync');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a license against the current workspace state without applying it.
|
||||
*
|
||||
* Runs the same validation pipeline used when a license is set (URL, periods and limits),
|
||||
* but does not store the license, change validity, replace modules/tags nor emit events.
|
||||
* It is meant to power a preview of whether a license would be accepted before committing
|
||||
* to it from the admin UI.
|
||||
*
|
||||
* Returns whether the license would be accepted and, when not, the behaviors that reject it.
|
||||
* A malformed string is reported as invalid with no reasons rather than thrown, so callers can
|
||||
* treat every rejected license uniformly. Mirrors the apply path in throwing
|
||||
* `NotReadyForValidation` while the workspace can't validate yet.
|
||||
*/
|
||||
public async validateLicenseForPreview(
|
||||
encryptedLicense: string,
|
||||
): Promise<{ valid: true } | { valid: false; reasons: BehaviorWithContext[] }> {
|
||||
if (!isReadyForValidation.call(this)) {
|
||||
throw new NotReadyForValidation();
|
||||
}
|
||||
|
||||
let license: ILicenseV3;
|
||||
try {
|
||||
await validateFormat(encryptedLicense);
|
||||
|
||||
const decrypted = JSON.parse(await decrypt(encryptedLicense));
|
||||
license = encryptedLicense.startsWith('RCV3_') ? decrypted : convertToV3(decrypted);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Invalid raw license provided for validation preview', err });
|
||||
return { valid: false, reasons: [] };
|
||||
}
|
||||
|
||||
const validationResult = await runValidation.call(this, license, {
|
||||
behaviors: licenseValidationBehaviors,
|
||||
isNewLicense: true,
|
||||
suppressLog: true,
|
||||
});
|
||||
|
||||
const reasons = filterBehaviorsResult(validationResult, ['invalidate_license', 'prevent_installation']);
|
||||
|
||||
return reasons.length ? { valid: false, reasons } : { valid: true };
|
||||
}
|
||||
|
||||
public async setLicense(encryptedLicense: string, isNewLicense = true): Promise<boolean> {
|
||||
if (!(await validateFormat(encryptedLicense))) {
|
||||
throw new InvalidLicenseError();
|
||||
}
|
||||
|
||||
if (this.isLicenseDuplicated(encryptedLicense)) {
|
||||
// If there is a pending license but the user is trying to revert to the license that is currently active
|
||||
if (hasPendingLicense.call(this) && !isPendingLicense.call(this, encryptedLicense)) {
|
||||
// simply remove the pending license
|
||||
clearPendingLicense.call(this);
|
||||
throw new Error('Invalid license');
|
||||
}
|
||||
|
||||
/**
|
||||
* The license can be set with future minimum date, failing during the first set,
|
||||
* but if the user tries to set the same license again later it can be valid or not, so we need to check it again
|
||||
*/
|
||||
if (this.hasValidLicense()) {
|
||||
throw new DuplicatedLicenseError();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReadyForValidation.call(this)) {
|
||||
// If we can't validate the license data yet, but is a valid license string, store it to validate when we can
|
||||
setPendingLicense.call(this, encryptedLicense);
|
||||
throw new NotReadyForValidation();
|
||||
}
|
||||
|
||||
logger.info('New Enterprise License');
|
||||
try {
|
||||
const decrypted = JSON.parse(await decrypt(encryptedLicense));
|
||||
|
||||
logger.debug({ msg: 'license', decrypted });
|
||||
|
||||
if (!encryptedLicense.startsWith('RCV3_')) {
|
||||
await this.setLicenseV2(decrypted, encryptedLicense, isNewLicense);
|
||||
return true;
|
||||
}
|
||||
await this.setLicenseV3(decrypted, encryptedLicense, decrypted, isNewLicense);
|
||||
|
||||
this.emit('installed');
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Invalid raw license', encryptedLicense, err });
|
||||
|
||||
throw new InvalidLicenseError();
|
||||
}
|
||||
}
|
||||
|
||||
private triggerBehaviorEvents(validationResult: BehaviorWithContext[]): void {
|
||||
for (const { ...options } of validationResult) {
|
||||
behaviorTriggered.call(this, { ...options });
|
||||
}
|
||||
}
|
||||
|
||||
private triggerBehaviorEventsToggled(validationResult: BehaviorWithContext[]): void {
|
||||
for (const { ...options } of validationResult) {
|
||||
behaviorTriggeredToggled.call(this, { ...options });
|
||||
}
|
||||
}
|
||||
|
||||
public hasValidLicense(): boolean {
|
||||
return Boolean(this.getLicense());
|
||||
}
|
||||
|
||||
public getLicense(): ILicenseV3 | undefined {
|
||||
if (this._valid && this._license) {
|
||||
return this._license;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public hasOfflineLicense(): boolean {
|
||||
return this.getLicense()?.information.offline ?? false;
|
||||
}
|
||||
|
||||
public syncShouldPreventActionResults(actions: Record<LicenseLimitKind, boolean>): void {
|
||||
for (const [action, shouldPreventAction] of Object.entries(actions)) {
|
||||
this.shouldPreventActionResults.set(action as LicenseLimitKind, shouldPreventAction);
|
||||
}
|
||||
}
|
||||
|
||||
public async shouldPreventActionResultsMap(): Promise<{
|
||||
[key in LicenseLimitKind]: boolean;
|
||||
}> {
|
||||
const keys: LicenseLimitKind[] = [
|
||||
'activeUsers',
|
||||
'guestUsers',
|
||||
'roomsPerGuest',
|
||||
'privateApps',
|
||||
'marketplaceApps',
|
||||
'monthlyActiveContacts',
|
||||
];
|
||||
|
||||
const license = this.getLicense();
|
||||
|
||||
const items = await Promise.all(
|
||||
keys.map(async (limit) => {
|
||||
const cached = this.shouldPreventActionResults.get(limit);
|
||||
|
||||
if (cached !== undefined) {
|
||||
return [limit, cached];
|
||||
}
|
||||
|
||||
const fresh = license
|
||||
? isBehaviorsInResult(
|
||||
await validateLicenseLimits.call(this, license, {
|
||||
behaviors: ['prevent_action'],
|
||||
limits: [limit],
|
||||
}),
|
||||
['prevent_action'],
|
||||
)
|
||||
: isBehaviorsInResult(await validateDefaultLimits.call(this, { behaviors: ['prevent_action'], limits: [limit] }), [
|
||||
'prevent_action',
|
||||
]);
|
||||
|
||||
this.shouldPreventActionResults.set(limit, fresh);
|
||||
|
||||
return [limit, fresh];
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.fromEntries(items);
|
||||
}
|
||||
|
||||
public async shouldPreventAction<T extends LicenseLimitKind>(
|
||||
action: T,
|
||||
extraCount = 0,
|
||||
context: Partial<LimitContext<T>> = {},
|
||||
{ suppressLog }: Pick<LicenseValidationOptions, 'suppressLog'> = {
|
||||
suppressLog: process.env.LICENSE_VALIDATION_SUPPRESS_LOG !== 'false',
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const options: LicenseValidationOptions = {
|
||||
...(extraCount && { behaviors: ['prevent_action'] }),
|
||||
isNewLicense: false,
|
||||
suppressLog: !!suppressLog,
|
||||
limits: [action],
|
||||
context: {
|
||||
[action]: {
|
||||
extraCount,
|
||||
...context,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const license = this.getLicense();
|
||||
if (!license) {
|
||||
return isBehaviorsInResult(await validateDefaultLimits.call(this, options), ['prevent_action']);
|
||||
}
|
||||
|
||||
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 shouldPreventAction;
|
||||
}
|
||||
|
||||
// check if any of the behaviors that should trigger a sync changed
|
||||
if (
|
||||
(['invalidate_license', 'disable_modules', 'start_fair_policy'] as const).some((behavior) => {
|
||||
const hasChanged = this.consolidateBehaviorState(action, behavior, isBehaviorsInResult(validationResult, [behavior]));
|
||||
if (hasChanged && behavior === 'start_fair_policy') {
|
||||
this.triggerBehaviorEventsToggled([
|
||||
{
|
||||
behavior: 'start_fair_policy',
|
||||
reason: 'limit',
|
||||
limit: action,
|
||||
},
|
||||
]);
|
||||
}
|
||||
return hasChanged;
|
||||
})
|
||||
) {
|
||||
await this.revalidateLicense();
|
||||
}
|
||||
|
||||
const eventsToEmit = shouldPreventAction
|
||||
? filterBehaviorsResult(validationResult, ['prevent_action'])
|
||||
: [
|
||||
{
|
||||
behavior: 'allow_action',
|
||||
modules: [],
|
||||
reason: 'limit',
|
||||
limit: action,
|
||||
} as BehaviorWithContext,
|
||||
];
|
||||
|
||||
if (this.consolidateBehaviorState(action, 'prevent_action', shouldPreventAction)) {
|
||||
this.triggerBehaviorEventsToggled(eventsToEmit);
|
||||
}
|
||||
|
||||
this.triggerBehaviorEvents(eventsToEmit);
|
||||
|
||||
return shouldPreventAction;
|
||||
}
|
||||
|
||||
private consolidateBehaviorState<T extends LicenseLimitKind>(action: T, behavior: LicenseBehavior, triggered: boolean): boolean {
|
||||
// check if the behavior changed
|
||||
const state = this.states.get(behavior) ?? new Map<LicenseLimitKind, boolean>();
|
||||
|
||||
const currentState = state.get(action) ?? false;
|
||||
|
||||
if (currentState === triggered) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if it changed, update the state
|
||||
state.set(action, triggered);
|
||||
|
||||
this.states.set(behavior, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getInfo({
|
||||
limits: includeLimits,
|
||||
currentValues: loadCurrentValues,
|
||||
license: includeLicense,
|
||||
}: {
|
||||
limits: boolean;
|
||||
currentValues: boolean;
|
||||
license: boolean;
|
||||
}): Promise<LicenseInfo> {
|
||||
const activeModules = getModules.call(this);
|
||||
const externalModules = getExternalModules.call(this);
|
||||
const license = this.getLicense();
|
||||
|
||||
// Get all limits present in the license and their current value
|
||||
const limits = Object.fromEntries(
|
||||
(includeLimits &&
|
||||
(await Promise.all(
|
||||
globalLimitKinds
|
||||
.map((limitKey) => [limitKey, getLicenseLimit(license, limitKey)] as const)
|
||||
.map(async ([limitKey, max]) => {
|
||||
return [
|
||||
limitKey,
|
||||
{
|
||||
...(loadCurrentValues && { value: await getCurrentValueForLicenseLimit.call(this, limitKey) }),
|
||||
max,
|
||||
},
|
||||
];
|
||||
}),
|
||||
))) ||
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
license: (includeLicense && license) || undefined,
|
||||
activeModules,
|
||||
externalModules,
|
||||
preventedActions: await this.shouldPreventActionResultsMap(),
|
||||
limits: limits as Record<LicenseLimitKind, { max: number; value: number }>,
|
||||
tags: license?.information.tags || [],
|
||||
trial: Boolean(license?.information.trial),
|
||||
hasValidLicense: this.hasValidLicense(),
|
||||
};
|
||||
}
|
||||
}
|
||||
118
ee/packages/license/src/licenseImp.ts
Normal file
118
ee/packages/license/src/licenseImp.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { LicenseLimitKind, LimitContext } from '@rocket.chat/core-typings';
|
||||
|
||||
import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated';
|
||||
import { onLicense } from './events/deprecated';
|
||||
import {
|
||||
onBehaviorToggled,
|
||||
onBehaviorTriggered,
|
||||
onInvalidFeature,
|
||||
onInvalidateLicense,
|
||||
onLimitReached,
|
||||
onModule,
|
||||
onChange,
|
||||
onToggledFeature,
|
||||
onValidFeature,
|
||||
onValidateLicense,
|
||||
onInstall,
|
||||
onInvalidate,
|
||||
onRemoveLicense,
|
||||
} from './events/listeners';
|
||||
import { overwriteClassOnLicense } from './events/overwriteClassOnLicense';
|
||||
import { LicenseManager } from './license';
|
||||
import { logger } from './logger';
|
||||
import { getExternalModules, getModuleDefinition, getModules, hasModule } from './modules';
|
||||
import { showLicense } from './showLicense';
|
||||
import { getTags } from './tags';
|
||||
import { getCurrentValueForLicenseLimit, setLicenseLimitCounter } from './validation/getCurrentValueForLicenseLimit';
|
||||
import { validateFormat } from './validation/validateFormat';
|
||||
|
||||
export class LicenseImp extends LicenseManager {
|
||||
constructor() {
|
||||
super();
|
||||
this.onValidateLicense(() => showLicense.call(this, this.getLicense(), this.hasValidLicense()));
|
||||
|
||||
this.onValidateLicense(() => {
|
||||
logger.startup({
|
||||
msg: 'License installed',
|
||||
version: this.getLicense()?.version,
|
||||
hash: this._lockedLicense?.slice(-8),
|
||||
});
|
||||
});
|
||||
|
||||
this.onRemoveLicense(() => {
|
||||
logger.startup({
|
||||
msg: 'License removed',
|
||||
});
|
||||
});
|
||||
|
||||
this.onInvalidateLicense(() => {
|
||||
logger.startup({
|
||||
msg: 'License invalidated',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
validateFormat = validateFormat;
|
||||
|
||||
hasModule = hasModule;
|
||||
|
||||
getModules = getModules;
|
||||
|
||||
getModuleDefinition = getModuleDefinition;
|
||||
|
||||
getExternalModules = getExternalModules;
|
||||
|
||||
getTags = getTags;
|
||||
|
||||
overwriteClassOnLicense = overwriteClassOnLicense;
|
||||
|
||||
public setLicenseLimitCounter = setLicenseLimitCounter;
|
||||
|
||||
getCurrentValueForLicenseLimit = getCurrentValueForLicenseLimit;
|
||||
|
||||
public async isLimitReached<T extends LicenseLimitKind>(action: T, context?: Partial<LimitContext<T>>): Promise<boolean> {
|
||||
return this.shouldPreventAction(action, 0, context);
|
||||
}
|
||||
|
||||
onChange = onChange;
|
||||
|
||||
onInstall = onInstall;
|
||||
|
||||
onRemoveLicense = onRemoveLicense;
|
||||
|
||||
onInvalidate = onInvalidate;
|
||||
|
||||
onValidFeature = onValidFeature;
|
||||
|
||||
onInvalidFeature = onInvalidFeature;
|
||||
|
||||
onToggledFeature = onToggledFeature;
|
||||
|
||||
onModule = onModule;
|
||||
|
||||
onValidateLicense = onValidateLicense;
|
||||
|
||||
onInvalidateLicense = onInvalidateLicense;
|
||||
|
||||
onLimitReached = onLimitReached;
|
||||
|
||||
onBehaviorTriggered = onBehaviorTriggered;
|
||||
|
||||
onBehaviorToggled = onBehaviorToggled;
|
||||
|
||||
// Deprecated:
|
||||
onLicense = onLicense;
|
||||
|
||||
// Deprecated:
|
||||
getMaxActiveUsers = getMaxActiveUsers;
|
||||
|
||||
// Deprecated:
|
||||
getAppsConfig = getAppsConfig;
|
||||
|
||||
// Deprecated:
|
||||
getUnmodifiedLicenseAndModules = getUnmodifiedLicenseAndModules;
|
||||
}
|
||||
|
||||
const license = new LicenseImp();
|
||||
|
||||
export { license as License };
|
||||
3
ee/packages/license/src/logger.ts
Normal file
3
ee/packages/license/src/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('License');
|
||||
101
ee/packages/license/src/modules.spec.ts
Normal file
101
ee/packages/license/src/modules.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { LicenseModule } from '@rocket.chat/core-typings';
|
||||
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder';
|
||||
|
||||
describe('getModules', () => {
|
||||
it('should return internal and external', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const modules = ['auditing', 'livechat-enterprise', 'ldap-enterprise', 'chat.rocket.test-addon'] as LicenseModule[];
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(modules).sign();
|
||||
|
||||
await expect(licenseManager.setLicense(license)).resolves.toBe(true);
|
||||
|
||||
expect(licenseManager.getModules()).toContain('auditing');
|
||||
expect(licenseManager.getModules()).toContain('livechat-enterprise');
|
||||
expect(licenseManager.getModules()).toContain('ldap-enterprise');
|
||||
expect(licenseManager.getModules()).toContain('chat.rocket.test-addon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModuleDefinition', () => {
|
||||
it('should not return `external` property for an internal module', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing', 'chat.rocket.test-addon']).sign();
|
||||
|
||||
await licenseManager.setLicense(license);
|
||||
|
||||
const module = licenseManager.getModuleDefinition('auditing');
|
||||
|
||||
expect(module).toMatchObject({ module: 'auditing' });
|
||||
});
|
||||
|
||||
it('should return `undefined` for a non-existing module', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing', 'chat.rocket.test-addon']).sign();
|
||||
|
||||
await licenseManager.setLicense(license);
|
||||
|
||||
const module = licenseManager.getModuleDefinition('livechat-enterprise');
|
||||
|
||||
expect(module).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return `undefined` if there is no license available', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const module = licenseManager.getModuleDefinition('livechat-enterprise');
|
||||
|
||||
expect(module).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return `external` property for an external module', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing', 'chat.rocket.test-addon']).sign();
|
||||
|
||||
await licenseManager.setLicense(license);
|
||||
|
||||
const module = licenseManager.getModuleDefinition('chat.rocket.test-addon');
|
||||
|
||||
expect(module).toMatchObject({ module: 'chat.rocket.test-addon', external: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExternalModules', () => {
|
||||
it('should return only external modules', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing', 'chat.rocket.test-addon']).sign();
|
||||
|
||||
await licenseManager.setLicense(license);
|
||||
|
||||
const modules = licenseManager.getExternalModules();
|
||||
|
||||
expect(modules).toHaveLength(1);
|
||||
expect(modules[0]).toMatchObject({ external: true, module: 'chat.rocket.test-addon' });
|
||||
});
|
||||
|
||||
it('should return empty array if no external module is present', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing', 'livechat-enterprise']).sign();
|
||||
|
||||
await licenseManager.setLicense(license);
|
||||
|
||||
const modules = licenseManager.getExternalModules();
|
||||
|
||||
expect(modules).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array if license is not available', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const modules = licenseManager.getExternalModules();
|
||||
|
||||
expect(modules).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
83
ee/packages/license/src/modules.ts
Normal file
83
ee/packages/license/src/modules.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { LicenseModule, InternalModuleName, ExternalModule } from '@rocket.chat/core-typings';
|
||||
import { CoreModules } from '@rocket.chat/core-typings';
|
||||
|
||||
import { moduleRemoved, moduleValidated } from './events/emitter';
|
||||
import type { LicenseManager } from './license';
|
||||
|
||||
export function isInternalModuleName(module: string): module is InternalModuleName {
|
||||
return CoreModules.includes(module as InternalModuleName);
|
||||
}
|
||||
|
||||
export function notifyValidatedModules(this: LicenseManager, licenseModules: LicenseModule[]) {
|
||||
licenseModules.forEach((module) => {
|
||||
this.modules.add(module);
|
||||
moduleValidated.call(this, module);
|
||||
});
|
||||
}
|
||||
|
||||
export function notifyInvalidatedModules(this: LicenseManager, licenseModules: LicenseModule[]) {
|
||||
licenseModules.forEach((module) => {
|
||||
moduleRemoved.call(this, module);
|
||||
this.modules.delete(module);
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateAll(this: LicenseManager) {
|
||||
notifyInvalidatedModules.call(this, [...this.modules]);
|
||||
this.modules.clear();
|
||||
}
|
||||
|
||||
export function getModules(this: LicenseManager) {
|
||||
return [...this.modules];
|
||||
}
|
||||
|
||||
export function getModuleDefinition(this: LicenseManager, moduleName: LicenseModule) {
|
||||
const license = this.getLicense();
|
||||
|
||||
if (!license) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moduleDefinition = license.grantedModules.find(({ module }) => module === moduleName);
|
||||
|
||||
return moduleDefinition;
|
||||
}
|
||||
|
||||
export function getExternalModules(this: LicenseManager): ExternalModule[] {
|
||||
const license = this.getLicense();
|
||||
|
||||
if (!license) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...license.grantedModules.filter<ExternalModule>((value): value is ExternalModule => !isInternalModuleName(value.module))];
|
||||
}
|
||||
|
||||
export function hasModule(this: LicenseManager, module: LicenseModule) {
|
||||
return this.modules.has(module);
|
||||
}
|
||||
|
||||
export function replaceModules(this: LicenseManager, newModules: LicenseModule[]): boolean {
|
||||
let anyChange = false;
|
||||
for (const moduleName of newModules) {
|
||||
if (this.modules.has(moduleName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.modules.add(moduleName);
|
||||
moduleValidated.call(this, moduleName);
|
||||
anyChange = true;
|
||||
}
|
||||
|
||||
for (const moduleName of this.modules) {
|
||||
if (newModules.includes(moduleName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
moduleRemoved.call(this, moduleName);
|
||||
this.modules.delete(moduleName);
|
||||
anyChange = true;
|
||||
}
|
||||
|
||||
return anyChange;
|
||||
}
|
||||
32
ee/packages/license/src/pendingLicense.ts
Normal file
32
ee/packages/license/src/pendingLicense.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { LicenseManager } from './license';
|
||||
import { logger } from './logger';
|
||||
|
||||
export function setPendingLicense(this: LicenseManager, encryptedLicense: string) {
|
||||
this.pendingLicense = encryptedLicense;
|
||||
if (this.pendingLicense) {
|
||||
logger.info('Storing license as pending validation.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyPendingLicense(this: LicenseManager) {
|
||||
if (this.pendingLicense) {
|
||||
logger.info('Applying pending license.');
|
||||
await this.setLicense(this.pendingLicense);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPendingLicense(this: LicenseManager) {
|
||||
return Boolean(this.pendingLicense);
|
||||
}
|
||||
|
||||
export function isPendingLicense(this: LicenseManager, encryptedLicense: string) {
|
||||
return !!this.pendingLicense && this.pendingLicense === encryptedLicense;
|
||||
}
|
||||
|
||||
export function clearPendingLicense(this: LicenseManager) {
|
||||
if (this.pendingLicense) {
|
||||
logger.info('Removing pending license.');
|
||||
}
|
||||
|
||||
this.pendingLicense = '';
|
||||
}
|
||||
28
ee/packages/license/src/showLicense.ts
Normal file
28
ee/packages/license/src/showLicense.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ILicenseV3 } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from './license';
|
||||
import { getModules } from './modules';
|
||||
|
||||
export function showLicense(this: LicenseManager, license: ILicenseV3 | undefined, valid: boolean | undefined) {
|
||||
if (!process.env.LICENSE_DEBUG || process.env.LICENSE_DEBUG === 'false') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!license || !valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
validation: { serverUrls, validPeriods },
|
||||
limits,
|
||||
} = license;
|
||||
|
||||
const modules = getModules.call(this);
|
||||
|
||||
console.log('---- License enabled ----');
|
||||
console.log(' url ->', JSON.stringify(serverUrls));
|
||||
console.log(' periods ->', JSON.stringify(validPeriods));
|
||||
console.log(' limits ->', JSON.stringify(limits));
|
||||
console.log(' modules ->', modules.join(', '));
|
||||
console.log('-------------------------');
|
||||
}
|
||||
25
ee/packages/license/src/tags.ts
Normal file
25
ee/packages/license/src/tags.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ILicenseTag } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from './license';
|
||||
|
||||
export function addTag(this: LicenseManager, tag: ILicenseTag) {
|
||||
// make sure to not add duplicated tag names
|
||||
for (const addedTag of this.tags) {
|
||||
if (addedTag.name.toLowerCase() === tag.name.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.tags.add(tag);
|
||||
}
|
||||
|
||||
export function replaceTags(this: LicenseManager, newTags: ILicenseTag[]) {
|
||||
this.tags.clear();
|
||||
for (const tag of newTags) {
|
||||
addTag.call(this, tag);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTags(this: LicenseManager) {
|
||||
return [...this.tags];
|
||||
}
|
||||
89
ee/packages/license/src/token.ts
Normal file
89
ee/packages/license/src/token.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { ILicenseV3 } from '@rocket.chat/core-typings';
|
||||
import { verify, sign, getPairs } from '@rocket.chat/jwt';
|
||||
|
||||
const base64Decode = (key: string): string => Buffer.from(key, 'base64').toString('utf-8');
|
||||
|
||||
const PUBLIC_LICENSE_KEY_V2 =
|
||||
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFxV1Nza2Q5LzZ6Ung4a3lQY2ljcwpiMzJ3Mnd4VnV3N3lCVDk2clEvOEQreU1lQ01POXdTU3BIYS85bkZ5d293RXRpZ3B0L3dyb1BOK1ZHU3didHdQCkZYQmVxRWxCbmRHRkFsODZlNStFbGlIOEt6L2hHbkNtSk5tWHB4RUsyUkUwM1g0SXhzWVg3RERCN010eC9pcXMKY2pCL091dlNCa2ppU2xlUzdibE5JVC9kQTdLNC9DSjNvaXUwMmJMNEV4Y2xDSGVwenFOTWVQM3dVWmdweE9uZgpOT3VkOElYWUs3M3pTY3VFOEUxNTdZd3B6Q0twVmFIWDdaSmY4UXVOc09PNVcvYUlqS2wzTDYyNjkrZUlPRXJHCndPTm1hSG56Zmc5RkxwSmh6Z3BPMzhhVm43NnZENUtLakJhaldza1krNGEyZ1NRbUtOZUZxYXFPb3p5RUZNMGUKY0ZXWlZWWjNMZWg0dkVNb1lWUHlJeng5Nng4ZjIveW1QbmhJdXZRdjV3TjRmeWVwYTdFWTVVQ2NwNzF6OGtmUAo0RmNVelBBMElEV3lNaWhYUi9HNlhnUVFaNEdiL3FCQmh2cnZpSkNGemZZRGNKZ0w3RmVnRllIUDNQR0wwN1FnCnZMZXZNSytpUVpQcnhyYnh5U3FkUE9rZ3VyS2pWclhUVXI0QTlUZ2lMeUlYNVVsSnEzRS9SVjdtZk9xWm5MVGEKU0NWWEhCaHVQbG5DR1pSMDFUb1RDZktoTUcxdTBDRm5MMisxNWhDOWZxT21XdjlRa2U0M3FsSjBQZ0YzVkovWAp1eC9tVHBuazlnbmJHOUpIK21mSDM5Um9GdlROaW5Zd1NNdll6dXRWT242OXNPemR3aERsYTkwbDNBQ2g0eENWCks3Sk9YK3VIa29OdTNnMmlWeGlaVU0wQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=';
|
||||
const PUBLIC_STATS_KEY_V2 =
|
||||
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuT0IvR3lCUXg1cVJZOC83dGxhTApkd0hSOWZBWHVzQ3ZBVU9YRjFPYjExaWx4ejdqY0pqWitaRjE2UTk3bjF3UDlvQnJMUDg5M3ZXUlc1bUtDdFpDClJQNTdJU1o2YjlHOXoyNStnV0NEa3ZZUUg1djJlMGoxUnE5TnNYMktlTWViOUZXenRQVFEvdDRvUW1SdUpiTEIKV013ajRRbHZ4OStpdWpkdGdQYmx5S0VFM0I3T1RPWk8xNzNOK2RDZW8wOWlQcStyKzBiRjNGSTRVcVFiZkFrdApOdGd0OUxVbjdlalNaQXNhSTZPbVBmOVVvSzM4NUdxWUxvbk1WbXBFbmZyRDlHSHl0OXFOY2pCTmZ1MUJLZWlUClBwVit6WE1FViszLzZ2bi84OW1id28zS0ZtTnRxVTJpRk5Yak5Cb2w1ZE82N3VlM0pURXBVQmJWRHZ2V3gwTk8KWndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==';
|
||||
|
||||
const PUBLIC_LICENSE_KEY_V3 = base64Decode(PUBLIC_LICENSE_KEY_V2);
|
||||
|
||||
let TEST_KEYS: [string, string] | undefined = undefined;
|
||||
|
||||
export async function decryptStatsToken(encrypted: string): Promise<string> {
|
||||
if (process.env.NODE_ENV?.toLowerCase() === 'test') {
|
||||
TEST_KEYS = TEST_KEYS ?? (await getPairs());
|
||||
|
||||
if (!TEST_KEYS) {
|
||||
throw new Error('Missing PUBLIC_STATS_KEY_V3');
|
||||
}
|
||||
|
||||
const [spki] = TEST_KEYS;
|
||||
|
||||
const [payload] = await verify(encrypted, spki);
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
const [payload] = await verify(encrypted, base64Decode(PUBLIC_STATS_KEY_V2));
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export async function decrypt(encrypted: string): Promise<string> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
if (encrypted.startsWith('RCV3_')) {
|
||||
const jwt = encrypted.substring(5);
|
||||
|
||||
TEST_KEYS = TEST_KEYS ?? (await getPairs());
|
||||
|
||||
if (!TEST_KEYS) {
|
||||
throw new Error('Missing PUBLIC_LICENSE_KEY_V3');
|
||||
}
|
||||
|
||||
const [spki] = TEST_KEYS;
|
||||
|
||||
const [payload] = await verify(jwt, spki);
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
}
|
||||
|
||||
// handle V3
|
||||
if (encrypted.startsWith('RCV3_')) {
|
||||
const jwt = encrypted.substring(5);
|
||||
const [payload] = await verify(jwt, PUBLIC_LICENSE_KEY_V3);
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
const decrypted = crypto.publicDecrypt(Buffer.from(PUBLIC_LICENSE_KEY_V2, 'base64').toString('utf-8'), Buffer.from(encrypted, 'base64'));
|
||||
|
||||
return decrypted.toString('utf-8');
|
||||
}
|
||||
|
||||
export async function encrypt(license: ILicenseV3): Promise<string> {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('This function should only be used in tests');
|
||||
}
|
||||
|
||||
TEST_KEYS = TEST_KEYS ?? (await getPairs());
|
||||
|
||||
const [, pkcs8] = TEST_KEYS;
|
||||
|
||||
return `RCV3_${await sign(license, pkcs8)}`;
|
||||
}
|
||||
|
||||
export async function encryptStatsToken(data: Record<string, any>): Promise<string> {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('This function should only be used in tests');
|
||||
}
|
||||
|
||||
TEST_KEYS = TEST_KEYS ?? (await getPairs());
|
||||
|
||||
const [, pkcs8] = TEST_KEYS;
|
||||
|
||||
return sign(data, pkcs8);
|
||||
}
|
||||
45
ee/packages/license/src/v2/bundles.ts
Normal file
45
ee/packages/license/src/v2/bundles.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { CoreModules, type LicenseModule } from '@rocket.chat/core-typings';
|
||||
|
||||
interface IBundle {
|
||||
[key: string]: readonly LicenseModule[];
|
||||
}
|
||||
|
||||
const bundles: IBundle = {
|
||||
enterprise: CoreModules,
|
||||
pro: [],
|
||||
};
|
||||
|
||||
export const getBundleFromModule = (moduleName: string): string | undefined => {
|
||||
const match = moduleName.match(/(.*):\*$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
return match[1];
|
||||
};
|
||||
|
||||
export function isBundle(moduleName: string): boolean {
|
||||
if (moduleName === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const bundle = getBundleFromModule(moduleName);
|
||||
if (!bundle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getBundleModules(moduleName: string): readonly string[] {
|
||||
if (moduleName === '*') {
|
||||
return Object.keys(bundles).reduce<string[]>((modules, bundle) => modules.concat(bundles[bundle]), []);
|
||||
}
|
||||
|
||||
const bundle = getBundleFromModule(moduleName);
|
||||
if (!bundle || !bundles[bundle]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return bundles[bundle];
|
||||
}
|
||||
112
ee/packages/license/src/v2/convertToV3.ts
Normal file
112
ee/packages/license/src/v2/convertToV3.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* FromV2ToV3
|
||||
* Transform a License V2 into a V3 representation.
|
||||
*/
|
||||
|
||||
import type { ILicenseV2, ILicenseV3, InternalModuleName } from '@rocket.chat/core-typings';
|
||||
|
||||
import { isBundle, getBundleFromModule, getBundleModules } from './bundles';
|
||||
import { getTagColor } from './getTagColor';
|
||||
|
||||
export const convertToV3 = (v2: ILicenseV2): ILicenseV3 => {
|
||||
return {
|
||||
version: '3.0',
|
||||
information: {
|
||||
autoRenew: false,
|
||||
...((v2.meta?.trialEnd || v2.expiry) && { visualExpiration: new Date(Date.parse(v2.meta?.trialEnd || v2.expiry)).toISOString() }),
|
||||
trial: v2.meta?.trial || false,
|
||||
offline: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
grantedBy: {
|
||||
method: 'manual',
|
||||
seller: 'V2',
|
||||
},
|
||||
// if no tag present, it means it is an old license, so try check for bundles and use them as tags
|
||||
tags: v2.tag
|
||||
? [v2.tag]
|
||||
: [
|
||||
...(v2.modules.filter(isBundle).map(getBundleFromModule).filter(Boolean) as string[]).map((tag) => ({
|
||||
name: tag,
|
||||
color: getTagColor(tag),
|
||||
})),
|
||||
],
|
||||
},
|
||||
validation: {
|
||||
serverUrls: [
|
||||
{
|
||||
value: v2.url,
|
||||
type: 'regex',
|
||||
},
|
||||
],
|
||||
validPeriods: [
|
||||
{
|
||||
validUntil: new Date(Date.parse(v2.expiry)).toISOString(),
|
||||
invalidBehavior: 'invalidate_license',
|
||||
},
|
||||
],
|
||||
statisticsReport: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
grantedModules: [
|
||||
...new Set(
|
||||
['outbound-messaging', 'teams-voip', 'contact-id-verification', 'hide-watermark', ...v2.modules]
|
||||
.flatMap((licenseModule) => (isBundle(licenseModule) ? getBundleModules(licenseModule) : [licenseModule]))
|
||||
.map((licenseModule) => ({ module: licenseModule as InternalModuleName })),
|
||||
),
|
||||
],
|
||||
limits: {
|
||||
...(v2.maxActiveUsers
|
||||
? {
|
||||
activeUsers: [
|
||||
{
|
||||
max: v2.maxActiveUsers,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(v2.maxGuestUsers
|
||||
? {
|
||||
guestUsers: [
|
||||
{
|
||||
max: v2.maxGuestUsers,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(v2.maxRoomsPerGuest
|
||||
? {
|
||||
roomsPerGuest: [
|
||||
{
|
||||
max: v2.maxRoomsPerGuest,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(v2.apps?.maxPrivateApps
|
||||
? {
|
||||
privateApps: [
|
||||
{
|
||||
max: v2.apps.maxPrivateApps,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(v2.apps?.maxMarketplaceApps
|
||||
? {
|
||||
marketplaceApps: [
|
||||
{
|
||||
max: v2.apps.maxMarketplaceApps,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
cloudMeta: v2.meta,
|
||||
};
|
||||
};
|
||||
12
ee/packages/license/src/v2/getTagColor.ts
Normal file
12
ee/packages/license/src/v2/getTagColor.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function getTagColor(tag: string): string {
|
||||
switch (tag) {
|
||||
case 'bronze':
|
||||
return '#BD5A0B';
|
||||
case 'silver':
|
||||
return '#9EA2A8';
|
||||
case 'gold':
|
||||
return '#F3BE08';
|
||||
default:
|
||||
return '#4411DD';
|
||||
}
|
||||
}
|
||||
92
ee/packages/license/src/validateLicenseForPreview.spec.ts
Normal file
92
ee/packages/license/src/validateLicenseForPreview.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { LicenseImp } from '.';
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder';
|
||||
import { NotReadyForValidation } from './errors/NotReadyForValidation';
|
||||
|
||||
describe('validateLicenseForPreview', () => {
|
||||
it('should report a non-license string as invalid with no reasons', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const result = await licenseManager.validateLicenseForPreview('not-a-license');
|
||||
|
||||
expect(result).toEqual({ valid: false, reasons: [] });
|
||||
});
|
||||
|
||||
it('should throw NotReadyForValidation when the workspace cannot validate yet', async () => {
|
||||
const licenseManager = new LicenseImp();
|
||||
const license = await new MockedLicenseBuilder().sign();
|
||||
|
||||
await expect(licenseManager.validateLicenseForPreview(license)).rejects.toThrow(NotReadyForValidation);
|
||||
});
|
||||
|
||||
it('should report a valid license as valid and not apply it', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise', 'engagement-dashboard']).sign();
|
||||
|
||||
const result = await licenseManager.validateLicenseForPreview(license);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
|
||||
// previewing must not apply the license
|
||||
expect(licenseManager.hasValidLicense()).toBe(false);
|
||||
expect(licenseManager.getModules()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not apply a license even when it is already active and being previewed again', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise']).sign();
|
||||
|
||||
await licenseManager.setLicense(license);
|
||||
expect(licenseManager.hasValidLicense()).toBe(true);
|
||||
|
||||
const result = await licenseManager.validateLicenseForPreview(license);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should report a reason when the workspace URL does not match', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({ value: 'another-workspace.com', type: 'url' }).sign();
|
||||
|
||||
const result = await licenseManager.validateLicenseForPreview(license);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.valid === false && result.reasons).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ behavior: 'invalidate_license', reason: 'url' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should report a reason when an invalidating period has expired', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().resetValidPeriods().withExpiredDate().sign();
|
||||
|
||||
const result = await licenseManager.validateLicenseForPreview(license);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.valid === false && result.reasons).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ behavior: 'invalidate_license', reason: 'period' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should report valid when only a disable_modules period has expired', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
// MockedLicenseBuilder seeds a `disable_modules` period for `livechat-enterprise`; expire it.
|
||||
const builder = new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise', 'engagement-dashboard']);
|
||||
builder.validation.validPeriods = [
|
||||
{
|
||||
invalidBehavior: 'disable_modules',
|
||||
modules: ['livechat-enterprise'],
|
||||
validUntil: new Date(new Date().setMinutes(new Date().getMinutes() - 1)).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = await licenseManager.validateLicenseForPreview(await builder.sign());
|
||||
|
||||
// disabling modules does not invalidate the license, so it would still be accepted
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { BehaviorWithContext, LicenseBehavior } from '@rocket.chat/core-typings';
|
||||
|
||||
export const filterBehaviorsResult = (result: BehaviorWithContext[], expectedBehaviors: LicenseBehavior[]) =>
|
||||
result.filter(({ behavior }) => expectedBehaviors.includes(behavior));
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LicenseLimitKind, LimitContext } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from '../license';
|
||||
import { logger } from '../logger';
|
||||
import { applyPendingLicense, hasPendingLicense } from '../pendingLicense';
|
||||
|
||||
export function setLicenseLimitCounter<T extends LicenseLimitKind>(
|
||||
this: LicenseManager,
|
||||
limitKey: T,
|
||||
fn: (context?: LimitContext<T>) => Promise<number> | number,
|
||||
) {
|
||||
this.dataCounters.set(limitKey, fn as (context?: LimitContext<LicenseLimitKind>) => Promise<number>);
|
||||
|
||||
if (hasPendingLicense.call(this) && hasAllDataCounters.call(this)) {
|
||||
void applyPendingLicense.call(this);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentValueForLicenseLimit<T extends LicenseLimitKind>(
|
||||
this: LicenseManager,
|
||||
limitKey: T,
|
||||
context?: Partial<LimitContext<T>>,
|
||||
): Promise<number> {
|
||||
const counterFn = this.dataCounters.get(limitKey);
|
||||
if (!counterFn) {
|
||||
logger.error({ msg: 'Unable to validate license limit due to missing data counter.', limitKey });
|
||||
throw new Error(`Unable to validate license limit due to missing ${limitKey} data counter`);
|
||||
}
|
||||
|
||||
return counterFn(context as LimitContext<LicenseLimitKind> | undefined);
|
||||
}
|
||||
|
||||
export function hasAllDataCounters(this: LicenseManager) {
|
||||
return (
|
||||
['activeUsers', 'guestUsers', 'roomsPerGuest', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts'] as LicenseLimitKind[]
|
||||
).every((limitKey) => this.dataCounters.has(limitKey));
|
||||
}
|
||||
14
ee/packages/license/src/validation/getModulesToDisable.ts
Normal file
14
ee/packages/license/src/validation/getModulesToDisable.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { BehaviorWithContext, LicenseBehavior, LicenseModule } from '@rocket.chat/core-typings';
|
||||
|
||||
const filterValidationResult = (result: BehaviorWithContext[], expectedBehavior: LicenseBehavior) =>
|
||||
result.filter(({ behavior }) => behavior === expectedBehavior) as BehaviorWithContext[];
|
||||
|
||||
export const getModulesToDisable = (validationResult: BehaviorWithContext[]): LicenseModule[] => {
|
||||
return [
|
||||
...new Set([
|
||||
...filterValidationResult(validationResult, 'disable_modules')
|
||||
.map(({ modules }) => modules || [])
|
||||
.flat(),
|
||||
]),
|
||||
];
|
||||
};
|
||||
25
ee/packages/license/src/validation/getResultingBehavior.ts
Normal file
25
ee/packages/license/src/validation/getResultingBehavior.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { LicenseLimitKind, BehaviorWithContext, LicenseLimit, LicensePeriod } from '@rocket.chat/core-typings';
|
||||
|
||||
export const getResultingBehavior = (
|
||||
data: LicenseLimit | LicensePeriod | Partial<Omit<BehaviorWithContext, 'reason'>>,
|
||||
{ reason, limit }: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind },
|
||||
): BehaviorWithContext => {
|
||||
const behavior = 'invalidBehavior' in data ? data.invalidBehavior : data.behavior;
|
||||
|
||||
switch (behavior) {
|
||||
case 'disable_modules':
|
||||
return {
|
||||
behavior,
|
||||
modules: ('modules' in data && data.modules) || [],
|
||||
reason,
|
||||
limit,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
behavior,
|
||||
reason,
|
||||
limit,
|
||||
} as BehaviorWithContext;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { BehaviorWithContext, LicenseBehavior } from '@rocket.chat/core-typings';
|
||||
|
||||
export const isBehaviorsInResult = (result: BehaviorWithContext[], expectedBehaviors: LicenseBehavior[]) =>
|
||||
result.some(({ behavior }) => expectedBehaviors.includes(behavior));
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LicenseManager } from '../license';
|
||||
import { hasAllDataCounters } from './getCurrentValueForLicenseLimit';
|
||||
|
||||
// Can only validate licenses once the workspace URL and the data counter functions are set
|
||||
export function isReadyForValidation(this: LicenseManager) {
|
||||
return Boolean(this.getWorkspaceUrl() && hasAllDataCounters.call(this));
|
||||
}
|
||||
11
ee/packages/license/src/validation/logKind.ts
Normal file
11
ee/packages/license/src/validation/logKind.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { LicenseBehavior } from '@rocket.chat/core-typings';
|
||||
|
||||
export const logKind = (behavior: LicenseBehavior) => {
|
||||
switch (behavior) {
|
||||
case 'prevent_installation':
|
||||
case 'invalidate_license':
|
||||
return 'error';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
38
ee/packages/license/src/validation/runValidation.spec.ts
Normal file
38
ee/packages/license/src/validation/runValidation.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { runValidation } from './runValidation';
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from '../../__tests__/MockedLicenseBuilder';
|
||||
|
||||
describe('Validation behaviors', () => {
|
||||
it('should return a behavior if the license period is invalid', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
// two days ago
|
||||
const validFrom = new Date(new Date().setDate(new Date().getDate() - 2));
|
||||
// one day ago
|
||||
const validUntil = new Date(new Date().setDate(new Date().getDate() - 1));
|
||||
|
||||
const license = await new MockedLicenseBuilder().resetValidPeriods().withValidPeriod({
|
||||
validFrom: validFrom.toISOString(),
|
||||
validUntil: validUntil.toISOString(),
|
||||
invalidBehavior: 'disable_modules',
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runValidation.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: true,
|
||||
}),
|
||||
).resolves.toStrictEqual([
|
||||
{
|
||||
behavior: 'disable_modules',
|
||||
limit: undefined,
|
||||
modules: ['livechat-enterprise'],
|
||||
reason: 'period',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
18
ee/packages/license/src/validation/runValidation.ts
Normal file
18
ee/packages/license/src/validation/runValidation.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from '../license';
|
||||
import { validateLicenseLimits } from './validateLicenseLimits';
|
||||
import { validateLicensePeriods } from './validateLicensePeriods';
|
||||
import { validateLicenseUrl } from './validateLicenseUrl';
|
||||
|
||||
export async function runValidation(
|
||||
this: LicenseManager,
|
||||
license: ILicenseV3,
|
||||
options: LicenseValidationOptions,
|
||||
): Promise<BehaviorWithContext[]> {
|
||||
return [
|
||||
...validateLicenseUrl.call(this, license, options),
|
||||
...validateLicensePeriods(license, options),
|
||||
...(await validateLicenseLimits.call(this, license, options)),
|
||||
];
|
||||
}
|
||||
27
ee/packages/license/src/validation/validateDefaultLimits.ts
Normal file
27
ee/packages/license/src/validation/validateDefaultLimits.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { BehaviorWithContext, LicenseLimit, LicenseValidationOptions } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from '../license';
|
||||
import { validateLimits } from './validateLimits';
|
||||
|
||||
export const defaultLimits: {
|
||||
privateApps: LicenseLimit[];
|
||||
marketplaceApps: LicenseLimit[];
|
||||
// monthlyActiveContacts?: LicenseLimit[];
|
||||
} = {
|
||||
privateApps: [
|
||||
{
|
||||
behavior: 'prevent_action',
|
||||
max: 0,
|
||||
},
|
||||
],
|
||||
marketplaceApps: [
|
||||
{
|
||||
behavior: 'prevent_action',
|
||||
max: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export async function validateDefaultLimits(this: LicenseManager, options: LicenseValidationOptions): Promise<BehaviorWithContext[]> {
|
||||
return validateLimits.call(this, defaultLimits, options);
|
||||
}
|
||||
16
ee/packages/license/src/validation/validateFormat.ts
Normal file
16
ee/packages/license/src/validation/validateFormat.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { InvalidLicenseError } from '../errors/InvalidLicenseError';
|
||||
import { decrypt } from '../token';
|
||||
|
||||
export const validateFormat = async (encryptedLicense: string): Promise<boolean> => {
|
||||
if (!encryptedLicense || String(encryptedLicense).trim() === '') {
|
||||
throw new InvalidLicenseError('Empty license');
|
||||
}
|
||||
|
||||
try {
|
||||
await decrypt(encryptedLicense);
|
||||
} catch (e) {
|
||||
throw new InvalidLicenseError();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from '../../__tests__/MockedLicenseBuilder';
|
||||
|
||||
describe('validateLicenseLimits', () => {
|
||||
describe('limit max: -1', () => {
|
||||
it('should return prevent_action when the limit is reached', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: -1,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 99999999);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
describe('limit max: 0', () => {
|
||||
it('should return prevent_action when the limit is reached', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
|
||||
{
|
||||
max: 0,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
|
||||
|
||||
licenseManager.setLicenseLimitCounter('activeUsers', () => 99999999);
|
||||
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
ee/packages/license/src/validation/validateLicenseLimits.ts
Normal file
14
ee/packages/license/src/validation/validateLicenseLimits.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
|
||||
|
||||
import type { LicenseManager } from '../license';
|
||||
import { validateLimits } from './validateLimits';
|
||||
|
||||
export async function validateLicenseLimits(
|
||||
this: LicenseManager,
|
||||
license: ILicenseV3,
|
||||
options: LicenseValidationOptions,
|
||||
): Promise<BehaviorWithContext[]> {
|
||||
const { limits } = license;
|
||||
|
||||
return validateLimits.call(this, limits, options);
|
||||
}
|
||||
40
ee/packages/license/src/validation/validateLicensePeriods.ts
Normal file
40
ee/packages/license/src/validation/validateLicensePeriods.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
|
||||
|
||||
import { isBehaviorAllowed } from '../isItemAllowed';
|
||||
import { logger } from '../logger';
|
||||
import { getResultingBehavior } from './getResultingBehavior';
|
||||
|
||||
export const isPeriodInvalid = (from: string | undefined, until: string | undefined) => {
|
||||
const now = new Date();
|
||||
|
||||
if (from && now < new Date(from)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (until && now > new Date(until)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const validateLicensePeriods = (license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] => {
|
||||
const {
|
||||
validation: { validPeriods },
|
||||
} = license;
|
||||
|
||||
return validPeriods
|
||||
.filter(
|
||||
({ validFrom, validUntil, invalidBehavior }) => isBehaviorAllowed(invalidBehavior, options) && isPeriodInvalid(validFrom, validUntil),
|
||||
)
|
||||
.map((period) => {
|
||||
if (!options.suppressLog) {
|
||||
logger.error({
|
||||
msg: 'Period validation failed',
|
||||
period,
|
||||
});
|
||||
}
|
||||
|
||||
return getResultingBehavior(period, { reason: 'period' });
|
||||
});
|
||||
};
|
||||
163
ee/packages/license/src/validation/validateLicenseUrl.spec.ts
Normal file
163
ee/packages/license/src/validation/validateLicenseUrl.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { validateLicenseUrl } from './validateLicenseUrl';
|
||||
import { MockedLicenseBuilder, getReadyLicenseManager } from '../../__tests__/MockedLicenseBuilder';
|
||||
|
||||
describe('Url Validation', () => {
|
||||
describe('url method', () => {
|
||||
it('should return a behavior if the license url is invalid', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({
|
||||
value: 'localhost:3001',
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateLicenseUrl.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: true,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
behavior: 'invalidate_license',
|
||||
limit: undefined,
|
||||
reason: 'url',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array if the license url is valid', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({
|
||||
value: 'localhost:3000',
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateLicenseUrl.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: true,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex method', () => {
|
||||
it('should return a behavior if the license does not match the regex', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({
|
||||
value: 'unstable.rocket.*',
|
||||
type: 'regex',
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateLicenseUrl.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: true,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
behavior: 'invalidate_license',
|
||||
limit: undefined,
|
||||
reason: 'url',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array if the license matches the regex', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({
|
||||
value: 'localhost:300*',
|
||||
type: 'regex',
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateLicenseUrl.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: true,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hash method', () => {
|
||||
it('should return a behavior if the license does not match the hash', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const hash = crypto.createHash('sha256').update('localhost:3001').digest('hex');
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({
|
||||
value: hash,
|
||||
type: 'hash',
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateLicenseUrl.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: true,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
behavior: 'invalidate_license',
|
||||
limit: undefined,
|
||||
reason: 'url',
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should return an empty array if the license matches the hash', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const hash = crypto.createHash('sha256').update('localhost:3000').digest('hex');
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({
|
||||
value: hash,
|
||||
type: 'hash',
|
||||
});
|
||||
await expect(
|
||||
validateLicenseUrl.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: true,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type mismatch', () => {
|
||||
it('should validate as hash if the type is url but the value looks like a hash', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const hash = crypto.createHash('sha256').update('localhost:3000').digest('hex');
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({
|
||||
value: hash,
|
||||
type: 'url',
|
||||
});
|
||||
await expect(
|
||||
validateLicenseUrl.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: false,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should validate as url if the type is hash but the value looks like a url', async () => {
|
||||
const licenseManager = await getReadyLicenseManager();
|
||||
|
||||
const license = await new MockedLicenseBuilder().withServerUrls({
|
||||
value: 'localhost:3000',
|
||||
type: 'hash',
|
||||
});
|
||||
await expect(
|
||||
validateLicenseUrl.call(licenseManager, await license.build(), {
|
||||
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
|
||||
suppressLog: false,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
88
ee/packages/license/src/validation/validateLicenseUrl.ts
Normal file
88
ee/packages/license/src/validation/validateLicenseUrl.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
|
||||
|
||||
import { isBehaviorAllowed } from '../isItemAllowed';
|
||||
import type { LicenseManager } from '../license';
|
||||
import { logger } from '../logger';
|
||||
import { getResultingBehavior } from './getResultingBehavior';
|
||||
|
||||
const validateRegex = (licenseURL: string, url: string) => {
|
||||
licenseURL = licenseURL
|
||||
.replace(/\./g, '\\.') // convert dots to literal
|
||||
.replace(/\*/g, '.*'); // convert * to .*
|
||||
const regex = new RegExp(`^${licenseURL}$`, 'i');
|
||||
|
||||
return !!regex.exec(url);
|
||||
};
|
||||
|
||||
const validateUrl = (licenseURL: string, url: string) => {
|
||||
return licenseURL.toLowerCase() === url.toLowerCase();
|
||||
};
|
||||
|
||||
const validateHash = (licenseURL: string, hashedUrl: string) => {
|
||||
return licenseURL === hashedUrl;
|
||||
};
|
||||
|
||||
export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] {
|
||||
if (!isBehaviorAllowed('invalidate_license', options)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {
|
||||
validation: { serverUrls },
|
||||
} = license;
|
||||
|
||||
const workspaceUrl = this.getWorkspaceUrl();
|
||||
|
||||
if (!workspaceUrl) {
|
||||
logger.error('Unable to validate license URL without knowing the workspace URL.');
|
||||
return [getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' })];
|
||||
}
|
||||
|
||||
const hashedWorkspaceUrl = this.hashWorkspaceUrl(workspaceUrl);
|
||||
|
||||
return serverUrls
|
||||
.filter((url) => {
|
||||
if (
|
||||
url.type === 'url' &&
|
||||
url.value.length === 64 &&
|
||||
/^[a-f0-9]{64}$/i.test(url.value) &&
|
||||
validateHash(url.value, hashedWorkspaceUrl)
|
||||
) {
|
||||
// If the url type is 'url' but the value looks like a hash, validate it as a hash to avoid invalidating licenses unnecessarily.
|
||||
logger.warn(
|
||||
`License URL with type 'url' is actually a hash. Validating as hash to avoid invalidating license unnecessarily. url: ${url.value}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.type === 'hash' && !/^[a-f0-9]{64}$/i.test(url.value) && validateUrl(url.value, workspaceUrl)) {
|
||||
// If the url type is 'hash' but the value looks like a url, validate it as a url to avoid invalidating licenses unnecessarily.
|
||||
logger.warn(
|
||||
`License URL with type 'hash' does not look like a hash. Validating as url to avoid invalidating license unnecessarily. url: ${url.value}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (url.type) {
|
||||
case 'regex':
|
||||
return !validateRegex(url.value, workspaceUrl);
|
||||
case 'hash':
|
||||
return !validateHash(url.value, hashedWorkspaceUrl);
|
||||
case 'url':
|
||||
return !validateUrl(url.value, workspaceUrl);
|
||||
default:
|
||||
return true; // If the type is unknown, consider it invalid to be safe.
|
||||
}
|
||||
})
|
||||
.map((url) => {
|
||||
if (!options.suppressLog) {
|
||||
logger.error({
|
||||
msg: 'Url validation failed',
|
||||
licenseUrl: url,
|
||||
workspaceUrl,
|
||||
hashedWorkspaceUrl,
|
||||
});
|
||||
}
|
||||
return getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' });
|
||||
});
|
||||
}
|
||||
62
ee/packages/license/src/validation/validateLimit.ts
Normal file
62
ee/packages/license/src/validation/validateLimit.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { LicenseBehavior } from '@rocket.chat/core-typings';
|
||||
|
||||
/**
|
||||
* The difference between validateLimit and validateWarnLimit is that the first one
|
||||
* is used to trigger the client so for start_fair_policy we trigger as soon the limit is reached
|
||||
* for the visual warning we trigger when the limit is reached + 1
|
||||
|
||||
/**
|
||||
* Validates if the current value is over the limit
|
||||
* @param max The maximum value allowed
|
||||
* @param currentValue The current value
|
||||
* @param behavior The behavior to be applied if the limit is reached
|
||||
* @param extraCount The extra count to be added to the current value
|
||||
* @returns
|
||||
* - true if the limit is reached
|
||||
* - false if the limit is not reached
|
||||
*/
|
||||
export function validateLimit(max: number, currentValue: number, behavior: LicenseBehavior, extraCount = 0) {
|
||||
switch (behavior) {
|
||||
case 'invalidate_license':
|
||||
case 'prevent_installation':
|
||||
case 'disable_modules':
|
||||
default:
|
||||
return currentValue > max;
|
||||
case 'start_fair_policy':
|
||||
return currentValue >= max;
|
||||
case 'prevent_action':
|
||||
/**
|
||||
* if we are validating the current count the limit should be equal or over the max, if we are validating the future count the limit should be over the max
|
||||
*/
|
||||
|
||||
return extraCount ? currentValue > max : currentValue >= max;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the current value is over the limit
|
||||
* @param max The maximum value allowed
|
||||
* @param currentValue The current value
|
||||
* @param behavior The behavior to be applied if the limit is reached
|
||||
* @param extraCount The extra count to be added to the current value
|
||||
* @returns
|
||||
* - true if the limit is reached
|
||||
* - false if the limit is not reached
|
||||
*/
|
||||
|
||||
export function validateWarnLimit(max: number, currentValue: number, behavior: LicenseBehavior, extraCount = 0) {
|
||||
switch (behavior) {
|
||||
case 'invalidate_license':
|
||||
case 'prevent_installation':
|
||||
case 'disable_modules':
|
||||
case 'start_fair_policy':
|
||||
default:
|
||||
return currentValue > max;
|
||||
case 'prevent_action':
|
||||
/**
|
||||
* if we are validating the current count the limit should be equal or over the max, if we are validating the future count the limit should be over the max
|
||||
*/
|
||||
|
||||
return extraCount ? currentValue > max : currentValue >= max;
|
||||
}
|
||||
}
|
||||
55
ee/packages/license/src/validation/validateLimits.ts
Normal file
55
ee/packages/license/src/validation/validateLimits.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ILicenseV3, LicenseLimitKind, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
|
||||
|
||||
import { isLimitAllowed, isBehaviorAllowed } from '../isItemAllowed';
|
||||
import type { LicenseManager } from '../license';
|
||||
import { logger } from '../logger';
|
||||
import { getCurrentValueForLicenseLimit } from './getCurrentValueForLicenseLimit';
|
||||
import { getResultingBehavior } from './getResultingBehavior';
|
||||
import { logKind } from './logKind';
|
||||
import { validateLimit } from './validateLimit';
|
||||
|
||||
export async function validateLimits(
|
||||
this: LicenseManager,
|
||||
limits: ILicenseV3['limits'],
|
||||
options: LicenseValidationOptions,
|
||||
): Promise<BehaviorWithContext[]> {
|
||||
const limitKeys = (Object.keys(limits) as LicenseLimitKind[]).filter((limit) => isLimitAllowed(limit, options));
|
||||
return (
|
||||
await Promise.all(
|
||||
limitKeys.map(async (limitKey) => {
|
||||
// Filter the limit list before running any query in the database so we don't end up loading some value we won't use.
|
||||
const limitList = limits[limitKey]?.filter(({ behavior, max }) => max >= 0 && isBehaviorAllowed(behavior, options));
|
||||
if (!limitList?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extraCount = options.context?.[limitKey]?.extraCount ?? 0;
|
||||
const currentValue = (await getCurrentValueForLicenseLimit.call(this, limitKey, options.context?.[limitKey])) + extraCount;
|
||||
|
||||
return limitList
|
||||
.filter(({ max, behavior }) => validateLimit(max, currentValue, behavior, extraCount))
|
||||
.map((limit) => {
|
||||
if (!options.suppressLog) {
|
||||
switch (logKind(limit.behavior)) {
|
||||
case 'error':
|
||||
logger.error({
|
||||
msg: 'Limit validation failed',
|
||||
kind: limitKey,
|
||||
limit,
|
||||
});
|
||||
break;
|
||||
case 'info':
|
||||
logger.info({
|
||||
msg: 'Limit validation failed',
|
||||
kind: limitKey,
|
||||
limit,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return getResultingBehavior(limit, { reason: 'limit', limit: limitKey });
|
||||
});
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
9
ee/packages/license/tsconfig.json
Normal file
9
ee/packages/license/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user