225 lines
7.2 KiB
TypeScript
225 lines
7.2 KiB
TypeScript
/**
|
|
* Tests for webhook delivery system.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {
|
|
signWebhookPayload,
|
|
verifyWebhookSignature,
|
|
buildWebhookPayload,
|
|
triggerJobWebhook,
|
|
getWebhookDelivery,
|
|
listJobDeliveries,
|
|
getDeliveryStats,
|
|
retryWebhookDelivery,
|
|
resetDeliveryLog,
|
|
type WebhookConfig,
|
|
} from './webhooks.js';
|
|
|
|
describe('webhooks', () => {
|
|
const mockJob = {
|
|
id: 'job-123',
|
|
status: 'completed' as const,
|
|
inputs: [{ text: 'test input' }],
|
|
results: [
|
|
{
|
|
extractions: [{ extraction_class: 'test', extraction_text: 'result' }],
|
|
metadata: { model_id: 'gemini', duration_ms: 100, char_count: 10 },
|
|
},
|
|
],
|
|
errors: [],
|
|
progress: { completed: 1, total: 1 },
|
|
createdAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const mockConfig: WebhookConfig = {
|
|
url: 'https://example.com/webhook',
|
|
secret: 'test-secret',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
resetDeliveryLog();
|
|
});
|
|
|
|
describe('signWebhookPayload', () => {
|
|
it('generates consistent HMAC signatures', () => {
|
|
const payload = '{"test": true}';
|
|
const sig1 = signWebhookPayload(payload, 'secret');
|
|
const sig2 = signWebhookPayload(payload, 'secret');
|
|
expect(sig1).toBe(sig2);
|
|
expect(sig1).toHaveLength(64); // SHA-256 hex
|
|
});
|
|
|
|
it('produces different signatures for different secrets', () => {
|
|
const payload = '{"test": true}';
|
|
const sig1 = signWebhookPayload(payload, 'secret1');
|
|
const sig2 = signWebhookPayload(payload, 'secret2');
|
|
expect(sig1).not.toBe(sig2);
|
|
});
|
|
});
|
|
|
|
describe('verifyWebhookSignature', () => {
|
|
it('returns true for valid signature', () => {
|
|
const payload = '{"test": true}';
|
|
const secret = 'my-secret';
|
|
const signature = signWebhookPayload(payload, secret);
|
|
expect(verifyWebhookSignature(payload, signature, secret)).toBe(true);
|
|
});
|
|
|
|
it('returns false for invalid signature', () => {
|
|
const payload = '{"test": true}';
|
|
const signature = signWebhookPayload(payload, 'correct-secret');
|
|
expect(verifyWebhookSignature(payload, signature, 'wrong-secret')).toBe(false);
|
|
});
|
|
|
|
it('returns false for tampered payload', () => {
|
|
const originalPayload = '{"test": true}';
|
|
const tamperedPayload = '{"test": false}';
|
|
const secret = 'my-secret';
|
|
const signature = signWebhookPayload(originalPayload, secret);
|
|
expect(verifyWebhookSignature(tamperedPayload, signature, secret)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildWebhookPayload', () => {
|
|
it('includes job metadata', () => {
|
|
const payload = JSON.parse(buildWebhookPayload(mockJob));
|
|
expect(payload.event).toBe('job.completed');
|
|
expect(payload.jobId).toBe('job-123');
|
|
expect(payload.status).toBe('completed');
|
|
expect(payload.timestamp).toBeDefined();
|
|
});
|
|
|
|
it('includes result summary', () => {
|
|
const payload = JSON.parse(buildWebhookPayload(mockJob));
|
|
expect(payload.resultSummary).toEqual({
|
|
totalInputs: 1,
|
|
successfulResults: 1,
|
|
errorCount: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('triggerJobWebhook', () => {
|
|
it('delivers webhook successfully', async () => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
});
|
|
|
|
await triggerJobWebhook(mockJob, mockConfig);
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'https://example.com/webhook',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'X-Webhook-Event': 'job.completed',
|
|
}),
|
|
})
|
|
);
|
|
|
|
const delivery = listJobDeliveries('job-123')[0];
|
|
expect(delivery).toBeDefined();
|
|
expect(delivery.status).toBe('delivered');
|
|
});
|
|
|
|
it('records delivery on failure', async () => {
|
|
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
|
|
await triggerJobWebhook(mockJob, mockConfig);
|
|
|
|
const delivery = listJobDeliveries('job-123')[0];
|
|
expect(delivery).toBeDefined();
|
|
expect(delivery.status).toBe('failed');
|
|
expect(delivery.error).toContain('Network error');
|
|
});
|
|
|
|
it('includes correct signature in request', async () => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
await triggerJobWebhook(mockJob, mockConfig);
|
|
|
|
const call = vi.mocked(fetch).mock.calls[0];
|
|
const headers = call[1]?.headers as Record<string, string>;
|
|
const payload = call[1]?.body as string;
|
|
|
|
expect(headers['X-Webhook-Signature']).toMatch(/^sha256=/);
|
|
const signature = headers['X-Webhook-Signature'].replace('sha256=', '');
|
|
expect(verifyWebhookSignature(payload, signature, mockConfig.secret)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('delivery stats', () => {
|
|
it('returns zero stats when no deliveries', () => {
|
|
const stats = getDeliveryStats();
|
|
expect(stats.total).toBe(0);
|
|
expect(stats.delivered).toBe(0);
|
|
expect(stats.failed).toBe(0);
|
|
expect(stats.pending).toBe(0);
|
|
});
|
|
|
|
it('accurately counts delivery statuses', async () => {
|
|
globalThis.fetch = vi.fn()
|
|
.mockResolvedValueOnce({ ok: true, status: 200 })
|
|
.mockRejectedValueOnce(new Error('Failed'));
|
|
|
|
const job1 = { ...mockJob, id: 'job-1' };
|
|
const job2 = { ...mockJob, id: 'job-2' };
|
|
|
|
await triggerJobWebhook(job1, mockConfig);
|
|
await triggerJobWebhook(job2, mockConfig);
|
|
|
|
const stats = getDeliveryStats();
|
|
expect(stats.total).toBe(2);
|
|
expect(stats.delivered).toBe(1);
|
|
expect(stats.failed).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('retryWebhookDelivery', () => {
|
|
it('retries failed delivery successfully', async () => {
|
|
// First, make all fetch calls fail (for initial trigger with retries)
|
|
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Persistent failure'));
|
|
|
|
await triggerJobWebhook(mockJob, mockConfig);
|
|
const delivery = listJobDeliveries('job-123')[0];
|
|
expect(delivery.status).toBe('failed');
|
|
|
|
// Now set up mock to succeed for the retry
|
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
const retried = await retryWebhookDelivery(delivery.id, mockJob, mockConfig);
|
|
expect(retried).toBe(true);
|
|
|
|
const updated = getWebhookDelivery(delivery.id);
|
|
expect(updated?.status).toBe('delivered');
|
|
});
|
|
|
|
it('returns false for unknown delivery ID', async () => {
|
|
const result = await retryWebhookDelivery('unknown-id', mockJob, mockConfig);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('listJobDeliveries', () => {
|
|
it('returns deliveries sorted by creation time', async () => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
const job1 = { ...mockJob, id: 'job-1' };
|
|
const job2 = { ...mockJob, id: 'job-2' };
|
|
|
|
await triggerJobWebhook(job1, mockConfig);
|
|
await new Promise(r => setTimeout(r, 10));
|
|
await triggerJobWebhook(job2, mockConfig);
|
|
|
|
const deliveries = listJobDeliveries('job-1');
|
|
expect(deliveries).toHaveLength(1);
|
|
expect(deliveries[0].jobId).toBe('job-1');
|
|
});
|
|
});
|
|
});
|