test: harden business boundary conditions
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import { IsString, MinLength, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@IsString()
|
||||
@@ -10,6 +19,9 @@ export class CreateRoleDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
permissionIds?: number[];
|
||||
}
|
||||
|
||||
@@ -24,6 +36,9 @@ export class UpdateRoleDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
permissionIds?: number[];
|
||||
}
|
||||
|
||||
@@ -40,6 +55,9 @@ export class CreateUserDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
roleIds?: number[];
|
||||
}
|
||||
|
||||
@@ -58,6 +76,9 @@ export class UpdateUserDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
roleIds?: number[];
|
||||
}
|
||||
|
||||
@@ -79,4 +100,3 @@ export class UpdateProfileDto {
|
||||
@IsString()
|
||||
qualifications?: string;
|
||||
}
|
||||
|
||||
|
||||
95
apps/server/src/rbac/rbac.boundary.spec.ts
Normal file
95
apps/server/src/rbac/rbac.boundary.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { RbacService } from './rbac.service';
|
||||
import { CreateRoleDto, CreateUserDto, UpdateUserDto } from './dto/rbac.dto';
|
||||
|
||||
function makeService(overrides?: {
|
||||
permRepo?: Record<string, jest.Mock>;
|
||||
roleRepo?: Record<string, jest.Mock>;
|
||||
userRepo?: Record<string, jest.Mock>;
|
||||
}) {
|
||||
const permRepo = {
|
||||
findByIds: jest.fn().mockResolvedValue([]),
|
||||
...(overrides?.permRepo ?? {}),
|
||||
};
|
||||
const roleRepo = {
|
||||
create: jest.fn((value) => ({ ...value })),
|
||||
save: jest.fn(async (value) => value),
|
||||
findByIds: jest.fn().mockResolvedValue([]),
|
||||
findOneOrFail: jest.fn(),
|
||||
...(overrides?.roleRepo ?? {}),
|
||||
};
|
||||
const userRepo = {
|
||||
create: jest.fn((value) => ({ ...value })),
|
||||
save: jest.fn(async (value) => value),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
...(overrides?.userRepo ?? {}),
|
||||
};
|
||||
return {
|
||||
service: new RbacService(
|
||||
permRepo as never,
|
||||
roleRepo as never,
|
||||
userRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
),
|
||||
permRepo,
|
||||
roleRepo,
|
||||
userRepo,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RBAC mutation boundaries', () => {
|
||||
it('rejects a role when any requested permission id does not exist', async () => {
|
||||
const { service, roleRepo } = makeService({
|
||||
permRepo: { findByIds: jest.fn().mockResolvedValue([{ id: 1, code: 'student:view' }]) },
|
||||
});
|
||||
|
||||
await expect(service.createRole({ name: 'partial', permissionIds: [1, 999] })).rejects.toThrow(
|
||||
'权限不存在: 999',
|
||||
);
|
||||
expect(roleRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a user when any requested role id does not exist', async () => {
|
||||
const { service, userRepo } = makeService({
|
||||
roleRepo: { findByIds: jest.fn().mockResolvedValue([{ id: 2, name: '老师' }]) },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createUser({
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
name: 'Alice',
|
||||
roleIds: [2, 404],
|
||||
}),
|
||||
).rejects.toThrow('角色不存在: 404');
|
||||
expect(userRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows explicitly clearing all roles from an existing user', async () => {
|
||||
const user = { id: 7, username: 'alice', name: 'Alice', roles: [{ id: 2 }] };
|
||||
const { service, userRepo } = makeService({
|
||||
userRepo: { findOne: jest.fn().mockResolvedValue(user) },
|
||||
});
|
||||
|
||||
await expect(service.updateUser(7, { roleIds: [] })).resolves.toEqual({ message: '更新成功' });
|
||||
expect(user.roles).toEqual([]);
|
||||
expect(userRepo.save).toHaveBeenCalledWith(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RBAC DTO id arrays', () => {
|
||||
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||
|
||||
it.each([
|
||||
[CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }],
|
||||
[CreateRoleDto, { name: 'role', permissionIds: [1, 1] }],
|
||||
[CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice', roleIds: [0] }],
|
||||
[UpdateUserDto, { roleIds: [1.5] }],
|
||||
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
|
||||
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -301,7 +301,11 @@ export class RbacService {
|
||||
}
|
||||
}
|
||||
|
||||
if (role.code !== preset.code || role.name !== preset.name || role.description !== preset.description) {
|
||||
if (
|
||||
role.code !== preset.code ||
|
||||
role.name !== preset.name ||
|
||||
role.description !== preset.description
|
||||
) {
|
||||
role.code = preset.code;
|
||||
role.name = preset.name;
|
||||
role.description = preset.description;
|
||||
@@ -368,6 +372,28 @@ export class RbacService {
|
||||
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
|
||||
}
|
||||
|
||||
private async resolvePermissions(permissionIds: number[]): Promise<Permission[]> {
|
||||
const uniqueIds = [...new Set(permissionIds)];
|
||||
const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : [];
|
||||
if (permissions.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(permissions.map((permission) => permission.id));
|
||||
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new Error(`权限不存在: ${missingIds.join(',')}`);
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
private async resolveRoles(roleIds: number[]): Promise<Role[]> {
|
||||
const uniqueIds = [...new Set(roleIds)];
|
||||
const roles = uniqueIds.length > 0 ? await this.roleRepo.findByIds(uniqueIds) : [];
|
||||
if (roles.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(roles.map((role) => role.id));
|
||||
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new Error(`角色不存在: ${missingIds.join(',')}`);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
async createRole(dto: {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -375,7 +401,7 @@ export class RbacService {
|
||||
}): Promise<Role> {
|
||||
const role = this.roleRepo.create({ name: dto.name, description: dto.description });
|
||||
if (dto.permissionIds && dto.permissionIds.length > 0) {
|
||||
role.permissions = await this.permRepo.findByIds(dto.permissionIds);
|
||||
role.permissions = await this.resolvePermissions(dto.permissionIds);
|
||||
}
|
||||
return this.roleRepo.save(role);
|
||||
}
|
||||
@@ -392,7 +418,7 @@ export class RbacService {
|
||||
if (dto.description !== undefined) role.description = dto.description;
|
||||
if (dto.permissionIds !== undefined) {
|
||||
role.permissions =
|
||||
dto.permissionIds.length > 0 ? await this.permRepo.findByIds(dto.permissionIds) : [];
|
||||
dto.permissionIds.length > 0 ? await this.resolvePermissions(dto.permissionIds) : [];
|
||||
}
|
||||
return this.roleRepo.save(role);
|
||||
}
|
||||
@@ -472,7 +498,7 @@ export class RbacService {
|
||||
name: dto.name,
|
||||
});
|
||||
if (dto.roleIds && dto.roleIds.length > 0) {
|
||||
user.roles = await this.roleRepo.findByIds(dto.roleIds);
|
||||
user.roles = await this.resolveRoles(dto.roleIds);
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
return { message: '用户创建成功' };
|
||||
@@ -492,7 +518,7 @@ export class RbacService {
|
||||
if (dto.name !== undefined) user.name = dto.name;
|
||||
if (dto.isActive !== undefined) user.isActive = dto.isActive;
|
||||
if (dto.roleIds !== undefined) {
|
||||
user.roles = dto.roleIds.length > 0 ? await this.roleRepo.findByIds(dto.roleIds) : [];
|
||||
user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : [];
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
return { message: '更新成功' };
|
||||
|
||||
Reference in New Issue
Block a user