Merge pull request #17946 from overleaf/rh-promisify-third-party-identity-

[web] Promisify ThirdPartyIdentityManager and ThirdPartyIdentityManagerTests

GitOrigin-RevId: f7d24f73213fb0a43eb453aa21749b21ba60b83d
This commit is contained in:
roo hutton 2024-04-22 08:08:39 +01:00 committed by Copybot
parent a496e479b2
commit 9601fd097a
2 changed files with 211 additions and 229 deletions

View file

@ -4,44 +4,36 @@ const EmailOptionsHelper = require('../../../../app/src/Features/Email/EmailOpti
const Errors = require('../Errors/Errors') const Errors = require('../Errors/Errors')
const _ = require('lodash') const _ = require('lodash')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const OError = require('@overleaf/o-error')
const settings = require('@overleaf/settings') const settings = require('@overleaf/settings')
const { User } = require('../../../../app/src/models/User') const { User } = require('../../../../app/src/models/User')
const { promisifyAll } = require('@overleaf/promise-utils') const { callbackify } = require('@overleaf/promise-utils')
const OError = require('@overleaf/o-error')
const oauthProviders = settings.oauthProviders || {} const oauthProviders = settings.oauthProviders || {}
function getUser(providerId, externalUserId, callback) { async function getUser(providerId, externalUserId) {
if (providerId == null || externalUserId == null) { if (providerId == null || externalUserId == null) {
return callback( throw new OError('invalid SSO arguments', {
new OError('invalid SSO arguments', {
externalUserId, externalUserId,
providerId, providerId,
}) })
)
} }
const query = _getUserQuery(providerId, externalUserId) const query = _getUserQuery(providerId, externalUserId)
User.findOne(query, function (err, user) { const user = await User.findOne(query).exec()
if (err != null) {
return callback(err)
}
if (!user) { if (!user) {
return callback(new Errors.ThirdPartyUserNotFoundError()) throw new Errors.ThirdPartyUserNotFoundError()
} }
callback(null, user) return user
})
} }
function login(providerId, externalUserId, externalData, callback) { async function login(providerId, externalUserId, externalData) {
ThirdPartyIdentityManager.getUser( const user = await ThirdPartyIdentityManager.promises.getUser(
providerId, providerId,
externalUserId, externalUserId
function (err, user) { )
if (err != null) {
return callback(err)
}
if (!externalData) { if (!externalData) {
return callback(null, user) return user
} }
const query = _getUserQuery(providerId, externalUserId) const query = _getUserQuery(providerId, externalUserId)
const update = _thirdPartyIdentifierUpdate( const update = _thirdPartyIdentifierUpdate(
@ -50,37 +42,32 @@ function login(providerId, externalUserId, externalData, callback) {
externalUserId, externalUserId,
externalData externalData
) )
User.findOneAndUpdate(query, update, { new: true }, callback) return await User.findOneAndUpdate(query, update, { new: true }).exec()
}
)
} }
function link( async function link(
userId, userId,
providerId, providerId,
externalUserId, externalUserId,
externalData, externalData,
auditLog, auditLog,
callback,
retry retry
) { ) {
const accountLinked = true const accountLinked = true
if (!oauthProviders[providerId]) { if (!oauthProviders[providerId]) {
return callback(new Error('Not a valid provider')) throw new Error('Not a valid provider')
} }
UserAuditLogHandler.addEntry( await UserAuditLogHandler.promises.addEntry(
userId, userId,
'link-sso', 'link-sso',
auditLog.initiatorId, auditLog.initiatorId,
auditLog.ipAddress, auditLog.ipAddress,
{ {
providerId, providerId,
},
error => {
if (error) {
return callback(error)
} }
)
const query = { const query = {
_id: userId, _id: userId,
'thirdPartyIdentifiers.providerId': { 'thirdPartyIdentifiers.providerId': {
@ -98,54 +85,47 @@ function link(
} }
// add new tpi only if an entry for the provider does not exist // add new tpi only if an entry for the provider does not exist
// projection includes thirdPartyIdentifiers for tests // projection includes thirdPartyIdentifiers for tests
User.findOneAndUpdate(query, update, { new: 1 }, (err, res) => { let res
if (err && err.code === 11000) { try {
callback( res = await User.findOneAndUpdate(query, update, { new: 1 }).exec()
new Errors.ThirdPartyIdentityExistsError({ } catch (err) {
if (err.code === 11000) {
throw new Errors.ThirdPartyIdentityExistsError({
info: { externalUserId }, info: { externalUserId },
}) })
)
} else if (err != null) {
callback(err)
} else if (res) {
_sendSecurityAlert(accountLinked, providerId, res, userId)
callback(null, res)
} else if (retry) {
// if already retried then throw error
callback(new Error('update failed'))
} else {
// attempt to clear existing entry then retry
ThirdPartyIdentityManager.unlink(
userId,
providerId,
auditLog,
function (err) {
if (err != null) {
return callback(err)
} }
ThirdPartyIdentityManager.link( throw err
}
if (res) {
_sendSecurityAlert(accountLinked, providerId, res, userId)
return res
}
if (retry) {
// if already retried then throw error
throw new Error('update failed')
}
// attempt to clear existing entry then retry
await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog)
return await ThirdPartyIdentityManager.promises.link(
userId, userId,
providerId, providerId,
externalUserId, externalUserId,
externalData, externalData,
auditLog, auditLog,
callback,
true true
) )
}
)
}
})
}
)
} }
function unlink(userId, providerId, auditLog, callback) { async function unlink(userId, providerId, auditLog) {
const accountLinked = false const accountLinked = false
if (!oauthProviders[providerId]) { if (!oauthProviders[providerId]) {
return callback(new Error('Not a valid provider')) throw new Error('Not a valid provider')
} }
UserAuditLogHandler.addEntry(
await UserAuditLogHandler.promises.addEntry(
userId, userId,
'unlink-sso', 'unlink-sso',
auditLog.initiatorId, auditLog.initiatorId,
@ -153,11 +133,9 @@ function unlink(userId, providerId, auditLog, callback) {
{ {
...(auditLog.extraInfo || {}), ...(auditLog.extraInfo || {}),
providerId, providerId,
},
error => {
if (error) {
return callback(error)
} }
)
const query = { const query = {
_id: userId, _id: userId,
} }
@ -169,19 +147,12 @@ function unlink(userId, providerId, auditLog, callback) {
}, },
} }
// projection includes thirdPartyIdentifiers for tests // projection includes thirdPartyIdentifiers for tests
User.findOneAndUpdate(query, update, { new: 1 }, (err, res) => { const res = await User.findOneAndUpdate(query, update, { new: 1 })
if (err != null) { if (!res) {
callback(err) throw new Error('update failed')
} else if (!res) { }
callback(new Error('update failed'))
} else {
// no need to wait, errors are logged and not passed back
_sendSecurityAlert(accountLinked, providerId, res, userId) _sendSecurityAlert(accountLinked, providerId, res, userId)
callback(null, res) return res
}
})
}
)
} }
function _getUserQuery(providerId, externalUserId) { function _getUserQuery(providerId, externalUserId) {
@ -194,21 +165,21 @@ function _getUserQuery(providerId, externalUserId) {
return query return query
} }
function _sendSecurityAlert(accountLinked, providerId, user, userId) { async function _sendSecurityAlert(accountLinked, providerId, user, userId) {
const providerName = oauthProviders[providerId].name const providerName = oauthProviders[providerId].name
const emailOptions = EmailOptionsHelper.linkOrUnlink( const emailOptions = EmailOptionsHelper.linkOrUnlink(
accountLinked, accountLinked,
providerName, providerName,
user.email user.email
) )
EmailHandler.sendEmail('securityAlert', emailOptions, error => { try {
if (error) { await EmailHandler.promises.sendEmail('securityAlert', emailOptions)
} catch (error) {
logger.error( logger.error(
{ err: error, userId }, { err: error, userId },
`could not send security alert email when ${emailOptions.action.toLowerCase()}` `could not send security alert email when ${emailOptions.action.toLowerCase()}`
) )
} }
})
} }
function _thirdPartyIdentifierUpdate( function _thirdPartyIdentifierUpdate(
@ -230,12 +201,17 @@ function _thirdPartyIdentifierUpdate(
} }
const ThirdPartyIdentityManager = { const ThirdPartyIdentityManager = {
getUser: callbackify(getUser),
login: callbackify(login),
link: callbackify(link),
unlink: callbackify(unlink),
}
ThirdPartyIdentityManager.promises = {
getUser, getUser,
login, login,
link, link,
unlink, unlink,
} }
ThirdPartyIdentityManager.promises = promisifyAll(ThirdPartyIdentityManager)
module.exports = ThirdPartyIdentityManager module.exports = ThirdPartyIdentityManager

View file

@ -1,6 +1,7 @@
const sinon = require('sinon') const sinon = require('sinon')
const { expect } = require('chai') const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module') const SandboxedModule = require('sandboxed-module')
const OError = require('@overleaf/o-error')
const modulePath = const modulePath =
'../../../../app/src/Features/User/ThirdPartyIdentityManager.js' '../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
@ -18,16 +19,24 @@ describe('ThirdPartyIdentityManager', function () {
requires: { requires: {
'../../../../app/src/Features/User/UserAuditLogHandler': '../../../../app/src/Features/User/UserAuditLogHandler':
(this.UserAuditLogHandler = { (this.UserAuditLogHandler = {
addEntry: sinon.stub().yields(), promises: {
addEntry: sinon.stub().resolves(),
},
}), }),
'../../../../app/src/Features/Email/EmailHandler': (this.EmailHandler = '../../../../app/src/Features/Email/EmailHandler': (this.EmailHandler =
{ {
sendEmail: sinon.stub().yields(), promises: {
sendEmail: sinon.stub().resolves(),
},
}), }),
'../../../../app/src/models/User': { '../../../../app/src/models/User': {
User: (this.User = { User: (this.User = {
findOneAndUpdate: sinon.stub().yields(undefined, this.user), findOneAndUpdate: sinon
findOne: sinon.stub().yields(undefined, undefined), .stub()
.returns({ exec: sinon.stub().resolves(this.user) }),
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(undefined),
}),
}), }),
}, },
'@overleaf/settings': { '@overleaf/settings': {
@ -44,28 +53,23 @@ describe('ThirdPartyIdentityManager', function () {
}) })
}) })
describe('getUser', function () { describe('getUser', function () {
it('should an error when missing providerId or externalUserId', function (done) { it('should throw an error when missing providerId or externalUserId', async function () {
this.ThirdPartyIdentityManager.getUser( await expect(
undefined, this.ThirdPartyIdentityManager.promises.getUser(undefined, undefined)
undefined, ).to.be.rejectedWith(OError, `invalid SSO arguments`)
(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 () { describe('when user linked', function () {
beforeEach(function () { beforeEach(function () {
this.User.findOne.yields(undefined, this.user) this.User.findOne.returns({
exec: sinon.stub().resolves(this.user),
})
}) })
it('should return the user', async function () { it('should return the user', async function () {
this.User.findOne.returns(undefined, this.user) this.User.findOne.returns({
exec: sinon.stub().resolves(this.user),
})
const user = await this.ThirdPartyIdentityManager.promises.getUser( const user = await this.ThirdPartyIdentityManager.promises.getUser(
'google', 'google',
'an-id-linked' 'an-id-linked'
@ -94,7 +98,7 @@ describe('ThirdPartyIdentityManager', function () {
this.externalData, this.externalData,
this.auditLog this.auditLog
) )
const emailCall = this.EmailHandler.sendEmail.getCall(0) const emailCall = this.EmailHandler.promises.sendEmail.getCall(0)
expect(emailCall.args[0]).to.equal('securityAlert') expect(emailCall.args[0]).to.equal('securityAlert')
expect(emailCall.args[1].actionDescribed).to.contain( expect(emailCall.args[1].actionDescribed).to.contain(
'a Google account was linked' 'a Google account was linked'
@ -109,7 +113,9 @@ describe('ThirdPartyIdentityManager', function () {
this.externalData, this.externalData,
this.auditLog this.auditLog
) )
expect(this.UserAuditLogHandler.addEntry).to.have.been.calledOnceWith( expect(
this.UserAuditLogHandler.promises.addEntry
).to.have.been.calledOnceWith(
this.userId, this.userId,
'link-sso', 'link-sso',
this.auditLog.initiatorId, this.auditLog.initiatorId,
@ -121,35 +127,35 @@ describe('ThirdPartyIdentityManager', function () {
}) })
describe('errors', function () { describe('errors', function () {
const anError = new Error('oops') const anError = new Error('oops')
it('should not unlink if the UserAuditLogHandler throws an error', function (done) {
this.UserAuditLogHandler.addEntry.yields(anError) it('should not unlink if the UserAuditLogHandler throws an error', async function () {
this.ThirdPartyIdentityManager.link( this.UserAuditLogHandler.promises.addEntry.throws(anError)
await expect(
this.ThirdPartyIdentityManager.promises.link(
this.userId, this.userId,
'google', 'google',
this.externalUserId, this.externalUserId,
this.externalData, this.externalData,
this.auditLog, this.auditLog
error => {
expect(error).to.exist
expect(error).to.equal(anError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
done()
}
) )
).to.be.rejectedWith(anError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
}) })
describe('EmailHandler', function () { describe('EmailHandler', function () {
beforeEach(function () { beforeEach(function () {
this.EmailHandler.sendEmail.yields(anError) this.EmailHandler.promises.sendEmail.throws(anError)
}) })
it('should log but not return the error', function (done) { it('should log but not return the error', async function () {
this.ThirdPartyIdentityManager.link( await expect(
this.ThirdPartyIdentityManager.promises.link(
this.userId, this.userId,
'google', 'google',
this.externalUserId, this.externalUserId,
this.externalData, this.externalData,
this.auditLog, this.auditLog
error => { )
expect(error).to.not.exist ).to.be.fulfilled
expect(this.logger.error.lastCall).to.be.calledWithExactly( expect(this.logger.error.lastCall).to.be.calledWithExactly(
{ {
err: anError, err: anError,
@ -157,13 +163,11 @@ describe('ThirdPartyIdentityManager', function () {
}, },
'could not send security alert email when new account linked' 'could not send security alert email when new account linked'
) )
done()
}
)
}) })
}) })
}) })
}) })
describe('unlink', function () { describe('unlink', function () {
it('should send email alert', async function () { it('should send email alert', async function () {
await this.ThirdPartyIdentityManager.promises.unlink( await this.ThirdPartyIdentityManager.promises.unlink(
@ -171,7 +175,7 @@ describe('ThirdPartyIdentityManager', function () {
'orcid', 'orcid',
this.auditLog this.auditLog
) )
const emailCall = this.EmailHandler.sendEmail.getCall(0) const emailCall = this.EmailHandler.promises.sendEmail.getCall(0)
expect(emailCall.args[0]).to.equal('securityAlert') expect(emailCall.args[0]).to.equal('securityAlert')
expect(emailCall.args[1].actionDescribed).to.contain( expect(emailCall.args[1].actionDescribed).to.contain(
'an ORCID account was unlinked from' 'an ORCID account was unlinked from'
@ -183,7 +187,9 @@ describe('ThirdPartyIdentityManager', function () {
'orcid', 'orcid',
this.auditLog this.auditLog
) )
expect(this.UserAuditLogHandler.addEntry).to.have.been.calledOnceWith( expect(
this.UserAuditLogHandler.promises.addEntry
).to.have.been.calledOnceWith(
this.userId, this.userId,
'unlink-sso', 'unlink-sso',
this.auditLog.initiatorId, this.auditLog.initiatorId,
@ -193,34 +199,37 @@ describe('ThirdPartyIdentityManager', function () {
} }
) )
}) })
describe('errors', function () { describe('errors', function () {
const anError = new Error('oops') const anError = new Error('oops')
it('should not unlink if the UserAuditLogHandler throws an error', function (done) {
this.UserAuditLogHandler.addEntry.yields(anError) it('should not unlink if the UserAuditLogHandler throws an error', async function () {
this.ThirdPartyIdentityManager.unlink( this.UserAuditLogHandler.promises.addEntry.throws(anError)
await expect(
this.ThirdPartyIdentityManager.promises.unlink(
this.userId, this.userId,
'orcid', 'orcid',
this.auditLog, this.auditLog
error => {
expect(error).to.exist
expect(error).to.equal(anError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
done()
}
) )
).to.be.rejectedWith(anError)
expect(this.User.findOneAndUpdate).to.not.have.been.called expect(this.User.findOneAndUpdate).to.not.have.been.called
}) })
describe('EmailHandler', function () { describe('EmailHandler', function () {
beforeEach(function () { beforeEach(function () {
this.EmailHandler.sendEmail.yields(anError) this.EmailHandler.promises.sendEmail.throws(anError)
}) })
it('should log but not return the error', function (done) { it('should log but not return the error', async function () {
this.ThirdPartyIdentityManager.unlink( await expect(
this.ThirdPartyIdentityManager.promises.unlink(
this.userId, this.userId,
'google', 'google',
this.auditLog, this.auditLog
error => { )
expect(error).to.not.exist ).to.be.fulfilled
expect(this.logger.error.lastCall).to.be.calledWithExactly( expect(this.logger.error.lastCall).to.be.calledWithExactly(
{ {
err: anError, err: anError,
@ -228,9 +237,6 @@ describe('ThirdPartyIdentityManager', function () {
}, },
'could not send security alert email when account no longer linked' 'could not send security alert email when account no longer linked'
) )
done()
}
)
}) })
}) })
}) })