#!/usr/bin/env node require("dotenv/config"); const fs = require("node:fs"); const path = require("node:path"); const { Pool } = require("pg"); const { getConfig } = require("../config"); const { APPROVAL_APPLY_EVENT_ENDPOINT, APPROVAL_CREATE_TEMPLATE_ENDPOINT, APPROVAL_POC_TARGETS, fetchWecomApprovalApplyEvent, fetchWecomApprovalTemplateCreate, maskApprovalIdentifier, } = require("../services/integrations/wecomApproval"); const { executeApprovalPoc } = require("./check-wecom-approval-poc"); const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"); const DEFAULT_APPLICANT_EMPLOYEE_NO = "SMOKE002"; const DEFAULT_APPROVER_EMPLOYEE_NO = "SMOKE101"; const WRITE_CONFIRM_ENV = "WECOM_APPROVAL_FIXTURE_WRITE"; const REPORT_PRIVACY = "approval fixture counts/masked userid/template_id/sp_no prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload"; function printHelp() { process.stdout.write(`Usage: node src/scripts/check-wecom-approval-fixtures.js [options] QA-only 企业微信审批 POC 样件自动生成 runner。 默认 dry-run:只构建脱敏 payload 和报告,不获取写入 token,不调用企微写接口,不创建模板,不提交审批单。 Options: --live 启用 live 写入候选路径。 --create-templates live 模式下允许创建 OA-POC 测试模板。 --submit-test-approvals live 模式下允许提交低风险测试审批单。 --applicant-employee-no= 申请人员工号,默认 SMOKE002。 --approver-employee-no= 审批人员工号,默认 SMOKE101。 --format=text|json 输出格式,默认 text。 --report-dir= 报告目录,默认 artifacts/qa-flow/wecom-approval-fixtures-。 --help 显示帮助。 live 写入必须同时满足: 1) --live --create-templates --submit-test-approvals 2) ${WRITE_CONFIRM_ENV}=1 Secret 固定规则: 创建模板/提交审批:WECOM_AGENT_SECRET。 不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。 隐私边界: 报告只输出员工号、计数、状态、脱敏 userid/template_id/sp_no 前缀和错误分类。 不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 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 parseArgs(argv) { const options = { live: false, createTemplates: false, submitTestApprovals: false, applicantEmployeeNo: DEFAULT_APPLICANT_EMPLOYEE_NO, approverEmployeeNo: DEFAULT_APPROVER_EMPLOYEE_NO, format: "text", reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-fixtures-${timestamp()}`), 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 === "--create-templates") { options.createTemplates = true; } else if (arg === "--submit-test-approvals") { options.submitTestApprovals = true; } else if (arg === "--applicant-employee-no") { options.applicantEmployeeNo = toSafeString(argv[index + 1], 80); index += 1; } else if (arg.startsWith("--applicant-employee-no=")) { options.applicantEmployeeNo = toSafeString(arg.slice("--applicant-employee-no=".length), 80); } else if (arg === "--approver-employee-no") { options.approverEmployeeNo = toSafeString(argv[index + 1], 80); index += 1; } else if (arg.startsWith("--approver-employee-no=")) { options.approverEmployeeNo = toSafeString(arg.slice("--approver-employee-no=".length), 80); } 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 { throw new Error(`Unsupported argument: ${arg}`); } } if (!["text", "json"].includes(options.format)) { throw new Error(`Unsupported --format: ${options.format}`); } if (!options.applicantEmployeeNo) { throw new Error("--applicant-employee-no requires non-empty value"); } if (!options.approverEmployeeNo) { throw new Error("--approver-employee-no requires non-empty value"); } return options; } function localized(text) { return [{ text, lang: "zh_CN" }]; } function targetLabel(targetKey) { return APPROVAL_POC_TARGETS.find((item) => item.key === targetKey)?.label || targetKey; } function fixtureNumber(targetKey, generatedAt) { return `WAPC2F-${targetKey}-${generatedAt.slice(0, 10).replace(/-/g, "")}`; } function fixtureControlIds() { return { type: "Text-01", date: "Date-01", reason: "Textarea-01", }; } function buildTemplateControl(control, id, title, options = {}) { return { property: { control, id, title: localized(title), placeholder: localized(options.placeholder || title), require: options.require ? 1 : 0, un_print: 1, }, config: options.config || {}, }; } function dayTimestamp(generatedAt) { const date = new Date(`${generatedAt.slice(0, 10)}T00:00:00.000Z`); return String(Math.floor(date.getTime() / 1000)); } function buildTemplateFixturePayload(target, generatedAt) { const label = targetLabel(target.key); const number = fixtureNumber(target.key, generatedAt); const controlIds = fixtureControlIds(); return { template_name: localized(`OA-POC-${label}-${generatedAt.slice(0, 10)}`), template_content: { controls: [ buildTemplateControl("Text", controlIds.type, "类型", { require: true }), buildTemplateControl("Date", controlIds.date, "日期", { require: true, config: { date: { type: "day" } }, }), buildTemplateControl("Textarea", controlIds.reason, "说明", { require: false }), ], }, fixture_marker: { issue: "WAPC2F", number, target: target.key, lowRisk: true, }, }; } function buildApplyFixturePayload({ target, templateId, applicantUserId, approverUserId, generatedAt }) { const label = targetLabel(target.key); const number = fixtureNumber(target.key, generatedAt); const controlIds = fixtureControlIds(); return { creator_userid: applicantUserId, template_id: templateId, use_template_approver: 0, approver: [ { attr: 1, userid: [approverUserId], }, ], notifyer: [ approverUserId, ], notify_type: 1, apply_data: { contents: [ { control: "Text", id: controlIds.type, title: localized("类型"), value: { text: label }, }, { control: "Date", id: controlIds.date, title: localized("日期"), value: { date: { type: "day", s_timestamp: dayTimestamp(generatedAt) } }, }, { control: "Textarea", id: controlIds.reason, title: localized("说明"), value: { text: `${number} low-risk dry-run fixture` }, }, ], }, summary_list: [ { summary_info: localized(`OA-POC ${label} ${number}`), }, ], }; } function summarizeFixturePayload(payload, kind) { const controls = kind === "template" ? payload?.template_content?.controls || [] : payload?.apply_data?.contents || []; const controlTypes = {}; for (const control of controls) { const key = toSafeString(control?.control || control?.property?.control || "unknown", 80) || "unknown"; controlTypes[key] = (controlTypes[key] || 0) + 1; } return { kind, fieldCount: controls.length, fieldTypeCounts: controlTypes, hasApprover: kind === "approval" ? Boolean(payload?.approver?.length) : false, hasNotifyer: kind === "approval" ? Boolean(payload?.notifyer?.length) : false, hasApplicant: kind === "approval" ? Boolean(payload?.creator_userid) : false, }; } function buildSecretCheck(config) { const missing = []; if (!config.wecomCorpId) missing.push("WECOM_CORP_ID"); if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET"); return { id: "approval_fixture_secret_matrix", source: "WECOM_AGENT_SECRET", endpoints: [ `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`, `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`, ], status: missing.length ? "NEEDS_CONFIG" : "OK", missing, noFallback: [ "WECOM_CONTACT_SECRET", "WECOM_CHECKIN_SECRET", "WECOM_HR_ASSISTANT_SECRET", ], }; } function isOaActive(row) { return row?.status === "active" && !row?.archived; } function sanitizeActor(row) { if (!row) return null; return { employeeNo: row.employeeNo, status: row.status, archived: Boolean(row.archived), hasWecomBinding: Boolean(row.wecomExternalUserId), wecomUserIdPrefix: row.wecomExternalUserId ? maskApprovalIdentifier(row.wecomExternalUserId) : null, }; } async function queryApprovalActorRows(client, employeeNos) { const result = await client.query(` SELECT e.id::text, e.employee_no, e.status::text, (e.archived_at IS NOT NULL) AS archived, w.external_user_id AS wecom_external_user_id FROM employees e LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id WHERE e.employee_no = ANY($1::text[]) ORDER BY e.employee_no ASC `, [employeeNos]); return result.rows.map((row) => ({ id: row.id, employeeNo: toSafeString(row.employee_no, 80), status: toSafeString(row.status, 40), archived: Boolean(row.archived), wecomExternalUserId: toSafeString(row.wecom_external_user_id, 160), })); } async function queryApprovalActorRowsReadOnly(employeeNos) { if (!process.env.DATABASE_URL) { throw new Error("Missing DATABASE_URL"); } const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const client = await pool.connect(); try { await client.query("BEGIN READ ONLY"); const rows = await queryApprovalActorRows(client, employeeNos); await client.query("COMMIT"); return rows; } catch (error) { try { await client.query("ROLLBACK"); } catch { // Preserve the original read-only error. } throw error; } finally { client.release(); await pool.end(); } } function buildActorPrecheck(rows, options) { const byEmployeeNo = new Map((rows || []).map((row) => [row.employeeNo, row])); const applicant = byEmployeeNo.get(options.applicantEmployeeNo) || null; const approver = byEmployeeNo.get(options.approverEmployeeNo) || null; const gaps = []; if (!applicant) gaps.push({ id: "applicant_missing", employeeNo: options.applicantEmployeeNo }); if (!approver) gaps.push({ id: "approver_missing", employeeNo: options.approverEmployeeNo }); if (applicant && !isOaActive(applicant)) gaps.push({ id: "applicant_inactive", employeeNo: applicant.employeeNo }); if (approver && !isOaActive(approver)) gaps.push({ id: "approver_inactive", employeeNo: approver.employeeNo }); if (applicant && !applicant.wecomExternalUserId) gaps.push({ id: "applicant_missing_wecom_binding", employeeNo: applicant.employeeNo }); if (approver && !approver.wecomExternalUserId) gaps.push({ id: "approver_missing_wecom_binding", employeeNo: approver.employeeNo }); if (applicant && approver && applicant.id === approver.id) { gaps.push({ id: "self_approval_same_oa_employee", employeeNo: applicant.employeeNo }); } if (applicant?.wecomExternalUserId && approver?.wecomExternalUserId && applicant.wecomExternalUserId === approver.wecomExternalUserId) { gaps.push({ id: "self_approval_same_wecom_userid", employeeNo: applicant.employeeNo }); } return { id: "approval_fixture_actor_precheck", status: gaps.length ? "NEEDS_CONFIG" : "OK", applicant: sanitizeActor(applicant), approver: sanitizeActor(approver), applicantEmployeeNo: options.applicantEmployeeNo, approverEmployeeNo: options.approverEmployeeNo, visibilityCheck: "deferred_to_live_wecom_api", gaps, internalActors: { applicant, approver }, }; } function buildFixturePlans(actorCheck, generatedAt) { const applicantUserId = actorCheck.internalActors.applicant?.wecomExternalUserId || ""; const approverUserId = actorCheck.internalActors.approver?.wecomExternalUserId || ""; return APPROVAL_POC_TARGETS.map((target) => { const templatePayload = buildTemplateFixturePayload(target, generatedAt); const applyPayload = buildApplyFixturePayload({ target, templateId: `DRY_RUN_TEMPLATE_ID_${target.key}`, applicantUserId, approverUserId, generatedAt, }); return { target: target.key, label: target.label, template: { endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`, status: "DRY_RUN_READY", payloadSummary: summarizeFixturePayload(templatePayload, "template"), payload: templatePayload, }, approval: { endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`, status: "DRY_RUN_READY", payloadSummary: summarizeFixturePayload(applyPayload, "approval"), payload: applyPayload, }, }; }); } function liveWriteRequested(options) { return Boolean(options.live && options.createTemplates && options.submitTestApprovals); } function liveWriteAllowed(options, env) { return liveWriteRequested(options) && env[WRITE_CONFIRM_ENV] === "1"; } function summarizeWriteResult(result, prefixKey) { return { status: result?.ok ? "OK" : "BLOCKED", reason: result?.ok ? null : result?.code || "wecom_approval_fixture_api_error", message: result?.message ? safeMessage(result.message) : null, source: result?.tokenSource || "WECOM_AGENT_SECRET", response: result?.response || null, [prefixKey]: prefixKey === "templateIdPrefix" ? maskApprovalIdentifier(result?.templateId) : maskApprovalIdentifier(result?.spNo), }; } async function executeLiveWrite({ config, actorCheck, fixturePlans, generatedAt, createTemplateImpl, applyEventImpl, }) { const createdTemplates = {}; const submittedApprovals = {}; const targetResults = []; for (const plan of fixturePlans) { const createResult = await createTemplateImpl({ config, payload: plan.template.payload, }); const templateSummary = summarizeWriteResult(createResult, "templateIdPrefix"); let approvalSummary = { status: "SKIPPED", reason: "template_create_not_ok", spNoPrefix: null, }; if (createResult.ok && createResult.templateId) { createdTemplates[plan.target] = createResult.templateId; const approvalPayload = buildApplyFixturePayload({ target: { key: plan.target, label: plan.label }, templateId: createResult.templateId, applicantUserId: actorCheck.internalActors.applicant.wecomExternalUserId, approverUserId: actorCheck.internalActors.approver.wecomExternalUserId, generatedAt, }); const applyResult = await applyEventImpl({ config, payload: approvalPayload, }); approvalSummary = summarizeWriteResult(applyResult, "spNoPrefix"); if (applyResult.ok && applyResult.spNo) { submittedApprovals[plan.target] = applyResult.spNo; } } targetResults.push({ target: plan.target, label: plan.label, template: templateSummary, approval: approvalSummary, }); } return { status: targetResults.every((item) => item.template.status === "OK" && item.approval.status === "OK") ? "OK" : "BLOCKED", targets: targetResults, createdTemplates, submittedApprovals, }; } async function executeAutoRerun({ liveWrite, executeApprovalPocImpl, generatedAt }) { const templateEntries = Object.entries(liveWrite?.createdTemplates || {}); const spNoEntries = Object.entries(liveWrite?.submittedApprovals || {}); if (!templateEntries.length || !spNoEntries.length) { return { status: "DRY_RUN_SKIPPED", reason: "no_live_generated_template_or_sp_no", wouldRun: "check:wecom-approval-poc", }; } const templates = Object.fromEntries(templateEntries); const spNos = Object.fromEntries(spNoEntries); const report = await executeApprovalPocImpl({ live: true, format: "json", reportDir: "", templates, spNos, help: false, }, { generatedAt, }); return { status: report.status === "PASS_APPROVAL_POC" ? "OK" : "BLOCKED", conclusion: report.conclusion, targetCount: templateEntries.length, templateIdPrefixes: Object.fromEntries(templateEntries.map(([key, value]) => [key, maskApprovalIdentifier(value)])), spNoPrefixes: Object.fromEntries(spNoEntries.map(([key, value]) => [key, maskApprovalIdentifier(value)])), }; } function collectConfigGaps({ secretCheck, actorCheck, options, env }) { const gaps = []; if (secretCheck.status !== "OK") { gaps.push({ id: secretCheck.id, status: "NEEDS_CONFIG", missingEnv: secretCheck.missing, }); } for (const gap of actorCheck.gaps || []) { gaps.push({ id: gap.id, status: "NEEDS_CONFIG", employeeNo: gap.employeeNo, }); } if (options.live && !liveWriteAllowed(options, env)) { gaps.push({ id: "approval_fixture_write_guard", status: "NEEDS_CONFIG", missing: [ ...(!options.createTemplates ? ["--create-templates"] : []), ...(!options.submitTestApprovals ? ["--submit-test-approvals"] : []), ...(env[WRITE_CONFIRM_ENV] !== "1" ? [`${WRITE_CONFIRM_ENV}=1`] : []), ], }); } return gaps; } function collectMainBlockers(liveWrite) { if (!liveWrite || liveWrite.status !== "BLOCKED") return []; const blockers = []; for (const item of liveWrite.targets || []) { for (const check of [item.template, item.approval]) { if (check?.status === "BLOCKED") { blockers.push({ id: item.target, reason: check.reason || "wecom_approval_fixture_api_error", }); } } } return blockers; } function sanitizeLiveWrite(liveWrite) { const templateIds = liveWrite?.createdTemplates || {}; const spNos = liveWrite?.submittedApprovals || {}; return { ...liveWrite, createdTemplates: undefined, submittedApprovals: undefined, createdTemplatePrefixes: Object.fromEntries( Object.entries(templateIds).map(([key, value]) => [key, maskApprovalIdentifier(value)]), ), submittedApprovalPrefixes: Object.fromEntries( Object.entries(spNos).map(([key, value]) => [key, maskApprovalIdentifier(value)]), ), }; } function evaluateFixtureConclusion({ configGaps, mainBlockers, liveWrite, writeAllowed }) { if (mainBlockers.length) { return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG", }; } if (configGaps.length) { return { status: "PARTIAL_FIXTURE_NEEDS_CONFIG", conclusion: "PARTIAL_FIXTURE_NEEDS_CONFIG", }; } if (!writeAllowed) { return { status: "PASS_FIXTURE_DRY_RUN_READY", conclusion: "PASS_FIXTURE_DRY_RUN_READY", }; } if (liveWrite?.status === "OK") { return { status: "PASS_FIXTURE_CREATED", conclusion: "PASS_FIXTURE_CREATED", }; } return { status: "PARTIAL_FIXTURE_NEEDS_CONFIG", conclusion: "PARTIAL_FIXTURE_NEEDS_CONFIG", }; } async function executeApprovalFixtures(options, deps = {}) { const env = deps.env || process.env; const generatedAt = deps.generatedAt || new Date().toISOString(); const config = deps.config || getConfig(); const secretCheck = buildSecretCheck(config); let actorRows = deps.actorRows; let actorReadError = null; if (!actorRows) { try { actorRows = await (deps.queryActorRowsReadOnlyImpl || queryApprovalActorRowsReadOnly)([ options.applicantEmployeeNo, options.approverEmployeeNo, ]); } catch (error) { actorReadError = safeMessage(error?.message || error); actorRows = []; } } const actorCheck = buildActorPrecheck(actorRows, options); if (actorReadError) { actorCheck.status = "NEEDS_CONFIG"; actorCheck.gaps.push({ id: "database_read_unavailable", employeeNo: null, message: actorReadError }); } const fixturePlans = buildFixturePlans(actorCheck, generatedAt); const writeRequested = liveWriteRequested(options); const writeAllowed = liveWriteAllowed(options, env); let liveWrite = { status: "DRY_RUN_READY", requested: writeRequested, allowed: writeAllowed, guard: writeAllowed ? "enabled" : "dry_run_or_missing_double_confirm", targets: [], }; let autoRerun = { status: "DRY_RUN_SKIPPED", reason: "no_live_write_in_this_run", wouldRun: "check:wecom-approval-poc", }; const configGapsBeforeWrite = collectConfigGaps({ secretCheck, actorCheck, options, env }); if (writeAllowed && !configGapsBeforeWrite.length) { liveWrite = await executeLiveWrite({ config, actorCheck, fixturePlans, generatedAt, createTemplateImpl: deps.createTemplateImpl || fetchWecomApprovalTemplateCreate, applyEventImpl: deps.applyEventImpl || fetchWecomApprovalApplyEvent, }); autoRerun = await executeAutoRerun({ liveWrite, executeApprovalPocImpl: deps.executeApprovalPocImpl || executeApprovalPoc, generatedAt, }); } const configGaps = collectConfigGaps({ secretCheck, actorCheck, options, env }); const mainBlockers = collectMainBlockers(liveWrite); const conclusion = evaluateFixtureConclusion({ configGaps, mainBlockers, liveWrite, writeAllowed, }); return { mode: "qa_only", generatedAt, status: conclusion.status, conclusion: conclusion.conclusion, privacy: REPORT_PRIVACY, scope: { issue: "WAPC2F", targets: APPROVAL_POC_TARGETS.map((item) => item.key), applicantEmployeeNo: options.applicantEmployeeNo, approverEmployeeNo: options.approverEmployeeNo, templateDepth: "minimal_generic", writeBehavior: writeAllowed ? "live write enabled by CLI and env" : "dry-run only; no WeCom write token, no template creation, no approval submission", deepMirror: "not_started", callback: "not_configured", }, checks: { secretMatrix: secretCheck, actors: { ...actorCheck, internalActors: undefined, }, fixturePayloads: fixturePlans.map((item) => ({ target: item.target, label: item.label, template: { endpoint: item.template.endpoint, status: item.template.status, payloadSummary: item.template.payloadSummary, }, approval: { endpoint: item.approval.endpoint, status: item.approval.status, payloadSummary: item.approval.payloadSummary, }, })), liveWrite: sanitizeLiveWrite(liveWrite), autoRerun, }, configGaps, 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-fixtures] status=${report.status} conclusion=${report.conclusion}\n`); process.stdout.write(`[wecom-approval-fixtures] report=${reportPath}\n`); process.stdout.write(`[wecom-approval-fixtures] privacy=${report.privacy}\n`); process.stdout.write(`[wecom-approval-fixtures] writeBehavior=${report.scope.writeBehavior}\n`); if (report.configGaps?.length) { process.stdout.write(`[wecom-approval-fixtures] configGaps=${JSON.stringify(report.configGaps)}\n`); } if (report.mainBlockers?.length) { process.stdout.write(`[wecom-approval-fixtures] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`); } } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } const report = await executeApprovalFixtures(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-fixtures][FAIL] ${safeMessage(error?.message || error)}\n`); process.exitCode = 1; }); } module.exports = { buildActorPrecheck, buildApplyFixturePayload, buildSecretCheck, buildTemplateFixturePayload, evaluateFixtureConclusion, executeApprovalFixtures, liveWriteAllowed, parseArgs, summarizeFixturePayload, };