文件
hyb-oa/scripts/qa/check-oa-only-smoke-readiness.mjs
T

295 行
9.8 KiB
JavaScript

#!/usr/bin/env node
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
const reportDir = process.env.OA_ONLY_SMOKE_READINESS_REPORT_DIR
|| path.resolve(`artifacts/qa-flow/oa-only-smoke-readiness-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`);
const ROLE_TARGETS = [
{
key: "admin",
identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_ADMIN_PASSWORD",
expectedRoles: ["admin", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "assets", path: "/api/assets?pageSize=1" },
{ id: "users", path: "/api/users?page=1&pageSize=1" },
],
},
{
key: "employee",
identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_EMPLOYEE_PASSWORD",
expectedRoles: ["employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "my-assets", path: "/api/assets?pageSize=1&mine=1" },
],
},
{
key: "manager",
identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_MANAGER_PASSWORD",
expectedRoles: ["manager", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "workflow", path: "/api/workflow/tasks?page=1&pageSize=1" },
],
},
{
key: "ceo",
identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_CEO_PASSWORD",
expectedRoles: ["ceo", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "dashboard", path: "/api/dashboard/expense-overview" },
],
},
{
key: "finance",
identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_FINANCE_PASSWORD",
expectedRoles: ["finance", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "payment-batches", path: "/api/payment-batches?page=1&pageSize=1" },
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?page=1&pageSize=1" },
],
},
{
key: "finance_mgr",
identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_FINANCE_MGR_PASSWORD",
expectedRoles: ["finance", "finance_mgr", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "payment-batches", path: "/api/payment-batches?page=1&pageSize=1" },
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?page=1&pageSize=1" },
],
},
{
key: "hr",
identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_HR_PASSWORD",
expectedRoles: ["hr", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "wecom-archive", path: "/api/wecom/approval-archive?page=1&pageSize=1&scope=all" },
],
},
];
function maskIdentifier(value) {
const text = String(value || "").trim();
if (!text) return null;
if (text.includes("@")) {
const [local, domain] = text.split("@");
const suffix = String(domain || "").split(".").at(-1) || "***";
return `${local.slice(0, 2) || "*"}***@***.${suffix}`;
}
if (text.length <= 4) return "***";
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
function sanitizeError(value) {
return String(value ?? "")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
.slice(0, 160);
}
async function fetchJson(url, init = {}) {
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(20_000),
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, json, text: text.slice(0, 120) };
} catch (error) {
return { ok: false, status: 0, error: sanitizeError(error instanceof Error ? error.message : String(error)) };
}
}
function summarizeData(json) {
const data = json?.data;
if (Array.isArray(data?.items)) return { itemCount: data.items.length, total: data.total ?? null };
if (Array.isArray(data)) return { itemCount: data.length };
if (typeof data === "object" && data) {
const summary = {};
for (const [key, value] of Object.entries(data)) {
if (typeof value === "number" || typeof value === "boolean") summary[key] = value;
if (/^(status|mode|conclusion)$/i.test(key) && typeof value === "string" && /^[a-z0-9_/-]{1,60}$/i.test(value)) {
summary[key] = value;
}
if (key === "roleCodes" && Array.isArray(value) && value.every((item) => typeof item === "string")) {
summary.roleCodes = value.slice().sort();
}
if (key === "permissionCodes" && Array.isArray(value)) {
summary.permissionCount = value.length;
}
}
return summary;
}
return {};
}
async function login(target) {
const identifier = process.env[target.identifierEnv]?.trim();
const password = process.env[target.passwordEnv]?.trim();
if (!identifier || !password) {
return {
status: "SKIP",
reason: "missing_credentials",
maskedIdentifier: maskIdentifier(identifier),
};
}
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-device-fingerprint": `oa-only-smoke-${target.key}`,
},
body: JSON.stringify({ identifier, password }),
});
const accessToken = result.json?.data?.accessToken;
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
return {
status: "FAIL",
reason: `login_http_${result.status}`,
code: result.json?.error?.code ?? null,
maskedIdentifier: maskIdentifier(identifier),
};
}
return {
status: "PASS",
accessToken,
maskedIdentifier: maskIdentifier(identifier),
};
}
async function runTarget(target) {
const loginState = await login(target);
const result = {
key: target.key,
expectedRoles: target.expectedRoles,
maskedIdentifier: loginState.maskedIdentifier,
login: {
status: loginState.status,
reason: loginState.reason || null,
code: loginState.code || null,
},
roleMatch: "SKIP",
probes: [],
};
if (loginState.status !== "PASS") {
return result;
}
for (const probe of target.probes) {
const response = await fetchJson(`${baseUrl}${probe.path}`, {
headers: {
accept: "application/json",
authorization: `Bearer ${loginState.accessToken}`,
},
});
const summary = response.ok ? summarizeData(response.json) : {
code: response.json?.error?.code ?? null,
message: sanitizeError(response.json?.error?.message ?? response.error ?? response.text),
};
result.probes.push({
id: probe.id,
status: response.ok ? "PASS" : "FAIL",
httpStatus: response.status,
summary,
});
if (probe.id === "me" && response.ok) {
const actualRoles = response.json?.data?.roleCodes || response.json?.data?.roles || [];
const missingRoles = target.expectedRoles.filter((role) => !actualRoles.includes(role));
result.roleMatch = missingRoles.length ? "FAIL" : "PASS";
result.actualRoles = actualRoles.filter((role) => typeof role === "string").sort();
result.missingRoles = missingRoles;
}
}
return result;
}
async function main() {
mkdirSync(reportDir, { recursive: true });
const results = [];
for (const target of ROLE_TARGETS) {
results.push(await runTarget(target));
}
const blocking = results.flatMap((target) => {
const issues = [];
if (target.login.status === "FAIL") issues.push({ key: target.key, source: "login", status: "FAIL", code: target.login.code || target.login.reason });
if (target.roleMatch === "FAIL") issues.push({ key: target.key, source: "roles", status: "FAIL", missingRoles: target.missingRoles });
for (const probe of target.probes) {
if (probe.status === "FAIL") issues.push({ key: target.key, source: probe.id, status: "FAIL", httpStatus: probe.httpStatus, code: probe.summary?.code ?? null });
}
return issues;
});
const skipped = results.filter((target) => target.login.status === "SKIP");
let status = "PASS_OA_ONLY_SMOKE_READY";
if (blocking.length) {
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
} else if (skipped.length) {
status = "PASS_WITH_FINDINGS";
}
const summary = {
issueId: "OSMOKE1",
mode: "oa-only-smoke-readiness",
status,
conclusion: status,
baseUrl,
checkedAt: new Date().toISOString(),
businessReadOnly: true,
readOnlyNote: "performs login/readiness probes only; no business data writes and no WeCom writes",
privacy: "role/masked identifier/status/counts only; no password/token/secret/full email/full phone",
targets: results.map(({ accessToken, ...item }) => item),
findings: [
...blocking,
...skipped.map((target) => ({ key: target.key, source: "credentials", status: "SKIP", reason: target.login.reason })),
],
boundaries: {
noBusinessWrites: true,
noWeComWrites: true,
noPasswordOutput: true,
noRealEmployeeIdentifiers: true,
},
};
const summaryPath = path.join(reportDir, "summary.json");
writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
console.log(JSON.stringify(summary, null, 2));
console.log(`[oa-only-smoke-readiness] report=${summaryPath}`);
if (blocking.length) process.exit(1);
}
main().catch((error) => {
console.error(`[oa-only-smoke-readiness][FAIL] ${sanitizeError(error instanceof Error ? error.message : String(error))}`);
process.exit(1);
});