Skip to content

Commit

Permalink
reset email with SES; depcheck (#890)
Browse files Browse the repository at this point in the history
* reset email with SES; depcheck

* use sendMail function

* fix tests
  • Loading branch information
sspenst authored Mar 21, 2023
1 parent 8ef8fec commit bfa1b41
Show file tree
Hide file tree
Showing 15 changed files with 952 additions and 923 deletions.
1 change: 0 additions & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ env:
JWT_SECRET: anything
LOCAL: true
REVALIDATE_SECRET: whatever
EMAIL_PASSWORD: nah
NEW_RELIC_LICENSE_KEY: dummy
NEW_RELIC_APP_NAME: dummy
INTERNAL_JOB_TOKEN_SECRET_EMAILDIGEST: cocomelon
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ A recreation of [k2xl](https://k2xl.com)'s Psychopath 2 using [Next.js](https://
- Run `npm install`
- Create a `.env` file in the root directory containing the following:
```
EMAIL_PASSWORD=password
JWT_SECRET=anything
LOCAL=true
NEW_RELIC_APP_NAME=dummy
Expand Down
36 changes: 10 additions & 26 deletions lib/sendPasswordResetEmail.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,19 @@
import { Types } from 'mongoose';
import { NextApiRequest } from 'next';
import nodemailer from 'nodemailer';
import { EmailType } from '../constants/emailDigest';
import User from '../models/db/user';
import { sendMail } from '../pages/api/internal-jobs/email-digest';
import getResetPasswordToken from './getResetPasswordToken';

export default async function sendPasswordResetEmail(req: NextApiRequest, user: User) {
if (!process.env.EMAIL_PASSWORD) {
throw new Error('EMAIL_PASSWORD not defined');
}

const pathologyEmail = 'pathology.do.not.reply@gmail.com';
const token = getResetPasswordToken(user);
const url = `${req.headers.origin}/reset-password/${user._id}/${token}`;

// NB: less secure apps will no longer be available on may 30, 2022:
// https://support.google.com/accounts/answer/6010255
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: {
user: pathologyEmail,
pass: process.env.EMAIL_PASSWORD,
},
});

const mailOptions = {
from: `Pathology <${pathologyEmail}>`,
to: user.name + ' <' + user.email + '>',
subject: `Password Reset - ${user.name}`,
text: `Click here to reset your password: ${url}`,
};

return await transporter.sendMail(mailOptions);
return await sendMail(
new Types.ObjectId(),
EmailType.EMAIL_PASSWORD_RESET,
user,
`Password Reset - ${user.name}`,
`Click here to reset your password: ${url}`,
);
}
1,712 changes: 895 additions & 817 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 2 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@aws-sdk/client-ses": "3.289.0",
"@aws-sdk/credential-provider-node": "3.289.0",
"@aws-sdk/client-ses": "^3.295.0",
"@aws-sdk/credential-provider-node": "^3.295.0",
"@headlessui/react": "^1.7.13",
"@headlessui/tailwindcss": "^0.1.2",
"@newrelic/next": "^0.4.0",
"@newrelic/winston-enricher": "^4.0.1",
"@popperjs/core": "^2.11.6",
"@socket.io/mongo-adapter": "^0.3.0",
"@socket.io/mongo-emitter": "^0.1.0",
"aws-sdk": "^2.1335.0",
"bcryptjs": "^2.4.3",
"cookie": "^0.5.0",
"debounce": "^1.2.1",
Expand All @@ -26,7 +25,6 @@
"next-seo": "^5.15.0",
"next-test-api-route-handler": "^3.1.8",
"nodemailer": "^6.9.1",
"nodemailer-sendinblue-transport": "^2.0.1",
"nprogress": "^0.2.0",
"pureimage": "^0.3.17",
"react": "^18.2.0",
Expand Down
1 change: 0 additions & 1 deletion pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ if (process.env.NO_LOGS !== 'true') {
[ false, 'DISCORD_WEBHOOK_TOKEN_LEVELS', (v: string) => (v.length > 0) ],
[ false, 'DISCORD_WEBHOOK_TOKEN_NOTIFS', (v: string) => (v.length > 0) ],
[ true, 'JWT_SECRET', (v: string) => (v.length > 0) ],
[ true, 'EMAIL_PASSWORD', (v: string) => (v.length > 0) ],
[ true, 'REVALIDATE_SECRET', (v: string) => (v.length > 0)],
[ false, 'PROD_MONGODB_URI', (v: string) => (v.length > 0) ],
[ false, 'STAGE_MONGODB_URI', (v: string) => (v.length > 0) ],
Expand Down
6 changes: 3 additions & 3 deletions pages/api/forgot-password/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ export default apiWrapper({ POST: {
}

try {
const sentMessageInfo = await sendPasswordResetEmail(req, user);
const err = await sendPasswordResetEmail(req, user);

if (!sentMessageInfo) {
if (err) {
logger.error('Error sending password reset email for ' + user.email);

return res.status(500).json({
error: 'Could not send password reset email',
});
}

return res.status(200).json({ success: sentMessageInfo.rejected.length === 0 });
return res.status(200).json({ success: !err });
} catch (e) {
logger.error(e);

Expand Down
4 changes: 2 additions & 2 deletions pages/api/signup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ export default apiWrapper({ POST: {
if (userWithEmail) {
// if the user exists but there is no ts, send them an email so they sign up with the existing account
if (!userWithEmail.ts) {
const sentMessageInfo = await sendPasswordResetEmail(req, userWithEmail);
const err = await sendPasswordResetEmail(req, userWithEmail);

return res.status(400).json({ error: sentMessageInfo.rejected.length === 0 ? 'We tried emailing you a reset password link. If you still have problems please contact Pathology devs via Discord.' : 'Error trying to register. Please contact pathology devs via Discord' });
return res.status(400).json({ error: !err ? 'We tried emailing you a reset password link. If you still have problems please contact Pathology devs via Discord.' : 'Error trying to register. Please contact pathology devs via Discord' });
} else {
return res.status(401).json({
error: 'Email already exists',
Expand Down
7 changes: 0 additions & 7 deletions tests/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { NextApiRequest } from 'next';
import cookieOptions from '../lib/cookieOptions';
import dbConnect, { dbDisconnect } from '../lib/dbConnect';
import { getTokenCookieValue } from '../lib/getTokenCookie';
import sendPasswordResetEmail from '../lib/sendPasswordResetEmail';
import { getUserFromToken } from '../lib/withAuth';
import User from '../models/db/user';

// https://stackoverflow.com/questions/48033841/test-process-env-with-jest
const OLD_ENV = process.env;
Expand Down Expand Up @@ -40,11 +38,6 @@ describe('pages/api/level/index.ts', () => {
expect(() => getTokenCookieValue('')).toThrow('JWT_SECRET not defined');
});

test('sendPasswordResetEmail', async () => {
process.env.EMAIL_PASSWORD = undefined;
await expect(sendPasswordResetEmail({} as NextApiRequest, {} as User)).rejects.toThrow('EMAIL_PASSWORD not defined');
});

test('getUserFromToken', async () => {
process.env.JWT_SECRET = undefined;
await expect(getUserFromToken(undefined, {} as NextApiRequest)).rejects.toThrow('token not defined');
Expand Down
67 changes: 17 additions & 50 deletions tests/pages/api/forgot-password/forgot-password.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
import { enableFetchMocks } from 'jest-fetch-mock';
import { testApiHandler } from 'next-test-api-route-handler';
import { SentMessageInfo } from 'nodemailer';
import { Logger } from 'winston';
import { logger } from '../../../../helpers/logger';
import dbConnect, { dbDisconnect } from '../../../../lib/dbConnect';
import { NextApiRequestWithAuth } from '../../../../lib/withAuth';
import forgotPasswordHandler from '../../../../pages/api/forgot-password/index';

let sendMailMock: jest.Mock = jest.fn((obj: SentMessageInfo) => {
throw new Error('Email was not expected to be sent, but received' + obj);
});
const throwMock = () => {
throw new Error('Mock email error');
};
const acceptMock = () => {
return { rejected: [] };
};

const sendMailRefMock = { ref: acceptMock };

beforeAll(async () => {
await dbConnect();
});
jest.mock('nodemailer', () => ({
createTransport: jest.fn().mockImplementation(() => ({
sendMail: sendMailMock,
sendMail: jest.fn().mockImplementation(() => {
return sendMailRefMock.ref();
}),
})),
}));

beforeAll(async () => {
await dbConnect();
});

afterAll(async () => {
await dbDisconnect();
});
Expand Down Expand Up @@ -106,15 +113,8 @@ describe('Forgot a password API should function right', () => {
});
});
test('Sending forgot a password request with correct parameters should succeed', async () => {
sendMailMock = jest.fn((obj: SentMessageInfo) => {
expect(obj.to).toBe('test <test@gmail.com>');
expect(obj.from).toBe('Pathology <pathology.do.not.reply@gmail.com>');
expect(obj.subject).toBe('Password Reset - test');

expect(obj.text).toMatch(/Click here to reset your password: http:\/\/localhost:3000\/reset-password\/600000000000000000000000\/[A-Za-z0-9.\-_]{10,}$/);
sendMailRefMock.ref = acceptMock;

return { rejected: [] };
});
await testApiHandler({
handler: async (_, res) => {
const req: NextApiRequestWithAuth = {
Expand All @@ -140,42 +140,9 @@ describe('Forgot a password API should function right', () => {
},
});
});
test('Sending forgot a password request when sendMail returns null should fail gracefully', async () => {
jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger));

sendMailMock = jest.fn(() => {
return null;
});
await testApiHandler({
handler: async (_, res) => {
const req: NextApiRequestWithAuth = {
method: 'POST',
body: {
email: 'test@gmail.com'
},
headers: {
'content-type': 'application/json',
'origin': 'http://localhost:3000',
},
} as unknown as NextApiRequestWithAuth;

await forgotPasswordHandler(req, res);
},
test: async ({ fetch }) => {
const res = await fetch();
const response = await res.json();

expect(response.error).toBe('Could not send password reset email');
expect(res.status).toBe(500);
},
});
});
test('Sending forgot a password request when sendMail throws an error should fail gracefully', async () => {
jest.spyOn(logger, 'error').mockImplementation(() => ({} as Logger));
sendMailRefMock.ref = throwMock;

sendMailMock = jest.fn(() => {
throw new Error('Some example exception in sendMail');
});
await testApiHandler({
handler: async (_, res) => {
const req: NextApiRequestWithAuth = {
Expand Down
7 changes: 5 additions & 2 deletions tests/pages/api/internal-jobs/email-autounsubscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import { EmailLogModel, UserConfigModel } from '../../../../models/mongoose';
import { EmailState } from '../../../../models/schemas/emailLogSchema';
import handler from '../../../../pages/api/internal-jobs/email-digest';

const throwMock = () => {throw new Error('Throwing error as no email should be sent');};
const throwMock = () => {
throw new Error('Throwing error as no email should be sent');
};
const acceptMock = () => {
return { rejected: [] };};
return { rejected: [] };
};

const sendMailRefMock = { ref: acceptMock };

Expand Down
7 changes: 5 additions & 2 deletions tests/pages/api/internal-jobs/email-digest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import { EmailLogModel, NotificationModel, UserConfigModel, UserModel } from '..
import { EmailState } from '../../../../models/schemas/emailLogSchema';
import handler from '../../../../pages/api/internal-jobs/email-digest';

const throwMock = () => {throw new Error('Mock email error');};
const throwMock = () => {
throw new Error('Mock email error');
};
const acceptMock = () => {
return { rejected: [] };};
return { rejected: [] };
};

const sendMailRefMock = { ref: acceptMock };

Expand Down
10 changes: 7 additions & 3 deletions tests/pages/api/internal-jobs/email-reactivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ import { EmailLogModel, UserModel } from '../../../../models/mongoose';
import { EmailState } from '../../../../models/schemas/emailLogSchema';
import handler from '../../../../pages/api/internal-jobs/email-digest';

const throwMock = () => {throw new Error('Throwing error as no email should be sent');};
const throwMock = () => {
throw new Error('Throwing error as no email should be sent');
};
const acceptMock = () => {
return { rejected: [] };};
return { rejected: [] };
};
const rejectMock = () => {
return { rejected: ['Test rejection'], rejectedErrors: ['Test rejection error'] };};
return { rejected: ['Test rejection'], rejectedErrors: ['Test rejection error'] };
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendMailRefMock: any = { ref: acceptMock };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { EmailState } from '../../../../models/schemas/emailLogSchema';
import handler from '../../../../pages/api/internal-jobs/email-digest';

const acceptMock = () => {
return { rejected: [] };};
return { rejected: [] };
};

const sendMailRefMock = { ref: acceptMock };

Expand Down
7 changes: 4 additions & 3 deletions tests/pages/api/reviews/reviews.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,10 @@ describe('pages/api/reviews', () => {
test: async ({ fetch }) => {
jest.spyOn(ReviewModel, 'find').mockReturnValueOnce({
populate: function() {
return { sort: function() {
return null;
}
return {
sort: function() {
return null;
}
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down

0 comments on commit bfa1b41

Please sign in to comment.