#!/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); });