mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
refactor(core): Improve test-webhooks (no-changelog) (#8069)
Remove duplication, improve readability, and expand tests for `TestWebhooks.ts` - in anticipation for storing test webhooks in Redis. --------- Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
parent
38d1336fa7
commit
9dc491c3a5
|
@ -1,205 +0,0 @@
|
||||||
import { Service } from 'typedi';
|
|
||||||
import type {
|
|
||||||
IWebhookData,
|
|
||||||
IHttpRequestMethods,
|
|
||||||
Workflow,
|
|
||||||
WorkflowActivateMode,
|
|
||||||
WorkflowExecuteMode,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { ApplicationError, WebhookPathTakenError } from 'n8n-workflow';
|
|
||||||
import * as NodeExecuteFunctions from 'n8n-core';
|
|
||||||
|
|
||||||
@Service()
|
|
||||||
export class ActiveWebhooks {
|
|
||||||
private workflowWebhooks: {
|
|
||||||
[key: string]: IWebhookData[];
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
private webhookUrls: {
|
|
||||||
[key: string]: IWebhookData[];
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
testWebhooks = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new webhook
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
async add(
|
|
||||||
workflow: Workflow,
|
|
||||||
webhookData: IWebhookData,
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
activation: WorkflowActivateMode,
|
|
||||||
): Promise<void> {
|
|
||||||
if (workflow.id === undefined) {
|
|
||||||
throw new ApplicationError(
|
|
||||||
'Webhooks can only be added for saved workflows as an ID is needed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (webhookData.path.endsWith('/')) {
|
|
||||||
webhookData.path = webhookData.path.slice(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhookKey = this.getWebhookKey(
|
|
||||||
webhookData.httpMethod,
|
|
||||||
webhookData.path,
|
|
||||||
webhookData.webhookId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// check that there is not a webhook already registered with that path/method
|
|
||||||
if (this.webhookUrls[webhookKey] && !webhookData.webhookId) {
|
|
||||||
throw new WebhookPathTakenError(webhookData.node);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.workflowWebhooks[webhookData.workflowId] === undefined) {
|
|
||||||
this.workflowWebhooks[webhookData.workflowId] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the webhook available directly because sometimes to create it successfully
|
|
||||||
// it gets called
|
|
||||||
if (!this.webhookUrls[webhookKey]) {
|
|
||||||
this.webhookUrls[webhookKey] = [];
|
|
||||||
}
|
|
||||||
this.webhookUrls[webhookKey].push(webhookData);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await workflow.createWebhookIfNotExists(
|
|
||||||
webhookData,
|
|
||||||
NodeExecuteFunctions,
|
|
||||||
mode,
|
|
||||||
activation,
|
|
||||||
this.testWebhooks,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// If there was a problem unregister the webhook again
|
|
||||||
if (this.webhookUrls[webhookKey].length <= 1) {
|
|
||||||
delete this.webhookUrls[webhookKey];
|
|
||||||
} else {
|
|
||||||
this.webhookUrls[webhookKey] = this.webhookUrls[webhookKey].filter(
|
|
||||||
(webhook) => webhook.path !== webhookData.path,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.workflowWebhooks[webhookData.workflowId].push(webhookData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns webhookData if a webhook with matches is currently registered
|
|
||||||
*
|
|
||||||
* @param {(string | undefined)} webhookId
|
|
||||||
*/
|
|
||||||
get(httpMethod: IHttpRequestMethods, path: string, webhookId?: string): IWebhookData | undefined {
|
|
||||||
const webhookKey = this.getWebhookKey(httpMethod, path, webhookId);
|
|
||||||
if (this.webhookUrls[webhookKey] === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let webhook: IWebhookData | undefined;
|
|
||||||
let maxMatches = 0;
|
|
||||||
const pathElementsSet = new Set(path.split('/'));
|
|
||||||
// check if static elements match in path
|
|
||||||
// if more results have been returned choose the one with the most static-route matches
|
|
||||||
this.webhookUrls[webhookKey].forEach((dynamicWebhook) => {
|
|
||||||
const staticElements = dynamicWebhook.path.split('/').filter((ele) => !ele.startsWith(':'));
|
|
||||||
const allStaticExist = staticElements.every((staticEle) => pathElementsSet.has(staticEle));
|
|
||||||
|
|
||||||
if (allStaticExist && staticElements.length > maxMatches) {
|
|
||||||
maxMatches = staticElements.length;
|
|
||||||
webhook = dynamicWebhook;
|
|
||||||
}
|
|
||||||
// handle routes with no static elements
|
|
||||||
else if (staticElements.length === 0 && !webhook) {
|
|
||||||
webhook = dynamicWebhook;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return webhook;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all request methods associated with a single webhook
|
|
||||||
*/
|
|
||||||
getWebhookMethods(path: string): IHttpRequestMethods[] {
|
|
||||||
return Object.keys(this.webhookUrls)
|
|
||||||
.filter((key) => key.includes(path))
|
|
||||||
.map((key) => key.split('|')[0] as IHttpRequestMethods);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ids of all the workflows which have active webhooks
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
getWorkflowIds(): string[] {
|
|
||||||
return Object.keys(this.workflowWebhooks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns key to uniquely identify a webhook
|
|
||||||
*
|
|
||||||
* @param {(string | undefined)} webhookId
|
|
||||||
*/
|
|
||||||
getWebhookKey(httpMethod: IHttpRequestMethods, path: string, webhookId?: string): string {
|
|
||||||
if (webhookId) {
|
|
||||||
if (path.startsWith(webhookId)) {
|
|
||||||
const cutFromIndex = path.indexOf('/') + 1;
|
|
||||||
|
|
||||||
path = path.slice(cutFromIndex);
|
|
||||||
}
|
|
||||||
return `${httpMethod}|${webhookId}|${path.split('/').length}`;
|
|
||||||
}
|
|
||||||
return `${httpMethod}|${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes all webhooks of a workflow
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
async removeWorkflow(workflow: Workflow): Promise<boolean> {
|
|
||||||
const workflowId = workflow.id;
|
|
||||||
|
|
||||||
if (this.workflowWebhooks[workflowId] === undefined) {
|
|
||||||
// If it did not exist then there is nothing to remove
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhooks = this.workflowWebhooks[workflowId];
|
|
||||||
|
|
||||||
const mode = 'internal';
|
|
||||||
|
|
||||||
// Go through all the registered webhooks of the workflow and remove them
|
|
||||||
|
|
||||||
for (const webhookData of webhooks) {
|
|
||||||
await workflow.deleteWebhook(
|
|
||||||
webhookData,
|
|
||||||
NodeExecuteFunctions,
|
|
||||||
mode,
|
|
||||||
'update',
|
|
||||||
this.testWebhooks,
|
|
||||||
);
|
|
||||||
|
|
||||||
delete this.webhookUrls[
|
|
||||||
this.getWebhookKey(webhookData.httpMethod, webhookData.path, webhookData.webhookId)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove also the workflow-webhook entry
|
|
||||||
delete this.workflowWebhooks[workflowId];
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes all the webhooks of the given workflows
|
|
||||||
*/
|
|
||||||
async removeAll(workflows: Workflow[]): Promise<void> {
|
|
||||||
const removePromises = [];
|
|
||||||
|
|
||||||
for (const workflow of workflows) {
|
|
||||||
removePromises.push(this.removeWorkflow(workflow));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(removePromises);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -60,7 +60,7 @@ import { WorkflowRunner } from '@/WorkflowRunner';
|
||||||
import { ExternalHooks } from '@/ExternalHooks';
|
import { ExternalHooks } from '@/ExternalHooks';
|
||||||
import { whereClause } from './UserManagement/UserManagementHelper';
|
import { whereClause } from './UserManagement/UserManagementHelper';
|
||||||
import { WorkflowService } from './workflows/workflow.service';
|
import { WorkflowService } from './workflows/workflow.service';
|
||||||
import { webhookNotFoundErrorMessage } from './utils';
|
import { WebhookNotFoundError } from './errors/response-errors/webhook-not-found.error';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import { WebhookService } from './services/webhook.service';
|
import { WebhookService } from './services/webhook.service';
|
||||||
import { Logger } from './Logger';
|
import { Logger } from './Logger';
|
||||||
|
@ -71,9 +71,6 @@ import { ActivationErrorsService } from '@/ActivationErrors.service';
|
||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
import { NotFoundError } from './errors/response-errors/not-found.error';
|
import { NotFoundError } from './errors/response-errors/not-found.error';
|
||||||
|
|
||||||
const WEBHOOK_PROD_UNREGISTERED_HINT =
|
|
||||||
"The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)";
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class ActiveWorkflowRunner implements IWebhookManager {
|
export class ActiveWorkflowRunner implements IWebhookManager {
|
||||||
activeWorkflows = new ActiveWorkflows();
|
activeWorkflows = new ActiveWorkflows();
|
||||||
|
@ -256,10 +253,7 @@ export class ActiveWorkflowRunner implements IWebhookManager {
|
||||||
|
|
||||||
const webhook = await this.webhookService.findWebhook(httpMethod, path);
|
const webhook = await this.webhookService.findWebhook(httpMethod, path);
|
||||||
if (webhook === null) {
|
if (webhook === null) {
|
||||||
throw new NotFoundError(
|
throw new WebhookNotFoundError({ path, httpMethod }, { hint: 'production' });
|
||||||
webhookNotFoundErrorMessage(path, httpMethod),
|
|
||||||
WEBHOOK_PROD_UNREGISTERED_HINT,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return webhook;
|
return webhook;
|
||||||
|
@ -383,7 +377,6 @@ export class ActiveWorkflowRunner implements IWebhookManager {
|
||||||
NodeExecuteFunctions,
|
NodeExecuteFunctions,
|
||||||
mode,
|
mode,
|
||||||
activation,
|
activation,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (activation === 'init' && error.name === 'QueryFailedError') {
|
if (activation === 'init' && error.name === 'QueryFailedError') {
|
||||||
|
@ -455,7 +448,7 @@ export class ActiveWorkflowRunner implements IWebhookManager {
|
||||||
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true);
|
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true);
|
||||||
|
|
||||||
for (const webhookData of webhooks) {
|
for (const webhookData of webhooks) {
|
||||||
await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update', false);
|
await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Container.get(WorkflowService).saveStaticData(workflow);
|
await Container.get(WorkflowService).saveStaticData(workflow);
|
||||||
|
|
|
@ -261,7 +261,10 @@ export interface IExternalHooksClass {
|
||||||
|
|
||||||
export type WebhookCORSRequest = Request & { method: 'OPTIONS' };
|
export type WebhookCORSRequest = Request & { method: 'OPTIONS' };
|
||||||
|
|
||||||
export type WebhookRequest = Request<{ path: string }> & { method: IHttpRequestMethods };
|
export type WebhookRequest = Request<{ path: string }> & {
|
||||||
|
method: IHttpRequestMethods;
|
||||||
|
params: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type WaitingWebhookRequest = WebhookRequest & {
|
export type WaitingWebhookRequest = WebhookRequest & {
|
||||||
params: WebhookRequest['path'] & { suffix?: string };
|
params: WebhookRequest['path'] & { suffix?: string };
|
||||||
|
@ -874,3 +877,11 @@ export abstract class SecretsProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type N8nInstanceType = 'main' | 'webhook' | 'worker';
|
export type N8nInstanceType = 'main' | 'webhook' | 'worker';
|
||||||
|
|
||||||
|
export type RegisteredWebhook = {
|
||||||
|
sessionId?: string;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
workflowEntity: IWorkflowDb;
|
||||||
|
workflow: Workflow;
|
||||||
|
destinationNode?: string;
|
||||||
|
};
|
||||||
|
|
|
@ -8,45 +8,38 @@ import {
|
||||||
type Workflow,
|
type Workflow,
|
||||||
type WorkflowActivateMode,
|
type WorkflowActivateMode,
|
||||||
type WorkflowExecuteMode,
|
type WorkflowExecuteMode,
|
||||||
ApplicationError,
|
WebhookPathTakenError,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { ActiveWebhooks } from '@/ActiveWebhooks';
|
|
||||||
import type {
|
import type {
|
||||||
IResponseCallbackData,
|
IResponseCallbackData,
|
||||||
IWebhookManager,
|
IWebhookManager,
|
||||||
IWorkflowDb,
|
IWorkflowDb,
|
||||||
|
RegisteredWebhook,
|
||||||
WebhookAccessControlOptions,
|
WebhookAccessControlOptions,
|
||||||
WebhookRequest,
|
WebhookRequest,
|
||||||
} from '@/Interfaces';
|
} from '@/Interfaces';
|
||||||
import { Push } from '@/push';
|
import { Push } from '@/push';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import * as WebhookHelpers from '@/WebhookHelpers';
|
import * as WebhookHelpers from '@/WebhookHelpers';
|
||||||
import { webhookNotFoundErrorMessage } from './utils';
|
|
||||||
import { NotFoundError } from './errors/response-errors/not-found.error';
|
import { NotFoundError } from './errors/response-errors/not-found.error';
|
||||||
|
import { TIME } from './constants';
|
||||||
const WEBHOOK_TEST_UNREGISTERED_HINT =
|
import { WorkflowMissingIdError } from './errors/workflow-missing-id.error';
|
||||||
"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)";
|
import { WebhookNotFoundError } from './errors/response-errors/webhook-not-found.error';
|
||||||
|
import * as NodeExecuteFunctions from 'n8n-core';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class TestWebhooks implements IWebhookManager {
|
export class TestWebhooks implements IWebhookManager {
|
||||||
private testWebhookData: {
|
|
||||||
[key: string]: {
|
|
||||||
sessionId?: string;
|
|
||||||
timeout: NodeJS.Timeout;
|
|
||||||
workflowData: IWorkflowDb;
|
|
||||||
workflow: Workflow;
|
|
||||||
destinationNode?: string;
|
|
||||||
};
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly activeWebhooks: ActiveWebhooks,
|
|
||||||
private readonly push: Push,
|
private readonly push: Push,
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
) {
|
) {}
|
||||||
activeWebhooks.testWebhooks = true;
|
|
||||||
}
|
private registeredWebhooks: { [webhookKey: string]: RegisteredWebhook } = {};
|
||||||
|
|
||||||
|
private workflowWebhooks: { [workflowId: string]: IWebhookData[] } = {};
|
||||||
|
|
||||||
|
private webhookUrls: { [webhookUrl: string]: IWebhookData[] } = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a test-webhook and returns the data. It also makes sure that the
|
* Executes a test-webhook and returns the data. It also makes sure that the
|
||||||
|
@ -58,69 +51,57 @@ export class TestWebhooks implements IWebhookManager {
|
||||||
response: express.Response,
|
response: express.Response,
|
||||||
): Promise<IResponseCallbackData> {
|
): Promise<IResponseCallbackData> {
|
||||||
const httpMethod = request.method;
|
const httpMethod = request.method;
|
||||||
let path = request.params.path;
|
|
||||||
|
|
||||||
// Reset request parameters
|
let path = request.params.path.endsWith('/')
|
||||||
|
? request.params.path.slice(0, -1)
|
||||||
|
: request.params.path;
|
||||||
|
|
||||||
request.params = {} as WebhookRequest['params'];
|
request.params = {} as WebhookRequest['params'];
|
||||||
|
|
||||||
// Remove trailing slash
|
let webhook = this.getActiveWebhook(httpMethod, path);
|
||||||
if (path.endsWith('/')) {
|
|
||||||
path = path.slice(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { activeWebhooks, push, testWebhookData } = this;
|
if (!webhook) {
|
||||||
|
// no static webhook, so check if dynamic
|
||||||
|
// e.g. `/webhook-test/<uuid>/user/:id/create`
|
||||||
|
|
||||||
let webhookData: IWebhookData | undefined = activeWebhooks.get(httpMethod, path);
|
const [webhookId, ...segments] = path.split('/');
|
||||||
|
|
||||||
// check if path is dynamic
|
webhook = this.getActiveWebhook(httpMethod, segments.join('/'), webhookId);
|
||||||
if (webhookData === undefined) {
|
|
||||||
const pathElements = path.split('/');
|
|
||||||
const webhookId = pathElements.shift();
|
|
||||||
|
|
||||||
webhookData = activeWebhooks.get(httpMethod, pathElements.join('/'), webhookId);
|
if (!webhook)
|
||||||
if (webhookData === undefined) {
|
throw new WebhookNotFoundError({
|
||||||
// The requested webhook is not registered
|
path,
|
||||||
const methods = await this.getWebhookMethods(path);
|
httpMethod,
|
||||||
throw new NotFoundError(
|
webhookMethods: await this.getWebhookMethods(path),
|
||||||
webhookNotFoundErrorMessage(path, httpMethod, methods),
|
});
|
||||||
WEBHOOK_TEST_UNREGISTERED_HINT,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
path = webhookData.path;
|
path = webhook.path;
|
||||||
// extracting params from path
|
|
||||||
path.split('/').forEach((ele, index) => {
|
path.split('/').forEach((segment, index) => {
|
||||||
if (ele.startsWith(':')) {
|
if (segment.startsWith(':')) {
|
||||||
// write params to req.params
|
request.params[segment.slice(1)] = segments[index];
|
||||||
// @ts-ignore
|
|
||||||
request.params[ele.slice(1)] = pathElements[index];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { workflowId } = webhookData;
|
const key = [
|
||||||
const webhookKey = `${activeWebhooks.getWebhookKey(
|
this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId),
|
||||||
webhookData.httpMethod,
|
webhook.workflowId,
|
||||||
webhookData.path,
|
].join('|');
|
||||||
webhookData.webhookId,
|
|
||||||
)}|${workflowId}`;
|
|
||||||
|
|
||||||
// TODO: Clean that duplication up one day and improve code generally
|
if (!(key in this.registeredWebhooks))
|
||||||
if (testWebhookData[webhookKey] === undefined) {
|
throw new WebhookNotFoundError({
|
||||||
// The requested webhook is not registered
|
path,
|
||||||
const methods = await this.getWebhookMethods(path);
|
httpMethod,
|
||||||
throw new NotFoundError(
|
webhookMethods: await this.getWebhookMethods(path),
|
||||||
webhookNotFoundErrorMessage(path, httpMethod, methods),
|
});
|
||||||
WEBHOOK_TEST_UNREGISTERED_HINT,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { destinationNode, sessionId, workflow, workflowData, timeout } =
|
const { destinationNode, sessionId, workflow, workflowEntity, timeout } =
|
||||||
testWebhookData[webhookKey];
|
this.registeredWebhooks[key];
|
||||||
|
|
||||||
// Get the node which has the webhook defined to know where to start from and to
|
// Get the node which has the webhook defined to know where to start from and to
|
||||||
// get additional data
|
// get additional data
|
||||||
const workflowStartNode = workflow.getNode(webhookData.node);
|
const workflowStartNode = workflow.getNode(webhook.node);
|
||||||
if (workflowStartNode === null) {
|
if (workflowStartNode === null) {
|
||||||
throw new NotFoundError('Could not find node to process webhook.');
|
throw new NotFoundError('Could not find node to process webhook.');
|
||||||
}
|
}
|
||||||
|
@ -130,8 +111,8 @@ export class TestWebhooks implements IWebhookManager {
|
||||||
const executionMode = 'manual';
|
const executionMode = 'manual';
|
||||||
const executionId = await WebhookHelpers.executeWebhook(
|
const executionId = await WebhookHelpers.executeWebhook(
|
||||||
workflow,
|
workflow,
|
||||||
webhookData!,
|
webhook!,
|
||||||
workflowData,
|
workflowEntity,
|
||||||
workflowStartNode,
|
workflowStartNode,
|
||||||
executionMode,
|
executionMode,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
@ -153,107 +134,101 @@ export class TestWebhooks implements IWebhookManager {
|
||||||
|
|
||||||
// Inform editor-ui that webhook got received
|
// Inform editor-ui that webhook got received
|
||||||
if (sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
push.send('testWebhookReceived', { workflowId, executionId }, sessionId);
|
this.push.send(
|
||||||
|
'testWebhookReceived',
|
||||||
|
{ workflowId: webhook?.workflowId, executionId },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Delete webhook also if an error is thrown
|
// Delete webhook also if an error is thrown
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
delete testWebhookData[webhookKey];
|
delete this.registeredWebhooks[key];
|
||||||
|
|
||||||
await activeWebhooks.removeWorkflow(workflow);
|
await this.deactivateWebhooksFor(workflow);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWebhookMethods(path: string): Promise<IHttpRequestMethods[]> {
|
async getWebhookMethods(path: string) {
|
||||||
const webhookMethods = this.activeWebhooks.getWebhookMethods(path);
|
const webhookMethods = Object.keys(this.webhookUrls)
|
||||||
if (!webhookMethods.length) {
|
.filter((key) => key.includes(path))
|
||||||
// The requested webhook is not registered
|
.map((key) => key.split('|')[0] as IHttpRequestMethods);
|
||||||
throw new NotFoundError(webhookNotFoundErrorMessage(path), WEBHOOK_TEST_UNREGISTERED_HINT);
|
|
||||||
}
|
if (!webhookMethods.length) throw new WebhookNotFoundError({ path });
|
||||||
|
|
||||||
return webhookMethods;
|
return webhookMethods;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAccessControlOptions(path: string, httpMethod: IHttpRequestMethods) {
|
async findAccessControlOptions(path: string, httpMethod: IHttpRequestMethods) {
|
||||||
const webhookKey = Object.keys(this.testWebhookData).find(
|
const webhookKey = Object.keys(this.registeredWebhooks).find(
|
||||||
(key) => key.includes(path) && key.startsWith(httpMethod),
|
(key) => key.includes(path) && key.startsWith(httpMethod),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!webhookKey) return;
|
if (!webhookKey) return;
|
||||||
|
|
||||||
const { workflow } = this.testWebhookData[webhookKey];
|
const { workflow } = this.registeredWebhooks[webhookKey];
|
||||||
const webhookNode = Object.values(workflow.nodes).find(
|
const webhookNode = Object.values(workflow.nodes).find(
|
||||||
({ type, parameters, typeVersion }) =>
|
({ type, parameters, typeVersion }) =>
|
||||||
parameters?.path === path &&
|
parameters?.path === path &&
|
||||||
(parameters?.httpMethod ?? 'GET') === httpMethod &&
|
(parameters?.httpMethod ?? 'GET') === httpMethod &&
|
||||||
'webhook' in this.nodeTypes.getByNameAndVersion(type, typeVersion),
|
'webhook' in this.nodeTypes.getByNameAndVersion(type, typeVersion),
|
||||||
);
|
);
|
||||||
|
|
||||||
return webhookNode?.parameters?.options as WebhookAccessControlOptions;
|
return webhookNode?.parameters?.options as WebhookAccessControlOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async needsWebhook(
|
||||||
* Checks if it has to wait for webhook data to execute the workflow.
|
workflowEntity: IWorkflowDb,
|
||||||
* 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,
|
workflow: Workflow,
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
mode: WorkflowExecuteMode,
|
executionMode: WorkflowExecuteMode,
|
||||||
activation: WorkflowActivateMode,
|
activationMode: WorkflowActivateMode,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
destinationNode?: string,
|
destinationNode?: string,
|
||||||
): Promise<boolean> {
|
) {
|
||||||
|
if (!workflow.id) throw new WorkflowMissingIdError(workflow);
|
||||||
|
|
||||||
const webhooks = WebhookHelpers.getWorkflowWebhooks(
|
const webhooks = WebhookHelpers.getWorkflowWebhooks(
|
||||||
workflow,
|
workflow,
|
||||||
additionalData,
|
additionalData,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!webhooks.find((webhook) => webhook.webhookDescription.restartWebhook !== true)) {
|
|
||||||
// No webhooks found to start a workflow
|
if (!webhooks.find((w) => w.webhookDescription.restartWebhook !== true)) {
|
||||||
return false;
|
return false; // no webhooks found to start a workflow
|
||||||
}
|
}
|
||||||
|
|
||||||
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(() => {
|
const timeout = setTimeout(() => {
|
||||||
this.cancelTestWebhook(workflowData.id);
|
this.cancelTestWebhook(workflowEntity.id);
|
||||||
}, 120000);
|
}, 2 * TIME.MINUTE);
|
||||||
|
|
||||||
const { activeWebhooks, testWebhookData } = this;
|
const activatedKeys: string[] = [];
|
||||||
|
|
||||||
let key: string;
|
for (const webhook of webhooks) {
|
||||||
const activatedKey: string[] = [];
|
const key = [
|
||||||
|
this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId),
|
||||||
|
workflowEntity.id,
|
||||||
|
].join('|');
|
||||||
|
|
||||||
for (const webhookData of webhooks) {
|
activatedKeys.push(key);
|
||||||
key = `${activeWebhooks.getWebhookKey(
|
|
||||||
webhookData.httpMethod,
|
|
||||||
webhookData.path,
|
|
||||||
webhookData.webhookId,
|
|
||||||
)}|${workflowData.id}`;
|
|
||||||
|
|
||||||
activatedKey.push(key);
|
this.registeredWebhooks[key] = {
|
||||||
|
|
||||||
testWebhookData[key] = {
|
|
||||||
sessionId,
|
sessionId,
|
||||||
timeout,
|
timeout,
|
||||||
workflow,
|
workflow,
|
||||||
workflowData,
|
workflowEntity,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await activeWebhooks.add(workflow, webhookData, mode, activation);
|
await this.activateWebhook(workflow, webhook, executionMode, activationMode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
activatedKey.forEach((deleteKey) => delete testWebhookData[deleteKey]);
|
activatedKeys.forEach((ak) => delete this.registeredWebhooks[ak]);
|
||||||
|
|
||||||
|
await this.deactivateWebhooksFor(workflow);
|
||||||
|
|
||||||
await activeWebhooks.removeWorkflow(workflow);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -261,38 +236,29 @@ export class TestWebhooks implements IWebhookManager {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
cancelTestWebhook(workflowId: string) {
|
||||||
* Removes a test webhook of the workflow with the given id
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
cancelTestWebhook(workflowId: string): boolean {
|
|
||||||
let foundWebhook = false;
|
let foundWebhook = false;
|
||||||
const { activeWebhooks, push, testWebhookData } = this;
|
|
||||||
|
|
||||||
for (const webhookKey of Object.keys(testWebhookData)) {
|
for (const key of Object.keys(this.registeredWebhooks)) {
|
||||||
const { sessionId, timeout, workflow, workflowData } = testWebhookData[webhookKey];
|
const { sessionId, timeout, workflow, workflowEntity } = this.registeredWebhooks[key];
|
||||||
|
|
||||||
if (workflowData.id !== workflowId) {
|
if (workflowEntity.id !== workflowId) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
// Inform editor-ui that webhook got received
|
|
||||||
if (sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
try {
|
try {
|
||||||
push.send('testWebhookDeleted', { workflowId }, sessionId);
|
this.push.send('testWebhookDeleted', { workflowId }, sessionId);
|
||||||
} catch {
|
} catch {
|
||||||
// Could not inform editor, probably is not connected anymore. So simply go on.
|
// Could not inform editor, probably is not connected anymore. So simply go on.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the webhook
|
delete this.registeredWebhooks[key];
|
||||||
delete testWebhookData[webhookKey];
|
|
||||||
|
|
||||||
if (!foundWebhook) {
|
if (!foundWebhook) {
|
||||||
// As it removes all webhooks of the workflow execute only once
|
// As it removes all webhooks of the workflow execute only once
|
||||||
void activeWebhooks.removeWorkflow(workflow);
|
void this.deactivateWebhooksFor(workflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
foundWebhook = true;
|
foundWebhook = true;
|
||||||
|
@ -300,4 +266,127 @@ export class TestWebhooks implements IWebhookManager {
|
||||||
|
|
||||||
return foundWebhook;
|
return foundWebhook;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async activateWebhook(
|
||||||
|
workflow: Workflow,
|
||||||
|
webhook: IWebhookData,
|
||||||
|
executionMode: WorkflowExecuteMode,
|
||||||
|
activationMode: WorkflowActivateMode,
|
||||||
|
) {
|
||||||
|
if (!workflow.id) throw new WorkflowMissingIdError(workflow);
|
||||||
|
|
||||||
|
if (webhook.path.endsWith('/')) {
|
||||||
|
webhook.path = webhook.path.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.toWebhookKey(webhook.httpMethod, webhook.path, webhook.webhookId);
|
||||||
|
|
||||||
|
// check that there is not a webhook already registered with that path/method
|
||||||
|
if (this.webhookUrls[key] && !webhook.webhookId) {
|
||||||
|
throw new WebhookPathTakenError(webhook.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.workflowWebhooks[webhook.workflowId] === undefined) {
|
||||||
|
this.workflowWebhooks[webhook.workflowId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the webhook available directly because sometimes to create it successfully
|
||||||
|
// it gets called
|
||||||
|
if (!this.webhookUrls[key]) {
|
||||||
|
this.webhookUrls[key] = [];
|
||||||
|
}
|
||||||
|
webhook.isTest = true;
|
||||||
|
this.webhookUrls[key].push(webhook);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workflow.createWebhookIfNotExists(
|
||||||
|
webhook,
|
||||||
|
NodeExecuteFunctions,
|
||||||
|
executionMode,
|
||||||
|
activationMode,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// If there was a problem unregister the webhook again
|
||||||
|
if (this.webhookUrls[key].length <= 1) {
|
||||||
|
delete this.webhookUrls[key];
|
||||||
|
} else {
|
||||||
|
this.webhookUrls[key] = this.webhookUrls[key].filter((w) => w.path !== w.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.workflowWebhooks[webhook.workflowId].push(webhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveWebhook(httpMethod: IHttpRequestMethods, path: string, webhookId?: string) {
|
||||||
|
const webhookKey = this.toWebhookKey(httpMethod, path, webhookId);
|
||||||
|
if (this.webhookUrls[webhookKey] === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let webhook: IWebhookData | undefined;
|
||||||
|
let maxMatches = 0;
|
||||||
|
const pathElementsSet = new Set(path.split('/'));
|
||||||
|
// check if static elements match in path
|
||||||
|
// if more results have been returned choose the one with the most static-route matches
|
||||||
|
this.webhookUrls[webhookKey].forEach((dynamicWebhook) => {
|
||||||
|
const staticElements = dynamicWebhook.path.split('/').filter((ele) => !ele.startsWith(':'));
|
||||||
|
const allStaticExist = staticElements.every((staticEle) => pathElementsSet.has(staticEle));
|
||||||
|
|
||||||
|
if (allStaticExist && staticElements.length > maxMatches) {
|
||||||
|
maxMatches = staticElements.length;
|
||||||
|
webhook = dynamicWebhook;
|
||||||
|
}
|
||||||
|
// handle routes with no static elements
|
||||||
|
else if (staticElements.length === 0 && !webhook) {
|
||||||
|
webhook = dynamicWebhook;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
toWebhookKey(httpMethod: IHttpRequestMethods, path: string, webhookId?: string) {
|
||||||
|
if (!webhookId) return `${httpMethod}|${path}`;
|
||||||
|
|
||||||
|
if (path.startsWith(webhookId)) {
|
||||||
|
const cutFromIndex = path.indexOf('/') + 1;
|
||||||
|
|
||||||
|
path = path.slice(cutFromIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${httpMethod}|${webhookId}|${path.split('/').length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateWebhooksFor(workflow: Workflow) {
|
||||||
|
const workflowId = workflow.id;
|
||||||
|
|
||||||
|
if (this.workflowWebhooks[workflowId] === undefined) {
|
||||||
|
// If it did not exist then there is nothing to remove
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhooks = this.workflowWebhooks[workflowId];
|
||||||
|
|
||||||
|
const mode = 'internal';
|
||||||
|
|
||||||
|
// Go through all the registered webhooks of the workflow and remove them
|
||||||
|
|
||||||
|
for (const webhookData of webhooks) {
|
||||||
|
await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update');
|
||||||
|
|
||||||
|
const key = this.toWebhookKey(
|
||||||
|
webhookData.httpMethod,
|
||||||
|
webhookData.path,
|
||||||
|
webhookData.webhookId,
|
||||||
|
);
|
||||||
|
|
||||||
|
delete this.webhookUrls[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove also the workflow-webhook entry
|
||||||
|
delete this.workflowWebhooks[workflowId];
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,10 +23,12 @@ export class WebhookEntity {
|
||||||
pathLength?: number;
|
pathLength?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unique section of production webhook path, appended to `${instanceUrl}/webhook/`.
|
* Unique section of webhook path.
|
||||||
* - Example for static UUID webhook: `87dd035f-9606-47b7-b443-8b675fe25719`
|
*
|
||||||
* - Example for static user-defined webhook: `user/:id/posts`
|
* - Static: `${uuid}` or `user/defined/path`
|
||||||
* - Example for dynamic webhook: `7e0e2b2a-19ba-4a6c-b452-4b46c0e11749/user/:id/posts`
|
* - Dynamic: `${uuid}/user/:id/posts`
|
||||||
|
*
|
||||||
|
* Appended to `${instanceUrl}/webhook/` or `${instanceUrl}/test-webhook/`.
|
||||||
*/
|
*/
|
||||||
private get uniquePath() {
|
private get uniquePath() {
|
||||||
return this.webhookPath.includes(':')
|
return this.webhookPath.includes(':')
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { NotFoundError } from './not-found.error';
|
||||||
|
|
||||||
|
export const webhookNotFoundErrorMessage = ({
|
||||||
|
path,
|
||||||
|
httpMethod,
|
||||||
|
webhookMethods,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
httpMethod?: string;
|
||||||
|
webhookMethods?: string[];
|
||||||
|
}) => {
|
||||||
|
let webhookPath = path;
|
||||||
|
|
||||||
|
if (httpMethod) {
|
||||||
|
webhookPath = `${httpMethod} ${webhookPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webhookMethods?.length && httpMethod) {
|
||||||
|
let methods = '';
|
||||||
|
|
||||||
|
if (webhookMethods.length === 1) {
|
||||||
|
methods = webhookMethods[0];
|
||||||
|
} else {
|
||||||
|
const lastMethod = webhookMethods.pop();
|
||||||
|
|
||||||
|
methods = `${webhookMethods.join(', ')} or ${lastMethod as string}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `This webhook is not registered for ${httpMethod} requests. Did you mean to make a ${methods} request?`;
|
||||||
|
} else {
|
||||||
|
return `The requested webhook "${webhookPath}" is not registered.`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WebhookNotFoundError extends NotFoundError {
|
||||||
|
constructor(
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
httpMethod,
|
||||||
|
webhookMethods,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
httpMethod?: string;
|
||||||
|
webhookMethods?: string[];
|
||||||
|
},
|
||||||
|
{ hint }: { hint: 'default' | 'production' } = { hint: 'default' },
|
||||||
|
) {
|
||||||
|
const errorMsg = webhookNotFoundErrorMessage({ path, httpMethod, webhookMethods });
|
||||||
|
|
||||||
|
const hintMsg =
|
||||||
|
hint === 'default'
|
||||||
|
? "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)"
|
||||||
|
: "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)";
|
||||||
|
|
||||||
|
super(errorMsg, hintMsg);
|
||||||
|
}
|
||||||
|
}
|
8
packages/cli/src/errors/workflow-missing-id.error.ts
Normal file
8
packages/cli/src/errors/workflow-missing-id.error.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import type { Workflow } from 'n8n-workflow';
|
||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class WorkflowMissingIdError extends ApplicationError {
|
||||||
|
constructor(workflow: Workflow) {
|
||||||
|
super('Detected ID-less worklfow', { extra: { workflow } });
|
||||||
|
}
|
||||||
|
}
|
|
@ -60,34 +60,6 @@ export const separate = <T>(array: T[], test: (element: T) => boolean) => {
|
||||||
return [pass, fail];
|
return [pass, fail];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const webhookNotFoundErrorMessage = (
|
|
||||||
path: string,
|
|
||||||
httpMethod?: string,
|
|
||||||
webhookMethods?: string[],
|
|
||||||
) => {
|
|
||||||
let webhookPath = path;
|
|
||||||
|
|
||||||
if (httpMethod) {
|
|
||||||
webhookPath = `${httpMethod} ${webhookPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (webhookMethods?.length && httpMethod) {
|
|
||||||
let methods = '';
|
|
||||||
|
|
||||||
if (webhookMethods.length === 1) {
|
|
||||||
methods = webhookMethods[0];
|
|
||||||
} else {
|
|
||||||
const lastMethod = webhookMethods.pop();
|
|
||||||
|
|
||||||
methods = `${webhookMethods.join(', ')} or ${lastMethod as string}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `This webhook is not registered for ${httpMethod} requests. Did you mean to make a ${methods} request?`;
|
|
||||||
} else {
|
|
||||||
return `The requested webhook "${webhookPath}" is not registered.`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const toError = (maybeError: unknown) =>
|
export const toError = (maybeError: unknown) =>
|
||||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||||
maybeError instanceof Error ? maybeError : new Error(`${maybeError}`);
|
maybeError instanceof Error ? maybeError : new Error(`${maybeError}`);
|
||||||
|
|
|
@ -432,7 +432,7 @@ export class WorkflowService {
|
||||||
|
|
||||||
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
|
||||||
|
|
||||||
const needsWebhook = await this.testWebhooks.needsWebhookData(
|
const needsWebhook = await this.testWebhooks.needsWebhook(
|
||||||
workflowData,
|
workflowData,
|
||||||
workflow,
|
workflow,
|
||||||
additionalData,
|
additionalData,
|
||||||
|
@ -441,11 +441,8 @@ export class WorkflowService {
|
||||||
sessionId,
|
sessionId,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
);
|
);
|
||||||
if (needsWebhook) {
|
|
||||||
return {
|
if (needsWebhook) return { waitingForWebhook: true };
|
||||||
waitingForWebhook: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For manual testing always set to not active
|
// For manual testing always set to not active
|
||||||
|
|
154
packages/cli/test/unit/TestWebhooks.test.ts
Normal file
154
packages/cli/test/unit/TestWebhooks.test.ts
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import { mockInstance } from '../shared/mocking';
|
||||||
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
|
import { Push } from '@/push';
|
||||||
|
import { TestWebhooks } from '@/TestWebhooks';
|
||||||
|
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { generateNanoId } from '@/databases/utils/generators';
|
||||||
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
import * as WebhookHelpers from '@/WebhookHelpers';
|
||||||
|
|
||||||
|
import type { IWorkflowDb, WebhookRequest } from '@/Interfaces';
|
||||||
|
import type express from 'express';
|
||||||
|
import type {
|
||||||
|
IWebhookData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
describe('TestWebhooks', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
const push = mockInstance(Push);
|
||||||
|
const nodeTypes = mockInstance(NodeTypes);
|
||||||
|
|
||||||
|
const testWebhooks = new TestWebhooks(push, nodeTypes);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('needsWebhook()', () => {
|
||||||
|
const httpMethod = 'GET';
|
||||||
|
const path = uuid();
|
||||||
|
const workflowId = generateNanoId();
|
||||||
|
|
||||||
|
const webhook = {
|
||||||
|
httpMethod,
|
||||||
|
path,
|
||||||
|
workflowId,
|
||||||
|
webhookDescription: {},
|
||||||
|
} as IWebhookData;
|
||||||
|
|
||||||
|
const keyPart = [httpMethod, path].join('|');
|
||||||
|
|
||||||
|
type NeedsWebhookArgs = [
|
||||||
|
IWorkflowDb,
|
||||||
|
Workflow,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
id: workflowId,
|
||||||
|
createWebhookIfNotExists: () => {},
|
||||||
|
deleteWebhook: () => {},
|
||||||
|
} as unknown as Workflow;
|
||||||
|
|
||||||
|
const args: NeedsWebhookArgs = [
|
||||||
|
{ id: workflowId } as unknown as IWorkflowDb,
|
||||||
|
workflow,
|
||||||
|
{} as unknown as IWorkflowExecuteAdditionalData,
|
||||||
|
'manual',
|
||||||
|
'manual',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('should register a webhook as active', async () => {
|
||||||
|
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
|
||||||
|
jest.spyOn(testWebhooks, 'toWebhookKey').mockReturnValue(keyPart);
|
||||||
|
const activateWebhookSpy = jest.spyOn(testWebhooks, 'activateWebhook');
|
||||||
|
|
||||||
|
const needsWebhook = await testWebhooks.needsWebhook(...args);
|
||||||
|
|
||||||
|
expect(needsWebhook).toBe(true);
|
||||||
|
expect(activateWebhookSpy).toHaveBeenCalledWith(workflow, webhook, 'manual', 'manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove from active webhooks on failure to add', async () => {
|
||||||
|
const msg = 'Failed to add webhook to active webhooks';
|
||||||
|
|
||||||
|
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
|
||||||
|
jest.spyOn(testWebhooks, 'toWebhookKey').mockReturnValue(keyPart);
|
||||||
|
jest.spyOn(testWebhooks, 'activateWebhook').mockRejectedValue(new Error(msg));
|
||||||
|
const deactivateSpy = jest.spyOn(testWebhooks, 'deactivateWebhooksFor');
|
||||||
|
|
||||||
|
const needsWebhook = testWebhooks.needsWebhook(...args);
|
||||||
|
|
||||||
|
await expect(needsWebhook).rejects.toThrowError(msg);
|
||||||
|
expect(deactivateSpy).toHaveBeenCalledWith(workflow);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false if no webhook to start workflow', async () => {
|
||||||
|
webhook.webhookDescription.restartWebhook = true;
|
||||||
|
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
|
||||||
|
|
||||||
|
const result = await testWebhooks.needsWebhook(...args);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('executeWebhook()', () => {
|
||||||
|
const httpMethod = 'GET';
|
||||||
|
const path = uuid();
|
||||||
|
const workflowId = generateNanoId();
|
||||||
|
|
||||||
|
const webhook = {
|
||||||
|
httpMethod,
|
||||||
|
path,
|
||||||
|
workflowId,
|
||||||
|
} as IWebhookData;
|
||||||
|
|
||||||
|
const keyPart = [httpMethod, path].join('|');
|
||||||
|
|
||||||
|
test('should throw if webhook is not registered', async () => {
|
||||||
|
jest.spyOn(testWebhooks, 'getActiveWebhook').mockReturnValue(webhook);
|
||||||
|
jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]);
|
||||||
|
jest.spyOn(testWebhooks, 'toWebhookKey').mockReturnValue(keyPart);
|
||||||
|
|
||||||
|
const request = { params: { path } } as WebhookRequest;
|
||||||
|
const response = {} as express.Response;
|
||||||
|
const promise = testWebhooks.executeWebhook(request, response);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrowError(WebhookNotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw if webhook node is registered but missing from workflow', async () => {
|
||||||
|
jest.spyOn(testWebhooks, 'getActiveWebhook').mockReturnValue(webhook);
|
||||||
|
jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]);
|
||||||
|
jest.spyOn(testWebhooks, 'toWebhookKey').mockReturnValue(keyPart);
|
||||||
|
|
||||||
|
// @ts-expect-error Private property
|
||||||
|
testWebhooks.registeredWebhooks[`${keyPart}|${workflowId}`] = {
|
||||||
|
sessionId: 'some-session-id',
|
||||||
|
timeout: setTimeout(() => {}, 0),
|
||||||
|
workflowEntity: {} as IWorkflowDb,
|
||||||
|
workflow: {
|
||||||
|
getNode: () => null,
|
||||||
|
} as unknown as Workflow,
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = { params: { path } } as WebhookRequest;
|
||||||
|
const response = {} as express.Response;
|
||||||
|
const promise = testWebhooks.executeWebhook(request, response);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrowError(NotFoundError);
|
||||||
|
|
||||||
|
// @ts-expect-error Private property
|
||||||
|
delete testWebhooks.registeredWebhooks[`${keyPart}|${workflowId}`];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,32 +1,44 @@
|
||||||
import { webhookNotFoundErrorMessage } from '@/utils';
|
import { webhookNotFoundErrorMessage } from '@/errors/response-errors/webhook-not-found.error';
|
||||||
|
|
||||||
describe('utils test webhookNotFoundErrorMessage ', () => {
|
describe('utils test webhookNotFoundErrorMessage ', () => {
|
||||||
it('should return a message with path and method', () => {
|
it('should return a message with path and method', () => {
|
||||||
const message = webhookNotFoundErrorMessage('webhook12345', 'GET');
|
const message = webhookNotFoundErrorMessage({ path: 'webhook12345', httpMethod: 'GET' });
|
||||||
|
|
||||||
expect(message).toEqual('The requested webhook "GET webhook12345" is not registered.');
|
expect(message).toEqual('The requested webhook "GET webhook12345" is not registered.');
|
||||||
});
|
});
|
||||||
it('should return a message with path', () => {
|
it('should return a message with path', () => {
|
||||||
const message = webhookNotFoundErrorMessage('webhook12345');
|
const message = webhookNotFoundErrorMessage({ path: 'webhook12345' });
|
||||||
|
|
||||||
expect(message).toEqual('The requested webhook "webhook12345" is not registered.');
|
expect(message).toEqual('The requested webhook "webhook12345" is not registered.');
|
||||||
});
|
});
|
||||||
it('should return a message with method with tip', () => {
|
it('should return a message with method with tip', () => {
|
||||||
const message = webhookNotFoundErrorMessage('webhook12345', 'POST', ['GET', 'PUT']);
|
const message = webhookNotFoundErrorMessage({
|
||||||
|
path: 'webhook12345',
|
||||||
|
httpMethod: 'POST',
|
||||||
|
webhookMethods: ['GET', 'PUT'],
|
||||||
|
});
|
||||||
|
|
||||||
expect(message).toEqual(
|
expect(message).toEqual(
|
||||||
'This webhook is not registered for POST requests. Did you mean to make a GET or PUT request?',
|
'This webhook is not registered for POST requests. Did you mean to make a GET or PUT request?',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('should return a message with method with tip', () => {
|
it('should return a message with method with tip', () => {
|
||||||
const message = webhookNotFoundErrorMessage('webhook12345', 'POST', ['PUT']);
|
const message = webhookNotFoundErrorMessage({
|
||||||
|
path: 'webhook12345',
|
||||||
|
httpMethod: 'POST',
|
||||||
|
webhookMethods: ['PUT'],
|
||||||
|
});
|
||||||
|
|
||||||
expect(message).toEqual(
|
expect(message).toEqual(
|
||||||
'This webhook is not registered for POST requests. Did you mean to make a PUT request?',
|
'This webhook is not registered for POST requests. Did you mean to make a PUT request?',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('should return a message with method with tip', () => {
|
it('should return a message with method with tip', () => {
|
||||||
const message = webhookNotFoundErrorMessage('webhook12345', 'POST', ['GET', 'PUT', 'DELETE']);
|
const message = webhookNotFoundErrorMessage({
|
||||||
|
path: 'webhook12345',
|
||||||
|
httpMethod: 'POST',
|
||||||
|
webhookMethods: ['GET', 'PUT', 'DELETE'],
|
||||||
|
});
|
||||||
|
|
||||||
expect(message).toEqual(
|
expect(message).toEqual(
|
||||||
'This webhook is not registered for POST requests. Did you mean to make a GET, PUT or DELETE request?',
|
'This webhook is not registered for POST requests. Did you mean to make a GET, PUT or DELETE request?',
|
||||||
|
|
|
@ -3768,7 +3768,6 @@ export function getExecuteHookFunctions(
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
isTest?: boolean,
|
|
||||||
webhookData?: IWebhookData,
|
webhookData?: IWebhookData,
|
||||||
): IHookFunctions {
|
): IHookFunctions {
|
||||||
return ((workflow: Workflow, node: INode) => {
|
return ((workflow: Workflow, node: INode) => {
|
||||||
|
@ -3810,7 +3809,7 @@ export function getExecuteHookFunctions(
|
||||||
additionalData,
|
additionalData,
|
||||||
mode,
|
mode,
|
||||||
getAdditionalKeys(additionalData, mode, null),
|
getAdditionalKeys(additionalData, mode, null),
|
||||||
isTest,
|
webhookData?.isTest,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getWebhookName(): string {
|
getWebhookName(): string {
|
||||||
|
|
|
@ -450,7 +450,6 @@ export interface IGetExecuteHookFunctions {
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
isTest?: boolean,
|
|
||||||
webhookData?: IWebhookData,
|
webhookData?: IWebhookData,
|
||||||
): IHookFunctions;
|
): IHookFunctions;
|
||||||
}
|
}
|
||||||
|
@ -1660,6 +1659,7 @@ export interface IWebhookData {
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
workflowExecuteAdditionalData: IWorkflowExecuteAdditionalData;
|
workflowExecuteAdditionalData: IWorkflowExecuteAdditionalData;
|
||||||
webhookId?: string;
|
webhookId?: string;
|
||||||
|
isTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWebhookDescription {
|
export interface IWebhookDescription {
|
||||||
|
|
|
@ -1009,7 +1009,6 @@ export class Workflow {
|
||||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
nodeExecuteFunctions: INodeExecuteFunctions,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
isTest?: boolean,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const webhookExists = await this.runWebhookMethod(
|
const webhookExists = await this.runWebhookMethod(
|
||||||
'checkExists',
|
'checkExists',
|
||||||
|
@ -1017,18 +1016,10 @@ export class Workflow {
|
||||||
nodeExecuteFunctions,
|
nodeExecuteFunctions,
|
||||||
mode,
|
mode,
|
||||||
activation,
|
activation,
|
||||||
isTest,
|
|
||||||
);
|
);
|
||||||
if (!webhookExists) {
|
if (!webhookExists) {
|
||||||
// If webhook does not exist yet create it
|
// If webhook does not exist yet create it
|
||||||
await this.runWebhookMethod(
|
await this.runWebhookMethod('create', webhookData, nodeExecuteFunctions, mode, activation);
|
||||||
'create',
|
|
||||||
webhookData,
|
|
||||||
nodeExecuteFunctions,
|
|
||||||
mode,
|
|
||||||
activation,
|
|
||||||
isTest,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1037,16 +1028,8 @@ export class Workflow {
|
||||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
nodeExecuteFunctions: INodeExecuteFunctions,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
isTest?: boolean,
|
|
||||||
) {
|
) {
|
||||||
await this.runWebhookMethod(
|
await this.runWebhookMethod('delete', webhookData, nodeExecuteFunctions, mode, activation);
|
||||||
'delete',
|
|
||||||
webhookData,
|
|
||||||
nodeExecuteFunctions,
|
|
||||||
mode,
|
|
||||||
activation,
|
|
||||||
isTest,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runWebhookMethod(
|
private async runWebhookMethod(
|
||||||
|
@ -1055,7 +1038,6 @@ export class Workflow {
|
||||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
nodeExecuteFunctions: INodeExecuteFunctions,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
isTest?: boolean,
|
|
||||||
): Promise<boolean | undefined> {
|
): Promise<boolean | undefined> {
|
||||||
const node = this.getNode(webhookData.node);
|
const node = this.getNode(webhookData.node);
|
||||||
|
|
||||||
|
@ -1072,7 +1054,6 @@ export class Workflow {
|
||||||
webhookData.workflowExecuteAdditionalData,
|
webhookData.workflowExecuteAdditionalData,
|
||||||
mode,
|
mode,
|
||||||
activation,
|
activation,
|
||||||
isTest,
|
|
||||||
webhookData,
|
webhookData,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue