WAPC2 实现企微审批只读 POC
这个提交包含在:
@@ -29,6 +29,7 @@
|
||||
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
||||
"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-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",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env node
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getConfig } = require("../config");
|
||||
const {
|
||||
APPROVAL_POC_TARGETS,
|
||||
fetchWecomApprovalDetail,
|
||||
fetchWecomApprovalTemplateDetail,
|
||||
maskApprovalIdentifier,
|
||||
} = require("../services/integrations/wecomApproval");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
|
||||
const REPORT_PRIVACY =
|
||||
"approval type/counts/masked template and sp_no prefixes only; no phone/email/token/secret/full userid/full sp_no/form values/attachments/comments/raw payload";
|
||||
|
||||
const TEMPLATE_ENV = Object.freeze({
|
||||
leave: "WECOM_APPROVAL_LEAVE_TEMPLATE_ID",
|
||||
missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID",
|
||||
});
|
||||
|
||||
const SP_NO_ENV = Object.freeze({
|
||||
leave: "WECOM_APPROVAL_LEAVE_TEST_SP_NO",
|
||||
missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEST_SP_NO",
|
||||
});
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-poc.js [options]
|
||||
|
||||
QA-only 企业微信审批模板与低风险 sp_no 只读 POC。
|
||||
默认不写 OA/企微资料,不接真实回调,不进入审批镜像。
|
||||
|
||||
Options:
|
||||
--live 启用企微 live 只读检查。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-poc-<timestamp>。
|
||||
--template=leave:<id> 指定请假模板 ID。
|
||||
--template=missed_punch:<id> 指定补卡模板 ID。
|
||||
--sp-no=leave:<sp_no> 指定请假低风险测试审批单号。
|
||||
--sp-no=missed_punch:<sp_no> 指定补卡低风险测试审批单号。
|
||||
--help 显示帮助。
|
||||
|
||||
Secret 固定规则:
|
||||
审批模板详情/审批详情:WECOM_AGENT_SECRET。
|
||||
不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。
|
||||
|
||||
隐私边界:
|
||||
报告只输出模板类型、计数、状态、脱敏模板 ID / sp_no 前缀和错误分类。
|
||||
不输出手机号、邮箱、token、secret、完整 userid、完整 sp_no、表单值、附件、评论或企微原始 payload。
|
||||
`);
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
function toSafeString(value, maxLength = 500) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeMessage(value) {
|
||||
return toSafeString(value, 500)
|
||||
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
||||
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
function supportedTargetKeys() {
|
||||
return APPROVAL_POC_TARGETS.map((item) => item.key);
|
||||
}
|
||||
|
||||
function parseTargetPair(value, flagName) {
|
||||
const raw = toSafeString(value, 500);
|
||||
const separatorIndex = raw.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(`${flagName} requires <target>:<value>`);
|
||||
}
|
||||
const target = raw.slice(0, separatorIndex).trim();
|
||||
const itemValue = raw.slice(separatorIndex + 1).trim();
|
||||
if (!supportedTargetKeys().includes(target)) {
|
||||
throw new Error(`${flagName} unsupported target: ${target}`);
|
||||
}
|
||||
if (!itemValue) {
|
||||
throw new Error(`${flagName} requires non-empty value`);
|
||||
}
|
||||
return { target, value: itemValue };
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
live: process.env.WECOM_APPROVAL_POC_LIVE === "1",
|
||||
format: "text",
|
||||
reportDir: process.env.WECOM_APPROVAL_POC_REPORT_DIR
|
||||
|| path.join(DEFAULT_REPORT_ROOT, `wecom-approval-poc-${timestamp()}`),
|
||||
templates: {},
|
||||
spNos: {},
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
} else if (arg === "--live") {
|
||||
options.live = true;
|
||||
} else if (arg === "--no-live") {
|
||||
options.live = false;
|
||||
} 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 === "--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 === "--template") {
|
||||
const pair = parseTargetPair(argv[index + 1], "--template");
|
||||
options.templates[pair.target] = pair.value;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--template=")) {
|
||||
const pair = parseTargetPair(arg.slice("--template=".length), "--template");
|
||||
options.templates[pair.target] = pair.value;
|
||||
} else if (arg === "--sp-no") {
|
||||
const pair = parseTargetPair(argv[index + 1], "--sp-no");
|
||||
options.spNos[pair.target] = pair.value;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--sp-no=")) {
|
||||
const pair = parseTargetPair(arg.slice("--sp-no=".length), "--sp-no");
|
||||
options.spNos[pair.target] = pair.value;
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function envValue(env, name) {
|
||||
return toSafeString(env[name], 500);
|
||||
}
|
||||
|
||||
function buildInputs(options, env = process.env) {
|
||||
return APPROVAL_POC_TARGETS.map((target) => {
|
||||
const cliTemplateId = toSafeString(options.templates[target.key], 500);
|
||||
const cliSpNo = toSafeString(options.spNos[target.key], 500);
|
||||
const envTemplateId = envValue(env, TEMPLATE_ENV[target.key]);
|
||||
const envSpNo = envValue(env, SP_NO_ENV[target.key]);
|
||||
const templateId = cliTemplateId || envTemplateId;
|
||||
const spNo = cliSpNo || envSpNo;
|
||||
|
||||
return {
|
||||
key: target.key,
|
||||
label: target.label,
|
||||
templateId,
|
||||
templateIdPrefix: maskApprovalIdentifier(templateId),
|
||||
templateSource: templateId ? (cliTemplateId ? "cli" : TEMPLATE_ENV[target.key]) : null,
|
||||
templateMissingEnv: templateId ? [] : [TEMPLATE_ENV[target.key]],
|
||||
spNo,
|
||||
spNoPrefix: maskApprovalIdentifier(spNo),
|
||||
spNoSource: spNo ? (cliSpNo ? "cli" : SP_NO_ENV[target.key]) : null,
|
||||
spNoMissingEnv: spNo ? [] : [SP_NO_ENV[target.key]],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildSecretCheck(config) {
|
||||
const missing = [];
|
||||
if (!config.wecomCorpId) missing.push("WECOM_CORP_ID");
|
||||
if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET");
|
||||
return {
|
||||
id: "approval_secret_matrix",
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
endpoints: [
|
||||
"POST /cgi-bin/oa/gettemplatedetail",
|
||||
"POST /cgi-bin/oa/getapprovaldetail",
|
||||
],
|
||||
status: missing.length ? "BLOCKED" : "OK",
|
||||
missing,
|
||||
noFallback: [
|
||||
"WECOM_CONTACT_SECRET",
|
||||
"WECOM_CHECKIN_SECRET",
|
||||
"WECOM_HR_ASSISTANT_SECRET",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeApiResult(result) {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
const summary = {};
|
||||
for (const key of ["errcode", "errmsg", "fieldCount", "fieldTypeCounts", "spStatus", "applyFieldCount", "approvalNodeCount", "notifyerCount", "commentCount"]) {
|
||||
if (result[key] != null) {
|
||||
summary[key] = key === "errmsg" ? safeMessage(result[key]) : result[key];
|
||||
}
|
||||
}
|
||||
return Object.keys(summary).length ? summary : null;
|
||||
}
|
||||
|
||||
function buildMissingTemplateCheck(input) {
|
||||
return {
|
||||
id: "approval_template_detail",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: "POST /cgi-bin/oa/gettemplatedetail",
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
status: "NEEDS_CONFIG",
|
||||
missingEnv: input.templateMissingEnv,
|
||||
templateIdPrefix: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMissingApprovalCheck(input) {
|
||||
return {
|
||||
id: "approval_detail",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: "POST /cgi-bin/oa/getapprovaldetail",
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
status: "NEEDS_CONFIG",
|
||||
missingEnv: input.spNoMissingEnv,
|
||||
spNoPrefix: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDryRunTemplateCheck(input) {
|
||||
return {
|
||||
id: "approval_template_detail",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: "POST /cgi-bin/oa/gettemplatedetail",
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
status: "DRY_RUN_READY",
|
||||
templateIdPrefix: input.templateIdPrefix,
|
||||
configuredFrom: input.templateSource,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDryRunApprovalCheck(input) {
|
||||
return {
|
||||
id: "approval_detail",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: "POST /cgi-bin/oa/getapprovaldetail",
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
status: "DRY_RUN_READY",
|
||||
spNoPrefix: input.spNoPrefix,
|
||||
configuredFrom: input.spNoSource,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLiveTemplateCheck(input, result) {
|
||||
return {
|
||||
id: "approval_template_detail",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: "POST /cgi-bin/oa/gettemplatedetail",
|
||||
source: result.tokenSource || "WECOM_AGENT_SECRET",
|
||||
status: result.ok ? "OK" : "BLOCKED",
|
||||
reason: result.ok ? null : result.code,
|
||||
message: result.message ? safeMessage(result.message) : null,
|
||||
templateIdPrefix: input.templateIdPrefix,
|
||||
configuredFrom: input.templateSource,
|
||||
summary: result.summary,
|
||||
response: summarizeApiResult(result.response),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLiveApprovalCheck(input, result) {
|
||||
return {
|
||||
id: "approval_detail",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: "POST /cgi-bin/oa/getapprovaldetail",
|
||||
source: result.tokenSource || "WECOM_AGENT_SECRET",
|
||||
status: result.ok ? "OK" : "BLOCKED",
|
||||
reason: result.ok ? null : result.code,
|
||||
message: result.message ? safeMessage(result.message) : null,
|
||||
spNoPrefix: input.spNoPrefix,
|
||||
configuredFrom: input.spNoSource,
|
||||
summary: result.summary,
|
||||
response: summarizeApiResult(result.response),
|
||||
};
|
||||
}
|
||||
|
||||
async function runTargetChecks({
|
||||
config,
|
||||
input,
|
||||
options,
|
||||
fetchTemplateDetailImpl = fetchWecomApprovalTemplateDetail,
|
||||
fetchApprovalDetailImpl = fetchWecomApprovalDetail,
|
||||
}) {
|
||||
let template = null;
|
||||
let approval = null;
|
||||
|
||||
if (!input.templateId) {
|
||||
template = buildMissingTemplateCheck(input);
|
||||
} else if (!options.live) {
|
||||
template = buildDryRunTemplateCheck(input);
|
||||
} else {
|
||||
template = buildLiveTemplateCheck(input, await fetchTemplateDetailImpl({
|
||||
config,
|
||||
templateId: input.templateId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!input.spNo) {
|
||||
approval = buildMissingApprovalCheck(input);
|
||||
} else if (!options.live) {
|
||||
approval = buildDryRunApprovalCheck(input);
|
||||
} else {
|
||||
approval = buildLiveApprovalCheck(input, await fetchApprovalDetailImpl({
|
||||
config,
|
||||
spNo: input.spNo,
|
||||
}));
|
||||
}
|
||||
|
||||
return { target: input.key, label: input.label, template, approval };
|
||||
}
|
||||
|
||||
function collectConfigGaps(targetChecks) {
|
||||
const gaps = [];
|
||||
for (const item of targetChecks) {
|
||||
for (const check of [item.template, item.approval]) {
|
||||
if (check?.status === "NEEDS_CONFIG") {
|
||||
gaps.push({
|
||||
id: check.id,
|
||||
target: check.target,
|
||||
status: check.status,
|
||||
missingEnv: check.missingEnv || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function collectMainBlockers(secretCheck, targetChecks) {
|
||||
const blockers = [];
|
||||
if (secretCheck.status === "BLOCKED") {
|
||||
blockers.push({
|
||||
id: secretCheck.id,
|
||||
reason: "missing_required_wecom_approval_config",
|
||||
missing: secretCheck.missing,
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of targetChecks) {
|
||||
for (const check of [item.template, item.approval]) {
|
||||
if (check?.status === "BLOCKED") {
|
||||
blockers.push({
|
||||
id: check.id,
|
||||
target: check.target,
|
||||
reason: check.reason || "wecom_approval_api_blocked",
|
||||
missing: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function evaluateConclusion({ mainBlockers, configGaps, verificationGaps }) {
|
||||
if (mainBlockers.length) {
|
||||
return {
|
||||
status: "SKIP",
|
||||
conclusion: "BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
};
|
||||
}
|
||||
if (configGaps.length || verificationGaps.length) {
|
||||
return {
|
||||
status: "PARTIAL_APPROVAL_POC_NEEDS_CONFIG",
|
||||
conclusion: "PARTIAL_APPROVAL_POC_NEEDS_CONFIG",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "PASS_APPROVAL_POC",
|
||||
conclusion: "PASS_APPROVAL_POC",
|
||||
};
|
||||
}
|
||||
|
||||
async function executeApprovalPoc(options, deps = {}) {
|
||||
const env = deps.env || process.env;
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const config = deps.config || getConfig();
|
||||
const inputs = buildInputs(options, env);
|
||||
const secretCheck = buildSecretCheck(config);
|
||||
const targetChecks = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
targetChecks.push(await runTargetChecks({
|
||||
config,
|
||||
input,
|
||||
options,
|
||||
fetchTemplateDetailImpl: deps.fetchTemplateDetailImpl,
|
||||
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl,
|
||||
}));
|
||||
}
|
||||
|
||||
const configGaps = collectConfigGaps(targetChecks);
|
||||
const verificationGaps = options.live ? [] : [{
|
||||
id: "live_read",
|
||||
status: "DRY_RUN",
|
||||
reason: "run_with_--live_for_public_readonly_gate",
|
||||
}];
|
||||
const mainBlockers = collectMainBlockers(secretCheck, targetChecks);
|
||||
const conclusion = evaluateConclusion({ mainBlockers, configGaps, verificationGaps });
|
||||
|
||||
return {
|
||||
mode: "qa_only",
|
||||
generatedAt,
|
||||
status: conclusion.status,
|
||||
conclusion: conclusion.conclusion,
|
||||
privacy: REPORT_PRIVACY,
|
||||
scope: {
|
||||
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
|
||||
writeBehavior: "no OA writes, no WeCom writes, no approval callbacks, no mirror import",
|
||||
liveRead: Boolean(options.live),
|
||||
deepMirror: "not_started",
|
||||
},
|
||||
inputs: {
|
||||
templates: Object.fromEntries(inputs.map((item) => [item.key, {
|
||||
configured: Boolean(item.templateId),
|
||||
configuredFrom: item.templateSource,
|
||||
templateIdPrefix: item.templateIdPrefix,
|
||||
}])),
|
||||
spNos: Object.fromEntries(inputs.map((item) => [item.key, {
|
||||
configured: Boolean(item.spNo),
|
||||
configuredFrom: item.spNoSource,
|
||||
spNoPrefix: item.spNoPrefix,
|
||||
}])),
|
||||
},
|
||||
checks: {
|
||||
secretMatrix: secretCheck,
|
||||
targets: targetChecks,
|
||||
},
|
||||
configGaps,
|
||||
verificationGaps,
|
||||
mainBlockers,
|
||||
};
|
||||
}
|
||||
|
||||
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`, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function printText(report, reportPath) {
|
||||
process.stdout.write(`[wecom-approval-poc] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||
process.stdout.write(`[wecom-approval-poc] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-approval-poc] privacy=${report.privacy}\n`);
|
||||
process.stdout.write(`[wecom-approval-poc] liveRead=${report.scope.liveRead} targets=${report.scope.targets.join(",")}\n`);
|
||||
if (report.configGaps?.length) {
|
||||
process.stdout.write(`[wecom-approval-poc] configGaps=${JSON.stringify(report.configGaps)}\n`);
|
||||
}
|
||||
if (report.mainBlockers?.length) {
|
||||
process.stdout.write(`[wecom-approval-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const report = await executeApprovalPoc(options);
|
||||
const reportPath = writeReport(options.reportDir, report);
|
||||
if (options.format === "json") {
|
||||
process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`);
|
||||
} else {
|
||||
printText(report, reportPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[wecom-approval-poc][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInputs,
|
||||
buildSecretCheck,
|
||||
evaluateConclusion,
|
||||
executeApprovalPoc,
|
||||
parseArgs,
|
||||
summarizeApiResult,
|
||||
};
|
||||
@@ -0,0 +1,345 @@
|
||||
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
|
||||
|
||||
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
|
||||
|
||||
const APPROVAL_POC_TARGETS = Object.freeze([
|
||||
{ key: "leave", label: "请假" },
|
||||
{ key: "missed_punch", label: "补卡" },
|
||||
]);
|
||||
|
||||
class WecomApprovalPocError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "WecomApprovalPocError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function toSafeString(value, maxLength = 500) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function sanitizeErrorMessage(value) {
|
||||
return toSafeString(value, 500)
|
||||
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
||||
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
function maskApprovalIdentifier(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 countBy(items, selector) {
|
||||
const counts = {};
|
||||
for (const item of items) {
|
||||
const key = toSafeString(selector(item), 80) || "unknown";
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function normalizeControls(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.filter((item) => item && typeof item === "object");
|
||||
if (typeof value === "object") {
|
||||
for (const key of ["controls", "contents", "items", "children"]) {
|
||||
if (Array.isArray(value[key])) return normalizeControls(value[key]);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function controlType(control) {
|
||||
if (!control || typeof control !== "object") return "unknown";
|
||||
return (
|
||||
control.control
|
||||
|| control.control_type
|
||||
|| control.controlType
|
||||
|| control.type
|
||||
|| control.property?.control
|
||||
|| control.property?.control_type
|
||||
|| control.property?.type
|
||||
|| "unknown"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeTemplatePayload(payload) {
|
||||
const templateId = payload?.template_id ?? payload?.templateId;
|
||||
const controls = normalizeControls(payload?.template_content?.controls)
|
||||
.concat(normalizeControls(payload?.template_content?.contents))
|
||||
.concat(normalizeControls(payload?.controls));
|
||||
const dedupedControls = controls.length ? controls : normalizeControls(payload?.template_content);
|
||||
|
||||
return {
|
||||
templateIdPrefix: maskApprovalIdentifier(templateId),
|
||||
fieldCount: dedupedControls.length,
|
||||
fieldTypeCounts: countBy(dedupedControls, controlType),
|
||||
hasTemplateNames: Array.isArray(payload?.template_names) && payload.template_names.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeApprovalPayload(payload) {
|
||||
const info = payload?.info && typeof payload.info === "object" ? payload.info : {};
|
||||
const applyData = info.apply_data && typeof info.apply_data === "object" ? info.apply_data : {};
|
||||
const contents = Array.isArray(applyData.contents) ? applyData.contents : [];
|
||||
const records = Array.isArray(info.sp_record) ? info.sp_record : [];
|
||||
const notifyers = Array.isArray(info.notifyer) ? info.notifyer : [];
|
||||
const comments = Array.isArray(info.comments) ? info.comments : [];
|
||||
|
||||
return {
|
||||
spNoPrefix: maskApprovalIdentifier(info.sp_no ?? payload?.sp_no),
|
||||
templateIdPrefix: maskApprovalIdentifier(info.template_id),
|
||||
spStatus: info.sp_status == null ? null : toSafeString(info.sp_status, 40),
|
||||
applyFieldCount: contents.length,
|
||||
applyFieldTypeCounts: countBy(contents, controlType),
|
||||
approvalNodeCount: records.length,
|
||||
notifyerCount: notifyers.length,
|
||||
commentCount: comments.length,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeApprovalApiResponse(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = sanitizeWecomResponse(payload) ?? {};
|
||||
if (payload.template_content) {
|
||||
const summary = summarizeTemplatePayload(payload);
|
||||
result.fieldCount = summary.fieldCount;
|
||||
result.fieldTypeCounts = summary.fieldTypeCounts;
|
||||
}
|
||||
if (payload.info) {
|
||||
const summary = summarizeApprovalPayload(payload);
|
||||
result.spStatus = summary.spStatus;
|
||||
result.applyFieldCount = summary.applyFieldCount;
|
||||
result.approvalNodeCount = summary.approvalNodeCount;
|
||||
result.notifyerCount = summary.notifyerCount;
|
||||
result.commentCount = summary.commentCount;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function classifyWecomApprovalResult(result, fallbackCode = "wecom_approval_api_error") {
|
||||
if (result?.networkError) {
|
||||
return {
|
||||
code: result.errorMessage === "timeout" ? "wecom_approval_timeout" : "wecom_approval_network_error",
|
||||
message: result.errorMessage || "WeCom approval API network error",
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
|
||||
const errcode = Number(result?.payload?.errcode);
|
||||
if (Number.isFinite(errcode) && errcode !== 0) {
|
||||
return {
|
||||
code: "wecom_approval_api_error",
|
||||
message: `WeCom approval API errcode ${errcode}`,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: result?.httpStatus ? `wecom_approval_http_${result.httpStatus}` : fallbackCode,
|
||||
message: "WeCom approval API failed",
|
||||
response: sanitizeApprovalApiResponse(result?.payload),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalJson(pathName, accessToken, body, options = {}) {
|
||||
const url = new URL(`${WECOM_API_BASE}${pathName}`);
|
||||
url.searchParams.set("access_token", accessToken);
|
||||
|
||||
const timeoutMs = Math.max(500, Number(options.timeoutMs ?? 10000));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await (options.fetchImpl ?? fetch)(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
const errcode = payload && typeof payload === "object" ? Number(payload.errcode) : NaN;
|
||||
return {
|
||||
ok: response.ok && Number.isFinite(errcode) && errcode === 0,
|
||||
httpStatus: response.status,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
httpStatus: null,
|
||||
payload: null,
|
||||
networkError: true,
|
||||
errorMessage: error?.name === "AbortError" ? "timeout" : sanitizeErrorMessage(error?.message ?? error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function getApprovalAccessToken({ config, fetchImpl, timeoutMs }) {
|
||||
const tokenResult = await getWecomAppAccessToken({
|
||||
config: {
|
||||
...config,
|
||||
wecomRequireAgentId: false,
|
||||
},
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
if (!tokenResult.ok) {
|
||||
throw new WecomApprovalPocError(tokenResult.code, tokenResult.message || "WeCom app token unavailable", {
|
||||
response: tokenResult.response,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: tokenResult.accessToken,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: tokenResult.response,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalTemplateDetail({
|
||||
config,
|
||||
templateId,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
|
||||
}) {
|
||||
const resolvedTemplateId = toSafeString(templateId, 160);
|
||||
if (!resolvedTemplateId) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "approval_template_id_missing",
|
||||
message: "Missing approval template id",
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
|
||||
const result = await fetchWecomApprovalJson("/cgi-bin/oa/gettemplatedetail", token.accessToken, {
|
||||
template_id: resolvedTemplateId,
|
||||
}, { fetchImpl, timeoutMs });
|
||||
|
||||
if (!result.ok) {
|
||||
const error = classifyWecomApprovalResult(result, "wecom_approval_template_detail_failed");
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
tokenSource: token.tokenSource,
|
||||
response: error.response,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_approval_template_detail_fetched",
|
||||
message: null,
|
||||
tokenSource: token.tokenSource,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
summary: summarizeTemplatePayload(result.payload),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: Boolean(error?.details?.retryable),
|
||||
code: error?.code || "wecom_approval_template_detail_failed",
|
||||
message: sanitizeErrorMessage(error?.message || error),
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: error?.details?.response || null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalDetail({
|
||||
config,
|
||||
spNo,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
|
||||
}) {
|
||||
const resolvedSpNo = toSafeString(spNo, 160);
|
||||
if (!resolvedSpNo) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "approval_sp_no_missing",
|
||||
message: "Missing approval sp_no",
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
|
||||
const result = await fetchWecomApprovalJson("/cgi-bin/oa/getapprovaldetail", token.accessToken, {
|
||||
sp_no: resolvedSpNo,
|
||||
}, { fetchImpl, timeoutMs });
|
||||
|
||||
if (!result.ok) {
|
||||
const error = classifyWecomApprovalResult(result, "wecom_approval_detail_failed");
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
tokenSource: token.tokenSource,
|
||||
response: error.response,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_approval_detail_fetched",
|
||||
message: null,
|
||||
tokenSource: token.tokenSource,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
summary: summarizeApprovalPayload(result.payload),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: Boolean(error?.details?.retryable),
|
||||
code: error?.code || "wecom_approval_detail_failed",
|
||||
message: sanitizeErrorMessage(error?.message || error),
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: error?.details?.response || null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APPROVAL_POC_TARGETS,
|
||||
WecomApprovalPocError,
|
||||
fetchWecomApprovalDetail,
|
||||
fetchWecomApprovalTemplateDetail,
|
||||
maskApprovalIdentifier,
|
||||
sanitizeApprovalApiResponse,
|
||||
summarizeApprovalPayload,
|
||||
summarizeTemplatePayload,
|
||||
};
|
||||
@@ -0,0 +1,408 @@
|
||||
const {
|
||||
buildInputs,
|
||||
buildSecretCheck,
|
||||
evaluateConclusion,
|
||||
executeApprovalPoc,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/check-wecom-approval-poc");
|
||||
const {
|
||||
fetchWecomApprovalDetail,
|
||||
fetchWecomApprovalTemplateDetail,
|
||||
maskApprovalIdentifier,
|
||||
summarizeApprovalPayload,
|
||||
summarizeTemplatePayload,
|
||||
} = require("../../../src/services/integrations/wecomApproval");
|
||||
const { clearWecomAppAccessTokenCache } = require("../../../src/services/integrations/wecomAccessToken");
|
||||
|
||||
const SENSITIVE_AGENT_CREDENTIAL = ["approval", "agent", "credential", "should", "never", "leak"].join("-");
|
||||
const SENSITIVE_CONTACT_CREDENTIAL = ["approval", "contact", "credential", "unused"].join("-");
|
||||
const SENSITIVE_CHECKIN_CREDENTIAL = ["approval", "checkin", "credential", "unused"].join("-");
|
||||
const SENSITIVE_HR_CREDENTIAL = ["approval", "hr", "credential", "unused"].join("-");
|
||||
const SENSITIVE_TEMPLATE_ID = ["tpl", "leave", "full", "identifier", "should", "not", "leak"].join("-");
|
||||
const SENSITIVE_SP_NO = ["sp", "leave", "full", "identifier", "should", "not", "leak"].join("-");
|
||||
const SENSITIVE_USER_ID = ["approval", "userid", "full", "should", "not", "leak"].join("_");
|
||||
const SENSITIVE_FORM_VALUE = "这里是低风险测试单中的表单值但报告不得输出";
|
||||
|
||||
function baseConfig(overrides = {}) {
|
||||
return {
|
||||
wecomCorpId: "corp-approval",
|
||||
wecomAgentId: "1000002",
|
||||
wecomAgentSecret: SENSITIVE_AGENT_CREDENTIAL,
|
||||
wecomContactSecret: SENSITIVE_CONTACT_CREDENTIAL,
|
||||
wecomCheckinSecret: SENSITIVE_CHECKIN_CREDENTIAL,
|
||||
wecomHrAssistantSecret: SENSITIVE_HR_CREDENTIAL,
|
||||
wecomRequestTimeoutMs: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseOptions(overrides = {}) {
|
||||
return {
|
||||
live: true,
|
||||
format: "json",
|
||||
reportDir: "/tmp/wecom-approval-poc-test",
|
||||
templates: {},
|
||||
spNos: {},
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function templatePayload(overrides = {}) {
|
||||
return {
|
||||
errcode: 0,
|
||||
errmsg: "ok",
|
||||
template_id: SENSITIVE_TEMPLATE_ID,
|
||||
template_names: [{ text: "请假" }],
|
||||
template_content: {
|
||||
controls: [
|
||||
{ property: { control: "Selector" } },
|
||||
{ property: { control: "Textarea" } },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalPayload(overrides = {}) {
|
||||
return {
|
||||
errcode: 0,
|
||||
errmsg: "ok",
|
||||
info: {
|
||||
sp_no: SENSITIVE_SP_NO,
|
||||
template_id: SENSITIVE_TEMPLATE_ID,
|
||||
sp_status: 2,
|
||||
applyer: { userid: SENSITIVE_USER_ID },
|
||||
apply_data: {
|
||||
contents: [
|
||||
{ control: "Textarea", value: { text: SENSITIVE_FORM_VALUE } },
|
||||
{ property: { control: "Vacation" }, value: { text: "年假" } },
|
||||
],
|
||||
},
|
||||
sp_record: [{ userid: SENSITIVE_USER_ID }],
|
||||
notifyer: [{ userid: SENSITIVE_USER_ID }],
|
||||
comments: [{ comment: SENSITIVE_FORM_VALUE }],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-approval-poc", () => {
|
||||
beforeEach(() => {
|
||||
clearWecomAppAccessTokenCache();
|
||||
});
|
||||
|
||||
it("parses CLI template and sp_no pairs", () => {
|
||||
const options = parseArgs([
|
||||
"--live",
|
||||
"--format=json",
|
||||
"--template=leave:tpl-leave",
|
||||
"--template=missed_punch:tpl-missed",
|
||||
"--sp-no=leave:sp-leave",
|
||||
"--sp-no=missed_punch:sp-missed",
|
||||
]);
|
||||
|
||||
expect(options.live).toBe(true);
|
||||
expect(options.format).toBe("json");
|
||||
expect(options.templates).toEqual({
|
||||
leave: "tpl-leave",
|
||||
missed_punch: "tpl-missed",
|
||||
});
|
||||
expect(options.spNos).toEqual({
|
||||
leave: "sp-leave",
|
||||
missed_punch: "sp-missed",
|
||||
});
|
||||
expect(() => parseArgs(["--template=outing:tpl"])).toThrow(/unsupported target/);
|
||||
});
|
||||
|
||||
it("builds inputs from CLI first, then env, with masked previews", () => {
|
||||
const inputs = buildInputs(baseOptions({
|
||||
templates: { leave: SENSITIVE_TEMPLATE_ID },
|
||||
spNos: { leave: SENSITIVE_SP_NO },
|
||||
}), {
|
||||
WECOM_APPROVAL_LEAVE_TEMPLATE_ID: "env-should-not-win",
|
||||
WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID: "env-missed-template",
|
||||
WECOM_APPROVAL_MISSED_PUNCH_TEST_SP_NO: "env-missed-sp",
|
||||
});
|
||||
|
||||
expect(inputs.find((item) => item.key === "leave")).toMatchObject({
|
||||
templateSource: "cli",
|
||||
spNoSource: "cli",
|
||||
templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||
spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO),
|
||||
});
|
||||
expect(inputs.find((item) => item.key === "missed_punch")).toMatchObject({
|
||||
templateSource: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID",
|
||||
spNoSource: "WECOM_APPROVAL_MISSED_PUNCH_TEST_SP_NO",
|
||||
});
|
||||
});
|
||||
|
||||
it("documents WECOM_AGENT_SECRET as the only approval Secret source", () => {
|
||||
const check = buildSecretCheck(baseConfig());
|
||||
expect(check).toMatchObject({
|
||||
status: "OK",
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
endpoints: [
|
||||
"POST /cgi-bin/oa/gettemplatedetail",
|
||||
"POST /cgi-bin/oa/getapprovaldetail",
|
||||
],
|
||||
});
|
||||
expect(check.noFallback).toEqual([
|
||||
"WECOM_CONTACT_SECRET",
|
||||
"WECOM_CHECKIN_SECRET",
|
||||
"WECOM_HR_ASSISTANT_SECRET",
|
||||
]);
|
||||
expect(buildSecretCheck(baseConfig({ wecomAgentSecret: "" }))).toMatchObject({
|
||||
status: "BLOCKED",
|
||||
missing: ["WECOM_AGENT_SECRET"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns PARTIAL_APPROVAL_POC_NEEDS_CONFIG when template IDs and sp_no are missing", async () => {
|
||||
const fetchTemplateDetailImpl = vi.fn();
|
||||
const fetchApprovalDetailImpl = vi.fn();
|
||||
const report = await executeApprovalPoc(baseOptions(), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
fetchTemplateDetailImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
generatedAt: "2026-05-19T09:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_APPROVAL_POC_NEEDS_CONFIG");
|
||||
expect(report.conclusion).toBe("PARTIAL_APPROVAL_POC_NEEDS_CONFIG");
|
||||
expect(report.configGaps).toHaveLength(4);
|
||||
expect(report.mainBlockers).toEqual([]);
|
||||
expect(fetchTemplateDetailImpl).not.toHaveBeenCalled();
|
||||
expect(fetchApprovalDetailImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads leave and missed-punch template/detail in live mode and returns PASS_APPROVAL_POC", async () => {
|
||||
const options = baseOptions({
|
||||
templates: {
|
||||
leave: "tpl-leave",
|
||||
missed_punch: "tpl-missed",
|
||||
},
|
||||
spNos: {
|
||||
leave: "sp-leave",
|
||||
missed_punch: "sp-missed",
|
||||
},
|
||||
});
|
||||
const fetchTemplateDetailImpl = vi.fn(async ({ config, templateId }) => {
|
||||
expect(config.wecomAgentSecret).toBe(SENSITIVE_AGENT_CREDENTIAL);
|
||||
expect([SENSITIVE_CONTACT_CREDENTIAL, SENSITIVE_CHECKIN_CREDENTIAL, SENSITIVE_HR_CREDENTIAL]).not.toContain(config.wecomAgentSecret);
|
||||
return {
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: summarizeTemplatePayload(templatePayload({ template_id: templateId })),
|
||||
response: { errcode: 0, fieldCount: 2, fieldTypeCounts: { Selector: 1, Textarea: 1 } },
|
||||
};
|
||||
});
|
||||
const fetchApprovalDetailImpl = vi.fn(async ({ spNo }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: summarizeApprovalPayload(approvalPayload({ info: { ...approvalPayload().info, sp_no: spNo } })),
|
||||
response: { errcode: 0, spStatus: 2, applyFieldCount: 2, approvalNodeCount: 1 },
|
||||
}));
|
||||
|
||||
const report = await executeApprovalPoc(options, {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
fetchTemplateDetailImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
generatedAt: "2026-05-19T09:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_APPROVAL_POC");
|
||||
expect(report.configGaps).toEqual([]);
|
||||
expect(report.mainBlockers).toEqual([]);
|
||||
expect(fetchTemplateDetailImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchApprovalDetailImpl).toHaveBeenCalledTimes(2);
|
||||
expect(report.checks.targets.every((item) => item.template.status === "OK" && item.approval.status === "OK")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps WeCom API errors as BLOCKED_BY_EXTERNAL_CONFIG", async () => {
|
||||
const report = await executeApprovalPoc(baseOptions({
|
||||
templates: { leave: "tpl-leave", missed_punch: "tpl-missed" },
|
||||
spNos: { leave: "sp-leave", missed_punch: "sp-missed" },
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
fetchTemplateDetailImpl: vi.fn(async ({ templateId }) => ({
|
||||
ok: templateId !== "tpl-missed",
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
code: "wecom_approval_api_error",
|
||||
message: "WeCom approval API errcode 48002",
|
||||
response: { errcode: 48002, errmsg: "api forbidden" },
|
||||
summary: templateId === "tpl-missed" ? null : summarizeTemplatePayload(templatePayload({ template_id: templateId })),
|
||||
})),
|
||||
fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: summarizeApprovalPayload(approvalPayload({ info: { ...approvalPayload().info, sp_no: spNo } })),
|
||||
response: { errcode: 0, applyFieldCount: 2, approvalNodeCount: 1 },
|
||||
})),
|
||||
generatedAt: "2026-05-19T09:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(report.mainBlockers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "approval_template_detail",
|
||||
target: "missed_punch",
|
||||
reason: "wecom_approval_api_error",
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses WECOM_AGENT_SECRET for template/detail API calls without fallback", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = vi.fn(async (url, options = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
body: options.body ? JSON.parse(options.body) : null,
|
||||
});
|
||||
if (String(url).includes("/cgi-bin/gettoken")) {
|
||||
expect(String(url)).toContain(`corpsecret=${encodeURIComponent(SENSITIVE_AGENT_CREDENTIAL)}`);
|
||||
expect(String(url)).not.toContain(SENSITIVE_CONTACT_CREDENTIAL);
|
||||
expect(String(url)).not.toContain(SENSITIVE_CHECKIN_CREDENTIAL);
|
||||
expect(String(url)).not.toContain(SENSITIVE_HR_CREDENTIAL);
|
||||
return jsonResponse({ errcode: 0, access_token: "approval-token", expires_in: 7200 });
|
||||
}
|
||||
if (String(url).includes("/cgi-bin/oa/gettemplatedetail")) {
|
||||
return jsonResponse(templatePayload());
|
||||
}
|
||||
if (String(url).includes("/cgi-bin/oa/getapprovaldetail")) {
|
||||
return jsonResponse(approvalPayload());
|
||||
}
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
});
|
||||
|
||||
const template = await fetchWecomApprovalTemplateDetail({
|
||||
config: baseConfig(),
|
||||
templateId: SENSITIVE_TEMPLATE_ID,
|
||||
fetchImpl,
|
||||
});
|
||||
const detail = await fetchWecomApprovalDetail({
|
||||
config: baseConfig(),
|
||||
spNo: SENSITIVE_SP_NO,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(template).toMatchObject({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: {
|
||||
fieldCount: 2,
|
||||
},
|
||||
});
|
||||
expect(detail).toMatchObject({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: {
|
||||
applyFieldCount: 2,
|
||||
approvalNodeCount: 1,
|
||||
},
|
||||
});
|
||||
expect(calls.some((item) => item.url.includes("/cgi-bin/oa/gettemplatedetail") && item.body.template_id === SENSITIVE_TEMPLATE_ID)).toBe(true);
|
||||
expect(calls.some((item) => item.url.includes("/cgi-bin/oa/getapprovaldetail") && item.body.sp_no === SENSITIVE_SP_NO)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat non-json approval API responses as success", async () => {
|
||||
const fetchImpl = vi.fn(async (url) => {
|
||||
if (String(url).includes("/cgi-bin/gettoken")) {
|
||||
return jsonResponse({ errcode: 0, access_token: "approval-token", expires_in: 7200 });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error("invalid json");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const template = await fetchWecomApprovalTemplateDetail({
|
||||
config: baseConfig(),
|
||||
templateId: SENSITIVE_TEMPLATE_ID,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(template).toMatchObject({
|
||||
ok: false,
|
||||
code: "wecom_approval_http_200",
|
||||
summary: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps report output redacted", async () => {
|
||||
const report = await executeApprovalPoc(baseOptions({
|
||||
templates: { leave: SENSITIVE_TEMPLATE_ID, missed_punch: "tpl-missed" },
|
||||
spNos: { leave: SENSITIVE_SP_NO, missed_punch: "sp-missed" },
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
fetchTemplateDetailImpl: vi.fn(async ({ templateId }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: summarizeTemplatePayload(templatePayload({ template_id: templateId })),
|
||||
response: { errcode: 0, fieldCount: 2, fieldTypeCounts: { Selector: 1, Textarea: 1 } },
|
||||
})),
|
||||
fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: summarizeApprovalPayload(approvalPayload({ info: { ...approvalPayload().info, sp_no: spNo } })),
|
||||
response: { errcode: 0, spStatus: 2, applyFieldCount: 2, approvalNodeCount: 1 },
|
||||
})),
|
||||
generatedAt: "2026-05-19T09:00:00.000Z",
|
||||
});
|
||||
const serialized = JSON.stringify(report);
|
||||
|
||||
expect(serialized).not.toContain(SENSITIVE_AGENT_CREDENTIAL);
|
||||
expect(serialized).not.toContain(SENSITIVE_CONTACT_CREDENTIAL);
|
||||
expect(serialized).not.toContain(SENSITIVE_CHECKIN_CREDENTIAL);
|
||||
expect(serialized).not.toContain(SENSITIVE_HR_CREDENTIAL);
|
||||
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_SP_NO);
|
||||
expect(serialized).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_FORM_VALUE);
|
||||
expect(serialized).toContain(maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID));
|
||||
expect(serialized).toContain(maskApprovalIdentifier(SENSITIVE_SP_NO));
|
||||
});
|
||||
|
||||
it("evaluates conclusion categories", () => {
|
||||
expect(evaluateConclusion({
|
||||
mainBlockers: [{ id: "approval_secret_matrix" }],
|
||||
configGaps: [],
|
||||
verificationGaps: [],
|
||||
})).toEqual({
|
||||
status: "SKIP",
|
||||
conclusion: "BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
});
|
||||
expect(evaluateConclusion({
|
||||
mainBlockers: [],
|
||||
configGaps: [{ id: "approval_detail" }],
|
||||
verificationGaps: [],
|
||||
})).toEqual({
|
||||
status: "PARTIAL_APPROVAL_POC_NEEDS_CONFIG",
|
||||
conclusion: "PARTIAL_APPROVAL_POC_NEEDS_CONFIG",
|
||||
});
|
||||
expect(evaluateConclusion({
|
||||
mainBlockers: [],
|
||||
configGaps: [],
|
||||
verificationGaps: [],
|
||||
})).toEqual({
|
||||
status: "PASS_APPROVAL_POC",
|
||||
conclusion: "PASS_APPROVAL_POC",
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户