WCBK1 实现企微审批回调只读 POC
这个提交包含在:
@@ -0,0 +1,120 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const wecomApprovalCallbackRoutes = require("../../../src/routes/wecomApprovalCallback");
|
||||
const {
|
||||
buildEncryptedApprovalEventSample,
|
||||
buildUrlVerificationSample,
|
||||
} = require("../../../src/services/integrations/wecomCallbackCrypto");
|
||||
|
||||
const TOKEN = "callback-token";
|
||||
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||
const CORP_ID = "ww-callback-corp";
|
||||
|
||||
async function buildApp({ config = {}, evidenceStore } = {}) {
|
||||
const app = Fastify();
|
||||
app.decorate("config", {
|
||||
wecomCorpId: CORP_ID,
|
||||
wecomCallbackToken: TOKEN,
|
||||
wecomCallbackEncodingAesKey: AES_KEY,
|
||||
...config,
|
||||
});
|
||||
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api", evidenceStore });
|
||||
return app;
|
||||
}
|
||||
|
||||
function urlQuery(sample) {
|
||||
return `msg_signature=${encodeURIComponent(sample.signature)}×tamp=${encodeURIComponent(sample.timestamp)}&nonce=${encodeURIComponent(sample.nonce)}`;
|
||||
}
|
||||
|
||||
describe("wecomApprovalCallback routes", () => {
|
||||
it("verifies callback URL and returns decrypted echostr", async () => {
|
||||
const app = await buildApp();
|
||||
const sample = buildUrlVerificationSample({
|
||||
token: TOKEN,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
plainText: "wcbk1-url-verify-ok",
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/wecom/approval/callback?${urlQuery(sample)}&echostr=${encodeURIComponent(sample.encrypted)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toBe("wcbk1-url-verify-ok");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects invalid URL verification signatures", async () => {
|
||||
const app = await buildApp();
|
||||
const sample = buildUrlVerificationSample({
|
||||
token: TOKEN,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
plainText: "wcbk1-url-verify-ok",
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/wecom/approval/callback?msg_signature=bad×tamp=${sample.timestamp}&nonce=${sample.nonce}&echostr=${encodeURIComponent(sample.encrypted)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body).toBe("invalid signature");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("accepts approval callback POST and records only normalized evidence", async () => {
|
||||
const evidenceStore = {
|
||||
record: vi.fn().mockResolvedValue({ recorded: true, duplicate: false }),
|
||||
};
|
||||
const app = await buildApp({ evidenceStore });
|
||||
const sample = buildEncryptedApprovalEventSample({
|
||||
token: TOKEN,
|
||||
encodingAesKey: AES_KEY,
|
||||
receiveId: CORP_ID,
|
||||
timestamp: "1770000000",
|
||||
nonce: "nonce",
|
||||
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
|
||||
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
|
||||
headers: { "content-type": "text/xml" },
|
||||
payload: sample.encryptedXml,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toBe("success");
|
||||
expect(evidenceStore.record).toHaveBeenCalledTimes(1);
|
||||
const summary = evidenceStore.record.mock.calls[0][0];
|
||||
expect(summary).toMatchObject({
|
||||
eventType: "open_approval_change",
|
||||
callbackKind: "approval_status_change",
|
||||
approvalType: "leave",
|
||||
statusLabel: "已通过",
|
||||
});
|
||||
expect(JSON.stringify(summary)).not.toContain("SPFULLIDENTIFIERSHOULDNOTLEAK");
|
||||
expect(JSON.stringify(summary)).not.toContain("TPLFULLIDENTIFIERSHOULDNOTLEAK");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reports missing callback config without treating it as pass", async () => {
|
||||
const app = await buildApp({ config: { wecomCallbackToken: "" } });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/wecom/approval/callback?msg_signature=x×tamp=1&nonce=2&echostr=3",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toBe("callback config missing");
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,24 @@ function validFixtureRow(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function validCallbackRow(overrides = {}) {
|
||||
return {
|
||||
ID: "WCBK1-0",
|
||||
Title: "合法企微审批回调只读合同",
|
||||
Description: "WCBK1 回调只读 POC,路线:WCBK1 -> WACP3 -> WADM1 -> wecom_attendance_approval_mirror_v1;范围只验证企微审批回调。",
|
||||
Acceptance: "固定 WECOM_APPROVAL_CALLBACK_URL、WECOM_CALLBACK_TOKEN、WECOM_CALLBACK_ENCODING_AES_KEY;GET /api/wecom/approval/callback 做 URL 验证;POST /api/wecom/approval/callback 接收事件;使用 msg_signature 验签和 EncodingAESKey 解密;只读、幂等,不写 OA 业务镜像,不写企微数据;不使用通讯录 Secret,不尝试其他 Secret,不允许运行时猜 Secret;缺配置记录 NEEDS_CONFIG,外部阻断记录 SKIP/BLOCKED_BY_EXTERNAL_CONFIG。",
|
||||
Test_Method: "受影响流程:WAPC2FLIVE 样件 -> WCBK1 回调只读 POC -> WACP3 补偿拉取;验证步骤:node scripts/qa/check-process-contract.mjs --issue callback.csv --profile wecom-callback --json。",
|
||||
Tools: "node|vitest",
|
||||
Dev_Status: "TODO",
|
||||
Review1_Status: "TODO",
|
||||
Regression_Status: "TODO",
|
||||
Files: "server/src/routes/wecomApprovalCallback.js|server/src/services/integrations/wecomCallbackCrypto.js",
|
||||
Dependencies: "[]",
|
||||
Notes: "回滚路径:移除回调 URL 或新增路由;报告不得输出原始 XML、密文、完整 sp_no、完整 userid、完整 template_id、Token、EncodingAESKey 或原始 payload。",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function evaluate(rows, profile = "wecom-approval-fixture") {
|
||||
return gate.evaluateIssueContract({
|
||||
csvText: buildCsv(rows),
|
||||
@@ -72,6 +90,14 @@ describe("check-process-contract", () => {
|
||||
expect(result.checks.some((item) => item.name === "profile.wecom_approval_fixture")).toBe(true);
|
||||
});
|
||||
|
||||
it("passes a valid WCBK1 callback contract", () => {
|
||||
const result = evaluate([validCallbackRow()], "wecom-callback");
|
||||
|
||||
expect(result.status).toBe("PASS");
|
||||
expect(errorCodes(result)).toEqual([]);
|
||||
expect(result.checks.some((item) => item.name === "profile.wecom_callback")).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when Dependencies is not a JSON array", () => {
|
||||
const result = evaluate([validFixtureRow({ Dependencies: "WAPC2F-0" })]);
|
||||
|
||||
@@ -138,6 +164,10 @@ describe("check-process-contract", () => {
|
||||
issue: "a.csv",
|
||||
profile: "wecom",
|
||||
});
|
||||
expect(gate.parseArgs(["--issue", "a.csv", "--profile", "wecom-callback"])).toMatchObject({
|
||||
issue: "a.csv",
|
||||
profile: "wecom-callback",
|
||||
});
|
||||
expect(() => gate.parseArgs(["--issue", "a.csv", "--profile", "unknown"])).toThrow(/unsupported profile/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
buildCallbackConfigCheck,
|
||||
executeCallbackPoc,
|
||||
parseArgs,
|
||||
runSyntheticCheck,
|
||||
runUrlVerificationCheck,
|
||||
} = require("../../../src/scripts/check-wecom-approval-callback-poc");
|
||||
|
||||
const TOKEN = "callback-token";
|
||||
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||
const CORP_ID = "ww-callback-corp";
|
||||
const SECRET_VALUE = "secret-value-should-not-leak";
|
||||
|
||||
function baseConfig(overrides = {}) {
|
||||
return {
|
||||
wecomCorpId: CORP_ID,
|
||||
wecomApprovalCallbackUrl: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||
wecomCallbackToken: TOKEN,
|
||||
wecomCallbackEncodingAesKey: AES_KEY,
|
||||
wecomApprovalCallbackEvidenceFile: path.join("/tmp", `wcbk1-script-${Date.now()}-${Math.random()}.jsonl`),
|
||||
wecomRequestTimeoutMs: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function textResponse(body, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-approval-callback-poc", () => {
|
||||
it("parses CLI options", () => {
|
||||
const options = parseArgs([
|
||||
"--live",
|
||||
"--format=json",
|
||||
"--callback-url=https://oa.hyinda.com/api/wecom/approval/callback",
|
||||
"--report-dir=/tmp/wcbk1",
|
||||
]);
|
||||
|
||||
expect(options).toMatchObject({
|
||||
live: true,
|
||||
format: "json",
|
||||
callbackUrl: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||
reportDir: "/tmp/wcbk1",
|
||||
});
|
||||
expect(() => parseArgs(["--format=xml"])).toThrow(/unsupported format/);
|
||||
});
|
||||
|
||||
it("classifies missing callback config as NEEDS_CONFIG", () => {
|
||||
const check = buildCallbackConfigCheck(baseConfig({ wecomCallbackToken: "", wecomApprovalCallbackUrl: "" }), "");
|
||||
|
||||
expect(check.status).toBe("NEEDS_CONFIG");
|
||||
expect(check.missing).toEqual(expect.arrayContaining([
|
||||
"WECOM_APPROVAL_CALLBACK_URL",
|
||||
"WECOM_CALLBACK_TOKEN",
|
||||
]));
|
||||
expect(JSON.stringify(check)).not.toContain(SECRET_VALUE);
|
||||
});
|
||||
|
||||
it("runs synthetic crypto check without leaking raw identifiers", () => {
|
||||
const check = runSyntheticCheck(baseConfig());
|
||||
|
||||
expect(check.status).toBe("OK");
|
||||
expect(check.summary).toMatchObject({
|
||||
eventType: "open_approval_change",
|
||||
approvalType: "leave",
|
||||
statusLabel: "已通过",
|
||||
});
|
||||
expect(JSON.stringify(check)).not.toContain("SPWCBK1SYNTHETIC000001");
|
||||
expect(JSON.stringify(check)).not.toContain("TPLWCBK1SYNTHETIC000001");
|
||||
});
|
||||
|
||||
it("verifies live callback URL with generated echostr", async () => {
|
||||
const fetchImpl = vi.fn(async (url) => {
|
||||
expect(String(url)).toContain("msg_signature=");
|
||||
expect(String(url)).not.toContain(TOKEN);
|
||||
expect(String(url)).not.toContain(AES_KEY);
|
||||
return textResponse("wcbk1-url-verify-ok");
|
||||
});
|
||||
|
||||
const check = await runUrlVerificationCheck({
|
||||
config: baseConfig(),
|
||||
callbackUrl: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||
fetchImpl,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(check).toMatchObject({
|
||||
status: "OK",
|
||||
matched: true,
|
||||
callbackUrlPreview: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns PARTIAL_CALLBACK_NEEDS_CONFIG when real callback config is missing", async () => {
|
||||
const report = await executeCallbackPoc({
|
||||
live: false,
|
||||
format: "json",
|
||||
}, {
|
||||
config: baseConfig({
|
||||
wecomApprovalCallbackUrl: "",
|
||||
wecomCallbackToken: "",
|
||||
wecomCallbackEncodingAesKey: "",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_CALLBACK_NEEDS_CONFIG");
|
||||
expect(report.checks.synthetic.status).toBe("OK");
|
||||
expect(report.configGaps.map((item) => item.name)).toEqual(expect.arrayContaining([
|
||||
"WECOM_APPROVAL_CALLBACK_URL",
|
||||
"WECOM_CALLBACK_TOKEN",
|
||||
"WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||
]));
|
||||
});
|
||||
|
||||
it("returns PASS_CALLBACK_EVENT_CONFIRMED when sanitized event evidence exists", async () => {
|
||||
const config = baseConfig();
|
||||
await fs.mkdir(path.dirname(config.wecomApprovalCallbackEvidenceFile), { recursive: true });
|
||||
await fs.writeFile(config.wecomApprovalCallbackEvidenceFile, `${JSON.stringify({
|
||||
recordedAt: "2026-05-19T06:00:00.000Z",
|
||||
eventType: "open_approval_change",
|
||||
callbackKind: "approval_status_change",
|
||||
approvalType: "leave",
|
||||
spStatus: "2",
|
||||
statusLabel: "已通过",
|
||||
spNoPrefix: "SPFULLID***",
|
||||
templateIdPrefix: "TPLFULLI***",
|
||||
idempotencyKey: "idem",
|
||||
})}\n`);
|
||||
const fetchImpl = vi.fn(async () => textResponse("wcbk1-url-verify-ok"));
|
||||
|
||||
const report = await executeCallbackPoc({ live: true }, { config, fetchImpl });
|
||||
|
||||
expect(report.status).toBe("PASS_CALLBACK_EVENT_CONFIRMED");
|
||||
expect(report.checks.eventEvidence.eventCount).toBe(1);
|
||||
expect(JSON.stringify(report)).not.toContain(TOKEN);
|
||||
expect(JSON.stringify(report)).not.toContain(AES_KEY);
|
||||
});
|
||||
});
|
||||
@@ -267,7 +267,7 @@ describe("check-wecom-runtime-ready", () => {
|
||||
it("classifies missing approval and callback values as NEEDS_CONFIG", () => {
|
||||
const checks = buildApprovalConfigChecks({
|
||||
WECOM_APPROVAL_TEMPLATE_ID: "approval-template-id",
|
||||
WECOM_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/callback",
|
||||
WECOM_APPROVAL_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||
});
|
||||
|
||||
expect(checks.find((item) => item.id === "approval_template_id")).toMatchObject({
|
||||
@@ -276,7 +276,7 @@ describe("check-wecom-runtime-ready", () => {
|
||||
});
|
||||
expect(checks.find((item) => item.id === "approval_callback_url")).toMatchObject({
|
||||
status: "CONFIGURED",
|
||||
configuredEnv: "WECOM_CALLBACK_URL",
|
||||
configuredEnv: "WECOM_APPROVAL_CALLBACK_URL",
|
||||
valuePreview: "https://oa.hyinda.com/...",
|
||||
});
|
||||
expect(checks.find((item) => item.id === "approval_test_sp_no")).toMatchObject({
|
||||
@@ -301,9 +301,9 @@ describe("check-wecom-runtime-ready", () => {
|
||||
WECOM_RUNTIME_TEST_SEND: "1",
|
||||
WECOM_APPROVAL_TEMPLATE_ID: "tpl",
|
||||
WECOM_APPROVAL_TEST_SP_NO: "sp",
|
||||
WECOM_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/callback",
|
||||
WECOM_APPROVAL_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||
WECOM_CALLBACK_TOKEN: SENSITIVE_CALLBACK_TOKEN,
|
||||
WECOM_CALLBACK_AES_KEY: SENSITIVE_CALLBACK_AES_KEY,
|
||||
WECOM_CALLBACK_ENCODING_AES_KEY: SENSITIVE_CALLBACK_AES_KEY,
|
||||
},
|
||||
oaRows: [boundEmployee()],
|
||||
fetchProfilesImpl: vi.fn(async () => ({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户