Add version-aware settings filtering for n8n API compatibility

This commit adds automatic detection of n8n server version and filters
workflow settings properties based on what the target version supports.

Changes:
- Add n8n-version.ts service for version detection via /rest/settings
- Fix nested response structure handling (data.data.versionCli)
- Add N8nVersionInfo and N8nSettingsData types
- Update N8nApiClient with getVersion() and version-aware filtering
- Clean workflow settings before API update based on detected version

Version compatibility:
- n8n < 1.37.0: 7 core properties only
- n8n 1.37.0+: adds executionOrder
- n8n 1.119.0+: adds callerPolicy, callerIds, timeSavedPerExecution, availableInMCP

Fixes #466
This commit is contained in:
thesved
2025-12-04 19:49:18 +02:00
committed by Romuald Członkowski
parent 934124fa7b
commit a70d96a373
6 changed files with 684 additions and 92 deletions

View File

@@ -14,6 +14,7 @@ import {
TagListParams,
TagListResponse,
HealthCheckResponse,
N8nVersionInfo,
Variable,
WebhookRequest,
WorkflowExport,
@@ -24,6 +25,11 @@ import {
} from '../types/n8n-api';
import { handleN8nApiError, logN8nError } from '../utils/n8n-errors';
import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation';
import {
fetchN8nVersion,
cleanSettingsForVersion,
getCachedVersion,
} from './n8n-version';
export interface N8nApiClientConfig {
baseUrl: string;
@@ -35,15 +41,19 @@ export interface N8nApiClientConfig {
export class N8nApiClient {
private client: AxiosInstance;
private maxRetries: number;
private baseUrl: string;
private versionInfo: N8nVersionInfo | null = null;
private versionFetched = false;
constructor(config: N8nApiClientConfig) {
const { baseUrl, apiKey, timeout = 30000, maxRetries = 3 } = config;
this.maxRetries = maxRetries;
this.baseUrl = baseUrl;
// Ensure baseUrl ends with /api/v1
const apiUrl = baseUrl.endsWith('/api/v1')
? baseUrl
const apiUrl = baseUrl.endsWith('/api/v1')
? baseUrl
: `${baseUrl.replace(/\/$/, '')}/api/v1`;
this.client = axios.create({
@@ -84,25 +94,52 @@ export class N8nApiClient {
);
}
/**
* Get the n8n version, fetching it if not already cached
*/
async getVersion(): Promise<N8nVersionInfo | null> {
if (!this.versionFetched) {
// Check if already cached globally
this.versionInfo = getCachedVersion(this.baseUrl);
if (!this.versionInfo) {
// Fetch from server
this.versionInfo = await fetchN8nVersion(this.baseUrl);
}
this.versionFetched = true;
}
return this.versionInfo;
}
/**
* Get cached version info without fetching
*/
getCachedVersionInfo(): N8nVersionInfo | null {
return this.versionInfo;
}
// Health check to verify API connectivity
async healthCheck(): Promise<HealthCheckResponse> {
try {
// Try the standard healthz endpoint (available on all n8n instances)
const baseUrl = this.client.defaults.baseURL || '';
const healthzUrl = baseUrl.replace(/\/api\/v\d+\/?$/, '') + '/healthz';
const response = await axios.get(healthzUrl, {
timeout: 5000,
validateStatus: (status) => status < 500
});
// Also fetch version info (will be cached)
const versionInfo = await this.getVersion();
if (response.status === 200 && response.data?.status === 'ok') {
return {
return {
status: 'ok',
features: {} // Features detection would require additional endpoints
n8nVersion: versionInfo?.version,
features: {}
};
}
// If healthz doesn't work, fall back to API check
throw new Error('healthz endpoint not available');
} catch (error) {
@@ -110,8 +147,13 @@ export class N8nApiClient {
// This is a fallback for older n8n versions
try {
await this.client.get('/workflows', { params: { limit: 1 } });
return {
// Still try to get version
const versionInfo = await this.getVersion();
return {
status: 'ok',
n8nVersion: versionInfo?.version,
features: {}
};
} catch (fallbackError) {
@@ -142,8 +184,25 @@ export class N8nApiClient {
async updateWorkflow(id: string, workflow: Partial<Workflow>): Promise<Workflow> {
try {
// First, try PUT method (newer n8n versions)
// Step 1: Basic cleaning (remove read-only fields, filter to known settings)
const cleanedWorkflow = cleanWorkflowForUpdate(workflow as Workflow);
// Step 2: Version-aware settings filtering for older n8n compatibility
// This prevents "additional properties" errors on n8n < 1.119.0
const versionInfo = await this.getVersion();
if (versionInfo) {
logger.debug(`Updating workflow with n8n version ${versionInfo.version}`);
// Apply version-specific filtering to settings
cleanedWorkflow.settings = cleanSettingsForVersion(
cleanedWorkflow.settings as Record<string, unknown>,
versionInfo
);
} else {
logger.warn('Could not determine n8n version, sending all known settings properties');
// Without version info, we send all known properties (might fail on old n8n)
}
// First, try PUT method (newer n8n versions)
try {
const response = await this.client.put(`/workflows/${id}`, cleanedWorkflow);
return response.data;
@@ -288,7 +347,7 @@ export class N8nApiClient {
// Create a new axios instance for webhook requests to avoid API interceptors
const webhookClient = axios.create({
baseURL: new URL('/', webhookUrl).toString(),
validateStatus: (status) => status < 500, // Don't throw on 4xx
validateStatus: (status: number) => status < 500, // Don't throw on 4xx
});
const response = await webhookClient.request(config);

View File

@@ -116,102 +116,73 @@ export function cleanWorkflowForCreate(workflow: Partial<Workflow>): Partial<Wor
* Clean workflow data for update operations.
*
* This function removes read-only and computed fields that should not be sent
* in API update requests. It does NOT add any default values or new fields.
* in API update requests. It filters settings to known API-accepted properties
* to prevent "additional properties" errors.
*
* Note: Unlike cleanWorkflowForCreate, this function does not add default settings.
* The n8n API will reject update requests that include properties not present in
* the original workflow ("settings must NOT have additional properties" error).
*
* Settings are filtered to only include whitelisted properties to prevent API
* errors when workflows from n8n contain UI-only or deprecated properties.
* NOTE: This function filters settings to ALL known properties (12 total).
* For version-specific filtering (compatibility with older n8n versions),
* use N8nApiClient.updateWorkflow() which automatically detects the n8n version
* and filters settings accordingly.
*
* @param workflow - The workflow object to clean
* @returns A cleaned partial workflow suitable for API updates
*/
export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
const {
// Remove read-only/computed fields
// Remove ALL read-only/computed fields (comprehensive list)
id,
createdAt,
updatedAt,
versionId,
versionCounter, // Added: n8n 1.118.1+ returns this but rejects it in updates
versionCounter,
meta,
staticData,
// Remove fields that cause API errors
pinData,
tags,
description, // Issue #431: n8n returns this field but rejects it in updates
// Remove additional fields that n8n API doesn't accept
description,
isArchived,
usedCredentials,
sharedWithProjects,
triggerCount,
shared,
active,
// Remove version-related read-only fields (Issue #466)
activeVersionId,
activeVersion,
// Keep everything else
...cleanedWorkflow
} = workflow as any;
// CRITICAL FIX for Issue #248:
// The n8n API has version-specific behavior for settings in workflow updates:
//
// PROBLEM:
// - Some versions reject updates with settings properties (community forum reports)
// - Properties like callerPolicy cause "additional properties" errors
// - Empty settings objects {} cause "additional properties" validation errors (Issue #431)
//
// SOLUTION:
// - Filter settings to only include whitelisted properties (OpenAPI spec)
// - If no settings after filtering, omit the property entirely (n8n API rejects empty objects)
// - Omitting the property prevents "additional properties" validation errors
// - Whitelisted properties prevent "additional properties" errors
//
// References:
// - Issue #431: Empty settings validation error
// - https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916
// - OpenAPI spec: workflowSettings schema
// - Tested on n8n.estyl.team (cloud) and localhost (self-hosted)
// Whitelisted settings properties from n8n Public API OpenAPI spec
// Source: https://github.com/n8n-io/n8n/blob/master/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml
//
// CRITICAL: The n8n Public API uses strict JSON schema validation with
// additionalProperties: false. These 12 properties are accepted:
const safeSettingsProperties = [
'saveExecutionProgress', // boolean
'saveManualExecutions', // boolean
'saveDataErrorExecution', // string enum: 'all' | 'none'
'saveDataSuccessExecution', // string enum: 'all' | 'none'
'executionTimeout', // number (max: 3600)
'errorWorkflow', // string (workflow ID)
'timezone', // string (e.g., 'America/New_York')
'executionOrder', // string (e.g., 'v1')
'callerPolicy', // string enum: 'any' | 'none' | 'workflowsFromAList' | 'workflowsFromSameOwner'
'callerIds', // string (comma-separated workflow IDs)
'timeSavedPerExecution', // number (in minutes)
'availableInMCP', // boolean (default: false)
];
// ALL known settings properties accepted by n8n Public API (as of n8n 1.119.0+)
// This list is the UNION of all properties ever accepted by any n8n version
// Version-specific filtering is handled by N8nApiClient.updateWorkflow()
const ALL_KNOWN_SETTINGS_PROPERTIES = new Set([
// Core properties (all versions)
'saveExecutionProgress',
'saveManualExecutions',
'saveDataErrorExecution',
'saveDataSuccessExecution',
'executionTimeout',
'errorWorkflow',
'timezone',
// Added in n8n 1.37.0
'executionOrder',
// Added in n8n 1.119.0
'callerPolicy',
'callerIds',
'timeSavedPerExecution',
'availableInMCP',
]);
if (cleanedWorkflow.settings && typeof cleanedWorkflow.settings === 'object') {
// Filter to only safe properties
const filteredSettings: any = {};
for (const key of safeSettingsProperties) {
if (key in cleanedWorkflow.settings) {
filteredSettings[key] = (cleanedWorkflow.settings as any)[key];
// Filter to only known properties (security + prevent garbage)
const filteredSettings: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cleanedWorkflow.settings)) {
if (ALL_KNOWN_SETTINGS_PROPERTIES.has(key)) {
filteredSettings[key] = value;
}
}
// n8n API behavior with settings:
// - The settings property is REQUIRED by the API
// - Empty settings objects {} are now accepted (n8n preserves existing settings)
// - Only whitelisted properties are accepted; others cause "additional properties" error
cleanedWorkflow.settings = filteredSettings;
} else {
// No settings provided - use empty object (required by API)
cleanedWorkflow.settings = {};
}

225
src/services/n8n-version.ts Normal file
View File

@@ -0,0 +1,225 @@
/**
* n8n Version Detection and Version-Aware Settings Filtering
*
* This module provides version detection for n8n instances and filters
* workflow settings based on what the target n8n version supports.
*
* VERSION HISTORY for workflowSettings in n8n Public API:
* - All versions: 7 core properties (saveExecutionProgress, saveManualExecutions,
* saveDataErrorExecution, saveDataSuccessExecution, executionTimeout,
* errorWorkflow, timezone)
* - 1.37.0+: Added executionOrder
* - 1.119.0+: Added callerPolicy, callerIds, timeSavedPerExecution, availableInMCP
*
* References:
* - https://github.com/n8n-io/n8n/pull/21297 (PR adding 4 new properties in 1.119.0)
* - https://community.n8n.io/t/n8n-api-update-workflow-does-not-accept-executionorder-setting/44512
*/
import axios from 'axios';
import { logger } from '../utils/logger';
import { N8nVersionInfo, N8nSettingsResponse } from '../types/n8n-api';
// Cache version info per base URL to avoid repeated API calls
const versionCache = new Map<string, N8nVersionInfo>();
// Settings properties supported by each n8n version range
// These are CUMULATIVE - each version adds to the previous
const SETTINGS_BY_VERSION = {
// Core properties supported by all versions
core: [
'saveExecutionProgress',
'saveManualExecutions',
'saveDataErrorExecution',
'saveDataSuccessExecution',
'executionTimeout',
'errorWorkflow',
'timezone',
],
// Added in n8n 1.37.0
v1_37_0: [
'executionOrder',
],
// Added in n8n 1.119.0 (PR #21297)
v1_119_0: [
'callerPolicy',
'callerIds',
'timeSavedPerExecution',
'availableInMCP',
],
};
/**
* Parse version string into structured version info
*/
export function parseVersion(versionString: string): N8nVersionInfo | null {
// Handle formats like "1.119.0", "1.37.0-beta.1", "0.200.0"
const match = versionString.match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
return {
version: versionString,
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
};
}
/**
* Compare two versions: returns -1 if a < b, 0 if equal, 1 if a > b
*/
export function compareVersions(a: N8nVersionInfo, b: N8nVersionInfo): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
/**
* Check if version meets minimum requirement
*/
export function versionAtLeast(version: N8nVersionInfo, major: number, minor: number, patch = 0): boolean {
const target = { version: '', major, minor, patch };
return compareVersions(version, target) >= 0;
}
/**
* Get supported settings properties for a given n8n version
*/
export function getSupportedSettingsProperties(version: N8nVersionInfo): Set<string> {
const supported = new Set<string>(SETTINGS_BY_VERSION.core);
// Add executionOrder if >= 1.37.0
if (versionAtLeast(version, 1, 37, 0)) {
SETTINGS_BY_VERSION.v1_37_0.forEach(prop => supported.add(prop));
}
// Add new properties if >= 1.119.0
if (versionAtLeast(version, 1, 119, 0)) {
SETTINGS_BY_VERSION.v1_119_0.forEach(prop => supported.add(prop));
}
return supported;
}
/**
* Fetch n8n version from /rest/settings endpoint
*
* This endpoint is available on all n8n instances and doesn't require authentication.
* Note: There's a security concern about this being unauthenticated (see n8n community),
* but it's the only reliable way to get version info.
*/
export async function fetchN8nVersion(baseUrl: string): Promise<N8nVersionInfo | null> {
// Check cache first
const cached = versionCache.get(baseUrl);
if (cached) {
logger.debug(`Using cached n8n version for ${baseUrl}: ${cached.version}`);
return cached;
}
try {
// Remove /api/v1 suffix if present to get base URL
const cleanBaseUrl = baseUrl.replace(/\/api\/v\d+\/?$/, '').replace(/\/$/, '');
const settingsUrl = `${cleanBaseUrl}/rest/settings`;
logger.debug(`Fetching n8n version from ${settingsUrl}`);
const response = await axios.get<N8nSettingsResponse>(settingsUrl, {
timeout: 5000,
validateStatus: (status: number) => status < 500,
});
if (response.status === 200 && response.data) {
// n8n wraps the settings in a "data" property
const settings = response.data.data;
if (!settings) {
logger.warn('No data in settings response');
return null;
}
// n8n can return version in different fields
const versionString = settings.n8nVersion || settings.versionCli;
if (versionString) {
const versionInfo = parseVersion(versionString);
if (versionInfo) {
// Cache the result
versionCache.set(baseUrl, versionInfo);
logger.debug(`Detected n8n version: ${versionInfo.version}`);
return versionInfo;
}
}
}
logger.warn(`Could not determine n8n version from ${settingsUrl}`);
return null;
} catch (error) {
logger.warn(`Failed to fetch n8n version: ${error instanceof Error ? error.message : 'Unknown error'}`);
return null;
}
}
/**
* Clear version cache (useful for testing or when server changes)
*/
export function clearVersionCache(): void {
versionCache.clear();
}
/**
* Get cached version for a base URL (or null if not cached)
*/
export function getCachedVersion(baseUrl: string): N8nVersionInfo | null {
return versionCache.get(baseUrl) || null;
}
/**
* Set cached version (useful for testing or when version is known)
*/
export function setCachedVersion(baseUrl: string, version: N8nVersionInfo): void {
versionCache.set(baseUrl, version);
}
/**
* Clean workflow settings for API update based on n8n version
*
* This function filters workflow settings to only include properties
* that the target n8n version supports, preventing "additional properties" errors.
*
* @param settings - The workflow settings to clean
* @param version - The target n8n version (if null, returns settings unchanged)
* @returns Cleaned settings object
*/
export function cleanSettingsForVersion(
settings: Record<string, unknown> | undefined,
version: N8nVersionInfo | null
): Record<string, unknown> {
if (!settings || typeof settings !== 'object') {
return {};
}
// If version unknown, return settings unchanged (let the API decide)
if (!version) {
return settings;
}
const supportedProperties = getSupportedSettingsProperties(version);
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(settings)) {
if (supportedProperties.has(key)) {
cleaned[key] = value;
} else {
logger.debug(`Filtered out unsupported settings property: ${key} (n8n ${version.version})`);
}
}
return cleaned;
}
// Export version thresholds for testing
export const VERSION_THRESHOLDS = {
EXECUTION_ORDER: { major: 1, minor: 37, patch: 0 },
CALLER_POLICY: { major: 1, minor: 119, patch: 0 },
};

View File

@@ -225,6 +225,28 @@ export interface HealthCheckResponse {
};
}
// n8n Version Information
export interface N8nVersionInfo {
version: string; // Full version string, e.g., "1.119.0"
major: number; // Major version number
minor: number; // Minor version number
patch: number; // Patch version number
}
// Settings data within the response
export interface N8nSettingsData {
n8nVersion?: string;
versionCli?: string;
instanceId?: string;
[key: string]: unknown;
}
// Response from /rest/settings endpoint (unauthenticated)
// The actual response wraps settings in a "data" property
export interface N8nSettingsResponse {
data?: N8nSettingsData;
}
// Request Parameter Types
export interface WorkflowListParams {
limit?: number;

View File

@@ -383,7 +383,7 @@ describe('n8n-validation', () => {
expect(cleaned.name).toBe('Test Workflow');
});
it('should provide minimal default settings when no settings provided (Issue #431)', () => {
it('should provide empty settings when no settings provided (Issue #431)', () => {
const workflow = {
name: 'Test Workflow',
nodes: [],
@@ -391,8 +391,8 @@ describe('n8n-validation', () => {
} as any;
const cleaned = cleanWorkflowForUpdate(workflow);
// n8n API requires settings to be present, so we provide minimal defaults (v1 is modern default)
expect(cleaned.settings).toEqual({ executionOrder: 'v1' });
// n8n API now accepts empty settings {} - server preserves existing values
expect(cleaned.settings).toEqual({});
});
it('should filter settings to safe properties to prevent API errors (Issue #248 - final fix)', () => {
@@ -403,20 +403,22 @@ describe('n8n-validation', () => {
settings: {
executionOrder: 'v1' as const,
saveDataSuccessExecution: 'none' as const,
callerPolicy: 'workflowsFromSameOwner' as const, // Now whitelisted (n8n 1.121+)
timeSavedPerExecution: 5, // Filtered out (UI-only property)
callerPolicy: 'workflowsFromSameOwner' as const, // Whitelisted (n8n 1.119+)
timeSavedPerExecution: 5, // Whitelisted (n8n 1.119+, PR #21297)
unknownProperty: 'should be filtered', // Unknown properties ARE filtered
},
} as any;
const cleaned = cleanWorkflowForUpdate(workflow);
// Unsafe properties filtered out, safe properties kept (callerPolicy now whitelisted)
// All 4 properties from n8n 1.119+ are whitelisted, unknown properties filtered
expect(cleaned.settings).toEqual({
executionOrder: 'v1',
saveDataSuccessExecution: 'none',
callerPolicy: 'workflowsFromSameOwner'
callerPolicy: 'workflowsFromSameOwner',
timeSavedPerExecution: 5,
});
expect(cleaned.settings).not.toHaveProperty('timeSavedPerExecution');
expect(cleaned.settings).not.toHaveProperty('unknownProperty');
});
it('should preserve callerPolicy and availableInMCP (n8n 1.121+ settings)', () => {
@@ -487,26 +489,26 @@ describe('n8n-validation', () => {
} as any;
const cleaned = cleanWorkflowForUpdate(workflow);
// n8n API requires settings, so we provide minimal defaults (v1 is modern default)
expect(cleaned.settings).toEqual({ executionOrder: 'v1' });
// n8n API now accepts empty settings {} - server preserves existing values
expect(cleaned.settings).toEqual({});
});
it('should provide minimal settings when only non-whitelisted properties exist (Issue #431)', () => {
it('should return empty settings when only non-whitelisted properties exist (Issue #431)', () => {
const workflow = {
name: 'Test Workflow',
nodes: [],
connections: {},
settings: {
timeSavedPerExecution: 5, // Filtered out (UI-only)
someOtherProperty: 'value', // Filtered out
timeSavedPerExecution: 5, // Whitelisted (n8n 1.119+)
someOtherProperty: 'value', // Filtered out (unknown)
},
} as any;
const cleaned = cleanWorkflowForUpdate(workflow);
// All properties were filtered out, but n8n API requires settings
// so we provide minimal defaults (v1 is modern default) to avoid both
// "additional properties" and "required property" API errors
expect(cleaned.settings).toEqual({ executionOrder: 'v1' });
// timeSavedPerExecution is now whitelisted, someOtherProperty is filtered out
// n8n API now accepts empty or partial settings {} - server preserves existing values
expect(cleaned.settings).toEqual({ timeSavedPerExecution: 5 });
expect(cleaned.settings).not.toHaveProperty('someOtherProperty');
});
it('should preserve whitelisted settings when mixed with non-whitelisted (Issue #431)', () => {
@@ -1408,8 +1410,8 @@ describe('n8n-validation', () => {
expect(forUpdate).not.toHaveProperty('active');
expect(forUpdate).not.toHaveProperty('tags');
expect(forUpdate).not.toHaveProperty('meta');
// n8n API requires settings in updates, so minimal defaults (v1) are provided (Issue #431)
expect(forUpdate.settings).toEqual({ executionOrder: 'v1' });
// n8n API now accepts empty settings {} - server preserves existing values
expect(forUpdate.settings).toEqual({});
expect(validateWorkflowStructure(forUpdate)).toEqual([]);
});
});

View File

@@ -0,0 +1,313 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
parseVersion,
compareVersions,
versionAtLeast,
getSupportedSettingsProperties,
cleanSettingsForVersion,
clearVersionCache,
setCachedVersion,
getCachedVersion,
VERSION_THRESHOLDS,
} from '@/services/n8n-version';
import type { N8nVersionInfo } from '@/types/n8n-api';
describe('n8n-version', () => {
beforeEach(() => {
clearVersionCache();
});
describe('parseVersion', () => {
it('should parse standard version strings', () => {
expect(parseVersion('1.119.0')).toEqual({
version: '1.119.0',
major: 1,
minor: 119,
patch: 0,
});
expect(parseVersion('1.37.0')).toEqual({
version: '1.37.0',
major: 1,
minor: 37,
patch: 0,
});
expect(parseVersion('0.200.0')).toEqual({
version: '0.200.0',
major: 0,
minor: 200,
patch: 0,
});
});
it('should parse beta/pre-release versions', () => {
const result = parseVersion('1.119.0-beta.1');
expect(result).toEqual({
version: '1.119.0-beta.1',
major: 1,
minor: 119,
patch: 0,
});
});
it('should return null for invalid versions', () => {
expect(parseVersion('invalid')).toBeNull();
expect(parseVersion('')).toBeNull();
expect(parseVersion('1.2')).toBeNull();
expect(parseVersion('v1.2.3')).toBeNull(); // No 'v' prefix support
});
});
describe('compareVersions', () => {
it('should compare major versions correctly', () => {
const v1 = parseVersion('1.0.0')!;
const v2 = parseVersion('2.0.0')!;
expect(compareVersions(v1, v2)).toBeLessThan(0);
expect(compareVersions(v2, v1)).toBeGreaterThan(0);
});
it('should compare minor versions correctly', () => {
const v1 = parseVersion('1.37.0')!;
const v2 = parseVersion('1.119.0')!;
expect(compareVersions(v1, v2)).toBeLessThan(0);
expect(compareVersions(v2, v1)).toBeGreaterThan(0);
});
it('should compare patch versions correctly', () => {
const v1 = parseVersion('1.119.0')!;
const v2 = parseVersion('1.119.1')!;
expect(compareVersions(v1, v2)).toBeLessThan(0);
});
it('should return 0 for equal versions', () => {
const v1 = parseVersion('1.119.0')!;
const v2 = parseVersion('1.119.0')!;
expect(compareVersions(v1, v2)).toBe(0);
});
});
describe('versionAtLeast', () => {
it('should return true when version meets requirement', () => {
const v = parseVersion('1.119.0')!;
expect(versionAtLeast(v, 1, 119, 0)).toBe(true);
expect(versionAtLeast(v, 1, 37, 0)).toBe(true);
expect(versionAtLeast(v, 1, 0, 0)).toBe(true);
expect(versionAtLeast(v, 0, 200, 0)).toBe(true);
});
it('should return false when version is too old', () => {
const v = parseVersion('1.36.0')!;
expect(versionAtLeast(v, 1, 37, 0)).toBe(false);
expect(versionAtLeast(v, 1, 119, 0)).toBe(false);
expect(versionAtLeast(v, 2, 0, 0)).toBe(false);
});
it('should handle edge cases at version boundaries', () => {
const v37 = parseVersion('1.37.0')!;
const v36 = parseVersion('1.36.99')!;
expect(versionAtLeast(v37, 1, 37, 0)).toBe(true);
expect(versionAtLeast(v36, 1, 37, 0)).toBe(false);
});
});
describe('getSupportedSettingsProperties', () => {
it('should return core properties for old versions (< 1.37.0)', () => {
const v = parseVersion('1.30.0')!;
const supported = getSupportedSettingsProperties(v);
// Core properties should be supported
expect(supported.has('saveExecutionProgress')).toBe(true);
expect(supported.has('saveManualExecutions')).toBe(true);
expect(supported.has('saveDataErrorExecution')).toBe(true);
expect(supported.has('saveDataSuccessExecution')).toBe(true);
expect(supported.has('executionTimeout')).toBe(true);
expect(supported.has('errorWorkflow')).toBe(true);
expect(supported.has('timezone')).toBe(true);
// executionOrder should NOT be supported
expect(supported.has('executionOrder')).toBe(false);
// New properties should NOT be supported
expect(supported.has('callerPolicy')).toBe(false);
expect(supported.has('callerIds')).toBe(false);
expect(supported.has('timeSavedPerExecution')).toBe(false);
expect(supported.has('availableInMCP')).toBe(false);
});
it('should return core + executionOrder for v1.37.0+', () => {
const v = parseVersion('1.37.0')!;
const supported = getSupportedSettingsProperties(v);
// Core properties
expect(supported.has('saveExecutionProgress')).toBe(true);
expect(supported.has('timezone')).toBe(true);
// executionOrder should be supported
expect(supported.has('executionOrder')).toBe(true);
// New properties should NOT be supported
expect(supported.has('callerPolicy')).toBe(false);
});
it('should return all properties for v1.119.0+', () => {
const v = parseVersion('1.119.0')!;
const supported = getSupportedSettingsProperties(v);
// All 12 properties should be supported
expect(supported.has('saveExecutionProgress')).toBe(true);
expect(supported.has('saveManualExecutions')).toBe(true);
expect(supported.has('saveDataErrorExecution')).toBe(true);
expect(supported.has('saveDataSuccessExecution')).toBe(true);
expect(supported.has('executionTimeout')).toBe(true);
expect(supported.has('errorWorkflow')).toBe(true);
expect(supported.has('timezone')).toBe(true);
expect(supported.has('executionOrder')).toBe(true);
expect(supported.has('callerPolicy')).toBe(true);
expect(supported.has('callerIds')).toBe(true);
expect(supported.has('timeSavedPerExecution')).toBe(true);
expect(supported.has('availableInMCP')).toBe(true);
expect(supported.size).toBe(12);
});
});
describe('cleanSettingsForVersion', () => {
const fullSettings = {
saveExecutionProgress: false,
saveManualExecutions: true,
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'none',
executionTimeout: 3600,
errorWorkflow: '',
timezone: 'UTC',
executionOrder: 'v1',
callerPolicy: 'workflowsFromSameOwner',
callerIds: '',
timeSavedPerExecution: 0,
availableInMCP: false,
};
it('should filter to core properties for old versions', () => {
const v = parseVersion('1.30.0')!;
const cleaned = cleanSettingsForVersion(fullSettings, v);
expect(Object.keys(cleaned)).toHaveLength(7);
expect(cleaned).toHaveProperty('saveExecutionProgress');
expect(cleaned).toHaveProperty('timezone');
expect(cleaned).not.toHaveProperty('executionOrder');
expect(cleaned).not.toHaveProperty('callerPolicy');
});
it('should include executionOrder for v1.37.0+', () => {
const v = parseVersion('1.37.0')!;
const cleaned = cleanSettingsForVersion(fullSettings, v);
expect(Object.keys(cleaned)).toHaveLength(8);
expect(cleaned).toHaveProperty('executionOrder');
expect(cleaned).not.toHaveProperty('callerPolicy');
});
it('should include all properties for v1.119.0+', () => {
const v = parseVersion('1.119.0')!;
const cleaned = cleanSettingsForVersion(fullSettings, v);
expect(Object.keys(cleaned)).toHaveLength(12);
expect(cleaned).toHaveProperty('callerPolicy');
expect(cleaned).toHaveProperty('availableInMCP');
});
it('should return settings unchanged when version is null', () => {
// When version unknown, return settings unchanged (let API decide)
const cleaned = cleanSettingsForVersion(fullSettings, null);
expect(cleaned).toEqual(fullSettings);
});
it('should handle empty settings', () => {
const v = parseVersion('1.119.0')!;
expect(cleanSettingsForVersion({}, v)).toEqual({});
expect(cleanSettingsForVersion(undefined, v)).toEqual({});
});
});
describe('Version cache', () => {
it('should cache and retrieve versions', () => {
const baseUrl = 'http://localhost:5678';
const version: N8nVersionInfo = {
version: '1.119.0',
major: 1,
minor: 119,
patch: 0,
};
expect(getCachedVersion(baseUrl)).toBeNull();
setCachedVersion(baseUrl, version);
expect(getCachedVersion(baseUrl)).toEqual(version);
clearVersionCache();
expect(getCachedVersion(baseUrl)).toBeNull();
});
it('should handle multiple base URLs', () => {
const url1 = 'http://localhost:5678';
const url2 = 'http://production:5678';
const v1: N8nVersionInfo = { version: '1.119.0', major: 1, minor: 119, patch: 0 };
const v2: N8nVersionInfo = { version: '1.37.0', major: 1, minor: 37, patch: 0 };
setCachedVersion(url1, v1);
setCachedVersion(url2, v2);
expect(getCachedVersion(url1)).toEqual(v1);
expect(getCachedVersion(url2)).toEqual(v2);
});
});
describe('VERSION_THRESHOLDS', () => {
it('should have correct threshold values', () => {
expect(VERSION_THRESHOLDS.EXECUTION_ORDER).toEqual({ major: 1, minor: 37, patch: 0 });
expect(VERSION_THRESHOLDS.CALLER_POLICY).toEqual({ major: 1, minor: 119, patch: 0 });
});
});
describe('Real-world version scenarios', () => {
it('should handle n8n cloud versions', () => {
// Cloud typically runs latest
const cloudVersion = parseVersion('1.125.0')!;
const supported = getSupportedSettingsProperties(cloudVersion);
expect(supported.size).toBe(12);
});
it('should handle self-hosted older versions', () => {
// Common self-hosted older version
const selfHosted = parseVersion('1.50.0')!;
const supported = getSupportedSettingsProperties(selfHosted);
expect(supported.has('executionOrder')).toBe(true);
expect(supported.has('callerPolicy')).toBe(false);
});
it('should handle workflow migration scenario', () => {
// Workflow from n8n 1.119+ with all settings
const fullSettings = {
saveExecutionProgress: true,
executionOrder: 'v1',
callerPolicy: 'workflowsFromSameOwner',
callerIds: '',
timeSavedPerExecution: 5,
availableInMCP: true,
};
// Updating to n8n 1.100 (older)
const targetVersion = parseVersion('1.100.0')!;
const cleaned = cleanSettingsForVersion(fullSettings, targetVersion);
// Should filter out properties not supported in 1.100
expect(cleaned).toHaveProperty('executionOrder');
expect(cleaned).not.toHaveProperty('callerPolicy');
expect(cleaned).not.toHaveProperty('availableInMCP');
});
});
});