mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user