mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-06 23:59:01 +02:00
a0a621f727
Replace Mailchimp newsletter subscriptions with customer.io topic subscriptions GitOrigin-RevId: de536fb7ef24ff44c494659b2ef5ede5b33cc977
1236 lines
36 KiB
JavaScript
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)
|
|
})
|
|
})
|
|
})
|