mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
fix(core): Scope live and test webhook handlers to their route family (#28804)
This commit is contained in:
@@ -232,12 +232,16 @@ export abstract class AbstractServer {
|
||||
|
||||
// Setup webhook handlers before bodyParser, to let the Webhook node handle binary data in requests
|
||||
if (this.webhooksEnabled) {
|
||||
const liveWebhooksRequestHandler = createWebhookHandlerFor(Container.get(LiveWebhooks));
|
||||
const liveWebhooks = Container.get(LiveWebhooks);
|
||||
|
||||
// Register a handler for live forms
|
||||
this.app.all(`/${this.endpointForm}/*path`, liveWebhooksRequestHandler);
|
||||
this.app.all(`/${this.endpointForm}/*path`, createWebhookHandlerFor(liveWebhooks, 'form'));
|
||||
|
||||
// Register a handler for live webhooks
|
||||
this.app.all(`/${this.endpointWebhook}/*path`, liveWebhooksRequestHandler);
|
||||
this.app.all(
|
||||
`/${this.endpointWebhook}/*path`,
|
||||
createWebhookHandlerFor(liveWebhooks, 'webhook'),
|
||||
);
|
||||
|
||||
// Register a handler for waiting forms
|
||||
this.app.all(
|
||||
@@ -252,18 +256,23 @@ export abstract class AbstractServer {
|
||||
);
|
||||
|
||||
// Register a handler for live MCP servers
|
||||
this.app.all(`/${this.endpointMcp}/*path`, liveWebhooksRequestHandler);
|
||||
this.app.all(`/${this.endpointMcp}/*path`, createWebhookHandlerFor(liveWebhooks, 'mcp'));
|
||||
}
|
||||
|
||||
if (this.testWebhooksEnabled) {
|
||||
const testWebhooksRequestHandler = createWebhookHandlerFor(Container.get(TestWebhooks));
|
||||
const testWebhooks = Container.get(TestWebhooks);
|
||||
|
||||
// Register a handler
|
||||
this.app.all(`/${this.endpointFormTest}/*path`, testWebhooksRequestHandler);
|
||||
this.app.all(`/${this.endpointWebhookTest}/*path`, testWebhooksRequestHandler);
|
||||
this.app.all(
|
||||
`/${this.endpointFormTest}/*path`,
|
||||
createWebhookHandlerFor(testWebhooks, 'form'),
|
||||
);
|
||||
this.app.all(
|
||||
`/${this.endpointWebhookTest}/*path`,
|
||||
createWebhookHandlerFor(testWebhooks, 'webhook'),
|
||||
);
|
||||
|
||||
// Register a handler for test MCP servers
|
||||
this.app.all(`/${this.endpointMcpTest}/*path`, testWebhooksRequestHandler);
|
||||
this.app.all(`/${this.endpointMcpTest}/*path`, createWebhookHandlerFor(testWebhooks, 'mcp'));
|
||||
}
|
||||
|
||||
// Block bots from scanning the application
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import { LiveWebhooks } from '@/webhooks/live-webhooks';
|
||||
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
||||
@@ -105,7 +106,7 @@ describe('LiveWebhooks', () => {
|
||||
httpMethod,
|
||||
path: webhookPath,
|
||||
node: nodeName,
|
||||
webhookDescription: {},
|
||||
webhookDescription: { nodeType: undefined } as never,
|
||||
workflowId,
|
||||
});
|
||||
|
||||
@@ -218,7 +219,7 @@ describe('LiveWebhooks', () => {
|
||||
httpMethod,
|
||||
path: webhookPath,
|
||||
node: nodeName,
|
||||
webhookDescription: {},
|
||||
webhookDescription: { nodeType: undefined } as never,
|
||||
workflowId,
|
||||
});
|
||||
|
||||
@@ -261,4 +262,141 @@ describe('LiveWebhooks', () => {
|
||||
expect(capturedWorkflowData!.nodes[1].id).not.toBe('set-node-draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('route family scoping', () => {
|
||||
const workflowId = 'workflow-1';
|
||||
const nodeName = 'Trigger';
|
||||
const webhookPath = 'my-path';
|
||||
const httpMethod: IHttpRequestMethods = 'GET';
|
||||
|
||||
const setupMocks = (
|
||||
declaredNodeType: 'form' | 'webhook' | 'mcp' | undefined,
|
||||
nodeTypeName = 'n8n-nodes-base.webhook',
|
||||
) => {
|
||||
const node: INode = {
|
||||
id: 'trigger-node',
|
||||
name: nodeName,
|
||||
type: nodeTypeName,
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: { path: webhookPath, httpMethod },
|
||||
};
|
||||
|
||||
const activeVersion = mock<WorkflowHistory>({
|
||||
versionId: 'v1',
|
||||
workflowId,
|
||||
nodes: [node],
|
||||
connections: {},
|
||||
});
|
||||
|
||||
const workflowEntity = mock<WorkflowEntity>({
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
activeVersionId: 'v1',
|
||||
nodes: [node],
|
||||
connections: {},
|
||||
activeVersion,
|
||||
shared: [{ role: 'workflow:owner', project: { id: 'project-1', projectRelations: [] } }],
|
||||
});
|
||||
|
||||
const webhookEntity = mock<WebhookEntity>({
|
||||
workflowId,
|
||||
node: nodeName,
|
||||
webhookPath,
|
||||
method: httpMethod,
|
||||
isDynamic: false,
|
||||
});
|
||||
|
||||
const webhookNodeType = mock<INodeType>({
|
||||
description: {
|
||||
name: nodeTypeName,
|
||||
properties: [],
|
||||
webhooks: [{ nodeType: declaredNodeType, name: 'default' } as never],
|
||||
},
|
||||
webhook: jest.fn(),
|
||||
});
|
||||
|
||||
const webhookData = mock<IWebhookData>({
|
||||
httpMethod,
|
||||
path: webhookPath,
|
||||
node: nodeName,
|
||||
webhookDescription: { nodeType: declaredNodeType } as never,
|
||||
workflowId,
|
||||
});
|
||||
|
||||
webhookService.findWebhook.mockResolvedValue(webhookEntity);
|
||||
webhookService.getWebhookMethods.mockResolvedValue([httpMethod]);
|
||||
workflowRepository.findOne.mockResolvedValue(workflowEntity);
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(webhookNodeType);
|
||||
webhookService.getNodeWebhooks.mockReturnValue([webhookData]);
|
||||
|
||||
(WebhookHelpers.executeWebhook as jest.Mock).mockImplementation((...args: unknown[]) => {
|
||||
const webhookCallback = args[args.length - 1] as (
|
||||
error: Error | null,
|
||||
data: object,
|
||||
) => void;
|
||||
void webhookCallback(null, {});
|
||||
});
|
||||
};
|
||||
|
||||
const buildRequest = () =>
|
||||
mock<WebhookRequest>({ method: httpMethod, params: { path: webhookPath } });
|
||||
|
||||
it('executes a form trigger on the form route family', async () => {
|
||||
setupMocks('form', 'n8n-nodes-base.formTrigger');
|
||||
|
||||
await expect(
|
||||
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'form'),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a not-found error when a form trigger is requested on the webhook route family', async () => {
|
||||
setupMocks('form', 'n8n-nodes-base.formTrigger');
|
||||
|
||||
await expect(
|
||||
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'webhook'),
|
||||
).rejects.toBeInstanceOf(WebhookNotFoundError);
|
||||
});
|
||||
|
||||
it('executes a regular webhook on the webhook route family', async () => {
|
||||
setupMocks(undefined);
|
||||
|
||||
await expect(
|
||||
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'webhook'),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a not-found error when a regular webhook is requested on the form route family', async () => {
|
||||
setupMocks(undefined);
|
||||
|
||||
await expect(
|
||||
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'form'),
|
||||
).rejects.toBeInstanceOf(WebhookNotFoundError);
|
||||
});
|
||||
|
||||
it('executes an mcp trigger on the mcp route family', async () => {
|
||||
setupMocks('mcp', '@n8n/n8n-nodes-langchain.mcpTrigger');
|
||||
|
||||
await expect(
|
||||
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'mcp'),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a not-found error when an mcp trigger is requested on the form route family', async () => {
|
||||
setupMocks('mcp', '@n8n/n8n-nodes-langchain.mcpTrigger');
|
||||
|
||||
await expect(
|
||||
liveWebhooks.executeWebhook(buildRequest(), mock<Response>(), 'form'),
|
||||
).rejects.toBeInstanceOf(WebhookNotFoundError);
|
||||
});
|
||||
|
||||
it('executes without family scoping when expectedNodeType is not provided', async () => {
|
||||
setupMocks('form', 'n8n-nodes-base.formTrigger');
|
||||
|
||||
await expect(
|
||||
liveWebhooks.executeWebhook(buildRequest(), mock<Response>()),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,6 +381,46 @@ describe('TestWebhooks', () => {
|
||||
|
||||
await expect(promise).rejects.toThrowError(NotFoundError);
|
||||
});
|
||||
|
||||
test('returns a not-found error when a form trigger is requested on the webhook route family', async () => {
|
||||
const formWebhook = mock<IWebhookData>({
|
||||
httpMethod,
|
||||
path,
|
||||
workflowId: workflowEntity.id,
|
||||
webhookDescription: { nodeType: 'form' } as never,
|
||||
});
|
||||
|
||||
jest.spyOn(testWebhooks, 'getActiveWebhook').mockResolvedValue(formWebhook);
|
||||
jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]);
|
||||
|
||||
const promise = testWebhooks.executeWebhook(
|
||||
mock<WebhookRequest>({ params: { path } }),
|
||||
mock<express.Response>(),
|
||||
'webhook',
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrowError(WebhookNotFoundError);
|
||||
});
|
||||
|
||||
test('returns a not-found error when a regular webhook is requested on the form route family', async () => {
|
||||
const regularWebhook = mock<IWebhookData>({
|
||||
httpMethod,
|
||||
path,
|
||||
workflowId: workflowEntity.id,
|
||||
webhookDescription: { nodeType: undefined } as never,
|
||||
});
|
||||
|
||||
jest.spyOn(testWebhooks, 'getActiveWebhook').mockResolvedValue(regularWebhook);
|
||||
jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]);
|
||||
|
||||
const promise = testWebhooks.executeWebhook(
|
||||
mock<WebhookRequest>({ params: { path } }),
|
||||
mock<express.Response>(),
|
||||
'form',
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrowError(WebhookNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivateWebhooks()', () => {
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('WebhookRequestHandler', () => {
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res);
|
||||
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.setHeaders).toHaveBeenCalledWith(new Map([['x-custom-header', 'test']]));
|
||||
expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data);
|
||||
@@ -190,7 +190,7 @@ describe('WebhookRequestHandler', () => {
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res);
|
||||
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
code: 100,
|
||||
@@ -264,7 +264,7 @@ describe('WebhookRequestHandler', () => {
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res);
|
||||
expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data);
|
||||
},
|
||||
|
||||
@@ -5,15 +5,6 @@ import type { Response } from 'express';
|
||||
import { Workflow, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
import type { INode, IWebhookData, IHttpRequestMethods, IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import { authAllowlistedNodes } from './constants';
|
||||
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
|
||||
import type {
|
||||
IWebhookResponseCallbackData,
|
||||
IWebhookManager,
|
||||
WebhookAccessControlOptions,
|
||||
WebhookRequest,
|
||||
} from './webhook.types';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
@@ -22,6 +13,17 @@ import { WebhookService } from '@/webhooks/webhook.service';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||
|
||||
import { authAllowlistedNodes } from './constants';
|
||||
import { matchesExpectedNodeType } from './node-type-matcher';
|
||||
import type { ExpectedWebhookNodeType } from './node-type-matcher';
|
||||
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
|
||||
import type {
|
||||
IWebhookResponseCallbackData,
|
||||
IWebhookManager,
|
||||
WebhookAccessControlOptions,
|
||||
WebhookRequest,
|
||||
} from './webhook.types';
|
||||
|
||||
/**
|
||||
* Service for handling the execution of live webhooks, i.e. webhooks
|
||||
* that belong to activated workflows and use the production URL
|
||||
@@ -62,6 +64,7 @@ export class LiveWebhooks implements IWebhookManager {
|
||||
// we need to use webhookId for matching
|
||||
isChatWebhookNode(type, webhookId),
|
||||
);
|
||||
|
||||
return webhookNode?.parameters?.options as WebhookAccessControlOptions;
|
||||
}
|
||||
|
||||
@@ -71,6 +74,7 @@ export class LiveWebhooks implements IWebhookManager {
|
||||
async executeWebhook(
|
||||
request: WebhookRequest,
|
||||
response: Response,
|
||||
expectedNodeType?: ExpectedWebhookNodeType,
|
||||
): Promise<IWebhookResponseCallbackData> {
|
||||
const httpMethod = request.method;
|
||||
const path = request.params.path;
|
||||
@@ -146,6 +150,16 @@ export class LiveWebhooks implements IWebhookManager {
|
||||
.getNodeWebhooks(workflow, workflow.getNode(webhook.node) as INode, additionalData)
|
||||
.find((w) => w.httpMethod === httpMethod && w.path === webhook.webhookPath) as IWebhookData;
|
||||
|
||||
if (
|
||||
expectedNodeType &&
|
||||
!matchesExpectedNodeType(expectedNodeType, webhookData?.webhookDescription.nodeType)
|
||||
) {
|
||||
throw new WebhookNotFoundError(
|
||||
{ path, httpMethod, webhookMethods: await this.getWebhookMethods(path) },
|
||||
{ hint: 'production' },
|
||||
);
|
||||
}
|
||||
|
||||
// Get the node which has the webhook defined to know where to start from and to
|
||||
// get additional data
|
||||
const workflowStartNode = workflow.getNode(webhookData.node);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IWebhookDescription } from 'n8n-workflow';
|
||||
|
||||
export type ExpectedWebhookNodeType = NonNullable<IWebhookDescription['nodeType']>;
|
||||
|
||||
export function matchesExpectedNodeType(
|
||||
expected: ExpectedWebhookNodeType,
|
||||
declared: IWebhookDescription['nodeType'] = 'webhook',
|
||||
): boolean {
|
||||
return expected === declared;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TEST_WEBHOOK_TIMEOUT } from '@/constants';
|
||||
import { OnPubSubEvent } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import type express from 'express';
|
||||
@@ -13,16 +12,7 @@ import type {
|
||||
IDestinationNode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { authAllowlistedNodes } from './constants';
|
||||
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
|
||||
import { WebhookService } from './webhook.service';
|
||||
import type {
|
||||
IWebhookResponseCallbackData,
|
||||
IWebhookManager,
|
||||
WebhookAccessControlOptions,
|
||||
WebhookRequest,
|
||||
} from './webhook.types';
|
||||
|
||||
import { TEST_WEBHOOK_TIMEOUT } from '@/constants';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
|
||||
import { SingleWebhookTriggerError } from '@/errors/single-webhook-trigger.error';
|
||||
@@ -37,6 +27,18 @@ import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
import type { WorkflowRequest } from '@/workflows/workflow.request';
|
||||
|
||||
import { authAllowlistedNodes } from './constants';
|
||||
import { matchesExpectedNodeType } from './node-type-matcher';
|
||||
import type { ExpectedWebhookNodeType } from './node-type-matcher';
|
||||
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
|
||||
import { WebhookService } from './webhook.service';
|
||||
import type {
|
||||
IWebhookResponseCallbackData,
|
||||
IWebhookManager,
|
||||
WebhookAccessControlOptions,
|
||||
WebhookRequest,
|
||||
} from './webhook.types';
|
||||
|
||||
const SINGLE_WEBHOOK_TRIGGERS = [
|
||||
'n8n-nodes-base.telegramTrigger',
|
||||
'n8n-nodes-base.slackTrigger',
|
||||
@@ -67,6 +69,7 @@ export class TestWebhooks implements IWebhookManager {
|
||||
async executeWebhook(
|
||||
request: WebhookRequest,
|
||||
response: express.Response,
|
||||
expectedNodeType?: ExpectedWebhookNodeType,
|
||||
): Promise<IWebhookResponseCallbackData> {
|
||||
const httpMethod = request.method;
|
||||
|
||||
@@ -100,6 +103,17 @@ export class TestWebhooks implements IWebhookManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
expectedNodeType &&
|
||||
!matchesExpectedNodeType(expectedNodeType, webhook.webhookDescription.nodeType)
|
||||
) {
|
||||
throw new WebhookNotFoundError({
|
||||
path,
|
||||
httpMethod,
|
||||
webhookMethods: await this.getWebhookMethods(path),
|
||||
});
|
||||
}
|
||||
|
||||
const key = this.registrations.toKey(webhook);
|
||||
|
||||
const registration = await this.registrations.get(key);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { finished } from 'stream/promises';
|
||||
|
||||
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
|
||||
import * as ResponseHelper from '@/response-helper';
|
||||
import type { ExpectedWebhookNodeType } from '@/webhooks/node-type-matcher';
|
||||
import type {
|
||||
WebhookStaticResponse,
|
||||
WebhookResponse,
|
||||
@@ -29,7 +30,10 @@ import type {
|
||||
const WEBHOOK_METHODS: IHttpRequestMethods[] = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'];
|
||||
|
||||
class WebhookRequestHandler {
|
||||
constructor(private readonly webhookManager: IWebhookManager) {}
|
||||
constructor(
|
||||
private readonly webhookManager: IWebhookManager,
|
||||
private readonly expectedNodeType?: ExpectedWebhookNodeType,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handles an incoming webhook request. Handles CORS and delegates the
|
||||
@@ -58,7 +62,7 @@ class WebhookRequestHandler {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.webhookManager.executeWebhook(req, res);
|
||||
const response = await this.webhookManager.executeWebhook(req, res, this.expectedNodeType);
|
||||
|
||||
// Modern way of responding to webhooks
|
||||
if (isWebhookResponse(response)) {
|
||||
@@ -231,8 +235,11 @@ class WebhookRequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebhookHandlerFor(webhookManager: IWebhookManager) {
|
||||
const handler = new WebhookRequestHandler(webhookManager);
|
||||
export function createWebhookHandlerFor(
|
||||
webhookManager: IWebhookManager,
|
||||
expectedNodeType?: ExpectedWebhookNodeType,
|
||||
) {
|
||||
const handler = new WebhookRequestHandler(webhookManager, expectedNodeType);
|
||||
|
||||
return async (req: WebhookRequest | WebhookOptionsRequest, res: express.Response) => {
|
||||
const { params } = req;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IDataObject, IHttpRequestMethods } from 'n8n-workflow';
|
||||
|
||||
import type { ExpectedWebhookNodeType } from './node-type-matcher';
|
||||
|
||||
export type WebhookOptionsRequest = Request & { method: 'OPTIONS' };
|
||||
|
||||
export type WebhookRequest = Request<{ path: string }> & {
|
||||
@@ -26,7 +28,11 @@ export interface IWebhookManager {
|
||||
httpMethod: IHttpRequestMethods,
|
||||
) => Promise<WebhookAccessControlOptions | undefined>;
|
||||
|
||||
executeWebhook(req: WebhookRequest, res: Response): Promise<IWebhookResponseCallbackData>;
|
||||
executeWebhook(
|
||||
req: WebhookRequest,
|
||||
res: Response,
|
||||
expectedNodeType?: ExpectedWebhookNodeType,
|
||||
): Promise<IWebhookResponseCallbackData>;
|
||||
}
|
||||
|
||||
export interface IWebhookResponseCallbackData {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.use({ capability: { env: { TEST_ISOLATION: 'form-endpoint-isolation' } } });
|
||||
|
||||
test.describe(
|
||||
'Form Endpoint Isolation',
|
||||
{
|
||||
annotation: [{ type: 'owner', description: 'NODES' }],
|
||||
},
|
||||
() => {
|
||||
const cleanupWorkflowIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({ api }) => {
|
||||
while (cleanupWorkflowIds.length > 0) {
|
||||
try {
|
||||
// Webhook paths are non-unique in these tests, so a flaky run would otherwise
|
||||
// leave active webhooks behind and fail the next activation with a conflict.
|
||||
const workflowId = cleanupWorkflowIds.pop();
|
||||
await api.workflows.deactivate(workflowId!);
|
||||
await api.workflows.delete(workflowId!);
|
||||
} catch {
|
||||
// ignore potential errors in the cleanup process
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('form triggers are served on /form and return 404 on /webhook', async ({ api }) => {
|
||||
const webhookId = nanoid();
|
||||
const workflow: Partial<IWorkflowBase> = {
|
||||
name: `Form Endpoint Test ${nanoid()}`,
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
formTitle: 'Test Form',
|
||||
formDescription: 'This is a test form',
|
||||
formFields: {
|
||||
values: [
|
||||
{
|
||||
fieldLabel: 'Name',
|
||||
fieldType: 'text',
|
||||
requiredField: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 2.5,
|
||||
position: [0, 0],
|
||||
id: nanoid(),
|
||||
name: 'Form Trigger',
|
||||
webhookId,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const { workflowId, createdWorkflow } = await api.workflows.createWorkflowFromDefinition(
|
||||
workflow,
|
||||
{ makeUnique: false },
|
||||
);
|
||||
cleanupWorkflowIds.push(workflowId);
|
||||
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const formResponse = await api.webhooks.trigger(`/form/${webhookId}`);
|
||||
expect(formResponse.ok()).toBe(true);
|
||||
expect(formResponse.status()).toBe(200);
|
||||
|
||||
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookId}`);
|
||||
expect(webhookResponse.ok()).toBe(false);
|
||||
expect(webhookResponse.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('regular webhooks are served on /webhook and return 404 on /form', async ({ api }) => {
|
||||
const webhookPath = `webhook-test-${nanoid()}`;
|
||||
const workflow: Partial<IWorkflowBase> = {
|
||||
name: `Webhook Endpoint Test ${nanoid()}`,
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: webhookPath,
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
id: nanoid(),
|
||||
name: 'Webhook',
|
||||
webhookId: nanoid(),
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const { workflowId, createdWorkflow } = await api.workflows.createWorkflowFromDefinition(
|
||||
workflow,
|
||||
{ makeUnique: false },
|
||||
);
|
||||
cleanupWorkflowIds.push(workflowId);
|
||||
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
|
||||
expect(webhookResponse.ok()).toBe(true);
|
||||
expect(webhookResponse.status()).toBe(200);
|
||||
|
||||
const formResponse = await api.webhooks.trigger(`/form/${webhookPath}`);
|
||||
expect(formResponse.ok()).toBe(false);
|
||||
expect(formResponse.status()).toBe(404);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -29,7 +29,7 @@ test.describe(
|
||||
|
||||
// Get the MCP path from the workflow
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
|
||||
@@ -44,7 +44,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
const tools = await api.mcp.listTools(session, mcpPath);
|
||||
@@ -61,7 +61,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
const result = await api.mcp.callTool(session, mcpPath, 'echo', {
|
||||
@@ -80,7 +80,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
const deleteResponse = await api.mcp.streamableHttpDelete(session, mcpPath);
|
||||
@@ -98,7 +98,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.sseSetup(mcpPath);
|
||||
|
||||
@@ -118,7 +118,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.sseSetup(mcpPath);
|
||||
|
||||
@@ -139,7 +139,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.sseSetup(mcpPath);
|
||||
|
||||
@@ -182,7 +182,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Try without auth - should fail
|
||||
const noAuthResponse = await api.webhooks.trigger(mcpPath, {
|
||||
@@ -223,7 +223,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Try with valid auth - should succeed
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath, {
|
||||
@@ -259,7 +259,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Try without auth - should fail
|
||||
const noAuthResponse = await api.webhooks.trigger(mcpPath, {
|
||||
@@ -290,7 +290,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
const tools = await api.mcp.listTools(session, mcpPath);
|
||||
@@ -308,7 +308,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
|
||||
@@ -334,7 +334,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
|
||||
@@ -360,7 +360,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Create a fake session with an invalid session ID
|
||||
const fakeSession: McpSession = {
|
||||
@@ -382,7 +382,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Initialize and then delete session
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
@@ -398,7 +398,7 @@ test.describe(
|
||||
|
||||
test.describe('Error Handling', () => {
|
||||
test('should return 404 for non-existent endpoint', async ({ api }) => {
|
||||
const response = await api.webhooks.trigger('webhook/non-existent-mcp-endpoint-12345', {
|
||||
const response = await api.webhooks.trigger('mcp/non-existent-mcp-endpoint-12345', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: api.mcp.createMessage('initialize'),
|
||||
@@ -414,7 +414,7 @@ test.describe(
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// First establish a valid session
|
||||
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
||||
@@ -450,7 +450,7 @@ test.describe('MCP Trigger - Queue Mode', () => {
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// In SSE mode with queue mode enabled, tool calls should return 202 Accepted
|
||||
// because the execution is queued and response comes via Redis pub/sub
|
||||
@@ -494,7 +494,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Initialize session on main-1 (direct access, bypassing load balancer)
|
||||
const main1Api = await createApiForMain(0);
|
||||
@@ -527,7 +527,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Initialize session on main-1
|
||||
const main1Api = await createApiForMain(0);
|
||||
@@ -576,7 +576,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Initialize SSE session on main-1
|
||||
const main1Api = await createApiForMain(0);
|
||||
@@ -615,7 +615,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Initialize SSE session on main-1
|
||||
const main1Api = await createApiForMain(0);
|
||||
@@ -670,7 +670,7 @@ test.describe('MCP Trigger - Multi-Main', () => {
|
||||
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
||||
|
||||
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
||||
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
||||
const mcpPath = `mcp/${mcpNode?.parameters.path as string}`;
|
||||
|
||||
// Initialize SSE session on main-1
|
||||
const main1Api = await createApiForMain(0);
|
||||
|
||||
Reference in New Issue
Block a user