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; 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())); } }); });