diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index 1ead017151..9ac2b17357 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -1,6 +1,13 @@ import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import pick from 'lodash/pick'; -import { Node, NodeConnectionTypes } from 'n8n-workflow'; +import { + Node, + NodeConnectionTypes, + NodeOperationError, + assertParamIsBoolean, + validateNodeParameters, + assertParamIsString, +} from 'n8n-workflow'; import type { IDataObject, IWebhookFunctions, @@ -15,7 +22,7 @@ import type { import { cssVariables } from './constants'; import { validateAuth } from './GenericFunctions'; import { createPage } from './templates'; -import type { LoadPreviousSessionChatOption } from './types'; +import { assertValidLoadPreviousSessionOption } from './types'; const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat'; const allowFileUploadsOption: INodeProperties = { @@ -579,8 +586,12 @@ export class ChatTrigger extends Node { async webhook(ctx: IWebhookFunctions): Promise { const res = ctx.getResponseObject(); - const isPublic = ctx.getNodeParameter('public', false) as boolean; - const nodeMode = ctx.getNodeParameter('mode', 'hostedChat') as string; + const isPublic = ctx.getNodeParameter('public', false); + assertParamIsBoolean('public', isPublic, ctx.getNode()); + + const nodeMode = ctx.getNodeParameter('mode', 'hostedChat'); + assertParamIsString('mode', nodeMode, ctx.getNode()); + if (!isPublic) { res.status(404).end(); return { @@ -588,18 +599,26 @@ export class ChatTrigger extends Node { }; } - const options = ctx.getNodeParameter('options', {}) as { - getStarted?: string; - inputPlaceholder?: string; - loadPreviousSession?: LoadPreviousSessionChatOption; - showWelcomeScreen?: boolean; - subtitle?: string; - title?: string; - allowFileUploads?: boolean; - allowedFilesMimeTypes?: string; - customCss?: string; - responseMode?: string; - }; + const options = ctx.getNodeParameter('options', {}); + validateNodeParameters( + options, + { + getStarted: { type: 'string' }, + inputPlaceholder: { type: 'string' }, + loadPreviousSession: { type: 'string' }, + showWelcomeScreen: { type: 'boolean' }, + subtitle: { type: 'string' }, + title: { type: 'string' }, + allowFileUploads: { type: 'boolean' }, + allowedFilesMimeTypes: { type: 'string' }, + customCss: { type: 'string' }, + responseMode: { type: 'string' }, + }, + ctx.getNode(), + ); + + const loadPreviousSession = options.loadPreviousSession; + assertValidLoadPreviousSessionOption(loadPreviousSession, ctx.getNode()); const enableStreaming = options.responseMode === 'streaming'; @@ -623,29 +642,36 @@ export class ChatTrigger extends Node { if (nodeMode === 'hostedChat') { // Show the chat on GET request if (webhookName === 'setup') { - const webhookUrlRaw = ctx.getNodeWebhookUrl('default') as string; + const webhookUrlRaw = ctx.getNodeWebhookUrl('default'); + if (!webhookUrlRaw) { + throw new NodeOperationError(ctx.getNode(), 'Default webhook url not set'); + } + const webhookUrl = mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw; const authentication = ctx.getNodeParameter('authentication') as | 'none' | 'basicAuth' | 'n8nUserAuth'; - const initialMessagesRaw = ctx.getNodeParameter('initialMessages', '') as string; - const initialMessages = initialMessagesRaw - .split('\n') - .filter((line) => line) - .map((line) => line.trim()); + const initialMessagesRaw = ctx.getNodeParameter('initialMessages', ''); + assertParamIsString('initialMessage', initialMessagesRaw, ctx.getNode()); const instanceId = ctx.getInstanceId(); - const i18nConfig = pick(options, ['getStarted', 'inputPlaceholder', 'subtitle', 'title']); + const i18nConfig: Record = {}; + const keys = ['getStarted', 'inputPlaceholder', 'subtitle', 'title'] as const; + for (const key of keys) { + if (options[key] !== undefined) { + i18nConfig[key] = options[key]; + } + } const page = createPage({ i18n: { en: i18nConfig, }, showWelcomeScreen: options.showWelcomeScreen, - loadPreviousSession: options.loadPreviousSession, - initialMessages, + loadPreviousSession, + initialMessages: initialMessagesRaw, webhookUrl, mode, instanceId, diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts new file mode 100644 index 0000000000..cee980b9e1 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts @@ -0,0 +1,331 @@ +import { createPage, getSanitizedInitialMessages, getSanitizedI18nConfig } from '../templates'; + +describe('ChatTrigger Templates Security', () => { + const defaultParams = { + instanceId: 'test-instance', + webhookUrl: 'http://test.com/webhook', + showWelcomeScreen: false, + loadPreviousSession: 'notSupported' as const, + i18n: { + en: {}, + }, + mode: 'test' as const, + authentication: 'none' as const, + allowFileUploads: false, + allowedFilesMimeTypes: '', + customCss: '', + enableStreaming: false, + }; + + describe('XSS Prevention in initialMessages', () => { + it('should prevent script injection through script context breakout', () => { + const maliciousInput = '"%09'; + + const result = createPage({ + ...defaultParams, + initialMessages: maliciousInput, + }); + + // Should not contain the malicious script + expect(result).not.toContain(''); + expect(result).not.toContain('"%09'; + + const result = createPage({ + ...defaultParams, + initialMessages: maliciousInput, + }); + + // Should not appear anywhere in the HTML outside of the sanitized JSON + const lines = result.split('\n'); + const unsafeLines = lines.filter( + (line) => + line.includes('') && !line.includes('initialMessages: ['), + ); + + expect(unsafeLines).toHaveLength(0); + }); + }); + + describe('I18n XSS Prevention', () => { + it('should prevent script injection through i18n config values', () => { + const maliciousInput = ''; + + const result = createPage({ + ...defaultParams, + initialMessages: '', + i18n: { + en: { + title: maliciousInput, + subtitle: maliciousInput, + getStarted: maliciousInput, + inputPlaceholder: maliciousInput, + }, + }, + }); + + // Should not contain the malicious script + expect(result).not.toContain(''); + expect(result).not.toContain('"%09'; + const result = getSanitizedInitialMessages(maliciousInput); + + expect(result).toEqual(['"%09']); + expect(result.join('')).not.toContain('', + 'vbscript:msgbox(1)', + ]; + + inputs.forEach((input) => { + const result = getSanitizedInitialMessages(input); + const joined = result.join(''); + expect(joined).not.toContain('javascript:'); + expect(joined).not.toContain('data:'); + expect(joined).not.toContain('vbscript:'); + }); + }); + + it('should preserve legitimate content', () => { + const input = 'Hello world!\nHow are you?\nGoodbye!'; + const result = getSanitizedInitialMessages(input); + + expect(result).toEqual(['Hello world!', 'How are you?', 'Goodbye!']); + }); + + it('should handle empty and whitespace-only input', () => { + expect(getSanitizedInitialMessages('')).toEqual([]); + expect(getSanitizedInitialMessages(' \n\n \t \n ')).toEqual([]); + }); + + it('should trim and filter empty lines', () => { + const input = ' First message \n\n \n Second message \n'; + const result = getSanitizedInitialMessages(input); + + expect(result).toEqual(['First message', 'Second message']); + }); + }); + + describe('getSanitizedI18nConfig function', () => { + it('should sanitize XSS payloads in all values', () => { + const maliciousInput = ''; + const input = { + title: maliciousInput, + subtitle: maliciousInput, + getStarted: maliciousInput, + inputPlaceholder: maliciousInput, + }; + + const result = getSanitizedI18nConfig(input); + + Object.values(result).forEach((value) => { + expect(value).not.toContain(''); + }); + }); + + it('should remove dangerous protocols', () => { + const input = { + title: 'javascript:alert(1)', + subtitle: 'data:text/html,', + getStarted: 'vbscript:msgbox(1)', + }; + + const result = getSanitizedI18nConfig(input); + + Object.values(result).forEach((value) => { + expect(value).not.toContain('javascript:'); + expect(value).not.toContain('data:'); + expect(value).not.toContain('vbscript:'); + }); + }); + + it('should preserve legitimate content', () => { + const input = { + title: 'Welcome to Chat', + subtitle: 'How can we help you today?', + getStarted: 'Start Conversation', + inputPlaceholder: 'Type your message...', + }; + + const result = getSanitizedI18nConfig(input); + + expect(result).toEqual(input); + }); + + it('should handle empty object', () => { + const result = getSanitizedI18nConfig({}); + expect(result).toEqual({}); + }); + + it('should handle non-string values gracefully', () => { + const input = { + title: 'Valid title', + count: 123, + enabled: true, + obj: { test: 1 }, + } as any; + + const result = getSanitizedI18nConfig(input); + + expect(result.title).toBe('Valid title'); + expect(result.count).toBe('123'); + expect(result.enabled).toBe(''); + expect(result.obj).toBe(''); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts index 7b5d4964f4..94691a8f2c 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts @@ -1,6 +1,38 @@ import sanitizeHtml from 'sanitize-html'; import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types'; + +function sanitizeUserInput(input: string): string { + // Sanitize HTML tags and entities + let sanitized = sanitizeHtml(input, { + allowedTags: [], + allowedAttributes: {}, + }); + // Remove dangerous protocols + sanitized = sanitized.replace(/javascript:/gi, ''); + sanitized = sanitized.replace(/data:/gi, ''); + sanitized = sanitized.replace(/vbscript:/gi, ''); + return sanitized; +} + +export function getSanitizedInitialMessages(initialMessages: string): string[] { + const sanitizedString = sanitizeUserInput(initialMessages); + + return sanitizedString + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== ''); +} + +export function getSanitizedI18nConfig(config: Record): Record { + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(config)) { + sanitized[key] = sanitizeUserInput(value); + } + + return sanitized; +} export function createPage({ instanceId, webhookUrl, @@ -21,7 +53,7 @@ export function createPage({ i18n: { en: Record; }; - initialMessages: string[]; + initialMessages: string; mode: 'test' | 'production'; authentication: AuthenticationChatOption; allowFileUploads?: boolean; @@ -57,6 +89,9 @@ export function createPage({ ? loadPreviousSession : 'notSupported'; + const sanitizedInitialMessages = getSanitizedInitialMessages(initialMessages); + const sanitizedI18nConfig = getSanitizedI18nConfig(en || {}); + return ` @@ -123,9 +158,9 @@ export function createPage({ allowFileUploads: ${sanitizedAllowFileUploads}, allowedFilesMimeTypes: '${sanitizedAllowedFilesMimeTypes}', i18n: { - ${en ? `en: ${JSON.stringify(en)},` : ''} + ${Object.keys(sanitizedI18nConfig).length ? `en: ${JSON.stringify(sanitizedI18nConfig)},` : ''} }, - ${initialMessages.length ? `initialMessages: ${JSON.stringify(initialMessages)},` : ''} + ${sanitizedInitialMessages.length ? `initialMessages: ${JSON.stringify(sanitizedInitialMessages)},` : ''} enableStreaming: ${!!enableStreaming}, }); })(); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts index dedf7e48cb..f6eb248cb3 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts @@ -1,2 +1,19 @@ +import type { INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +const validOptions = ['notSupported', 'memory', 'manually'] as const; export type AuthenticationChatOption = 'none' | 'basicAuth' | 'n8nUserAuth'; -export type LoadPreviousSessionChatOption = 'manually' | 'memory' | 'notSupported'; +export type LoadPreviousSessionChatOption = (typeof validOptions)[number]; + +function isValidLoadPreviousSessionOption(value: unknown): value is LoadPreviousSessionChatOption { + return typeof value === 'string' && (validOptions as readonly string[]).includes(value); +} + +export function assertValidLoadPreviousSessionOption( + value: string | undefined, + node: INode, +): asserts value is LoadPreviousSessionChatOption | undefined { + if (value && !isValidLoadPreviousSessionOption(value)) { + throw new NodeOperationError(node, `Invalid loadPreviousSession option: ${value}`); + } +} diff --git a/packages/nodes-base/nodes/Beeminder/Beeminder.node.ts b/packages/nodes-base/nodes/Beeminder/Beeminder.node.ts index 684e8909aa..752261bbcb 100644 --- a/packages/nodes-base/nodes/Beeminder/Beeminder.node.ts +++ b/packages/nodes-base/nodes/Beeminder/Beeminder.node.ts @@ -10,6 +10,10 @@ import { NodeConnectionTypes, NodeOperationError, jsonParse, + assertParamIsString, + validateNodeParameters, + assertParamIsNumber, + assertParamIsArray, } from 'n8n-workflow'; import type { Datapoint } from './Beeminder.node.functions'; @@ -34,12 +38,6 @@ import { getUser, } from './Beeminder.node.functions'; import { beeminderApiRequest } from './GenericFunctions'; -import { - assertIsString, - assertIsNodeParameters, - assertIsNumber, - assertIsArray, -} from '../../utils/types'; export class Beeminder implements INodeType { description: INodeTypeDescription = { @@ -1042,7 +1040,7 @@ export class Beeminder implements INodeType { if (resource === 'datapoint') { const goalName = this.getNodeParameter('goalName', i); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, this.getNode()); results = await executeDatapointOperations(this, operation, goalName, i, timezone); } else if (resource === 'charge') { results = await executeChargeOperations(this, operation, i); @@ -1091,22 +1089,22 @@ async function executeDatapointCreate( timezone: string, ): Promise { const value = context.getNodeParameter('value', itemIndex); - assertIsNumber('value', value); + assertParamIsNumber('value', value, context.getNode()); const options = context.getNodeParameter('additionalFields', itemIndex); if (options.timestamp) { options.timestamp = moment.tz(options.timestamp, timezone).unix(); } - assertIsNodeParameters<{ - comment?: string; - timestamp?: number; - requestid?: string; - }>(options, { - comment: { type: 'string', optional: true }, - timestamp: { type: 'number', optional: true }, - requestid: { type: 'string', optional: true }, - }); + validateNodeParameters( + options, + { + comment: { type: 'string' }, + timestamp: { type: 'number' }, + requestid: { type: 'string' }, + }, + context.getNode(), + ); const data = { value, @@ -1124,15 +1122,15 @@ async function executeDatapointGetAll( ): Promise { const returnAll = context.getNodeParameter('returnAll', itemIndex); const options = context.getNodeParameter('options', itemIndex); - assertIsNodeParameters<{ - sort?: string; - page?: number; - per?: number; - }>(options, { - sort: { type: 'string', optional: true }, - page: { type: 'number', optional: true }, - per: { type: 'number', optional: true }, - }); + validateNodeParameters( + options, + { + sort: { type: 'string' }, + page: { type: 'number' }, + per: { type: 'number' }, + }, + context.getNode(), + ); const data = { goalName, @@ -1150,21 +1148,21 @@ async function executeDatapointUpdate( timezone: string, ): Promise { const datapointId = context.getNodeParameter('datapointId', itemIndex); - assertIsString('datapointId', datapointId); + assertParamIsString('datapointId', datapointId, context.getNode()); const options = context.getNodeParameter('updateFields', itemIndex); if (options.timestamp) { options.timestamp = moment.tz(options.timestamp, timezone).unix(); } - assertIsNodeParameters<{ - value?: number; - comment?: string; - timestamp?: number; - }>(options, { - value: { type: 'number', optional: true }, - comment: { type: 'string', optional: true }, - timestamp: { type: 'number', optional: true }, - }); + validateNodeParameters( + options, + { + value: { type: 'number' }, + comment: { type: 'string' }, + timestamp: { type: 'number' }, + }, + context.getNode(), + ); const data = { goalName, @@ -1181,7 +1179,7 @@ async function executeDatapointDelete( itemIndex: number, ): Promise { const datapointId = context.getNodeParameter('datapointId', itemIndex); - assertIsString('datapointId', datapointId); + assertParamIsString('datapointId', datapointId, context.getNode()); const data = { goalName, datapointId, @@ -1196,10 +1194,11 @@ async function executeDatapointCreateAll( ): Promise { const datapoints = context.getNodeParameter('datapoints', itemIndex); const parsedDatapoints = typeof datapoints === 'string' ? jsonParse(datapoints) : datapoints; - assertIsArray( + assertParamIsArray( 'datapoints', parsedDatapoints, (val): val is Datapoint => typeof val === 'object' && val !== null && 'value' in val, + context.getNode(), ); const data = { @@ -1215,7 +1214,7 @@ async function executeDatapointGet( itemIndex: number, ): Promise { const datapointId = context.getNodeParameter('datapointId', itemIndex); - assertIsString('datapointId', datapointId); + assertParamIsString('datapointId', datapointId, context.getNode()); const data = { goalName, datapointId, @@ -1255,15 +1254,16 @@ async function executeChargeOperations( ): Promise { if (operation === 'create') { const amount = context.getNodeParameter('amount', itemIndex); - assertIsNumber('amount', amount); + assertParamIsNumber('amount', amount, context.getNode()); const options = context.getNodeParameter('additionalFields', itemIndex); - assertIsNodeParameters<{ - note?: string; - dryrun?: boolean; - }>(options, { - note: { type: 'string', optional: true }, - dryrun: { type: 'boolean', optional: true }, - }); + validateNodeParameters( + options, + { + note: { type: 'string' }, + dryrun: { type: 'boolean' }, + }, + context.getNode(), + ); const data = { amount, ...options, @@ -1280,13 +1280,13 @@ async function executeGoalCreate( timezone: string, ): Promise { const slug = context.getNodeParameter('slug', itemIndex); - assertIsString('slug', slug); + assertParamIsString('slug', slug, context.getNode()); const title = context.getNodeParameter('title', itemIndex); - assertIsString('title', title); + assertParamIsString('title', title, context.getNode()); const goalType = context.getNodeParameter('goal_type', itemIndex); - assertIsString('goalType', goalType); + assertParamIsString('goalType', goalType, context.getNode()); const gunits = context.getNodeParameter('gunits', itemIndex); - assertIsString('gunits', gunits); + assertParamIsString('gunits', gunits, context.getNode()); const options = context.getNodeParameter('additionalFields', itemIndex); if ('tags' in options && typeof options.tags === 'string') { options.tags = jsonParse(options.tags); @@ -1295,27 +1295,21 @@ async function executeGoalCreate( options.goaldate = moment.tz(options.goaldate, timezone).unix(); } - assertIsNodeParameters<{ - goaldate?: number; - goalval?: number; - rate?: number; - initval?: number; - secret?: boolean; - datapublic?: boolean; - datasource?: string; - dryrun?: boolean; - tags?: string[]; - }>(options, { - goaldate: { type: 'number', optional: true }, - goalval: { type: 'number', optional: true }, - rate: { type: 'number', optional: true }, - initval: { type: 'number', optional: true }, - secret: { type: 'boolean', optional: true }, - datapublic: { type: 'boolean', optional: true }, - datasource: { type: 'string', optional: true }, - dryrun: { type: 'boolean', optional: true }, - tags: { type: 'string[]', optional: true }, - }); + validateNodeParameters( + options, + { + goaldate: { type: 'number' }, + goalval: { type: 'number' }, + rate: { type: 'number' }, + initval: { type: 'number' }, + secret: { type: 'boolean' }, + datapublic: { type: 'boolean' }, + datasource: { type: 'string' }, + dryrun: { type: 'boolean' }, + tags: { type: 'string[]' }, + }, + context.getNode(), + ); const data = { slug, @@ -1333,15 +1327,16 @@ async function executeGoalGet( itemIndex: number, ): Promise { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const options = context.getNodeParameter('additionalFields', itemIndex); - assertIsNodeParameters<{ - datapoints?: boolean; - emaciated?: boolean; - }>(options, { - datapoints: { type: 'boolean', optional: true }, - emaciated: { type: 'boolean', optional: true }, - }); + validateNodeParameters( + options, + { + datapoints: { type: 'boolean' }, + emaciated: { type: 'boolean' }, + }, + context.getNode(), + ); const data = { goalName, ...options, @@ -1355,11 +1350,13 @@ async function executeGoalGetAll( itemIndex: number, ): Promise { const options = context.getNodeParameter('additionalFields', itemIndex); - assertIsNodeParameters<{ - emaciated?: boolean; - }>(options, { - emaciated: { type: 'boolean', optional: true }, - }); + validateNodeParameters( + options, + { + emaciated: { type: 'boolean' }, + }, + context.getNode(), + ); const data = { ...options }; return await getAllGoals.call(context, data); @@ -1370,11 +1367,13 @@ async function executeGoalGetArchived( itemIndex: number, ): Promise { const options = context.getNodeParameter('additionalFields', itemIndex); - assertIsNodeParameters<{ - emaciated?: boolean; - }>(options, { - emaciated: { type: 'boolean', optional: true }, - }); + validateNodeParameters( + options, + { + emaciated: { type: 'boolean' }, + }, + context.getNode(), + ); const data = { ...options }; return await getArchivedGoals.call(context, data); @@ -1386,7 +1385,7 @@ async function executeGoalUpdate( timezone: string, ): Promise { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const options = context.getNodeParameter('updateFields', itemIndex); if ('tags' in options && typeof options.tags === 'string') { options.tags = jsonParse(options.tags); @@ -1394,37 +1393,29 @@ async function executeGoalUpdate( if ('roadall' in options && typeof options.roadall === 'string') { options.roadall = jsonParse(options.roadall); } - console.log('roadall', typeof options.roadall, options.roadall); - assertIsNodeParameters<{ - title?: string; - yaxis?: string; - tmin?: string; - tmax?: string; - goaldate?: number; - secret?: boolean; - datapublic?: boolean; - roadall?: object; - datasource?: string; - tags?: string[]; - }>(options, { - title: { type: 'string', optional: true }, - yaxis: { type: 'string', optional: true }, - tmin: { type: 'string', optional: true }, - tmax: { type: 'string', optional: true }, - secret: { type: 'boolean', optional: true }, - datapublic: { type: 'boolean', optional: true }, - roadall: { type: 'object', optional: true }, - datasource: { type: 'string', optional: true }, - tags: { type: 'string[]', optional: true }, - }); + if ('goaldate' in options && options.goaldate) { + options.goaldate = moment.tz(options.goaldate, timezone).unix(); + } + validateNodeParameters( + options, + { + title: { type: 'string' }, + yaxis: { type: 'string' }, + tmin: { type: 'string' }, + tmax: { type: 'string' }, + goaldate: { type: 'number' }, + secret: { type: 'boolean' }, + datapublic: { type: 'boolean' }, + roadall: { type: 'object' }, + datasource: { type: 'string' }, + tags: { type: 'string[]' }, + }, + context.getNode(), + ); const data = { goalName, ...options, }; - - if (data.goaldate) { - data.goaldate = moment.tz(data.goaldate, timezone).unix(); - } return await updateGoal.call(context, data); } @@ -1433,7 +1424,7 @@ async function executeGoalRefresh( itemIndex: number, ): Promise { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, }; @@ -1445,7 +1436,7 @@ async function executeGoalShortCircuit( itemIndex: number, ): Promise { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, @@ -1458,7 +1449,7 @@ async function executeGoalStepDown( itemIndex: number, ): Promise { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, @@ -1471,7 +1462,7 @@ async function executeGoalCancelStepDown( itemIndex: number, ): Promise { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, }; @@ -1483,7 +1474,7 @@ async function executeGoalUncle( itemIndex: number, ): Promise { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, }; @@ -1534,19 +1525,17 @@ async function executeUserOperations( if (options.diff_since) { options.diff_since = moment.tz(options.diff_since, timezone).unix(); } - assertIsNodeParameters<{ - associations?: boolean; - diff_since?: number; - skinny?: boolean; - emaciated?: boolean; - datapoints_count?: number; - }>(options, { - associations: { type: 'boolean', optional: true }, - diff_since: { type: 'number', optional: true }, - skinny: { type: 'boolean', optional: true }, - emaciated: { type: 'boolean', optional: true }, - datapoints_count: { type: 'number', optional: true }, - }); + validateNodeParameters( + options, + { + associations: { type: 'boolean' }, + diff_since: { type: 'number' }, + skinny: { type: 'boolean' }, + emaciated: { type: 'boolean' }, + datapoints_count: { type: 'number' }, + }, + context.getNode(), + ); const data = { ...options }; return await getUser.call(context, data); diff --git a/packages/nodes-base/utils/types.ts b/packages/nodes-base/utils/types.ts deleted file mode 100644 index c10fdc3085..0000000000 --- a/packages/nodes-base/utils/types.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { assert } from 'n8n-workflow'; - -function assertIsType( - parameterName: string, - value: unknown, - type: 'string' | 'number' | 'boolean', -): asserts value is T { - assert(typeof value === type, `Parameter "${parameterName}" is not ${type}`); -} - -export function assertIsNumber(parameterName: string, value: unknown): asserts value is number { - assertIsType(parameterName, value, 'number'); -} - -export function assertIsString(parameterName: string, value: unknown): asserts value is string { - assertIsType(parameterName, value, 'string'); -} - -export function assertIsArray( - parameterName: string, - value: unknown, - validator: (val: unknown) => val is T, -): asserts value is T[] { - assert(Array.isArray(value), `Parameter "${parameterName}" is not an array`); - assert( - value.every(validator), - `Parameter "${parameterName}" has elements that don't match expected types`, - ); -} - -export function assertIsNodeParameters( - value: unknown, - parameters: Record< - string, - { - type: - | 'string' - | 'boolean' - | 'number' - | 'resource-locator' - | 'string[]' - | 'number[]' - | 'boolean[]' - | 'object'; - optional?: boolean; - } - >, -): asserts value is T { - assert(typeof value === 'object' && value !== null, 'Value is not a valid object'); - - const obj = value as Record; - - Object.keys(parameters).forEach((key) => { - const param = parameters[key]; - const paramValue = obj[key]; - - if (!param.optional && paramValue === undefined) { - assert(false, `Required parameter "${key}" is missing`); - } - - if (paramValue !== undefined) { - if (param.type === 'resource-locator') { - assert( - typeof paramValue === 'object' && - paramValue !== null && - '__rl' in paramValue && - 'mode' in paramValue && - 'value' in paramValue, - `Parameter "${key}" is not a valid resource locator object`, - ); - } else if (param.type === 'object') { - assert( - typeof paramValue === 'object' && paramValue !== null, - `Parameter "${key}" is not a valid object`, - ); - } else if (param.type.endsWith('[]')) { - const baseType = param.type.slice(0, -2); - const elementType = - baseType === 'string' || baseType === 'number' || baseType === 'boolean' - ? baseType - : 'string'; - assert(Array.isArray(paramValue), `Parameter "${key}" is not an array`); - paramValue.forEach((item, index) => { - assert( - typeof item === elementType, - `Parameter "${key}[${index}]" is not a valid ${elementType}`, - ); - }); - } else { - assert(typeof paramValue === param.type, `Parameter "${key}" is not a valid ${param.type}`); - } - } - }); -} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 9676365ee0..0bd86b1b8e 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -67,6 +67,7 @@ export { ExpressionExtensions } from './extensions'; export * as ExpressionParser from './extensions/expression-parser'; export { NativeMethods } from './native-methods'; export * from './node-parameters/filter-parameter'; +export * from './node-parameters/parameter-type-validation'; export * from './evaluation-helpers'; export type { diff --git a/packages/workflow/src/node-parameters/parameter-type-validation.ts b/packages/workflow/src/node-parameters/parameter-type-validation.ts new file mode 100644 index 0000000000..d2c40a4f4a --- /dev/null +++ b/packages/workflow/src/node-parameters/parameter-type-validation.ts @@ -0,0 +1,253 @@ +import { NodeOperationError } from '../errors'; +import type { INode } from '../interfaces'; +import { assert } from '../utils'; + +type ParameterType = + | 'string' + | 'boolean' + | 'number' + | 'resource-locator' + | 'string[]' + | 'number[]' + | 'boolean[]' + | 'object'; + +function assertUserInput(condition: T, message: string, node: INode): asserts condition { + try { + assert(condition, message); + } catch (e: unknown) { + if (e instanceof Error) { + // Use level 'info' to prevent reporting to Sentry (only 'error' and 'fatal' levels are reported) + const nodeError = new NodeOperationError(node, e.message, { level: 'info' }); + nodeError.stack = e.stack; + throw nodeError; + } + + throw e; + } +} + +function assertParamIsType( + parameterName: string, + value: unknown, + type: 'string' | 'number' | 'boolean', + node: INode, +): asserts value is T { + assertUserInput(typeof value === type, `Parameter "${parameterName}" is not ${type}`, node); +} + +export function assertParamIsNumber( + parameterName: string, + value: unknown, + node: INode, +): asserts value is number { + assertParamIsType(parameterName, value, 'number', node); +} + +export function assertParamIsString( + parameterName: string, + value: unknown, + node: INode, +): asserts value is string { + assertParamIsType(parameterName, value, 'string', node); +} + +export function assertParamIsBoolean( + parameterName: string, + value: unknown, + node: INode, +): asserts value is boolean { + assertParamIsType(parameterName, value, 'boolean', node); +} + +export function assertParamIsArray( + parameterName: string, + value: unknown, + validator: (val: unknown) => val is T, + node: INode, +): asserts value is T[] { + assertUserInput(Array.isArray(value), `Parameter "${parameterName}" is not an array`, node); + + // Use for loop instead of .every() to properly handle sparse arrays + // .every() skips empty/sparse indices, which could allow invalid arrays to pass + for (let i = 0; i < value.length; i++) { + if (!validator(value[i])) { + assertUserInput( + false, + `Parameter "${parameterName}" has elements that don't match expected types`, + node, + ); + } + } +} + +function assertIsValidObject( + value: unknown, + node: INode, +): asserts value is Record { + assertUserInput(typeof value === 'object' && value !== null, 'Value is not a valid object', node); +} + +function assertIsRequiredParameter( + parameterName: string, + value: unknown, + isRequired: boolean, + node: INode, +): void { + if (isRequired && value === undefined) { + assertUserInput(false, `Required parameter "${parameterName}" is missing`, node); + } +} + +function assertIsResourceLocator(parameterName: string, value: unknown, node: INode): void { + assertUserInput( + typeof value === 'object' && + value !== null && + '__rl' in value && + 'mode' in value && + 'value' in value, + `Parameter "${parameterName}" is not a valid resource locator object`, + node, + ); +} + +function assertParamIsObject(parameterName: string, value: unknown, node: INode): void { + assertUserInput( + typeof value === 'object' && value !== null, + `Parameter "${parameterName}" is not a valid object`, + node, + ); +} + +function createElementValidator(elementType: T) { + return ( + val: unknown, + ): val is T extends 'string' ? string : T extends 'number' ? number : boolean => + typeof val === elementType; +} + +function assertParamIsArrayOfType( + parameterName: string, + value: unknown, + arrayType: string, + node: INode, +): void { + const baseType = arrayType.slice(0, -2); + const elementType = + baseType === 'string' || baseType === 'number' || baseType === 'boolean' ? baseType : 'string'; + + const validator = createElementValidator(elementType); + assertParamIsArray(parameterName, value, validator, node); +} + +function assertParamIsPrimitive( + parameterName: string, + value: unknown, + type: string, + node: INode, +): void { + assertUserInput( + typeof value === type, + `Parameter "${parameterName}" is not a valid ${type}`, + node, + ); +} + +function validateParameterType( + parameterName: string, + value: unknown, + type: ParameterType, + node: INode, +): boolean { + try { + if (type === 'resource-locator') { + assertIsResourceLocator(parameterName, value, node); + } else if (type === 'object') { + assertParamIsObject(parameterName, value, node); + } else if (type.endsWith('[]')) { + assertParamIsArrayOfType(parameterName, value, type, node); + } else { + assertParamIsPrimitive(parameterName, value, type, node); + } + return true; + } catch { + return false; + } +} + +function validateParameterAgainstTypes( + parameterName: string, + value: unknown, + types: ParameterType[], + node: INode, +): void { + let isValid = false; + + for (const type of types) { + if (validateParameterType(parameterName, value, type, node)) { + isValid = true; + break; + } + } + + if (!isValid) { + const typeList = types.join(' or '); + assertUserInput( + false, + `Parameter "${parameterName}" does not match any of the expected types: ${typeList}`, + node, + ); + } +} + +type InferParameterType = T extends ParameterType[] + ? InferSingleParameterType + : T extends ParameterType + ? InferSingleParameterType + : never; + +type InferSingleParameterType = T extends 'string' + ? string + : T extends 'boolean' + ? boolean + : T extends 'number' + ? number + : T extends 'resource-locator' + ? Record + : T extends 'string[]' + ? string[] + : T extends 'number[]' + ? number[] + : T extends 'boolean[]' + ? boolean[] + : T extends 'object' + ? Record + : unknown; + +export function validateNodeParameters< + T extends Record, +>( + value: unknown, + parameters: T, + node: INode, +): asserts value is { + [K in keyof T]: T[K]['required'] extends true + ? InferParameterType + : InferParameterType | undefined; +} { + assertIsValidObject(value, node); + + Object.keys(parameters).forEach((key) => { + const param = parameters[key]; + const paramValue = value[key]; + + assertIsRequiredParameter(key, paramValue, param.required ?? false, node); + + // If required, value cannot be undefined and must be validated + // If not required, value can be undefined but should be validated when present + if (param.required || paramValue !== undefined) { + const types = Array.isArray(param.type) ? param.type : [param.type]; + validateParameterAgainstTypes(key, paramValue, types, node); + } + }); +} diff --git a/packages/workflow/test/node-parameters/parameter-type-validation.test.ts b/packages/workflow/test/node-parameters/parameter-type-validation.test.ts new file mode 100644 index 0000000000..6e8d3d1d47 --- /dev/null +++ b/packages/workflow/test/node-parameters/parameter-type-validation.test.ts @@ -0,0 +1,660 @@ +import { + validateNodeParameters, + assertParamIsString, + assertParamIsNumber, + assertParamIsBoolean, + assertParamIsArray, +} from '../../src/node-parameters/parameter-type-validation'; +import type { INode } from '../../src/interfaces'; + +describe('Type assertion functions', () => { + const mockNode: INode = { + id: 'test-node-id', + name: 'TestNode', + type: 'n8n-nodes-base.testNode', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + describe('assertIsNodeParameters', () => { + it('should pass for valid object with all required parameters', () => { + const value = { + name: 'test', + age: 25, + active: true, + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + age: { type: 'number' as const, required: true }, + active: { type: 'boolean' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid object with optional parameters present', () => { + const value = { + name: 'test', + description: 'optional description', + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + description: { type: 'string' as const }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid object with optional parameters missing', () => { + const value = { + name: 'test', + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + description: { type: 'string' as const }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid array parameters', () => { + const value = { + tags: ['tag1', 'tag2'], + numbers: [1, 2, 3], + flags: [true, false], + }; + + const parameters = { + tags: { type: 'string[]' as const, required: true }, + numbers: { type: 'number[]' as const, required: true }, + flags: { type: 'boolean[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid resource-locator parameter', () => { + const value = { + resource: { + __rl: true, + mode: 'list', + value: 'some-value', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid object parameter', () => { + const value = { + config: { + setting1: 'value1', + setting2: 42, + }, + }; + + const parameters = { + config: { type: 'object' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for parameter with multiple allowed types', () => { + const value = { + multiType: 'string value', + }; + + const parameters = { + multiType: { type: ['string', 'number'] as Array<'string' | 'number'>, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + + // Test with number value + const value2 = { + multiType: 42, + }; + + expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow(); + }); + + it('should throw for null value', () => { + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(null, parameters, mockNode)).toThrow( + 'Value is not a valid object', + ); + }); + + it('should throw for non-object value', () => { + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters('not an object', parameters, mockNode)).toThrow( + 'Value is not a valid object', + ); + expect(() => validateNodeParameters(123, parameters, mockNode)).toThrow( + 'Value is not a valid object', + ); + expect(() => validateNodeParameters(true, parameters, mockNode)).toThrow( + 'Value is not a valid object', + ); + }); + + it('should throw for missing required parameter', () => { + const value = { + // name is missing + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Required parameter "name" is missing', + ); + }); + + it('should throw for parameter with wrong type', () => { + const value = { + name: 123, // should be string + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "name" does not match any of the expected types: string', + ); + }); + + it('should throw for invalid array parameter', () => { + const value = { + tags: 'not an array', + }; + + const parameters = { + tags: { type: 'string[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "tags" does not match any of the expected types: string[]', + ); + }); + + it('should throw for array with wrong element type', () => { + const value = { + tags: ['valid', 123, 'also valid'], // 123 is not a string + }; + + const parameters = { + tags: { type: 'string[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "tags" does not match any of the expected types: string[]', + ); + }); + + it('should throw for invalid resource-locator parameter', () => { + const value = { + resource: { + // missing required properties + mode: 'list', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "resource" does not match any of the expected types: resource-locator', + ); + }); + + it('should throw for invalid object parameter', () => { + const value = { + config: 'not an object', + }; + + const parameters = { + config: { type: 'object' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "config" does not match any of the expected types: object', + ); + }); + + it('should throw for parameter that matches none of the allowed types', () => { + const value = { + multiType: true, // should be string or number + }; + + const parameters = { + multiType: { type: ['string', 'number'] as Array<'string' | 'number'>, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "multiType" does not match any of the expected types: string or number', + ); + }); + + it('should handle empty parameter definition', () => { + const value = { + extra: 'should be ignored', + }; + + const parameters = {}; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle complex nested scenarios', () => { + const value = { + name: 'test', + tags: ['tag1', 'tag2'], + config: { + enabled: true, + timeout: 5000, + }, + resource: { + __rl: true, + mode: 'id', + value: '12345', + }, + optionalField: undefined, + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + tags: { type: 'string[]' as const, required: true }, + config: { type: 'object' as const, required: true }, + resource: { type: 'resource-locator' as const, required: true }, + optionalField: { type: 'string' as const }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle empty arrays', () => { + const value = { + emptyTags: [], + }; + + const parameters = { + emptyTags: { type: 'string[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle null values for optional parameters', () => { + const value = { + name: 'test', + optionalField: null, + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + optionalField: { type: 'string' as const }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "optionalField" does not match any of the expected types: string', + ); + }); + + it('should handle resource-locator with additional properties', () => { + const value = { + resource: { + __rl: true, + mode: 'list', + value: 'some-value', + extraProperty: 'ignored', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + }); + + describe('assertParamIsBoolean', () => { + it('should pass for valid boolean values', () => { + expect(() => assertParamIsBoolean('testParam', true, mockNode)).not.toThrow(); + expect(() => assertParamIsBoolean('testParam', false, mockNode)).not.toThrow(); + }); + + it('should throw for non-boolean values', () => { + expect(() => assertParamIsBoolean('testParam', 'true', mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + expect(() => assertParamIsBoolean('testParam', 1, mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + expect(() => assertParamIsBoolean('testParam', 0, mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + expect(() => assertParamIsBoolean('testParam', null, mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + expect(() => assertParamIsBoolean('testParam', undefined, mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + }); + }); + + describe('assertIsString', () => { + it('should pass for valid string', () => { + expect(() => assertParamIsString('testParam', 'hello', mockNode)).not.toThrow(); + }); + + it('should throw for non-string values', () => { + expect(() => assertParamIsString('testParam', 123, mockNode)).toThrow( + 'Parameter "testParam" is not string', + ); + expect(() => assertParamIsString('testParam', true, mockNode)).toThrow( + 'Parameter "testParam" is not string', + ); + expect(() => assertParamIsString('testParam', null, mockNode)).toThrow( + 'Parameter "testParam" is not string', + ); + expect(() => assertParamIsString('testParam', undefined, mockNode)).toThrow( + 'Parameter "testParam" is not string', + ); + }); + }); + + describe('assertIsNumber', () => { + it('should pass for valid number', () => { + expect(() => assertParamIsNumber('testParam', 123, mockNode)).not.toThrow(); + expect(() => assertParamIsNumber('testParam', 0, mockNode)).not.toThrow(); + expect(() => assertParamIsNumber('testParam', -5.5, mockNode)).not.toThrow(); + }); + + it('should throw for non-number values', () => { + expect(() => assertParamIsNumber('testParam', '123', mockNode)).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertParamIsNumber('testParam', true, mockNode)).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertParamIsNumber('testParam', null, mockNode)).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertParamIsNumber('testParam', undefined, mockNode)).toThrow( + 'Parameter "testParam" is not number', + ); + }); + }); + + describe('assertIsArray', () => { + const isString = (val: unknown): val is string => typeof val === 'string'; + const isNumber = (val: unknown): val is number => typeof val === 'number'; + + it('should pass for valid array with correct element types', () => { + expect(() => + assertParamIsArray('testParam', ['a', 'b', 'c'], isString, mockNode), + ).not.toThrow(); + expect(() => assertParamIsArray('testParam', [1, 2, 3], isNumber, mockNode)).not.toThrow(); + expect(() => assertParamIsArray('testParam', [], isString, mockNode)).not.toThrow(); // empty array + }); + + it('should throw for non-array values', () => { + expect(() => assertParamIsArray('testParam', 'not array', isString, mockNode)).toThrow( + 'Parameter "testParam" is not an array', + ); + expect(() => assertParamIsArray('testParam', { length: 3 }, isString, mockNode)).toThrow( + 'Parameter "testParam" is not an array', + ); + }); + + it('should throw for array with incorrect element types', () => { + expect(() => assertParamIsArray('testParam', ['a', 1, 'c'], isString, mockNode)).toThrow( + 'Parameter "testParam" has elements that don\'t match expected types', + ); + expect(() => assertParamIsArray('testParam', [1, 'b', 3], isNumber, mockNode)).toThrow( + 'Parameter "testParam" has elements that don\'t match expected types', + ); + }); + }); + + describe('Edge cases and additional scenarios', () => { + describe('validateNodeParameters edge cases', () => { + it('should handle NaN values correctly', () => { + const value = { + number: NaN, + }; + + const parameters = { + number: { type: 'number' as const, required: true }, + }; + + // NaN is of type 'number' in JavaScript + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle Infinity values correctly', () => { + const value = { + number: Infinity, + }; + + const parameters = { + number: { type: 'number' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle mixed array types correctly', () => { + const value = { + mixed: [1, '2', 3], // Invalid: mixed types in array + }; + + const parameters = { + mixed: { type: 'number[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "mixed" does not match any of the expected types: number[]', + ); + }); + + it('should handle nested arrays', () => { + const value = { + nested: [ + [1, 2], + [3, 4], + ], // Array of arrays + }; + + const parameters = { + nested: { type: 'object' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle resource-locator with false __rl property', () => { + const value = { + resource: { + __rl: false, // Should still be valid as it has the property + mode: 'list', + value: 'some-value', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle resource-locator missing __rl property', () => { + const value = { + resource: { + mode: 'list', + value: 'some-value', + // __rl is missing + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "resource" does not match any of the expected types: resource-locator', + ); + }); + + it('should handle empty string as valid string parameter', () => { + const value = { + name: '', + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle zero as valid number parameter', () => { + const value = { + count: 0, + }; + + const parameters = { + count: { type: 'number' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle arrays with only false values', () => { + const value = { + flags: [false, false, false], + }; + + const parameters = { + flags: { type: 'boolean[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle three or more type unions', () => { + const value = { + multiType: 'string value', + }; + + const parameters = { + multiType: { + type: ['string', 'number', 'boolean'] as Array<'string' | 'number' | 'boolean'>, + required: true, + }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + + // Test with boolean value + const value2 = { + multiType: true, + }; + + expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow(); + }); + + it('should handle array types in multi-type parameters', () => { + const value = { + flexParam: ['a', 'b', 'c'], + }; + + const parameters = { + flexParam: { + type: ['string', 'string[]'] as Array<'string' | 'string[]'>, + required: true, + }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + + // Test with single string + const value2 = { + flexParam: 'single string', + }; + + expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow(); + }); + + it('should handle object with null prototype', () => { + const value = Object.create(null); + value.name = 'test'; + + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + }); + + describe('assertParamIsArray edge cases', () => { + const isString = (val: unknown): val is string => typeof val === 'string'; + + it('should handle array-like objects', () => { + const arrayLike = { 0: 'a', 1: 'b', length: 2 }; + + expect(() => assertParamIsArray('testParam', arrayLike, isString, mockNode)).toThrow( + 'Parameter "testParam" is not an array', + ); + }); + + it('should handle sparse arrays', () => { + const sparse = new Array(3); + sparse[0] = 'a'; + sparse[2] = 'c'; + // sparse[1] is undefined + + // For loop implementation properly validates sparse arrays and throws on undefined elements + expect(() => assertParamIsArray('testParam', sparse, isString, mockNode)).toThrow( + 'Parameter "testParam" has elements that don\'t match expected types', + ); + }); + + it('should handle arrays with explicit undefined values', () => { + const arrayWithUndefined = ['a', undefined, 'c']; + + expect(() => + assertParamIsArray('testParam', arrayWithUndefined, isString, mockNode), + ).toThrow('Parameter "testParam" has elements that don\'t match expected types'); + }); + + it('should handle very large arrays efficiently', () => { + const largeArray = new Array(1000).fill('test'); + + expect(() => assertParamIsArray('testParam', largeArray, isString, mockNode)).not.toThrow(); + }); + }); + }); +});