const SandboxedModule = require('sandboxed-module') const path = require('path') const sinon = require('sinon') const modulePath = path.join( __dirname, '../../../../app/src/Features/User/UserUpdater' ) const tk = require('timekeeper') const { expect } = require('chai') const { normalizeQuery } = require('../../../../app/src/Features/Helpers/Mongo') describe('UserUpdater', function () { beforeEach(function () { tk.freeze(Date.now()) this.mongodb = { db: {}, ObjectId(id) { return id }, } this.UserGetter = { getUserEmail: sinon.stub(), getUserByAnyEmail: sinon.stub(), promises: { ensureUniqueEmailAddress: sinon.stub(), getUser: sinon.stub(), }, } this.addAffiliation = sinon.stub().yields() this.removeAffiliation = sinon.stub().callsArgWith(2, null) this.refreshFeatures = sinon.stub().yields() this.NewsletterManager = { promises: { changeEmail: sinon.stub(), }, } this.RecurlyWrapper = { promises: { updateAccountEmailAddress: sinon.stub(), }, } this.UserUpdater = SandboxedModule.require(modulePath, { requires: { '../Helpers/Mongo': { normalizeQuery }, '../../infrastructure/mongodb': this.mongodb, '@overleaf/metrics': { timeAsyncMethod: sinon.stub(), }, './UserGetter': this.UserGetter, '../Institutions/InstitutionsAPI': (this.InstitutionsAPI = { addAffiliation: this.addAffiliation, removeAffiliation: this.removeAffiliation, promises: { addAffiliation: sinon.stub(), }, }), '../Email/EmailHandler': (this.EmailHandler = { promises: { sendEmail: sinon.stub(), }, }), '../../infrastructure/Features': (this.Features = { hasFeature: sinon.stub().returns(false), }), '../Subscription/FeaturesUpdater': (this.FeaturesUpdater = { refreshFeatures: this.refreshFeatures, promises: { refreshFeatures: sinon.stub().resolves(), }, }), '@overleaf/settings': (this.settings = {}), request: (this.request = {}), '../Newsletter/NewsletterManager': this.NewsletterManager, '../Subscription/RecurlyWrapper': this.RecurlyWrapper, './UserAuditLogHandler': (this.UserAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, }), }, }) this.stubbedUserEmail = 'hello@world.com' this.stubbedUser = { _id: '3131231', name: 'bob', email: this.stubbedUserEmail, emails: [ { email: this.stubbedUserEmail, }, ], } this.newEmail = 'bob@bob.com' this.callback = sinon.stub() }) afterEach(function () { return tk.reset() }) describe('addAffiliationForNewUser', function (done) { beforeEach(function () { this.UserUpdater.updateUser = sinon .stub() .yields(null, { matchedCount: 1, modifiedCount: 1 }) }) it('should not remove affiliationUnchecked flag if v1 returns an error', function (done) { this.addAffiliation.yields(true) this.UserUpdater.addAffiliationForNewUser( this.stubbedUser._id, this.newEmail, (error, updated) => { expect(error).to.exist expect(updated).to.be.undefined sinon.assert.notCalled(this.UserUpdater.updateUser) done() } ) }) it('should remove affiliationUnchecked flag if v1 does not return an error', function (done) { this.addAffiliation.yields() this.UserUpdater.addAffiliationForNewUser( this.stubbedUser._id, this.newEmail, error => { expect(error).not.to.exist sinon.assert.calledOnce(this.UserUpdater.updateUser) sinon.assert.calledWithMatch( this.UserUpdater.updateUser, { _id: this.stubbedUser._id, 'emails.email': this.newEmail }, { $unset: { 'emails.$.affiliationUnchecked': 1 } } ) done() } ) }) }) describe('changeEmailAddress', function () { beforeEach(function () { this.auditLog = { initiatorId: 'abc123', ipAddress: '0:0:0:0', } this.UserGetter.getUserEmail.callsArgWith(1, null, this.stubbedUser.email) this.UserUpdater.addEmailAddress = sinon.stub().callsArgWith(4) this.UserUpdater.setDefaultEmailAddress = sinon.stub().yields() this.UserUpdater.removeEmailAddress = sinon.stub().callsArgWith(2) }) it('change email', function (done) { this.UserUpdater.changeEmailAddress( this.stubbedUser._id, this.newEmail, this.auditLog, err => { expect(err).not.to.exist this.UserUpdater.addEmailAddress .calledWith(this.stubbedUser._id, this.newEmail, {}, this.auditLog) .should.equal(true) this.UserUpdater.setDefaultEmailAddress .calledWith( this.stubbedUser._id, this.newEmail, true, this.auditLog, true ) .should.equal(true) this.UserUpdater.removeEmailAddress .calledWith(this.stubbedUser._id, this.stubbedUser.email) .should.equal(true) done() } ) }) it('validates email', function (done) { this.UserUpdater.changeEmailAddress( this.stubbedUser._id, 'foo', this.auditLog, err => { expect(err).to.exist done() } ) }) it('handle error', function (done) { this.UserUpdater.removeEmailAddress.callsArgWith(2, new Error('nope')) this.UserUpdater.changeEmailAddress( this.stubbedUser._id, this.newEmail, this.auditLog, err => { expect(err).to.exist done() } ) }) }) describe('addEmailAddress', function () { beforeEach(function () { this.UserGetter.promises.ensureUniqueEmailAddress = sinon .stub() .resolves() this.UserUpdater.promises.updateUser = sinon.stub().resolves() }) it('add email', function (done) { this.UserUpdater.addEmailAddress( this.stubbedUser._id, this.newEmail, {}, { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' }, err => { this.UserGetter.promises.ensureUniqueEmailAddress.called.should.equal( true ) expect(err).to.not.exist const reversedHostname = this.newEmail .split('@')[1] .split('') .reverse() .join('') this.UserUpdater.promises.updateUser .calledWith(this.stubbedUser._id, { $push: { emails: { email: this.newEmail, createdAt: sinon.match.date, reversedHostname, }, }, }) .should.equal(true) done() } ) }) it('add affiliation', function (done) { const affiliationOptions = { university: { id: 1 }, role: 'Prof', department: 'Math', } this.UserUpdater.addEmailAddress( this.stubbedUser._id, this.newEmail, affiliationOptions, { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' }, err => { expect(err).not.to.exist this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal( true ) const { args } = this.InstitutionsAPI.promises.addAffiliation.lastCall args[0].should.equal(this.stubbedUser._id) args[1].should.equal(this.newEmail) args[2].should.equal(affiliationOptions) done() } ) }) it('handle affiliation error', function (done) { this.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope')) this.UserUpdater.addEmailAddress( this.stubbedUser._id, this.newEmail, {}, { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' }, err => { expect(err).to.exist this.UserUpdater.promises.updateUser.called.should.equal(false) done() } ) }) it('validates email', function (done) { this.UserUpdater.addEmailAddress( this.stubbedUser._id, 'bar', {}, { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' }, err => { expect(err).to.exist done() } ) }) it('updates the audit log', function (done) { this.ip = '127:0:0:0' this.UserUpdater.addEmailAddress( this.stubbedUser._id, this.newEmail, {}, { initiatorId: this.stubbedUser._id, ipAddress: this.ip }, error => { expect(error).to.not.exist this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal( true ) const { args } = this.UserAuditLogHandler.promises.addEntry.lastCall expect(args[0]).to.equal(this.stubbedUser._id) expect(args[1]).to.equal('add-email') expect(args[2]).to.equal(this.stubbedUser._id) expect(args[3]).to.equal(this.ip) expect(args[4]).to.deep.equal({ newSecondaryEmail: this.newEmail, }) done() } ) }) describe('errors', function () { describe('via UserAuditLogHandler', function () { const anError = new Error('oops') beforeEach(function () { this.UserAuditLogHandler.promises.addEntry.throws(anError) }) it('should not add email and should return error', function (done) { this.UserUpdater.addEmailAddress( this.stubbedUser._id, this.newEmail, {}, { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' }, error => { expect(error).to.exist expect(error).to.equal(anError) expect(this.UserUpdater.promises.updateUser).to.not.have.been .called done() } ) }) }) }) }) describe('removeEmailAddress', function () { beforeEach(function () { this.UserUpdater.updateUser = sinon .stub() .yields(null, { matchedCount: 1 }) }) it('remove email', function (done) { this.UserUpdater.removeEmailAddress( this.stubbedUser._id, this.newEmail, err => { expect(err).not.to.exist this.UserUpdater.updateUser .calledWith( { _id: this.stubbedUser._id, email: { $ne: this.newEmail } }, { $pull: { emails: { email: this.newEmail } } } ) .should.equal(true) done() } ) }) it('remove affiliation', function (done) { this.UserUpdater.removeEmailAddress( this.stubbedUser._id, this.newEmail, err => { expect(err).not.to.exist this.removeAffiliation.calledOnce.should.equal(true) const { args } = this.removeAffiliation.lastCall args[0].should.equal(this.stubbedUser._id) args[1].should.equal(this.newEmail) done() } ) }) it('refresh features', function (done) { this.UserUpdater.removeEmailAddress( this.stubbedUser._id, this.newEmail, err => { expect(err).not.to.exist sinon.assert.calledWith(this.refreshFeatures, this.stubbedUser._id) done() } ) }) it('handle error', function (done) { this.UserUpdater.updateUser = sinon .stub() .callsArgWith(2, new Error('nope')) this.UserUpdater.removeEmailAddress( this.stubbedUser._id, this.newEmail, err => { expect(err).to.exist done() } ) }) it('handle missed update', function (done) { this.UserUpdater.updateUser = sinon .stub() .yields(null, { matchedCount: 0 }) this.UserUpdater.removeEmailAddress( this.stubbedUser._id, this.newEmail, err => { expect(err).to.exist done() } ) }) it('handle affiliation error', function (done) { this.removeAffiliation.callsArgWith(2, new Error('nope')) this.UserUpdater.removeEmailAddress( this.stubbedUser._id, this.newEmail, err => { expect(err).to.exist this.UserUpdater.updateUser.called.should.equal(false) done() } ) }) it('validates email', function (done) { this.UserUpdater.removeEmailAddress(this.stubbedUser._id, 'baz', err => { expect(err).to.exist done() }) }) }) describe('setDefaultEmailAddress', function () { beforeEach(function () { this.auditLog = { initiatorId: this.stubbedUser, ipAddress: '0:0:0:0', } this.stubbedUser.emails = [ { email: this.newEmail, confirmedAt: new Date(), }, ] this.UserGetter.promises.getUser.resolves(this.stubbedUser) this.NewsletterManager.promises.changeEmail.callsArgWith(2, null) this.RecurlyWrapper.promises.updateAccountEmailAddress.resolves() }) it('set default', function (done) { this.UserUpdater.promises.updateUser = sinon .stub() .resolves({ matchedCount: 1 }) this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, err => { expect(err).not.to.exist this.UserUpdater.promises.updateUser .calledWith( { _id: this.stubbedUser._id, 'emails.email': this.newEmail }, { $set: { email: this.newEmail } } ) .should.equal(true) done() } ) }) it('set changed the email in newsletter', function (done) { this.UserUpdater.promises.updateUser = sinon .stub() .resolves({ matchedCount: 1 }) this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, err => { expect(err).not.to.exist this.NewsletterManager.promises.changeEmail .calledWith(this.stubbedUser, this.newEmail) .should.equal(true) this.RecurlyWrapper.promises.updateAccountEmailAddress .calledWith(this.stubbedUser._id, this.newEmail) .should.equal(true) done() } ) }) it('handle error', function (done) { this.UserUpdater.promises.updateUser = sinon.stub().rejects(Error('nope')) this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, err => { expect(err).to.exist done() } ) }) it('handle missed update', function (done) { this.UserUpdater.promises.updateUser = sinon .stub() .resolves({ matchedCount: 0 }) this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, err => { expect(err).to.exist done() } ) }) it('validates email', function (done) { this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, '.edu', false, this.auditLog, err => { expect(err).to.exist done() } ) }) it('updates audit log', function (done) { this.UserUpdater.promises.updateUser = sinon .stub() .resolves({ matchedCount: 1 }) this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, error => { expect(error).to.not.exist expect( this.UserAuditLogHandler.promises.addEntry ).to.have.been.calledWith( this.stubbedUser._id, 'change-primary-email', this.auditLog.initiatorId, this.auditLog.ipAddress, { newPrimaryEmail: this.newEmail, oldPrimaryEmail: this.stubbedUser.email, } ) done() } ) }) it('blocks email update if audit log returns an error', function (done) { this.UserUpdater.promises.updateUser = sinon.stub() this.UserAuditLogHandler.promises.addEntry.rejects(new Error('oops')) this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, error => { expect(error).to.exist expect(this.UserUpdater.promises.updateUser).to.not.have.been.called done() } ) }) describe('when email not confirmed', function () { beforeEach(function () { this.stubbedUser.emails = [ { email: this.newEmail, confirmedAt: null, }, ] this.UserUpdater.promises.updateUser = sinon.stub() }) it('should callback with error', function () { this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, error => { expect(error).to.exist expect(error.name).to.equal('UnconfirmedEmailError') this.UserUpdater.promises.updateUser.callCount.should.equal(0) this.NewsletterManager.promises.changeEmail.callCount.should.equal( 0 ) } ) }) }) describe('when email does not belong to user', function () { beforeEach(function () { this.stubbedUser.emails = [] this.UserGetter.promises.getUser.resolves(this.stubbedUser) this.UserUpdater.promises.updateUser = sinon.stub() }) it('should callback with error', function () { this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._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', function (done) { // this.UserGetter.promises.getUser.resolves(this.stubbedUser) this.UserUpdater.promises.updateUser = sinon .stub() .resolves({ matchedCount: 1 }) this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, true, error => { expect(error).to.not.exist this.EmailHandler.promises.sendEmail.callCount.should.equal(2) const toOldEmailAlert = this.EmailHandler.promises.sendEmail .firstCall expect(toOldEmailAlert.args[0]).to.equal('securityAlert') const toNewEmailAlert = this.EmailHandler.promises.sendEmail .lastCall expect(toOldEmailAlert.args[1].to).to.equal(this.stubbedUser.email) expect(toNewEmailAlert.args[0]).to.equal('securityAlert') expect(toNewEmailAlert.args[1].to).to.equal(this.newEmail) done() } ) }) describe('errors', function () { const anError = new Error('oops') describe('EmailHandler', function () { beforeEach(function () { this.EmailHandler.promises.sendEmail.rejects(anError) this.UserUpdater.promises.updateUser = sinon .stub() .resolves({ matchedCount: 1 }) }) it('should log but not pass back the error', function (done) { this.UserUpdater.setDefaultEmailAddress( this.stubbedUser._id, this.newEmail, false, this.auditLog, true, error => { expect(error).to.not.exist const loggerCall = this.logger.error.firstCall expect(loggerCall.args[0]).to.deep.equal({ error: anError, userId: this.stubbedUser._id, }) expect(loggerCall.args[1]).to.contain( 'could not send security alert email when primary email changed' ) done() } ) }) }) }) }) }) describe('confirmEmail', function () { beforeEach(function () { this.UserUpdater.promises.updateUser = sinon .stub() .resolves({ matchedCount: 1 }) }) it('should update the email record', function (done) { this.UserUpdater.confirmEmail( this.stubbedUser._id, this.stubbedUserEmail, err => { expect(err).not.to.exist this.UserUpdater.promises.updateUser .calledWith( { _id: this.stubbedUser._id, 'emails.email': this.stubbedUserEmail, }, { $set: { 'emails.$.reconfirmedAt': new Date(), }, $min: { 'emails.$.confirmedAt': new Date(), }, } ) .should.equal(true) done() } ) }) it('add affiliation', function (done) { this.UserUpdater.confirmEmail( this.stubbedUser._id, this.newEmail, err => { expect(err).not.to.exist this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal( true ) sinon.assert.calledWith( this.InstitutionsAPI.promises.addAffiliation, this.stubbedUser._id, this.newEmail, { confirmedAt: new Date() } ) done() } ) }) it('handle error', function (done) { this.UserUpdater.promises.updateUser = sinon .stub() .throws(new Error('nope')) this.UserUpdater.confirmEmail( this.stubbedUser._id, this.newEmail, err => { expect(err).to.exist done() } ) }) it('handle missed update', function (done) { this.UserUpdater.promises.updateUser = sinon .stub() .resolves({ matchedCount: 0 }) this.UserUpdater.confirmEmail( this.stubbedUser._id, this.newEmail, err => { expect(err).to.exist done() } ) }) it('validates email', function (done) { this.UserUpdater.confirmEmail(this.stubbedUser._id, '@', err => { expect(err).to.exist done() }) }) it('handle affiliation error', function (done) { this.InstitutionsAPI.promises.addAffiliation.throws(Error('nope')) this.UserUpdater.confirmEmail( this.stubbedUser._id, this.newEmail, err => { expect(err).to.exist this.UserUpdater.promises.updateUser.called.should.equal(false) done() } ) }) it('refresh features', function (done) { this.UserUpdater.confirmEmail( this.stubbedUser._id, this.newEmail, err => { expect(err).not.to.exist sinon.assert.calledWith( this.FeaturesUpdater.promises.refreshFeatures, this.stubbedUser._id ) done() } ) }) }) })