Jelajahi Sumber

Merge pull request #17947 from overleaf/dp-secondary-email-confirmation-code

Add endpoints for secondary email confirmation by code

GitOrigin-RevId: c2829672fd9aeca457f76958d4922b9c95086f26
David 2 tahun lalu
induk
melakukan
c2448ff3d2

+ 6 - 4
services/web/app/src/Features/Email/EmailBuilder.js

@@ -257,10 +257,12 @@ templates.confirmCode = NoCTAEmailTemplate({
     return 'Confirm your email address'
   },
   message(opts, isPlainText) {
-    const msg = [
-      `Welcome to Overleaf! We're so glad you joined us.`,
-      'Use this 6-digit confirmation code to finish your setup.',
-    ]
+    const msg = opts.isSecondary
+      ? ['Use this 6-digit code to confirm your email address.']
+      : [
+          `Welcome to Overleaf! We're so glad you joined us.`,
+          'Use this 6-digit confirmation code to finish your setup.',
+        ]
 
     if (isPlainText && opts.confirmCode) {
       msg.push(opts.confirmCode)

+ 2 - 1
services/web/app/src/Features/User/UserEmailsConfirmationHandler.js

@@ -43,7 +43,7 @@ function sendConfirmationEmail(userId, email, emailTemplate, callback) {
   )
 }
 
-async function sendConfirmationCode(email) {
+async function sendConfirmationCode(email, isSecondary) {
   if (!EmailHelper.parseEmail(email)) {
     throw new Error('invalid email')
   }
@@ -55,6 +55,7 @@ async function sendConfirmationCode(email) {
   await EmailHandler.promises.sendEmail('confirmCode', {
     to: email,
     confirmCode,
+    isSecondary,
     category: ['ConfirmEmail'],
   })
 

+ 294 - 0
services/web/app/src/Features/User/UserEmailsController.js

@@ -1,3 +1,4 @@
+const AuthenticationController = require('../Authentication/AuthenticationController')
 const Settings = require('@overleaf/settings')
 const logger = require('@overleaf/logger')
 const SessionManager = require('../Authentication/SessionManager')
@@ -15,9 +16,34 @@ const AsyncFormHelper = require('../Helpers/AsyncFormHelper')
 const AnalyticsManager = require('../Analytics/AnalyticsManager')
 const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
 const UserAuditLogHandler = require('./UserAuditLogHandler')
+const { RateLimiter } = require('../../infrastructure/RateLimiter')
+const tsscmp = require('tsscmp')
 
 const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
 
+const sendSecondaryConfirmCodeRateLimiter = new RateLimiter(
+  'send-secondary-confirmation-code',
+  {
+    points: 1,
+    duration: 60,
+  }
+)
+const checkSecondaryConfirmCodeRateLimiter = new RateLimiter(
+  'check-secondary-confirmation-code-per-email',
+  {
+    points: 10,
+    duration: 60,
+  }
+)
+
+const resendSecondaryConfirmCodeRateLimiter = new RateLimiter(
+  'resend-secondary-confirmation-code',
+  {
+    points: 1,
+    duration: 60,
+  }
+)
+
 async function _sendSecurityAlertEmail(user, email) {
   const emailOptions = {
     to: user.email,
@@ -30,6 +56,10 @@ async function _sendSecurityAlertEmail(user, email) {
   await EmailHandler.promises.sendEmail('securityAlert', emailOptions)
 }
 
+/**
+ * This method is for adding a secondary email to be confirmed via an emailed link.
+ * For code confirmation, see the `addWithConfirmationCode` method in this file.
+ */
 async function add(req, res, next) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const email = EmailHelper.parseEmail(req.body.email)
@@ -127,6 +157,263 @@ function sendReconfirmation(req, res, next) {
   })
 }
 
+/**
+ * This method is for adding a secondary email to be confirmed via a code.
+ * For email link confirmation see the `add` method in this file.
+ */
+async function addWithConfirmationCode(req, res) {
+  delete req.session.pendingSecondaryEmail
+
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const email = EmailHelper.parseEmail(req.body.email)
+  if (!email) {
+    return res.sendStatus(422)
+  }
+
+  const user = await UserGetter.promises.getUser(userId, {
+    email: 1,
+    'emails.email': 1,
+  })
+
+  if (user.emails.length >= Settings.emailAddressLimit) {
+    return res.status(422).json({ message: 'secondary email limit exceeded' })
+  }
+
+  try {
+    await UserGetter.promises.ensureUniqueEmailAddress(email)
+
+    await sendSecondaryConfirmCodeRateLimiter.consume(email, 1, {
+      method: 'email',
+    })
+
+    await UserAuditLogHandler.promises.addEntry(
+      userId,
+      'request-add-email-code',
+      userId,
+      req.ip,
+      {
+        newSecondaryEmail: email,
+      }
+    )
+
+    const { confirmCode, confirmCodeExpiresTimestamp } =
+      await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
+        email,
+        true
+      )
+
+    req.session.pendingSecondaryEmail = {
+      email,
+      confirmCode,
+      confirmCodeExpiresTimestamp,
+    }
+
+    return res.json({
+      redir: '/user/emails/confirm-secondary',
+    })
+  } catch (err) {
+    if (err.name === 'EmailExistsError') {
+      return res.status(409).json({
+        message: {
+          type: 'error',
+          text: req.i18n.translate('email_already_registered'),
+        },
+      })
+    }
+
+    if (err?.remainingPoints === 0) {
+      return res.status(429).json({})
+    }
+
+    logger.err({ err }, 'failed to send confirmation code')
+
+    delete req.session.pendingSecondaryEmail
+
+    return res.status(500).json({
+      message: {
+        key: 'error_performing_request',
+      },
+    })
+  }
+}
+
+async function checkSecondaryEmailConfirmationCode(req, res) {
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const code = req.body.code
+  const user = await UserGetter.promises.getUser(userId, {
+    email: 1,
+    'emails.email': 1,
+  })
+
+  if (!req.session.pendingSecondaryEmail) {
+    logger.err(
+      {},
+      'error checking confirmation code. missing pendingSecondaryEmail'
+    )
+
+    return res.status(500).json({
+      message: {
+        key: 'error_performing_request',
+      },
+    })
+  }
+
+  try {
+    await checkSecondaryConfirmCodeRateLimiter.consume(
+      req.session.pendingSecondaryEmail.email,
+      1,
+      { method: 'email' }
+    )
+  } catch (err) {
+    if (err?.remainingPoints === 0) {
+      return res.sendStatus(429)
+    } else {
+      return res.status(500).json({
+        message: {
+          key: 'error_performing_request',
+        },
+      })
+    }
+  }
+
+  if (
+    req.session.pendingSecondaryEmail.confirmCodeExpiresTimestamp < Date.now()
+  ) {
+    return res.status(403).json({
+      message: { key: 'expired_confirmation_code' },
+    })
+  }
+
+  if (!tsscmp(req.session.pendingSecondaryEmail.confirmCode, code)) {
+    return res.status(403).json({
+      message: { key: 'invalid_confirmation_code' },
+    })
+  }
+
+  try {
+    await UserAuditLogHandler.promises.addEntry(
+      userId,
+      'add-email-via-code',
+      userId,
+      req.ip,
+      {
+        newSecondaryEmail: req.session.pendingSecondaryEmail.email,
+      }
+    )
+
+    await UserUpdater.promises.addEmailAddress(
+      userId,
+      req.session.pendingSecondaryEmail.email,
+      {},
+      {
+        initiatorId: user._id,
+        ipAddress: req.ip,
+      }
+    )
+
+    await UserUpdater.promises.confirmEmail(
+      userId,
+      req.session.pendingSecondaryEmail.email,
+      {}
+    )
+
+    delete req.session.pendingSecondaryEmail
+
+    AnalyticsManager.recordEventForUser(user._id, 'email-verified', {
+      provider: 'email',
+      verification_type: 'token',
+      isPrimary: false,
+    })
+
+    const redirectUrl =
+      AuthenticationController.getRedirectFromSession(req) || '/project'
+
+    return res.json({
+      redir: redirectUrl,
+    })
+  } catch (error) {
+    if (error.name === 'EmailExistsError') {
+      return res.status(409).json({
+        message: {
+          type: 'error',
+          text: req.i18n.translate('email_already_registered'),
+        },
+      })
+    }
+
+    logger.err({ error }, 'failed to check confirmation code')
+
+    return res.status(500).json({
+      message: {
+        key: 'error_performing_request',
+      },
+    })
+  }
+}
+
+async function resendSecondaryEmailConfirmationCode(req, res) {
+  if (!req.session.pendingSecondaryEmail) {
+    logger.err(
+      {},
+      'error resending confirmation code. missing pendingSecondaryEmail'
+    )
+
+    return res.status(500).json({
+      message: {
+        key: 'error_performing_request',
+      },
+    })
+  }
+
+  const email = req.session.pendingSecondaryEmail.email
+
+  try {
+    await resendSecondaryConfirmCodeRateLimiter.consume(email, 1, {
+      method: 'email',
+    })
+  } catch (err) {
+    if (err?.remainingPoints === 0) {
+      return res.status(429).json({})
+    } else {
+      throw err
+    }
+  }
+
+  try {
+    const userId = SessionManager.getLoggedInUserId(req.session)
+
+    await UserAuditLogHandler.promises.addEntry(
+      userId,
+      'resend-add-email-code',
+      userId,
+      req.ip,
+      {
+        newSecondaryEmail: email,
+      }
+    )
+
+    const { confirmCode, confirmCodeExpiresTimestamp } =
+      await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
+        email,
+        true
+      )
+
+    req.session.pendingSecondaryEmail.confirmCode = confirmCode
+    req.session.pendingSecondaryEmail.confirmCodeExpiresTimestamp =
+      confirmCodeExpiresTimestamp
+
+    return res.status(200).json({
+      message: { key: 'we_sent_new_code' },
+    })
+  } catch (err) {
+    logger.err({ err, email }, 'failed to send confirmation code')
+
+    return res.status(500).json({
+      key: 'error_performing_request',
+    })
+  }
+}
+
 async function primaryEmailCheckPage(req, res) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const user = await UserGetter.promises.getUser(userId, {
@@ -175,6 +462,13 @@ const UserEmailsController = {
   },
 
   add: expressify(add),
+  addWithConfirmationCode: expressify(addWithConfirmationCode),
+  checkSecondaryEmailConfirmationCode: expressify(
+    checkSecondaryEmailConfirmationCode
+  ),
+  resendSecondaryEmailConfirmationCode: expressify(
+    resendSecondaryEmailConfirmationCode
+  ),
 
   remove(req, res, next) {
     const userId = SessionManager.getLoggedInUserId(req.session)

+ 24 - 0
services/web/app/src/router.js

@@ -366,6 +366,30 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
       RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail),
       UserEmailsController.endorse
     )
+
+    webRouter.post(
+      '/user/emails/secondary',
+      AuthenticationController.requireLogin(),
+      PermissionsController.requirePermission('add-secondary-email'),
+      RateLimiterMiddleware.rateLimit(rateLimiters.addEmail),
+      UserEmailsController.addWithConfirmationCode
+    )
+
+    webRouter.post(
+      '/user/emails/confirm-secondary',
+      AuthenticationController.requireLogin(),
+      PermissionsController.requirePermission('add-secondary-email'),
+      RateLimiterMiddleware.rateLimit(rateLimiters.checkEmailConfirmationCode),
+      UserEmailsController.checkSecondaryEmailConfirmationCode
+    )
+
+    webRouter.post(
+      '/user/emails/resend-secondary-confirmation',
+      AuthenticationController.requireLogin(),
+      PermissionsController.requirePermission('add-secondary-email'),
+      RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmationCode),
+      UserEmailsController.resendSecondaryEmailConfirmationCode
+    )
   }
 
   webRouter.get(

+ 192 - 0
services/web/test/acceptance/src/AddSecondaryEmailTests.js

@@ -0,0 +1,192 @@
+const { expect } = require('chai')
+const User = require('./helpers/User').promises
+const logger = require('@overleaf/logger')
+const sinon = require('sinon')
+const { db } = require('../../../app/src/infrastructure/mongodb')
+const Features = require('../../../app/src/infrastructure/Features')
+
+describe('Add secondary email address confirmation code email', function () {
+  let spy
+  let user, user2, res, confirmCode
+
+  const extractConfirmCode = () => {
+    const emailDebugLog = spy.args.find(
+      ([, msg]) => msg === 'Would send email if enabled.'
+    )
+    const emailConfirmSubject = emailDebugLog[0].options.subject
+    return emailConfirmSubject.match(/\((\d{6})\)/)[1]
+  }
+
+  beforeEach(async function () {
+    if (!Features.hasFeature('affiliations')) {
+      this.skip()
+    }
+
+    spy = sinon.spy(logger, 'debug')
+    user = new User()
+    await user.register()
+    await user.login()
+    spy.resetHistory()
+
+    res = await user.doRequest('POST', {
+      json: {
+        email: 'secondary@overleaf.com',
+      },
+      uri: `/user/emails/secondary`,
+    })
+
+    confirmCode = extractConfirmCode()
+  })
+
+  afterEach(function () {
+    if (!Features.hasFeature('affiliations')) {
+      this.skip()
+    }
+
+    spy.restore()
+  })
+
+  it('should redirect to confirm secondary email page', function () {
+    expect(res.response.statusCode).to.equal(200)
+    expect(res.body.redir).to.equal('/user/emails/confirm-secondary')
+    expect(confirmCode.length).to.equal(6)
+  })
+
+  describe('with a valid confirmation code', function () {
+    beforeEach(async function () {
+      this.result = await user.doRequest('POST', {
+        json: {
+          code: confirmCode,
+        },
+        uri: '/user/emails/confirm-secondary',
+      })
+    })
+
+    it('should redirect to /project', async function () {
+      expect(this.result.response.statusCode).to.equal(200)
+      expect(this.result.body.redir).to.equal('/project')
+    })
+
+    it('the new email should be saved in mongo', async function () {
+      const userInDb = await db.users.findOne(
+        { email: user.email },
+        { projection: { emails: 1 } }
+      )
+      expect(userInDb).to.exist
+      const newSecondaryEmail = userInDb.emails.find(
+        email => email.email === 'secondary@overleaf.com'
+      )
+      expect(newSecondaryEmail).to.exist
+      expect(newSecondaryEmail.confirmedAt).to.exist
+      expect(newSecondaryEmail.reconfirmedAt).to.exist
+      expect(newSecondaryEmail.reconfirmedAt).to.deep.equal(
+        newSecondaryEmail.confirmedAt
+      )
+    })
+  })
+
+  describe('with an invalid confirmation code', function () {
+    beforeEach(async function () {
+      this.result = await user.doRequest('POST', {
+        json: {
+          code: '123',
+        },
+        uri: '/user/emails/confirm-secondary',
+      })
+    })
+
+    it('should respond with invalid confirmation code error', async function () {
+      expect(this.result.response.statusCode).to.equal(403)
+      expect(this.result.body.message.key).to.equal('invalid_confirmation_code')
+    })
+  })
+
+  describe('with a duplicate email', async function () {
+    beforeEach(async function () {
+      await user.doRequest('POST', {
+        json: {
+          code: confirmCode,
+        },
+        uri: '/user/emails/confirm-secondary',
+      })
+
+      user2 = new User()
+      await user2.register()
+      await user2.login()
+    })
+
+    it('should respond with a email already registered error', async function () {
+      res = await user2.doRequest('POST', {
+        json: {
+          email: 'secondary@overleaf.com',
+        },
+        uri: `/user/emails/secondary`,
+      })
+
+      expect(res.response.statusCode).to.equal(409)
+      expect(res.body.message.text).to.equal('This email is already registered')
+    })
+  })
+
+  it('should hit rate limit on code check', async function () {
+    let confirmEmailReq
+    for (let i = 0; i < 20; i++) {
+      confirmEmailReq = await user.doRequest('POST', {
+        json: {
+          code: '123',
+        },
+        uri: '/user/emails/confirm-secondary',
+      })
+    }
+
+    expect(confirmEmailReq.response.statusCode).to.equal(429)
+  })
+
+  it('should resend confirm code', async function () {
+    const oldConfirmCode = extractConfirmCode()
+    spy.resetHistory()
+
+    const resendCodeRes = await user.doRequest('POST', {
+      uri: '/user/emails/resend-secondary-confirmation',
+    })
+
+    const newConfirmCode = extractConfirmCode()
+
+    expect(resendCodeRes.response.statusCode).to.equal(200)
+    expect(JSON.parse(resendCodeRes.body).message.key).to.equal(
+      'we_sent_new_code'
+    )
+
+    const oldConfirmRes = await user.doRequest('POST', {
+      json: {
+        code: oldConfirmCode,
+      },
+      uri: '/user/emails/confirm-secondary',
+    })
+
+    expect(oldConfirmRes.response.statusCode).to.equal(403)
+    expect(oldConfirmRes.body.message.key).to.equal('invalid_confirmation_code')
+
+    const newCodeRes = await user.doRequest('POST', {
+      json: {
+        code: newConfirmCode,
+      },
+      uri: '/user/emails/confirm-secondary',
+    })
+
+    expect(newCodeRes.response.statusCode).to.equal(200)
+    expect(newCodeRes.body.redir).to.equal('/project')
+  })
+
+  it('should hit rate limit on code resend', async function () {
+    let resendCodeReq
+    for (let i = 0; i < 5; i++) {
+      resendCodeReq = await user.doRequest('POST', {
+        json: true,
+        uri: '/user/emails/resend-secondary-confirmation',
+      })
+    }
+
+    expect(resendCodeReq.response.statusCode).to.equal(429)
+  })
+})

+ 209 - 1
services/web/test/unit/src/User/UserEmailsControllerTests.js

@@ -17,7 +17,7 @@ describe('UserEmailsController', function () {
     this.user = {
       _id: 'mock-user-id',
       email: 'example@overleaf.com',
-      emails: {},
+      emails: [],
     }
 
     this.UserGetter = {
@@ -25,6 +25,7 @@ describe('UserEmailsController', function () {
       getUserFullEmails: sinon.stub(),
       getUserByAnyEmail: sinon.stub(),
       promises: {
+        ensureUniqueEmailAddress: sinon.stub().resolves(),
         getUser: sinon.stub().resolves(this.user),
       },
     }
@@ -46,6 +47,7 @@ describe('UserEmailsController', function () {
       updateV1AndSetDefaultEmailAddress: sinon.stub(),
       promises: {
         addEmailAddress: sinon.stub().resolves(),
+        confirmEmail: sinon.stub().resolves(),
       },
     }
     this.EmailHelper = { parseEmail: sinon.stub() }
@@ -59,9 +61,23 @@ describe('UserEmailsController', function () {
     }
     this.UserAuditLogHandler = {
       addEntry: sinon.stub().yields(),
+      promises: {
+        addEntry: sinon.stub().resolves(),
+      },
+    }
+    this.rateLimiter = {
+      consume: sinon.stub().resolves(),
+    }
+    this.RateLimiter = {
+      RateLimiter: sinon.stub().returns(this.rateLimiter),
+    }
+    this.AuthenticationController = {
+      getRedirectFromSession: sinon.stub().returns(null),
     }
     this.UserEmailsController = SandboxedModule.require(modulePath, {
       requires: {
+        '../Authentication/AuthenticationController':
+          this.AuthenticationController,
         '../Authentication/SessionManager': this.SessionManager,
         '../../infrastructure/Features': this.Features,
         './UserSessionsManager': this.UserSessionsManager,
@@ -84,6 +100,7 @@ describe('UserEmailsController', function () {
         '../Errors/HttpErrorHandler': this.HttpErrorHandler,
         '../Analytics/AnalyticsManager': this.AnalyticsManager,
         './UserAuditLogHandler': this.UserAuditLogHandler,
+        '../../infrastructure/RateLimiter': this.RateLimiter,
       },
     })
   })
@@ -267,6 +284,196 @@ describe('UserEmailsController', function () {
     })
   })
 
+  describe('addWithConfirmationCode', function () {
+    beforeEach(function () {
+      this.newEmail = 'new_email@baz.com'
+      this.req.body = {
+        email: this.newEmail,
+      }
+      this.EmailHelper.parseEmail.returns(this.newEmail)
+      this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
+        .stub()
+        .resolves({
+          confirmCode: '123456',
+          confirmCodeExpiresTimestamp: new Date(),
+        })
+    })
+
+    it('sends an email confirmation', function (done) {
+      this.UserEmailsController.addWithConfirmationCode(this.req, {
+        json: ({ redir }) => {
+          redir.should.equal('/user/emails/confirm-secondary')
+          assertCalledWith(
+            this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
+            this.newEmail,
+            true
+          )
+          done()
+        },
+      })
+    })
+
+    it('handles email parse error', function (done) {
+      this.EmailHelper.parseEmail.returns(null)
+      this.UserEmailsController.addWithConfirmationCode(this.req, {
+        sendStatus: code => {
+          code.should.equal(422)
+          done()
+        },
+      })
+    })
+
+    it('handles when the email already exists', function (done) {
+      this.UserGetter.promises.ensureUniqueEmailAddress.rejects(
+        new Errors.EmailExistsError()
+      )
+
+      this.UserEmailsController.addWithConfirmationCode(this.req, {
+        status: code => {
+          code.should.equal(409)
+          return { json: () => done() }
+        },
+      })
+    })
+
+    it('should fail to add new emails when the limit has been reached', function (done) {
+      this.user.emails = []
+      for (let i = 0; i < 10; i++) {
+        this.user.emails.push({ email: `example${i}@overleaf.com` })
+      }
+      this.UserEmailsController.addWithConfirmationCode(this.req, {
+        status: code => {
+          expect(code).to.equal(422)
+          return {
+            json: error => {
+              expect(error.message).to.equal('secondary email limit exceeded')
+              done()
+            },
+          }
+        },
+      })
+    })
+  })
+
+  describe('checkSecondaryEmailConfirmationCode', function () {
+    beforeEach(function () {
+      this.newEmail = 'new_email@baz.com'
+      this.req.session.pendingSecondaryEmail = {
+        confirmCode: '123456',
+        email: this.newEmail,
+        confirmCodeExpiresTimestamp: new Date(Math.max),
+      }
+    })
+
+    describe('with a valid confirmation code', function () {
+      beforeEach(function () {
+        this.req.body = {
+          code: '123456',
+        }
+      })
+
+      it('adds the email', function (done) {
+        this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+          this.req,
+          {
+            json: () => {
+              assertCalledWith(
+                this.UserUpdater.promises.addEmailAddress,
+                this.user._id,
+                this.newEmail
+              )
+              assertCalledWith(
+                this.UserUpdater.promises.confirmEmail,
+                this.user._id,
+                this.newEmail
+              )
+              done()
+            },
+          }
+        )
+      })
+
+      it('redirects to /project', function (done) {
+        this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+          this.req,
+          {
+            json: ({ redir }) => {
+              redir.should.equal('/project')
+              done()
+            },
+          }
+        )
+      })
+    })
+
+    describe('with an invalid confirmation code', function () {
+      beforeEach(function () {
+        this.req.body = {
+          code: '999999',
+        }
+      })
+
+      it('does not add the email', function (done) {
+        this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+          this.req,
+          {
+            status: () => {
+              assertNotCalled(this.UserUpdater.promises.addEmailAddress)
+              assertNotCalled(this.UserUpdater.promises.confirmEmail)
+              done()
+              return { json: this.next }
+            },
+          }
+        )
+      })
+
+      it('responds with a 403', function (done) {
+        this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+          this.req,
+          {
+            status: code => {
+              code.should.equal(403)
+              done()
+              return { json: this.next }
+            },
+          }
+        )
+      })
+    })
+  })
+
+  describe('resendSecondaryEmailConfirmationCode', function () {
+    beforeEach(function () {
+      this.newEmail = 'new_email@baz.com'
+      this.req.session.pendingSecondaryEmail = {
+        confirmCode: '123456',
+        email: this.newEmail,
+        confirmCodeExpiresTimestamp: new Date(Math.max),
+      }
+      this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
+        .stub()
+        .resolves({
+          confirmCode: '123456',
+          confirmCodeExpiresTimestamp: new Date(),
+        })
+    })
+
+    it('should send the email', function (done) {
+      this.UserEmailsController.resendSecondaryEmailConfirmationCode(this.req, {
+        status: code => {
+          code.should.equal(200)
+          assertCalledWith(
+            this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
+            this.newEmail,
+            true
+          )
+          done()
+          return { json: this.next }
+        },
+      })
+    })
+  })
+
   describe('remove', function () {
     beforeEach(function () {
       this.email = 'email_to_remove@bar.com'
@@ -541,6 +748,7 @@ describe('UserEmailsController', function () {
       expect(this.res.sendStatus.lastCall.args[0]).to.equal(422)
       done()
     })
+
     describe('email on another user account', function () {
       beforeEach(function () {
         this.UserGetter.getUserByAnyEmail.yields(undefined, {