718 行
28 KiB
JavaScript
718 行
28 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_PERMISSION_MATRIX_REPORT_DIR
|
|
|| path.resolve(`artifacts/qa-flow/oa-permission-matrix-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`);
|
|
const loginCooldownMs = Math.max(0, Number.parseInt(process.env.OA_PERMISSION_MATRIX_LOGIN_COOLDOWN_MS || "1500", 10) || 0);
|
|
const retryAfterRateLimitMs = Math.max(0, Number.parseInt(process.env.OA_PERMISSION_MATRIX_RATE_LIMIT_WAIT_MS || "65000", 10) || 0);
|
|
|
|
const ROLE_TARGETS = [
|
|
{
|
|
key: "employee",
|
|
identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
|
|
passwordEnv: "LIVE_SMOKE_EMPLOYEE_PASSWORD",
|
|
expectedRoles: ["employee"],
|
|
positive: [
|
|
{ id: "me", path: "/api/me", statuses: [200] },
|
|
{
|
|
id: "portal",
|
|
path: "/api/portal/home",
|
|
statuses: [200],
|
|
dataExpectations: [
|
|
{ path: "data.roleCapabilities.canApproveTasks", equals: false },
|
|
{ path: "data.roleCapabilities.canUseFinance", equals: false },
|
|
{ path: "data.roleCapabilities.canManageSystem", equals: false },
|
|
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
|
|
],
|
|
},
|
|
{ id: "own-invoices", path: "/api/invoice-pool?ownerScope=own&pageSize=5", statuses: [200] },
|
|
{
|
|
id: "navigation",
|
|
path: "/api/system/navigation",
|
|
statuses: [200],
|
|
navigationExpectations: {
|
|
visible: ["/portal", "/reimbursements", "/assets"],
|
|
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches", "/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"],
|
|
},
|
|
},
|
|
],
|
|
negative: [
|
|
{ id: "budget", path: "/api/budgets", statuses: [403] },
|
|
{ id: "ap", path: "/api/ap-operations", statuses: [403, 409] },
|
|
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] },
|
|
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] },
|
|
{ id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [403] },
|
|
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] },
|
|
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] },
|
|
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] },
|
|
{ id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] },
|
|
{ id: "payroll-items", path: "/api/payroll/items", statuses: [403] },
|
|
{ id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] },
|
|
{ id: "travel-requests", path: "/api/travel-requests?page=1&pageSize=5", statuses: [403, 409] },
|
|
{
|
|
id: "global-search-sensitive",
|
|
path: "/api/search?q=TEST&limit=10",
|
|
statuses: [200, 409],
|
|
searchExpectations: {
|
|
forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"],
|
|
forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "manager",
|
|
identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER",
|
|
passwordEnv: "LIVE_SMOKE_MANAGER_PASSWORD",
|
|
expectedRoles: ["manager", "employee"],
|
|
positive: [
|
|
{ id: "me", path: "/api/me", statuses: [200] },
|
|
{
|
|
id: "portal",
|
|
path: "/api/portal/home",
|
|
statuses: [200],
|
|
dataExpectations: [
|
|
{ path: "data.roleCapabilities.canApproveTasks", equals: true },
|
|
{ path: "data.roleCapabilities.canUseFinance", equals: false },
|
|
{ path: "data.roleCapabilities.canManageSystem", equals: false },
|
|
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
|
|
],
|
|
},
|
|
{ id: "workflow", path: "/api/workflow/tasks?page=1&pageSize=5", statuses: [200] },
|
|
{
|
|
id: "navigation",
|
|
path: "/api/system/navigation",
|
|
statuses: [200],
|
|
navigationExpectations: {
|
|
visible: ["/workflow/tasks"],
|
|
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"],
|
|
},
|
|
},
|
|
],
|
|
negative: [
|
|
{ id: "ap", path: "/api/ap-operations", statuses: [403, 409] },
|
|
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] },
|
|
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] },
|
|
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] },
|
|
{ id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] },
|
|
{ id: "payroll-items", path: "/api/payroll/items", statuses: [403] },
|
|
{ id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] },
|
|
{ id: "travel-requests", path: "/api/travel-requests?page=1&pageSize=5", statuses: [403, 409] },
|
|
{
|
|
id: "global-search-sensitive",
|
|
path: "/api/search?q=TEST&limit=10",
|
|
statuses: [200, 409],
|
|
searchExpectations: {
|
|
forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"],
|
|
forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "finance",
|
|
identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER",
|
|
passwordEnv: "LIVE_SMOKE_FINANCE_PASSWORD",
|
|
expectedRoles: ["finance", "employee"],
|
|
positive: [
|
|
{ id: "me", path: "/api/me", statuses: [200] },
|
|
{
|
|
id: "portal",
|
|
path: "/api/portal/home",
|
|
statuses: [200],
|
|
dataExpectations: [
|
|
{ path: "data.roleCapabilities.canApproveTasks", equals: true },
|
|
{ path: "data.roleCapabilities.canUseFinance", equals: true },
|
|
{ path: "data.roleCapabilities.canManageSystem", equals: false },
|
|
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
|
|
],
|
|
},
|
|
{ id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [200] },
|
|
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [200, 409] },
|
|
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [200, 409] },
|
|
{
|
|
id: "navigation",
|
|
path: "/api/system/navigation",
|
|
statuses: [200],
|
|
navigationExpectations: {
|
|
visible: ["/invoice-pool", "/payment-batches", "/ap-operations"],
|
|
hidden: ["/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"],
|
|
},
|
|
},
|
|
],
|
|
negative: [
|
|
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] },
|
|
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] },
|
|
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] },
|
|
{ id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] },
|
|
{ id: "payroll-items", path: "/api/payroll/items", statuses: [403] },
|
|
{ id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] },
|
|
{ id: "travel-requests", path: "/api/travel-requests?page=1&pageSize=5", statuses: [403, 409] },
|
|
{
|
|
id: "global-search-sensitive",
|
|
path: "/api/search?q=TEST&limit=10",
|
|
statuses: [200, 409],
|
|
searchExpectations: {
|
|
forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"],
|
|
forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "finance_mgr",
|
|
identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER",
|
|
passwordEnv: "LIVE_SMOKE_FINANCE_MGR_PASSWORD",
|
|
expectedRoles: ["finance", "finance_mgr", "employee"],
|
|
positive: [
|
|
{ id: "me", path: "/api/me", statuses: [200] },
|
|
{
|
|
id: "portal",
|
|
path: "/api/portal/home",
|
|
statuses: [200],
|
|
dataExpectations: [
|
|
{ path: "data.roleCapabilities.canApproveTasks", equals: true },
|
|
{ path: "data.roleCapabilities.canUseFinance", equals: true },
|
|
{ path: "data.roleCapabilities.canManageSystem", equals: true },
|
|
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
|
|
],
|
|
},
|
|
{ id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [200] },
|
|
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [200, 409] },
|
|
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [200, 409] },
|
|
{ id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [200] },
|
|
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [200] },
|
|
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [200] },
|
|
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [200] },
|
|
{
|
|
id: "navigation",
|
|
path: "/api/system/navigation",
|
|
statuses: [200],
|
|
navigationExpectations: {
|
|
visible: ["/invoice-pool", "/payment-batches", "/ap-operations", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"],
|
|
hidden: [],
|
|
},
|
|
},
|
|
],
|
|
negative: [],
|
|
},
|
|
{
|
|
key: "hr",
|
|
identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER",
|
|
passwordEnv: "LIVE_SMOKE_HR_PASSWORD",
|
|
expectedRoles: ["hr", "employee"],
|
|
positive: [
|
|
{ id: "me", path: "/api/me", statuses: [200] },
|
|
{
|
|
id: "portal",
|
|
path: "/api/portal/home",
|
|
statuses: [200],
|
|
dataExpectations: [
|
|
{ path: "data.roleCapabilities.canApproveTasks", equals: false },
|
|
{ path: "data.roleCapabilities.canUseFinance", equals: false },
|
|
{ path: "data.roleCapabilities.canManageSystem", equals: true },
|
|
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
|
|
],
|
|
},
|
|
{ id: "wecom-archive", path: "/api/wecom/approval-archive?page=1&pageSize=5&scope=all", statuses: [200] },
|
|
{
|
|
id: "navigation",
|
|
path: "/api/system/navigation",
|
|
statuses: [200],
|
|
navigationExpectations: {
|
|
visible: ["/people", "/system/settings"],
|
|
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"],
|
|
},
|
|
},
|
|
],
|
|
negative: [
|
|
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] },
|
|
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] },
|
|
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] },
|
|
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] },
|
|
{ id: "payroll-items", path: "/api/payroll/items", statuses: [403] },
|
|
{ id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] },
|
|
{
|
|
id: "global-search-sensitive",
|
|
path: "/api/search?q=TEST&limit=10",
|
|
statuses: [200, 409],
|
|
searchExpectations: {
|
|
forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"],
|
|
forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "ceo",
|
|
identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER",
|
|
passwordEnv: "LIVE_SMOKE_CEO_PASSWORD",
|
|
expectedRoles: ["ceo", "employee"],
|
|
positive: [
|
|
{ id: "me", path: "/api/me", statuses: [200] },
|
|
{
|
|
id: "portal",
|
|
path: "/api/portal/home",
|
|
statuses: [200],
|
|
dataExpectations: [
|
|
{ path: "data.roleCapabilities.canApproveTasks", equals: true },
|
|
{ path: "data.roleCapabilities.canUseFinance", equals: false },
|
|
{ path: "data.roleCapabilities.canManageSystem", equals: false },
|
|
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
|
|
],
|
|
},
|
|
{ id: "dashboard", path: "/api/dashboard/expense-overview", statuses: [200] },
|
|
{
|
|
id: "navigation",
|
|
path: "/api/system/navigation",
|
|
statuses: [200],
|
|
navigationExpectations: {
|
|
visible: ["/workflow/tasks", "/budgets"],
|
|
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"],
|
|
},
|
|
},
|
|
],
|
|
negative: [
|
|
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] },
|
|
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] },
|
|
{ id: "payroll-items", path: "/api/payroll/items", statuses: [403] },
|
|
{ id: "performance-cycles", path: "/api/performance/cycles", statuses: [403, 409] },
|
|
{
|
|
id: "global-search-sensitive",
|
|
path: "/api/search?q=TEST&limit=10",
|
|
statuses: [200, 409],
|
|
searchExpectations: {
|
|
forbiddenEntityTypes: ["contract_registry", "confidential_doc_ref", "travel_request"],
|
|
forbiddenHrefPrefixes: ["/contracts-registry", "/travel-requests"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "admin",
|
|
identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER",
|
|
passwordEnv: "LIVE_SMOKE_ADMIN_PASSWORD",
|
|
expectedRoles: ["admin", "employee"],
|
|
positive: [
|
|
{ id: "me", path: "/api/me", statuses: [200] },
|
|
{
|
|
id: "portal",
|
|
path: "/api/portal/home",
|
|
statuses: [200],
|
|
dataExpectations: [
|
|
{ path: "data.roleCapabilities.canApproveTasks", equals: false },
|
|
{ path: "data.roleCapabilities.canUseFinance", equals: false },
|
|
{ path: "data.roleCapabilities.canManageSystem", equals: true },
|
|
{ path: "data.roleCapabilities.isAdminOnly", equals: true },
|
|
],
|
|
},
|
|
{ id: "users", path: "/api/users?page=1&pageSize=1", statuses: [200] },
|
|
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [200] },
|
|
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [200] },
|
|
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [200] },
|
|
{ id: "payroll-items", path: "/api/payroll/items", statuses: [200] },
|
|
{ id: "performance-cycles", path: "/api/performance/cycles", statuses: [200, 409] },
|
|
{ id: "global-search", path: "/api/search?q=TEST&limit=10", statuses: [200, 409] },
|
|
{ id: "assets", path: "/api/assets?pageSize=5", statuses: [200] },
|
|
{
|
|
id: "navigation",
|
|
path: "/api/system/navigation",
|
|
statuses: [200],
|
|
navigationExpectations: {
|
|
visible: ["/audit-logs", "/system/settings", "/system/feature-flags"],
|
|
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches"],
|
|
},
|
|
},
|
|
],
|
|
negative: [],
|
|
},
|
|
];
|
|
|
|
function wait(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
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(/authorization:\s*bearer\s+[^\s]+/gi, "authorization: bearer ***")
|
|
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
|
|
.slice(0, 160);
|
|
}
|
|
|
|
function summarizeData(json) {
|
|
const data = json?.data;
|
|
if (Array.isArray(data?.items)) return { itemCount: data.items.length, total: data.total ?? null };
|
|
if (!data || typeof data !== "object") return {};
|
|
if (data.sections && typeof data.sections === "object") {
|
|
const hrefs = collectNavigationHrefs(json);
|
|
return { navigationItemCount: hrefs.length };
|
|
}
|
|
const summary = {};
|
|
for (const [key, value] of Object.entries(data)) {
|
|
if (key === "roleCodes" && Array.isArray(value)) {
|
|
summary.roleCodes = value.filter((item) => typeof item === "string").sort();
|
|
continue;
|
|
}
|
|
if (key === "permissionCodes" && Array.isArray(value)) {
|
|
summary.permissionCount = value.length;
|
|
continue;
|
|
}
|
|
if (key === "roleCapabilities" && value && typeof value === "object") {
|
|
summary.roleCapabilities = Object.fromEntries(
|
|
Object.entries(value)
|
|
.filter(([, capabilityValue]) => typeof capabilityValue === "boolean")
|
|
.sort(([left], [right]) => left.localeCompare(right)),
|
|
);
|
|
continue;
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") summary[key] = value;
|
|
if (/^(status|mode|conclusion|ownerScope)$/i.test(key) && typeof value === "string" && /^[a-z0-9_/-]{1,60}$/i.test(value)) {
|
|
summary[key] = value;
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
function getByPath(value, dottedPath) {
|
|
return String(dottedPath || "")
|
|
.split(".")
|
|
.filter(Boolean)
|
|
.reduce((current, key) => {
|
|
if (!current || typeof current !== "object") return undefined;
|
|
return current[key];
|
|
}, value);
|
|
}
|
|
|
|
function evaluateDataExpectations(json, expectations = []) {
|
|
return expectations.map((expectation) => {
|
|
const actual = getByPath(json, expectation.path);
|
|
const pass = actual === expectation.equals;
|
|
return {
|
|
path: expectation.path,
|
|
status: pass ? "PASS" : "FAIL",
|
|
expected: expectation.equals,
|
|
actual: typeof actual === "boolean" || typeof actual === "number" || typeof actual === "string" || actual === null
|
|
? actual
|
|
: typeof actual,
|
|
};
|
|
});
|
|
}
|
|
|
|
function collectNavigationHrefs(json) {
|
|
const sections = json?.data?.sections;
|
|
if (!sections || typeof sections !== "object") return [];
|
|
const hrefs = [];
|
|
for (const sectionItems of Object.values(sections)) {
|
|
if (!Array.isArray(sectionItems)) continue;
|
|
for (const item of sectionItems) {
|
|
if (item && typeof item === "object" && typeof item.href === "string") {
|
|
hrefs.push(item.href);
|
|
}
|
|
}
|
|
}
|
|
return Array.from(new Set(hrefs)).sort();
|
|
}
|
|
|
|
function evaluateNavigationExpectations(json, expectations) {
|
|
if (!expectations) return [];
|
|
const hrefs = new Set(collectNavigationHrefs(json));
|
|
const visible = (expectations.visible || []).map((href) => ({
|
|
type: "visible",
|
|
href,
|
|
status: hrefs.has(href) ? "PASS" : "FAIL",
|
|
}));
|
|
const hidden = (expectations.hidden || []).map((href) => ({
|
|
type: "hidden",
|
|
href,
|
|
status: hrefs.has(href) ? "FAIL" : "PASS",
|
|
}));
|
|
return [...visible, ...hidden];
|
|
}
|
|
|
|
function evaluateSearchExpectations(json, expectations) {
|
|
if (!expectations || !Array.isArray(json?.data?.items)) return [];
|
|
const items = json.data.items.filter((item) => item && typeof item === "object");
|
|
const forbiddenEntityTypes = new Set(expectations.forbiddenEntityTypes || []);
|
|
const forbiddenHrefPrefixes = expectations.forbiddenHrefPrefixes || [];
|
|
const results = [];
|
|
for (const entityType of forbiddenEntityTypes) {
|
|
const matchedCount = items.filter((item) => item.entityType === entityType).length;
|
|
results.push({
|
|
type: "forbidden_entity_type",
|
|
value: entityType,
|
|
status: matchedCount === 0 ? "PASS" : "FAIL",
|
|
matchedCount,
|
|
});
|
|
}
|
|
for (const hrefPrefix of forbiddenHrefPrefixes) {
|
|
const matchedCount = items.filter((item) => typeof item.href === "string" && item.href.startsWith(hrefPrefix)).length;
|
|
results.push({
|
|
type: "forbidden_href_prefix",
|
|
value: hrefPrefix,
|
|
status: matchedCount === 0 ? "PASS" : "FAIL",
|
|
matchedCount,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function collectSensitiveKeyHits(value, currentPath = "data", hits = []) {
|
|
if (hits.length >= 12) return hits;
|
|
if (Array.isArray(value)) {
|
|
value.slice(0, 5).forEach((item, index) => collectSensitiveKeyHits(item, `${currentPath}[${index}]`, hits));
|
|
return hits;
|
|
}
|
|
if (!value || typeof value !== "object") return hits;
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (/(password|token|secret|encodingaeskey|privatekey)/i.test(key)) {
|
|
hits.push(`${currentPath}.${key}`);
|
|
if (hits.length >= 12) return hits;
|
|
}
|
|
collectSensitiveKeyHits(child, `${currentPath}.${key}`, hits);
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
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)) };
|
|
}
|
|
}
|
|
|
|
async function login(target, attempt = 0) {
|
|
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),
|
|
};
|
|
}
|
|
|
|
if (loginCooldownMs > 0) await wait(loginCooldownMs);
|
|
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-device-fingerprint": `oa-permission-matrix-${target.key}`,
|
|
},
|
|
body: JSON.stringify({ identifier, password }),
|
|
});
|
|
const code = result.json?.error?.code ?? null;
|
|
if (result.status === 429 && attempt === 0 && retryAfterRateLimitMs > 0) {
|
|
await wait(retryAfterRateLimitMs);
|
|
return login(target, attempt + 1);
|
|
}
|
|
const accessToken = result.json?.data?.accessToken;
|
|
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
|
|
return {
|
|
status: "FAIL",
|
|
reason: `login_http_${result.status}`,
|
|
code,
|
|
maskedIdentifier: maskIdentifier(identifier),
|
|
};
|
|
}
|
|
return {
|
|
status: "PASS",
|
|
accessToken,
|
|
maskedIdentifier: maskIdentifier(identifier),
|
|
};
|
|
}
|
|
|
|
async function probe(accessToken, probe, kind) {
|
|
const response = await fetchJson(`${baseUrl}${probe.path}`, {
|
|
headers: {
|
|
accept: "application/json",
|
|
authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
const expected = probe.statuses.includes(response.status);
|
|
const dataExpectations = response.ok ? evaluateDataExpectations(response.json, probe.dataExpectations) : [];
|
|
const navigationExpectations = response.ok ? evaluateNavigationExpectations(response.json, probe.navigationExpectations) : [];
|
|
const searchExpectations = response.ok ? evaluateSearchExpectations(response.json, probe.searchExpectations) : [];
|
|
const dataExpectationFailed = dataExpectations.some((item) => item.status === "FAIL");
|
|
const navigationExpectationFailed = navigationExpectations.some((item) => item.status === "FAIL");
|
|
const searchExpectationFailed = searchExpectations.some((item) => item.status === "FAIL");
|
|
const sensitiveKeyHits = response.ok ? collectSensitiveKeyHits(response.json?.data).slice(0, 12) : [];
|
|
return {
|
|
id: probe.id,
|
|
kind,
|
|
status: expected && !dataExpectationFailed && !navigationExpectationFailed && !searchExpectationFailed && sensitiveKeyHits.length === 0 ? "PASS" : "FAIL",
|
|
httpStatus: response.status,
|
|
expectedStatuses: probe.statuses,
|
|
dataExpectations,
|
|
navigationExpectations,
|
|
searchExpectations,
|
|
sensitiveKeyHits,
|
|
summary: response.ok ? summarizeData(response.json) : {
|
|
code: response.json?.error?.code ?? null,
|
|
message: sanitizeError(response.json?.error?.message ?? response.error ?? response.text),
|
|
},
|
|
};
|
|
}
|
|
|
|
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",
|
|
actualRoles: [],
|
|
probes: [],
|
|
};
|
|
|
|
if (loginState.status !== "PASS") {
|
|
return result;
|
|
}
|
|
|
|
for (const item of target.positive) {
|
|
const probeResult = await probe(loginState.accessToken, item, "positive");
|
|
result.probes.push(probeResult);
|
|
if (item.id === "me" && probeResult.status === "PASS") {
|
|
const actualRoles = probeResult.summary?.roleCodes || [];
|
|
const missingRoles = target.expectedRoles.filter((role) => !actualRoles.includes(role));
|
|
result.roleMatch = missingRoles.length ? "FAIL" : "PASS";
|
|
result.actualRoles = actualRoles;
|
|
result.missingRoles = missingRoles;
|
|
}
|
|
}
|
|
for (const item of target.negative) {
|
|
result.probes.push(await probe(loginState.accessToken, item, "negative"));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function collectIssues(results) {
|
|
return 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 probeResult of target.probes) {
|
|
if (probeResult.status === "FAIL") {
|
|
issues.push({
|
|
key: target.key,
|
|
source: probeResult.id,
|
|
kind: probeResult.kind,
|
|
status: "FAIL",
|
|
httpStatus: probeResult.httpStatus,
|
|
expectedStatuses: probeResult.expectedStatuses,
|
|
code: probeResult.summary?.code ?? null,
|
|
failedDataExpectations: probeResult.dataExpectations?.filter((item) => item.status === "FAIL") || [],
|
|
failedNavigationExpectations: probeResult.navigationExpectations?.filter((item) => item.status === "FAIL") || [],
|
|
failedSearchExpectations: probeResult.searchExpectations?.filter((item) => item.status === "FAIL") || [],
|
|
sensitiveKeyHits: probeResult.sensitiveKeyHits || [],
|
|
});
|
|
}
|
|
}
|
|
return issues;
|
|
});
|
|
}
|
|
|
|
function writeReports(summary) {
|
|
mkdirSync(reportDir, { recursive: true });
|
|
writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf-8");
|
|
const lines = [
|
|
"# OA 权限矩阵脱敏自动验证报告",
|
|
"",
|
|
`生成时间:${summary.checkedAt}`,
|
|
`结论:${summary.status}`,
|
|
"",
|
|
"## 统计",
|
|
"",
|
|
`- 角色数:${summary.counts.roles}`,
|
|
`- 正向探针:${summary.counts.positiveProbes}`,
|
|
`- 负向探针:${summary.counts.negativeProbes}`,
|
|
`- 问题数:${summary.issues.length}`,
|
|
"",
|
|
"## 角色结果",
|
|
"",
|
|
"| 角色 | 登录 | 角色匹配 | 探针通过/总数 |",
|
|
"| --- | --- | --- | ---: |",
|
|
...summary.results.map((item) => {
|
|
const passed = item.probes.filter((probeResult) => probeResult.status === "PASS").length;
|
|
return `| ${item.key} | ${item.login.status} | ${item.roleMatch} | ${passed}/${item.probes.length} |`;
|
|
}),
|
|
"",
|
|
"## 说明",
|
|
"",
|
|
"- 本报告只输出角色、HTTP 状态、脱敏账号和摘要计数,不输出密码、token、Secret、完整账号、合同正文、票据内容或原始 payload。",
|
|
"- 409 表示功能门禁关闭且未泄露数据;对财务/AP/付款等冻结或受控功能可作为可解释拒绝。",
|
|
];
|
|
writeFileSync(path.join(reportDir, "summary.md"), `${lines.join("\n")}\n`, "utf-8");
|
|
}
|
|
|
|
async function main() {
|
|
const results = [];
|
|
for (const target of ROLE_TARGETS) {
|
|
results.push(await runTarget(target));
|
|
}
|
|
const issues = collectIssues(results);
|
|
const skipped = results.filter((item) => item.login.status === "SKIP");
|
|
let status = "PASS_PERMISSION_MATRIX_READY";
|
|
if (issues.length) {
|
|
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
|
} else if (skipped.length) {
|
|
status = "PASS_WITH_FINDINGS";
|
|
}
|
|
const summary = {
|
|
issueId: "OANEXT2-4",
|
|
status,
|
|
checkedAt: new Date().toISOString(),
|
|
baseUrl,
|
|
privacy: "redacted role/status/count evidence only; no password, token, Secret, full account, invoice, contract, attachment, or raw payload",
|
|
counts: {
|
|
roles: results.length,
|
|
positiveProbes: results.reduce((sum, item) => sum + item.probes.filter((probeResult) => probeResult.kind === "positive").length, 0),
|
|
negativeProbes: results.reduce((sum, item) => sum + item.probes.filter((probeResult) => probeResult.kind === "negative").length, 0),
|
|
},
|
|
issues,
|
|
results,
|
|
};
|
|
writeReports(summary);
|
|
console.log(`[oa-permission-matrix] status=${summary.status} report=${reportDir}`);
|
|
if (issues.length) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[oa-permission-matrix][ERROR] ${sanitizeError(error instanceof Error ? error.message : String(error))}`);
|
|
process.exit(1);
|
|
});
|