const sinon = require('sinon') const chai = require('chai') const { expect } = chai const SandboxedModule = require('sandboxed-module') const modulePath = '../../../../app/src/Features/User/ThirdPartyIdentityManager.js' describe('ThirdPartyIdentityManager', function() { beforeEach(function() { this.userId = 'a1b2c3' this.user = { _id: this.userId, email: 'example@overleaf.com' } this.externalUserId = 'id789' this.externalData = {} this.auditLog = { initiatorId: this.userId, ipAddress: '0:0:0:0' } this.ThirdPartyIdentityManager = SandboxedModule.require(modulePath, { globals: { console: console }, requires: { '../../../../app/src/Features/User/UserAuditLogHandler': (this.UserAuditLogHandler = { addEntry: sinon.stub().yields() }), '../../../../app/src/Features/Email/EmailHandler': (this.EmailHandler = { sendEmail: sinon.stub().yields() }), 'logger-sharelatex': (this.logger = { error: sinon.stub() }), '../../../../app/src/models/User': { User: (this.User = { findOneAndUpdate: sinon.stub().yields(undefined, this.user), findOne: sinon.stub().yields(undefined, undefined) }) }, 'settings-sharelatex': { oauthProviders: { google: { name: 'Google' }, orcid: { name: 'Orcid' } } } } }) }) describe('getUser', function() { it('should an error when missing providerId or externalUserId', function(done) { this.ThirdPartyIdentityManager.getUser( undefined, undefined, (error, user) => { expect(error).to.exist expect(error.message).to.equal('invalid SSO arguments') expect(error.info).to.deep.equal({ externalUserId: undefined, providerId: undefined }) done() } ) }) describe('when user linked', function() { beforeEach(function() { this.User.findOne.yields(undefined, this.user) }) it('should return the user', async function() { this.User.findOne.returns(undefined, this.user) const user = await this.ThirdPartyIdentityManager.promises.getUser( 'google', 'an-id-linked' ) expect(user).to.deep.equal(this.user) }) }) it('should return ThirdPartyUserNotFoundError when no user linked', function(done) { this.ThirdPartyIdentityManager.getUser( 'google', 'an-id-not-linked', (error, user) => { expect(error).to.exist expect(error.name).to.equal('ThirdPartyUserNotFoundError') done() } ) }) }) describe('link', function() { it('should send email alert', async function() { await this.ThirdPartyIdentityManager.promises.link( this.userId, 'google', this.externalUserId, this.externalData, this.auditLog ) const emailCall = this.EmailHandler.sendEmail.getCall(0) expect(emailCall.args[0]).to.equal('securityAlert') expect(emailCall.args[1].actionDescribed).to.contain( 'a Google account was linked' ) }) it('should update user audit log', async function() { await this.ThirdPartyIdentityManager.promises.link( this.userId, 'google', this.externalUserId, this.externalData, this.auditLog ) expect(this.UserAuditLogHandler.addEntry).to.have.been.calledOnceWith( this.userId, 'link-sso', this.auditLog.initiatorId, this.auditLog.ipAddress, { providerId: 'google' } ) }) describe('errors', function() { const anError = new Error('oops') it('should not unlink if the UserAuditLogHandler throws an error', function(done) { this.UserAuditLogHandler.addEntry.yields(anError) this.ThirdPartyIdentityManager.link( this.userId, 'google', this.externalUserId, this.externalData, this.auditLog, error => { expect(error).to.exist expect(error).to.equal(anError) expect(this.User.findOneAndUpdate).to.not.have.been.called done() } ) }) describe('EmailHandler', function() { beforeEach(function() { this.EmailHandler.sendEmail.yields(anError) }) it('should log but not return the error', function(done) { this.ThirdPartyIdentityManager.link( this.userId, 'google', this.externalUserId, this.externalData, this.auditLog, error => { expect(error).to.not.exist expect(this.logger.error.lastCall).to.be.calledWithExactly( { err: anError, userId: this.userId }, 'could not send security alert email when Google account linked' ) done() } ) }) }) }) }) describe('unlink', function() { it('should send email alert', async function() { await this.ThirdPartyIdentityManager.promises.unlink( this.userId, 'orcid', this.auditLog ) const emailCall = this.EmailHandler.sendEmail.getCall(0) expect(emailCall.args[0]).to.equal('securityAlert') expect(emailCall.args[1].actionDescribed).to.contain( 'an Orcid account was unlinked from' ) }) it('should update user audit log', async function() { await this.ThirdPartyIdentityManager.promises.unlink( this.userId, 'orcid', this.auditLog ) expect(this.UserAuditLogHandler.addEntry).to.have.been.calledOnceWith( this.userId, 'unlink-sso', this.auditLog.initiatorId, this.auditLog.ipAddress, { providerId: 'orcid' } ) }) describe('errors', function() { const anError = new Error('oops') it('should not unlink if the UserAuditLogHandler throws an error', function(done) { this.UserAuditLogHandler.addEntry.yields(anError) this.ThirdPartyIdentityManager.unlink( this.userId, 'orcid', this.auditLog, error => { expect(error).to.exist expect(error).to.equal(anError) expect(this.User.findOneAndUpdate).to.not.have.been.called done() } ) expect(this.User.findOneAndUpdate).to.not.have.been.called }) describe('EmailHandler', function() { beforeEach(function() { this.EmailHandler.sendEmail.yields(anError) }) it('should log but not return the error', function(done) { this.ThirdPartyIdentityManager.unlink( this.userId, 'google', this.auditLog, error => { expect(error).to.not.exist expect(this.logger.error.lastCall).to.be.calledWithExactly( { err: anError, userId: this.userId }, 'could not send security alert email when Google account no longer linked' ) done() } ) }) }) }) }) })