Files
overleaf-cep/services/web/test/unit/src/User/SAMLIdentityManagerTests.js
Mathias Jakobsen c371732e6e Merge pull request #16186 from overleaf/mj-mongo-object-id
[web] Use constructor for ObjectId

GitOrigin-RevId: 9eb8b377ea599605b72af237d1ab12f4d8287162
2023-12-19 09:04:02 +00:00

739 lines
23 KiB
JavaScript

const { ObjectId } = require('mongodb')
const sinon = require('sinon')
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const modulePath = '../../../../app/src/Features/User/SAMLIdentityManager.js'
describe('SAMLIdentityManager', function () {
const linkedEmail = 'another@example.com'
beforeEach(function () {
this.userId = '6005c75b12cbcaf771f4a105'
this.user = {
_id: this.userId,
email: 'not-linked@overleaf.com',
emails: [{ email: 'not-linked@overleaf.com' }],
samlIdentifiers: [],
}
this.auditLog = {
initiatorId: this.userId,
ipAddress: '0:0:0:0',
}
this.userAlreadyLinked = {
_id: '6005c7a012cbcaf771f4a106',
email: 'linked@overleaf.com',
emails: [{ email: 'linked@overleaf.com', samlProviderId: '1' }],
samlIdentifiers: [{ externalUserId: 'linked-id', providerId: '1' }],
}
this.userEmailExists = {
_id: '6005c7a012cbcaf771f4a107',
email: 'exists@overleaf.com',
emails: [{ email: 'exists@overleaf.com' }],
samlIdentifiers: [],
}
this.institution = {
name: 'Overleaf University',
}
this.InstitutionsAPI = {
promises: {
addEntitlement: sinon.stub().resolves(),
removeEntitlement: sinon.stub().resolves(),
},
}
this.SAMLIdentityManager = SandboxedModule.require(modulePath, {
requires: {
'../Email/EmailHandler': (this.EmailHandler = {
sendEmail: sinon.stub().yields(),
}),
'../Notifications/NotificationsBuilder': (this.NotificationsBuilder = {
promises: {
redundantPersonalSubscription: sinon
.stub()
.returns({ create: sinon.stub().resolves() }),
},
}),
'../Subscription/SubscriptionLocator': (this.SubscriptionLocator = {
promises: {
getUserIndividualSubscription: sinon.stub().resolves(),
},
}),
'../../models/User': {
User: (this.User = {
findOneAndUpdate: sinon.stub().returns({
exec: sinon.stub().resolves(this.user),
}),
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
updateOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
}),
},
'../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
}),
'../User/UserGetter': (this.UserGetter = {
getUser: sinon.stub(),
promises: {
getUser: sinon.stub().resolves(this.user),
getUserByAnyEmail: sinon.stub().resolves(),
getUserFullEmails: sinon.stub().resolves(),
},
}),
'../User/UserUpdater': (this.UserUpdater = {
addEmailAddress: sinon.stub(),
promises: {
addEmailAddress: sinon.stub().resolves(),
confirmEmail: sinon.stub().resolves(),
updateUser: sinon.stub().resolves(),
},
}),
'../Institutions/InstitutionsAPI': this.InstitutionsAPI,
},
})
})
describe('getUser', function () {
it('should throw an error if missing all of: provider ID, external user ID, attribute', async function () {
let error
try {
await this.SAMLIdentityManager.getUser(undefined, undefined, undefined)
} catch (e) {
error = e
} finally {
expect(error).to.exist
expect(error.message).to.equal(
'invalid arguments: providerId: undefined, externalUserId: undefined, userIdAttribute: undefined'
)
}
})
it('should throw an error if missing provider ID', async function () {
let error
try {
await this.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr')
} catch (e) {
error = e
} finally {
expect(error).to.exist
expect(error.message).to.equal(
'invalid arguments: providerId: undefined, externalUserId: id123, userIdAttribute: someAttr'
)
}
})
it('should throw an error if missing external user ID', async function () {
let error
try {
await this.SAMLIdentityManager.getUser('123', null, 'someAttr')
} catch (e) {
error = e
} finally {
expect(error).to.exist
}
})
it('should throw an error if missing attribute', async function () {
let error
try {
await this.SAMLIdentityManager.getUser('123', 'id123', undefined)
} catch (e) {
error = e
} finally {
expect(error).to.exist
expect(error.message).to.equal(
'invalid arguments: providerId: 123, externalUserId: id123, userIdAttribute: undefined'
)
}
})
})
describe('linkAccounts', function () {
describe('errors', function () {
beforeEach(function () {
// first call is to get userWithProvider; should be falsy
this.UserGetter.promises.getUser.onFirstCall().resolves()
this.UserGetter.promises.getUser.onSecondCall().resolves(this.user)
})
it('should throw an error if missing all data', async function () {
let error
try {
await this.SAMLIdentityManager.linkAccounts(null, null, null)
} catch (e) {
error = e
} finally {
expect(error).to.exist
}
})
describe('linking data', function () {
const requiredData = {
externalUserId: 'someUniqueId',
institutionEmail: 'user@example.com',
providerId: '123',
userIdAttribute: 'attribute',
}
for (const [data] of Object.entries(requiredData)) {
const testData = { ...requiredData }
delete testData[data]
let error
it(`should throw an error when missing ${data}`, async function () {
try {
await this.SAMLIdentityManager.linkAccounts('123', testData, {})
} catch (e) {
error = e
} finally {
expect(error).to.exist
expect(error.message).to.contain(
'missing data when linking institution SSO'
)
}
})
}
})
describe('when email is already associated with another Overleaf account', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(
this.userEmailExists
)
})
it('should throw an EmailExistsError error', async function () {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'not-linked-id',
institutionEmail: 'exists@overleaf.com',
universityId: 'provider-id',
universityName: 'provider-name',
hasEntitlement: true,
userIdAttribute: 'someAttribute',
},
{
intiatorId: '6005c75b12cbcaf771f4a105',
ip: '0:0:0:0',
}
)
} catch (e) {
error = e
} finally {
expect(error).to.be.instanceof(Errors.EmailExistsError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
}
})
})
describe('when email is not affiliated', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
this.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'not-affiliated@overleaf.com',
},
])
})
it('should throw SAMLEmailNotAffiliatedError', async function () {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'not-linked-id',
institutionEmail: 'not-affiliated@overleaf.com',
universityId: 'provider-id',
universityName: 'provider-name',
hasEntitlement: true,
userIdAttribute: 'someAttribute',
},
{
intiatorId: 'user-id-1',
ip: '0:0:0:0',
}
)
} catch (e) {
error = e
} finally {
expect(error).to.be.instanceof(Errors.SAMLEmailNotAffiliatedError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
}
})
})
describe('when email is affiliated with another institution', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
this.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'affiliated@overleaf.com',
affiliation: { institution: { id: '987' } },
},
])
})
it('should throw SAMLEmailAffiliatedWithAnotherInstitutionError', async function () {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'not-linked-id',
institutionEmail: 'affiliated@overleaf.com',
universityId: 'provider-id',
universityName: 'provider-name',
hasEntitlement: true,
userIdAttribute: 'someAttribute',
},
{
intiatorId: 'user-id-1',
ip: '0:0:0:0',
}
)
} catch (e) {
error = e
} finally {
expect(error).to.be.instanceof(
Errors.SAMLEmailAffiliatedWithAnotherInstitutionError
)
expect(this.User.findOneAndUpdate).to.not.have.been.called
}
})
})
describe('when institution identifier is already associated with another Overleaf account', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(
this.userAlreadyLinked
)
})
it('should throw an SAMLIdentityExistsError error', async function () {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'already-linked-id',
institutionEmail: 'linked@overleaf.com',
universityId: 'provider-id',
universityName: 'provider-name',
hasEntitlement: true,
userIdAttribute: 'someAttribute',
},
{
intiatorId: '6005c75b12cbcaf771f4a105',
ip: '0:0:0:0',
}
)
} catch (e) {
error = e
} finally {
expect(error).to.be.instanceof(Errors.SAMLIdentityExistsError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
}
})
})
describe('when institution provider is already associated with the user', function () {
beforeEach(function () {
// first call is to get userWithProvider; resolves with any user
this.UserGetter.promises.getUser.onFirstCall().resolves(this.user)
})
it('should throw an SAMLAlreadyLinkedError error', async function () {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'already-linked-id',
institutionEmail: 'linked@overleaf.com',
universityId: 123456,
universityName: 'provider-name',
hasEntitlement: true,
userIdAttribute: 'someAttribute',
},
{
intiatorId: '6005c75b12cbcaf771f4a105',
ip: '0:0:0:0',
}
)
} catch (e) {
error = e
} finally {
expect(
this.UserGetter.promises.getUser
).to.have.been.calledWithMatch({
_id: new ObjectId('6005c75b12cbcaf771f4a105'),
'samlIdentifiers.providerId': '123456',
})
expect(error).to.be.instanceof(Errors.SAMLAlreadyLinkedError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
}
})
})
it('should pass back errors via UserAuditLogHandler', async function () {
let error
const anError = new Error('oops')
this.UserAuditLogHandler.promises.addEntry.rejects(anError)
try {
await this.SAMLIdentityManager.linkAccounts(
this.user._id,
{
externalUserId: 'externalUserId',
institutionEmail: this.user.email,
universityId: '1',
universityName: 'Overleaf University',
hasEntitlement: false,
userIdAttribute: 'someAttribute',
},
{
intiatorId: '6005c75b12cbcaf771f4a105',
ipAddress: '0:0:0:0',
}
)
} catch (e) {
error = e
} finally {
expect(error).to.exist
expect(error).to.equal(anError)
expect(this.EmailHandler.sendEmail).to.not.have.been.called
expect(this.User.updateOne).to.not.have.been.called
}
})
})
describe('success', function () {
beforeEach(function () {
// first call is to get userWithProvider; should be falsy
this.UserGetter.promises.getUser.onFirstCall().resolves()
this.UserGetter.promises.getUser.onSecondCall().resolves(this.user)
})
it('should update the user audit log', async function () {
const auditLog = {
initiatorId: '6005c75b12cbcaf771f4a105',
ipAddress: '0:0:0:0',
}
await this.SAMLIdentityManager.linkAccounts(
this.user._id,
{
externalUserId: 'externalUserId',
institutionEmail: this.user.email,
universityId: '1',
universityName: 'Overleaf University',
hasEntitlement: false,
userIdAttribute: 'uniqueId',
},
auditLog
)
expect(
this.UserAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
this.user._id,
'link-institution-sso',
auditLog.initiatorId,
auditLog.ipAddress,
{
institutionEmail: this.user.email,
providerId: '1',
providerName: 'Overleaf University',
userIdAttribute: 'uniqueId',
}
)
})
it('should send an email notification', async function () {
await this.SAMLIdentityManager.linkAccounts(
this.user._id,
{
externalUserId: 'externalUserId',
institutionEmail: this.user.email,
universityId: '1',
universityName: 'Overleaf University',
hasEntitlement: false,
userIdAttribute: 'someAttribute',
},
{
intiatorId: '6005c75b12cbcaf771f4a105',
ipAddress: '0:0:0:0',
}
)
expect(this.User.findOneAndUpdate).to.have.been.called
expect(this.EmailHandler.sendEmail).to.have.been.calledOnce
const emailArgs = this.EmailHandler.sendEmail.lastCall.args
expect(emailArgs[0]).to.equal('securityAlert')
expect(emailArgs[1].to).to.equal(this.user.email)
expect(emailArgs[1].actionDescribed).to.contain('was linked')
expect(emailArgs[1].message[0]).to.contain('Linked')
expect(emailArgs[1].message[0]).to.contain(this.user.email)
})
})
})
describe('unlinkAccounts', function () {
it('should update the audit log', async function () {
await this.SAMLIdentityManager.unlinkAccounts(
this.user._id,
linkedEmail,
this.user.email,
'1',
'Overleaf University',
this.auditLog
)
expect(
this.UserAuditLogHandler.promises.addEntry
).to.have.been.calledOnce.and.calledWithMatch(
this.user._id,
'unlink-institution-sso',
this.auditLog.initiatorId,
this.auditLog.ipAddress,
{
institutionEmail: linkedEmail,
providerId: '1',
providerName: 'Overleaf University',
}
)
})
it('should remove the identifier', async function () {
await this.SAMLIdentityManager.unlinkAccounts(
this.user._id,
linkedEmail,
this.user.email,
'1',
'Overleaf University',
this.auditLog
)
const query = {
_id: this.user._id,
}
const update = {
$pull: {
samlIdentifiers: {
providerId: '1',
},
},
}
expect(this.User.updateOne).to.have.been.calledOnce.and.calledWithMatch(
query,
update
)
})
it('should send an email notification', async function () {
await this.SAMLIdentityManager.unlinkAccounts(
this.user._id,
linkedEmail,
this.user.email,
'1',
'Overleaf University',
this.auditLog
)
expect(this.User.updateOne).to.have.been.called
expect(this.EmailHandler.sendEmail).to.have.been.calledOnce
const emailArgs = this.EmailHandler.sendEmail.lastCall.args
expect(emailArgs[0]).to.equal('securityAlert')
expect(emailArgs[1].to).to.equal(this.user.email)
expect(emailArgs[1].actionDescribed).to.contain('was unlinked')
expect(emailArgs[1].message[0]).to.contain('No longer linked')
expect(emailArgs[1].message[0]).to.contain(linkedEmail)
})
describe('errors', function () {
it('should pass back errors via UserAuditLogHandler', async function () {
let error
const anError = new Error('oops')
this.UserAuditLogHandler.promises.addEntry.rejects(anError)
try {
await this.SAMLIdentityManager.unlinkAccounts(
this.user._id,
linkedEmail,
this.user.email,
'1',
'Overleaf University',
this.auditLog
)
} catch (e) {
error = e
} finally {
expect(error).to.exist
expect(error).to.equal(anError)
expect(this.EmailHandler.sendEmail).to.not.have.been.called
expect(this.User.updateOne).to.not.have.been.called
}
})
})
})
describe('entitlementAttributeMatches', function () {
it('should return true when entitlement matches on string', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
'foo bar',
'bar'
).should.equal(true)
})
it('should return false when entitlement does not match on string', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
'foo bar',
'bam'
).should.equal(false)
})
it('should return false on an invalid matcher', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
'foo bar',
'('
).should.equal(false)
})
it('should log error on an invalid matcher', function () {
this.SAMLIdentityManager.entitlementAttributeMatches('foo bar', '(')
this.logger.error.firstCall.args[0].err.message.should.equal(
'Invalid regular expression: /(/: Unterminated group'
)
})
it('should return true when entitlement matches on array', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
['foo', 'bar'],
'bar'
).should.equal(true)
})
it('should return false when entitlement does not match array', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
['foo', 'bar'],
'bam'
).should.equal(false)
})
})
describe('migrateIdentifier', function () {
const userId = '5efb8b6e9b647b0027e4c0b0'
const externalUserId = '987zyx'
const providerId = 123
const hasEntitlement = false
const institutionEmail = 'someone@email.com'
const providerName = 'Example University'
const auditLog = {
initiatorId: userId,
ipAddress: '0.0.0.0',
migration: {
from: 'uniqueId',
to: 'newUniqueId',
},
}
const userIdAttribute = 'newUniqueId'
it('should remove the old identifier and add the new identifier', async function () {
this.UserGetter.promises.getUser.resolves()
this.UserGetter.promises.getUserByAnyEmail
.withArgs(institutionEmail)
.resolves({ _id: userId, emails: [{ email: institutionEmail }] })
this.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([
{
email: institutionEmail,
affiliation: { institution: { id: providerId } },
},
])
await this.SAMLIdentityManager.migrateIdentifier(
userId,
externalUserId,
providerId,
hasEntitlement,
institutionEmail,
providerName,
auditLog,
userIdAttribute
)
expect(this.User.updateOne).to.have.been.calledOnce
const query = {
_id: userId,
'samlIdentifiers.providerId': providerId.toString(),
}
const update = {
$set: {
'samlIdentifiers.$.externalUserId': externalUserId,
'samlIdentifiers.$.userIdAttribute': userIdAttribute,
},
}
expect(this.User.updateOne.lastCall.args).to.deep.equal([query, update])
})
})
describe('unlinkNotMigrated', function () {
const userId = '5efb8b6e9b647b0027e4c0b0'
const providerId = '123'
const institutionEmail = 'someone@email.com'
const providerName = 'Example University'
const auditLog = {
ipAddress: 'N/A',
}
it('should remove the identifier om samlIdentifiers and samlProviderId on the email', async function () {
this.User.findOneAndUpdate = sinon.stub().returns({
exec: sinon.stub().resolves({
_id: userId,
emails: [{ email: institutionEmail, samlProviderId: providerId }],
}),
})
await this.SAMLIdentityManager.unlinkNotMigrated(
userId,
providerId,
providerName,
auditLog
)
expect(this.User.findOneAndUpdate).to.have.been.calledOnce
const query = {
_id: userId,
'emails.samlProviderId': providerId,
}
const update = {
$pull: {
samlIdentifiers: {
providerId,
},
},
$unset: {
'emails.$.samlProviderId': 1,
},
}
expect(this.User.findOneAndUpdate.lastCall.args).to.deep.equal([
query,
update,
])
expect(this.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce
expect(
this.UserAuditLogHandler.promises.addEntry.lastCall.args
).to.deep.equal([
userId,
'unlink-institution-sso-not-migrated',
undefined,
'N/A',
{ providerId, providerName },
])
expect(this.InstitutionsAPI.promises.removeEntitlement).to.have.been
.calledOnce
expect(
this.InstitutionsAPI.promises.removeEntitlement.lastCall.args
).to.deep.equal([userId, institutionEmail])
})
})
})