Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
import type { IDataObject, IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
|
|
|
|
import {
|
|
createWebhook,
|
|
deleteWebhook,
|
|
findWebhookByUrl,
|
|
generateWebhookSecret,
|
|
listWebhooks,
|
|
updateWebhook,
|
|
verifyWebhook,
|
|
} from '../CurrentsTriggerHelpers';
|
|
|
|
describe('CurrentsTriggerHelpers', () => {
|
|
describe('generateWebhookSecret', () => {
|
|
it('should generate a 64-character hex string', () => {
|
|
const secret = generateWebhookSecret();
|
|
expect(secret).toHaveLength(64);
|
|
expect(/^[0-9a-f]+$/.test(secret)).toBe(true);
|
|
});
|
|
|
|
it('should generate unique secrets', () => {
|
|
const secret1 = generateWebhookSecret();
|
|
const secret2 = generateWebhookSecret();
|
|
expect(secret1).not.toBe(secret2);
|
|
});
|
|
});
|
|
|
|
describe('verifyWebhook', () => {
|
|
let mockWebhookFunctions: Partial<IWebhookFunctions>;
|
|
|
|
beforeEach(() => {
|
|
mockWebhookFunctions = {
|
|
getRequestObject: jest.fn(),
|
|
getHeaderData: jest.fn(),
|
|
getWorkflowStaticData: jest.fn(),
|
|
};
|
|
});
|
|
|
|
it('should return true when no secret in static data (no verification)', () => {
|
|
const nowMs = Date.now();
|
|
|
|
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
|
headers: { 'x-timestamp': String(nowMs) },
|
|
});
|
|
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({});
|
|
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({});
|
|
|
|
const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when timestamp is stale', () => {
|
|
const tenMinutesAgoMs = Date.now() - 600 * 1000;
|
|
|
|
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
|
headers: { 'x-timestamp': String(tenMinutesAgoMs) },
|
|
});
|
|
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({});
|
|
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({});
|
|
|
|
const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return false when timestamp is invalid/non-numeric', () => {
|
|
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
|
headers: { 'x-timestamp': 'not-a-number' },
|
|
});
|
|
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({});
|
|
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({});
|
|
|
|
const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return true when secret matches from static data', () => {
|
|
const nowMs = Date.now();
|
|
const secret = 'auto-generated-secret';
|
|
|
|
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
|
headers: { 'x-timestamp': String(nowMs) },
|
|
});
|
|
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
|
|
'x-webhook-secret': secret,
|
|
});
|
|
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
|
|
webhookSecret: secret,
|
|
} as IDataObject);
|
|
|
|
const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when secret does not match (different length)', () => {
|
|
const nowMs = Date.now();
|
|
|
|
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
|
headers: { 'x-timestamp': String(nowMs) },
|
|
});
|
|
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
|
|
'x-webhook-secret': 'wrong-secret',
|
|
});
|
|
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
|
|
webhookSecret: 'correct-secret',
|
|
} as IDataObject);
|
|
|
|
const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return false when secret does not match (same length)', () => {
|
|
const nowMs = Date.now();
|
|
|
|
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
|
headers: { 'x-timestamp': String(nowMs) },
|
|
});
|
|
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
|
|
'x-webhook-secret': 'wrong-secret-aa',
|
|
});
|
|
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
|
|
webhookSecret: 'correct-secret',
|
|
} as IDataObject);
|
|
|
|
const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return false when secret header is missing but expected', () => {
|
|
const nowMs = Date.now();
|
|
|
|
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
|
headers: { 'x-timestamp': String(nowMs) },
|
|
});
|
|
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({});
|
|
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
|
|
webhookSecret: 'expected-secret',
|
|
} as IDataObject);
|
|
|
|
const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should handle missing timestamp header gracefully', () => {
|
|
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
|
headers: {},
|
|
});
|
|
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({});
|
|
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({});
|
|
|
|
const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('listWebhooks', () => {
|
|
let mockHookFunctions: Partial<IHookFunctions>;
|
|
|
|
beforeEach(() => {
|
|
mockHookFunctions = {
|
|
helpers: {
|
|
httpRequestWithAuthentication: jest.fn(),
|
|
} as unknown as IHookFunctions['helpers'],
|
|
};
|
|
});
|
|
|
|
it('should return webhooks from API response', async () => {
|
|
const mockWebhooks = [
|
|
{
|
|
hookId: 'hook-1',
|
|
projectId: 'project-123',
|
|
url: 'https://example.com/webhook1',
|
|
hookEvents: ['RUN_FINISH'],
|
|
},
|
|
{
|
|
hookId: 'hook-2',
|
|
projectId: 'project-123',
|
|
url: 'https://example.com/webhook2',
|
|
hookEvents: ['RUN_START', 'RUN_FINISH'],
|
|
},
|
|
];
|
|
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({
|
|
data: mockWebhooks,
|
|
});
|
|
|
|
const result = await listWebhooks.call(mockHookFunctions as IHookFunctions, 'project-123');
|
|
|
|
expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
|
'currentsApi',
|
|
{
|
|
method: 'GET',
|
|
url: 'https://api.currents.dev/v1/webhooks',
|
|
qs: { projectId: 'project-123' },
|
|
},
|
|
);
|
|
expect(result).toEqual(mockWebhooks);
|
|
});
|
|
|
|
it('should return empty array when no webhooks exist', async () => {
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({
|
|
data: null,
|
|
});
|
|
|
|
const result = await listWebhooks.call(mockHookFunctions as IHookFunctions, 'project-123');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('findWebhookByUrl', () => {
|
|
let mockHookFunctions: Partial<IHookFunctions>;
|
|
|
|
beforeEach(() => {
|
|
mockHookFunctions = {
|
|
helpers: {
|
|
httpRequestWithAuthentication: jest.fn(),
|
|
} as unknown as IHookFunctions['helpers'],
|
|
};
|
|
});
|
|
|
|
it('should find webhook matching URL', async () => {
|
|
const targetUrl = 'https://example.com/webhook2';
|
|
const mockWebhooks = [
|
|
{
|
|
hookId: 'hook-1',
|
|
projectId: 'project-123',
|
|
url: 'https://example.com/webhook1',
|
|
hookEvents: ['RUN_FINISH'],
|
|
},
|
|
{
|
|
hookId: 'hook-2',
|
|
projectId: 'project-123',
|
|
url: targetUrl,
|
|
hookEvents: ['RUN_START'],
|
|
},
|
|
];
|
|
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({
|
|
data: mockWebhooks,
|
|
});
|
|
|
|
const result = await findWebhookByUrl.call(
|
|
mockHookFunctions as IHookFunctions,
|
|
'project-123',
|
|
targetUrl,
|
|
);
|
|
|
|
expect(result).toEqual(mockWebhooks[1]);
|
|
});
|
|
|
|
it('should return undefined when no webhook matches URL', async () => {
|
|
const mockWebhooks = [
|
|
{
|
|
hookId: 'hook-1',
|
|
projectId: 'project-123',
|
|
url: 'https://example.com/webhook1',
|
|
hookEvents: ['RUN_FINISH'],
|
|
},
|
|
];
|
|
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({
|
|
data: mockWebhooks,
|
|
});
|
|
|
|
const result = await findWebhookByUrl.call(
|
|
mockHookFunctions as IHookFunctions,
|
|
'project-123',
|
|
'https://example.com/nonexistent',
|
|
);
|
|
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('createWebhook', () => {
|
|
let mockHookFunctions: Partial<IHookFunctions>;
|
|
|
|
beforeEach(() => {
|
|
mockHookFunctions = {
|
|
helpers: {
|
|
httpRequestWithAuthentication: jest.fn(),
|
|
} as unknown as IHookFunctions['helpers'],
|
|
};
|
|
});
|
|
|
|
it('should create webhook with all options', async () => {
|
|
const createdWebhook = {
|
|
hookId: 'new-hook-id',
|
|
projectId: 'project-123',
|
|
url: 'https://example.com/webhook',
|
|
hookEvents: ['RUN_FINISH', 'RUN_START'],
|
|
headers: '{"x-webhook-secret":"secret123"}',
|
|
label: 'n8n workflow 456',
|
|
};
|
|
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({
|
|
data: createdWebhook,
|
|
});
|
|
|
|
const result = await createWebhook.call(mockHookFunctions as IHookFunctions, 'project-123', {
|
|
url: 'https://example.com/webhook',
|
|
hookEvents: ['RUN_FINISH', 'RUN_START'],
|
|
headers: '{"x-webhook-secret":"secret123"}',
|
|
label: 'n8n workflow 456',
|
|
});
|
|
|
|
expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
|
'currentsApi',
|
|
{
|
|
method: 'POST',
|
|
url: 'https://api.currents.dev/v1/webhooks',
|
|
qs: { projectId: 'project-123' },
|
|
body: {
|
|
url: 'https://example.com/webhook',
|
|
hookEvents: ['RUN_FINISH', 'RUN_START'],
|
|
headers: '{"x-webhook-secret":"secret123"}',
|
|
label: 'n8n workflow 456',
|
|
},
|
|
},
|
|
);
|
|
expect(result).toEqual(createdWebhook);
|
|
});
|
|
|
|
it('should create webhook with minimal options', async () => {
|
|
const createdWebhook = {
|
|
hookId: 'new-hook-id',
|
|
projectId: 'project-123',
|
|
url: 'https://example.com/webhook',
|
|
hookEvents: [],
|
|
};
|
|
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({
|
|
data: createdWebhook,
|
|
});
|
|
|
|
const result = await createWebhook.call(mockHookFunctions as IHookFunctions, 'project-123', {
|
|
url: 'https://example.com/webhook',
|
|
});
|
|
|
|
expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
|
'currentsApi',
|
|
{
|
|
method: 'POST',
|
|
url: 'https://api.currents.dev/v1/webhooks',
|
|
qs: { projectId: 'project-123' },
|
|
body: {
|
|
url: 'https://example.com/webhook',
|
|
hookEvents: [],
|
|
headers: undefined,
|
|
label: undefined,
|
|
},
|
|
},
|
|
);
|
|
expect(result).toEqual(createdWebhook);
|
|
});
|
|
});
|
|
|
|
describe('updateWebhook', () => {
|
|
let mockHookFunctions: Partial<IHookFunctions>;
|
|
|
|
beforeEach(() => {
|
|
mockHookFunctions = {
|
|
helpers: {
|
|
httpRequestWithAuthentication: jest.fn(),
|
|
} as unknown as IHookFunctions['helpers'],
|
|
};
|
|
});
|
|
|
|
it('should update webhook with new events', async () => {
|
|
const updatedWebhook = {
|
|
hookId: 'hook-123',
|
|
projectId: 'project-123',
|
|
url: 'https://example.com/webhook',
|
|
hookEvents: ['RUN_FINISH', 'RUN_TIMEOUT'],
|
|
};
|
|
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({
|
|
data: updatedWebhook,
|
|
});
|
|
|
|
const result = await updateWebhook.call(mockHookFunctions as IHookFunctions, 'hook-123', {
|
|
hookEvents: ['RUN_FINISH', 'RUN_TIMEOUT'],
|
|
});
|
|
|
|
expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
|
'currentsApi',
|
|
{
|
|
method: 'PUT',
|
|
url: 'https://api.currents.dev/v1/webhooks/hook-123',
|
|
body: {
|
|
hookEvents: ['RUN_FINISH', 'RUN_TIMEOUT'],
|
|
},
|
|
},
|
|
);
|
|
expect(result).toEqual(updatedWebhook);
|
|
});
|
|
|
|
it('should update webhook with multiple fields', async () => {
|
|
const updatedWebhook = {
|
|
hookId: 'hook-123',
|
|
projectId: 'project-123',
|
|
url: 'https://example.com/new-webhook',
|
|
hookEvents: ['RUN_START'],
|
|
headers: '{"x-webhook-secret":"newsecret"}',
|
|
label: 'updated label',
|
|
};
|
|
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({
|
|
data: updatedWebhook,
|
|
});
|
|
|
|
const result = await updateWebhook.call(mockHookFunctions as IHookFunctions, 'hook-123', {
|
|
url: 'https://example.com/new-webhook',
|
|
hookEvents: ['RUN_START'],
|
|
headers: '{"x-webhook-secret":"newsecret"}',
|
|
label: 'updated label',
|
|
});
|
|
|
|
expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
|
'currentsApi',
|
|
{
|
|
method: 'PUT',
|
|
url: 'https://api.currents.dev/v1/webhooks/hook-123',
|
|
body: {
|
|
url: 'https://example.com/new-webhook',
|
|
hookEvents: ['RUN_START'],
|
|
headers: '{"x-webhook-secret":"newsecret"}',
|
|
label: 'updated label',
|
|
},
|
|
},
|
|
);
|
|
expect(result).toEqual(updatedWebhook);
|
|
});
|
|
});
|
|
|
|
describe('deleteWebhook', () => {
|
|
let mockHookFunctions: Partial<IHookFunctions>;
|
|
|
|
beforeEach(() => {
|
|
mockHookFunctions = {
|
|
helpers: {
|
|
httpRequestWithAuthentication: jest.fn(),
|
|
} as unknown as IHookFunctions['helpers'],
|
|
};
|
|
});
|
|
|
|
it('should delete webhook by hookId', async () => {
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({});
|
|
|
|
await deleteWebhook.call(mockHookFunctions as IHookFunctions, 'hook-123');
|
|
|
|
expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
|
'currentsApi',
|
|
{
|
|
method: 'DELETE',
|
|
url: 'https://api.currents.dev/v1/webhooks/hook-123',
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should not throw on successful deletion', async () => {
|
|
(mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({});
|
|
|
|
await expect(
|
|
deleteWebhook.call(mockHookFunctions as IHookFunctions, 'hook-123'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
});
|