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