WACP3 实现企微审批补偿拉取 POC
这个提交包含在:
@@ -31,6 +31,7 @@
|
||||
"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-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-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,741 @@
|
||||
#!/usr/bin/env node
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getConfig } = require("../config");
|
||||
const {
|
||||
APPROVAL_GET_INFO_ENDPOINT,
|
||||
APPROVAL_POC_TARGETS,
|
||||
fetchWecomApprovalDetail,
|
||||
fetchWecomApprovalInfoList,
|
||||
maskApprovalIdentifier,
|
||||
} = require("../services/integrations/wecomApproval");
|
||||
const { readCallbackEvidence } = require("../services/integrations/wecomCallbackCrypto");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_WINDOW_HOURS = 72;
|
||||
const DEFAULT_PAGE_SIZE = 100;
|
||||
const DEFAULT_MAX_PAGES = 10;
|
||||
|
||||
const REPORT_PRIVACY =
|
||||
"approval counts/status summaries/masked sp_no/template/userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload";
|
||||
|
||||
const TEMPLATE_ENV = Object.freeze({
|
||||
leave: "WECOM_APPROVAL_LEAVE_TEMPLATE_ID",
|
||||
missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID",
|
||||
});
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-compensation-poc.js [options]
|
||||
|
||||
QA-only 企业微信审批补偿拉取 POC。
|
||||
默认 dry-run 只检查配置和计划;--live 仅做只读 getapprovalinfo/getapprovaldetail,不写 OA/企微数据。
|
||||
|
||||
Options:
|
||||
--live 启用企微 live 只读拉取。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-compensation-poc-<timestamp>。
|
||||
--start-time=<iso|epoch> 窗口开始时间,默认 end-time 前 72 小时。
|
||||
--end-time=<iso|epoch> 窗口结束时间,默认当前时间。
|
||||
--template=leave:<id> 指定请假模板 ID,用于 template_id filter。
|
||||
--template=missed_punch:<id> 指定补卡模板 ID,用于 template_id filter。
|
||||
--size=<1..100> 单页审批单号数量,默认 100。
|
||||
--help 显示帮助。
|
||||
|
||||
Secret 固定规则:
|
||||
审批单号批量查询和审批详情读取:WECOM_AGENT_SECRET。
|
||||
不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。
|
||||
|
||||
隐私边界:
|
||||
报告只输出审批类型、计数、状态摘要、脱敏 sp_no/template/userid 前缀和错误分类。
|
||||
不输出手机号、邮箱、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 supportedTargetKeys() {
|
||||
return APPROVAL_POC_TARGETS.map((item) => item.key);
|
||||
}
|
||||
|
||||
function parseTargetPair(value, flagName) {
|
||||
const raw = toSafeString(value, 500);
|
||||
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 (!supportedTargetKeys().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) {
|
||||
const options = {
|
||||
live: false,
|
||||
format: "text",
|
||||
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-compensation-poc-${timestamp()}`),
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
templates: {},
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
maxPages: DEFAULT_MAX_PAGES,
|
||||
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 === "--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 === "--start-time") {
|
||||
options.startTime = argv[index + 1] || "";
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--start-time=")) {
|
||||
options.startTime = arg.slice("--start-time=".length);
|
||||
} else if (arg === "--end-time") {
|
||||
options.endTime = argv[index + 1] || "";
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--end-time=")) {
|
||||
options.endTime = arg.slice("--end-time=".length);
|
||||
} else if (arg === "--template") {
|
||||
const pair = parseTargetPair(argv[index + 1], "--template");
|
||||
options.templates[pair.target] = pair.value;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--template=")) {
|
||||
const pair = parseTargetPair(arg.slice("--template=".length), "--template");
|
||||
options.templates[pair.target] = pair.value;
|
||||
} else if (arg === "--size") {
|
||||
options.size = Number(argv[index + 1] || options.size);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--size=")) {
|
||||
options.size = Number(arg.slice("--size=".length));
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
if (!Number.isFinite(options.size) || options.size < 1 || options.size > 100) {
|
||||
throw new Error("--size must be between 1 and 100");
|
||||
}
|
||||
options.size = Math.floor(options.size);
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseTimeInput(value) {
|
||||
const raw = toSafeString(value, 80);
|
||||
if (!raw) return null;
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const numeric = Number(raw);
|
||||
return numeric > 100000000000 ? Math.floor(numeric / 1000) : numeric;
|
||||
}
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`Invalid time value: ${raw}`);
|
||||
}
|
||||
return Math.floor(parsed / 1000);
|
||||
}
|
||||
|
||||
function buildTimeWindow(options, generatedAt) {
|
||||
const generatedEnd = Math.floor(new Date(generatedAt).getTime() / 1000);
|
||||
const endTime = parseTimeInput(options.endTime) || generatedEnd;
|
||||
const startTime = parseTimeInput(options.startTime) || (endTime - DEFAULT_WINDOW_HOURS * 60 * 60);
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || startTime <= 0 || endTime <= 0 || startTime >= endTime) {
|
||||
throw new Error("Invalid approval compensation time window");
|
||||
}
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
startIso: new Date(startTime * 1000).toISOString(),
|
||||
endIso: new Date(endTime * 1000).toISOString(),
|
||||
defaultWindowHours: DEFAULT_WINDOW_HOURS,
|
||||
};
|
||||
}
|
||||
|
||||
function envValue(env, name) {
|
||||
return toSafeString(env[name], 500);
|
||||
}
|
||||
|
||||
function buildInputs(options, env = process.env) {
|
||||
return APPROVAL_POC_TARGETS.map((target) => {
|
||||
const cliTemplateId = toSafeString(options.templates[target.key], 500);
|
||||
const envTemplateId = envValue(env, TEMPLATE_ENV[target.key]);
|
||||
const templateId = cliTemplateId || envTemplateId;
|
||||
return {
|
||||
key: target.key,
|
||||
label: target.label,
|
||||
templateId,
|
||||
templateIdPrefix: maskApprovalIdentifier(templateId),
|
||||
templateSource: templateId ? (cliTemplateId ? "cli" : TEMPLATE_ENV[target.key]) : null,
|
||||
templateMissingEnv: templateId ? [] : [TEMPLATE_ENV[target.key]],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildSecretCheck(config) {
|
||||
const missing = [];
|
||||
if (!config.wecomCorpId) missing.push("WECOM_CORP_ID");
|
||||
if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET");
|
||||
return {
|
||||
id: "approval_compensation_secret_matrix",
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
endpoints: [
|
||||
`POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
"POST /cgi-bin/oa/getapprovaldetail",
|
||||
],
|
||||
status: missing.length ? "BLOCKED" : "OK",
|
||||
missing,
|
||||
noFallback: [
|
||||
"WECOM_CONTACT_SECRET",
|
||||
"WECOM_CHECKIN_SECRET",
|
||||
"WECOM_HR_ASSISTANT_SECRET",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeApiResponse(response) {
|
||||
if (!response || typeof response !== "object") return null;
|
||||
const summary = {};
|
||||
for (const key of [
|
||||
"errcode",
|
||||
"errmsg",
|
||||
"spNoCount",
|
||||
"spNoPrefixes",
|
||||
"nextCursorPresent",
|
||||
"spStatus",
|
||||
"applyFieldCount",
|
||||
"approvalNodeCount",
|
||||
"notifyerCount",
|
||||
"commentCount",
|
||||
]) {
|
||||
if (response[key] != null) {
|
||||
summary[key] = key === "errmsg" ? safeMessage(response[key]) : response[key];
|
||||
}
|
||||
}
|
||||
return Object.keys(summary).length ? summary : null;
|
||||
}
|
||||
|
||||
function statusLabelFromSpStatus(value) {
|
||||
const key = toSafeString(value, 20);
|
||||
return {
|
||||
"1": "审批中",
|
||||
"2": "已通过",
|
||||
"3": "已驳回",
|
||||
"4": "已撤销",
|
||||
"6": "通过后撤销",
|
||||
"7": "已删除",
|
||||
"10": "已支付",
|
||||
}[key] || (key ? `状态${key}` : null);
|
||||
}
|
||||
|
||||
function summarizeDetailResult(target, spNo, result) {
|
||||
const summary = result?.summary || {};
|
||||
const spStatus = summary.spStatus == null ? null : toSafeString(summary.spStatus, 40);
|
||||
return {
|
||||
target: target.key,
|
||||
label: target.label,
|
||||
status: result?.ok ? "OK" : "BLOCKED",
|
||||
reason: result?.ok ? null : result?.code || "wecom_approval_detail_failed",
|
||||
message: result?.message ? safeMessage(result.message) : null,
|
||||
source: result?.tokenSource || "WECOM_AGENT_SECRET",
|
||||
spNoPrefix: maskApprovalIdentifier(spNo),
|
||||
templateIdPrefix: summary.templateIdPrefix || target.templateIdPrefix,
|
||||
spStatus,
|
||||
statusLabel: statusLabelFromSpStatus(spStatus),
|
||||
applyFieldCount: summary.applyFieldCount,
|
||||
approvalNodeCount: summary.approvalNodeCount,
|
||||
notifyerCount: summary.notifyerCount,
|
||||
commentCount: summary.commentCount,
|
||||
response: summarizeApiResponse(result?.response),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMissingTemplateCheck(input) {
|
||||
return {
|
||||
id: "approval_compensation_pull",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
status: "NEEDS_CONFIG",
|
||||
missingEnv: input.templateMissingEnv,
|
||||
templateIdPrefix: null,
|
||||
pageCount: 0,
|
||||
spNoCount: 0,
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDryRunTargetCheck(input) {
|
||||
return {
|
||||
id: "approval_compensation_pull",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
status: "DRY_RUN_READY",
|
||||
templateIdPrefix: input.templateIdPrefix,
|
||||
configuredFrom: input.templateSource,
|
||||
pageCount: 0,
|
||||
spNoCount: 0,
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function runLiveTarget({
|
||||
config,
|
||||
input,
|
||||
timeWindow,
|
||||
options,
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
}) {
|
||||
const pages = [];
|
||||
const spNos = [];
|
||||
let cursor = "";
|
||||
let pageIndex = 0;
|
||||
let blocked = null;
|
||||
|
||||
do {
|
||||
pageIndex += 1;
|
||||
const result = await fetchApprovalInfoListImpl({
|
||||
config,
|
||||
startTime: timeWindow.startTime,
|
||||
endTime: timeWindow.endTime,
|
||||
size: options.size,
|
||||
nextCursor: cursor,
|
||||
filters: [{ key: "template_id", value: input.templateId }],
|
||||
});
|
||||
pages.push({
|
||||
page: pageIndex,
|
||||
status: result.ok ? "OK" : "BLOCKED",
|
||||
reason: result.ok ? null : result.code,
|
||||
message: result.message ? safeMessage(result.message) : null,
|
||||
source: result.tokenSource || "WECOM_AGENT_SECRET",
|
||||
response: summarizeApiResponse(result.response),
|
||||
spNoCount: result.spNos?.length || 0,
|
||||
spNoPrefixes: (result.spNos || []).map(maskApprovalIdentifier),
|
||||
nextCursorPresent: Boolean(result.nextCursor),
|
||||
});
|
||||
if (!result.ok) {
|
||||
blocked = result;
|
||||
break;
|
||||
}
|
||||
spNos.push(...(result.spNos || []));
|
||||
const nextCursor = toSafeString(result.nextCursor, 160);
|
||||
cursor = nextCursor && nextCursor !== cursor ? nextCursor : "";
|
||||
} while (cursor && pageIndex < options.maxPages);
|
||||
|
||||
if (blocked) {
|
||||
return {
|
||||
id: "approval_compensation_pull",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
source: blocked.tokenSource || "WECOM_AGENT_SECRET",
|
||||
status: "BLOCKED",
|
||||
reason: blocked.code,
|
||||
message: blocked.message ? safeMessage(blocked.message) : null,
|
||||
templateIdPrefix: input.templateIdPrefix,
|
||||
configuredFrom: input.templateSource,
|
||||
pageCount: pages.length,
|
||||
spNoCount: 0,
|
||||
pages,
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
const uniqueSpNos = Array.from(new Set(spNos));
|
||||
const details = [];
|
||||
for (const spNo of uniqueSpNos) {
|
||||
const detailResult = await fetchApprovalDetailImpl({ config, spNo });
|
||||
details.push(summarizeDetailResult(input, spNo, detailResult));
|
||||
}
|
||||
|
||||
const detailBlocked = details.find((item) => item.status === "BLOCKED");
|
||||
return {
|
||||
id: "approval_compensation_pull",
|
||||
target: input.key,
|
||||
label: input.label,
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
status: detailBlocked ? "BLOCKED" : "OK",
|
||||
reason: detailBlocked ? detailBlocked.reason : null,
|
||||
templateIdPrefix: input.templateIdPrefix,
|
||||
configuredFrom: input.templateSource,
|
||||
pageCount: pages.length,
|
||||
spNoCount: uniqueSpNos.length,
|
||||
pages,
|
||||
details,
|
||||
findings: cursor ? [{ id: "approval_compensation_pagination_truncated", status: "FINDING" }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function runTargetChecks({
|
||||
config,
|
||||
inputs,
|
||||
timeWindow,
|
||||
options,
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
}) {
|
||||
const targetChecks = [];
|
||||
for (const input of inputs) {
|
||||
if (!input.templateId) {
|
||||
targetChecks.push(buildMissingTemplateCheck(input));
|
||||
} else if (!options.live) {
|
||||
targetChecks.push(buildDryRunTargetCheck(input));
|
||||
} else {
|
||||
targetChecks.push(await runLiveTarget({
|
||||
config,
|
||||
input,
|
||||
timeWindow,
|
||||
options,
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return targetChecks;
|
||||
}
|
||||
|
||||
function sanitizeEvidenceEvent(event) {
|
||||
return {
|
||||
callbackKind: toSafeString(event.callbackKind, 80),
|
||||
approvalType: toSafeString(event.approvalType, 80),
|
||||
spStatus: event.spStatus == null ? null : toSafeString(event.spStatus, 40),
|
||||
statusLabel: toSafeString(event.statusLabel, 80) || null,
|
||||
spNoPrefix: toSafeString(event.spNoPrefix, 80) || null,
|
||||
templateIdPrefix: toSafeString(event.templateIdPrefix, 80) || null,
|
||||
recordedAt: toSafeString(event.recordedAt, 80) || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildEvidenceCheck({ config, readCallbackEvidenceImpl = readCallbackEvidence }) {
|
||||
const events = await readCallbackEvidenceImpl({
|
||||
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
|
||||
});
|
||||
const approvalEvents = (events || [])
|
||||
.filter((item) => item?.callbackKind === "approval_status_change")
|
||||
.map(sanitizeEvidenceEvent);
|
||||
const bySpNoPrefix = {};
|
||||
for (const event of approvalEvents) {
|
||||
if (!event.spNoPrefix) continue;
|
||||
bySpNoPrefix[event.spNoPrefix] = bySpNoPrefix[event.spNoPrefix] || [];
|
||||
bySpNoPrefix[event.spNoPrefix].push(event);
|
||||
}
|
||||
return {
|
||||
id: "callback_event_evidence",
|
||||
status: approvalEvents.length ? "OK" : "PENDING",
|
||||
eventCount: approvalEvents.length,
|
||||
latestEvent: approvalEvents[approvalEvents.length - 1] || null,
|
||||
bySpNoPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
function collectPulledDetails(targetChecks) {
|
||||
return targetChecks.flatMap((target) => (
|
||||
Array.isArray(target.details)
|
||||
? target.details.filter((item) => item.status === "OK")
|
||||
: []
|
||||
));
|
||||
}
|
||||
|
||||
function compareCallbackEvidence(targetChecks, evidenceCheck) {
|
||||
const findings = [];
|
||||
const pulledDetails = collectPulledDetails(targetChecks);
|
||||
const evidenceBySpNo = evidenceCheck.bySpNoPrefix || {};
|
||||
const pulledPrefixes = new Set();
|
||||
|
||||
for (const detail of pulledDetails) {
|
||||
if (!detail.spNoPrefix) continue;
|
||||
pulledPrefixes.add(detail.spNoPrefix);
|
||||
const events = evidenceBySpNo[detail.spNoPrefix] || [];
|
||||
if (!events.length) {
|
||||
findings.push({
|
||||
id: "callback_missing_for_pulled_detail",
|
||||
target: detail.target,
|
||||
spNoPrefix: detail.spNoPrefix,
|
||||
status: "FINDING",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (events.length > 1) {
|
||||
findings.push({
|
||||
id: "callback_duplicate_for_pulled_detail",
|
||||
target: detail.target,
|
||||
spNoPrefix: detail.spNoPrefix,
|
||||
count: events.length,
|
||||
status: "FINDING",
|
||||
});
|
||||
}
|
||||
const hasStatusMatch = events.some((event) => (
|
||||
(event.spStatus && detail.spStatus && event.spStatus === detail.spStatus)
|
||||
|| (event.statusLabel && detail.statusLabel && event.statusLabel === detail.statusLabel)
|
||||
));
|
||||
if (!hasStatusMatch) {
|
||||
findings.push({
|
||||
id: "callback_status_mismatch",
|
||||
target: detail.target,
|
||||
spNoPrefix: detail.spNoPrefix,
|
||||
pulledStatus: detail.statusLabel || detail.spStatus,
|
||||
callbackStatus: events[0]?.statusLabel || events[0]?.spStatus || null,
|
||||
status: "FINDING",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [spNoPrefix, events] of Object.entries(evidenceBySpNo)) {
|
||||
if (!pulledPrefixes.has(spNoPrefix)) {
|
||||
findings.push({
|
||||
id: "callback_not_in_compensation_window",
|
||||
spNoPrefix,
|
||||
count: events.length,
|
||||
status: "FINDING",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: "callback_compensation_compare",
|
||||
status: findings.length ? "FINDINGS" : "OK",
|
||||
pulledDetailCount: pulledDetails.length,
|
||||
callbackEventCount: evidenceCheck.eventCount,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
function collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck) {
|
||||
const gaps = [];
|
||||
for (const check of targetChecks) {
|
||||
if (check.status === "NEEDS_CONFIG") {
|
||||
gaps.push({
|
||||
id: check.id,
|
||||
target: check.target,
|
||||
status: "NEEDS_CONFIG",
|
||||
missingEnv: check.missingEnv || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const gap of verificationGaps) {
|
||||
gaps.push(gap);
|
||||
}
|
||||
const pulledCount = compareCheck?.pulledDetailCount || 0;
|
||||
const hasTemplateConfigGap = targetChecks.some((item) => item.status === "NEEDS_CONFIG");
|
||||
if (pulledCount === 0
|
||||
&& !verificationGaps.length
|
||||
&& !hasTemplateConfigGap
|
||||
&& !targetChecks.some((item) => item.status === "BLOCKED")) {
|
||||
gaps.push({
|
||||
id: "approval_compensation_window_empty",
|
||||
status: "NEEDS_CONFIG",
|
||||
reason: "no_existing_oa_poc_approval_in_window",
|
||||
});
|
||||
}
|
||||
if (!verificationGaps.length && pulledCount > 0 && evidenceCheck.status !== "OK") {
|
||||
gaps.push({
|
||||
id: "callback_evidence_missing",
|
||||
status: "NEEDS_CONFIG",
|
||||
reason: "no_wcbk1_callback_evidence_available_for_compare",
|
||||
});
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function collectMainBlockers(secretCheck, targetChecks) {
|
||||
const blockers = [];
|
||||
if (secretCheck.status === "BLOCKED") {
|
||||
blockers.push({
|
||||
id: secretCheck.id,
|
||||
reason: "missing_required_wecom_approval_config",
|
||||
missing: secretCheck.missing,
|
||||
});
|
||||
}
|
||||
for (const check of targetChecks) {
|
||||
if (check.status === "BLOCKED") {
|
||||
blockers.push({
|
||||
id: check.id,
|
||||
target: check.target,
|
||||
reason: check.reason || "wecom_approval_compensation_api_blocked",
|
||||
});
|
||||
}
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function evaluateConclusion({ mainBlockers, configGaps, compareCheck }) {
|
||||
if (mainBlockers.length) {
|
||||
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
|
||||
}
|
||||
if (configGaps.length) {
|
||||
return {
|
||||
status: "PARTIAL_COMPENSATION_NEEDS_CONFIG",
|
||||
conclusion: "PARTIAL_COMPENSATION_NEEDS_CONFIG",
|
||||
};
|
||||
}
|
||||
if (compareCheck?.findings?.length) {
|
||||
return { status: "PASS_WITH_FINDINGS", conclusion: "PASS_WITH_FINDINGS" };
|
||||
}
|
||||
return { status: "PASS_COMPENSATION_POC", conclusion: "PASS_COMPENSATION_POC" };
|
||||
}
|
||||
|
||||
async function executeCompensationPoc(options, deps = {}) {
|
||||
const env = deps.env || process.env;
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const config = deps.config || getConfig();
|
||||
const timeWindow = buildTimeWindow(options, generatedAt);
|
||||
const inputs = buildInputs(options, env);
|
||||
const secretCheck = buildSecretCheck(config);
|
||||
const targetChecks = await runTargetChecks({
|
||||
config,
|
||||
inputs,
|
||||
timeWindow,
|
||||
options,
|
||||
fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList,
|
||||
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
|
||||
});
|
||||
const evidenceCheck = await buildEvidenceCheck({
|
||||
config,
|
||||
readCallbackEvidenceImpl: deps.readCallbackEvidenceImpl,
|
||||
});
|
||||
const compareCheck = compareCallbackEvidence(targetChecks, evidenceCheck);
|
||||
const verificationGaps = options.live ? [] : [{
|
||||
id: "live_read",
|
||||
status: "DRY_RUN",
|
||||
reason: "run_with_--live_for_public_readonly_gate",
|
||||
}];
|
||||
const mainBlockers = collectMainBlockers(secretCheck, targetChecks);
|
||||
const configGaps = collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck);
|
||||
const conclusion = evaluateConclusion({ mainBlockers, configGaps, compareCheck });
|
||||
|
||||
return {
|
||||
mode: "qa_only",
|
||||
generatedAt,
|
||||
status: conclusion.status,
|
||||
conclusion: conclusion.conclusion,
|
||||
privacy: REPORT_PRIVACY,
|
||||
scope: {
|
||||
issue: "WACP3",
|
||||
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
|
||||
writeBehavior: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission",
|
||||
liveRead: Boolean(options.live),
|
||||
deepMirror: "not_started",
|
||||
nextStage: "WREAD1",
|
||||
},
|
||||
timeWindow,
|
||||
inputs: {
|
||||
templates: Object.fromEntries(inputs.map((item) => [item.key, {
|
||||
configured: Boolean(item.templateId),
|
||||
configuredFrom: item.templateSource,
|
||||
templateIdPrefix: item.templateIdPrefix,
|
||||
}])),
|
||||
pageSize: options.size,
|
||||
maxPages: options.maxPages,
|
||||
},
|
||||
checks: {
|
||||
secretMatrix: secretCheck,
|
||||
targets: targetChecks,
|
||||
callbackEvidence: {
|
||||
...evidenceCheck,
|
||||
bySpNoPrefix: undefined,
|
||||
},
|
||||
callbackCompare: compareCheck,
|
||||
},
|
||||
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-compensation-poc] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||
process.stdout.write(`[wecom-approval-compensation-poc] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-approval-compensation-poc] privacy=${report.privacy}\n`);
|
||||
process.stdout.write(`[wecom-approval-compensation-poc] liveRead=${report.scope.liveRead} targets=${report.scope.targets.join(",")}\n`);
|
||||
if (report.configGaps?.length) {
|
||||
process.stdout.write(`[wecom-approval-compensation-poc] configGaps=${JSON.stringify(report.configGaps)}\n`);
|
||||
}
|
||||
if (report.mainBlockers?.length) {
|
||||
process.stdout.write(`[wecom-approval-compensation-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const report = await executeCompensationPoc(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-compensation-poc][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInputs,
|
||||
buildSecretCheck,
|
||||
buildTimeWindow,
|
||||
compareCallbackEvidence,
|
||||
evaluateConclusion,
|
||||
executeCompensationPoc,
|
||||
parseArgs,
|
||||
statusLabelFromSpStatus,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
|
||||
|
||||
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
|
||||
const APPROVAL_GET_INFO_ENDPOINT = "/cgi-bin/oa/getapprovalinfo";
|
||||
const APPROVAL_CREATE_TEMPLATE_ENDPOINT = "/cgi-bin/oa/approval/create_template";
|
||||
const APPROVAL_APPLY_EVENT_ENDPOINT = "/cgi-bin/oa/applyevent";
|
||||
|
||||
@@ -106,6 +107,32 @@ function summarizeApprovalPayload(payload) {
|
||||
};
|
||||
}
|
||||
|
||||
function extractApprovalInfoSpNos(payload) {
|
||||
if (!payload || typeof payload !== "object") return [];
|
||||
const candidates = [
|
||||
payload.sp_no_list,
|
||||
payload.spNoList,
|
||||
payload.info?.sp_no_list,
|
||||
];
|
||||
const list = candidates.find((item) => Array.isArray(item)) || [];
|
||||
return list.map((item) => toSafeString(item, 160)).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractApprovalInfoNextCursor(payload) {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
return toSafeString(payload.next_cursor ?? payload.nextCursor ?? payload.info?.next_cursor, 160);
|
||||
}
|
||||
|
||||
function summarizeApprovalInfoPayload(payload) {
|
||||
const spNos = extractApprovalInfoSpNos(payload);
|
||||
const nextCursor = extractApprovalInfoNextCursor(payload);
|
||||
return {
|
||||
spNoCount: spNos.length,
|
||||
spNoPrefixes: spNos.map(maskApprovalIdentifier),
|
||||
nextCursorPresent: Boolean(nextCursor),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeApprovalApiResponse(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = sanitizeWecomResponse(payload) ?? {};
|
||||
@@ -128,6 +155,12 @@ function sanitizeApprovalApiResponse(payload) {
|
||||
result.notifyerCount = summary.notifyerCount;
|
||||
result.commentCount = summary.commentCount;
|
||||
}
|
||||
if (Array.isArray(payload.sp_no_list) || Array.isArray(payload.spNoList)) {
|
||||
const summary = summarizeApprovalInfoPayload(payload);
|
||||
result.spNoCount = summary.spNoCount;
|
||||
result.spNoPrefixes = summary.spNoPrefixes;
|
||||
result.nextCursorPresent = summary.nextCursorPresent;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
@@ -341,6 +374,148 @@ async function fetchWecomApprovalDetail({
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApprovalInfoFilter(filter) {
|
||||
if (!filter || typeof filter !== "object") return null;
|
||||
const key = toSafeString(filter.key, 80);
|
||||
const value = toSafeString(filter.value, 200);
|
||||
if (!key || !value) return null;
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
function normalizeApprovalInfoRequest({
|
||||
startTime,
|
||||
endTime,
|
||||
size,
|
||||
nextCursor,
|
||||
filters,
|
||||
}) {
|
||||
const normalizedStart = Math.floor(Number(startTime));
|
||||
const normalizedEnd = Math.floor(Number(endTime));
|
||||
const normalizedSize = Math.max(1, Math.min(100, Math.floor(Number(size || 100))));
|
||||
|
||||
const body = {
|
||||
starttime: normalizedStart,
|
||||
endtime: normalizedEnd,
|
||||
size: normalizedSize,
|
||||
};
|
||||
const cursor = toSafeString(nextCursor, 160);
|
||||
if (cursor) body.next_cursor = cursor;
|
||||
|
||||
const normalizedFilters = Array.isArray(filters)
|
||||
? filters.map(normalizeApprovalInfoFilter).filter(Boolean)
|
||||
: [];
|
||||
if (normalizedFilters.length) {
|
||||
body.filters = normalizedFilters;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalInfoList({
|
||||
config,
|
||||
startTime,
|
||||
endTime,
|
||||
size = 100,
|
||||
nextCursor = "",
|
||||
filters = [],
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
|
||||
}) {
|
||||
const normalizedStart = Math.floor(Number(startTime));
|
||||
const normalizedEnd = Math.floor(Number(endTime));
|
||||
if (!Number.isFinite(normalizedStart) || !Number.isFinite(normalizedEnd) || normalizedStart <= 0 || normalizedEnd <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "approval_compensation_window_invalid",
|
||||
message: "Missing or invalid approval compensation time window",
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
response: null,
|
||||
spNos: [],
|
||||
nextCursor: "",
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
if (normalizedStart >= normalizedEnd) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "approval_compensation_window_invalid",
|
||||
message: "Approval compensation startTime must be before endTime",
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
response: null,
|
||||
spNos: [],
|
||||
nextCursor: "",
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
|
||||
const requestBody = normalizeApprovalInfoRequest({
|
||||
startTime: normalizedStart,
|
||||
endTime: normalizedEnd,
|
||||
size,
|
||||
nextCursor,
|
||||
filters,
|
||||
});
|
||||
const result = await fetchWecomApprovalJson(APPROVAL_GET_INFO_ENDPOINT, token.accessToken, requestBody, {
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const error = classifyWecomApprovalResult(result, "wecom_approval_info_failed");
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
tokenSource: token.tokenSource,
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
response: error.response,
|
||||
spNos: [],
|
||||
nextCursor: "",
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
const spNos = extractApprovalInfoSpNos(result.payload);
|
||||
const nextCursorValue = extractApprovalInfoNextCursor(result.payload);
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_approval_info_fetched",
|
||||
message: null,
|
||||
tokenSource: token.tokenSource,
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
spNos,
|
||||
nextCursor: nextCursorValue,
|
||||
summary: summarizeApprovalInfoPayload(result.payload),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: Boolean(error?.details?.retryable),
|
||||
code: error?.code || "wecom_approval_info_failed",
|
||||
message: sanitizeErrorMessage(error?.message || error),
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
|
||||
response: error?.details?.response || null,
|
||||
spNos: [],
|
||||
nextCursor: "",
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function extractTemplateId(payload) {
|
||||
return toSafeString(payload?.template_id ?? payload?.templateId, 160);
|
||||
}
|
||||
@@ -473,13 +648,16 @@ module.exports = {
|
||||
APPROVAL_POC_TARGETS,
|
||||
APPROVAL_APPLY_EVENT_ENDPOINT,
|
||||
APPROVAL_CREATE_TEMPLATE_ENDPOINT,
|
||||
APPROVAL_GET_INFO_ENDPOINT,
|
||||
WecomApprovalPocError,
|
||||
fetchWecomApprovalApplyEvent,
|
||||
fetchWecomApprovalDetail,
|
||||
fetchWecomApprovalInfoList,
|
||||
fetchWecomApprovalTemplateCreate,
|
||||
fetchWecomApprovalTemplateDetail,
|
||||
maskApprovalIdentifier,
|
||||
sanitizeApprovalApiResponse,
|
||||
summarizeApprovalInfoPayload,
|
||||
summarizeApprovalPayload,
|
||||
summarizeTemplatePayload,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
const {
|
||||
buildInputs,
|
||||
buildSecretCheck,
|
||||
buildTimeWindow,
|
||||
compareCallbackEvidence,
|
||||
executeCompensationPoc,
|
||||
parseArgs,
|
||||
statusLabelFromSpStatus,
|
||||
} = require("../../../src/scripts/check-wecom-approval-compensation-poc");
|
||||
const {
|
||||
fetchWecomApprovalInfoList,
|
||||
maskApprovalIdentifier,
|
||||
} = require("../../../src/services/integrations/wecomApproval");
|
||||
const { clearWecomAppAccessTokenCache } = require("../../../src/services/integrations/wecomAccessToken");
|
||||
|
||||
const SENSITIVE_AGENT_CREDENTIAL = ["compensation", "agent", "credential", "should", "never", "leak"].join("-");
|
||||
const SENSITIVE_CONTACT_CREDENTIAL = ["compensation", "contact", "credential", "unused"].join("-");
|
||||
const SENSITIVE_CHECKIN_CREDENTIAL = ["compensation", "checkin", "credential", "unused"].join("-");
|
||||
const SENSITIVE_HR_CREDENTIAL = ["compensation", "hr", "credential", "unused"].join("-");
|
||||
const SENSITIVE_TEMPLATE_ID = ["tpl", "compensation", "full", "identifier", "must", "not", "leak"].join("-");
|
||||
const SENSITIVE_SP_NO = ["sp", "compensation", "full", "identifier", "must", "not", "leak"].join("-");
|
||||
const SENSITIVE_USER_ID = ["compensation", "userid", "full", "must", "not", "leak"].join("_");
|
||||
const SENSITIVE_FORM_VALUE = "这里是审批表单值,报告不得输出";
|
||||
|
||||
function baseConfig(overrides = {}) {
|
||||
return {
|
||||
wecomCorpId: "corp-compensation",
|
||||
wecomAgentId: "1000002",
|
||||
wecomAgentSecret: SENSITIVE_AGENT_CREDENTIAL,
|
||||
wecomContactSecret: SENSITIVE_CONTACT_CREDENTIAL,
|
||||
wecomCheckinSecret: SENSITIVE_CHECKIN_CREDENTIAL,
|
||||
wecomHrAssistantSecret: SENSITIVE_HR_CREDENTIAL,
|
||||
wecomApprovalCallbackEvidenceFile: "/tmp/wacp3-test-evidence.jsonl",
|
||||
wecomRequestTimeoutMs: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseOptions(overrides = {}) {
|
||||
return {
|
||||
live: true,
|
||||
format: "json",
|
||||
reportDir: "/tmp/wecom-approval-compensation-test",
|
||||
startTime: "2026-05-19T00:00:00.000Z",
|
||||
endTime: "2026-05-20T00:00:00.000Z",
|
||||
templates: {
|
||||
leave: SENSITIVE_TEMPLATE_ID,
|
||||
missed_punch: "tpl-missed-punch",
|
||||
},
|
||||
size: 100,
|
||||
maxPages: 10,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
};
|
||||
}
|
||||
|
||||
function detailSummary(spNo = SENSITIVE_SP_NO, overrides = {}) {
|
||||
return {
|
||||
spNoPrefix: maskApprovalIdentifier(spNo),
|
||||
templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||
spStatus: "2",
|
||||
applyFieldCount: 3,
|
||||
approvalNodeCount: 1,
|
||||
notifyerCount: 1,
|
||||
commentCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-approval-compensation-poc", () => {
|
||||
beforeEach(() => {
|
||||
clearWecomAppAccessTokenCache();
|
||||
});
|
||||
|
||||
it("parses CLI window, size and template filters", () => {
|
||||
const options = parseArgs([
|
||||
"--live",
|
||||
"--format=json",
|
||||
"--start-time=2026-05-19T00:00:00.000Z",
|
||||
"--end-time=2026-05-20T00:00:00.000Z",
|
||||
"--size=50",
|
||||
"--template=leave:tpl-leave",
|
||||
"--template=missed_punch:tpl-missed",
|
||||
]);
|
||||
|
||||
expect(options.live).toBe(true);
|
||||
expect(options.format).toBe("json");
|
||||
expect(options.size).toBe(50);
|
||||
expect(options.templates).toEqual({
|
||||
leave: "tpl-leave",
|
||||
missed_punch: "tpl-missed",
|
||||
});
|
||||
expect(() => parseArgs(["--template=outing:tpl"])).toThrow(/unsupported target/);
|
||||
expect(() => parseArgs(["--size=101"])).toThrow(/--size/);
|
||||
});
|
||||
|
||||
it("builds a default 72-hour time window", () => {
|
||||
const window = buildTimeWindow(baseOptions({ startTime: "", endTime: "" }), "2026-05-20T00:00:00.000Z");
|
||||
|
||||
expect(window.endTime - window.startTime).toBe(72 * 60 * 60);
|
||||
expect(window.defaultWindowHours).toBe(72);
|
||||
});
|
||||
|
||||
it("builds inputs from CLI first, then env, with masked template previews", () => {
|
||||
const inputs = buildInputs(baseOptions({
|
||||
templates: { leave: SENSITIVE_TEMPLATE_ID },
|
||||
}), {
|
||||
WECOM_APPROVAL_LEAVE_TEMPLATE_ID: "env-should-not-win",
|
||||
WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID: "env-missed-template",
|
||||
});
|
||||
|
||||
expect(inputs.find((item) => item.key === "leave")).toMatchObject({
|
||||
templateSource: "cli",
|
||||
templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||
});
|
||||
expect(inputs.find((item) => item.key === "missed_punch")).toMatchObject({
|
||||
templateSource: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID",
|
||||
templateIdPrefix: maskApprovalIdentifier("env-missed-template"),
|
||||
});
|
||||
});
|
||||
|
||||
it("documents WECOM_AGENT_SECRET as the only Secret source", () => {
|
||||
const check = buildSecretCheck(baseConfig());
|
||||
expect(check).toMatchObject({
|
||||
status: "OK",
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
endpoints: [
|
||||
"POST /cgi-bin/oa/getapprovalinfo",
|
||||
"POST /cgi-bin/oa/getapprovaldetail",
|
||||
],
|
||||
});
|
||||
expect(check.noFallback).toEqual([
|
||||
"WECOM_CONTACT_SECRET",
|
||||
"WECOM_CHECKIN_SECRET",
|
||||
"WECOM_HR_ASSISTANT_SECRET",
|
||||
]);
|
||||
expect(buildSecretCheck(baseConfig({ wecomAgentSecret: "" }))).toMatchObject({
|
||||
status: "BLOCKED",
|
||||
missing: ["WECOM_AGENT_SECRET"],
|
||||
});
|
||||
});
|
||||
|
||||
it("calls getapprovalinfo with template filter and redacts sp_no list", async () => {
|
||||
const fetchImpl = vi.fn(async (url, init) => {
|
||||
const urlText = String(url);
|
||||
if (urlText.includes("/cgi-bin/gettoken")) {
|
||||
expect(urlText).toContain("corpsecret=");
|
||||
return jsonResponse({ errcode: 0, errmsg: "ok", access_token: "token-compensation", expires_in: 7200 });
|
||||
}
|
||||
expect(urlText).toContain("/cgi-bin/oa/getapprovalinfo");
|
||||
expect(urlText).not.toContain(SENSITIVE_AGENT_CREDENTIAL);
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body).toMatchObject({
|
||||
starttime: 1779148800,
|
||||
endtime: 1779235200,
|
||||
size: 50,
|
||||
filters: [{ key: "template_id", value: SENSITIVE_TEMPLATE_ID }],
|
||||
});
|
||||
return jsonResponse({
|
||||
errcode: 0,
|
||||
errmsg: "ok",
|
||||
sp_no_list: [SENSITIVE_SP_NO],
|
||||
next_cursor: "",
|
||||
});
|
||||
});
|
||||
|
||||
const result = await fetchWecomApprovalInfoList({
|
||||
config: baseConfig(),
|
||||
startTime: 1779148800,
|
||||
endTime: 1779235200,
|
||||
size: 50,
|
||||
filters: [{ key: "template_id", value: SENSITIVE_TEMPLATE_ID }],
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.spNos).toEqual([SENSITIVE_SP_NO]);
|
||||
expect(result.response.spNoPrefixes).toEqual([maskApprovalIdentifier(SENSITIVE_SP_NO)]);
|
||||
expect(JSON.stringify(result.response)).not.toContain(SENSITIVE_SP_NO);
|
||||
});
|
||||
|
||||
it("returns PARTIAL_COMPENSATION_NEEDS_CONFIG when template filters are missing", async () => {
|
||||
const report = await executeCompensationPoc(baseOptions({
|
||||
templates: {},
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
fetchApprovalInfoListImpl: vi.fn(),
|
||||
fetchApprovalDetailImpl: vi.fn(),
|
||||
readCallbackEvidenceImpl: vi.fn(async () => []),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_COMPENSATION_NEEDS_CONFIG");
|
||||
expect(report.configGaps.map((item) => item.id)).toContain("approval_compensation_pull");
|
||||
expect(report.configGaps.map((item) => item.id)).not.toContain("approval_compensation_window_empty");
|
||||
expect(report.checks.targets.every((item) => item.status === "NEEDS_CONFIG")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns PARTIAL_COMPENSATION_NEEDS_CONFIG in dry-run even when templates are configured", async () => {
|
||||
const report = await executeCompensationPoc(baseOptions({ live: false }), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
readCallbackEvidenceImpl: vi.fn(async () => []),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_COMPENSATION_NEEDS_CONFIG");
|
||||
expect(report.configGaps.map((item) => item.id)).toContain("live_read");
|
||||
expect(report.checks.targets.every((item) => item.status === "DRY_RUN_READY")).toBe(true);
|
||||
});
|
||||
|
||||
it("pulls approval numbers and details, then passes when callback evidence matches", async () => {
|
||||
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: {
|
||||
errcode: 0,
|
||||
spNoCount: 1,
|
||||
spNoPrefixes: [maskApprovalIdentifier(`${filters[0].value}-sp`)],
|
||||
},
|
||||
spNos: [`${filters[0].value}-sp`],
|
||||
nextCursor: "",
|
||||
}));
|
||||
const fetchApprovalDetailImpl = vi.fn(async ({ spNo }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: detailSummary(spNo, {
|
||||
templateIdPrefix: spNo.startsWith(SENSITIVE_TEMPLATE_ID)
|
||||
? maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID)
|
||||
: maskApprovalIdentifier("tpl-missed-punch"),
|
||||
}),
|
||||
response: {
|
||||
errcode: 0,
|
||||
spStatus: "2",
|
||||
applyFieldCount: 3,
|
||||
approvalNodeCount: 1,
|
||||
commentCount: 0,
|
||||
},
|
||||
}));
|
||||
const evidence = [
|
||||
{
|
||||
callbackKind: "approval_status_change",
|
||||
approvalType: "leave",
|
||||
spStatus: "2",
|
||||
statusLabel: "已通过",
|
||||
spNoPrefix: maskApprovalIdentifier(`${SENSITIVE_TEMPLATE_ID}-sp`),
|
||||
templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||
},
|
||||
{
|
||||
callbackKind: "approval_status_change",
|
||||
approvalType: "missed_punch",
|
||||
spStatus: "2",
|
||||
statusLabel: "已通过",
|
||||
spNoPrefix: maskApprovalIdentifier("tpl-missed-punch-sp"),
|
||||
templateIdPrefix: maskApprovalIdentifier("tpl-missed-punch"),
|
||||
},
|
||||
];
|
||||
|
||||
const report = await executeCompensationPoc(baseOptions(), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
readCallbackEvidenceImpl: vi.fn(async () => evidence),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_COMPENSATION_POC");
|
||||
expect(fetchApprovalInfoListImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchApprovalDetailImpl).toHaveBeenCalledTimes(2);
|
||||
expect(report.checks.callbackCompare.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns PASS_WITH_FINDINGS for missing callback and status mismatch", async () => {
|
||||
const report = await executeCompensationPoc(baseOptions(), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
fetchApprovalInfoListImpl: vi.fn(async () => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: { errcode: 0, spNoCount: 1 },
|
||||
spNos: [SENSITIVE_SP_NO],
|
||||
nextCursor: "",
|
||||
})),
|
||||
fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: detailSummary(spNo, { spStatus: "2" }),
|
||||
response: { errcode: 0, spStatus: "2" },
|
||||
})),
|
||||
readCallbackEvidenceImpl: vi.fn(async () => [{
|
||||
callbackKind: "approval_status_change",
|
||||
spStatus: "3",
|
||||
statusLabel: "已驳回",
|
||||
spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO),
|
||||
}]),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_FINDINGS");
|
||||
expect(report.checks.callbackCompare.findings.map((item) => item.id)).toContain("callback_status_mismatch");
|
||||
});
|
||||
|
||||
it("classifies WeCom API errors as BLOCKED_BY_EXTERNAL_CONFIG", async () => {
|
||||
const report = await executeCompensationPoc(baseOptions({
|
||||
templates: { leave: SENSITIVE_TEMPLATE_ID },
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
fetchApprovalInfoListImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
code: "wecom_approval_api_error",
|
||||
message: "WeCom approval API errcode 48002",
|
||||
response: { errcode: 48002, errmsg: "api forbidden" },
|
||||
spNos: [],
|
||||
})),
|
||||
fetchApprovalDetailImpl: vi.fn(),
|
||||
readCallbackEvidenceImpl: vi.fn(async () => []),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(report.mainBlockers[0]).toMatchObject({
|
||||
reason: "wecom_approval_api_error",
|
||||
});
|
||||
});
|
||||
|
||||
it("compares callback evidence using redacted prefixes", () => {
|
||||
const compare = compareCallbackEvidence([
|
||||
{
|
||||
details: [{
|
||||
target: "leave",
|
||||
spNoPrefix: "SPMATCH***",
|
||||
spStatus: "2",
|
||||
statusLabel: "已通过",
|
||||
status: "OK",
|
||||
}],
|
||||
},
|
||||
], {
|
||||
eventCount: 2,
|
||||
bySpNoPrefix: {
|
||||
"SPMATCH***": [
|
||||
{ spNoPrefix: "SPMATCH***", spStatus: "2", statusLabel: "已通过" },
|
||||
{ spNoPrefix: "SPMATCH***", spStatus: "2", statusLabel: "已通过" },
|
||||
],
|
||||
"SPONLYCB***": [
|
||||
{ spNoPrefix: "SPONLYCB***", spStatus: "2", statusLabel: "已通过" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(compare.status).toBe("FINDINGS");
|
||||
expect(compare.findings.map((item) => item.id)).toEqual(expect.arrayContaining([
|
||||
"callback_duplicate_for_pulled_detail",
|
||||
"callback_not_in_compensation_window",
|
||||
]));
|
||||
});
|
||||
|
||||
it("redacts secrets, full identifiers and form values from reports", async () => {
|
||||
const report = await executeCompensationPoc(baseOptions({
|
||||
templates: { leave: SENSITIVE_TEMPLATE_ID },
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {
|
||||
WECOM_AGENT_SECRET: SENSITIVE_AGENT_CREDENTIAL,
|
||||
},
|
||||
generatedAt: "2026-05-20T00:00:00.000Z",
|
||||
fetchApprovalInfoListImpl: vi.fn(async () => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: { errcode: 0, spNoCount: 1, spNoPrefixes: [maskApprovalIdentifier(SENSITIVE_SP_NO)] },
|
||||
spNos: [SENSITIVE_SP_NO],
|
||||
nextCursor: "",
|
||||
})),
|
||||
fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({
|
||||
ok: true,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
summary: detailSummary(spNo),
|
||||
response: {
|
||||
errcode: 0,
|
||||
spStatus: "2",
|
||||
applyFieldCount: 3,
|
||||
approvalNodeCount: 1,
|
||||
},
|
||||
})),
|
||||
readCallbackEvidenceImpl: vi.fn(async () => [{
|
||||
callbackKind: "approval_status_change",
|
||||
spStatus: "2",
|
||||
statusLabel: "已通过",
|
||||
spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO),
|
||||
templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||
}]),
|
||||
});
|
||||
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_SP_NO);
|
||||
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_FORM_VALUE);
|
||||
expect(statusLabelFromSpStatus("2")).toBe("已通过");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户