R3 完成权限直连审计
这个提交包含在:
@@ -0,0 +1,205 @@
|
||||
import { expect, test, type APIRequestContext, type APIResponse, type Browser } from "@playwright/test";
|
||||
|
||||
import {
|
||||
ADMIN,
|
||||
EMPLOYEE,
|
||||
FINANCE,
|
||||
FINANCE_MGR,
|
||||
MANAGER,
|
||||
createAuthenticatedApiClient,
|
||||
login,
|
||||
type AuthenticatedApiClient,
|
||||
type E2EUser,
|
||||
} from "./helpers/auth";
|
||||
import { assertForbiddenPage } from "./helpers/smokeAssertions";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type ApiExpectation = {
|
||||
role: string;
|
||||
api: APIRequestContext;
|
||||
path: string;
|
||||
statuses: number[];
|
||||
note: string;
|
||||
};
|
||||
|
||||
const SENSITIVE_KEY_PATTERN = /(secret|password|token)/i;
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
async function parseJson(response: APIResponse) {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as { data?: unknown; error?: unknown };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function expectStatus(actual: number, expected: number[], context: string) {
|
||||
expect(expected, `${context} returned unexpected HTTP ${actual}`).toContain(actual);
|
||||
}
|
||||
|
||||
async function expectApiStatus({ role, api, path, statuses, note }: ApiExpectation) {
|
||||
const response = await api.get(path);
|
||||
expectStatus(response.status(), statuses, `${role} GET ${path} (${note})`);
|
||||
return {
|
||||
response,
|
||||
body: await parseJson(response),
|
||||
};
|
||||
}
|
||||
|
||||
async function openAs(browser: Browser, user: E2EUser) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await login(page, user);
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function expectForbiddenRoutes(browser: Browser, user: E2EUser, paths: string[]) {
|
||||
const { context, page } = await openAs(browser, user);
|
||||
try {
|
||||
for (const path of paths) {
|
||||
await page.goto(path);
|
||||
await assertForbiddenPage(page);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectDirectRoutesNotForbidden(browser: Browser, user: E2EUser, paths: string[]) {
|
||||
const { context, page } = await openAs(browser, user);
|
||||
try {
|
||||
for (const path of paths) {
|
||||
await page.goto(path);
|
||||
await expect(page, `${user.name} should not be routed to /403 for ${path}`).not.toHaveURL(/\/403/);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
function expectNoSensitiveKeys(value: unknown, path = "data") {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => expectNoSensitiveKeys(item, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return;
|
||||
|
||||
for (const [key, child] of Object.entries(value as JsonObject)) {
|
||||
expect(key, `${path}.${key} must not expose secret-like fields`).not.toMatch(SENSITIVE_KEY_PATTERN);
|
||||
expectNoSensitiveKeys(child, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectArray(value: unknown, context: string) {
|
||||
expect(Array.isArray(value), `${context} should be an array`).toBe(true);
|
||||
return value as JsonObject[];
|
||||
}
|
||||
|
||||
test.describe("R3 权限直连审计", () => {
|
||||
test("前端直连备用路径按角色拦截", async ({ browser }) => {
|
||||
await expectForbiddenRoutes(browser, EMPLOYEE, [
|
||||
"/budgets",
|
||||
"/ap-operations",
|
||||
"/system/integrations/erp",
|
||||
"/payment-batches",
|
||||
]);
|
||||
|
||||
await expectForbiddenRoutes(browser, MANAGER, [
|
||||
"/ap-operations",
|
||||
"/system/integrations/erp",
|
||||
"/payment-batches",
|
||||
]);
|
||||
|
||||
await expectForbiddenRoutes(browser, FINANCE, [
|
||||
"/system/integrations/erp",
|
||||
]);
|
||||
});
|
||||
|
||||
test("员工自助直连页面不进入管理态 403", async ({ browser }) => {
|
||||
await expectDirectRoutesNotForbidden(browser, EMPLOYEE, [
|
||||
"/travel-requests",
|
||||
"/invoice-pool",
|
||||
"/contracts-registry",
|
||||
]);
|
||||
});
|
||||
|
||||
test("后端直连接口按权限拒绝或限制范围", async () => {
|
||||
const clients: AuthenticatedApiClient[] = [];
|
||||
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const manager = await createAuthenticatedApiClient(MANAGER);
|
||||
const finance = await createAuthenticatedApiClient(FINANCE);
|
||||
const financeMgr = await createAuthenticatedApiClient(FINANCE_MGR);
|
||||
const admin = await createAuthenticatedApiClient(ADMIN);
|
||||
clients.push(employee, manager, finance, financeMgr, admin);
|
||||
|
||||
try {
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/budgets", statuses: [403], note: "员工不得直连预算" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/ap-operations", statuses: [403, 409], note: "员工不得直连 AP;409 表示功能门禁关闭且未泄露数据" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/payment-batches", statuses: [403, 409], note: "员工不得直连付款批次;409 表示功能门禁关闭且未泄露数据" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "员工不得直连 ERP 管理" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/contracts-registry", statuses: [200, 409], note: "员工合同台账只能走范围过滤或功能门禁" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/travel-requests?mine=0&pageSize=50", statuses: [200], note: "员工差旅列表强制本人范围" });
|
||||
|
||||
const employeeInvoices = await expectApiStatus({
|
||||
role: "employee",
|
||||
api: employee.api,
|
||||
path: "/api/invoice-pool?ownerScope=own&pageSize=50",
|
||||
statuses: [200],
|
||||
note: "员工票据列表强制本人范围",
|
||||
});
|
||||
expect((employeeInvoices.body?.data as JsonObject)?.ownerScope).toBe("own");
|
||||
expectArray((employeeInvoices.body?.data as JsonObject)?.items, "employee invoice items");
|
||||
|
||||
const employeeTravel = await expectApiStatus({
|
||||
role: "employee",
|
||||
api: employee.api,
|
||||
path: "/api/travel-requests?mine=0&pageSize=50",
|
||||
statuses: [200],
|
||||
note: "员工差旅 mine=0 被服务端收敛为 mine=true",
|
||||
});
|
||||
const travelData = employeeTravel.body?.data as JsonObject;
|
||||
expect(travelData.mine).toBe(true);
|
||||
for (const item of expectArray(travelData.items, "employee travel items")) {
|
||||
expect(
|
||||
item.applicantEmployeeId === employee.me.id || item.submitterId === employee.me.id,
|
||||
`travel request ${String(item.id)} must belong to employee scope`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
|
||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [403, 409], note: "主管不得直连付款批次" });
|
||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
|
||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/budgets", statuses: [200], note: "主管可直连预算" });
|
||||
|
||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "财务不得直连 ERP 管理" });
|
||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/invoice-pool?ownerScope=all&pageSize=20", statuses: [200], note: "财务可看票据财务范围" });
|
||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/payment-batches", statuses: [200, 409], note: "财务可看付款批次或受功能门禁限制" });
|
||||
|
||||
const erpStatus = await expectApiStatus({
|
||||
role: "finance_mgr",
|
||||
api: financeMgr.api,
|
||||
path: "/api/integrations/erp/master-data/status",
|
||||
statuses: [200],
|
||||
note: "财务主管可进入 ERP 管理状态",
|
||||
});
|
||||
expectNoSensitiveKeys(erpStatus.body?.data);
|
||||
|
||||
const wecomConfig = await expectApiStatus({
|
||||
role: "admin",
|
||||
api: admin.api,
|
||||
path: "/api/auth/wecom/config",
|
||||
statuses: [200],
|
||||
note: "企业微信探活不得返回 secret 明文",
|
||||
});
|
||||
expectNoSensitiveKeys(wecomConfig.body?.data);
|
||||
} finally {
|
||||
await Promise.all(clients.map((client) => client.dispose()));
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户