From 7860fd417ce063ec1b6ea4645fc296d91b617fda Mon Sep 17 00:00:00 2001 From: FerreiroAlberto Date: Thu, 2 May 2024 20:32:45 +0200 Subject: [PATCH] Add guards and validation --- src/core/auth/logged.guard.spec.ts | 69 ++++++++++++++++++++ src/core/auth/logged.guard.ts | 28 ++++++++ src/core/auth/owner.guard.spec.ts | 89 ++++++++++++++++++++++++++ src/core/auth/owner.guard.ts | 30 +++++++++ src/policies/dto/create-policy.dto.ts | 8 +++ src/policies/entities/policy.entity.ts | 2 +- src/policies/policies.controller.ts | 22 +++++-- src/policies/policies.module.ts | 7 +- src/policies/policies.service.ts | 19 +++--- src/users/dto/create-user.dto.ts | 10 +++ src/users/dto/update-user.dto.ts | 18 +++++- src/users/entities/user.entity.ts | 4 ++ src/users/users.controller.ts | 13 ++-- src/users/users.module.ts | 4 +- src/users/users.service.spec.ts | 9 ++- src/users/users.service.ts | 17 +++-- 16 files changed, 318 insertions(+), 31 deletions(-) create mode 100644 src/core/auth/logged.guard.spec.ts create mode 100644 src/core/auth/logged.guard.ts create mode 100644 src/core/auth/owner.guard.spec.ts create mode 100644 src/core/auth/owner.guard.ts diff --git a/src/core/auth/logged.guard.spec.ts b/src/core/auth/logged.guard.spec.ts new file mode 100644 index 0000000..f03481d --- /dev/null +++ b/src/core/auth/logged.guard.spec.ts @@ -0,0 +1,69 @@ +import { LoggedGuard } from './logged.guard'; +import { ExecutionContext } from '@nestjs/common'; +import { CryptoService } from '../crypto/crypto.service'; + +const cryptoServiceMock: CryptoService = { + verifyToken: jest.fn().mockResolvedValue({}), +} as unknown as CryptoService; + +describe('AuthGuard', () => { + const loggedGuard = new LoggedGuard(cryptoServiceMock); + it('should be defined', () => { + expect(loggedGuard).toBeDefined(); + }); + + describe('When we call canActivate method', () => { + it('should return true', async () => { + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + authorization: 'Bearer token', + }, + }), + }), + } as unknown as ExecutionContext; + const result = await loggedGuard.canActivate(context); + expect(result).toBe(true); + }); + + describe('And there are NOT Authorization header', () => { + it('should throw BadRequestException', async () => { + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: {}, + }), + }), + } as ExecutionContext; + try { + await loggedGuard.canActivate(context); + } catch (error) { + expect(error.message).toBe('Authorization header is required'); + } + }); + }); + + describe('And token is invalid', () => { + it('should throw ForbiddenException', async () => { + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + authorization: 'Bearer token', + }, + }), + }), + } as ExecutionContext; + cryptoServiceMock.verifyToken = jest + .fn() + .mockRejectedValue(new Error()); + try { + await loggedGuard.canActivate(context); + } catch (error) { + expect(error.message).toBe('Invalid token'); + } + }); + }); + }); +}); diff --git a/src/core/auth/logged.guard.ts b/src/core/auth/logged.guard.ts new file mode 100644 index 0000000..8687223 --- /dev/null +++ b/src/core/auth/logged.guard.ts @@ -0,0 +1,28 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { CryptoService } from '../crypto/crypto.service'; + +@Injectable() +export class LoggedGuard implements CanActivate { + constructor(private readonly cryptoService: CryptoService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const auth = request.headers.authorization; + if (!auth) { + throw new BadRequestException('Authorization header is required'); + } + const token = auth.split(' ')[1]; + try { + request.payload = await this.cryptoService.verifyToken(token); + return true; + } catch (error) { + throw new ForbiddenException('Invalid token'); + } + } +} diff --git a/src/core/auth/owner.guard.spec.ts b/src/core/auth/owner.guard.spec.ts new file mode 100644 index 0000000..8b9a50a --- /dev/null +++ b/src/core/auth/owner.guard.spec.ts @@ -0,0 +1,89 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + ExecutionContext, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { PolicyOwnerGuard } from './owner.guard'; +import { PoliciesService } from '../../policies/policies.service'; + +describe('PolicyOwnerGuard', () => { + let guard: PolicyOwnerGuard; + let policiesService: PoliciesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PolicyOwnerGuard, + { provide: PoliciesService, useValue: { findOne: jest.fn() } }, + ], + }).compile(); + + guard = module.get(PolicyOwnerGuard); + policiesService = module.get(PoliciesService); + }); + + it('should allow access if the user is the owner of the policy', async () => { + const request = { + user: { id: 'user1' }, + params: { id: 'policy1' }, + }; + const policy = { + id: 'policy1', + userId: 'user1', + carMake: 'CarBrand', + carModel: 'ModelX', + carAge: 5, + plateNumber: 'XYZ1234', + policyNumber: 101, + claims: [], + }; + + jest.spyOn(policiesService, 'findOne').mockResolvedValue(policy); + const context = { + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; + + await expect(guard.canActivate(context)).resolves.toBeTruthy(); + }); + + it('should throw a ForbiddenException if the user is not the owner of the policy', async () => { + const request = { + user: { id: 'user2' }, + params: { id: 'policy1' }, + }; + const policy = { + id: 'policy1', + userId: 'user1', + carMake: 'CarBrand', + carModel: 'ModelX', + carAge: 5, + plateNumber: 'XYZ1234', + policyNumber: 101, + claims: [], + }; + + jest.spyOn(policiesService, 'findOne').mockResolvedValue(policy); + const context = { + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('should throw a NotFoundException if the policy does not exist', async () => { + const request = { + user: { id: 'user1' }, + params: { id: 'policy1' }, + }; + + jest.spyOn(policiesService, 'findOne').mockResolvedValue(null as any); + const context = { + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; + + await expect(guard.canActivate(context)).rejects.toThrow(NotFoundException); + }); +}); diff --git a/src/core/auth/owner.guard.ts b/src/core/auth/owner.guard.ts new file mode 100644 index 0000000..b82b127 --- /dev/null +++ b/src/core/auth/owner.guard.ts @@ -0,0 +1,30 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { PoliciesService } from '../../policies/policies.service'; + +@Injectable() +export class PolicyOwnerGuard implements CanActivate { + constructor(private readonly policiesService: PoliciesService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user; + const policyId = request.params.id; + + const policy = await this.policiesService.findOne(policyId); + if (!policy) { + throw new NotFoundException(`Policy not found`); + } + + if (policy.userId !== user.id) { + throw new ForbiddenException('Access Denied'); + } + + return true; + } +} diff --git a/src/policies/dto/create-policy.dto.ts b/src/policies/dto/create-policy.dto.ts index dedb30f..3d91305 100644 --- a/src/policies/dto/create-policy.dto.ts +++ b/src/policies/dto/create-policy.dto.ts @@ -1,8 +1,16 @@ +import { IsInt, IsString } from 'class-validator'; + export class CreatePolicyDto { + @IsString() carMake: string; + @IsString() carModel: string; + @IsInt() carAge: number; + @IsString() plateNumber: string; + @IsString() policyType: string; + @IsString() userId: string; } diff --git a/src/policies/entities/policy.entity.ts b/src/policies/entities/policy.entity.ts index 603c42d..9b63712 100644 --- a/src/policies/entities/policy.entity.ts +++ b/src/policies/entities/policy.entity.ts @@ -1,6 +1,6 @@ export class Policy { id: string; - userId: number; + userId: string; carMake: string; carModel: string; carAge: number; diff --git a/src/policies/policies.controller.ts b/src/policies/policies.controller.ts index 6d914f8..a2f6eba 100644 --- a/src/policies/policies.controller.ts +++ b/src/policies/policies.controller.ts @@ -6,35 +6,49 @@ import { Patch, Param, Delete, + UseGuards, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { PoliciesService } from './policies.service'; import { CreatePolicyDto } from './dto/create-policy.dto'; import { UpdatePolicyDto } from './dto/update-policy.dto'; +import { PolicyOwnerGuard } from 'src/core/auth/owner.guard'; +import { LoggedGuard } from 'src/core/auth/logged.guard'; +@UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + }), +) @Controller('policies') export class PoliciesController { constructor(private readonly policiesService: PoliciesService) {} - @Post() + @Post('create') create(@Param('id') id: string, @Body() createPolicyDto: CreatePolicyDto) { return this.policiesService.create(id, createPolicyDto); } - + @UseGuards(LoggedGuard) @Get() findAll() { return this.policiesService.findAll(); } - + @UseGuards(LoggedGuard) @Get(':id') findOne(@Param('id') id: string) { return this.policiesService.findOne(id); } + @UseGuards(LoggedGuard) + @UseGuards(PolicyOwnerGuard) @Patch(':id') update(@Param('id') id: string, @Body() updatePolicyDto: UpdatePolicyDto) { return this.policiesService.update(id, updatePolicyDto); } - + @UseGuards(LoggedGuard) + @UseGuards(PolicyOwnerGuard) @Delete(':id') delete(@Param('id') id: string) { return this.policiesService.delete(id); diff --git a/src/policies/policies.module.ts b/src/policies/policies.module.ts index 11209ef..8d2ecfc 100644 --- a/src/policies/policies.module.ts +++ b/src/policies/policies.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { PoliciesService } from './policies.service'; import { PoliciesController } from './policies.controller'; -import { PrismaService } from 'src/prisma/prisma.service'; -import { PrismaModule } from 'src/prisma/prisma.module'; +import { PrismaService } from '../prisma/prisma.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { CoreModule } from '../core/core.module'; @Module({ controllers: [PoliciesController], providers: [PoliciesService, PrismaService], - imports: [PrismaModule], + imports: [PrismaModule, CoreModule], }) export class PoliciesModule {} diff --git a/src/policies/policies.service.ts b/src/policies/policies.service.ts index 65acd17..bf71d97 100644 --- a/src/policies/policies.service.ts +++ b/src/policies/policies.service.ts @@ -10,6 +10,7 @@ const select = { carAge: true, plateNumber: true, policyNumber: true, + userId: true, claims: { select: { status: true, @@ -34,37 +35,37 @@ export class PoliciesService { return this.prisma.policy.findMany({ select }); } - async findOne(id: string) { + async findOne(inputId: string) { const policy = await this.prisma.policy.findUnique({ - where: { id }, + where: { id: inputId }, select, }); if (!policy) { - throw new NotFoundException(`Policy ${id} not found`); + throw new NotFoundException(`Policy ${inputId} not found`); } return policy; } - async update(id: string, data: UpdatePolicyDto) { + async update(inputId: string, data: UpdatePolicyDto) { try { return await this.prisma.policy.update({ - where: { id }, + where: { id: inputId }, data, select, }); } catch (error) { - throw new NotFoundException(`Policy ${id} not found`); + throw new NotFoundException(`Policy ${inputId} not found`); } } - async delete(id: string) { + async delete(inputId: string) { try { return await this.prisma.policy.delete({ - where: { id }, + where: { id: inputId }, select, }); } catch (error) { - throw new NotFoundException(`Policy ${id} not found`); + throw new NotFoundException(`Policy ${inputId} not found`); } } } diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 57ca1ca..2863dba 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -1,7 +1,17 @@ +import { IsEmail, IsInt, IsString, Min } from 'class-validator'; + export class CreateUserDto { + @IsString() name: string; + @IsString() + @IsEmail() email: string; + @IsInt() + @Min(0) age: number; + @IsInt() + @Min(1950) licenseYear: number; + @IsString() password: string; } diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index dfd37fb..2cce879 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -1,4 +1,20 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateUserDto } from './create-user.dto'; +import { IsEmail, IsOptional, IsString } from 'class-validator'; -export class UpdateUserDto extends PartialType(CreateUserDto) {} +export class UpdateUserDto extends PartialType(CreateUserDto) { + @IsString() + @IsOptional() + name?: string; + @IsString() + @IsEmail() + @IsOptional() + email?: string; + @IsOptional() + age?: number; + @IsOptional() + licenseYear?: number; + @IsOptional() + @IsString() + password?: string; +} diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index ca1fdb2..9de1f37 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -1,3 +1,4 @@ +import { IsString, IsEmail } from 'class-validator'; import { Policy } from 'src/policies/entities/policy.entity'; export class User { @@ -12,6 +13,9 @@ export class User { } export class SignUser { + @IsString() + @IsEmail() email: string; + @IsString() password: string; } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 82a4bb2..70a50ca 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -7,13 +7,18 @@ import { Param, BadRequestException, ForbiddenException, + UsePipes, + ValidationPipe, + UseGuards, } from '@nestjs/common'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { CryptoService } from '../core/crypto/crypto.service'; import { SignUser } from './entities/user.entity'; +import { LoggedGuard } from '../core/auth/logged.guard'; +@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) @Controller('users') export class UsersController { constructor( @@ -21,14 +26,14 @@ export class UsersController { private readonly crypto: CryptoService, ) {} - @Post() + @Post('register') register(@Body() createUserDto: CreateUserDto) { return this.usersService.create(createUserDto); } @Post('login') - async login(@Body() createUserDto: SignUser) { - const { email, password } = createUserDto; + async login(@Body() data: SignUser) { + const { email, password } = data; if (!email || !password) { throw new BadRequestException('Email and password are required'); } @@ -55,7 +60,7 @@ export class UsersController { findOne(@Param('id') id: string) { return this.usersService.findOne(id); } - + @UseGuards(LoggedGuard) @Patch(':id') update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { return this.usersService.update(id, updateUserDto); diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 93f3e3b..43180c4 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -3,11 +3,11 @@ import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaModule } from 'src/prisma/prisma.module'; -import { CryptoService } from 'src/core/crypto/crypto.service'; +import { CoreModule } from 'src/core/core.module'; @Module({ controllers: [UsersController], providers: [UsersService, PrismaService], - imports: [PrismaModule, CryptoService], + imports: [PrismaModule, CoreModule], }) export class UsersModule {} diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 29f8a44..138c413 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -2,6 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersService } from './users.service'; import { PrismaService } from '../prisma/prisma.service'; import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { CryptoService } from '../core/crypto/crypto.service'; const mockPrisma = { user: { @@ -11,6 +13,10 @@ const mockPrisma = { update: jest.fn().mockReturnValue({}), }, }; +const mockCryptoService = { + hash: jest.fn().mockResolvedValue(''), + compare: jest.fn().mockResolvedValue(''), +}; describe('UsersService', () => { let service: UsersService; @@ -18,6 +24,7 @@ describe('UsersService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ { provide: PrismaService, useValue: mockPrisma }, + { provide: CryptoService, useValue: mockCryptoService }, UsersService, ], }).compile(); @@ -71,7 +78,7 @@ describe('UsersService', () => { }); describe('When we use the method update', () => { it('Then it should return the updated user', async () => { - const result = await service.update('1', {}); + const result = await service.update('1', {} as UpdateUserDto); expect(mockPrisma.user.update).toHaveBeenCalled(); expect(result).toEqual({}); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index ec1a9dd..5d316be 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -6,6 +6,7 @@ import { import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { PrismaService } from '../prisma/prisma.service'; +import { CryptoService } from '../core/crypto/crypto.service'; const select = { id: true, @@ -23,8 +24,12 @@ const select = { @Injectable() export class UsersService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private crypto: CryptoService, + ) {} async create(data: CreateUserDto) { + data.password = await this.crypto.hash(data.password); return this.prisma.user.create({ data, select, @@ -35,22 +40,22 @@ export class UsersService { return this.prisma.user.findMany({ select }); } - async findOne(id: string) { + async findOne(inputId: string) { const user = await this.prisma.user.findUnique({ - where: { id }, + where: { id: inputId }, select, }); if (!user) { - throw new NotFoundException(`User ${id} not found`); + throw new NotFoundException(`User ${inputId} not found`); } return user; } - async findForLogin(email: string) { + async findForLogin(inputEmail: string) { const user = await this.prisma.user.findUnique({ - where: { email }, + where: { email: inputEmail }, select: { email: true, password: true,