diff --git a/package-lock.json b/package-lock.json index a22ab6c..2c8e66a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,8 @@ "devDependencies": { "@athenna/test": "^4.23.0", "@athenna/tsconfig": "^4.12.0", + "@types/bcrypt": "^5.0.2", + "@types/jsonwebtoken": "^9.0.6", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", "eslint": "^8.36.0", @@ -1609,6 +1611,15 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/bytes": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/bytes/-/bytes-3.1.4.tgz", @@ -1662,12 +1673,20 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.8.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.6.tgz", "integrity": "sha512-eWO4K2Ji70QzKUqRy6oyJWUeB7+g2cRagT3T/nxYibYcT4y2BDL8lqolRXjTHmkZCdJfIPaY73KbJAZmcryxTQ==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~5.25.1" } @@ -10042,8 +10061,7 @@ "version": "5.25.3", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/untildify": { "version": "4.0.0", diff --git a/package.json b/package.json index ff6a5eb..70b3b5b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "devDependencies": { "@athenna/test": "^4.23.0", "@athenna/tsconfig": "^4.12.0", + "@types/bcrypt": "^5.0.2", + "@types/jsonwebtoken": "^9.0.6", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", "eslint": "^8.36.0", @@ -170,6 +172,11 @@ "make:terminator": "@athenna/http/commands/MakeTerminatorCommand", "serve": { "path": "@athenna/core/commands/ServeCommand", + "nodemon": { + "ignore": [ + "storage/*" + ] + }, "stayAlive": true }, "build": { diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index c48d15e..c61d5dd 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -28,8 +28,36 @@ export class AuthController { return response.status(201).send(user) } - public async verifyEmail({ request, response }: Context) { - await this.authService.verifyEmail(request.query('emailToken')) + public async confirm({ request, response }: Context) { + await this.authService.confirm(request.query('token')) + + return response.status(204) + } + + public async confirmEmailChange({ request, response }: Context) { + await this.authService.confirmEmailChange( + request.query('email'), + request.query('token') + ) + + return response.status(204) + } + + public async confirmPasswordChange({ request, response }: Context) { + await this.authService.confirmPasswordChange( + request.query('password'), + request.query('token') + ) + + return response.status(204) + } + + public async confirmEmailPasswordChange({ request, response }: Context) { + await this.authService.confirmEmailPasswordChange( + request.query('email'), + request.query('password'), + request.query('token') + ) return response.status(204) } diff --git a/src/database/migrations/2024_04_16_195846_create_users_table.ts b/src/database/migrations/2024_04_16_195846_create_users_table.ts index 496d13b..65e49c4 100644 --- a/src/database/migrations/2024_04_16_195846_create_users_table.ts +++ b/src/database/migrations/2024_04_16_195846_create_users_table.ts @@ -9,7 +9,7 @@ export class Users extends BaseMigration { builder.string('name').notNullable() builder.string('email').unique().notNullable() builder.string('password').notNullable() - builder.string('email_token').notNullable() + builder.string('token').unique().notNullable() builder.timestamp('email_verified_at').defaultTo(null) builder.timestamps(true, true, false) builder.timestamp('deleted_at').defaultTo(null) diff --git a/src/database/seeders/user.seeder.ts b/src/database/seeders/user.seeder.ts index ffce2a5..4da907c 100644 --- a/src/database/seeders/user.seeder.ts +++ b/src/database/seeders/user.seeder.ts @@ -12,7 +12,7 @@ export class UserSeeder extends BaseSeeder { name: 'Admin', email: 'admin@athenna.io', password: await bcrypt.hash('12345', 10), - emailToken: Uuid.generate(), + token: Uuid.generate(), emailVerifiedAt: new Date() }) @@ -20,7 +20,7 @@ export class UserSeeder extends BaseSeeder { name: 'Customer', email: 'customer@athenna.io', password: await bcrypt.hash('12345', 10), - emailToken: Uuid.generate(), + token: Uuid.generate(), emailVerifiedAt: new Date() }) diff --git a/src/models/user.ts b/src/models/user.ts index e9bb524..1257fbd 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,3 +1,4 @@ +import bcrypt from 'bcrypt' import { Role } from '#src/models/role' import { RoleUser } from '#src/models/roleuser' import { Column, BaseModel, BelongsToMany } from '@athenna/database' @@ -15,8 +16,8 @@ export class User extends BaseModel { @Column({ isHidden: true, isNullable: false }) public password: string - @Column({ name: 'email_token', isUnique: true, isNullable: false }) - public emailToken: string + @Column({ isUnique: true, isNullable: false }) + public token: string @Column({ name: 'email_verified_at' }) public emailVerifiedAt: Date @@ -33,6 +34,30 @@ export class User extends BaseModel { @BelongsToMany(() => Role, () => RoleUser) public roles: Role[] + public isEmailEqual(email: string) { + /** + * If there are no email to validate, + * it means no change is going to be made. + */ + if (!email) { + return true + } + + return this.email === email + } + + public isPasswordEqual(password: string) { + /** + * If there are no password to validate, + * it means no change is going to be made. + */ + if (!password) { + return true + } + + return bcrypt.compareSync(password, this.password) + } + public static attributes(): Partial { return {} } diff --git a/src/providers/queueworker.provider.ts b/src/providers/queueworker.provider.ts index 20cfef0..378fef0 100644 --- a/src/providers/queueworker.provider.ts +++ b/src/providers/queueworker.provider.ts @@ -7,20 +7,50 @@ export default class QueueWorkerProvider extends ServiceProvider { public intervals = [] public async boot() { - this.processByQueue('user:register', async user => { + this.processByQueue('user:confirm', async user => { return Mail.from('noreply@athenna.io') .to(user.email) - .subject('Athenna Account Activation') - .view('mail/register', { user }) + .subject('Athenna Account Confirmation') + .view('mail/confirm', { user }) .send() }) + + this.processByQueue('user:email', async ({ user, token, email }) => { + return Mail.from('noreply@athenna.io') + .to(user.email) + .subject('Athenna Email Change') + .view('mail/change-email', { user, email, token }) + .send() + }) + + this.processByQueue('user:password', async ({ user, token, password }) => { + return Mail.from('noreply@athenna.io') + .to(user.email) + .subject('Athenna Password Change') + .view('mail/change-password', { user, password, token }) + .send() + }) + + this.processByQueue( + 'user:email:password', + async ({ user, token, email, password }) => { + return Mail.from('noreply@athenna.io') + .to(user.email) + .subject('Athenna Email & Password Change') + .view('mail/change-email-password', { user, email, password, token }) + .send() + } + ) } public async shutdown() { this.intervals.forEach(interval => clearInterval(interval)) } - public processByQueue(queueName: string, processor: any) { + public processByQueue( + queueName: string, + processor: (data: any) => any | Promise + ) { const interval = setInterval(async () => { const queue = await Queue.queue(queueName) diff --git a/src/resources/views/mail/change-email-password.edge b/src/resources/views/mail/change-email-password.edge new file mode 100644 index 0000000..1707dc8 --- /dev/null +++ b/src/resources/views/mail/change-email-password.edge @@ -0,0 +1,84 @@ + + + + + +Change Email & Password + + + +
+ Minerva Logo +

Hey there {{ user.name }}!

+
+
+

+ We are sending you this email because you have requested to + change your account email to {{ email }} and also to + change your password. To confirm the update, click the link bellow: +

+ CONFIRM EMAIL & PASSWORD CHANGE +
+
+

+ If this was not you or if you have any questions, please email us + at support@athenna.io or + visit our FAQS, you can also chat with a real human during our + operating hours. They can answer questions about your account. +

+
+ + + diff --git a/src/resources/views/mail/change-email.edge b/src/resources/views/mail/change-email.edge new file mode 100644 index 0000000..cab1cbe --- /dev/null +++ b/src/resources/views/mail/change-email.edge @@ -0,0 +1,84 @@ + + + + + +Change Email + + + +
+ Minerva Logo +

Hey there {{ user.name }}!

+
+
+

+ We are sending you this email because you have requested to + change your account email to {{ email }}. To confirm the update, + click the link bellow: +

+ CONFIRM EMAIL CHANGE +
+
+

+ If this was not you or if you have any questions, please email us + at support@athenna.io or + visit our FAQS, you can also chat with a real human during our + operating hours. They can answer questions about your account. +

+
+ + + diff --git a/src/resources/views/mail/change-password.edge b/src/resources/views/mail/change-password.edge new file mode 100644 index 0000000..1be2fd1 --- /dev/null +++ b/src/resources/views/mail/change-password.edge @@ -0,0 +1,83 @@ + + + + + +Change Password + + + +
+ Minerva Logo +

Hey there {{ user.name }}!

+
+
+

+ We are sending you this email because you have requested to + change your account password. To confirm the update, click the link bellow: +

+ CONFIRM PASSWORD CHANGE +
+
+

+ If this was not you or if you have any questions, please email us + at support@athenna.io or + visit our FAQS, you can also chat with a real human during our + operating hours. They can answer questions about your account. +

+
+ + + diff --git a/src/resources/views/mail/register.edge b/src/resources/views/mail/confirm.edge similarity index 96% rename from src/resources/views/mail/register.edge rename to src/resources/views/mail/confirm.edge index a450fb3..83b2ea2 100644 --- a/src/resources/views/mail/register.edge +++ b/src/resources/views/mail/confirm.edge @@ -65,7 +65,7 @@

We are happy to have you here. You still need to confirm your account:

- CONFIRM ACCOUNT + CONFIRM ACCOUNT

diff --git a/src/routes/http.ts b/src/routes/http.ts index 0a970c3..a86003a 100644 --- a/src/routes/http.ts +++ b/src/routes/http.ts @@ -16,7 +16,14 @@ Route.group(() => { }).name('users') }).middleware('auth') - Route.get('verify-email', 'AuthController.verifyEmail') + Route.get('confirm/account', 'AuthController.confirm') + Route.get('confirm/email', 'AuthController.confirmEmailChange') + Route.get('confirm/password', 'AuthController.confirmPasswordChange') + Route.get( + 'confirm/email/password', + 'AuthController.confirmEmailPasswordChange' + ) + Route.post('login', 'AuthController.login').middleware('login:validator') Route.post('register', 'AuthController.register').middleware( 'register:validator' diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 8692ac8..3e96961 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -4,7 +4,7 @@ import { Log } from '@athenna/logger' import { Uuid } from '@athenna/common' import { Service } from '@athenna/ioc' import { Config } from '@athenna/config' -import type { User } from '#src/models/user' +import { User } from '#src/models/user' import { Queue } from '#src/providers/facades/queue' import { UnauthorizedException } from '@athenna/http' import type { UserService } from '#src/services/user.service' @@ -24,7 +24,7 @@ export class AuthService { public async login(email: string, password: string) { try { const user = await this.userService.getByEmail(email) - const passwordMatch = await bcrypt.compare(password, user.password) + const passwordMatch = user.isPasswordEqual(password) if (!passwordMatch) { throw new Error('Password does not match') @@ -42,21 +42,54 @@ export class AuthService { } public async register(data: Partial) { - data.emailToken = Uuid.generate() + data.token = Uuid.generate() data.password = await bcrypt.hash(data.password, 10) const user = await this.userService.create(data) - await Queue.queue('user:register').then(q => q.add(user)) + await Queue.queue('user:confirm').then(q => q.add(user)) return user } - public async verifyEmail(emailToken: string) { - const user = await this.userService.getByEmailToken(emailToken) + public async confirm(token: string) { + const user = await this.userService.getByToken(token) user.emailVerifiedAt = new Date() await user.save() } + + public async confirmEmailChange(email: string, token: string) { + const user = await this.userService.getByToken(token) + + user.email = email + + await user.save() + } + + public async confirmPasswordChange(password: string, token: string) { + const user = await this.userService.getByToken(token) + + /** + * Password is already hashed before sending + * the data to queue. + */ + user.password = password + + await user.save() + } + + public async confirmEmailPasswordChange( + email: string, + password: string, + token: string + ) { + const user = await this.userService.getByToken(token) + + user.email = email + user.password = password + + await user.save() + } } diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 1da543f..a5b54ee 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,9 +1,11 @@ +import bcrypt from 'bcrypt' import { Service } from '@athenna/ioc' import { User } from '#src/models/user' import { Role } from '#src/models/role' import { RoleUser } from '#src/models/roleuser' import { RoleEnum } from '#src/enums/role.enum' import { NotFoundException } from '@athenna/http' +import { Queue } from '#src/providers/facades/queue' import { Json, type PaginationOptions } from '@athenna/common' @Service() @@ -41,27 +43,51 @@ export class UserService { return user } - public async getByEmailToken(token: string) { - const user = await User.query() - .whereNull('emailVerifiedAt') - .where('emailToken', token) - .find() + public async getByToken(token: string) { + const user = await User.query().where('token', token).find() if (!user) { - throw new NotFoundException( - `Not found any user with email token ${token}.` - ) + throw new NotFoundException(`Not found any user with token ${token}.`) } return user } public async update(id: number, data: Partial): Promise { + const user = await this.getById(id) + + const token = user.token + const isEmailEqual = user.isEmailEqual(data.email) + const isPasswordEqual = user.isPasswordEqual(data.password) + + switch (`${isEmailEqual}:${isPasswordEqual}`) { + case 'false:true': + // TODO Validate that email isn't already registered. + await Queue.queue('user:email').then(q => + q.add({ user, token, email: data.email }) + ) + break + case 'true:false': + data.password = await bcrypt.hash(data.password, 10) + + await Queue.queue('user:password').then(q => + q.add({ user, token, password: data.password }) + ) + break + case 'false:false': + // TODO Validate that email isn't already registered. + data.password = await bcrypt.hash(data.password, 10) + + await Queue.queue('user:email:password').then(q => + q.add({ user, token, email: data.email, password: data.password }) + ) + } + data = Json.omit(data, ['email', 'password']) - const user = await User.query().where('id', id).update(data) + const userUpdated = await User.query().where('id', id).update(data) - return user as User + return userUpdated as User } public async delete(id: number) { diff --git a/storage/queues.json b/storage/queues.json index 73cb0c4..1fe0897 100644 --- a/storage/queues.json +++ b/storage/queues.json @@ -1 +1 @@ -{"default":[],"deadletter":[],"user:register":[]} \ No newline at end of file +{"default":[],"deadletter":[],"user:email":[],"user:password":[{"user":{"name":"João Lenon","email":"Dereck_Conn@hotmail.com"},"password":"$2b$10$wpmwbiGnqA.W0toBLwGQsu9li3/qldRGx6vwGReQYx1PmQd6bkmMC"}],"user:email:password":[],"user:confirm":[]} \ No newline at end of file diff --git a/tests/e2e/auth.controller.test.ts b/tests/e2e/auth.controller.test.ts index 45e8c73..a2bbcef 100644 --- a/tests/e2e/auth.controller.test.ts +++ b/tests/e2e/auth.controller.test.ts @@ -1,3 +1,4 @@ +import bcrypt from 'bcrypt' import jwt from 'jsonwebtoken' import { Uuid } from '@athenna/common' import { User } from '#src/models/user' @@ -133,7 +134,7 @@ export default class AuthControllerTest extends BaseHttpTest { } }) - const queue = await Queue.queue('user:register') + const queue = await Queue.queue('user:confirm') assert.deepEqual(await queue.length(), 1) assert.isTrue(await User.exists({ email: 'test@athenna.io' })) @@ -238,12 +239,12 @@ export default class AuthControllerTest extends BaseHttpTest { } @Test() - public async shouldBeAbleToVerifyUserEmail({ assert, request }: Context) { - const user = await User.factory().create({ emailToken: Uuid.generate(), emailVerifiedAt: null }) + public async shouldBeAbleToConfirmUserAccount({ assert, request }: Context) { + const user = await User.factory().create({ token: Uuid.generate(), emailVerifiedAt: null }) - const response = await request.get('/api/v1/verify-email', { + const response = await request.get('/api/v1/confirm/account', { query: { - emailToken: user.emailToken + token: user.token } }) @@ -254,10 +255,10 @@ export default class AuthControllerTest extends BaseHttpTest { } @Test() - public async shouldThrowNotFoundExceptionIfEmailTokenDoesNotExist({ request }: Context) { - const response = await request.get('/api/v1/verify-email', { + public async shouldThrowNotFoundExceptionIfTokenDoesNotExistWhenConfirmingAccount({ request }: Context) { + const response = await request.get('/api/v1/confirm/account', { query: { - emailToken: 'not-found' + token: 'not-found' } }) @@ -265,19 +266,106 @@ export default class AuthControllerTest extends BaseHttpTest { response.assertBodyContains({ data: { code: 'E_NOT_FOUND_ERROR', - message: 'Not found any user with email token not-found.', + message: 'Not found any user with token not-found.', name: 'NotFoundException' } }) } @Test() - public async shouldThrowNotFoundExceptionIfEmailIsAlreadyVerified({ request }: Context) { - const user = await User.find({ name: 'Customer' }) + public async shouldBeAbleToConfirmUserEmail({ assert, request }: Context) { + const user = await User.factory().create({ token: Uuid.generate() }) - const response = await request.get('/api/v1/verify-email', { + const response = await request.get('/api/v1/confirm/email', { query: { - emailToken: user.emailToken + token: user.token, + email: 'newemail@athenna.io' + } + }) + + await user.refresh() + + assert.deepEqual(user.email, 'newemail@athenna.io') + response.assertStatusCode(204) + } + + @Test() + public async shouldThrowNotFoundExceptionIfTokenDoesNotExistWhenConfirmingEmail({ request }: Context) { + const response = await request.get('/api/v1/confirm/email', { + query: { + token: 'not-found' + } + }) + + response.assertStatusCode(404) + response.assertBodyContains({ + data: { + code: 'E_NOT_FOUND_ERROR', + message: 'Not found any user with token not-found.', + name: 'NotFoundException' + } + }) + } + + @Test() + public async shouldBeAbleToConfirmUserPassword({ assert, request }: Context) { + const user = await User.factory().create({ token: Uuid.generate() }) + + const response = await request.get('/api/v1/confirm/password', { + query: { + token: user.token, + password: await bcrypt.hash('1234567', 10) + } + }) + + await user.refresh() + + assert.isTrue(user.isPasswordEqual('1234567')) + response.assertStatusCode(204) + } + + @Test() + public async shouldThrowNotFoundExceptionIfTokenDoesNotExistWhenConfirmingPassword({ request }: Context) { + const response = await request.get('/api/v1/confirm/password', { + query: { + token: 'not-found' + } + }) + + response.assertStatusCode(404) + response.assertBodyContains({ + data: { + code: 'E_NOT_FOUND_ERROR', + message: 'Not found any user with token not-found.', + name: 'NotFoundException' + } + }) + } + + @Test() + public async shouldBeAbleToConfirmUserEmailPassword({ assert, request }: Context) { + const user = await User.factory().create({ token: Uuid.generate() }) + + const response = await request.get('/api/v1/confirm/email/password', { + query: { + token: user.token, + email: 'newemaill@athenna.io', + password: await bcrypt.hash('1234567', 10) + } + }) + + await user.refresh() + + assert.deepEqual(user.email, 'newemaill@athenna.io') + assert.isTrue(user.isPasswordEqual('1234567')) + response.assertStatusCode(204) + } + + @Test() + public async shouldThrowNotFoundExceptionIfTokenDoesNotExistWhenConfirmingPassword({ request }: Context) { + const response = await request.get('/api/v1/confirm/email/password', { + query: { + token: 'not-found' } }) @@ -285,7 +373,7 @@ export default class AuthControllerTest extends BaseHttpTest { response.assertBodyContains({ data: { code: 'E_NOT_FOUND_ERROR', - message: `Not found any user with email token ${user.emailToken}.`, + message: 'Not found any user with token not-found.', name: 'NotFoundException' } }) diff --git a/tests/e2e/user.controller.test.ts b/tests/e2e/user.controller.test.ts index c8fd782..c1f8c1f 100644 --- a/tests/e2e/user.controller.test.ts +++ b/tests/e2e/user.controller.test.ts @@ -1,14 +1,17 @@ import bcrypt from 'bcrypt' import { User } from '#src/models/user' import { Role } from '#src/models/role' +import { SmtpServer } from '@athenna/mail' import { Database } from '@athenna/database' import { RoleUser } from '#src/models/roleuser' +import { Queue } from '#src/providers/facades/queue' import { BaseHttpTest } from '@athenna/core/testing/BaseHttpTest' import { Test, type Context, AfterEach, BeforeEach } from '@athenna/test' export default class UserControllerTest extends BaseHttpTest { @BeforeEach() public async beforeEach() { + await SmtpServer.create({ disabledCommands: ['AUTH'] }).listen(5025) await Database.runSeeders() } @@ -17,6 +20,7 @@ export default class UserControllerTest extends BaseHttpTest { await User.truncate() await Role.truncate() await RoleUser.truncate() + await SmtpServer.close() await Database.close() } @@ -140,7 +144,7 @@ export default class UserControllerTest extends BaseHttpTest { } @Test() - public async shouldNotBeAbleToUpdateAUserEmail({ assert, request }: Context) { + public async shouldNotBeAbleToUpdateAUserEmailWithoutEmailConfirmation({ assert, request }: Context) { const user = await User.find({ email: 'customer@athenna.io' }) const token = await ioc.use('authService').login('admin@athenna.io', '12345') const response = await request.put(`/api/v1/users/${user.id}`, { @@ -150,6 +154,9 @@ export default class UserControllerTest extends BaseHttpTest { await user.refresh() + const queue = await Queue.queue('user:email') + + assert.deepEqual(await queue.length(), 1) assert.deepEqual(user.name, 'Customer Updated') assert.deepEqual(user.email, 'customer@athenna.io') response.assertStatusCode(200) @@ -159,7 +166,7 @@ export default class UserControllerTest extends BaseHttpTest { } @Test() - public async shouldNotBeAbleToUpdateAUserPassword({ assert, request }: Context) { + public async shouldNotBeAbleToUpdateAUserPasswordWithoutEmailConfirmation({ assert, request }: Context) { const user = await User.find({ email: 'customer@athenna.io' }) const token = await ioc.use('authService').login('admin@athenna.io', '12345') const response = await request.put(`/api/v1/users/${user.id}`, { @@ -169,6 +176,31 @@ export default class UserControllerTest extends BaseHttpTest { await user.refresh() + const queue = await Queue.queue('user:password') + + assert.deepEqual(await queue.length(), 1) + assert.deepEqual(user.name, 'Customer Updated') + assert.isTrue(await bcrypt.compare('12345', user.password)) + response.assertStatusCode(200) + response.assertBodyContains({ + data: { name: 'Customer Updated', email: 'customer@athenna.io' } + }) + } + + @Test() + public async shouldNotBeAbleToUpdateAUserEmailAndPasswordWithoutEmailConfirmation({ assert, request }: Context) { + const user = await User.find({ email: 'customer@athenna.io' }) + const token = await ioc.use('authService').login('admin@athenna.io', '12345') + const response = await request.put(`/api/v1/users/${user.id}`, { + body: { name: 'Customer Updated', email: 'customer-updated@athenna.io', password: '123456' }, + headers: { authorization: token } + }) + + await user.refresh() + + const queue = await Queue.queue('user:email:password') + + assert.deepEqual(await queue.length(), 1) assert.deepEqual(user.name, 'Customer Updated') assert.isTrue(await bcrypt.compare('12345', user.password)) response.assertStatusCode(200) diff --git a/tests/unit/auth.service.test.ts b/tests/unit/auth.service.test.ts index b9f049a..3247060 100644 --- a/tests/unit/auth.service.test.ts +++ b/tests/unit/auth.service.test.ts @@ -49,7 +49,9 @@ export default class AuthServiceTest { Mock.when(this.userService, 'getByEmail').resolve({ password: await bcrypt.hash('12345', 10), toJSON: () => {}, - load: () => {} + load: () => {}, + isEmailEqual: () => {}, + isPasswordEqual: () => true }) const authService = new AuthService(this.userService) @@ -101,30 +103,28 @@ export default class AuthServiceTest { name: 'João Lenon', email: 'lenon@athenna.io', password: '12345', - emailToken: Uuid.generate(), + token: Uuid.generate(), emailVerifiedAt: null, save: Mock.fake() } - Mock.when(this.userService, 'getByEmailToken').resolve(userRegistered) + Mock.when(this.userService, 'getByToken').resolve(userRegistered) const authService = new AuthService(this.userService) - await authService.verifyEmail(userRegistered.emailToken) + await authService.confirm(userRegistered.token) assert.calledOnce(userRegistered.save) } @Test() public async shouldThrowNotFoundExceptionWhenUserDoesNotExist({ assert }: Context) { - const emailToken = Uuid.generate() + const token = Uuid.generate() - Mock.when(this.userService, 'getByEmailToken').reject( - new NotFoundException(`Not found user with email token ${emailToken}`) - ) + Mock.when(this.userService, 'getByToken').reject(new NotFoundException(`Not found user with email token ${token}`)) const authService = new AuthService(this.userService) - await assert.rejects(() => authService.verifyEmail(emailToken), NotFoundException) + await assert.rejects(() => authService.confirm(token), NotFoundException) } } diff --git a/tests/unit/user.service.test.ts b/tests/unit/user.service.test.ts index cf2eb00..16aace7 100644 --- a/tests/unit/user.service.test.ts +++ b/tests/unit/user.service.test.ts @@ -90,24 +90,24 @@ export default class UserServiceTest { } @Test() - public async shouldBeAbleToGetAnUserByEmailToken({ assert }: Context) { + public async shouldBeAbleToGetAnUserBytoken({ assert }: Context) { const fakeUser = await User.factory().count(1).make() Mock.when(Database.driver, 'where').returnThis() Mock.when(Database.driver, 'find').resolve(fakeUser) - const emailToken = Uuid.generate() - const user = await new UserService().getByEmailToken(emailToken) + const token = Uuid.generate() + const user = await new UserService().getByToken(token) assert.deepEqual(user.toJSON(), fakeUser.toJSON()) - assert.calledWith(Database.driver.where, 'email_token', emailToken) + assert.calledWith(Database.driver.where, 'token', token) } @Test() - public async shouldThrowNotFoundExceptionIfEmailTokenDoesNotExist({ assert }: Context) { + public async shouldThrowNotFoundExceptionIftokenDoesNotExist({ assert }: Context) { Mock.when(Database.driver, 'find').resolve(undefined) - await assert.rejects(() => new UserService().getByEmailToken(Uuid.generate()), NotFoundException) + await assert.rejects(() => new UserService().getByToken(Uuid.generate()), NotFoundException) } @Test() @@ -117,7 +117,7 @@ export default class UserServiceTest { } const fakeUser = await User.factory().count(1).make(userToUpdate) - Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'find').resolve(fakeUser) Mock.when(Database.driver, 'update').resolve(fakeUser) await new UserService().update(1, userToUpdate) @@ -133,6 +133,7 @@ export default class UserServiceTest { } const fakeUser = await User.factory().count(1).make(userToUpdate) + Mock.when(Database.driver, 'find').resolve(fakeUser) Mock.when(Database.driver, 'update').resolve(fakeUser) await new UserService().update(1, userToUpdate) @@ -149,6 +150,7 @@ export default class UserServiceTest { } const fakeUser = await User.factory().count(1).make(userToUpdate) + Mock.when(Database.driver, 'find').resolve(fakeUser) Mock.when(Database.driver, 'update').resolve(fakeUser) await new UserService().update(1, userToUpdate)