qwen_2.5_model / src /modules /queue /processors /webhook.processor.spec.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
8.3 kB
import { Job } from 'bullmq';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { WebhookProcessor } from './webhook.processor';
import { Webhook } from '../../webhook/entities/webhook.entity';
import { WebhookDeliveryFailure } from '../../webhook/entities/webhook-delivery-failure.entity';
import { HookManager } from '../../../core/hooks';
import { WebhookJobData } from '../../webhook/webhook.service';
import { fetch as undiciFetch } from 'undici';
// Delivery goes through undici's fetch (via the SSRF-pinning helper), so mock that, not global fetch.
jest.mock('undici', () => {
const actual = jest.requireActual<typeof import('undici')>('undici');
return { __esModule: true, ...actual, fetch: jest.fn() };
});
/**
* Regression coverage for the production (QUEUE_ENABLED) webhook delivery path, which was
* previously untested. Covers the success path, the off-by-one final-attempt gate, the
* retry-count header, and the redirect refusal when SSRF protection is on.
*/
describe('WebhookProcessor', () => {
let processor: WebhookProcessor;
let repo: { update: jest.Mock };
let failureRepo: { insert: jest.Mock };
let hookManager: { execute: jest.Mock };
let configService: { get: jest.Mock };
let mockFetch: jest.Mock;
const origProtect = process.env.WEBHOOK_SSRF_PROTECT;
const makeJob = (overrides: Partial<WebhookJobData> = {}, attemptsMade = 0): Job<WebhookJobData> =>
({
id: 'job-1',
attemptsMade,
data: {
webhookId: 'wh-1',
url: 'https://8.8.8.8/hook', // IP literal → SSRF guard needs no DNS lookup
event: 'message.received',
payload: {
event: 'message.received',
timestamp: '',
sessionId: 'sess-1',
idempotencyKey: 'k',
deliveryId: 'd',
data: {},
},
headers: { 'Content-Type': 'application/json' },
attempt: 1,
maxRetries: 3,
...overrides,
},
}) as unknown as Job<WebhookJobData>;
beforeEach(() => {
repo = { update: jest.fn().mockResolvedValue({ affected: 1 }) };
failureRepo = { insert: jest.fn().mockResolvedValue({}) };
hookManager = { execute: jest.fn().mockResolvedValue({ continue: true, data: {} }) };
configService = { get: jest.fn((key: string, def?: unknown) => (key === 'webhook.timeout' ? 25000 : def)) };
processor = new WebhookProcessor(
repo as unknown as Repository<Webhook>,
failureRepo as unknown as Repository<WebhookDeliveryFailure>,
hookManager as unknown as HookManager,
configService as unknown as ConfigService,
);
// The merged delivery path uses withSafeFetch (undici), so mock undici's fetch, not global.fetch.
mockFetch = undiciFetch as jest.Mock;
process.env.WEBHOOK_SSRF_PROTECT = 'false'; // delivery-logic tests; redirect test flips it on
});
afterEach(() => {
mockFetch.mockReset();
if (origProtect === undefined) delete process.env.WEBHOOK_SSRF_PROTECT;
else process.env.WEBHOOK_SSRF_PROTECT = origProtect;
});
it('uses the configured WEBHOOK_TIMEOUT for the request abort signal (not a hardcoded 10s)', async () => {
const timeoutSpy = jest.spyOn(AbortSignal, 'timeout');
mockFetch.mockResolvedValue({ ok: true, status: 200 });
await processor.process(makeJob());
expect(configService.get).toHaveBeenCalledWith('webhook.timeout', 10000);
expect(timeoutSpy).toHaveBeenCalledWith(25000);
timeoutSpy.mockRestore();
});
it('on success updates lastTriggeredAt and fires webhook:delivered', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200 });
const result = await processor.process(makeJob());
expect(result.success).toBe(true);
expect(repo.update).toHaveBeenCalledTimes(1);
const updateArgs = repo.update.mock.calls[0] as unknown as [string, { lastTriggeredAt: Date }];
expect(updateArgs[0]).toBe('wh-1');
expect(updateArgs[1].lastTriggeredAt).toBeInstanceOf(Date);
expect(hookManager.execute).toHaveBeenCalledWith('webhook:delivered', expect.anything(), expect.anything());
});
it('sets X-OpenWA-Retry-Count to the attempt number', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200 });
await processor.process(makeJob({}, 2));
const call = mockFetch.mock.calls[0] as unknown as [string, { headers: Record<string, string> }];
expect(call[1].headers['X-OpenWA-Retry-Count']).toBe('2');
});
it('throws on a non-ok response WITHOUT firing webhook:error before the final attempt', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' });
await expect(processor.process(makeJob({ maxRetries: 3 }, 0))).rejects.toThrow();
expect(hookManager.execute).not.toHaveBeenCalledWith('webhook:error', expect.anything(), expect.anything());
});
it('fires webhook:error only on the final attempt', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' });
// attemptsMade=2, maxRetries=3 -> attemptsMade+1 >= maxRetries -> final
await expect(processor.process(makeJob({ maxRetries: 3 }, 2))).rejects.toThrow();
expect(hookManager.execute).toHaveBeenCalledWith('webhook:error', expect.anything(), expect.anything());
});
it('persists a durable delivery-failure record on the final attempt (with parsed HTTP status)', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable' });
await expect(
processor.process(makeJob({ maxRetries: 3, webhookId: 'wh-x', url: 'https://8.8.8.8/h' }, 2)),
).rejects.toThrow();
expect(failureRepo.insert).toHaveBeenCalledTimes(1);
expect(failureRepo.insert).toHaveBeenCalledWith(
expect.objectContaining({
webhookId: 'wh-x',
url: 'https://8.8.8.8/h',
sessionId: 'sess-1',
attempts: 3,
lastStatusCode: 503,
lastError: 'HTTP 503: Service Unavailable',
}),
);
});
it('does NOT persist a delivery-failure record before the final attempt', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' });
await expect(processor.process(makeJob({ maxRetries: 3 }, 0))).rejects.toThrow();
expect(failureRepo.insert).not.toHaveBeenCalled();
});
it('refuses to follow a redirect when SSRF protection is on', async () => {
process.env.WEBHOOK_SSRF_PROTECT = 'true';
mockFetch.mockResolvedValue({ ok: false, status: 0, type: 'opaqueredirect' });
await expect(processor.process(makeJob({ maxRetries: 1 }, 0))).rejects.toThrow();
expect(mockFetch).toHaveBeenCalledWith('https://8.8.8.8/hook', expect.objectContaining({ redirect: 'manual' }));
expect(repo.update).not.toHaveBeenCalled(); // never treated as delivered
});
// A literal link-local IP triggers the SSRF guard synchronously before any fetch/DNS, so this is
// fully offline. The webhook:error hook payload and the durable DLQ row must both carry the generic
// message — the resolved internal IP is a recon oracle. The server-side logger.error keeps full detail.
it('redacts the resolved internal IP from the webhook:error payload and DLQ row on an SSRF block', async () => {
process.env.WEBHOOK_SSRF_PROTECT = 'true';
// final attempt (attemptsMade=0, maxRetries=1 → 1 >= 1) so the hook + DLQ fire
await expect(processor.process(makeJob({ url: 'https://169.254.169.254/h', maxRetries: 1 }, 0))).rejects.toThrow();
expect(mockFetch).not.toHaveBeenCalled(); // blocked before any network
const hookCalls = hookManager.execute.mock.calls as unknown as Array<[string, { error: string }, unknown]>;
const errorHookCall = hookCalls.find(c => c[0] === 'webhook:error');
expect(errorHookCall).toBeDefined();
expect(errorHookCall![1].error).toBe('Destination address is not allowed');
expect(errorHookCall![1].error).not.toMatch(/169\.254\.169\.254/);
expect(failureRepo.insert).toHaveBeenCalledTimes(1);
const inserted = (failureRepo.insert.mock.calls[0] as unknown[])[0] as { lastError: string };
expect(inserted.lastError).toBe('Destination address is not allowed');
expect(inserted.lastError).not.toMatch(/169\.254\.169\.254/);
});
});