d6452e3f9d
- 以接口权限定义中的 prerequisite_permissions 作为前置依赖单一事实源,生成运行时前置权限映射\n- 保存项目角色权限前预检缺失前置权限与受影响下游权限,未确认时返回 409 调整详情\n- 确认保存时自动合并补齐前置权限与取消下游权限,并提供有效权限矩阵读取接口\n- 前端角色权限编辑改为保存时执行依赖校验,弹窗展示需补充前置权限和受影响下游权限\n- 新增编辑权限取消按钮,清理权限选择即时确认弹窗残留\n- 补充后端接口测试、前端权限编辑测试与优化设计文档
404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { mount } from "@vue/test-utils";
|
|
import { createPinia, setActivePinia } from "pinia";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
|
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
|
|
|
vi.mock("@/api/projectPermissions", () => ({
|
|
fetchApiOperations: vi.fn().mockResolvedValue({ data: { operations: [] } }),
|
|
fetchPermissionTemplates: vi.fn().mockResolvedValue({
|
|
data: [
|
|
{ category: "PV", name: "PV", description: "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" },
|
|
{ category: "QA", name: "QA", description: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
|
|
{ category: "CTA", name: "CTA", description: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
|
|
{ category: "CRA", name: "CRA", description: "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" },
|
|
{ category: "PM", name: "PM", description: "项目负责人,统筹项目全局,协调进度、资源与关键决策。" },
|
|
],
|
|
}),
|
|
}));
|
|
|
|
describe("ApiEndpointPermissions.vue", () => {
|
|
beforeEach(() => {
|
|
Object.defineProperty(window, "localStorage", {
|
|
value: {
|
|
getItem: vi.fn(() => null),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn(),
|
|
clear: vi.fn(),
|
|
},
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
value: window.localStorage,
|
|
configurable: true,
|
|
});
|
|
setActivePinia(createPinia());
|
|
});
|
|
|
|
const mockMatrix: ApiEndpointPermissionsResponse = {
|
|
PM: {
|
|
"subjects:create": true,
|
|
"subjects:list": true,
|
|
"subjects:read": true,
|
|
"subjects:update": true,
|
|
"subjects:delete": true,
|
|
},
|
|
CRA: {
|
|
"subjects:create": true,
|
|
"subjects:list": true,
|
|
"subjects:read": true,
|
|
"subjects:update": true,
|
|
"subjects:delete": false,
|
|
},
|
|
};
|
|
|
|
it("renders permission matrix table", () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: mockMatrix,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(wrapper.find(".api-permissions").exists()).toBe(true);
|
|
});
|
|
|
|
it("keeps matrix read-only and renders authorization states", async () => {
|
|
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
|
|
|
expect(source).not.toContain("只读查看各角色权限覆盖");
|
|
expect(source).toContain("permission-state--allowed");
|
|
expect(source).toContain("permission-state--disabled");
|
|
expect(source).toContain("CircleCloseFilled");
|
|
expect(source).not.toContain('? "✓" : "—"');
|
|
expect(source).not.toContain("defineEmits");
|
|
expect(source).not.toContain("el-checkbox");
|
|
});
|
|
|
|
it("renders role headers from template display names while keeping matrix role keys", () => {
|
|
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
|
|
|
expect(source).toContain("useRoleTemplateMeta");
|
|
expect(source).toContain("compareRolesByTemplateOrder");
|
|
expect(source).toContain(':label="roleLabel(role)"');
|
|
expect(source).toContain(':key="role"');
|
|
expect(source).toContain("isOperationAllowed(row.operation_key, role)");
|
|
expect(source).toContain("permission-state");
|
|
expect(source).toContain('label="权限类型"');
|
|
expect(source).not.toContain(':label="role"');
|
|
});
|
|
|
|
it("pins PM first in matrix role columns even when templates return PM last", async () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: {
|
|
CRA: {},
|
|
CTA: {},
|
|
QA: {},
|
|
PM: {},
|
|
PV: {},
|
|
},
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
},
|
|
},
|
|
});
|
|
await vi.dynamicImportSettled();
|
|
|
|
expect((wrapper.vm as any).roles).toEqual(["PM", "PV", "QA", "CTA", "CRA"]);
|
|
});
|
|
|
|
it("sorts project permission modules by sidebar menu order", async () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: mockMatrix,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
(wrapper.vm as any).operations = [
|
|
{ operation_key: "subjects:read", module: "subjects", action: "read", description: "查询参与者详情", default_roles: [] },
|
|
{ operation_key: "sites:read", module: "sites", action: "read", description: "查询中心详情", default_roles: [] },
|
|
{ operation_key: "audit_logs:read", module: "audit_export", action: "read", description: "查询审计日志", default_roles: [] },
|
|
{ operation_key: "project_overview:read", module: "project_overview", action: "read", description: "查询项目总览", default_roles: [] },
|
|
{ operation_key: "startup_auth:read", module: "startup_auth", action: "read", description: "查询启动授权", default_roles: [] },
|
|
{ operation_key: "fees_contracts:update", module: "fees", action: "write", description: "更新合同费用", default_roles: [] },
|
|
{ operation_key: "fees_contracts:read", module: "fees", action: "read", description: "查询合同费用", default_roles: [] },
|
|
{ operation_key: "monitoring_issues:read", module: "risk_issues", action: "read", description: "查询监查访视问题详情", default_roles: [] },
|
|
];
|
|
|
|
expect((wrapper.vm as any).filteredOperations.map((operation: any) => operation.module)).toEqual([
|
|
"sites",
|
|
"audit_export",
|
|
"project_overview",
|
|
"fees",
|
|
"fees",
|
|
"startup_auth",
|
|
"subjects",
|
|
"risk_issues",
|
|
]);
|
|
expect((wrapper.vm as any).filteredOperations.map((operation: any) => operation.operation_key)).toEqual([
|
|
"sites:read",
|
|
"audit_logs:read",
|
|
"project_overview:read",
|
|
"fees_contracts:update",
|
|
"fees_contracts:read",
|
|
"startup_auth:read",
|
|
"subjects:read",
|
|
"monitoring_issues:read",
|
|
]);
|
|
expect((wrapper.vm as any).uniqueModules).toEqual([
|
|
"sites",
|
|
"audit_export",
|
|
"project_overview",
|
|
"fees",
|
|
"startup_auth",
|
|
"subjects",
|
|
"risk_issues",
|
|
]);
|
|
expect((wrapper.vm as any).getPermissionCategoryLabel({ module: "sites" })).toBe("通用权限");
|
|
expect((wrapper.vm as any).getPermissionCategoryLabel({ module: "fees" })).toBe("业务权限");
|
|
});
|
|
|
|
it("groups participant permissions by section labels", async () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: mockMatrix,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "subject_aes:create",
|
|
module: "subjects",
|
|
action: "write",
|
|
description: "创建参与者AE",
|
|
default_roles: [],
|
|
})).toBe("AE");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "subject_pds:create",
|
|
module: "subjects",
|
|
action: "write",
|
|
description: "创建参与者PD",
|
|
default_roles: [],
|
|
})).toBe("PD");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "visits:list",
|
|
module: "subjects",
|
|
action: "read",
|
|
description: "查询访视列表",
|
|
default_roles: [],
|
|
})).toBe("访视");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "monitoring_issues:create",
|
|
module: "risk_issues",
|
|
action: "write",
|
|
description: "创建监查访视问题",
|
|
default_roles: [],
|
|
})).toBe("监查访视问题");
|
|
});
|
|
|
|
it("projects AE/SAE and PD permissions into risk issues while reusing participant operation keys", async () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: mockMatrix,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
(wrapper.vm as any).operations = [
|
|
{ operation_key: "subject_aes:read", module: "subjects", action: "read", description: "查询参与者AE列表", default_roles: [] },
|
|
{ operation_key: "subject_pds:list", module: "subjects", action: "read", description: "查询参与者PD列表", default_roles: [] },
|
|
{ operation_key: "monitoring_issues:read", module: "risk_issues", action: "read", description: "查询监查访视问题列表", default_roles: [] },
|
|
];
|
|
|
|
const riskRows = (wrapper.vm as any).filteredOperations.filter((operation: any) => operation.module === "risk_issues");
|
|
|
|
expect(riskRows.map((operation: any) => operation.operation_key)).toEqual([
|
|
"subject_aes:read",
|
|
"subject_pds:list",
|
|
"monitoring_issues:read",
|
|
]);
|
|
expect(riskRows.map((operation: any) => (wrapper.vm as any).getOperationSection(operation))).toEqual([
|
|
"AE/SAE",
|
|
"PD",
|
|
"监查访视问题",
|
|
]);
|
|
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2);
|
|
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps contract fee permissions as one module section", () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: mockMatrix,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "fees_contracts:create",
|
|
module: "fees",
|
|
action: "write",
|
|
description: "创建合同费用",
|
|
default_roles: [],
|
|
})).toBe("合同费用");
|
|
|
|
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
|
expect(source).not.toContain("finance_contracts");
|
|
expect(source).not.toContain("fees_payments");
|
|
expect(source).not.toContain("fees_attachments");
|
|
expect(source).not.toContain('contract_fees: "合同费用"');
|
|
expect(source).not.toContain("FEE_SECTION_LABELS");
|
|
});
|
|
|
|
it("groups materials permissions into drug flow and equipment sections", () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: mockMatrix,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "drug_shipments:read",
|
|
module: "materials",
|
|
action: "read",
|
|
description: "查询药品流向详情",
|
|
default_roles: [],
|
|
})).toBe("药品流向管理");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "drug_shipments_attachments:delete",
|
|
module: "materials",
|
|
action: "write",
|
|
description: "删除药物发货附件",
|
|
default_roles: [],
|
|
})).toBe("药品流向管理");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "material_equipments:read",
|
|
module: "materials",
|
|
action: "read",
|
|
description: "查询设备详情",
|
|
default_roles: [],
|
|
})).toBe("设备管理");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "material_equipments_attachments:delete",
|
|
module: "materials",
|
|
action: "write",
|
|
description: "删除物资设备附件",
|
|
default_roles: [],
|
|
})).toBe("设备管理");
|
|
});
|
|
|
|
it("groups startup ethics permissions into initiation and ethics sections", () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: mockMatrix,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "startup_initiation:read",
|
|
module: "startup_ethics",
|
|
action: "read",
|
|
description: "查询立项记录详情",
|
|
default_roles: [],
|
|
})).toBe("立项");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "startup_initiation_attachments:delete",
|
|
module: "startup_ethics",
|
|
action: "write",
|
|
description: "删除立项记录附件",
|
|
default_roles: [],
|
|
})).toBe("立项");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "startup_ethics:read",
|
|
module: "startup_ethics",
|
|
action: "read",
|
|
description: "查询伦理记录详情",
|
|
default_roles: [],
|
|
})).toBe("伦理");
|
|
expect((wrapper.vm as any).getOperationSection({
|
|
operation_key: "startup_ethics_attachments:delete",
|
|
module: "startup_ethics",
|
|
action: "write",
|
|
description: "删除伦理记录附件",
|
|
default_roles: [],
|
|
})).toBe("伦理");
|
|
});
|
|
|
|
it("filters endpoints by search text", async () => {
|
|
const wrapper = mount(ApiEndpointPermissions, {
|
|
props: {
|
|
project: { id: "test-id", name: "Test Project" },
|
|
matrix: mockMatrix,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
ElTable: false,
|
|
ElTableColumn: false,
|
|
ElInput: false,
|
|
ElSelect: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
// 获取搜索输入框
|
|
const searchInput = wrapper.find("input[placeholder*='搜索']");
|
|
if (searchInput.exists()) {
|
|
await searchInput.setValue("subjects");
|
|
// 验证过滤逻辑
|
|
expect((wrapper.vm as any).searchText).toBe("subjects");
|
|
}
|
|
});
|
|
});
|