Files
overleaf-cep/services/web/test/unit/src/User/UserUpdater.test.mjs
T
roo hutton a0a621f727 Merge pull request #30788 from overleaf/rh-cio-sub-topics
Replace Mailchimp newsletter subscriptions with customer.io topic subscriptions

GitOrigin-RevId: de536fb7ef24ff44c494659b2ef5ede5b33cc977
2026-02-04 09:06:24 +00:00

1236 lines
36 KiB
JavaScript

import { vi, expect } from 'vitest'
import { setTimeout } from 'node:timers/promises'
import path from 'node:path'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import tk from 'timekeeper'
import MongoHelpers from '../../../../app/src/Features/Helpers/Mongo.mjs'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
const { normalizeQuery } = MongoHelpers
const { ObjectId } = mongodb
const MODULE_PATH = path.join(
import.meta.dirname,
'../../../../app/src/Features/User/UserUpdater'
)
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('UserUpdater', function () {
beforeEach(async function (ctx) {
tk.freeze(Date.now())
ctx.user = {
_id: new ObjectId(),
name: 'bob',
email: 'hello@world.com',
emails: [{ email: 'hello@world.com' }],
}
ctx.db = {
users: {
updateOne: sinon.stub().resolves({ matchedCount: 1, modifiedCount: 1 }),
},
}
ctx.mongodb = {
db: ctx.db,
ObjectId,
}
ctx.UserGetter = {
promises: {
ensureUniqueEmailAddress: sinon.stub().resolves(),
getUser: sinon.stub(),
getUserByMainEmail: sinon.stub(),
getUserFullEmails: sinon.stub(),
getUserEmail: sinon.stub(),
},
}
ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
ctx.UserGetter.promises.getUserByMainEmail
.withArgs(ctx.user.email)
.resolves(ctx.user)
ctx.UserGetter.promises.getUserFullEmails
.withArgs(ctx.user._id)
.resolves(ctx.user.emails)
ctx.UserGetter.promises.getUserEmail
.withArgs(ctx.user._id)
.resolves(ctx.user.email)
ctx.AnalyticsManager = {
recordEventForUserInBackground: sinon.stub(),
}
ctx.InstitutionsAPI = {
promises: {
addAffiliation: sinon.stub().resolves(),
removeAffiliation: sinon.stub().resolves(),
getUserAffiliations: sinon.stub().resolves(),
},
}
ctx.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}
ctx.Features = {
hasFeature: sinon.stub().returns(false),
}
ctx.FeaturesUpdater = {
promises: {
refreshFeatures: sinon.stub().resolves(),
},
}
ctx.UserAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
}
ctx.SubscriptionLocator = {
promises: {
getUserIndividualSubscription: sinon.stub().resolves(),
},
}
ctx.NotificationsBuilder = {
promises: {
redundantPersonalSubscription: sinon
.stub()
.returns({ create: () => {} }),
},
}
ctx.Modules = {
promises: {
hooks: {
fire: sinon.stub().resolves([]),
},
},
}
ctx.UserSessionsManager = {
promises: {
removeSessionsFromRedis: sinon.stub().resolves(),
},
}
ctx.AsyncLocalStorage = {
removeItem: sinon.stub(),
}
vi.doMock('../../../../app/src/Features/Helpers/Mongo', () => ({
default: { normalizeQuery },
}))
vi.doMock('../../../../app/src/infrastructure/mongodb', () => ctx.mongodb)
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsAPI',
() => ({
default: ctx.InstitutionsAPI,
})
)
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
default: ctx.EmailHandler,
}))
vi.doMock('../../../../app/src/infrastructure/Features', () => ({
default: ctx.Features,
}))
vi.doMock(
'../../../../app/src/Features/Subscription/FeaturesUpdater',
() => ({
default: ctx.FeaturesUpdater,
})
)
vi.doMock('@overleaf/settings', () => ({
default: (ctx.settings = {}),
}))
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyWrapper',
() => ({
default: ctx.RecurlyWrapper,
})
)
vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
default: ctx.UserAuditLogHandler,
}))
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: ctx.AnalyticsManager,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionLocator',
() => ({
default: ctx.SubscriptionLocator,
})
)
vi.doMock(
'../../../../app/src/Features/Notifications/NotificationsBuilder',
() => ({
default: ctx.NotificationsBuilder,
})
)
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
default: ctx.Modules,
}))
vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({
default: ctx.UserSessionsManager,
}))
vi.doMock(
'../../../../app/src/Features/User/ThirdPartyIdentityManager',
() => ({
default: ctx.ThirdPartyIdentityManager,
})
)
vi.doMock('../../../../app/src/infrastructure/AsyncLocalStorage', () => ({
default: ctx.AsyncLocalStorage,
}))
ctx.UserUpdater = (await import(MODULE_PATH)).default
ctx.newEmail = 'bob@bob.com'
})
afterEach(function () {
return tk.reset()
})
describe('addAffiliationForNewUser', function () {
it('should not remove affiliationUnchecked flag if v1 returns an error', async function (ctx) {
ctx.InstitutionsAPI.promises.addAffiliation.rejects()
await expect(
ctx.UserUpdater.promises.addAffiliationForNewUser(
ctx.user._id,
ctx.newEmail
)
).to.be.rejected
sinon.assert.notCalled(ctx.db.users.updateOne)
})
it('should remove affiliationUnchecked flag if v1 does not return an error', async function (ctx) {
await ctx.UserUpdater.promises.addAffiliationForNewUser(
ctx.user._id,
ctx.newEmail
)
sinon.assert.calledOnce(ctx.db.users.updateOne)
sinon.assert.calledWithMatch(
ctx.db.users.updateOne,
{ _id: ctx.user._id, 'emails.email': ctx.newEmail },
{ $unset: { 'emails.$.affiliationUnchecked': 1 } }
)
})
it('should not throw if removing affiliationUnchecked flag errors', async function (ctx) {
ctx.db.users.updateOne.rejects(new Error('nope'))
await ctx.UserUpdater.promises.addAffiliationForNewUser(
ctx.user._id,
ctx.newEmail
)
})
it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
await ctx.UserUpdater.promises.addAffiliationForNewUser(
ctx.user._id,
ctx.newEmail
)
expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
'userFullEmails'
)
})
})
describe('changeEmailAddress', function () {
beforeEach(async function (ctx) {
ctx.auditLog = {
initiatorId: 'abc123',
ipAddress: '0:0:0:0',
}
// After the email changed, make sure that UserGetter.getUser() returns a
// user with the new email.
ctx.UserGetter.promises.getUser
.withArgs(ctx.user._id)
.onCall(1)
.resolves({
...ctx.user,
emails: [...ctx.user.emails, { email: ctx.newEmail }],
})
// The main email changes as a result of the email change
ctx.UserGetter.promises.getUserByMainEmail
.withArgs(ctx.user.email)
.resolves(null)
ctx.user.emails.push({ email: ctx.newEmail })
await ctx.UserUpdater.promises.changeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
})
it('adds the new email', function (ctx) {
expect(ctx.db.users.updateOne).to.have.been.calledWith(
{ _id: ctx.user._id, 'emails.email': { $ne: ctx.newEmail } },
{
$push: {
emails: sinon.match({ email: ctx.newEmail }),
},
}
)
})
it('adds the new affiliation', function (ctx) {
ctx.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith(
ctx.user._id,
ctx.newEmail
)
})
it('removes the old email', function (ctx) {
expect(ctx.db.users.updateOne).to.have.been.calledWith(
{ _id: ctx.user._id, email: { $ne: ctx.user.email } },
{ $pull: { emails: { email: ctx.user.email } } }
)
})
it('removes the affiliation', function (ctx) {
expect(
ctx.InstitutionsAPI.promises.removeAffiliation
).to.have.been.calledWith(ctx.user._id, ctx.user.email)
})
it('refreshes features', function (ctx) {
sinon.assert.calledWith(
ctx.FeaturesUpdater.promises.refreshFeatures,
ctx.user._id
)
})
it('sets the default email', function (ctx) {
expect(ctx.db.users.updateOne).to.have.been.calledWith(
{ _id: ctx.user._id, 'emails.email': ctx.newEmail },
{
$set: sinon.match({
email: ctx.newEmail,
}),
}
)
})
it('fires userEmailChanged hook', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'userEmailChanged',
ctx.user,
ctx.newEmail
)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'updateAccountEmailAddress',
ctx.user._id,
ctx.newEmail
)
})
it('validates email', async function (ctx) {
await expect(
ctx.UserUpdater.promises.changeEmailAddress(
ctx.user._id,
'foo',
ctx.auditLog
)
).to.be.rejected
})
})
describe('addEmailAddress', function () {
it('adds the email', async function (ctx) {
await ctx.UserUpdater.promises.addEmailAddress(
ctx.user._id,
ctx.newEmail,
{},
{ initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
)
ctx.UserGetter.promises.ensureUniqueEmailAddress.should.have.been.called
const reversedHostname = ctx.newEmail
.split('@')[1]
.split('')
.reverse()
.join('')
ctx.db.users.updateOne.should.have.been.calledWith(
{ _id: ctx.user._id, 'emails.email': { $ne: ctx.newEmail } },
{
$push: {
emails: {
email: ctx.newEmail,
createdAt: sinon.match.date,
reversedHostname,
},
},
}
)
})
it('adds the affiliation', async function (ctx) {
const affiliationOptions = {
university: { id: 1 },
role: 'Prof',
department: 'Math',
}
await ctx.UserUpdater.promises.addEmailAddress(
ctx.user._id,
ctx.newEmail,
affiliationOptions,
{ initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
)
ctx.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith(
ctx.user._id,
ctx.newEmail,
affiliationOptions
)
})
it('handles affiliation errors', async function (ctx) {
ctx.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
await expect(
ctx.UserUpdater.promises.addEmailAddress(
ctx.user._id,
ctx.newEmail,
{},
{ initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
)
).to.be.rejected
ctx.db.users.updateOne.should.not.have.been.called
})
it('validates the email', async function (ctx) {
expect(
ctx.UserUpdater.promises.addEmailAddress(
ctx.user._id,
'bar',
{},
{ initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
)
).to.be.rejected
})
it('updates the audit log', async function (ctx) {
ctx.ip = '127:0:0:0'
await ctx.UserUpdater.promises.addEmailAddress(
ctx.user._id,
ctx.newEmail,
{},
{ initiatorId: ctx.user._id, ipAddress: ctx.ip }
)
ctx.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true)
const { args } = ctx.UserAuditLogHandler.promises.addEntry.lastCall
expect(args[0]).to.equal(ctx.user._id)
expect(args[1]).to.equal('add-email')
expect(args[2]).to.equal(ctx.user._id)
expect(args[3]).to.equal(ctx.ip)
expect(args[4]).to.deep.equal({ newSecondaryEmail: ctx.newEmail })
})
describe('errors', function () {
describe('via UserAuditLogHandler', function () {
const anError = new Error('oops')
beforeEach(function (ctx) {
ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
})
it('should not add email and should return error', async function (ctx) {
await expect(
ctx.UserUpdater.promises.addEmailAddress(
ctx.user._id,
ctx.newEmail,
{},
{ initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
)
).to.be.rejectedWith(anError)
expect(ctx.db.users.updateOne).to.not.have.been.called
})
})
})
it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
await ctx.UserUpdater.promises.addEmailAddress(
ctx.user._id,
ctx.newEmail,
{},
{ initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
)
expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
'userFullEmails'
)
})
})
describe('removeEmailAddress', function () {
beforeEach(function (ctx) {
ctx.auditLog = { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
})
it('removes the email', async function (ctx) {
await ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
expect(ctx.db.users.updateOne).to.have.been.calledWith(
{ _id: ctx.user._id, email: { $ne: ctx.newEmail } },
{ $pull: { emails: { email: ctx.newEmail } } }
)
})
it('removes the affiliation', async function (ctx) {
await ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
expect(ctx.InstitutionsAPI.promises.removeAffiliation).to.have.been
.calledOnce
const { args } = ctx.InstitutionsAPI.promises.removeAffiliation.lastCall
args[0].should.equal(ctx.user._id)
args[1].should.equal(ctx.newEmail)
})
it('refreshes features', async function (ctx) {
await ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
sinon.assert.calledWith(
ctx.FeaturesUpdater.promises.refreshFeatures,
ctx.user._id
)
})
it('handles Mongo errors', async function (ctx) {
const anError = new Error('nope')
ctx.db.users.updateOne.rejects(anError)
await expect(
ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
).to.be.rejected
expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
.called
})
it('handles missed update', async function (ctx) {
ctx.db.users.updateOne.resolves({ matchedCount: 0 })
await expect(
ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
).to.be.rejectedWith('Cannot remove email')
expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
.called
})
it('handles an affiliation error', async function (ctx) {
const anError = new Error('nope')
ctx.InstitutionsAPI.promises.removeAffiliation.rejects(anError)
await expect(
ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
).to.be.rejected
expect(ctx.db.users.updateOne).not.to.have.been.called
expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
.called
})
it('throws an error when removing the primary email', async function (ctx) {
await expect(
ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.user.email,
ctx.auditLog
)
).to.be.rejectedWith('cannot remove primary email')
expect(ctx.db.users.updateOne).not.to.have.been.called
expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
.called
})
it('validates the email', function (ctx) {
expect(
ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
'baz',
ctx.auditLog
)
).to.be.rejectedWith('invalid email')
})
it('skips email validation when skipParseEmail included', async function (ctx) {
const skipParseEmail = true
await ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
'baz',
ctx.auditLog,
skipParseEmail
)
})
it('throws an error when skipParseEmail included but email is not a string', async function (ctx) {
const skipParseEmail = true
await expect(
ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
1,
ctx.auditLog,
skipParseEmail
)
).to.be.rejectedWith('email must be a string')
})
it('logs the removal to the audit log', async function (ctx) {
await ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith(
ctx.user._id,
'remove-email',
ctx.auditLog.initiatorId,
ctx.auditLog.ipAddress,
{
removedEmail: ctx.newEmail,
}
)
})
it('logs the removal from script to the audit log', async function (ctx) {
ctx.auditLog = {
initiatorId: undefined,
ipAddress: '0.0.0.0',
extraInfo: {
script: true,
},
}
await ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith(
ctx.user._id,
'remove-email',
ctx.auditLog.initiatorId,
ctx.auditLog.ipAddress,
{
removedEmail: ctx.newEmail,
script: true,
}
)
})
it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
await ctx.UserUpdater.promises.removeEmailAddress(
ctx.user._id,
ctx.newEmail,
ctx.auditLog
)
expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
'userFullEmails'
)
})
})
describe('setDefaultEmailAddress', function () {
function setUserEmails(test, emails) {
test.user.emails = emails
test.UserGetter.promises.getUserFullEmails
.withArgs(test.user._id)
.resolves(emails)
}
beforeEach(function (ctx) {
ctx.auditLog = {
initiatorId: ctx.user,
ipAddress: '0:0:0:0',
}
setUserEmails(ctx, [
{
email: ctx.newEmail,
confirmedAt: new Date(),
},
])
})
it('set default', async function (ctx) {
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
expect(ctx.db.users.updateOne).to.have.been.calledWith(
{ _id: ctx.user._id, 'emails.email': ctx.newEmail },
{
$set: {
email: ctx.newEmail,
lastPrimaryEmailCheck: sinon.match.date,
},
}
)
})
it('fires userEmailChanged hook', async function (ctx) {
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'userEmailChanged',
ctx.user,
ctx.newEmail
)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'updateAccountEmailAddress',
ctx.user._id,
ctx.newEmail
)
})
it('handles Mongo errors', async function (ctx) {
ctx.db.users.updateOne = sinon.stub().rejects(Error('nope'))
await expect(
ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
).to.be.rejected
})
it('handles missed updates', async function (ctx) {
ctx.db.users.updateOne.resolves({ matchedCount: 0 })
await expect(
ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
).to.be.rejected
})
it('validates the email', async function (ctx) {
await expect(
ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
'.edu',
false,
ctx.auditLog
)
).to.be.rejected
})
it('updates the audit log', async function (ctx) {
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith(
ctx.user._id,
'change-primary-email',
ctx.auditLog.initiatorId,
ctx.auditLog.ipAddress,
{
newPrimaryEmail: ctx.newEmail,
oldPrimaryEmail: ctx.user.email,
}
)
})
it('blocks email update if audit log returns an error', async function (ctx) {
ctx.UserAuditLogHandler.promises.addEntry.rejects(new Error('oops'))
await expect(
ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
).to.be.rejected
expect(ctx.db.users.updateOne).to.not.have.been.called
})
it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
'userFullEmails'
)
})
describe('when email not confirmed', function () {
beforeEach(function (ctx) {
setUserEmails(ctx, [
{
email: ctx.newEmail,
confirmedAt: null,
},
])
})
it('should throw an error', async function (ctx) {
await expect(
ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
).to.be.rejectedWith(Errors.UnconfirmedEmailError)
expect(ctx.db.users.updateOne).to.not.have.been.called
})
})
describe('when email does not belong to user', function () {
beforeEach(function (ctx) {
setUserEmails(ctx, [])
ctx.UserUpdater.promises.updateUser = sinon.stub()
})
it('should callback with error', function (ctx) {
ctx.UserUpdater.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog,
error => {
expect(error).to.exist
expect(error.name).to.equal('Error')
ctx.UserUpdater.promises.updateUser.callCount.should.equal(0)
}
)
})
})
describe('security alert', function () {
it('should be sent to old and new email when sendSecurityAlert=true', async function (ctx) {
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog,
true
)
// Emails are sent asynchronously. Wait a bit.
await setTimeout(100)
ctx.EmailHandler.promises.sendEmail.callCount.should.equal(2)
for (const recipient of [ctx.user.email, ctx.newEmail]) {
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'securityAlert',
sinon.match({ to: recipient })
)
}
})
it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails', async function (ctx) {
setUserEmails(ctx, [
{
email: '1@a1.uni',
confirmedAt: new Date(2020, 0, 1),
reConfirmedAt: new Date(2021, 2, 11),
lastConfirmedAt: new Date(2021, 2, 11),
default: false,
affiliation: {
institution: {
id: 123,
name: 'A1 University',
},
cachedConfirmedAt: '2020-01-01T18:25:01.639Z',
cachedReconfirmedAt: '2021-03-11T18:25:01.639Z',
},
},
{
email: '2@a1.uni',
confirmedAt: new Date(2019, 0, 1),
reConfirmedAt: new Date(2022, 2, 11),
lastConfirmedAt: new Date(2022, 2, 11),
default: false,
affiliation: {
institution: {
id: 123,
name: 'A1 University',
},
cachedConfirmedAt: '2019-01-01T18:25:01.639Z',
cachedReconfirmedAt: '2022-03-11T18:25:01.639Z',
},
},
{
email: '2020@foo.bar',
confirmedAt: new Date(2020, 6, 1),
lastConfirmedAt: new Date(2020, 6, 1),
},
{
email: '2021@foo.bar',
confirmedAt: new Date(2021, 6, 1),
lastConfirmedAt: new Date(2021, 6, 1),
},
{
email: ctx.user.email,
confirmedAt: new Date(2021, 6, 1),
lastConfirmedAt: new Date(2021, 6, 1),
},
{
email: ctx.newEmail,
confirmedAt: new Date(2021, 6, 1),
lastConfirmedAt: new Date(2021, 6, 1),
},
])
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog,
true
)
// Emails are sent asynchronously. Wait a bit.
await setTimeout(100)
ctx.EmailHandler.promises.sendEmail.callCount.should.equal(4)
for (const recipient of [
ctx.user.email,
ctx.newEmail,
'2@a1.uni',
'2021@foo.bar',
]) {
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'securityAlert',
sinon.match({ to: recipient })
)
}
})
it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails (multiple institutions and unaffiliated email domains)', async function (ctx) {
setUserEmails(ctx, [
{
email: '1@a1.uni',
confirmedAt: new Date(2020, 0, 1),
reConfirmedAt: new Date(2021, 2, 11),
lastConfirmedAt: new Date(2021, 2, 11),
default: false,
affiliation: {
institution: {
id: 123,
name: 'A1 University',
},
cachedConfirmedAt: '2020-01-01T18:25:01.639Z',
cachedReconfirmedAt: '2021-03-11T18:25:01.639Z',
},
},
{
email: '1@b2.uni',
confirmedAt: new Date(2019, 0, 1),
reConfirmedAt: new Date(2022, 2, 11),
lastConfirmedAt: new Date(2022, 2, 11),
default: false,
affiliation: {
institution: {
id: 234,
name: 'B2 University',
},
cachedConfirmedAt: '2019-01-01T18:25:01.639Z',
cachedReconfirmedAt: '2022-03-11T18:25:01.639Z',
},
},
{
email: '2020@foo.bar',
confirmedAt: new Date(2020, 6, 1),
lastConfirmedAt: new Date(2020, 6, 1),
},
{
email: '2021@bar.foo',
confirmedAt: new Date(2021, 6, 1),
lastConfirmedAt: new Date(2021, 6, 1),
},
{
email: ctx.user.email,
confirmedAt: new Date(2021, 6, 1),
lastConfirmedAt: new Date(2021, 6, 1),
},
{
email: ctx.newEmail,
confirmedAt: new Date(2021, 6, 1),
lastConfirmedAt: new Date(2021, 6, 1),
},
])
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog,
true
)
// Emails are sent asynchronously. Wait a bit.
await setTimeout(100)
ctx.EmailHandler.promises.sendEmail.callCount.should.equal(6)
for (const recipient of [
ctx.user.email,
ctx.newEmail,
'1@a1.uni',
'1@b2.uni',
'2020@foo.bar',
'2021@bar.foo',
]) {
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'securityAlert',
sinon.match({ to: recipient })
)
}
})
describe('errors', function () {
const anError = new Error('oops')
describe('EmailHandler', function () {
beforeEach(function (ctx) {
ctx.EmailHandler.promises.sendEmail.rejects(anError)
})
it('should log but not pass back the error', async function (ctx) {
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog,
true
)
const loggerCall = ctx.logger.error.mock.calls[0]
expect(loggerCall[0]).to.deep.equal({
error: anError,
userId: ctx.user._id,
})
expect(loggerCall[1]).to.contain(
'could not send security alert email when primary email changed'
)
})
})
})
})
})
describe('confirmEmail', function () {
it('should update the email record', async function (ctx) {
await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.user.email)
expect(ctx.db.users.updateOne).to.have.been.calledWith(
{
_id: ctx.user._id,
'emails.email': ctx.user.email,
},
{
$set: {
'emails.$.reconfirmedAt': new Date(),
},
$min: {
'emails.$.confirmedAt': new Date(),
},
}
)
})
it('adds affiliation', async function (ctx) {
await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
ctx.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true)
sinon.assert.calledWith(
ctx.InstitutionsAPI.promises.addAffiliation,
ctx.user._id,
ctx.newEmail,
{ confirmedAt: new Date() }
)
})
it('handles errors', async function (ctx) {
ctx.db.users.updateOne.rejects(new Error('nope'))
await expect(
ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
).to.be.rejected
})
it('handle missed update', async function (ctx) {
ctx.db.users.updateOne.resolves({ matchedCount: 0 })
await expect(
ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
).to.be.rejected
})
it('validates email', async function (ctx) {
expect(ctx.UserUpdater.promises.confirmEmail(ctx.user._id, '@')).to.be
.rejected
})
it('handles affiliation errors', async function (ctx) {
ctx.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
await expect(
ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
).to.be.rejected
expect(ctx.db.users.updateOne).to.not.have.been.called
})
it('refreshes features', async function (ctx) {
await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
sinon.assert.calledWith(
ctx.FeaturesUpdater.promises.refreshFeatures,
ctx.user._id
)
})
it('should not call redundantPersonalSubscription when user is not on a commons license', async function (ctx) {
ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([])
ctx.SubscriptionLocator.promises.getUserIndividualSubscription.resolves({
planCode: 'personal',
groupPlan: false,
})
await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
sinon.assert.notCalled(
ctx.NotificationsBuilder.promises.redundantPersonalSubscription
)
})
it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
expect(ctx.AsyncLocalStorage.removeItem).to.have.been.called
expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
'userFullEmails'
)
})
describe('with institution licence and subscription', function () {
beforeEach(async function (ctx) {
ctx.affiliation = {
email: ctx.newEmail,
licence: 'pro_plus',
institution: {
id: 123,
name: 'Institution',
},
}
ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([
ctx.affiliation,
{ email: 'other@email.edu' },
])
ctx.SubscriptionLocator.promises.getUserIndividualSubscription.resolves(
{
planCode: 'personal',
groupPlan: false,
}
)
})
it('creates redundant subscription notification', async function (ctx) {
await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
sinon.assert.calledWith(
ctx.InstitutionsAPI.promises.getUserAffiliations,
ctx.user._id
)
sinon.assert.calledWith(
ctx.SubscriptionLocator.promises.getUserIndividualSubscription,
ctx.user._id
)
sinon.assert.calledWith(
ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
{
institutionId: 123,
institutionName: 'Institution',
},
{ _id: ctx.user._id }
)
})
})
})
describe('suspendUser', function () {
beforeEach(function (ctx) {
ctx.auditLog = {
initiatorId: 'abc123',
ip: '0.0.0.0',
}
})
it('should suspend the user', async function (ctx) {
await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
expect(ctx.db.users.updateOne).to.have.been.calledWith(
{ _id: ctx.user._id, suspended: { $ne: true } },
{ $set: { suspended: true } }
)
})
it('should remove sessions from redis', async function (ctx) {
await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
expect(
ctx.UserSessionsManager.promises.removeSessionsFromRedis
).to.have.been.calledWith({ _id: ctx.user._id })
})
it('should log the suspension to the audit log', async function (ctx) {
await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith(
ctx.user._id,
'account-suspension',
ctx.auditLog.initiatorId,
ctx.auditLog.ip,
{}
)
})
it('should fire the removeDropbox hook', async function (ctx) {
await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'removeDropbox',
ctx.user._id,
'account-suspension'
)
})
it('should handle not finding a record to update', async function (ctx) {
ctx.db.users.updateOne.resolves({ matchedCount: 0 })
await expect(
ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
})