Skip to content

Commit

Permalink
feat(auth): ✨ update current user information
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
leosuncin committed Oct 13, 2021
1 parent 956b179 commit 36d03d8
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 4 deletions.
54 changes: 52 additions & 2 deletions __tests__/routes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('Auth routes', () => {
login: '/auth/login',
user: '/auth/me',
};
const password = 'Th€Pa$$w0rd!';

beforeAll(async () => {
connection = await createConnection();
Expand All @@ -27,7 +28,7 @@ describe('Auth routes', () => {
repo.create({
name: faker.name.findName(),
email: faker.internet.email().toLowerCase(),
password: 'Th€Pa$$w0rd!',
password,
}),
);
});
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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<ErrorResponse>({
details: {
password: '"password" is wrong',
},
message: '"password" is wrong',
reason: ReasonPhrases.BAD_REQUEST,
statusCode: StatusCodes.BAD_REQUEST,
});
});
});
});
49 changes: 48 additions & 1 deletion __tests__/schemas/auth-validation.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
},
);
});
52 changes: 51 additions & 1 deletion src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
21 changes: 21 additions & 0 deletions src/schemas/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

0 comments on commit 36d03d8

Please sign in to comment.