import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { verifyHCaptcha } from '../src/integrations/hcaptcha.js'; import { sendNtfyNotification } from '../src/integrations/ntfy.js'; import { sendEmailNotification } from '../src/integrations/email.js'; import type { EmailConfig, NtfyConfig, StoredRequest } from '../src/types/index.js'; describe('verifyHCaptcha', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('returns success boolean when response is ok', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValue({ ok: true, statusText: 'OK', json: async () => ({ success: true }), } as Response); const result = await verifyHCaptcha('token', 'secret'); expect(result.left().present()).toBe(false); expect(result.right().get()).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]!; expect(url).toBe('https://hcaptcha.com/siteverify'); expect(init?.method).toBe('POST'); expect(init?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); expect(init?.body).toBeInstanceOf(URLSearchParams); expect((init?.body as URLSearchParams).get('secret')).toBe('secret'); expect((init?.body as URLSearchParams).get('response')).toBe('token'); }); it('returns error when response is not ok', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValue({ ok: false, statusText: 'Bad Request', } as Response); const result = await verifyHCaptcha('token', 'secret'); expect(result.left().present()).toBe(true); expect(result.left().get().message).toBe('hCaptcha verification failed: Bad Request'); }); }); describe('sendNtfyNotification', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('is a no-op when not enabled or misconfigured', async () => { const fetchMock = vi.mocked(fetch); const config: NtfyConfig = { enabled: false }; const request: StoredRequest = { timestamp: 1, uuid: 'uuid', routeName: 'route1', method: 'POST', headers: {}, body: {}, }; const result = await sendNtfyNotification(config, request); expect(result.left().present()).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); it('posts a notification to the configured server/topic', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValue({ ok: true, statusText: 'OK' } as Response); const config: NtfyConfig = { enabled: true, server: 'https://ntfy.example.com', topic: 'topic1' }; const request: StoredRequest = { timestamp: Date.parse('2020-01-01T00:00:00.000Z'), uuid: 'uuid', routeName: 'route1', method: 'POST', headers: {}, body: {}, }; const result = await sendNtfyNotification(config, request); expect(result.left().present()).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]!; expect(url).toBe('https://ntfy.example.com/topic1'); expect(init?.method).toBe('POST'); expect(init?.headers).toEqual({ Title: 'Webhook received: route1', Tags: 'webhook,posthook', Priority: '3', }); expect(init?.body).toContain('Method: POST'); expect(init?.body).toContain('Timestamp: 2020-01-01T00:00:00.000Z'); expect(init?.body).toContain('UUID: uuid'); }); it('returns an error when ntfy responds with non-2xx', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValue({ ok: false, statusText: 'Unauthorized' } as Response); const config: NtfyConfig = { enabled: true, server: 'https://ntfy.example.com', topic: 'topic1' }; const request: StoredRequest = { timestamp: 1, uuid: 'uuid', routeName: 'route1', method: 'POST', headers: {}, body: {}, }; const result = await sendNtfyNotification(config, request); expect(result.left().present()).toBe(true); expect(result.left().get().message).toBe('ntfy notification failed: Unauthorized'); }); }); describe('sendEmailNotification', () => { beforeEach(() => { vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn().mockResolvedValue(undefined), })), }, })); }); afterEach(() => { vi.clearAllMocks(); }); it('is a no-op when not enabled or misconfigured', async () => { const { default: nodemailer } = await import('nodemailer'); const config: EmailConfig = { enabled: false }; const request: StoredRequest = { timestamp: 1, uuid: 'uuid', routeName: 'route1', method: 'POST', headers: {}, body: {}, }; const result = await sendEmailNotification(config, request); expect(result.left().present()).toBe(false); expect(nodemailer.createTransport).not.toHaveBeenCalled(); }); it('sends an email with the configured settings', async () => { const { default: nodemailer } = await import('nodemailer'); const sendMailMock = vi.fn().mockResolvedValue(undefined); vi.mocked(nodemailer.createTransport).mockReturnValue({ sendMail: sendMailMock, } as any); const config: EmailConfig = { enabled: true, to: 'admin@example.com', from: 'webhook@example.com', host: 'smtp.example.com', port: 587, secure: true, username: 'user', password: 'pass', subject: 'Test Subject', includeBody: true, includeHeaders: false, }; const request: StoredRequest = { timestamp: Date.parse('2020-01-01T00:00:00.000Z'), uuid: 'test-uuid', routeName: 'test-route', method: 'POST', headers: { 'content-type': 'application/json' }, body: { test: 'data' }, }; const result = await sendEmailNotification(config, request); expect(result.left().present()).toBe(false); expect(nodemailer.createTransport).toHaveBeenCalledWith({ host: 'smtp.example.com', port: 587, secure: true, auth: { user: 'user', pass: 'pass', }, }); expect(sendMailMock).toHaveBeenCalledTimes(1); const mailOptions = sendMailMock.mock.calls[0][0]; expect(mailOptions.from).toBe('webhook@example.com'); expect(mailOptions.to).toBe('admin@example.com'); expect(mailOptions.subject).toBe('Test Subject'); expect(mailOptions.html).toContain('test-route'); expect(mailOptions.html).toContain('POST'); expect(mailOptions.html).toContain('2020-01-01T00:00:00.000Z'); expect(mailOptions.html).toContain('test-uuid'); expect(mailOptions.html).toContain('"test": "data"'); }); it('uses default subject when not configured', async () => { const { default: nodemailer } = await import('nodemailer'); const sendMailMock = vi.fn().mockResolvedValue(undefined); vi.mocked(nodemailer.createTransport).mockReturnValue({ sendMail: sendMailMock, } as any); const config: EmailConfig = { enabled: true, to: 'admin@example.com', from: 'webhook@example.com', }; const request: StoredRequest = { timestamp: 1, uuid: 'uuid', routeName: 'my-route', method: 'POST', headers: {}, body: {}, }; await sendEmailNotification(config, request); const mailOptions = sendMailMock.mock.calls[0][0]; expect(mailOptions.subject).toBe('Webhook received: my-route'); }); it('returns an error when email sending fails', async () => { const { default: nodemailer } = await import('nodemailer'); const sendMailMock = vi.fn().mockRejectedValue(new Error('SMTP error')); vi.mocked(nodemailer.createTransport).mockReturnValue({ sendMail: sendMailMock, } as any); const config: EmailConfig = { enabled: true, to: 'admin@example.com', from: 'webhook@example.com', }; const request: StoredRequest = { timestamp: 1, uuid: 'uuid', routeName: 'route1', method: 'POST', headers: {}, body: {}, }; const result = await sendEmailNotification(config, request); expect(result.left().present()).toBe(true); expect(result.left().get().message).toBe('SMTP error'); }); });