From 9ae4cbcf772e0503db4c5303e846e8f76ff4d04c Mon Sep 17 00:00:00 2001 From: vicky4196 Date: Sun, 28 Jun 2026 15:36:02 +0000 Subject: [PATCH] test: add tests for X-Request-Id header in webhook dispatcher - Add test for X-Request-Id propagation when request context exists - Add test for omitting X-Request-Id when no context is set - Add comprehensive test for all webhook headers - Update webhooks.md documentation to include all outbound headers closes #511 --- docs/webhooks.md | 7 ++++- src/webhooks/webhook.dispatcher.test.ts | 42 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/webhooks.md b/docs/webhooks.md index 9df9945..190ea80 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -87,12 +87,17 @@ Internal/private IP addresses are blocked. The following ranges are rejected: ### Signature Verification -If you provide a `secret` during registration, each webhook delivery includes two headers: +If you provide a `secret` during registration, each webhook delivery includes these headers: | Header | Format | Description | |-----------------------------|---------------------|---------------------------------------| +| `X-Request-Id` | string | Correlation ID from the triggering request | | `X-Callora-Signature-256` | `sha256=` | HMAC-SHA256 of signed payload | | `X-Callora-Timestamp` | ISO-8601 timestamp | Delivery timestamp for replay defense | +| `X-Callora-Event` | string | Event type being delivered | +| `X-Callora-Delivery` | UUID | Unique delivery identifier for idempotency | +| `User-Agent` | `Callora-Webhook/1.0` | Identifies Callora as the sender | +| `Content-Type` | `application/json` | Payload content type | #### Signed Payload Format diff --git a/src/webhooks/webhook.dispatcher.test.ts b/src/webhooks/webhook.dispatcher.test.ts index d8882fa..fbdeabd 100644 --- a/src/webhooks/webhook.dispatcher.test.ts +++ b/src/webhooks/webhook.dispatcher.test.ts @@ -71,6 +71,48 @@ describe('Webhook Dispatcher', () => { expect(headers['X-Request-Id']).toBe('req-webhook-als'); }); + it('omits X-Request-Id header when no request context is set', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + } as Response); + global.fetch = fetchMock as any; + + await dispatchWebhook(config, payload); + + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers['X-Request-Id']).toBeUndefined(); + }); + + it('includes all expected webhook headers on dispatch', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + } as Response); + global.fetch = fetchMock as any; + const { runWithRequestContext } = await import('../utils/asyncContext.js'); + + const configWithSecret: WebhookConfig = { + ...config, + secret: 'test-secret', + }; + + await runWithRequestContext({ requestId: 'req-test-123' }, async () => { + await dispatchWebhook(configWithSecret, payload); + }); + + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['User-Agent']).toBe('Callora-Webhook/1.0'); + expect(headers['X-Callora-Event']).toBe(payload.event); + expect(headers['X-Callora-Timestamp']).toBe(payload.timestamp); + expect(headers['X-Callora-Delivery']).toBeDefined(); + expect(headers['X-Request-Id']).toBe('req-test-123'); + expect(headers['X-Callora-Signature']).toMatch(/^sha256=/); + }); + it('retries on non-2xx response and uses same idempotency key', async () => { const fetchMock = jest.fn() .mockResolvedValueOnce({