test: harden business boundary conditions

This commit is contained in:
2026-07-15 00:03:55 +08:00
parent 17a5046ea0
commit b1f35f9d1a
65 changed files with 2311 additions and 293 deletions

View File

@@ -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;
}

View 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();
});
});

View File

@@ -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: '更新成功' };