From 93ec96a843cb855eaf76b1949224823812cda147 Mon Sep 17 00:00:00 2001 From: Bryce Egley Date: Thu, 7 Oct 2021 09:31:58 -0700 Subject: [PATCH 1/3] Test sendgrid email driver for malformed emails --- api/test/application/email/sendgrid.js | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 api/test/application/email/sendgrid.js diff --git a/api/test/application/email/sendgrid.js b/api/test/application/email/sendgrid.js new file mode 100644 index 00000000..8ce1f9d6 --- /dev/null +++ b/api/test/application/email/sendgrid.js @@ -0,0 +1,68 @@ +const Lab = require('@hapi/lab'); +const Code = require('@hapi/code'); +const { describe, it, before } = exports.lab = Lab.script(); +const sinon = require('sinon'); +const { expect } = Code; +const getSendGrid = require('../../../src/application/email/drivers/sendgrid'); + +describe('send email with sendgrid', () => { + let emailService; + + const email_template = { + to: 'test@example.com', + subject: 'Welcome', + html: '

Welcome to ODIC!

', + from: 'no-reply@example.com' + }; + + before(async () => { + emailService = getSendGrid(); + }); + + it(`throws error if no to address provided`, async () => { + const email = { ...email_template, to: '' }; + + try { + await emailService.send(email); + } catch (err) { + expect(err).to.equal('no to address provided') + } + }); + + it(`throws error if no subject is provided`, async () => { + const email = { ...email_template, subject: '' }; + + try { + await emailService.send(email); + } catch (err) { + expect(err).to.equal('no subject provided') + } + }); + + it(`throws error if no text or html body provided`, async () => { + const email = { ...email_template, html: '' }; + + try { + await emailService.send(email); + } catch (err) { + expect(err).to.equal('no text or html body provided') + } + }); + + + + it(`send email if it passes whitelist`, async () => { + emailService.send= sinon.stub().callsFake(params => { + return { + promise: () => new Promise(resolve => resolve({})), + }; + }); + + await emailService.send(email_template); + + expect(emailService.send.calledOnce).to.equal(true); + + sinon.restore(); + + }); +}) \ No newline at end of file From 61ec5cba90b5e678f8ce85963355878bac37cf8f Mon Sep 17 00:00:00 2001 From: Bryce Egley Date: Thu, 4 Nov 2021 23:30:22 -0700 Subject: [PATCH 2/3] Tests for sendgrid, ses, and mailgun drivers. Email env variables now passed through api configuration file --- api/config.js | 13 ++- api/package.json | 3 +- api/src/application/email/check-whitelist.js | 5 +- api/src/application/email/drivers/mailgun.js | 21 ++-- api/src/application/email/drivers/sendgrid.js | 100 ++++++++++-------- api/src/application/email/drivers/ses.js | 12 ++- api/src/application/email/email-service.js | 4 +- api/test/application/api/invite-post.js | 2 +- api/test/application/email/mailgun.js | 71 +++++++++++++ api/test/application/email/sendgrid.js | 69 ++++++++++++ api/test/application/email/ses.js | 74 +++++++++++++ api/test/helpers/factory/user.js | 5 +- 12 files changed, 312 insertions(+), 67 deletions(-) create mode 100644 api/test/application/email/mailgun.js create mode 100644 api/test/application/email/sendgrid.js create mode 100644 api/test/application/email/ses.js diff --git a/api/config.js b/api/config.js index 38a73c60..a5ee27b5 100644 --- a/api/config.js +++ b/api/config.js @@ -19,7 +19,14 @@ var config = { userSessionTrackingEnabled: process.env.ENABLE_USER_SESSION_TRACKING || false, tls: process.env.REDIS_TLS === 'true' ? { port: process.env.REDIS_TLS_PORT || 6380 } : undefined, }, - email: process.env.OIDC_EMAIL_DRIVER, + email: { + driver: process.env.OIDC_EMAIL_DRIVER, + domain: process.env.OIDC_EMAIL_DOMAIN, + whitelist: process.env.OIDC_EMAIL_WHITELIST, + trap: process.env.OIDC_EMAIL_TRAP, + mailgunApiKey: process.env.MAILGUN_API_KEY, + sendGridApiKey: process.env.SENDGRID_API_KEY + }, oidc: { cookieKeys: [process.env.COOKIE_KEY, process.env.OLD_COOKIE_KEY], initialAccessToken: process.env.OIDC_INITIAL_ACCESS_TOKEN, @@ -47,6 +54,10 @@ var config = { dbConnection: { database: `${process.env.OIDC_DB_NAME}_test`, }, + email: { + mailgunApiKey: 'test-key', + sendGridApiKey: 'SG.test-key-123' + } }, }; diff --git a/api/package.json b/api/package.json index efddcbc8..e3608690 100644 --- a/api/package.json +++ b/api/package.json @@ -72,6 +72,7 @@ "factory-girl": "^5.0.4", "jsdom": "^10.1.0", "nodemon": "1.18.9", - "sinon": "^9.0.2" + "sinon": "^9.0.2", + "aws-sdk-mock": "^5.4.0" } } diff --git a/api/src/application/email/check-whitelist.js b/api/src/application/email/check-whitelist.js index 432c66b0..f6ed2e1d 100644 --- a/api/src/application/email/check-whitelist.js +++ b/api/src/application/email/check-whitelist.js @@ -1,8 +1,9 @@ const _ = require('lodash'); +const config = require('../../../config'); const options = { - trap: process.env.OIDC_EMAIL_TRAP, - whitelist: process.env.OIDC_EMAIL_WHITELIST ? process.env.OIDC_EMAIL_WHITELIST.split(',') : null, + trap: config('email/trap'), + whitelist: config('email/whitelist') ? config('email/whitelist').split(',') : null, }; const nonAlphaNumericPattern = /\W/g; diff --git a/api/src/application/email/drivers/mailgun.js b/api/src/application/email/drivers/mailgun.js index 081fc8b6..1e877a0b 100644 --- a/api/src/application/email/drivers/mailgun.js +++ b/api/src/application/email/drivers/mailgun.js @@ -3,12 +3,14 @@ const Mailgun = require('mailgun-js'); const checkWhitelist = require('../check-whitelist'); const logger = require('../../../lib/logger'); +const Boom = require('boom'); +const config = require('../../../../config'); module.exports = function () { const mailgunClient = Mailgun({ - apiKey: process.env.MAILGUN_API_KEY, - domain: process.env.OIDC_EMAIL_DOMAIN, + apiKey: config('/email/mailgunApiKey'), + domain: config('/email/domain'), }); return { @@ -17,19 +19,19 @@ module.exports = function () { return new Promise((resolve, reject) => { if (!emailObject.to) { - return reject('no to address provided'); + return reject(new Error('no to address provided')); } if (!emailObject.subject) { - return reject('no subject provided'); + return reject(new Error('no subject provided')); } if (!emailObject.text && !emailObject.html) { - return reject('no text or html body provided'); + return reject(new Error('no text or html body provided')); } const mail = { - from: emailObject.from || 'no-reply@' + process.env.OIDC_EMAIL_DOMAIN, + from: emailObject.from || 'no-reply@' + config('/email/domain'), to: checkWhitelist(emailObject.to, reject), subject: emailObject.subject, text: emailObject.text || '', @@ -38,7 +40,7 @@ module.exports = function () { if (emailObject.attachments) { if (!Array.isArray(emailObject.attachments)) { - return reject('attachments must be an array'); + return reject(new Error('attachments must be an array')); } const attachments = []; @@ -60,13 +62,16 @@ module.exports = function () { mailgunClient.messages().send(mail, (error, body) => { if (error) { logger.error(error, mail); - reject(error); + return reject(Boom.wrap(error, error.statusCode)); } else { resolve(body); } }); }); + }, + client: () => { + return mailgunClient; } }; }; diff --git a/api/src/application/email/drivers/sendgrid.js b/api/src/application/email/drivers/sendgrid.js index 1e3f014b..425a038e 100644 --- a/api/src/application/email/drivers/sendgrid.js +++ b/api/src/application/email/drivers/sendgrid.js @@ -2,66 +2,74 @@ const sgMail = require('@sendgrid/mail'); const checkWhitelist = require('../check-whitelist'); const logger = require('../../../lib/logger'); +const Boom = require('boom'); +const config = require('../../../../config'); module.exports = function () { - sgMail.setApiKey(process.env.SENDGRID_API_KEY); + sgMail.setApiKey(config('/email/sendGridApiKey')); return { - send: async (emailObject) => { - if (!emailObject.to) { - throw 'no to address provided'; - } + send: (emailObject) => { - if (!emailObject.subject) { - throw 'no subject provided'; - } + return new Promise((resolve, reject) => { + if (!emailObject.to) { + return reject(new Error('no to address provided')); + } + + if (!emailObject.subject) { + return reject(new Error('no subject provided')); + } + + if (!emailObject.text && !emailObject.html) { + return reject(new Error('no text or html body provided')); + } - if (!emailObject.text && !emailObject.html) { - throw 'no text or html body provided'; - } + let whitelistError = false; + const to = checkWhitelist(emailObject.to, err => whitelistError = err); + if (whitelistError) { + return reject(new Error(whitelistError)); + } - let whitelistError = false; - const to = checkWhitelist(emailObject.to, err => whitelistError = err); - if (whitelistError) { - throw whitelistError; - } + const mail = { + from: emailObject.from || 'no-reply@' + config('/email/domain'), + to, + subject: emailObject.subject, + text: emailObject.text || ' ', + html: emailObject.html || ' ' + }; - const mail = { - from: emailObject.from || 'no-reply@' + process.env.OIDC_EMAIL_DOMAIN, - to, - subject: emailObject.subject, - text: emailObject.text || ' ', - html: emailObject.html || ' ' - }; + if (emailObject.attachments) { + if (!Array.isArray(emailObject.attachments)) { + return reject(new Error('attachments must be an array')); + } - if (emailObject.attachments) { - if (!Array.isArray(emailObject.attachments)) { - throw 'attachments must be an array'; + mail.attachments = emailObject.attachments.map(attachment => ({ + type: attachment.contentType, + filename: attachment.filename, + content: + typeof attachment.data === 'string' + ? new Buffer(attachment.data) + : attachment.data, + disposition: 'attachment', + contentId: '', + })); } - mail.attachments = emailObject.attachments.map(attachment => ({ - type: attachment.contentType, - filename: attachment.filename, - content: - typeof attachment.data === 'string' - ? new Buffer(attachment.data) - : attachment.data, - disposition: 'attachment', - contentId: '', - })); - } + sgMail.send(mail, (error, body) => { + if(error) { + logger.error(error); - try { - return await sgMail.send(mail); - } catch (error) { - logger.error(error); + if (error.response) { + logger.error(error.response.body); + } - if (error.response) { - logger.error(error.response.body); - } + return reject(Boom.wrap(error, error.statusCode)); - throw error; - } + } else { + resolve(body); + } + }); + }); } }; }; diff --git a/api/src/application/email/drivers/ses.js b/api/src/application/email/drivers/ses.js index 5ae1b6d5..91b8479f 100644 --- a/api/src/application/email/drivers/ses.js +++ b/api/src/application/email/drivers/ses.js @@ -1,10 +1,14 @@ const AWS = require('aws-sdk'); -const ses = new AWS.SES({apiVersion: '2010-12-01'}); const checkWhitelist = require('../check-whitelist'); const Boom = require('boom'); +const config = require('../../../../config'); class SesDriver { + send(emailObject) { + + const ses = new AWS.SES({apiVersion: '2010-12-01'}); + return new Promise((resolve, reject) => { if (!emailObject.to) { return reject(new Error('no to address provided')); @@ -34,15 +38,15 @@ class SesDriver { Data: emailObject.subject, }, }, - Source: emailObject.from || 'no-reply@' + process.env.OIDC_EMAIL_DOMAIN, + Source: emailObject.from || 'no-reply@' + config('/email/domain'), }; ses.sendEmail(params).promise().then(result => { resolve(result); }) - .catch(err => { + .catch(err => { reject(Boom.wrap(err, err.statusCode)); - }); + }); }); } } diff --git a/api/src/application/email/email-service.js b/api/src/application/email/email-service.js index b472e5da..a20d179b 100644 --- a/api/src/application/email/email-service.js +++ b/api/src/application/email/email-service.js @@ -7,8 +7,8 @@ const sendgrid = require('./drivers/sendgrid'); const drivers = { mailgun, ses, sendgrid }; module.exports = () => { - if (drivers[config('/email')]) { - return new drivers[config('/email')](); + if (drivers[config('/email/driver')]) { + return new drivers[config('/email/driver')](); } else { return { send: () => { diff --git a/api/test/application/api/invite-post.js b/api/test/application/api/invite-post.js index 9a00ee4a..985d3094 100644 --- a/api/test/application/api/invite-post.js +++ b/api/test/application/api/invite-post.js @@ -26,7 +26,7 @@ describe(`POST /api/invite`, () => { afterEach(() => { sinon.restore(); - }) + }); it(`invites user`, async () => { const sendEmailMock = await mockSendEmail(); diff --git a/api/test/application/email/mailgun.js b/api/test/application/email/mailgun.js new file mode 100644 index 00000000..74ea2af9 --- /dev/null +++ b/api/test/application/email/mailgun.js @@ -0,0 +1,71 @@ +const Lab = require('@hapi/lab'); +const Code = require('@hapi/code'); +const { truncateAll } = require('../../helpers/db'); + +const { describe, it, before, after, beforeEach, afterEach } = exports.lab = Lab.script(); +const sinon = require('sinon'); +const { expect } = Code; +const getMailgunDriver = require('../../../src/application/email/drivers/mailgun'); + +describe('send email with mailgun', () => { + + let mailgunDriver; + + const email_template = { + to: 'test@example.com', + subject: 'Welcome', + html: '

Welcome to ODIC!

', + from: 'no-reply@example.com' + }; + + before(async () => { + mailgunDriver = getMailgunDriver(); + mailgunClient = mailgunDriver.client(); + }); + + beforeEach(async () => { + mailgunStub = sinon.stub().yields(null, "resolved"); + sinon.stub(mailgunClient, 'messages').returns({ + send: mailgunStub + }); + }); + + after(async () => { + await truncateAll(); + }); + + afterEach(async() => { + sinon.restore(); + }); + + it(`throws error if no to address provided`, async () => { + const email = { ...email_template, to: '' }; + await expect(mailgunDriver.send(email)).to.reject(Error, 'no to address provided'); + expect(mailgunClient.messages().send.calledOnce).to.equal(false); + }); + + it(`throws error if no subject is provided`, async () => { + const email = { ...email_template, subject: '' }; + await expect(mailgunDriver.send(email)).to.reject(Error, 'no subject provided'); + expect(mailgunClient.messages().send.calledOnce).to.equal(false); + }); + + it(`throws error if no text or html body provided`, async () => { + const email = { ...email_template, html: '' }; + await expect(mailgunDriver.send(email)).to.reject(Error, 'no text or html body provided'); + expect(mailgunClient.messages().send.calledOnce).to.equal(false); + }); + + it(`throws error if attachments are present`, async () => { + const email = { ...email_template, attachments: {} }; + await expect(mailgunDriver.send(email)).to.reject(Error, 'attachments must be an array'); + expect(mailgunClient.messages().send.calledOnce).to.equal(false); + }); + + it(`sends an email when given a valid email object`, async () => { + const email = { ...email_template }; + const response = await mailgunDriver.send(email); + expect(mailgunClient.messages().send.calledOnce).to.equal(true); + expect(response).to.equal("resolved"); + }); +}) \ No newline at end of file diff --git a/api/test/application/email/sendgrid.js b/api/test/application/email/sendgrid.js new file mode 100644 index 00000000..f491e752 --- /dev/null +++ b/api/test/application/email/sendgrid.js @@ -0,0 +1,69 @@ +const Lab = require('@hapi/lab'); +const Code = require('@hapi/code'); +const { truncateAll } = require('../../helpers/db'); + +const { describe, it, before, after, beforeEach, afterEach } = exports.lab = Lab.script(); +const sinon = require('sinon'); +const { expect } = Code; +const getSendGridDriver = require('../../../src/application/email/drivers/sendgrid'); +const sgMail = require('@sendgrid/mail'); + +describe('send email with sendgrid', () => { + + let sendGridDriver; + + const email_template = { + to: 'test@example.com', + subject: 'Welcome', + html: '

Welcome to ODIC!

', + from: 'no-reply@example.com' + }; + + + before(async () => { + sendGridDriver = getSendGridDriver(); + }); + + beforeEach(async () => { + sinon.stub(sgMail, "send").yields(null, "resolved"); + }); + + after(async () => { + await truncateAll(); + }); + + afterEach(async() => { + sinon.restore(); + }); + + it(`throws error if no to address provided`, async () => { + const email = { ...email_template, to: '' }; + await expect(sendGridDriver.send(email)).to.reject(Error, 'no to address provided'); + expect(sgMail.send.calledOnce).to.equal(false); + }); + + it(`throws error if no subject is provided`, async () => { + const email = { ...email_template, subject: '' }; + await expect(sendGridDriver.send(email)).to.reject(Error, 'no subject provided'); + expect(sgMail.send.calledOnce).to.equal(false); + }); + + it(`throws error if no text or html body provided`, async () => { + const email = { ...email_template, html: '' }; + await expect(sendGridDriver.send(email)).to.reject(Error, 'no text or html body provided'); + expect(sgMail.send.calledOnce).to.equal(false); + }); + + it(`throws error if attachments are not provided as an array`, async () => { + const email = { ...email_template, attachments: {} }; + await expect(sendGridDriver.send(email)).to.reject(Error, 'attachments must be an array'); + expect(sgMail.send.calledOnce).to.equal(false); + }); + + it(`sends an email when given a valid email object`, async () => { + const email = { ...email_template, attachments: [] }; + const response = await sendGridDriver.send(email); + expect(response).to.equal("resolved"); + expect(sgMail.send.calledOnce).to.equal(true); + }); +}) \ No newline at end of file diff --git a/api/test/application/email/ses.js b/api/test/application/email/ses.js new file mode 100644 index 00000000..7431e802 --- /dev/null +++ b/api/test/application/email/ses.js @@ -0,0 +1,74 @@ +const Lab = require('@hapi/lab'); +const Code = require('@hapi/code'); +const { truncateAll } = require('../../helpers/db'); + +const { describe, it, before, after, beforeEach, afterEach } = exports.lab = Lab.script(); +const sinon = require('sinon'); +const { expect } = Code; +const getSesDriver = require('../../../src/application/email/drivers/ses'); + +const AWS = require('aws-sdk-mock'); +const path = require('path'); +AWS.setSDK(path.resolve(__dirname, '../../../node_modules/aws-sdk')) + +describe('send email with ses', () => { + + let sesDriver, sendEmailStub; + + const email_template = { + to: 'test@example.com', + subject: 'Welcome', + html: '

Welcome to ODIC!

', + from: 'no-reply@example.com' + }; + + before(async () => { + sesDriver = new getSesDriver(); + }); + + beforeEach(async () => { + sendEmailStub = sinon.stub().resolves("resolved"); + }); + + after(async () => { + await truncateAll(); + }); + + afterEach(async() => { + sinon.restore(); + }); + + it(`throws error if no to address provided`, async () => { + const email = { ...email_template, to: '' }; + await expect(sesDriver.send(email)).to.reject(Error, 'no to address provided'); + expect(sendEmailStub.calledOnce).to.equal(false); + }); + + it(`throws error if no subject is provided`, async () => { + const email = { ...email_template, subject: '' }; + await expect(sesDriver.send(email)).to.reject(Error, 'no subject provided'); + expect(sendEmailStub.calledOnce).to.equal(false); + }); + + it(`throws error if no text or html body provided`, async () => { + const email = { ...email_template, html: '' }; + await expect(sesDriver.send(email)).to.reject(Error, 'no text or html body provided'); + expect(sendEmailStub.calledOnce).to.equal(false); + + }); + + it(`throws error if attachments are present`, async () => { + const email = { ...email_template, attachments: {} }; + await expect(sesDriver.send(email)).to.reject(Error, 'ses driver does not currently support attachments'); + expect(sendEmailStub.calledOnce).to.equal(false); + }); + + it(`sends an email when given a valid email object`, async () => { + AWS.mock("SES", "sendEmail", sendEmailStub); + const email = { ...email_template }; + const response = await sesDriver.send(email); + expect(sendEmailStub.calledOnce).to.equal(true); + expect(response).to.equal("resolved"); + AWS.restore(); + }); +}) \ No newline at end of file diff --git a/api/test/helpers/factory/user.js b/api/test/helpers/factory/user.js index 285558b6..5f5190ce 100644 --- a/api/test/helpers/factory/user.js +++ b/api/test/helpers/factory/user.js @@ -1,5 +1,6 @@ const bookshelf = require('../../../src/lib/bookshelf'); const User = bookshelf.model('user'); +const config = require('../../../config'); const initialize = factory => { factory.define('user', User, { @@ -9,8 +10,8 @@ const initialize = factory => { email_verified: false, phone_number_verified: false, }, - email: factory.chance('email', { domain: process.env.OIDC_EMAIL_DOMAIN || 'example.com' }), - email_lower: factory.chance('email', { domain: process.env.OIDC_EMAIL_DOMAIN || 'example.com' }), + email: factory.chance('email', { domain: config('email/domain') || 'example.com' }), + email_lower: factory.chance('email', { domain: config('email/domain') || 'example.com' }), }); }; From 77604016f2f24240cc67a9a067af6172c4764317 Mon Sep 17 00:00:00 2001 From: Bryce Egley Date: Mon, 8 Nov 2021 16:48:34 -0700 Subject: [PATCH 3/3] changed test title --- api/test/application/email/mailgun.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/test/application/email/mailgun.js b/api/test/application/email/mailgun.js index 74ea2af9..86741e1d 100644 --- a/api/test/application/email/mailgun.js +++ b/api/test/application/email/mailgun.js @@ -56,7 +56,7 @@ describe('send email with mailgun', () => { expect(mailgunClient.messages().send.calledOnce).to.equal(false); }); - it(`throws error if attachments are present`, async () => { + it(`throws error if attachments are not in an array`, async () => { const email = { ...email_template, attachments: {} }; await expect(mailgunDriver.send(email)).to.reject(Error, 'attachments must be an array'); expect(mailgunClient.messages().send.calledOnce).to.equal(false);