98 行
3.3 KiB
TypeScript
98 行
3.3 KiB
TypeScript
import {
|
|
LeadWebhookDispatcher,
|
|
computeWebhookSignature,
|
|
verifyWebhookSignature,
|
|
LeadWebhookPayload,
|
|
} from '../../../src/modules/crm/service/webhook-dispatcher';
|
|
|
|
describe('Point 51: 出海商机实时 Webhook 触发器与 HMAC-SHA256 签名分发', () => {
|
|
let dispatcher: LeadWebhookDispatcher;
|
|
const mockSecret = 'whsec_8f9a2b4c6e1d7g5h3j8k9l0m';
|
|
const mockPayload: LeadWebhookPayload = {
|
|
event: 'lead.created',
|
|
timestamp: Date.now(),
|
|
tenantId: 'tenant-uae-101',
|
|
data: {
|
|
leadId: 501,
|
|
name: 'Sheikh Khalid',
|
|
company: 'Dubai Financial Group',
|
|
score: 95,
|
|
channel: 'nfc_tap',
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockPayload.timestamp = Date.now();
|
|
dispatcher = new LeadWebhookDispatcher();
|
|
});
|
|
|
|
describe('1. computeWebhookSignature & verifyWebhookSignature (HMAC-SHA256 签名验签)', () => {
|
|
it('应计算规范的 sha256 签名串', () => {
|
|
const payloadStr = JSON.stringify(mockPayload);
|
|
const sig = computeWebhookSignature(payloadStr, mockSecret, mockPayload.timestamp);
|
|
|
|
expect(sig).toHaveLength(64);
|
|
expect(/^[0-9a-f]{64}$/i.test(sig)).toBe(true);
|
|
|
|
// 验签通过
|
|
const isValid = verifyWebhookSignature(
|
|
payloadStr,
|
|
sig,
|
|
mockSecret,
|
|
mockPayload.timestamp
|
|
);
|
|
expect(isValid).toBe(true);
|
|
});
|
|
|
|
it('篡改 Payload 任何内容时验签应失败', () => {
|
|
const payloadStr = JSON.stringify(mockPayload);
|
|
const sig = computeWebhookSignature(payloadStr, mockSecret, mockPayload.timestamp);
|
|
|
|
const tamperedStr = JSON.stringify({ ...mockPayload, tenantId: 'tampered-tenant' });
|
|
expect(verifyWebhookSignature(tamperedStr, sig, mockSecret, mockPayload.timestamp)).toBe(false);
|
|
});
|
|
|
|
it('超出 5 分钟 (300,000ms) 时钟窗口的请求应被拒绝', () => {
|
|
const oldTs = Date.now() - 6 * 60 * 1000;
|
|
const payloadStr = JSON.stringify({ ...mockPayload, timestamp: oldTs });
|
|
const sig = computeWebhookSignature(payloadStr, mockSecret, oldTs);
|
|
|
|
expect(verifyWebhookSignature(payloadStr, sig, mockSecret, oldTs)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('2. LeadWebhookDispatcher 异步分发与重试逻辑', () => {
|
|
it('目标端成功接收 (200 OK) 时标记成功', async () => {
|
|
const mockHttpPoster = jest.fn(async () => ({ statusCode: 200 }));
|
|
dispatcher.setHttpPoster(mockHttpPoster);
|
|
|
|
const result = await dispatcher.dispatch({
|
|
targetUrl: 'https://api.partner.com/omnicard/webhook',
|
|
secret: mockSecret,
|
|
payload: mockPayload,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.attempts).toBe(1);
|
|
expect(mockHttpPoster).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('遇到 500 异常时应自动重试,全部失败后记入死信', async () => {
|
|
const mockHttpPoster = jest.fn(async () => ({ statusCode: 500 }));
|
|
dispatcher.setHttpPoster(mockHttpPoster);
|
|
|
|
const result = await dispatcher.dispatch({
|
|
targetUrl: 'https://api.partner.com/failing/webhook',
|
|
secret: mockSecret,
|
|
payload: mockPayload,
|
|
maxRetries: 2,
|
|
initialBackoffMs: 10,
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.attempts).toBe(2);
|
|
expect(result.isDeadLetter).toBe(true);
|
|
});
|
|
});
|
|
});
|