WRULE1 实现企微审批链校验器 POC
这个提交包含在:
@@ -34,6 +34,7 @@
|
||||
"check:wecom-approval-compensation-poc": "node src/scripts/check-wecom-approval-compensation-poc.js",
|
||||
"check:wecom-approval-archive": "node src/scripts/check-wecom-approval-archive.js",
|
||||
"check:wecom-approval-readability-gate": "node src/scripts/check-wecom-approval-readability-gate.js",
|
||||
"check:wecom-approval-rule-validator": "node src/scripts/check-wecom-approval-rule-validator.js",
|
||||
"check:wecom-approval-poc": "node src/scripts/check-wecom-approval-poc.js",
|
||||
"check:wecom-checkin-summary-archive": "node src/scripts/check-wecom-checkin-summary-archive.js",
|
||||
"check:wecom-runtime-ready": "node src/scripts/check-wecom-runtime-ready.js",
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env node
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const {
|
||||
APPROVAL_RULE_TARGETS,
|
||||
countBy,
|
||||
evaluateRuleReport,
|
||||
normalizeManualGates,
|
||||
validateApprovalRuleArchive,
|
||||
} = require("../services/integrations/wecomApprovalRuleValidator");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_FALLBACK_EMPLOYEE_NO = "HYD000006";
|
||||
const DEFAULT_APPROVAL_ROLES = Object.freeze(["manager"]);
|
||||
const REPORT_PRIVACY =
|
||||
"rule status/counts/masked sp_no/template/employee 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-rule-validator.js [options]
|
||||
|
||||
QA-only 企微 HR/考勤审批链校验器 POC。
|
||||
默认只读取 OA DB 已归档的企微审批详情,不调用企业微信 API,不写 OA/企微数据。
|
||||
|
||||
Options:
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-rule-validator-<timestamp>。
|
||||
--template=leave 检查请假。
|
||||
--template=missed_punch 检查补卡。
|
||||
--template=outing 检查外出。
|
||||
--template=overtime 检查加班。
|
||||
--manual-gate=leave:A WAPPR1 人工门禁,A/B/C/D;支持 --manual-gate=all:A。
|
||||
--approval-role=manager 无有效直属主管时允许的业务审批角色,默认 manager;可重复。
|
||||
--fallback-employee-no=<no> 管理员兜底员工号,默认 HYD000006。
|
||||
--status=<status> 按企微归档规范化状态过滤。
|
||||
--limit=<1..100> 最大读取条数,默认 50。
|
||||
--help 显示帮助。
|
||||
|
||||
Secret 固定规则:
|
||||
本脚本默认只读 OA DB,不读取 Secret、不调用企微。
|
||||
上游企微审批详情读取仍固定 WECOM_AGENT_SECRET,不使用通讯录、打卡或历史 HR assistant Secret 兜底。
|
||||
|
||||
隐私边界:
|
||||
报告只输出结论、计数、脱敏 sp_no/template/employee 前缀和 finding 类型。
|
||||
不输出手机号、邮箱、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 parseManualGate(value) {
|
||||
const raw = toSafeString(value, 80);
|
||||
const separatorIndex = raw.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error("--manual-gate requires <template|all>:<A|B|C|D>");
|
||||
}
|
||||
const target = raw.slice(0, separatorIndex).trim();
|
||||
const gate = raw.slice(separatorIndex + 1).trim().toUpperCase();
|
||||
if (![...APPROVAL_RULE_TARGETS, "all"].includes(target)) {
|
||||
throw new Error(`Unsupported manual gate target: ${target}`);
|
||||
}
|
||||
if (!["A", "B", "C", "D"].includes(gate)) {
|
||||
throw new Error(`Unsupported manual gate value: ${gate}`);
|
||||
}
|
||||
return { target, gate };
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
format: "text",
|
||||
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-rule-validator-${timestamp()}`),
|
||||
templates: [],
|
||||
manualGates: {},
|
||||
approvalRoles: [],
|
||||
fallbackEmployeeNo: DEFAULT_FALLBACK_EMPLOYEE_NO,
|
||||
status: "",
|
||||
limit: 50,
|
||||
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 === "--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") {
|
||||
options.templates.push(toSafeString(argv[index + 1], 40));
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--template=")) {
|
||||
options.templates.push(toSafeString(arg.slice("--template=".length), 40));
|
||||
} else if (arg === "--manual-gate") {
|
||||
const { target, gate } = parseManualGate(argv[index + 1]);
|
||||
if (target === "all") {
|
||||
for (const template of APPROVAL_RULE_TARGETS) options.manualGates[template] = gate;
|
||||
} else {
|
||||
options.manualGates[target] = gate;
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--manual-gate=")) {
|
||||
const { target, gate } = parseManualGate(arg.slice("--manual-gate=".length));
|
||||
if (target === "all") {
|
||||
for (const template of APPROVAL_RULE_TARGETS) options.manualGates[template] = gate;
|
||||
} else {
|
||||
options.manualGates[target] = gate;
|
||||
}
|
||||
} else if (arg === "--approval-role") {
|
||||
options.approvalRoles.push(toSafeString(argv[index + 1], 40));
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--approval-role=")) {
|
||||
options.approvalRoles.push(toSafeString(arg.slice("--approval-role=".length), 40));
|
||||
} else if (arg === "--fallback-employee-no") {
|
||||
options.fallbackEmployeeNo = toSafeString(argv[index + 1], 80);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--fallback-employee-no=")) {
|
||||
options.fallbackEmployeeNo = toSafeString(arg.slice("--fallback-employee-no=".length), 80);
|
||||
} else if (arg === "--status") {
|
||||
options.status = toSafeString(argv[index + 1], 40);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--status=")) {
|
||||
options.status = toSafeString(arg.slice("--status=".length), 40);
|
||||
} else if (arg === "--limit") {
|
||||
options.limit = Number(argv[index + 1] || options.limit);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--limit=")) {
|
||||
options.limit = Number(arg.slice("--limit=".length));
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
const uniqueTemplates = Array.from(new Set(options.templates.filter(Boolean)));
|
||||
for (const template of uniqueTemplates) {
|
||||
if (!APPROVAL_RULE_TARGETS.includes(template)) {
|
||||
throw new Error(`Unsupported --template: ${template}`);
|
||||
}
|
||||
}
|
||||
options.templates = uniqueTemplates.length ? uniqueTemplates : [...APPROVAL_RULE_TARGETS];
|
||||
const uniqueRoles = Array.from(new Set(options.approvalRoles.filter(Boolean)));
|
||||
options.approvalRoles = uniqueRoles.length ? uniqueRoles : [...DEFAULT_APPROVAL_ROLES];
|
||||
if (!options.fallbackEmployeeNo) {
|
||||
throw new Error("--fallback-employee-no requires non-empty value");
|
||||
}
|
||||
if (!Number.isFinite(options.limit) || options.limit < 1 || options.limit > 100) {
|
||||
throw new Error("--limit must be between 1 and 100");
|
||||
}
|
||||
options.limit = Math.floor(options.limit);
|
||||
options.manualGates = normalizeManualGates(options.manualGates);
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildArchiveWhere(options) {
|
||||
const where = { template_type: { in: options.templates } };
|
||||
if (options.status) where.normalized_status = options.status;
|
||||
return where;
|
||||
}
|
||||
|
||||
const EMPLOYEE_SELECT = {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
wecom_identity_binding: { select: { external_user_id: true } },
|
||||
};
|
||||
|
||||
async function readApprovalRuleInputs(prisma, options) {
|
||||
const [archives, fallbackEmployee, approvalRoleEmployees] = await Promise.all([
|
||||
prisma.wecomApprovalArchive.findMany({
|
||||
where: buildArchiveWhere(options),
|
||||
orderBy: [{ status_observed_at: "desc" }, { updated_at: "desc" }],
|
||||
take: options.limit,
|
||||
select: {
|
||||
id: true,
|
||||
sp_no: true,
|
||||
template_id: true,
|
||||
template_type: true,
|
||||
normalized_status: true,
|
||||
status_observed_at: true,
|
||||
applicant: {
|
||||
select: {
|
||||
...EMPLOYEE_SELECT,
|
||||
direct_manager_id: true,
|
||||
direct_manager: { select: EMPLOYEE_SELECT },
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
where: { role: "approver" },
|
||||
orderBy: [{ node_index: "asc" }, { created_at: "asc" }],
|
||||
select: {
|
||||
role: true,
|
||||
node_index: true,
|
||||
employee: { select: EMPLOYEE_SELECT },
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
orderBy: [{ action_time: "asc" }, { created_at: "asc" }],
|
||||
select: {
|
||||
action_type: true,
|
||||
node_index: true,
|
||||
operator: { select: EMPLOYEE_SELECT },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.employee.findUnique({
|
||||
where: { employee_no: options.fallbackEmployeeNo },
|
||||
select: EMPLOYEE_SELECT,
|
||||
}).catch(() => null),
|
||||
prisma.employee.findMany({
|
||||
where: {
|
||||
status: "active",
|
||||
roles: { some: { role_code: { in: options.approvalRoles } } },
|
||||
},
|
||||
select: EMPLOYEE_SELECT,
|
||||
}),
|
||||
]);
|
||||
return { archives, fallbackEmployee, approvalRoleEmployees };
|
||||
}
|
||||
|
||||
function manualGateGapsForTemplates(options) {
|
||||
return manualGateFindingsForTemplates(options)
|
||||
.filter((item) => item.status === "PAUSED_MANUAL_CONFIRMATION");
|
||||
}
|
||||
|
||||
function manualGateFindingsForTemplates(options) {
|
||||
return options.templates
|
||||
.map((template) => {
|
||||
const gate = options.manualGates[template];
|
||||
if (!gate) {
|
||||
return {
|
||||
templateType: template,
|
||||
status: "PAUSED_MANUAL_CONFIRMATION",
|
||||
reason: "manual_wappr1_gate_missing",
|
||||
};
|
||||
}
|
||||
if (gate === "D") {
|
||||
return {
|
||||
templateType: template,
|
||||
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
reason: "manual_wappr1_template_not_visible_or_not_configured",
|
||||
};
|
||||
}
|
||||
if (gate === "B" || gate === "C") {
|
||||
return {
|
||||
templateType: template,
|
||||
status: "FAIL_APPLICANT_CAN_CHOOSE",
|
||||
reason: gate === "B"
|
||||
? "manual_wappr1_approver_can_be_changed"
|
||||
: "manual_wappr1_approver_must_be_chosen_by_applicant",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function templateDataGapsForTemplates(options, archives) {
|
||||
const presentTemplates = new Set((archives || []).map((archive) => archive?.template_type).filter(Boolean));
|
||||
return options.templates
|
||||
.filter((template) => !presentTemplates.has(template))
|
||||
.map((template) => ({
|
||||
templateType: template,
|
||||
status: options.manualGates[template] ? "SKIP/BLOCKED_BY_EXTERNAL_CONFIG" : "PAUSED_MANUAL_CONFIRMATION",
|
||||
reason: "no_wecom_approval_archive_rows_for_template_scope",
|
||||
}));
|
||||
}
|
||||
|
||||
async function executeApprovalRuleValidator(options, deps = {}) {
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const env = deps.env || process.env;
|
||||
let ownedPrisma = null;
|
||||
let ownedPool = null;
|
||||
let readError = null;
|
||||
let rawInputs = { archives: [], fallbackEmployee: null, approvalRoleEmployees: [] };
|
||||
const mainBlockers = [];
|
||||
|
||||
try {
|
||||
let prisma = deps.prisma;
|
||||
if (!prisma) {
|
||||
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is required for approval rule validation");
|
||||
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
|
||||
ownedPrisma = created.prisma;
|
||||
ownedPool = created.pool;
|
||||
prisma = ownedPrisma;
|
||||
}
|
||||
rawInputs = await (deps.readApprovalRuleInputsImpl || readApprovalRuleInputs)(prisma, options);
|
||||
} catch (error) {
|
||||
readError = error;
|
||||
mainBlockers.push({
|
||||
id: "approval_rule_db_read",
|
||||
reason: "oa_archive_or_org_rule_read_failed",
|
||||
errorCode: toSafeString(error?.code || "unknown", 40) || "unknown",
|
||||
message: safeMessage(error?.message || error),
|
||||
});
|
||||
} finally {
|
||||
if (ownedPrisma?.$disconnect) await ownedPrisma.$disconnect().catch(() => null);
|
||||
if (ownedPool?.end) await ownedPool.end().catch(() => null);
|
||||
}
|
||||
|
||||
const manualGateFindings = manualGateFindingsForTemplates(options);
|
||||
const manualGateGaps = manualGateFindings.filter((item) => item.status === "PAUSED_MANUAL_CONFIRMATION");
|
||||
const templateDataGaps = templateDataGapsForTemplates(options, rawInputs.archives || []);
|
||||
const items = (rawInputs.archives || []).map((archive) => validateApprovalRuleArchive(archive, {
|
||||
manualGates: options.manualGates,
|
||||
fallbackEmployee: rawInputs.fallbackEmployee,
|
||||
approvalRoleEmployees: rawInputs.approvalRoleEmployees,
|
||||
}));
|
||||
const noData = !readError && !items.length;
|
||||
if (noData) {
|
||||
mainBlockers.push({
|
||||
id: "approval_archive_data",
|
||||
reason: "no_wecom_approval_archive_rows_for_rule_scope",
|
||||
});
|
||||
}
|
||||
const status = evaluateRuleReport(items, {
|
||||
readError,
|
||||
manualGateFindings,
|
||||
manualGateGaps,
|
||||
templateDataGaps,
|
||||
noData,
|
||||
});
|
||||
|
||||
return {
|
||||
mode: "qa_only",
|
||||
generatedAt,
|
||||
status,
|
||||
conclusion: status,
|
||||
privacy: REPORT_PRIVACY,
|
||||
scope: {
|
||||
issue: "WRULE1",
|
||||
source: "OA_ARCHIVED_WECOM_APPROVAL_DETAIL",
|
||||
writeBehavior: "read-only OA DB; no OA writes, no WeCom writes",
|
||||
wecomApiCalls: false,
|
||||
upstreamWecomSecret: "WECOM_AGENT_SECRET",
|
||||
noSecretFallback: true,
|
||||
targets: options.templates,
|
||||
realEmployees: "not_enabled",
|
||||
outingOvertimeLive: "not_authorized",
|
||||
nextStage: "WAPPR1_A_then_WEXT1_LIVE_then_WREAL1",
|
||||
},
|
||||
filters: {
|
||||
templates: options.templates,
|
||||
status: options.status || null,
|
||||
limit: options.limit,
|
||||
approvalRoles: options.approvalRoles,
|
||||
fallbackEmployeeNoPrefix: maskSimple(options.fallbackEmployeeNo),
|
||||
manualGates: options.manualGates,
|
||||
},
|
||||
checks: {
|
||||
ruleValidation: {
|
||||
status,
|
||||
count: items.length,
|
||||
statusCounts: countBy(items, (item) => item.status),
|
||||
templateCounts: countBy(items, (item) => item.templateType),
|
||||
ruleModeCounts: countBy(items, (item) => item.ruleMode),
|
||||
},
|
||||
manualGateFindings,
|
||||
manualGateGaps,
|
||||
templateDataGaps,
|
||||
items,
|
||||
},
|
||||
mainBlockers,
|
||||
};
|
||||
}
|
||||
|
||||
function maskSimple(value) {
|
||||
const raw = toSafeString(value, 80);
|
||||
if (!raw) return null;
|
||||
if (raw.length <= 4) return `${raw.slice(0, 1)}***`;
|
||||
return `${raw.slice(0, 3)}***${raw.slice(-2)}`;
|
||||
}
|
||||
|
||||
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-rule-validator] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||
process.stdout.write(`[wecom-approval-rule-validator] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-approval-rule-validator] privacy=${report.privacy}\n`);
|
||||
process.stdout.write(`[wecom-approval-rule-validator] targets=${report.scope.targets.join(",")} count=${report.checks.ruleValidation.count}\n`);
|
||||
if (report.checks.manualGateGaps?.length) {
|
||||
process.stdout.write(`[wecom-approval-rule-validator] manualGateGaps=${JSON.stringify(report.checks.manualGateGaps)}\n`);
|
||||
}
|
||||
if (report.mainBlockers?.length) {
|
||||
process.stdout.write(`[wecom-approval-rule-validator] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const report = await executeApprovalRuleValidator(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-rule-validator][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildArchiveWhere,
|
||||
executeApprovalRuleValidator,
|
||||
manualGateFindingsForTemplates,
|
||||
parseArgs,
|
||||
readApprovalRuleInputs,
|
||||
templateDataGapsForTemplates,
|
||||
};
|
||||
@@ -0,0 +1,331 @@
|
||||
const { maskApprovalIdentifier } = require("./wecomApproval");
|
||||
|
||||
const APPROVAL_RULE_TARGETS = Object.freeze(["leave", "missed_punch", "outing", "overtime"]);
|
||||
const MANUAL_GATE_VALUES = Object.freeze(["A", "B", "C", "D"]);
|
||||
|
||||
function toSafeString(value, maxLength = 500) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function roleCodes(employee) {
|
||||
return toArray(employee?.roles)
|
||||
.map((item) => (typeof item === "string" ? item : item?.role_code))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasWecomBinding(employee) {
|
||||
return Boolean(employee?.wecom_identity_binding?.external_user_id);
|
||||
}
|
||||
|
||||
function isActiveEmployee(employee) {
|
||||
return Boolean(employee && employee.status === "active");
|
||||
}
|
||||
|
||||
function employeeSafeSummary(employee) {
|
||||
if (!employee) return null;
|
||||
return {
|
||||
employeeNoPrefix: maskEmployeeIdentifier(employee.employee_no),
|
||||
status: employee.status || "unknown",
|
||||
roleCodes: roleCodes(employee),
|
||||
hasWecomBinding: hasWecomBinding(employee),
|
||||
};
|
||||
}
|
||||
|
||||
function maskEmployeeIdentifier(value) {
|
||||
const raw = toSafeString(value, 80);
|
||||
if (!raw) return null;
|
||||
if (raw.length <= 4) return `${raw.slice(0, 1)}***`;
|
||||
return `${raw.slice(0, 3)}***${raw.slice(-2)}`;
|
||||
}
|
||||
|
||||
function uniqueById(items) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const item of items || []) {
|
||||
if (!item?.id || seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeManualGates(rawManualGates = {}) {
|
||||
const gates = {};
|
||||
for (const [target, value] of Object.entries(rawManualGates || {})) {
|
||||
const normalizedTarget = toSafeString(target, 40);
|
||||
const gate = toSafeString(value, 4).toUpperCase();
|
||||
if (!APPROVAL_RULE_TARGETS.includes(normalizedTarget) || !MANUAL_GATE_VALUES.includes(gate)) continue;
|
||||
gates[normalizedTarget] = gate;
|
||||
}
|
||||
return gates;
|
||||
}
|
||||
|
||||
function buildExpectedApprovers({ applicant, fallbackEmployee, approvalRoleEmployees = [] }) {
|
||||
const findings = [];
|
||||
const directManager = applicant?.direct_manager || null;
|
||||
if (directManager && directManager.id === applicant?.id) {
|
||||
findings.push({
|
||||
type: "FAIL_SELF_APPROVAL",
|
||||
severity: "blocker",
|
||||
reason: "direct_manager_resolves_to_applicant",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
directManager
|
||||
&& directManager.id !== applicant?.id
|
||||
&& isActiveEmployee(directManager)
|
||||
&& hasWecomBinding(directManager)
|
||||
) {
|
||||
return {
|
||||
mode: "direct_manager",
|
||||
expectedEmployees: [directManager],
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
const validRoleEmployees = uniqueById(approvalRoleEmployees.filter((employee) => (
|
||||
employee.id !== applicant?.id
|
||||
&& isActiveEmployee(employee)
|
||||
&& hasWecomBinding(employee)
|
||||
)));
|
||||
if (validRoleEmployees.length) {
|
||||
return {
|
||||
mode: "approval_role",
|
||||
expectedEmployees: validRoleEmployees,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
fallbackEmployee
|
||||
&& fallbackEmployee.id !== applicant?.id
|
||||
&& isActiveEmployee(fallbackEmployee)
|
||||
&& hasWecomBinding(fallbackEmployee)
|
||||
) {
|
||||
return {
|
||||
mode: "admin_fallback",
|
||||
expectedEmployees: [fallbackEmployee],
|
||||
findings: [
|
||||
...findings,
|
||||
{
|
||||
type: "PASS_WITH_ADMIN_FALLBACK",
|
||||
severity: "warning",
|
||||
reason: "direct_manager_or_role_approver_missing_or_unavailable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "no_candidate",
|
||||
expectedEmployees: [],
|
||||
findings: [
|
||||
...findings,
|
||||
{
|
||||
type: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
severity: "blocker",
|
||||
reason: "no_valid_direct_manager_role_or_admin_fallback",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function actualApproverEmployees(archive) {
|
||||
const fromParticipants = toArray(archive?.participants)
|
||||
.filter((item) => item?.role === "approver" && item.employee)
|
||||
.sort((a, b) => (a.node_index || 0) - (b.node_index || 0))
|
||||
.map((item) => item.employee);
|
||||
if (fromParticipants.length) return uniqueById(fromParticipants);
|
||||
|
||||
const fromActions = toArray(archive?.actions)
|
||||
.filter((item) => item?.operator && ["approve", "reject", "pending"].includes(item.action_type))
|
||||
.sort((a, b) => (a.node_index || 0) - (b.node_index || 0))
|
||||
.map((item) => item.operator);
|
||||
return uniqueById(fromActions);
|
||||
}
|
||||
|
||||
function manualGateFinding(templateType, gate) {
|
||||
if (!gate) {
|
||||
return {
|
||||
type: "PAUSED_MANUAL_CONFIRMATION",
|
||||
severity: "warning",
|
||||
reason: "manual_wappr1_gate_missing",
|
||||
templateType,
|
||||
};
|
||||
}
|
||||
if (gate === "A") return null;
|
||||
if (gate === "D") {
|
||||
return {
|
||||
type: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
severity: "blocker",
|
||||
reason: "manual_wappr1_template_not_visible_or_not_configured",
|
||||
templateType,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "FAIL_APPLICANT_CAN_CHOOSE",
|
||||
severity: "blocker",
|
||||
reason: gate === "B" ? "manual_wappr1_approver_can_be_changed" : "manual_wappr1_approver_must_be_chosen_by_applicant",
|
||||
templateType,
|
||||
};
|
||||
}
|
||||
|
||||
function validateApprovalRuleArchive(archive, context = {}) {
|
||||
const templateType = archive?.template_type || "unknown";
|
||||
const manualGates = normalizeManualGates(context.manualGates);
|
||||
const applicant = archive?.applicant || null;
|
||||
const findings = [];
|
||||
const manualFinding = manualGateFinding(templateType, manualGates[templateType]);
|
||||
if (manualFinding) findings.push(manualFinding);
|
||||
|
||||
if (!applicant) {
|
||||
findings.push({
|
||||
type: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
severity: "blocker",
|
||||
reason: "applicant_not_mapped_to_oa_employee",
|
||||
templateType,
|
||||
});
|
||||
}
|
||||
|
||||
const expected = applicant
|
||||
? buildExpectedApprovers({
|
||||
applicant,
|
||||
fallbackEmployee: context.fallbackEmployee || null,
|
||||
approvalRoleEmployees: context.approvalRoleEmployees || [],
|
||||
})
|
||||
: { mode: "no_applicant", expectedEmployees: [], findings: [] };
|
||||
findings.push(...expected.findings);
|
||||
|
||||
const actualEmployees = actualApproverEmployees(archive);
|
||||
if (!actualEmployees.length) {
|
||||
findings.push({
|
||||
type: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
severity: "blocker",
|
||||
reason: "actual_approver_missing_or_unmapped",
|
||||
templateType,
|
||||
});
|
||||
}
|
||||
|
||||
if (applicant && actualEmployees.some((employee) => employee.id === applicant.id)) {
|
||||
findings.push({
|
||||
type: "FAIL_SELF_APPROVAL",
|
||||
severity: "blocker",
|
||||
reason: "actual_approver_is_applicant",
|
||||
templateType,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedIds = new Set(expected.expectedEmployees.map((employee) => employee.id));
|
||||
const actualIds = new Set(actualEmployees.map((employee) => employee.id));
|
||||
const matched = [...actualIds].some((id) => expectedIds.has(id));
|
||||
if (actualEmployees.length && expected.expectedEmployees.length && !matched) {
|
||||
findings.push({
|
||||
type: "FAIL_APPROVER_MISMATCH",
|
||||
severity: "blocker",
|
||||
reason: "actual_approver_not_in_oa_theoretical_approvers",
|
||||
templateType,
|
||||
});
|
||||
}
|
||||
|
||||
const blockerTypes = new Set(findings.filter((item) => item.severity === "blocker").map((item) => item.type));
|
||||
let status = "PASS_RULE_MATCH";
|
||||
if (blockerTypes.has("FAIL_SELF_APPROVAL")) {
|
||||
status = "FAIL_SELF_APPROVAL";
|
||||
} else if (blockerTypes.has("FAIL_APPLICANT_CAN_CHOOSE")) {
|
||||
status = "FAIL_APPLICANT_CAN_CHOOSE";
|
||||
} else if (blockerTypes.has("FAIL_APPROVER_MISMATCH")) {
|
||||
status = "FAIL_APPROVER_MISMATCH";
|
||||
} else if (blockerTypes.has("SKIP/BLOCKED_BY_EXTERNAL_CONFIG")) {
|
||||
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
} else if (findings.some((item) => item.type === "PAUSED_MANUAL_CONFIRMATION")) {
|
||||
status = "PAUSED_MANUAL_CONFIRMATION";
|
||||
} else if (findings.some((item) => item.type === "PASS_WITH_ADMIN_FALLBACK")) {
|
||||
status = "PASS_WITH_ADMIN_FALLBACK";
|
||||
}
|
||||
|
||||
return {
|
||||
archiveId: archive?.id || null,
|
||||
spNoPrefix: maskApprovalIdentifier(archive?.sp_no),
|
||||
templateIdPrefix: maskApprovalIdentifier(archive?.template_id),
|
||||
templateType,
|
||||
normalizedStatus: archive?.normalized_status || "unknown",
|
||||
ruleMode: expected.mode,
|
||||
status,
|
||||
manualGate: manualGates[templateType] || null,
|
||||
applicant: employeeSafeSummary(applicant),
|
||||
expectedApprovers: expected.expectedEmployees.map(employeeSafeSummary),
|
||||
actualApprovers: actualEmployees.map(employeeSafeSummary),
|
||||
findings: findings.map((item) => ({
|
||||
type: item.type,
|
||||
severity: item.severity,
|
||||
reason: item.reason,
|
||||
templateType: item.templateType || templateType,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function countBy(items, selector) {
|
||||
const counts = {};
|
||||
for (const item of items || []) {
|
||||
const key = toSafeString(selector(item), 100) || "unknown";
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function evaluateRuleReport(items, {
|
||||
readError = null,
|
||||
manualGateFindings = [],
|
||||
manualGateGaps = [],
|
||||
templateDataGaps = [],
|
||||
noData = false,
|
||||
} = {}) {
|
||||
if (readError) return "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
if (items.some((item) => item.status === "FAIL_SELF_APPROVAL")) return "FAIL_SELF_APPROVAL";
|
||||
if (
|
||||
items.some((item) => item.status === "FAIL_APPLICANT_CAN_CHOOSE")
|
||||
|| manualGateFindings.some((item) => item.status === "FAIL_APPLICANT_CAN_CHOOSE")
|
||||
) {
|
||||
return "FAIL_APPLICANT_CAN_CHOOSE";
|
||||
}
|
||||
if (items.some((item) => item.status === "FAIL_APPROVER_MISMATCH")) return "FAIL_APPROVER_MISMATCH";
|
||||
if (
|
||||
items.some((item) => item.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG")
|
||||
|| manualGateFindings.some((item) => item.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG")
|
||||
) {
|
||||
return "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
}
|
||||
if (
|
||||
manualGateGaps.length
|
||||
|| items.some((item) => item.status === "PAUSED_MANUAL_CONFIRMATION")
|
||||
|| templateDataGaps.some((item) => item.status === "PAUSED_MANUAL_CONFIRMATION")
|
||||
) {
|
||||
return "PAUSED_MANUAL_CONFIRMATION";
|
||||
}
|
||||
if (templateDataGaps.some((item) => item.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG")) {
|
||||
return "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
}
|
||||
if (noData) return "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
if (items.some((item) => item.status === "PASS_WITH_ADMIN_FALLBACK")) return "PASS_WITH_ADMIN_FALLBACK";
|
||||
return "PASS_RULE_MATCH";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APPROVAL_RULE_TARGETS,
|
||||
MANUAL_GATE_VALUES,
|
||||
actualApproverEmployees,
|
||||
buildExpectedApprovers,
|
||||
countBy,
|
||||
employeeSafeSummary,
|
||||
evaluateRuleReport,
|
||||
maskEmployeeIdentifier,
|
||||
normalizeManualGates,
|
||||
validateApprovalRuleArchive,
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
const {
|
||||
executeApprovalRuleValidator,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/check-wecom-approval-rule-validator");
|
||||
|
||||
function employee(id, overrides = {}) {
|
||||
const { roles = ["employee"], ...rest } = overrides;
|
||||
return {
|
||||
id,
|
||||
employee_no: rest.employee_no || `EMP-${id}`,
|
||||
status: rest.status || "active",
|
||||
roles: roles.map((role_code) => ({ role_code })),
|
||||
wecom_identity_binding: rest.bound === false ? null : { external_user_id: `wecom-${id}` },
|
||||
direct_manager: rest.direct_manager || null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function archiveRow() {
|
||||
const manager = employee("manager", { employee_no: "SMOKE101", roles: ["manager"] });
|
||||
return {
|
||||
id: "archive-1",
|
||||
sp_no: "SP_SCRIPT_RULE_VALIDATOR_FULL_SHOULD_NOT_LEAK",
|
||||
template_id: "TPL_SCRIPT_RULE_VALIDATOR_FULL_SHOULD_NOT_LEAK",
|
||||
template_type: "leave",
|
||||
normalized_status: "approved",
|
||||
applicant: employee("applicant", {
|
||||
employee_no: "SMOKE002",
|
||||
direct_manager: manager,
|
||||
}),
|
||||
participants: [{
|
||||
role: "approver",
|
||||
node_index: 1,
|
||||
employee: manager,
|
||||
}],
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-approval-rule-validator", () => {
|
||||
it("parses templates, manual gates and rule options", () => {
|
||||
const options = parseArgs([
|
||||
"--format=json",
|
||||
"--template=leave",
|
||||
"--manual-gate=all:A",
|
||||
"--approval-role=hr",
|
||||
"--fallback-employee-no=HYD000006",
|
||||
"--limit=20",
|
||||
]);
|
||||
|
||||
expect(options).toMatchObject({
|
||||
format: "json",
|
||||
templates: ["leave"],
|
||||
manualGates: {
|
||||
leave: "A",
|
||||
missed_punch: "A",
|
||||
outing: "A",
|
||||
overtime: "A",
|
||||
},
|
||||
approvalRoles: ["hr"],
|
||||
fallbackEmployeeNo: "HYD000006",
|
||||
limit: 20,
|
||||
});
|
||||
expect(() => parseArgs(["--template=expense"])).toThrow(/Unsupported --template/);
|
||||
expect(() => parseArgs(["--manual-gate=leave:E"])).toThrow(/Unsupported manual gate value/);
|
||||
});
|
||||
|
||||
it("returns PASS_RULE_MATCH when archived WeCom approver matches OA rule", async () => {
|
||||
const report = await executeApprovalRuleValidator(parseArgs([
|
||||
"--template=leave",
|
||||
"--manual-gate=leave:A",
|
||||
]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readApprovalRuleInputsImpl: vi.fn(async () => ({
|
||||
archives: [archiveRow()],
|
||||
fallbackEmployee: null,
|
||||
approvalRoleEmployees: [],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_RULE_MATCH");
|
||||
expect(report.scope.wecomApiCalls).toBe(false);
|
||||
expect(report.checks.ruleValidation.count).toBe(1);
|
||||
});
|
||||
|
||||
it("pauses when WAPPR1 manual gates are missing", async () => {
|
||||
const report = await executeApprovalRuleValidator(parseArgs(["--template=leave"]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readApprovalRuleInputsImpl: vi.fn(async () => ({
|
||||
archives: [archiveRow()],
|
||||
fallbackEmployee: null,
|
||||
approvalRoleEmployees: [],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PAUSED_MANUAL_CONFIRMATION");
|
||||
expect(report.checks.manualGateGaps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks when a selected template has no archived approval rows", async () => {
|
||||
const report = await executeApprovalRuleValidator(parseArgs([
|
||||
"--template=leave",
|
||||
"--template=outing",
|
||||
"--manual-gate=leave:A",
|
||||
"--manual-gate=outing:A",
|
||||
]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readApprovalRuleInputsImpl: vi.fn(async () => ({
|
||||
archives: [archiveRow()],
|
||||
fallbackEmployee: null,
|
||||
approvalRoleEmployees: [],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP/BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(report.checks.templateDataGaps).toEqual([{
|
||||
templateType: "outing",
|
||||
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
reason: "no_wecom_approval_archive_rows_for_template_scope",
|
||||
}]);
|
||||
});
|
||||
|
||||
it("blocks applicant-chosen manual gates even when that template has no archive rows", async () => {
|
||||
const report = await executeApprovalRuleValidator(parseArgs([
|
||||
"--template=leave",
|
||||
"--template=outing",
|
||||
"--manual-gate=leave:A",
|
||||
"--manual-gate=outing:B",
|
||||
]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readApprovalRuleInputsImpl: vi.fn(async () => ({
|
||||
archives: [archiveRow()],
|
||||
fallbackEmployee: null,
|
||||
approvalRoleEmployees: [],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("FAIL_APPLICANT_CAN_CHOOSE");
|
||||
expect(report.checks.manualGateFindings).toContainEqual({
|
||||
templateType: "outing",
|
||||
status: "FAIL_APPLICANT_CAN_CHOOSE",
|
||||
reason: "manual_wappr1_approver_can_be_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies manual D gates as external config blockers", async () => {
|
||||
const report = await executeApprovalRuleValidator(parseArgs([
|
||||
"--template=leave",
|
||||
"--manual-gate=leave:D",
|
||||
]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readApprovalRuleInputsImpl: vi.fn(async () => ({
|
||||
archives: [archiveRow()],
|
||||
fallbackEmployee: null,
|
||||
approvalRoleEmployees: [],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP/BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(report.checks.manualGateFindings[0]).toMatchObject({
|
||||
templateType: "leave",
|
||||
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies DB read failures as external blockers without leaking long secrets", async () => {
|
||||
const report = await executeApprovalRuleValidator(parseArgs(["--template=leave", "--manual-gate=leave:A"]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
prisma: {},
|
||||
readApprovalRuleInputsImpl: vi.fn(async () => {
|
||||
throw new Error("database down secret-like-token-123456789012345678901234567890");
|
||||
}),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP/BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(report.mainBlockers[0]).toMatchObject({
|
||||
id: "approval_rule_db_read",
|
||||
reason: "oa_archive_or_org_rule_read_failed",
|
||||
errorCode: "unknown",
|
||||
});
|
||||
expect(JSON.stringify(report)).not.toContain("secret-like-token-123456789012345678901234567890");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
const {
|
||||
evaluateRuleReport,
|
||||
validateApprovalRuleArchive,
|
||||
} = require("../../../src/services/integrations/wecomApprovalRuleValidator");
|
||||
|
||||
const SENSITIVE_SP_NO = "SP_RULE_VALIDATOR_FULL_SHOULD_NOT_LEAK";
|
||||
const SENSITIVE_TEMPLATE_ID = "TPL_RULE_VALIDATOR_FULL_SHOULD_NOT_LEAK";
|
||||
|
||||
function employee(id, overrides = {}) {
|
||||
const { roles = ["employee"], ...rest } = overrides;
|
||||
return {
|
||||
id,
|
||||
employee_no: rest.employee_no || `EMP-${id}`,
|
||||
status: rest.status || "active",
|
||||
roles: roles.map((role_code) => ({ role_code })),
|
||||
wecom_identity_binding: rest.bound === false ? null : { external_user_id: `wecom-${id}-secret-userid` },
|
||||
direct_manager: rest.direct_manager || null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function archive(overrides = {}) {
|
||||
const manager = employee("manager", { employee_no: "SMOKE101", roles: ["manager"] });
|
||||
const applicant = employee("applicant", {
|
||||
employee_no: "SMOKE002",
|
||||
direct_manager: manager,
|
||||
});
|
||||
return {
|
||||
id: "archive-1",
|
||||
sp_no: SENSITIVE_SP_NO,
|
||||
template_id: SENSITIVE_TEMPLATE_ID,
|
||||
template_type: "leave",
|
||||
normalized_status: "approved",
|
||||
applicant,
|
||||
participants: [{
|
||||
role: "approver",
|
||||
node_index: 1,
|
||||
employee: manager,
|
||||
}],
|
||||
actions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("wecomApprovalRuleValidator", () => {
|
||||
it("passes when actual approver matches OA direct manager", () => {
|
||||
const result = validateApprovalRuleArchive(archive(), { manualGates: { leave: "A" } });
|
||||
|
||||
expect(result.status).toBe("PASS_RULE_MATCH");
|
||||
expect(result.ruleMode).toBe("direct_manager");
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails when applicant is the actual approver", () => {
|
||||
const row = archive();
|
||||
row.participants = [{ role: "approver", node_index: 1, employee: row.applicant }];
|
||||
|
||||
const result = validateApprovalRuleArchive(row, { manualGates: { leave: "A" } });
|
||||
|
||||
expect(result.status).toBe("FAIL_SELF_APPROVAL");
|
||||
expect(result.findings.map((item) => item.type)).toContain("FAIL_SELF_APPROVAL");
|
||||
});
|
||||
|
||||
it("fails when manual WAPPR1 gate says applicant can choose approver", () => {
|
||||
const result = validateApprovalRuleArchive(archive(), { manualGates: { leave: "B" } });
|
||||
|
||||
expect(result.status).toBe("FAIL_APPLICANT_CAN_CHOOSE");
|
||||
expect(result.findings[0]).toMatchObject({
|
||||
type: "FAIL_APPLICANT_CAN_CHOOSE",
|
||||
reason: "manual_wappr1_approver_can_be_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when WeCom actual approver does not match OA theoretical approver", () => {
|
||||
const result = validateApprovalRuleArchive(archive({
|
||||
participants: [{
|
||||
role: "approver",
|
||||
node_index: 1,
|
||||
employee: employee("other", { employee_no: "OTHER001", roles: ["employee"] }),
|
||||
}],
|
||||
}), { manualGates: { leave: "A" } });
|
||||
|
||||
expect(result.status).toBe("FAIL_APPROVER_MISMATCH");
|
||||
});
|
||||
|
||||
it("allows explainable admin fallback when no manager or approval role candidate exists", () => {
|
||||
const fallback = employee("admin", { employee_no: "HYD000006", roles: ["admin"] });
|
||||
const applicant = employee("applicant", {
|
||||
employee_no: "SMOKE002",
|
||||
direct_manager: null,
|
||||
});
|
||||
const result = validateApprovalRuleArchive(archive({
|
||||
applicant,
|
||||
participants: [{ role: "approver", node_index: 1, employee: fallback }],
|
||||
}), {
|
||||
manualGates: { leave: "A" },
|
||||
fallbackEmployee: fallback,
|
||||
approvalRoleEmployees: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("PASS_WITH_ADMIN_FALLBACK");
|
||||
expect(result.ruleMode).toBe("admin_fallback");
|
||||
});
|
||||
|
||||
it("pauses item status before admin fallback when manual gate is missing", () => {
|
||||
const fallback = employee("admin", { employee_no: "HYD000006", roles: ["admin"] });
|
||||
const applicant = employee("applicant", {
|
||||
employee_no: "SMOKE002",
|
||||
direct_manager: null,
|
||||
});
|
||||
const result = validateApprovalRuleArchive(archive({
|
||||
applicant,
|
||||
participants: [{ role: "approver", node_index: 1, employee: fallback }],
|
||||
}), {
|
||||
fallbackEmployee: fallback,
|
||||
approvalRoleEmployees: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("PAUSED_MANUAL_CONFIRMATION");
|
||||
expect(result.findings.map((item) => item.type)).toContain("PASS_WITH_ADMIN_FALLBACK");
|
||||
});
|
||||
|
||||
it("uses approval-role approver when direct manager is unavailable", () => {
|
||||
const roleApprover = employee("hr", { employee_no: "HR001", roles: ["hr"] });
|
||||
const applicant = employee("applicant", {
|
||||
employee_no: "SMOKE002",
|
||||
direct_manager: employee("manager", { employee_no: "SMOKE101", status: "inactive", roles: ["manager"] }),
|
||||
});
|
||||
const result = validateApprovalRuleArchive(archive({
|
||||
applicant,
|
||||
participants: [{ role: "approver", node_index: 1, employee: roleApprover }],
|
||||
}), {
|
||||
manualGates: { leave: "A" },
|
||||
approvalRoleEmployees: [roleApprover],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("PASS_RULE_MATCH");
|
||||
expect(result.ruleMode).toBe("approval_role");
|
||||
});
|
||||
|
||||
it("blocks when no approver candidate or fallback exists", () => {
|
||||
const applicant = employee("applicant", {
|
||||
employee_no: "SMOKE002",
|
||||
direct_manager: null,
|
||||
});
|
||||
const result = validateApprovalRuleArchive(archive({
|
||||
applicant,
|
||||
participants: [{ role: "approver", node_index: 1, employee: employee("other") }],
|
||||
}), { manualGates: { leave: "A" } });
|
||||
|
||||
expect(result.status).toBe("SKIP/BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(result.findings.map((item) => item.reason)).toContain("no_valid_direct_manager_role_or_admin_fallback");
|
||||
});
|
||||
|
||||
it("redacts full external identifiers from public result", () => {
|
||||
const result = validateApprovalRuleArchive(archive(), { manualGates: { leave: "A" } });
|
||||
const serialized = JSON.stringify(result);
|
||||
|
||||
expect(serialized).not.toContain(SENSITIVE_SP_NO);
|
||||
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
expect(serialized).not.toContain("wecom-manager-secret-userid");
|
||||
expect(serialized).not.toContain("SMOKE101");
|
||||
});
|
||||
|
||||
it("prioritizes blocker conclusions in report evaluation", () => {
|
||||
expect(evaluateRuleReport([{ status: "PASS_RULE_MATCH" }])).toBe("PASS_RULE_MATCH");
|
||||
expect(evaluateRuleReport([{ status: "PASS_WITH_ADMIN_FALLBACK" }])).toBe("PASS_WITH_ADMIN_FALLBACK");
|
||||
expect(evaluateRuleReport([{ status: "FAIL_APPROVER_MISMATCH" }, { status: "PASS_RULE_MATCH" }]))
|
||||
.toBe("FAIL_APPROVER_MISMATCH");
|
||||
expect(evaluateRuleReport([{ status: "PASS_RULE_MATCH" }], {
|
||||
manualGateFindings: [{ status: "FAIL_APPLICANT_CAN_CHOOSE" }],
|
||||
})).toBe("FAIL_APPLICANT_CAN_CHOOSE");
|
||||
expect(evaluateRuleReport([{ status: "PASS_RULE_MATCH" }], {
|
||||
templateDataGaps: [{ status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG" }],
|
||||
})).toBe("SKIP/BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(evaluateRuleReport([], { noData: true })).toBe("SKIP/BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户