feat(core): Register test webhook triggers as OAuth protected resources (#35363)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart
2026-08-03 12:24:49 +02:00
committed by GitHub
parent 891dba3181
commit a78bcbf41a
4 changed files with 685 additions and 9 deletions
@@ -0,0 +1,486 @@
import {
createWorkflowWithHistory,
setActiveVersion,
shareWorkflowWithUsers,
testDb,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { WebhookRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { IHttpRequestMethods, INode, IWebhookData, IWorkflowBase } from 'n8n-workflow';
import { WEBHOOK_NODE_TYPE } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import { createOwner, createMember } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service';
import { CacheService } from '@/services/cache/cache.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
let owner: User;
let member: User;
let webhookEndpoint: string;
let webhookTestEndpoint: string;
let registrations: TestWebhookRegistrationsService;
const webhookBaseUrl = () => Container.get(UrlService).getWebhookBaseUrl().replace(/\/$/, '');
const testWebhookBaseUrl = () =>
Container.get(UrlService).getTestWebhookBaseUrl().replace(/\/$/, '');
const resourceUrlFor = (webhookPath: string, method: IHttpRequestMethods = 'POST') =>
`${testWebhookBaseUrl()}/${webhookTestEndpoint}/${webhookPath}?method=${method}`;
const prmPathFor = (webhookPath: string, method?: IHttpRequestMethods) =>
`/.well-known/oauth-protected-resource/${webhookTestEndpoint}/${webhookPath}` +
(method ? `?method=${method}` : '');
const webhookNode = ({
name = 'Webhook',
authentication = 'n8nOAuth2',
disabled = false,
requireExecuteAccess,
}: {
name?: string;
authentication?: string;
disabled?: boolean;
requireExecuteAccess?: boolean;
} = {}): INode => ({
id: randomUUID(),
name,
type: WEBHOOK_NODE_TYPE,
typeVersion: 2.1,
position: [0, 0],
disabled,
parameters: {
path: 'unused',
httpMethod: 'POST',
authentication,
...(requireExecuteAccess === undefined ? {} : { requireExecuteAccess }),
},
});
/**
* Mirrors what `TestWebhooks.needsWebhook` registers — one row per method — when
* the user tests a webhook trigger in the editor. The workflow does not need to
* exist in the DB: the registration is self-contained.
*/
const registerTestWebhook = async (
webhookPath: string,
node: INode,
{
workflowId = randomUUID(),
workflowName = 'My test workflow',
methods = ['POST'] as IHttpRequestMethods[],
webhookId,
}: {
workflowId?: string;
workflowName?: string;
methods?: IHttpRequestMethods[];
webhookId?: string;
} = {},
) => {
for (const httpMethod of methods) {
await registrations.register({
version: 1,
workflowEntity: {
id: workflowId,
name: workflowName,
active: false,
nodes: [node],
connections: {},
} as IWorkflowBase,
webhook: {
httpMethod,
path: webhookPath,
node: node.name,
workflowId,
...(webhookId ? { webhookId } : {}),
} as IWebhookData,
});
}
return { workflowId, workflowName };
};
const resolveResource = async (webhookPath: string, method?: IHttpRequestMethods) =>
await Container.get(ProtectedResourceRegistry).getByResourcePath(
`/${webhookTestEndpoint}/${webhookPath}` + (method ? `?method=${method}` : ''),
);
const registerOAuthClient = async () => {
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send({
client_name: 'webhook-test-resolver-tests',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
});
expect(response.statusCode).toBe(201);
return response.body.client_id as string;
};
beforeAll(async () => {
process.env.N8N_ENV_FEAT_WEBHOOK_PRIVATE_CREDENTIALS = 'true'; // gates the webhook-trigger resolvers
owner = await createOwner();
member = await createMember();
const { endpoints } = Container.get(GlobalConfig);
webhookEndpoint = endpoints.webhook;
webhookTestEndpoint = endpoints.webhookTest;
registrations = Container.get(TestWebhookRegistrationsService);
});
afterAll(() => {
delete process.env.N8N_ENV_FEAT_WEBHOOK_PRIVATE_CREDENTIALS;
});
afterEach(async () => {
await Container.get(CacheService).reset(); // test webhook registrations live in the cache
await testDb.truncate([
'AccessToken',
'RefreshToken',
'AuthorizationCode',
'OAuthClient',
'WebhookEntity',
'SharedWorkflow',
'WorkflowEntity',
'WorkflowHistory',
]);
});
describe('protected resource metadata for test webhook triggers', () => {
test('should serve the metadata document while a test registration exists', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode());
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
resource: resourceUrlFor(webhookPath),
bearer_methods_supported: ['header'],
authorization_servers: [expect.any(String)],
});
});
test('should resolve from the registration alone, without the workflow in the DB', async () => {
const webhookPath = randomUUID();
const { workflowName } = await registerTestWebhook(webhookPath, webhookNode(), {
workflowName: 'Unsaved workflow',
});
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
expect(response.statusCode).toBe(200);
expect(workflowName).toBe('Unsaved workflow');
});
test('should not resolve an unknown test path', async () => {
const response = await testServer.restlessAgent.get(prmPathFor(randomUUID()));
expect(response.statusCode).toBe(404);
});
test('should not resolve when the registration node name does not match', async () => {
const webhookPath = randomUUID();
await registrations.register({
version: 1,
workflowEntity: {
id: randomUUID(),
name: 'My test workflow',
active: false,
nodes: [webhookNode()],
connections: {},
} as IWorkflowBase,
webhook: {
httpMethod: 'POST',
path: webhookPath,
node: 'Ghost node',
workflowId: randomUUID(),
} as IWebhookData,
});
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
expect(response.statusCode).toBe(404);
});
test('should not resolve when the feature flag is disabled', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode());
delete process.env.N8N_ENV_FEAT_WEBHOOK_PRIVATE_CREDENTIALS;
try {
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
expect(response.statusCode).toBe(404);
} finally {
process.env.N8N_ENV_FEAT_WEBHOOK_PRIVATE_CREDENTIALS = 'true';
}
});
test('should not resolve a non-test-webhook path even if the registration exists', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode());
const response = await testServer.restlessAgent.get(
`/.well-known/oauth-protected-resource/${webhookEndpoint}/${webhookPath}`,
);
expect(response.statusCode).toBe(404);
});
test.each([
['authentication is none', webhookNode({ authentication: 'none' })],
['authentication is basicAuth', webhookNode({ authentication: 'basicAuth' })],
['the node is disabled', webhookNode({ disabled: true })],
])('should not resolve when %s', async (_, node) => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, node);
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
expect(response.statusCode).toBe(404);
});
test('should stop resolving as soon as the registration is removed', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode());
expect((await testServer.restlessAgent.get(prmPathFor(webhookPath))).statusCode).toBe(200);
await registrations.deregister(registrations.toKey({ httpMethod: 'POST', path: webhookPath }));
expect((await testServer.restlessAgent.get(prmPathFor(webhookPath))).statusCode).toBe(404);
});
});
describe('method disambiguation', () => {
test('should resolve a non-POST webhook, encoding the method in the resource', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode(), { methods: ['GET'] });
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
expect(response.statusCode).toBe(200);
expect(response.body.resource).toBe(resourceUrlFor(webhookPath, 'GET'));
});
test('should accept every method of a multi-method trigger as one audience set', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode(), { methods: ['GET', 'POST'] });
const viaGet = await resolveResource(webhookPath, 'GET');
const viaPost = await resolveResource(webhookPath, 'POST');
expect(viaGet?.id).toBe(viaPost?.id);
const expectedAudiences = [
resourceUrlFor(webhookPath, 'GET'),
resourceUrlFor(webhookPath, 'POST'),
];
expect(viaGet?.getAudiences()).toEqual(expectedAudiences);
expect(viaPost?.getAudiences()).toEqual(expectedAudiences);
});
test('should resolve disjoint-method triggers on a shared path', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode({ name: 'GetHook' }), { methods: ['GET'] });
await registerTestWebhook(webhookPath, webhookNode({ name: 'PostHook' }), {
methods: ['POST'],
});
const getResponse = await testServer.restlessAgent.get(prmPathFor(webhookPath, 'GET'));
expect(getResponse.statusCode).toBe(200);
expect(getResponse.body.resource).toBe(resourceUrlFor(webhookPath, 'GET'));
const postResponse = await testServer.restlessAgent.get(prmPathFor(webhookPath, 'POST'));
expect(postResponse.statusCode).toBe(200);
expect(postResponse.body.resource).toBe(resourceUrlFor(webhookPath, 'POST'));
});
test('should refuse a shared path probed without a ?method selector', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode({ name: 'GetHook' }), { methods: ['GET'] });
await registerTestWebhook(webhookPath, webhookNode({ name: 'PostHook' }), {
methods: ['POST'],
});
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
expect(response.statusCode).toBe(404);
});
test('should not resolve a method no trigger listens on', async () => {
const webhookPath = randomUUID();
await registerTestWebhook(webhookPath, webhookNode(), { methods: ['GET'] });
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath, 'PUT'));
expect(response.statusCode).toBe(404);
});
});
describe('dynamic webhooks', () => {
// `n8n-nodes-base.webhook` uses `isFullPath: true`, so `IWebhookData.path` on the
// registration is the raw configured template (`user/:id`, no `webhookId` prefix) —
// see `webhookResourcePath`. The served/resolved URL still carries the prefix.
test('should resolve a concrete request path to the templated resource identity', async () => {
const webhookId = randomUUID();
await registerTestWebhook('user/:id', webhookNode(), { webhookId });
const response = await testServer.restlessAgent.get(prmPathFor(`${webhookId}/user/42`));
expect(response.statusCode).toBe(200);
expect(response.body.resource).toBe(resourceUrlFor(`${webhookId}/user/:id`));
});
test('should resolve the templated request path to the same identity', async () => {
const webhookId = randomUUID();
await registerTestWebhook('user/:id', webhookNode(), { webhookId });
const response = await testServer.restlessAgent.get(prmPathFor(`${webhookId}/user/:id`));
expect(response.statusCode).toBe(200);
expect(response.body.resource).toBe(resourceUrlFor(`${webhookId}/user/:id`));
});
test('should cover a multi-method dynamic trigger with one resource', async () => {
const webhookId = randomUUID();
await registerTestWebhook('user/:id', webhookNode(), {
webhookId,
methods: ['GET', 'POST'],
});
const response = await testServer.restlessAgent.get(prmPathFor(`${webhookId}/user/99`, 'POST'));
expect(response.statusCode).toBe(200);
expect(response.body.resource).toBe(resourceUrlFor(`${webhookId}/user/:id`, 'POST'));
});
});
describe('test vs production resources', () => {
test('should serve the same trigger path as two distinct resources', async () => {
const webhookPath = randomUUID();
const node = webhookNode();
// production: active workflow with published version + webhook row
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
await setActiveVersion(workflow.id, workflow.versionId);
await Container.get(WebhookRepository).insert({
workflowId: workflow.id,
webhookPath,
method: 'POST',
node: node.name,
});
// test: editor registration for the same path
await registerTestWebhook(webhookPath, node, { workflowId: workflow.id });
const production = await testServer.restlessAgent.get(
`/.well-known/oauth-protected-resource/${webhookEndpoint}/${webhookPath}?method=POST`,
);
const test = await testServer.restlessAgent.get(prmPathFor(webhookPath));
expect(production.statusCode).toBe(200);
expect(test.statusCode).toBe(200);
expect(production.body.resource).not.toBe(test.body.resource);
});
test('should reject a test-resource token at the production resource and vice versa', async () => {
const webhookPath = randomUUID();
const node = webhookNode();
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
await setActiveVersion(workflow.id, workflow.versionId);
await Container.get(WebhookRepository).insert({
workflowId: workflow.id,
webhookPath,
method: 'POST',
node: node.name,
});
await registerTestWebhook(webhookPath, node, { workflowId: workflow.id });
const clientId = await registerOAuthClient();
const tokenService = Container.get(OAuthTokenService);
const productionResourceUrl = `${webhookBaseUrl()}/${webhookEndpoint}/${webhookPath}?method=POST`;
const testResourceUrl = resourceUrlFor(webhookPath);
const testToken = tokenService.generateTokenPair(owner.id, clientId, testResourceUrl, []);
await tokenService.saveTokenPair(
testToken.accessToken,
testToken.refreshToken,
clientId,
owner.id,
[],
);
const productionToken = tokenService.generateTokenPair(
owner.id,
clientId,
productionResourceUrl,
[],
);
await tokenService.saveTokenPair(
productionToken.accessToken,
productionToken.refreshToken,
clientId,
owner.id,
[],
);
await expect(
tokenService.verifyAccessToken(testToken.accessToken, testResourceUrl),
).resolves.toMatchObject({ clientId });
await expect(
tokenService.verifyAccessToken(productionToken.accessToken, productionResourceUrl),
).resolves.toMatchObject({ clientId });
await expect(
tokenService.verifyAccessToken(testToken.accessToken, productionResourceUrl),
).rejects.toThrow();
await expect(
tokenService.verifyAccessToken(productionToken.accessToken, testResourceUrl),
).rejects.toThrow();
});
});
describe('authorize gate (workflow:execute)', () => {
/** A saved (but not activated/published) workflow — a realistic "testing a draft" state. */
const createDraftWorkflow = async (node: INode, ownedBy: User = owner) =>
await createWorkflowWithHistory({ active: false, nodes: [node] }, ownedBy);
test('authorizes the owner but denies a user without execute access', async () => {
const webhookPath = randomUUID();
const node = webhookNode();
const workflow = await createDraftWorkflow(node);
await registerTestWebhook(webhookPath, node, { workflowId: workflow.id });
const resource = await resolveResource(webhookPath);
await expect(resource?.authorize(owner)).resolves.toBe(true);
await expect(resource?.authorize(member)).resolves.toBe(false);
});
test('authorizes a user granted execute via a project role', async () => {
const webhookPath = randomUUID();
const node = webhookNode();
const workflow = await createDraftWorkflow(node);
await shareWorkflowWithUsers(workflow, [member]);
await registerTestWebhook(webhookPath, node, { workflowId: workflow.id });
const resource = await resolveResource(webhookPath);
await expect(resource?.authorize(member)).resolves.toBe(true);
});
test('authorizes any authenticated user when require-execute is turned off', async () => {
const webhookPath = randomUUID();
const node = webhookNode({ requireExecuteAccess: false });
const workflow = await createDraftWorkflow(node);
await registerTestWebhook(webhookPath, node, { workflowId: workflow.id });
const resource = await resolveResource(webhookPath);
await expect(resource?.authorize(member)).resolves.toBe(true);
});
});
@@ -4,6 +4,7 @@ import { FormTriggerTestResourceResolver } from './form-trigger-test-resource.re
import { FormTriggerResourceResolver } from './form-trigger-resource.resolver';
import { WorkflowMcpTestTriggerResourceResolver } from './workflow-mcp-test-trigger-resource.resolver';
import { WorkflowMcpTriggerResourceResolver } from './workflow-mcp-trigger-resource.resolver';
import { WorkflowWebhookTestTriggerResourceResolver } from './workflow-webhook-test-trigger-resource.resolver';
import { WorkflowWebhookTriggerResourceResolver } from './workflow-webhook-trigger-resource.resolver';
export function registerProtectedResourceResolvers() {
@@ -22,4 +23,7 @@ export function registerProtectedResourceResolvers() {
Container.get(ProtectedResourceRegistry).registerResolver(
Container.get(WorkflowWebhookTriggerResourceResolver),
);
Container.get(ProtectedResourceRegistry).registerResolver(
Container.get(WorkflowWebhookTestTriggerResourceResolver),
);
}
@@ -0,0 +1,172 @@
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { WEBHOOK_NODE_TYPE } from 'n8n-workflow';
import { isWebhookOAuth2Enabled } from '@/constants/oauth2-triggers';
import type {
ProtectedResource,
ProtectedResourceResolver,
} from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import { TestWebhooks } from '@/webhooks/test-webhooks';
import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import {
WEBHOOK_TRIGGER_SCOPES,
methodQueryString,
parseMethodParam,
resourceUrlToWebhookPath,
trimSlashes,
trimTrailingSlash,
webhookResourcePath,
} from './utils';
/**
* Test-mode counterpart to {@link WorkflowWebhookTriggerResourceResolver}: resolves
* `/webhook-test/*` paths, backed by {@link TestWebhookRegistrationsService} instead
* of the DB. Mirrors how {@link WorkflowMcpTestTriggerResourceResolver} and
* {@link FormTriggerTestResourceResolver} sit alongside their production resolvers.
*
* The registration holds the workflow exactly as the editor is testing it (including
* unsaved changes), so `workflowEntity` is the source of truth here — there is no
* "active version" to fall back to for a test webhook. Method-selector and dynamic-path
* handling otherwise match the production resolver, since — unlike MCP/Form triggers —
* a webhook trigger can listen on any HTTP method and on a dynamic path.
*/
@Service()
export class WorkflowWebhookTestTriggerResourceResolver implements ProtectedResourceResolver {
constructor(
private readonly config: GlobalConfig,
private readonly testWebhooks: TestWebhooks,
private readonly urlService: UrlService,
private readonly logger: Logger,
private readonly workflowFinderService: WorkflowFinderService,
) {}
readonly id = 'workflow-webhook-test-trigger';
readonly scopes = WEBHOOK_TRIGGER_SCOPES;
async resolveByUrl(resourceUrl: string) {
const pathname = resourceUrlToWebhookPath(resourceUrl, this.urlService.getTestWebhookBaseUrl());
if (pathname === undefined) {
this.logger.debug(`Resource URL is not under the test webhook base URL: ${resourceUrl}`);
return undefined;
}
// Can't throw — `resourceUrlToWebhookPath` already parsed the URL.
return await this.resolveByPath(pathname, new URL(resourceUrl).search);
}
async resolveByPath(pathname: string, search?: string) {
if (!isWebhookOAuth2Enabled()) {
return undefined;
}
if (!pathname.startsWith(`/${this.config.endpoints.webhookTest}/`)) {
// we can quickly rule out non-test-webhook paths without doing any cache work
return undefined;
}
// The method being served selects the trigger (see `methodQueryString`).
const requestedMethod = parseMethodParam(new URLSearchParams(search).get('method'));
const path = trimSlashes(pathname.slice(this.config.endpoints.webhookTest.length + 1));
this.logger.debug(`Resolving workflow webhook test trigger resource for path: ${path}`);
const registrations = await this.testWebhooks.getRegistrationsFromPath(path);
if (registrations.length === 0) {
this.logger.debug(`No test webhook registration found for path: ${path}`);
return undefined;
}
// A test registration's key is `(httpMethod, path)`, mirroring the webhook
// table's primary key in production: the requested method picks exactly one
// registration. Without a method (a bare well-known probe) we can only answer
// when the path hosts a single registration.
const winner = requestedMethod
? registrations.find((r) => r.webhook.httpMethod === requestedMethod)
: registrations.length === 1
? registrations[0]
: undefined;
if (!winner) {
this.logger.debug(
`No test webhook at path ${path} for method ${requestedMethod ?? '(unspecified)'}`,
);
return undefined;
}
// Every method of the *same* trigger becomes an accepted audience, so one token
// spans a multi-method node — same rule as the production resolver.
const triggerMethods = registrations
.filter(
(r) =>
r.webhook.workflowId === winner.webhook.workflowId &&
r.webhook.node === winner.webhook.node,
)
.map((r) => r.webhook.httpMethod);
// `IWebhookData.path` omits the `webhookId` prefix for dynamic webhooks (see
// `webhookResourcePath`), and is the templated path (placeholders intact) either
// way, since `getActiveWebhookFromRegistration` returns the registration's own
// stored webhook object unmodified regardless of the concrete request path.
const resourcePath = webhookResourcePath(winner.webhook.path, winner.webhook.webhookId);
return this.resolveWebhookNode(winner, resourcePath, winner.webhook.httpMethod, triggerMethods);
}
private resolveWebhookNode(
{ workflowEntity, webhook }: TestWebhookRegistration,
resourcePath: string,
requestedMethod: string,
triggerMethods: string[],
): ProtectedResource | undefined {
const node = workflowEntity.nodes.find((n) => n.name === webhook.node);
if (!node) {
this.logger.debug(
`No node found with name ${webhook.node} in test registration for workflow with ID: ${workflowEntity.id}`,
);
return undefined;
}
if (
node.type === WEBHOOK_NODE_TYPE &&
!node.disabled &&
node.parameters.authentication === 'n8nOAuth2'
) {
const baseUrl = `${trimTrailingSlash(this.urlService.getTestWebhookBaseUrl())}/${this.config.endpoints.webhookTest}/${resourcePath}`;
const urlFor = (method: string) => `${baseUrl}${methodQueryString(method)}`;
const methods = [...new Set(triggerMethods.map((method) => method.toUpperCase()))].sort();
const requireExecute = node.parameters.requireExecuteAccess !== false;
return {
id: `workflow-webhook-test:${workflowEntity.id}:${resourcePath}`,
getResourceUrl: () => urlFor(requestedMethod),
getAudiences: () => methods.map(urlFor),
scopes: WEBHOOK_TRIGGER_SCOPES,
displayName: workflowEntity.name,
authorize: async (user: User) => {
if (requireExecute) {
return (
await this.workflowFinderService.findWorkflowIdsWithScopeForUser(
[workflowEntity.id],
user,
['workflow:execute'],
)
).has(workflowEntity.id);
}
return true;
},
};
}
this.logger.debug(
`Node with name ${webhook.node} in test registration for workflow with ID: ${workflowEntity.id} is not an enabled webhook trigger with n8nOAuth2 authentication`,
);
return undefined;
}
}
+23 -9
View File
@@ -248,27 +248,41 @@ export class TestWebhooks implements IWebhookManager {
if (timeout) clearTimeout(timeout);
}
async getWebhooksFromPath(rawPath: string) {
/**
* Find every test-webhook registration at the given path, across all HTTP
* methods. Used by {@link getWebhooksFromPath} and by the OAuth
* protected-resource resolver for test webhook triggers, which (unlike
* {@link getActiveWebhook}) needs the full registration — not just the
* `IWebhookData` — to read the trigger's node parameters straight off
* `workflowEntity` without touching the DB.
*/
async getRegistrationsFromPath(rawPath: string): Promise<TestWebhookRegistration[]> {
const path = removeTrailingSlash(rawPath);
const webhooks: IWebhookData[] = [];
const found: TestWebhookRegistration[] = [];
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) {
let registration = registrations?.[key];
if (!registration) {
// 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]);
const candidate = registrations?.[key];
if (candidate && this.getActiveWebhookFromRegistration(segments.join('/'), candidate)) {
registration = candidate;
}
}
if (webhook) {
webhooks.push(webhook);
if (registration) {
found.push(registration);
}
}
return webhooks;
return found;
}
async getWebhooksFromPath(rawPath: string) {
const registrations = await this.getRegistrationsFromPath(rawPath);
return registrations.map((registration) => registration.webhook);
}
async getWebhookMethods(rawPath: string) {