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
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:
@@ -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'");
|
||||
});
|
||||
Reference in New Issue
Block a user