import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { WebhookHandler, _verifyHmac, _renderTemplate } from './webhooks.js'; import type { WebhookConfig } from '../config/schema.js'; import type { InboundMessage } from '../channels/types.js'; import type { IncomingMessage, ServerResponse } from 'http'; import { createHmac } from 'crypto'; import { EventEmitter } from 'events'; function makeWebhook(overrides?: Partial): WebhookConfig { return { name: 'test-hook', message: '{{body}}', output: { channel: 'telegram', peer: '123' }, enabled: true, ...overrides, }; } /** Create a mock IncomingMessage that emits the given body. */ function mockRequest(body: string, headers: Record = {}): IncomingMessage { const emitter = new EventEmitter(); (emitter as any).headers = headers; // Simulate data arriving next tick process.nextTick(() => { emitter.emit('data', Buffer.from(body)); emitter.emit('end'); }); return emitter as unknown as IncomingMessage; } /** Create a mock ServerResponse that captures writeHead and end calls. */ function mockResponse(): ServerResponse & { statusCode_: number; body_: string; headers_: Record } { const res: any = { statusCode_: 0, body_: '', headers_: {}, writeHead(code: number, headers?: Record) { res.statusCode_ = code; if (headers) {res.headers_ = headers;} return res; }, end(body?: string) { res.body_ = body ?? ''; return res; }, }; return res; } describe('WebhookHandler', () => { let handler: WebhookHandler; let mockChannelRegistry: { get: ReturnType }; beforeEach(() => { mockChannelRegistry = { get: vi.fn(), }; }); afterEach(async () => { if (handler) { await handler.disconnect(); } }); it('implements ChannelAdapter interface', () => { handler = new WebhookHandler([], mockChannelRegistry as any); expect(handler.name).toBe('webhook'); expect(handler.status).toBe('disconnected'); }); it('status changes to connected after connect()', async () => { handler = new WebhookHandler([], mockChannelRegistry as any); await handler.connect(); expect(handler.status).toBe('connected'); }); it('status changes to disconnected after disconnect()', async () => { handler = new WebhookHandler([], mockChannelRegistry as any); await handler.connect(); await handler.disconnect(); expect(handler.status).toBe('disconnected'); }); it('lists registered webhook names', () => { const webhooks = [ makeWebhook({ name: 'hook-a' }), makeWebhook({ name: 'hook-b', enabled: false }), ]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); const names = handler.getWebhookNames(); expect(names).toEqual(['hook-a', 'hook-b']); }); it('handleRequest produces correct InboundMessage', async () => { const webhooks = [makeWebhook()]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); const messages: InboundMessage[] = []; handler.onMessage((msg: InboundMessage) => messages.push(msg)); await handler.connect(); const req = mockRequest('hello world'); const res = mockResponse(); const result = await handler.handleRequest('test-hook', req, res); expect(result).toBe(true); expect(res.statusCode_).toBe(202); expect(messages).toHaveLength(1); expect(messages[0].channel).toBe('webhook'); expect(messages[0].senderId).toBe('test-hook'); expect(messages[0].text).toBe('hello world'); }); it('handleRequest uses isolated sender IDs when delivery mode is isolated_job', async () => { const webhooks = [makeWebhook()]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any, 'isolated_job'); const messages: InboundMessage[] = []; handler.onMessage((msg: InboundMessage) => messages.push(msg)); await handler.connect(); const req = mockRequest('hello world'); const res = mockResponse(); const result = await handler.handleRequest('test-hook', req, res); expect(result).toBe(true); expect(messages).toHaveLength(1); expect(messages[0].senderId).toMatch(/^test-hook:run-/); expect(messages[0].metadata?.replyPeerId).toBe('test-hook'); expect(messages[0].metadata?.deliveryMode).toBe('isolated_job'); }); it('returns false for unknown webhook', async () => { handler = new WebhookHandler([], mockChannelRegistry as any); await handler.connect(); const req = mockRequest('test'); const res = mockResponse(); const result = await handler.handleRequest('nonexistent', req, res); expect(result).toBe(false); expect(res.statusCode_).toBe(404); }); it('returns false for disabled webhook', async () => { const webhooks = [makeWebhook({ enabled: false })]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); await handler.connect(); const req = mockRequest('test'); const res = mockResponse(); const result = await handler.handleRequest('test-hook', req, res); expect(result).toBe(false); expect(res.statusCode_).toBe(404); }); it('verifies valid HMAC signature', async () => { const secret = 'my-secret-key'; const webhooks = [makeWebhook({ secret })]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); const messages: InboundMessage[] = []; handler.onMessage((msg: InboundMessage) => messages.push(msg)); await handler.connect(); const body = '{"event":"push"}'; const signature = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex'); const req = mockRequest(body, { 'x-webhook-signature': signature }); const res = mockResponse(); const result = await handler.handleRequest('test-hook', req, res); expect(result).toBe(true); expect(res.statusCode_).toBe(202); expect(messages).toHaveLength(1); }); it('rejects invalid HMAC signature', async () => { const secret = 'my-secret-key'; const webhooks = [makeWebhook({ secret })]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); const messages: InboundMessage[] = []; handler.onMessage((msg: InboundMessage) => messages.push(msg)); await handler.connect(); const req = mockRequest('{"event":"push"}', { 'x-webhook-signature': 'sha256=invalid' }); const res = mockResponse(); const result = await handler.handleRequest('test-hook', req, res); expect(result).toBe(false); expect(res.statusCode_).toBe(401); expect(messages).toHaveLength(0); }); it('rejects missing HMAC signature when secret is configured', async () => { const secret = 'my-secret-key'; const webhooks = [makeWebhook({ secret })]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); const messages: InboundMessage[] = []; handler.onMessage((msg: InboundMessage) => messages.push(msg)); await handler.connect(); const req = mockRequest('{"event":"push"}'); const res = mockResponse(); const result = await handler.handleRequest('test-hook', req, res); expect(result).toBe(false); expect(res.statusCode_).toBe(401); expect(messages).toHaveLength(0); }); it('rejects oversized payloads with 413', async () => { const webhooks = [makeWebhook()]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any, 'shared_session', 16); const messages: InboundMessage[] = []; handler.onMessage((msg: InboundMessage) => messages.push(msg)); await handler.connect(); const req = mockRequest('x'.repeat(64)); const res = mockResponse(); const result = await handler.handleRequest('test-hook', req, res); expect(result).toBe(false); expect(res.statusCode_).toBe(413); expect(messages).toHaveLength(0); }); it('forwards response to output channel on send()', async () => { const mockOutputAdapter = { send: vi.fn().mockResolvedValue(undefined), }; mockChannelRegistry.get.mockReturnValue(mockOutputAdapter); const webhooks = [makeWebhook()]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); await handler.connect(); await handler.send('test-hook', { text: 'Agent response' }); expect(mockChannelRegistry.get).toHaveBeenCalledWith('telegram'); expect(mockOutputAdapter.send).toHaveBeenCalledWith('123', { text: 'Agent response' }); }); it('logs warning when output channel not found', async () => { mockChannelRegistry.get.mockReturnValue(undefined); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const webhooks = [makeWebhook()]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); await handler.connect(); await handler.send('test-hook', { text: 'Agent response' }); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Output channel')); warnSpy.mockRestore(); }); it('logs warning when webhook name not found in send()', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const webhooks = [makeWebhook()]; handler = new WebhookHandler(webhooks, mockChannelRegistry as any); await handler.connect(); await handler.send('nonexistent-hook', { text: 'response' }); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No webhook')); warnSpy.mockRestore(); }); }); describe('renderTemplate', () => { it('replaces {{body}} with raw body', () => { const result = _renderTemplate('Received: {{body}}', 'hello'); expect(result).toBe('Received: hello'); }); it('replaces {{json.field}} with JSON field value', () => { const result = _renderTemplate('Event: {{json.action}}', '{"action":"push","repo":"test"}'); expect(result).toBe('Event: push'); }); it('replaces multiple {{json.field}} placeholders', () => { const result = _renderTemplate( '{{json.action}} on {{json.repo}}', '{"action":"push","repo":"my-repo"}', ); expect(result).toBe('push on my-repo'); }); it('returns empty string for missing JSON fields', () => { const result = _renderTemplate('Value: {{json.missing}}', '{"action":"push"}'); expect(result).toBe('Value: '); }); it('returns empty string for invalid JSON body with json placeholder', () => { const result = _renderTemplate('Value: {{json.field}}', 'not-json'); expect(result).toBe('Value: '); }); it('stringifies non-string JSON values', () => { const result = _renderTemplate('Count: {{json.count}}', '{"count":42}'); expect(result).toBe('Count: 42'); }); it('handles template with both {{body}} and {{json.field}}', () => { const body = '{"action":"deploy"}'; const result = _renderTemplate('Action: {{json.action}}, Raw: {{body}}', body); expect(result).toBe('Action: deploy, Raw: {"action":"deploy"}'); }); }); describe('verifyHmac', () => { it('returns true for valid signature with sha256= prefix', () => { const secret = 'test-secret'; const body = 'test-body'; const sig = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex'); expect(_verifyHmac(body, secret, sig)).toBe(true); }); it('returns true for valid signature without prefix', () => { const secret = 'test-secret'; const body = 'test-body'; const sig = createHmac('sha256', secret).update(body).digest('hex'); expect(_verifyHmac(body, secret, sig)).toBe(true); }); it('returns false for invalid signature', () => { expect(_verifyHmac('body', 'secret', 'sha256=deadbeef')).toBe(false); }); });