diff --git a/backend/src/users-module/users.controller.spec.ts b/backend/src/users-module/users.controller.spec.ts index 9bd0d810..866a7218 100644 --- a/backend/src/users-module/users.controller.spec.ts +++ b/backend/src/users-module/users.controller.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ThrottlerModule } from '@nestjs/throttler'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { CreateUserDto } from './create-user.dto'; @@ -30,6 +31,9 @@ describe('UsersController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ + ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]), + ], controllers: [UsersController], providers: [{ provide: UsersService, useValue: mockService }], }).compile(); @@ -66,7 +70,13 @@ describe('UsersController', () => { describe('findAll', () => { it('should return paginated users', async () => { const pagination: PaginationDto = { page: 1, limit: 10 }; - const expected = { data: [mockProfile()], total: 1, page: 1, limit: 10, totalPages: 1 }; + const expected = { + data: [mockProfile()], + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }; mockService.findAll.mockResolvedValue(expected); const result = await controller.findAll(pagination); @@ -90,7 +100,10 @@ describe('UsersController', () => { describe('update', () => { it('should update and return the user', async () => { const dto: UpdateUserDto = { firstName: 'Jane' }; - mockService.update.mockResolvedValue({ ...mockProfile(), firstName: 'Jane' }); + mockService.update.mockResolvedValue({ + ...mockProfile(), + firstName: 'Jane', + }); const result = await controller.update('uuid-1', dto); diff --git a/backend/src/users-module/users.controller.ts b/backend/src/users-module/users.controller.ts index ddefbc78..84960089 100644 --- a/backend/src/users-module/users.controller.ts +++ b/backend/src/users-module/users.controller.ts @@ -12,13 +12,10 @@ import { ParseUUIDPipe, UseInterceptors, ClassSerializerInterceptor, + UseGuards, } from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiParam, -} from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { UsersService } from './users.service'; import { CreateUserDto } from './create-user.dto'; @@ -28,18 +25,25 @@ import { UserProfileDto } from './user-profile.dto'; import { PaginatedUsersResponseDto } from './paginated-users-response.dto'; @ApiTags('Users') +@UseGuards(ThrottlerGuard) @Controller({ path: 'users', version: '1' }) @UseInterceptors(ClassSerializerInterceptor) export class UsersController { - constructor(private readonly usersService: UsersService) { } + constructor(private readonly usersService: UsersService) {} // POST /users @Post() + @Throttle({ default: { limit: 5, ttl: 60000 } }) @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create a new user' }) - @ApiResponse({ status: 201, description: 'User created', type: UserProfileDto }) + @ApiResponse({ + status: 201, + description: 'User created', + type: UserProfileDto, + }) @ApiResponse({ status: 409, description: 'Email or username already in use' }) @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 429, description: 'Too many requests (5 per minute)' }) create(@Body() dto: CreateUserDto): Promise { return this.usersService.create(dto); } @@ -62,7 +66,11 @@ export class UsersController { @Get(':id') @ApiOperation({ summary: 'Get a single user by ID' }) @ApiParam({ name: 'id', description: 'User UUID' }) - @ApiResponse({ status: 200, description: 'User profile', type: UserProfileDto }) + @ApiResponse({ + status: 200, + description: 'User profile', + type: UserProfileDto, + }) @ApiResponse({ status: 404, description: 'User not found' }) findOne(@Param('id', ParseUUIDPipe) id: string): Promise { return this.usersService.findOne(id); @@ -70,11 +78,17 @@ export class UsersController { // PATCH /users/:id @Patch(':id') + @Throttle({ default: { limit: 5, ttl: 60000 } }) @ApiOperation({ summary: 'Update a user' }) @ApiParam({ name: 'id', description: 'User UUID' }) - @ApiResponse({ status: 200, description: 'Updated user profile', type: UserProfileDto }) + @ApiResponse({ + status: 200, + description: 'Updated user profile', + type: UserProfileDto, + }) @ApiResponse({ status: 404, description: 'User not found' }) @ApiResponse({ status: 409, description: 'Email or username already in use' }) + @ApiResponse({ status: 429, description: 'Too many requests (5 per minute)' }) update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateUserDto, @@ -84,11 +98,13 @@ export class UsersController { // DELETE /users/:id @Delete(':id') + @Throttle({ default: { limit: 5, ttl: 60000 } }) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a user' }) @ApiParam({ name: 'id', description: 'User UUID' }) @ApiResponse({ status: 204, description: 'User deleted' }) @ApiResponse({ status: 404, description: 'User not found' }) + @ApiResponse({ status: 429, description: 'Too many requests (5 per minute)' }) remove(@Param('id', ParseUUIDPipe) id: string): Promise { return this.usersService.remove(id); } diff --git a/backend/src/users-module/users.throttle.spec.ts b/backend/src/users-module/users.throttle.spec.ts new file mode 100644 index 00000000..36843445 --- /dev/null +++ b/backend/src/users-module/users.throttle.spec.ts @@ -0,0 +1,71 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; +import { + THROTTLER_LIMIT, + THROTTLER_TTL, +} from '@nestjs/throttler/dist/throttler.constants'; + +import { UsersController } from './users.controller'; + +const WRITE_HANDLERS = ['create', 'update', 'remove'] as const; +const READ_HANDLERS = ['findAll', 'findOne'] as const; + +type UsersControllerHandler = ( + typeof WRITE_HANDLERS | typeof READ_HANDLERS +)[number]; + +const handlerFor = (handlerName: UsersControllerHandler): unknown => + (UsersController.prototype as Record)[ + handlerName + ]; + +const apiResponsesFor = ( + handlerName: UsersControllerHandler, +): Record => { + const responses = Reflect.getMetadata( + 'swagger/apiResponse', + handlerFor(handlerName), + ) as Record | undefined; + + return responses ?? {}; +}; + +describe('UsersController rate limiting metadata', () => { + it('applies ThrottlerGuard at the controller level', () => { + const guards = Reflect.getMetadata('__guards__', UsersController) as + unknown[] | undefined; + + expect(guards ?? []).toContain(ThrottlerGuard); + }); + + it.each(WRITE_HANDLERS)( + 'limits %s to 5 write requests per minute', + (handlerName) => { + const handler = handlerFor(handlerName); + + expect(Reflect.getMetadata(THROTTLER_LIMIT + 'default', handler)).toBe(5); + expect(Reflect.getMetadata(THROTTLER_TTL + 'default', handler)).toBe( + 60000, + ); + }, + ); + + it.each(READ_HANDLERS)( + 'does not add explicit throttle metadata to read handler %s', + (handlerName) => { + const handler = handlerFor(handlerName); + + expect( + Reflect.getMetadata(THROTTLER_LIMIT + 'default', handler), + ).toBeUndefined(); + expect( + Reflect.getMetadata(THROTTLER_TTL + 'default', handler), + ).toBeUndefined(); + }, + ); + + it.each(WRITE_HANDLERS)('documents 429 responses for %s', (handlerName) => { + expect(apiResponsesFor(handlerName)['429']).toMatchObject({ + description: 'Too many requests (5 per minute)', + }); + }); +});