first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
+618
View File
@@ -0,0 +1,618 @@
import { Container } from '@n8n/di';
import fs from 'fs';
import { mock } from 'jest-mock-extended';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { UserManagementConfig } from '../src/configs/user-management.config';
import type { DatabaseConfig } from '../src/index';
import { GlobalConfig } from '../src/index';
jest.mock('fs');
const mockFs = mock<typeof fs>();
fs.readFileSync = mockFs.readFileSync;
const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(() => {});
describe('GlobalConfig', () => {
beforeEach(() => {
Container.reset();
jest.clearAllMocks();
});
const originalEnv = process.env;
afterEach(() => {
process.env = originalEnv;
});
const defaultConfig: GlobalConfig = {
path: '/',
host: 'localhost',
port: 5678,
listen_address: '::',
protocol: 'http',
auth: {
cookie: {
samesite: 'lax',
secure: true,
},
},
defaultLocale: 'en',
hideUsagePage: false,
deployment: {
type: 'default',
},
mfa: {
enabled: true,
},
hiringBanner: {
enabled: true,
},
personalization: {
enabled: true,
},
proxy_hops: 0,
ssl_key: '',
ssl_cert: '',
editorBaseUrl: '',
dataTable: {
maxSize: 50 * 1024 * 1024,
sizeCheckCacheDuration: 5 * 1000,
cleanupIntervalMs: 60 * 1000,
fileMaxAgeMs: 2 * 60 * 1000,
uploadDir: path.join(tmpdir(), 'n8nDataTableUploads'),
},
database: {
logging: {
enabled: false,
maxQueryExecutionTime: 0,
options: 'error',
},
postgresdb: {
database: 'n8n',
host: 'localhost',
password: '',
poolSize: 2,
port: 5432,
schema: 'public',
connectionTimeoutMs: 20_000,
idleTimeoutMs: 30_000,
statementTimeoutMs: 5 * 60 * 1000,
ssl: {
ca: '',
cert: '',
enabled: false,
key: '',
rejectUnauthorized: true,
},
user: 'postgres',
},
sqlite: {
database: 'database.sqlite',
executeVacuumOnStartup: false,
poolSize: 3,
},
tablePrefix: '',
type: 'sqlite',
pingIntervalSeconds: 2,
} as DatabaseConfig,
credentials: {
defaultName: 'My credentials',
overwrite: {
data: '{}',
endpoint: '',
endpointAuthToken: '',
persistence: false,
},
},
userManagement: {
inviteLinksEmailOnly: false,
jwtSecret: '',
jwtSessionDurationHours: 168,
jwtRefreshTimeoutHours: 0,
emails: {
mode: 'smtp',
smtp: {
host: '',
port: 465,
secure: true,
sender: '',
startTLS: true,
auth: {
pass: '',
user: '',
privateKey: '',
serviceClient: '',
},
},
template: {
'credentials-shared': '',
'user-invited': '',
'password-reset-requested': '',
'workflow-deactivated': '',
'workflow-failure': '',
'workflow-shared': '',
'project-shared': '',
},
},
} as UserManagementConfig,
eventBus: {
checkUnsentInterval: 0,
crashRecoveryMode: 'extensive',
logWriter: {
keepLogCount: 3,
logBaseName: 'n8nEventLog',
maxFileSizeInKB: 10240,
},
},
externalHooks: {
files: [],
},
nodes: {
errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [],
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
pythonEnabled: true,
},
publicApi: {
disabled: false,
path: 'api',
swaggerUiDisabled: false,
},
templates: {
enabled: true,
host: 'https://api.n8n.io/api/',
dynamicTemplatesHost: 'https://dynamic-templates.n8n.io/templates',
},
versionNotifications: {
enabled: true,
endpoint: 'https://api.n8n.io/api/versions/',
whatsNewEnabled: true,
whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
},
dynamicBanners: {
endpoint: 'https://api.n8n.io/api/banners',
enabled: true,
},
workflows: {
defaultName: 'My workflow',
callerPolicyDefaultOption: 'workflowsFromSameOwner',
activationBatchSize: 1,
indexingEnabled: true,
useWorkflowPublicationService: false,
},
endpoints: {
metrics: {
enable: false,
prefix: 'n8n_',
includeWorkflowIdLabel: false,
includeWorkflowNameLabel: false,
includeDefaultMetrics: true,
includeMessageEventBusMetrics: false,
includeNodeTypeLabel: false,
includeCacheMetrics: false,
includeApiEndpoints: false,
includeApiPathLabel: false,
includeApiMethodLabel: false,
includeCredentialTypeLabel: false,
includeApiStatusCodeLabel: false,
includeQueueMetrics: false,
queueMetricsInterval: 20,
activeWorkflowCountInterval: 60,
includeWorkflowStatistics: false,
workflowStatisticsInterval: 300,
},
additionalNonUIRoutes: '',
disableProductionWebhooksOnMainProcess: false,
disableUi: false,
form: 'form',
formTest: 'form-test',
formWaiting: 'form-waiting',
mcp: 'mcp',
mcpTest: 'mcp-test',
payloadSizeMax: 16,
formDataFileSizeMax: 200,
rest: 'rest',
webhook: 'webhook',
webhookTest: 'webhook-test',
webhookWaiting: 'webhook-waiting',
health: '/healthz',
},
cache: {
backend: 'auto',
memory: {
maxSize: 3145728,
ttl: 3600000,
},
redis: {
prefix: 'cache',
ttl: 3600000,
},
},
chatHub: {
executionContextTtl: 3600,
maxBufferedChunks: 1000,
streamStateTtl: 300,
},
queue: {
health: {
active: false,
port: 5678,
address: '::',
},
bull: {
redis: {
db: 0,
host: 'localhost',
password: '',
port: 6379,
timeoutThreshold: 10_000,
username: '',
clusterNodes: '',
tls: false,
dualStack: false,
slotsRefreshInterval: 5_000,
slotsRefreshTimeout: 1_000,
dnsResolveStrategy: 'LOOKUP',
keepAlive: false,
keepAliveDelay: 5000,
keepAliveInterval: 5000,
reconnectOnFailover: true,
},
gracefulShutdownTimeout: 30,
prefix: 'bull',
settings: {
lockDuration: 60_000,
lockRenewTime: 10_000,
stalledInterval: 30_000,
},
},
},
taskRunners: {
mode: 'internal',
path: '/runners',
authToken: '',
listenAddress: '127.0.0.1',
maxPayload: 1024 * 1024 * 1024,
port: 5679,
maxOldSpaceSize: '',
maxConcurrency: 10,
taskTimeout: 300,
taskRequestTimeout: 60,
heartbeatInterval: 30,
insecureMode: false,
},
sentry: {
backendDsn: '',
frontendDsn: '',
environment: '',
deploymentName: '',
profilesSampleRate: 0,
tracesSampleRate: 0,
eventLoopBlockThreshold: 500,
},
logging: {
level: 'info',
format: 'text',
outputs: ['console'],
file: {
fileCountMax: 100,
fileSizeMax: 16,
location: 'logs/n8n.log',
},
scopes: [],
cron: {
activeInterval: 0,
},
},
multiMainSetup: {
enabled: false,
ttl: 10,
interval: 3,
},
generic: {
timezone: 'America/New_York',
releaseChannel: 'dev',
gracefulShutdownTimeout: 30,
},
license: {
serverUrl: 'https://license.n8n.io/v1',
autoRenewalEnabled: true,
detachFloatingOnShutdown: true,
activationKey: '',
tenantId: 1,
cert: '',
},
security: {
restrictFileAccessTo: '~/.n8n-files',
blockFileAccessToN8nFiles: true,
blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$',
daysAbandonedWorkflow: 90,
contentSecurityPolicy: '{}',
contentSecurityPolicyReportOnly: false,
crossOriginOpenerPolicy: 'same-origin',
disableWebhookHtmlSandboxing: false,
disableBareRepos: true,
awsSystemCredentialsAccess: false,
enableGitNodeHooks: false,
enableGitNodeAllConfigKeys: false,
},
executions: {
mode: 'regular',
timeout: -1,
maxTimeout: 3600,
pruneData: true,
pruneDataMaxAge: 336,
pruneDataMaxCount: 10_000,
pruneDataHardDeleteBuffer: 1,
pruneDataIntervals: {
hardDelete: 15,
softDelete: 60,
},
concurrency: {
productionLimit: -1,
evaluationLimit: -1,
},
queueRecovery: {
interval: 180,
batchSize: 100,
},
recovery: {
maxLastExecutions: 3,
workflowDeactivationEnabled: false,
},
saveDataOnError: 'all',
saveDataOnSuccess: 'all',
saveExecutionProgress: false,
saveDataManualExecutions: true,
},
diagnostics: {
enabled: true,
frontendConfig: '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io',
backendConfig: '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io',
posthogConfig: {
apiKey: 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo',
apiHost: 'https://us.i.posthog.com',
},
},
aiAssistant: {
baseUrl: '',
},
aiBuilder: {
apiKey: '',
},
tags: {
disabled: false,
},
workflowHistory: {
pruneTime: -1,
},
sso: {
justInTimeProvisioning: true,
redirectLoginToSso: true,
saml: {
loginEnabled: false,
loginLabel: '',
},
oidc: {
loginEnabled: false,
},
ldap: {
loginEnabled: false,
loginLabel: '',
},
provisioning: {
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
scopesName: 'n8n',
scopesInstanceRoleClaimName: 'n8n_instance_role',
scopesProjectsRolesClaimName: 'n8n_projects',
},
},
redis: {
prefix: 'n8n',
},
externalFrontendHooksUrls: '',
// @ts-expect-error structuredClone ignores properties defined as a getter
ai: {
enabled: false,
persistBuilderSessions: false,
timeout: 3600000,
allowSendingParameterValues: true,
},
workflowHistoryCompaction: {
batchDelayMs: 1_000,
batchSize: 100,
optimizingMinimumAgeHours: 0.25,
optimizingTimeWindowHours: 2,
trimmingMinimumAgeDays: 7,
trimmingTimeWindowDays: 2,
trimOnStartUp: false,
},
};
it('should use all default values when no env variables are defined', () => {
process.env = {};
const config = Container.get(GlobalConfig);
// Makes sure the objects are structurally equal while respecting getters,
// which `toEqual` and `toBe` does not do.
expect(defaultConfig).toMatchObject(config);
expect(config).toMatchObject(defaultConfig);
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
it('should use values from env variables when defined', () => {
process.env = {
DB_POSTGRESDB_HOST: 'some-host',
DB_POSTGRESDB_USER: 'n8n',
DB_POSTGRESDB_IDLE_CONNECTION_TIMEOUT: '10000',
DB_TABLE_PREFIX: 'test_',
DB_PING_INTERVAL_SECONDS: '2',
NODES_INCLUDE: '["n8n-nodes-base.hackerNews"]',
DB_LOGGING_MAX_EXECUTION_TIME: '0',
N8N_METRICS: 'TRUE',
N8N_TEMPLATES_ENABLED: '0',
N8N_DYNAMIC_BANNERS_ENDPOINT: 'https://localhost:5678/api/banners',
N8N_DYNAMIC_BANNERS_ENABLED: 'false',
};
const config = Container.get(GlobalConfig);
expect(structuredClone(config)).toEqual({
...defaultConfig,
database: {
logging: defaultConfig.database.logging,
postgresdb: {
...defaultConfig.database.postgresdb,
host: 'some-host',
user: 'n8n',
idleTimeoutMs: 10_000,
},
sqlite: defaultConfig.database.sqlite,
tablePrefix: 'test_',
type: 'sqlite',
pingIntervalSeconds: 2,
},
endpoints: {
...defaultConfig.endpoints,
metrics: {
...defaultConfig.endpoints.metrics,
enable: true,
},
},
nodes: {
...defaultConfig.nodes,
include: ['n8n-nodes-base.hackerNews'],
},
templates: {
...defaultConfig.templates,
enabled: false,
},
dynamicBanners: {
endpoint: 'https://localhost:5678/api/banners',
enabled: false,
},
});
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
it('should read values from files using _FILE env variables', () => {
const passwordFile = '/path/to/postgres/password';
process.env = {
DB_POSTGRESDB_PASSWORD_FILE: passwordFile,
};
mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file');
const config = Container.get(GlobalConfig);
const expected = {
...defaultConfig,
database: {
...defaultConfig.database,
postgresdb: {
...defaultConfig.database.postgresdb,
password: 'password-from-file',
},
},
};
// Makes sure the objects are structurally equal while respecting getters,
// which `toEqual` and `toBe` does not do.
expect(config).toMatchObject(expected);
expect(expected).toMatchObject(config);
expect(mockFs.readFileSync).toHaveBeenCalled();
});
it('should warn when _FILE env variable value contains whitespace', () => {
const passwordFile = '/path/to/postgres/password';
process.env = {
DB_POSTGRESDB_PASSWORD_FILE: passwordFile,
};
mockFs.readFileSync
.calledWith(passwordFile, 'utf8')
.mockReturnValueOnce('password-from-file\n');
const config = Container.get(GlobalConfig);
expect(config.database.postgresdb.password).toBe('password-from-file');
expect(consoleWarnMock).toHaveBeenCalledWith(
expect.stringContaining(
'DB_POSTGRESDB_PASSWORD_FILE contains leading or trailing whitespace',
),
);
});
it('should handle invalid numbers', () => {
process.env = {
DB_LOGGING_MAX_EXECUTION_TIME: 'abcd',
};
const config = Container.get(GlobalConfig);
expect(config.database.logging.maxQueryExecutionTime).toEqual(0);
expect(consoleWarnMock).toHaveBeenCalledWith(
'Invalid number value for DB_LOGGING_MAX_EXECUTION_TIME: abcd',
);
});
describe('string unions', () => {
it('on invalid value, should warn and fall back to default value', () => {
process.env = {
N8N_RUNNERS_MODE: 'non-existing-mode',
DB_TYPE: 'postgresdb',
};
const globalConfig = Container.get(GlobalConfig);
expect(globalConfig.taskRunners.mode).toEqual('internal');
expect(consoleWarnMock).toHaveBeenCalledWith(
expect.stringContaining(
"Invalid value for N8N_RUNNERS_MODE - Invalid enum value. Expected 'internal' | 'external', received 'non-existing-mode'. Falling back to default value.",
),
);
expect(globalConfig.database.type).toEqual('postgresdb');
});
it('should validate crossOriginOpenerPolicy enum values', () => {
process.env = {
N8N_CROSS_ORIGIN_OPENER_POLICY: 'same-origin-allow-popups',
};
const globalConfig = Container.get(GlobalConfig);
expect(globalConfig.security.crossOriginOpenerPolicy).toEqual('same-origin-allow-popups');
});
it('should warn and fall back to default for invalid crossOriginOpenerPolicy', () => {
process.env = {
N8N_CROSS_ORIGIN_OPENER_POLICY: 'invalid-policy',
};
const globalConfig = Container.get(GlobalConfig);
expect(globalConfig.security.crossOriginOpenerPolicy).toEqual('same-origin');
});
});
describe('health endpoint transformation', () => {
it('should add leading slash if not present', () => {
process.env = {
N8N_ENDPOINT_HEALTH: 'healthz',
};
const config = Container.get(GlobalConfig);
expect(config.endpoints.health).toEqual('/healthz');
});
it('should keep leading slash if already present', () => {
process.env = {
N8N_ENDPOINT_HEALTH: '/custom-health',
};
const config = Container.get(GlobalConfig);
expect(config.endpoints.health).toEqual('/custom-health');
});
it('should add leading slash to paths with multiple segments', () => {
process.env = {
N8N_ENDPOINT_HEALTH: 'api/v1/health',
};
const config = Container.get(GlobalConfig);
expect(config.endpoints.health).toEqual('/api/v1/health');
});
});
});
@@ -0,0 +1,25 @@
import { CommaSeparatedStringArray, ColonSeparatedStringArray } from '../src/custom-types';
describe('CommaSeparatedStringArray', () => {
it('should parse comma-separated string into array', () => {
const result = new CommaSeparatedStringArray('a,b,c');
expect(result).toEqual(['a', 'b', 'c']);
});
it('should handle empty strings', () => {
const result = new CommaSeparatedStringArray('a,b,,,');
expect(result).toEqual(['a', 'b']);
});
});
describe('ColonSeparatedStringArray', () => {
it('should parse colon-separated string into array', () => {
const result = new ColonSeparatedStringArray('a:b:c');
expect(result).toEqual(['a', 'b', 'c']);
});
it('should handle empty strings', () => {
const result = new ColonSeparatedStringArray('a::b:::');
expect(result).toEqual(['a', 'b']);
});
});
@@ -0,0 +1,86 @@
import { Container } from '@n8n/di';
import fs from 'fs';
import { Config, Env } from '../src/decorators';
jest.mock('fs');
const mockFs = jest.mocked(fs);
describe('decorators', () => {
const originalEnv = process.env;
beforeEach(() => {
Container.reset();
process.env = {};
jest.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
});
it('should throw when explicit typing is missing', () => {
expect(() => {
@Config
class InvalidConfig {
@Env('STRING_VALUE')
value = 'string';
}
Container.get(InvalidConfig);
}).toThrowError(
'Invalid decorator metadata on key "value" on InvalidConfig\n Please use explicit typing on all config fields',
);
});
it('should read value from _FILE env variable', () => {
const filePath = '/path/to/secret';
process.env.TEST_VALUE_FILE = filePath;
mockFs.readFileSync.mockReturnValueOnce('secret-value');
@Config
class TestConfig {
@Env('TEST_VALUE')
value: string = 'default';
}
const config = Container.get(TestConfig);
expect(config.value).toBe('secret-value');
expect(mockFs.readFileSync).toHaveBeenCalledWith(filePath, 'utf8');
});
it('should warn when _FILE env variable value contains whitespace', () => {
const filePath = '/path/to/secret';
process.env.TEST_VALUE_FILE = filePath;
mockFs.readFileSync.mockReturnValueOnce('secret-value\n');
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
@Config
class TestConfig {
@Env('TEST_VALUE')
value: string = 'default';
}
const config = Container.get(TestConfig);
expect(config.value).toBe('secret-value');
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('TEST_VALUE_FILE contains leading or trailing whitespace'),
);
consoleWarnSpy.mockRestore();
});
it('should prefer direct env variable over _FILE variant', () => {
const filePath = '/path/to/secret';
process.env.TEST_VALUE = 'direct-value';
process.env.TEST_VALUE_FILE = filePath;
@Config
class TestConfig {
@Env('TEST_VALUE')
value: string = 'default';
}
const config = Container.get(TestConfig);
expect(config.value).toBe('direct-value');
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,123 @@
import { Container } from '@n8n/di';
import { GlobalConfig } from '../src/index';
beforeEach(() => {
Container.reset();
jest.clearAllMocks();
});
const originalEnv = process.env;
afterEach(() => {
process.env = originalEnv;
});
it('should strip double quotes from string values', () => {
process.env = {
GENERIC_TIMEZONE: '"America/Bogota"',
N8N_HOST: '"localhost"',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/Bogota');
expect(config.host).toBe('localhost');
});
it('should strip single quotes from string values', () => {
process.env = {
GENERIC_TIMEZONE: "'America/Bogota'",
N8N_HOST: "'localhost'",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/Bogota');
expect(config.host).toBe('localhost');
});
it('should trim whitespace from quoted values', () => {
process.env = {
GENERIC_TIMEZONE: ' "America/Bogota" ',
N8N_HOST: " 'localhost' ",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/Bogota');
expect(config.host).toBe('localhost');
});
it('should trim whitespace from unquoted values', () => {
process.env = {
GENERIC_TIMEZONE: ' America/Bogota ',
N8N_HOST: ' localhost ',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/Bogota');
expect(config.host).toBe('localhost');
});
it('should leave mismatched quotes unchanged', () => {
process.env = {
GENERIC_TIMEZONE: '"America/Bogota\'',
N8N_HOST: '\'localhost"',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('"America/Bogota\'');
expect(config.host).toBe('\'localhost"');
});
it('should handle empty quotes', () => {
process.env = {
GENERIC_TIMEZONE: '""',
N8N_HOST: "''",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('');
expect(config.host).toBe('');
});
it('should handle single character in quotes', () => {
process.env = {
GENERIC_TIMEZONE: '"A"',
N8N_HOST: "'B'",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('A');
expect(config.host).toBe('B');
});
it('should handle values with spaces in quotes', () => {
process.env = {
GENERIC_TIMEZONE: '"America/New York"',
N8N_HOST: "'my host name'",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/New York');
expect(config.host).toBe('my host name');
});
it('should handle nested quotes', () => {
process.env = {
GENERIC_TIMEZONE: '"America/\'Bogota\'"',
N8N_HOST: '\'"localhost"\'',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe("America/'Bogota'");
expect(config.host).toBe('"localhost"');
});
it('should handle only opening or closing quotes', () => {
process.env = {
GENERIC_TIMEZONE: '"America/Bogota',
N8N_HOST: 'localhost"',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('"America/Bogota');
expect(config.host).toBe('localhost"');
});
it('should handle multiple quote pairs', () => {
process.env = {
GENERIC_TIMEZONE: '""America/Bogota""',
N8N_HOST: "''localhost''",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('"America/Bogota"'); // should strip only outer quotes
expect(config.host).toBe("'localhost'");
});