OSMOKE1 修复 OA-only smoke 账号治理

这个提交包含在:
2026-05-23 14:46:14 +08:00
父节点 88b06346da
当前提交 5a64809004
共修改 15 个文件,包含 1209 行新增和 13 行删除
+1
查看文件
@@ -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",
"repair:oa-only-smoke": "node src/scripts/repair-oa-only-smoke-accounts.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",
@@ -15,6 +15,15 @@ const {
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_LIMIT = 200;
const MAX_REPORT_ITEMS = 200;
const OA_ONLY_SMOKE_IDENTIFIER_ENVS = [
"LIVE_SMOKE_ADMIN_IDENTIFIER",
"LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
"LIVE_SMOKE_MANAGER_IDENTIFIER",
"LIVE_SMOKE_CEO_IDENTIFIER",
"LIVE_SMOKE_FINANCE_IDENTIFIER",
"LIVE_SMOKE_FINANCE_MGR_IDENTIFIER",
"LIVE_SMOKE_HR_IDENTIFIER",
];
function printHelp() {
process.stdout.write(`Usage: node src/scripts/audit-wecom-oa-user-consistency.js [options]
@@ -90,6 +99,43 @@ function normalizeKey(value) {
return raw ? raw.toLowerCase() : "";
}
function splitList(value) {
return String(value ?? "")
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function buildOaOnlySmokeIdentifierSet(env = process.env) {
const identifiers = [
...splitList(env.OA_ONLY_SMOKE_IDENTIFIERS),
...OA_ONLY_SMOKE_IDENTIFIER_ENVS.map((key) => env[key]).filter(Boolean),
];
return new Set(identifiers.map(normalizeKey).filter(Boolean));
}
function isDefaultOaOnlySmokeCandidate(employee) {
const employeeNo = String(employee?.employeeNo ?? "").trim();
const emailLocal = String(employee?.email ?? "").trim().toLowerCase().split("@")[0] || "";
const name = String(employee?.name ?? "");
return (
/^SMOKE[A-Z0-9_-]*/i.test(employeeNo)
|| /^smoke[-_.]/.test(emailLocal)
|| /^oa[-_.]?smoke[-_.]/.test(emailLocal)
|| /测试|试运行/.test(name)
);
}
function isOaOnlySmokeEmployee(employee, identifierSet) {
if (!employee) return false;
if (isDefaultOaOnlySmokeCandidate(employee)) return true;
if (!identifierSet?.size) return false;
return [employee.employeeNo, employee.email, employee.phone]
.map(normalizeKey)
.filter(Boolean)
.some((key) => identifierSet.has(key));
}
function safeMessage(value) {
return String(value ?? "")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
@@ -219,8 +265,10 @@ function summarizeFindings(findings) {
}
function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new Date().toISOString(), limit = DEFAULT_LIMIT }) {
const oaOnlySmokeIdentifierSet = buildOaOnlySmokeIdentifierSet();
const findings = {
matchedActive: [],
oaOnlySmokeExempted: [],
oaActiveMissingInWecom: [],
wecomActiveMissingInOa: [],
oaActiveMissingBinding: [],
@@ -308,13 +356,20 @@ function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new
for (const employee of oaEmployees) {
if (isOaActive(employee) && !matchedOrConflictedEmployeeIds.has(employee.id)) {
findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee));
if (isOaOnlySmokeEmployee(employee, oaOnlySmokeIdentifierSet)) {
findings.oaOnlySmokeExempted.push({
...sanitizeEmployee(employee),
reason: "oa_only_smoke_no_wecom_account_required",
});
} else {
findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee));
}
}
}
const findingCounts = summarizeFindings(findings);
const findingTotal = Object.entries(findingCounts)
.filter(([key]) => key !== "matchedActive")
.filter(([key]) => !["matchedActive", "oaOnlySmokeExempted"].includes(key))
.reduce((total, [, count]) => total + count, 0);
return {
@@ -326,6 +381,7 @@ function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new
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",
oaOnlySmoke: "env-listed OA-only smoke accounts are exempt from WeCom-missing findings",
},
inputs: {
limit,
@@ -410,6 +466,7 @@ async function queryOaEmployeesReadOnly() {
function buildSkipReport({ generatedAt, reason, details, oaEmployees = [], limit }) {
const findings = {
matchedActive: [],
oaOnlySmokeExempted: [],
oaActiveMissingInWecom: [],
wecomActiveMissingInOa: [],
oaActiveMissingBinding: [],
@@ -0,0 +1,656 @@
#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { Pool } = require("pg");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const ROLE_VALUES = new Set([
"employee",
"manager",
"ceo",
"finance",
"finance_mgr",
"administration",
"hr",
"admin",
]);
const DEFAULT_TARGETS = [
{ key: "admin", identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER", roles: ["admin", "employee"] },
{ key: "employee", identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", roles: ["employee"] },
{ key: "manager", identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER", roles: ["manager", "employee"] },
{ key: "ceo", identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER", roles: ["ceo", "employee"] },
{ key: "finance", identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER", roles: ["finance", "employee"] },
{ key: "finance_mgr", identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", roles: ["finance", "finance_mgr", "employee"] },
{ key: "hr", identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER", roles: ["hr", "employee"] },
];
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function printHelp() {
process.stdout.write(`Usage: node src/scripts/repair-oa-only-smoke-accounts.js [options]
治理 OA-only smoke 账号。默认 dry-run;真实修复必须同时指定 --apply 且设置 OA_ONLY_SMOKE_REPAIR_APPLY=1。
Options:
--dry-run 只生成计划,不写数据库,默认。
--apply 执行受控 OA DB 修复,需 OA_ONLY_SMOKE_REPAIR_APPLY=1。
--format=text|json 输出格式,默认 text。
--report-dir=<path> 输出目录,默认 artifacts/qa-flow/oa-only-smoke-repair-<timestamp>。
--target=<key>:<identifier>:<role+role>
追加一个目标;identifier 可为 employee_no/email/phone。
--help 显示帮助。
环境变量:
LIVE_SMOKE_*_IDENTIFIER 默认目标来源;不会读取或输出密码。
OA_ONLY_SMOKE_TARGETS_JSON 可选 JSON 数组,元素形如 {"key":"admin","identifier":"...","roles":["admin","employee"]}。
隐私边界:
输出只包含目标 key、脱敏标识、员工号、状态、角色和动作。
不输出手机号、邮箱、密码哈希、密码、token、secret、完整本地路径或原始 payload。
`);
}
function splitList(value) {
return String(value ?? "")
.split(/[,+\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeRoleList(roles) {
const values = Array.isArray(roles) ? roles : splitList(roles);
const seen = new Set();
const result = [];
for (const raw of values) {
const role = String(raw ?? "").trim();
if (!ROLE_VALUES.has(role) || seen.has(role)) continue;
seen.add(role);
result.push(role);
}
return result.length ? result : ["employee"];
}
function normalizeIdentifier(value) {
return String(value ?? "").trim().slice(0, 256);
}
function normalizeKey(value) {
return String(value ?? "").trim().slice(0, 64) || "target";
}
function maskIdentifier(value) {
const text = normalizeIdentifier(value);
if (!text) return null;
if (text.includes("@")) {
const [local, domain] = text.split("@");
const safeLocal = local.length <= 2 ? `${local[0] || "*"}***` : `${local.slice(0, 2)}***`;
const domainParts = String(domain || "").split(".");
const safeDomain = domainParts.length > 1 ? `***.${domainParts.at(-1)}` : "***";
return `${safeLocal}@${safeDomain}`;
}
if (text.length <= 4) return "***";
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
function sanitizeEmployeeNo(value) {
return String(value ?? "").trim().slice(0, 64);
}
function sanitizeRoles(roles) {
return Array.isArray(roles)
? roles.filter((role) => typeof role === "string").sort()
: [];
}
function isSmokeLikeValue(value) {
const text = String(value ?? "").trim();
if (!text) return false;
const lower = text.toLowerCase();
const emailLocal = lower.includes("@") ? lower.split("@")[0] : lower;
return (
/^smoke[-_.]/.test(emailLocal)
|| /^oa[-_.]?smoke[-_.]/.test(emailLocal)
|| /^(SMOKE|OSMOKE|WFULL|PRELAUNCH|LAUNCHAUTO|HRWDTEST)[A-Z0-9_-]*/i.test(text)
|| /测试|试运行/.test(text)
);
}
function isSafeOaOnlySmokeTarget(target, row) {
return [
target?.identifier,
row?.employeeNo,
row?.email,
row?.name,
].some(isSmokeLikeValue);
}
function sanitizeErrorMessage(value) {
return String(value ?? "")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
.slice(0, 300);
}
function parseTargetArg(value) {
const parts = String(value ?? "").split(":");
if (parts.length < 2) {
throw new Error("--target must be <key>:<identifier>:<role+role>");
}
const [key, identifier, rolesRaw = "employee"] = parts;
const normalizedIdentifier = normalizeIdentifier(identifier);
if (!normalizedIdentifier) {
throw new Error("--target identifier is empty");
}
return {
key: normalizeKey(key),
identifier: normalizedIdentifier,
roles: normalizeRoleList(rolesRaw),
source: "cli",
};
}
function parseArgs(argv) {
const options = {
mode: "dry_run",
format: "text",
reportDir: process.env.OA_ONLY_SMOKE_REPAIR_REPORT_DIR
|| path.join(DEFAULT_REPORT_ROOT, `oa-only-smoke-repair-${timestamp()}`),
targets: [],
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 === "--dry-run") {
options.mode = "dry_run";
} else if (arg === "--apply") {
options.mode = "apply";
} 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 === "--target") {
options.targets.push(parseTargetArg(argv[index + 1] || ""));
index += 1;
} else if (arg.startsWith("--target=")) {
options.targets.push(parseTargetArg(arg.slice("--target=".length)));
} else {
throw new Error(`Unsupported argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
return options;
}
function parseJsonTargets() {
const raw = String(process.env.OA_ONLY_SMOKE_TARGETS_JSON ?? "").trim();
if (!raw) return [];
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Invalid OA_ONLY_SMOKE_TARGETS_JSON: ${error.message}`);
}
if (!Array.isArray(parsed)) {
throw new Error("OA_ONLY_SMOKE_TARGETS_JSON must be a JSON array");
}
return parsed.map((item, index) => {
const identifier = normalizeIdentifier(item?.identifier);
if (!identifier) {
throw new Error(`OA_ONLY_SMOKE_TARGETS_JSON[${index}].identifier is empty`);
}
return {
key: normalizeKey(item?.key || `json_${index + 1}`),
identifier,
roles: normalizeRoleList(item?.roles),
source: "env_json",
};
});
}
function loadTargets(options) {
const defaults = DEFAULT_TARGETS
.map((target) => {
const identifier = normalizeIdentifier(process.env[target.identifierEnv]);
if (!identifier) return null;
return {
key: target.key,
identifier,
roles: target.roles,
source: target.identifierEnv,
};
})
.filter(Boolean);
const merged = [...defaults, ...parseJsonTargets(), ...options.targets];
const seen = new Set();
const result = [];
for (const target of merged) {
const identityKey = `${target.key}:${target.identifier.toLowerCase()}`;
if (seen.has(identityKey)) continue;
seen.add(identityKey);
result.push({
key: target.key,
identifier: target.identifier,
maskedIdentifier: maskIdentifier(target.identifier),
roles: normalizeRoleList(target.roles),
source: target.source,
});
}
return result;
}
function assertApplyAllowed(options) {
if (options.mode !== "apply") return;
if (process.env.OA_ONLY_SMOKE_REPAIR_APPLY !== "1") {
throw new Error("Refusing apply without OA_ONLY_SMOKE_REPAIR_APPLY=1");
}
}
async function queryTarget(client, identifier) {
const result = await client.query(
`
SELECT
e.id::text,
e.employee_no,
e.name,
e.email,
e.phone,
e.status::text,
e.employment_status::text,
(e.archived_at IS NOT NULL) AS archived,
e.login_failed_count,
e.login_failed_first_at IS NOT NULL AS has_login_failed_first_at,
e.login_locked_until,
COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles,
(
SELECT COUNT(*)::int
FROM auth_sessions s
WHERE s.employee_id = e.id
AND s.revoked_at IS NULL
) AS active_auth_sessions
FROM employees e
LEFT JOIN employee_roles er ON er.employee_id = e.id
WHERE e.employee_no = $1
OR lower(e.email) = lower($1)
OR e.phone = $1
GROUP BY e.id
ORDER BY
CASE WHEN e.status = 'active' AND e.archived_at IS NULL THEN 0 ELSE 1 END,
e.employee_no ASC
LIMIT 1
`,
[identifier],
);
const row = result.rows[0];
if (!row) return null;
return {
id: row.id,
employeeNo: sanitizeEmployeeNo(row.employee_no),
name: row.name || "",
email: row.email || "",
phone: row.phone || "",
status: row.status,
employmentStatus: row.employment_status,
archived: Boolean(row.archived),
loginFailedCount: Number(row.login_failed_count ?? 0),
hasLoginFailedFirstAt: Boolean(row.has_login_failed_first_at),
loginLocked: row.login_locked_until ? new Date(row.login_locked_until) > new Date() : false,
roles: sanitizeRoles(row.roles),
activeAuthSessions: Number(row.active_auth_sessions ?? 0),
};
}
function planForTarget(target, row) {
if (!row) {
return {
key: target.key,
maskedIdentifier: target.maskedIdentifier,
expectedRoles: target.roles,
employeeNo: null,
status: "SKIP",
reason: "employee_not_found",
actions: [],
before: null,
};
}
if (!isSafeOaOnlySmokeTarget(target, row)) {
return {
key: target.key,
maskedIdentifier: target.maskedIdentifier,
expectedRoles: target.roles,
employeeNo: row.employeeNo,
status: "BLOCKED",
reason: "unsafe_non_smoke_target",
actions: [],
missingRoles: [],
before: {
status: row.status,
employmentStatus: row.employmentStatus,
archived: row.archived,
roles: row.roles,
loginFailedCount: row.loginFailedCount,
loginLocked: row.loginLocked,
activeAuthSessions: row.activeAuthSessions,
safeOaOnlySmoke: false,
},
};
}
const actions = [];
if (row.status !== "active" || row.employmentStatus !== "active" || row.archived) {
actions.push("restore_active_employee");
}
if (row.loginFailedCount > 0 || row.hasLoginFailedFirstAt || row.loginLocked) {
actions.push("clear_login_lock");
}
const missingRoles = target.roles.filter((role) => !row.roles.includes(role));
if (missingRoles.length) {
actions.push("add_missing_roles");
}
return {
key: target.key,
maskedIdentifier: target.maskedIdentifier,
expectedRoles: target.roles,
employeeNo: row.employeeNo,
status: actions.length ? "PLANNED" : "OK",
reason: actions.length ? "needs_repair" : "already_ready",
actions,
missingRoles,
before: {
status: row.status,
employmentStatus: row.employmentStatus,
archived: row.archived,
roles: row.roles,
loginFailedCount: row.loginFailedCount,
loginLocked: row.loginLocked,
activeAuthSessions: row.activeAuthSessions,
safeOaOnlySmoke: true,
},
};
}
async function applyTarget(client, target, plan, now) {
if (!plan.employeeNo || !plan.actions.length) {
return { ...plan, result: plan.status === "OK" ? "noop" : "skipped" };
}
const row = await queryTarget(client, target.identifier);
if (!row?.id) {
return { ...plan, result: "skipped", reason: "employee_not_found_at_apply" };
}
await client.query(
`
UPDATE employees
SET
status = 'active',
employment_status = 'active',
archived_at = NULL,
login_failed_count = 0,
login_failed_first_at = NULL,
login_locked_until = NULL,
token_version = token_version + 1,
updated_at = $2::timestamptz,
updated_by = NULL
WHERE id = $1::uuid
`,
[row.id, now.toISOString()],
);
for (const role of target.roles) {
await client.query(
`
INSERT INTO employee_roles (employee_id, role_code, created_at, created_by)
VALUES ($1::uuid, $2::"RoleCode", $3::timestamptz, NULL)
ON CONFLICT (employee_id, role_code) DO NOTHING
`,
[row.id, role, now.toISOString()],
);
}
await client.query(
`
INSERT INTO employee_lifecycle_events (id, employee_id, event_type, effective_date, payload_json, created_at, created_by)
VALUES (gen_random_uuid(), $1::uuid, 'transfer', $2::date, $3::jsonb, $4::timestamptz, NULL)
`,
[
row.id,
now.toISOString().slice(0, 10),
JSON.stringify({
source: "OSMOKE1",
reason: "oa_only_smoke_account_repair",
actions: plan.actions,
expectedRoles: target.roles,
}),
now.toISOString(),
],
);
const after = await queryTarget(client, target.identifier);
return {
...plan,
result: "applied",
after: after ? {
status: after.status,
employmentStatus: after.employmentStatus,
archived: after.archived,
roles: after.roles,
loginFailedCount: after.loginFailedCount,
loginLocked: after.loginLocked,
activeAuthSessions: after.activeAuthSessions,
} : null,
};
}
function buildNoDatabaseReport(targets) {
return {
mode: "dry_run",
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
generatedAt: new Date().toISOString(),
privacy: "target key/masked identifier/role only; no password/token/secret/full email/full phone",
reason: "missing_DATABASE_URL",
targets: targets.map((target) => ({
key: target.key,
maskedIdentifier: target.maskedIdentifier,
expectedRoles: target.roles,
source: target.source,
})),
totals: {
targets: targets.length,
plannedRepairs: 0,
appliedRepairs: 0,
missingTargets: 0,
},
};
}
function buildDatabaseUnavailableReport(targets, error) {
return {
...buildNoDatabaseReport(targets),
reason: "database_unavailable",
details: {
message: sanitizeErrorMessage(error?.message || error),
name: error?.name || null,
},
};
}
function summarizeReport({ mode, targets, planned, generatedAt }) {
const plannedRepairs = planned.filter((item) => item.status === "PLANNED").length;
const missingTargets = planned.filter((item) => item.reason === "employee_not_found").length;
const blockedTargets = planned.filter((item) => item.status === "BLOCKED").length;
const appliedRepairs = planned.filter((item) => item.result === "applied").length;
const blockers = missingTargets;
let status = "PASS_OA_ONLY_SMOKE_READY";
if (blockedTargets > 0) {
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
} else if (!targets.length) {
status = "PAUSED_MANUAL_CONFIRMATION";
} else if (mode === "apply") {
status = blockers ? "PASS_WITH_FINDINGS" : "APPLIED_OA_ONLY_SMOKE_REPAIR";
} else if (plannedRepairs > 0 || missingTargets > 0) {
status = "PASS_WITH_REPAIR_PLAN";
}
return {
mode,
status,
generatedAt,
privacy: "target key/masked identifier/employee_no/status/roles/actions only; no password/token/secret/full email/full phone",
scope: {
accountClass: "oa_only_smoke",
wecomWrites: 0,
passwordReset: false,
realEmployees: "not_targeted",
},
totals: {
targets: targets.length,
plannedRepairs,
appliedRepairs,
missingTargets,
blockedTargets,
readyTargets: planned.filter((item) => item.status === "OK").length,
},
targets: planned,
manualGate: mode === "dry_run" && plannedRepairs > 0
? "A:按 dry-run 结果恢复现有 OA-only smoke;B:另建新账号;C:暂停自动化。"
: null,
};
}
async function execute(options) {
const targets = loadTargets(options);
assertApplyAllowed(options);
if (!targets.length) {
return summarizeReport({
mode: options.mode,
targets,
planned: [],
generatedAt: new Date().toISOString(),
});
}
if (!process.env.DATABASE_URL) {
return buildNoDatabaseReport(targets);
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
let client;
try {
client = await pool.connect();
} catch (error) {
await pool.end();
if (options.mode !== "apply") {
return buildDatabaseUnavailableReport(targets, error);
}
throw error;
}
const generatedAt = new Date().toISOString();
const now = new Date();
try {
await client.query(options.mode === "apply" ? "BEGIN" : "BEGIN READ ONLY");
const planned = [];
for (const target of targets) {
const row = await queryTarget(client, target.identifier);
const plan = planForTarget(target, row);
planned.push(plan);
}
if (options.mode === "apply" && planned.some((item) => item.status === "BLOCKED")) {
await client.query("ROLLBACK");
return summarizeReport({ mode: options.mode, targets, planned, generatedAt });
}
if (options.mode === "apply") {
const applied = [];
for (let index = 0; index < targets.length; index += 1) {
applied.push(await applyTarget(client, targets[index], planned[index], now));
}
planned.splice(0, planned.length, ...applied);
}
await client.query("COMMIT");
return summarizeReport({ mode: options.mode, targets, planned, generatedAt });
} catch (error) {
try {
await client.query("ROLLBACK");
} catch {
// Keep original error.
}
throw error;
} finally {
client.release();
await pool.end();
}
}
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(`[oa-only-smoke-repair] status=${report.status} mode=${report.mode}\n`);
process.stdout.write(`[oa-only-smoke-repair] report=${reportPath}\n`);
process.stdout.write(`[oa-only-smoke-repair] totals=${JSON.stringify(report.totals)}\n`);
if (report.manualGate) {
process.stdout.write(`[oa-only-smoke-repair] manualGate=${report.manualGate}\n`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const report = await execute(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 (report.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
process.exitCode = 1;
}
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`[oa-only-smoke-repair][FAIL] ${sanitizeErrorMessage(error?.message || error)}\n`);
process.exitCode = 1;
});
}
module.exports = {
DEFAULT_TARGETS,
execute,
isSafeOaOnlySmokeTarget,
isSmokeLikeValue,
loadTargets,
normalizeRoleList,
parseArgs,
planForTarget,
};
@@ -23,6 +23,8 @@ Options:
--dry-run 只生成计划,不写数据库,默认。
--apply 执行受控 OA 写入,需 WOUA_FIX_APPLY=1。
--allow-employee-no=<no[,no]> 限定允许处理的员工号;可重复。
--oa-only-smoke-employee-no=<no[,no]>
明确标记 OA-only smoke 员工号;这些员工缺企微账号时不归档。
--clear-inactive-bindings 显式清理 inactiveWithBinding 中的绑定;默认不清理。
--repair-manager-effects 将归档测试主管的 active 下属和 pending 待办转给 fallback active manager。
--manager-fallback-employee-no=<no>
@@ -45,6 +47,7 @@ function parseArgs(argv) {
reportDir: process.env.WOUA_FIX_REPORT_DIR
|| path.join(DEFAULT_REPORT_ROOT, `wecom-oa-user-consistency-fix-${timestamp()}`),
allowEmployeeNos: [],
oaOnlySmokeEmployeeNos: splitList(process.env.OA_ONLY_SMOKE_EMPLOYEE_NOS || ""),
clearInactiveBindings: false,
repairManagerEffects: false,
managerFallbackEmployeeNo: "",
@@ -79,6 +82,11 @@ function parseArgs(argv) {
index += 1;
} else if (arg.startsWith("--allow-employee-no=")) {
options.allowEmployeeNos.push(...splitList(arg.slice("--allow-employee-no=".length)));
} else if (arg === "--oa-only-smoke-employee-no") {
options.oaOnlySmokeEmployeeNos.push(...splitList(argv[index + 1] || ""));
index += 1;
} else if (arg.startsWith("--oa-only-smoke-employee-no=")) {
options.oaOnlySmokeEmployeeNos.push(...splitList(arg.slice("--oa-only-smoke-employee-no=".length)));
} else if (arg === "--clear-inactive-bindings") {
options.clearInactiveBindings = true;
} else if (arg === "--repair-manager-effects") {
@@ -97,6 +105,7 @@ function parseArgs(argv) {
throw new Error(`Unsupported --format: ${options.format}`);
}
options.allowEmployeeNos = normalizeEmployeeNoList(options.allowEmployeeNos);
options.oaOnlySmokeEmployeeNos = normalizeEmployeeNoList(options.oaOnlySmokeEmployeeNos);
return options;
}
@@ -170,18 +179,40 @@ function buildAcceptedMissingRequiredFields(items) {
function buildFixPlan(report, options = {}) {
const findings = report?.findings ?? {};
const allowSet = new Set(normalizeEmployeeNoList(options.allowEmployeeNos));
const oaOnlySmokeSet = new Set(normalizeEmployeeNoList(options.oaOnlySmokeEmployeeNos));
const shouldInclude = (item) => !allowSet.size || allowSet.has(item.employeeNo);
const archiveCandidates = buildEmployeeNoActions(
const archiveSource = buildEmployeeNoActions(
findings.oaActiveMissingInWecom,
"archive_oa_test_employee",
"user_confirmed_oa_active_missing_in_wecom_are_test_data",
).filter(shouldInclude);
);
const oaOnlySmokeExempted = [
...buildEmployeeNoActions(
findings.oaOnlySmokeExempted,
"oa_only_smoke_exempted",
"source_report_classified_oa_only_smoke",
),
...archiveSource
.filter((item) => oaOnlySmokeSet.has(item.employeeNo))
.map((item) => ({
employeeNo: item.employeeNo,
action: "oa_only_smoke_exempted",
reason: "explicit_oa_only_smoke_employee_no",
})),
];
const exemptSet = new Set(oaOnlySmokeExempted.map((item) => item.employeeNo));
const archiveCandidates = archiveSource
.filter((item) => !exemptSet.has(item.employeeNo))
.filter(shouldInclude);
const excludedByAllowlist = allowSet.size
? buildEmployeeNoActions(
findings.oaActiveMissingInWecom,
"excluded_by_allowlist",
"allowlist_did_not_include_employee_no",
).filter((item) => !allowSet.has(item.employeeNo))
? archiveSource
.filter((item) => !exemptSet.has(item.employeeNo))
.map((item) => ({
employeeNo: item.employeeNo,
action: "excluded_by_allowlist",
reason: "allowlist_did_not_include_employee_no",
}))
.filter((item) => !allowSet.has(item.employeeNo))
: [];
const inactiveBindingAction = options.clearInactiveBindings
@@ -194,12 +225,14 @@ function buildFixPlan(report, options = {}) {
sourceGeneratedAt: report?.generatedAt ?? null,
decisions: {
oaActiveMissingInWecom: "archive_oa_test_employees",
oaOnlySmokeMissingInWecom: "exempt_no_archive",
missingRequiredFields: "accepted_no_write",
inactiveWithBinding: inactiveBindingAction,
managerSideEffects: options.repairManagerEffects ? "repair_to_active_manager" : "report_only",
wecomWriteBehavior: "no_wecom_writes",
},
archiveOaTestEmployees: archiveCandidates,
oaOnlySmokeExempted,
excludedByAllowlist,
acceptedMissingRequiredFields: buildAcceptedMissingRequiredFields(findings.missingRequiredFields),
inactiveWithBinding: buildEmployeeNoActions(
@@ -234,6 +267,7 @@ function buildDryRunResult(plan, options = {}) {
decisions: plan.decisions,
totals: {
archiveOaTestEmployees: plan.archiveOaTestEmployees.length,
oaOnlySmokeExempted: plan.oaOnlySmokeExempted.length,
excludedByAllowlist: plan.excludedByAllowlist.length,
acceptedMissingRequiredFields: plan.acceptedMissingRequiredFields.length,
inactiveWithBinding: plan.inactiveWithBinding.length,
@@ -243,6 +277,7 @@ function buildDryRunResult(plan, options = {}) {
},
actions: {
archiveOaTestEmployees: sample(plan.archiveOaTestEmployees),
oaOnlySmokeExempted: sample(plan.oaOnlySmokeExempted),
excludedByAllowlist: sample(plan.excludedByAllowlist),
acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields),
inactiveWithBinding: sample(plan.inactiveWithBinding),
@@ -610,6 +645,7 @@ function buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs
decisions: plan.decisions,
totals: {
archiveOaTestEmployees: archived.length,
oaOnlySmokeExempted: plan.oaOnlySmokeExempted.length,
archivedApplied: countByResult(archived, "applied"),
archivedPlanned: countByResult(archived, "planned"),
archivedNoop: countByResult(archived, "noop"),
@@ -629,6 +665,7 @@ function buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs
},
actions: {
archiveOaTestEmployees: sample(archived),
oaOnlySmokeExempted: sample(plan.oaOnlySmokeExempted),
excludedByAllowlist: sample(plan.excludedByAllowlist),
acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields),
inactiveWithBinding: sample(inactiveBindings),
@@ -64,6 +64,28 @@ describe("audit-wecom-oa-user-consistency", () => {
});
});
it("exempts default OA-only smoke accounts from WeCom-missing findings", () => {
const report = buildConsistencyReport({
oaEmployees: [
employee({
employeeNo: "T0001",
name: "OA smoke admin",
email: "[email protected]",
wecomExternalUserId: "",
}),
],
wecomProfiles: [],
});
expect(report.status).toBe("PASS_NO_DRIFT");
expect(count(report, "oaOnlySmokeExempted")).toBe(1);
expect(count(report, "oaActiveMissingInWecom")).toBe(0);
expect(report.findings.oaOnlySmokeExempted[0]).toMatchObject({
employeeNo: "T0001",
reason: "oa_only_smoke_no_wecom_account_required",
});
});
it("classifies WeCom active profiles missing from OA", () => {
const report = buildConsistencyReport({
oaEmployees: [],
@@ -0,0 +1,88 @@
const {
isSafeOaOnlySmokeTarget,
isSmokeLikeValue,
normalizeRoleList,
parseArgs,
planForTarget,
} = require("../../../src/scripts/repair-oa-only-smoke-accounts");
function target(overrides = {}) {
return {
key: overrides.key ?? "admin",
identifier: overrides.identifier ?? "[email protected]",
maskedIdentifier: "sm***@***.com",
roles: overrides.roles ?? ["admin", "employee"],
};
}
function row(overrides = {}) {
return {
id: "11111111-1111-4111-8111-111111111111",
employeeNo: overrides.employeeNo ?? "T0001",
name: overrides.name ?? "OA试运行测试管理员",
email: overrides.email ?? "[email protected]",
phone: overrides.phone ?? "",
status: overrides.status ?? "archived",
employmentStatus: overrides.employmentStatus ?? "inactive",
archived: overrides.archived ?? true,
roles: overrides.roles ?? ["employee"],
loginFailedCount: overrides.loginFailedCount ?? 3,
hasLoginFailedFirstAt: overrides.hasLoginFailedFirstAt ?? true,
loginLocked: overrides.loginLocked ?? true,
activeAuthSessions: overrides.activeAuthSessions ?? 0,
};
}
describe("repair-oa-only-smoke-accounts", () => {
afterEach(() => {
delete process.env.OA_ONLY_SMOKE_REPAIR_APPLY;
});
it("recognizes only smoke-like identifiers as safe targets", () => {
expect(isSmokeLikeValue("[email protected]")).toBe(true);
expect(isSmokeLikeValue("SMOKE002")).toBe(true);
expect(isSmokeLikeValue("OA试运行测试管理员")).toBe(true);
expect(isSmokeLikeValue("[email protected]")).toBe(false);
});
it("blocks non-smoke employee matches before planning role repair", () => {
const plan = planForTarget(
target({ identifier: "[email protected]", roles: ["admin", "employee"] }),
row({
employeeNo: "HYD000100",
name: "真实员工",
email: "[email protected]",
status: "archived",
employmentStatus: "inactive",
archived: true,
}),
);
expect(isSafeOaOnlySmokeTarget(target({ identifier: "[email protected]" }), plan.before)).toBe(false);
expect(plan.status).toBe("BLOCKED");
expect(plan.reason).toBe("unsafe_non_smoke_target");
expect(plan.actions).toEqual([]);
});
it("plans only state, lock, and missing-role repairs for safe OA-only smoke rows", () => {
const plan = planForTarget(target(), row());
expect(plan.status).toBe("PLANNED");
expect(plan.actions).toEqual(["restore_active_employee", "clear_login_lock", "add_missing_roles"]);
expect(plan.missingRoles).toEqual(["admin"]);
expect(JSON.stringify(plan)).not.toContain("[email protected]");
});
it("normalizes roles and parses apply mode without enabling apply by itself", () => {
const options = parseArgs(["--apply", "--target=admin:[email protected]:admin+employee", "--format=json"]);
expect(options.mode).toBe("apply");
expect(options.targets[0]).toMatchObject({
key: "admin",
identifier: "[email protected]",
roles: ["admin", "employee"],
});
expect(normalizeRoleList(["admin", "admin", "invalid", "employee"])).toEqual(["admin", "employee"]);
expect(process.env.OA_ONLY_SMOKE_REPAIR_APPLY).toBeUndefined();
});
});
@@ -27,6 +27,7 @@ function auditReport(overrides = {}) {
{ employeeNo: "HRWDTEST-20260517214548", status: "archived", archived: true, roles: ["employee"] },
],
matchedActive: [],
oaOnlySmokeExempted: [],
wecomActiveMissingInOa: [],
oaActiveMissingBinding: [],
statusMismatch: [],
@@ -103,6 +104,27 @@ describe("resolve-wecom-oa-user-consistency", () => {
expect(plan.excludedByAllowlist.map((item) => item.employeeNo)).toEqual(["TEST001"]);
});
it("exempts source-classified OA-only smoke accounts from archive plans", () => {
const plan = buildFixPlan(auditReport({
findings: {
...auditReport().findings,
oaOnlySmokeExempted: [
{ employeeNo: "TEST001", status: "active", roles: ["admin", "employee"] },
],
},
}));
expect(plan.archiveOaTestEmployees.map((item) => item.employeeNo)).toEqual(["TEST002"]);
expect(plan.oaOnlySmokeExempted.map((item) => item.employeeNo)).toEqual(["TEST001"]);
});
it("supports explicit OA-only smoke employee-no exemption for legacy reports", () => {
const plan = buildFixPlan(auditReport(), { oaOnlySmokeEmployeeNos: ["TEST002"] });
expect(plan.archiveOaTestEmployees.map((item) => item.employeeNo)).toEqual(["TEST001"]);
expect(plan.oaOnlySmokeExempted.map((item) => item.employeeNo)).toEqual(["TEST002"]);
});
it("requires explicit environment approval for apply mode", () => {
expect(() => assertApplyAllowed({ mode: "apply" })).toThrow(/WOUA_FIX_APPLY=1/);
process.env.WOUA_FIX_APPLY = "1";
@@ -116,6 +138,7 @@ describe("resolve-wecom-oa-user-consistency", () => {
"--allow-employee-no=TEST001,TEST002",
"--allow-employee-no",
"TEST001",
"--oa-only-smoke-employee-no=TEST003",
"--repair-manager-effects",
"--manager-fallback-employee-no=HYD000007",
"--format=json",
@@ -125,6 +148,7 @@ describe("resolve-wecom-oa-user-consistency", () => {
expect(options.reportPath).toBe("summary.json");
expect(options.format).toBe("json");
expect(options.allowEmployeeNos).toEqual(["TEST001", "TEST002"]);
expect(options.oaOnlySmokeEmployeeNos).toEqual(["TEST003"]);
expect(options.repairManagerEffects).toBe(true);
expect(options.managerFallbackEmployeeNo).toBe("HYD000007");
});