/** * 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; 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'); }); }); });