508 行
16 KiB
JavaScript
508 行
16 KiB
JavaScript
#!/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,
|
|
};
|