WRV1 收口企微运行优先门禁
这个提交包含在:
@@ -29,6 +29,7 @@
|
||||
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
|
||||
"audit:org-master-data": "node src/scripts/audit-org-master-data.js",
|
||||
"audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js",
|
||||
"check:wecom-runtime-ready": "node src/scripts/check-wecom-runtime-ready.js",
|
||||
"resolve:wecom-oa-users": "node src/scripts/resolve-wecom-oa-user-consistency.js",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
#!/usr/bin/env node
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Pool } = require("pg");
|
||||
|
||||
const { getConfig } = require("../config");
|
||||
const {
|
||||
WecomHrMasterSyncError,
|
||||
fetchWecomHrProfiles,
|
||||
maskExternalUserId,
|
||||
} = require("../services/integrations/wecomHrMaster");
|
||||
const {
|
||||
fetchWecomCheckinData,
|
||||
formatDateInTimezone,
|
||||
resolveCheckinSecret,
|
||||
} = require("../services/integrations/wecomCheckin");
|
||||
const {
|
||||
buildWecomTextPreview,
|
||||
sendWecomMessage,
|
||||
} = require("../services/integrations/im/wecom");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 200;
|
||||
const MAX_SAMPLE_ITEMS = 50;
|
||||
|
||||
const REPORT_PRIVACY =
|
||||
"employee_no/counts/masked wecom userid prefixes only; no phone/email/password/token/secret/full userid/raw payload";
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/check-wecom-runtime-ready.js [options]
|
||||
|
||||
QA-only 企业微信运行优先 V1 门禁检查。
|
||||
默认不写 OA/企微资料,不调用 WHMS dry-run/apply,不发送真实企微消息。
|
||||
|
||||
Options:
|
||||
--live 启用企微 live 只读检查。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--limit=<n> 通讯录/人事助手读取上限,默认 20。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-runtime-ready-<timestamp>。
|
||||
--send-test-message 发送一条低风险测试消息;必须同时设置 WECOM_RUNTIME_TEST_SEND=1。
|
||||
--message-recipient-employee-no=<employee> 指定接收测试消息的 OA 员工号。
|
||||
--help 显示帮助。
|
||||
|
||||
Secret 固定规则:
|
||||
通讯录:WECOM_CONTACT_SECRET + POST /cgi-bin/user/list_id。
|
||||
人事助手/花名册:WECOM_AGENT_SECRET + POST /cgi-bin/hr/get_staff_info。
|
||||
打卡:默认 WECOM_AGENT_SECRET;仅 WECOM_CHECKIN_SECRET 显式配置时覆盖。
|
||||
|
||||
隐私边界:
|
||||
报告只输出计数、员工号、脱敏 userid 前缀、配置项状态和问题类型。
|
||||
不输出手机号、邮箱、token、secret、完整 userid 或企微原始 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 parseArgs(argv) {
|
||||
const options = {
|
||||
live: process.env.WECOM_RUNTIME_READY_LIVE === "1",
|
||||
format: "text",
|
||||
limit: Number(process.env.WECOM_RUNTIME_READY_LIMIT || DEFAULT_LIMIT),
|
||||
reportDir:
|
||||
process.env.WECOM_RUNTIME_READY_REPORT_DIR
|
||||
|| path.join(DEFAULT_REPORT_ROOT, `wecom-runtime-ready-${timestamp()}`),
|
||||
sendTestMessage: false,
|
||||
messageRecipientEmployeeNo: "",
|
||||
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 === "--limit") {
|
||||
options.limit = Number(argv[index + 1] || options.limit);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--limit=")) {
|
||||
options.limit = Number(arg.slice("--limit=".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 === "--send-test-message") {
|
||||
options.sendTestMessage = true;
|
||||
} else if (arg === "--message-recipient-employee-no") {
|
||||
options.messageRecipientEmployeeNo = toSafeString(argv[index + 1], 80);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--message-recipient-employee-no=")) {
|
||||
options.messageRecipientEmployeeNo = toSafeString(arg.slice("--message-recipient-employee-no=".length), 80);
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
options.limit = Number.isFinite(options.limit)
|
||||
? Math.max(1, Math.min(MAX_LIMIT, Math.floor(options.limit)))
|
||||
: DEFAULT_LIMIT;
|
||||
return options;
|
||||
}
|
||||
|
||||
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 assertSendAllowed(options, env = process.env) {
|
||||
if (options.sendTestMessage && env.WECOM_RUNTIME_TEST_SEND !== "1") {
|
||||
throw new Error("--send-test-message requires WECOM_RUNTIME_TEST_SEND=1");
|
||||
}
|
||||
}
|
||||
|
||||
function envValue(env, names) {
|
||||
for (const name of names) {
|
||||
const value = toSafeString(env[name], 1000);
|
||||
if (value) return { name, value };
|
||||
}
|
||||
return { name: names[0], value: "" };
|
||||
}
|
||||
|
||||
function configured(value) {
|
||||
return toSafeString(value, 1000).length > 0;
|
||||
}
|
||||
|
||||
function maskHostLike(value) {
|
||||
const raw = toSafeString(value, 500);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return `${url.protocol}//${url.host}${url.pathname ? "/..." : ""}`;
|
||||
} catch {
|
||||
return raw.length > 48 ? `${raw.slice(0, 20)}...` : raw;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOriginHosts(rawOrigins) {
|
||||
const hosts = new Set();
|
||||
for (const item of toSafeString(rawOrigins, 2000).split(",").map((part) => part.trim()).filter(Boolean)) {
|
||||
try {
|
||||
hosts.add(new URL(item).host);
|
||||
} catch {
|
||||
hosts.add(item);
|
||||
}
|
||||
}
|
||||
return Array.from(hosts).sort();
|
||||
}
|
||||
|
||||
function sample(items, maxItems = MAX_SAMPLE_ITEMS) {
|
||||
return items.slice(0, maxItems);
|
||||
}
|
||||
|
||||
function isOaActive(row) {
|
||||
return row?.status === "active" && !row?.archived;
|
||||
}
|
||||
|
||||
function sanitizeEmployeeNo(value) {
|
||||
return toSafeString(value, 80);
|
||||
}
|
||||
|
||||
function sanitizeApiSummary(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const result = {};
|
||||
for (const key of ["errcode", "errmsg", "status", "code", "message", "cached", "expiresIn", "recipientCount"]) {
|
||||
if (value[key] != null) {
|
||||
result[key] = key === "errmsg" || key === "message" ? safeMessage(value[key]) : value[key];
|
||||
}
|
||||
}
|
||||
if (value.body && typeof value.body === "object") {
|
||||
result.body = sanitizeApiSummary(value.body);
|
||||
}
|
||||
if (value.missing) {
|
||||
result.missing = Array.isArray(value.missing) ? value.missing.map((item) => toSafeString(item, 80)) : [];
|
||||
}
|
||||
if (value.userCount != null) result.userCount = Number(value.userCount) || 0;
|
||||
if (value.checkindataCount != null) result.checkindataCount = Number(value.checkindataCount) || 0;
|
||||
if (value.staffInfoCount != null) result.staffInfoCount = Number(value.staffInfoCount) || 0;
|
||||
if (value.invaliduser) result.invaliduserPresent = true;
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function buildEntryOauthCheck(config) {
|
||||
const missing = [];
|
||||
if (!config.wecomEnabled) missing.push("WECOM_ENABLED");
|
||||
if (!config.wecomEntryEnabled) missing.push("WECOM_ENTRY_ENABLED");
|
||||
if (!config.wecomOauthEnabled) missing.push("WECOM_OAUTH_ENABLED");
|
||||
if (!config.wecomCorpId) missing.push("WECOM_CORP_ID");
|
||||
if (!config.wecomAgentId) missing.push("WECOM_AGENT_ID");
|
||||
if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET");
|
||||
if (!config.wecomRedirectUri) missing.push("WECOM_REDIRECT_URI");
|
||||
|
||||
const returnHosts = Array.from(new Set([
|
||||
...parseOriginHosts(config.appOrigin),
|
||||
...parseOriginHosts(config.wecomAllowedReturnHosts),
|
||||
])).sort();
|
||||
if (!returnHosts.length) missing.push("WECOM_ALLOWED_RETURN_HOSTS_OR_APP_ORIGIN");
|
||||
|
||||
return {
|
||||
id: "entry_oauth",
|
||||
status: missing.length ? "BLOCKED" : "OK",
|
||||
missing,
|
||||
evidence: {
|
||||
endpoint: "/api/auth/wecom/config",
|
||||
loginUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=login",
|
||||
bindUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=bind",
|
||||
returnHosts,
|
||||
enabled: Boolean(config.wecomEnabled && config.wecomEntryEnabled && config.wecomOauthEnabled),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSecretMatrix(config) {
|
||||
return {
|
||||
contact: {
|
||||
source: "WECOM_CONTACT_SECRET",
|
||||
configured: configured(config.wecomContactSecret),
|
||||
endpoint: "POST /cgi-bin/user/list_id",
|
||||
},
|
||||
hrAssistant: {
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
configured: configured(config.wecomAgentSecret),
|
||||
endpoint: "POST /cgi-bin/hr/get_staff_info",
|
||||
legacyNotDefault: "WECOM_HR_ASSISTANT_SECRET",
|
||||
},
|
||||
checkin: {
|
||||
source: resolveCheckinSecretSource(config),
|
||||
configured: configured(resolveCheckinSecret(config)),
|
||||
endpoint: "POST /cgi-bin/checkin/getcheckindata",
|
||||
override: configured(config.wecomCheckinSecret),
|
||||
overrideEnv: configured(config.wecomCheckinSecret) ? "WECOM_CHECKIN_SECRET" : null,
|
||||
},
|
||||
messageAndOauth: {
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
configured: configured(config.wecomAgentSecret),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequiredSecretCheck(config) {
|
||||
const missing = [];
|
||||
if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET");
|
||||
if (!config.wecomContactSecret) missing.push("WECOM_CONTACT_SECRET");
|
||||
if (!resolveCheckinSecret(config)) missing.push("WECOM_CHECKIN_SECRET_OR_WECOM_AGENT_SECRET");
|
||||
return {
|
||||
id: "secret_matrix",
|
||||
status: missing.length ? "BLOCKED" : "OK",
|
||||
missing,
|
||||
matrix: buildSecretMatrix(config),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCheckinSecretSource(config) {
|
||||
if (configured(config?.wecomCheckinSecret)) return "WECOM_CHECKIN_SECRET";
|
||||
if (configured(config?.wecomAgentSecret)) return "WECOM_AGENT_SECRET";
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildApprovalConfigChecks(env = process.env) {
|
||||
const items = [
|
||||
{
|
||||
id: "approval_template_id",
|
||||
label: "审批模板 ID",
|
||||
env: ["WECOM_APPROVAL_TEMPLATE_ID", "WECOM_APPROVAL_TEMPLATE_IDS"],
|
||||
},
|
||||
{
|
||||
id: "approval_test_sp_no",
|
||||
label: "测试审批单号 sp_no",
|
||||
env: ["WECOM_APPROVAL_TEST_SP_NO", "WECOM_APPROVAL_SP_NO", "WECOM_APPROVAL_INSTANCE_ID"],
|
||||
},
|
||||
{
|
||||
id: "approval_callback_url",
|
||||
label: "真实回调 URL",
|
||||
env: ["WECOM_APPROVAL_CALLBACK_URL", "WECOM_CALLBACK_URL"],
|
||||
mask: maskHostLike,
|
||||
},
|
||||
{
|
||||
id: "callback_token",
|
||||
label: "回调 Token",
|
||||
env: ["WECOM_CALLBACK_TOKEN"],
|
||||
},
|
||||
{
|
||||
id: "callback_encoding_aes_key",
|
||||
label: "回调 EncodingAESKey",
|
||||
env: ["WECOM_CALLBACK_ENCODING_AES_KEY", "WECOM_CALLBACK_AES_KEY"],
|
||||
},
|
||||
];
|
||||
|
||||
return items.map((item) => {
|
||||
const resolved = envValue(env, item.env);
|
||||
return {
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
status: resolved.value ? "CONFIGURED" : "NEEDS_CONFIG",
|
||||
configuredEnv: resolved.value ? resolved.name : null,
|
||||
valuePreview: resolved.value && item.mask ? item.mask(resolved.value) : null,
|
||||
missingEnv: resolved.value ? [] : item.env,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeConfigGaps(checks) {
|
||||
return checks
|
||||
.filter((item) => item.status === "NEEDS_CONFIG")
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
missingEnv: item.missingEnv,
|
||||
}));
|
||||
}
|
||||
|
||||
async function queryOaBindingRows(client) {
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
e.id::text,
|
||||
e.employee_no,
|
||||
e.status::text,
|
||||
(e.archived_at IS NOT NULL) AS archived,
|
||||
w.external_user_id AS wecom_external_user_id
|
||||
FROM employees e
|
||||
LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id
|
||||
ORDER BY e.employee_no ASC
|
||||
`);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
employeeNo: sanitizeEmployeeNo(row.employee_no),
|
||||
status: toSafeString(row.status, 40),
|
||||
archived: Boolean(row.archived),
|
||||
wecomExternalUserId: toSafeString(row.wecom_external_user_id, 128),
|
||||
}));
|
||||
}
|
||||
|
||||
async function queryOaBindingRowsReadOnly() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("Missing DATABASE_URL");
|
||||
}
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN READ ONLY");
|
||||
const rows = await queryOaBindingRows(client);
|
||||
await client.query("COMMIT");
|
||||
return rows;
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {
|
||||
// Preserve the original read-only error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeOaBindings(rows) {
|
||||
const activeRows = rows.filter(isOaActive);
|
||||
const activeWithBinding = activeRows.filter((row) => row.wecomExternalUserId);
|
||||
const activeMissingBinding = activeRows.filter((row) => !row.wecomExternalUserId);
|
||||
const inactiveWithBinding = rows.filter((row) => !isOaActive(row) && row.wecomExternalUserId);
|
||||
|
||||
return {
|
||||
totalEmployees: rows.length,
|
||||
activeEmployees: activeRows.length,
|
||||
activeWithWecomBinding: activeWithBinding.length,
|
||||
activeMissingBinding: activeMissingBinding.length,
|
||||
inactiveWithBinding: inactiveWithBinding.length,
|
||||
activeBoundEmployeeNos: sample(activeWithBinding.map((row) => row.employeeNo).filter(Boolean)),
|
||||
activeMissingBindingEmployeeNos: sample(activeMissingBinding.map((row) => row.employeeNo).filter(Boolean)),
|
||||
inactiveWithBindingEmployeeNos: sample(inactiveWithBinding.map((row) => row.employeeNo).filter(Boolean)),
|
||||
activeBoundUserIdPrefixes: sample(activeWithBinding.map((row) => maskExternalUserId(row.wecomExternalUserId)).filter(Boolean)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOaBindingCheck(rows, options) {
|
||||
if (!Array.isArray(rows)) {
|
||||
const status = options.live ? "BLOCKED" : "DRY_RUN";
|
||||
return {
|
||||
id: "oa_active_bindings",
|
||||
status,
|
||||
reason: options.live ? "database_read_unavailable" : "database_not_checked_without_live",
|
||||
summary: summarizeOaBindings([]),
|
||||
};
|
||||
}
|
||||
|
||||
const summary = summarizeOaBindings(rows);
|
||||
const blockers = [];
|
||||
if (summary.activeEmployees <= 0) blockers.push("oa_active_employee_missing");
|
||||
if (summary.activeMissingBinding > 0) blockers.push("oa_active_missing_wecom_binding");
|
||||
return {
|
||||
id: "oa_active_bindings",
|
||||
status: blockers.length ? "BLOCKED" : "OK",
|
||||
blockers,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeMessage(config, generatedAt) {
|
||||
const baseOrigin = toSafeString(config.appOrigin, 500).split(",").map((item) => item.trim()).filter(Boolean)[0] || "";
|
||||
const detailUrl = baseOrigin ? `${baseOrigin.replace(/\/$/, "")}/mobile/notifications` : "/mobile/notifications";
|
||||
return {
|
||||
title: "OA 运行就绪检查",
|
||||
body: [
|
||||
"类型:WECOM-RUNTIME-READY-V1",
|
||||
`编号:WRV1-${generatedAt.slice(0, 10).replace(/-/g, "")}`,
|
||||
"状态:只读门禁",
|
||||
].join("\n"),
|
||||
detailUrl,
|
||||
deepLinkLabel: "打开 OA",
|
||||
footer: "仅含脱敏摘要,不含业务附件",
|
||||
};
|
||||
}
|
||||
|
||||
function selectMessageRecipient(rows, employeeNo) {
|
||||
const activeBound = (rows || []).filter((row) => isOaActive(row) && row.wecomExternalUserId);
|
||||
if (employeeNo) {
|
||||
const matched = activeBound.find((row) => row.employeeNo === employeeNo);
|
||||
return matched || null;
|
||||
}
|
||||
return activeBound[0] || null;
|
||||
}
|
||||
|
||||
async function runMessageCheck({ config, rows, options, generatedAt, sendMessageImpl = sendWecomMessage }) {
|
||||
const message = buildRuntimeMessage(config, generatedAt);
|
||||
const selected = selectMessageRecipient(rows, options.messageRecipientEmployeeNo);
|
||||
const previewMessage = buildWecomTextPreview(message);
|
||||
const base = {
|
||||
id: "message_dehydrated_app",
|
||||
mode: "wecom_app_message",
|
||||
payloadFields: ["title", "type", "number", "status", "deeplink"],
|
||||
previewMessage,
|
||||
recipientCount: selected ? 1 : 0,
|
||||
recipientEmployeeNo: selected?.employeeNo || null,
|
||||
recipientUserIdPrefixes: selected ? [maskExternalUserId(selected.wecomExternalUserId)] : [],
|
||||
};
|
||||
|
||||
if (!options.sendTestMessage) {
|
||||
return {
|
||||
...base,
|
||||
status: "DRY_RUN_READY",
|
||||
sendBehavior: "not_sent",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await sendMessageImpl({
|
||||
config,
|
||||
channel: { config_json: { mode: "wecom_app_message", timeoutMs: config.wecomRequestTimeoutMs ?? 5000 } },
|
||||
message,
|
||||
recipientExternalUserIds: selected ? [selected.wecomExternalUserId] : [],
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
status: result.ok ? "OK" : "BLOCKED",
|
||||
sendBehavior: "sent_with_double_confirmation",
|
||||
result: {
|
||||
ok: Boolean(result.ok),
|
||||
code: toSafeString(result.code, 120),
|
||||
retryable: Boolean(result.retryable),
|
||||
response: sanitizeApiSummary(result.response),
|
||||
message: result.message ? safeMessage(result.message) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runHrReadCheck({ config, options, fetchProfilesImpl = fetchWecomHrProfiles }) {
|
||||
const contract = {
|
||||
id: "contact_and_hr_read",
|
||||
contact: {
|
||||
source: "WECOM_CONTACT_SECRET",
|
||||
endpoint: "POST /cgi-bin/user/list_id",
|
||||
},
|
||||
hrAssistant: {
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
endpoint: "POST /cgi-bin/hr/get_staff_info",
|
||||
},
|
||||
};
|
||||
|
||||
if (!options.live) {
|
||||
return {
|
||||
...contract,
|
||||
status: "DRY_RUN",
|
||||
reason: "live_read_disabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.wecomContactSecret) {
|
||||
return {
|
||||
...contract,
|
||||
status: "BLOCKED",
|
||||
reason: "contact_secret_missing",
|
||||
missing: ["WECOM_CONTACT_SECRET"],
|
||||
};
|
||||
}
|
||||
if (!config.wecomAgentSecret) {
|
||||
return {
|
||||
...contract,
|
||||
status: "BLOCKED",
|
||||
reason: "agent_secret_missing",
|
||||
missing: ["WECOM_AGENT_SECRET"],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchProfilesImpl({
|
||||
config,
|
||||
userids: [],
|
||||
limit: options.limit,
|
||||
});
|
||||
const profiles = Array.isArray(result.profiles) ? result.profiles : [];
|
||||
return {
|
||||
...contract,
|
||||
status: "OK",
|
||||
tokenSource: {
|
||||
contact: result.tokenSource?.contact || null,
|
||||
hr: result.tokenSource?.hr || null,
|
||||
},
|
||||
totals: {
|
||||
profiles: profiles.length,
|
||||
activeProfiles: profiles.filter((profile) => profile.sourceStatus === "active").length,
|
||||
missingRequiredFieldProfiles: profiles.filter((profile) => profile.missingRequiredFields?.length).length,
|
||||
},
|
||||
samples: {
|
||||
userIdPrefixes: sample(profiles.map((profile) => maskExternalUserId(profile.externalUserId)).filter(Boolean)),
|
||||
},
|
||||
mayBeTruncated: profiles.length >= options.limit,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...contract,
|
||||
status: "BLOCKED",
|
||||
reason: error instanceof WecomHrMasterSyncError ? error.code : "wecom_hr_read_failed",
|
||||
message: safeMessage(error?.message || error),
|
||||
details: sanitizeApiSummary(error?.details?.responseSummary) || error?.details || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildCheckinWindow(now = new Date(), timeZone = "Asia/Shanghai") {
|
||||
const endDate = formatDateInTimezone(now, timeZone);
|
||||
const startDate = formatDateInTimezone(new Date(now.getTime() - 24 * 60 * 60 * 1000), timeZone);
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
async function runCheckinReadCheck({
|
||||
config,
|
||||
rows,
|
||||
options,
|
||||
now = new Date(),
|
||||
fetchCheckinImpl = fetchWecomCheckinData,
|
||||
}) {
|
||||
const secretSource = resolveCheckinSecretSource(config);
|
||||
const contract = {
|
||||
id: "checkin_short_window_read",
|
||||
source: secretSource,
|
||||
endpoint: "POST /cgi-bin/checkin/getcheckindata",
|
||||
override: secretSource === "WECOM_CHECKIN_SECRET",
|
||||
};
|
||||
|
||||
if (!options.live) {
|
||||
return {
|
||||
...contract,
|
||||
status: "DRY_RUN",
|
||||
reason: "live_read_disabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.wecomCheckinSyncEnabled) {
|
||||
return {
|
||||
...contract,
|
||||
status: "BLOCKED",
|
||||
reason: "wecom_checkin_sync_disabled",
|
||||
missing: ["WECOM_CHECKIN_SYNC_ENABLED=true"],
|
||||
};
|
||||
}
|
||||
if (!resolveCheckinSecret(config)) {
|
||||
return {
|
||||
...contract,
|
||||
status: "BLOCKED",
|
||||
reason: "wecom_checkin_secret_missing",
|
||||
missing: ["WECOM_CHECKIN_SECRET", "WECOM_AGENT_SECRET"],
|
||||
};
|
||||
}
|
||||
|
||||
const externalUserIds = sample((rows || [])
|
||||
.filter((row) => isOaActive(row) && row.wecomExternalUserId)
|
||||
.map((row) => row.wecomExternalUserId), 100);
|
||||
const window = buildCheckinWindow(now, config.appTimezone || "Asia/Shanghai");
|
||||
const result = await fetchCheckinImpl({
|
||||
config,
|
||||
startDate: window.startDate,
|
||||
endDate: window.endDate,
|
||||
externalUserIds,
|
||||
});
|
||||
|
||||
return {
|
||||
...contract,
|
||||
status: result.ok ? "OK" : "BLOCKED",
|
||||
reason: result.code || null,
|
||||
window,
|
||||
userCount: externalUserIds.length,
|
||||
recordCount: Array.isArray(result.records) ? result.records.length : 0,
|
||||
response: sanitizeApiSummary(result.response),
|
||||
message: result.message ? safeMessage(result.message) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function collectMainBlockers(checks) {
|
||||
const blockers = [];
|
||||
for (const check of checks) {
|
||||
if (!check || check.status !== "BLOCKED") continue;
|
||||
blockers.push({
|
||||
id: check.id,
|
||||
reason: check.reason || check.blockers?.join(",") || "blocked",
|
||||
missing: check.missing || [],
|
||||
});
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function evaluateConclusion({ mainBlockers, configGaps, live }) {
|
||||
if (mainBlockers.length) {
|
||||
return {
|
||||
status: "SKIP",
|
||||
conclusion: "BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
};
|
||||
}
|
||||
if (configGaps.length || !live) {
|
||||
return {
|
||||
status: "PASS_WITH_CONFIG_GAPS",
|
||||
conclusion: "PASS_WITH_CONFIG_GAPS",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "PASS_RUNTIME_READY",
|
||||
conclusion: "PASS_RUNTIME_READY",
|
||||
};
|
||||
}
|
||||
|
||||
async function executeRuntimeReady(options, deps = {}) {
|
||||
const env = deps.env || process.env;
|
||||
assertSendAllowed(options, env);
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const config = deps.config || getConfig();
|
||||
|
||||
const entryOauth = buildEntryOauthCheck(config);
|
||||
const secretMatrix = buildRequiredSecretCheck(config);
|
||||
const approvalConfig = buildApprovalConfigChecks(env);
|
||||
let oaRows = null;
|
||||
let oaReadError = null;
|
||||
try {
|
||||
oaRows = Array.isArray(deps.oaRows) ? deps.oaRows : await (deps.queryOaRows || queryOaBindingRowsReadOnly)();
|
||||
} catch (error) {
|
||||
oaRows = null;
|
||||
oaReadError = {
|
||||
code: "database_read_failed",
|
||||
message: safeMessage(error?.message || error),
|
||||
};
|
||||
}
|
||||
|
||||
const oaBindings = buildOaBindingCheck(oaRows, options);
|
||||
if (oaReadError) {
|
||||
oaBindings.error = oaReadError;
|
||||
}
|
||||
|
||||
const hrRead = await runHrReadCheck({
|
||||
config,
|
||||
options,
|
||||
fetchProfilesImpl: deps.fetchProfilesImpl,
|
||||
});
|
||||
const checkinRead = await runCheckinReadCheck({
|
||||
config,
|
||||
rows: oaRows || [],
|
||||
options,
|
||||
now: deps.now,
|
||||
fetchCheckinImpl: deps.fetchCheckinImpl,
|
||||
});
|
||||
const message = await runMessageCheck({
|
||||
config,
|
||||
rows: oaRows || [],
|
||||
options,
|
||||
generatedAt,
|
||||
sendMessageImpl: deps.sendMessageImpl,
|
||||
});
|
||||
|
||||
const configGaps = summarizeConfigGaps(approvalConfig);
|
||||
const verificationGaps = options.live ? [] : [{ id: "live_read", status: "DRY_RUN", reason: "run_with_--live_for_public_readonly_gate" }];
|
||||
const mainChecks = [entryOauth, secretMatrix, oaBindings, hrRead, checkinRead, message];
|
||||
const mainBlockers = collectMainBlockers(mainChecks);
|
||||
const conclusion = evaluateConclusion({
|
||||
mainBlockers,
|
||||
configGaps: [...configGaps, ...verificationGaps],
|
||||
live: options.live,
|
||||
});
|
||||
|
||||
return {
|
||||
mode: "qa_only",
|
||||
generatedAt,
|
||||
status: conclusion.status,
|
||||
conclusion: conclusion.conclusion,
|
||||
privacy: REPORT_PRIVACY,
|
||||
scope: {
|
||||
writeBehavior: "no OA writes, no WeCom user data writes, no WHMS dry-run/apply API call",
|
||||
liveRead: Boolean(options.live),
|
||||
messageSend: options.sendTestMessage ? "double_confirmed_low_risk_test_message" : "dry_run_only",
|
||||
deepMirror: "not_started",
|
||||
},
|
||||
inputs: {
|
||||
limit: options.limit,
|
||||
sendTestMessage: Boolean(options.sendTestMessage),
|
||||
messageRecipientEmployeeNo: options.messageRecipientEmployeeNo || null,
|
||||
},
|
||||
checks: {
|
||||
entryOauth,
|
||||
secretMatrix,
|
||||
oaBindings,
|
||||
contactAndHrRead: hrRead,
|
||||
checkinRead,
|
||||
message,
|
||||
approvalAndCallbackConfig: approvalConfig,
|
||||
},
|
||||
configGaps,
|
||||
verificationGaps,
|
||||
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-runtime-ready] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||
process.stdout.write(`[wecom-runtime-ready] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-runtime-ready] privacy=${report.privacy}\n`);
|
||||
process.stdout.write(`[wecom-runtime-ready] liveRead=${report.scope.liveRead} messageSend=${report.scope.messageSend}\n`);
|
||||
if (report.checks?.oaBindings?.summary) {
|
||||
process.stdout.write(`[wecom-runtime-ready] oaBindings=${JSON.stringify(report.checks.oaBindings.summary)}\n`);
|
||||
}
|
||||
if (report.configGaps?.length) {
|
||||
process.stdout.write(`[wecom-runtime-ready] configGaps=${JSON.stringify(report.configGaps)}\n`);
|
||||
}
|
||||
if (report.mainBlockers?.length) {
|
||||
process.stdout.write(`[wecom-runtime-ready] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const report = await executeRuntimeReady(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-runtime-ready][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertSendAllowed,
|
||||
buildApprovalConfigChecks,
|
||||
buildEntryOauthCheck,
|
||||
buildRequiredSecretCheck,
|
||||
buildRuntimeMessage,
|
||||
buildSecretMatrix,
|
||||
buildCheckinWindow,
|
||||
evaluateConclusion,
|
||||
executeRuntimeReady,
|
||||
parseArgs,
|
||||
resolveCheckinSecretSource,
|
||||
sanitizeApiSummary,
|
||||
summarizeOaBindings,
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
const {
|
||||
assertSendAllowed,
|
||||
buildApprovalConfigChecks,
|
||||
buildSecretMatrix,
|
||||
evaluateConclusion,
|
||||
executeRuntimeReady,
|
||||
parseArgs,
|
||||
resolveCheckinSecretSource,
|
||||
} = require("../../../src/scripts/check-wecom-runtime-ready");
|
||||
|
||||
const SENSITIVE_AGENT_CREDENTIAL = ["agent", "credential", "should", "never", "leak"].join("-");
|
||||
const SENSITIVE_CONTACT_CREDENTIAL = ["contact", "credential", "should", "never", "leak"].join("-");
|
||||
const SENSITIVE_LEGACY_HR_CREDENTIAL = ["legacy", "hr", "credential", "should", "not", "be", "used"].join("-");
|
||||
const SENSITIVE_CHECKIN_CREDENTIAL = ["checkin", "credential", "override"].join("-");
|
||||
const SENSITIVE_CALLBACK_TOKEN = ["callback", "credential", "token"].join("-");
|
||||
const SENSITIVE_CALLBACK_AES_KEY = ["callback", "credential", "aes"].join("-");
|
||||
const SENSITIVE_USER_ID = ["oa", "smoke002", "full", "userid", "should", "not", "leak"].join("_");
|
||||
const SENSITIVE_PHONE = ["138", "0000", "0001"].join("");
|
||||
const SENSITIVE_EMAIL = ["employee", "example.com"].join("@");
|
||||
|
||||
function baseConfig(overrides = {}) {
|
||||
return {
|
||||
appOrigin: "https://oa.hyinda.com",
|
||||
appTimezone: "Asia/Shanghai",
|
||||
wecomEnabled: true,
|
||||
wecomEntryEnabled: true,
|
||||
wecomOauthEnabled: true,
|
||||
wecomCorpId: "corp-runtime",
|
||||
wecomAgentId: "1000002",
|
||||
wecomAgentSecret: SENSITIVE_AGENT_CREDENTIAL,
|
||||
wecomContactSecret: SENSITIVE_CONTACT_CREDENTIAL,
|
||||
wecomCheckinSecret: "",
|
||||
wecomCheckinSyncEnabled: true,
|
||||
wecomRedirectUri: "https://oa.hyinda.com/api/auth/wecom/callback",
|
||||
wecomAllowedReturnHosts: "oa.hyinda.com",
|
||||
wecomRequestTimeoutMs: 1000,
|
||||
wecomHrAssistantSecret: SENSITIVE_LEGACY_HR_CREDENTIAL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseOptions(overrides = {}) {
|
||||
return {
|
||||
live: true,
|
||||
format: "json",
|
||||
limit: 2,
|
||||
reportDir: "/tmp/wecom-runtime-ready-test",
|
||||
sendTestMessage: false,
|
||||
messageRecipientEmployeeNo: "",
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function boundEmployee(overrides = {}) {
|
||||
return {
|
||||
id: "employee-1",
|
||||
employeeNo: "SMOKE002",
|
||||
status: "active",
|
||||
archived: false,
|
||||
wecomExternalUserId: SENSITIVE_USER_ID,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wecomProfile(overrides = {}) {
|
||||
return {
|
||||
externalUserId: SENSITIVE_USER_ID,
|
||||
sourceStatus: "active",
|
||||
missingRequiredFields: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-runtime-ready", () => {
|
||||
it("parses runner options and enforces message send double confirmation", () => {
|
||||
const options = parseArgs([
|
||||
"--live",
|
||||
"--format=json",
|
||||
"--limit=500",
|
||||
"--send-test-message",
|
||||
"--message-recipient-employee-no=SMOKE002",
|
||||
]);
|
||||
|
||||
expect(options.live).toBe(true);
|
||||
expect(options.format).toBe("json");
|
||||
expect(options.limit).toBe(200);
|
||||
expect(options.sendTestMessage).toBe(true);
|
||||
expect(options.messageRecipientEmployeeNo).toBe("SMOKE002");
|
||||
expect(() => assertSendAllowed(options, {})).toThrow(/WECOM_RUNTIME_TEST_SEND=1/);
|
||||
expect(() => assertSendAllowed(options, { WECOM_RUNTIME_TEST_SEND: "1" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("documents fixed Secret ownership without HR assistant fallback", () => {
|
||||
const matrix = buildSecretMatrix(baseConfig({
|
||||
wecomCheckinSecret: SENSITIVE_CHECKIN_CREDENTIAL,
|
||||
}));
|
||||
|
||||
expect(matrix.contact).toMatchObject({
|
||||
source: "WECOM_CONTACT_SECRET",
|
||||
endpoint: "POST /cgi-bin/user/list_id",
|
||||
configured: true,
|
||||
});
|
||||
expect(matrix.hrAssistant).toMatchObject({
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
endpoint: "POST /cgi-bin/hr/get_staff_info",
|
||||
configured: true,
|
||||
legacyNotDefault: "WECOM_HR_ASSISTANT_SECRET",
|
||||
});
|
||||
expect(matrix.checkin).toMatchObject({
|
||||
source: "WECOM_CHECKIN_SECRET",
|
||||
override: true,
|
||||
});
|
||||
expect(resolveCheckinSecretSource(baseConfig({ wecomCheckinSecret: "" }))).toBe("WECOM_AGENT_SECRET");
|
||||
});
|
||||
|
||||
it("returns PASS_WITH_CONFIG_GAPS when runtime chain works but approval/callback config is missing", async () => {
|
||||
const fetchProfilesImpl = vi.fn(async ({ config, userids, limit }) => {
|
||||
expect(userids).toEqual([]);
|
||||
expect(limit).toBe(2);
|
||||
expect(config.wecomContactSecret).toBe(SENSITIVE_CONTACT_CREDENTIAL);
|
||||
expect(config.wecomAgentSecret).toBe(SENSITIVE_AGENT_CREDENTIAL);
|
||||
expect(config.wecomHrAssistantSecret).toBe(SENSITIVE_LEGACY_HR_CREDENTIAL);
|
||||
return {
|
||||
tokenSource: {
|
||||
contact: "WECOM_CONTACT_SECRET",
|
||||
hr: "WECOM_AGENT_SECRET",
|
||||
},
|
||||
profiles: [wecomProfile()],
|
||||
};
|
||||
});
|
||||
const fetchCheckinImpl = vi.fn(async ({ config, externalUserIds }) => {
|
||||
expect(resolveCheckinSecretSource(config)).toBe("WECOM_AGENT_SECRET");
|
||||
expect(externalUserIds).toEqual([SENSITIVE_USER_ID]);
|
||||
return {
|
||||
ok: true,
|
||||
code: "wecom_checkin_fetched",
|
||||
records: [],
|
||||
response: { checkindataCount: 0 },
|
||||
};
|
||||
});
|
||||
const sendMessageImpl = vi.fn();
|
||||
|
||||
const report = await executeRuntimeReady(baseOptions(), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
oaRows: [boundEmployee()],
|
||||
fetchProfilesImpl,
|
||||
fetchCheckinImpl,
|
||||
sendMessageImpl,
|
||||
generatedAt: "2026-05-19T01:00:00.000Z",
|
||||
now: new Date("2026-05-19T01:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_CONFIG_GAPS");
|
||||
expect(report.conclusion).toBe("PASS_WITH_CONFIG_GAPS");
|
||||
expect(report.checks.contactAndHrRead.status).toBe("OK");
|
||||
expect(report.checks.contactAndHrRead.contact.endpoint).toBe("POST /cgi-bin/user/list_id");
|
||||
expect(report.checks.contactAndHrRead.hrAssistant.endpoint).toBe("POST /cgi-bin/hr/get_staff_info");
|
||||
expect(report.checks.contactAndHrRead.tokenSource).toEqual({
|
||||
contact: "WECOM_CONTACT_SECRET",
|
||||
hr: "WECOM_AGENT_SECRET",
|
||||
});
|
||||
expect(report.checks.checkinRead.status).toBe("OK");
|
||||
expect(report.checks.message.status).toBe("DRY_RUN_READY");
|
||||
expect(sendMessageImpl).not.toHaveBeenCalled();
|
||||
expect(report.configGaps.map((item) => item.id)).toEqual([
|
||||
"approval_template_id",
|
||||
"approval_test_sp_no",
|
||||
"approval_callback_url",
|
||||
"callback_token",
|
||||
"callback_encoding_aes_key",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks instead of falling back when contact Secret is missing", async () => {
|
||||
const report = await executeRuntimeReady(baseOptions(), {
|
||||
config: baseConfig({ wecomContactSecret: "" }),
|
||||
env: {},
|
||||
oaRows: [boundEmployee()],
|
||||
fetchProfilesImpl: vi.fn(),
|
||||
fetchCheckinImpl: vi.fn(async () => ({ ok: true, code: "wecom_checkin_fetched", records: [], response: {} })),
|
||||
generatedAt: "2026-05-19T01:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(report.mainBlockers.some((item) => item.id === "secret_matrix")).toBe(true);
|
||||
expect(report.checks.contactAndHrRead).toMatchObject({
|
||||
status: "BLOCKED",
|
||||
missing: ["WECOM_CONTACT_SECRET"],
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies missing approval and callback values as NEEDS_CONFIG", () => {
|
||||
const checks = buildApprovalConfigChecks({
|
||||
WECOM_APPROVAL_TEMPLATE_ID: "approval-template-id",
|
||||
WECOM_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/callback",
|
||||
});
|
||||
|
||||
expect(checks.find((item) => item.id === "approval_template_id")).toMatchObject({
|
||||
status: "CONFIGURED",
|
||||
configuredEnv: "WECOM_APPROVAL_TEMPLATE_ID",
|
||||
});
|
||||
expect(checks.find((item) => item.id === "approval_callback_url")).toMatchObject({
|
||||
status: "CONFIGURED",
|
||||
configuredEnv: "WECOM_CALLBACK_URL",
|
||||
valuePreview: "https://oa.hyinda.com/...",
|
||||
});
|
||||
expect(checks.find((item) => item.id === "approval_test_sp_no")).toMatchObject({
|
||||
status: "NEEDS_CONFIG",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a test message only after double confirmation", async () => {
|
||||
const sendMessageImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
code: "wecom_app_sent",
|
||||
retryable: false,
|
||||
response: { errcode: 0, recipientCount: 1, invaliduser: SENSITIVE_USER_ID },
|
||||
message: null,
|
||||
}));
|
||||
const report = await executeRuntimeReady(baseOptions({
|
||||
sendTestMessage: true,
|
||||
messageRecipientEmployeeNo: "SMOKE002",
|
||||
}), {
|
||||
config: baseConfig(),
|
||||
env: {
|
||||
WECOM_RUNTIME_TEST_SEND: "1",
|
||||
WECOM_APPROVAL_TEMPLATE_ID: "tpl",
|
||||
WECOM_APPROVAL_TEST_SP_NO: "sp",
|
||||
WECOM_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/callback",
|
||||
WECOM_CALLBACK_TOKEN: SENSITIVE_CALLBACK_TOKEN,
|
||||
WECOM_CALLBACK_AES_KEY: SENSITIVE_CALLBACK_AES_KEY,
|
||||
},
|
||||
oaRows: [boundEmployee()],
|
||||
fetchProfilesImpl: vi.fn(async () => ({
|
||||
tokenSource: { contact: "WECOM_CONTACT_SECRET", hr: "WECOM_AGENT_SECRET" },
|
||||
profiles: [wecomProfile()],
|
||||
})),
|
||||
fetchCheckinImpl: vi.fn(async () => ({ ok: true, code: "wecom_checkin_fetched", records: [], response: {} })),
|
||||
sendMessageImpl,
|
||||
generatedAt: "2026-05-19T01:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_RUNTIME_READY");
|
||||
expect(sendMessageImpl).toHaveBeenCalledTimes(1);
|
||||
expect(report.checks.message.status).toBe("OK");
|
||||
expect(report.checks.message.result.response).toMatchObject({
|
||||
errcode: 0,
|
||||
recipientCount: 1,
|
||||
invaliduserPresent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps report output redacted", async () => {
|
||||
const report = await executeRuntimeReady(baseOptions(), {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
oaRows: [boundEmployee()],
|
||||
fetchProfilesImpl: vi.fn(async () => ({
|
||||
tokenSource: { contact: "WECOM_CONTACT_SECRET", hr: "WECOM_AGENT_SECRET" },
|
||||
profiles: [wecomProfile()],
|
||||
})),
|
||||
fetchCheckinImpl: vi.fn(async () => ({
|
||||
ok: true,
|
||||
code: "wecom_checkin_fetched",
|
||||
records: [],
|
||||
response: { checkindataCount: 0 },
|
||||
})),
|
||||
generatedAt: "2026-05-19T01:00:00.000Z",
|
||||
});
|
||||
const serialized = JSON.stringify(report);
|
||||
|
||||
expect(serialized).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(serialized).not.toContain(SENSITIVE_AGENT_CREDENTIAL);
|
||||
expect(serialized).not.toContain(SENSITIVE_CONTACT_CREDENTIAL);
|
||||
expect(serialized).not.toContain(SENSITIVE_LEGACY_HR_CREDENTIAL);
|
||||
expect(serialized).not.toContain(SENSITIVE_PHONE);
|
||||
expect(serialized).not.toContain(SENSITIVE_EMAIL);
|
||||
expect(serialized).toContain("oa_smoke***");
|
||||
});
|
||||
|
||||
it("evaluates final conclusion categories", () => {
|
||||
expect(evaluateConclusion({ mainBlockers: [{ id: "secret_matrix" }], configGaps: [], live: true })).toEqual({
|
||||
status: "SKIP",
|
||||
conclusion: "BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
});
|
||||
expect(evaluateConclusion({ mainBlockers: [], configGaps: [{ id: "approval_template_id" }], live: true })).toEqual({
|
||||
status: "PASS_WITH_CONFIG_GAPS",
|
||||
conclusion: "PASS_WITH_CONFIG_GAPS",
|
||||
});
|
||||
expect(evaluateConclusion({ mainBlockers: [], configGaps: [], live: true })).toEqual({
|
||||
status: "PASS_RUNTIME_READY",
|
||||
conclusion: "PASS_RUNTIME_READY",
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户