From 36d03d8b8f67364540293ace0649b839a5dc7031 Mon Sep 17 00:00:00 2001 From: Jaime Leonardo Suncin Cruz Date: Tue, 12 Oct 2021 23:53:22 -0600 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E2=9C=A8=20update=20current=20us?= =?UTF-8?q?er=20information?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow to update the name and password of the user. Use conditional validation with Joi (is pure pain). koi-joi-router do not support `validateAsync` that is required when a schema use `external`. Add more test cases. --- __tests__/routes/auth.spec.ts | 54 ++++++++++++++++++++++- __tests__/schemas/auth-validation.spec.ts | 49 +++++++++++++++++++- src/routes/auth.ts | 52 +++++++++++++++++++++- src/schemas/auth.ts | 21 +++++++++ 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/__tests__/routes/auth.spec.ts b/__tests__/routes/auth.spec.ts index 5c39e586..f973874d 100644 --- a/__tests__/routes/auth.spec.ts +++ b/__tests__/routes/auth.spec.ts @@ -16,6 +16,7 @@ describe('Auth routes', () => { login: '/auth/login', user: '/auth/me', }; + const password = 'Th€Pa$$w0rd!'; beforeAll(async () => { connection = await createConnection(); @@ -27,7 +28,7 @@ describe('Auth routes', () => { repo.create({ name: faker.name.findName(), email: faker.internet.email().toLowerCase(), - password: 'Th€Pa$$w0rd!', + password, }), ); }); @@ -85,7 +86,7 @@ describe('Auth routes', () => { it('should login with existing user', async () => { const payload = { email: user.email, - password: 'Th€Pa$$w0rd!', + password, }; await request(server.callback()) @@ -188,4 +189,53 @@ describe('Auth routes', () => { }); }); }); + + it.each([ + { name: faker.name.findName() }, + { password, newPassword: faker.internet.password(12, true) }, + { + name: faker.name.findName(), + password, + newPassword: faker.internet.password(12), + }, + ])('should update my user with %o', async (payload) => { + const token = generateToken(user); + + await request(server.callback()) + .put(url.user) + .set('Authorization', `Bearer ${token}`) + .send(payload) + .expect(StatusCodes.OK) + .expect(({ body }) => { + expect(body).toMatchObject({ + id: user.id, + name: payload.name ?? expect.any(String), + email: user.email, + }); + }); + }); + + it('should fail to update my password when the current password is wrong', async () => { + const token = generateToken(user); + const payload = { + password: faker.internet.password(12, true), + newPassword: faker.internet.password(12), + }; + + await request(server.callback()) + .put(url.user) + .set('Authorization', `Bearer ${token}`) + .send(payload) + .expect(StatusCodes.BAD_REQUEST) + .expect(({ body }) => { + expect(body).toMatchObject({ + details: { + password: '"password" is wrong', + }, + message: '"password" is wrong', + reason: ReasonPhrases.BAD_REQUEST, + statusCode: StatusCodes.BAD_REQUEST, + }); + }); + }); }); diff --git a/__tests__/schemas/auth-validation.spec.ts b/__tests__/schemas/auth-validation.spec.ts index 209c613c..2dbbb33e 100644 --- a/__tests__/schemas/auth-validation.spec.ts +++ b/__tests__/schemas/auth-validation.spec.ts @@ -1,6 +1,6 @@ import * as fc from 'fast-check'; -import { loginUser, registerUser } from '@/schemas/auth'; +import { loginUser, registerUser, updateUser } from '@/schemas/auth'; describe('Auth validation schemas', () => { const name = 'John Doe'; @@ -112,4 +112,51 @@ describe('Auth validation schemas', () => { ).rejects.toThrow(expectedError); }, ); + + it('should validate the update data', async () => { + const name = fc.string({ minLength: 1 }); + const newPassword = fc.string({ minLength: 12, maxLength: 32 }); + + await fc.asyncProperty( + fc.oneof( + fc.record({ name }), + fc.record({ + password: fc.constant(password), + newPassword, + }), + fc.record({ + name, + password: fc.constant(password), + newPassword, + }), + ), + async (data) => { + fc.pre( + 'newPassword' in data ? data.password !== data.newPassword : true, + ); + + await expect(updateUser.validateAsync(data)).resolves.toEqual(data); + }, + ); + }); + + it.each([ + [{}, '"name" is required'], + [{ password }, '"password" is not allowed'], + [ + { newPassword: 'myc0NTR4s3ñ@' }, + '"password" is required. "new password" missing required peer "password"', + ], + [ + { password, newPassword: password }, + '"new password" contains an invalid value', + ], + ])( + 'should fail with invalid update data %o and throw the error %s', + async (data, expectedError) => { + await expect( + updateUser.options({ abortEarly: false }).validateAsync(data), + ).rejects.toThrow(expectedError); + }, + ); }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 8695584b..38eab747 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,5 +1,5 @@ import { StatusCodes } from 'http-status-codes'; -import router from 'koa-joi-router'; +import router, { Joi } from 'koa-joi-router'; import { getRepository } from 'typeorm'; import { User } from '@/entities/user'; @@ -123,4 +123,54 @@ authRouter.get( }, ); +authRouter.put( + '/me', + { + validate: { + header: schemas.withAuthenticationHeader, + body: schemas.updateUser, + type: 'json', + output: { + 200: { + body: schemas.user, + }, + '400-599': { + body: errorResponse, + }, + }, + validateOptions: { + abortEarly: false, + stripUnknown: true, + }, + }, + }, + auth, + async (context) => { + const userRepository = getRepository(User); + const user = await userRepository.findOne({ id: context.state.user.sub }); + + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + if ( + context.request.body.newPassword && + !user!.checkPassword(context.request.body.password) + ) { + context.throw( + StatusCodes.BAD_REQUEST, + // hack the validation: koi-joi-router do not support `validateAsync`, so the error has to be thrown manually + new Joi.ValidationError( + '"password" is wrong', + [{ path: ['password'], message: '"password" is wrong' }], + null, + ), + ); + } + + userRepository.merge(user!, context.request.body); + await userRepository.save(user!); + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + + context.body = user; + }, +); + export default authRouter; diff --git a/src/schemas/auth.ts b/src/schemas/auth.ts index 2bccb52a..deb266c0 100644 --- a/src/schemas/auth.ts +++ b/src/schemas/auth.ts @@ -54,3 +54,24 @@ export const withAuthenticationHeader = Joi.object({ .label('Bearer Token') .description('Bearer token that needs to be a JSON Web Token'), }).options({ allowUnknown: true }); + +export const updateUser = Joi.object({ + name, + password, + newPassword: password + .optional() + .not(Joi.ref('password')) + .label('new password') + .description('The new password of the user'), +}) + .with('newPassword', 'password') + .when('.newPassword', { + is: Joi.exist(), + then: Joi.object({ name: Joi.optional(), password: Joi.required() }), + otherwise: Joi.object({ name: Joi.required(), password: Joi.forbidden() }), + }) + .when('.password', { + is: Joi.exist(), + then: Joi.object({ name: Joi.optional() }), + }) + .description("Update user's information");