Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ export type {
WebhookDeliveryResult,
WebhookEventInput,
} from './notification/webhook';
// Unified webhook barrel (Issue #727 Technical Scope)
export { WebhookManagementApi, webhookManagementApi } from './notification/webhookManagementApi';
// Event catalog — wildcard filtering & schema validation
export {
EventCatalogRegistry,
eventCatalog,
EVENT_CATALOG,
} from './webhook/eventCatalog';
export type { EventDefinition, EventCategory, SchemaField } from './webhook/eventCatalog';
export { EventSchemaValidator, eventSchemaValidator } from './webhook/eventSchemaValidator';
export type { ValidationResult } from './webhook/eventSchemaValidator';
export { WebSocketServer, webSocketServer } from './notification/websocket';
export type {
SubscriptionEventType as WSSubscriptionEventType,
Expand Down
185 changes: 185 additions & 0 deletions backend/services/notification/__tests__/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,189 @@ describe('WebhookDeliveryService', () => {
expect(retry.delivery.status).toBe('delivered');
expect(retry.delivery.attempts).toBeGreaterThanOrEqual(1);
});

// ── Wildcard Event Filtering (Issue #727) ────────────────────────────────────

it('allows events matching a wildcard category pattern (subscription.*)', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch });

const webhook = service.registerWebhook({
merchantId: 'merchant_1',
url: 'https://example.com/webhook',
events: ['subscription.*' as any],
secretKey: 'secret',
});

// subscription.created should match subscription.*
const result = await service.deliverEvent(
makeInput({ webhookId: webhook.id, eventType: 'subscription.created' as any })
);
expect(result?.delivery.status).toBe('delivered');
expect(fetchImpl).toHaveBeenCalledTimes(1);
});

it('blocks events that do not match the wildcard category (subscription.* vs payment.failed)', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch });

const webhook = service.registerWebhook({
merchantId: 'merchant_1',
url: 'https://example.com/webhook',
events: ['subscription.*' as any],
secretKey: 'secret',
});

const result = await service.deliverEvent(
makeInput({ webhookId: webhook.id, eventType: 'payment.failed' as any })
);
// deliverEvent returns null when the event is not allowed
expect(result).toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});

it('allows all events with the global wildcard (*)', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch });

const webhook = service.registerWebhook({
merchantId: 'merchant_1',
url: 'https://example.com/webhook',
events: ['*' as any],
secretKey: 'secret',
});

const r1 = await service.deliverEvent(
makeInput({ webhookId: webhook.id, eventType: 'subscription.created' as any })
);
const r2 = await service.deliverEvent(
makeInput({ webhookId: webhook.id, eventType: 'payment.failed' as any, idempotencyKey: 'key-2' })
);
const r3 = await service.deliverEvent(
makeInput({ webhookId: webhook.id, eventType: 'invoice.paid' as any, idempotencyKey: 'key-3' })
);

expect(r1?.delivery.status).toBe('delivered');
expect(r2?.delivery.status).toBe('delivered');
expect(r3?.delivery.status).toBe('delivered');
expect(fetchImpl).toHaveBeenCalledTimes(3);
});

it('blocks delivery to a paused webhook regardless of wildcard pattern', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch });

const webhook = service.registerWebhook({
merchantId: 'merchant_1',
url: 'https://example.com/webhook',
events: ['*' as any],
secretKey: 'secret',
isPaused: true,
});

const result = await service.deliverEvent(
makeInput({ webhookId: webhook.id, eventType: 'subscription.created' as any })
);
expect(result).toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});

// ── Exponential Backoff Delay Calculation (Issue #727) ───────────────────────

it('computes exponential backoff delays correctly across multiple attempts', async () => {
const delays: number[] = [];
const fetchImpl = jest
.fn()
.mockRejectedValueOnce(new Error('fail'))
.mockRejectedValueOnce(new Error('fail'))
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce({ ok: true, status: 200 });
const sleepImpl = jest.fn((ms: number) => {
delays.push(ms);
return Promise.resolve();
});
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch, sleepImpl });

const webhook = service.registerWebhook({
merchantId: 'merchant_1',
url: 'https://example.com/webhook',
events: ['subscription.charged'],
secretKey: 'secret',
retryPolicy: {
maxRetries: 3,
initialDelayMs: 1_000,
maxDelayMs: 60_000,
backoffFactor: 2,
// No retryDelaysMs — uses the exponential formula
},
});

await service.deliverEvent(makeInput({ webhookId: webhook.id }));

