Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
710 lines
21 KiB
TypeScript
710 lines
21 KiB
TypeScript
/**
|
|
* Tests for webhook utilities.
|
|
*/
|
|
|
|
import { jsonParse } from 'n8n-workflow';
|
|
|
|
import {
|
|
validateWebhookUrl,
|
|
sendWebhookNotification,
|
|
generateWebhookSignature,
|
|
verifyWebhookSignature,
|
|
WEBHOOK_SIGNATURE_HEADER,
|
|
WEBHOOK_TIMESTAMP_HEADER,
|
|
type WebhookPayload,
|
|
} from '../cli/webhook';
|
|
import type { RunSummary } from '../harness/harness-types';
|
|
|
|
const mockFetch = jest.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
jest.mock('node:dns/promises', () => ({
|
|
resolve: jest.fn().mockResolvedValue(['93.184.216.34']),
|
|
resolve6: jest.fn().mockRejectedValue(new Error('ENODATA')),
|
|
}));
|
|
|
|
/** Helper to create a mock logger */
|
|
function createMockLogger() {
|
|
return {
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
verbose: jest.fn(),
|
|
debug: jest.fn(),
|
|
success: jest.fn(),
|
|
dim: jest.fn(),
|
|
isVerbose: false,
|
|
};
|
|
}
|
|
|
|
/** Helper to create a mock run summary */
|
|
function createMockSummary(overrides: Partial<RunSummary> = {}): RunSummary {
|
|
return {
|
|
totalExamples: 10,
|
|
passed: 8,
|
|
failed: 2,
|
|
errors: 0,
|
|
averageScore: 0.85,
|
|
totalDurationMs: 5000,
|
|
evaluatorAverages: {
|
|
'llm-judge': 0.85,
|
|
programmatic: 0.9,
|
|
},
|
|
langsmith: {
|
|
experimentName: 'test-experiment-2024',
|
|
experimentId: 'exp-uuid-123',
|
|
datasetId: 'dataset-uuid-456',
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('webhook utilities', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('generateWebhookSignature()', () => {
|
|
it('generates consistent signatures for same payload and secret', () => {
|
|
const payload = '{"test": "data"}';
|
|
const secret = 'test-secret-key-1234';
|
|
|
|
const sig1 = generateWebhookSignature(payload, secret);
|
|
const sig2 = generateWebhookSignature(payload, secret);
|
|
|
|
expect(sig1).toBe(sig2);
|
|
expect(sig1).toMatch(/^sha256=[a-f0-9]{64}$/);
|
|
});
|
|
|
|
it('generates different signatures for different payloads', () => {
|
|
const secret = 'test-secret-key-1234';
|
|
|
|
const sig1 = generateWebhookSignature('payload1', secret);
|
|
const sig2 = generateWebhookSignature('payload2', secret);
|
|
|
|
expect(sig1).not.toBe(sig2);
|
|
});
|
|
|
|
it('generates different signatures for different secrets', () => {
|
|
const payload = '{"test": "data"}';
|
|
|
|
const sig1 = generateWebhookSignature(payload, 'secret1');
|
|
const sig2 = generateWebhookSignature(payload, 'secret2');
|
|
|
|
expect(sig1).not.toBe(sig2);
|
|
});
|
|
|
|
it('generates known signature for test vector', () => {
|
|
// This provides a reference for receiver implementations
|
|
const payload = '{"suite":"llm-judge","summary":{"totalExamples":10}}';
|
|
const secret = 'test-webhook-secret-12345678';
|
|
|
|
const signature = generateWebhookSignature(payload, secret);
|
|
|
|
// Verify format
|
|
expect(signature).toMatch(/^sha256=[a-f0-9]{64}$/);
|
|
|
|
// This is the actual expected signature - useful for testing receiver implementations
|
|
expect(signature).toBe(
|
|
'sha256=0905f894294181ac73c6b2d61538c23dfbc5b023f4c2ab83513b1078a7fabe3c',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('verifyWebhookSignature()', () => {
|
|
it('returns true for valid signature', () => {
|
|
const payload = '{"test": "data"}';
|
|
const secret = 'test-secret-key-1234';
|
|
const signature = generateWebhookSignature(payload, secret);
|
|
|
|
expect(verifyWebhookSignature(payload, signature, secret)).toBe(true);
|
|
});
|
|
|
|
it('returns false for invalid signature', () => {
|
|
const payload = '{"test": "data"}';
|
|
const secret = 'test-secret-key-1234';
|
|
|
|
expect(verifyWebhookSignature(payload, 'sha256=invalid', secret)).toBe(false);
|
|
});
|
|
|
|
it('returns false for wrong secret', () => {
|
|
const payload = '{"test": "data"}';
|
|
const signature = generateWebhookSignature(payload, 'correct-secret');
|
|
|
|
expect(verifyWebhookSignature(payload, signature, 'wrong-secret')).toBe(false);
|
|
});
|
|
|
|
it('returns false for tampered payload', () => {
|
|
const secret = 'test-secret-key-1234';
|
|
const signature = generateWebhookSignature('original payload', secret);
|
|
|
|
expect(verifyWebhookSignature('tampered payload', signature, secret)).toBe(false);
|
|
});
|
|
|
|
it('returns false for signature with wrong length', () => {
|
|
const payload = '{"test": "data"}';
|
|
const secret = 'test-secret-key-1234';
|
|
|
|
expect(verifyWebhookSignature(payload, 'sha256=tooshort', secret)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validateWebhookUrl()', () => {
|
|
describe('protocol validation', () => {
|
|
it('accepts valid HTTPS URLs', () => {
|
|
expect(() => validateWebhookUrl('https://example.com/webhook')).not.toThrow();
|
|
expect(() => validateWebhookUrl('https://api.example.com/v1/hook')).not.toThrow();
|
|
expect(() =>
|
|
validateWebhookUrl('https://hooks.slack.com/services/T00/B00/xxx'),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('rejects HTTP URLs', () => {
|
|
expect(() => validateWebhookUrl('http://example.com/webhook')).toThrow(
|
|
'Webhook URL must use HTTPS',
|
|
);
|
|
});
|
|
|
|
it('rejects other protocols', () => {
|
|
expect(() => validateWebhookUrl('ftp://example.com/webhook')).toThrow(
|
|
'Webhook URL must use HTTPS',
|
|
);
|
|
expect(() => validateWebhookUrl('file:///etc/passwd')).toThrow(
|
|
'Webhook URL must use HTTPS',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('localhost blocking', () => {
|
|
it('rejects localhost', () => {
|
|
expect(() => validateWebhookUrl('https://localhost/webhook')).toThrow(
|
|
'Webhook URL cannot target localhost',
|
|
);
|
|
expect(() => validateWebhookUrl('https://localhost:3000/webhook')).toThrow(
|
|
'Webhook URL cannot target localhost',
|
|
);
|
|
});
|
|
|
|
it('rejects 127.0.0.1', () => {
|
|
expect(() => validateWebhookUrl('https://127.0.0.1/webhook')).toThrow(
|
|
'Webhook URL cannot target localhost',
|
|
);
|
|
expect(() => validateWebhookUrl('https://127.0.0.1:8080/webhook')).toThrow(
|
|
'Webhook URL cannot target localhost',
|
|
);
|
|
});
|
|
|
|
it('rejects IPv6 localhost (::1)', () => {
|
|
expect(() => validateWebhookUrl('https://[::1]/webhook')).toThrow(
|
|
'Webhook URL cannot target localhost',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('private IP blocking (SSRF prevention)', () => {
|
|
it('rejects 10.x.x.x addresses', () => {
|
|
expect(() => validateWebhookUrl('https://10.0.0.1/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
expect(() => validateWebhookUrl('https://10.255.255.255/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
expect(() => validateWebhookUrl('https://10.1.2.3:8080/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
});
|
|
|
|
it('rejects 172.16-31.x.x addresses', () => {
|
|
expect(() => validateWebhookUrl('https://172.16.0.1/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
expect(() => validateWebhookUrl('https://172.31.255.255/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
expect(() => validateWebhookUrl('https://172.20.10.5/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
});
|
|
|
|
it('allows 172.x.x.x addresses outside private range', () => {
|
|
expect(() => validateWebhookUrl('https://172.15.0.1/webhook')).not.toThrow();
|
|
expect(() => validateWebhookUrl('https://172.32.0.1/webhook')).not.toThrow();
|
|
});
|
|
|
|
it('rejects 192.168.x.x addresses', () => {
|
|
expect(() => validateWebhookUrl('https://192.168.0.1/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
expect(() => validateWebhookUrl('https://192.168.1.100/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
expect(() => validateWebhookUrl('https://192.168.255.255/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
});
|
|
|
|
it('rejects 169.254.x.x link-local addresses', () => {
|
|
expect(() => validateWebhookUrl('https://169.254.0.1/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
expect(() => validateWebhookUrl('https://169.254.169.254/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
});
|
|
|
|
it('rejects 0.0.0.0', () => {
|
|
expect(() => validateWebhookUrl('https://0.0.0.0/webhook')).toThrow(
|
|
'Webhook URL cannot target private/internal IP addresses',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('internal hostname blocking', () => {
|
|
it('rejects "internal" hostname', () => {
|
|
expect(() => validateWebhookUrl('https://internal/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
expect(() => validateWebhookUrl('https://api.internal/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
});
|
|
|
|
it('rejects "intranet" hostname', () => {
|
|
expect(() => validateWebhookUrl('https://intranet/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
expect(() => validateWebhookUrl('https://app.intranet/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
});
|
|
|
|
it('rejects "corp" hostname', () => {
|
|
expect(() => validateWebhookUrl('https://corp/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
expect(() => validateWebhookUrl('https://api.corp/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
});
|
|
|
|
it('rejects "private" hostname', () => {
|
|
expect(() => validateWebhookUrl('https://private/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
expect(() => validateWebhookUrl('https://services.private/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
});
|
|
|
|
it('rejects "local" hostname', () => {
|
|
expect(() => validateWebhookUrl('https://local/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
expect(() => validateWebhookUrl('https://api.local/webhook')).toThrow(
|
|
'Webhook URL cannot target internal hostname',
|
|
);
|
|
});
|
|
|
|
it('allows hostnames containing blocked words but not ending with them', () => {
|
|
expect(() => validateWebhookUrl('https://internal-api.example.com/webhook')).not.toThrow();
|
|
expect(() =>
|
|
validateWebhookUrl('https://my-intranet-app.example.com/webhook'),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('valid URLs', () => {
|
|
it('accepts various valid public URLs', () => {
|
|
expect(() => validateWebhookUrl('https://hooks.slack.com/services/xxx')).not.toThrow();
|
|
expect(() => validateWebhookUrl('https://api.github.com/webhooks')).not.toThrow();
|
|
expect(() => validateWebhookUrl('https://webhook.site/unique-id')).not.toThrow();
|
|
expect(() => validateWebhookUrl('https://n8n.io/webhook/abc123')).not.toThrow();
|
|
});
|
|
|
|
it('accepts URLs with paths, query params, and ports', () => {
|
|
expect(() =>
|
|
validateWebhookUrl('https://example.com:8443/api/v1/webhook?token=abc'),
|
|
).not.toThrow();
|
|
expect(() =>
|
|
validateWebhookUrl('https://api.example.com/webhooks/eval/notify'),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('invalid URL format', () => {
|
|
it('throws on invalid URL strings', () => {
|
|
expect(() => validateWebhookUrl('not-a-url')).toThrow();
|
|
expect(() => validateWebhookUrl('')).toThrow();
|
|
expect(() => validateWebhookUrl('://missing-protocol.com')).toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('sendWebhookNotification()', () => {
|
|
it('sends POST request with correct payload', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
const summary = createMockSummary();
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary,
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: { source: 'ci', trigger: 'push' },
|
|
logger,
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
expect(mockFetch).toHaveBeenCalledWith('https://example.com/webhook', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: expect.any(String),
|
|
});
|
|
|
|
// Verify payload structure
|
|
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
const payload = jsonParse<WebhookPayload>(callArgs[1].body as string);
|
|
|
|
expect(payload).toEqual({
|
|
suite: 'llm-judge',
|
|
summary: {
|
|
totalExamples: 10,
|
|
passed: 8,
|
|
failed: 2,
|
|
errors: 0,
|
|
averageScore: 0.85,
|
|
},
|
|
evaluatorAverages: {
|
|
'llm-judge': 0.85,
|
|
programmatic: 0.9,
|
|
},
|
|
totalDurationMs: 5000,
|
|
metadata: { source: 'ci', trigger: 'push' },
|
|
langsmith: {
|
|
experimentName: 'test-experiment-2024',
|
|
experimentId: 'exp-uuid-123',
|
|
datasetId: 'dataset-uuid-456',
|
|
datasetName: 'test-dataset',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('logs info messages on success', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
});
|
|
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
'Sending results to webhook: https://example.com/***',
|
|
);
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
'Webhook notification sent successfully (status: 200)',
|
|
);
|
|
});
|
|
|
|
it('throws error when webhook URL validation fails', async () => {
|
|
const logger = createMockLogger();
|
|
|
|
await expect(
|
|
sendWebhookNotification({
|
|
webhookUrl: 'http://example.com/webhook', // HTTP not allowed
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
}),
|
|
).rejects.toThrow('Webhook URL must use HTTPS');
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws error when fetch returns non-ok response', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
|
|
await expect(
|
|
sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
}),
|
|
).rejects.toThrow('Webhook request failed: 500 Internal Server Error');
|
|
});
|
|
|
|
it('throws error when fetch returns 4xx response', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
|
|
await expect(
|
|
sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
}),
|
|
).rejects.toThrow('Webhook request failed: 401 Unauthorized');
|
|
});
|
|
|
|
it('throws error when fetch fails with network error', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const logger = createMockLogger();
|
|
|
|
await expect(
|
|
sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
}),
|
|
).rejects.toThrow('Network error');
|
|
});
|
|
|
|
it('handles summary without evaluatorAverages', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
const summary = createMockSummary({ evaluatorAverages: undefined });
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary,
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
});
|
|
|
|
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
const payload = jsonParse<WebhookPayload>(callArgs[1].body as string);
|
|
|
|
expect(payload.evaluatorAverages).toBeUndefined();
|
|
});
|
|
|
|
it('handles summary without langsmith data (local mode)', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
const summary = createMockSummary({ langsmith: undefined });
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary,
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
});
|
|
|
|
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
const payload = jsonParse<WebhookPayload>(callArgs[1].body as string);
|
|
|
|
expect(payload.langsmith).toBeUndefined();
|
|
});
|
|
|
|
it('validates URL before making fetch request (SSRF prevention)', async () => {
|
|
const logger = createMockLogger();
|
|
|
|
await expect(
|
|
sendWebhookNotification({
|
|
webhookUrl: 'https://192.168.1.1/webhook',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
}),
|
|
).rejects.toThrow('Webhook URL cannot target private/internal IP addresses');
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('HMAC signature', () => {
|
|
it('includes signature headers when webhookSecret is provided', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
webhookSecret: 'test-secret-key-1234',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
const headers = callArgs[1].headers as Record<string, string>;
|
|
|
|
// Verify signature header is present and valid format
|
|
expect(headers[WEBHOOK_SIGNATURE_HEADER]).toMatch(/^sha256=[a-f0-9]{64}$/);
|
|
|
|
// Verify timestamp header is present and valid
|
|
expect(headers[WEBHOOK_TIMESTAMP_HEADER]).toMatch(/^\d+$/);
|
|
const timestamp = parseInt(headers[WEBHOOK_TIMESTAMP_HEADER], 10);
|
|
expect(timestamp).toBeGreaterThan(Date.now() - 60000); // Within last minute
|
|
expect(timestamp).toBeLessThanOrEqual(Date.now());
|
|
});
|
|
|
|
it('signature can be verified by receiver', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
const secret = 'test-secret-key-1234';
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
webhookSecret: secret,
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
});
|
|
|
|
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
const headers = callArgs[1].headers as Record<string, string>;
|
|
const body = callArgs[1].body as string;
|
|
|
|
const signature = headers[WEBHOOK_SIGNATURE_HEADER];
|
|
const timestamp = headers[WEBHOOK_TIMESTAMP_HEADER];
|
|
|
|
// Verify that the receiver can validate the signature
|
|
// Signature is computed as: timestamp.body
|
|
const signaturePayload = `${timestamp}.${body}`;
|
|
expect(verifyWebhookSignature(signaturePayload, signature, secret)).toBe(true);
|
|
});
|
|
|
|
it('does not include signature headers when webhookSecret is not provided', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
});
|
|
|
|
const callArgs = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
const headers = callArgs[1].headers as Record<string, string>;
|
|
|
|
expect(headers[WEBHOOK_SIGNATURE_HEADER]).toBeUndefined();
|
|
expect(headers[WEBHOOK_TIMESTAMP_HEADER]).toBeUndefined();
|
|
});
|
|
|
|
it('logs warning when webhook secret is not provided', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
});
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('No webhook secret provided'),
|
|
);
|
|
});
|
|
|
|
it('logs info when request is signed', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
});
|
|
|
|
const logger = createMockLogger();
|
|
|
|
await sendWebhookNotification({
|
|
webhookUrl: 'https://example.com/webhook',
|
|
webhookSecret: 'test-secret-key-1234',
|
|
summary: createMockSummary(),
|
|
dataset: 'test-dataset',
|
|
suite: 'llm-judge',
|
|
metadata: {},
|
|
logger,
|
|
});
|
|
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
expect.stringContaining('signed with HMAC-SHA256'),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|