mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-29 22:12:05 +00:00
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:
committed by
Romuald Członkowski
parent
934124fa7b
commit
a70d96a373
@@ -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);
|
||||
|
||||
@@ -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
225
src/services/n8n-version.ts
Normal 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 },
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
313
tests/unit/services/n8n-version.test.ts
Normal file
313
tests/unit/services/n8n-version.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user