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,148 +4,128 @@ 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 user = await User.findOne(query).exec()
if (!user) {
throw new Errors.ThirdPartyUserNotFoundError()
}
return user
}
async function login(providerId, externalUserId, externalData) {
const user = await ThirdPartyIdentityManager.promises.getUser(
providerId,
externalUserId
)
if (!externalData) {
return user
} }
const query = _getUserQuery(providerId, externalUserId) const query = _getUserQuery(providerId, externalUserId)
User.findOne(query, function (err, user) { const update = _thirdPartyIdentifierUpdate(
if (err != null) { user,
return callback(err)
}
if (!user) {
return callback(new Errors.ThirdPartyUserNotFoundError())
}
callback(null, user)
})
}
function login(providerId, externalUserId, externalData, callback) {
ThirdPartyIdentityManager.getUser(
providerId, providerId,
externalUserId, externalUserId,
function (err, user) { externalData
if (err != null) {
return callback(err)
}
if (!externalData) {
return callback(null, user)
}
const query = _getUserQuery(providerId, externalUserId)
const update = _thirdPartyIdentifierUpdate(
user,
providerId,
externalUserId,
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,
}
)
const query = {
_id: userId,
'thirdPartyIdentifiers.providerId': {
$ne: providerId,
}, },
error => { }
if (error) { const update = {
return callback(error) $push: {
} thirdPartyIdentifiers: {
const query = { externalUserId,
_id: userId, externalData,
'thirdPartyIdentifiers.providerId': { providerId,
$ne: providerId, },
}, },
} }
const update = { // add new tpi only if an entry for the provider does not exist
$push: { // projection includes thirdPartyIdentifiers for tests
thirdPartyIdentifiers: { let res
externalUserId, try {
externalData, res = await User.findOneAndUpdate(query, update, { new: 1 }).exec()
providerId, } catch (err) {
}, if (err.code === 11000) {
}, throw new Errors.ThirdPartyIdentityExistsError({
} info: { externalUserId },
// add new tpi only if an entry for the provider does not exist
// projection includes thirdPartyIdentifiers for tests
User.findOneAndUpdate(query, update, { new: 1 }, (err, res) => {
if (err && err.code === 11000) {
callback(
new Errors.ThirdPartyIdentityExistsError({
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(
userId,
providerId,
externalUserId,
externalData,
auditLog,
callback,
true
)
}
)
}
}) })
} }
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,
providerId,
externalUserId,
externalData,
auditLog,
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,35 +133,26 @@ function unlink(userId, providerId, auditLog, callback) {
{ {
...(auditLog.extraInfo || {}), ...(auditLog.extraInfo || {}),
providerId, providerId,
},
error => {
if (error) {
return callback(error)
}
const query = {
_id: userId,
}
const update = {
$pull: {
thirdPartyIdentifiers: {
providerId,
},
},
}
// projection includes thirdPartyIdentifiers for tests
User.findOneAndUpdate(query, update, { new: 1 }, (err, res) => {
if (err != null) {
callback(err)
} 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)
callback(null, res)
}
})
} }
) )
const query = {
_id: userId,
}
const update = {
$pull: {
thirdPartyIdentifiers: {
providerId,
},
},
}
// projection includes thirdPartyIdentifiers for tests
const res = await User.findOneAndUpdate(query, update, { new: 1 })
if (!res) {
throw new Error('update failed')
}
_sendSecurityAlert(accountLinked, providerId, res, userId)
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)
logger.error( } catch (error) {
{ err: error, userId }, logger.error(
`could not send security alert email when ${emailOptions.action.toLowerCase()}` { err: error, userId },
) `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,49 +127,47 @@ 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)
this.userId, await expect(
'google', this.ThirdPartyIdentityManager.promises.link(
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, this.userId,
'google', 'google',
this.externalUserId, this.externalUserId,
this.externalData, this.externalData,
this.auditLog, this.auditLog
error => { )
expect(error).to.not.exist ).to.be.rejectedWith(anError)
expect(this.logger.error.lastCall).to.be.calledWithExactly( expect(this.User.findOneAndUpdate).to.not.have.been.called
{ })
err: anError,
userId: this.userId, describe('EmailHandler', function () {
}, beforeEach(function () {
'could not send security alert email when new account linked' this.EmailHandler.promises.sendEmail.throws(anError)
) })
done() it('should log but not return the error', async function () {
} await expect(
this.ThirdPartyIdentityManager.promises.link(
this.userId,
'google',
this.externalUserId,
this.externalData,
this.auditLog
)
).to.be.fulfilled
expect(this.logger.error.lastCall).to.be.calledWithExactly(
{
err: anError,
userId: this.userId,
},
'could not send security alert email when new account linked'
) )
}) })
}) })
}) })
}) })
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,43 +199,43 @@ 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)
this.userId,
'orcid', await expect(
this.auditLog, this.ThirdPartyIdentityManager.promises.unlink(
error => { this.userId,
expect(error).to.exist 'orcid',
expect(error).to.equal(anError) this.auditLog
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.userId, this.ThirdPartyIdentityManager.promises.unlink(
'google', this.userId,
this.auditLog, 'google',
error => { this.auditLog
expect(error).to.not.exist )
expect(this.logger.error.lastCall).to.be.calledWithExactly( ).to.be.fulfilled
{
err: anError, expect(this.logger.error.lastCall).to.be.calledWithExactly(
userId: this.userId, {
}, err: anError,
'could not send security alert email when account no longer linked' userId: this.userId,
) },
done() 'could not send security alert email when account no longer linked'
}
) )
}) })
}) })