PA6 补齐权限与审计回归
这个提交包含在:
@@ -6,6 +6,8 @@ import {
|
||||
FINANCE,
|
||||
FINANCE_MGR,
|
||||
MANAGER,
|
||||
apiGetData,
|
||||
apiPostData,
|
||||
createAuthenticatedApiClient,
|
||||
login,
|
||||
type AuthenticatedApiClient,
|
||||
@@ -23,6 +25,81 @@ type ApiExpectation = {
|
||||
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 = {
|
||||
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 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" });
|
||||
@@ -50,6 +127,20 @@ async function expectApiStatus({ role, api, path, statuses, note }: ApiExpectati
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -100,6 +191,172 @@ function expectArray(value: unknown, context: string) {
|
||||
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-action-grid").locator('a[href="/mobile/approvals"]').first()).toBeVisible();
|
||||
await page.goto("/mobile/approvals");
|
||||
await expect(page).not.toHaveURL(/\/403/);
|
||||
} else {
|
||||
await expect(approvalTab).toBeDisabled();
|
||||
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
|
||||
await page.goto("/mobile/approvals");
|
||||
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<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 }) => {
|
||||
await expectForbiddenRoutes(browser, EMPLOYEE, [
|
||||
@@ -118,6 +375,93 @@ test.describe("R3 权限直连审计", () => {
|
||||
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<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("员工自助直连页面不进入管理态 403", async ({ browser }) => {
|
||||
@@ -143,6 +487,9 @@ test.describe("R3 权限直连审计", () => {
|
||||
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: "员工差旅列表强制本人范围" });
|
||||
|
||||
@@ -175,9 +522,13 @@ test.describe("R3 权限直连审计", () => {
|
||||
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/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: "财务可看付款批次或受功能门禁限制" });
|
||||
|
||||
@@ -190,6 +541,24 @@ test.describe("R3 权限直连审计", () => {
|
||||
});
|
||||
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,
|
||||
@@ -202,4 +571,111 @@ test.describe("R3 权限直连审计", () => {
|
||||
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("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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户