node-specific-validators.test.ts•76.8 kB
import { describe, it, expect, beforeEach } from 'vitest';
import { NodeSpecificValidators, NodeValidationContext } from '@/services/node-specific-validators';
import { ValidationError, ValidationWarning } from '@/services/config-validator';
describe('NodeSpecificValidators', () => {
let context: NodeValidationContext;
beforeEach(() => {
context = {
config: {},
errors: [],
warnings: [],
suggestions: [],
autofix: {}
};
});
describe('validateSlack', () => {
describe('message send operation', () => {
beforeEach(() => {
context.config = {
resource: 'message',
operation: 'send'
};
});
it('should require channel for sending messages', () => {
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toHaveLength(2); // channel and text errors
expect(context.errors[0]).toMatchObject({
type: 'missing_required',
property: 'channel',
message: 'Channel is required to send a message'
});
});
it('should accept channelId as alternative to channel', () => {
context.config.channelId = 'C1234567890';
context.config.text = 'Hello';
NodeSpecificValidators.validateSlack(context);
const channelErrors = context.errors.filter(e => e.property === 'channel');
expect(channelErrors).toHaveLength(0);
});
it('should require message content', () => {
context.config.channel = '#general';
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'text',
message: 'Message content is required - provide text, blocks, or attachments',
fix: 'Add text field with your message content'
});
});
it('should accept blocks as alternative to text', () => {
context.config.channel = '#general';
context.config.blocks = [{ type: 'section', text: { type: 'mrkdwn', text: 'Hello' } }];
NodeSpecificValidators.validateSlack(context);
const textErrors = context.errors.filter(e => e.property === 'text');
expect(textErrors).toHaveLength(0);
});
it('should accept attachments as alternative to text', () => {
context.config.channel = '#general';
context.config.attachments = [{ text: 'Attachment text' }];
NodeSpecificValidators.validateSlack(context);
const textErrors = context.errors.filter(e => e.property === 'text');
expect(textErrors).toHaveLength(0);
});
it('should warn about text exceeding character limit', () => {
context.config.channel = '#general';
context.config.text = 'a'.repeat(40001);
NodeSpecificValidators.validateSlack(context);
expect(context.warnings).toContainEqual({
type: 'inefficient',
property: 'text',
message: 'Message text exceeds Slack\'s 40,000 character limit',
suggestion: 'Split into multiple messages or use a file upload'
});
});
it('should warn about missing threadTs when replying to thread', () => {
context.config.channel = '#general';
context.config.text = 'Reply';
context.config.replyToThread = true;
NodeSpecificValidators.validateSlack(context);
expect(context.warnings).toContainEqual({
type: 'missing_common',
property: 'threadTs',
message: 'Thread timestamp required when replying to thread',
suggestion: 'Set threadTs to the timestamp of the thread parent message'
});
});
it('should suggest linkNames for mentions', () => {
context.config.channel = '#general';
context.config.text = 'Hello @user';
NodeSpecificValidators.validateSlack(context);
expect(context.suggestions).toContain('Set linkNames=true to convert @mentions to user links');
expect(context.autofix.linkNames).toBe(true);
});
});
describe('message update operation', () => {
beforeEach(() => {
context.config = {
resource: 'message',
operation: 'update'
};
});
it('should require timestamp for updating messages', () => {
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'ts',
message: 'Message timestamp (ts) is required to update a message',
fix: 'Provide the timestamp of the message to update'
});
});
it('should require channel for updating messages', () => {
context.config.ts = '1234567890.123456';
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'channel',
message: 'Channel is required to update a message',
fix: 'Provide the channel where the message exists'
});
});
});
describe('message delete operation', () => {
beforeEach(() => {
context.config = {
resource: 'message',
operation: 'delete'
};
});
it('should require timestamp for deleting messages', () => {
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'ts',
message: 'Message timestamp (ts) is required to delete a message',
fix: 'Provide the timestamp of the message to delete'
});
});
it('should warn about permanent deletion', () => {
context.config.ts = '1234567890.123456';
context.config.channel = '#general';
NodeSpecificValidators.validateSlack(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'Message deletion is permanent and cannot be undone',
suggestion: 'Consider archiving or updating the message instead if you need to preserve history'
});
});
});
describe('channel create operation', () => {
beforeEach(() => {
context.config = {
resource: 'channel',
operation: 'create'
};
});
it('should require channel name', () => {
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'name',
message: 'Channel name is required',
fix: 'Provide a channel name (lowercase, no spaces, 1-80 characters)'
});
});
it('should validate channel name format', () => {
context.config.name = 'Test Channel';
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'name',
message: 'Channel names cannot contain spaces',
fix: 'Use hyphens or underscores instead of spaces'
});
});
it('should require lowercase channel names', () => {
context.config.name = 'TestChannel';
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'name',
message: 'Channel names must be lowercase',
fix: 'Convert the channel name to lowercase'
});
});
it('should validate channel name length', () => {
context.config.name = 'a'.repeat(81);
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'name',
message: 'Channel name exceeds 80 character limit',
fix: 'Shorten the channel name'
});
});
});
describe('user operations', () => {
it('should require user identifier for get operation', () => {
context.config = {
resource: 'user',
operation: 'get'
};
NodeSpecificValidators.validateSlack(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'user',
message: 'User identifier required - use email, user ID, or username',
fix: 'Set user to an email like "john@example.com" or user ID like "U1234567890"'
});
});
});
describe('error handling', () => {
it('should suggest error handling for Slack operations', () => {
context.config = {
resource: 'message',
operation: 'send',
channel: '#general',
text: 'Hello'
};
NodeSpecificValidators.validateSlack(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'Slack API can have rate limits and transient failures',
suggestion: 'Add onError: "continueRegularOutput" with retryOnFail for resilience'
});
expect(context.autofix).toMatchObject({
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 2,
waitBetweenTries: 3000
});
});
it('should warn about deprecated continueOnFail', () => {
context.config = {
resource: 'message',
operation: 'send',
channel: '#general',
text: 'Hello',
continueOnFail: true
};
NodeSpecificValidators.validateSlack(context);
expect(context.warnings).toContainEqual({
type: 'deprecated',
property: 'continueOnFail',
message: 'continueOnFail is deprecated. Use onError instead',
suggestion: 'Replace with onError: "continueRegularOutput"'
});
});
});
});
describe('validateGoogleSheets', () => {
describe('common validations', () => {
it('should require spreadsheet ID', () => {
context.config = {
operation: 'read'
};
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'sheetId',
message: 'Spreadsheet ID is required',
fix: 'Provide the Google Sheets document ID from the URL'
});
});
it('should accept documentId as alternative to sheetId', () => {
context.config = {
operation: 'read',
documentId: '1234567890',
range: 'Sheet1!A:B'
};
NodeSpecificValidators.validateGoogleSheets(context);
const sheetIdErrors = context.errors.filter(e => e.property === 'sheetId');
expect(sheetIdErrors).toHaveLength(0);
});
});
describe('append operation', () => {
beforeEach(() => {
context.config = {
operation: 'append',
sheetId: '1234567890'
};
});
it('should require range or columns for append', () => {
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'range',
message: 'Range or columns mapping is required for append operation',
fix: 'Specify range like "Sheet1!A:B" OR use columns with mappingMode'
});
});
it('should suggest valueInputMode', () => {
context.config.range = 'Sheet1!A:B';
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.warnings).toContainEqual({
type: 'missing_common',
property: 'options.valueInputMode',
message: 'Consider setting valueInputMode for proper data formatting',
suggestion: 'Use "USER_ENTERED" to parse formulas and dates, or "RAW" for literal values'
});
expect(context.autofix.options).toMatchObject({
valueInputMode: 'USER_ENTERED'
});
});
});
describe('read operation', () => {
beforeEach(() => {
context.config = {
operation: 'read',
sheetId: '1234567890'
};
});
it('should require range for read', () => {
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'range',
message: 'Range is required for read operation',
fix: 'Specify range like "Sheet1!A:B" or "Sheet1!A1:B10"'
});
});
it('should suggest data structure option', () => {
context.config.range = 'Sheet1!A:B';
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.suggestions).toContain('Consider setting options.dataStructure to "object" for easier data manipulation');
});
});
describe('update operation', () => {
beforeEach(() => {
context.config = {
operation: 'update',
sheetId: '1234567890'
};
});
it('should require range for update', () => {
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'range',
message: 'Range is required for update operation',
fix: 'Specify the exact range to update like "Sheet1!A1:B10"'
});
});
it('should require values for update', () => {
context.config.range = 'Sheet1!A1:B10';
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'values',
message: 'Values are required for update operation',
fix: 'Provide the data to write to the spreadsheet'
});
});
it('should accept rawData as alternative to values', () => {
context.config.range = 'Sheet1!A1:B10';
context.config.rawData = [[1, 2], [3, 4]];
NodeSpecificValidators.validateGoogleSheets(context);
const valuesErrors = context.errors.filter(e => e.property === 'values');
expect(valuesErrors).toHaveLength(0);
});
});
describe('delete operation', () => {
beforeEach(() => {
context.config = {
operation: 'delete',
sheetId: '1234567890'
};
});
it('should require toDelete specification', () => {
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'toDelete',
message: 'Specify what to delete (rows or columns)',
fix: 'Set toDelete to "rows" or "columns"'
});
});
it('should require startIndex for row deletion', () => {
context.config.toDelete = 'rows';
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'startIndex',
message: 'Start index is required when deleting rows',
fix: 'Specify the starting row index (0-based)'
});
});
it('should accept startIndex of 0', () => {
context.config.toDelete = 'rows';
context.config.startIndex = 0;
NodeSpecificValidators.validateGoogleSheets(context);
const startIndexErrors = context.errors.filter(e => e.property === 'startIndex');
expect(startIndexErrors).toHaveLength(0);
});
it('should warn about permanent deletion', () => {
context.config.toDelete = 'rows';
context.config.startIndex = 0;
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'Deletion is permanent. Consider backing up data first',
suggestion: 'Read the data before deletion to create a backup'
});
});
});
describe('range validation', () => {
beforeEach(() => {
context.config = {
operation: 'read',
sheetId: '1234567890'
};
});
it('should suggest including sheet name in range', () => {
context.config.range = 'A1:B10';
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.warnings).toContainEqual({
type: 'inefficient',
property: 'range',
message: 'Range should include sheet name for clarity',
suggestion: 'Format: "SheetName!A1:B10" or "SheetName!A:B"'
});
});
it('should validate sheet names with spaces', () => {
context.config.range = 'Sheet Name!A1:B10';
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'range',
message: 'Sheet names with spaces must be quoted',
fix: 'Use single quotes around sheet name: \'Sheet Name\'!A1:B10'
});
});
it('should accept quoted sheet names with spaces', () => {
context.config.range = "'Sheet Name'!A1:B10";
NodeSpecificValidators.validateGoogleSheets(context);
const rangeErrors = context.errors.filter(e => e.property === 'range' && e.message.includes('quoted'));
expect(rangeErrors).toHaveLength(0);
});
it('should validate A1 notation format', () => {
// Use an invalid range that doesn't match the A1 pattern
context.config.range = 'Sheet1!123ABC';
NodeSpecificValidators.validateGoogleSheets(context);
expect(context.warnings).toContainEqual({
type: 'inefficient',
property: 'range',
message: 'Range may not be in valid A1 notation',
suggestion: 'Examples: "Sheet1!A1:B10", "Sheet1!A:B", "Sheet1!1:10"'
});
});
});
});
describe('validateOpenAI', () => {
describe('chat create operation', () => {
beforeEach(() => {
context.config = {
resource: 'chat',
operation: 'create'
};
});
it('should require model selection', () => {
NodeSpecificValidators.validateOpenAI(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'model',
message: 'Model selection is required',
fix: 'Choose a model like "gpt-4", "gpt-3.5-turbo", etc.'
});
});
it('should warn about deprecated models', () => {
context.config.model = 'text-davinci-003';
context.config.messages = [{ role: 'user', content: 'Hello' }];
NodeSpecificValidators.validateOpenAI(context);
expect(context.warnings).toContainEqual({
type: 'deprecated',
property: 'model',
message: 'Model text-davinci-003 is deprecated',
suggestion: 'Use "gpt-3.5-turbo" or "gpt-4" instead'
});
});
it('should require messages or prompt', () => {
context.config.model = 'gpt-4';
NodeSpecificValidators.validateOpenAI(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'messages',
message: 'Messages or prompt required for chat completion',
fix: 'Add messages array or use the prompt field'
});
});
it('should accept prompt as alternative to messages', () => {
context.config.model = 'gpt-4';
context.config.prompt = 'Hello AI';
NodeSpecificValidators.validateOpenAI(context);
const messageErrors = context.errors.filter(e => e.property === 'messages');
expect(messageErrors).toHaveLength(0);
});
it('should warn about high token limits', () => {
context.config.model = 'gpt-4';
context.config.messages = [{ role: 'user', content: 'Hello' }];
context.config.maxTokens = 5000;
NodeSpecificValidators.validateOpenAI(context);
expect(context.warnings).toContainEqual({
type: 'inefficient',
property: 'maxTokens',
message: 'High token limit may increase costs significantly',
suggestion: 'Consider if you really need more than 4000 tokens'
});
});
it('should validate temperature range', () => {
context.config.model = 'gpt-4';
context.config.messages = [{ role: 'user', content: 'Hello' }];
context.config.temperature = 2.5;
NodeSpecificValidators.validateOpenAI(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'temperature',
message: 'Temperature must be between 0 and 2',
fix: 'Set temperature between 0 (deterministic) and 2 (creative)'
});
});
});
describe('error handling', () => {
it('should suggest error handling for AI API calls', () => {
context.config = {
resource: 'chat',
operation: 'create',
model: 'gpt-4',
messages: [{ role: 'user', content: 'Hello' }]
};
NodeSpecificValidators.validateOpenAI(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'AI APIs have rate limits and can return errors',
suggestion: 'Add onError: "continueRegularOutput" with retryOnFail and longer wait times'
});
expect(context.autofix).toMatchObject({
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 3,
waitBetweenTries: 5000,
alwaysOutputData: true
});
});
it('should warn about deprecated continueOnFail', () => {
context.config = {
resource: 'chat',
operation: 'create',
model: 'gpt-4',
messages: [{ role: 'user', content: 'Hello' }],
continueOnFail: true
};
NodeSpecificValidators.validateOpenAI(context);
expect(context.warnings).toContainEqual({
type: 'deprecated',
property: 'continueOnFail',
message: 'continueOnFail is deprecated. Use onError instead',
suggestion: 'Replace with onError: "continueRegularOutput"'
});
});
});
});
describe('validateMongoDB', () => {
describe('common validations', () => {
it('should require collection name', () => {
context.config = {
operation: 'find'
};
NodeSpecificValidators.validateMongoDB(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'collection',
message: 'Collection name is required',
fix: 'Specify the MongoDB collection to work with'
});
});
});
describe('find operation', () => {
beforeEach(() => {
context.config = {
operation: 'find',
collection: 'users'
};
});
it('should validate query JSON', () => {
context.config.query = '{ invalid json';
NodeSpecificValidators.validateMongoDB(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'query',
message: 'Query must be valid JSON',
fix: 'Ensure query is valid JSON like: {"name": "John"}'
});
});
it('should accept valid JSON query', () => {
context.config.query = '{"name": "John"}';
NodeSpecificValidators.validateMongoDB(context);
const queryErrors = context.errors.filter(e => e.property === 'query');
expect(queryErrors).toHaveLength(0);
});
});
describe('insert operation', () => {
beforeEach(() => {
context.config = {
operation: 'insert',
collection: 'users'
};
});
it('should require document data', () => {
NodeSpecificValidators.validateMongoDB(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'fields',
message: 'Document data is required for insert',
fix: 'Provide the data to insert'
});
});
it('should accept documents as alternative to fields', () => {
context.config.documents = [{ name: 'John' }];
NodeSpecificValidators.validateMongoDB(context);
const fieldsErrors = context.errors.filter(e => e.property === 'fields');
expect(fieldsErrors).toHaveLength(0);
});
});
describe('update operation', () => {
beforeEach(() => {
context.config = {
operation: 'update',
collection: 'users'
};
});
it('should warn about update without query', () => {
NodeSpecificValidators.validateMongoDB(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'Update without query will affect all documents',
suggestion: 'Add a query to target specific documents'
});
});
});
describe('delete operation', () => {
beforeEach(() => {
context.config = {
operation: 'delete',
collection: 'users'
};
});
it('should error on delete without query', () => {
NodeSpecificValidators.validateMongoDB(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'query',
message: 'Delete without query would remove all documents - this is a critical security issue',
fix: 'Add a query to specify which documents to delete'
});
});
it('should error on delete with empty query', () => {
context.config.query = '{}';
NodeSpecificValidators.validateMongoDB(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'query',
message: 'Delete without query would remove all documents - this is a critical security issue',
fix: 'Add a query to specify which documents to delete'
});
});
});
describe('error handling', () => {
it('should suggest error handling for find operations', () => {
context.config = {
operation: 'find',
collection: 'users'
};
NodeSpecificValidators.validateMongoDB(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'MongoDB queries can fail due to connection issues',
suggestion: 'Add onError: "continueRegularOutput" with retryOnFail'
});
expect(context.autofix).toMatchObject({
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 3
});
});
it('should suggest different error handling for write operations', () => {
context.config = {
operation: 'insert',
collection: 'users',
fields: { name: 'John' }
};
NodeSpecificValidators.validateMongoDB(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'MongoDB write operations should handle errors carefully',
suggestion: 'Add onError: "continueErrorOutput" to handle write failures separately'
});
expect(context.autofix).toMatchObject({
onError: 'continueErrorOutput',
retryOnFail: true,
maxTries: 2,
waitBetweenTries: 1000
});
});
it('should warn about deprecated continueOnFail', () => {
context.config = {
operation: 'find',
collection: 'users',
continueOnFail: true
};
NodeSpecificValidators.validateMongoDB(context);
expect(context.warnings).toContainEqual({
type: 'deprecated',
property: 'continueOnFail',
message: 'continueOnFail is deprecated. Use onError instead',
suggestion: 'Replace with onError: "continueRegularOutput" or "continueErrorOutput"'
});
});
});
});
describe('validatePostgres', () => {
describe('insert operation', () => {
beforeEach(() => {
context.config = {
operation: 'insert'
};
});
it('should require table name', () => {
NodeSpecificValidators.validatePostgres(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'table',
message: 'Table name is required for insert operation',
fix: 'Specify the table to insert data into'
});
});
it('should warn about missing columns', () => {
context.config.table = 'users';
NodeSpecificValidators.validatePostgres(context);
expect(context.warnings).toContainEqual({
type: 'missing_common',
property: 'columns',
message: 'No columns specified for insert',
suggestion: 'Define which columns to insert data into'
});
});
it('should not warn if dataMode is set', () => {
context.config.table = 'users';
context.config.dataMode = 'autoMapInputData';
NodeSpecificValidators.validatePostgres(context);
const columnWarnings = context.warnings.filter(w => w.property === 'columns');
expect(columnWarnings).toHaveLength(0);
});
});
describe('update operation', () => {
beforeEach(() => {
context.config = {
operation: 'update'
};
});
it('should require table name', () => {
NodeSpecificValidators.validatePostgres(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'table',
message: 'Table name is required for update operation',
fix: 'Specify the table to update'
});
});
it('should warn about missing updateKey', () => {
context.config.table = 'users';
NodeSpecificValidators.validatePostgres(context);
expect(context.warnings).toContainEqual({
type: 'missing_common',
property: 'updateKey',
message: 'No update key specified',
suggestion: 'Set updateKey to identify which rows to update (e.g., "id")'
});
});
});
describe('delete operation', () => {
beforeEach(() => {
context.config = {
operation: 'delete'
};
});
it('should require table name', () => {
NodeSpecificValidators.validatePostgres(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'table',
message: 'Table name is required for delete operation',
fix: 'Specify the table to delete from'
});
});
it('should require deleteKey', () => {
context.config.table = 'users';
NodeSpecificValidators.validatePostgres(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'deleteKey',
message: 'Delete key is required to identify rows',
fix: 'Set deleteKey (e.g., "id") to specify which rows to delete'
});
});
});
describe('execute operation', () => {
beforeEach(() => {
context.config = {
operation: 'execute'
};
});
it('should require SQL query', () => {
NodeSpecificValidators.validatePostgres(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'query',
message: 'SQL query is required',
fix: 'Provide the SQL query to execute'
});
});
});
describe('SQL query validation', () => {
beforeEach(() => {
context.config = {
operation: 'execute'
};
});
it('should warn about SQL injection risks', () => {
context.config.query = 'SELECT * FROM users WHERE id = ${userId}';
NodeSpecificValidators.validatePostgres(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'Query contains template expressions that might be vulnerable to SQL injection',
suggestion: 'Use parameterized queries with query parameters instead of string interpolation'
});
});
it('should error on DELETE without WHERE', () => {
context.config.query = 'DELETE FROM users';
NodeSpecificValidators.validatePostgres(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'query',
message: 'DELETE query without WHERE clause will delete all records',
fix: 'Add a WHERE clause to specify which records to delete'
});
});
it('should warn on UPDATE without WHERE', () => {
context.config.query = 'UPDATE users SET active = true';
NodeSpecificValidators.validatePostgres(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'UPDATE query without WHERE clause will update all records',
suggestion: 'Add a WHERE clause to specify which records to update'
});
});
it('should warn about TRUNCATE', () => {
context.config.query = 'TRUNCATE TABLE users';
NodeSpecificValidators.validatePostgres(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'TRUNCATE will remove all data from the table',
suggestion: 'Consider using DELETE with WHERE clause if you need to keep some data'
});
});
it('should error on DROP operations', () => {
context.config.query = 'DROP TABLE users';
NodeSpecificValidators.validatePostgres(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'query',
message: 'DROP operations are extremely dangerous and will permanently delete database objects',
fix: 'Use this only if you really intend to delete tables/databases permanently'
});
});
it('should suggest specific columns instead of SELECT *', () => {
context.config.query = 'SELECT * FROM users';
NodeSpecificValidators.validatePostgres(context);
expect(context.suggestions).toContain('Consider selecting specific columns instead of * for better performance');
});
it('should suggest PostgreSQL-specific dollar quotes', () => {
context.config.query = 'CREATE FUNCTION test() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql';
NodeSpecificValidators.validatePostgres(context);
expect(context.suggestions).toContain('Dollar-quoted strings detected - ensure they are properly closed');
});
});
describe('connection and error handling', () => {
it('should suggest connection timeout', () => {
context.config = {
operation: 'execute',
query: 'SELECT * FROM users'
};
NodeSpecificValidators.validatePostgres(context);
expect(context.suggestions).toContain('Consider setting connectionTimeout to handle slow connections');
});
it('should suggest error handling for read operations', () => {
context.config = {
operation: 'execute',
query: 'SELECT * FROM users'
};
NodeSpecificValidators.validatePostgres(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'Database reads can fail due to connection issues',
suggestion: 'Add onError: "continueRegularOutput" and retryOnFail: true'
});
expect(context.autofix).toMatchObject({
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 3
});
});
it('should suggest different error handling for write operations', () => {
context.config = {
operation: 'insert',
table: 'users'
};
NodeSpecificValidators.validatePostgres(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'Database writes should handle errors carefully',
suggestion: 'Add onError: "stopWorkflow" with retryOnFail for transient failures'
});
expect(context.autofix).toMatchObject({
onError: 'stopWorkflow',
retryOnFail: true,
maxTries: 2,
waitBetweenTries: 2000
});
});
it('should warn about deprecated continueOnFail', () => {
context.config = {
operation: 'execute',
query: 'SELECT * FROM users',
continueOnFail: true
};
NodeSpecificValidators.validatePostgres(context);
expect(context.warnings).toContainEqual({
type: 'deprecated',
property: 'continueOnFail',
message: 'continueOnFail is deprecated. Use onError instead',
suggestion: 'Replace with onError: "continueRegularOutput" or "stopWorkflow"'
});
});
});
});
describe('validateMySQL', () => {
describe('operations', () => {
it('should validate insert operation', () => {
context.config = {
operation: 'insert'
};
NodeSpecificValidators.validateMySQL(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'table',
message: 'Table name is required for insert operation',
fix: 'Specify the table to insert data into'
});
});
it('should validate update operation', () => {
context.config = {
operation: 'update'
};
NodeSpecificValidators.validateMySQL(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'table',
message: 'Table name is required for update operation',
fix: 'Specify the table to update'
});
});
it('should validate delete operation', () => {
context.config = {
operation: 'delete'
};
NodeSpecificValidators.validateMySQL(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'table',
message: 'Table name is required for delete operation',
fix: 'Specify the table to delete from'
});
});
it('should validate execute operation', () => {
context.config = {
operation: 'execute'
};
NodeSpecificValidators.validateMySQL(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'query',
message: 'SQL query is required',
fix: 'Provide the SQL query to execute'
});
});
});
describe('MySQL-specific features', () => {
it('should suggest timezone configuration', () => {
context.config = {
operation: 'execute',
query: 'SELECT NOW()'
};
NodeSpecificValidators.validateMySQL(context);
expect(context.suggestions).toContain('Consider setting timezone to ensure consistent date/time handling');
});
it('should check for MySQL backticks', () => {
context.config = {
operation: 'execute',
query: 'SELECT `name` FROM `users`'
};
NodeSpecificValidators.validateMySQL(context);
expect(context.suggestions).toContain('Using backticks for identifiers - ensure they are properly paired');
});
});
describe('error handling', () => {
it('should suggest error handling for queries', () => {
context.config = {
operation: 'execute',
query: 'SELECT * FROM users'
};
NodeSpecificValidators.validateMySQL(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'Database queries can fail due to connection issues',
suggestion: 'Add onError: "continueRegularOutput" and retryOnFail: true'
});
});
it('should suggest error handling for modifications', () => {
context.config = {
operation: 'update',
table: 'users',
updateKey: 'id'
};
NodeSpecificValidators.validateMySQL(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'Database modifications should handle errors carefully',
suggestion: 'Add onError: "stopWorkflow" with retryOnFail for transient failures'
});
});
});
});
describe('validateHttpRequest', () => {
describe('URL validation', () => {
it('should require URL', () => {
context.config = {
method: 'GET'
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'url',
message: 'URL is required for HTTP requests',
fix: 'Provide the full URL including protocol (https://...)'
});
});
it('should warn about missing protocol', () => {
context.config = {
method: 'GET',
url: 'example.com/api'
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
property: 'url',
message: 'URL should start with http:// or https://',
suggestion: 'Use https:// for secure connections'
});
});
it('should accept URLs with expressions', () => {
context.config = {
method: 'GET',
url: '{{$node.Config.json.apiUrl}}/users'
};
NodeSpecificValidators.validateHttpRequest(context);
const urlWarnings = context.warnings.filter(w => w.property === 'url');
expect(urlWarnings).toHaveLength(0);
});
});
describe('method-specific validation', () => {
it('should suggest body for POST requests', () => {
context.config = {
method: 'POST',
url: 'https://api.example.com/users'
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.warnings).toContainEqual({
type: 'missing_common',
property: 'sendBody',
message: 'POST requests typically include a body',
suggestion: 'Set sendBody: true and configure the body content'
});
});
it('should suggest body for PUT requests', () => {
context.config = {
method: 'PUT',
url: 'https://api.example.com/users/1'
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.warnings).toContainEqual({
type: 'missing_common',
property: 'sendBody',
message: 'PUT requests typically include a body',
suggestion: 'Set sendBody: true and configure the body content'
});
});
it('should suggest body for PATCH requests', () => {
context.config = {
method: 'PATCH',
url: 'https://api.example.com/users/1'
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.warnings).toContainEqual({
type: 'missing_common',
property: 'sendBody',
message: 'PATCH requests typically include a body',
suggestion: 'Set sendBody: true and configure the body content'
});
});
});
describe('error handling', () => {
it('should suggest error handling for HTTP requests', () => {
context.config = {
method: 'GET',
url: 'https://api.example.com/data'
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'HTTP requests can fail due to network issues or server errors',
suggestion: 'Add onError: "continueRegularOutput" and retryOnFail: true for resilience'
});
expect(context.autofix).toMatchObject({
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 3,
waitBetweenTries: 1000
});
});
it('should handle deprecated continueOnFail', () => {
context.config = {
method: 'GET',
url: 'https://api.example.com/data',
continueOnFail: true
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.warnings).toContainEqual({
type: 'deprecated',
property: 'continueOnFail',
message: 'continueOnFail is deprecated. Use onError instead',
suggestion: 'Replace with onError: "continueRegularOutput"'
});
expect(context.autofix.onError).toBe('continueRegularOutput');
expect(context.autofix.continueOnFail).toBeUndefined();
});
it('should handle continueOnFail false', () => {
context.config = {
method: 'GET',
url: 'https://api.example.com/data',
continueOnFail: false
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.autofix.onError).toBe('stopWorkflow');
});
});
describe('retry configuration', () => {
it('should warn about retrying non-idempotent operations', () => {
context.config = {
method: 'POST',
url: 'https://api.example.com/orders',
retryOnFail: true,
maxTries: 5
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'maxTries',
message: 'POST requests might not be idempotent. Use fewer retries.',
suggestion: 'Set maxTries: 2 for non-idempotent operations'
});
});
it('should suggest alwaysOutputData for debugging', () => {
context.config = {
method: 'GET',
url: 'https://api.example.com/data',
retryOnFail: true
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.suggestions).toContain('Enable alwaysOutputData to capture error responses for debugging');
expect(context.autofix.alwaysOutputData).toBe(true);
});
});
describe('authentication and security', () => {
it('should warn about missing authentication for API endpoints', () => {
context.config = {
method: 'GET',
url: 'https://api.example.com/users'
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.warnings).toContainEqual({
type: 'security',
property: 'authentication',
message: 'API endpoints typically require authentication',
suggestion: 'Configure authentication method (Bearer token, API key, etc.)'
});
});
it('should not warn about authentication for non-API URLs', () => {
context.config = {
method: 'GET',
url: 'https://example.com/public-page'
};
NodeSpecificValidators.validateHttpRequest(context);
const authWarnings = context.warnings.filter(w => w.property === 'authentication');
expect(authWarnings).toHaveLength(0);
});
});
describe('timeout', () => {
it('should suggest timeout configuration', () => {
context.config = {
method: 'GET',
url: 'https://api.example.com/data'
};
NodeSpecificValidators.validateHttpRequest(context);
expect(context.suggestions).toContain('Consider setting a timeout to prevent hanging requests');
});
});
});
describe('validateWebhook', () => {
describe('path validation', () => {
it('should require webhook path', () => {
context.config = {
httpMethod: 'POST'
};
NodeSpecificValidators.validateWebhook(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'path',
message: 'Webhook path is required',
fix: 'Provide a unique path like "my-webhook" or "github-events"'
});
});
it('should warn about leading slash in path', () => {
context.config = {
path: '/my-webhook',
httpMethod: 'POST'
};
NodeSpecificValidators.validateWebhook(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
property: 'path',
message: 'Webhook path should not start with /',
suggestion: 'Use "webhook-name" instead of "/webhook-name"'
});
});
});
describe('error handling', () => {
it('should suggest error handling for webhooks', () => {
context.config = {
path: 'my-webhook',
httpMethod: 'POST'
};
NodeSpecificValidators.validateWebhook(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'onError',
message: 'Webhooks should always send a response, even on error',
suggestion: 'Set onError: "continueRegularOutput" to ensure webhook responses'
});
expect(context.autofix.onError).toBe('continueRegularOutput');
});
it('should handle deprecated continueOnFail', () => {
context.config = {
path: 'my-webhook',
httpMethod: 'POST',
continueOnFail: true
};
NodeSpecificValidators.validateWebhook(context);
expect(context.warnings).toContainEqual({
type: 'deprecated',
property: 'continueOnFail',
message: 'continueOnFail is deprecated. Use onError instead',
suggestion: 'Replace with onError: "continueRegularOutput"'
});
expect(context.autofix.onError).toBe('continueRegularOutput');
expect(context.autofix.continueOnFail).toBeUndefined();
});
});
describe('response mode validation', () => {
// NOTE: responseNode mode validation was moved to workflow-validator.ts in Phase 5
// because it requires access to node-level onError property, not just config/parameters.
// See workflow-validator.ts checkWebhookErrorHandling() method for the actual implementation.
// The validation cannot be performed at the node-specific-validator level.
it.skip('should error on responseNode without error handling - MOVED TO WORKFLOW VALIDATOR', () => {
context.config = {
path: 'my-webhook',
httpMethod: 'POST',
responseMode: 'responseNode'
};
NodeSpecificValidators.validateWebhook(context);
expect(context.errors).toContainEqual({
type: 'invalid_configuration',
property: 'responseMode',
message: 'responseNode mode requires onError: "continueRegularOutput"',
fix: 'Set onError to ensure response is always sent'
});
});
it.skip('should not error on responseNode with proper error handling - MOVED TO WORKFLOW VALIDATOR', () => {
context.config = {
path: 'my-webhook',
httpMethod: 'POST',
responseMode: 'responseNode',
onError: 'continueRegularOutput'
};
NodeSpecificValidators.validateWebhook(context);
const responseModeErrors = context.errors.filter(e => e.property === 'responseMode');
expect(responseModeErrors).toHaveLength(0);
});
});
describe('debugging and security', () => {
it('should suggest alwaysOutputData for debugging', () => {
context.config = {
path: 'my-webhook',
httpMethod: 'POST'
};
NodeSpecificValidators.validateWebhook(context);
expect(context.suggestions).toContain('Enable alwaysOutputData to debug webhook payloads');
expect(context.autofix.alwaysOutputData).toBe(true);
});
it('should suggest security measures', () => {
context.config = {
path: 'my-webhook',
httpMethod: 'POST'
};
NodeSpecificValidators.validateWebhook(context);
expect(context.suggestions).toContain('Consider adding webhook validation (HMAC signature verification)');
expect(context.suggestions).toContain('Implement rate limiting for public webhooks');
});
});
});
describe('validateCode', () => {
describe('empty code validation', () => {
it('should error on empty JavaScript code', () => {
context.config = {
language: 'javaScript',
jsCode: ''
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'jsCode',
message: 'Code cannot be empty',
fix: 'Add your code logic. Start with: return [{json: {result: "success"}}]'
});
});
it('should error on whitespace-only code', () => {
context.config = {
language: 'javaScript',
jsCode: ' \n\t '
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'jsCode',
message: 'Code cannot be empty',
fix: 'Add your code logic. Start with: return [{json: {result: "success"}}]'
});
});
it('should error on empty Python code', () => {
context.config = {
language: 'python',
pythonCode: ''
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'pythonCode',
message: 'Code cannot be empty',
fix: 'Add your code logic. Start with: return [{json: {result: "success"}}]'
});
});
});
describe('JavaScript syntax validation', () => {
it('should detect duplicate const declarations', () => {
context.config = {
language: 'javaScript',
jsCode: 'const const x = 5; return [{json: {x}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Syntax error: Duplicate const declaration',
fix: 'Check your JavaScript syntax'
});
});
it('should warn about await in non-async function', () => {
context.config = {
language: 'javaScript',
jsCode: `
function fetchData() {
const result = await fetch('https://api.example.com');
return [{json: result}];
}
`
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
message: 'Using await inside a non-async function',
suggestion: 'Add async keyword to the function, or use top-level await (Code nodes support it)'
});
});
it('should suggest async usage for $helpers.httpRequest', () => {
context.config = {
language: 'javaScript',
jsCode: 'const response = $helpers.httpRequest(...); return [{json: response}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.suggestions).toContain('$helpers.httpRequest is async - use: const response = await $helpers.httpRequest(...)');
});
it('should warn about DateTime usage', () => {
context.config = {
language: 'javaScript',
jsCode: 'const now = DateTime(); return [{json: {now}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
message: 'DateTime is from Luxon library',
suggestion: 'Use DateTime.now() or DateTime.fromISO() for date operations'
});
});
});
describe('Python syntax validation', () => {
it('should warn about unnecessary main check', () => {
context.config = {
language: 'python',
pythonCode: `
if __name__ == "__main__":
result = {"status": "ok"}
return [{"json": result}]
`
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'inefficient',
message: 'if __name__ == "__main__" is not needed in Code nodes',
suggestion: 'Code node Python runs directly - remove the main check'
});
});
it('should not warn about __name__ without __main__', () => {
context.config = {
language: 'python',
pythonCode: `
module_name = __name__
return [{"json": {"module": module_name}}]
`
};
NodeSpecificValidators.validateCode(context);
const mainWarnings = context.warnings.filter(w => w.message.includes('__main__'));
expect(mainWarnings).toHaveLength(0);
});
it('should error on unavailable imports', () => {
context.config = {
language: 'python',
pythonCode: 'import requests\nreturn [{"json": {"status": "ok"}}]'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'pythonCode',
message: 'Module \'requests\' is not available in Code nodes',
fix: 'Use JavaScript Code node with $helpers.httpRequest for HTTP requests'
});
});
it('should check indentation after colons', () => {
context.config = {
language: 'python',
pythonCode: `
def process():
result = "ok"
return [{"json": {"result": result}}]
`
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'pythonCode',
message: 'Missing indentation after line 2',
fix: 'Indent the line after the colon'
});
});
});
describe('return statement validation', () => {
it('should error on missing return statement', () => {
context.config = {
language: 'javaScript',
jsCode: 'const result = {status: "ok"}; // missing return'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'missing_required',
property: 'jsCode',
message: 'Code must return data for the next node',
fix: 'Add: return [{json: {result: "success"}}]'
});
});
it('should error on object return without array', () => {
context.config = {
language: 'javaScript',
jsCode: 'return {status: "ok"};'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Return value must be an array of objects',
fix: 'Wrap in array: return [{json: yourObject}]'
});
});
it('should error on primitive return', () => {
context.config = {
language: 'javaScript',
jsCode: 'return "success";'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Cannot return primitive values directly',
fix: 'Return array of objects: return [{json: {value: yourData}}]'
});
});
it('should error on Python primitive return', () => {
context.config = {
language: 'python',
pythonCode: 'return "success"'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'pythonCode',
message: 'Cannot return primitive values directly',
fix: 'Return list of dicts: return [{"json": {"value": your_data}}]'
});
});
it('should error on array of non-objects', () => {
context.config = {
language: 'javaScript',
jsCode: 'return ["item1", "item2"];'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Array items must be objects with json property',
fix: 'Use: return [{json: {value: "data"}}] not return ["data"]'
});
});
it('should suggest proper items return format', () => {
context.config = {
language: 'javaScript',
jsCode: 'return items;'
};
NodeSpecificValidators.validateCode(context);
expect(context.suggestions).toContain(
'Returning items directly is fine if they already have {json: ...} structure. ' +
'To modify: return items.map(item => ({json: {...item.json, newField: "value"}}))'
);
});
});
describe('n8n variable usage', () => {
it('should warn when code doesn\'t reference input data', () => {
context.config = {
language: 'javaScript',
jsCode: 'const result = Math.random(); return [{json: {result}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'missing_common',
message: 'Code doesn\'t reference input data',
suggestion: 'Access input with: items, $input.all(), or $json (single-item mode)'
});
});
it('should error on expression syntax in code', () => {
context.config = {
language: 'javaScript',
jsCode: 'const name = {{$json.name}}; return [{json: {name}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Expression syntax {{...}} is not valid in Code nodes',
fix: 'Use regular JavaScript/Python syntax without double curly braces'
});
});
it('should warn about wrong $node syntax', () => {
context.config = {
language: 'javaScript',
jsCode: 'const data = $node[\'Previous Node\'].json; return [{json: data}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Use $(\'Node Name\') instead of $node[\'Node Name\'] in Code nodes',
suggestion: 'Replace $node[\'NodeName\'] with $(\'NodeName\')'
});
});
it('should warn about expression-only functions', () => {
context.config = {
language: 'javaScript',
jsCode: 'const now = $now(); return [{json: {now}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: '$now() is an expression-only function not available in Code nodes',
suggestion: 'See Code node documentation for alternatives'
});
});
it('should warn about invalid $ usage', () => {
context.config = {
language: 'javaScript',
jsCode: 'const value = $; return [{json: {value}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
message: 'Invalid $ usage detected',
suggestion: 'n8n variables start with $: $json, $input, $node, $workflow, $execution'
});
});
it('should correct helpers usage', () => {
context.config = {
language: 'javaScript',
jsCode: 'const result = helpers.httpRequest(); return [{json: {result}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Use $helpers not helpers',
suggestion: 'Change helpers. to $helpers.'
});
});
it('should warn about $helpers availability', () => {
context.config = {
language: 'javaScript',
jsCode: 'const result = await $helpers.httpRequest(); return [{json: {result}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
message: '$helpers availability varies by n8n version',
suggestion: 'Check availability first: if (typeof $helpers !== "undefined" && $helpers.httpRequest) { ... }'
});
});
it('should error on incorrect getWorkflowStaticData usage', () => {
context.config = {
language: 'javaScript',
jsCode: 'const data = $helpers.getWorkflowStaticData(); return [{json: data}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: '$helpers.getWorkflowStaticData() will cause "$helpers is not defined" error',
fix: 'Use $getWorkflowStaticData("global") or $getWorkflowStaticData("node") directly'
});
});
it('should warn about wrong JMESPath parameter order', () => {
context.config = {
language: 'javaScript',
jsCode: 'const result = $jmespath("name", data); return [{json: {result}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Code node $jmespath has reversed parameter order: $jmespath(data, query)',
suggestion: 'Use: $jmespath(dataObject, "query.path") not $jmespath("query.path", dataObject)'
});
});
it('should warn about webhook data access', () => {
context.config = {
language: 'javaScript',
jsCode: 'const payload = items[0].json.payload; return [{json: {payload}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
message: 'If processing webhook data, remember it\'s nested under .body',
suggestion: 'Webhook payloads are at items[0].json.body, not items[0].json'
});
});
it('should warn about webhook data access when webhook node is referenced', () => {
context.config = {
language: 'javaScript',
jsCode: 'const webhookData = $("Webhook"); const data = items[0].json.someField; return [{json: {data}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Webhook data is nested under .body property',
suggestion: 'Use items[0].json.body.fieldName instead of items[0].json.fieldName for webhook data'
});
});
it('should warn when code includes webhook string', () => {
context.config = {
language: 'javaScript',
jsCode: '// Process webhook response\nconst data = items[0].json.data; return [{json: {data}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'Webhook data is nested under .body property',
suggestion: 'Use items[0].json.body.fieldName instead of items[0].json.fieldName for webhook data'
});
});
it('should error on JMESPath numeric literals without backticks', () => {
context.config = {
language: 'javaScript',
jsCode: 'const filtered = $jmespath(data, "[?age >= 18]"); return [{json: {filtered}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.errors).toContainEqual({
type: 'invalid_value',
property: 'jsCode',
message: 'JMESPath numeric literal 18 must be wrapped in backticks',
fix: 'Change [?field >= 18] to [?field >= `18`]'
});
});
});
describe('code security', () => {
it('should warn about eval usage', () => {
context.config = {
language: 'javaScript',
jsCode: 'const result = eval("1 + 1"); return [{json: {result}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'Avoid eval() - it\'s a security risk',
suggestion: 'Use safer alternatives or built-in functions'
});
});
it('should warn about Function constructor', () => {
context.config = {
language: 'javaScript',
jsCode: 'const fn = new Function("return 1"); return [{json: {result: fn()}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'Avoid Function constructor - use regular functions',
suggestion: 'Use safer alternatives or built-in functions'
});
});
it('should warn about unavailable modules', () => {
context.config = {
language: 'javaScript',
jsCode: 'const axios = require("axios"); return [{json: {}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'Cannot require(\'axios\') - only built-in Node.js modules are available',
suggestion: 'Available modules: crypto, util, querystring, url, buffer'
});
});
it('should warn about dynamic require', () => {
context.config = {
language: 'javaScript',
jsCode: 'const module = require(moduleName); return [{json: {}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'Dynamic require() not supported',
suggestion: 'Use static require with string literals: require("crypto")'
});
});
it('should warn about crypto usage without require', () => {
context.config = {
language: 'javaScript',
jsCode: 'const hash = crypto.createHash("sha256"); return [{json: {hash}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'invalid_value',
message: 'Using crypto without require statement',
suggestion: 'Add: const crypto = require("crypto"); at the beginning (ignore editor warnings)'
});
});
it('should warn about file system access', () => {
context.config = {
language: 'javaScript',
jsCode: 'const fs = require("fs"); return [{json: {}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'security',
message: 'File system and process access not available in Code nodes',
suggestion: 'Use other n8n nodes for file operations (e.g., Read/Write Files node)'
});
});
});
describe('mode-specific validation', () => {
it('should warn about items usage in single-item mode', () => {
context.config = {
mode: 'runOnceForEachItem',
language: 'javaScript',
jsCode: 'const allItems = items.length; return [{json: {count: allItems}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
message: 'In "Run Once for Each Item" mode, use $json instead of items array',
suggestion: 'Access current item data with $json.fieldName'
});
});
it('should warn about $json usage without single-item mode', () => {
context.config = {
language: 'javaScript',
jsCode: 'const name = $json.name; return [{json: {name}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
message: '$json only works in "Run Once for Each Item" mode',
suggestion: 'Either set mode: "runOnceForEachItem" or use items[0].json'
});
});
});
describe('error handling', () => {
it('should suggest error handling for complex code', () => {
context.config = {
language: 'javaScript',
jsCode: 'a'.repeat(101) + '\nreturn [{json: {}}];'
};
NodeSpecificValidators.validateCode(context);
expect(context.warnings).toContainEqual({
type: 'best_practice',
property: 'errorHandling',
message: 'Code nodes can throw errors - consider error handling',
suggestion: 'Add onError: "continueRegularOutput" to handle errors gracefully'
});
expect(context.autofix.onError).toBe('continueRegularOutput');
});
});
});
});