fix(core): Fix getting webhook methods from path only when dynamic webhook path (#17803)

This commit is contained in:
Guillaume Jacquart
2025-08-20 14:20:53 +02:00
committed by GitHub
parent 6c1352f75c
commit a5c6e2fecf
5 changed files with 131 additions and 28 deletions

View File

@@ -1,3 +1,4 @@
import type { WorkflowEntity } from '@n8n/db';
import { generateNanoId } from '@n8n/db'; import { generateNanoId } from '@n8n/db';
import type * as express from 'express'; import type * as express from 'express';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
@@ -7,6 +8,7 @@ import type {
IWebhookData, IWebhookData,
IWorkflowExecuteAdditionalData, IWorkflowExecuteAdditionalData,
Workflow, Workflow,
IHttpRequestMethods,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -195,11 +197,40 @@ describe('TestWebhooks', () => {
}); });
describe('getWebhookMethods()', () => { describe('getWebhookMethods()', () => {
beforeEach(() => {
registrations.toKey.mockImplementation(
(webhook: Pick<IWebhookData, 'webhookId' | 'httpMethod' | 'path'>) => {
const { webhookId, httpMethod, path: webhookPath } = webhook;
if (!webhookId) return [httpMethod, webhookPath].join('|');
let path = webhookPath;
if (path.startsWith(webhookId)) {
const cutFromIndex = path.indexOf('/') + 1;
path = path.slice(cutFromIndex);
}
return [httpMethod, webhookId, path.split('/').length].join('|');
},
);
});
test('should normalize trailing slash', async () => { test('should normalize trailing slash', async () => {
const METHOD = 'POST'; const METHOD = 'POST';
const PATH_WITH_SLASH = 'register/'; const PATH_WITH_SLASH = 'register/';
const PATH_WITHOUT_SLASH = 'register'; const PATH_WITHOUT_SLASH = 'register';
registrations.getAllKeys.mockResolvedValue([`${METHOD}|${PATH_WITHOUT_SLASH}`]); const webhookData = {
httpMethod: METHOD as IHttpRequestMethods,
path: PATH_WITHOUT_SLASH,
} as IWebhookData;
registrations.getRegistrationsHash.mockImplementation(async () => {
return {
[registrations.toKey(webhookData)]: {
workflowEntity: mock<WorkflowEntity>(),
webhook: webhookData,
},
};
});
const resultWithSlash = await testWebhooks.getWebhookMethods(PATH_WITH_SLASH); const resultWithSlash = await testWebhooks.getWebhookMethods(PATH_WITH_SLASH);
const resultWithoutSlash = await testWebhooks.getWebhookMethods(PATH_WITHOUT_SLASH); const resultWithoutSlash = await testWebhooks.getWebhookMethods(PATH_WITHOUT_SLASH);
@@ -207,5 +238,30 @@ describe('TestWebhooks', () => {
expect(resultWithSlash).toEqual([METHOD]); expect(resultWithSlash).toEqual([METHOD]);
expect(resultWithoutSlash).toEqual([METHOD]); expect(resultWithoutSlash).toEqual([METHOD]);
}); });
test('should return methods for webhooks with dynamic paths', async () => {
const METHOD = 'POST';
const PATH = '12345/register/:id';
const webhookData = {
webhookId: '12345',
httpMethod: METHOD as IHttpRequestMethods,
// Path for dynamic webhook does not contain webhookId
path: 'register/:id',
};
registrations.getRegistrationsHash.mockImplementation(async () => {
return {
[registrations.toKey(webhookData)]: {
workflowEntity: mock<WorkflowEntity>(),
webhook: webhookData as IWebhookData,
},
};
});
const result = await testWebhooks.getWebhookMethods(PATH);
expect(result).toEqual([METHOD]);
});
}); });
}); });

View File

