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:
1 parent
f97f97b0cd
commit
61894df401
80 files changed
+14380
-237
No files matched your search
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ulid } from '../ulid.js';
|
||||
import {
|
||||
createRoleInputSchema,
|
||||
permissionCodeSchema,
|
||||
permissionTreeSchema,
|
||||
roleDataScopeInputSchema,
|
||||
roleDetailSchema,
|
||||
roleListItemSchema,
|
||||
roleListQuerySchema,
|
||||
roleSaveResultSchema,
|
||||
updateRoleInputSchema,
|
||||
} from './roles.js';
|
||||
|
||||
const ISO = '2026-11-03T08:00:00.000Z';
|
||||
|
||||
function makeListItem(): Record<string, unknown> {
|
||||
return {
|
||||
id: ulid(),
|
||||
code: 'material_operator',
|
||||
name: '素材运营',
|
||||
description: '素材生产、快筛、导出与归档',
|
||||
isSystem: false,
|
||||
status: 'active',
|
||||
permissionCount: 9,
|
||||
specialPermissionCount: 0,
|
||||
userCount: 2,
|
||||
dataScope: { type: 'owned_customers' },
|
||||
createdAt: ISO,
|
||||
updatedAt: ISO,
|
||||
};
|
||||
}
|
||||
|
||||
describe('roleListItemSchema / roleDetailSchema', () => {
|
||||
it('列表项包含目录统计与数据范围', () => {
|
||||
const parsed = roleListItemSchema.parse(makeListItem());
|
||||
expect(parsed.permissionCount).toBe(9);
|
||||
expect(parsed.userCount).toBe(2);
|
||||
expect(parsed.dataScope.type).toBe('owned_customers');
|
||||
});
|
||||
|
||||
it('详情在列表项基础上含普通权限、特殊权限与绑定用户', () => {
|
||||
const detail = roleDetailSchema.parse({
|
||||
...makeListItem(),
|
||||
permissions: ['script:read', 'script:write'],
|
||||
specialPermissions: ['delivery:export'],
|
||||
boundUsers: [
|
||||
{
|
||||
id: ulid(),
|
||||
name: '唐嘉',
|
||||
account: 'tang.jia',
|
||||
phoneMasked: '135****6641',
|
||||
departmentName: '素材一组',
|
||||
status: 'disabled',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(detail.permissions).toHaveLength(2);
|
||||
expect(detail.specialPermissions).toEqual(['delivery:export']);
|
||||
expect(detail.boundUsers[0]?.phoneMasked).toBe('135****6641');
|
||||
});
|
||||
|
||||
it('拒绝负统计值 / 未登记权限点 / 非法状态', () => {
|
||||
expect(() => roleListItemSchema.parse({ ...makeListItem(), userCount: -1 })).toThrow();
|
||||
expect(() =>
|
||||
roleDetailSchema.parse({
|
||||
...makeListItem(),
|
||||
permissions: ['script:delete_all'],
|
||||
specialPermissions: [],
|
||||
boundUsers: [],
|
||||
}),
|
||||
).toThrow('未登记的权限点编码');
|
||||
expect(() => roleListItemSchema.parse({ ...makeListItem(), status: 'enabled' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色输入 schema', () => {
|
||||
it('新建角色:权限与特殊权限默认空,但数据范围必填(六类单选)', () => {
|
||||
expect(
|
||||
createRoleInputSchema.parse({ name: '运营', dataScope: { type: 'created_by_me' } }),
|
||||
).toEqual({ name: '运营', dataScope: { type: 'created_by_me' } });
|
||||
expect(() => createRoleInputSchema.parse({ name: '运营' })).toThrow();
|
||||
});
|
||||
|
||||
it('权限点必须已登记,且特殊权限单独字段提交', () => {
|
||||
expect(() =>
|
||||
createRoleInputSchema.parse({
|
||||
name: '运营',
|
||||
dataScope: { type: 'all' },
|
||||
permissions: ['script:delete_all'],
|
||||
}),
|
||||
).toThrow('未登记的权限点编码');
|
||||
expect(() =>
|
||||
createRoleInputSchema.parse({
|
||||
name: '运营',
|
||||
dataScope: { type: 'all' },
|
||||
permissions: ['delivery:export'],
|
||||
}),
|
||||
).toThrow('必须放入 specialPermissions');
|
||||
expect(() =>
|
||||
createRoleInputSchema.parse({
|
||||
name: '运营',
|
||||
dataScope: { type: 'all' },
|
||||
specialPermissions: ['script:read'],
|
||||
}),
|
||||
).toThrow('不能放入 specialPermissions');
|
||||
const parsed = createRoleInputSchema.parse({
|
||||
name: '运营',
|
||||
dataScope: { type: 'customer_groups', targetIds: [] },
|
||||
permissions: ['script:read'],
|
||||
specialPermissions: ['delivery:export'],
|
||||
});
|
||||
expect(parsed.permissions).toEqual(['script:read']);
|
||||
expect(parsed.specialPermissions).toEqual(['delivery:export']);
|
||||
});
|
||||
|
||||
it('未知字段被拒绝(strict)', () => {
|
||||
expect(() =>
|
||||
createRoleInputSchema.parse({ name: '运营', dataScope: { type: 'all' }, unknown: 1 }),
|
||||
).toThrow();
|
||||
expect(() => updateRoleInputSchema.parse({ copyFromRoleId: ulid() })).toThrow();
|
||||
});
|
||||
|
||||
it('更新支持 status enabled / disabled 与停用原因', () => {
|
||||
expect(updateRoleInputSchema.parse({ status: 'disabled', reason: '岗位合并' })).toEqual({
|
||||
status: 'disabled',
|
||||
reason: '岗位合并',
|
||||
});
|
||||
expect(() => updateRoleInputSchema.parse({ status: 'active' })).toThrow();
|
||||
});
|
||||
|
||||
it('权限点编码 schema 与目录同源', () => {
|
||||
expect(permissionCodeSchema.parse('role:view')).toBe('role:view');
|
||||
expect(() => permissionCodeSchema.parse('role:delete')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleDataScopeInputSchema', () => {
|
||||
it('六类范围可用;指定客户组可带 targetIds', () => {
|
||||
expect(roleDataScopeInputSchema.parse({ type: 'all' })).toEqual({ type: 'all' });
|
||||
const groups = roleDataScopeInputSchema.parse({
|
||||
type: 'customer_groups',
|
||||
targetIds: [ulid()],
|
||||
});
|
||||
expect(groups.targetIds).toHaveLength(1);
|
||||
expect(roleDataScopeInputSchema.parse({ type: 'customer_groups', targetIds: [] })).toEqual({
|
||||
type: 'customer_groups',
|
||||
targetIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('非「指定客户组」范围不允许 targetIds', () => {
|
||||
expect(() => roleDataScopeInputSchema.parse({ type: 'all', targetIds: [ulid()] })).toThrow(
|
||||
'只有「指定客户组」范围允许填写 targetIds',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleListQuerySchema', () => {
|
||||
it('默认 status=active(兼容 A1 角色多选)、sort=created_at、order=desc、limit=20', () => {
|
||||
expect(roleListQuerySchema.parse({})).toEqual({
|
||||
status: 'active',
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('白名单校验排序与状态', () => {
|
||||
expect(roleListQuerySchema.parse({ sort: 'name', order: 'asc', status: 'disabled' }).sort).toBe(
|
||||
'name',
|
||||
);
|
||||
expect(() => roleListQuerySchema.parse({ sort: 'permission_count' })).toThrow();
|
||||
expect(() => roleListQuerySchema.parse({ status: 'enabled' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permissionTreeSchema / roleSaveResultSchema', () => {
|
||||
it('权限树含模块、特殊权限与 SoD 对', () => {
|
||||
const parsed = permissionTreeSchema.parse({
|
||||
modules: [
|
||||
{
|
||||
module: 'script',
|
||||
moduleLabel: '脚本',
|
||||
sortOrder: 50,
|
||||
permissions: [
|
||||
{ code: 'script:read', name: '脚本查看', description: '', isSpecial: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
specialPermissions: [
|
||||
{ code: 'delivery:export', name: '交付导出', description: '', isSpecial: true },
|
||||
],
|
||||
sodPairs: [
|
||||
{
|
||||
id: 'script-write-review',
|
||||
label: '脚本生成 × 脚本审核',
|
||||
description: '',
|
||||
permissions: ['script:write', 'script:review'],
|
||||
},
|
||||
],
|
||||
totalPermissions: 30,
|
||||
});
|
||||
expect(parsed.modules).toHaveLength(1);
|
||||
expect(parsed.totalPermissions).toBe(30);
|
||||
});
|
||||
|
||||
it('保存结果含角色详情、影响用户数与软提示', () => {
|
||||
const result = roleSaveResultSchema.parse({
|
||||
role: {
|
||||
...makeListItem(),
|
||||
permissions: [],
|
||||
specialPermissions: [],
|
||||
boundUsers: [],
|
||||
},
|
||||
affectedUserCount: 3,
|
||||
warnings: ['角色数量已达建议上限 20'],
|
||||
});
|
||||
expect(result.affectedUserCount).toBe(3);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user