mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
fix(Chat Trigger Node): Prevent XSS vulnerabilities and improve parameter validation (#18148)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<IWebhookResponseData> {
|
||||
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<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({
|
||||
i18n: {
|
||||
en: i18nConfig,
|
||||
},
|
||||
showWelcomeScreen: options.showWelcomeScreen,
|
||||
loadPreviousSession: options.loadPreviousSession,
|
||||
initialMessages,
|
||||
loadPreviousSession,
|
||||
initialMessages: initialMessagesRaw,
|
||||
webhookUrl,
|
||||
mode,
|
||||
instanceId,
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<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({
|
||||
instanceId,
|
||||
webhookUrl,
|
||||
@@ -21,7 +53,7 @@ export function createPage({
|
||||
i18n: {
|
||||
en: Record<string, string>;
|
||||
};
|
||||
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 `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -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},
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
const datapoints = context.getNodeParameter('datapoints', itemIndex);
|
||||
const parsedDatapoints = typeof datapoints === 'string' ? jsonParse(datapoints) : datapoints;
|
||||
assertIsArray<Datapoint>(
|
||||
assertParamIsArray<Datapoint>(
|
||||
'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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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<JsonObject[]> {
|
||||
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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user