n8n/packages/cli/src/TestWebhooks.ts
Iván Ovejero c08c5cc37b
refactor(core): Switch plain errors in cli to ApplicationError (#7857)
Ensure all errors in `cli` are `ApplicationError` or children of it and
contain no variables in the message, to continue normalizing all the
errors we report to Sentry

Follow-up to: https://github.com/n8n-io/n8n/pull/7839
2023-11-29 12:25:10 +01:00

304 lines
8.4 KiB
TypeScript

import type express from 'express';
import { Service } from 'typedi';
import {
type IWebhookData,
type IWorkflowExecuteAdditionalData,
type IHttpRequestMethods,
type Workflow,
type WorkflowActivateMode,
type WorkflowExecuteMode,
ApplicationError,
} from 'n8n-workflow';
import { ActiveWebhooks } from '@/ActiveWebhooks';
import type {
IResponseCallbackData,
IWebhookManager,
IWorkflowDb,
WebhookAccessControlOptions,
WebhookRequest,
} from '@/Interfaces';
import { Push } from '@/push';
import { NodeTypes } from '@/NodeTypes';
import * as WebhookHelpers from '@/WebhookHelpers';
import { webhookNotFoundErrorMessage } from './utils';
import { NotFoundError } from './errors/response-errors/not-found.error';
const WEBHOOK_TEST_UNREGISTERED_HINT =
"Click the 'Execute workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)";
@Service()
export class TestWebhooks implements IWebhookManager {
private testWebhookData: {
[key: string]: {
sessionId?: string;
timeout: NodeJS.Timeout;
workflowData: IWorkflowDb;
workflow: Workflow;
destinationNode?: string;
};
} = {};
constructor(
private readonly activeWebhooks: ActiveWebhooks,
private readonly push: Push,
private readonly nodeTypes: NodeTypes,
) {
activeWebhooks.testWebhooks = true;
}
/**
* Executes a test-webhook and returns the data. It also makes sure that the
* data gets additionally send to the UI. After the request got handled it
* automatically remove the test-webhook.
*/
async executeWebhook(
request: WebhookRequest,
response: express.Response,
): Promise<IResponseCallbackData> {
const httpMethod = request.method;
let path = request.params.path;
// Reset request parameters
request.params = {} as WebhookRequest['params'];
// Remove trailing slash
if (path.endsWith('/')) {
path = path.slice(0, -1);
}
const { activeWebhooks, push, testWebhookData } = this;
let webhookData: IWebhookData | undefined = activeWebhooks.get(httpMethod, path);
// check if path is dynamic
if (webhookData === undefined) {
const pathElements = path.split('/');
const webhookId = pathElements.shift();
webhookData = activeWebhooks.get(httpMethod, pathElements.join('/'), webhookId);
if (webhookData === undefined) {
// The requested webhook is not registered
const methods = await this.getWebhookMethods(path);
throw new NotFoundError(
webhookNotFoundErrorMessage(path, httpMethod, methods),
WEBHOOK_TEST_UNREGISTERED_HINT,
);
}
path = webhookData.path;
// extracting params from path
path.split('/').forEach((ele, index) => {
if (ele.startsWith(':')) {
// write params to req.params
// @ts-ignore
request.params[ele.slice(1)] = pathElements[index];
}
});
}
const { workflowId } = webhookData;
const webhookKey = `${activeWebhooks.getWebhookKey(
webhookData.httpMethod,
webhookData.path,
webhookData.webhookId,
)}|${workflowId}`;
// TODO: Clean that duplication up one day and improve code generally
if (testWebhookData[webhookKey] === undefined) {
// The requested webhook is not registered
const methods = await this.getWebhookMethods(path);
throw new NotFoundError(
webhookNotFoundErrorMessage(path, httpMethod, methods),
WEBHOOK_TEST_UNREGISTERED_HINT,
);
}
const { destinationNode, sessionId, workflow, workflowData, timeout } =
testWebhookData[webhookKey];
// 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);
if (workflowStartNode === null) {
throw new NotFoundError('Could not find node to process webhook.');
}
return new Promise(async (resolve, reject) => {
try {
const executionMode = 'manual';
const executionId = await WebhookHelpers.executeWebhook(
workflow,
webhookData!,
workflowData,
workflowStartNode,
executionMode,
sessionId,
undefined,
undefined,
request,
response,
(error: Error | null, data: IResponseCallbackData) => {
if (error !== null) reject(error);
else resolve(data);
},
destinationNode,
);
// The workflow did not run as the request was probably setup related
// or a ping so do not resolve the promise and wait for the real webhook
// request instead.
if (executionId === undefined) return;
// Inform editor-ui that webhook got received
if (sessionId !== undefined) {
push.send('testWebhookReceived', { workflowId, executionId }, sessionId);
}
} catch {}
// Delete webhook also if an error is thrown
if (timeout) clearTimeout(timeout);
delete testWebhookData[webhookKey];
await activeWebhooks.removeWorkflow(workflow);
});
}
async getWebhookMethods(path: string): Promise<IHttpRequestMethods[]> {
const webhookMethods = this.activeWebhooks.getWebhookMethods(path);
if (!webhookMethods.length) {
// The requested webhook is not registered
throw new NotFoundError(webhookNotFoundErrorMessage(path), WEBHOOK_TEST_UNREGISTERED_HINT);
}
return webhookMethods;
}
async findAccessControlOptions(path: string, httpMethod: IHttpRequestMethods) {
const webhookKey = Object.keys(this.testWebhookData).find(
(key) => key.includes(path) && key.startsWith(httpMethod),
);
if (!webhookKey) return;
const { workflow } = this.testWebhookData[webhookKey];
const webhookNode = Object.values(workflow.nodes).find(
({ type, parameters, typeVersion }) =>
parameters?.path === path &&
(parameters?.httpMethod ?? 'GET') === httpMethod &&
'webhook' in this.nodeTypes.getByNameAndVersion(type, typeVersion),
);
return webhookNode?.parameters?.options as WebhookAccessControlOptions;
}
/**
* Checks if it has to wait for webhook data to execute the workflow.
* If yes it waits for it and resolves with the result of the workflow if not it simply resolves with undefined
*/
async needsWebhookData(
workflowData: IWorkflowDb,
workflow: Workflow,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
activation: WorkflowActivateMode,
sessionId?: string,
destinationNode?: string,
): Promise<boolean> {
const webhooks = WebhookHelpers.getWorkflowWebhooks(
workflow,
additionalData,
destinationNode,
true,
);
if (!webhooks.find((webhook) => webhook.webhookDescription.restartWebhook !== true)) {
// No webhooks found to start a workflow
return false;
}
if (workflow.id === undefined) {
throw new ApplicationError(
'Webhooks can only be added for saved workflows as an ID is needed',
);
}
// Remove test-webhooks automatically if they do not get called (after 120 seconds)
const timeout = setTimeout(() => {
this.cancelTestWebhook(workflowData.id);
}, 120000);
const { activeWebhooks, testWebhookData } = this;
let key: string;
const activatedKey: string[] = [];
for (const webhookData of webhooks) {
key = `${activeWebhooks.getWebhookKey(
webhookData.httpMethod,
webhookData.path,
webhookData.webhookId,
)}|${workflowData.id}`;
activatedKey.push(key);
testWebhookData[key] = {
sessionId,
timeout,
workflow,
workflowData,
destinationNode,
};
try {
await activeWebhooks.add(workflow, webhookData, mode, activation);
} catch (error) {
activatedKey.forEach((deleteKey) => delete testWebhookData[deleteKey]);
await activeWebhooks.removeWorkflow(workflow);
throw error;
}
}
return true;
}
/**
* Removes a test webhook of the workflow with the given id
*
*/
cancelTestWebhook(workflowId: string): boolean {
let foundWebhook = false;
const { activeWebhooks, push, testWebhookData } = this;
for (const webhookKey of Object.keys(testWebhookData)) {
const { sessionId, timeout, workflow, workflowData } = testWebhookData[webhookKey];
if (workflowData.id !== workflowId) {
continue;
}
clearTimeout(timeout);
// Inform editor-ui that webhook got received
if (sessionId !== undefined) {
try {
push.send('testWebhookDeleted', { workflowId }, sessionId);
} catch {
// Could not inform editor, probably is not connected anymore. So simply go on.
}
}
// Remove the webhook
delete testWebhookData[webhookKey];
if (!foundWebhook) {
// As it removes all webhooks of the workflow execute only once
void activeWebhooks.removeWorkflow(workflow);
}
foundWebhook = true;
}
return foundWebhook;
}
}