mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 01:26:44 +00:00
feat(editor): Using special env vars as feature flags in the frontend (#17355)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
37
cypress/e2e/env-feature-flags.cy.ts
Normal file
37
cypress/e2e/env-feature-flags.cy.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
describe('Environment Feature Flags', () => {
|
||||
it('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', () => {
|
||||
cy.setEnvFeatureFlags({
|
||||
N8N_ENV_FEAT_TEST: true,
|
||||
});
|
||||
cy.signinAsOwner();
|
||||
cy.intercept('GET', '/rest/settings').as('getSettings');
|
||||
cy.visit('/');
|
||||
cy.wait('@getSettings').then((interception) => {
|
||||
expect(interception.response?.body.data.envFeatureFlags).to.be.an('object');
|
||||
expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.equal(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset feature flags at runtime', () => {
|
||||
cy.setEnvFeatureFlags({
|
||||
N8N_ENV_FEAT_TEST: true,
|
||||
});
|
||||
cy.signinAsOwner();
|
||||
cy.intercept('GET', '/rest/settings').as('getSettings');
|
||||
cy.visit('/');
|
||||
cy.wait('@getSettings').then((interception) => {
|
||||
expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.equal(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
cy.clearEnvFeatureFlags();
|
||||
cy.visit('/');
|
||||
cy.wait('@getSettings').then((interception) => {
|
||||
expect(interception.response?.body.data.envFeatureFlags).to.be.an('object');
|
||||
expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,14 @@ function runTests(options) {
|
||||
process.env.E2E_TESTS = 'true';
|
||||
process.env.NODE_OPTIONS = '--dns-result-order=ipv4first';
|
||||
|
||||
// Automatically pass through any N8N_ENV_FEAT_* environment variables
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('N8N_ENV_FEAT_')) {
|
||||
// These are already in process.env and will be inherited by the spawned process
|
||||
console.log(`Passing through environment feature flag: ${key}=${process.env[key]}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (options.customEnv) {
|
||||
Object.keys(options.customEnv).forEach((key) => {
|
||||
process.env[key] = options.customEnv[key];
|
||||
@@ -21,7 +29,11 @@ function runTests(options) {
|
||||
}
|
||||
|
||||
const cmd = `start-server-and-test ${options.startCommand} ${options.url} '${options.testCommand}'`;
|
||||
const testProcess = spawn(cmd, [], { stdio: 'inherit', shell: true });
|
||||
const testProcess = spawn(cmd, [], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: process.env, // TODO: Maybe pass only the necessary environment variables instead of all
|
||||
});
|
||||
|
||||
// Listen for termination signals to properly kill the test process
|
||||
process.on('SIGINT', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'cypress-real-events';
|
||||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
import type { FrontendSettings, N8nEnvFeatFlags } from '@n8n/api-types';
|
||||
import FakeTimers from '@sinonjs/fake-timers';
|
||||
|
||||
import {
|
||||
@@ -115,6 +115,25 @@ Cypress.Commands.add('disableFeature', (feature: string) => setFeature(feature,
|
||||
Cypress.Commands.add('enableQueueMode', () => setQueueMode(true));
|
||||
Cypress.Commands.add('disableQueueMode', () => setQueueMode(false));
|
||||
|
||||
const setEnvFeatureFlags = (flags: N8nEnvFeatFlags) =>
|
||||
cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/env-feature-flags`, {
|
||||
flags,
|
||||
});
|
||||
|
||||
const getEnvFeatureFlags = () =>
|
||||
cy.request('GET', `${BACKEND_BASE_URL}/rest/e2e/env-feature-flags`);
|
||||
|
||||
// Environment feature flags commands (using E2E API)
|
||||
Cypress.Commands.add('setEnvFeatureFlags', (flags: N8nEnvFeatFlags) =>
|
||||
setEnvFeatureFlags(flags).then((response) => response.body.data),
|
||||
);
|
||||
Cypress.Commands.add('clearEnvFeatureFlags', () =>
|
||||
setEnvFeatureFlags({}).then((response) => response.body.data),
|
||||
);
|
||||
Cypress.Commands.add('getEnvFeatureFlags', () =>
|
||||
getEnvFeatureFlags().then((response) => response.body.data),
|
||||
);
|
||||
|
||||
Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => {
|
||||
if (Cypress.isBrowser('chrome')) {
|
||||
cy.wrap(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Load type definitions that come with Cypress module
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import type { FrontendSettings, PushPayload, PushType } from '@n8n/api-types';
|
||||
import type { FrontendSettings, PushPayload, PushType, N8nEnvFeatFlags } from '@n8n/api-types';
|
||||
|
||||
Cypress.Keyboard.defaults({
|
||||
keystrokeDelay: 0,
|
||||
@@ -51,6 +51,11 @@ declare global {
|
||||
enableQueueMode(): void;
|
||||
disableQueueMode(): void;
|
||||
changeQuota(feature: string, value: number): void;
|
||||
setEnvFeatureFlags(
|
||||
flags: N8nEnvFeatFlags,
|
||||
): Chainable<{ success: boolean; flags?: N8nEnvFeatFlags; error?: string }>;
|
||||
clearEnvFeatureFlags(): Chainable<{ success: boolean; flags: N8nEnvFeatFlags }>;
|
||||
getEnvFeatureFlags(): Chainable<N8nEnvFeatFlags>;
|
||||
waitForLoad(waitForIntercepts?: boolean): void;
|
||||
grantBrowserPermissions(...permissions: string[]): void;
|
||||
readClipboard(): Chainable<string>;
|
||||
|
||||
@@ -202,6 +202,7 @@ export interface FrontendSettings {
|
||||
|
||||
/** Backend modules that were initialized during startup. */
|
||||
activeModules: string[];
|
||||
envFeatureFlags: N8nEnvFeatFlags;
|
||||
}
|
||||
|
||||
export type FrontendModuleSettings = {
|
||||
@@ -218,3 +219,6 @@ export type FrontendModuleSettings = {
|
||||
dateRanges: InsightsDateRange[];
|
||||
};
|
||||
};
|
||||
|
||||
export type N8nEnvFeatFlagValue = boolean | string | number | undefined;
|
||||
export type N8nEnvFeatFlags = Record<`N8N_ENV_FEAT_${Uppercase<string>}`, N8nEnvFeatFlagValue>;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Logger } from '@n8n/backend-common';
|
||||
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
|
||||
import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
|
||||
import { SettingsRepository, UserRepository } from '@n8n/db';
|
||||
import { Patch, Post, RestController } from '@n8n/decorators';
|
||||
import { Get, Patch, Post, RestController } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import { Request } from 'express';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -17,6 +17,7 @@ import { License } from '@/license';
|
||||
import { MfaService } from '@/mfa/mfa.service';
|
||||
import { Push } from '@/push';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { FrontendService } from '@/services/frontend.service';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
|
||||
if (!inE2ETests) {
|
||||
@@ -154,6 +155,7 @@ export class E2EController {
|
||||
private readonly passwordUtility: PasswordUtility,
|
||||
private readonly eventBus: MessageEventBus,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly frontendService: FrontendService,
|
||||
) {
|
||||
license.isLicensed = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false;
|
||||
|
||||
@@ -207,6 +209,47 @@ export class E2EController {
|
||||
return { success: true, message: `Queue mode set to ${config.getEnv('executions.mode')}` };
|
||||
}
|
||||
|
||||
@Get('/env-feature-flags', { skipAuth: true })
|
||||
async getEnvFeatureFlags() {
|
||||
const currentFlags = this.frontendService.getSettings().envFeatureFlags;
|
||||
return currentFlags;
|
||||
}
|
||||
|
||||
@Patch('/env-feature-flags', { skipAuth: true })
|
||||
async setEnvFeatureFlags(req: Request<{}, {}, { flags: Record<string, string> }>) {
|
||||
const { flags } = req.body;
|
||||
|
||||
// Validate that all flags start with N8N_ENV_FEAT_
|
||||
for (const key of Object.keys(flags)) {
|
||||
if (!key.startsWith('N8N_ENV_FEAT_')) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid flag key: ${key}. Must start with N8N_ENV_FEAT_`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing N8N_ENV_FEAT_ environment variables
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith('N8N_ENV_FEAT_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Set new environment variables
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
// Return the current environment feature flags
|
||||
const currentFlags = this.frontendService.getSettings().envFeatureFlags;
|
||||
return {
|
||||
success: true,
|
||||
message: 'Environment feature flags updated',
|
||||
flags: currentFlags,
|
||||
};
|
||||
}
|
||||
|
||||
private resetFeatures() {
|
||||
for (const feature of Object.keys(this.enabledFeatures)) {
|
||||
this.enabledFeatures[feature as BooleanLicenseFeature] = false;
|
||||
|
||||
269
packages/cli/src/services/__tests__/frontend.service.test.ts
Normal file
269
packages/cli/src/services/__tests__/frontend.service.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { GlobalConfig, SecurityConfig } from '@n8n/config';
|
||||
import type { Logger, LicenseState, ModuleRegistry } from '@n8n/backend-common';
|
||||
import type { InstanceSettings, BinaryDataConfig } from 'n8n-core';
|
||||
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import type { CredentialTypes } from '@/credential-types';
|
||||
import type { CredentialsOverwrites } from '@/credentials-overwrites';
|
||||
import type { License } from '@/license';
|
||||
import type { UserManagementMailer } from '@/user-management/email';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
import type { PushConfig } from '@/push/push.config';
|
||||
import type { MfaService } from '@/mfa/mfa.service';
|
||||
|
||||
import { FrontendService } from '@/services/frontend.service';
|
||||
|
||||
describe('FrontendService', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('envFeatureFlags functionality', () => {
|
||||
const createMockService = () => {
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
database: { type: 'sqlite' },
|
||||
endpoints: { rest: 'rest' },
|
||||
diagnostics: { enabled: false },
|
||||
templates: { enabled: false, host: '' },
|
||||
nodes: { communityPackages: { enabled: false } },
|
||||
tags: { disabled: false },
|
||||
logging: { level: 'info' },
|
||||
hiringBanner: { enabled: false },
|
||||
versionNotifications: {
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
whatsNewEnabled: false,
|
||||
whatsNewEndpoint: '',
|
||||
infoUrl: '',
|
||||
},
|
||||
personalization: { enabled: false },
|
||||
defaultLocale: 'en',
|
||||
auth: { cookie: { secure: false } },
|
||||
generic: { releaseChannel: 'stable', timezone: 'UTC' },
|
||||
publicApi: { path: 'api', swaggerUiDisabled: false },
|
||||
workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' },
|
||||
executions: { pruneData: false, pruneDataMaxAge: 336, pruneDataMaxCount: 10000 },
|
||||
hideUsagePage: false,
|
||||
license: { tenantId: 1 },
|
||||
mfa: { enabled: false },
|
||||
deployment: { type: 'default' },
|
||||
workflowHistory: { enabled: false },
|
||||
partialExecutions: { version: 1 },
|
||||
path: '',
|
||||
sso: {
|
||||
ldap: { loginEnabled: false },
|
||||
saml: { loginEnabled: false },
|
||||
oidc: { loginEnabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const logger = mock<Logger>();
|
||||
const instanceSettings = mock<InstanceSettings>({
|
||||
isDocker: false,
|
||||
instanceId: 'test-instance',
|
||||
isMultiMain: false,
|
||||
hostId: 'test-host',
|
||||
staticCacheDir: '/tmp/test-cache',
|
||||
});
|
||||
|
||||
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
|
||||
addPostProcessor: jest.fn(),
|
||||
types: {
|
||||
credentials: [],
|
||||
nodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const binaryDataConfig = mock<BinaryDataConfig>({
|
||||
mode: 'default',
|
||||
availableModes: ['default'],
|
||||
});
|
||||
|
||||
const credentialTypes = mock<CredentialTypes>({
|
||||
getParentTypes: jest.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const credentialsOverwrites = mock<CredentialsOverwrites>({
|
||||
getAll: jest.fn().mockReturnValue({}),
|
||||
});
|
||||
|
||||
const license = mock<License>({
|
||||
getUsersLimit: jest.fn().mockReturnValue(100),
|
||||
getPlanName: jest.fn().mockReturnValue('Community'),
|
||||
getConsumerId: jest.fn().mockReturnValue('test-consumer'),
|
||||
isSharingEnabled: jest.fn().mockReturnValue(false),
|
||||
isLogStreamingEnabled: jest.fn().mockReturnValue(false),
|
||||
isLdapEnabled: jest.fn().mockReturnValue(false),
|
||||
isSamlEnabled: jest.fn().mockReturnValue(false),
|
||||
isAdvancedExecutionFiltersEnabled: jest.fn().mockReturnValue(false),
|
||||
isVariablesEnabled: jest.fn().mockReturnValue(false),
|
||||
isSourceControlLicensed: jest.fn().mockReturnValue(false),
|
||||
isExternalSecretsEnabled: jest.fn().mockReturnValue(false),
|
||||
isLicensed: jest.fn().mockReturnValue(false),
|
||||
isDebugInEditorLicensed: jest.fn().mockReturnValue(false),
|
||||
isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false),
|
||||
isWorkerViewLicensed: jest.fn().mockReturnValue(false),
|
||||
isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false),
|
||||
isApiKeyScopesEnabled: jest.fn().mockReturnValue(false),
|
||||
getVariablesLimit: jest.fn().mockReturnValue(0),
|
||||
getTeamProjectLimit: jest.fn().mockReturnValue(0),
|
||||
isBinaryDataS3Licensed: jest.fn().mockReturnValue(false),
|
||||
isAiAssistantEnabled: jest.fn().mockReturnValue(false),
|
||||
isAskAiEnabled: jest.fn().mockReturnValue(false),
|
||||
isAiCreditsEnabled: jest.fn().mockReturnValue(false),
|
||||
getAiCredits: jest.fn().mockReturnValue(0),
|
||||
isFoldersEnabled: jest.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
const mailer = mock<UserManagementMailer>({
|
||||
isEmailSetUp: false,
|
||||
});
|
||||
|
||||
const urlService = mock<UrlService>({
|
||||
getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
|
||||
getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
|
||||
});
|
||||
|
||||
const securityConfig = mock<SecurityConfig>({
|
||||
blockFileAccessToN8nFiles: false,
|
||||
});
|
||||
|
||||
const pushConfig = mock<PushConfig>({
|
||||
backend: 'websocket',
|
||||
});
|
||||
|
||||
const licenseState = mock<LicenseState>({
|
||||
isOidcLicensed: jest.fn().mockReturnValue(false),
|
||||
isMFAEnforcementLicensed: jest.fn().mockReturnValue(false),
|
||||
getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0),
|
||||
});
|
||||
|
||||
const moduleRegistry = mock<ModuleRegistry>({
|
||||
getActiveModules: jest.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const mfaService = mock<MfaService>({
|
||||
isMFAEnforced: jest.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
return new FrontendService(
|
||||
globalConfig,
|
||||
logger,
|
||||
loadNodesAndCredentials,
|
||||
credentialTypes,
|
||||
credentialsOverwrites,
|
||||
license,
|
||||
mailer,
|
||||
instanceSettings,
|
||||
urlService,
|
||||
securityConfig,
|
||||
pushConfig,
|
||||
binaryDataConfig,
|
||||
licenseState,
|
||||
moduleRegistry,
|
||||
mfaService,
|
||||
);
|
||||
};
|
||||
|
||||
describe('collectEnvFeatureFlags', () => {
|
||||
it('should collect environment variables with N8N_ENV_FEAT_ prefix', () => {
|
||||
process.env = {
|
||||
N8N_ENV_FEAT_TEST_FLAG: 'true',
|
||||
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
|
||||
N8N_ENV_FEAT_NUMERIC_FLAG: '123',
|
||||
REGULAR_ENV_VAR: 'should-not-be-included',
|
||||
N8N_OTHER_PREFIX: 'should-not-be-included',
|
||||
};
|
||||
|
||||
const service = createMockService();
|
||||
const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
|
||||
const result = collectEnvFeatureFlags();
|
||||
|
||||
expect(result).toEqual({
|
||||
N8N_ENV_FEAT_TEST_FLAG: 'true',
|
||||
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
|
||||
N8N_ENV_FEAT_NUMERIC_FLAG: '123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no N8N_ENV_FEAT_ variables are set', () => {
|
||||
process.env = {
|
||||
REGULAR_ENV_VAR: 'value',
|
||||
N8N_OTHER_PREFIX: 'value',
|
||||
};
|
||||
|
||||
const service = createMockService();
|
||||
const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
|
||||
const result = collectEnvFeatureFlags();
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should filter out undefined environment variable values', () => {
|
||||
process.env = {
|
||||
N8N_ENV_FEAT_DEFINED_FLAG: 'true',
|
||||
N8N_ENV_FEAT_UNDEFINED_FLAG: undefined,
|
||||
};
|
||||
|
||||
const service = createMockService();
|
||||
const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
|
||||
const result = collectEnvFeatureFlags();
|
||||
|
||||
expect(result).toEqual({
|
||||
N8N_ENV_FEAT_DEFINED_FLAG: 'true',
|
||||
// N8N_ENV_FEAT_UNDEFINED_FLAG should be filtered out
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings integration', () => {
|
||||
it('should include envFeatureFlags in initial settings', () => {
|
||||
process.env = {
|
||||
N8N_ENV_FEAT_INIT_FLAG: 'true',
|
||||
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
|
||||
};
|
||||
|
||||
const service = createMockService();
|
||||
|
||||
expect(service.settings.envFeatureFlags).toEqual({
|
||||
N8N_ENV_FEAT_INIT_FLAG: 'true',
|
||||
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh envFeatureFlags when getSettings is called', () => {
|
||||
process.env = {
|
||||
N8N_ENV_FEAT_INITIAL_FLAG: 'true',
|
||||
};
|
||||
|
||||
const service = createMockService();
|
||||
|
||||
// Verify initial state
|
||||
expect(service.settings.envFeatureFlags).toEqual({
|
||||
N8N_ENV_FEAT_INITIAL_FLAG: 'true',
|
||||
});
|
||||
|
||||
// Change environment
|
||||
process.env = {
|
||||
N8N_ENV_FEAT_INITIAL_FLAG: 'false',
|
||||
N8N_ENV_FEAT_NEW_FLAG: 'true',
|
||||
};
|
||||
|
||||
// getSettings should refresh the flags
|
||||
const settings = service.getSettings();
|
||||
|
||||
expect(settings.envFeatureFlags).toEqual({
|
||||
N8N_ENV_FEAT_INITIAL_FLAG: 'false',
|
||||
N8N_ENV_FEAT_NEW_FLAG: 'true',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types';
|
||||
import type { FrontendSettings, ITelemetrySettings, N8nEnvFeatFlags } from '@n8n/api-types';
|
||||
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||
import { GlobalConfig, SecurityConfig } from '@n8n/config';
|
||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
@@ -66,6 +66,18 @@ export class FrontendService {
|
||||
}
|
||||
}
|
||||
|
||||
private collectEnvFeatureFlags(): N8nEnvFeatFlags {
|
||||
const envFeatureFlags: N8nEnvFeatFlags = {};
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('N8N_ENV_FEAT_') && value !== undefined) {
|
||||
envFeatureFlags[key as keyof N8nEnvFeatFlags] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return envFeatureFlags;
|
||||
}
|
||||
|
||||
private initSettings() {
|
||||
const instanceBaseUrl = this.urlService.getInstanceBaseUrl();
|
||||
const restEndpoint = this.globalConfig.endpoints.rest;
|
||||
@@ -260,6 +272,7 @@ export class FrontendService {
|
||||
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
|
||||
},
|
||||
activeModules: this.moduleRegistry.getActiveModules(),
|
||||
envFeatureFlags: this.collectEnvFeatureFlags(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -404,6 +417,9 @@ export class FrontendService {
|
||||
// Refresh evaluation settings
|
||||
this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations();
|
||||
|
||||
// Refresh environment feature flags
|
||||
this.settings.envFeatureFlags = this.collectEnvFeatureFlags();
|
||||
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
|
||||
@@ -153,4 +153,5 @@ export const defaultSettings: FrontendSettings = {
|
||||
quota: 0,
|
||||
},
|
||||
activeModules: [],
|
||||
envFeatureFlags: {},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import type { FrontendSettings, N8nEnvFeatFlags, N8nEnvFeatFlagValue } from '@n8n/api-types';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore, type MockedStore } from '@/__tests__/utils';
|
||||
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
const renderComponent = createComponentRenderer(EnvFeatureFlag);
|
||||
|
||||
describe('EnvFeatureFlag', () => {
|
||||
let settingsStore: MockedStore<typeof useSettingsStore>;
|
||||
const originalEnv = { ...import.meta.env };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(import.meta.env).forEach((key) => {
|
||||
if (key.startsWith('N8N_ENV_FEAT_')) {
|
||||
delete (import.meta.env as N8nEnvFeatFlags)[key as keyof N8nEnvFeatFlags];
|
||||
}
|
||||
});
|
||||
createTestingPinia();
|
||||
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.settings = {
|
||||
envFeatureFlags: {},
|
||||
} as FrontendSettings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(import.meta.env, originalEnv);
|
||||
});
|
||||
|
||||
test.each<[N8nEnvFeatFlagValue, Uppercase<string>, boolean]>([
|
||||
// Truthy values that should render content
|
||||
['true', 'TEST_FLAG', true],
|
||||
['enabled', 'TEST_FLAG', true],
|
||||
['yes', 'TEST_FLAG', true],
|
||||
['1', 'TEST_FLAG', true],
|
||||
['on', 'TEST_FLAG', true],
|
||||
[true, 'TEST_FLAG', true],
|
||||
|
||||
// Falsy values that should not render content
|
||||
['false', 'TEST_FLAG', false],
|
||||
[false, 'TEST_FLAG', false],
|
||||
['', 'TEST_FLAG', false],
|
||||
[undefined, 'TEST_FLAG', false],
|
||||
[0, 'TEST_FLAG', false],
|
||||
])(
|
||||
'should %s render slot content when feature flag value is %s',
|
||||
(value, flagName, shouldRender) => {
|
||||
const envKey: keyof N8nEnvFeatFlags = `N8N_ENV_FEAT_${flagName}`;
|
||||
|
||||
settingsStore.settings.envFeatureFlags = {
|
||||
[envKey]: value,
|
||||
};
|
||||
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
name: flagName,
|
||||
},
|
||||
slots: {
|
||||
default: '<div data-testid="slot-content">Feature content</div>',
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldRender) {
|
||||
expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy();
|
||||
} else {
|
||||
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('should work with different flag names', () => {
|
||||
settingsStore.settings.envFeatureFlags = {
|
||||
N8N_ENV_FEAT_WORKFLOW_DIFFS: 'true',
|
||||
N8N_ENV_FEAT_ANOTHER_FEATURE: 'false',
|
||||
};
|
||||
|
||||
const { container: container1 } = renderComponent({
|
||||
props: {
|
||||
name: 'WORKFLOW_DIFFS',
|
||||
},
|
||||
slots: {
|
||||
default: '<div data-testid="feature-1">Feature 1</div>',
|
||||
},
|
||||
});
|
||||
|
||||
const { container: container2 } = renderComponent({
|
||||
props: {
|
||||
name: 'ANOTHER_FEATURE',
|
||||
},
|
||||
slots: {
|
||||
default: '<div data-testid="feature-2">Feature 2</div>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(container1.querySelector('[data-testid="feature-1"]')).toBeTruthy();
|
||||
expect(container2.querySelector('[data-testid="feature-2"]')).toBeNull();
|
||||
});
|
||||
|
||||
describe('runtime vs build-time priority', () => {
|
||||
it('should prioritize runtime settings over build-time env vars', () => {
|
||||
// Set build-time env var
|
||||
(import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true';
|
||||
|
||||
// Set runtime setting to override
|
||||
settingsStore.settings.envFeatureFlags = {
|
||||
N8N_ENV_FEAT_TEST_FLAG: 'false',
|
||||
};
|
||||
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
name: 'TEST_FLAG',
|
||||
},
|
||||
slots: {
|
||||
default: '<div data-testid="slot-content">Feature content</div>',
|
||||
},
|
||||
});
|
||||
|
||||
// Should use runtime value (false) over build-time value (true)
|
||||
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should fallback to build-time env vars when runtime settings are not available', () => {
|
||||
// Set build-time env var
|
||||
(import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true';
|
||||
|
||||
// Runtime settings are empty
|
||||
settingsStore.settings.envFeatureFlags = {};
|
||||
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
name: 'TEST_FLAG',
|
||||
},
|
||||
slots: {
|
||||
default: '<div data-testid="slot-content">Feature content</div>',
|
||||
},
|
||||
});
|
||||
|
||||
// Should use build-time value
|
||||
expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when neither runtime nor build-time values are set', () => {
|
||||
// No runtime setting
|
||||
settingsStore.settings.envFeatureFlags = {};
|
||||
|
||||
// No build-time env var (already cleared in beforeEach)
|
||||
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
name: 'TEST_FLAG',
|
||||
},
|
||||
slots: {
|
||||
default: '<div data-testid="slot-content">Feature content</div>',
|
||||
},
|
||||
});
|
||||
|
||||
// Should default to false
|
||||
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useEnvFeatureFlag } from '@/features/env-feature-flag/useEnvFeatureFlag';
|
||||
|
||||
/*
|
||||
EnvFeatureFlag conditionally renders content based on environment variable based feature flags
|
||||
Environment variable feature flags are defined in form of `N8N_ENV_FEAT_<FEATURE_NAME>`
|
||||
The component's name property should be in uppercase and match the environment variable name without the prefix
|
||||
Usage example: <EnvFeatureFlag name="FEATURE_NAME"> Feature content </EnvFeatureFlag>
|
||||
*/
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EnvFeatureFlag',
|
||||
props: {
|
||||
name: {
|
||||
type: String as () => Uppercase<string>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const envFeatureFlag = useEnvFeatureFlag();
|
||||
const isEnabled = computed(() => envFeatureFlag.check.value(props.name));
|
||||
|
||||
return () => (isEnabled.value && slots.default ? slots.default() : null);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue';
|
||||
import type { N8nEnvFeatFlags } from '@n8n/api-types';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
export const useEnvFeatureFlag = () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const check = computed(() => (flag: Uppercase<string>): boolean => {
|
||||
const key = `N8N_ENV_FEAT_${flag}` as const;
|
||||
|
||||
// Settings provided by the backend take precedence over build-time or runtime flags
|
||||
const settingsProvidedEnvFeatFlag = settingsStore.settings.envFeatureFlags?.[key];
|
||||
if (settingsProvidedEnvFeatFlag !== undefined) {
|
||||
return settingsProvidedEnvFeatFlag !== 'false' && !!settingsProvidedEnvFeatFlag;
|
||||
}
|
||||
|
||||
// "Vite exposes certain constants under the special import.meta.env object. These constants are defined as global variables during dev and statically replaced at build time to make tree-shaking effective."
|
||||
// See https://vite.dev/guide/env-and-mode.html
|
||||
const buildTimeValue = (import.meta.env as N8nEnvFeatFlags)[key];
|
||||
if (buildTimeValue !== undefined) {
|
||||
return buildTimeValue !== 'false' && !!buildTimeValue;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
check,
|
||||
};
|
||||
};
|
||||
@@ -141,7 +141,7 @@ export default mergeConfig(
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
base: publicPath,
|
||||
envPrefix: 'VUE',
|
||||
envPrefix: ['VUE', 'N8N_ENV_FEAT'],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
|
||||
Reference in New Issue
Block a user