WRULE1 实现企微审批链校验器 POC
这个提交包含在:
@@ -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");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户