背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。 改动: - shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。 - prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。 - api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。 - web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。 - 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。 验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
211 lines
6.6 KiB
TypeScript
211 lines
6.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import { useAuthStore } from '@/stores/auth-store';
|
||
|
||
import {
|
||
createRole,
|
||
deleteRole,
|
||
fetchPermissionTree,
|
||
fetchRoleDetail,
|
||
fetchRoleList,
|
||
toRoleListSearchParams,
|
||
updateRole,
|
||
} from './roles';
|
||
|
||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||
|
||
const roleListItem = {
|
||
id: ROLE_ID,
|
||
code: 'reviewer',
|
||
name: '审核员',
|
||
description: '脚本与素材审核',
|
||
isSystem: false,
|
||
status: 'active',
|
||
permissionCount: 4,
|
||
specialPermissionCount: 0,
|
||
userCount: 3,
|
||
dataScope: { type: 'department_and_children' },
|
||
createdAt: '2026-10-27T02:40:00.000Z',
|
||
updatedAt: '2026-10-27T02:40:00.000Z',
|
||
};
|
||
|
||
const roleDetail = {
|
||
...roleListItem,
|
||
permissions: ['script:read', 'script:review', 'material:read', 'material:review'],
|
||
specialPermissions: [],
|
||
boundUsers: [
|
||
{
|
||
id: USER_ID,
|
||
name: '赵一诺',
|
||
account: 'zhao.yinuo',
|
||
phoneMasked: '136****3188',
|
||
departmentName: '内容生产中心 / 素材二组',
|
||
status: 'active',
|
||
},
|
||
],
|
||
};
|
||
|
||
const permissionTree = {
|
||
modules: [
|
||
{
|
||
module: 'script',
|
||
moduleLabel: '脚本',
|
||
sortOrder: 10,
|
||
permissions: [
|
||
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
|
||
{ code: 'script:review', name: '审核脚本', description: '入库审核', isSpecial: false },
|
||
],
|
||
},
|
||
{
|
||
module: 'delivery',
|
||
moduleLabel: '导出交付',
|
||
sortOrder: 40,
|
||
permissions: [
|
||
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
|
||
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||
],
|
||
},
|
||
],
|
||
specialPermissions: [
|
||
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||
],
|
||
sodPairs: [
|
||
{
|
||
id: 'script-write-review',
|
||
label: '脚本生成 × 脚本审核',
|
||
description: '同一角色不得同时拥有',
|
||
permissions: ['script:write', 'script:review'],
|
||
},
|
||
],
|
||
totalPermissions: 4,
|
||
};
|
||
|
||
const saveResult = {
|
||
role: roleDetail,
|
||
affectedUserCount: 3,
|
||
warnings: [],
|
||
};
|
||
|
||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||
new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
|
||
function toUrl(input: string | URL | Request | undefined): string {
|
||
if (typeof input === 'string') {
|
||
return input;
|
||
}
|
||
if (input instanceof URL) {
|
||
return input.href;
|
||
}
|
||
return input?.url ?? '';
|
||
}
|
||
|
||
function bodyOf(init: RequestInit | undefined): unknown {
|
||
return typeof init?.body === 'string' ? JSON.parse(init.body) : null;
|
||
}
|
||
|
||
describe('角色列表请求参数', () => {
|
||
it('按后端 query 约定拼装 snake_case 参数,空关键词不传', () => {
|
||
const params = toRoleListSearchParams({
|
||
limit: 100,
|
||
status: 'all',
|
||
sort: 'created_at',
|
||
order: 'desc',
|
||
keyword: ' 审核 ',
|
||
});
|
||
expect(params.toString()).toBe(
|
||
'limit=100&keyword=%E5%AE%A1%E6%A0%B8&status=all&sort=created_at&order=desc',
|
||
);
|
||
const empty = toRoleListSearchParams({ limit: 20, status: 'all', keyword: '' });
|
||
expect(empty.has('keyword')).toBe(false);
|
||
expect(empty.has('cursor')).toBe(false);
|
||
});
|
||
|
||
it('带游标时完整输出', () => {
|
||
const params = toRoleListSearchParams({ limit: 20, status: 'disabled', cursor: 'cursor-2' });
|
||
expect(params.get('cursor')).toBe('cursor-2');
|
||
expect(params.get('status')).toBe('disabled');
|
||
});
|
||
});
|
||
|
||
describe('角色 API 客户端', () => {
|
||
beforeEach(() => {
|
||
window.localStorage.clear();
|
||
useAuthStore.getState().setSession({
|
||
accessToken: 'token',
|
||
user: { id: ROLE_ID, name: '灵犀管理员', account: 'admin' },
|
||
permissions: ['role:view'],
|
||
});
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('解析角色列表信封与详情', async () => {
|
||
const fetchMock = vi
|
||
.spyOn(globalThis, 'fetch')
|
||
.mockResolvedValueOnce(jsonResponse({ items: [roleListItem], next_cursor: 'cursor-2' }))
|
||
.mockResolvedValueOnce(jsonResponse(roleDetail));
|
||
|
||
const list = await fetchRoleList({ limit: 100, status: 'all' });
|
||
expect(list.items).toHaveLength(1);
|
||
expect(list.next_cursor).toBe('cursor-2');
|
||
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/roles?limit=100&status=all');
|
||
|
||
const detail = await fetchRoleDetail(ROLE_ID);
|
||
expect(detail.permissions).toContain('script:review');
|
||
expect(detail.boundUsers[0]?.name).toBe('赵一诺');
|
||
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/roles/${ROLE_ID}`);
|
||
});
|
||
|
||
it('解析权限点目录', async () => {
|
||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(permissionTree));
|
||
const tree = await fetchPermissionTree();
|
||
expect(tree.totalPermissions).toBe(4);
|
||
expect(tree.specialPermissions[0]?.code).toBe('delivery:export');
|
||
});
|
||
|
||
it('新建角色以 camelCase body 提交并解析保存结果', async () => {
|
||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
|
||
const result = await createRole({
|
||
name: '审核员',
|
||
description: '脚本与素材审核',
|
||
permissions: ['script:read', 'script:review'],
|
||
dataScope: { type: 'department_and_children' },
|
||
});
|
||
expect(result.affectedUserCount).toBe(3);
|
||
const init = fetchMock.mock.calls[0]?.[1];
|
||
expect(init?.method).toBe('POST');
|
||
expect(bodyOf(init)).toEqual({
|
||
name: '审核员',
|
||
description: '脚本与素材审核',
|
||
permissions: ['script:read', 'script:review'],
|
||
dataScope: { type: 'department_and_children' },
|
||
});
|
||
});
|
||
|
||
it('更新角色状态与停用原因', async () => {
|
||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
|
||
await updateRole(ROLE_ID, { status: 'disabled', reason: '岗位调整' });
|
||
const init = fetchMock.mock.calls[0]?.[1];
|
||
expect(init?.method).toBe('PATCH');
|
||
expect(bodyOf(init)).toEqual({ status: 'disabled', reason: '岗位调整' });
|
||
});
|
||
|
||
it('删除角色返回 204', async () => {
|
||
const fetchMock = vi
|
||
.spyOn(globalThis, 'fetch')
|
||
.mockResolvedValue(new Response(null, { status: 204 }));
|
||
await expect(deleteRole(ROLE_ID)).resolves.toBeUndefined();
|
||
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('DELETE');
|
||
});
|
||
|
||
it('响应用共享 schema 校验:非法权限点编码直接报错', async () => {
|
||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||
jsonResponse({ ...roleDetail, permissions: ['ghost:permission'] }),
|
||
);
|
||
await expect(fetchRoleDetail(ROLE_ID)).rejects.toThrow();
|
||
});
|
||
});
|