WCBK1 实现企微审批回调只读 POC
这个提交包含在:
@@ -0,0 +1,149 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
WecomCallbackError,
|
||||
buildEncryptedApprovalEventSample,
|
||||
buildUrlVerificationSample,
|
||||
createWecomSignature,
|
||||
decryptWecomMessage,
|
||||
extractEncryptedFromXml,
|
||||
maskWecomIdentifier,
|
||||
normalizeApprovalCallbackEvent,
|
||||
readCallbackEvidence,
|
||||
recordCallbackEvidence,
|
||||
verifyWecomSignature,
|
||||
} = require("../../../src/services/integrations/wecomCallbackCrypto");
|
||||
|
||||
const TOKEN = "callback-token";
|
||||
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||
const CORP_ID = "ww-callback-corp";
|
||||
const SENSITIVE_SP_NO = "SPFULLIDENTIFIERSHOULDNOTLEAK0001";
|
||||
const SENSITIVE_TEMPLATE_ID = "TPLFULLIDENTIFIERSHOULDNOTLEAK0001";
|
||||
|
||||
describe("wecomCallbackCrypto", () => {
|
||||
it("creates and verifies WeCom callback signatures", () => {
|
||||
const signature = createWecomSignature({
|
||||
token: TOKEN,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
encrypted: "encrypted-payload",
|
||||
});
|
||||
|
||||
expect(signature).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(verifyWecomSignature({
|
||||
token: TOKEN,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
encrypted: "encrypted-payload",
|
||||
signature,
|
||||
})).toBe(true);
|
||||
expect(verifyWecomSignature({
|
||||
token: TOKEN,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
encrypted: "other",
|
||||
signature,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("decrypts URL verification echostr samples", () => {
|
||||
const sample = buildUrlVerificationSample({
|
||||
token: TOKEN,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
plainText: "wcbk1-url-verify-ok",
|
||||
});
|
||||
|
||||
const decrypted = decryptWecomMessage({
|
||||
encrypted: sample.encrypted,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
});
|
||||
|
||||
expect(sample.signature).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(decrypted.plainXml).toBe("wcbk1-url-verify-ok");
|
||||
expect(decrypted.receiveId).toBe(CORP_ID);
|
||||
});
|
||||
|
||||
it("normalizes approval callback events without leaking raw identifiers", () => {
|
||||
const sample = buildEncryptedApprovalEventSample({
|
||||
token: TOKEN,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
spNo: SENSITIVE_SP_NO,
|
||||
templateId: SENSITIVE_TEMPLATE_ID,
|
||||
});
|
||||
const encrypted = extractEncryptedFromXml(sample.encryptedXml);
|
||||
const decrypted = decryptWecomMessage({
|
||||
encrypted,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
});
|
||||
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
eventType: "open_approval_change",
|
||||
msgType: "event",
|
||||
callbackKind: "approval_status_change",
|
||||
approvalType: "leave",
|
||||
spStatus: "2",
|
||||
statusLabel: "已通过",
|
||||
spNoPrefix: maskWecomIdentifier(SENSITIVE_SP_NO),
|
||||
templateIdPrefix: maskWecomIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||
});
|
||||
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_SP_NO);
|
||||
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
expect(summary.idempotencyKey).toHaveLength(32);
|
||||
});
|
||||
|
||||
it("rejects mismatched receiveId", () => {
|
||||
const sample = buildUrlVerificationSample({
|
||||
token: TOKEN,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
plainText: "ok",
|
||||
});
|
||||
|
||||
expect(() => decryptWecomMessage({
|
||||
encrypted: sample.encrypted,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: "other-corp",
|
||||
})).toThrow(WecomCallbackError);
|
||||
});
|
||||
|
||||
it("records callback evidence idempotently and only stores redacted fields", async () => {
|
||||
const evidenceFile = path.join("/tmp", `wcbk1-evidence-${Date.now()}-${Math.random()}.jsonl`);
|
||||
const summary = {
|
||||
eventType: "open_approval_change",
|
||||
msgType: "event",
|
||||
callbackKind: "approval_status_change",
|
||||
approvalType: "leave",
|
||||
spStatus: "2",
|
||||
statusLabel: "已通过",
|
||||
spNoPrefix: maskWecomIdentifier(SENSITIVE_SP_NO),
|
||||
templateIdPrefix: maskWecomIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||
createTime: "1770000000",
|
||||
applyTime: "1770000000",
|
||||
agentIdPrefix: "1000***",
|
||||
idempotencyKey: "idem-key",
|
||||
};
|
||||
|
||||
const first = await recordCallbackEvidence(summary, { evidenceFile });
|
||||
const second = await recordCallbackEvidence(summary, { evidenceFile });
|
||||
const events = await readCallbackEvidence({ evidenceFile });
|
||||
const raw = await fs.readFile(evidenceFile, "utf8");
|
||||
|
||||
expect(first.recorded).toBe(true);
|
||||
expect(second.duplicate).toBe(true);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(raw).not.toContain(SENSITIVE_SP_NO);
|
||||
expect(raw).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户