fix(Chat Trigger Node): Prevent XSS vulnerabilities and improve parameter validation (#18148)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour
2025-08-11 12:37:07 +02:00
committed by GitHub
parent f4a04132d9
commit d4ef191be0
9 changed files with 1477 additions and 259 deletions

View File

@@ -1,6 +1,13 @@
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import { Node, NodeConnectionTypes } from 'n8n-workflow'; import {
Node,
NodeConnectionTypes,
NodeOperationError,
assertParamIsBoolean,
validateNodeParameters,
assertParamIsString,
} from 'n8n-workflow';
import type { import type {
IDataObject, IDataObject,
IWebhookFunctions, IWebhookFunctions,
@@ -15,7 +22,7 @@ import type {
import { cssVariables } from './constants'; import { cssVariables } from './constants';
import { validateAuth } from './GenericFunctions'; import { validateAuth } from './GenericFunctions';
import { createPage } from './templates'; import { createPage } from './templates';
import type { LoadPreviousSessionChatOption } from './types'; import { assertValidLoadPreviousSessionOption } from './types';
const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat'; const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat';
const allowFileUploadsOption: INodeProperties = { const allowFileUploadsOption: INodeProperties = {
@@ -579,8 +586,12 @@ export class ChatTrigger extends Node {
async webhook(ctx: IWebhookFunctions): Promise<IWebhookResponseData> { async webhook(ctx: IWebhookFunctions): Promise<IWebhookResponseData> {
const res = ctx.getResponseObject(); const res = ctx.getResponseObject();
const isPublic = ctx.getNodeParameter('public', false) as boolean; const isPublic = ctx.getNodeParameter('public', false);
const nodeMode = ctx.getNodeParameter('mode', 'hostedChat') as string; assertParamIsBoolean('public', isPublic, ctx.getNode());
const nodeMode = ctx.getNodeParameter('mode', 'hostedChat');
assertParamIsString('mode', nodeMode, ctx.getNode());
if (!isPublic) { if (!isPublic) {
res.status(404).end(); res.status(404).end();
return { return {
@@ -588,18 +599,26 @@ export class ChatTrigger extends Node {
}; };
} }
const options = ctx.getNodeParameter('options', {}) as { const options = ctx.getNodeParameter('options', {});
getStarted?: string; validateNodeParameters(
inputPlaceholder?: string; options,
loadPreviousSession?: LoadPreviousSessionChatOption; {
showWelcomeScreen?: boolean; getStarted: { type: 'string' },
subtitle?: string; inputPlaceholder: { type: 'string' },
title?: string; loadPreviousSession: { type: 'string' },
allowFileUploads?: boolean; showWelcomeScreen: { type: 'boolean' },
allowedFilesMimeTypes?: string; subtitle: { type: 'string' },
customCss?: string; title: { type: 'string' },
responseMode?: 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'; const enableStreaming = options.responseMode === 'streaming';
@@ -623,29 +642,36 @@ export class ChatTrigger extends Node {
if (nodeMode === 'hostedChat') { if (nodeMode === 'hostedChat') {
// Show the chat on GET request // Show the chat on GET request
if (webhookName === 'setup') { 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 = const webhookUrl =
mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw; mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw;
const authentication = ctx.getNodeParameter('authentication') as const authentication = ctx.getNodeParameter('authentication') as
| 'none' | 'none'
| 'basicAuth' | 'basicAuth'
| 'n8nUserAuth'; | 'n8nUserAuth';
const initialMessagesRaw = ctx.getNodeParameter('initialMessages', '') as string; const initialMessagesRaw = ctx.getNodeParameter('initialMessages', '');
const initialMessages = initialMessagesRaw assertParamIsString('initialMessage', initialMessagesRaw, ctx.getNode());
.split('\n')
.filter((line) => line)
.map((line) => line.trim());
const instanceId = ctx.getInstanceId(); const instanceId = ctx.getInstanceId();
const i18nConfig = pick(options, ['getStarted', 'inputPlaceholder', 'subtitle', 'title']); const i18nConfig: Record<string, string> = {};
const keys = ['getStarted', 'inputPlaceholder', 'subtitle', 'title'] as const;
for (const key of keys) {
if (options[key] !== undefined) {
i18nConfig[key] = options[key];
}
}
const page = createPage({ const page = createPage({
i18n: { i18n: {
en: i18nConfig, en: i18nConfig,
}, },
showWelcomeScreen: options.showWelcomeScreen, showWelcomeScreen: options.showWelcomeScreen,
loadPreviousSession: options.loadPreviousSession, loadPreviousSession,
initialMessages, initialMessages: initialMessagesRaw,
webhookUrl, webhookUrl,
mode, mode,
instanceId, instanceId,

View File

@@ -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 = '</script>"%09<script>alert(document.cookie)</script>';
const result = createPage({
...defaultParams,
initialMessages: maliciousInput,
});
// Should not contain the malicious script
expect(result).not.toContain('<script>alert(document.cookie)</script>');
expect(result).not.toContain('</script>"%09<script>');
expect(result).not.toContain('alert(document.cookie)');
// Should contain initialMessages (the exact format is less important than security)
expect(result).toContain('initialMessages:');
// Should contain the tab character but not the dangerous script tags
expect(result).toContain('%09');
});
it('should sanitize common XSS payloads', () => {
const xssPayloads = [
{ input: '<img src=x onerror=alert(1)>', dangerous: ['onerror=', '<img'] },
{ input: '<svg onload=alert(1)>', dangerous: ['onload=', '<svg'] },
{ input: 'javascript:alert(1)', dangerous: ['javascript:'] },
{
input: '<iframe src="javascript:alert(1)"></iframe>',
dangerous: ['<iframe', 'javascript:'],
},
];
xssPayloads.forEach(({ input, dangerous }) => {
const result = createPage({
...defaultParams,
initialMessages: input,
});
// Should not contain dangerous HTML elements or protocols
dangerous.forEach((dangerousContent) => {
expect(result).not.toContain(dangerousContent);
});
});
});
it('should preserve legitimate messages', () => {
const legitimateMessages = [
'Hello, how can I help you?',
'Welcome to our chat service!',
'Please describe your issue.',
'Multi-line\nmessage content\nwith breaks',
];
legitimateMessages.forEach((message) => {
const result = createPage({
...defaultParams,
initialMessages: message,
});
// Should contain the sanitized legitimate content
const expectedLines = message
.split('\n')
.filter((line) => line)
.map((line) => line.trim());
expect(result).toContain(`initialMessages: ${JSON.stringify(expectedLines)}`);
});
});
it('should handle empty initialMessages', () => {
const result = createPage({
...defaultParams,
initialMessages: '',
});
// Should not include initialMessages property when empty
expect(result).not.toContain('initialMessages:');
});
it('should handle whitespace-only initialMessages', () => {
const result = createPage({
...defaultParams,
initialMessages: ' \n\n\t \n ',
});
// Should not include initialMessages property when only whitespace
expect(result).not.toContain('initialMessages:');
});
it('should filter empty lines and trim content', () => {
const result = createPage({
...defaultParams,
initialMessages: ' First message \n\n \n Second message \n',
});
// Should only include non-empty, trimmed lines
expect(result).toContain('initialMessages: ["First message","Second message"]');
});
});
describe('General Security', () => {
it('should not expose raw user input in HTML comments or other locations', () => {
const maliciousInput = '</script><script>alert("XSS")</script>';
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('<script>alert("XSS")</script>') && !line.includes('initialMessages: ['),
);
expect(unsafeLines).toHaveLength(0);
});
});
describe('I18n XSS Prevention', () => {
it('should prevent script injection through i18n config values', () => {
const maliciousInput = '</script><script>alert(document.cookie)</script>';
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('<script>alert(document.cookie)</script>');
expect(result).not.toContain('</script><script>');
expect(result).not.toContain('alert(document.cookie)');
// Should contain i18n config but sanitized
expect(result).toContain('i18n:');
});
it('should sanitize individual i18n fields', () => {
const xssPayload = '<img src=x onerror=alert(1)>';
const fields = ['title', 'subtitle', 'getStarted', 'inputPlaceholder'];
fields.forEach((field) => {
const config = { [field]: xssPayload };
const result = createPage({
...defaultParams,
initialMessages: '',
i18n: { en: config },
});
// Should not contain dangerous HTML
expect(result).not.toContain('onerror=');
expect(result).not.toContain('<img');
expect(result).not.toContain('alert(1)');
});
});
it('should preserve legitimate i18n content', () => {
const legitimateConfig = {
title: 'Welcome to Chat',
subtitle: 'How can we help you today?',
getStarted: 'Start Conversation',
inputPlaceholder: 'Type your message...',
};
const result = createPage({
...defaultParams,
initialMessages: '',
i18n: { en: legitimateConfig },
});
// Should contain the legitimate content
expect(result).toContain(JSON.stringify(legitimateConfig));
});
it('should handle empty i18n config', () => {
const result = createPage({
...defaultParams,
initialMessages: '',
i18n: { en: {} },
});
// Should still have i18n structure but no en property in the i18n config
expect(result).toContain('i18n: {');
expect(result).not.toContain('en: {');
});
});
describe('getSanitizedInitialMessages function', () => {
it('should sanitize XSS payloads', () => {
const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>';
const result = getSanitizedInitialMessages(maliciousInput);
expect(result).toEqual(['"%09']);
expect(result.join('')).not.toContain('<script>');
expect(result.join('')).not.toContain('alert');
});
it('should remove dangerous protocols', () => {
const inputs = [
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'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 = '</script><script>alert(document.cookie)</script>';
const input = {
title: maliciousInput,
subtitle: maliciousInput,
getStarted: maliciousInput,
inputPlaceholder: maliciousInput,
};
const result = getSanitizedI18nConfig(input);
Object.values(result).forEach((value) => {
expect(value).not.toContain('<script>');
expect(value).not.toContain('alert');
expect(value).not.toContain('</script>');
});
});
it('should remove dangerous protocols', () => {
const input = {
title: 'javascript:alert(1)',
subtitle: 'data:text/html,<script>alert(1)</script>',
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('');
});
});
});

View File

@@ -1,6 +1,38 @@
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types'; 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<string, string>): Record<string, string> {
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries<string>(config)) {
sanitized[key] = sanitizeUserInput(value);
}
return sanitized;
}
export function createPage({ export function createPage({
instanceId, instanceId,
webhookUrl, webhookUrl,
@@ -21,7 +53,7 @@ export function createPage({
i18n: { i18n: {
en: Record<string, string>; en: Record<string, string>;
}; };
initialMessages: string[]; initialMessages: string;
mode: 'test' | 'production'; mode: 'test' | 'production';
authentication: AuthenticationChatOption; authentication: AuthenticationChatOption;
allowFileUploads?: boolean; allowFileUploads?: boolean;
@@ -57,6 +89,9 @@ export function createPage({
? loadPreviousSession ? loadPreviousSession
: 'notSupported'; : 'notSupported';
const sanitizedInitialMessages = getSanitizedInitialMessages(initialMessages);
const sanitizedI18nConfig = getSanitizedI18nConfig(en || {});
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -123,9 +158,9 @@ export function createPage({
allowFileUploads: ${sanitizedAllowFileUploads}, allowFileUploads: ${sanitizedAllowFileUploads},
allowedFilesMimeTypes: '${sanitizedAllowedFilesMimeTypes}', allowedFilesMimeTypes: '${sanitizedAllowedFilesMimeTypes}',
i18n: { 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}, enableStreaming: ${!!enableStreaming},
}); });
})(); })();

View File

@@ -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 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}`);
}
}

View File

@@ -10,6 +10,10 @@ import {
NodeConnectionTypes, NodeConnectionTypes,
NodeOperationError, NodeOperationError,
jsonParse, jsonParse,
assertParamIsString,
validateNodeParameters,
assertParamIsNumber,
assertParamIsArray,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { Datapoint } from './Beeminder.node.functions'; import type { Datapoint } from './Beeminder.node.functions';
@@ -34,12 +38,6 @@ import {
getUser, getUser,
} from './Beeminder.node.functions'; } from './Beeminder.node.functions';
import { beeminderApiRequest } from './GenericFunctions'; import { beeminderApiRequest } from './GenericFunctions';
import {
assertIsString,
assertIsNodeParameters,
assertIsNumber,
assertIsArray,
} from '../../utils/types';
export class Beeminder implements INodeType { export class Beeminder implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@@ -1042,7 +1040,7 @@ export class Beeminder implements INodeType {
if (resource === 'datapoint') { if (resource === 'datapoint') {
const goalName = this.getNodeParameter('goalName', i); const goalName = this.getNodeParameter('goalName', i);
assertIsString('goalName', goalName); assertParamIsString('goalName', goalName, this.getNode());
results = await executeDatapointOperations(this, operation, goalName, i, timezone); results = await executeDatapointOperations(this, operation, goalName, i, timezone);
} else if (resource === 'charge') { } else if (resource === 'charge') {
results = await executeChargeOperations(this, operation, i); results = await executeChargeOperations(this, operation, i);
@@ -1091,22 +1089,22 @@ async function executeDatapointCreate(
timezone: string, timezone: string,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const value = context.getNodeParameter('value', itemIndex); const value = context.getNodeParameter('value', itemIndex);
assertIsNumber('value', value); assertParamIsNumber('value', value, context.getNode());
const options = context.getNodeParameter('additionalFields', itemIndex); const options = context.getNodeParameter('additionalFields', itemIndex);
if (options.timestamp) { if (options.timestamp) {
options.timestamp = moment.tz(options.timestamp, timezone).unix(); options.timestamp = moment.tz(options.timestamp, timezone).unix();
} }
assertIsNodeParameters<{ validateNodeParameters(
comment?: string; options,
timestamp?: number; {
requestid?: string; comment: { type: 'string' },
}>(options, { timestamp: { type: 'number' },
comment: { type: 'string', optional: true }, requestid: { type: 'string' },
timestamp: { type: 'number', optional: true }, },
requestid: { type: 'string', optional: true }, context.getNode(),
}); );
const data = { const data = {
value, value,
@@ -1124,15 +1122,15 @@ async function executeDatapointGetAll(
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const returnAll = context.getNodeParameter('returnAll', itemIndex); const returnAll = context.getNodeParameter('returnAll', itemIndex);
const options = context.getNodeParameter('options', itemIndex); const options = context.getNodeParameter('options', itemIndex);
assertIsNodeParameters<{ validateNodeParameters(
sort?: string; options,
page?: number; {
per?: number; sort: { type: 'string' },
}>(options, { page: { type: 'number' },
sort: { type: 'string', optional: true }, per: { type: 'number' },
page: { type: 'number', optional: true }, },
per: { type: 'number', optional: true }, context.getNode(),
}); );
const data = { const data = {
goalName, goalName,
@@ -1150,21 +1148,21 @@ async function executeDatapointUpdate(
timezone: string, timezone: string,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const datapointId = context.getNodeParameter('datapointId', itemIndex); const datapointId = context.getNodeParameter('datapointId', itemIndex);
assertIsString('datapointId', datapointId); assertParamIsString('datapointId', datapointId, context.getNode());
const options = context.getNodeParameter('updateFields', itemIndex); const options = context.getNodeParameter('updateFields', itemIndex);
if (options.timestamp) { if (options.timestamp) {
options.timestamp = moment.tz(options.timestamp, timezone).unix(); options.timestamp = moment.tz(options.timestamp, timezone).unix();
} }
assertIsNodeParameters<{ validateNodeParameters(
value?: number; options,
comment?: string; {
timestamp?: number; value: { type: 'number' },
}>(options, { comment: { type: 'string' },
value: { type: 'number', optional: true }, timestamp: { type: 'number' },
comment: { type: 'string', optional: true }, },
timestamp: { type: 'number', optional: true }, context.getNode(),
}); );
const data = { const data = {
goalName, goalName,
@@ -1181,7 +1179,7 @@ async function executeDatapointDelete(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const datapointId = context.getNodeParameter('datapointId', itemIndex); const datapointId = context.getNodeParameter('datapointId', itemIndex);
assertIsString('datapointId', datapointId); assertParamIsString('datapointId', datapointId, context.getNode());
const data = { const data = {
goalName, goalName,
datapointId, datapointId,
@@ -1196,10 +1194,11 @@ async function executeDatapointCreateAll(
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const datapoints = context.getNodeParameter('datapoints', itemIndex); const datapoints = context.getNodeParameter('datapoints', itemIndex);
const parsedDatapoints = typeof datapoints === 'string' ? jsonParse(datapoints) : datapoints; const parsedDatapoints = typeof datapoints === 'string' ? jsonParse(datapoints) : datapoints;
assertIsArray<Datapoint>( assertParamIsArray<Datapoint>(
'datapoints', 'datapoints',
parsedDatapoints, parsedDatapoints,
(val): val is Datapoint => typeof val === 'object' && val !== null && 'value' in val, (val): val is Datapoint => typeof val === 'object' && val !== null && 'value' in val,
context.getNode(),
); );
const data = { const data = {
@@ -1215,7 +1214,7 @@ async function executeDatapointGet(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const datapointId = context.getNodeParameter('datapointId', itemIndex); const datapointId = context.getNodeParameter('datapointId', itemIndex);
assertIsString('datapointId', datapointId); assertParamIsString('datapointId', datapointId, context.getNode());
const data = { const data = {
goalName, goalName,
datapointId, datapointId,
@@ -1255,15 +1254,16 @@ async function executeChargeOperations(
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
if (operation === 'create') { if (operation === 'create') {
const amount = context.getNodeParameter('amount', itemIndex); const amount = context.getNodeParameter('amount', itemIndex);
assertIsNumber('amount', amount); assertParamIsNumber('amount', amount, context.getNode());
const options = context.getNodeParameter('additionalFields', itemIndex); const options = context.getNodeParameter('additionalFields', itemIndex);
assertIsNodeParameters<{ validateNodeParameters(
note?: string; options,
dryrun?: boolean; {
}>(options, { note: { type: 'string' },
note: { type: 'string', optional: true }, dryrun: { type: 'boolean' },
dryrun: { type: 'boolean', optional: true }, },
}); context.getNode(),
);
const data = { const data = {
amount, amount,
...options, ...options,
@@ -1280,13 +1280,13 @@ async function executeGoalCreate(
timezone: string, timezone: string,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const slug = context.getNodeParameter('slug', itemIndex); const slug = context.getNodeParameter('slug', itemIndex);
assertIsString('slug', slug); assertParamIsString('slug', slug, context.getNode());
const title = context.getNodeParameter('title', itemIndex); const title = context.getNodeParameter('title', itemIndex);
assertIsString('title', title); assertParamIsString('title', title, context.getNode());
const goalType = context.getNodeParameter('goal_type', itemIndex); const goalType = context.getNodeParameter('goal_type', itemIndex);
assertIsString('goalType', goalType); assertParamIsString('goalType', goalType, context.getNode());
const gunits = context.getNodeParameter('gunits', itemIndex); const gunits = context.getNodeParameter('gunits', itemIndex);
assertIsString('gunits', gunits); assertParamIsString('gunits', gunits, context.getNode());
const options = context.getNodeParameter('additionalFields', itemIndex); const options = context.getNodeParameter('additionalFields', itemIndex);
if ('tags' in options && typeof options.tags === 'string') { if ('tags' in options && typeof options.tags === 'string') {
options.tags = jsonParse(options.tags); options.tags = jsonParse(options.tags);
@@ -1295,27 +1295,21 @@ async function executeGoalCreate(
options.goaldate = moment.tz(options.goaldate, timezone).unix(); options.goaldate = moment.tz(options.goaldate, timezone).unix();
} }
assertIsNodeParameters<{ validateNodeParameters(
goaldate?: number; options,
goalval?: number; {
rate?: number; goaldate: { type: 'number' },
initval?: number; goalval: { type: 'number' },
secret?: boolean; rate: { type: 'number' },
datapublic?: boolean; initval: { type: 'number' },
datasource?: string; secret: { type: 'boolean' },
dryrun?: boolean; datapublic: { type: 'boolean' },
tags?: string[]; datasource: { type: 'string' },
}>(options, { dryrun: { type: 'boolean' },
goaldate: { type: 'number', optional: true }, tags: { type: 'string[]' },
goalval: { type: 'number', optional: true }, },
rate: { type: 'number', optional: true }, context.getNode(),
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 },
});
const data = { const data = {
slug, slug,
@@ -1333,15 +1327,16 @@ async function executeGoalGet(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const goalName = context.getNodeParameter('goalName', itemIndex); const goalName = context.getNodeParameter('goalName', itemIndex);
assertIsString('goalName', goalName); assertParamIsString('goalName', goalName, context.getNode());
const options = context.getNodeParameter('additionalFields', itemIndex); const options = context.getNodeParameter('additionalFields', itemIndex);
assertIsNodeParameters<{ validateNodeParameters(
datapoints?: boolean; options,
emaciated?: boolean; {
}>(options, { datapoints: { type: 'boolean' },
datapoints: { type: 'boolean', optional: true }, emaciated: { type: 'boolean' },
emaciated: { type: 'boolean', optional: true }, },
}); context.getNode(),
);
const data = { const data = {
goalName, goalName,
...options, ...options,
@@ -1355,11 +1350,13 @@ async function executeGoalGetAll(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const options = context.getNodeParameter('additionalFields', itemIndex); const options = context.getNodeParameter('additionalFields', itemIndex);
assertIsNodeParameters<{ validateNodeParameters(
emaciated?: boolean; options,
}>(options, { {
emaciated: { type: 'boolean', optional: true }, emaciated: { type: 'boolean' },
}); },
context.getNode(),
);
const data = { ...options }; const data = { ...options };
return await getAllGoals.call(context, data); return await getAllGoals.call(context, data);
@@ -1370,11 +1367,13 @@ async function executeGoalGetArchived(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const options = context.getNodeParameter('additionalFields', itemIndex); const options = context.getNodeParameter('additionalFields', itemIndex);
assertIsNodeParameters<{ validateNodeParameters(
emaciated?: boolean; options,
}>(options, { {
emaciated: { type: 'boolean', optional: true }, emaciated: { type: 'boolean' },
}); },
context.getNode(),
);
const data = { ...options }; const data = { ...options };
return await getArchivedGoals.call(context, data); return await getArchivedGoals.call(context, data);
@@ -1386,7 +1385,7 @@ async function executeGoalUpdate(
timezone: string, timezone: string,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const goalName = context.getNodeParameter('goalName', itemIndex); const goalName = context.getNodeParameter('goalName', itemIndex);
assertIsString('goalName', goalName); assertParamIsString('goalName', goalName, context.getNode());
const options = context.getNodeParameter('updateFields', itemIndex); const options = context.getNodeParameter('updateFields', itemIndex);
if ('tags' in options && typeof options.tags === 'string') { if ('tags' in options && typeof options.tags === 'string') {
options.tags = jsonParse(options.tags); options.tags = jsonParse(options.tags);
@@ -1394,37 +1393,29 @@ async function executeGoalUpdate(
if ('roadall' in options && typeof options.roadall === 'string') { if ('roadall' in options && typeof options.roadall === 'string') {
options.roadall = jsonParse(options.roadall); options.roadall = jsonParse(options.roadall);
} }
console.log('roadall', typeof options.roadall, options.roadall); if ('goaldate' in options && options.goaldate) {
assertIsNodeParameters<{ options.goaldate = moment.tz(options.goaldate, timezone).unix();
title?: string; }
yaxis?: string; validateNodeParameters(
tmin?: string; options,
tmax?: string; {
goaldate?: number; title: { type: 'string' },
secret?: boolean; yaxis: { type: 'string' },
datapublic?: boolean; tmin: { type: 'string' },
roadall?: object; tmax: { type: 'string' },
datasource?: string; goaldate: { type: 'number' },
tags?: string[]; secret: { type: 'boolean' },
}>(options, { datapublic: { type: 'boolean' },
title: { type: 'string', optional: true }, roadall: { type: 'object' },
yaxis: { type: 'string', optional: true }, datasource: { type: 'string' },
tmin: { type: 'string', optional: true }, tags: { type: 'string[]' },
tmax: { type: 'string', optional: true }, },
secret: { type: 'boolean', optional: true }, context.getNode(),
datapublic: { type: 'boolean', optional: true }, );
roadall: { type: 'object', optional: true },
datasource: { type: 'string', optional: true },
tags: { type: 'string[]', optional: true },
});
const data = { const data = {
goalName, goalName,
...options, ...options,
}; };
if (data.goaldate) {
data.goaldate = moment.tz(data.goaldate, timezone).unix();
}
return await updateGoal.call(context, data); return await updateGoal.call(context, data);
} }
@@ -1433,7 +1424,7 @@ async function executeGoalRefresh(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const goalName = context.getNodeParameter('goalName', itemIndex); const goalName = context.getNodeParameter('goalName', itemIndex);
assertIsString('goalName', goalName); assertParamIsString('goalName', goalName, context.getNode());
const data = { const data = {
goalName, goalName,
}; };
@@ -1445,7 +1436,7 @@ async function executeGoalShortCircuit(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const goalName = context.getNodeParameter('goalName', itemIndex); const goalName = context.getNodeParameter('goalName', itemIndex);
assertIsString('goalName', goalName); assertParamIsString('goalName', goalName, context.getNode());
const data = { const data = {
goalName, goalName,
@@ -1458,7 +1449,7 @@ async function executeGoalStepDown(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const goalName = context.getNodeParameter('goalName', itemIndex); const goalName = context.getNodeParameter('goalName', itemIndex);
assertIsString('goalName', goalName); assertParamIsString('goalName', goalName, context.getNode());
const data = { const data = {
goalName, goalName,
@@ -1471,7 +1462,7 @@ async function executeGoalCancelStepDown(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const goalName = context.getNodeParameter('goalName', itemIndex); const goalName = context.getNodeParameter('goalName', itemIndex);
assertIsString('goalName', goalName); assertParamIsString('goalName', goalName, context.getNode());
const data = { const data = {
goalName, goalName,
}; };
@@ -1483,7 +1474,7 @@ async function executeGoalUncle(
itemIndex: number, itemIndex: number,
): Promise<JsonObject[]> { ): Promise<JsonObject[]> {
const goalName = context.getNodeParameter('goalName', itemIndex); const goalName = context.getNodeParameter('goalName', itemIndex);
assertIsString('goalName', goalName); assertParamIsString('goalName', goalName, context.getNode());
const data = { const data = {
goalName, goalName,
}; };
@@ -1534,19 +1525,17 @@ async function executeUserOperations(
if (options.diff_since) { if (options.diff_since) {
options.diff_since = moment.tz(options.diff_since, timezone).unix(); options.diff_since = moment.tz(options.diff_since, timezone).unix();
} }
assertIsNodeParameters<{ validateNodeParameters(
associations?: boolean; options,
diff_since?: number; {
skinny?: boolean; associations: { type: 'boolean' },
emaciated?: boolean; diff_since: { type: 'number' },
datapoints_count?: number; skinny: { type: 'boolean' },
}>(options, { emaciated: { type: 'boolean' },
associations: { type: 'boolean', optional: true }, datapoints_count: { type: 'number' },
diff_since: { type: 'number', optional: true }, },
skinny: { type: 'boolean', optional: true }, context.getNode(),
emaciated: { type: 'boolean', optional: true }, );
datapoints_count: { type: 'number', optional: true },
});
const data = { ...options }; const data = { ...options };
return await getUser.call(context, data); return await getUser.call(context, data);

View File

@@ -1,94 +0,0 @@
import { assert } from 'n8n-workflow';
function assertIsType<T>(
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<number>(parameterName, value, 'number');
}
export function assertIsString(parameterName: string, value: unknown): asserts value is string {
assertIsType<string>(parameterName, value, 'string');
}
export function assertIsArray<T>(
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<T>(
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<string, unknown>;
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}`);
}
}
});
}

View File

@@ -67,6 +67,7 @@ export { ExpressionExtensions } from './extensions';
export * as ExpressionParser from './extensions/expression-parser'; export * as ExpressionParser from './extensions/expression-parser';
export { NativeMethods } from './native-methods'; export { NativeMethods } from './native-methods';
export * from './node-parameters/filter-parameter'; export * from './node-parameters/filter-parameter';
export * from './node-parameters/parameter-type-validation';
export * from './evaluation-helpers'; export * from './evaluation-helpers';
export type { export type {

View File

@@ -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<T>(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<T>(
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<number>(parameterName, value, 'number', node);
}
export function assertParamIsString(
parameterName: string,
value: unknown,
node: INode,
): asserts value is string {
assertParamIsType<string>(parameterName, value, 'string', node);
}
export function assertParamIsBoolean(
parameterName: string,
value: unknown,
node: INode,
): asserts value is boolean {
assertParamIsType<boolean>(parameterName, value, 'boolean', node);
}
export function assertParamIsArray<T>(
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<string, unknown> {
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<T extends 'string' | 'number' | 'boolean'>(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 | ParameterType[]> = T extends ParameterType[]
? InferSingleParameterType<T[number]>
: T extends ParameterType
? InferSingleParameterType<T>
: never;
type InferSingleParameterType<T extends ParameterType> = T extends 'string'
? string
: T extends 'boolean'
? boolean
: T extends 'number'
? number
: T extends 'resource-locator'
? Record<string, unknown>
: T extends 'string[]'
? string[]
: T extends 'number[]'
? number[]
: T extends 'boolean[]'
? boolean[]
: T extends 'object'
? Record<string, unknown>
: unknown;
export function validateNodeParameters<
T extends Record<string, { type: ParameterType | ParameterType[]; required?: boolean }>,
>(
value: unknown,
parameters: T,
node: INode,
): asserts value is {
[K in keyof T]: T[K]['required'] extends true
? InferParameterType<T[K]['type']>
: InferParameterType<T[K]['type']> | 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);
}
});
}

View File

@@ -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();
});
});
});
});