fase(16): integrations module
This commit is contained in:
203
tests/modules/integrations.test.ts
Normal file
203
tests/modules/integrations.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
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<string, string>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user