import { createHmac } from 'crypto'; import { Integration } from '../../src/modules/integrations/domain/entities/Integration'; import { IntegrationType } from '../../src/modules/integrations/domain/value-objects/IntegrationType'; import { WebhookEndpoint } from '../../src/modules/integrations/domain/entities/WebhookEndpoint'; import { WebhookSecret } from '../../src/modules/integrations/domain/value-objects/WebhookSecret'; import { WebhookDispatcher } from '../../src/modules/integrations/infrastructure/webhooks/WebhookDispatcher'; import { FindingPayload } from '../../src/modules/integrations/domain/ports/IIntegrationProvider'; import { IWebhookEndpointRepository } from '../../src/modules/integrations/domain/ports/IWebhookEndpointRepository'; import { Logger } from 'pino'; // ─── Integration Entity ─────────────────────────────────────────────────────── describe('Integration', () => { it('creates with defaults', () => { const integration = Integration.create({ name: 'My Slack', type: IntegrationType.slack(), config: { webhookUrl: 'https://hooks.slack.com/test' }, }); expect(integration.name).toBe('My Slack'); expect(integration.type.value).toBe('slack'); expect(integration.enabled).toBe(true); expect(integration.config.webhookUrl).toBe('https://hooks.slack.com/test'); }); it('enable and disable', () => { const integration = Integration.create({ name: 'Test', type: IntegrationType.github(), config: {}, }); integration.disable(); expect(integration.enabled).toBe(false); integration.enable(); expect(integration.enabled).toBe(true); }); it('updateConfig merges config', () => { const integration = Integration.create({ name: 'Jira', type: IntegrationType.jira(), config: { host: 'https://old.atlassian.net' }, }); integration.updateConfig({ host: 'https://new.atlassian.net', token: 'tok' }); expect(integration.config.host).toBe('https://new.atlassian.net'); expect(integration.config.token).toBe('tok'); }); }); // ─── IntegrationType ────────────────────────────────────────────────────────── describe('IntegrationType', () => { it('parses all valid types', () => { expect(IntegrationType.fromString('slack').value).toBe('slack'); expect(IntegrationType.fromString('github').value).toBe('github'); expect(IntegrationType.fromString('jira').value).toBe('jira'); expect(IntegrationType.fromString('webhook').value).toBe('webhook'); }); it('throws on invalid type', () => { expect(() => IntegrationType.fromString('unknown')).toThrow(); }); }); // ─── WebhookEndpoint ────────────────────────────────────────────────────────── describe('WebhookEndpoint', () => { it('creates with auto-generated secret', () => { const endpoint = WebhookEndpoint.create({ url: 'https://example.com/hook' }); expect(endpoint.url).toBe('https://example.com/hook'); expect(endpoint.enabled).toBe(true); expect(endpoint.secret.value).toBeTruthy(); expect(endpoint.secret.value.length).toBeGreaterThan(20); }); it('records delivery', () => { const endpoint = WebhookEndpoint.create({ url: 'https://example.com/hook' }); expect(endpoint.lastStatus).toBeUndefined(); endpoint.recordDelivery(200); expect(endpoint.lastStatus).toBe(200); expect(endpoint.lastDeliveredAt).toBeDefined(); }); }); // ─── WebhookSecret ──────────────────────────────────────────────────────────── describe('WebhookSecret', () => { it('generates a secret', () => { const s = WebhookSecret.generate(); expect(s.value.length).toBeGreaterThan(20); }); it('fromString round-trips', () => { const s = WebhookSecret.fromString('mysecret-at-least-16chars'); expect(s.value).toBe('mysecret-at-least-16chars'); }); it('throws when secret too short', () => { expect(() => WebhookSecret.fromString('short')).toThrow(); }); }); // ─── HMAC signature verification ───────────────────────────────────────────── describe('HMAC webhook signature', () => { it('produces valid sha256 signature', () => { const secret = 'test-secret-abc123'; const body = JSON.stringify({ event: 'finding.created', data: { id: '1' } }); const sig = createHmac('sha256', secret).update(body).digest('hex'); expect(sig).toBeTruthy(); expect(sig).toMatch(/^[0-9a-f]{64}$/); }); it('same body + secret → same signature', () => { const secret = 'test-secret'; const body = 'hello world'; const sig1 = createHmac('sha256', secret).update(body).digest('hex'); const sig2 = createHmac('sha256', secret).update(body).digest('hex'); expect(sig1).toBe(sig2); }); it('different body → different signature', () => { const secret = 'test-secret'; const sig1 = createHmac('sha256', secret).update('body1').digest('hex'); const sig2 = createHmac('sha256', secret).update('body2').digest('hex'); expect(sig1).not.toBe(sig2); }); }); // ─── WebhookDispatcher ─────────────────────────────────────────────────────── describe('WebhookDispatcher', () => { const silentLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), child: jest.fn().mockReturnThis(), } as unknown as Logger; it('calls fetch for each enabled endpoint', async () => { const secret = WebhookSecret.fromString('secret123456789abcdef'); const endpoint = WebhookEndpoint.reconstitute( { url: 'https://example.com/hook', secret, enabled: true, createdAt: new Date() }, { toString: () => 'ep-1', equals: () => false } as never ); const mockRepo: IWebhookEndpointRepository = { save: jest.fn(), findById: jest.fn(), findAll: jest.fn(), findEnabled: jest.fn().mockResolvedValue([endpoint]), update: jest.fn(), delete: jest.fn(), }; const fetchMock = jest.fn().mockResolvedValue({ status: 200, ok: true }); global.fetch = fetchMock; const dispatcher = new WebhookDispatcher(mockRepo, silentLogger); const finding: FindingPayload = { id: 'f-1', title: 'XSS in login form', severity: 'high', type: 'xss', description: 'Reflected XSS', sessionId: 's-1', }; await dispatcher.dispatchFinding(finding); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://example.com/hook'); expect(opts.method).toBe('POST'); const headers = opts.headers as Record; expect(headers['X-ABE-Event']).toBe('finding.created'); expect(headers['X-ABE-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/); }); it('does not throw when no endpoints', async () => { const mockRepo: IWebhookEndpointRepository = { save: jest.fn(), findById: jest.fn(), findAll: jest.fn(), findEnabled: jest.fn().mockResolvedValue([]), update: jest.fn(), delete: jest.fn(), }; const dispatcher = new WebhookDispatcher(mockRepo, silentLogger); const finding: FindingPayload = { id: 'f-1', title: 'Test', severity: 'low', type: 'info', description: 'Test', sessionId: 's-1', }; await expect(dispatcher.dispatchFinding(finding)).resolves.toBeUndefined(); }); });