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: {
+23 -20
View File
@@ -210,6 +210,9 @@ importers:
connect-mongo:
specifier: 4.6.0
version: 4.6.0(express-session@1.17.3)(mongodb@4.17.1)
date-fns:
specifier: 2.30.0
version: 2.30.0
dotenv:
specifier: 16.3.1
version: 16.3.1
@@ -6452,7 +6455,7 @@ packages:
/@redux-saga/core@1.2.3:
resolution: {integrity: sha512-U1JO6ncFBAklFTwoQ3mjAeQZ6QGutsJzwNBjgVLSWDpZTRhobUzuVDS1qH3SKGJD8fvqoaYOjp6XJ3gCmeZWgA==}
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
'@redux-saga/deferred': 1.2.1
'@redux-saga/delay-p': 1.2.1
'@redux-saga/is': 1.1.3
@@ -11040,7 +11043,7 @@ packages:
/babel-plugin-macros@2.8.0:
resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==}
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
cosmiconfig: 6.0.0
resolve: 1.22.6
@@ -11173,7 +11176,7 @@ packages:
gatsby: ^3.0.0-next.0
dependencies:
'@babel/core': 7.23.0
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
'@babel/types': 7.23.0
gatsby: 3.15.0(@types/node@20.8.0)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@4.9.5)(webpack-cli@4.10.0)
gatsby-core-utils: 2.15.0
@@ -11657,7 +11660,7 @@ packages:
'@babel/plugin-transform-spread': 7.22.5(@babel/core@7.23.0)
'@babel/preset-env': 7.22.20(@babel/core@7.23.0)
'@babel/preset-react': 7.22.15(@babel/core@7.23.0)
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
babel-plugin-dynamic-import-node: 2.3.3
babel-plugin-macros: 2.8.0
babel-plugin-transform-react-remove-prop-types: 0.4.24
@@ -13379,7 +13382,7 @@ packages:
resolution: {integrity: sha512-l6U4w4h8uGwKH92I8lopiY9hpa6BPN8IsTm06VwfG4U7xZH1HI5KuP7MVO126cCy+iG482cerhMFE/kTJ/J0Jw==}
hasBin: true
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
/create-hash@1.2.0:
resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==}
@@ -14449,13 +14452,13 @@ packages:
/dom-helpers@3.4.0:
resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==}
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
dev: false
/dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
csstype: 3.1.2
dev: false
@@ -15248,7 +15251,7 @@ packages:
peerDependencies:
graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
graphql: 15.8.0
graphql-config: 3.4.1(@types/node@20.8.0)(graphql@15.8.0)(typescript@4.9.5)
lodash.flatten: 4.4.0
@@ -16943,7 +16946,7 @@ packages:
resolution: {integrity: sha512-QspRxfSgD4Yb5syp/yNPN+ljXgatfgqq4/TIIJw5mVxVMhNenb8mQ8ihVL5vdhV7x3wUjKTwVIjZ+eU/sMLz7g==}
engines: {node: '>=12.13.0'}
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
ci-info: 2.0.0
configstore: 5.0.1
file-type: 16.5.4
@@ -16958,12 +16961,12 @@ packages:
resolution: {integrity: sha512-w9QSjOBJKi8nADypZycRBNEzRHyBEERUbMygMHEa/Lr8JkWi5YV5TplneDnJv9/R4VXiKA3CcpbIrpGqzp7qmA==}
engines: {node: '>=12.13.0'}
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
/gatsby-legacy-polyfills@1.15.0:
resolution: {integrity: sha512-CysOx6kjH7jomkhUdMDy5oFPC0vfdHfh96O+ZENW5tk9k4SQ+G64weywm9EfC6coIFPYytBhW/SrE7ZwizKnPA==}
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
core-js-compat: 3.9.0
/gatsby-link@3.15.0(@gatsbyjs/reach-router@1.3.9)(react-dom@16.14.0)(react@16.14.0):
@@ -16985,7 +16988,7 @@ packages:
resolution: {integrity: sha512-Nj5lcMJaACCaWVgRc6T45U7KxhqEPpWZo/IlOaCdX2pWjK/5oWI3hVtfukfFpDRunYtIdw2Vl8bicLgU+viAWA==}
engines: {node: '>=12.13.0'}
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
bluebird: 3.7.2
chokidar: 3.5.3
fs-exists-cached: 1.0.0
@@ -17041,7 +17044,7 @@ packages:
peerDependencies:
gatsby: ^3.0.0-next.0
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
'@babel/traverse': 7.23.0
'@sindresorhus/slugify': 1.1.2
chokidar: 3.5.3
@@ -17111,7 +17114,7 @@ packages:
'@babel/plugin-proposal-numeric-separator': 7.18.6(@babel/core@7.23.0)
'@babel/plugin-proposal-optional-chaining': 7.17.12(@babel/core@7.23.0)
'@babel/preset-typescript': 7.23.0(@babel/core@7.23.0)
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
babel-plugin-remove-graphql-queries: 3.15.0(@babel/core@7.23.0)(gatsby@3.15.0)
gatsby: 3.15.0(@types/node@20.8.0)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@4.9.5)(webpack-cli@4.10.0)
transitivePeerDependencies:
@@ -17124,7 +17127,7 @@ packages:
gatsby: ^3.0.0-next.0
graphql: ^15.0.0
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
fastq: 1.15.0
gatsby: 3.15.0(@types/node@20.8.0)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@4.9.5)(webpack-cli@4.10.0)
graphql: 15.8.0
@@ -17151,7 +17154,7 @@ packages:
react: ^16.9.0 || ^17.0.0
react-dom: ^16.9.0 || ^17.0.0
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
'@gatsbyjs/reach-router': 1.3.9(react-dom@16.14.0)(react@16.14.0)
prop-types: 15.8.1
react: 16.14.0
@@ -17165,7 +17168,7 @@ packages:
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-proposal-optional-chaining': 7.17.12(@babel/core@7.23.0)
'@babel/plugin-transform-react-jsx': 7.22.15(@babel/core@7.23.0)
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
'@babel/standalone': 7.23.1
'@babel/template': 7.22.15
'@babel/types': 7.23.0
@@ -17267,7 +17270,7 @@ packages:
requiresBuild: true
dependencies:
'@babel/code-frame': 7.22.13
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
'@turist/fetch': 7.2.0(node-fetch@2.7.0)
'@turist/time': 0.0.2
async-retry-ng: 2.0.1
@@ -17321,7 +17324,7 @@ packages:
engines: {node: '>=12.13.0'}
dependencies:
'@babel/core': 7.23.0
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
transitivePeerDependencies:
- supports-color
@@ -25439,7 +25442,7 @@ packages:
react: '>0.13.0'
react-dom: '>0.13.0'
dependencies:
'@babel/runtime': 7.20.13
'@babel/runtime': 7.23.1
get-node-dimensions: 1.2.1
prop-types: 15.8.1
react: 16.14.0