#!/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= 报告目录,默认 artifacts/qa-flow/wecom-approval-poc-。 --template=leave: 指定请假模板 ID。 --template=missed_punch: 指定补卡模板 ID。 --sp-no=leave: 指定请假低风险测试审批单号。 --sp-no=missed_punch: 指定补卡低风险测试审批单号。 --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 :`); } 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, };