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

这个提交包含在:
2026-05-19 15:00:22 +08:00
父节点 a32ec1e658
当前提交 8122898348
共修改 30 个文件,包含 1650 行新增和 53 行删除
+1
查看文件
@@ -28,6 +28,7 @@
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"exceljs": "^4.4.0",
"fast-xml-parser": "5.3.4",
"fastify": "^5.7.4",
"fastify-plugin": "^5.1.0",
"node-cron": "^4.2.1",
+2
查看文件
@@ -30,6 +30,7 @@
"audit:org-master-data": "node src/scripts/audit-org-master-data.js",
"audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js",
"check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.js",
"check:wecom-approval-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js",
"check:wecom-approval-poc": "node src/scripts/check-wecom-approval-poc.js",
"check:wecom-runtime-ready": "node src/scripts/check-wecom-runtime-ready.js",
"resolve:wecom-oa-users": "node src/scripts/resolve-wecom-oa-user-consistency.js",
@@ -58,6 +59,7 @@
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"exceljs": "^4.4.0",
"fast-xml-parser": "5.3.4",
"fastify": "^5.7.4",
"fastify-plugin": "^5.1.0",
"node-cron": "^4.2.1",
+2
查看文件
@@ -17,6 +17,7 @@ const prismaPlugin = require("./plugins/prisma");
const swaggerDocsPlugin = require("./plugins/swagger");
const authRoutes = require("./routes/auth");
const wecomAuthRoutes = require("./routes/wecomAuth");
const wecomApprovalCallbackRoutes = require("./routes/wecomApprovalCallback");
const meRoutes = require("./routes/me");
const reimbursementRoutes = require("./routes/reimbursements");
const invoicePoolRoutes = require("./routes/invoicePool");
@@ -336,6 +337,7 @@ async function buildApp() {
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" });
await app.register(eventsRoutes, { prefix: "/api" });
await app.register(meRoutes, { prefix: "/api" });
await app.register(reimbursementRoutes, { prefix: "/api" });
+4
查看文件
@@ -70,6 +70,10 @@ function getConfig() {
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
wecomApprovalCallbackUrl: process.env.WECOM_APPROVAL_CALLBACK_URL ?? "",
wecomCallbackToken: process.env.WECOM_CALLBACK_TOKEN ?? "",
wecomCallbackEncodingAesKey: process.env.WECOM_CALLBACK_ENCODING_AES_KEY ?? "",
wecomApprovalCallbackEvidenceFile: process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE ?? "",
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
storageDir: path.resolve(serverRoot, storageDir),
+161
查看文件
@@ -0,0 +1,161 @@
const {
WecomCallbackError,
assertWecomSignature,
decryptWecomMessage,
extractEncryptedFromXml,
maskWecomIdentifier,
normalizeApprovalCallbackEvent,
recordCallbackEvidence,
sanitizeErrorMessage,
validateCallbackConfig,
} = require("../services/integrations/wecomCallbackCrypto");
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function callbackConfig(fastify) {
const config = fastify.config || {};
return {
receiveId: config.wecomCorpId,
token: config.wecomCallbackToken,
encodingAesKey: config.wecomCallbackEncodingAesKey,
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
};
}
function sendPlain(reply, statusCode, body) {
return reply.status(statusCode).type("text/plain; charset=utf-8").send(body);
}
function classifyCallbackError(error) {
if (error instanceof WecomCallbackError) {
if (error.code === "invalid_msg_signature") return { statusCode: 403, body: "invalid signature" };
if (error.code === "receive_id_mismatch") return { statusCode: 403, body: "receive id mismatch" };
return { statusCode: 400, body: "invalid callback payload" };
}
return { statusCode: 500, body: "callback error" };
}
function readSignatureQuery(request) {
const query = request.query || {};
return {
signature: toSafeString(query.msg_signature, 120),
timestamp: toSafeString(query.timestamp, 80),
nonce: toSafeString(query.nonce, 120),
};
}
async function wecomApprovalCallbackRoutes(fastify, options = {}) {
fastify.addContentTypeParser(
["text/xml", "application/xml", "application/octet-stream"],
{ parseAs: "string" },
(request, body, done) => done(null, body),
);
fastify.addContentTypeParser(
/^application\/.*\+xml$/i,
{ parseAs: "string" },
(request, body, done) => done(null, body),
);
fastify.get("/wecom/approval/callback", async (request, reply) => {
const runtimeConfig = callbackConfig(fastify);
const configCheck = validateCallbackConfig({
wecomCorpId: runtimeConfig.receiveId,
wecomCallbackToken: runtimeConfig.token,
wecomCallbackEncodingAesKey: runtimeConfig.encodingAesKey,
});
if (!configCheck.ok) {
fastify.log.warn({ missing: configCheck.missing }, "WeCom approval callback config missing");
return sendPlain(reply, 503, "callback config missing");
}
const query = readSignatureQuery(request);
const encrypted = toSafeString(request.query?.echostr, 8192);
try {
assertWecomSignature({
token: runtimeConfig.token,
timestamp: query.timestamp,
nonce: query.nonce,
encrypted,
signature: query.signature,
});
const decrypted = decryptWecomMessage({
encrypted,
encodingAesKey: runtimeConfig.encodingAesKey,
receiveId: runtimeConfig.receiveId,
});
return sendPlain(reply, 200, decrypted.plainXml);
} catch (error) {
const classified = classifyCallbackError(error);
fastify.log.warn({
code: error?.code || "callback_url_verify_failed",
message: sanitizeErrorMessage(error?.message || error),
}, "WeCom approval callback URL verification failed");
return sendPlain(reply, classified.statusCode, classified.body);
}
});
fastify.post("/wecom/approval/callback", async (request, reply) => {
const runtimeConfig = callbackConfig(fastify);
const configCheck = validateCallbackConfig({
wecomCorpId: runtimeConfig.receiveId,
wecomCallbackToken: runtimeConfig.token,
wecomCallbackEncodingAesKey: runtimeConfig.encodingAesKey,
});
if (!configCheck.ok) {
fastify.log.warn({ missing: configCheck.missing }, "WeCom approval callback config missing");
return sendPlain(reply, 503, "callback config missing");
}
const query = readSignatureQuery(request);
const body = toSafeString(request.body, 20000);
try {
const encrypted = extractEncryptedFromXml(body);
assertWecomSignature({
token: runtimeConfig.token,
timestamp: query.timestamp,
nonce: query.nonce,
encrypted,
signature: query.signature,
});
const decrypted = decryptWecomMessage({
encrypted,
encodingAesKey: runtimeConfig.encodingAesKey,
receiveId: runtimeConfig.receiveId,
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
const evidenceStore = options.evidenceStore || {
record: (eventSummary) => recordCallbackEvidence(eventSummary, { evidenceFile: runtimeConfig.evidenceFile }),
};
const stored = await evidenceStore.record(summary);
fastify.log.info({
eventType: summary.eventType,
callbackKind: summary.callbackKind,
approvalType: summary.approvalType,
statusLabel: summary.statusLabel,
spNoPrefix: summary.spNoPrefix,
templateIdPrefix: summary.templateIdPrefix,
idempotencyKey: maskWecomIdentifier(summary.idempotencyKey),
duplicate: Boolean(stored?.duplicate),
}, "WeCom approval callback event accepted");
return sendPlain(reply, 200, "success");
} catch (error) {
const classified = classifyCallbackError(error);
fastify.log.warn({
code: error?.code || "callback_event_failed",
message: sanitizeErrorMessage(error?.message || error),
}, "WeCom approval callback event rejected");
return sendPlain(reply, classified.statusCode, classified.body);
}
});
}
wecomApprovalCallbackRoutes.__testables = {
classifyCallbackError,
readSignatureQuery,
};
module.exports = wecomApprovalCallbackRoutes;
@@ -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"],
},
];
@@ -0,0 +1,456 @@
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const path = require("node:path");
const { XMLParser, XMLBuilder } = require("fast-xml-parser");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../../artifacts/qa-flow");
const DEFAULT_EVIDENCE_FILE = path.join(
DEFAULT_REPORT_ROOT,
"wecom-approval-callback-events",
"events.jsonl",
);
const CALLBACK_EVENT_TYPES = new Set(["open_approval_change", "sys_approval_change"]);
class WecomCallbackError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = "WecomCallbackError";
this.code = code;
this.details = details;
}
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
parseTagValue: false,
parseAttributeValue: false,
trimValues: true,
});
const xmlBuilder = new XMLBuilder({
ignoreAttributes: false,
suppressEmptyNode: false,
});
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function sanitizeErrorMessage(value) {
return toSafeString(value, 500)
.replace(/(msg_signature=)[^&\s]+/gi, "$1***")
.replace(/(echostr=)[^&\s]+/gi, "$1***")
.replace(/[A-Za-z0-9+/=_-]{32,}/g, "***")
.slice(0, 240);
}
function maskWecomIdentifier(value) {
const raw = toSafeString(value, 160);
if (!raw) return null;
if (raw.length <= 6) return `${raw.slice(0, 2)}***`;
return `${raw.slice(0, Math.min(8, raw.length))}***`;
}
function normalizeEncodingAesKey(encodingAesKey) {
const raw = toSafeString(encodingAesKey, 80);
if (!raw) {
throw new WecomCallbackError("missing_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY is required");
}
if (raw.length !== 43) {
throw new WecomCallbackError("invalid_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY must be 43 characters");
}
const key = Buffer.from(`${raw}=`, "base64");
if (key.length !== 32) {
throw new WecomCallbackError("invalid_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY decoded length is invalid");
}
return key;
}
function createWecomSignature({ token, timestamp, nonce, encrypted }) {
const parts = [token, timestamp, nonce, encrypted].map((item) => toSafeString(item, 4096));
if (parts.some((item) => !item)) {
throw new WecomCallbackError("missing_signature_input", "Token, timestamp, nonce and encrypted payload are required");
}
return crypto.createHash("sha1").update(parts.sort().join("")).digest("hex");
}
function timingSafeEqualHex(left, right) {
const a = Buffer.from(toSafeString(left, 80), "utf8");
const b = Buffer.from(toSafeString(right, 80), "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function verifyWecomSignature({ token, timestamp, nonce, encrypted, signature }) {
const expected = createWecomSignature({ token, timestamp, nonce, encrypted });
return timingSafeEqualHex(expected, signature);
}
function assertWecomSignature(input) {
if (!verifyWecomSignature(input)) {
throw new WecomCallbackError("invalid_msg_signature", "WeCom callback signature mismatch");
}
}
function pkcs7Pad(buffer, blockSize = 32) {
const padding = blockSize - (buffer.length % blockSize || blockSize);
const padLength = padding === 0 ? blockSize : padding;
return Buffer.concat([buffer, Buffer.alloc(padLength, padLength)]);
}
function pkcs7Unpad(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted payload is empty");
}
const padLength = buffer[buffer.length - 1];
if (padLength < 1 || padLength > 32 || padLength > buffer.length) {
throw new WecomCallbackError("invalid_decrypted_padding", "Invalid WeCom callback padding");
}
return buffer.subarray(0, buffer.length - padLength);
}
function decryptWecomMessage({ encrypted, encodingAesKey, receiveId }) {
const safeEncrypted = toSafeString(encrypted, 8192);
if (!safeEncrypted) {
throw new WecomCallbackError("missing_encrypted_payload", "Encrypted callback payload is required");
}
const key = normalizeEncodingAesKey(encodingAesKey);
let encryptedBuffer;
try {
encryptedBuffer = Buffer.from(safeEncrypted, "base64");
} catch (error) {
throw new WecomCallbackError("invalid_encrypted_payload", "Encrypted callback payload is not valid base64");
}
try {
const decipher = crypto.createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
decipher.setAutoPadding(false);
const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
const plain = pkcs7Unpad(decrypted);
if (plain.length < 20) {
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted callback payload is too short");
}
const messageLength = plain.readUInt32BE(16);
const messageStart = 20;
const messageEnd = messageStart + messageLength;
if (messageLength < 0 || messageEnd > plain.length) {
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted callback XML length is invalid");
}
const plainXml = plain.subarray(messageStart, messageEnd).toString("utf8");
const decryptedReceiveId = plain.subarray(messageEnd).toString("utf8");
const expectedReceiveId = toSafeString(receiveId, 160);
if (expectedReceiveId && decryptedReceiveId !== expectedReceiveId) {
throw new WecomCallbackError("receive_id_mismatch", "WeCom callback receiveId mismatch", {
receiveIdPrefix: maskWecomIdentifier(decryptedReceiveId),
});
}
return {
plainXml,
receiveId: decryptedReceiveId,
receiveIdPrefix: maskWecomIdentifier(decryptedReceiveId),
};
} catch (error) {
if (error instanceof WecomCallbackError) throw error;
throw new WecomCallbackError("decrypt_failed", sanitizeErrorMessage(error?.message || "WeCom callback decrypt failed"));
}
}
function encryptWecomMessage({ plainXml, encodingAesKey, receiveId, randomBytes }) {
const key = normalizeEncodingAesKey(encodingAesKey);
const xmlBuffer = Buffer.from(toSafeString(plainXml, 20000), "utf8");
const lengthBuffer = Buffer.alloc(4);
lengthBuffer.writeUInt32BE(xmlBuffer.length, 0);
const random = randomBytes ? Buffer.from(randomBytes).subarray(0, 16) : crypto.randomBytes(16);
if (random.length !== 16) {
throw new WecomCallbackError("invalid_random_bytes", "WeCom encrypted message needs 16 random bytes");
}
const plain = pkcs7Pad(Buffer.concat([
random,
lengthBuffer,
xmlBuffer,
Buffer.from(toSafeString(receiveId, 160), "utf8"),
]));
const cipher = crypto.createCipheriv("aes-256-cbc", key, key.subarray(0, 16));
cipher.setAutoPadding(false);
return Buffer.concat([cipher.update(plain), cipher.final()]).toString("base64");
}
function parseWecomXml(xml) {
const safeXml = toSafeString(xml, 20000);
if (!safeXml) {
throw new WecomCallbackError("missing_xml", "WeCom callback XML is empty");
}
try {
const parsed = xmlParser.parse(safeXml);
const root = parsed?.xml && typeof parsed.xml === "object" ? parsed.xml : parsed;
if (!root || typeof root !== "object") {
throw new Error("missing xml root");
}
return root;
} catch (error) {
throw new WecomCallbackError("invalid_xml", sanitizeErrorMessage(error?.message || "WeCom callback XML parse failed"));
}
}
function buildEncryptedXml(encrypted) {
return xmlBuilder.build({ xml: { Encrypt: toSafeString(encrypted, 8192) } });
}
function extractEncryptedFromXml(xml) {
const root = parseWecomXml(xml);
const encrypted = toSafeString(root.Encrypt, 8192);
if (!encrypted) {
throw new WecomCallbackError("missing_encrypt_node", "WeCom callback XML missing Encrypt node");
}
return encrypted;
}
function pickText(source, names) {
if (!source || typeof source !== "object") return "";
for (const name of names) {
const value = source[name];
if (value == null) continue;
if (typeof value === "object") {
const nested = pickText(value, ["UserId", "userid", "Userid", "Name", "name", "#text"]);
if (nested) return nested;
} else {
const text = toSafeString(value, 300);
if (text) return text;
}
}
return "";
}
function inferApprovalType({ spName, templateId }) {
const text = `${spName || ""} ${templateId || ""}`;
if (/请假|leave/i.test(text)) return "leave";
if (/补卡|missed|punch|打卡/i.test(text)) return "missed_punch";
return "unknown";
}
function statusLabel(status) {
const text = toSafeString(status, 40);
const labels = {
"1": "审批中",
"2": "已通过",
"3": "已驳回",
"4": "已撤销",
"6": "通过后撤销",
"7": "已删除",
"10": "已支付",
};
return labels[text] || text || null;
}
function buildIdempotencyKey(parts) {
return crypto
.createHash("sha256")
.update(parts.map((item) => toSafeString(item, 300)).join("|"))
.digest("hex")
.slice(0, 32);
}
function normalizeApprovalCallbackEvent(plainXml) {
const root = parseWecomXml(plainXml);
const eventType = pickText(root, ["Event", "event"]);
const msgType = pickText(root, ["MsgType", "msgtype"]);
const approvalInfo = root.ApprovalInfo && typeof root.ApprovalInfo === "object"
? root.ApprovalInfo
: {};
const spNo = pickText(approvalInfo, ["SpNo", "sp_no", "SpNO", "Spno"]);
const templateId = pickText(approvalInfo, ["TemplateId", "TemplateID", "template_id"]);
const spName = pickText(approvalInfo, ["SpName", "sp_name", "TemplateName"]);
const spStatus = pickText(approvalInfo, ["SpStatus", "sp_status", "Status"]);
const createTime = pickText(root, ["CreateTime", "create_time"]);
const applyTime = pickText(approvalInfo, ["ApplyTime", "apply_time"]);
const agentId = pickText(root, ["AgentID", "AgentId", "agentid"]);
const approvalType = inferApprovalType({ spName, templateId });
const idempotencyKey = buildIdempotencyKey([
eventType,
spNo,
templateId,
spStatus,
createTime,
applyTime,
]);
return {
eventType: eventType || "unknown",
msgType: msgType || "unknown",
approvalType,
spStatus: spStatus || null,
statusLabel: statusLabel(spStatus),
spNoPrefix: maskWecomIdentifier(spNo),
templateIdPrefix: maskWecomIdentifier(templateId),
createTime: createTime || null,
applyTime: applyTime || null,
agentIdPrefix: maskWecomIdentifier(agentId),
callbackKind: CALLBACK_EVENT_TYPES.has(eventType) ? "approval_status_change" : "unknown_event",
idempotencyKey,
};
}
function validateCallbackConfig(config) {
const missing = [];
if (!toSafeString(config?.wecomCorpId, 160)) missing.push("WECOM_CORP_ID");
if (!toSafeString(config?.wecomCallbackToken, 300)) missing.push("WECOM_CALLBACK_TOKEN");
if (!toSafeString(config?.wecomCallbackEncodingAesKey, 80)) missing.push("WECOM_CALLBACK_ENCODING_AES_KEY");
return {
ok: missing.length === 0,
missing,
};
}
function buildUrlVerificationSample({ token, encodingAesKey, receiveId, timestamp, nonce, plainText }) {
const encrypted = encryptWecomMessage({
plainXml: plainText,
encodingAesKey,
receiveId,
randomBytes: Buffer.from("0123456789abcdef"),
});
const signature = createWecomSignature({ token, timestamp, nonce, encrypted });
return {
encrypted,
signature,
timestamp,
nonce,
plainText,
};
}
function buildEncryptedApprovalEventSample({ token, encodingAesKey, receiveId, timestamp, nonce, spNo, templateId }) {
const plainXml = xmlBuilder.build({
xml: {
ToUserName: receiveId,
FromUserName: "sys",
CreateTime: timestamp,
MsgType: "event",
Event: "open_approval_change",
AgentID: "1000002",
ApprovalInfo: {
SpNo: spNo,
SpName: "OA-POC 请假",
SpStatus: "2",
TemplateId: templateId,
ApplyTime: timestamp,
},
},
});
const encrypted = encryptWecomMessage({
plainXml,
encodingAesKey,
receiveId,
randomBytes: Buffer.from("fedcba9876543210"),
});
return {
plainXml,
encryptedXml: buildEncryptedXml(encrypted),
encrypted,
signature: createWecomSignature({ token, timestamp, nonce, encrypted }),
timestamp,
nonce,
};
}
async function recordCallbackEvidence(summary, options = {}) {
const evidenceFile = options.evidenceFile || process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || DEFAULT_EVIDENCE_FILE;
await fs.mkdir(path.dirname(evidenceFile), { recursive: true });
const idempotencyKey = toSafeString(summary?.idempotencyKey, 80);
let existingKeys = new Set();
try {
const content = await fs.readFile(evidenceFile, "utf8");
existingKeys = new Set(content
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line).idempotencyKey;
} catch {
return null;
}
})
.filter(Boolean));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
if (idempotencyKey && existingKeys.has(idempotencyKey)) {
return {
recorded: false,
duplicate: true,
evidenceFile,
idempotencyKey,
};
}
const event = {
recordedAt: new Date().toISOString(),
eventType: summary.eventType,
msgType: summary.msgType,
callbackKind: summary.callbackKind,
approvalType: summary.approvalType,
spStatus: summary.spStatus,
statusLabel: summary.statusLabel,
spNoPrefix: summary.spNoPrefix,
templateIdPrefix: summary.templateIdPrefix,
createTime: summary.createTime,
applyTime: summary.applyTime,
agentIdPrefix: summary.agentIdPrefix,
idempotencyKey,
};
await fs.appendFile(evidenceFile, `${JSON.stringify(event)}\n`, "utf8");
return {
recorded: true,
duplicate: false,
evidenceFile,
idempotencyKey,
};
}
async function readCallbackEvidence(options = {}) {
const evidenceFile = options.evidenceFile || process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || DEFAULT_EVIDENCE_FILE;
try {
const content = await fs.readFile(evidenceFile, "utf8");
return content
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
}
module.exports = {
CALLBACK_EVENT_TYPES,
DEFAULT_EVIDENCE_FILE,
WecomCallbackError,
assertWecomSignature,
buildEncryptedApprovalEventSample,
buildEncryptedXml,
buildUrlVerificationSample,
createWecomSignature,
decryptWecomMessage,
encryptWecomMessage,
extractEncryptedFromXml,
maskWecomIdentifier,
normalizeApprovalCallbackEvent,
parseWecomXml,
readCallbackEvidence,
recordCallbackEvidence,
sanitizeErrorMessage,
validateCallbackConfig,
verifyWecomSignature,
};
@@ -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)}&timestamp=${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&timestamp=${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&timestamp=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);
});
});