From a70d96a373ddc945e997af1db5b3499e58106a6f Mon Sep 17 00:00:00 2001 From: thesved <2893181+thesved@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:49:18 +0200 Subject: [PATCH] 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 --- src/services/n8n-api-client.ts | 79 +++++- src/services/n8n-validation.ts | 97 +++---- src/services/n8n-version.ts | 225 +++++++++++++++ src/types/n8n-api.ts | 22 ++ tests/unit/services/n8n-validation.test.ts | 40 +-- tests/unit/services/n8n-version.test.ts | 313 +++++++++++++++++++++ 6 files changed, 684 insertions(+), 92 deletions(-) create mode 100644 src/services/n8n-version.ts create mode 100644 tests/unit/services/n8n-version.test.ts diff --git a/src/services/n8n-api-client.ts b/src/services/n8n-api-client.ts index a369d35..a697dbf 100644 --- a/src/services/n8n-api-client.ts +++ b/src/services/n8n-api-client.ts @@ -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 { + 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 { 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): Promise { 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, + 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); diff --git a/src/services/n8n-validation.ts b/src/services/n8n-validation.ts index ec866e5..3fe3a15 100644 --- a/src/services/n8n-validation.ts +++ b/src/services/n8n-validation.ts @@ -116,102 +116,73 @@ export function cleanWorkflowForCreate(workflow: Partial): Partial { 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 = {}; + 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 = {}; } diff --git a/src/services/n8n-version.ts b/src/services/n8n-version.ts new file mode 100644 index 0000000..3c6f279 --- /dev/null +++ b/src/services/n8n-version.ts @@ -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(); + +// 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 { + const supported = new Set(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 { + // 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(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 | undefined, + version: N8nVersionInfo | null +): Record { + 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 = {}; + 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 }, +}; diff --git a/src/types/n8n-api.ts b/src/types/n8n-api.ts index 66c7254..8801074 100644 --- a/src/types/n8n-api.ts +++ b/src/types/n8n-api.ts @@ -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; diff --git a/tests/unit/services/n8n-validation.test.ts b/tests/unit/services/n8n-validation.test.ts index 0211818..ffc44fe 100644 --- a/tests/unit/services/n8n-validation.test.ts +++ b/tests/unit/services/n8n-validation.test.ts @@ -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([]); }); }); diff --git a/tests/unit/services/n8n-version.test.ts b/tests/unit/services/n8n-version.test.ts new file mode 100644 index 0000000..6e7826f --- /dev/null +++ b/tests/unit/services/n8n-version.test.ts @@ -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'); + }); + }); +});