@@ -154,6 +154,21 @@ describe('WebhookService', () => {
expect(returnedMethods).toEqual([]); expect(returnedMethods).toEqual([]);
}); });
test('should return dynamic webhook method when static search returns nothing', async () => {
const webhookId = uuid();
const dynamicPath = `${webhookId}/user/1`;
const mockDynamicWebhook = createWebhook('POST', 'user/:id', webhookId, 2);
// Mock static webhook search to return empty
webhookRepository.find.mockResolvedValue([]);
// Mock dynamic webhook search to return a webhook
webhookRepository.findBy.mockResolvedValue([mockDynamicWebhook]);
const returnedMethods = await webhookService.getWebhookMethods(dynamicPath);
expect(returnedMethods).toEqual(['POST']);
});
}); });
describe('deleteWorkflowWebhooks()', () => { describe('deleteWorkflowWebhooks()', () => {

View File

@@ -71,6 +71,10 @@ export class TestWebhookRegistrationsService {
return Object.values(hash); return Object.values(hash);
} }
async getRegistrationsHash() {
return await this.cacheService.getHash<TestWebhookRegistration>(this.cacheKey);
}
async deregisterAll() { async deregisterAll() {
await this.cacheService.delete(this.cacheKey); await this.cacheService.delete(this.cacheKey);
} }

View File

@@ -200,13 +200,34 @@ export class TestWebhooks implements IWebhookManager {
if (timeout) clearTimeout(timeout); if (timeout) clearTimeout(timeout);
} }
async getWebhooksFromPath(rawPath: string) {
const path = removeTrailingSlash(rawPath);
const webhooks: IWebhookData[] = [];
const registrations = await this.registrations.getRegistrationsHash();
for (const httpMethod of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as IHttpRequestMethods[]) {
const key = this.registrations.toKey({ httpMethod, path });
let webhook = registrations?.[key]?.webhook;
if (!webhook) {
// check for dynamic webhooks
const [webhookId, ...segments] = path.split('/');
const key = this.registrations.toKey({ httpMethod, path, webhookId });
if (registrations?.[key]) {
webhook = this.getActiveWebhookFromRegistration(segments.join('/'), registrations?.[key]);
}
}
if (webhook) {
webhooks.push(webhook);
}
}
return webhooks;
}
async getWebhookMethods(rawPath: string) { async getWebhookMethods(rawPath: string) {
const path = removeTrailingSlash(rawPath); const path = removeTrailingSlash(rawPath);
const allKeys = await this.registrations.getAllKeys(); const webhooks = await this.getWebhooksFromPath(path);
const webhookMethods = allKeys const webhookMethods = webhooks.map((webhook) => webhook.httpMethod);
.filter((key) => key.includes(path))
.map((key) => key.split('|')[0] as IHttpRequestMethods);
if (!webhookMethods.length) throw new WebhookNotFoundError({ path }); if (!webhookMethods.length) throw new WebhookNotFoundError({ path });
@@ -393,33 +414,31 @@ export class TestWebhooks implements IWebhookManager {
return foundWebhook; return foundWebhook;
} }
async getActiveWebhook(httpMethod: IHttpRequestMethods, path: string, webhookId?: string) { getActiveWebhookFromRegistration(
const key = this.registrations.toKey({ httpMethod, path, webhookId }); path: string,
registration: TestWebhookRegistration,
let webhook: IWebhookData | undefined; ): IWebhookData | undefined {
let maxMatches = 0;
const pathElementsSet = new Set(path.split('/')); 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
const registration = await this.registrations.get(key);
if (!registration) return;
const { webhook: dynamicWebhook } = registration; const { webhook: dynamicWebhook } = registration;
const staticElements = dynamicWebhook.path.split('/').filter((ele) => !ele.startsWith(':')); const staticElements = dynamicWebhook.path.split('/').filter((ele) => !ele.startsWith(':'));
const allStaticExist = staticElements.every((staticEle) => pathElementsSet.has(staticEle)); const allStaticExist = staticElements.every((staticEle) => pathElementsSet.has(staticEle));
if (allStaticExist && staticElements.length > maxMatches) { // webhook matches if all static elements exist or if there are no static elements
maxMatches = staticElements.length; if ((allStaticExist && staticElements.length > 0) || staticElements.length === 0) {
webhook = dynamicWebhook; return dynamicWebhook;
} }
// handle routes with no static elements return undefined;
else if (staticElements.length === 0 && !webhook) {
webhook = dynamicWebhook;
} }
return webhook; async getActiveWebhook(httpMethod: IHttpRequestMethods, path: string, webhookId?: string) {
const key = this.registrations.toKey({ httpMethod, path, webhookId });
const registration = await this.registrations.get(key);
if (!registration) return;
return this.getActiveWebhookFromRegistration(path, registration);
} }
/** /**

View File

@@ -57,7 +57,7 @@ export class WebhookService {
return dbStaticWebhook; return dbStaticWebhook;
} }
return await this.findDynamicWebhook(method, path); return await this.findDynamicWebhook(path, method);
} }
/** /**
@@ -71,7 +71,7 @@ export class WebhookService {
* Find a matching webhook with one or more dynamic path segments, e.g. `<uuid>/user/:id/posts`. * Find a matching webhook with one or more dynamic path segments, e.g. `<uuid>/user/:id/posts`.
* It is mandatory for dynamic webhooks to have `<uuid>/` at the base. * It is mandatory for dynamic webhooks to have `<uuid>/` at the base.
*/ */
private async findDynamicWebhook(method: Method, path: string) { private async findDynamicWebhook(path: string, method?: Method) {
const [uuidSegment, ...otherSegments] = path.split('/'); const [uuidSegment, ...otherSegments] = path.split('/');
const dynamicWebhooks = await this.webhookRepository.findBy({ const dynamicWebhooks = await this.webhookRepository.findBy({
@@ -133,10 +133,19 @@ export class WebhookService {
return await this.webhookRepository.remove(webhooks); return await this.webhookRepository.remove(webhooks);
} }
async getWebhookMethods(path: string) { async getWebhookMethods(rawPath: string) {
return await this.webhookRepository // Try to find static webhooks first
.find({ select: ['method'], where: { webhookPath: path } }) const staticMethods = await this.webhookRepository
.find({ select: ['method'], where: { webhookPath: rawPath } })
.then((rows) => rows.map((r) => r.method)); .then((rows) => rows.map((r) => r.method));
if (staticMethods.length > 0) {
return staticMethods;
}
// Otherwise, try to find dynamic webhooks based on path only
const dynamicWebhooks = await this.findDynamicWebhook(rawPath);
return dynamicWebhooks ? [dynamicWebhooks.method] : [];
} }
private isDynamicPath(rawPath: string) { private isDynamicPath(rawPath: string) {