From bb25d4cc388a08fe90b7f504fc67edb4f667404d Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Fri, 24 Apr 2026 13:06:02 +0200 Subject: [PATCH] fix(core): Scope live and test webhook handlers to their route family (#28804) --- packages/cli/src/abstract-server.ts | 27 ++-- .../webhooks/__tests__/live-webhooks.test.ts | 142 +++++++++++++++++- .../webhooks/__tests__/test-webhooks.test.ts | 40 +++++ .../__tests__/webhook-request-handler.test.ts | 6 +- packages/cli/src/webhooks/live-webhooks.ts | 32 ++-- .../cli/src/webhooks/node-type-matcher.ts | 10 ++ packages/cli/src/webhooks/test-webhooks.ts | 36 +++-- .../src/webhooks/webhook-request-handler.ts | 15 +- packages/cli/src/webhooks/webhook.types.ts | 8 +- .../e2e/api/form-endpoint-isolation.spec.ts | 117 +++++++++++++++ .../tests/e2e/nodes/mcp-trigger.spec.ts | 46 +++--- 11 files changed, 417 insertions(+), 62 deletions(-) create mode 100644 packages/cli/src/webhooks/node-type-matcher.ts create mode 100644 packages/testing/playwright/tests/e2e/api/form-endpoint-isolation.spec.ts diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index d648204cd0f..577a248ee43 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -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 diff --git a/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts index b2bb8bddb3e..ca63c0ffe1c 100644 --- a/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts @@ -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({ + versionId: 'v1', + workflowId, + nodes: [node], + connections: {}, + }); + + const workflowEntity = mock({ + 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({ + workflowId, + node: nodeName, + webhookPath, + method: httpMethod, + isDynamic: false, + }); + + const webhookNodeType = mock({ + description: { + name: nodeTypeName, + properties: [], + webhooks: [{ nodeType: declaredNodeType, name: 'default' } as never], + }, + webhook: jest.fn(), + }); + + const webhookData = mock({ + 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({ 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(), '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(), 'webhook'), + ).rejects.toBeInstanceOf(WebhookNotFoundError); + }); + + it('executes a regular webhook on the webhook route family', async () => { + setupMocks(undefined); + + await expect( + liveWebhooks.executeWebhook(buildRequest(), mock(), '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(), '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(), '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(), '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()), + ).resolves.toBeDefined(); + }); + }); }); diff --git a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts index 1d99dadf98d..021ae8a341f 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts @@ -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({ + 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({ params: { path } }), + mock(), + '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({ + 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({ params: { path } }), + mock(), + 'form', + ); + + await expect(promise).rejects.toThrowError(WebhookNotFoundError); + }); }); describe('deactivateWebhooks()', () => { diff --git a/packages/cli/src/webhooks/__tests__/webhook-request-handler.test.ts b/packages/cli/src/webhooks/__tests__/webhook-request-handler.test.ts index e4b95bd9929..8d2a14503bb 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-request-handler.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-request-handler.test.ts @@ -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); }, diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 60d3e734095..16d23661246 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -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 { 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); diff --git a/packages/cli/src/webhooks/node-type-matcher.ts b/packages/cli/src/webhooks/node-type-matcher.ts new file mode 100644 index 00000000000..c6aa585afb2 --- /dev/null +++ b/packages/cli/src/webhooks/node-type-matcher.ts @@ -0,0 +1,10 @@ +import type { IWebhookDescription } from 'n8n-workflow'; + +export type ExpectedWebhookNodeType = NonNullable; + +export function matchesExpectedNodeType( + expected: ExpectedWebhookNodeType, + declared: IWebhookDescription['nodeType'] = 'webhook', +): boolean { + return expected === declared; +} diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index 9ecc38f4575..43752921f36 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -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 { 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); diff --git a/packages/cli/src/webhooks/webhook-request-handler.ts b/packages/cli/src/webhooks/webhook-request-handler.ts index 843f5abb1b7..f813aeaa266 100644 --- a/packages/cli/src/webhooks/webhook-request-handler.ts +++ b/packages/cli/src/webhooks/webhook-request-handler.ts @@ -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; diff --git a/packages/cli/src/webhooks/webhook.types.ts b/packages/cli/src/webhooks/webhook.types.ts index 7082468b0e1..53bc6db4103 100644 --- a/packages/cli/src/webhooks/webhook.types.ts +++ b/packages/cli/src/webhooks/webhook.types.ts @@ -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; - executeWebhook(req: WebhookRequest, res: Response): Promise; + executeWebhook( + req: WebhookRequest, + res: Response, + expectedNodeType?: ExpectedWebhookNodeType, + ): Promise; } export interface IWebhookResponseCallbackData { diff --git a/packages/testing/playwright/tests/e2e/api/form-endpoint-isolation.spec.ts b/packages/testing/playwright/tests/e2e/api/form-endpoint-isolation.spec.ts new file mode 100644 index 00000000000..0e2cf5be138 --- /dev/null +++ b/packages/testing/playwright/tests/e2e/api/form-endpoint-isolation.spec.ts @@ -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 = { + 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 = { + 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); + }); + }, +); diff --git a/packages/testing/playwright/tests/e2e/nodes/mcp-trigger.spec.ts b/packages/testing/playwright/tests/e2e/nodes/mcp-trigger.spec.ts index 4030c1de530..18fd25c9172 100644 --- a/packages/testing/playwright/tests/e2e/nodes/mcp-trigger.spec.ts +++ b/packages/testing/playwright/tests/e2e/nodes/mcp-trigger.spec.ts @@ -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);