diff --git a/api/src/modules/auth/authentication.service.ts b/api/src/modules/auth/authentication.service.ts index a7f66173..21220b99 100644 --- a/api/src/modules/auth/authentication.service.ts +++ b/api/src/modules/auth/authentication.service.ts @@ -17,6 +17,8 @@ import { JwtManager } from '@api/modules/auth/services/jwt.manager'; import { SignUpDto } from '@shared/schemas/auth/sign-up.schema'; import { UserSignedUpEvent } from '@api/modules/admin/events/user-signed-up.event'; import { UpdateUserPasswordDto } from '@shared/dtos/users/update-user-password.dto'; +import { RequestEmailUpdateDto } from '@shared/dtos/users/request-email-update.dto'; +import { SendEmailConfirmationEmailCommand } from '@api/modules/notifications/email/commands/send-email-confirmation-email.command'; @Injectable() export class AuthenticationService { @@ -116,4 +118,31 @@ export class AuthenticationService { async hashPassword(password: string) { return bcrypt.hash(password, 10); } + + async requestEmailUpdate( + user: User, + dto: RequestEmailUpdateDto, + origin: string, + ) { + const { email, newEmail } = dto; + const existingUser = await this.usersService.findByEmail(newEmail); + if (existingUser) { + throw new ConflictException(`Email already in use`); + } + if (email === newEmail) { + throw new ConflictException( + 'New email must be different from the current one', + ); + } + if (user.email !== email) { + this.logger.warn( + `User ${user.id} tried to update email without providing the correct email`, + ); + throw new UnauthorizedException('Invalid email provided'); + } + + await this.commandBus.execute( + new SendEmailConfirmationEmailCommand(user, newEmail, origin), + ); + } } diff --git a/api/src/modules/notifications/email/commands/handlers/send-email-confirmation.handler.ts b/api/src/modules/notifications/email/commands/handlers/send-email-confirmation.handler.ts index 0c4a61fb..f30fa6d1 100644 --- a/api/src/modules/notifications/email/commands/handlers/send-email-confirmation.handler.ts +++ b/api/src/modules/notifications/email/commands/handlers/send-email-confirmation.handler.ts @@ -3,7 +3,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { SendEmailConfirmationEmailCommand } from '@api/modules/notifications/email/commands/send-email-confirmation-email.command'; @CommandHandler(SendEmailConfirmationEmailCommand) -export class SendWelcomeEmailHandler +export class SendEmailConfirmationHandler implements ICommandHandler { constructor(private readonly authMailer: AuthMailer) {} diff --git a/api/src/modules/notifications/email/email.module.ts b/api/src/modules/notifications/email/email.module.ts index d6838783..5bbef799 100644 --- a/api/src/modules/notifications/email/email.module.ts +++ b/api/src/modules/notifications/email/email.module.ts @@ -4,11 +4,13 @@ import { NodemailerEmailService } from '@api/modules/notifications/email/nodemai import { AuthModule } from '@api/modules/auth/auth.module'; import { EmailFailedEventHandler } from '@api/modules/notifications/email/events/handlers/emai-failed-event.handler'; import { SendWelcomeEmailHandler } from '@api/modules/notifications/email/commands/handlers/send-welcome-email.handler'; +import { SendEmailConfirmationHandler } from '@api/modules/notifications/email/commands/handlers/send-email-confirmation.handler'; @Module({ imports: [forwardRef(() => AuthModule)], providers: [ { provide: IEmailServiceToken, useClass: NodemailerEmailService }, + SendEmailConfirmationHandler, SendWelcomeEmailHandler, EmailFailedEventHandler, ], diff --git a/api/src/modules/users/users.controller.ts b/api/src/modules/users/users.controller.ts index 7a1c9845..25903819 100644 --- a/api/src/modules/users/users.controller.ts +++ b/api/src/modules/users/users.controller.ts @@ -1,6 +1,7 @@ import { ClassSerializerInterceptor, Controller, + Headers, HttpStatus, UnauthorizedException, UseGuards, @@ -57,9 +58,12 @@ export class UsersController { } @TsRestHandler(usersContract.requestEmailUpdate) - async requestEmailUpdate(@GetUser() user: User): ControllerResponse { + async requestEmailUpdate( + @GetUser() user: User, + @Headers('origin') origin: string, + ): ControllerResponse { return tsRestHandler(usersContract.requestEmailUpdate, async ({ body }) => { - await this.usersService.remove(user.id); + await this.auth.requestEmailUpdate(user, body, origin); return { body: null, status: HttpStatus.OK }; }); } diff --git a/api/src/modules/users/users.service.ts b/api/src/modules/users/users.service.ts index cf06f8fe..d3049cf6 100644 --- a/api/src/modules/users/users.service.ts +++ b/api/src/modules/users/users.service.ts @@ -47,11 +47,4 @@ export class UsersService extends AppBaseService< const user = await this.userRepository.findOneBy({ id }); return user.isActive; } - - async requestEmailUpdate(user: User, dto: RequestEmailUpdateDto) { - const { email, newEmail } = dto; - if (user.email !== email) { - throw new UnauthorizedException(); - } - } } diff --git a/api/test/integration/auth/sign-up.spec.ts b/api/test/integration/auth/sign-up.spec.ts index 6281d517..8ee4b878 100644 --- a/api/test/integration/auth/sign-up.spec.ts +++ b/api/test/integration/auth/sign-up.spec.ts @@ -6,9 +6,7 @@ import { ROLES } from '@shared/entities/users/roles.enum'; import { JwtManager } from '@api/modules/auth/services/jwt.manager'; import { User } from '@shared/entities/users/user.entity'; -//create-user.feature - -describe('Create Users', () => { +describe('Sign Up', () => { let testManager: TestManager; let jwtManager: JwtManager; diff --git a/api/test/integration/users/users-email-update.spec.ts b/api/test/integration/users/users-email-update.spec.ts new file mode 100644 index 00000000..edf043c5 --- /dev/null +++ b/api/test/integration/users/users-email-update.spec.ts @@ -0,0 +1,139 @@ +import { createUser } from '@shared/lib/entity-mocks'; +import { TestManager } from '../../utils/test-manager'; +import { User } from '@shared/entities/users/user.entity'; +import { HttpStatus } from '@nestjs/common'; +import { usersContract } from '@shared/contracts/users.contract'; +import { ROLES } from '@shared/entities/users/roles.enum'; +import { MockEmailService } from '../../utils/mocks/mock-email.service'; +import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; + +describe('Users ME (e2e)', () => { + let testManager: TestManager; + let emailService: MockEmailService; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + emailService = testManager.getModule(IEmailServiceToken); + }); + + beforeEach(async () => { + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + + describe('Send Email Update notification', () => { + it('Should fail if the current email sent does not match, and no email should be sent', async () => { + const user = await testManager + .mocks() + .createUser({ role: ROLES.PARTNER }); + const { jwtToken } = await testManager.logUserIn(user); + + const response = await testManager + .request() + .patch(usersContract.requestEmailUpdate.path) + .send({ email: 'notcurrent@mail.com', newEmail: 'new@mail.com' }) + .set('Authorization', `Bearer ${jwtToken}`); + + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + expect(response.body.errors[0].title).toBe('Invalid email provided'); + expect(emailService.sendMail).toHaveBeenCalledTimes(0); + }); + it('Should fail if the new email is already in use, and no email should be sent', async () => { + const previousUser = await testManager + .mocks() + .createUser({ role: ROLES.PARTNER }); + const user = await testManager + .mocks() + .createUser({ email: 'user2@mail.com', role: ROLES.PARTNER }); + + const { jwtToken } = await testManager.logUserIn(user); + + const response = await testManager + .request() + .patch(usersContract.requestEmailUpdate.path) + .send({ email: user.email, newEmail: previousUser.email }) + .set('Authorization', `Bearer ${jwtToken}`); + + expect(response.status).toBe(HttpStatus.CONFLICT); + expect(response.body.errors[0].title).toBe('Email already in use'); + }); + + it('Should send an email to the new email address', async () => { + const user = await testManager + .mocks() + .createUser({ role: ROLES.PARTNER }); + const { jwtToken } = await testManager.logUserIn(user); + + const newEmail = 'newmail@test.com'; + const response = await testManager + .request() + .patch(usersContract.requestEmailUpdate.path) + .send({ email: user.email, newEmail }) + .set('Authorization', `Bearer ${jwtToken}`); + + expect(response.status).toBe(HttpStatus.OK); + expect(emailService.sendMail).toHaveBeenCalledTimes(1); + }); + }); + describe('Confirm email update', () => { + it('should update my own password', async () => { + const user = await testManager + .mocks() + .createUser({ email: 'test@test.com', role: ROLES.PARTNER }); + + const { jwtToken, password: oldPassword } = + await testManager.logUserIn(user); + const newPassword = 'newPassword'; + const response = await testManager + .request() + .patch(usersContract.updatePassword.path) + .send({ password: oldPassword, newPassword }) + .set('Authorization', `Bearer ${jwtToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.id).toEqual(user.id); + + const { jwtToken: noToken } = await testManager.logUserIn({ + ...user, + password: oldPassword, + }); + expect(noToken).toBeUndefined(); + + const { jwtToken: newToken } = await testManager.logUserIn({ + ...user, + password: newPassword, + }); + + expect(newToken).toBeDefined(); + }); + it('should update a user name', async () => { + const user = await createUser(testManager.getDataSource(), { + email: 'user@test.com', + role: ROLES.PARTNER, + }); + + const { jwtToken } = await testManager.logUserIn(user); + const newName = 'newName'; + const response = await testManager + .request() + .patch(usersContract.updateMe.path) + .send({ name: newName }) + .set('Authorization', `Bearer ${jwtToken}`); + expect(response.status).toBe(201); + expect(response.body.data.id).toEqual(user.id); + expect(response.body.data.name).toEqual(newName); + + // Previous token should work after updating the user's email + const userMeResponse = await testManager + .request() + .get('/users/me') + .set('Authorization', `Bearer ${jwtToken}`); + + expect(userMeResponse.status).toBe(200); + expect(userMeResponse.body.data.name).toEqual(newName); + }); + }); +}); diff --git a/api/test/integration/users/users-me.spec.ts b/api/test/integration/users/users-me.spec.ts index 08a2af41..feb8cb06 100644 --- a/api/test/integration/users/users-me.spec.ts +++ b/api/test/integration/users/users-me.spec.ts @@ -4,12 +4,16 @@ import { User } from '@shared/entities/users/user.entity'; import { HttpStatus } from '@nestjs/common'; import { usersContract } from '@shared/contracts/users.contract'; import { ROLES } from '@shared/entities/users/roles.enum'; +import { MockEmailService } from '../../utils/mocks/mock-email.service'; +import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; describe('Users ME (e2e)', () => { let testManager: TestManager; + let emailService: MockEmailService; beforeAll(async () => { testManager = await TestManager.createTestManager(); + emailService = testManager.getModule(IEmailServiceToken); }); beforeEach(async () => { @@ -20,106 +24,111 @@ describe('Users ME (e2e)', () => { await testManager.close(); }); - it('should return only my own info', async () => { - const createdUsers: User[] = []; - for (const n of Array(3).keys()) { - createdUsers.push( - await testManager.mocks().createUser({ - email: `user${n}@mail.com`, - role: ROLES.PARTNER, - }), - ); - } - const { jwtToken } = await testManager.logUserIn(createdUsers[0]); - const response = await testManager - .request() - .get(usersContract.findMe.path) - .set('Authorization', `Bearer ${jwtToken}`); - - expect(response.status).toBe(HttpStatus.OK); - expect(response.body.data.id).toEqual(createdUsers[0].id); + describe('Read', () => { + it('should return only my own info', async () => { + const createdUsers: User[] = []; + for (const n of Array(3).keys()) { + createdUsers.push( + await testManager.mocks().createUser({ + email: `user${n}@mail.com`, + role: ROLES.PARTNER, + }), + ); + } + const { jwtToken } = await testManager.logUserIn(createdUsers[0]); + const response = await testManager + .request() + .get(usersContract.findMe.path) + .set('Authorization', `Bearer ${jwtToken}`); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body.data.id).toEqual(createdUsers[0].id); + }); }); - it('should update my own password', async () => { - const user = await testManager - .mocks() - .createUser({ email: 'test@test.com', role: ROLES.PARTNER }); - - const { jwtToken, password: oldPassword } = - await testManager.logUserIn(user); - const newPassword = 'newPassword'; - const response = await testManager - .request() - .patch(usersContract.updatePassword.path) - .send({ password: oldPassword, newPassword }) - .set('Authorization', `Bearer ${jwtToken}`); - - expect(response.status).toBe(200); - expect(response.body.data.id).toEqual(user.id); - - const { jwtToken: noToken } = await testManager.logUserIn({ - ...user, - password: oldPassword, + describe('Update', () => { + it('should update my own password', async () => { + const user = await testManager + .mocks() + .createUser({ email: 'test@test.com', role: ROLES.PARTNER }); + + const { jwtToken, password: oldPassword } = + await testManager.logUserIn(user); + const newPassword = 'newPassword'; + const response = await testManager + .request() + .patch(usersContract.updatePassword.path) + .send({ password: oldPassword, newPassword }) + .set('Authorization', `Bearer ${jwtToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.id).toEqual(user.id); + + const { jwtToken: noToken } = await testManager.logUserIn({ + ...user, + password: oldPassword, + }); + expect(noToken).toBeUndefined(); + + const { jwtToken: newToken } = await testManager.logUserIn({ + ...user, + password: newPassword, + }); + + expect(newToken).toBeDefined(); }); - expect(noToken).toBeUndefined(); - - const { jwtToken: newToken } = await testManager.logUserIn({ - ...user, - password: newPassword, + it('should update a user name', async () => { + const user = await testManager.mocks().createUser({ + email: 'user@test.com', + role: ROLES.PARTNER, + }); + + const { jwtToken } = await testManager.logUserIn(user); + const newName = 'newName'; + const response = await testManager + .request() + .patch(usersContract.updateMe.path) + .send({ name: newName }) + .set('Authorization', `Bearer ${jwtToken}`); + expect(response.status).toBe(201); + expect(response.body.data.id).toEqual(user.id); + expect(response.body.data.name).toEqual(newName); + + // Previous token should work after updating the user's email + const userMeResponse = await testManager + .request() + .get('/users/me') + .set('Authorization', `Bearer ${jwtToken}`); + + expect(userMeResponse.status).toBe(200); + expect(userMeResponse.body.data.name).toEqual(newName); }); - - expect(newToken).toBeDefined(); }); - it('should update a user name', async () => { - const user = await createUser(testManager.getDataSource(), { - email: 'user@test.com', - role: ROLES.PARTNER, + describe('Delete', () => { + it('should delete my own user', async () => { + const users: User[] = []; + for (const n of Array(3).keys()) { + users.push( + await testManager + .mocks() + .createUser({ email: `user${n}@test.com`, role: ROLES.PARTNER }), + ); + } + const user = users[0]; + const { jwtToken } = await testManager.logUserIn(user); + const response = await testManager + .request() + .delete(usersContract.deleteMe.path) + .set('Authorization', `Bearer ${jwtToken}`); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body).toEqual({}); + const foundUser = await testManager + .getDataSource() + .getRepository(User) + .findOneBy({ id: user.id }); + + expect(foundUser).toBeNull(); }); - - const { jwtToken } = await testManager.logUserIn(user); - const newName = 'newName'; - const response = await testManager - .request() - .patch(usersContract.updateMe.path) - .send({ name: newName }) - .set('Authorization', `Bearer ${jwtToken}`); - expect(response.status).toBe(201); - expect(response.body.data.id).toEqual(user.id); - expect(response.body.data.name).toEqual(newName); - - // Previous token should work after updating the user's email - const userMeResponse = await testManager - .request() - .get('/users/me') - .set('Authorization', `Bearer ${jwtToken}`); - - expect(userMeResponse.status).toBe(200); - expect(userMeResponse.body.data.name).toEqual(newName); - }); - - it('should delete my own user', async () => { - const users: User[] = []; - for (const n of Array(3).keys()) { - users.push( - await testManager - .mocks() - .createUser({ email: `user${n}@test.com`, role: ROLES.PARTNER }), - ); - } - const user = users[0]; - const { jwtToken } = await testManager.logUserIn(user); - const response = await testManager - .request() - .delete(usersContract.deleteMe.path) - .set('Authorization', `Bearer ${jwtToken}`); - - expect(response.status).toBe(HttpStatus.OK); - expect(response.body).toEqual({}); - const foundUser = await testManager - .getDataSource() - .getRepository(User) - .findOneBy({ id: user.id }); - - expect(foundUser).toBeNull(); }); }); diff --git a/api/test/utils/test-manager.ts b/api/test/utils/test-manager.ts index f7888c39..601cec3a 100644 --- a/api/test/utils/test-manager.ts +++ b/api/test/utils/test-manager.ts @@ -85,7 +85,7 @@ export class TestManager { } request() { - return request(this.testApp.getHttpServer()); + return request(this.getApp().getHttpServer()); } mocks() {