import { expect, test, type APIRequestContext, type APIResponse, type Browser } from "@playwright/test"; import { ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER, apiGetData, apiPostData, 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; }; type ApiMutationExpectation = ApiExpectation & { method: "post" | "patch" | "delete"; data?: unknown; }; type PortalHomeResponse = { roleCapabilities?: { canApproveTasks: boolean; canUseFinance: boolean; canManageSystem: boolean; isAdminOnly: boolean; }; financeSummary?: unknown; }; type WorkflowSubmitResponse = { currentTaskId: string; workflowInstanceId: string; }; type WorkflowTaskDetailResponse = { task?: { status: string; }; history: Array<{ action: string; actorId: string | null; payload: unknown; }>; }; type AuditLogListResponse = { items: Array<{ action: string; targetType: string | null; targetId: string | null; actorId: string | null; extra: unknown; }>; }; type NotificationDeliveryListResponse = { items: unknown[]; }; type NotificationListResponse = { items: Array<{ type: string; targetId: string | null; title: string; }>; }; type WorkflowTaskSummaryResponse = { pendingTotal: number; overdueTotal: number; byEntityType: Array<{ key: string; pendingTotal: number; overdueTotal: number; }>; }; type LeaveTypeListResponse = { items: Array<{ id: string; name: string; requiresAttachment?: boolean; }>; }; type ReimbursementCreatedResponse = { id: string; formNumber?: string | null; }; type ReimbursementAttachmentUploadResponse = { items: Array<{ id: string; }>; }; type MasterDataListResponse = { items: Array<{ id: string; legalEntityId?: string | null; entityType?: string | null; }>; }; type PurchaseCreateResponse = { purchaseRequest: { id: 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 expectApiMutationStatus({ role, api, path, statuses, note, method, data }: ApiMutationExpectation) { const response = method === "post" ? await api.post(path, data === undefined ? undefined : { data }) : method === "patch" ? await api.patch(path, data === undefined ? undefined : { data }) : await api.delete(path); expectStatus(response.status(), statuses, `${role} ${method.toUpperCase()} ${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[]; } async function expectPcNavMatrix(browser: Browser, user: E2EUser, input: { visible?: string[]; hidden?: string[]; }) { const { context, page } = await openAs(browser, user); try { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto("/portal"); for (const href of input.visible ?? []) { await expect .poll(() => page.locator(`nav a[href="${href}"]`).count(), { message: `${user.name} should have entry ${href}`, }) .toBeGreaterThan(0); } for (const href of input.hidden ?? []) { await expect(page.locator(`nav a[href="${href}"]`), `${user.name} should not see ${href}`).toHaveCount(0); } } finally { await context.close(); } } async function expectMobileApprovalEntry(browser: Browser, user: E2EUser, canApprove: boolean) { const { context, page } = await openAs(browser, user); try { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/mobile"); await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible(); const approvalTab = page.getByTestId("mobile-nav-approvals"); if (canApprove) { await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals"); await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批"); await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0); await page.goto("/mobile/approvals"); await expect(page).not.toHaveURL(/\/403/); } else { await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0); if (user.identifier === EMPLOYEE.identifier) { await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度"); } await page.goto("/mobile/approvals"); if (user.identifier === EMPLOYEE.identifier) { await expect(page).not.toHaveURL(/\/403/); } else { await assertForbiddenPage(page); } } } finally { await context.close(); } } function formatDate(offsetDays: number) { const value = new Date(); value.setUTCDate(value.getUTCDate() + offsetDays); return value.toISOString().slice(0, 10); } async function createSubmittedLeave(api: APIRequestContext, label: string): Promise { const leaveTypes = await apiGetData(api, "/api/leave-types?enabled=true"); const leaveType = leaveTypes.items.find((item) => !item.requiresAttachment) ?? leaveTypes.items[0]; if (!leaveType) { throw new Error("未找到可用于 PA6 回归的请假类型"); } const startDate = formatDate(3600 + (Date.now() % 1200)); const created = await apiPostData<{ id: string }>(api, "/api/leave-requests", { leaveTypeId: leaveType.id, startDate, endDate: startDate, startSession: "am", endSession: "pm", reason: `PA6 权限回归 ${label}`, }); const submitted = await apiPostData(api, `/api/leave-requests/${created.id}/submit`, {}); return { ...submitted, leaveRequestId: created.id, }; } async function firstItem(items: T[], context: string) { const item = items[0]; if (!item) { throw new Error(`未找到可用于 PA6 回归的${context}`); } return item; } async function createSubmittedPurchase(api: APIRequestContext, label: string): Promise { const legalEntities = await apiGetData(api, "/api/master-data/legal-entities?enabled=true"); const legalEntity = await firstItem(legalEntities.items, "法人主体"); const costCenters = await apiGetData( api, `/api/master-data/cost-centers?enabled=true&legalEntityId=${legalEntity.id}`, ); const costCenter = await firstItem(costCenters.items, "成本中心"); const suppliers = await apiGetData( api, `/api/master-data/suppliers?enabled=true&legalEntityId=${legalEntity.id}`, ); const supplier = await firstItem(suppliers.items, "供应商"); const documentCategories = await apiGetData( api, "/api/master-data/document-categories?enabled=true&entityType=purchase_request", ); const documentCategory = await firstItem(documentCategories.items, "采购单据类型"); const created = await apiPostData(api, "/api/purchase-requests", {}); const purchaseRequestId = created.purchaseRequest.id; const updateResponse = await api.patch(`/api/purchase-requests/${purchaseRequestId}`, { data: { title: `PA6 采购权限回归 ${label}`, reason: "PA6 验证采购自审和 admin-only 审批拒绝", legalEntityId: legalEntity.id, costCenterId: costCenter.id, supplierId: supplier.id, documentCategoryId: documentCategory.id, expectedDeliveryDate: formatDate(45), items: [ { description: `PA6 采购项 ${label}`, quantity: "1", unitPrice: "88.00", isAssetCandidate: false, }, ], }, }); expect(updateResponse.ok(), `purchase update failed: ${updateResponse.status()} ${await updateResponse.text()}`).toBe(true); const submitted = await apiPostData(api, `/api/purchase-requests/${purchaseRequestId}/submit`, {}); return { ...submitted, purchaseRequestId, }; } async function createReimbursementAttachment(api: APIRequestContext) { const reimbursementDate = formatDate(30 + (Date.now() % 90)); const reimbursement = await apiPostData(api, "/api/reimbursements", { reimbursementDate, description: `PA6 附件审计 ${Date.now()}`, }); const uploadResponse = await api.post(`/api/reimbursements/${reimbursement.id}/attachments`, { multipart: { file: { name: `pa6-attachment-${Date.now()}.pdf`, mimeType: "application/pdf", buffer: Buffer.from(`%PDF-1.4\nPA6_ATTACHMENT_AUDIT_${Date.now()}\n%%EOF\n`, "utf8"), }, }, }); expect(uploadResponse.ok(), `upload attachment failed: ${uploadResponse.status()} ${await uploadResponse.text()}`).toBe(true); const payload = await parseJson(uploadResponse) as { data?: ReimbursementAttachmentUploadResponse } | null; const attachmentId = payload?.data?.items?.[0]?.id; if (!attachmentId) { throw new Error(`上传附件后未返回 attachmentId: ${JSON.stringify(payload)}`); } return { reimbursementId: reimbursement.id, attachmentId, }; } async function expectAuditAction(api: APIRequestContext, action: string, predicate: (item: AuditLogListResponse["items"][number]) => boolean) { const encoded = encodeURIComponent(action); const data = await apiGetData(api, `/api/audit-logs?action=${encoded}&page=1&pageSize=50`); expectNoSensitiveKeys(data); expect(data.items.some(predicate), `audit action ${action} should be traceable`).toBe(true); } 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", ]); await expectForbiddenRoutes(browser, ADMIN, [ "/workflow/tasks", "/mobile/approvals", ]); }); test("PA6 角色入口矩阵:PC 与移动入口按正式业务角色裁剪", async ({ browser }) => { await expectPcNavMatrix(browser, EMPLOYEE, { visible: ["/portal", "/reimbursements"], hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"], }); await expectPcNavMatrix(browser, MANAGER, { visible: ["/workflow/tasks"], hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"], }); await expectPcNavMatrix(browser, FINANCE, { visible: ["/invoice-pool"], hidden: ["/audit-logs", "/system/settings", "/system/integrations/erp"], }); await expectPcNavMatrix(browser, ADMIN, { visible: ["/audit-logs", "/system/settings"], hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches"], }); await expectMobileApprovalEntry(browser, EMPLOYEE, false); await expectMobileApprovalEntry(browser, MANAGER, true); await expectMobileApprovalEntry(browser, ADMIN, false); const { context, page } = await openAs(browser, FINANCE); try { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/mobile/me"); await expect(page.getByText("管理入口", { exact: true })).toHaveCount(0); await expect(page.getByTestId("mobile-me-action-link").filter({ hasText: "审批" }).first()).toBeVisible(); } finally { await context.close(); } }); test("PA6 portal 角色能力字段不把 admin-only 当业务审批人", async () => { const clients = await Promise.all([ createAuthenticatedApiClient(EMPLOYEE), createAuthenticatedApiClient(MANAGER), createAuthenticatedApiClient(FINANCE), createAuthenticatedApiClient(ADMIN), ]); const [employee, manager, finance, admin] = clients; try { const employeePortal = await apiGetData(employee.api, "/api/portal/home"); expect(employeePortal.roleCapabilities).toMatchObject({ canApproveTasks: false, canUseFinance: false, canManageSystem: false, isAdminOnly: false, }); expect(employeePortal.financeSummary).toBeNull(); const managerPortal = await apiGetData(manager.api, "/api/portal/home"); expect(managerPortal.roleCapabilities).toMatchObject({ canApproveTasks: true, canUseFinance: false, canManageSystem: false, isAdminOnly: false, }); const financePortal = await apiGetData(finance.api, "/api/portal/home"); expect(financePortal.roleCapabilities).toMatchObject({ canApproveTasks: true, canUseFinance: true, canManageSystem: false, isAdminOnly: false, }); expect(financePortal.financeSummary).toBeTruthy(); const adminPortal = await apiGetData(admin.api, "/api/portal/home"); expect(adminPortal.roleCapabilities).toMatchObject({ canApproveTasks: false, canUseFinance: false, canManageSystem: true, isAdminOnly: true, }); expect(adminPortal.financeSummary).toBeNull(); } finally { await Promise.all(clients.map((client) => client.dispose())); } }); 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/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "员工不得直连财务台账;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/audit-logs", statuses: [403], note: "员工不得查看审计日志" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/notifications/deliveries", statuses: [403], note: "员工不得查看通知投递审计" }); await expectApiMutationStatus({ role: "employee", api: employee.api, path: "/api/exports/ap-operations", method: "post", data: { format: "csv" }, statuses: [403, 409], note: "员工不得导出 AP 运营数据;409 表示功能门禁关闭且未泄露数据" }); 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/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "主管不得直连财务台账" }); 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/audit-logs", statuses: [403], note: "主管不得查看审计日志" }); 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/audit-logs", statuses: [403], note: "普通财务不得查看审计日志" }); await expectApiStatus({ role: "finance", api: finance.api, path: "/api/system/feature-flags", statuses: [403], note: "普通财务不得管理系统开关" }); await expectApiStatus({ role: "finance", api: finance.api, path: "/api/notifications/deliveries", statuses: [403], note: "普通财务不得查看通知渠道投递审计" }); 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: "财务可看付款批次或受功能门禁限制" }); await expectApiStatus({ role: "finance", api: finance.api, path: "/api/ap-operations/reimbursement-ledger?pageSize=5", 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 adminAudit = await expectApiStatus({ role: "admin", api: admin.api, path: "/api/audit-logs?page=1&pageSize=5", statuses: [200], note: "管理员可查看审计日志", }); expectNoSensitiveKeys(adminAudit.body?.data); const deliveryAudit = await expectApiStatus({ role: "admin", api: admin.api, path: "/api/notifications/deliveries?limit=5", statuses: [200], note: "管理员可查看通知投递审计", }); expectNoSensitiveKeys(deliveryAudit.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())); } }); test("PA6 请假和采购自审、admin-only 业务审批被服务端拒绝", async () => { const employee = await createAuthenticatedApiClient(EMPLOYEE); const manager = await createAuthenticatedApiClient(MANAGER); const admin = await createAuthenticatedApiClient(ADMIN); const clients = [employee, manager, admin]; try { const leave = await createSubmittedLeave(employee.api, `admin-only-${Date.now()}`); const purchase = await createSubmittedPurchase(employee.api, `admin-only-${Date.now()}`); await expectApiMutationStatus({ role: "employee", api: employee.api, path: `/api/workflow/tasks/${leave.currentTaskId}/approve`, method: "post", statuses: [403, 409], note: "提交人本人不能审批自己的请假单", }); await expectApiMutationStatus({ role: "admin", api: admin.api, path: `/api/workflow/tasks/${leave.currentTaskId}/approve`, method: "post", statuses: [403, 409], note: "admin-only 不获得业务审批权", }); await expectApiMutationStatus({ role: "employee", api: employee.api, path: `/api/workflow/tasks/${purchase.currentTaskId}/approve`, method: "post", statuses: [403, 409], note: "提交人本人不能审批自己的采购单", }); await expectApiMutationStatus({ role: "admin", api: admin.api, path: `/api/workflow/tasks/${purchase.currentTaskId}/approve`, method: "post", statuses: [403, 409], note: "admin-only 不获得采购业务审批权", }); const approved = await apiPostData<{ outcome: string; workflowInstanceId: string }>( manager.api, `/api/workflow/tasks/${leave.currentTaskId}/approve`, {}, ); expect(approved.outcome).toBe("approved"); const detail = await apiGetData(manager.api, `/api/workflow/tasks/${leave.currentTaskId}`); expect(detail.history.map((item) => item.action)).toEqual(expect.arrayContaining(["start", "approve"])); expectNoSensitiveKeys(detail.history); const purchaseApproved = await apiPostData<{ outcome: string }>( manager.api, `/api/workflow/tasks/${purchase.currentTaskId}/approve`, {}, ); expect(purchaseApproved.outcome).toBe("approved"); } finally { await Promise.all(clients.map((client) => client.dispose())); } }); test("AP6 审批催办与待办汇总:申请人可催办且重复催办被限制", async () => { const employee = await createAuthenticatedApiClient(EMPLOYEE); const manager = await createAuthenticatedApiClient(MANAGER); const admin = await createAuthenticatedApiClient(ADMIN); const clients = [employee, manager, admin]; try { const leave = await createSubmittedLeave(employee.api, `ap6-remind-${Date.now()}`); const summaryBefore = await apiGetData(manager.api, "/api/workflow/tasks/summary"); expect(summaryBefore.pendingTotal).toBeGreaterThanOrEqual(1); expect(summaryBefore.byEntityType.some((item) => item.key === "leave" && item.pendingTotal > 0)).toBe(true); const managerRemind = await manager.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} }); expectStatus(managerRemind.status(), [403], "manager POST workflow remind should not act as applicant"); const adminRemind = await admin.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} }); expectStatus(adminRemind.status(), [403], "admin-only POST workflow remind should not act as applicant"); const reminded = await apiPostData<{ outcome: string; notificationCount: number }>( employee.api, `/api/workflow/tasks/${leave.currentTaskId}/remind`, {}, ); expect(reminded.outcome).toBe("reminded"); expect(reminded.notificationCount).toBeGreaterThanOrEqual(1); const duplicate = await employee.api.post(`/api/workflow/tasks/${leave.currentTaskId}/remind`, { data: {} }); expectStatus(duplicate.status(), [409], "duplicate remind should hit 24h cooldown"); const workflowDetail = await apiGetData(manager.api, `/api/workflow/tasks/${leave.currentTaskId}`); expect(workflowDetail.task?.status).toBe("pending"); expect(workflowDetail.history.some((item) => item.action === "remind" && item.actorId === employee.me.id)).toBe(true); expectNoSensitiveKeys(workflowDetail.history); const notifications = await apiGetData( manager.api, "/api/notifications?type=workflow.task_reminded&page=1&pageSize=20", ); expect( notifications.items.some((item) => item.targetId === leave.currentTaskId && item.type === "workflow.task_reminded"), ).toBe(true); expectNoSensitiveKeys(notifications); await expectAuditAction( admin.api, "WORKFLOW_TASK_REMINDED", (item) => item.targetId === leave.currentTaskId && item.actorId === employee.me.id, ); const systemSummary = await apiGetData( admin.api, "/api/workflow/tasks/summary?scope=system", ); expect(systemSummary.pendingTotal).toBeGreaterThanOrEqual(summaryBefore.pendingTotal); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/workflow/tasks/summary?scope=system", statuses: [403], note: "普通员工不得查看全局审批效率摘要", }); } finally { await Promise.all(clients.map((client) => client.dispose())); } }); test("PA6 审计留痕:登录、审批历史、附件下载、导出与通知投递可追溯且脱敏", async () => { const employee = await createAuthenticatedApiClient(EMPLOYEE); const manager = await createAuthenticatedApiClient(MANAGER); const finance = await createAuthenticatedApiClient(FINANCE); const admin = await createAuthenticatedApiClient(ADMIN); const clients = [employee, manager, finance, admin]; try { await expectAuditAction(admin.api, "AUTH_LOGIN_SUCCESS", (item) => item.actorId === employee.me.id); const leave = await createSubmittedLeave(employee.api, `audit-${Date.now()}`); await apiPostData<{ outcome: string }>(manager.api, `/api/workflow/tasks/${leave.currentTaskId}/approve`, {}); const workflowDetail = await apiGetData(manager.api, `/api/workflow/tasks/${leave.currentTaskId}`); expect(workflowDetail.history.some((item) => item.action === "approve" && item.actorId === manager.me.id)).toBe(true); expectNoSensitiveKeys(workflowDetail.history); const attachment = await createReimbursementAttachment(employee.api); const downloadResponse = await employee.api.get( `/api/reimbursements/${attachment.reimbursementId}/attachments/${attachment.attachmentId}/file`, ); expect(downloadResponse.ok(), `download attachment failed: ${downloadResponse.status()}`).toBe(true); await expectAuditAction( admin.api, "REIMBURSEMENT_ATTACHMENT_DOWNLOADED", (item) => item.targetId === attachment.reimbursementId && (item.extra as JsonObject | null)?.attachmentId === attachment.attachmentId, ); const exportResponse = await finance.api.post("/api/exports/ap-operations", { data: { format: "csv" } }); expectStatus(exportResponse.status(), [200, 409], "finance POST /api/exports/ap-operations"); if (exportResponse.status() === 200) { await expectAuditAction(admin.api, "EXPORT_AP_OPERATIONS", (item) => item.actorId === finance.me.id); } const deliveryData = await apiGetData(admin.api, "/api/notifications/deliveries?limit=10"); expect(Array.isArray(deliveryData.items), "notification delivery audit items should be an array").toBe(true); expectNoSensitiveKeys(deliveryData); } finally { await Promise.all(clients.map((client) => client.dispose())); } }); });