// Delay for attempt 1: initialDelayMs * factor^0 = 1000 * 1 = 1000
// Delay for attempt 2: initialDelayMs * factor^1 = 1000 * 2 = 2000
// Delay for attempt 3: initialDelayMs * factor^2 = 1000 * 4 = 4000
expect(delays).toEqual([1_000, 2_000, 4_000]);
expect(fetchImpl).toHaveBeenCalledTimes(4);
});

// ── Test Webhook Simulation (Issue #727) ─────────────────────────────────────

it('testWebhook sends a simulated delivery and returns a delivery record', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch });

const webhook = service.registerWebhook({
merchantId: 'merchant_1',
url: 'https://example.com/webhook',
events: ['subscription.created' as any],
secretKey: 'secret',
});

const result = await service.testWebhook(webhook.id, 'subscription.created' as any);

expect(result.delivery.status).toBe('delivered');
expect(result.delivery.webhookId).toBe(webhook.id);
expect(fetchImpl).toHaveBeenCalledTimes(1);
// The delivery should carry the HMAC signature header
const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>)['X-SubTrackr-Signature']).toBeDefined();
});

it('testWebhook bypasses idempotency — repeated calls all fire', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch });

const webhook = service.registerWebhook({
merchantId: 'merchant_1',
url: 'https://example.com/webhook',
events: ['subscription.created' as any],
secretKey: 'secret',
});

// Fire the same event type three times — each gets a unique test payload id
await service.testWebhook(webhook.id, 'subscription.created' as any);
await service.testWebhook(webhook.id, 'subscription.created' as any);
await service.testWebhook(webhook.id, 'subscription.created' as any);

expect(fetchImpl).toHaveBeenCalledTimes(3);
});

it('testWebhook accepts a custom payload override', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch });

const webhook = service.registerWebhook({
merchantId: 'merchant_1',
url: 'https://example.com/webhook',
events: ['subscription.created' as any],
secretKey: 'secret',
});

const custom = { merchantId: 'override_merchant' };
const result = await service.testWebhook(webhook.id, 'subscription.created' as any, custom);

expect(result.delivery.status).toBe('delivered');
expect(result.delivery.payload.merchantId).toBe('override_merchant');
});
});
6 changes: 6 additions & 0 deletions backend/services/notification/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface IWebhookDeliveryService {
deleteWebhook(id: string): void;
pauseWebhook(id: string): WebhookConfig;
resumeWebhook(id: string): WebhookConfig;
rotateSecret(id: string, newSecret: string, overlapMs?: number): WebhookConfig;
listWebhooks(merchantId: string): WebhookConfig[];
getWebhook(id: string): WebhookConfig | undefined;
getWebhookDeliveries(webhookId: string, limit: number): WebhookDelivery[];
Expand All @@ -42,6 +43,11 @@ export interface IWebhookDeliveryService {
checkWebhookHealth(id: string): Promise<WebhookConfig>;
deliverEvent(input: WebhookEventInput): Promise<WebhookDeliveryResult | null>;
retryWebhookDelivery(deliveryId: string): Promise<WebhookDeliveryResult>;
listDeadLetters(webhookId?: string): WebhookDelivery[];
replayDeadLetter(deliveryId: string): Promise<WebhookDeliveryResult>;
cleanupDeadLetters(maxAgeMs?: number): number;
cleanupExpiredIdempotencyKeys(windowMs?: number): number;
testWebhook(webhookId: string, eventType?: string, customPayload?: Record<string, unknown>): Promise<WebhookDeliveryResult>;
}

export interface IWebsocketService {
Expand Down
88 changes: 87 additions & 1 deletion backend/services/notification/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
WebhookRetryPolicy,
WebhookSecret,
} from '../../../src/types/webhook';
import { eventCatalog } from '../webhook/eventCatalog';
import { eventSchemaValidator } from '../webhook/eventSchemaValidator';

export type { WebhookEventInput } from '../../../src/types/webhook';

Expand Down Expand Up @@ -104,10 +106,23 @@ export const buildWebhookPayload = (input: WebhookEventInput): WebhookEventPaylo
};
};

/**
* Determines whether a given event type should be delivered to this webhook.
*
* Subscription patterns are matched with wildcard support:
* - `"*"` — matches every event type
* - `"subscription.*"` — matches all events whose type starts with `subscription.`
* - `"payment.failed"` — exact match only
*
* Paused webhooks always return false regardless of pattern.
*/
export const isWebhookEventAllowed = (
webhook: Pick<WebhookConfig, 'events' | 'isPaused'>,
eventType: WebhookEventType
): boolean => !webhook.isPaused && webhook.events.includes(eventType);
): boolean => {
if (webhook.isPaused) return false;
return webhook.events.some((pattern) => eventCatalog.matchesWildcard(pattern, eventType));
};

