295 行
9.8 KiB
JavaScript
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);
|
|
});
|