Files
Autonomous-Bug-Explorer/tests/modules/integrations.test.ts
2026-03-06 07:22:00 -05:00

204 lines
7.8 KiB
TypeScript

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