diff --git a/src/modules/authentication/authentication.mapper.ts b/src/modules/authentication/authentication.mapper.ts index 367fd07..7566f68 100644 --- a/src/modules/authentication/authentication.mapper.ts +++ b/src/modules/authentication/authentication.mapper.ts @@ -31,12 +31,14 @@ export class AuthenticationMapper const record: AuthenticationWriteModel = { uuid: copy.id, password: copy.password, - usernames: { - create: copy.usernames.map((username: UsernameProps) => ({ - username: username.name, - type: username.type, - })), - }, + usernames: copy.usernames + ? { + create: copy.usernames.map((username: UsernameProps) => ({ + username: username.name, + type: username.type, + })), + } + : undefined, createdAt: copy.createdAt, updatedAt: copy.updatedAt, }; @@ -51,7 +53,7 @@ export class AuthenticationMapper props: { userId: record.uuid, password: record.password, - usernames: record.usernames.map((username: UsernameModel) => ({ + usernames: record.usernames?.map((username: UsernameModel) => ({ userId: record.uuid, name: username.username, type: Type[username.type], diff --git a/src/modules/authentication/authentication.module.ts b/src/modules/authentication/authentication.module.ts index 9b928ab..c319fa3 100644 --- a/src/modules/authentication/authentication.module.ts +++ b/src/modules/authentication/authentication.module.ts @@ -21,6 +21,8 @@ import { DeleteUsernameGrpcController } from './interface/grpc-controllers/delet import { DeleteUsernameService } from './core/application/commands/delete-username/delete-username.service'; import { UpdateUsernameGrpcController } from './interface/grpc-controllers/update-username.grpc.controller'; import { UpdateUsernameService } from './core/application/commands/update-username/update-username.service'; +import { UpdatePasswordGrpcController } from './interface/grpc-controllers/update-password.grpc.controller'; +import { UpdatePasswordService } from './core/application/commands/update-password/update-password.service'; const grpcControllers = [ CreateAuthenticationGrpcController, @@ -28,6 +30,7 @@ const grpcControllers = [ AddUsernameGrpcController, UpdateUsernameGrpcController, DeleteUsernameGrpcController, + UpdatePasswordGrpcController, ]; const commandHandlers: Provider[] = [ @@ -36,6 +39,7 @@ const commandHandlers: Provider[] = [ AddUsernameService, UpdateUsernameService, DeleteUsernameService, + UpdatePasswordService, ]; const mappers: Provider[] = [AuthenticationMapper, UsernameMapper]; diff --git a/src/modules/authentication/core/application/commands/update-password/update-password.command.ts b/src/modules/authentication/core/application/commands/update-password/update-password.command.ts new file mode 100644 index 0000000..5e11f2c --- /dev/null +++ b/src/modules/authentication/core/application/commands/update-password/update-password.command.ts @@ -0,0 +1,12 @@ +import { Command, CommandProps } from '@mobicoop/ddd-library'; + +export class UpdatePasswordCommand extends Command { + readonly userId: string; + readonly password: string; + + constructor(props: CommandProps) { + super(props); + this.userId = props.userId; + this.password = props.password; + } +} diff --git a/src/modules/authentication/core/application/commands/update-password/update-password.service.ts b/src/modules/authentication/core/application/commands/update-password/update-password.service.ts new file mode 100644 index 0000000..61ef660 --- /dev/null +++ b/src/modules/authentication/core/application/commands/update-password/update-password.service.ts @@ -0,0 +1,23 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { AggregateID } from '@mobicoop/ddd-library'; +import { AUTHENTICATION_REPOSITORY } from '@modules/authentication/authentication.di-tokens'; +import { UpdatePasswordCommand } from './update-password.command'; +import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port'; +import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity'; + +@CommandHandler(UpdatePasswordCommand) +export class UpdatePasswordService implements ICommandHandler { + constructor( + @Inject(AUTHENTICATION_REPOSITORY) + private readonly authenticationRepository: AuthenticationRepositoryPort, + ) {} + + async execute(command: UpdatePasswordCommand): Promise { + const authentication: AuthenticationEntity = + await this.authenticationRepository.findOneById(command.userId); + await authentication.updatePassword(command.password); + await this.authenticationRepository.update(command.userId, authentication); + return authentication.id; + } +} diff --git a/src/modules/authentication/core/domain/authentication.entity.ts b/src/modules/authentication/core/domain/authentication.entity.ts index 81ed651..6b900af 100644 --- a/src/modules/authentication/core/domain/authentication.entity.ts +++ b/src/modules/authentication/core/domain/authentication.entity.ts @@ -6,6 +6,7 @@ import { } from './authentication.types'; import { AuthenticationCreatedDomainEvent } from './events/authentication-created.domain-event'; import { AuthenticationDeletedDomainEvent } from './events/authentication-deleted.domain-event'; +import { PasswordUpdatedDomainEvent } from './events/password-updated.domain-event'; export class AuthenticationEntity extends AggregateRoot { protected readonly _id: AggregateID; @@ -14,7 +15,7 @@ export class AuthenticationEntity extends AggregateRoot { create: CreateAuthenticationProps, ): Promise => { const props: AuthenticationProps = { ...create }; - const hash = await bcrypt.hash(props.password, 10); + const hash = await AuthenticationEntity.encryptPassword(props.password); const authentication = new AuthenticationEntity({ id: props.userId, props: { @@ -29,6 +30,15 @@ export class AuthenticationEntity extends AggregateRoot { return authentication; }; + updatePassword = async (password: string): Promise => { + this.props.password = await AuthenticationEntity.encryptPassword(password); + this.addEvent( + new PasswordUpdatedDomainEvent({ + aggregateId: this.id, + }), + ); + }; + delete(): void { this.addEvent( new AuthenticationDeletedDomainEvent({ @@ -40,4 +50,7 @@ export class AuthenticationEntity extends AggregateRoot { validate(): void { // entity business rules validation to protect it's invariant before saving entity to a database } + + private static encryptPassword = async (password: string): Promise => + await bcrypt.hash(password, 10); } diff --git a/src/modules/authentication/core/domain/events/password-updated.domain-event.ts b/src/modules/authentication/core/domain/events/password-updated.domain-event.ts new file mode 100644 index 0000000..f644476 --- /dev/null +++ b/src/modules/authentication/core/domain/events/password-updated.domain-event.ts @@ -0,0 +1,7 @@ +import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library'; + +export class PasswordUpdatedDomainEvent extends DomainEvent { + constructor(props: DomainEventProps) { + super(props); + } +} diff --git a/src/modules/authentication/interface/grpc-controllers/dtos/update-password.request.dto.ts b/src/modules/authentication/interface/grpc-controllers/dtos/update-password.request.dto.ts new file mode 100644 index 0000000..ba76c1f --- /dev/null +++ b/src/modules/authentication/interface/grpc-controllers/dtos/update-password.request.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UpdatePasswordRequestDto { + @IsString() + @IsNotEmpty() + userId: string; + + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/src/modules/authentication/interface/grpc-controllers/update-password.grpc.controller.ts b/src/modules/authentication/interface/grpc-controllers/update-password.grpc.controller.ts new file mode 100644 index 0000000..9453f8b --- /dev/null +++ b/src/modules/authentication/interface/grpc-controllers/update-password.grpc.controller.ts @@ -0,0 +1,40 @@ +import { + AggregateID, + IdResponse, + RpcExceptionCode, + RpcValidationPipe, +} from '@mobicoop/ddd-library'; +import { Controller, UsePipes } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { GrpcMethod, RpcException } from '@nestjs/microservices'; +import { UpdatePasswordRequestDto } from './dtos/update-password.request.dto'; +import { UpdatePasswordCommand } from '@modules/authentication/core/application/commands/update-password/update-password.command'; + +@UsePipes( + new RpcValidationPipe({ + whitelist: true, + forbidUnknownValues: false, + }), +) +@Controller() +export class UpdatePasswordGrpcController { + constructor(private readonly commandBus: CommandBus) {} + + @GrpcMethod('AuthenticationService', 'UpdatePassword') + async updatePassword(data: UpdatePasswordRequestDto): Promise { + try { + const aggregateID: AggregateID = await this.commandBus.execute( + new UpdatePasswordCommand({ + userId: data.userId, + password: data.password, + }), + ); + return new IdResponse(aggregateID); + } catch (error: any) { + throw new RpcException({ + code: RpcExceptionCode.UNKNOWN, + message: error.message, + }); + } + } +} diff --git a/src/modules/authentication/tests/unit/core/update-password.service.spec.ts b/src/modules/authentication/tests/unit/core/update-password.service.spec.ts new file mode 100644 index 0000000..57ede71 --- /dev/null +++ b/src/modules/authentication/tests/unit/core/update-password.service.spec.ts @@ -0,0 +1,62 @@ +import { AggregateID } from '@mobicoop/ddd-library'; +import { AUTHENTICATION_REPOSITORY } from '@modules/authentication/authentication.di-tokens'; +import { UpdatePasswordService } from '@modules/authentication/core/application/commands/update-password/update-password.service'; +import { Type } from '@modules/authentication/core/domain/username.types'; +import { UpdatePasswordRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/update-password.request.dto'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UpdatePasswordCommand } from '@modules/authentication/core/application/commands/update-password/update-password.command'; + +const updatePasswordRequest: UpdatePasswordRequestDto = { + userId: '165192d4-398a-4469-a16b-98c02cc6f531', + password: '@Br@ndN3wPa$$w0rd', +}; + +const mockAuthenticationRepository = { + findOneById: jest.fn().mockImplementation(() => ({ + id: '165192d4-398a-4469-a16b-98c02cc6f531', + updatePassword: jest.fn(), + getProps: jest.fn().mockImplementation(() => ({ + userId: '165192d4-398a-4469-a16b-98c02cc6f531', + name: 'john.doe@email.com', + type: Type.EMAIL, + })), + })), + update: jest.fn().mockImplementation(), +}; + +describe('Update Password Service', () => { + let updatePasswordService: UpdatePasswordService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AUTHENTICATION_REPOSITORY, + useValue: mockAuthenticationRepository, + }, + UpdatePasswordService, + ], + }).compile(); + + updatePasswordService = module.get( + UpdatePasswordService, + ); + }); + + it('should be defined', () => { + expect(updatePasswordService).toBeDefined(); + }); + + describe('execution', () => { + const updatePasswordCommand = new UpdatePasswordCommand({ + userId: updatePasswordRequest.userId, + password: updatePasswordRequest.password, + }); + it('should update the password', async () => { + const result: AggregateID = await updatePasswordService.execute( + updatePasswordCommand, + ); + expect(result).toBe('165192d4-398a-4469-a16b-98c02cc6f531'); + }); + }); +});