WAPC2F 实现企微审批样件 dry-run
这个提交包含在:
@@ -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-fixtures": "node src/scripts/check-wecom-approval-fixtures.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",
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
#!/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=<no> 申请人员工号,默认 SMOKE002。
|
||||
--approver-employee-no=<no> 审批人员工号,默认 SMOKE101。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-fixtures-<timestamp>。
|
||||
--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 buildTemplateFixturePayload(target, generatedAt) {
|
||||
const label = targetLabel(target.key);
|
||||
const number = fixtureNumber(target.key, generatedAt);
|
||||
return {
|
||||
template_names: localized(`OA-POC-${label}-${generatedAt.slice(0, 10)}`),
|
||||
template_content: {
|
||||
controls: [
|
||||
{
|
||||
control: "Text",
|
||||
id: "fixture_type",
|
||||
title: localized("类型"),
|
||||
require: 1,
|
||||
},
|
||||
{
|
||||
control: "Date",
|
||||
id: "fixture_date",
|
||||
title: localized("日期"),
|
||||
require: 1,
|
||||
},
|
||||
{
|
||||
control: "Textarea",
|
||||
id: "fixture_reason",
|
||||
title: localized("说明"),
|
||||
require: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
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);
|
||||
return {
|
||||
creator_userid: applicantUserId,
|
||||
template_id: templateId,
|
||||
use_template_approver: 0,
|
||||
approver: [
|
||||
{
|
||||
attr: 2,
|
||||
userid: [approverUserId],
|
||||
},
|
||||
],
|
||||
notifyer: [
|
||||
{
|
||||
userid: [approverUserId],
|
||||
},
|
||||
],
|
||||
notify_type: 1,
|
||||
apply_data: {
|
||||
contents: [
|
||||
{
|
||||
control: "Text",
|
||||
id: "fixture_type",
|
||||
title: localized("类型"),
|
||||
value: { text: label },
|
||||
},
|
||||
{
|
||||
control: "Date",
|
||||
id: "fixture_date",
|
||||
title: localized("日期"),
|
||||
value: { date: generatedAt.slice(0, 10) },
|
||||
},
|
||||
{
|
||||
control: "Textarea",
|
||||
id: "fixture_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 || "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,
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
|
||||
|
||||
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
|
||||
const APPROVAL_CREATE_TEMPLATE_ENDPOINT = "/cgi-bin/oa/approval/create_template";
|
||||
const APPROVAL_APPLY_EVENT_ENDPOINT = "/cgi-bin/oa/applyevent";
|
||||
|
||||
const APPROVAL_POC_TARGETS = Object.freeze([
|
||||
{ key: "leave", label: "请假" },
|
||||
@@ -107,6 +109,12 @@ function summarizeApprovalPayload(payload) {
|
||||
function sanitizeApprovalApiResponse(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = sanitizeWecomResponse(payload) ?? {};
|
||||
if (payload.template_id || payload.templateId) {
|
||||
result.templateIdPrefix = maskApprovalIdentifier(payload.template_id ?? payload.templateId);
|
||||
}
|
||||
if (payload.sp_no || payload.spNo) {
|
||||
result.spNoPrefix = maskApprovalIdentifier(payload.sp_no ?? payload.spNo);
|
||||
}
|
||||
if (payload.template_content) {
|
||||
const summary = summarizeTemplatePayload(payload);
|
||||
result.fieldCount = summary.fieldCount;
|
||||
@@ -333,10 +341,142 @@ async function fetchWecomApprovalDetail({
|
||||
}
|
||||
}
|
||||
|
||||
function extractTemplateId(payload) {
|
||||
return toSafeString(payload?.template_id ?? payload?.templateId, 160);
|
||||
}
|
||||
|
||||
function extractSpNo(payload) {
|
||||
return toSafeString(payload?.sp_no ?? payload?.spNo, 160);
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalTemplateCreate({
|
||||
config,
|
||||
payload,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
|
||||
}) {
|
||||
try {
|
||||
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
|
||||
const result = await fetchWecomApprovalJson(APPROVAL_CREATE_TEMPLATE_ENDPOINT, token.accessToken, payload, {
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const error = classifyWecomApprovalResult(result, "wecom_approval_create_template_failed");
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
tokenSource: token.tokenSource,
|
||||
endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`,
|
||||
response: error.response,
|
||||
templateId: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
const templateId = extractTemplateId(result.payload);
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_approval_template_created",
|
||||
message: null,
|
||||
tokenSource: token.tokenSource,
|
||||
endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
templateId,
|
||||
summary: {
|
||||
templateIdPrefix: maskApprovalIdentifier(templateId),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: Boolean(error?.details?.retryable),
|
||||
code: error?.code || "wecom_approval_create_template_failed",
|
||||
message: sanitizeErrorMessage(error?.message || error),
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`,
|
||||
response: error?.details?.response || null,
|
||||
templateId: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalApplyEvent({
|
||||
config,
|
||||
payload,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
|
||||
}) {
|
||||
try {
|
||||
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
|
||||
const result = await fetchWecomApprovalJson(APPROVAL_APPLY_EVENT_ENDPOINT, token.accessToken, payload, {
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const error = classifyWecomApprovalResult(result, "wecom_approval_apply_event_failed");
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
tokenSource: token.tokenSource,
|
||||
endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`,
|
||||
response: error.response,
|
||||
spNo: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
const spNo = extractSpNo(result.payload);
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_approval_apply_event_submitted",
|
||||
message: null,
|
||||
tokenSource: token.tokenSource,
|
||||
endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
spNo,
|
||||
summary: {
|
||||
spNoPrefix: maskApprovalIdentifier(spNo),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: Boolean(error?.details?.retryable),
|
||||
code: error?.code || "wecom_approval_apply_event_failed",
|
||||
message: sanitizeErrorMessage(error?.message || error),
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`,
|
||||
response: error?.details?.response || null,
|
||||
spNo: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APPROVAL_POC_TARGETS,
|
||||
APPROVAL_APPLY_EVENT_ENDPOINT,
|
||||
APPROVAL_CREATE_TEMPLATE_ENDPOINT,
|
||||
WecomApprovalPocError,
|
||||
fetchWecomApprovalApplyEvent,
|
||||
fetchWecomApprovalDetail,
|
||||
fetchWecomApprovalTemplateCreate,
|
||||
fetchWecomApprovalTemplateDetail,
|
||||
maskApprovalIdentifier,
|
||||
sanitizeApprovalApiResponse,
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
const {
|
||||
buildActorPrecheck,
|
||||
buildApplyFixturePayload,
|
||||
buildSecretCheck,
|
||||
buildTemplateFixturePayload,
|
||||
executeApprovalFixtures,
|
||||
liveWriteAllowed,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/check-wecom-approval-fixtures");
|
||||
const {
|
||||
fetchWecomApprovalApplyEvent,
|
||||
fetchWecomApprovalTemplateCreate,
|
||||
} = require("../../../src/services/integrations/wecomApproval");
|
||||
const { clearWecomAppAccessTokenCache } = require("../../../src/services/integrations/wecomAccessToken");
|
||||
|
||||
const SENSITIVE_AGENT_CREDENTIAL = ["fixture", "agent", "credential", "should", "never", "leak"].join("-");
|
||||
const SENSITIVE_CONTACT_CREDENTIAL = ["fixture", "contact", "credential", "unused"].join("-");
|
||||
const SENSITIVE_CHECKIN_CREDENTIAL = ["fixture", "checkin", "credential", "unused"].join("-");
|
||||
const SENSITIVE_HR_CREDENTIAL = ["fixture", "hr", "credential", "unused"].join("-");
|
||||
const SENSITIVE_APPLICANT_USERID = ["fixture", "applicant", "userid", "full", "secret"].join("_");
|
||||
const SENSITIVE_APPROVER_USERID = ["fixture", "approver", "userid", "full", "secret"].join("_");
|
||||
const SENSITIVE_TEMPLATE_ID = ["tpl", "fixture", "full", "identifier", "must", "not", "leak"].join("-");
|
||||
const SENSITIVE_SP_NO = ["sp", "fixture", "full", "identifier", "must", "not", "leak"].join("-");
|
||||
|
||||
function baseConfig(overrides = {}) {
|
||||
return {
|
||||
wecomCorpId: "corp-fixture",
|
||||
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: false,
|
||||
createTemplates: false,
|
||||
submitTestApprovals: false,
|
||||
applicantEmployeeNo: "SMOKE002",
|
||||
approverEmployeeNo: "SMOKE101",
|
||||
format: "json",
|
||||
reportDir: "/tmp/wecom-approval-fixtures-test",
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function actorRows(overrides = {}) {
|
||||
return [
|
||||
{
|
||||
id: "applicant-id",
|
||||
employeeNo: "SMOKE002",
|
||||
status: "active",
|
||||
archived: false,
|
||||
wecomExternalUserId: SENSITIVE_APPLICANT_USERID,
|
||||
},
|
||||
{
|
||||
id: "approver-id",
|
||||
employeeNo: "SMOKE101",
|
||||
status: "active",
|
||||
archived: false,
|
||||
wecomExternalUserId: SENSITIVE_APPROVER_USERID,
|
||||
},
|
||||
].map((row) => ({ ...row, ...(overrides[row.employeeNo] || {}) }));
|
||||
}
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-approval-fixtures", () => {
|
||||
beforeEach(() => {
|
||||
clearWecomAppAccessTokenCache();
|
||||
});
|
||||
|
||||
it("parses defaults and live write flags", () => {
|
||||
expect(parseArgs(["--format=json"])).toMatchObject({
|
||||
live: false,
|
||||
applicantEmployeeNo: "SMOKE002",
|
||||
approverEmployeeNo: "SMOKE101",
|
||||
format: "json",
|
||||
});
|
||||
const live = parseArgs([
|
||||
"--live",
|
||||
"--create-templates",
|
||||
"--submit-test-approvals",
|
||||
"--applicant-employee-no=SMOKE002",
|
||||
"--approver-employee-no",
|
||||
"SMOKE101",
|
||||
]);
|
||||
expect(live).toMatchObject({
|
||||
live: true,
|
||||
createTemplates: true,
|
||||
submitTestApprovals: true,
|
||||
applicantEmployeeNo: "SMOKE002",
|
||||
approverEmployeeNo: "SMOKE101",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes dry-run without calling WeCom write APIs", async () => {
|
||||
const createTemplateImpl = vi.fn();
|
||||
const applyEventImpl = vi.fn();
|
||||
const report = await executeApprovalFixtures(baseOptions(), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
actorRows: actorRows(),
|
||||
createTemplateImpl,
|
||||
applyEventImpl,
|
||||
generatedAt: "2026-05-19T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_FIXTURE_DRY_RUN_READY");
|
||||
expect(report.scope.writeBehavior).toContain("dry-run only");
|
||||
expect(createTemplateImpl).not.toHaveBeenCalled();
|
||||
expect(applyEventImpl).not.toHaveBeenCalled();
|
||||
expect(report.checks.fixturePayloads).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not write in live mode without the explicit env switch", async () => {
|
||||
const createTemplateImpl = vi.fn();
|
||||
const report = await executeApprovalFixtures(baseOptions({
|
||||
live: true,
|
||||
createTemplates: true,
|
||||
submitTestApprovals: true,
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
actorRows: actorRows(),
|
||||
createTemplateImpl,
|
||||
generatedAt: "2026-05-19T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(liveWriteAllowed(baseOptions({
|
||||
live: true,
|
||||
createTemplates: true,
|
||||
submitTestApprovals: true,
|
||||
}), {})).toBe(false);
|
||||
expect(report.status).toBe("PARTIAL_FIXTURE_NEEDS_CONFIG");
|
||||
expect(report.configGaps).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "approval_fixture_write_guard" }),
|
||||
]));
|
||||
expect(createTemplateImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks self-approval by OA employee or WeCom userid", () => {
|
||||
const sameEmployee = buildActorPrecheck(actorRows({
|
||||
SMOKE101: { id: "applicant-id" },
|
||||
}), baseOptions());
|
||||
expect(sameEmployee.status).toBe("NEEDS_CONFIG");
|
||||
expect(sameEmployee.gaps.map((item) => item.id)).toContain("self_approval_same_oa_employee");
|
||||
|
||||
const sameWecom = buildActorPrecheck(actorRows({
|
||||
SMOKE101: { wecomExternalUserId: SENSITIVE_APPLICANT_USERID },
|
||||
}), baseOptions());
|
||||
expect(sameWecom.gaps.map((item) => item.id)).toContain("self_approval_same_wecom_userid");
|
||||
});
|
||||
|
||||
it("keeps SMOKE101 unavailable as a config gap and does not auto-replace", async () => {
|
||||
const report = await executeApprovalFixtures(baseOptions(), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
actorRows: actorRows({
|
||||
SMOKE101: { status: "inactive" },
|
||||
}),
|
||||
generatedAt: "2026-05-19T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_FIXTURE_NEEDS_CONFIG");
|
||||
expect(report.configGaps).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "approver_inactive",
|
||||
employeeNo: "SMOKE101",
|
||||
}),
|
||||
]));
|
||||
expect(JSON.stringify(report)).not.toContain("HYD000007");
|
||||
});
|
||||
|
||||
it("uses WECOM_AGENT_SECRET for create_template and applyevent 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: "fixture-token", expires_in: 7200 });
|
||||
}
|
||||
if (String(url).includes("/cgi-bin/oa/approval/create_template")) {
|
||||
return jsonResponse({ errcode: 0, errmsg: "ok", template_id: SENSITIVE_TEMPLATE_ID });
|
||||
}
|
||||
if (String(url).includes("/cgi-bin/oa/applyevent")) {
|
||||
return jsonResponse({ errcode: 0, errmsg: "ok", sp_no: SENSITIVE_SP_NO });
|
||||
}
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
});
|
||||
|
||||
const template = await fetchWecomApprovalTemplateCreate({
|
||||
config: baseConfig(),
|
||||
payload: buildTemplateFixturePayload({ key: "leave", label: "请假" }, "2026-05-19T10:00:00.000Z"),
|
||||
fetchImpl,
|
||||
});
|
||||
const approval = await fetchWecomApprovalApplyEvent({
|
||||
config: baseConfig(),
|
||||
payload: buildApplyFixturePayload({
|
||||
target: { key: "leave", label: "请假" },
|
||||
templateId: SENSITIVE_TEMPLATE_ID,
|
||||
applicantUserId: SENSITIVE_APPLICANT_USERID,
|
||||
approverUserId: SENSITIVE_APPROVER_USERID,
|
||||
generatedAt: "2026-05-19T10:00:00.000Z",
|
||||
}),
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(template).toMatchObject({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
templateId: SENSITIVE_TEMPLATE_ID,
|
||||
});
|
||||
expect(approval).toMatchObject({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
spNo: SENSITIVE_SP_NO,
|
||||
});
|
||||
expect(calls.some((item) => item.url.includes("/cgi-bin/oa/approval/create_template"))).toBe(true);
|
||||
expect(calls.some((item) => item.url.includes("/cgi-bin/oa/applyevent"))).toBe(true);
|
||||
});
|
||||
|
||||
it("passes generated template IDs and sp_no into WAPC2 auto rerun without reporting full IDs", async () => {
|
||||
const executeApprovalPocImpl = vi.fn(async (options) => {
|
||||
expect(options.live).toBe(true);
|
||||
expect(options.templates).toEqual({
|
||||
leave: `${SENSITIVE_TEMPLATE_ID}-leave`,
|
||||
missed_punch: `${SENSITIVE_TEMPLATE_ID}-missed_punch`,
|
||||
});
|
||||
expect(options.spNos).toEqual({
|
||||
leave: `${SENSITIVE_SP_NO}-leave`,
|
||||
missed_punch: `${SENSITIVE_SP_NO}-missed_punch`,
|
||||
});
|
||||
return { status: "PASS_APPROVAL_POC", conclusion: "PASS_APPROVAL_POC" };
|
||||
});
|
||||
|
||||
const report = await executeApprovalFixtures(baseOptions({
|
||||
live: true,
|
||||
createTemplates: true,
|
||||
submitTestApprovals: true,
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: { WECOM_APPROVAL_FIXTURE_WRITE: "1" },
|
||||
actorRows: actorRows(),
|
||||
createTemplateImpl: vi.fn(async ({ payload }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
templateId: `${SENSITIVE_TEMPLATE_ID}-${payload.fixture_marker.target}`,
|
||||
response: { errcode: 0 },
|
||||
})),
|
||||
applyEventImpl: vi.fn(async ({ payload }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
spNo: `${SENSITIVE_SP_NO}-${payload.template_id.split("-").pop()}`,
|
||||
response: { errcode: 0 },
|
||||
})),
|
||||
executeApprovalPocImpl,
|
||||
generatedAt: "2026-05-19T10:00:00.000Z",
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(report);
|
||||
expect(report.status).toBe("PASS_FIXTURE_CREATED");
|
||||
expect(executeApprovalPocImpl).toHaveBeenCalledTimes(1);
|
||||
expect(serialized).not.toContain(`${SENSITIVE_TEMPLATE_ID}-leave`);
|
||||
expect(serialized).not.toContain(`${SENSITIVE_SP_NO}-leave`);
|
||||
expect(serialized).toContain("tpl-fixt***");
|
||||
expect(serialized).toContain("sp-fixtu***");
|
||||
});
|
||||
|
||||
it("keeps report output redacted", async () => {
|
||||
const report = await executeApprovalFixtures(baseOptions(), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
actorRows: actorRows(),
|
||||
generatedAt: "2026-05-19T10: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_APPLICANT_USERID);
|
||||
expect(serialized).not.toContain(SENSITIVE_APPROVER_USERID);
|
||||
expect(serialized).not.toContain("low-risk dry-run fixture");
|
||||
expect(serialized).toContain("fixture_***");
|
||||
});
|
||||
|
||||
it("documents missing WECOM_AGENT_SECRET as a config gap", () => {
|
||||
const check = buildSecretCheck(baseConfig({ wecomAgentSecret: "" }));
|
||||
expect(check).toMatchObject({
|
||||
status: "NEEDS_CONFIG",
|
||||
missing: ["WECOM_AGENT_SECRET"],
|
||||
});
|
||||
expect(check.noFallback).toEqual([
|
||||
"WECOM_CONTACT_SECRET",
|
||||
"WECOM_CHECKIN_SECRET",
|
||||
"WECOM_HR_ASSISTANT_SECRET",
|
||||
]);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户