mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 11:35:03 +02:00
feat(Webhook Node): Add "Only Run If" option to filter requests (#28872)
This commit is contained in:
parent
bbd0500512
commit
d64aeb24b6
|
|
@ -5,6 +5,7 @@ import type {
|
|||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IRunExecutionData,
|
||||
IWebhookData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
|
|
@ -79,6 +80,51 @@ describe('WebhookContext', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('connectionInputData', () => {
|
||||
it('should expose the HTTP request as input data when there is no execution stack', () => {
|
||||
const context = new WebhookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
webhookData,
|
||||
[],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(context.connectionInputData).toEqual([
|
||||
{
|
||||
json: {
|
||||
body: { test: 'body' },
|
||||
headers: { test: 'header' },
|
||||
params: { test: 'param' },
|
||||
query: { test: 'query' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not throw and should leave input empty when the seeded execution stack has no main data', () => {
|
||||
const runExecutionDataWithEmptyStack = {
|
||||
executionData: {
|
||||
nodeExecutionStack: [{ node, data: { main: [] }, source: null }],
|
||||
},
|
||||
} as unknown as IRunExecutionData;
|
||||
|
||||
const context = new WebhookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
webhookData,
|
||||
[],
|
||||
runExecutionDataWithEmptyStack,
|
||||
);
|
||||
|
||||
expect(context.connectionInputData).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
it('should get decrypted credentials', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
|
|
|
|||
|
|
@ -47,10 +47,24 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc
|
|||
if (runExecutionData?.executionData !== undefined) {
|
||||
executionData = runExecutionData.executionData.nodeExecutionStack[0];
|
||||
if (executionData !== undefined) {
|
||||
connectionInputData = executionData.data.main[0]!;
|
||||
connectionInputData = executionData.data.main[0] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
if (executionData === undefined && additionalData.httpRequest) {
|
||||
const req = additionalData.httpRequest;
|
||||
connectionInputData = [
|
||||
{
|
||||
json: {
|
||||
body: (req.body ?? {}) as IDataObject,
|
||||
headers: req.headers,
|
||||
params: req.params as IDataObject,
|
||||
query: req.query as IDataObject,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
super(
|
||||
workflow,
|
||||
node,
|
||||
|
|
|
|||
|
|
@ -252,6 +252,21 @@ export class Webhook extends Node {
|
|||
throw error;
|
||||
}
|
||||
|
||||
const node = context.getNode();
|
||||
const rawOptions = node.parameters?.options as { onlyRunIf?: unknown } | undefined;
|
||||
const rawOnlyRunIf = rawOptions?.onlyRunIf;
|
||||
if (typeof rawOnlyRunIf === 'string' && rawOnlyRunIf.startsWith('=')) {
|
||||
try {
|
||||
const result = context.evaluateExpression(rawOnlyRunIf.slice(1), 0);
|
||||
if (!result) return {};
|
||||
} catch (error) {
|
||||
context.logger.warn(
|
||||
`Webhook "Only Run If" expression failed to evaluate; allowing request through. ${(error as Error).message}`,
|
||||
{ nodeName: node.name },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prepareOutput = setupOutputConnection(context, requestMethod, {
|
||||
jwtPayload: validationData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -298,6 +298,16 @@ export const optionsProperty: INodeProperties = {
|
|||
default: false,
|
||||
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
|
||||
},
|
||||
{
|
||||
displayName: 'Only Run If',
|
||||
name: 'onlyRunIf',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: "{{ $json.body.campaign_id === 'user-research-invite' }}",
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-json
|
||||
description:
|
||||
'Expression evaluated against the incoming request. The workflow will run only if the expression returns true. <code>$json</code> exposes the request as <code>{ body, headers, params, query }</code>. Requests that do not match receive a 200 response, without creating an execution. If the expression fails to evaluate, the request is allowed through and the error is logged.',
|
||||
},
|
||||
{
|
||||
displayName: 'IP(s) Allowlist',
|
||||
name: 'ipWhitelist',
|
||||
|
|
|
|||
|
|
@ -166,4 +166,122 @@ describe('Test Webhook Node', () => {
|
|||
expect(node.description.sensitiveOutputFields).not.toContain('headers.content-type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onlyRunIf filter', () => {
|
||||
const node = new Webhook();
|
||||
let context: ReturnType<typeof mock<IWebhookFunctions>>;
|
||||
let req: ReturnType<typeof mock<Request>>;
|
||||
let res: ReturnType<typeof mock<Response>>;
|
||||
|
||||
const setup = (
|
||||
storedOptions: Record<string, unknown>,
|
||||
runtimeOptions: Record<string, unknown> = storedOptions,
|
||||
) => {
|
||||
context = mock<IWebhookFunctions>({ nodeHelpers: mock(), logger: mock() });
|
||||
req = mock<Request>();
|
||||
res = mock<Response>();
|
||||
|
||||
context.getRequestObject.mockReturnValue(req);
|
||||
context.getResponseObject.mockReturnValue(res);
|
||||
context.getChildNodes.mockReturnValue([]);
|
||||
context.getNode.mockReturnValue({
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
name: 'Webhook',
|
||||
parameters: { options: storedOptions },
|
||||
} as any);
|
||||
context.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') return runtimeOptions;
|
||||
if (paramName === 'responseMode') return 'onReceived';
|
||||
if (paramName === 'httpMethod') return 'POST';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
req.headers = { 'content-type': 'application/json' };
|
||||
req.params = {};
|
||||
req.query = {};
|
||||
req.body = { campaign_id: 'user-research-invite' };
|
||||
Object.defineProperty(req, 'ips', { value: [], configurable: true });
|
||||
Object.defineProperty(req, 'ip', { value: '127.0.0.1', configurable: true });
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('runs the workflow when the expression evaluates truthy', async () => {
|
||||
setup({ onlyRunIf: "={{ $json.body.campaign_id === 'user-research-invite' }}" });
|
||||
context.evaluateExpression.mockReturnValue(true);
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
expect(context.evaluateExpression).toHaveBeenCalledWith(
|
||||
"{{ $json.body.campaign_id === 'user-research-invite' }}",
|
||||
0,
|
||||
);
|
||||
expect(result.workflowData).toBeDefined();
|
||||
});
|
||||
|
||||
it('skips execution when the expression evaluates falsy', async () => {
|
||||
setup({ onlyRunIf: "={{ $json.body.campaign_id === 'other' }}" });
|
||||
context.evaluateExpression.mockReturnValue(false);
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores plain-string values (non-expression)', async () => {
|
||||
setup({ onlyRunIf: "body.campaign_id === 'foo'" });
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
expect(context.evaluateExpression).not.toHaveBeenCalled();
|
||||
expect(result.workflowData).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores empty filter values', async () => {
|
||||
setup({ onlyRunIf: '' });
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
expect(context.evaluateExpression).not.toHaveBeenCalled();
|
||||
expect(result.workflowData).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores a missing filter option entirely', async () => {
|
||||
setup({});
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
expect(context.evaluateExpression).not.toHaveBeenCalled();
|
||||
expect(result.workflowData).toBeDefined();
|
||||
});
|
||||
|
||||
it('allows the request through and logs a warning when the expression throws', async () => {
|
||||
setup({ onlyRunIf: '={{ $json.body.nothing.foo === 1 }}' });
|
||||
context.evaluateExpression.mockImplementation(() => {
|
||||
throw new Error('nothing is undefined');
|
||||
});
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
expect(result.workflowData).toBeDefined();
|
||||
expect(context.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Only Run If'),
|
||||
expect.objectContaining({ nodeName: 'Webhook' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run the filter before auth/IP checks reject', async () => {
|
||||
setup({
|
||||
ipWhitelist: '10.0.0.1',
|
||||
onlyRunIf: '={{ true }}',
|
||||
});
|
||||
|
||||
const result = await node.webhook(context);
|
||||
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
expect(res.writeHead).toHaveBeenCalledWith(403);
|
||||
expect(context.evaluateExpression).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user