fix: read frontend entitlements and trigger quotas from license state

This commit is contained in:
n8n-cat-bot[bot] 2026-10-02 13:36:13 +00:00
parent 1043504c7a
commit 3fb0ae4e3e
4 changed files with 93 additions and 84 deletions

View File

@ -2,6 +2,7 @@ import type { LicenseState } from '@n8n/backend-common';
import { markHttpRequestError } from '@n8n/backend-network';
import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import type { EventService } from '@n8n/backend-services';
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import type { WorkflowRepository } from '@n8n/db';
import type { TEntitlement } from '@n8n_io/license-sdk';
import { AxiosError } from 'axios';
@ -31,7 +32,7 @@ describe('LicenseService', () => {
);
license.getMainPlan.mockReturnValue(entitlement);
license.getTriggerLimit.mockReturnValue(400);
licenseState.getMaxActiveWorkflows.mockReturnValue(400);
license.getPlanName.mockReturnValue('Test Plan');
licenseState.getMaxWorkflowsWithEvaluations.mockReturnValue(2);
workflowRepository.getActiveTriggerCount.mockResolvedValue(7);
@ -66,6 +67,22 @@ describe('LicenseService', () => {
},
});
});
it('should return the unlimited active workflow quota', async () => {
licenseState.getMaxActiveWorkflows.mockReturnValueOnce(UNLIMITED_LICENSE_QUOTA);
const data = await licenseService.getLicenseData();
expect(data.usage.activeWorkflowTriggers.limit).toBe(UNLIMITED_LICENSE_QUOTA);
});
it('should return a zero active workflow quota', async () => {
licenseState.getMaxActiveWorkflows.mockReturnValueOnce(0);
const data = await licenseService.getLicenseData();
expect(data.usage.activeWorkflowTriggers.limit).toBe(0);
});
});
describe('activateLicense', () => {

View File

@ -51,8 +51,7 @@ export class LicenseService {
usage: {
activeWorkflowTriggers: {
value: triggerCount,
// oxlint-disable-next-line typescript/no-deprecated - Tech debt, tests fail if we move to this.licenseState.getMaxActiveWorkflows
limit: this.license.getTriggerLimit(),
limit: this.licenseState.getMaxActiveWorkflows(),
warningThreshold: 0.8,
},
workflowsHavingEvaluations: {

View File

@ -1,6 +1,7 @@
import type { Mock } from 'vitest';
import type { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig, SecurityConfig } from '@n8n/config';
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import type { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
@ -122,30 +123,9 @@ describe('FrontendService', () => {
});
const license = mock<License>({
getUsersLimit: vi.fn().mockReturnValue(100),
getPlanName: vi.fn().mockReturnValue('Community'),
getConsumerId: vi.fn().mockReturnValue('test-consumer'),
isSharingEnabled: vi.fn().mockReturnValue(false),
isLogStreamingEnabled: vi.fn().mockReturnValue(false),
isLdapEnabled: vi.fn().mockReturnValue(false),
isSamlEnabled: vi.fn().mockReturnValue(false),
isAdvancedExecutionFiltersEnabled: vi.fn().mockReturnValue(false),
isVariablesEnabled: vi.fn().mockReturnValue(false),
isSourceControlLicensed: vi.fn().mockReturnValue(false),
isExternalSecretsEnabled: vi.fn().mockReturnValue(false),
isLicensed: vi.fn().mockReturnValue(false),
isDebugInEditorLicensed: vi.fn().mockReturnValue(false),
isWorkerViewLicensed: vi.fn().mockReturnValue(false),
isAdvancedPermissionsLicensed: vi.fn().mockReturnValue(false),
getVariablesLimit: vi.fn().mockReturnValue(0),
getTeamProjectLimit: vi.fn().mockReturnValue(0),
isBinaryDataS3Licensed: vi.fn().mockReturnValue(false),
isAiAssistantEnabled: vi.fn().mockReturnValue(false),
isAskAiEnabled: vi.fn().mockReturnValue(false),
isAiCreditsEnabled: vi.fn().mockReturnValue(false),
getAiCredits: vi.fn().mockReturnValue(0),
isFoldersEnabled: vi.fn().mockReturnValue(false),
});
const mailer = mock<UserManagementMailer>({
@ -168,6 +148,12 @@ describe('FrontendService', () => {
});
const licenseState = mock<LicenseState>({
getMaxUsers: vi.fn().mockReturnValue(100),
getMaxVariables: vi.fn().mockReturnValue(0),
getMaxTeamProjects: vi.fn().mockReturnValue(0),
getMaxAiCredits: vi.fn().mockReturnValue(0),
isLdapLicensed: vi.fn().mockReturnValue(false),
isSamlLicensed: vi.fn().mockReturnValue(false),
isOidcLicensed: vi.fn().mockReturnValue(false),
isMFAEnforcementLicensed: vi.fn().mockReturnValue(false),
isOtelCustomSpanAttributesLicensed: vi.fn().mockReturnValue(false),
@ -280,6 +266,38 @@ describe('FrontendService', () => {
);
});
it('should expose licensed features and quotas from license state', async () => {
licenseState.isSharingLicensed.mockReturnValueOnce(true);
licenseState.isSamlLicensed.mockReturnValueOnce(true).mockReturnValueOnce(true);
licenseState.isVariablesLicensed.mockReturnValueOnce(true).mockReturnValueOnce(true);
licenseState.isFoldersLicensed.mockReturnValueOnce(true);
licenseState.getMaxUsers.mockReturnValueOnce(25).mockReturnValueOnce(25);
licenseState.getMaxVariables.mockReturnValueOnce(50);
licenseState.getMaxTeamProjects.mockReturnValueOnce(3);
const { service } = createMockService();
const settings = await service.getSettings();
expect(settings.enterprise).toMatchObject({ sharing: true, saml: true, variables: true });
expect(settings.userManagement.quota).toBe(25);
expect(settings.variables.limit).toBe(50);
expect(settings.enterprise.projects.team.limit).toBe(3);
expect(settings.folders.enabled).toBe(true);
});
it('should expose unlicensed features with unlimited user quotas', async () => {
licenseState.getMaxUsers
.mockReturnValueOnce(UNLIMITED_LICENSE_QUOTA)
.mockReturnValueOnce(UNLIMITED_LICENSE_QUOTA);
const { service } = createMockService();
const settings = await service.getSettings();
expect(settings.userManagement.quota).toBe(UNLIMITED_LICENSE_QUOTA);
expect(settings.enterprise.sharing).toBeFalsy();
expect(settings.folders.enabled).toBeFalsy();
});
it('should expose excluded node types from NODES_EXCLUDE', async () => {
globalConfig.nodes.exclude = ['n8n-nodes-base.executeWorkflow'];
const { service } = createMockService();
@ -325,8 +343,8 @@ describe('FrontendService', () => {
it('should surface the assistant Cloud UBB entitlement when the AI Assistant is enabled and entitled', async () => {
globalConfig.aiAssistant.baseUrl = 'https://ai-assistant.n8n.io';
licenseState.isAiAssistantCloudUbbEntitlementLicensed.mockReturnValue(true);
const { service, license } = createMockService();
license.isAiAssistantEnabled.mockReturnValue(true);
licenseState.isAiAssistantLicensed.mockReturnValueOnce(true);
const { service } = createMockService();
const settings = await service.getSettings();
@ -336,8 +354,7 @@ describe('FrontendService', () => {
it('should keep the assistant Cloud UBB entitlement off when the AI Assistant is disabled', async () => {
globalConfig.aiAssistant.baseUrl = '';
licenseState.isAiAssistantCloudUbbEntitlementLicensed.mockReturnValue(true);
const { service, license } = createMockService();
license.isAiAssistantEnabled.mockReturnValue(false);
const { service } = createMockService();
const settings = await service.getSettings();
@ -852,9 +869,9 @@ describe('FrontendService', () => {
});
it('should set aiBuilder.enabled to true when license has feat:aiBuilder', async () => {
const { service, license } = createMockService();
const { service } = createMockService();
license.isLicensed.mockImplementation((feature) => {
licenseState.isLicensed.mockImplementation((feature) => {
return feature === 'feat:aiBuilder';
});
@ -864,9 +881,9 @@ describe('FrontendService', () => {
});
it('should keep aiBuilder.enabled as false when license does not have feat:aiBuilder', async () => {
const { service, license } = createMockService();
const { service } = createMockService();
license.isLicensed.mockReturnValue(false);
licenseState.isLicensed.mockReturnValue(false);
const settings = await service.getSettings();

View File

@ -262,8 +262,7 @@ export class FrontendService {
this.globalConfig.personalization.enabled && this.globalConfig.diagnostics.enabled,
defaultLocale: this.globalConfig.defaultLocale,
userManagement: {
// oxlint-disable-next-line typescript/no-deprecated
quota: this.license.getUsersLimit(),
quota: this.licenseState.getMaxUsers(),
showSetupOnFirstLoad: await this.getShowSetupOnFirstLoad(),
smtpSetup: this.mailer.isEmailSetUp,
authenticationMethod: getCurrentAuthenticationMethod(),
@ -484,8 +483,7 @@ export class FrontendService {
// refresh user management status
Object.assign(this.settings.userManagement, {
// oxlint-disable-next-line typescript/no-deprecated
quota: this.license.getUsersLimit(),
quota: this.licenseState.getMaxUsers(),
authenticationMethod: getCurrentAuthenticationMethod(),
showSetupOnFirstLoad: await this.getShowSetupOnFirstLoad(),
});
@ -514,15 +512,11 @@ export class FrontendService {
const isS3Selected = this.binaryDataConfig.mode === 's3';
const isS3Available = this.binaryDataConfig.availableModes.includes('s3');
// oxlint-disable-next-line typescript/no-deprecated
const isS3Licensed = this.license.isBinaryDataS3Licensed();
// oxlint-disable-next-line typescript/no-deprecated
const isAiAssistantEnabled = this.license.isAiAssistantEnabled();
// oxlint-disable-next-line typescript/no-deprecated
const isAskAiEnabled = this.license.isAskAiEnabled();
// oxlint-disable-next-line typescript/no-deprecated
const isAiCreditsEnabled = this.license.isAiCreditsEnabled();
const isAiBuilderEnabled = this.license.isLicensed(LICENSE_FEATURES.AI_BUILDER);
const isS3Licensed = this.licenseState.isBinaryDataS3Licensed();
const isAiAssistantEnabled = this.licenseState.isAiAssistantLicensed();
const isAskAiEnabled = this.licenseState.isAskAiLicensed();
const isAiCreditsEnabled = this.licenseState.isAiCreditsLicensed();
const isAiBuilderEnabled = this.licenseState.isLicensed(LICENSE_FEATURES.AI_BUILDER);
this.settings.license.planName = this.license.getPlanName();
this.settings.license.consumerId = this.license.getConsumerId();
@ -537,36 +531,25 @@ export class FrontendService {
// refresh enterprise status
Object.assign(this.settings.enterprise, {
// oxlint-disable-next-line typescript/no-deprecated
sharing: this.license.isSharingEnabled(),
// oxlint-disable-next-line typescript/no-deprecated
logStreaming: this.license.isLogStreamingEnabled(),
// oxlint-disable-next-line typescript/no-deprecated
ldap: this.license.isLdapEnabled(),
// oxlint-disable-next-line typescript/no-deprecated
saml: this.license.isSamlEnabled(),
sharing: this.licenseState.isSharingLicensed(),
logStreaming: this.licenseState.isLogStreamingLicensed(),
ldap: this.licenseState.isLdapLicensed(),
saml: this.licenseState.isSamlLicensed(),
oidc: this.licenseState.isOidcLicensed(),
mfaEnforcement: this.licenseState.isMFAEnforcementLicensed(),
provisioning: false, // temporarily disabled until this feature is ready for release
// oxlint-disable-next-line typescript/no-deprecated
advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(),
// oxlint-disable-next-line typescript/no-deprecated
variables: this.license.isVariablesEnabled(),
// oxlint-disable-next-line typescript/no-deprecated
sourceControl: this.license.isSourceControlLicensed(),
// oxlint-disable-next-line typescript/no-deprecated
externalSecrets: this.license.isExternalSecretsEnabled(),
showNonProdBanner: this.license.isLicensed(LICENSE_FEATURES.SHOW_NON_PROD_BANNER),
// oxlint-disable-next-line typescript/no-deprecated
debugInEditor: this.license.isDebugInEditorLicensed(),
advancedExecutionFilters: this.licenseState.isAdvancedExecutionFiltersLicensed(),
variables: this.licenseState.isVariablesLicensed(),
sourceControl: this.licenseState.isSourceControlLicensed(),
externalSecrets: this.licenseState.isExternalSecretsLicensed(),
showNonProdBanner: this.licenseState.isLicensed(LICENSE_FEATURES.SHOW_NON_PROD_BANNER),
debugInEditor: this.licenseState.isDebugInEditorLicensed(),
binaryDataS3: isS3Available && isS3Selected && isS3Licensed,
// oxlint-disable-next-line typescript/no-deprecated
workerView: this.license.isWorkerViewLicensed(),
// oxlint-disable-next-line typescript/no-deprecated
advancedPermissions: this.license.isAdvancedPermissionsLicensed(),
workerView: this.licenseState.isWorkerViewLicensed(),
advancedPermissions: this.licenseState.isAdvancedPermissionsLicensed(),
workflowDiffs: this.licenseState.isWorkflowDiffsLicensed(),
namedVersions: this.license.isLicensed(LICENSE_FEATURES.NAMED_VERSIONS),
namedVersions: this.licenseState.isLicensed(LICENSE_FEATURES.NAMED_VERSIONS),
customRoles: this.licenseState.isCustomRolesLicensed(),
personalSpacePolicy: this.licenseState.isPersonalSpacePolicyLicensed(),
dataRedaction: this.licenseState.isDataRedactionLicensed(),
@ -577,16 +560,14 @@ export class FrontendService {
this.settings.workerPools.enabled =
this.globalConfig.queue.workerPool.enabled && this.licenseState.isWorkerPoolsLicensed();
// oxlint-disable-next-line typescript/no-deprecated
if (this.license.isLdapEnabled()) {
if (this.licenseState.isLdapLicensed()) {
Object.assign(this.settings.sso.ldap, {
loginLabel: this.globalConfig.sso.ldap.loginLabel,
loginEnabled: this.globalConfig.sso.ldap.loginEnabled,
});
}
// oxlint-disable-next-line typescript/no-deprecated
if (this.license.isSamlEnabled()) {
if (this.licenseState.isSamlLicensed()) {
Object.assign(this.settings.sso.saml, {
loginLabel: getSamlLoginLabel(),
loginEnabled: this.globalConfig.sso.saml.loginEnabled,
@ -599,10 +580,8 @@ export class FrontendService {
});
}
// oxlint-disable-next-line typescript/no-deprecated
if (this.license.isVariablesEnabled()) {
// oxlint-disable-next-line typescript/no-deprecated
this.settings.variables.limit = this.license.getVariablesLimit();
if (this.licenseState.isVariablesLicensed()) {
this.settings.variables.limit = this.licenseState.getMaxVariables();
}
if (isAiAssistantEnabled) {
@ -620,8 +599,7 @@ export class FrontendService {
if (isAiCreditsEnabled) {
this.settings.aiCredits.enabled = isAiCreditsEnabled;
// oxlint-disable-next-line typescript/no-deprecated
this.settings.aiCredits.credits = this.license.getAiCredits();
this.settings.aiCredits.credits = this.licenseState.getMaxAiCredits();
this.settings.aiCredits.setup = !!this.globalConfig.aiAssistant.baseUrl;
}
@ -632,7 +610,7 @@ export class FrontendService {
if (isAiGatewayEnabled) {
this.settings.aiGateway = {
enabled: true,
budget: this.license.getValue(LICENSE_QUOTAS.AI_GATEWAY_BUDGET) ?? 0,
budget: this.licenseState.getValue(LICENSE_QUOTAS.AI_GATEWAY_BUDGET) ?? 0,
cloudUbbEnabled: this.licenseState.isAiGatewayCloudUbbLicensed(),
};
}
@ -658,11 +636,9 @@ export class FrontendService {
this.settings.binaryDataMode = this.binaryDataConfig.mode;
// oxlint-disable-next-line typescript/no-deprecated
this.settings.enterprise.projects.team.limit = this.license.getTeamProjectLimit();
this.settings.enterprise.projects.team.limit = this.licenseState.getMaxTeamProjects();
// oxlint-disable-next-line typescript/no-deprecated
this.settings.folders.enabled = this.license.isFoldersEnabled();
this.settings.folders.enabled = this.licenseState.isFoldersLicensed();
// Refresh evaluation settings
this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations();