Files
flynn/src/automation/webhooks.test.ts
T
William Valentin 6090508bad style: auto-fix ESLint issues (curly braces and formatting)
- Add curly braces to all if/else/for/while statements
- Fix indentation and trailing spaces
- Auto-fixed 372 linting errors using eslint --fix
- Remaining issues are warnings only (non-null assertions, explicit any types)
2026-02-11 10:30:24 -08:00

308 lines
10 KiB
TypeScript

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>): 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<string, string> = {}): 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<string, string> } {
const res: any = {
statusCode_: 0,
body_: '',
headers_: {},
writeHead(code: number, headers?: Record<string, string>) {
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<typeof vi.fn> };
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('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('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);
});
});