mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 10:17:00 +02:00
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import { mock } from 'jest-mock-extended';
|
|
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
|
|
|
|
import { BoxTrigger } from '../BoxTrigger.node';
|
|
|
|
jest.mock('../BoxTriggerHelpers', () => ({
|
|
verifySignature: jest.fn(),
|
|
}));
|
|
|
|
import { verifySignature } from '../BoxTriggerHelpers';
|
|
|
|
const mockedVerifySignature = verifySignature as jest.MockedFunction<typeof verifySignature>;
|
|
|
|
describe('Box Trigger Webhook Lifecycle', () => {
|
|
const mockHookFunctions = mock<IHookFunctions>();
|
|
const mockStaticData: Record<string, string> = {};
|
|
const mockRequestOAuth2 = jest.fn();
|
|
|
|
beforeEach(() => {
|
|
// resetAllMocks clears Once queues and implementations — prevents bleed between tests
|
|
jest.resetAllMocks();
|
|
Object.keys(mockStaticData).forEach((key) => delete mockStaticData[key]);
|
|
mockHookFunctions.getWorkflowStaticData.mockReturnValue(mockStaticData);
|
|
mockHookFunctions.getNodeWebhookUrl.mockReturnValue('https://n8n.io/webhook/box-test');
|
|
mockHookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
|
switch (parameterName) {
|
|
case 'events':
|
|
return ['FILE.UPLOADED'];
|
|
case 'targetId':
|
|
return '12345';
|
|
case 'targetType':
|
|
return 'file';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
mockHookFunctions.helpers = {
|
|
...mockHookFunctions.helpers,
|
|
requestOAuth2: mockRequestOAuth2,
|
|
};
|
|
});
|
|
|
|
describe('checkExists', () => {
|
|
it('should return false and not store webhook ID when no matching webhook exists', async () => {
|
|
// List response: mini-webhook only (new Box API — no address/triggers in list)
|
|
mockRequestOAuth2
|
|
.mockResolvedValueOnce({
|
|
entries: [{ id: 'webhook_123', type: 'webhook', target: { id: '67890', type: 'file' } }],
|
|
// no next_marker — single page
|
|
})
|
|
// Full details for webhook_123 — address doesn't match
|
|
.mockResolvedValueOnce({
|
|
id: 'webhook_123',
|
|
address: 'https://other-app.com/webhook',
|
|
target: { id: '67890', type: 'file' },
|
|
triggers: ['FILE.UPLOADED'],
|
|
});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const exists = await boxTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
|
|
|
expect(exists).toBe(false);
|
|
expect(mockStaticData.webhookId).toBeUndefined();
|
|
});
|
|
|
|
it('should return true and store the correct webhook ID when a matching webhook exists', async () => {
|
|
// List returns two mini-webhooks; webhook_123 is skipped (target.id mismatch),
|
|
// only webhook_456 gets a full GET call
|
|
mockRequestOAuth2
|
|
.mockResolvedValueOnce({
|
|
entries: [
|
|
{ id: 'webhook_123', type: 'webhook', target: { id: '67890', type: 'file' } },
|
|
{ id: 'webhook_456', type: 'webhook', target: { id: '12345', type: 'file' } },
|
|
],
|
|
})
|
|
// Full details only for webhook_456 — webhook_123 is pre-filtered out
|
|
.mockResolvedValueOnce({
|
|
id: 'webhook_456',
|
|
address: 'https://n8n.io/webhook/box-test',
|
|
target: { id: '12345', type: 'file' },
|
|
triggers: ['FILE.UPLOADED'],
|
|
});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const exists = await boxTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
|
|
|
expect(exists).toBe(true);
|
|
expect(mockStaticData.webhookId).toBe('webhook_456');
|
|
});
|
|
|
|
it('should return false when address and target match but a required event is missing', async () => {
|
|
mockRequestOAuth2
|
|
.mockResolvedValueOnce({
|
|
entries: [{ id: 'webhook_456', type: 'webhook', target: { id: '12345', type: 'file' } }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: 'webhook_456',
|
|
address: 'https://n8n.io/webhook/box-test',
|
|
target: { id: '12345', type: 'file' },
|
|
triggers: ['FILE.DOWNLOADED'], // missing FILE.UPLOADED
|
|
});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const exists = await boxTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
|
|
|
expect(exists).toBe(false);
|
|
expect(mockStaticData.webhookId).toBeUndefined();
|
|
});
|
|
|
|
it('should return false when webhook target type does not match', async () => {
|
|
mockRequestOAuth2
|
|
.mockResolvedValueOnce({
|
|
entries: [
|
|
{ id: 'webhook_456', type: 'webhook', target: { id: '12345', type: 'folder' } },
|
|
],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: 'webhook_456',
|
|
address: 'https://n8n.io/webhook/box-test',
|
|
target: { id: '12345', type: 'folder' }, // wrong type
|
|
triggers: ['FILE.UPLOADED'],
|
|
});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const exists = await boxTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
|
|
|
expect(exists).toBe(false);
|
|
expect(mockStaticData.webhookId).toBeUndefined();
|
|
});
|
|
|
|
it('should page through multiple pages using next_marker before fetching individual webhooks', async () => {
|
|
// All list pages are consumed first by boxApiRequestAllItemsMarker,
|
|
// then per-webhook GET calls happen during the checkExists loop.
|
|
// webhook_123 (target 67890) is pre-filtered out — no GET for it.
|
|
mockRequestOAuth2
|
|
// List page 1 — has next_marker
|
|
.mockResolvedValueOnce({
|
|
entries: [{ id: 'webhook_123', type: 'webhook', target: { id: '67890', type: 'file' } }],
|
|
next_marker: 'page2marker',
|
|
})
|
|
// List page 2 (next_marker consumed) — no further pages
|
|
.mockResolvedValueOnce({
|
|
entries: [{ id: 'webhook_456', type: 'webhook', target: { id: '12345', type: 'file' } }],
|
|
})
|
|
// Full details only for webhook_456 — webhook_123 is pre-filtered out
|
|
.mockResolvedValueOnce({
|
|
id: 'webhook_456',
|
|
address: 'https://n8n.io/webhook/box-test',
|
|
target: { id: '12345', type: 'file' },
|
|
triggers: ['FILE.UPLOADED'],
|
|
});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const exists = await boxTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
|
|
|
expect(exists).toBe(true);
|
|
expect(mockStaticData.webhookId).toBe('webhook_456');
|
|
|
|
// 2 list calls (pagination) + 1 GET (webhook_123 skipped by target pre-filter) = 3 total
|
|
// If pagination didn't work, webhook_456 (page 2 only) would never be found
|
|
expect(mockRequestOAuth2).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('should skip the full GET fetch for webhooks whose target does not match', async () => {
|
|
// Two webhooks in list — only second has matching target
|
|
mockRequestOAuth2
|
|
.mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
id: 'webhook_wrong_target',
|
|
type: 'webhook',
|
|
target: { id: 'other_id', type: 'file' },
|
|
},
|
|
{ id: 'webhook_456', type: 'webhook', target: { id: '12345', type: 'file' } },
|
|
],
|
|
})
|
|
// Only one full GET expected — for webhook_456 (wrong_target is skipped)
|
|
.mockResolvedValueOnce({
|
|
id: 'webhook_456',
|
|
address: 'https://n8n.io/webhook/box-test',
|
|
target: { id: '12345', type: 'file' },
|
|
triggers: ['FILE.UPLOADED'],
|
|
});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const exists = await boxTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
|
|
|
expect(exists).toBe(true);
|
|
expect(mockStaticData.webhookId).toBe('webhook_456');
|
|
// 1 list call + 1 GET (not 2) — wrong-target webhook was skipped
|
|
expect(mockRequestOAuth2).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should return false when the webhook list is empty', async () => {
|
|
mockRequestOAuth2.mockResolvedValueOnce({ entries: [] });
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const exists = await boxTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
|
|
|
expect(exists).toBe(false);
|
|
expect(mockStaticData.webhookId).toBeUndefined();
|
|
});
|
|
|
|
it('should match when the stored webhook has a superset of the required events', async () => {
|
|
mockRequestOAuth2
|
|
.mockResolvedValueOnce({
|
|
entries: [{ id: 'webhook_456', type: 'webhook', target: { id: '12345', type: 'file' } }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: 'webhook_456',
|
|
address: 'https://n8n.io/webhook/box-test',
|
|
target: { id: '12345', type: 'file' },
|
|
triggers: ['FILE.UPLOADED', 'FILE.DOWNLOADED'], // superset — should still match
|
|
});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const exists = await boxTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
|
|
|
|
expect(exists).toBe(true);
|
|
expect(mockStaticData.webhookId).toBe('webhook_456');
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create a new webhook and store its ID', async () => {
|
|
mockRequestOAuth2.mockResolvedValue({ id: 'webhook_new_789' });
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const created = await boxTrigger.webhookMethods.default.create.call(mockHookFunctions);
|
|
|
|
expect(created).toBe(true);
|
|
expect(mockStaticData.webhookId).toBe('webhook_new_789');
|
|
expect(mockRequestOAuth2).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
uri: 'https://api.box.com/2.0/webhooks',
|
|
body: {
|
|
address: 'https://n8n.io/webhook/box-test',
|
|
triggers: ['FILE.UPLOADED'],
|
|
target: { id: '12345', type: 'file' },
|
|
},
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('should return false when the API response has no ID', async () => {
|
|
mockRequestOAuth2.mockResolvedValue({});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const created = await boxTrigger.webhookMethods.default.create.call(mockHookFunctions);
|
|
|
|
expect(created).toBe(false);
|
|
expect(mockStaticData.webhookId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should call DELETE with the stored webhook ID and clear static data', async () => {
|
|
mockStaticData.webhookId = 'webhook_456';
|
|
mockRequestOAuth2.mockResolvedValue({});
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const deleted = await boxTrigger.webhookMethods.default.delete.call(mockHookFunctions);
|
|
|
|
expect(deleted).toBe(true);
|
|
expect(mockRequestOAuth2).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
uri: 'https://api.box.com/2.0/webhooks/webhook_456',
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
expect(mockStaticData.webhookId).toBeUndefined();
|
|
});
|
|
|
|
it('should return true without making an API call when no webhook ID is stored', async () => {
|
|
const boxTrigger = new BoxTrigger();
|
|
const deleted = await boxTrigger.webhookMethods.default.delete.call(mockHookFunctions);
|
|
|
|
expect(deleted).toBe(true);
|
|
expect(mockRequestOAuth2).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return false and keep the webhook ID when the API call fails', async () => {
|
|
mockStaticData.webhookId = 'webhook_456';
|
|
mockRequestOAuth2.mockRejectedValue(new Error('API Error'));
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const deleted = await boxTrigger.webhookMethods.default.delete.call(mockHookFunctions);
|
|
|
|
expect(deleted).toBe(false);
|
|
expect(mockStaticData.webhookId).toBe('webhook_456');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Box Trigger webhook()', () => {
|
|
let mockWebhookFunctions: ReturnType<typeof mock<IWebhookFunctions>>;
|
|
let mockResponseObject: { status: jest.Mock; send: jest.Mock; end: jest.Mock };
|
|
|
|
beforeEach(() => {
|
|
jest.resetAllMocks();
|
|
mockWebhookFunctions = mock<IWebhookFunctions>();
|
|
|
|
mockResponseObject = {
|
|
status: jest.fn().mockReturnThis(),
|
|
send: jest.fn().mockReturnThis(),
|
|
end: jest.fn(),
|
|
};
|
|
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponseObject as never);
|
|
mockWebhookFunctions.getBodyData.mockReturnValue({
|
|
type: 'webhook_event',
|
|
trigger: 'FILE.UPLOADED',
|
|
});
|
|
mockWebhookFunctions.helpers = {
|
|
...mockWebhookFunctions.helpers,
|
|
returnJsonArray: jest.fn().mockImplementation((data) => [{ json: data }]),
|
|
};
|
|
});
|
|
|
|
it('should return workflow data when signature is valid', async () => {
|
|
mockedVerifySignature.mockResolvedValue(true);
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const result = await boxTrigger.webhook.call(mockWebhookFunctions);
|
|
|
|
expect(result.workflowData).toBeDefined();
|
|
expect(result.noWebhookResponse).toBeUndefined();
|
|
expect(mockResponseObject.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should respond with 401 and noWebhookResponse when signature is invalid', async () => {
|
|
mockedVerifySignature.mockResolvedValue(false);
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const result = await boxTrigger.webhook.call(mockWebhookFunctions);
|
|
|
|
expect(result.workflowData).toBeUndefined();
|
|
expect(result.noWebhookResponse).toBe(true);
|
|
expect(mockResponseObject.status).toHaveBeenCalledWith(401);
|
|
expect(mockResponseObject.send).toHaveBeenCalledWith('Unauthorized');
|
|
expect(mockResponseObject.end).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return workflow data when no signing keys are configured (backward compatibility)', async () => {
|
|
// verifySignature returns true when no keys are configured
|
|
mockedVerifySignature.mockResolvedValue(true);
|
|
|
|
const boxTrigger = new BoxTrigger();
|
|
const result = await boxTrigger.webhook.call(mockWebhookFunctions);
|
|
|
|
expect(result.workflowData).toBeDefined();
|
|
expect(mockResponseObject.status).not.toHaveBeenCalled();
|
|
});
|
|
});
|