/** Returns true if `signature` matches any signing secret currently valid for `webhook`. */
export const verifyWebhookSignatureAny = (
Expand Down Expand Up @@ -478,6 +493,77 @@ export class WebhookDeliveryService {
return result;
}

/**
* Sends a simulated test webhook delivery to the webhook's configured URL.
*
* Generates a realistic sample payload from the event catalog schema, signs it
* with the webhook's current secret, and dispatches it — bypassing idempotency
* deduplication and rate limiting so developers can rapidly invoke tests from
* the developer portal or CLI without interference.
*
* @param webhookId - The webhook to test.
* @param eventType - Which event type to simulate. Falls back to the first subscribed
* event if omitted.
* @param customPayload - Optional payload fields to merge over the generated example.
* @returns The delivery record for the simulated attempt.
*/
async testWebhook(
webhookId: string,
eventType?: WebhookEventType,
customPayload?: Record<string, unknown>
): Promise<WebhookDeliveryResult> {
const webhook = this.webhooks.get(webhookId);
if (!webhook) throw new Error(`Webhook ${webhookId} not found`);

// Resolve the event type to test — prefer the caller's choice, otherwise use the
// first concrete (non-wildcard) subscribed event, and finally fall back to the
// first entry in the catalog so the test always has a valid schema to work with.
const resolvedEventType: WebhookEventType =
eventType ??
(webhook.events.find((e) => !e.includes('*')) as WebhookEventType | undefined) ??
(eventCatalog.getActiveEvents()[0]?.type as WebhookEventType);

const schemaExample = eventSchemaValidator.generateExample(resolvedEventType) ?? {};
const testId = createId('tevt');
const testPayload: WebhookEventPayload = {
id: testId,
webhookId,
eventType: resolvedEventType,
occurredAt: now(),
merchantId: webhook.merchantId,
subscription: schemaExample['subscription'] as WebhookEventPayload['subscription'],
plan: schemaExample['plan'] as WebhookEventPayload['plan'],
payloadVersion: 1,
...(customPayload as Partial<WebhookEventPayload>),
};

const signature = signWebhookPayload(testPayload, webhook.secretKey);
const idempotencyKey = `test:${testId}`;

const delivery: WebhookDelivery = {
id: createId('tdel'),
webhookId,
eventId: testPayload.id,
eventType: resolvedEventType,
url: webhook.url,
payload: testPayload,
status: 'pending',
attempts: 0,
maxAttempts: webhook.retryPolicy.maxRetries,
createdAt: now(),
updatedAt: now(),
signature,
idempotencyKey,
};

this.deliveries.set(delivery.id, delivery);
// sendWithRetry handles the HTTP dispatch; test deliveries use the full
// retry machinery so portal users see realistic latency + status codes.
const result = await this.sendWithRetry(webhook, delivery);
this.deliveries.set(delivery.id, result.delivery);
return result;
}

async retryWebhookDelivery(deliveryId: string): Promise<WebhookDeliveryResult> {
const existing = this.deliveries.get(deliveryId);
if (!existing) throw new Error(`Delivery ${deliveryId} not found`);
Expand Down
26 changes: 24 additions & 2 deletions backend/services/notification/webhookManagementApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
webhookDeliveryService,
RegisterWebhookInput,
} from './webhook';
import type { WebhookConfig, WebhookDelivery, WebhookEventInput } from '../../../src/types/webhook';
import type { WebhookConfig, WebhookDelivery, WebhookEventInput, WebhookEventType } from '../../../src/types/webhook';

export interface ApiResponse<T = unknown> {
success: boolean;
Expand Down Expand Up @@ -136,9 +136,31 @@ export class WebhookManagementApi {
}
}

getAnalytics(webhookId: string) {
getAnalytics(webhookId: string): ApiResponse<ReturnType<WebhookDeliveryService['getAnalytics']>> {
return ok(this.service.getAnalytics(webhookId));
}

/**
* Sends a test webhook delivery to the endpoint, generating a realistic sample
* payload from the event catalog. Idempotency and rate limiting are bypassed so
* developers can rapidly fire test events from the developer portal or CLI.
*
* @param webhookId - Target webhook.
* @param eventType - Event type to simulate (defaults to first subscribed event).
* @param customPayload - Optional fields to merge over the generated example.
*/
async testWebhook(
webhookId: string,
eventType?: WebhookEventType,
customPayload?: Record<string, unknown>
): Promise<ApiResponse<WebhookDelivery>> {
try {
const result = await this.service.testWebhook(webhookId, eventType, customPayload);
return ok(result.delivery, `Test delivery ${result.delivery.status}`);
} catch (error) {
return fail(error, 'Failed to send test webhook');
}
}
}

export const webhookManagementApi = new WebhookManagementApi();
Loading