fix(core, editor): Move single webhook trigger check to the backend (#22450)

Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
mfsiega
2025-12-04 18:20:13 +01:00
committed by GitHub
co-authored by Danny Martini
parent 5851265ded
commit 3026a813b0
14 changed files with 208 additions and 249 deletions
@@ -981,4 +981,16 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
return await qb.getMany();
}
/**
* Returns if the workflow is stored as `active`.
*
* @important Do not confuse with `ActiveWorkflows.isActive()`,
* which checks if the workflow is active in memory.
*/
async isActive(workflowId: string) {
const workflow = await this.findOne({ select: ['activeVersionId'], where: { id: workflowId } });
return !!workflow?.activeVersionId;
}
}
@@ -138,21 +138,6 @@ export class ActiveWorkflowManager {
return this.activeWorkflows.allActiveWorkflows();
}
/**
* Returns if the workflow is stored as `active`.
*
* @important Do not confuse with `ActiveWorkflows.isActive()`,
* which checks if the workflow is active in memory.
*/
async isActive(workflowId: WorkflowId) {
const workflow = await this.workflowRepository.findOne({
select: ['activeVersionId'],
where: { id: workflowId },
});
return !!workflow?.activeVersionId;
}
/**
* Register workflow-defined webhooks in the `workflow_entity` table.
*/
@@ -0,0 +1,10 @@
import { UserError } from 'n8n-workflow';
export class SingleWebhookTriggerError extends UserError {
constructor(triggerName: string) {
super(
`Because of limitations in ${triggerName}, n8n can't listen for test executions at the same time as listening for production ones. Unpublish the workflow to execute.`,
{ extra: { triggerName } },
);
}
}
@@ -149,6 +149,72 @@ describe('TestWebhooks', () => {
expect(webhookService.createWebhookIfNotExists.mock.calls[0][1].node).toBe(webhook2.node);
expect(needsWebhook).toBe(true);
});
test.each([
{ published: true, withSingleWebhookTrigger: true, shouldThrow: true },
{ published: true, withSingleWebhookTrigger: false, shouldThrow: false },
{ published: false, withSingleWebhookTrigger: true, shouldThrow: false },
{ published: false, withSingleWebhookTrigger: false, shouldThrow: false },
] satisfies Array<{
published: boolean;
withSingleWebhookTrigger: boolean;
shouldThrow: boolean;
}>)(
'handles single webhook trigger when workflowIsActive=%s',
async ({ published: workflowIsActive, withSingleWebhookTrigger, shouldThrow }) => {
const workflow = mock<Workflow>();
const regularWebhook = mock<IWebhookData>({
node: 'Webhook',
httpMethod,
path: 'regular-path',
workflowId: workflowEntity.id,
userId,
});
const telegramWebhook = mock<IWebhookData>({
node: 'Telegram Trigger',
httpMethod,
path: 'telegram-path',
workflowId: workflowEntity.id,
userId,
});
const webhookNode = mock<IWorkflowBase['nodes'][number]>({
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
});
const telegramNode = mock<IWorkflowBase['nodes'][number]>({
name: 'Telegram Trigger',
type: 'n8n-nodes-base.telegramTrigger',
});
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
jest
.spyOn(WebhookHelpers, 'getWorkflowWebhooks')
.mockReturnValue([regularWebhook, telegramWebhook]);
jest.spyOn(workflow, 'getNode').mockImplementation((name: string) => {
if (name === 'Webhook') return webhookNode;
if (name === 'Telegram Trigger' && withSingleWebhookTrigger) return telegramNode;
return null;
});
if (shouldThrow) {
const promise = testWebhooks.needsWebhook({
...args,
workflowIsActive,
});
await expect(promise).rejects.toThrow(
"Because of limitations in Telegram Trigger, n8n can't listen for test executions at the same time as listening for production ones. Unpublish the workflow to execute.",
);
} else {
const needsWebhook = await testWebhooks.needsWebhook({
...args,
workflowIsActive,
});
expect(needsWebhook).toBe(true);
}
},
);
});
describe('executeWebhook()', () => {
@@ -25,6 +25,7 @@ import type {
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';
import { WorkflowMissingIdError } from '@/errors/workflow-missing-id.error';
import { NodeTypes } from '@/node-types';
import { Push } from '@/push';
@@ -36,6 +37,12 @@ import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import type { WorkflowRequest } from '@/workflows/workflow.request';
const SINGLE_WEBHOOK_TRIGGERS = [
'n8n-nodes-base.telegramTrigger',
'n8n-nodes-base.slackTrigger',
'n8n-nodes-base.facebookLeadAdsTrigger',
];
/**
* Service for handling the execution of webhooks of manual executions
* that use the [Test URL](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/#webhook-urls).
@@ -272,6 +279,7 @@ export class TestWebhooks implements IWebhookManager {
pushRef?: string;
destinationNode?: IDestinationNode;
triggerToStartFrom?: WorkflowRequest.FullManualExecutionFromKnownTriggerPayload['triggerToStartFrom'];
workflowIsActive?: boolean;
}) {
const {
userId,
@@ -281,6 +289,7 @@ export class TestWebhooks implements IWebhookManager {
pushRef,
destinationNode,
triggerToStartFrom,
workflowIsActive,
} = options;
if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity);
@@ -310,6 +319,18 @@ export class TestWebhooks implements IWebhookManager {
return false; // no webhooks found to start a workflow
}
// Check if any webhook is a single webhook trigger and workflow is active
if (workflowIsActive) {
const singleWebhookTrigger = webhooks.find((w) =>
SINGLE_WEBHOOK_TRIGGERS.includes(workflow.getNode(w.node)?.type ?? ''),
);
if (singleWebhookTrigger) {
throw new SingleWebhookTriggerError(
workflow.getNode(singleWebhookTrigger.node)?.name ?? '',
);
}
}
const timeout = setTimeout(
async () => await this.cancelWebhook(workflow.id),
TEST_WEBHOOK_TIMEOUT,
@@ -15,6 +15,7 @@ import {
import type { IWorkflowErrorData } from '@/interfaces';
import type { NodeTypes } from '@/node-types';
import type { TestWebhooks } from '@/webhooks/test-webhooks';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import type { WorkflowRunner } from '@/workflow-runner';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
@@ -336,6 +337,66 @@ describe('WorkflowExecutionService', () => {
expect(callArgs.executionMode).toBe('manual');
expect(result).toEqual({ executionId });
});
test('should pass workflowIsActive to testWebhooks.needsWebhook', async () => {
const userId = 'user-id';
const user = mock<User>({ id: userId });
const testWebhooks = mock<TestWebhooks>();
const workflowRepositoryMock = mock<WorkflowRepository>();
const telegramTrigger: INode = {
id: '1',
typeVersion: 1,
position: [1, 2],
parameters: {},
name: 'Telegram Trigger',
type: 'n8n-nodes-base.telegramTrigger',
};
const activeWorkflowData = {
id: 'workflow-id',
name: 'Test Workflow',
active: true,
activeVersionId: 'version-123',
isArchived: false,
nodes: [telegramTrigger],
connections: {},
createdAt: new Date(),
updatedAt: new Date(),
};
workflowRepositoryMock.isActive.mockResolvedValue(true);
const service = new WorkflowExecutionService(
mock(),
mock(),
mock(),
workflowRepositoryMock,
nodeTypes,
testWebhooks,
workflowRunner,
mock(),
mock(),
mock(),
);
const runPayload: WorkflowRequest.FullManualExecutionFromKnownTriggerPayload = {
workflowData: activeWorkflowData,
triggerToStartFrom: { name: telegramTrigger.name },
};
testWebhooks.needsWebhook.mockRejectedValue(
new Error(
'Cannot test webhook for node "Telegram Trigger" while workflow is active. Please deactivate the workflow first.',
),
);
await expect(service.executeManually(runPayload, user)).rejects.toThrow(
'Cannot test webhook for node "Telegram Trigger" while workflow is active. Please deactivate the workflow first.',
);
expect(testWebhooks.needsWebhook).toHaveBeenCalledWith(
expect.objectContaining({
workflowIsActive: true,
}),
);
});
});
describe('selectPinnedTrigger()', () => {
@@ -101,6 +101,9 @@ export class WorkflowExecutionService {
user: User,
pushRef?: string,
): Promise<{ executionId: string } | { waitingForWebhook: boolean }> {
// Check whether this workflow is active.
const workflowIsActive = await this.workflowRepository.isActive(payload.workflowData.id);
// For manual testing always set to not active
payload.workflowData.active = false;
payload.workflowData.activeVersionId = null;
@@ -146,6 +149,7 @@ export class WorkflowExecutionService {
pushRef,
triggerToStartFrom: payload.triggerToStartFrom,
destinationNode: payload.destinationNode,
workflowIsActive,
}))
) {
return { waitingForWebhook: true };
@@ -182,6 +186,7 @@ export class WorkflowExecutionService {
}),
pushRef,
destinationNode: payload.destinationNode,
workflowIsActive,
}))
) {
return { waitingForWebhook: true };
@@ -123,24 +123,6 @@ describe('init()', () => {
});
});
describe('isActive()', () => {
it('should return `true` for active workflow in storage', async () => {
const dbWorkflow = await createActiveWorkflow();
await activeWorkflowManager.init();
await expect(activeWorkflowManager.isActive(dbWorkflow.id)).resolves.toBe(true);
});
it('should return `false` for inactive workflow in storage', async () => {
const dbWorkflow = await createInactiveWorkflow();
await activeWorkflowManager.init();
await expect(activeWorkflowManager.isActive(dbWorkflow.id)).resolves.toBe(false);
});
});
describe('add()', () => {
describe('in single-main mode', () => {
test.each(['activate', 'update'])(
@@ -6,6 +6,7 @@ import {
createWorkflow,
testDb,
getWorkflowById,
setActiveVersion,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { WorkflowRepository, WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db';
@@ -262,6 +263,25 @@ describe('WorkflowRepository', () => {
});
});
describe('isActive()', () => {
it('should return `true` for active workflow in storage', async () => {
const workflowRepository = Container.get(WorkflowRepository);
const workflow = await createWorkflowWithHistory();
await setActiveVersion(workflow.id, workflow.versionId);
await expect(workflowRepository.isActive(workflow.id)).resolves.toBe(true);
});
it('should return `false` for inactive workflow in storage', async () => {
const workflowRepository = Container.get(WorkflowRepository);
const workflow = await createWorkflowWithHistory();
await expect(workflowRepository.isActive(workflow.id)).resolves.toBe(false);
});
});
// NOTE: these tests use the workflow dependency repository, which is not enabled
// on legacy Sqlite.
const globalConfig = Container.get(GlobalConfig);
@@ -9,7 +9,12 @@ import {
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db';
import { ProjectRepository, WorkflowHistoryRepository, SharedWorkflowRepository } from '@n8n/db';
import {
WorkflowRepository,
ProjectRepository,
WorkflowHistoryRepository,
SharedWorkflowRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { Not } from '@n8n/typeorm';
import { InstanceSettings } from 'n8n-core';
@@ -37,6 +42,7 @@ let memberPersonalProject: Project;
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
let activeWorkflowManager: ActiveWorkflowManager;
let workflowRepository: WorkflowRepository;
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
const license = testServer.license;
@@ -61,6 +67,7 @@ beforeAll(async () => {
await utils.initNodeTypes();
activeWorkflowManager = Container.get(ActiveWorkflowManager);
workflowRepository = Container.get(WorkflowRepository);
await activeWorkflowManager.init();
});
@@ -898,7 +905,7 @@ describe('POST /workflows/:id/activate', () => {
expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId);
// check whether the workflow is on the active workflow runner
expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true);
expect(await workflowRepository.isActive(workflow.id)).toBe(true);
});
test('should set activeVersionId when activating workflow', async () => {
@@ -974,7 +981,7 @@ describe('POST /workflows/:id/activate', () => {
expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId);
// check whether the workflow is on the active workflow runner
expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true);
expect(await workflowRepository.isActive(workflow.id)).toBe(true);
});
});
@@ -1039,7 +1046,7 @@ describe('POST /workflows/:id/deactivate', () => {
// check whether the workflow is deactivated in the database
expect(sharedWorkflow?.workflow.activeVersionId).toBeNull();
expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false);
expect(await workflowRepository.isActive(workflow.id)).toBe(false);
});
test('should clear activeVersionId when deactivating workflow', async () => {
@@ -1126,7 +1133,7 @@ describe('POST /workflows/:id/deactivate', () => {
expect(sharedWorkflow?.workflow.activeVersionId).toBeNull();
expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false);
expect(await workflowRepository.isActive(workflow.id)).toBe(false);
});
});
@@ -3083,7 +3083,6 @@
"workflowPreview.showError.previewError.title": "Preview error",
"workflowRun.noActiveConnectionToTheServer": "Lost connection to the server",
"workflowRun.showError.deactivate": "Deactivate workflow to execute",
"workflowRun.showError.productionActive": "Because of limitations in {nodeName}, n8n can't listen for test executions at the same time as listening for production ones",
"workflowRun.showError.title": "Problem running workflow",
"workflowRun.showError.payloadTooLarge": "Please execute the whole workflow, rather than just the node. (Existing execution data is too large.)",
"workflowRun.showError.resolveOutstandingIssues": "Please resolve outstanding issues before you activate it",
@@ -25,14 +25,11 @@ import type { WorkflowData } from '@n8n/rest-api-client/api/workflows';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useToast } from './useToast';
import { useI18n } from '@n8n/i18n';
import { captor, mock } from 'vitest-mock-extended';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { waitFor } from '@testing-library/vue';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
import { SLACK_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/app/constants';
vi.mock('@/app/stores/workflows.store', () => {
const storeState: Partial<ReturnType<typeof useWorkflowsStore>> & {
@@ -250,177 +247,6 @@ describe('useRunWorkflow({ router })', () => {
});
describe('runWorkflow()', () => {
it('should prevent execution and show error message when workflow is active with single webhook trigger', async () => {
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
const toast = useToast();
const i18n = useI18n();
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(workflowsStore).isWorkflowActive = true;
vi.mocked(useWorkflowHelpers()).getWorkflowDataToSave.mockResolvedValue({
nodes: [
{
name: 'Slack',
type: SLACK_TRIGGER_NODE_TYPE,
disabled: false,
},
],
} as unknown as WorkflowData);
const result = await runWorkflow({});
expect(result).toBeUndefined();
expect(toast.showMessage).toHaveBeenCalledWith({
title: i18n.baseText('workflowRun.showError.deactivate'),
message: i18n.baseText('workflowRun.showError.productionActive', {
interpolate: { nodeName: 'Webhook' },
}),
type: 'error',
});
});
it('should execute the workflow if the single webhook trigger has pin data', async () => {
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
const toast = useToast();
const i18n = useI18n();
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(workflowsStore).isWorkflowActive = true;
vi.mocked(useWorkflowHelpers()).getWorkflowDataToSave.mockResolvedValue({
nodes: [
{
name: 'Slack',
type: SLACK_TRIGGER_NODE_TYPE,
disabled: false,
},
],
pinData: {
Slack: [{ json: { value: 'data2' } }],
},
} as unknown as WorkflowData);
const mockExecutionResponse = { executionId: '123' };
vi.mocked(uiStore).activeActions = [''];
vi.mocked(workflowsStore).workflowObject = {
name: 'Test Workflow',
} as unknown as Workflow;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = true;
vi.mocked(workflowsStore).getWorkflowRunData = {
NodeName: [],
};
const result = await runWorkflow({});
expect(result).toEqual(mockExecutionResponse);
expect(toast.showMessage).not.toHaveBeenCalledWith({
title: i18n.baseText('workflowRun.showError.deactivate'),
message: i18n.baseText('workflowRun.showError.productionActive', {
interpolate: { nodeName: 'Webhook' },
}),
type: 'error',
});
});
it('should execute the workflow if there is a single webhook trigger, but another trigger is chosen', async () => {
// ARRANGE
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
const toast = useToast();
const i18n = useI18n();
const { runWorkflow } = useRunWorkflow({ router });
const mockExecutionResponse = { executionId: '123' };
const triggerNode = 'Manual';
vi.mocked(workflowsStore).isWorkflowActive = true;
vi.mocked(useWorkflowHelpers()).getWorkflowDataToSave.mockResolvedValue({
nodes: [
{
name: 'Slack',
type: SLACK_TRIGGER_NODE_TYPE,
disabled: false,
},
{
name: triggerNode,
type: MANUAL_TRIGGER_NODE_TYPE,
disabled: false,
},
],
} as unknown as WorkflowData);
vi.mocked(uiStore).activeActions = [''];
vi.mocked(workflowsStore).workflowObject = {
name: 'Test Workflow',
} as unknown as Workflow;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = true;
vi.mocked(workflowsStore).getWorkflowRunData = { NodeName: [] };
// ACT
const result = await runWorkflow({ triggerNode });
// ASSERT
expect(result).toEqual(mockExecutionResponse);
expect(toast.showMessage).not.toHaveBeenCalledWith({
title: i18n.baseText('workflowRun.showError.deactivate'),
message: i18n.baseText('workflowRun.showError.productionActive', {
interpolate: { nodeName: 'Webhook' },
}),
type: 'error',
});
});
it('should prevent execution and show error message when workflow is active with multiple triggers and a single webhook trigger is chosen', async () => {
// ARRANGE
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
const toast = useToast();
const i18n = useI18n();
const { runWorkflow } = useRunWorkflow({ router });
const mockExecutionResponse = { executionId: '123' };
const triggerNode = 'Slack';
vi.mocked(workflowsStore).isWorkflowActive = true;
vi.mocked(useWorkflowHelpers()).getWorkflowDataToSave.mockResolvedValue({
nodes: [
{
name: triggerNode,
type: SLACK_TRIGGER_NODE_TYPE,
disabled: false,
},
{
name: 'Manual',
type: MANUAL_TRIGGER_NODE_TYPE,
disabled: false,
},
],
} as unknown as WorkflowData);
vi.mocked(uiStore).activeActions = [''];
vi.mocked(workflowsStore).workflowObject = {
name: 'Test Workflow',
} as unknown as Workflow;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = true;
vi.mocked(workflowsStore).getWorkflowRunData = { NodeName: [] };
// ACT
const result = await runWorkflow({ triggerNode });
// ASSERT
expect(result).toBeUndefined();
expect(toast.showMessage).toHaveBeenCalledWith({
title: i18n.baseText('workflowRun.showError.deactivate'),
message: i18n.baseText('workflowRun.showError.productionActive', {
interpolate: { nodeName: 'Webhook' },
}),
type: 'error',
});
});
it('should return undefined if UI action "workflowRunning" is active', async () => {
const { runWorkflow } = useRunWorkflow({ router });
workflowState.setActiveExecutionId('123');
@@ -22,11 +22,7 @@ import { retry } from '@n8n/utils/retry';
import { useToast } from '@/app/composables/useToast';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import {
CHAT_TRIGGER_NODE_TYPE,
IN_PROGRESS_EXECUTION_ID,
SINGLE_WEBHOOK_TRIGGERS,
} from '@/app/constants';
import { CHAT_TRIGGER_NODE_TYPE, IN_PROGRESS_EXECUTION_ID } from '@/app/constants';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@@ -287,31 +283,6 @@ export function useRunWorkflow(useRunWorkflowOpts: {
return true;
});
const singleWebhookTrigger =
options.triggerNode === undefined
? // if there is no chosen trigger we check all triggers
triggers.find((node) => SINGLE_WEBHOOK_TRIGGERS.includes(node.type))
: // if there is a chosen trigger we check this one only
workflowData.nodes.find(
(node) =>
node.name === options.triggerNode && SINGLE_WEBHOOK_TRIGGERS.includes(node.type),
);
if (
singleWebhookTrigger &&
workflowsStore.isWorkflowActive &&
!workflowData.pinData?.[singleWebhookTrigger.name]
) {
toast.showMessage({
title: i18n.baseText('workflowRun.showError.deactivate'),
message: i18n.baseText('workflowRun.showError.productionActive', {
interpolate: { nodeName: singleWebhookTrigger.name },
}),
type: 'error',
});
return undefined;
}
const startRunData: IStartRunData = {
workflowData,
runData: isPartialExecution
@@ -143,12 +143,6 @@ export const OPEN_URL_PANEL_TRIGGER_NODE_TYPES = [
MCP_TRIGGER_NODE_TYPE,
];
export const SINGLE_WEBHOOK_TRIGGERS = [
TELEGRAM_TRIGGER_NODE_TYPE,
SLACK_TRIGGER_NODE_TYPE,
FACEBOOK_LEAD_ADS_TRIGGER_NODE_TYPE,
];
export const LIST_LIKE_NODE_OPERATIONS = ['getAll', 'getMany', 'read', 'search'];
export const PRODUCTION_ONLY_TRIGGER_NODE_TYPES = [CHAT_TRIGGER_NODE_TYPE];