mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(Calendly Trigger Node): Use API v2 for webhook subscriptions (#29771)
This commit is contained in:
parent
a316742c92
commit
0edcdcfe85
|
|
@ -6,24 +6,16 @@ import type {
|
|||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
const getAuthenticationType = (data: string): 'accessToken' | 'apiKey' => {
|
||||
// The access token is a JWT, so it will always include dots to separate
|
||||
// header, payoload and signature.
|
||||
return data.includes('.') ? 'accessToken' : 'apiKey';
|
||||
};
|
||||
|
||||
export class CalendlyApi implements ICredentialType {
|
||||
name = 'calendlyApi';
|
||||
|
||||
displayName = 'Calendly API';
|
||||
displayName = 'Calendly Personal Access Token API';
|
||||
|
||||
documentationUrl = 'calendly';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
// Change name to Personal Access Token once API Keys
|
||||
// are deprecated
|
||||
{
|
||||
displayName: 'API Key or Personal Access Token',
|
||||
displayName: 'Personal Access Token',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
|
|
@ -35,23 +27,16 @@ export class CalendlyApi implements ICredentialType {
|
|||
credentials: ICredentialDataDecryptedObject,
|
||||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<IHttpRequestOptions> {
|
||||
//check whether the token is an API Key or an access token
|
||||
const { apiKey } = credentials as { apiKey: string };
|
||||
const tokenType = getAuthenticationType(apiKey);
|
||||
// remove condition once v1 is deprecated
|
||||
// and only inject credentials as an access token
|
||||
if (tokenType === 'accessToken') {
|
||||
requestOptions.headers!.Authorization = `Bearer ${apiKey}`;
|
||||
} else {
|
||||
requestOptions.headers!['X-TOKEN'] = apiKey;
|
||||
}
|
||||
const apiKey = typeof credentials.apiKey === 'string' ? credentials.apiKey : '';
|
||||
requestOptions.headers = requestOptions.headers ?? {};
|
||||
requestOptions.headers.Authorization = `Bearer ${apiKey}`;
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://calendly.com',
|
||||
url: '/api/v1/users/me',
|
||||
baseURL: 'https://api.calendly.com',
|
||||
url: '/users/me',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import type { ICredentialDataDecryptedObject, IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
import { CalendlyApi } from '../CalendlyApi.credentials';
|
||||
|
||||
describe('CalendlyApi Credential', () => {
|
||||
const credential = new CalendlyApi();
|
||||
|
||||
it('should use Calendly API v2 for credential tests', () => {
|
||||
expect(credential.name).toBe('calendlyApi');
|
||||
expect(credential.displayName).toBe('Calendly Personal Access Token API');
|
||||
expect(credential.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
displayName: 'Personal Access Token',
|
||||
name: 'apiKey',
|
||||
typeOptions: { password: true },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(credential.test.request).toEqual({
|
||||
baseURL: 'https://api.calendly.com',
|
||||
url: '/users/me',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add personal access token as a bearer token', async () => {
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: '/users/me',
|
||||
};
|
||||
|
||||
const result = await credential.authenticate(
|
||||
{ apiKey: 'test-personal-access-token' } satisfies ICredentialDataDecryptedObject,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-personal-access-token',
|
||||
});
|
||||
expect(result.headers).not.toHaveProperty('X-TOKEN');
|
||||
});
|
||||
});
|
||||
|
|
@ -8,10 +8,36 @@ import type {
|
|||
INodeTypeDescription,
|
||||
IWebhookResponseData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { verifySignature } from './CalendlyTriggerHelpers';
|
||||
import { calendlyApiRequest, getAuthenticationType } from './GenericFunctions';
|
||||
import { calendlyApiRequest } from './GenericFunctions';
|
||||
|
||||
function isDataObject(value: unknown): value is IDataObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getStringField(data: IDataObject | undefined, key: string): string | undefined {
|
||||
const value = data?.[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function getStringArrayField(data: IDataObject, key: string): string[] | undefined {
|
||||
const value = data[key];
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
|
||||
const strings: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') return undefined;
|
||||
strings.push(item);
|
||||
}
|
||||
|
||||
return strings;
|
||||
}
|
||||
|
||||
function isValidCalendlyWebhookUrl(webhookUrl: string | undefined): webhookUrl is string {
|
||||
return webhookUrl?.startsWith('https://') === true;
|
||||
}
|
||||
|
||||
export class CalendlyTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
|
@ -66,31 +92,18 @@ export class CalendlyTrigger implements INodeType {
|
|||
value: 'oAuth2',
|
||||
},
|
||||
{
|
||||
name: 'API Key or Personal Access Token',
|
||||
name: 'Personal Access Token',
|
||||
value: 'apiKey',
|
||||
},
|
||||
],
|
||||
default: 'apiKey',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Action required: Calendly will discontinue API Key authentication on May 31, 2025. Update node to use OAuth2 authentication now to ensure your workflows continue to work.',
|
||||
name: 'deprecationNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['apiKey'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Scope',
|
||||
name: 'scope',
|
||||
type: 'options',
|
||||
default: 'user',
|
||||
required: true,
|
||||
hint: 'Ignored if you are using an API Key',
|
||||
options: [
|
||||
{
|
||||
name: 'Organization',
|
||||
|
|
@ -134,56 +147,49 @@ export class CalendlyTrigger implements INodeType {
|
|||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const events = this.getNodeParameter('events') as string[];
|
||||
|
||||
const authenticationType = await getAuthenticationType.call(this);
|
||||
const scope = this.getNodeParameter('scope', 0) as string;
|
||||
const userResponse = await calendlyApiRequest.call(this, 'GET', '/users/me');
|
||||
const user = isDataObject(userResponse.resource) ? userResponse.resource : undefined;
|
||||
const organization = getStringField(user, 'current_organization');
|
||||
const userUri = getStringField(user, 'uri');
|
||||
|
||||
// remove condition once API Keys are deprecated
|
||||
if (authenticationType === 'apiKey') {
|
||||
const endpoint = '/hooks';
|
||||
const { data } = await calendlyApiRequest.call(this, 'GET', endpoint, {});
|
||||
if (organization === undefined || (scope === 'user' && userUri === undefined)) return false;
|
||||
|
||||
for (const webhook of data) {
|
||||
if (webhook.attributes.url === webhookUrl) {
|
||||
for (const event of events) {
|
||||
if (!webhook.attributes.events.includes(event)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set webhook-id to be sure that it can be deleted
|
||||
webhookData.webhookId = webhook.id as string;
|
||||
return true;
|
||||
}
|
||||
const qs: IDataObject = {};
|
||||
|
||||
if (scope === 'user') {
|
||||
qs.scope = 'user';
|
||||
qs.organization = organization;
|
||||
qs.user = userUri;
|
||||
}
|
||||
|
||||
if (authenticationType === 'accessToken') {
|
||||
const scope = this.getNodeParameter('scope', 0) as string;
|
||||
const { resource } = await calendlyApiRequest.call(this, 'GET', '/users/me');
|
||||
if (scope === 'organization') {
|
||||
qs.scope = 'organization';
|
||||
qs.organization = organization;
|
||||
}
|
||||
|
||||
const qs: IDataObject = {};
|
||||
const endpoint = '/webhook_subscriptions';
|
||||
const subscriptionsResponse = await calendlyApiRequest.call(this, 'GET', endpoint, {}, qs);
|
||||
const collection = Array.isArray(subscriptionsResponse.collection)
|
||||
? subscriptionsResponse.collection
|
||||
: [];
|
||||
|
||||
if (scope === 'user') {
|
||||
qs.scope = 'user';
|
||||
qs.organization = resource.current_organization;
|
||||
qs.user = resource.uri;
|
||||
}
|
||||
for (const webhook of collection) {
|
||||
if (!isDataObject(webhook)) continue;
|
||||
|
||||
if (scope === 'organization') {
|
||||
qs.scope = 'organization';
|
||||
qs.organization = resource.current_organization;
|
||||
}
|
||||
const callbackUrl = getStringField(webhook, 'callback_url');
|
||||
const webhookEvents = getStringArrayField(webhook, 'events');
|
||||
const webhookUri = getStringField(webhook, 'uri');
|
||||
|
||||
const endpoint = '/webhook_subscriptions';
|
||||
const { collection } = await calendlyApiRequest.call(this, 'GET', endpoint, {}, qs);
|
||||
|
||||
for (const webhook of collection) {
|
||||
if (
|
||||
webhook.callback_url === webhookUrl &&
|
||||
events.length === webhook.events.length &&
|
||||
events.every((event: string) => webhook.events.includes(event))
|
||||
) {
|
||||
webhookData.webhookURI = webhook.uri;
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
callbackUrl === webhookUrl &&
|
||||
webhookUri !== undefined &&
|
||||
webhookEvents !== undefined &&
|
||||
events.length === webhookEvents.length &&
|
||||
events.every((event) => webhookEvents.includes(event))
|
||||
) {
|
||||
webhookData.webhookURI = webhookUri;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,98 +200,67 @@ export class CalendlyTrigger implements INodeType {
|
|||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const events = this.getNodeParameter('events') as string[];
|
||||
|
||||
const authenticationType = await getAuthenticationType.call(this);
|
||||
|
||||
// remove condition once API Keys are deprecated
|
||||
if (authenticationType === 'apiKey') {
|
||||
const endpoint = '/hooks';
|
||||
|
||||
const body = {
|
||||
url: webhookUrl,
|
||||
events,
|
||||
};
|
||||
|
||||
const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body);
|
||||
|
||||
if (responseData.id === undefined) {
|
||||
// Required data is missing so was not successful
|
||||
return false;
|
||||
}
|
||||
|
||||
webhookData.webhookId = responseData.id as string;
|
||||
if (!isValidCalendlyWebhookUrl(webhookUrl)) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Calendly requires a public HTTPS webhook URL',
|
||||
{
|
||||
description:
|
||||
'Set the n8n webhook URL to a public HTTPS address, or use a tunnel while testing locally.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (authenticationType === 'accessToken') {
|
||||
const scope = this.getNodeParameter('scope', 0) as string;
|
||||
const { resource } = await calendlyApiRequest.call(this, 'GET', '/users/me');
|
||||
const scope = this.getNodeParameter('scope', 0) as string;
|
||||
const userResponse = await calendlyApiRequest.call(this, 'GET', '/users/me');
|
||||
const user = isDataObject(userResponse.resource) ? userResponse.resource : undefined;
|
||||
const organization = getStringField(user, 'current_organization');
|
||||
const userUri = getStringField(user, 'uri');
|
||||
|
||||
const webhookSecret = randomBytes(32).toString('hex');
|
||||
if (organization === undefined || (scope === 'user' && userUri === undefined)) return false;
|
||||
|
||||
const body: IDataObject = {
|
||||
url: webhookUrl,
|
||||
events,
|
||||
organization: resource.current_organization,
|
||||
scope,
|
||||
signing_key: webhookSecret,
|
||||
};
|
||||
const webhookSecret = randomBytes(32).toString('hex');
|
||||
const body: IDataObject = {
|
||||
url: webhookUrl,
|
||||
events,
|
||||
organization,
|
||||
scope,
|
||||
signing_key: webhookSecret,
|
||||
};
|
||||
|
||||
if (scope === 'user') {
|
||||
body.user = resource.uri;
|
||||
}
|
||||
|
||||
const endpoint = '/webhook_subscriptions';
|
||||
const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body);
|
||||
|
||||
if (responseData?.resource === undefined || responseData?.resource?.uri === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
webhookData.webhookURI = responseData.resource.uri;
|
||||
webhookData.webhookSecret = webhookSecret;
|
||||
if (scope === 'user') {
|
||||
body.user = userUri;
|
||||
}
|
||||
|
||||
const endpoint = '/webhook_subscriptions';
|
||||
const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body);
|
||||
const subscription = isDataObject(responseData.resource)
|
||||
? responseData.resource
|
||||
: undefined;
|
||||
const subscriptionUri = getStringField(subscription, 'uri');
|
||||
|
||||
if (subscriptionUri === undefined) return false;
|
||||
|
||||
webhookData.webhookURI = subscriptionUri;
|
||||
webhookData.webhookSecret = webhookSecret;
|
||||
|
||||
return true;
|
||||
},
|
||||
async delete(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const authenticationType = await getAuthenticationType.call(this);
|
||||
const { webhookURI } = webhookData;
|
||||
|
||||
// remove condition once API Keys are deprecated
|
||||
if (authenticationType === 'apiKey') {
|
||||
if (webhookData.webhookId !== undefined) {
|
||||
const endpoint = `/hooks/${webhookData.webhookId}`;
|
||||
|
||||
try {
|
||||
await calendlyApiRequest.call(this, 'DELETE', endpoint);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from the static workflow data so that it is clear
|
||||
// that no webhooks are registered anymore
|
||||
delete webhookData.webhookId;
|
||||
if (typeof webhookURI === 'string') {
|
||||
try {
|
||||
await calendlyApiRequest.call(this, 'DELETE', '', {}, {}, webhookURI);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticationType === 'accessToken') {
|
||||
if (webhookData.webhookURI !== undefined) {
|
||||
try {
|
||||
await calendlyApiRequest.call(
|
||||
this,
|
||||
'DELETE',
|
||||
'',
|
||||
{},
|
||||
{},
|
||||
webhookData.webhookURI as string,
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete webhookData.webhookURI;
|
||||
delete webhookData.webhookSecret;
|
||||
}
|
||||
delete webhookData.webhookURI;
|
||||
delete webhookData.webhookSecret;
|
||||
}
|
||||
delete webhookData.webhookId;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,22 +8,11 @@ import type {
|
|||
IRequestOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
function getAuthenticationTypeFromApiKey(data: string): 'accessToken' | 'apiKey' {
|
||||
// The access token is a JWT, so it will always include dots to separate
|
||||
// header, payoload and signature.
|
||||
return data.includes('.') ? 'accessToken' : 'apiKey';
|
||||
}
|
||||
|
||||
export async function getAuthenticationType(
|
||||
export function getCredentialsType(
|
||||
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||
): Promise<'accessToken' | 'apiKey'> {
|
||||
): 'calendlyApi' | 'calendlyOAuth2Api' {
|
||||
const authentication = this.getNodeParameter('authentication', 0) as string;
|
||||
if (authentication === 'apiKey') {
|
||||
const { apiKey } = await this.getCredentials<{ apiKey: string }>('calendlyApi');
|
||||
return getAuthenticationTypeFromApiKey(apiKey);
|
||||
} else {
|
||||
return 'accessToken';
|
||||
}
|
||||
return authentication === 'apiKey' ? 'calendlyApi' : 'calendlyOAuth2Api';
|
||||
}
|
||||
|
||||
export async function calendlyApiRequest(
|
||||
|
|
@ -31,23 +20,16 @@ export async function calendlyApiRequest(
|
|||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
|
||||
body: any = {},
|
||||
body: IDataObject = {},
|
||||
query: IDataObject = {},
|
||||
uri?: string,
|
||||
option: IDataObject = {},
|
||||
): Promise<any> {
|
||||
const authenticationType = await getAuthenticationType.call(this);
|
||||
|
||||
): Promise<IDataObject> {
|
||||
const headers: IDataObject = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
let endpoint = 'https://api.calendly.com';
|
||||
|
||||
// remove once API key is deprecated
|
||||
if (authenticationType === 'apiKey') {
|
||||
endpoint = 'https://calendly.com/api/v1';
|
||||
}
|
||||
const endpoint = 'https://api.calendly.com';
|
||||
|
||||
let options: IRequestOptions = {
|
||||
headers,
|
||||
|
|
@ -58,17 +40,14 @@ export async function calendlyApiRequest(
|
|||
json: true,
|
||||
};
|
||||
|
||||
if (!Object.keys(body as IDataObject).length) {
|
||||
delete options.form;
|
||||
if (!Object.keys(body).length) {
|
||||
delete options.body;
|
||||
}
|
||||
if (!Object.keys(query).length) {
|
||||
delete options.qs;
|
||||
}
|
||||
options = Object.assign({}, options, option);
|
||||
|
||||
const credentialsType =
|
||||
(this.getNodeParameter('authentication', 0) as string) === 'apiKey'
|
||||
? 'calendlyApi'
|
||||
: 'calendlyOAuth2Api';
|
||||
const credentialsType = getCredentialsType.call(this);
|
||||
return await this.helpers.requestWithAuthentication.call(this, credentialsType, options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { IHookFunctions, IDataObject, IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { CalendlyTrigger } from '../CalendlyTrigger.node';
|
||||
import { verifySignature } from '../CalendlyTriggerHelpers';
|
||||
import { calendlyApiRequest, getAuthenticationType } from '../GenericFunctions';
|
||||
|
||||
jest.mock('../GenericFunctions');
|
||||
jest.mock('../CalendlyTriggerHelpers');
|
||||
jest.mock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
|
|
@ -13,49 +12,231 @@ jest.mock('crypto', () => ({
|
|||
}));
|
||||
|
||||
describe('CalendlyTrigger', () => {
|
||||
const webhookUrl = 'https://example.com/webhook/calendly';
|
||||
const organizationUri = 'https://api.calendly.com/organizations/ORGANIZATION_ID';
|
||||
const userUri = 'https://api.calendly.com/users/USER_ID';
|
||||
const webhookUri = 'https://api.calendly.com/webhook_subscriptions/WEBHOOK_ID';
|
||||
const webhookSecret = 'a'.repeat(64);
|
||||
|
||||
let trigger: CalendlyTrigger;
|
||||
let requestWithAuthentication: jest.Mock;
|
||||
let webhookData: IDataObject;
|
||||
let mockHookFunctions: jest.Mocked<IHookFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
trigger = new CalendlyTrigger();
|
||||
requestWithAuthentication = jest.fn();
|
||||
webhookData = {};
|
||||
|
||||
(randomBytes as jest.Mock).mockReturnValue({
|
||||
toString: jest.fn().mockReturnValue(webhookSecret),
|
||||
});
|
||||
|
||||
mockHookFunctions = {
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Calendly Trigger', type: 'calendlyTrigger' }),
|
||||
getNodeWebhookUrl: jest.fn().mockReturnValue(webhookUrl),
|
||||
getNodeParameter: jest.fn((name: string) => {
|
||||
if (name === 'authentication') return 'apiKey';
|
||||
if (name === 'events') return ['invitee.created'];
|
||||
if (name === 'scope') return 'user';
|
||||
return undefined;
|
||||
}),
|
||||
getWorkflowStaticData: jest.fn().mockReturnValue(webhookData),
|
||||
helpers: {
|
||||
requestWithAuthentication,
|
||||
},
|
||||
} as unknown as jest.Mocked<IHookFunctions>;
|
||||
|
||||
requestWithAuthentication.mockImplementation(async (_credentialsType, requestOptions) => {
|
||||
if (requestOptions.uri === 'https://api.calendly.com/users/me') {
|
||||
return {
|
||||
resource: {
|
||||
uri: userUri,
|
||||
current_organization: organizationUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
requestOptions.method === 'POST' &&
|
||||
requestOptions.uri === 'https://api.calendly.com/webhook_subscriptions'
|
||||
) {
|
||||
return {
|
||||
resource: {
|
||||
uri: webhookUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
requestOptions.method === 'GET' &&
|
||||
requestOptions.uri === 'https://api.calendly.com/webhook_subscriptions'
|
||||
) {
|
||||
return { collection: [] };
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhookMethods.default.create', () => {
|
||||
it('should generate a signing key and send it to Calendly via v2 API', async () => {
|
||||
const webhookSecret = 'a'.repeat(64);
|
||||
const userResource = {
|
||||
uri: 'https://api.calendly.com/users/USER',
|
||||
current_organization: 'https://api.calendly.com/organizations/ORG',
|
||||
};
|
||||
it('should create a user-scoped webhook subscription', async () => {
|
||||
const result = await trigger.webhookMethods.default.create.call(mockHookFunctions);
|
||||
|
||||
(getAuthenticationType as jest.Mock).mockResolvedValue('accessToken');
|
||||
(randomBytes as jest.Mock).mockReturnValue({
|
||||
toString: jest.fn().mockReturnValue(webhookSecret),
|
||||
});
|
||||
(calendlyApiRequest as jest.Mock)
|
||||
.mockResolvedValueOnce({ resource: userResource })
|
||||
.mockResolvedValueOnce({
|
||||
resource: { uri: 'https://api.calendly.com/webhook_subscriptions/SUB' },
|
||||
});
|
||||
|
||||
const webhookData: Record<string, unknown> = {};
|
||||
const mockHook = {
|
||||
getNodeWebhookUrl: jest.fn().mockReturnValue('https://example.com/webhook'),
|
||||
getNodeParameter: jest.fn().mockImplementation((name: string) => {
|
||||
if (name === 'events') return ['invitee.created'];
|
||||
if (name === 'scope') return 'user';
|
||||
}),
|
||||
getWorkflowStaticData: jest.fn().mockReturnValue(webhookData),
|
||||
} as unknown as IHookFunctions;
|
||||
|
||||
await trigger.webhookMethods!.default.create.call(mockHook);
|
||||
|
||||
expect(calendlyApiRequest).toHaveBeenLastCalledWith(
|
||||
'POST',
|
||||
'/webhook_subscriptions',
|
||||
expect.objectContaining({ signing_key: webhookSecret }),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(webhookData.webhookURI).toBe(webhookUri);
|
||||
expect(webhookData.webhookSecret).toBe(webhookSecret);
|
||||
expect(requestWithAuthentication).toHaveBeenLastCalledWith(
|
||||
'calendlyApi',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
uri: 'https://api.calendly.com/webhook_subscriptions',
|
||||
body: {
|
||||
url: webhookUrl,
|
||||
events: ['invitee.created'],
|
||||
organization: organizationUri,
|
||||
scope: 'user',
|
||||
signing_key: webhookSecret,
|
||||
user: userUri,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an organization-scoped webhook subscription', async () => {
|
||||
mockHookFunctions.getNodeParameter.mockImplementation((name: string) => {
|
||||
if (name === 'authentication') return 'apiKey';
|
||||
if (name === 'events') return ['invitee.created', 'invitee.canceled'];
|
||||
if (name === 'scope') return 'organization';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await trigger.webhookMethods.default.create.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(webhookData.webhookSecret).toBe(webhookSecret);
|
||||
expect(requestWithAuthentication).toHaveBeenLastCalledWith(
|
||||
'calendlyApi',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
uri: 'https://api.calendly.com/webhook_subscriptions',
|
||||
body: {
|
||||
url: webhookUrl,
|
||||
events: ['invitee.created', 'invitee.canceled'],
|
||||
organization: organizationUri,
|
||||
scope: 'organization',
|
||||
signing_key: webhookSecret,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-HTTPS webhook URLs before calling Calendly', async () => {
|
||||
mockHookFunctions.getNodeWebhookUrl.mockReturnValue('http://localhost:5678/webhook/test');
|
||||
|
||||
await expect(trigger.webhookMethods.default.create.call(mockHookFunctions)).rejects.toThrow(
|
||||
'Calendly requires a public HTTPS webhook URL',
|
||||
);
|
||||
expect(requestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhookMethods.default.checkExists', () => {
|
||||
it('should return true when matching webhook subscription exists', async () => {
|
||||
requestWithAuthentication.mockImplementation(async (_credentialsType, requestOptions) => {
|
||||
if (requestOptions.uri === 'https://api.calendly.com/users/me') {
|
||||
return {
|
||||
resource: {
|
||||
uri: userUri,
|
||||
current_organization: organizationUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
collection: [
|
||||
{
|
||||
callback_url: webhookUrl,
|
||||
events: ['invitee.created'],
|
||||
uri: webhookUri,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await trigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(webhookData.webhookURI).toBe(webhookUri);
|
||||
expect(requestWithAuthentication).toHaveBeenLastCalledWith(
|
||||
'calendlyApi',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
uri: 'https://api.calendly.com/webhook_subscriptions',
|
||||
qs: {
|
||||
scope: 'user',
|
||||
organization: organizationUri,
|
||||
user: userUri,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when webhook subscription events do not match', async () => {
|
||||
requestWithAuthentication.mockImplementation(async (_credentialsType, requestOptions) => {
|
||||
if (requestOptions.uri === 'https://api.calendly.com/users/me') {
|
||||
return {
|
||||
resource: {
|
||||
uri: userUri,
|
||||
current_organization: organizationUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
collection: [
|
||||
{
|
||||
callback_url: webhookUrl,
|
||||
events: ['invitee.canceled'],
|
||||
uri: webhookUri,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await trigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(webhookData.webhookURI).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhookMethods.default.delete', () => {
|
||||
it('should delete webhook subscription by stored URI', async () => {
|
||||
webhookData.webhookURI = webhookUri;
|
||||
webhookData.webhookSecret = webhookSecret;
|
||||
|
||||
const result = await trigger.webhookMethods.default.delete.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(requestWithAuthentication).toHaveBeenCalledWith(
|
||||
'calendlyApi',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
uri: webhookUri,
|
||||
}),
|
||||
);
|
||||
expect(webhookData.webhookURI).toBeUndefined();
|
||||
expect(webhookData.webhookSecret).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true when no webhook subscription is stored', async () => {
|
||||
const result = await trigger.webhookMethods.default.delete.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(requestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import type { IHookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { calendlyApiRequest } from '../GenericFunctions';
|
||||
|
||||
describe('Calendly GenericFunctions', () => {
|
||||
const requestWithAuthentication = jest.fn();
|
||||
|
||||
const mockHookFunctions = {
|
||||
getNodeParameter: jest.fn(),
|
||||
helpers: {
|
||||
requestWithAuthentication,
|
||||
},
|
||||
} as unknown as jest.Mocked<IHookFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
requestWithAuthentication.mockResolvedValue({});
|
||||
mockHookFunctions.getNodeParameter.mockReturnValue('apiKey');
|
||||
});
|
||||
|
||||
it('should use personal access token credentials for PAT authentication', async () => {
|
||||
await calendlyApiRequest.call(mockHookFunctions, 'GET', '/users/me');
|
||||
|
||||
expect(requestWithAuthentication).toHaveBeenCalledWith(
|
||||
'calendlyApi',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
uri: 'https://api.calendly.com/users/me',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use OAuth2 credentials for OAuth2 authentication', async () => {
|
||||
mockHookFunctions.getNodeParameter.mockReturnValue('oAuth2');
|
||||
|
||||
await calendlyApiRequest.call(mockHookFunctions, 'GET', '/users/me');
|
||||
|
||||
expect(requestWithAuthentication).toHaveBeenCalledWith(
|
||||
'calendlyOAuth2Api',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
uri: 'https://api.calendly.com/users/me',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should send requests to Calendly API v2', async () => {
|
||||
await calendlyApiRequest.call(mockHookFunctions, 'POST', '/webhook_subscriptions', {
|
||||
events: ['invitee.created'],
|
||||
});
|
||||
|
||||
expect(requestWithAuthentication).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
uri: 'https://api.calendly.com/webhook_subscriptions',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should omit empty body and query string parameters', async () => {
|
||||
await calendlyApiRequest.call(mockHookFunctions, 'GET', '/users/me');
|
||||
|
||||
const requestOptions = requestWithAuthentication.mock.calls[0][1];
|
||||
expect(requestOptions).not.toHaveProperty('body');
|
||||
expect(requestOptions).not.toHaveProperty('qs');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user