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

这个提交包含在:
2026-05-19 15:00:22 +08:00
父节点 a32ec1e658
当前提交 8122898348
共修改 30 个文件,包含 1650 行新增和 53 行删除
@@ -0,0 +1,433 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const {
WecomCallbackError,
buildEncryptedApprovalEventSample,
buildUrlVerificationSample,
decryptWecomMessage,
extractEncryptedFromXml,
maskWecomIdentifier,
normalizeApprovalCallbackEvent,
readCallbackEvidence,
validateCallbackConfig,
verifyWecomSignature,
} = require("../services/integrations/wecomCallbackCrypto");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const SYNTHETIC_TOKEN = "wcbk1-synthetic-token";
const SYNTHETIC_AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
const SYNTHETIC_CORP_ID = "wwsyntheticcallbackcorp";
const SYNTHETIC_SP_NO = "SPWCBK1SYNTHETIC000001";
const SYNTHETIC_TEMPLATE_ID = "TPLWCBK1SYNTHETIC000001";
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function usage() {
return `Usage: node src/scripts/check-wecom-approval-callback-poc.js [options]
Options:
--live 访问已部署 Callback URL 做 URL 验证,并检查真实事件证据。
--format=json 输出 JSON 到 stdout。
--callback-url=<url> 临时覆盖 WECOM_APPROVAL_CALLBACK_URL。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-callback-poc-<timestamp>。
--help 显示帮助。
该 runner 为 QA-only,只输出脱敏摘要,不打印 Token、EncodingAESKey、原始 XML、密文、完整 sp_no 或完整 userid。`;
}
function parseArgs(argv = process.argv.slice(2), env = process.env) {
const options = {
live: false,
format: "text",
callbackUrl: "",
reportDir: env.WECOM_APPROVAL_CALLBACK_POC_REPORT_DIR
|| path.join(DEFAULT_REPORT_ROOT, `wecom-approval-callback-poc-${timestamp()}`),
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--live") {
options.live = true;
} else if (arg === "--format=json" || arg === "--json") {
options.format = "json";
} else if (arg === "--format") {
options.format = argv[index + 1] || options.format;
index += 1;
} else if (arg.startsWith("--format=")) {
options.format = arg.slice("--format=".length);
} else if (arg === "--callback-url") {
options.callbackUrl = argv[index + 1] || "";
index += 1;
} else if (arg.startsWith("--callback-url=")) {
options.callbackUrl = arg.slice("--callback-url=".length);
} else if (arg === "--report-dir") {
options.reportDir = argv[index + 1] || options.reportDir;
index += 1;
} else if (arg.startsWith("--report-dir=")) {
options.reportDir = arg.slice("--report-dir=".length);
} else if (arg === "--help" || arg === "-h") {
options.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`unsupported format: ${options.format}`);
}
return options;
}
function configured(value) {
return String(value ?? "").trim().length > 0;
}
function maskHostLike(url) {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
} catch {
return null;
}
}
function loadCallbackConfig(env = process.env) {
return {
wecomCorpId: env.WECOM_CORP_ID || "",
wecomApprovalCallbackUrl: env.WECOM_APPROVAL_CALLBACK_URL || "",
wecomCallbackToken: env.WECOM_CALLBACK_TOKEN || "",
wecomCallbackEncodingAesKey: env.WECOM_CALLBACK_ENCODING_AES_KEY || "",
wecomApprovalCallbackEvidenceFile: env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || "",
wecomRequestTimeoutMs: Number(env.WECOM_REQUEST_TIMEOUT_MS || 10000),
};
}
function buildCallbackConfigCheck(config, callbackUrl) {
const missing = [];
if (!configured(config.wecomCorpId)) missing.push("WECOM_CORP_ID");
if (!configured(callbackUrl)) missing.push("WECOM_APPROVAL_CALLBACK_URL");
if (!configured(config.wecomCallbackToken)) missing.push("WECOM_CALLBACK_TOKEN");
if (!configured(config.wecomCallbackEncodingAesKey)) missing.push("WECOM_CALLBACK_ENCODING_AES_KEY");
return {
id: "callback_config",
status: missing.length ? "NEEDS_CONFIG" : "OK",
missing,
configured: {
corpId: configured(config.wecomCorpId),
callbackUrl: configured(callbackUrl),
callbackUrlPreview: configured(callbackUrl) ? maskHostLike(callbackUrl) : null,
token: configured(config.wecomCallbackToken),
encodingAesKey: configured(config.wecomCallbackEncodingAesKey),
},
};
}
function runSyntheticCheck(config = {}) {
const token = configured(config.wecomCallbackToken) ? config.wecomCallbackToken : SYNTHETIC_TOKEN;
const encodingAesKey = configured(config.wecomCallbackEncodingAesKey)
? config.wecomCallbackEncodingAesKey
: SYNTHETIC_AES_KEY;
const receiveId = configured(config.wecomCorpId) ? config.wecomCorpId : SYNTHETIC_CORP_ID;
const timestampValue = "1770000000";
const nonce = "wcbk1-synthetic";
try {
const sample = buildEncryptedApprovalEventSample({
token,
encodingAesKey,
receiveId,
timestamp: timestampValue,
nonce,
spNo: SYNTHETIC_SP_NO,
templateId: SYNTHETIC_TEMPLATE_ID,
});
const encrypted = extractEncryptedFromXml(sample.encryptedXml);
const signatureOk = verifyWecomSignature({
token,
timestamp: timestampValue,
nonce,
encrypted,
signature: sample.signature,
});
const decrypted = decryptWecomMessage({
encrypted,
encodingAesKey,
receiveId,
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
return {
id: "synthetic_callback_crypto",
status: signatureOk ? "OK" : "BLOCKED",
signatureOk,
decryptOk: Boolean(decrypted.plainXml),
summary: {
eventType: summary.eventType,
callbackKind: summary.callbackKind,
approvalType: summary.approvalType,
spStatus: summary.spStatus,
statusLabel: summary.statusLabel,
spNoPrefix: summary.spNoPrefix,
templateIdPrefix: summary.templateIdPrefix,
idempotencyKeyPrefix: maskWecomIdentifier(summary.idempotencyKey),
},
};
} catch (error) {
return {
id: "synthetic_callback_crypto",
status: "BLOCKED",
reason: error?.code || "synthetic_callback_failed",
message: error instanceof WecomCallbackError ? error.message : "synthetic callback crypto failed",
};
}
}
async function runUrlVerificationCheck({ config, callbackUrl, fetchImpl = fetch, timeoutMs = 10000 }) {
if (!configured(callbackUrl)) {
return {
id: "url_verification",
status: "NEEDS_CONFIG",
missing: ["WECOM_APPROVAL_CALLBACK_URL"],
};
}
const configCheck = validateCallbackConfig({
wecomCorpId: config.wecomCorpId,
wecomCallbackToken: config.wecomCallbackToken,
wecomCallbackEncodingAesKey: config.wecomCallbackEncodingAesKey,
});
if (!configCheck.ok) {
return {
id: "url_verification",
status: "NEEDS_CONFIG",
missing: configCheck.missing,
};
}
try {
const sample = buildUrlVerificationSample({
token: config.wecomCallbackToken,
encodingAesKey: config.wecomCallbackEncodingAesKey,
receiveId: config.wecomCorpId,
timestamp: String(Math.floor(Date.now() / 1000)),
nonce: "wcbk1-live-url",
plainText: "wcbk1-url-verify-ok",
});
const url = new URL(callbackUrl);
url.searchParams.set("msg_signature", sample.signature);
url.searchParams.set("timestamp", sample.timestamp);
url.searchParams.set("nonce", sample.nonce);
url.searchParams.set("echostr", sample.encrypted);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetchImpl(url, { method: "GET", signal: controller.signal });
clearTimeout(timer);
const body = await response.text();
const matched = response.ok && body.trim() === sample.plainText;
return {
id: "url_verification",
status: matched ? "OK" : "BLOCKED",
httpStatus: response.status,
callbackUrlPreview: maskHostLike(callbackUrl),
matched,
reason: matched ? null : "callback_url_verify_failed",
};
} catch (error) {
return {
id: "url_verification",
status: "BLOCKED",
callbackUrlPreview: maskHostLike(callbackUrl),
reason: error?.name === "AbortError" ? "callback_url_timeout" : "callback_url_network_error",
};
}
}
async function buildEvidenceCheck(config = {}) {
const events = await readCallbackEvidence({ evidenceFile: config.wecomApprovalCallbackEvidenceFile });
const approvalEvents = events.filter((item) => item.callbackKind === "approval_status_change");
const lastEvent = approvalEvents[approvalEvents.length - 1] || null;
return {
id: "callback_event_evidence",
status: approvalEvents.length ? "OK" : "PENDING",
eventCount: approvalEvents.length,
lastEvent: lastEvent
? {
eventType: lastEvent.eventType,
approvalType: lastEvent.approvalType,
spStatus: lastEvent.spStatus,
statusLabel: lastEvent.statusLabel,
spNoPrefix: lastEvent.spNoPrefix,
templateIdPrefix: lastEvent.templateIdPrefix,
recordedAt: lastEvent.recordedAt,
idempotencyKeyPrefix: maskWecomIdentifier(lastEvent.idempotencyKey),
}
: null,
};
}
function evaluateConclusion({ configCheck, syntheticCheck, urlCheck, evidenceCheck, live }) {
if (syntheticCheck.status !== "OK") {
return {
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
conclusion: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
mainBlockers: [{ id: syntheticCheck.id, reason: syntheticCheck.reason || "synthetic_callback_failed" }],
};
}
if (configCheck.status !== "OK") {
return {
status: "PARTIAL_CALLBACK_NEEDS_CONFIG",
conclusion: "PARTIAL_CALLBACK_NEEDS_CONFIG",
configGaps: configCheck.missing.map((name) => ({ name, status: "NEEDS_CONFIG" })),
mainBlockers: [],
};
}
if (live && urlCheck?.status === "BLOCKED") {
return {
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
conclusion: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
mainBlockers: [{ id: urlCheck.id, reason: urlCheck.reason || "callback_url_verify_failed" }],
};
}
if (evidenceCheck?.status === "OK") {
return {
status: "PASS_CALLBACK_EVENT_CONFIRMED",
conclusion: "PASS_CALLBACK_EVENT_CONFIRMED",
mainBlockers: [],
};
}
if (live && urlCheck?.status === "OK") {
return {
status: "PASS_CALLBACK_URL_VERIFIED",
conclusion: "PASS_CALLBACK_URL_VERIFIED",
mainBlockers: [],
};
}
return {
status: "PASS_CALLBACK_SYNTHETIC_READY",
conclusion: "PASS_CALLBACK_SYNTHETIC_READY",
mainBlockers: [],
};
}
async function executeCallbackPoc(options = {}, deps = {}) {
const config = deps.config || loadCallbackConfig(deps.env);
const callbackUrl = options.callbackUrl || config.wecomApprovalCallbackUrl || "";
const syntheticCheck = runSyntheticCheck(config);
const configCheck = buildCallbackConfigCheck(config, callbackUrl);
const urlCheck = options.live
? await runUrlVerificationCheck({
config,
callbackUrl,
fetchImpl: deps.fetchImpl || fetch,
timeoutMs: config.wecomRequestTimeoutMs || 10000,
})
: {
id: "url_verification",
status: configCheck.status === "OK" ? "PENDING" : "NEEDS_CONFIG",
};
const evidenceCheck = await buildEvidenceCheck(config);
const conclusion = evaluateConclusion({
configCheck,
syntheticCheck,
urlCheck,
evidenceCheck,
live: Boolean(options.live),
});
return {
status: conclusion.status,
conclusion: conclusion.conclusion,
generatedAt: new Date().toISOString(),
scope: {
targets: ["leave", "missed_punch"],
live: Boolean(options.live),
writeBehavior: "read_only_callback_poc",
callbackUrlPreview: configured(callbackUrl) ? maskHostLike(callbackUrl) : null,
},
privacy: "redacted_no_token_no_secret_no_raw_xml_no_ciphertext_no_full_sp_no_no_full_userid_no_raw_payload",
checks: {
config: configCheck,
synthetic: syntheticCheck,
urlVerification: urlCheck,
eventEvidence: evidenceCheck,
},
configGaps: conclusion.configGaps || [],
mainBlockers: conclusion.mainBlockers || [],
nextManualAction: conclusion.status === "PASS_CALLBACK_URL_VERIFIED"
? "请由 SMOKE101 在企业微信处理一条现有 OA-POC 测试审批单,然后复跑 live runner。"
: null,
};
}
function ensureReportDir(reportDir) {
fs.mkdirSync(reportDir, { recursive: true });
}
function writeReport(reportDir, report) {
ensureReportDir(reportDir);
const filePath = path.join(reportDir, "summary.json");
fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`);
return filePath;
}
async function main() {
let options;
try {
options = parseArgs();
} catch (error) {
process.stderr.write(`${error.message}\n${usage()}\n`);
process.exit(2);
}
if (options.help) {
process.stdout.write(`${usage()}\n`);
return;
}
try {
const report = await executeCallbackPoc(options);
const reportPath = writeReport(options.reportDir, report);
report.reportPath = reportPath;
if (options.format === "json") {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} else {
process.stdout.write(`[wecom-approval-callback-poc] status=${report.status} conclusion=${report.conclusion}\n`);
process.stdout.write(`[wecom-approval-callback-poc] report=${reportPath}\n`);
process.stdout.write(`[wecom-approval-callback-poc] privacy=${report.privacy}\n`);
if (report.configGaps.length) {
process.stdout.write(`[wecom-approval-callback-poc] configGaps=${JSON.stringify(report.configGaps)}\n`);
}
if (report.mainBlockers.length) {
process.stdout.write(`[wecom-approval-callback-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
}
}
if (report.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
process.exitCode = 1;
}
} catch (error) {
process.stderr.write(`[wecom-approval-callback-poc][FAIL] ${error?.message || error}\n`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
buildCallbackConfigCheck,
buildEvidenceCheck,
evaluateConclusion,
executeCallbackPoc,
loadCallbackConfig,
parseArgs,
runSyntheticCheck,
runUrlVerificationCheck,
writeReport,
};
@@ -296,7 +296,7 @@ function buildApprovalConfigChecks(env = process.env) {
{
id: "approval_callback_url",
label: "真实回调 URL",
env: ["WECOM_APPROVAL_CALLBACK_URL", "WECOM_CALLBACK_URL"],
env: ["WECOM_APPROVAL_CALLBACK_URL"],
mask: maskHostLike,
},
{
@@ -307,7 +307,7 @@ function buildApprovalConfigChecks(env = process.env) {
{
id: "callback_encoding_aes_key",
label: "回调 EncodingAESKey",
env: ["WECOM_CALLBACK_ENCODING_AES_KEY", "WECOM_CALLBACK_AES_KEY"],
env: ["WECOM_CALLBACK_ENCODING_AES_KEY"],
},
];