diff --git a/backend/src/access-logs/access-logs.controller.spec.ts b/backend/src/access-logs/access-logs.controller.spec.ts new file mode 100644 index 0000000..0c6accf --- /dev/null +++ b/backend/src/access-logs/access-logs.controller.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AccessLogsController } from './access-logs.controller'; +import { AccessLogsService } from './access-logs.service'; +import { FilterAccessLogsDto } from './dto/filter-access-logs.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; + +describe('AccessLogsController', () => { + let controller: AccessLogsController; + let service: AccessLogsService; + + const mockAccessLogsService = { + findAll: jest.fn().mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 10, + }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AccessLogsController], + providers: [ + { + provide: AccessLogsService, + useValue: mockAccessLogsService, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(AccessLogsController); + service = module.get(AccessLogsService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should return paginated access logs for admin user', async () => { + const filterDto: FilterAccessLogsDto = { page: 1, limit: 10 }; + const result = await controller.getAccessLogs(filterDto); + + expect(service.findAll).toHaveBeenCalledWith(filterDto); + expect(result).toHaveProperty('data'); + expect(result).toHaveProperty('total'); + }); +}); diff --git a/backend/src/access-logs/access-logs.controller.ts b/backend/src/access-logs/access-logs.controller.ts new file mode 100644 index 0000000..1742a99 --- /dev/null +++ b/backend/src/access-logs/access-logs.controller.ts @@ -0,0 +1,27 @@ +import { + Controller, + Get, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { AccessLogsService } from './access-logs.service'; +import { FilterAccessLogsDto } from './dto/filter-access-logs.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/guards/roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; + +@Controller('admin/access-logs') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AccessLogsController { + constructor(private readonly accessLogsService: AccessLogsService) {} + + @Get() + @HttpCode(HttpStatus.OK) + async getAccessLogs(@Query() filterDto: FilterAccessLogsDto) { + return this.accessLogsService.findAll(filterDto); + } +} diff --git a/backend/src/access-logs/access-logs.module.ts b/backend/src/access-logs/access-logs.module.ts new file mode 100644 index 0000000..6b649be --- /dev/null +++ b/backend/src/access-logs/access-logs.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AccessLog } from './entities/access-log.entity'; +import { AccessLogsService } from './access-logs.service'; +import { AccessLogsController } from './access-logs.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([AccessLog])], + controllers: [AccessLogsController], + providers: [AccessLogsService], + exports: [AccessLogsService], +}) +export class AccessLogsModule {} diff --git a/backend/src/access-logs/dto/create-access-log.dto.ts b/backend/src/access-logs/dto/create-access-log.dto.ts index 970363d..581cdca 100644 --- a/backend/src/access-logs/dto/create-access-log.dto.ts +++ b/backend/src/access-logs/dto/create-access-log.dto.ts @@ -1,6 +1,20 @@ +import { IsNumber, IsOptional, IsString } from 'class-validator'; + export class CreateAccessLogDto { - userId?: string; + @IsOptional() + @IsString() + userId?: string | null; + + @IsString() routePath: string; + + @IsString() httpMethod: string; - ipAddress?: string; + + @IsString() + ipAddress: string; + + @IsOptional() + @IsNumber() + statusCode?: number; } diff --git a/backend/src/access-logs/entities/access-log.entity.ts b/backend/src/access-logs/entities/access-log.entity.ts index 6702abe..7aecfca 100644 --- a/backend/src/access-logs/entities/access-log.entity.ts +++ b/backend/src/access-logs/entities/access-log.entity.ts @@ -24,4 +24,7 @@ export class AccessLog { @CreateDateColumn() createdAt: Date; + + @Column({ type: 'int', nullable: true }) + statusCode: number; } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 51ef429..c5b45e8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { WinstonModule } from 'nest-winston'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { AccessLogsModule } from './access-logs/access-logs.module'; import { AuthModule } from './auth/auth.module'; import { buildWinstonOptions } from './common/logger.config'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; @@ -50,6 +51,7 @@ import { ConfigValidationSchema } from './config/config.validation'; synchronize: true, }), }), + AccessLogsModule, UsersModule, AuthModule, DocumentsModule, diff --git a/backend/src/common/middleware/logger.middleware.ts b/backend/src/common/middleware/logger.middleware.ts index 33fe79b..9fd166c 100644 --- a/backend/src/common/middleware/logger.middleware.ts +++ b/backend/src/common/middleware/logger.middleware.ts @@ -2,12 +2,14 @@ import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import type { Logger } from 'winston'; import { Request, Response, NextFunction } from 'express'; +import { AccessLogsService } from '../../access-logs/access-logs.service'; @Injectable() export class LoggerMiddleware implements NestMiddleware { constructor( @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: Logger, + private readonly accessLogsService: AccessLogsService, ) {} use(req: Request, res: Response, next: NextFunction) { @@ -21,13 +23,19 @@ export class LoggerMiddleware implements NestMiddleware { res.on('finish', () => { const duration = Date.now() - start; + const routePath = req.originalUrl || req.url; + const httpMethod = req.method; + const statusCode = res.statusCode; + const userId = (req as any).user?.id || null; + const payload: Record = { - method: req.method, - path: req.originalUrl || req.url, - status: res.statusCode, + method: httpMethod, + path: routePath, + status: statusCode, duration_ms: duration, user_agent: userAgent, ip, + userId, }; if (req.headers.authorization) { @@ -35,6 +43,19 @@ export class LoggerMiddleware implements NestMiddleware { } this.logger.info('http-request', payload); + + // Persist access log via AccessLogsService asynchronously + this.accessLogsService + .create({ + userId, + routePath, + httpMethod, + ipAddress: ip || '', + statusCode, + }) + .catch((err) => { + this.logger.error('Failed to save access log record:', err); + }); }); next(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ba7937e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "SMALDA", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}