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
@@ -19,11 +19,18 @@ interface FixtureUser {
|
||||
account: string;
|
||||
}
|
||||
|
||||
interface MeBody {
|
||||
permissions: string[];
|
||||
dataScopes: { type: string; customerIds?: string[] }[];
|
||||
user: { id: string; phone?: string };
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
interface TokenBody {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean };
|
||||
me: MeBody;
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
@@ -88,6 +95,87 @@ async function setRole(userId: string, roleCode: string): Promise<void> {
|
||||
await prisma.userRole.create({ data: { userId, roleId: role.id } });
|
||||
}
|
||||
|
||||
interface JsonRequestResult<T> {
|
||||
status: number;
|
||||
body: T;
|
||||
}
|
||||
|
||||
async function callJson<T>(
|
||||
app: CreatedApiApp['app'],
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
token: string,
|
||||
payload?: unknown,
|
||||
): Promise<JsonRequestResult<T>> {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||
});
|
||||
return { status: response.statusCode, body: response.json<T>() };
|
||||
}
|
||||
|
||||
const ROLE_NAME_PREFIX = 'IT-角色-';
|
||||
|
||||
/** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */
|
||||
const createdRoleIds: string[] = [];
|
||||
|
||||
/** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */
|
||||
async function createTestRole(input: {
|
||||
name: string;
|
||||
permissions?: string[];
|
||||
isSystem?: boolean;
|
||||
dataScope?: { type: string; targetIds?: string[] };
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
}): Promise<{ id: string; name: string }> {
|
||||
const id = ulid();
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
id,
|
||||
code: `it_role_${id.toLowerCase()}`,
|
||||
name: `${ROLE_NAME_PREFIX}${input.name}`,
|
||||
description: '集成测试角色',
|
||||
isSystem: input.isSystem ?? false,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
permissions: {
|
||||
create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })),
|
||||
},
|
||||
dataScopes: {
|
||||
create: [
|
||||
{
|
||||
id: ulid(),
|
||||
scopeType: input.dataScope?.type ?? 'created_by_me',
|
||||
targetId: input.dataScope?.targetIds?.[0] ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdRoleIds.push(role.id);
|
||||
return { id: role.id, name: role.name };
|
||||
}
|
||||
|
||||
async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise<FixtureUser> {
|
||||
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||||
const id = ulid();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: `集成测试${suffix}`,
|
||||
account,
|
||||
email: `${account}@lingxi.cn`,
|
||||
phone: `138${String(Date.now()).slice(-8)}`,
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId }] },
|
||||
},
|
||||
});
|
||||
return { id, account };
|
||||
}
|
||||
|
||||
describe('identity 集成(真实 PG / Redis)', () => {
|
||||
let created: CreatedApiApp;
|
||||
let manager: FixtureUser;
|
||||
@@ -98,12 +186,17 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
||||
created = await createApp();
|
||||
await created.app.init();
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
|
||||
manager = await createFixtureUser('manager', 'platform_admin');
|
||||
viewer = await createFixtureUser('viewer', 'designer');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
|
||||
if (createdRoleIds.length > 0) {
|
||||
await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
await created.app.close();
|
||||
});
|
||||
@@ -235,4 +328,316 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
||||
expect(roles.statusCode).toBe(200);
|
||||
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// A2:角色权限、权限树、SoD、数据范围、停用即时生效
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const response = await callJson<{
|
||||
modules: { module: string; moduleLabel: string }[];
|
||||
specialPermissions: { code: string }[];
|
||||
sodPairs: { id: string }[];
|
||||
totalPermissions: number;
|
||||
}>(created.app, 'GET', '/permissions', body.accessToken);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.totalPermissions).toBe(30);
|
||||
expect(response.body.modules.map((module) => module.module)).toEqual([
|
||||
'script',
|
||||
'material',
|
||||
'asset',
|
||||
'delivery',
|
||||
'customer',
|
||||
'customer_group',
|
||||
'identity',
|
||||
'industry',
|
||||
'platform',
|
||||
'audit',
|
||||
]);
|
||||
expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组');
|
||||
expect(response.body.specialPermissions).toHaveLength(6);
|
||||
expect(response.body.sodPairs).toHaveLength(3);
|
||||
|
||||
// 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响)
|
||||
const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } });
|
||||
const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id);
|
||||
const designerLogin = await login(created.app, designerOnly.account);
|
||||
const denied = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'GET',
|
||||
'/permissions',
|
||||
designerLogin.body.accessToken,
|
||||
);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||
});
|
||||
|
||||
it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const response = await callJson<{
|
||||
items: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
isSystem: boolean;
|
||||
permissionCount: number;
|
||||
specialPermissionCount: number;
|
||||
userCount: number;
|
||||
dataScope: { type: string };
|
||||
}[];
|
||||
}>(created.app, 'GET', '/roles', body.accessToken);
|
||||
expect(response.status).toBe(200);
|
||||
const codes = response.body.items.map((item) => item.code);
|
||||
expect(codes).not.toContain('review_lead');
|
||||
expect(codes).toContain('material_operator');
|
||||
const superAdmin = response.body.items.find((item) => item.code === 'super_admin');
|
||||
expect(superAdmin?.isSystem).toBe(true);
|
||||
expect(superAdmin?.permissionCount).toBe(24);
|
||||
expect(superAdmin?.specialPermissionCount).toBe(6);
|
||||
expect(superAdmin?.dataScope).toEqual({ type: 'all' });
|
||||
|
||||
const all = await callJson<{ items: { code: string }[] }>(
|
||||
created.app,
|
||||
'GET',
|
||||
'/roles?status=all',
|
||||
body.accessToken,
|
||||
);
|
||||
expect(all.body.items.map((item) => item.code)).toContain('review_lead');
|
||||
|
||||
// A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选)
|
||||
const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] });
|
||||
const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id);
|
||||
const selectorLogin = await login(created.app, selector.account);
|
||||
const selectorRoles = await callJson<{ items: { code: string }[] }>(
|
||||
created.app,
|
||||
'GET',
|
||||
'/roles',
|
||||
selectorLogin.body.accessToken,
|
||||
);
|
||||
expect(selectorRoles.status).toBe(200);
|
||||
expect(selectorRoles.body.items.length).toBeGreaterThan(0);
|
||||
const selectorPermissions = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'GET',
|
||||
'/permissions',
|
||||
selectorLogin.body.accessToken,
|
||||
);
|
||||
expect(selectorPermissions.status).toBe(403);
|
||||
});
|
||||
|
||||
it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const createdRole = await callJson<{
|
||||
role: {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
permissions: string[];
|
||||
specialPermissions: string[];
|
||||
dataScope: { type: string };
|
||||
boundUsers: unknown[];
|
||||
};
|
||||
affectedUserCount: number;
|
||||
warnings: string[];
|
||||
}>(created.app, 'POST', '/roles', body.accessToken, {
|
||||
name: `${ROLE_NAME_PREFIX}内容运营`,
|
||||
permissions: ['script:read'],
|
||||
dataScope: { type: 'department' },
|
||||
});
|
||||
expect(createdRole.status).toBe(201);
|
||||
createdRoleIds.push(createdRole.body.role.id);
|
||||
expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/);
|
||||
expect(createdRole.body.role.permissions).toEqual(['script:read']);
|
||||
expect(createdRole.body.role.specialPermissions).toEqual([]);
|
||||
expect(createdRole.body.role.dataScope).toEqual({ type: 'department' });
|
||||
expect(createdRole.body.affectedUserCount).toBe(0);
|
||||
|
||||
const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>(
|
||||
created.app,
|
||||
'POST',
|
||||
'/roles',
|
||||
body.accessToken,
|
||||
{
|
||||
name: `${ROLE_NAME_PREFIX}互斥冲突`,
|
||||
permissions: ['script:write', 'script:review'],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
);
|
||||
expect(sod.status).toBe(403);
|
||||
expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION);
|
||||
expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review');
|
||||
|
||||
const specialInNormal = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'POST',
|
||||
'/roles',
|
||||
body.accessToken,
|
||||
{
|
||||
name: `${ROLE_NAME_PREFIX}特殊权限错位`,
|
||||
permissions: ['delivery:export'],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
);
|
||||
expect(specialInNormal.status).toBe(400);
|
||||
expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
|
||||
it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const role = await createTestRole({
|
||||
name: '待编辑',
|
||||
permissions: ['script:read'],
|
||||
dataScope: { type: 'department' },
|
||||
});
|
||||
const boundUser = await createFixtureUserWithRoleId('role-edit', role.id);
|
||||
|
||||
const patched = await callJson<{
|
||||
role: {
|
||||
permissions: string[];
|
||||
specialPermissions: string[];
|
||||
dataScope: { type: string };
|
||||
status: string;
|
||||
};
|
||||
affectedUserCount: number;
|
||||
warnings: string[];
|
||||
}>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, {
|
||||
permissions: ['script:read', 'user:view'],
|
||||
specialPermissions: ['delivery:export'],
|
||||
dataScope: { type: 'owned_customers' },
|
||||
});
|
||||
expect(patched.status).toBe(200);
|
||||
expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']);
|
||||
expect(patched.body.role.specialPermissions).toEqual(['delivery:export']);
|
||||
expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' });
|
||||
expect(patched.body.affectedUserCount).toBe(1);
|
||||
|
||||
const outbox = await prisma.outboxEvent.findFirst({
|
||||
where: { aggregateId: role.id, eventType: 'role.permissions.changed' },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
});
|
||||
expect(outbox).not.toBeNull();
|
||||
const payload = outbox?.payloadJson as {
|
||||
roleId: string;
|
||||
affectedUserIds: string[];
|
||||
changes: { permissions: { added: string[]; removed: string[] } };
|
||||
};
|
||||
expect(payload.roleId).toBe(role.id);
|
||||
expect(payload.affectedUserIds).toEqual([boundUser.id]);
|
||||
expect(payload.changes.permissions.added).toEqual(['user:view']);
|
||||
|
||||
// 系统角色保护:改权限 40903,仅显示名可改
|
||||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||
const protectedUpdate = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'PATCH',
|
||||
`/roles/${superAdminRole.id}`,
|
||||
body.accessToken,
|
||||
{ permissions: ['user:view'] },
|
||||
);
|
||||
expect(protectedUpdate.status).toBe(409);
|
||||
expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
const renamed = await callJson<{ role: { name: string } }>(
|
||||
created.app,
|
||||
'PATCH',
|
||||
`/roles/${superAdminRole.id}`,
|
||||
body.accessToken,
|
||||
{ name: '超级管理员' },
|
||||
);
|
||||
expect(renamed.status).toBe(200);
|
||||
expect(renamed.body.role.name).toBe('超级管理员');
|
||||
});
|
||||
|
||||
it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||
const systemDelete = await callJson<{ code: number }>(
|
||||
created.app,
|
||||
'DELETE',
|
||||
`/roles/${superAdminRole.id}`,
|
||||
body.accessToken,
|
||||
);
|
||||
expect(systemDelete.status).toBe(409);
|
||||
expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
const bound = await createTestRole({ name: '有绑定' });
|
||||
await createFixtureUserWithRoleId('role-bound', bound.id);
|
||||
const boundDelete = await callJson<{ code: number; details: { userCount: number } }>(
|
||||
created.app,
|
||||
'DELETE',
|
||||
`/roles/${bound.id}`,
|
||||
body.accessToken,
|
||||
);
|
||||
expect(boundDelete.status).toBe(409);
|
||||
expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
expect(boundDelete.body.details.userCount).toBe(1);
|
||||
|
||||
const free = await createTestRole({ name: '无绑定' });
|
||||
const freeDelete = await created.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `${API}/roles/${free.id}`,
|
||||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||
});
|
||||
expect(freeDelete.statusCode).toBe(204);
|
||||
const gone = await prisma.role.findUnique({ where: { id: free.id } });
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
|
||||
it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => {
|
||||
const { body } = await login(created.app, manager.account);
|
||||
const role = await createTestRole({ name: '待停用', permissions: ['user:view'] });
|
||||
const user = await createFixtureUserWithRoleId('role-disable', role.id);
|
||||
const userLogin = await login(created.app, user.account);
|
||||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||||
|
||||
const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>(
|
||||
created.app,
|
||||
'PATCH',
|
||||
`/roles/${role.id}`,
|
||||
body.accessToken,
|
||||
{ status: 'disabled', reason: '集成测试停用' },
|
||||
);
|
||||
expect(disabled.status).toBe(200);
|
||||
expect(disabled.body.role.status).toBe('disabled');
|
||||
expect(disabled.body.affectedUserCount).toBe(1);
|
||||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403);
|
||||
|
||||
const enabled = await callJson<{ role: { status: string } }>(
|
||||
created.app,
|
||||
'PATCH',
|
||||
`/roles/${role.id}`,
|
||||
body.accessToken,
|
||||
{ status: 'enabled' },
|
||||
);
|
||||
expect(enabled.body.role.status).toBe('active');
|
||||
// 再启用后同一 access token 恢复权限(每请求解析,无缓存)
|
||||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => {
|
||||
const adminLogin = await login(created.app, manager.account);
|
||||
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
|
||||
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
|
||||
expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]);
|
||||
|
||||
const ownedRole = await createTestRole({
|
||||
name: '负责客户范围',
|
||||
permissions: ['user:view'],
|
||||
dataScope: { type: 'owned_customers' },
|
||||
});
|
||||
const deptRole = await createTestRole({
|
||||
name: '部门范围',
|
||||
permissions: ['customer:read'],
|
||||
dataScope: { type: 'department' },
|
||||
});
|
||||
const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id);
|
||||
await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } });
|
||||
const userLogin = await login(created.app, user.account);
|
||||
expect(userLogin.status).toBe(200);
|
||||
const scopes = userLogin.body.me.dataScopes;
|
||||
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
|
||||
for (const scope of scopes) {
|
||||
expect(scope.customerIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user