forked from wangziqi/gongxue-base
test: harden business boundary conditions
This commit is contained in:
74
apps/server/src/operation-logs/dto/operation-log.dto.ts
Normal file
74
apps/server/src/operation-logs/dto/operation-log.dto.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsISO8601,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryOperationLogsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
module?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
userId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 50;
|
||||
}
|
||||
|
||||
export class CreateAuditLogDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
module: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
action: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
targetId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
targetType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
detail?: string;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { OperationLogsService } from './operation-logs.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('operation-logs')
|
||||
@@ -11,29 +12,15 @@ export class OperationLogsController {
|
||||
|
||||
@Get()
|
||||
@RequirePermission('log:view')
|
||||
findAll(
|
||||
@Query('module') module?: string,
|
||||
@Query('userId') userId?: string,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
module,
|
||||
userId: userId ? +userId : undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
page: page ? +page : 1,
|
||||
pageSize: pageSize ? +pageSize : 50,
|
||||
});
|
||||
findAll(@Query() query: QueryOperationLogsDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@Post('audit')
|
||||
@RequirePermission('log:create')
|
||||
async createAuditLog(
|
||||
@Body() body: { module: string; action: string; targetId?: number; targetType?: string; detail?: string },
|
||||
@Body() body: CreateAuditLogDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { QueryOperationLogsDto } from './dto/operation-log.dto';
|
||||
import { OperationLogsService } from './operation-logs.service';
|
||||
|
||||
const createQb = () => ({
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
});
|
||||
|
||||
describe('operation log query boundaries', () => {
|
||||
it.each(['0', '-1', '1.5', 'abc'])('rejects invalid page %s', async (page) => {
|
||||
const dto = plainToInstance(QueryOperationLogsDto, { page });
|
||||
expect((await validate(dto)).some((error) => error.property === 'page')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['0', '201', '1.5', 'abc'])('rejects invalid page size %s', async (pageSize) => {
|
||||
const dto = plainToInstance(QueryOperationLogsDto, { pageSize });
|
||||
expect((await validate(dto)).some((error) => error.property === 'pageSize')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it.each(['2026-02-31', '2026-07-13T00:00:00Z'])(
|
||||
'rejects invalid or non-date-only value %s',
|
||||
async (startDate) => {
|
||||
const dto = plainToInstance(QueryOperationLogsDto, { startDate });
|
||||
expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('transforms valid pagination and applies its database window', async () => {
|
||||
const dto = plainToInstance(QueryOperationLogsDto, { page: '2', pageSize: '20' });
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
const qb = createQb();
|
||||
const service = new OperationLogsService({ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never);
|
||||
await service.findAll(dto);
|
||||
expect(qb.skip).toHaveBeenCalledWith(20);
|
||||
expect(qb.take).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it('rejects a reversed period before opening a query', async () => {
|
||||
const repo = { createQueryBuilder: jest.fn() };
|
||||
const service = new OperationLogsService(repo as never);
|
||||
await expect(service.findAll({ startDate: '2026-08-01', endDate: '2026-07-31' }))
|
||||
.rejects.toThrow('结束日期不能早于开始日期');
|
||||
expect(repo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { OperationLog } from '../entities/operation-log.entity';
|
||||
@@ -31,6 +31,9 @@ export class OperationLogsService {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
if (query?.startDate && query?.endDate && query.startDate > query.endDate) {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
const qb = this.repo.createQueryBuilder('log').orderBy('log.createdAt', 'DESC');
|
||||
if (query?.module) qb.andWhere('log.module = :module', { module: query.module });
|
||||
if (query?.userId) qb.andWhere('log.userId = :userId', { userId: query.userId });
|
||||
|
||||
Reference in New Issue
Block a user