OSMOKE1 修复 OA-only smoke 账号治理
这个提交包含在:
@@ -0,0 +1,294 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -122,6 +122,7 @@ run_ready_and_runtime() {
|
||||
run_step "public-ready" curl -fsS "$PUBLIC_BASE_URL/ready"
|
||||
run_finding_step "live-smoke-env" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json
|
||||
run_finding_step "live-readiness" env QA_ISSUE_ID=OAOPS1 LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" LIVE_SMOKE_READINESS_DIR="$REPORT_DIR/live-smoke-readiness" bash "$ROOT_DIR/scripts/qa/run-live-smoke-readiness.sh"
|
||||
run_finding_step "oa-only-smoke-readiness" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" OA_ONLY_SMOKE_READINESS_REPORT_DIR="$REPORT_DIR/oa-only-smoke-readiness" node "$ROOT_DIR/scripts/qa/check-oa-only-smoke-readiness.mjs"
|
||||
run_finding_step "live-feature-flags" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-live-feature-flags.mjs" \
|
||||
--expect payment_batch_v1=force_on \
|
||||
--expect erp_sync=force_off \
|
||||
@@ -394,6 +395,7 @@ const stepNames = [
|
||||
"public-ready",
|
||||
"live-smoke-env",
|
||||
"live-readiness",
|
||||
"oa-only-smoke-readiness",
|
||||
"live-feature-flags",
|
||||
"authenticated-readonly-probe",
|
||||
"mobile-entry-matrix",
|
||||
|
||||
在新工单中引用
屏蔽一个用户