feat(identity): 完成 A2 角色权限与数据范围全栈落地

背景: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 全绿。
This commit is contained in:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
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();
});
});