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; 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; 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; 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; 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; 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; 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(); }); }); });