WCBK1 实现企微审批回调只读 POC
这个提交包含在:
@@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户