6090508bad
- 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)
308 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|