WOUA 增加 OA 与企微用户只读对账
这个提交包含在:
@@ -28,6 +28,7 @@
|
||||
"job:workflow-overdue-reminders": "node src/scripts/run-workflow-overdue-reminders.js",
|
||||
"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",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
#!/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 DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_REPORT_ITEMS = 200;
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/audit-wecom-oa-user-consistency.js [options]
|
||||
|
||||
只读对账 OA active 员工与企业微信通讯录 + 人事助手资料。
|
||||
脚本不会调用 WHMS dry-run/apply API,不会写 OA/企微用户数据。
|
||||
|
||||
Options:
|
||||
--live 启用企微 live 只读读取;未指定时输出 LIVE_DISABLED SKIP。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--limit=<n> 企微通讯录读取上限,默认 200。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-oa-user-consistency-<timestamp>。
|
||||
--help 显示帮助。
|
||||
|
||||
隐私边界:
|
||||
输出只包含员工号、状态、角色摘要、脱敏企微 userid 前缀、计数和问题类型。
|
||||
不输出手机号、邮箱、密码哈希、token、secret、完整企微 userid 或原始 payload。
|
||||
`);
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
live: process.env.WOUA_LIVE_READ === "1",
|
||||
format: "text",
|
||||
limit: Number(process.env.WOUA_LIMIT || DEFAULT_LIMIT),
|
||||
reportDir: process.env.WOUA_REPORT_DIR || path.join(DEFAULT_REPORT_ROOT, `wecom-oa-user-consistency-${timestamp()}`),
|
||||
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 {
|
||||
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(200, Math.floor(options.limit)))
|
||||
: DEFAULT_LIMIT;
|
||||
return options;
|
||||
}
|
||||
|
||||
function normalizeKey(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
return raw ? raw.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function safeMessage(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
||||
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
function isOaActive(employee) {
|
||||
return employee.status === "active" && !employee.archived;
|
||||
}
|
||||
|
||||
function isWecomActive(profile) {
|
||||
return profile.sourceStatus === "active";
|
||||
}
|
||||
|
||||
function sanitizeRoles(roles) {
|
||||
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string").sort() : [];
|
||||
}
|
||||
|
||||
function sanitizeEmployee(employee) {
|
||||
if (!employee) return null;
|
||||
return {
|
||||
employeeNo: employee.employeeNo,
|
||||
status: employee.status,
|
||||
archived: Boolean(employee.archived),
|
||||
roles: sanitizeRoles(employee.roles),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeWecomProfile(profile) {
|
||||
if (!profile) return null;
|
||||
return {
|
||||
externalUserIdPrefix: maskExternalUserId(profile.externalUserId),
|
||||
sourceStatus: profile.sourceStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMatched(employee, profile, matchedBy) {
|
||||
return {
|
||||
employee: sanitizeEmployee(employee),
|
||||
wecom: sanitizeWecomProfile(profile),
|
||||
matchedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function addToIndex(index, key, employee) {
|
||||
if (!key) return;
|
||||
const bucket = index.get(key) || [];
|
||||
bucket.push(employee);
|
||||
index.set(key, bucket);
|
||||
}
|
||||
|
||||
function buildOaIndexes(employees) {
|
||||
const byBinding = new Map();
|
||||
const byEmployeeNo = new Map();
|
||||
const byPhone = new Map();
|
||||
const byEmail = new Map();
|
||||
|
||||
for (const employee of employees) {
|
||||
addToIndex(byBinding, normalizeKey(employee.wecomExternalUserId), employee);
|
||||
addToIndex(byEmployeeNo, normalizeKey(employee.employeeNo), employee);
|
||||
addToIndex(byPhone, normalizeKey(employee.phone), employee);
|
||||
addToIndex(byEmail, normalizeKey(employee.email), employee);
|
||||
}
|
||||
|
||||
return { byBinding, byEmployeeNo, byPhone, byEmail };
|
||||
}
|
||||
|
||||
function firstIndexMatch(index, key) {
|
||||
const bucket = key ? index.get(key) || [] : [];
|
||||
if (bucket.length === 1) return { employee: bucket[0], duplicate: false };
|
||||
if (bucket.length > 1) return { employee: null, duplicate: true, employees: bucket };
|
||||
return { employee: null, duplicate: false };
|
||||
}
|
||||
|
||||
function collectMatchCandidates(profile, indexes) {
|
||||
const externalIdKey = normalizeKey(profile.externalUserId);
|
||||
const checks = [
|
||||
["binding_external_user_id", indexes.byBinding, externalIdKey],
|
||||
["employee_no_or_userid", indexes.byEmployeeNo, externalIdKey],
|
||||
["phone", indexes.byPhone, normalizeKey(profile.phone)],
|
||||
["email", indexes.byEmail, normalizeKey(profile.email)],
|
||||
];
|
||||
|
||||
const candidates = [];
|
||||
const duplicateKeys = [];
|
||||
for (const [method, index, key] of checks) {
|
||||
if (!key) continue;
|
||||
const match = firstIndexMatch(index, key);
|
||||
if (match.duplicate) {
|
||||
duplicateKeys.push({ method, count: match.employees.length });
|
||||
} else if (match.employee) {
|
||||
candidates.push({ method, employee: match.employee });
|
||||
}
|
||||
}
|
||||
|
||||
const unique = new Map();
|
||||
for (const candidate of candidates) {
|
||||
if (!unique.has(candidate.employee.id)) {
|
||||
unique.set(candidate.employee.id, { employee: candidate.employee, methods: [candidate.method] });
|
||||
} else {
|
||||
unique.get(candidate.employee.id).methods.push(candidate.method);
|
||||
}
|
||||
}
|
||||
|
||||
return { unique: Array.from(unique.values()), duplicateKeys };
|
||||
}
|
||||
|
||||
function buildConflictItem(profile, reason, details = {}) {
|
||||
return {
|
||||
reason,
|
||||
wecom: sanitizeWecomProfile(profile),
|
||||
...details,
|
||||
};
|
||||
}
|
||||
|
||||
function sample(items) {
|
||||
return items.slice(0, MAX_REPORT_ITEMS);
|
||||
}
|
||||
|
||||
function summarizeFindings(findings) {
|
||||
const summary = {};
|
||||
for (const [key, items] of Object.entries(findings)) {
|
||||
summary[key] = Array.isArray(items) ? items.length : 0;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new Date().toISOString(), limit = DEFAULT_LIMIT }) {
|
||||
const findings = {
|
||||
matchedActive: [],
|
||||
oaActiveMissingInWecom: [],
|
||||
wecomActiveMissingInOa: [],
|
||||
oaActiveMissingBinding: [],
|
||||
inactiveWithBinding: [],
|
||||
statusMismatch: [],
|
||||
missingRequiredFields: [],
|
||||
matchConflict: [],
|
||||
};
|
||||
const matchedOrConflictedEmployeeIds = new Set();
|
||||
const seenWecomIds = new Set();
|
||||
const indexes = buildOaIndexes(oaEmployees);
|
||||
|
||||
for (const employee of oaEmployees) {
|
||||
if (!isOaActive(employee) && employee.wecomExternalUserId) {
|
||||
findings.inactiveWithBinding.push(sanitizeEmployee(employee));
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of wecomProfiles) {
|
||||
const externalKey = normalizeKey(profile.externalUserId);
|
||||
if (externalKey && seenWecomIds.has(externalKey)) {
|
||||
findings.matchConflict.push(buildConflictItem(profile, "duplicate_wecom_external_user_id"));
|
||||
continue;
|
||||
}
|
||||
if (externalKey) seenWecomIds.add(externalKey);
|
||||
|
||||
const candidates = collectMatchCandidates(profile, indexes);
|
||||
if (candidates.duplicateKeys.length) {
|
||||
findings.matchConflict.push(buildConflictItem(profile, "duplicate_oa_match_key", { duplicateKeys: candidates.duplicateKeys }));
|
||||
for (const candidate of candidates.unique) matchedOrConflictedEmployeeIds.add(candidate.employee.id);
|
||||
continue;
|
||||
}
|
||||
if (candidates.unique.length > 1) {
|
||||
findings.matchConflict.push(buildConflictItem(profile, "multiple_oa_candidates", {
|
||||
candidates: candidates.unique.map((candidate) => ({
|
||||
employee: sanitizeEmployee(candidate.employee),
|
||||
methods: candidate.methods,
|
||||
})),
|
||||
}));
|
||||
for (const candidate of candidates.unique) matchedOrConflictedEmployeeIds.add(candidate.employee.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = candidates.unique[0] || null;
|
||||
if (!match) {
|
||||
if (isWecomActive(profile)) {
|
||||
findings.wecomActiveMissingInOa.push({ wecom: sanitizeWecomProfile(profile) });
|
||||
}
|
||||
if (profile.missingRequiredFields?.length) {
|
||||
findings.missingRequiredFields.push({
|
||||
employee: null,
|
||||
wecom: sanitizeWecomProfile(profile),
|
||||
fields: [...profile.missingRequiredFields].sort(),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const employee = match.employee;
|
||||
matchedOrConflictedEmployeeIds.add(employee.id);
|
||||
const matchedBy = match.methods[0];
|
||||
const oaActive = isOaActive(employee);
|
||||
const wecomActive = isWecomActive(profile);
|
||||
|
||||
if (oaActive && wecomActive) {
|
||||
findings.matchedActive.push(sanitizeMatched(employee, profile, matchedBy));
|
||||
}
|
||||
if (oaActive && !employee.wecomExternalUserId) {
|
||||
findings.oaActiveMissingBinding.push(sanitizeMatched(employee, profile, matchedBy));
|
||||
}
|
||||
if (oaActive !== wecomActive) {
|
||||
findings.statusMismatch.push({
|
||||
...sanitizeMatched(employee, profile, matchedBy),
|
||||
oaActive,
|
||||
wecomActive,
|
||||
});
|
||||
}
|
||||
if (profile.missingRequiredFields?.length) {
|
||||
findings.missingRequiredFields.push({
|
||||
...sanitizeMatched(employee, profile, matchedBy),
|
||||
fields: [...profile.missingRequiredFields].sort(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const employee of oaEmployees) {
|
||||
if (isOaActive(employee) && !matchedOrConflictedEmployeeIds.has(employee.id)) {
|
||||
findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee));
|
||||
}
|
||||
}
|
||||
|
||||
const findingCounts = summarizeFindings(findings);
|
||||
const findingTotal = Object.entries(findingCounts)
|
||||
.filter(([key]) => key !== "matchedActive")
|
||||
.reduce((total, [, count]) => total + count, 0);
|
||||
|
||||
return {
|
||||
mode: "read_only",
|
||||
generatedAt,
|
||||
status: findingTotal > 0 ? "PASS_WITH_FINDINGS" : "PASS_NO_DRIFT",
|
||||
privacy: "employee_no/status/roles/masked wecom userid only; no phone/email/password/token/secret/full userid/raw payload",
|
||||
scope: {
|
||||
oa: "active employees and wecom identity bindings",
|
||||
wecom: "contact members plus hr assistant staff info",
|
||||
writeBehavior: "no OA writes, no WeCom writes, no WHMS dry-run/apply API call",
|
||||
},
|
||||
inputs: {
|
||||
limit,
|
||||
mayBeTruncated: wecomProfiles.length >= limit,
|
||||
},
|
||||
totals: {
|
||||
oaEmployees: oaEmployees.length,
|
||||
oaActiveEmployees: oaEmployees.filter(isOaActive).length,
|
||||
oaActiveWithBinding: oaEmployees.filter((employee) => isOaActive(employee) && employee.wecomExternalUserId).length,
|
||||
wecomProfiles: wecomProfiles.length,
|
||||
wecomActiveProfiles: wecomProfiles.filter(isWecomActive).length,
|
||||
},
|
||||
findingCounts,
|
||||
findings: Object.fromEntries(Object.entries(findings).map(([key, value]) => [key, sample(value)])),
|
||||
};
|
||||
}
|
||||
|
||||
async function queryOaEmployees(client) {
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
e.id::text,
|
||||
e.employee_no,
|
||||
e.name,
|
||||
e.phone,
|
||||
e.email,
|
||||
e.status::text,
|
||||
(e.archived_at IS NOT NULL) AS archived,
|
||||
COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles,
|
||||
w.external_user_id AS wecom_external_user_id
|
||||
FROM employees e
|
||||
LEFT JOIN employee_roles er ON er.employee_id = e.id
|
||||
LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id
|
||||
GROUP BY
|
||||
e.id,
|
||||
e.employee_no,
|
||||
e.name,
|
||||
e.phone,
|
||||
e.email,
|
||||
e.status,
|
||||
e.archived_at,
|
||||
w.external_user_id
|
||||
ORDER BY e.employee_no ASC
|
||||
`);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
employeeNo: row.employee_no,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
status: row.status,
|
||||
archived: Boolean(row.archived),
|
||||
roles: row.roles || [],
|
||||
wecomExternalUserId: row.wecom_external_user_id || "",
|
||||
}));
|
||||
}
|
||||
|
||||
async function queryOaEmployeesReadOnly() {
|
||||
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 employees = await queryOaEmployees(client);
|
||||
await client.query("COMMIT");
|
||||
return employees;
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {
|
||||
// Keep the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function buildSkipReport({ generatedAt, reason, details, oaEmployees = [], limit }) {
|
||||
const findings = {
|
||||
matchedActive: [],
|
||||
oaActiveMissingInWecom: [],
|
||||
wecomActiveMissingInOa: [],
|
||||
oaActiveMissingBinding: [],
|
||||
inactiveWithBinding: [],
|
||||
statusMismatch: [],
|
||||
missingRequiredFields: [],
|
||||
matchConflict: [],
|
||||
};
|
||||
return {
|
||||
mode: "read_only",
|
||||
generatedAt,
|
||||
status: "SKIP",
|
||||
conclusion: reason,
|
||||
privacy: "employee_no/status/roles only; no phone/email/password/token/secret/full userid/raw payload",
|
||||
scope: {
|
||||
oa: "active employees and wecom identity bindings",
|
||||
wecom: "contact members plus hr assistant staff info",
|
||||
writeBehavior: "no OA writes, no WeCom writes, no WHMS dry-run/apply API call",
|
||||
},
|
||||
inputs: { limit },
|
||||
totals: {
|
||||
oaEmployees: oaEmployees.length,
|
||||
oaActiveEmployees: oaEmployees.filter(isOaActive).length,
|
||||
oaActiveWithBinding: oaEmployees.filter((employee) => isOaActive(employee) && employee.wecomExternalUserId).length,
|
||||
},
|
||||
findingCounts: summarizeFindings(findings),
|
||||
findings,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeAudit(options) {
|
||||
const generatedAt = new Date().toISOString();
|
||||
const oaEmployees = await queryOaEmployeesReadOnly();
|
||||
|
||||
if (!options.live) {
|
||||
return buildSkipReport({
|
||||
generatedAt,
|
||||
reason: "LIVE_DISABLED",
|
||||
details: { runWith: "--live" },
|
||||
oaEmployees,
|
||||
limit: options.limit,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getConfig();
|
||||
const { profiles, tokenSource } = await fetchWecomHrProfiles({
|
||||
config,
|
||||
userids: [],
|
||||
limit: options.limit,
|
||||
});
|
||||
const report = buildConsistencyReport({
|
||||
oaEmployees,
|
||||
wecomProfiles: profiles,
|
||||
generatedAt,
|
||||
limit: options.limit,
|
||||
});
|
||||
return {
|
||||
...report,
|
||||
tokenSource: {
|
||||
contact: tokenSource?.contact || null,
|
||||
hr: tokenSource?.hr || null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const details = {
|
||||
code: error?.code || (error instanceof WecomHrMasterSyncError ? "wecom_hr_master_sync_error" : "wecom_read_failed"),
|
||||
message: safeMessage(error?.message || error),
|
||||
};
|
||||
return buildSkipReport({
|
||||
generatedAt,
|
||||
reason: "BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
details,
|
||||
oaEmployees,
|
||||
limit: options.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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-oa-user-consistency] status=${report.status}${report.conclusion ? ` conclusion=${report.conclusion}` : ""}\n`);
|
||||
process.stdout.write(`[wecom-oa-user-consistency] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-oa-user-consistency] privacy=${report.privacy}\n`);
|
||||
if (report.totals) {
|
||||
process.stdout.write(`[wecom-oa-user-consistency] totals=${JSON.stringify(report.totals)}\n`);
|
||||
}
|
||||
if (report.findingCounts) {
|
||||
process.stdout.write(`[wecom-oa-user-consistency] findingCounts=${JSON.stringify(report.findingCounts)}\n`);
|
||||
}
|
||||
if (report.details) {
|
||||
process.stdout.write(`[wecom-oa-user-consistency] details=${JSON.stringify(report.details)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const report = await executeAudit(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-oa-user-consistency][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildConsistencyReport,
|
||||
collectMatchCandidates,
|
||||
executeAudit,
|
||||
isOaActive,
|
||||
isWecomActive,
|
||||
normalizeKey,
|
||||
parseArgs,
|
||||
sanitizeEmployee,
|
||||
sanitizeWecomProfile,
|
||||
summarizeFindings,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
const {
|
||||
buildConsistencyReport,
|
||||
} = require("../../../src/scripts/audit-wecom-oa-user-consistency");
|
||||
|
||||
function employee(overrides = {}) {
|
||||
return {
|
||||
id: overrides.id ?? "employee-1",
|
||||
employeeNo: overrides.employeeNo ?? "E001",
|
||||
name: overrides.name ?? "员工一",
|
||||
phone: overrides.phone ?? "13800000001",
|
||||
email: overrides.email ?? "[email protected]",
|
||||
status: overrides.status ?? "active",
|
||||
archived: overrides.archived ?? false,
|
||||
roles: overrides.roles ?? ["employee"],
|
||||
wecomExternalUserId: overrides.wecomExternalUserId ?? "wecom_user_001_very_long",
|
||||
};
|
||||
}
|
||||
|
||||
function profile(overrides = {}) {
|
||||
return {
|
||||
externalUserId: overrides.externalUserId ?? "wecom_user_001_very_long",
|
||||
name: overrides.name ?? "员工一",
|
||||
phone: overrides.phone ?? "13800000001",
|
||||
email: overrides.email ?? "[email protected]",
|
||||
title: overrides.title ?? "工程师",
|
||||
departmentExternalId: overrides.departmentExternalId ?? "1",
|
||||
departmentName: overrides.departmentName ?? "研发部",
|
||||
sourceStatus: overrides.sourceStatus ?? "active",
|
||||
missingRequiredFields: overrides.missingRequiredFields ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function count(report, key) {
|
||||
return report.findingCounts[key];
|
||||
}
|
||||
|
||||
describe("audit-wecom-oa-user-consistency", () => {
|
||||
it("reports no drift when OA active employee matches WeCom active profile by binding", () => {
|
||||
const report = buildConsistencyReport({
|
||||
oaEmployees: [employee()],
|
||||
wecomProfiles: [profile()],
|
||||
generatedAt: "2026-05-18T00:00:00.000Z",
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_NO_DRIFT");
|
||||
expect(count(report, "matchedActive")).toBe(1);
|
||||
expect(count(report, "oaActiveMissingInWecom")).toBe(0);
|
||||
expect(count(report, "wecomActiveMissingInOa")).toBe(0);
|
||||
expect(count(report, "matchConflict")).toBe(0);
|
||||
});
|
||||
|
||||
it("classifies OA active employees missing from WeCom", () => {
|
||||
const report = buildConsistencyReport({
|
||||
oaEmployees: [employee({ employeeNo: "E002", wecomExternalUserId: "" })],
|
||||
wecomProfiles: [],
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_FINDINGS");
|
||||
expect(count(report, "oaActiveMissingInWecom")).toBe(1);
|
||||
expect(report.findings.oaActiveMissingInWecom[0]).toMatchObject({
|
||||
employeeNo: "E002",
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies WeCom active profiles missing from OA", () => {
|
||||
const report = buildConsistencyReport({
|
||||
oaEmployees: [],
|
||||
wecomProfiles: [profile({ externalUserId: "wecom_user_new_very_long" })],
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_FINDINGS");
|
||||
expect(count(report, "wecomActiveMissingInOa")).toBe(1);
|
||||
expect(report.findings.wecomActiveMissingInOa[0].wecom.externalUserIdPrefix).toBe("wecom_us***");
|
||||
});
|
||||
|
||||
it("classifies missing required WeCom fields without exposing phone or email", () => {
|
||||
const report = buildConsistencyReport({
|
||||
oaEmployees: [employee()],
|
||||
wecomProfiles: [
|
||||
profile({
|
||||
phone: "",
|
||||
email: "",
|
||||
departmentExternalId: "",
|
||||
departmentName: "",
|
||||
missingRequiredFields: ["phone", "email", "department"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const serialized = JSON.stringify(report);
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_FINDINGS");
|
||||
expect(count(report, "missingRequiredFields")).toBe(1);
|
||||
expect(report.findings.missingRequiredFields[0].fields).toEqual(["department", "email", "phone"]);
|
||||
expect(serialized).not.toContain("13800000001");
|
||||
expect(serialized).not.toContain("[email protected]");
|
||||
expect(serialized).not.toContain("wecom_user_001_very_long");
|
||||
});
|
||||
|
||||
it("classifies inactive OA employees that still keep a WeCom binding", () => {
|
||||
const report = buildConsistencyReport({
|
||||
oaEmployees: [
|
||||
employee({
|
||||
employeeNo: "HRWDTEST-20260517214548",
|
||||
status: "archived",
|
||||
archived: true,
|
||||
wecomExternalUserId: "HRWDTEST-20260517214548",
|
||||
}),
|
||||
],
|
||||
wecomProfiles: [],
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_FINDINGS");
|
||||
expect(count(report, "inactiveWithBinding")).toBe(1);
|
||||
expect(report.findings.inactiveWithBinding[0].employeeNo).toBe("HRWDTEST-20260517214548");
|
||||
});
|
||||
|
||||
it("detects match conflicts across priority keys", () => {
|
||||
const report = buildConsistencyReport({
|
||||
oaEmployees: [
|
||||
employee({
|
||||
id: "employee-by-no",
|
||||
employeeNo: "wecom_user_conflict_very_long",
|
||||
phone: "13800000001",
|
||||
email: "[email protected]",
|
||||
wecomExternalUserId: "",
|
||||
}),
|
||||
employee({
|
||||
id: "employee-by-phone",
|
||||
employeeNo: "E003",
|
||||
phone: "13900000002",
|
||||
email: "[email protected]",
|
||||
wecomExternalUserId: "",
|
||||
}),
|
||||
],
|
||||
wecomProfiles: [
|
||||
profile({
|
||||
externalUserId: "wecom_user_conflict_very_long",
|
||||
phone: "13900000002",
|
||||
email: "[email protected]",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_WITH_FINDINGS");
|
||||
expect(count(report, "matchConflict")).toBe(1);
|
||||
expect(report.findings.matchConflict[0]).toMatchObject({ reason: "multiple_oa_candidates" });
|
||||
expect(report.findings.matchConflict[0].candidates).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户