| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059 |
- const { setTimeout } = require('timers/promises')
- const SandboxedModule = require('sandboxed-module')
- const path = require('path')
- const sinon = require('sinon')
- const { ObjectId } = require('mongodb')
- const tk = require('timekeeper')
- const { expect } = require('chai')
- const { normalizeQuery } = require('../../../../app/src/Features/Helpers/Mongo')
- const Errors = require('../../../../app/src/Features/Errors/Errors')
- const MODULE_PATH = path.join(
- __dirname,
- '../../../../app/src/Features/User/UserUpdater'
- )
- describe('UserUpdater', function () {
- beforeEach(function () {
- tk.freeze(Date.now())
- this.user = {
- _id: ObjectId(),
- name: 'bob',
- email: 'hello@world.com',
- emails: [{ email: 'hello@world.com' }],
- }
- this.db = {
- users: {
- updateOne: sinon.stub().resolves({ matchedCount: 1, modifiedCount: 1 }),
- },
- }
- this.mongodb = {
- db: this.db,
- ObjectId(id) {
- return id
- },
- }
- this.UserGetter = {
- promises: {
- ensureUniqueEmailAddress: sinon.stub().resolves(),
- getUser: sinon.stub(),
- getUserByMainEmail: sinon.stub(),
- getUserFullEmails: sinon.stub(),
- getUserEmail: sinon.stub(),
- },
- }
- this.UserGetter.promises.getUser.withArgs(this.user._id).resolves(this.user)
- this.UserGetter.promises.getUserByMainEmail
- .withArgs(this.user.email)
- .resolves(this.user)
- this.UserGetter.promises.getUserFullEmails
- .withArgs(this.user._id)
- .resolves(this.user.emails)
- this.UserGetter.promises.getUserEmail
- .withArgs(this.user._id)
- .resolves(this.user.email)
- this.NewsletterManager = {
- promises: {
- changeEmail: sinon.stub().resolves(),
- },
- }
- this.RecurlyWrapper = {
- promises: {
- updateAccountEmailAddress: sinon.stub().resolves(),
- },
- }
- this.AnalyticsManager = {
- recordEventForUser: sinon.stub(),
- }
- this.InstitutionsAPI = {
- promises: {
- addAffiliation: sinon.stub().resolves(),
- removeAffiliation: sinon.stub().resolves(),
- getUserAffiliations: sinon.stub().resolves(),
- },
- }
- this.EmailHandler = {
- promises: {
- sendEmail: sinon.stub().resolves(),
- },
- }
- this.Features = {
- hasFeature: sinon.stub().returns(false),
- }
- this.FeaturesUpdater = {
- promises: {
- refreshFeatures: sinon.stub().resolves(),
- },
- }
- this.UserAuditLogHandler = {
- promises: {
- addEntry: sinon.stub().resolves(),
- },
- }
- this.SubscriptionLocator = {
- promises: {
- getUserIndividualSubscription: sinon.stub().resolves(),
- },
- }
- this.NotificationsBuilder = {
- promises: {
- redundantPersonalSubscription: sinon
- .stub()
- .returns({ create: () => {} }),
- },
- }
- this.UserUpdater = SandboxedModule.require(MODULE_PATH, {
- requires: {
- '../Helpers/Mongo': { normalizeQuery },
- '../../infrastructure/mongodb': this.mongodb,
- './UserGetter': this.UserGetter,
- '../Institutions/InstitutionsAPI': this.InstitutionsAPI,
- '../Email/EmailHandler': this.EmailHandler,
- '../../infrastructure/Features': this.Features,
- '../Subscription/FeaturesUpdater': this.FeaturesUpdater,
- '@overleaf/settings': (this.settings = {}),
- '../Newsletter/NewsletterManager': this.NewsletterManager,
- '../Subscription/RecurlyWrapper': this.RecurlyWrapper,
- './UserAuditLogHandler': this.UserAuditLogHandler,
- '../Analytics/AnalyticsManager': this.AnalyticsManager,
- '../../Errors/Errors': Errors,
- '../Subscription/SubscriptionLocator': this.SubscriptionLocator,
- '../Notifications/NotificationsBuilder': this.NotificationsBuilder,
- },
- })
- this.newEmail = 'bob@bob.com'
- })
- afterEach(function () {
- return tk.reset()
- })
- describe('addAffiliationForNewUser', function () {
- it('should not remove affiliationUnchecked flag if v1 returns an error', async function () {
- this.InstitutionsAPI.promises.addAffiliation.rejects()
- await expect(
- this.UserUpdater.promises.addAffiliationForNewUser(
- this.user._id,
- this.newEmail
- )
- ).to.be.rejected
- sinon.assert.notCalled(this.db.users.updateOne)
- })
- it('should remove affiliationUnchecked flag if v1 does not return an error', async function () {
- await this.UserUpdater.promises.addAffiliationForNewUser(
- this.user._id,
- this.newEmail
- )
- sinon.assert.calledOnce(this.db.users.updateOne)
- sinon.assert.calledWithMatch(
- this.db.users.updateOne,
- { _id: this.user._id, 'emails.email': this.newEmail },
- { $unset: { 'emails.$.affiliationUnchecked': 1 } }
- )
- })
- it('should not throw if removing affiliationUnchecked flag errors', async function () {
- this.db.users.updateOne.rejects(new Error('nope'))
- await this.UserUpdater.promises.addAffiliationForNewUser(
- this.user._id,
- this.newEmail
- )
- })
- })
- describe('changeEmailAddress', function () {
- beforeEach(async function () {
- this.auditLog = {
- initiatorId: 'abc123',
- ipAddress: '0:0:0:0',
- }
- // After the email changed, make sure that UserGetter.getUser() returns a
- // user with the new email.
- this.UserGetter.promises.getUser
- .withArgs(this.user._id)
- .onCall(1)
- .resolves({
- ...this.user,
- emails: [...this.user.emails, { email: this.newEmail }],
- })
- // The main email changes as a result of the email change
- this.UserGetter.promises.getUserByMainEmail
- .withArgs(this.user.email)
- .resolves(null)
- this.user.emails.push({ email: this.newEmail })
- await this.UserUpdater.promises.changeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- })
- it('adds the new email', function () {
- expect(this.db.users.updateOne).to.have.been.calledWith(
- { _id: this.user._id },
- {
- $push: {
- emails: sinon.match({ email: this.newEmail }),
- },
- }
- )
- })
- it('adds the new affiliation', function () {
- this.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith(
- this.user._id,
- this.newEmail
- )
- })
- it('removes the old email', function () {
- expect(this.db.users.updateOne).to.have.been.calledWith(
- { _id: this.user._id, email: { $ne: this.user.email } },
- { $pull: { emails: { email: this.user.email } } }
- )
- })
- it('removes the affiliation', function () {
- expect(
- this.InstitutionsAPI.promises.removeAffiliation
- ).to.have.been.calledWith(this.user._id, this.user.email)
- })
- it('refreshes features', function () {
- sinon.assert.calledWith(
- this.FeaturesUpdater.promises.refreshFeatures,
- this.user._id
- )
- })
- it('sets the default email', function () {
- expect(this.db.users.updateOne).to.have.been.calledWith(
- { _id: this.user._id, 'emails.email': this.newEmail },
- {
- $set: sinon.match({
- email: this.newEmail,
- }),
- }
- )
- })
- it('sets the new email in the newsletter', function () {
- expect(
- this.NewsletterManager.promises.changeEmail
- ).to.have.been.calledWith(this.user, this.newEmail)
- expect(
- this.RecurlyWrapper.promises.updateAccountEmailAddress
- ).to.have.been.calledWith(this.user._id, this.newEmail)
- })
- it('validates email', async function () {
- await expect(
- this.UserUpdater.promises.changeEmailAddress(
- this.user._id,
- 'foo',
- this.auditLog
- )
- ).to.be.rejected
- })
- })
- describe('addEmailAddress', function () {
- it('adds the email', async function () {
- await this.UserUpdater.promises.addEmailAddress(
- this.user._id,
- this.newEmail,
- {},
- { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
- )
- this.UserGetter.promises.ensureUniqueEmailAddress.should.have.been.called
- const reversedHostname = this.newEmail
- .split('@')[1]
- .split('')
- .reverse()
- .join('')
- this.db.users.updateOne.should.have.been.calledWith(
- { _id: this.user._id },
- {
- $push: {
- emails: {
- email: this.newEmail,
- createdAt: sinon.match.date,
- reversedHostname,
- },
- },
- }
- )
- })
- it('adds the affiliation', async function () {
- const affiliationOptions = {
- university: { id: 1 },
- role: 'Prof',
- department: 'Math',
- }
- await this.UserUpdater.promises.addEmailAddress(
- this.user._id,
- this.newEmail,
- affiliationOptions,
- { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
- )
- this.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith(
- this.user._id,
- this.newEmail,
- affiliationOptions
- )
- })
- it('handles affiliation errors', async function () {
- this.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
- await expect(
- this.UserUpdater.promises.addEmailAddress(
- this.user._id,
- this.newEmail,
- {},
- { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
- )
- ).to.be.rejected
- this.db.users.updateOne.should.not.have.been.called
- })
- it('validates the email', async function () {
- expect(
- this.UserUpdater.promises.addEmailAddress(
- this.user._id,
- 'bar',
- {},
- { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
- )
- ).to.be.rejected
- })
- it('updates the audit log', async function () {
- this.ip = '127:0:0:0'
- await this.UserUpdater.promises.addEmailAddress(
- this.user._id,
- this.newEmail,
- {},
- { initiatorId: this.user._id, ipAddress: this.ip }
- )
- this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true)
- const { args } = this.UserAuditLogHandler.promises.addEntry.lastCall
- expect(args[0]).to.equal(this.user._id)
- expect(args[1]).to.equal('add-email')
- expect(args[2]).to.equal(this.user._id)
- expect(args[3]).to.equal(this.ip)
- expect(args[4]).to.deep.equal({ newSecondaryEmail: this.newEmail })
- })
- describe('errors', function () {
- describe('via UserAuditLogHandler', function () {
- const anError = new Error('oops')
- beforeEach(function () {
- this.UserAuditLogHandler.promises.addEntry.rejects(anError)
- })
- it('should not add email and should return error', async function () {
- await expect(
- this.UserUpdater.promises.addEmailAddress(
- this.user._id,
- this.newEmail,
- {},
- { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
- )
- ).to.be.rejectedWith(anError)
- expect(this.db.users.updateOne).to.not.have.been.called
- })
- })
- })
- })
- describe('removeEmailAddress', function () {
- this.beforeEach(function () {
- this.auditLog = { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
- })
- it('removes the email', async function () {
- await this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- expect(this.db.users.updateOne).to.have.been.calledWith(
- { _id: this.user._id, email: { $ne: this.newEmail } },
- { $pull: { emails: { email: this.newEmail } } }
- )
- })
- it('removes the affiliation', async function () {
- await this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- expect(this.InstitutionsAPI.promises.removeAffiliation).to.have.been
- .calledOnce
- const { args } = this.InstitutionsAPI.promises.removeAffiliation.lastCall
- args[0].should.equal(this.user._id)
- args[1].should.equal(this.newEmail)
- })
- it('refreshes features', async function () {
- await this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- sinon.assert.calledWith(
- this.FeaturesUpdater.promises.refreshFeatures,
- this.user._id
- )
- })
- it('handles Mongo errors', async function () {
- const anError = new Error('nope')
- this.db.users.updateOne.rejects(anError)
- await expect(
- this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- ).to.be.rejected
- expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
- .called
- })
- it('handles missed update', async function () {
- this.db.users.updateOne.resolves({ matchedCount: 0 })
- await expect(
- this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- ).to.be.rejectedWith('Cannot remove email')
- expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
- .called
- })
- it('handles an affiliation error', async function () {
- const anError = new Error('nope')
- this.InstitutionsAPI.promises.removeAffiliation.rejects(anError)
- await expect(
- this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- ).to.be.rejected
- expect(this.db.users.updateOne).not.to.have.been.called
- expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
- .called
- })
- it('throws an error when removing the primary email', async function () {
- await expect(
- this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.user.email,
- this.auditLog
- )
- ).to.be.rejectedWith('cannot remove primary email')
- expect(this.db.users.updateOne).not.to.have.been.called
- expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
- .called
- })
- it('validates the email', function () {
- expect(
- this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- 'baz',
- this.auditLog
- )
- ).to.be.rejectedWith('invalid email')
- })
- it('skips email validation when skipParseEmail included', async function () {
- const skipParseEmail = true
- await this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- 'baz',
- this.auditLog,
- skipParseEmail
- )
- })
- it('throws an error when skipParseEmail included but email is not a string', async function () {
- const skipParseEmail = true
- await expect(
- this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- 1,
- this.auditLog,
- skipParseEmail
- )
- ).to.be.rejectedWith('email must be a string')
- })
- it('logs the removal to the audit log', async function () {
- await this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- expect(
- this.UserAuditLogHandler.promises.addEntry
- ).to.have.been.calledWith(
- this.user._id,
- 'remove-email',
- this.auditLog.initiatorId,
- this.auditLog.ipAddress,
- {
- removedEmail: this.newEmail,
- }
- )
- })
- it('logs the removal from script to the audit log', async function () {
- this.auditLog = {
- initiatorId: undefined,
- ipAddress: '0.0.0.0',
- extraInfo: {
- script: true,
- },
- }
- await this.UserUpdater.promises.removeEmailAddress(
- this.user._id,
- this.newEmail,
- this.auditLog
- )
- expect(
- this.UserAuditLogHandler.promises.addEntry
- ).to.have.been.calledWith(
- this.user._id,
- 'remove-email',
- this.auditLog.initiatorId,
- this.auditLog.ipAddress,
- {
- removedEmail: this.newEmail,
- script: true,
- }
- )
- })
- })
- describe('setDefaultEmailAddress', function () {
- function setUserEmails(test, emails) {
- test.user.emails = emails
- test.UserGetter.promises.getUserFullEmails
- .withArgs(test.user._id)
- .resolves(emails)
- }
- beforeEach(function () {
- this.auditLog = {
- initiatorId: this.user,
- ipAddress: '0:0:0:0',
- }
- setUserEmails(this, [
- {
- email: this.newEmail,
- confirmedAt: new Date(),
- },
- ])
- })
- it('set default', async function () {
- await this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog
- )
- expect(this.db.users.updateOne).to.have.been.calledWith(
- { _id: this.user._id, 'emails.email': this.newEmail },
- {
- $set: {
- email: this.newEmail,
- lastPrimaryEmailCheck: sinon.match.date,
- },
- }
- )
- })
- it('sets the changed email in the newsletter', async function () {
- await this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog
- )
- expect(
- this.NewsletterManager.promises.changeEmail
- ).to.have.been.calledWith(this.user, this.newEmail)
- expect(
- this.RecurlyWrapper.promises.updateAccountEmailAddress
- ).to.have.been.calledWith(this.user._id, this.newEmail)
- })
- it('handles Mongo errors', async function () {
- this.db.users.updateOne = sinon.stub().rejects(Error('nope'))
- await expect(
- this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog
- )
- ).to.be.rejected
- })
- it('handles missed updates', async function () {
- this.db.users.updateOne.resolves({ matchedCount: 0 })
- await expect(
- this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog
- )
- ).to.be.rejected
- })
- it('validates the email', async function () {
- await expect(
- this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- '.edu',
- false,
- this.auditLog
- )
- ).to.be.rejected
- })
- it('updates the audit log', async function () {
- await this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog
- )
- expect(
- this.UserAuditLogHandler.promises.addEntry
- ).to.have.been.calledWith(
- this.user._id,
- 'change-primary-email',
- this.auditLog.initiatorId,
- this.auditLog.ipAddress,
- {
- newPrimaryEmail: this.newEmail,
- oldPrimaryEmail: this.user.email,
- }
- )
- })
- it('blocks email update if audit log returns an error', async function () {
- this.UserAuditLogHandler.promises.addEntry.rejects(new Error('oops'))
- await expect(
- this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog
- )
- ).to.be.rejected
- expect(this.db.users.updateOne).to.not.have.been.called
- })
- describe('when email not confirmed', function () {
- beforeEach(function () {
- setUserEmails(this, [
- {
- email: this.newEmail,
- confirmedAt: null,
- },
- ])
- })
- it('should throw an error', async function () {
- await expect(
- this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog
- )
- ).to.be.rejectedWith(Errors.UnconfirmedEmailError)
- expect(this.db.users.updateOne).to.not.have.been.called
- expect(this.NewsletterManager.promises.changeEmail).to.not.have.been
- .called
- })
- })
- describe('when email does not belong to user', function () {
- beforeEach(function () {
- setUserEmails(this, [])
- this.UserUpdater.promises.updateUser = sinon.stub()
- })
- it('should callback with error', function () {
- this.UserUpdater.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog,
- error => {
- expect(error).to.exist
- expect(error.name).to.equal('Error')
- this.UserUpdater.promises.updateUser.callCount.should.equal(0)
- this.NewsletterManager.promises.changeEmail.callCount.should.equal(
- 0
- )
- }
- )
- })
- })
- describe('security alert', function () {
- it('should be sent to old and new email when sendSecurityAlert=true', async function () {
- await this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog,
- true
- )
- // Emails are sent asynchronously. Wait a bit.
- await setTimeout(100)
- this.EmailHandler.promises.sendEmail.callCount.should.equal(2)
- for (const recipient of [this.user.email, this.newEmail]) {
- expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
- 'securityAlert',
- sinon.match({ to: recipient })
- )
- }
- })
- it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails', async function () {
- setUserEmails(this, [
- {
- email: '1@a1.uni',
- confirmedAt: new Date(2020, 0, 1),
- reConfirmedAt: new Date(2021, 2, 11),
- lastConfirmedAt: new Date(2021, 2, 11),
- default: false,
- affiliation: {
- institution: {
- id: 123,
- name: 'A1 University',
- },
- cachedConfirmedAt: '2020-01-01T18:25:01.639Z',
- cachedReconfirmedAt: '2021-03-11T18:25:01.639Z',
- },
- },
- {
- email: '2@a1.uni',
- confirmedAt: new Date(2019, 0, 1),
- reConfirmedAt: new Date(2022, 2, 11),
- lastConfirmedAt: new Date(2022, 2, 11),
- default: false,
- affiliation: {
- institution: {
- id: 123,
- name: 'A1 University',
- },
- cachedConfirmedAt: '2019-01-01T18:25:01.639Z',
- cachedReconfirmedAt: '2022-03-11T18:25:01.639Z',
- },
- },
- {
- email: '2020@foo.bar',
- confirmedAt: new Date(2020, 6, 1),
- lastConfirmedAt: new Date(2020, 6, 1),
- },
- {
- email: '2021@foo.bar',
- confirmedAt: new Date(2021, 6, 1),
- lastConfirmedAt: new Date(2021, 6, 1),
- },
- {
- email: this.user.email,
- confirmedAt: new Date(2021, 6, 1),
- lastConfirmedAt: new Date(2021, 6, 1),
- },
- {
- email: this.newEmail,
- confirmedAt: new Date(2021, 6, 1),
- lastConfirmedAt: new Date(2021, 6, 1),
- },
- ])
- await this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog,
- true
- )
- // Emails are sent asynchronously. Wait a bit.
- await setTimeout(100)
- this.EmailHandler.promises.sendEmail.callCount.should.equal(4)
- for (const recipient of [
- this.user.email,
- this.newEmail,
- '2@a1.uni',
- '2021@foo.bar',
- ]) {
- expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
- 'securityAlert',
- sinon.match({ to: recipient })
- )
- }
- })
- it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails (multiple institutions and unaffiliated email domains)', async function () {
- setUserEmails(this, [
- {
- email: '1@a1.uni',
- confirmedAt: new Date(2020, 0, 1),
- reConfirmedAt: new Date(2021, 2, 11),
- lastConfirmedAt: new Date(2021, 2, 11),
- default: false,
- affiliation: {
- institution: {
- id: 123,
- name: 'A1 University',
- },
- cachedConfirmedAt: '2020-01-01T18:25:01.639Z',
- cachedReconfirmedAt: '2021-03-11T18:25:01.639Z',
- },
- },
- {
- email: '1@b2.uni',
- confirmedAt: new Date(2019, 0, 1),
- reConfirmedAt: new Date(2022, 2, 11),
- lastConfirmedAt: new Date(2022, 2, 11),
- default: false,
- affiliation: {
- institution: {
- id: 234,
- name: 'B2 University',
- },
- cachedConfirmedAt: '2019-01-01T18:25:01.639Z',
- cachedReconfirmedAt: '2022-03-11T18:25:01.639Z',
- },
- },
- {
- email: '2020@foo.bar',
- confirmedAt: new Date(2020, 6, 1),
- lastConfirmedAt: new Date(2020, 6, 1),
- },
- {
- email: '2021@bar.foo',
- confirmedAt: new Date(2021, 6, 1),
- lastConfirmedAt: new Date(2021, 6, 1),
- },
- {
- email: this.user.email,
- confirmedAt: new Date(2021, 6, 1),
- lastConfirmedAt: new Date(2021, 6, 1),
- },
- {
- email: this.newEmail,
- confirmedAt: new Date(2021, 6, 1),
- lastConfirmedAt: new Date(2021, 6, 1),
- },
- ])
- await this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog,
- true
- )
- // Emails are sent asynchronously. Wait a bit.
- await setTimeout(100)
- this.EmailHandler.promises.sendEmail.callCount.should.equal(6)
- for (const recipient of [
- this.user.email,
- this.newEmail,
- '1@a1.uni',
- '1@b2.uni',
- '2020@foo.bar',
- '2021@bar.foo',
- ]) {
- expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
- 'securityAlert',
- sinon.match({ to: recipient })
- )
- }
- })
- describe('errors', function () {
- const anError = new Error('oops')
- describe('EmailHandler', function () {
- beforeEach(function () {
- this.EmailHandler.promises.sendEmail.rejects(anError)
- })
- it('should log but not pass back the error', async function () {
- await this.UserUpdater.promises.setDefaultEmailAddress(
- this.user._id,
- this.newEmail,
- false,
- this.auditLog,
- true
- )
- const loggerCall = this.logger.error.firstCall
- expect(loggerCall.args[0]).to.deep.equal({
- error: anError,
- userId: this.user._id,
- })
- expect(loggerCall.args[1]).to.contain(
- 'could not send security alert email when primary email changed'
- )
- })
- })
- })
- })
- })
- describe('confirmEmail', function () {
- it('should update the email record', async function () {
- await this.UserUpdater.promises.confirmEmail(
- this.user._id,
- this.user.email
- )
- expect(this.db.users.updateOne).to.have.been.calledWith(
- {
- _id: this.user._id,
- 'emails.email': this.user.email,
- },
- {
- $set: {
- 'emails.$.reconfirmedAt': new Date(),
- },
- $min: {
- 'emails.$.confirmedAt': new Date(),
- },
- }
- )
- })
- it('adds affiliation', async function () {
- await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
- this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true)
- sinon.assert.calledWith(
- this.InstitutionsAPI.promises.addAffiliation,
- this.user._id,
- this.newEmail,
- { confirmedAt: new Date() }
- )
- })
- it('handles errors', async function () {
- this.db.users.updateOne.rejects(new Error('nope'))
- await expect(
- this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
- ).to.be.rejected
- })
- it('handle missed update', async function () {
- this.db.users.updateOne.resolves({ matchedCount: 0 })
- await expect(
- this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
- ).to.be.rejected
- })
- it('validates email', async function () {
- expect(this.UserUpdater.promises.confirmEmail(this.user._id, '@')).to.be
- .rejected
- })
- it('handles affiliation errors', async function () {
- this.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
- await expect(
- this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
- ).to.be.rejected
- expect(this.db.users.updateOne).to.not.have.been.called
- })
- it('refreshes features', async function () {
- await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
- sinon.assert.calledWith(
- this.FeaturesUpdater.promises.refreshFeatures,
- this.user._id
- )
- })
- it('should not call redundantPersonalSubscription when user is not on a commons license', async function () {
- this.InstitutionsAPI.promises.getUserAffiliations.resolves([])
- this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves({
- planCode: 'personal',
- groupPlan: false,
- })
- await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
- sinon.assert.notCalled(
- this.NotificationsBuilder.promises.redundantPersonalSubscription
- )
- })
- describe('with institution licence and subscription', function () {
- beforeEach(async function () {
- this.affiliation = {
- email: this.newEmail,
- licence: 'pro_plus',
- institution: {
- id: 123,
- name: 'Institution',
- },
- }
- this.InstitutionsAPI.promises.getUserAffiliations.resolves([
- this.affiliation,
- { email: 'other@email.edu' },
- ])
- this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves(
- {
- planCode: 'personal',
- groupPlan: false,
- }
- )
- })
- it('creates redundant subscription notification', async function () {
- await this.UserUpdater.promises.confirmEmail(
- this.user._id,
- this.newEmail
- )
- sinon.assert.calledWith(
- this.InstitutionsAPI.promises.getUserAffiliations,
- this.user._id
- )
- sinon.assert.calledWith(
- this.SubscriptionLocator.promises.getUserIndividualSubscription,
- this.user._id
- )
- sinon.assert.calledWith(
- this.NotificationsBuilder.promises.redundantPersonalSubscription,
- {
- institutionId: 123,
- institutionName: 'Institution',
- },
- { _id: this.user._id }
- )
- })
- })
- })
- })
|