feat(api): add update vaildate email endpoint (#50276)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: mot01 <tmondloch01@gmail.com>
This commit is contained in:
Muhammed Mustafa
2023-10-06 16:06:42 +03:00
committed by GitHub
parent 0060e78715
commit 44d8add232
5 changed files with 354 additions and 23 deletions
+1
View File
@@ -17,6 +17,7 @@
"ajv": "8.12.0",
"ajv-formats": "2.1.1",
"connect-mongo": "4.6.0",
"date-fns": "2.30.0",
"dotenv": "16.3.1",
"fast-uri": "2.2.0",
"fastify": "4.21.0",
+183 -3
View File
@@ -1,4 +1,6 @@
import { devLogin, setupServer, superRequest } from '../../jest.utils';
import { defaultUser } from '../utils/default-user';
import { isPictureWithProtocol } from './settings';
const baseProfileUI = {
@@ -24,6 +26,11 @@ const profileUI = {
showPortfolio: true
};
const developerUserEmail = 'foo@bar.com';
const otherDeveloperUserEmail = 'bar@bar.com';
const unusedEmailOne = 'nobody@would.com';
const unusedEmailTwo = 'would@they.com';
const updateErrorResponse = {
type: 'danger',
message: 'flash.wrong-updating'
@@ -92,9 +99,22 @@ describe('settingRoutes', () => {
// profileUI, but we're interested in how the profileUI is updated. As
// such, setting this explicitly isolates these tests.
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
where: { email: developerUserEmail },
data: { profileUI: baseProfileUI }
});
const otherUser = await fastifyTestInstance.prisma.user.findFirst({
where: { email: otherDeveloperUserEmail }
});
if (!otherUser) {
await fastifyTestInstance.prisma.user.create({
data: {
...defaultUser,
email: otherDeveloperUserEmail
}
});
}
});
describe('/update-my-profileui', () => {
@@ -107,7 +127,7 @@ describe('settingRoutes', () => {
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
where: { email: developerUserEmail }
});
expect(response.body).toEqual({
@@ -130,7 +150,7 @@ describe('settingRoutes', () => {
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
where: { email: developerUserEmail }
});
expect(user?.profileUI).toEqual(profileUI);
@@ -156,6 +176,166 @@ describe('settingRoutes', () => {
});
});
describe('/update-my-email', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: developerUserEmail },
data: {
newEmail: null,
emailVerified: true,
emailVerifyTTL: null,
emailAuthLinkTTL: null
}
});
});
test('PUT returns 200 status code with "success" message', async () => {
const response = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: 'foo@foo.com' });
expect(response?.body).toEqual({
message: 'flash.email-valid',
type: 'success'
});
expect(response?.statusCode).toEqual(200);
});
test("PUT updates the user's record in preparation for receiving auth email", async () => {
const response = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: unusedEmailOne });
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: developerUserEmail },
select: { emailVerifyTTL: true, emailVerified: true, newEmail: true }
});
const emailVerifyTTL = user?.emailVerifyTTL;
expect(emailVerifyTTL).toBeTruthy();
// This throw is to mollify TS (if this is necessary a lot, create a
// helper)
if (!emailVerifyTTL) {
throw new Error('emailVerifyTTL is not defined');
}
expect(response?.statusCode).toEqual(200);
// expect the emailVerifyTTL to be within 10 seconds of the current time
const tenSeconds = 10 * 1000;
expect(emailVerifyTTL.getTime()).toBeGreaterThan(
Date.now() - tenSeconds
);
expect(emailVerifyTTL.getTime()).toBeLessThan(Date.now() + tenSeconds);
expect(user?.emailVerified).toEqual(false);
expect(user?.newEmail).toEqual(unusedEmailOne);
});
test('PUT rejects invalid email addresses', async () => {
const response = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: 'invalid' });
// We cannot use fastify's default validation failure response here
// because the client consumes the response and displays it to the user.
expect(response?.body).toEqual({
type: 'danger',
message: 'Email format is invalid'
});
expect(response?.statusCode).toEqual(400);
});
test('PUT accepts requests to update to the current email address (ignoring case) if it is not verified', async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: developerUserEmail },
data: { emailVerified: false }
});
const response = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: developerUserEmail.toUpperCase() });
expect(response?.statusCode).toEqual(200);
expect(response?.body).toEqual({
message: 'flash.email-valid',
type: 'success'
});
});
test('PUT rejects a request to update to the existing email (ignoring case) address', async () => {
const response = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: developerUserEmail.toUpperCase() });
expect(response?.body).toEqual({
type: 'info',
message: `${developerUserEmail} is already associated with this account.
You can update a new email address instead.`
});
expect(response?.statusCode).toEqual(400);
});
test('PUT rejects a request to update to the same email (ignoring case) twice', async () => {
const successResponse = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: unusedEmailOne });
expect(successResponse?.statusCode).toEqual(200);
const failResponse = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: unusedEmailOne.toUpperCase() });
expect(failResponse?.body).toEqual({
type: 'info',
message: `We have already sent an email confirmation request to ${unusedEmailOne}.
Please wait 5 minutes to resend an authentication link.`
});
expect(failResponse?.statusCode).toEqual(429);
});
test('PUT rejects a request if the new email is already in use', async () => {
const response = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: otherDeveloperUserEmail });
expect(response?.body).toEqual({
type: 'info',
message: `${otherDeveloperUserEmail} is already associated with another account.`
});
expect(response?.statusCode).toEqual(400);
});
test('PUT rejects the second request if is immediately after the first', async () => {
const successResponse = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: unusedEmailOne });
expect(successResponse?.statusCode).toEqual(200);
const failResponse = await superRequest('/update-my-email', {
method: 'PUT',
setCookies
}).send({ email: unusedEmailTwo });
expect(failResponse?.statusCode).toEqual(429);
expect(failResponse?.body).toEqual({
type: 'info',
message: `Please wait 5 minutes to resend an authentication link.`
});
});
// TODO: test that the correct email gets sent
});
describe('/update-my-theme', () => {
test('PUT returns 200 status code with "success" message', async () => {
const response = await superRequest('/update-my-theme', {
+128
View File
@@ -1,11 +1,38 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import { getMinutes, isBefore, sub } from 'date-fns';
import { isProfane } from 'no-profanity';
import { blocklistedUsernames } from '../../../shared/config/constants';
import { isValidUsername } from '../../../shared/utils/validate';
import { schemas } from '../schemas';
// TODO: move getWaitMessage and getWaitPeriod to own module and add tests
function getWaitMessage(lastEmailSentAt: Date | null) {
const minutesLeft = getWaitPeriod(lastEmailSentAt);
if (minutesLeft <= 0) {
return null;
}
const timeToWait = minutesLeft
? `${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}`
: 'a few seconds';
return `Please wait ${timeToWait} to resend an authentication link.`;
}
function getWaitPeriod(lastEmailSentAt: Date | null) {
if (!lastEmailSentAt) return 0;
const now = new Date();
const fiveMinutesAgo = sub(now, { minutes: 5 });
const isWaitPeriodOver = isBefore(lastEmailSentAt, fiveMinutesAgo);
return isWaitPeriodOver
? 0
: 5 - (getMinutes(now) - getMinutes(lastEmailSentAt));
}
/**
* Validate an image url.
*
@@ -92,6 +119,107 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
}
);
fastify.put(
'/update-my-email',
{
schema: schemas.updateMyEmail,
// We need to customize the responses to validation failures:
attachValidation: true
},
async (req, reply) => {
if (req.validationError) {
void reply.code(400);
return { message: 'Email format is invalid', type: 'danger' } as const;
}
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id },
select: {
email: true,
emailVerifyTTL: true,
newEmail: true,
emailVerified: true,
emailAuthLinkTTL: true
}
});
const newEmail = req.body.email.toLowerCase();
const currentEmailFormatted = user.email.toLowerCase();
const isVerifiedEmail = user.emailVerified;
const isOwnEmail = newEmail === currentEmailFormatted;
if (isOwnEmail && isVerifiedEmail) {
void reply.code(400);
return {
type: 'info',
message: `${newEmail} is already associated with this account.
You can update a new email address instead.`
} as const;
}
const isResendUpdateToSameEmail =
newEmail === user.newEmail?.toLowerCase();
const isLinkSentWithinLimitTTL = getWaitMessage(user.emailVerifyTTL);
if (isResendUpdateToSameEmail && isLinkSentWithinLimitTTL) {
void reply.code(429);
return {
type: 'info',
message: `We have already sent an email confirmation request to ${newEmail}.
${isLinkSentWithinLimitTTL}`
} as const;
}
const isEmailAlreadyTaken =
(await fastify.prisma.user.count({ where: { email: newEmail } })) > 0;
if (isEmailAlreadyTaken && !isOwnEmail) {
void reply.code(400);
return {
type: 'info',
message: `${newEmail} is already associated with another account.`
} as const;
}
// ToDo(MVP): email the new email and wait user to confirm it, before we update the user schema.
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
data: {
newEmail,
emailVerified: false,
emailVerifyTTL: new Date()
}
});
// TODO: combine emailVerifyTTL and emailAuthLinkTTL? I'm not sure why
// we need emailVeriftyTTL given that the main thing we want is to
// restrict the rate of attempts and the emailAuthLinkTTL already does
// that.
const tooManyRequestsMessage = getWaitMessage(user.emailAuthLinkTTL);
if (tooManyRequestsMessage) {
void reply.code(429);
return {
type: 'info',
message: tooManyRequestsMessage
} as const;
}
await fastify.prisma.user.update({
where: { id: req.session.user.id },
data: {
emailAuthLinkTTL: new Date()
}
});
return { message: 'flash.email-valid', type: 'success' } as const;
} catch (err) {
fastify.log.error(err);
void reply.code(500);
return { message: 'flash.wrong-updating', type: 'danger' } as const;
}
}
);
fastify.put(
'/update-my-theme',
{
+19
View File
@@ -216,6 +216,25 @@ export const schemas = {
})
}
},
updateMyEmail: {
body: Type.Object({
email: Type.String({ format: 'email', maxLength: 1024 })
}),
response: {
200: Type.Object({
message: Type.Literal('flash.email-valid'),
type: Type.Literal('success')
}),
'4xx': Type.Object({
message: Type.String(),
type: Type.Union([Type.Literal('danger'), Type.Literal('info')])
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
},
// User:
deleteMyAccount: {
response: {