WCBK1 实现企微审批回调只读 POC

这个提交包含在:
2026-05-19 15:00:22 +08:00
父节点 a32ec1e658
当前提交 8122898348
共修改 30 个文件,包含 1650 行新增和 53 行删除
@@ -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 () => ({