WREAD1 准备企微审批可读性门禁
这个提交包含在:
@@ -32,6 +32,7 @@
|
||||
"check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.js",
|
||||
"check:wecom-approval-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js",
|
||||
"check:wecom-approval-compensation-poc": "node src/scripts/check-wecom-approval-compensation-poc.js",
|
||||
"check:wecom-approval-readability-gate": "node src/scripts/check-wecom-approval-readability-gate.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,487 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
APPROVAL_POC_TARGETS,
|
||||
maskApprovalIdentifier,
|
||||
} = require("../services/integrations/wecomApproval");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const READABILITY_STATUSES = new Set([
|
||||
"PASS_READABLE",
|
||||
"PASS_WITH_TEMPLATE_ADJUSTMENTS",
|
||||
"FAIL_KEEP_OA_PRIMARY_FOR_THIS_TEMPLATE",
|
||||
]);
|
||||
|
||||
const REPORT_PRIVACY =
|
||||
"readability status, manual checklist, evidence statuses and masked prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload";
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `Usage: node src/scripts/check-wecom-approval-readability-gate.js [options]
|
||||
|
||||
QA-only 企业微信审批可读性与误审批防护门禁。
|
||||
默认只汇总现有 OA-POC 证据,不写 OA/企微数据,不创建模板或审批单。
|
||||
|
||||
Options:
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--artifact-root=<path> QA 报告根目录,默认 artifacts/qa-flow。
|
||||
--report-dir=<path> 输出目录,默认 artifacts/qa-flow/wecom-approval-readability-gate-<timestamp>。
|
||||
--fixtures-report=<path> 指定 WAPC2FLIVE summary.json 或目录。
|
||||
--callback-report=<path> 指定 WCBK1 summary.json 或目录。
|
||||
--compensation-report=<path> 指定 WACP3R summary.json 或目录。
|
||||
--manual-result=<target>:<status> 录入人工结论,target 为 leave 或 missed_punch。
|
||||
--manual-note=<target>:<text> 录入脱敏人工备注,不写表单值、附件、评论或完整 ID。
|
||||
--confirmed-by=<employeeNo> 人工确认人,仅记录员工号。
|
||||
--help 显示帮助。
|
||||
|
||||
人工结论:
|
||||
PASS_READABLE
|
||||
PASS_WITH_TEMPLATE_ADJUSTMENTS
|
||||
FAIL_KEEP_OA_PRIMARY_FOR_THIS_TEMPLATE
|
||||
`;
|
||||
}
|
||||
|
||||
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 targetKeys() {
|
||||
return APPROVAL_POC_TARGETS.map((item) => item.key);
|
||||
}
|
||||
|
||||
function parseTargetPair(value, flagName) {
|
||||
const raw = toSafeString(value, 700);
|
||||
const separatorIndex = raw.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(`${flagName} requires <target>:<value>`);
|
||||
}
|
||||
const target = raw.slice(0, separatorIndex).trim();
|
||||
const itemValue = raw.slice(separatorIndex + 1).trim();
|
||||
if (!targetKeys().includes(target)) {
|
||||
throw new Error(`${flagName} unsupported target: ${target}`);
|
||||
}
|
||||
if (!itemValue) {
|
||||
throw new Error(`${flagName} requires non-empty value`);
|
||||
}
|
||||
return { target, value: itemValue };
|
||||
}
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2), env = process.env) {
|
||||
const options = {
|
||||
format: "text",
|
||||
artifactRoot: env.WECOM_APPROVAL_READABILITY_ARTIFACT_ROOT || DEFAULT_REPORT_ROOT,
|
||||
reportDir: env.WECOM_APPROVAL_READABILITY_REPORT_DIR
|
||||
|| path.join(DEFAULT_REPORT_ROOT, `wecom-approval-readability-gate-${timestamp()}`),
|
||||
reports: {
|
||||
fixtures: "",
|
||||
callback: "",
|
||||
compensation: "",
|
||||
},
|
||||
manualResults: {},
|
||||
manualNotes: {},
|
||||
confirmedBy: "",
|
||||
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 === "--artifact-root") {
|
||||
options.artifactRoot = argv[index + 1] || options.artifactRoot;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--artifact-root=")) {
|
||||
options.artifactRoot = arg.slice("--artifact-root=".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 === "--fixtures-report") {
|
||||
options.reports.fixtures = argv[index + 1] || "";
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--fixtures-report=")) {
|
||||
options.reports.fixtures = arg.slice("--fixtures-report=".length);
|
||||
} else if (arg === "--callback-report") {
|
||||
options.reports.callback = argv[index + 1] || "";
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--callback-report=")) {
|
||||
options.reports.callback = arg.slice("--callback-report=".length);
|
||||
} else if (arg === "--compensation-report") {
|
||||
options.reports.compensation = argv[index + 1] || "";
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--compensation-report=")) {
|
||||
options.reports.compensation = arg.slice("--compensation-report=".length);
|
||||
} else if (arg === "--manual-result") {
|
||||
const pair = parseTargetPair(argv[index + 1], "--manual-result");
|
||||
options.manualResults[pair.target] = pair.value;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--manual-result=")) {
|
||||
const pair = parseTargetPair(arg.slice("--manual-result=".length), "--manual-result");
|
||||
options.manualResults[pair.target] = pair.value;
|
||||
} else if (arg === "--manual-note") {
|
||||
const pair = parseTargetPair(argv[index + 1], "--manual-note");
|
||||
options.manualNotes[pair.target] = pair.value;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--manual-note=")) {
|
||||
const pair = parseTargetPair(arg.slice("--manual-note=".length), "--manual-note");
|
||||
options.manualNotes[pair.target] = pair.value;
|
||||
} else if (arg === "--confirmed-by") {
|
||||
options.confirmedBy = toSafeString(argv[index + 1], 80);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--confirmed-by=")) {
|
||||
options.confirmedBy = toSafeString(arg.slice("--confirmed-by=".length), 80);
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
for (const [target, value] of Object.entries(options.manualResults)) {
|
||||
if (!READABILITY_STATUSES.has(value)) {
|
||||
throw new Error(`Unsupported manual result for ${target}: ${value}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function resolveSummaryPath(inputPath) {
|
||||
const value = toSafeString(inputPath, 1000);
|
||||
if (!value) return "";
|
||||
if (!fs.existsSync(value)) return "";
|
||||
const stat = fs.statSync(value);
|
||||
if (stat.isDirectory()) {
|
||||
const summaryPath = path.join(value, "summary.json");
|
||||
return fs.existsSync(summaryPath) ? summaryPath : "";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function findLatestSummary(artifactRoot, prefix) {
|
||||
const root = toSafeString(artifactRoot, 1000);
|
||||
if (!root || !fs.existsSync(root)) return "";
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix))
|
||||
.map((entry) => path.join(root, entry.name, "summary.json"))
|
||||
.filter((summaryPath) => fs.existsSync(summaryPath))
|
||||
.sort();
|
||||
return entries[entries.length - 1] || "";
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
const resolved = resolveSummaryPath(filePath);
|
||||
if (!resolved) return { ok: false, path: "", reason: "report_not_found", data: null };
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(resolved, "utf8"));
|
||||
return { ok: true, path: resolved, reason: null, data };
|
||||
} catch (error) {
|
||||
return { ok: false, path: resolved, reason: "report_parse_failed", message: safeMessage(error?.message || error), data: null };
|
||||
}
|
||||
}
|
||||
|
||||
function loadEvidenceReports(options, deps = {}) {
|
||||
const findLatestSummaryImpl = deps.findLatestSummaryImpl || findLatestSummary;
|
||||
const readJsonFileImpl = deps.readJsonFileImpl || readJsonFile;
|
||||
const paths = {
|
||||
fixtures: resolveSummaryPath(options.reports.fixtures)
|
||||
|| findLatestSummaryImpl(options.artifactRoot, "wecom-approval-fixtures-"),
|
||||
callback: resolveSummaryPath(options.reports.callback)
|
||||
|| findLatestSummaryImpl(options.artifactRoot, "wecom-approval-callback-poc-"),
|
||||
compensation: resolveSummaryPath(options.reports.compensation)
|
||||
|| findLatestSummaryImpl(options.artifactRoot, "wecom-approval-compensation-poc-"),
|
||||
};
|
||||
return {
|
||||
fixtures: readJsonFileImpl(paths.fixtures),
|
||||
callback: readJsonFileImpl(paths.callback),
|
||||
compensation: readJsonFileImpl(paths.compensation),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePath(filePath) {
|
||||
return toSafeString(filePath, 1000) || null;
|
||||
}
|
||||
|
||||
function targetLabel(target) {
|
||||
return APPROVAL_POC_TARGETS.find((item) => item.key === target)?.label || target;
|
||||
}
|
||||
|
||||
function maskEvidencePrefix(value) {
|
||||
const raw = toSafeString(value, 160);
|
||||
if (!raw) return null;
|
||||
return raw.endsWith("***") ? raw : maskApprovalIdentifier(raw);
|
||||
}
|
||||
|
||||
function summarizeFixtureEvidence(readResult) {
|
||||
const report = readResult.data || {};
|
||||
const liveWrite = report.checks?.liveWrite || {};
|
||||
const autoRerun = report.checks?.autoRerun || {};
|
||||
const createdTemplatePrefixes = liveWrite.createdTemplatePrefixes || autoRerun.templateIdPrefixes || {};
|
||||
const submittedApprovalPrefixes = liveWrite.submittedApprovalPrefixes || autoRerun.spNoPrefixes || {};
|
||||
const targets = targetKeys().map((target) => ({
|
||||
target,
|
||||
label: targetLabel(target),
|
||||
templateIdPrefix: maskEvidencePrefix(createdTemplatePrefixes[target]),
|
||||
spNoPrefix: maskEvidencePrefix(submittedApprovalPrefixes[target]),
|
||||
templateAvailable: Boolean(createdTemplatePrefixes[target]),
|
||||
approvalAvailable: Boolean(submittedApprovalPrefixes[target]),
|
||||
}));
|
||||
const ok = readResult.ok
|
||||
&& report.status === "PASS_FIXTURE_CREATED"
|
||||
&& autoRerun.status === "OK"
|
||||
&& targets.every((item) => item.templateAvailable && item.approvalAvailable);
|
||||
return {
|
||||
id: "wapc2flive_fixture_evidence",
|
||||
status: ok ? "OK" : (readResult.ok ? "PARTIAL" : "MISSING"),
|
||||
reportPath: sanitizePath(readResult.path),
|
||||
sourceStatus: report.status || null,
|
||||
sourceConclusion: report.conclusion || null,
|
||||
autoRerunStatus: autoRerun.status || null,
|
||||
targets,
|
||||
reason: readResult.ok ? null : readResult.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCallbackEvidence(readResult) {
|
||||
const report = readResult.data || {};
|
||||
const eventEvidence = report.checks?.eventEvidence || {};
|
||||
const ok = readResult.ok && report.status === "PASS_CALLBACK_EVENT_CONFIRMED";
|
||||
return {
|
||||
id: "wcbk1_callback_evidence",
|
||||
status: ok ? "OK" : (readResult.ok ? "PARTIAL" : "MISSING"),
|
||||
reportPath: sanitizePath(readResult.path),
|
||||
sourceStatus: report.status || null,
|
||||
sourceConclusion: report.conclusion || null,
|
||||
eventCount: Number(eventEvidence.eventCount || 0),
|
||||
lastEvent: eventEvidence.lastEvent ? {
|
||||
approvalType: eventEvidence.lastEvent.approvalType || null,
|
||||
statusLabel: eventEvidence.lastEvent.statusLabel || null,
|
||||
spNoPrefix: eventEvidence.lastEvent.spNoPrefix || null,
|
||||
templateIdPrefix: eventEvidence.lastEvent.templateIdPrefix || null,
|
||||
recordedAt: eventEvidence.lastEvent.recordedAt || null,
|
||||
} : null,
|
||||
reason: readResult.ok ? null : readResult.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCompensationEvidence(readResult) {
|
||||
const report = readResult.data || {};
|
||||
const targetChecks = Array.isArray(report.checks?.targets) ? report.checks.targets : [];
|
||||
const targets = targetKeys().map((target) => {
|
||||
const check = targetChecks.find((item) => item.target === target || item.key === target) || {};
|
||||
const details = Array.isArray(check.details) ? check.details : [];
|
||||
return {
|
||||
target,
|
||||
label: targetLabel(target),
|
||||
status: check.status || null,
|
||||
detailCount: Number(check.detailCount || details.length || 0),
|
||||
statusSummary: check.statusSummary || null,
|
||||
detailSpNoPrefixes: details.map((item) => item.spNoPrefix).filter(Boolean).slice(0, 5),
|
||||
};
|
||||
});
|
||||
const ok = readResult.ok
|
||||
&& ["PASS_COMPENSATION_POC", "PASS_WITH_FINDINGS"].includes(report.status)
|
||||
&& targets.every((item) => item.status === "OK" && item.detailCount > 0);
|
||||
return {
|
||||
id: "wacp3r_compensation_evidence",
|
||||
status: ok ? "OK" : (readResult.ok ? "PARTIAL" : "MISSING"),
|
||||
reportPath: sanitizePath(readResult.path),
|
||||
sourceStatus: report.status || null,
|
||||
sourceConclusion: report.conclusion || null,
|
||||
templateDiscoveryStatus: report.checks?.templateDiscovery?.status || null,
|
||||
findings: (report.checks?.callbackCompare?.findings || []).map((item) => ({
|
||||
id: item.id,
|
||||
target: item.target || null,
|
||||
spNoPrefix: item.spNoPrefix || null,
|
||||
})),
|
||||
targets,
|
||||
reason: readResult.ok ? null : readResult.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function buildManualChecklist() {
|
||||
return targetKeys().map((target) => ({
|
||||
target,
|
||||
label: targetLabel(target),
|
||||
questions: [
|
||||
"企微消息卡片是否能看出申请类型、申请人、时间和状态?",
|
||||
"企微审批列表是否能区分请假/补卡?",
|
||||
"详情页字段是否足够审批人判断?",
|
||||
"快捷同意/驳回是否容易误点?",
|
||||
"是否需要模板字段调整或回退 OA 主办?",
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function sanitizeManualNote(value) {
|
||||
return safeMessage(value)
|
||||
.replace(/SP[A-Za-z0-9_-]{8,}/g, "SP***")
|
||||
.replace(/[A-Za-z0-9_-]{16,}/g, "***");
|
||||
}
|
||||
|
||||
function buildManualResults(options) {
|
||||
return targetKeys().map((target) => ({
|
||||
target,
|
||||
label: targetLabel(target),
|
||||
status: options.manualResults[target] || "PENDING_MANUAL_CONFIRMATION",
|
||||
note: options.manualNotes[target] ? sanitizeManualNote(options.manualNotes[target]) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function evaluateConclusion({ evidence, manualResults }) {
|
||||
const sampleEvidenceOk = evidence.compensation.status === "OK";
|
||||
if (!sampleEvidenceOk) {
|
||||
return {
|
||||
status: "PARTIAL_READABILITY_NEEDS_FRESH_FIXTURE",
|
||||
conclusion: "PARTIAL_READABILITY_NEEDS_FRESH_FIXTURE",
|
||||
};
|
||||
}
|
||||
if (manualResults.some((item) => item.status === "PENDING_MANUAL_CONFIRMATION")) {
|
||||
return {
|
||||
status: "PAUSED_MANUAL_CONFIRMATION",
|
||||
conclusion: "PAUSED_MANUAL_CONFIRMATION",
|
||||
};
|
||||
}
|
||||
if (manualResults.some((item) => item.status === "FAIL_KEEP_OA_PRIMARY_FOR_THIS_TEMPLATE")) {
|
||||
return {
|
||||
status: "FAIL_KEEP_OA_PRIMARY_FOR_THIS_TEMPLATE",
|
||||
conclusion: "FAIL_KEEP_OA_PRIMARY_FOR_THIS_TEMPLATE",
|
||||
};
|
||||
}
|
||||
if (manualResults.some((item) => item.status === "PASS_WITH_TEMPLATE_ADJUSTMENTS")) {
|
||||
return {
|
||||
status: "PASS_WITH_TEMPLATE_ADJUSTMENTS",
|
||||
conclusion: "PASS_WITH_TEMPLATE_ADJUSTMENTS",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "PASS_READABLE",
|
||||
conclusion: "PASS_READABLE",
|
||||
};
|
||||
}
|
||||
|
||||
async function executeReadabilityGate(options, deps = {}) {
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const reports = loadEvidenceReports(options, deps);
|
||||
const evidence = {
|
||||
fixtures: summarizeFixtureEvidence(reports.fixtures),
|
||||
callback: summarizeCallbackEvidence(reports.callback),
|
||||
compensation: summarizeCompensationEvidence(reports.compensation),
|
||||
};
|
||||
const manualResults = buildManualResults(options);
|
||||
const conclusion = evaluateConclusion({ evidence, manualResults });
|
||||
const sampleEvidenceOk = evidence.compensation.status === "OK";
|
||||
|
||||
return {
|
||||
mode: "qa_only",
|
||||
generatedAt,
|
||||
status: conclusion.status,
|
||||
conclusion: conclusion.conclusion,
|
||||
privacy: REPORT_PRIVACY,
|
||||
scope: {
|
||||
issue: "WREAD1",
|
||||
targets: targetKeys(),
|
||||
samplePolicy: "reuse existing OA-POC leave/missed_punch samples first; do not create new WeCom templates or approvals",
|
||||
writeBehavior: "read-only evidence summary only; no OA writes, no WeCom writes, no callback setup, no mirror import",
|
||||
deepMirror: "not_started",
|
||||
nextStage: conclusion.status === "PASS_READABLE" || conclusion.status === "PASS_WITH_TEMPLATE_ADJUSTMENTS"
|
||||
? "WADM1"
|
||||
: "manual_confirmation_or_fresh_fixture_decision",
|
||||
},
|
||||
checks: {
|
||||
evidence,
|
||||
sampleEvidence: {
|
||||
status: sampleEvidenceOk ? "OK" : "PARTIAL",
|
||||
reason: sampleEvidenceOk
|
||||
? "wacp3r_compensation_detail_evidence_available"
|
||||
: "existing_oa_poc_compensation_detail_evidence_not_complete",
|
||||
fixtureEvidenceStatus: evidence.fixtures.status,
|
||||
callbackEvidenceStatus: evidence.callback.status,
|
||||
},
|
||||
manualChecklist: buildManualChecklist(),
|
||||
manualResults,
|
||||
},
|
||||
confirmedBy: options.confirmedBy || null,
|
||||
configGaps: sampleEvidenceOk ? [] : [{
|
||||
id: "readability_existing_sample",
|
||||
status: "NEEDS_CONFIG",
|
||||
reason: "existing_compensation_detail_evidence_not_complete_or_not_found",
|
||||
}],
|
||||
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-readability-gate] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||
process.stdout.write(`[wecom-approval-readability-gate] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-approval-readability-gate] privacy=${report.privacy}\n`);
|
||||
process.stdout.write(`[wecom-approval-readability-gate] targets=${report.scope.targets.join(",")}\n`);
|
||||
if (report.configGaps.length) {
|
||||
process.stdout.write(`[wecom-approval-readability-gate] configGaps=${JSON.stringify(report.configGaps)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
const report = await executeReadabilityGate(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-readability-gate][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildManualChecklist,
|
||||
buildManualResults,
|
||||
evaluateConclusion,
|
||||
executeReadabilityGate,
|
||||
findLatestSummary,
|
||||
parseArgs,
|
||||
summarizeCompensationEvidence,
|
||||
summarizeFixtureEvidence,
|
||||
writeReport,
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
executeReadabilityGate,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/check-wecom-approval-readability-gate");
|
||||
|
||||
const SENSITIVE_TEMPLATE_ID = "tpl_readability_full_identifier_must_not_leak";
|
||||
const SENSITIVE_SP_NO = "SPREADABILITYFULLIDENTIFIERMUSTNOTLEAK";
|
||||
const SENSITIVE_USER_ID = "readability_userid_full_must_not_leak";
|
||||
const SENSITIVE_FORM_VALUE = "这里是表单值,不应出现在报告里";
|
||||
|
||||
function tempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "wread1-test-"));
|
||||
}
|
||||
|
||||
function writeSummary(root, dirname, payload) {
|
||||
const dir = path.join(root, dirname);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, "summary.json");
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function writeOkEvidence(root) {
|
||||
writeSummary(root, "wecom-approval-fixtures-20260519T053400Z", {
|
||||
status: "PASS_FIXTURE_CREATED",
|
||||
conclusion: "PASS_FIXTURE_CREATED",
|
||||
checks: {
|
||||
liveWrite: {
|
||||
createdTemplatePrefixes: {
|
||||
leave: "tpl-leav***",
|
||||
missed_punch: "tpl-miss***",
|
||||
},
|
||||
submittedApprovalPrefixes: {
|
||||
leave: "SPLEAVE***",
|
||||
missed_punch: "SPMISS***",
|
||||
},
|
||||
},
|
||||
autoRerun: {
|
||||
status: "OK",
|
||||
},
|
||||
},
|
||||
rawShouldNotCopy: SENSITIVE_TEMPLATE_ID,
|
||||
});
|
||||
writeSummary(root, "wecom-approval-callback-poc-20260519T092648Z", {
|
||||
status: "PASS_CALLBACK_EVENT_CONFIRMED",
|
||||
conclusion: "PASS_CALLBACK_EVENT_CONFIRMED",
|
||||
checks: {
|
||||
eventEvidence: {
|
||||
eventCount: 1,
|
||||
lastEvent: {
|
||||
approvalType: "missed_punch",
|
||||
statusLabel: "已通过",
|
||||
spNoPrefix: "SPMISS***",
|
||||
templateIdPrefix: "tpl-miss***",
|
||||
recordedAt: "2026-05-19T09:26:48.000Z",
|
||||
rawShouldNotCopy: SENSITIVE_SP_NO,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeSummary(root, "wecom-approval-compensation-poc-20260519T115944Z", {
|
||||
status: "PASS_WITH_FINDINGS",
|
||||
conclusion: "PASS_WITH_FINDINGS",
|
||||
checks: {
|
||||
templateDiscovery: { status: "OK", discovered: { leave: SENSITIVE_TEMPLATE_ID } },
|
||||
targets: [
|
||||
{
|
||||
target: "leave",
|
||||
status: "OK",
|
||||
detailCount: 1,
|
||||
statusSummary: { "审批中": 1 },
|
||||
details: [{ spNoPrefix: "SPLEAVE***", rawShouldNotCopy: SENSITIVE_FORM_VALUE }],
|
||||
},
|
||||
{
|
||||
target: "missed_punch",
|
||||
status: "OK",
|
||||
detailCount: 1,
|
||||
statusSummary: { "已通过": 1 },
|
||||
details: [{ spNoPrefix: "SPMISS***" }],
|
||||
},
|
||||
],
|
||||
callbackCompare: {
|
||||
findings: [
|
||||
{ id: "callback_missing_for_pulled_detail", target: "leave", spNoPrefix: "SPLEAVE***" },
|
||||
],
|
||||
},
|
||||
},
|
||||
rawUserId: SENSITIVE_USER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
describe("check-wecom-approval-readability-gate", () => {
|
||||
it("parses manual results and report options", () => {
|
||||
const options = parseArgs([
|
||||
"--format=json",
|
||||
"--artifact-root=/tmp/wread1-artifacts",
|
||||
"--manual-result=leave:PASS_READABLE",
|
||||
"--manual-result=missed_punch:PASS_WITH_TEMPLATE_ADJUSTMENTS",
|
||||
"--manual-note=leave:卡片可读",
|
||||
"--confirmed-by=wangshunli",
|
||||
]);
|
||||
|
||||
expect(options.format).toBe("json");
|
||||
expect(options.artifactRoot).toBe("/tmp/wread1-artifacts");
|
||||
expect(options.manualResults).toEqual({
|
||||
leave: "PASS_READABLE",
|
||||
missed_punch: "PASS_WITH_TEMPLATE_ADJUSTMENTS",
|
||||
});
|
||||
expect(options.manualNotes.leave).toBe("卡片可读");
|
||||
expect(options.confirmedBy).toBe("wangshunli");
|
||||
expect(() => parseArgs(["--manual-result=leave:PASS"])).toThrow(/Unsupported manual result/);
|
||||
});
|
||||
|
||||
it("pauses for manual confirmation when existing evidence is available", async () => {
|
||||
const root = tempDir();
|
||||
writeOkEvidence(root);
|
||||
|
||||
const report = await executeReadabilityGate({
|
||||
...parseArgs(["--artifact-root", root]),
|
||||
reportDir: path.join(root, "out"),
|
||||
}, { generatedAt: "2026-05-20T00:00:00.000Z" });
|
||||
|
||||
expect(report.status).toBe("PAUSED_MANUAL_CONFIRMATION");
|
||||
expect(report.checks.sampleEvidence.status).toBe("OK");
|
||||
expect(report.checks.evidence.fixtures.status).toBe("OK");
|
||||
expect(report.checks.evidence.compensation.status).toBe("OK");
|
||||
expect(report.checks.manualResults.map((item) => item.status)).toEqual([
|
||||
"PENDING_MANUAL_CONFIRMATION",
|
||||
"PENDING_MANUAL_CONFIRMATION",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts WACP3R compensation detail evidence when historical fixture artifacts are not persisted", async () => {
|
||||
const root = tempDir();
|
||||
writeSummary(root, "wecom-approval-compensation-poc-20260519T115944Z", {
|
||||
status: "PASS_WITH_FINDINGS",
|
||||
conclusion: "PASS_WITH_FINDINGS",
|
||||
checks: {
|
||||
templateDiscovery: { status: "OK" },
|
||||
targets: [
|
||||
{
|
||||
target: "leave",
|
||||
status: "OK",
|
||||
details: [{ spNoPrefix: "SPLEAVE***" }],
|
||||
},
|
||||
{
|
||||
target: "missed_punch",
|
||||
status: "OK",
|
||||
details: [{ spNoPrefix: "SPMISS***" }],
|
||||
},
|
||||
],
|
||||
callbackCompare: { findings: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const report = await executeReadabilityGate(parseArgs(["--artifact-root", root]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PAUSED_MANUAL_CONFIRMATION");
|
||||
expect(report.checks.sampleEvidence.status).toBe("OK");
|
||||
expect(report.checks.sampleEvidence.reason).toBe("wacp3r_compensation_detail_evidence_available");
|
||||
expect(report.checks.evidence.fixtures.status).toBe("MISSING");
|
||||
});
|
||||
|
||||
it("returns PASS_WITH_TEMPLATE_ADJUSTMENTS when one template needs changes", async () => {
|
||||
const root = tempDir();
|
||||
writeOkEvidence(root);
|
||||
|
||||
const report = await executeReadabilityGate(parseArgs([
|
||||
"--artifact-root", root,
|
||||
"--manual-result=leave:PASS_READABLE",
|
||||
"--manual-result=missed_punch:PASS_WITH_TEMPLATE_ADJUSTMENTS",
|
||||
"--manual-note=missed_punch:快捷按钮容易误点,需要提醒审批人点详情",
|
||||
"--confirmed-by=wangshunli",
|
||||
]), { generatedAt: "2026-05-20T00:00:00.000Z" });
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_TEMPLATE_ADJUSTMENTS");
|
||||
expect(report.confirmedBy).toBe("wangshunli");
|
||||
expect(report.checks.manualResults.find((item) => item.target === "missed_punch").note).toContain("快捷按钮");
|
||||
});
|
||||
|
||||
it("asks for a fresh fixture when existing sample evidence is missing", async () => {
|
||||
const root = tempDir();
|
||||
writeSummary(root, "wecom-approval-compensation-poc-20260519T115944Z", {
|
||||
status: "PASS_WITH_FINDINGS",
|
||||
checks: { targets: [] },
|
||||
});
|
||||
|
||||
const report = await executeReadabilityGate(parseArgs(["--artifact-root", root]), {
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_READABILITY_NEEDS_FRESH_FIXTURE");
|
||||
expect(report.configGaps.map((item) => item.id)).toContain("readability_existing_sample");
|
||||
});
|
||||
|
||||
it("does not leak full template, sp_no, userid or form values", async () => {
|
||||
const root = tempDir();
|
||||
writeOkEvidence(root);
|
||||
|
||||
const report = await executeReadabilityGate(parseArgs([
|
||||
"--artifact-root", root,
|
||||
"--manual-result=leave:PASS_READABLE",
|
||||
"--manual-result=missed_punch:PASS_READABLE",
|
||||
"--manual-note=leave:不要泄露 SPREADABILITYFULLIDENTIFIERMUSTNOTLEAK",
|
||||
]), { generatedAt: "2026-05-20T00:00:00.000Z" });
|
||||
|
||||
const serialized = JSON.stringify(report);
|
||||
expect(report.status).toBe("PASS_READABLE");
|
||||
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_SP_NO);
|
||||
expect(serialized).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_FORM_VALUE);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户