Skip to content
Open
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
17 changes: 15 additions & 2 deletions backend/src/users-module/users.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand Down
36 changes: 26 additions & 10 deletions backend/src/users-module/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<UserProfileDto> {
return this.usersService.create(dto);
}
Expand All @@ -62,19 +66,29 @@ 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<UserProfileDto> {
return this.usersService.findOne(id);
}

// 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,
Expand All @@ -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<void> {
return this.usersService.remove(id);
}
Expand Down
71 changes: 71 additions & 0 deletions backend/src/users-module/users.throttle.spec.ts
Original file line number Diff line number Diff line change
@@ -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<UsersControllerHandler, unknown>)[
handlerName
];

const apiResponsesFor = (
handlerName: UsersControllerHandler,
): Record<string, unknown> => {
const responses = Reflect.getMetadata(
'swagger/apiResponse',
handlerFor(handlerName),
) as Record<string, unknown> | 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)',
});
});
});