Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions backend/src/access-logs/access-logs.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(AccessLogsController);
service = module.get<AccessLogsService>(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');
});
});
27 changes: 27 additions & 0 deletions backend/src/access-logs/access-logs.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions backend/src/access-logs/access-logs.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
18 changes: 16 additions & 2 deletions backend/src/access-logs/dto/create-access-log.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions backend/src/access-logs/entities/access-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ export class AccessLog {

@CreateDateColumn()
createdAt: Date;

@Column({ type: 'int', nullable: true })
statusCode: number;
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,6 +51,7 @@ import { ConfigValidationSchema } from './config/config.validation';
synchronize: true,
}),
}),
AccessLogsModule,
UsersModule,
AuthModule,
DocumentsModule,
Expand Down
27 changes: 24 additions & 3 deletions backend/src/common/middleware/logger.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -21,20 +23,39 @@ 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<string, unknown> = {
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) {
payload.authorization = '[REDACTED]';
}

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();
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading