first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,324 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { INode, IExecuteFunctions } from 'n8n-workflow';
import {
CHAT_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
FREE_TEXT_CHAT_RESPONSE_TYPE,
SEND_AND_WAIT_OPERATION,
} from 'n8n-workflow';
import { Chat } from '../Chat.node';
describe('Test Chat Node', () => {
let chat: Chat;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
chat = new Chat();
mockExecuteFunctions = mock<IExecuteFunctions>();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('v1.0', () => {
const chatNode = mock<INode>({
name: 'Chat',
type: CHAT_NODE_TYPE,
parameters: {},
typeVersion: 1.0,
});
it('should execute and send message', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
limitType: 'afterTimeInterval',
resumeAmount: 1,
resumeUnit: 'minutes',
});
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const result = await chat.execute.call(mockExecuteFunctions);
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
});
it('should execute and handle memory connection', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true });
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
limitType: 'afterTimeInterval',
resumeAmount: 1,
resumeUnit: 'minutes',
});
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const memory = { chatHistory: { addAIMessage: jest.fn() } };
mockExecuteFunctions.getInputConnectionData.mockResolvedValueOnce(memory);
await chat.execute.call(mockExecuteFunctions);
expect(memory.chatHistory.addAIMessage).toHaveBeenCalledWith('message');
});
it('should execute without memory connection', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
limitType: 'afterTimeInterval',
resumeAmount: 1,
resumeUnit: 'minutes',
});
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const result = await chat.execute.call(mockExecuteFunctions);
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
});
it('should execute with specified time limit', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
limitType: 'atSpecifiedTime',
maxDateAndTime: new Date().toISOString(),
});
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const result = await chat.execute.call(mockExecuteFunctions);
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
});
it('should process onMessage without waiting for reply', async () => {
const data = { json: { chatInput: 'user message' } };
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true });
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([[data]]);
});
});
describe('v1.1', () => {
const chatNode = mock<INode>({
name: 'Chat',
type: CHAT_NODE_TYPE,
parameters: {},
typeVersion: 1.1,
});
it('should process onMessage without waiting for reply', async () => {
const data = { json: { chatInput: 'user message' } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return 'send';
case 'options':
return { memoryConnection: false };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([[data]]);
});
it('should process onMessage with waiting for reply and free text response type', async () => {
const data = { json: { chatInput: 'user message' } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return SEND_AND_WAIT_OPERATION;
case 'responseType':
return FREE_TEXT_CHAT_RESPONSE_TYPE;
case 'options':
return { memoryConnection: false };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([
[
{
...data,
json: {
data: {
...data.json,
},
},
},
],
]);
});
it('should process onMessage with waiting for reply and approval response type', async () => {
const data = { json: { chatInput: 'user message' } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return SEND_AND_WAIT_OPERATION;
case 'responseType':
return 'approval';
case 'options':
return { memoryConnection: false };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([
[
{
...data,
json: {
data: {
...data.json,
approved: false,
},
},
},
],
]);
});
it('should add user message to memory', async () => {
const data = { json: { chatInput: 'user message' } };
const memory = { chatHistory: { addUserMessage: jest.fn() } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getInputConnectionData.mockResolvedValue(memory);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return SEND_AND_WAIT_OPERATION;
case 'responseType':
return FREE_TEXT_CHAT_RESPONSE_TYPE;
case 'options':
return { memoryConnection: true };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([
[
{
...data,
json: {
data: {
...data.json,
},
},
},
],
]);
expect(memory.chatHistory.addUserMessage).toHaveBeenCalledWith('user message');
});
it('v1.2 should return output data directly without nesting into `data` field (except `approved`)', async () => {
const chatNode = mock<INode>({
name: 'Chat',
type: CHAT_NODE_TYPE,
parameters: {},
typeVersion: 1.2,
});
const data = { json: { chatInput: 'user message', data: { nested: 'field' } } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return SEND_AND_WAIT_OPERATION;
case 'responseType':
return 'approval';
case 'options':
return { memoryConnection: false };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([
[
{
...data,
json: {
...data.json,
data: {
...data.json.data,
approved: false,
},
},
},
],
]);
});
});
});
@@ -0,0 +1,313 @@
import { jest } from '@jest/globals';
import type { Request, Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { IWebhookFunctions } from 'n8n-workflow';
import { ChatTrigger } from '../ChatTrigger.node';
import type { LoadPreviousSessionChatOption } from '../types';
jest.mock('../GenericFunctions', () => ({
validateAuth: jest.fn(),
}));
describe('ChatTrigger Node', () => {
const mockContext = mock<IWebhookFunctions>();
const mockRequest = mock<Request>();
const mockResponse = mock<Response>();
let chatTrigger: ChatTrigger;
beforeEach(() => {
jest.clearAllMocks();
chatTrigger = new ChatTrigger();
mockContext.getRequestObject.mockReturnValue(mockRequest);
mockContext.getResponseObject.mockReturnValue(mockResponse);
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return {};
return defaultValue;
},
);
mockContext.getBodyData.mockReturnValue({});
});
describe('webhook method: loadPreviousSession action', () => {
beforeEach(() => {
mockContext.getBodyData.mockReturnValue({ action: 'loadPreviousSession' });
});
it('should return empty array when loadPreviousSession is undefined', async () => {
// Mock options with undefined loadPreviousSession
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { loadPreviousSession: undefined };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify the returned result contains empty data array
expect(result).toEqual({
webhookResponse: { data: [] },
});
});
it('should return empty array when loadPreviousSession is "notSupported"', async () => {
// Mock options with notSupported loadPreviousSession
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { loadPreviousSession: 'notSupported' };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify the returned result contains empty data array
expect(result).toEqual({
webhookResponse: { data: [] },
});
});
it('should handle loadPreviousSession="memory" correctly', async () => {
// Mock chat history data
const mockMessages = [
{ toJSON: () => ({ content: 'Message 1' }) },
{ toJSON: () => ({ content: 'Message 2' }) },
];
// Mock memory with chat history
const mockMemory = {
chatHistory: {
getMessages: jest.fn().mockReturnValueOnce(mockMessages),
},
};
// Mock options with memory loadPreviousSession
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options')
return { loadPreviousSession: 'memory' as LoadPreviousSessionChatOption };
return defaultValue;
},
);
// Mock getInputConnectionData to return memory
mockContext.getInputConnectionData.mockResolvedValue(mockMemory);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify the returned result contains messages from memory
expect(result).toEqual({
webhookResponse: {
data: [{ content: 'Message 1' }, { content: 'Message 2' }],
},
});
});
});
describe('webhook method: streaming response mode', () => {
beforeEach(() => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getMode.mockReturnValue('production' as any);
mockContext.getBodyData.mockReturnValue({ message: 'Hello' });
(mockContext.helpers.returnJsonArray as any) = jest.fn().mockReturnValue([]);
mockResponse.writeHead.mockImplementation(() => mockResponse);
mockResponse.flushHeaders.mockImplementation(() => undefined);
});
it('should enable streaming when responseMode is "streaming"', async () => {
// Mock options with streaming responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { responseMode: 'streaming' };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are set
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(mockResponse.flushHeaders).toHaveBeenCalled();
// Verify response structure for streaming
expect(result).toEqual({
workflowData: expect.any(Array),
noWebhookResponse: true,
});
});
it('should not enable streaming when responseMode is not "streaming"', async () => {
// Mock options with lastNode responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { responseMode: 'lastNode' };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are NOT set
expect(mockResponse.writeHead).not.toHaveBeenCalled();
expect(mockResponse.flushHeaders).not.toHaveBeenCalled();
// Verify normal response structure
expect(result).toEqual({
webhookResponse: { status: 200 },
workflowData: expect.any(Array),
});
});
it('should enable streaming when availableInChat is true and responseMode is not set', async () => {
// Mock options with availableInChat true and no responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return {};
if (paramName === 'availableInChat') return true;
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are set
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(mockResponse.flushHeaders).toHaveBeenCalled();
// Verify response structure for streaming
expect(result).toEqual({
workflowData: expect.any(Array),
noWebhookResponse: true,
});
});
it('should enable streaming when availableInChat is true and responseMode is "streaming"', async () => {
// Mock options with availableInChat true and streaming responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { responseMode: 'streaming' };
if (paramName === 'availableInChat') return true;
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are set
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(mockResponse.flushHeaders).toHaveBeenCalled();
// Verify response structure for streaming
expect(result).toEqual({
workflowData: expect.any(Array),
noWebhookResponse: true,
});
});
it('should handle multipart form data with streaming enabled', async () => {
// Mock multipart form data request
mockRequest.contentType = 'multipart/form-data';
mockRequest.body = {
data: { message: 'Hello' },
files: {},
};
// Mock options with streaming responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { responseMode: 'streaming' };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are set
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(mockResponse.flushHeaders).toHaveBeenCalled();
// Verify response structure for streaming
expect(result).toEqual({
workflowData: expect.any(Array),
noWebhookResponse: true,
});
});
});
});
@@ -0,0 +1,156 @@
import { mock } from 'jest-mock-extended';
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
import { ChatTriggerAuthorizationError } from '../error';
import { validateAuth } from '../GenericFunctions';
describe('validateAuth', () => {
const mockContext = mock<IWebhookFunctions>();
beforeEach(() => {
jest.clearAllMocks();
});
describe('authentication = none', () => {
it('should pass without error', async () => {
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('none');
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
});
});
describe('authentication = basicAuth', () => {
beforeEach(() => {
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('basicAuth');
});
it('should throw 500 when credentials are not defined', async () => {
mockContext.getCredentials.mockRejectedValue(new Error('No credentials'));
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 500,
});
});
it('should throw 401 when no auth header is provided', async () => {
mockContext.getCredentials.mockResolvedValue({
user: 'admin',
password: 'secret',
} as ICredentialDataDecryptedObject);
mockContext.getRequestObject.mockReturnValue({
headers: {},
} as never);
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
});
});
it('should throw 403 when credentials are wrong', async () => {
mockContext.getCredentials.mockResolvedValue({
user: 'admin',
password: 'secret',
} as ICredentialDataDecryptedObject);
mockContext.getRequestObject.mockReturnValue({
headers: {
authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64'),
},
} as never);
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 403,
});
});
it('should pass with correct credentials', async () => {
mockContext.getCredentials.mockResolvedValue({
user: 'admin',
password: 'secret',
} as ICredentialDataDecryptedObject);
mockContext.getRequestObject.mockReturnValue({
headers: {
authorization: 'Basic ' + Buffer.from('admin:secret').toString('base64'),
},
} as never);
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
});
});
describe('authentication = n8nUserAuth', () => {
beforeEach(() => {
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('n8nUserAuth');
});
it('should skip validation for setup webhook', async () => {
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getHeaderData.mockReturnValue({});
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
});
it('should throw 401 when no n8n-auth cookie is present', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({});
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'User not authenticated!',
});
});
it('should throw 401 when cookie has a fake/invalid token', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({
cookie: 'n8n-auth=anything',
});
mockContext.validateCookieAuth.mockRejectedValue(new Error('Unauthorized'));
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'Invalid authentication token',
});
});
it('should throw 401 when validateCookieAuth rejects (revoked token)', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({
cookie: 'n8n-auth=some.revoked.token',
});
mockContext.validateCookieAuth.mockRejectedValue(new Error('Unauthorized'));
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'Invalid authentication token',
});
});
it('should pass with a valid token', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({
cookie: 'n8n-auth=valid.jwt.token',
});
mockContext.validateCookieAuth.mockResolvedValue(undefined);
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
expect(mockContext.validateCookieAuth).toHaveBeenCalledWith('valid.jwt.token');
});
it('should pass when cookie has other cookies alongside n8n-auth', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({
cookie: 'other=value; n8n-auth=valid.jwt.token; another=thing',
});
mockContext.validateCookieAuth.mockResolvedValue(undefined);
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
expect(mockContext.validateCookieAuth).toHaveBeenCalledWith('valid.jwt.token');
});
});
});
@@ -0,0 +1,380 @@
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,
initialMessages: '',
};
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('XSS Prevention in allowedFilesMimeTypes', () => {
it('should prevent script injection through allowedFilesMimeTypes', () => {
const maliciousInput = '</script><script>alert(document.cookie)</script>';
const result = createPage({
...defaultParams,
allowFileUploads: true,
allowedFilesMimeTypes: maliciousInput,
});
expect(result).not.toContain('<script>alert(document.cookie)</script>');
expect(result).not.toContain('</script><script>');
expect(result).not.toContain('alert(document.cookie)');
});
it('should sanitize common XSS payloads in allowedFilesMimeTypes', () => {
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:'] },
];
xssPayloads.forEach(({ input, dangerous }) => {
const result = createPage({
...defaultParams,
allowFileUploads: true,
allowedFilesMimeTypes: input,
});
dangerous.forEach((dangerousContent) => {
expect(result).not.toContain(dangerousContent);
});
});
});
it('should preserve legitimate MIME types', () => {
const legitimateMimeTypes = 'image/*,text/plain,application/pdf';
const result = createPage({
...defaultParams,
allowFileUploads: true,
allowedFilesMimeTypes: legitimateMimeTypes,
});
expect(result).toContain(legitimateMimeTypes);
});
});
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('');
});
});
});
@@ -0,0 +1,79 @@
import { mock, mockDeep } from 'jest-mock-extended';
import * as sendAndWaitUtils from 'n8n-nodes-base/dist/utils/sendAndWait/utils';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { ChatNodeMessageType, FREE_TEXT_CHAT_RESPONSE_TYPE } from 'n8n-workflow';
import { getChatMessage } from '../util';
describe('util', () => {
describe('getChatMessage', () => {
const ctx = mockDeep<IExecuteFunctions>();
beforeEach(() => {
jest.resetAllMocks();
});
it('should return a string for v1.0', () => {
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.0 }));
ctx.getNodeParameter.mockReturnValue('test');
const message = getChatMessage(ctx);
expect(message).toBe('test');
});
it('should return a string for v1.1 with free text response type', () => {
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
ctx.getNodeParameter.mockImplementation((paramName) => {
switch (paramName) {
case 'responseType':
return FREE_TEXT_CHAT_RESPONSE_TYPE;
case 'message':
return 'test';
default:
return undefined;
}
});
const message = getChatMessage(ctx);
expect(message).toBe('test');
});
it('should return ChatNodeMessageWithButtons for v1.1 with approval response type', () => {
jest.spyOn(sendAndWaitUtils, 'getSendAndWaitConfig').mockReturnValue({
title: '',
message: '',
options: [
{ label: 'Disapprove', url: 'https://no.com', style: 'secondary' },
{ label: 'Approve', url: 'https://yes.com', style: 'primary' },
],
});
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
ctx.getNodeParameter.mockImplementation((paramName) => {
switch (paramName) {
case 'responseType':
return 'approval';
case 'message':
return 'test';
case 'blockUserInput':
return true;
default:
return undefined;
}
});
const message = getChatMessage(ctx);
expect(message).toEqual({
type: ChatNodeMessageType.WITH_BUTTONS,
text: 'test',
blockUserInput: true,
buttons: [
{ text: 'Approve', link: 'https://yes.com', type: 'primary' },
{ text: 'Disapprove', link: 'https://no.com', type: 'secondary' },
],
});
});
});
});