feat(round-13): 潜客企业域名富化/雷达CSV报表导出/商机Webhook签名分发/销售管辖权限隔离 (91套件1033测试全绿)
这个提交包含在:
@@ -0,0 +1,97 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户