Files
lingxi/apps/web/src/api/roles.spec.ts
T
Stev_Wang 61894df401 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 全绿。
2026-11-03 12:26:51 +08:00

211 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});