文件
hyb-oa/client/tests/e2e/permission-direct-access.spec.ts
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

793 行
33 KiB
TypeScript

import { expect, test, type APIRequestContext, type APIResponse, type Browser, type Page } 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<string, unknown>;
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 assertAllowedRoute(page: Page, user: E2EUser, path: string) {
// A negative URL assertion can pass before auth bootstrap and permission
// routing finish. Wait for the real page before another document navigation.
await expect(page).toHaveURL(url => url.pathname === path);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: /^(无权限访问|页面不存在)$/ })).toHaveCount(0);
await expect(page.getByTestId("login-identifier")).toHaveCount(0);
await expect(page, `${user.name} should not be routed to /403 for ${path}`).not.toHaveURL(/\/403/);
}
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 assertAllowedRoute(page, user, path);
}
} 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(page: Page, user: E2EUser, input: {
visible?: string[];
hidden?: string[];
managementVisible?: string[];
managementHidden?: string[];
}) {
await page.setViewportSize({ width: 1440, height: 960 });
await expect(page).toHaveURL(/\/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);
}
if (input.managementVisible) {
await page.goto("/system/settings");
await expect(page.getByTestId("system-settings-summary")).toBeVisible();
for (const href of input.managementVisible) {
await expect(page.locator(`main a[href="${href}"]`)).toBeVisible();
}
for (const href of input.managementHidden ?? []) {
await expect(page.locator(`main a[href="${href}"]`)).toHaveCount(0);
}
}
}
async function expectMobileApprovalEntry(page: Page, user: E2EUser, canApprove: boolean) {
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);
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
await expect
.poll(
async () =>
(await page.getByTestId("mobile-approvals-progress").count()) +
(await page.getByTestId("mobile-approvals-list").count()),
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
await expect(page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
await expect(page.getByTestId("mobile-approvals-return")).toHaveCount(0);
}
}
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<WorkflowSubmitResponse & { leaveRequestId: string }> {
const leaveTypes = await apiGetData<LeaveTypeListResponse>(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<WorkflowSubmitResponse>(api, `/api/leave-requests/${created.id}/submit`, {});
return {
...submitted,
leaveRequestId: created.id,
};
}
async function firstItem<T extends { id: string }>(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<WorkflowSubmitResponse & { purchaseRequestId: string }> {
const legalEntities = await apiGetData<MasterDataListResponse>(api, "/api/master-data/legal-entities?enabled=true");
const legalEntity = await firstItem(legalEntities.items, "法人主体");
const costCenters = await apiGetData<MasterDataListResponse>(
api,
`/api/master-data/cost-centers?enabled=true&legalEntityId=${legalEntity.id}`,
);
const costCenter = await firstItem(costCenters.items, "成本中心");
const suppliers = await apiGetData<MasterDataListResponse>(
api,
`/api/master-data/suppliers?enabled=true&legalEntityId=${legalEntity.id}`,
);
const supplier = await firstItem(suppliers.items, "供应商");
const documentCategories = await apiGetData<MasterDataListResponse>(
api,
"/api/master-data/document-categories?enabled=true&entityType=purchase_request",
);
const documentCategory = await firstItem(documentCategories.items, "采购单据类型");
const created = await apiPostData<PurchaseCreateResponse>(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<WorkflowSubmitResponse>(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<ReimbursementCreatedResponse>(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<AuditLogListResponse>(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 }) => {
const forbiddenByRole = new Map<E2EUser, string[]>([
[EMPLOYEE, ["/budgets", "/ap-operations", "/payment-batches", "/assets/new"]],
[MANAGER, ["/ap-operations"]],
[ADMIN, ["/workflow/tasks"]],
]);
// Each role has an isolated session; reuse it for all of that role's URLs.
for (const user of [EMPLOYEE, MANAGER, FINANCE, FINANCE_MGR, ADMIN]) {
const { context, page } = await openAs(browser, user);
try {
await test.step(`${user.name} 直连路径`, async () => {
for (const path of forbiddenByRole.get(user) ?? []) {
await page.goto(path);
await assertForbiddenPage(page);
}
if (user === MANAGER) {
await page.goto("/payment-batches");
await assertAllowedRoute(page, user, "/payment-batches");
}
await page.goto("/system/integrations/erp");
await expect(page.getByRole("heading", { name: "页面不存在", exact: true })).toBeVisible();
});
} finally { await context.close(); }
}
});
test("PA6 角色入口矩阵:PC 与移动入口按正式业务角色裁剪", async ({ browser }) => {
async function checkRole(user: E2EUser, matrix: Parameters<typeof expectPcNavMatrix>[2], canApprove: boolean | null) {
const { context, page } = await openAs(browser, user);
try {
await test.step(`${user.name} PC 与移动入口`, async () => {
await expectPcNavMatrix(page, user, matrix);
if (canApprove !== null) {
await expectMobileApprovalEntry(page, user, canApprove);
} else {
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(); }
}
await checkRole(EMPLOYEE, {
visible: ["/portal", "/reimbursements"],
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"],
}, false);
await checkRole(MANAGER, {
visible: ["/workflow/tasks", "/payment-batches", "/system/settings"],
hidden: ["/ap-operations", "/audit-logs"],
managementVisible: ["/organization"],
managementHidden: ["/system/master-data", "/system/payment-accounts", "/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, true);
await checkRole(FINANCE, {
visible: ["/invoice-pool", "/system/settings"],
hidden: ["/audit-logs", "/system/integrations/erp"],
managementVisible: ["/organization", "/system/master-data", "/system/payment-accounts"],
managementHidden: ["/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, null);
await checkRole(ADMIN, {
visible: ["/system/settings", "/ap-operations", "/payment-batches"],
hidden: ["/workflow/tasks", "/audit-logs"],
managementVisible: ["/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, false);
// Legacy admin navigation compatibility does not grant a business reviewer role.
const admin = await createAuthenticatedApiClient(ADMIN);
try {
const response = await admin.api.post("/api/payment-batches/00000000-0000-4000-8000-000000000099/review", { data: {} });
expect(response.status()).toBe(403);
} finally { await admin.dispose(); }
});
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<PortalHomeResponse>(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<PortalHomeResponse>(manager.api, "/api/portal/home");
expect(managerPortal.roleCapabilities).toMatchObject({
canApproveTasks: true,
canUseFinance: false,
canManageSystem: false,
isAdminOnly: false,
});
const financePortal = await apiGetData<PortalHomeResponse>(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<PortalHomeResponse>(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("员工自助直连页面可用且冻结差旅拒绝进入", async ({ browser }) => {
const { context, page } = await openAs(browser, EMPLOYEE);
try {
for (const path of ["/invoice-pool", "/contracts-registry"]) {
await page.goto(path);
await assertAllowedRoute(page, EMPLOYEE, path);
}
await page.goto("/travel-requests");
await assertForbiddenPage(page);
} finally { await context.close(); }
});
test("资产创建路由只允许 asset.manage 角色直连", async ({ browser }) => {
await expectDirectRoutesNotForbidden(browser, FINANCE_MGR, ["/assets/new"]);
});
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: [404], 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: "员工不得导出应付账款数据;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: [403], 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");
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: [200, 409], note: "主管可读取待复核付款批次;409 表示功能门禁关闭" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [404], 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: [404], 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: [404],
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<WorkflowTaskDetailResponse>(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<WorkflowTaskSummaryResponse>(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<WorkflowTaskDetailResponse>(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<NotificationListResponse>(
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<WorkflowTaskSummaryResponse>(
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<WorkflowTaskDetailResponse>(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<NotificationDeliveryListResponse>(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()));
}
});
});