Merge pull request #28981 from overleaf/ac-some-web-esm-migration-6

[web] Convert some Features/User files to ES modules

GitOrigin-RevId: c0d487082fa4822c68130e1e98c043297d4bedeb
This commit is contained in:
Antoine Clausse
2025-10-16 13:18:53 +02:00
committed by Copybot
parent 8f54468566
commit a6438f03d6
34 changed files with 2693 additions and 2308 deletions

View File

@@ -3,8 +3,8 @@ import Modules from '../../infrastructure/Modules.js'
import ChatApiHandler from './ChatApiHandler.js'
import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
import SessionManager from '../Authentication/SessionManager.js'
import UserInfoManager from '../User/UserInfoManager.js'
import UserInfoController from '../User/UserInfoController.js'
import UserInfoManager from '../User/UserInfoManager.mjs'
import UserInfoController from '../User/UserInfoController.mjs'
import ChatManager from './ChatManager.mjs'
async function sendMessage(req, res) {

View File

@@ -1,4 +1,4 @@
import UserInfoController from '../User/UserInfoController.js'
import UserInfoController from '../User/UserInfoController.mjs'
import UserGetter from '../User/UserGetter.js'
import { callbackify } from '@overleaf/promise-utils'

View File

@@ -20,7 +20,7 @@ import NotificationsHandler from '../Notifications/NotificationsHandler.js'
import Modules from '../../infrastructure/Modules.js'
import { OError, V1ConnectionError } from '../Errors/Errors.js'
import { User } from '../../models/User.js'
import UserPrimaryEmailCheckHandler from '../User/UserPrimaryEmailCheckHandler.js'
import UserPrimaryEmailCheckHandler from '../User/UserPrimaryEmailCheckHandler.mjs'
import UserController from '../User/UserController.mjs'
import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
import GeoIpLookup from '../../infrastructure/GeoIpLookup.mjs'

View File

@@ -1,16 +1,18 @@
const { ObjectId } = require('mongodb-legacy')
const EmailHandler = require('../Email/EmailHandler')
const Errors = require('../Errors/Errors')
const InstitutionsAPI = require('../Institutions/InstitutionsAPI')
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
const OError = require('@overleaf/o-error')
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
const UserGetter = require('../User/UserGetter')
const UserUpdater = require('../User/UserUpdater')
const logger = require('@overleaf/logger')
const { User } = require('../../models/User')
const { promiseMapWithLimit } = require('@overleaf/promise-utils')
import mongodb from 'mongodb-legacy'
import EmailHandler from '../Email/EmailHandler.js'
import Errors from '../Errors/Errors.js'
import InstitutionsAPI from '../Institutions/InstitutionsAPI.js'
import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
import OError from '@overleaf/o-error'
import SubscriptionLocator from '../Subscription/SubscriptionLocator.js'
import UserAuditLogHandler from '../User/UserAuditLogHandler.js'
import UserGetter from '../User/UserGetter.js'
import UserUpdater from '../User/UserUpdater.js'
import logger from '@overleaf/logger'
import { User } from '../../models/User.js'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
const { ObjectId } = mongodb
async function _addAuditLogEntry(operation, userId, auditLog, extraInfo) {
await UserAuditLogHandler.promises.addEntry(
@@ -469,4 +471,4 @@ const SAMLIdentityManager = {
userHasEntitlement,
}
module.exports = SAMLIdentityManager
export default SAMLIdentityManager

View File

@@ -1,15 +1,15 @@
const logger = require('@overleaf/logger')
const util = require('util')
const { AffiliationError } = require('../Errors/Errors')
const Features = require('../../infrastructure/Features')
const { User } = require('../../models/User')
const UserDeleter = require('./UserDeleter')
const UserGetter = require('./UserGetter')
const UserUpdater = require('./UserUpdater')
const Analytics = require('../Analytics/AnalyticsManager')
const UserOnboardingEmailManager = require('./UserOnboardingEmailManager')
const UserPostRegistrationAnalyticsManager = require('./UserPostRegistrationAnalyticsManager')
const OError = require('@overleaf/o-error')
import logger from '@overleaf/logger'
import util from 'node:util'
import { AffiliationError } from '../Errors/Errors.js'
import Features from '../../infrastructure/Features.js'
import { User } from '../../models/User.js'
import UserDeleter from './UserDeleter.js'
import UserGetter from './UserGetter.js'
import UserUpdater from './UserUpdater.js'
import Analytics from '../Analytics/AnalyticsManager.js'
import UserOnboardingEmailManager from './UserOnboardingEmailManager.js'
import UserPostRegistrationAnalyticsManager from './UserPostRegistrationAnalyticsManager.js'
import OError from '@overleaf/o-error'
async function _addAffiliation(user, affiliationOptions) {
try {
@@ -142,4 +142,4 @@ const UserCreator = {
},
}
module.exports = UserCreator
export default UserCreator

View File

@@ -1,13 +1,13 @@
const EmailHelper = require('../Helpers/EmailHelper')
const EmailHandler = require('../Email/EmailHandler')
const OneTimeTokenHandler = require('../Security/OneTimeTokenHandler')
const settings = require('@overleaf/settings')
const Errors = require('../Errors/Errors')
const UserUpdater = require('./UserUpdater')
const UserGetter = require('./UserGetter')
const { callbackify } = require('util')
const crypto = require('crypto')
const SessionManager = require('../Authentication/SessionManager')
import EmailHelper from '../Helpers/EmailHelper.js'
import EmailHandler from '../Email/EmailHandler.js'
import OneTimeTokenHandler from '../Security/OneTimeTokenHandler.js'
import settings from '@overleaf/settings'
import Errors from '../Errors/Errors.js'
import UserUpdater from './UserUpdater.js'
import UserGetter from './UserGetter.js'
import { callbackify } from 'node:util'
import crypto from 'node:crypto'
import SessionManager from '../Authentication/SessionManager.js'
// Reject email confirmation tokens after 90 days
const TOKEN_EXPIRY_IN_S = 90 * 24 * 60 * 60
@@ -104,4 +104,4 @@ UserEmailsConfirmationHandler.promises = {
sendConfirmationCode,
}
module.exports = UserEmailsConfirmationHandler
export default UserEmailsConfirmationHandler

View File

@@ -1,25 +1,25 @@
const AuthenticationController = require('../Authentication/AuthenticationController')
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const SessionManager = require('../Authentication/SessionManager')
const UserGetter = require('./UserGetter')
const UserUpdater = require('./UserUpdater')
const UserSessionsManager = require('./UserSessionsManager')
const EmailHandler = require('../Email/EmailHandler')
const EmailHelper = require('../Helpers/EmailHelper')
const UserEmailsConfirmationHandler = require('./UserEmailsConfirmationHandler')
const { endorseAffiliation } = require('../Institutions/InstitutionsAPI')
const Errors = require('../Errors/Errors')
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const { expressify } = require('@overleaf/promise-utils')
const AsyncFormHelper = require('../Helpers/AsyncFormHelper')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
const UserAuditLogHandler = require('./UserAuditLogHandler')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const Features = require('../../infrastructure/Features')
const tsscmp = require('tsscmp')
const Modules = require('../../infrastructure/Modules')
import AuthenticationController from '../Authentication/AuthenticationController.js'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import SessionManager from '../Authentication/SessionManager.js'
import UserGetter from './UserGetter.js'
import UserUpdater from './UserUpdater.js'
import UserSessionsManager from './UserSessionsManager.js'
import EmailHandler from '../Email/EmailHandler.js'
import EmailHelper from '../Helpers/EmailHelper.js'
import UserEmailsConfirmationHandler from './UserEmailsConfirmationHandler.mjs'
import InstitutionsAPI from '../Institutions/InstitutionsAPI.js'
import Errors from '../Errors/Errors.js'
import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
import { expressify } from '@overleaf/promise-utils'
import AsyncFormHelper from '../Helpers/AsyncFormHelper.js'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import UserPrimaryEmailCheckHandler from '../User/UserPrimaryEmailCheckHandler.mjs'
import UserAuditLogHandler from './UserAuditLogHandler.js'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
import Features from '../../infrastructure/Features.js'
import tsscmp from 'tsscmp'
import Modules from '../../infrastructure/Modules.js'
const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
@@ -615,7 +615,7 @@ const UserEmailsController = {
return res.sendStatus(422)
}
endorseAffiliation(
InstitutionsAPI.endorseAffiliation(
userId,
email,
req.body.role,
@@ -729,4 +729,4 @@ const UserEmailsController = {
},
}
module.exports = UserEmailsController
export default UserEmailsController

View File

@@ -1,7 +1,9 @@
const UserGetter = require('./UserGetter')
const SessionManager = require('../Authentication/SessionManager')
const { ObjectId } = require('mongodb-legacy')
const { expressify } = require('@overleaf/promise-utils')
import UserGetter from './UserGetter.js'
import SessionManager from '../Authentication/SessionManager.js'
import mongodb from 'mongodb-legacy'
import { expressify } from '@overleaf/promise-utils'
const { ObjectId } = mongodb
function getLoggedInUsersPersonalInfo(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -88,7 +90,7 @@ async function getUserFeatures(req, res, next) {
return res.json(features)
}
module.exports = {
export default {
getLoggedInUsersPersonalInfo,
getPersonalInfo,
sendFormattedPersonalInfo,

View File

@@ -1,5 +1,5 @@
const UserGetter = require('./UserGetter')
const { callbackify } = require('@overleaf/promise-utils')
import UserGetter from './UserGetter.js'
import { callbackify } from '@overleaf/promise-utils'
async function getPersonalInfo(userId) {
return UserGetter.promises.getUser(userId, {
@@ -10,7 +10,7 @@ async function getPersonalInfo(userId) {
})
}
module.exports = {
export default {
getPersonalInfo: callbackify(getPersonalInfo),
promises: {
getPersonalInfo,

View File

@@ -1,4 +1,4 @@
const Settings = require('@overleaf/settings')
import Settings from '@overleaf/settings'
function requiresPrimaryEmailCheck({
email,
@@ -28,6 +28,6 @@ function requiresPrimaryEmailCheck({
}
}
module.exports = {
export default {
requiresPrimaryEmailCheck,
}

View File

@@ -1,19 +1,16 @@
const { User } = require('../../models/User')
const UserCreator = require('./UserCreator')
const UserGetter = require('./UserGetter')
const AuthenticationManager = require('../Authentication/AuthenticationManager')
const NewsletterManager = require('../Newsletter/NewsletterManager')
const logger = require('@overleaf/logger')
const crypto = require('crypto')
const EmailHandler = require('../Email/EmailHandler')
const OneTimeTokenHandler = require('../Security/OneTimeTokenHandler')
const settings = require('@overleaf/settings')
const EmailHelper = require('../Helpers/EmailHelper')
const {
callbackify,
callbackifyMultiResult,
} = require('@overleaf/promise-utils')
const OError = require('@overleaf/o-error')
import { User } from '../../models/User.js'
import UserCreator from './UserCreator.mjs'
import UserGetter from './UserGetter.js'
import AuthenticationManager from '../Authentication/AuthenticationManager.js'
import NewsletterManager from '../Newsletter/NewsletterManager.js'
import logger from '@overleaf/logger'
import crypto from 'node:crypto'
import EmailHandler from '../Email/EmailHandler.js'
import OneTimeTokenHandler from '../Security/OneTimeTokenHandler.js'
import settings from '@overleaf/settings'
import EmailHelper from '../Helpers/EmailHelper.js'
import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
import OError from '@overleaf/o-error'
const UserRegistrationHandler = {
_registrationRequestIsValid(body) {
@@ -126,7 +123,7 @@ const UserRegistrationHandler = {
},
}
module.exports = {
export default {
registerNewUser: callbackify(UserRegistrationHandler.registerNewUser),
registerNewUserAndSendActivationEmail: callbackifyMultiResult(
UserRegistrationHandler.registerNewUserAndSendActivationEmail,

View File

@@ -18,9 +18,9 @@ import SessionManager from './Features/Authentication/SessionManager.js'
import TagsController from './Features/Tags/TagsController.mjs'
import NotificationsController from './Features/Notifications/NotificationsController.mjs'
import CollaboratorsRouter from './Features/Collaborators/CollaboratorsRouter.mjs'
import UserInfoController from './Features/User/UserInfoController.js'
import UserInfoController from './Features/User/UserInfoController.mjs'
import UserController from './Features/User/UserController.mjs'
import UserEmailsController from './Features/User/UserEmailsController.js'
import UserEmailsController from './Features/User/UserEmailsController.mjs'
import UserPagesController from './Features/User/UserPagesController.mjs'
import TutorialController from './Features/Tutorial/TutorialController.mjs'
import DocumentController from './Features/Documents/DocumentController.mjs'

View File

@@ -4,7 +4,7 @@ import Settings from '@overleaf/settings'
import Path from 'node:path'
import { fileURLToPath } from 'node:url'
import logger from '@overleaf/logger'
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.js'
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs'
import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.js'
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
import { User } from '../../../../app/src/models/User.js'

View File

@@ -34,7 +34,7 @@ describe('LaunchpadController', function () {
}))
vi.doMock(
'../../../../../app/src/Features/User/UserRegistrationHandler.js',
'../../../../../app/src/Features/User/UserRegistrationHandler.mjs',
() => ({
default: (ctx.UserRegistrationHandler = {
promises: {},

View File

@@ -1,6 +1,6 @@
import minimist from 'minimist'
import { db } from '../../../app/src/infrastructure/mongodb.js'
import UserRegistrationHandler from '../../../app/src/Features/User/UserRegistrationHandler.js'
import UserRegistrationHandler from '../../../app/src/Features/User/UserRegistrationHandler.mjs'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)

View File

@@ -1,7 +1,7 @@
import Path from 'node:path'
import { fileURLToPath } from 'node:url'
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.js'
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs'
import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs'
import { expressify } from '@overleaf/promise-utils'

View File

@@ -35,7 +35,7 @@ describe('UserActivateController', function () {
}))
vi.doMock(
'../../../../../app/src/Features/User/UserRegistrationHandler.js',
'../../../../../app/src/Features/User/UserRegistrationHandler.mjs',
() => ({
default: ctx.UserRegistrationHandler,
})

View File

@@ -8,7 +8,7 @@ import GracefulShutdown from '../app/src/infrastructure/GracefulShutdown.js'
import ProjectDeleter from '../app/src/Features/Project/ProjectDeleter.js'
import SplitTestManager from '../app/src/Features/SplitTests/SplitTestManager.js'
import UserDeleter from '../app/src/Features/User/UserDeleter.js'
import UserRegistrationHandler from '../app/src/Features/User/UserRegistrationHandler.js'
import UserRegistrationHandler from '../app/src/Features/User/UserRegistrationHandler.mjs'
const MONOREPO = Path.dirname(
Path.dirname(Path.dirname(Path.dirname(fileURLToPath(import.meta.url))))

View File

@@ -17,7 +17,7 @@ import LockManager from '../../../app/src/infrastructure/LockManager.js'
import ProjectCreationHandler from '../../../app/src/Features/Project/ProjectCreationHandler.js'
import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
import ProjectEntityMongoUpdateHandler from '../../../app/src/Features/Project/ProjectEntityMongoUpdateHandler.js'
import UserCreator from '../../../app/src/Features/User/UserCreator.js'
import UserCreator from '../../../app/src/Features/User/UserCreator.mjs'
import { expect } from 'chai'
import _ from 'lodash'

View File

@@ -2,7 +2,7 @@ import { CookieJar } from 'tough-cookie'
import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.js'
import Settings from '@overleaf/settings'
import InstitutionsAPI from '../../../../app/src/Features/Institutions/InstitutionsAPI.js'
import UserCreator from '../../../../app/src/Features/User/UserCreator.js'
import UserCreator from '../../../../app/src/Features/User/UserCreator.mjs'
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js'
import moment from 'moment'

View File

@@ -51,12 +51,12 @@ describe('ChatController', function () {
})
)
vi.doMock('../../../../app/src/Features/User/UserInfoManager.js', () => ({
vi.doMock('../../../../app/src/Features/User/UserInfoManager.mjs', () => ({
default: ctx.UserInfoManager,
}))
vi.doMock(
'../../../../app/src/Features/User/UserInfoController.js',
'../../../../app/src/Features/User/UserInfoController.mjs',
() => ({
default: ctx.UserInfoController,
})

View File

@@ -1,108 +1,153 @@
const { ObjectId } = require('mongodb-legacy')
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'
import { vi, expect } from 'vitest'
import mongodb from 'mongodb-legacy'
import sinon from 'sinon'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
const { ObjectId } = mongodb
const modulePath = '../../../../app/src/Features/User/SAMLIdentityManager.mjs'
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('SAMLIdentityManager', function () {
const linkedEmail = 'another@example.com'
beforeEach(function () {
this.userId = '6005c75b12cbcaf771f4a105'
this.user = {
_id: this.userId,
beforeEach(async function (ctx) {
ctx.userId = '6005c75b12cbcaf771f4a105'
ctx.user = {
_id: ctx.userId,
email: 'not-linked@overleaf.com',
emails: [{ email: 'not-linked@overleaf.com' }],
samlIdentifiers: [],
}
this.auditLog = {
initiatorId: this.userId,
ctx.auditLog = {
initiatorId: ctx.userId,
ipAddress: '0:0:0:0',
}
this.userAlreadyLinked = {
ctx.userAlreadyLinked = {
_id: '6005c7a012cbcaf771f4a106',
email: 'linked@overleaf.com',
emails: [{ email: 'linked@overleaf.com', samlProviderId: '1' }],
samlIdentifiers: [{ externalUserId: 'linked-id', providerId: '1' }],
}
this.userEmailExists = {
ctx.userEmailExists = {
_id: '6005c7a012cbcaf771f4a107',
email: 'exists@overleaf.com',
emails: [{ email: 'exists@overleaf.com' }],
samlIdentifiers: [],
}
this.institution = {
ctx.institution = {
name: 'Overleaf University',
}
this.InstitutionsAPI = {
ctx.InstitutionsAPI = {
promises: {
addEntitlement: sinon.stub().resolves(),
removeEntitlement: sinon.stub().resolves(),
},
}
this.SAMLIdentityManager = SandboxedModule.require(modulePath, {
requires: {
'mongodb-legacy': { ObjectId },
'../Email/EmailHandler': (this.EmailHandler = {
sendEmail: sinon.stub().yields(),
}),
'../Notifications/NotificationsBuilder': (this.NotificationsBuilder = {
ctx.logger = {
error: sinon.stub(),
}
vi.doMock('@overleaf/logger', () => ({
default: ctx.logger,
}))
vi.doMock('mongodb-legacy', () => ({
default: { ObjectId },
}))
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
default: (ctx.EmailHandler = {
sendEmail: sinon.stub().yields(),
}),
}))
vi.doMock(
'../../../../app/src/Features/Notifications/NotificationsBuilder',
() => ({
default: (ctx.NotificationsBuilder = {
promises: {
redundantPersonalSubscription: sinon
.stub()
.returns({ create: sinon.stub().resolves() }),
},
}),
'../Subscription/SubscriptionLocator': (this.SubscriptionLocator = {
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionLocator',
() => ({
default: (ctx.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(),
}),
}),
})
)
vi.doMock('../../../../app/src/models/User', () => ({
User: (ctx.User = {
findOneAndUpdate: sinon.stub().returns({
exec: sinon.stub().resolves(ctx.user),
}),
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
updateOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
}),
}))
vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
default: (ctx.UserAuditLogHandler = {
promises: {
addEntry: 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,
},
})
}),
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: (ctx.UserGetter = {
getUser: sinon.stub(),
promises: {
getUser: sinon.stub().resolves(ctx.user),
getUserByAnyEmail: sinon.stub().resolves(),
getUserFullEmails: sinon.stub().resolves(),
},
}),
}))
vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
default: (ctx.UserUpdater = {
addEmailAddress: sinon.stub(),
promises: {
addEmailAddress: sinon.stub().resolves(),
confirmEmail: sinon.stub().resolves(),
updateUser: sinon.stub().resolves(),
},
}),
}))
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsAPI',
() => ({
default: ctx.InstitutionsAPI,
})
)
ctx.SAMLIdentityManager = (await import(modulePath)).default
})
describe('getUser', function () {
it('should throw an error if missing all of: provider ID, external user ID, attribute', async function () {
it('should throw an error if missing all of: provider ID, external user ID, attribute', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.getUser(undefined, undefined, undefined)
await ctx.SAMLIdentityManager.getUser(undefined, undefined, undefined)
} catch (e) {
error = e
} finally {
@@ -112,10 +157,10 @@ describe('SAMLIdentityManager', function () {
)
}
})
it('should throw an error if missing provider ID', async function () {
it('should throw an error if missing provider ID', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr')
await ctx.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr')
} catch (e) {
error = e
} finally {
@@ -125,20 +170,20 @@ describe('SAMLIdentityManager', function () {
)
}
})
it('should throw an error if missing external user ID', async function () {
it('should throw an error if missing external user ID', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.getUser('123', null, 'someAttr')
await ctx.SAMLIdentityManager.getUser('123', null, 'someAttr')
} catch (e) {
error = e
} finally {
expect(error).to.exist
}
})
it('should throw an error if missing attribute', async function () {
it('should throw an error if missing attribute', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.getUser('123', 'id123', undefined)
await ctx.SAMLIdentityManager.getUser('123', 'id123', undefined)
} catch (e) {
error = e
} finally {
@@ -152,16 +197,16 @@ describe('SAMLIdentityManager', function () {
describe('linkAccounts', function () {
describe('errors', function () {
beforeEach(function () {
beforeEach(function (ctx) {
// first call is to get userWithProvider; should be falsy
this.UserGetter.promises.getUser.onFirstCall().resolves()
this.UserGetter.promises.getUser.onSecondCall().resolves(this.user)
ctx.UserGetter.promises.getUser.onFirstCall().resolves()
ctx.UserGetter.promises.getUser.onSecondCall().resolves(ctx.user)
})
it('should throw an error if missing all data', async function () {
it('should throw an error if missing all data', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.linkAccounts(null, null, null)
await ctx.SAMLIdentityManager.linkAccounts(null, null, null)
} catch (e) {
error = e
} finally {
@@ -180,9 +225,9 @@ describe('SAMLIdentityManager', function () {
const testData = { ...requiredData }
delete testData[data]
let error
it(`should throw an error when missing ${data}`, async function () {
it(`should throw an error when missing ${data}`, async function (ctx) {
try {
await this.SAMLIdentityManager.linkAccounts('123', testData, {})
await ctx.SAMLIdentityManager.linkAccounts('123', testData, {})
} catch (e) {
error = e
} finally {
@@ -196,17 +241,17 @@ describe('SAMLIdentityManager', function () {
})
describe('when email is already associated with another Overleaf account', function () {
beforeEach(function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(
this.userEmailExists
beforeEach(function (ctx) {
ctx.UserGetter.promises.getUserByAnyEmail.resolves(
ctx.userEmailExists
)
})
it('should throw an EmailExistsError error', async function () {
it('should throw an EmailExistsError error', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
await ctx.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'not-linked-id',
@@ -225,25 +270,25 @@ describe('SAMLIdentityManager', function () {
error = e
} finally {
expect(error).to.be.instanceof(Errors.EmailExistsError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
expect(ctx.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([
beforeEach(function (ctx) {
ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
ctx.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'not-affiliated@overleaf.com',
},
])
})
it('should throw SAMLEmailNotAffiliatedError', async function () {
it('should throw SAMLEmailNotAffiliatedError', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
await ctx.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'not-linked-id',
@@ -262,15 +307,15 @@ describe('SAMLIdentityManager', function () {
error = e
} finally {
expect(error).to.be.instanceof(Errors.SAMLEmailNotAffiliatedError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
expect(ctx.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([
beforeEach(function (ctx) {
ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
ctx.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'affiliated@overleaf.com',
affiliation: { institution: { id: '987' } },
@@ -278,10 +323,10 @@ describe('SAMLIdentityManager', function () {
])
})
it('should throw SAMLEmailAffiliatedWithAnotherInstitutionError', async function () {
it('should throw SAMLEmailAffiliatedWithAnotherInstitutionError', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
await ctx.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'not-linked-id',
@@ -302,22 +347,22 @@ describe('SAMLIdentityManager', function () {
expect(error).to.be.instanceof(
Errors.SAMLEmailAffiliatedWithAnotherInstitutionError
)
expect(this.User.findOneAndUpdate).to.not.have.been.called
expect(ctx.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
beforeEach(function (ctx) {
ctx.UserGetter.promises.getUserByAnyEmail.resolves(
ctx.userAlreadyLinked
)
})
it('should throw an SAMLIdentityExistsError error', async function () {
it('should throw an SAMLIdentityExistsError error', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
await ctx.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'already-linked-id',
@@ -336,21 +381,21 @@ describe('SAMLIdentityManager', function () {
error = e
} finally {
expect(error).to.be.instanceof(Errors.SAMLIdentityExistsError)
expect(this.User.findOneAndUpdate).to.not.have.been.called
expect(ctx.User.findOneAndUpdate).to.not.have.been.called
}
})
})
describe('when institution provider is already associated with the user', function () {
beforeEach(function () {
beforeEach(function (ctx) {
// first call is to get userWithProvider; resolves with any user
this.UserGetter.promises.getUser.onFirstCall().resolves(this.user)
ctx.UserGetter.promises.getUser.onFirstCall().resolves(ctx.user)
})
it('should throw an SAMLAlreadyLinkedError error', async function () {
it('should throw an SAMLAlreadyLinkedError error', async function (ctx) {
let error
try {
await this.SAMLIdentityManager.linkAccounts(
await ctx.SAMLIdentityManager.linkAccounts(
'6005c75b12cbcaf771f4a105',
{
externalUserId: 'already-linked-id',
@@ -369,27 +414,27 @@ describe('SAMLIdentityManager', function () {
error = e
} finally {
expect(
this.UserGetter.promises.getUser
ctx.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
expect(ctx.User.findOneAndUpdate).to.not.have.been.called
}
})
})
it('should pass back errors via UserAuditLogHandler', async function () {
it('should pass back errors via UserAuditLogHandler', async function (ctx) {
let error
const anError = new Error('oops')
this.UserAuditLogHandler.promises.addEntry.rejects(anError)
ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
try {
await this.SAMLIdentityManager.linkAccounts(
this.user._id,
await ctx.SAMLIdentityManager.linkAccounts(
ctx.user._id,
{
externalUserId: 'externalUserId',
institutionEmail: this.user.email,
institutionEmail: ctx.user.email,
universityId: '1',
universityName: 'Overleaf University',
hasEntitlement: false,
@@ -405,29 +450,29 @@ describe('SAMLIdentityManager', function () {
} 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
expect(ctx.EmailHandler.sendEmail).to.not.have.been.called
expect(ctx.User.updateOne).to.not.have.been.called
}
})
})
describe('success', function () {
beforeEach(function () {
beforeEach(function (ctx) {
// first call is to get userWithProvider; should be falsy
this.UserGetter.promises.getUser.onFirstCall().resolves()
this.UserGetter.promises.getUser.onSecondCall().resolves(this.user)
ctx.UserGetter.promises.getUser.onFirstCall().resolves()
ctx.UserGetter.promises.getUser.onSecondCall().resolves(ctx.user)
})
it('should update the user audit log', async function () {
it('should update the user audit log', async function (ctx) {
const auditLog = {
initiatorId: '6005c75b12cbcaf771f4a105',
ipAddress: '0:0:0:0',
}
await this.SAMLIdentityManager.linkAccounts(
this.user._id,
await ctx.SAMLIdentityManager.linkAccounts(
ctx.user._id,
{
externalUserId: 'externalUserId',
institutionEmail: this.user.email,
institutionEmail: ctx.user.email,
universityId: '1',
universityName: 'Overleaf University',
hasEntitlement: false,
@@ -437,14 +482,14 @@ describe('SAMLIdentityManager', function () {
)
expect(
this.UserAuditLogHandler.promises.addEntry
ctx.UserAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
this.user._id,
ctx.user._id,
'link-institution-sso',
auditLog.initiatorId,
auditLog.ipAddress,
{
institutionEmail: this.user.email,
institutionEmail: ctx.user.email,
providerId: '1',
providerName: 'Overleaf University',
userIdAttribute: 'uniqueId',
@@ -453,12 +498,12 @@ describe('SAMLIdentityManager', function () {
)
})
it('should send an email notification', async function () {
await this.SAMLIdentityManager.linkAccounts(
this.user._id,
it('should send an email notification', async function (ctx) {
await ctx.SAMLIdentityManager.linkAccounts(
ctx.user._id,
{
externalUserId: 'externalUserId',
institutionEmail: this.user.email,
institutionEmail: ctx.user.email,
universityId: '1',
universityName: 'Overleaf University',
hasEntitlement: false,
@@ -470,35 +515,35 @@ describe('SAMLIdentityManager', function () {
}
)
expect(this.User.findOneAndUpdate).to.have.been.called
expect(this.EmailHandler.sendEmail).to.have.been.calledOnce
const emailArgs = this.EmailHandler.sendEmail.lastCall.args
expect(ctx.User.findOneAndUpdate).to.have.been.called
expect(ctx.EmailHandler.sendEmail).to.have.been.calledOnce
const emailArgs = ctx.EmailHandler.sendEmail.lastCall.args
expect(emailArgs[0]).to.equal('securityAlert')
expect(emailArgs[1].to).to.equal(this.user.email)
expect(emailArgs[1].to).to.equal(ctx.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)
expect(emailArgs[1].message[0]).to.contain(ctx.user.email)
})
})
})
describe('unlinkAccounts', function () {
it('should update the audit log', async function () {
await this.SAMLIdentityManager.unlinkAccounts(
this.user._id,
it('should update the audit log', async function (ctx) {
await ctx.SAMLIdentityManager.unlinkAccounts(
ctx.user._id,
linkedEmail,
this.user.email,
ctx.user.email,
'1',
'Overleaf University',
this.auditLog
ctx.auditLog
)
expect(
this.UserAuditLogHandler.promises.addEntry
ctx.UserAuditLogHandler.promises.addEntry
).to.have.been.calledOnce.and.calledWithMatch(
this.user._id,
ctx.user._id,
'unlink-institution-sso',
this.auditLog.initiatorId,
this.auditLog.ipAddress,
ctx.auditLog.initiatorId,
ctx.auditLog.ipAddress,
{
institutionEmail: linkedEmail,
providerId: '1',
@@ -506,17 +551,17 @@ describe('SAMLIdentityManager', function () {
}
)
})
it('should remove the identifier', async function () {
await this.SAMLIdentityManager.unlinkAccounts(
this.user._id,
it('should remove the identifier', async function (ctx) {
await ctx.SAMLIdentityManager.unlinkAccounts(
ctx.user._id,
linkedEmail,
this.user.email,
ctx.user.email,
'1',
'Overleaf University',
this.auditLog
ctx.auditLog
)
const query = {
_id: this.user._id,
_id: ctx.user._id,
}
const update = {
$pull: {
@@ -525,94 +570,94 @@ describe('SAMLIdentityManager', function () {
},
},
}
expect(this.User.updateOne).to.have.been.calledOnce.and.calledWithMatch(
expect(ctx.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,
it('should send an email notification', async function (ctx) {
await ctx.SAMLIdentityManager.unlinkAccounts(
ctx.user._id,
linkedEmail,
this.user.email,
ctx.user.email,
'1',
'Overleaf University',
this.auditLog
ctx.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(ctx.User.updateOne).to.have.been.called
expect(ctx.EmailHandler.sendEmail).to.have.been.calledOnce
const emailArgs = ctx.EmailHandler.sendEmail.lastCall.args
expect(emailArgs[0]).to.equal('securityAlert')
expect(emailArgs[1].to).to.equal(this.user.email)
expect(emailArgs[1].to).to.equal(ctx.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 () {
it('should pass back errors via UserAuditLogHandler', async function (ctx) {
let error
const anError = new Error('oops')
this.UserAuditLogHandler.promises.addEntry.rejects(anError)
ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
try {
await this.SAMLIdentityManager.unlinkAccounts(
this.user._id,
await ctx.SAMLIdentityManager.unlinkAccounts(
ctx.user._id,
linkedEmail,
this.user.email,
ctx.user.email,
'1',
'Overleaf University',
this.auditLog
ctx.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
expect(ctx.EmailHandler.sendEmail).to.not.have.been.called
expect(ctx.User.updateOne).to.not.have.been.called
}
})
})
})
describe('entitlementAttributeMatches', function () {
it('should return true when entitlement matches on string', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
it('should return true when entitlement matches on string', function (ctx) {
ctx.SAMLIdentityManager.entitlementAttributeMatches(
'foo bar',
'bar'
).should.equal(true)
})
it('should return false when entitlement does not match on string', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
it('should return false when entitlement does not match on string', function (ctx) {
ctx.SAMLIdentityManager.entitlementAttributeMatches(
'foo bar',
'bam'
).should.equal(false)
})
it('should return false on an invalid matcher', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
it('should return false on an invalid matcher', function (ctx) {
ctx.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(
it('should log error on an invalid matcher', function (ctx) {
ctx.SAMLIdentityManager.entitlementAttributeMatches('foo bar', '(')
ctx.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(
it('should return true when entitlement matches on array', function (ctx) {
ctx.SAMLIdentityManager.entitlementAttributeMatches(
['foo', 'bar'],
'bar'
).should.equal(true)
})
it('should return false when entitlement does not match array', function () {
this.SAMLIdentityManager.entitlementAttributeMatches(
it('should return false when entitlement does not match array', function (ctx) {
ctx.SAMLIdentityManager.entitlementAttributeMatches(
['foo', 'bar'],
'bam'
).should.equal(false)
@@ -636,18 +681,18 @@ describe('SAMLIdentityManager', function () {
}
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
it('should remove the old identifier and add the new identifier', async function (ctx) {
ctx.UserGetter.promises.getUser.resolves()
ctx.UserGetter.promises.getUserByAnyEmail
.withArgs(institutionEmail)
.resolves({ _id: userId, emails: [{ email: institutionEmail }] })
this.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([
ctx.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([
{
email: institutionEmail,
affiliation: { institution: { id: providerId } },
},
])
await this.SAMLIdentityManager.migrateIdentifier(
await ctx.SAMLIdentityManager.migrateIdentifier(
userId,
externalUserId,
providerId,
@@ -658,7 +703,7 @@ describe('SAMLIdentityManager', function () {
userIdAttribute
)
expect(this.User.updateOne).to.have.been.calledOnce
expect(ctx.User.updateOne).to.have.been.calledOnce
const query = {
_id: userId,
'samlIdentifiers.providerId': providerId.toString(),
@@ -671,7 +716,7 @@ describe('SAMLIdentityManager', function () {
},
}
expect(this.User.updateOne.lastCall.args).to.deep.equal([query, update])
expect(ctx.User.updateOne.lastCall.args).to.deep.equal([query, update])
})
})
@@ -684,22 +729,22 @@ describe('SAMLIdentityManager', function () {
ipAddress: 'N/A',
}
it('should remove the identifier om samlIdentifiers and samlProviderId on the email', async function () {
this.User.findOneAndUpdate = sinon.stub().returns({
it('should remove the identifier om samlIdentifiers and samlProviderId on the email', async function (ctx) {
ctx.User.findOneAndUpdate = sinon.stub().returns({
exec: sinon.stub().resolves({
_id: userId,
emails: [{ email: institutionEmail, samlProviderId: providerId }],
}),
})
await this.SAMLIdentityManager.unlinkNotMigrated(
await ctx.SAMLIdentityManager.unlinkNotMigrated(
userId,
providerId,
providerName,
auditLog
)
expect(this.User.findOneAndUpdate).to.have.been.calledOnce
expect(ctx.User.findOneAndUpdate).to.have.been.calledOnce
const query = {
_id: userId,
'emails.samlProviderId': providerId,
@@ -714,14 +759,14 @@ describe('SAMLIdentityManager', function () {
'emails.$.samlProviderId': 1,
},
}
expect(this.User.findOneAndUpdate.lastCall.args).to.deep.equal([
expect(ctx.User.findOneAndUpdate.lastCall.args).to.deep.equal([
query,
update,
])
expect(this.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce
expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce
expect(
this.UserAuditLogHandler.promises.addEntry.lastCall.args
ctx.UserAuditLogHandler.promises.addEntry.lastCall.args
).to.deep.equal([
userId,
'unlink-institution-sso-not-migrated',
@@ -730,10 +775,10 @@ describe('SAMLIdentityManager', function () {
{ providerId, providerName },
])
expect(this.InstitutionsAPI.promises.removeEntitlement).to.have.been
expect(ctx.InstitutionsAPI.promises.removeEntitlement).to.have.been
.calledOnce
expect(
this.InstitutionsAPI.promises.removeEntitlement.lastCall.args
ctx.InstitutionsAPI.promises.removeEntitlement.lastCall.args
).to.deep.equal([userId, institutionEmail])
})
})

View File

@@ -0,0 +1,362 @@
import { vi, assert } from 'vitest'
import sinon from 'sinon'
const modulePath = '../../../../app/src/Features/User/UserCreator.mjs'
describe('UserCreator', function () {
beforeEach(async function (ctx) {
const self = ctx
ctx.user = { _id: '12390i', ace: {} }
ctx.user.save = sinon.stub().resolves(self.user)
ctx.UserModel = class Project {
constructor() {
return self.user
}
}
ctx.logger = {
error: sinon.stub(),
}
vi.doMock('@overleaf/logger', () => ({
default: ctx.logger,
}))
vi.doMock('../../../../app/src/models/User', () => ({
User: ctx.UserModel,
}))
vi.doMock('../../../../app/src/infrastructure/Features', () => ({
default: (ctx.Features = {
hasFeature: sinon.stub().returns(false),
}),
}))
vi.doMock('../../../../app/src/Features/User/UserDeleter', () => ({
default: (ctx.UserDeleter = {
promises: {
deleteNewUser: sinon.stub().resolves(),
},
}),
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: (ctx.UserGetter = {
promises: {
getUser: sinon.stub().resolves(ctx.user),
},
}),
}))
vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
default: (ctx.UserUpdater = {
promises: {
addAffiliationForNewUser: sinon.stub().resolves({
matchedCount: 1,
modifiedCount: 1,
acknowledged: true,
}),
updateUser: sinon.stub().resolves(),
},
}),
}))
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: (ctx.Analytics = {
recordEventForUserInBackground: sinon.stub(),
setUserPropertyForUser: sinon.stub(),
}),
})
)
vi.doMock(
'../../../../app/src/Features/SplitTests/SplitTestHandler',
() => ({
default: (ctx.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub().resolves({ variant: 'active' }),
},
}),
})
)
vi.doMock(
'../../../../app/src/Features/User/UserOnboardingEmailManager',
() => ({
default: (ctx.UserOnboardingEmailManager = {
scheduleOnboardingEmail: sinon.stub(),
}),
})
)
vi.doMock(
'../../../../app/src/Features/User/UserPostRegistrationAnalyticsManager',
() => ({
default: (ctx.UserPostRegistrationAnalyticsManager = {
schedulePostRegistrationAnalytics: sinon.stub(),
}),
})
)
ctx.UserCreator = (await import(modulePath)).default
ctx.email = 'bob.oswald@gmail.com'
})
describe('createNewUser', function () {
describe('with callbacks', function () {
it('should take the opts and put them in the model', async function (ctx) {
const user = await ctx.UserCreator.promises.createNewUser({
email: ctx.email,
holdingAccount: true,
})
assert.equal(user.email, ctx.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.first_name, 'bob.oswald')
})
it('should use the start of the email if the first name is empty string', async function (ctx) {
const user = await ctx.UserCreator.promises.createNewUser({
email: ctx.email,
holdingAccount: true,
first_name: '',
})
assert.equal(user.email, ctx.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.first_name, 'bob.oswald')
})
it('should use the first name if passed', async function (ctx) {
const user = await ctx.UserCreator.promises.createNewUser({
email: ctx.email,
holdingAccount: true,
first_name: 'fiiirstname',
})
assert.equal(user.email, ctx.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.first_name, 'fiiirstname')
})
it('should use the last name if passed', async function (ctx) {
const user = await ctx.UserCreator.promises.createNewUser({
email: ctx.email,
holdingAccount: true,
last_name: 'lastNammmmeee',
})
assert.equal(user.email, ctx.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.last_name, 'lastNammmmeee')
})
it('should set emails attribute', async function (ctx) {
const user = await ctx.UserCreator.promises.createNewUser({
email: ctx.email,
})
user.email.should.equal(ctx.email)
user.emails.length.should.equal(1)
user.emails[0].email.should.equal(ctx.email)
user.emails[0].createdAt.should.be.a('date')
user.emails[0].reversedHostname.should.equal('moc.liamg')
})
describe('with affiliations feature', function () {
let attributes, user
beforeEach(function (ctx) {
attributes = { email: ctx.email }
ctx.Features.hasFeature = sinon
.stub()
.withArgs('affiliations')
.returns(true)
})
describe('when v1 affiliations API does not return an error', function () {
beforeEach(async function (ctx) {
user = await ctx.UserCreator.promises.createNewUser(attributes)
})
it('should flag that affiliation is unchecked', function () {
user.emails[0].affiliationUnchecked.should.equal(true)
})
it('should try to add affiliation to v1', function (ctx) {
sinon.assert.calledOnce(
ctx.UserUpdater.promises.addAffiliationForNewUser
)
sinon.assert.calledWithMatch(
ctx.UserUpdater.promises.addAffiliationForNewUser,
user._id,
ctx.email
)
})
it('should query for updated user data', function (ctx) {
sinon.assert.calledOnce(ctx.UserGetter.promises.getUser)
})
})
describe('when v1 affiliations API does return an error', function () {
beforeEach(async function (ctx) {
ctx.UserUpdater.promises.addAffiliationForNewUser.rejects()
user = await ctx.UserCreator.promises.createNewUser(attributes)
})
it('should flag that affiliation is unchecked', function () {
user.emails[0].affiliationUnchecked.should.equal(true)
})
it('should try to add affiliation to v1', function (ctx) {
sinon.assert.calledOnce(
ctx.UserUpdater.promises.addAffiliationForNewUser
)
sinon.assert.calledWithMatch(
ctx.UserUpdater.promises.addAffiliationForNewUser,
user._id,
ctx.email
)
})
it('should not query for updated user data', function (ctx) {
sinon.assert.notCalled(ctx.UserGetter.promises.getUser)
})
it('should log error', function (ctx) {
sinon.assert.calledOnce(ctx.logger.error)
})
})
describe('when v1 affiliations API returns an error and requireAffiliation=true', function () {
beforeEach(async function (ctx) {
ctx.UserUpdater.promises.addAffiliationForNewUser.rejects()
user = await ctx.UserCreator.promises.createNewUser(attributes)
})
it('should flag that affiliation is unchecked', function () {
user.emails[0].affiliationUnchecked.should.equal(true)
})
it('should try to add affiliation to v1', function (ctx) {
sinon.assert.calledOnce(
ctx.UserUpdater.promises.addAffiliationForNewUser
)
sinon.assert.calledWithMatch(
ctx.UserUpdater.promises.addAffiliationForNewUser,
user._id,
ctx.email
)
})
it('should not query for updated user data', function (ctx) {
sinon.assert.notCalled(ctx.UserGetter.promises.getUser)
})
it('should log error', function (ctx) {
sinon.assert.calledOnce(ctx.logger.error)
})
})
})
it('should not add affiliation when without affiliation feature', async function (ctx) {
const attributes = { email: ctx.email }
await ctx.UserCreator.promises.createNewUser(attributes)
sinon.assert.notCalled(
ctx.UserUpdater.promises.addAffiliationForNewUser
)
})
})
describe('with promises', function () {
it('should take the opts and put them in the model', async function (ctx) {
const opts = {
email: ctx.email,
holdingAccount: true,
}
const user = await ctx.UserCreator.promises.createNewUser(opts)
assert.equal(user.email, ctx.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.first_name, 'bob.oswald')
})
it('should add affiliation when with affiliation feature', async function (ctx) {
ctx.Features.hasFeature = sinon
.stub()
.withArgs('affiliations')
.returns(true)
const attributes = { email: ctx.email }
const user = await ctx.UserCreator.promises.createNewUser(attributes)
sinon.assert.calledOnce(
ctx.UserUpdater.promises.addAffiliationForNewUser
)
sinon.assert.calledWithMatch(
ctx.UserUpdater.promises.addAffiliationForNewUser,
user._id,
ctx.email
)
})
it('should not add affiliation when without affiliation feature', async function (ctx) {
ctx.Features.hasFeature = sinon.stub().returns(false)
const attributes = { email: ctx.email }
await ctx.UserCreator.promises.createNewUser(attributes)
sinon.assert.notCalled(
ctx.UserUpdater.promises.addAffiliationForNewUser
)
})
it('should include SAML provider ID with email', async function (ctx) {
const attributes = {
email: ctx.email,
samlIdentifiers: [{ email: ctx.email, providerId: '1' }],
}
const user = await ctx.UserCreator.promises.createNewUser(attributes)
assert.equal(user.emails[0].samlProviderId, '1')
})
it('should fire an analytics event and user property on registration', async function (ctx) {
const user = await ctx.UserCreator.promises.createNewUser({
email: ctx.email,
})
assert.equal(user.email, ctx.email)
sinon.assert.calledWith(
ctx.Analytics.recordEventForUserInBackground,
user._id,
'user-registered'
)
sinon.assert.calledWith(
ctx.Analytics.setUserPropertyForUser,
user._id,
'created-at'
)
})
it('should schedule post registration jobs on registration with saas feature', async function (ctx) {
ctx.Features.hasFeature = sinon.stub().withArgs('saas').returns(true)
const user = await ctx.UserCreator.promises.createNewUser({
email: ctx.email,
})
assert.equal(user.email, ctx.email)
sinon.assert.calledWith(
ctx.UserOnboardingEmailManager.scheduleOnboardingEmail,
user
)
sinon.assert.calledWith(
ctx.UserPostRegistrationAnalyticsManager
.schedulePostRegistrationAnalytics,
user
)
})
it('should not schedule post registration checks when without saas feature', async function (ctx) {
const attributes = { email: ctx.email }
await ctx.UserCreator.promises.createNewUser(attributes)
sinon.assert.notCalled(
ctx.UserOnboardingEmailManager.scheduleOnboardingEmail
)
sinon.assert.notCalled(
ctx.UserPostRegistrationAnalyticsManager
.schedulePostRegistrationAnalytics
)
})
})
})
})

View File

@@ -1,322 +0,0 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { assert } = require('chai')
const modulePath = '../../../../app/src/Features/User/UserCreator.js'
describe('UserCreator', function () {
beforeEach(function () {
const self = this
this.user = { _id: '12390i', ace: {} }
this.user.save = sinon.stub().resolves(self.user)
this.UserModel = class Project {
constructor() {
return self.user
}
}
this.UserCreator = SandboxedModule.require(modulePath, {
requires: {
'../../models/User': {
User: this.UserModel,
},
'../../infrastructure/Features': (this.Features = {
hasFeature: sinon.stub().returns(false),
}),
'./UserDeleter': (this.UserDeleter = {
promises: {
deleteNewUser: sinon.stub().resolves(),
},
}),
'./UserGetter': (this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(this.user),
},
}),
'./UserUpdater': (this.UserUpdater = {
promises: {
addAffiliationForNewUser: sinon.stub().resolves({
matchedCount: 1,
modifiedCount: 1,
acknowledged: true,
}),
updateUser: sinon.stub().resolves(),
},
}),
'../Analytics/AnalyticsManager': (this.Analytics = {
recordEventForUserInBackground: sinon.stub(),
setUserPropertyForUser: sinon.stub(),
}),
'../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub().resolves({ variant: 'active' }),
},
}),
'./UserOnboardingEmailManager': (this.UserOnboardingEmailManager = {
scheduleOnboardingEmail: sinon.stub(),
}),
'./UserPostRegistrationAnalyticsManager':
(this.UserPostRegistrationAnalyticsManager = {
schedulePostRegistrationAnalytics: sinon.stub(),
}),
},
})
this.email = 'bob.oswald@gmail.com'
})
describe('createNewUser', function () {
describe('with callbacks', function () {
it('should take the opts and put them in the model', async function () {
const user = await this.UserCreator.promises.createNewUser({
email: this.email,
holdingAccount: true,
})
assert.equal(user.email, this.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.first_name, 'bob.oswald')
})
it('should use the start of the email if the first name is empty string', async function () {
const user = await this.UserCreator.promises.createNewUser({
email: this.email,
holdingAccount: true,
first_name: '',
})
assert.equal(user.email, this.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.first_name, 'bob.oswald')
})
it('should use the first name if passed', async function () {
const user = await this.UserCreator.promises.createNewUser({
email: this.email,
holdingAccount: true,
first_name: 'fiiirstname',
})
assert.equal(user.email, this.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.first_name, 'fiiirstname')
})
it('should use the last name if passed', async function () {
const user = await this.UserCreator.promises.createNewUser({
email: this.email,
holdingAccount: true,
last_name: 'lastNammmmeee',
})
assert.equal(user.email, this.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.last_name, 'lastNammmmeee')
})
it('should set emails attribute', async function () {
const user = await this.UserCreator.promises.createNewUser({
email: this.email,
})
user.email.should.equal(this.email)
user.emails.length.should.equal(1)
user.emails[0].email.should.equal(this.email)
user.emails[0].createdAt.should.be.a('date')
user.emails[0].reversedHostname.should.equal('moc.liamg')
})
describe('with affiliations feature', function () {
let attributes, user
beforeEach(function () {
attributes = { email: this.email }
this.Features.hasFeature = sinon
.stub()
.withArgs('affiliations')
.returns(true)
})
describe('when v1 affiliations API does not return an error', function () {
beforeEach(async function () {
user = await this.UserCreator.promises.createNewUser(attributes)
})
it('should flag that affiliation is unchecked', function () {
user.emails[0].affiliationUnchecked.should.equal(true)
})
it('should try to add affiliation to v1', function () {
sinon.assert.calledOnce(
this.UserUpdater.promises.addAffiliationForNewUser
)
sinon.assert.calledWithMatch(
this.UserUpdater.promises.addAffiliationForNewUser,
user._id,
this.email
)
})
it('should query for updated user data', function () {
sinon.assert.calledOnce(this.UserGetter.promises.getUser)
})
})
describe('when v1 affiliations API does return an error', function () {
beforeEach(async function () {
this.UserUpdater.promises.addAffiliationForNewUser.rejects()
user = await this.UserCreator.promises.createNewUser(attributes)
})
it('should flag that affiliation is unchecked', function () {
user.emails[0].affiliationUnchecked.should.equal(true)
})
it('should try to add affiliation to v1', function () {
sinon.assert.calledOnce(
this.UserUpdater.promises.addAffiliationForNewUser
)
sinon.assert.calledWithMatch(
this.UserUpdater.promises.addAffiliationForNewUser,
user._id,
this.email
)
})
it('should not query for updated user data', function () {
sinon.assert.notCalled(this.UserGetter.promises.getUser)
})
it('should log error', function () {
sinon.assert.calledOnce(this.logger.error)
})
})
describe('when v1 affiliations API returns an error and requireAffiliation=true', function () {
beforeEach(async function () {
this.UserUpdater.promises.addAffiliationForNewUser.rejects()
user = await this.UserCreator.promises.createNewUser(attributes)
})
it('should flag that affiliation is unchecked', function () {
user.emails[0].affiliationUnchecked.should.equal(true)
})
it('should try to add affiliation to v1', function () {
sinon.assert.calledOnce(
this.UserUpdater.promises.addAffiliationForNewUser
)
sinon.assert.calledWithMatch(
this.UserUpdater.promises.addAffiliationForNewUser,
user._id,
this.email
)
})
it('should not query for updated user data', function () {
sinon.assert.notCalled(this.UserGetter.promises.getUser)
})
it('should log error', function () {
sinon.assert.calledOnce(this.logger.error)
})
})
})
it('should not add affiliation when without affiliation feature', async function () {
const attributes = { email: this.email }
await this.UserCreator.promises.createNewUser(attributes)
sinon.assert.notCalled(
this.UserUpdater.promises.addAffiliationForNewUser
)
})
})
describe('with promises', function () {
it('should take the opts and put them in the model', async function () {
const opts = {
email: this.email,
holdingAccount: true,
}
const user = await this.UserCreator.promises.createNewUser(opts)
assert.equal(user.email, this.email)
assert.equal(user.holdingAccount, true)
assert.equal(user.first_name, 'bob.oswald')
})
it('should add affiliation when with affiliation feature', async function () {
this.Features.hasFeature = sinon
.stub()
.withArgs('affiliations')
.returns(true)
const attributes = { email: this.email }
const user = await this.UserCreator.promises.createNewUser(attributes)
sinon.assert.calledOnce(
this.UserUpdater.promises.addAffiliationForNewUser
)
sinon.assert.calledWithMatch(
this.UserUpdater.promises.addAffiliationForNewUser,
user._id,
this.email
)
})
it('should not add affiliation when without affiliation feature', async function () {
this.Features.hasFeature = sinon.stub().returns(false)
const attributes = { email: this.email }
await this.UserCreator.promises.createNewUser(attributes)
sinon.assert.notCalled(
this.UserUpdater.promises.addAffiliationForNewUser
)
})
it('should include SAML provider ID with email', async function () {
const attributes = {
email: this.email,
samlIdentifiers: [{ email: this.email, providerId: '1' }],
}
const user = await this.UserCreator.promises.createNewUser(attributes)
assert.equal(user.emails[0].samlProviderId, '1')
})
it('should fire an analytics event and user property on registration', async function () {
const user = await this.UserCreator.promises.createNewUser({
email: this.email,
})
assert.equal(user.email, this.email)
sinon.assert.calledWith(
this.Analytics.recordEventForUserInBackground,
user._id,
'user-registered'
)
sinon.assert.calledWith(
this.Analytics.setUserPropertyForUser,
user._id,
'created-at'
)
})
it('should schedule post registration jobs on registration with saas feature', async function () {
this.Features.hasFeature = sinon.stub().withArgs('saas').returns(true)
const user = await this.UserCreator.promises.createNewUser({
email: this.email,
})
assert.equal(user.email, this.email)
sinon.assert.calledWith(
this.UserOnboardingEmailManager.scheduleOnboardingEmail,
user
)
sinon.assert.calledWith(
this.UserPostRegistrationAnalyticsManager
.schedulePostRegistrationAnalytics,
user
)
})
it('should not schedule post registration checks when without saas feature', async function () {
const attributes = { email: this.email }
await this.UserCreator.promises.createNewUser(attributes)
sinon.assert.notCalled(
this.UserOnboardingEmailManager.scheduleOnboardingEmail
)
sinon.assert.notCalled(
this.UserPostRegistrationAnalyticsManager
.schedulePostRegistrationAnalytics
)
})
})
})
})

View File

@@ -0,0 +1,290 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { vi, expect } from 'vitest'
import sinon from 'sinon'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import EmailHelper from '../../../../app/src/Features/Helpers/EmailHelper.js'
const modulePath =
'../../../../app/src/Features/User/UserEmailsConfirmationHandler'
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('UserEmailsConfirmationHandler', function () {
beforeEach(async function (ctx) {
ctx.mockUser = {
_id: 'mock-user-id',
email: 'mock@example.com',
emails: [{ email: 'mock@example.com' }],
}
ctx.user_id = ctx.mockUser._id
ctx.email = ctx.mockUser.email
ctx.req = {}
vi.doMock('@overleaf/settings', () => ({
default: (ctx.settings = {
siteUrl: 'https://emails.example.com',
}),
}))
vi.doMock(
'../../../../app/src/Features/Security/OneTimeTokenHandler',
() => ({
default: (ctx.OneTimeTokenHandler = {
promises: {},
}),
})
)
vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
default: (ctx.UserUpdater = {
promises: {},
}),
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: (ctx.UserGetter = {
getUser: sinon.stub().yields(null, ctx.mockUser),
promises: {
getUser: sinon.stub().resolves(ctx.mockUser),
},
}),
}))
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
default: (ctx.EmailHandler = {
promises: {},
}),
}))
vi.doMock('../../../../app/src/Features/Helpers/EmailHelper', () => ({
default: EmailHelper,
}))
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
default: (ctx.SessionManager = {
getLoggedInUserId: sinon.stub().returns(ctx.mockUser._id),
}),
})
)
ctx.UserEmailsConfirmationHandler = (await import(modulePath)).default
return (ctx.callback = sinon.stub())
})
describe('sendConfirmationEmail', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.getNewToken = sinon
.stub()
.resolves((ctx.token = 'new-token'))
return (ctx.EmailHandler.promises.sendEmail = sinon.stub().resolves())
})
describe('successfully', function () {
beforeEach(async function (ctx) {
await ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
ctx.user_id,
ctx.email
)
})
it('should generate a token for the user which references their id and email', function (ctx) {
return ctx.OneTimeTokenHandler.promises.getNewToken
.calledWith(
'email_confirmation',
{ user_id: ctx.user_id, email: ctx.email },
{ expiresIn: 90 * 24 * 60 * 60 }
)
.should.equal(true)
})
it('should send an email to the user', function (ctx) {
return ctx.EmailHandler.promises.sendEmail
.calledWith('confirmEmail', {
to: ctx.email,
confirmEmailUrl:
'https://emails.example.com/user/emails/confirm?token=new-token',
sendingUser_id: ctx.user_id,
})
.should.equal(true)
})
})
describe('with invalid email', function () {
it('should reject with an error', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
ctx.user_id,
'!"£$%^&*()'
)
).to.be.rejectedWith(Error)
})
})
describe('a custom template', function () {
beforeEach(async function (ctx) {
await ctx.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
ctx.user_id,
ctx.email,
'myCustomTemplate'
)
})
it('should send an email with the given template', function (ctx) {
return ctx.EmailHandler.promises.sendEmail
.calledWith('myCustomTemplate')
.should.equal(true)
})
})
})
describe('confirmEmailFromToken', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { user_id: ctx.user_id, email: ctx.email } })
ctx.OneTimeTokenHandler.promises.expireToken = sinon.stub().resolves()
ctx.UserUpdater.promises.confirmEmail = sinon.stub().resolves()
})
describe('successfully', function () {
beforeEach(async function (ctx) {
await ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
})
it('should call peekValueFromToken', function (ctx) {
return ctx.OneTimeTokenHandler.promises.peekValueFromToken
.calledWith('email_confirmation', ctx.token)
.should.equal(true)
})
it('should call expireToken', function (ctx) {
return ctx.OneTimeTokenHandler.promises.expireToken
.calledWith('email_confirmation', ctx.token)
.should.equal(true)
})
it('should confirm the email of the user_id', function (ctx) {
return ctx.UserUpdater.promises.confirmEmail
.calledWith(ctx.user_id, ctx.email)
.should.equal(true)
})
})
describe('with an expired token', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.rejects(new Errors.NotFoundError('no token found'))
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no user_id in the token', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { email: ctx.email } })
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no email in the token', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { user_id: ctx.user_id } })
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no user found', function () {
beforeEach(function (ctx) {
ctx.UserGetter.promises.getUser.resolves(null)
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with secondary email missing on user', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({
data: { user_id: ctx.user_id, email: 'deleted@email.com' },
})
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('when the logged in user is not the token user', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getLoggedInUserId = sinon
.stub()
.returns('other-user-id')
})
it('should reject with a ForbiddenError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.ForbiddenError)
})
})
})
})

View File

@@ -1,266 +0,0 @@
/* eslint-disable
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const assert = require('assert')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/User/UserEmailsConfirmationHandler'
)
const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const EmailHelper = require('../../../../app/src/Features/Helpers/EmailHelper')
describe('UserEmailsConfirmationHandler', function () {
beforeEach(function () {
this.mockUser = {
_id: 'mock-user-id',
email: 'mock@example.com',
emails: [{ email: 'mock@example.com' }],
}
this.user_id = this.mockUser._id
this.email = this.mockUser.email
this.req = {}
this.UserEmailsConfirmationHandler = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {
siteUrl: 'https://emails.example.com',
}),
'../Security/OneTimeTokenHandler': (this.OneTimeTokenHandler = {
promises: {},
}),
'./UserUpdater': (this.UserUpdater = {
promises: {},
}),
'./UserGetter': (this.UserGetter = {
getUser: sinon.stub().yields(null, this.mockUser),
promises: {
getUser: sinon.stub().resolves(this.mockUser),
},
}),
'../Email/EmailHandler': (this.EmailHandler = { promises: {} }),
'../Helpers/EmailHelper': EmailHelper,
'../Authentication/SessionManager': (this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.mockUser._id),
}),
},
})
return (this.callback = sinon.stub())
})
describe('sendConfirmationEmail', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.getNewToken = sinon
.stub()
.resolves((this.token = 'new-token'))
return (this.EmailHandler.promises.sendEmail = sinon.stub().resolves())
})
describe('successfully', function () {
beforeEach(async function () {
await this.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
this.user_id,
this.email
)
})
it('should generate a token for the user which references their id and email', function () {
return this.OneTimeTokenHandler.promises.getNewToken
.calledWith(
'email_confirmation',
{ user_id: this.user_id, email: this.email },
{ expiresIn: 90 * 24 * 60 * 60 }
)
.should.equal(true)
})
it('should send an email to the user', function () {
return this.EmailHandler.promises.sendEmail
.calledWith('confirmEmail', {
to: this.email,
confirmEmailUrl:
'https://emails.example.com/user/emails/confirm?token=new-token',
sendingUser_id: this.user_id,
})
.should.equal(true)
})
})
describe('with invalid email', function () {
it('should reject with an error', async function () {
await expect(
this.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
this.user_id,
'!"£$%^&*()'
)
).to.be.rejectedWith(Error)
})
})
describe('a custom template', function () {
beforeEach(async function () {
await this.UserEmailsConfirmationHandler.promises.sendConfirmationEmail(
this.user_id,
this.email,
'myCustomTemplate'
)
})
it('should send an email with the given template', function () {
return this.EmailHandler.promises.sendEmail
.calledWith('myCustomTemplate')
.should.equal(true)
})
})
})
describe('confirmEmailFromToken', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { user_id: this.user_id, email: this.email } })
this.OneTimeTokenHandler.promises.expireToken = sinon.stub().resolves()
this.UserUpdater.promises.confirmEmail = sinon.stub().resolves()
})
describe('successfully', function () {
beforeEach(async function () {
await this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
this.req,
(this.token = 'mock-token')
)
})
it('should call peekValueFromToken', function () {
return this.OneTimeTokenHandler.promises.peekValueFromToken
.calledWith('email_confirmation', this.token)
.should.equal(true)
})
it('should call expireToken', function () {
return this.OneTimeTokenHandler.promises.expireToken
.calledWith('email_confirmation', this.token)
.should.equal(true)
})
it('should confirm the email of the user_id', function () {
return this.UserUpdater.promises.confirmEmail
.calledWith(this.user_id, this.email)
.should.equal(true)
})
})
describe('with an expired token', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.rejects(new Errors.NotFoundError('no token found'))
})
it('should reject with a NotFoundError', async function () {
await expect(
this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
this.req,
(this.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no user_id in the token', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { email: this.email } })
})
it('should reject with a NotFoundError', async function () {
await expect(
this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
this.req,
(this.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no email in the token', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { user_id: this.user_id } })
})
it('should reject with a NotFoundError', async function () {
await expect(
this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
this.req,
(this.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no user found', function () {
beforeEach(function () {
this.UserGetter.promises.getUser.resolves(null)
})
it('should reject with a NotFoundError', async function () {
await expect(
this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
this.req,
(this.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with secondary email missing on user', function () {
beforeEach(function () {
this.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({
data: { user_id: this.user_id, email: 'deleted@email.com' },
})
})
it('should reject with a NotFoundError', async function () {
await expect(
this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
this.req,
(this.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('when the logged in user is not the token user', function () {
beforeEach(function () {
this.SessionManager.getLoggedInUserId = sinon
.stub()
.returns('other-user-id')
})
it('should reject with a ForbiddenError', async function () {
await expect(
this.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
this.req,
(this.token = 'mock-token')
)
).to.be.rejectedWith(Errors.ForbiddenError)
})
})
})
})

View File

@@ -0,0 +1,967 @@
import { vi, assert, expect } from 'vitest'
import { setTimeout } from 'node:timers/promises'
import MockRequest from '../helpers/MockRequestVitest.mjs'
import MockResponse from '../helpers/MockResponseVitest.mjs'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
const modulePath = '../../../../app/src/Features/User/UserEmailsController.mjs'
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('UserEmailsController', function () {
beforeEach(async function (ctx) {
ctx.req = new MockRequest(vi)
ctx.req.sessionID = Math.random().toString()
ctx.res = new MockResponse(vi)
ctx.next = vi.fn()
ctx.user = {
_id: 'mock-user-id',
email: 'example@overleaf.com',
emails: [],
}
ctx.UserGetter = {
getUser: vi.fn().mockImplementation((userId, projection, callback) => {
callback?.(null, ctx.user)
}),
getUserFullEmails: vi.fn(),
promises: {
ensureUniqueEmailAddress: vi.fn().mockResolvedValue(undefined),
getUser: vi.fn().mockResolvedValue(ctx.user),
getUserByAnyEmail: vi.fn(),
},
}
ctx.SessionManager = {
getSessionUser: vi.fn().mockReturnValue(ctx.user),
getLoggedInUserId: vi.fn().mockReturnValue(ctx.user._id),
setInSessionUser: vi.fn(),
}
ctx.Features = {
hasFeature: vi.fn(),
}
ctx.UserSessionsManager = {
promises: {
removeSessionsFromRedis: vi.fn().mockResolvedValue(undefined),
},
}
ctx.UserUpdater = {
addEmailAddress: vi.fn(),
updateV1AndSetDefaultEmailAddress: vi.fn(),
promises: {
addEmailAddress: vi.fn().mockResolvedValue(undefined),
confirmEmail: vi.fn().mockResolvedValue(undefined),
removeEmailAddress: vi.fn(),
setDefaultEmailAddress: vi.fn().mockResolvedValue(undefined),
},
}
ctx.EmailHelper = { parseEmail: vi.fn() }
ctx.endorseAffiliation = vi.fn((userId, email, role, dept, callback) =>
callback()
)
ctx.InstitutionsAPI = {
endorseAffiliation: ctx.endorseAffiliation,
}
ctx.HttpErrorHandler = { conflict: vi.fn() }
ctx.AnalyticsManager = {
recordEventForUserInBackground: vi.fn(),
}
ctx.UserAuditLogHandler = {
addEntry: vi.fn((userId, op, initiatorId, ip, info, callback) =>
callback()
),
promises: {
addEntry: vi.fn().mockResolvedValue(undefined),
},
}
ctx.rateLimiter = {
consume: vi.fn().mockResolvedValue(undefined),
}
ctx.RateLimiter = {
RateLimiter: vi.fn().mockReturnValue(ctx.rateLimiter),
}
ctx.AuthenticationController = {
getRedirectFromSession: vi.fn().mockReturnValue(null),
}
vi.doMock(
'../../../../app/src/Features/Authentication/AuthenticationController',
() => ({
default: ctx.AuthenticationController,
})
)
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
default: ctx.SessionManager,
})
)
vi.doMock('../../../../app/src/infrastructure/Features', () => ({
default: ctx.Features,
}))
vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({
default: ctx.UserSessionsManager,
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
default: ctx.UserUpdater,
}))
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
default: (ctx.EmailHandler = {
promises: {
sendEmail: vi.fn().mockResolvedValue(undefined),
},
}),
}))
vi.doMock('../../../../app/src/Features/Helpers/EmailHelper', () => ({
default: ctx.EmailHelper,
}))
vi.doMock(
'../../../../app/src/Features/User/UserEmailsConfirmationHandler',
() => ({
default: (ctx.UserEmailsConfirmationHandler = {
promises: {
sendConfirmationEmail: vi.fn().mockResolvedValue(undefined),
},
}),
})
)
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsAPI',
() => ({
default: ctx.InstitutionsAPI,
})
)
vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
default: ctx.HttpErrorHandler,
}))
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: ctx.AnalyticsManager,
})
)
vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
default: ctx.UserAuditLogHandler,
}))
vi.doMock(
'../../../../app/src/infrastructure/RateLimiter',
() => ctx.RateLimiter
)
ctx.UserEmailsController = (await import(modulePath)).default
})
describe('List', function () {
beforeEach(function () {})
it('lists emails', async function (ctx) {
expect.assertions(1)
const fullEmails = [{ some: 'data' }]
ctx.UserGetter.getUserFullEmails.mockImplementation(
(userId, callback) => {
callback(null, fullEmails)
}
)
await ctx.UserEmailsController.list(ctx.req, {
json: response => {
assert.deepEqual(response, fullEmails)
expect(ctx.UserGetter.getUserFullEmails).toHaveBeenCalledWith(
ctx.user._id,
expect.any(Function)
)
},
})
})
})
describe('addWithConfirmationCode', function () {
beforeEach(function (ctx) {
ctx.newEmail = 'new_email@baz.com'
ctx.req.body = {
email: ctx.newEmail,
}
ctx.EmailHelper.parseEmail.mockReturnValue(ctx.newEmail)
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode = vi
.fn()
.mockResolvedValue({
confirmCode: '123456',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('sends an email confirmation', async function (ctx) {
expect.assertions(2)
await ctx.UserEmailsController.addWithConfirmationCode(ctx.req, {
sendStatus: code => {
expect(code).to.equal(200)
expect(
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
).toHaveBeenCalledWith(ctx.newEmail, false)
},
})
})
it('handles email parse error', async function (ctx) {
expect.assertions(1)
ctx.EmailHelper.parseEmail.mockReturnValue(null)
await ctx.UserEmailsController.addWithConfirmationCode(ctx.req, {
sendStatus: code => {
expect(code).to.equal(422)
},
})
})
it('handles when the email already exists', async function (ctx) {
expect.assertions(1)
ctx.UserGetter.promises.ensureUniqueEmailAddress.mockRejectedValue(
new Errors.EmailExistsError()
)
await ctx.UserEmailsController.addWithConfirmationCode(ctx.req, {
status: code => {
expect(code).to.equal(409)
return { json: () => {} }
},
})
})
it('should fail to add new emails when the limit has been reached', async function (ctx) {
expect.assertions(2)
ctx.user.emails = []
for (let i = 0; i < 10; i++) {
ctx.user.emails.push({ email: `example${i}@overleaf.com` })
}
await ctx.UserEmailsController.addWithConfirmationCode(ctx.req, {
status: code => {
expect(code).to.equal(422)
return {
json: error => {
expect(error.message).to.equal('secondary email limit exceeded')
},
}
},
})
})
})
describe('checkNewSecondaryEmailConfirmationCode', function () {
beforeEach(function (ctx) {
ctx.newEmail = 'new_email@baz.com'
ctx.req.session.pendingSecondaryEmail = {
confirmCode: '123456',
email: ctx.newEmail,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
})
describe('with a valid confirmation code', function () {
beforeEach(function (ctx) {
ctx.req.body = {
code: '123456',
}
})
it('adds the email', async function (ctx) {
expect.assertions(2)
await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
ctx.req,
{
json: () => {
expect(
ctx.UserUpdater.promises.addEmailAddress
).toHaveBeenCalledWith(ctx.user._id, ctx.newEmail, undefined, {
initiatorId: 'mock-user-id',
ipAddress: '42.42.42.42',
})
expect(
ctx.UserUpdater.promises.confirmEmail
).toHaveBeenCalledWith(ctx.user._id, ctx.newEmail, undefined)
},
}
)
})
it('redirects to /project', async function (ctx) {
expect.assertions(1)
await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
ctx.req,
{
json: ({ redir }) => {
expect(redir).to.equal('/project')
},
}
)
})
it('sends a security alert email', async function (ctx) {
expect.assertions(4)
ctx.req.session.pendingSecondaryEmail = {
confirmCode: '123456',
email: ctx.newEmail,
confirmCodeExpiresTimestamp: new Date(Math.max),
affiliationOptions: {},
}
ctx.req.body.code = '123456'
await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
ctx.req,
{
json: vi.fn().mockResolvedValue(undefined),
}
)
const emailCall = ctx.EmailHandler.promises.sendEmail.mock.calls[0]
expect(emailCall[0]).to.equal('securityAlert')
expect(emailCall[1].to).to.equal(ctx.user.email)
expect(emailCall[1].actionDescribed).to.contain(
'a secondary email address'
)
expect(emailCall[1].message[0]).to.contain(ctx.newEmail)
})
})
describe('with an invalid confirmation code', function () {
beforeEach(function (ctx) {
ctx.req.body = {
code: '999999',
}
})
it('does not add the email', async function (ctx) {
expect.assertions(2)
await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
ctx.req,
{
status: () => {
expect(
ctx.UserUpdater.promises.addEmailAddress
).not.toHaveBeenCalled()
expect(
ctx.UserUpdater.promises.confirmEmail
).not.toHaveBeenCalled()
return { json: ctx.next }
},
}
)
})
it('responds with a 403', async function (ctx) {
expect.assertions(1)
await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
ctx.req,
{
status: code => {
expect(code).to.equal(403)
return { json: ctx.next }
},
}
)
})
})
})
describe('resendNewSecondaryEmailConfirmationCode', function () {
beforeEach(function (ctx) {
ctx.newEmail = 'new_email@baz.com'
ctx.req.session.pendingSecondaryEmail = {
confirmCode: '123456',
email: ctx.newEmail,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode = vi
.fn()
.mockResolvedValue({
confirmCode: '123456',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('should send the email', async function (ctx) {
expect.assertions(2)
await ctx.UserEmailsController.resendNewSecondaryEmailConfirmationCode(
ctx.req,
{
status: code => {
expect(code).to.equal(200)
expect(
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
).toHaveBeenCalledWith(ctx.newEmail, false)
return { json: ctx.next }
},
}
)
})
})
describe('remove', function () {
beforeEach(function (ctx) {
ctx.email = 'email_to_remove@bar.com'
ctx.req.body.email = ctx.email
ctx.EmailHelper.parseEmail.mockReturnValue(ctx.email)
})
it('removes email', async function (ctx) {
expect.assertions(3)
const auditLog = {
initiatorId: ctx.user._id,
ipAddress: ctx.req.ip,
}
ctx.UserUpdater.promises.removeEmailAddress.mockResolvedValue(undefined)
await ctx.UserEmailsController.remove(ctx.req, {
sendStatus: code => {
expect(code).to.equal(200)
expect(ctx.EmailHelper.parseEmail).toHaveBeenCalledWith(ctx.email)
expect(
ctx.UserUpdater.promises.removeEmailAddress
).toHaveBeenCalledWith(ctx.user._id, ctx.email, auditLog)
},
})
})
it('handles email parse error', async function (ctx) {
expect.assertions(2)
ctx.EmailHelper.parseEmail.mockReturnValue(null)
await ctx.UserEmailsController.remove(ctx.req, {
sendStatus: code => {
expect(code).to.equal(422)
expect(
ctx.UserUpdater.promises.removeEmailAddress
).not.toHaveBeenCalled()
},
})
})
})
describe('setDefault', function () {
beforeEach(function (ctx) {
ctx.email = 'email_to_set_default@bar.com'
ctx.req.body.email = ctx.email
ctx.EmailHelper.parseEmail.mockReturnValue(ctx.email)
ctx.SessionManager.setInSessionUser.mockReturnValue(null)
})
it('sets default email', async function (ctx) {
expect.assertions(4)
await ctx.UserEmailsController.setDefault(ctx.req, {
sendStatus: code => {
expect(code).to.equal(200)
expect(ctx.EmailHelper.parseEmail).toHaveBeenCalledWith(ctx.email)
expect(ctx.SessionManager.setInSessionUser).toHaveBeenCalledWith(
ctx.req.session,
{
email: ctx.email,
}
)
expect(
ctx.UserUpdater.promises.setDefaultEmailAddress
).toHaveBeenCalledWith(
ctx.user._id,
ctx.email,
false,
{ initiatorId: 'mock-user-id', ipAddress: '42.42.42.42' },
true,
false
)
},
})
})
it('deletes unconfirmed primary if delete-unconfirmed-primary is set', async function (ctx) {
expect.assertions(1)
ctx.user.emails = [{ email: 'example@overleaf.com' }]
ctx.req.query['delete-unconfirmed-primary'] = ''
await ctx.UserEmailsController.setDefault(ctx.req, {
sendStatus: () => {
expect(
ctx.UserUpdater.promises.removeEmailAddress
).toHaveBeenCalledWith(ctx.user._id, 'example@overleaf.com', {
initiatorId: ctx.user._id,
ipAddress: ctx.req.ip,
extraInfo: {
info: 'removed unconfirmed email after setting new primary',
},
})
},
})
})
it('doesnt delete a confirmed primary', async function (ctx) {
expect.assertions(1)
ctx.user.emails = [
{ email: 'example@overleaf.com', confirmedAt: '2000-01-01' },
]
ctx.req.query['delete-unconfirmed-primary'] = ''
await ctx.UserEmailsController.setDefault(ctx.req, {
sendStatus: () => {
expect(
ctx.UserUpdater.promises.removeEmailAddress
).not.toHaveBeenCalled()
},
})
})
it('doesnt delete primary if delete-unconfirmed-primary is not set', async function (ctx) {
await ctx.UserEmailsController.setDefault(ctx.req, {
sendStatus: () => {
expect(
ctx.UserUpdater.promises.removeEmailAddress
).not.toHaveBeenCalled()
},
})
})
it('handles email parse error', async function (ctx) {
expect.assertions(2)
ctx.EmailHelper.parseEmail.mockReturnValue(null)
await ctx.UserEmailsController.setDefault(ctx.req, {
sendStatus: code => {
expect(code).to.equal(422)
expect(
ctx.UserUpdater.promises.setDefaultEmailAddress
).not.toHaveBeenCalled()
},
})
})
it('should reset the users other sessions', async function (ctx) {
await ctx.UserEmailsController.setDefault(ctx.req, ctx.res)
expect(
ctx.UserSessionsManager.promises.removeSessionsFromRedis
).toHaveBeenCalledWith(ctx.user, ctx.req.sessionID)
})
it('handles error from revoking sessions and returns 200', async function (ctx) {
const redisError = new Error('redis error')
ctx.UserSessionsManager.promises.removeSessionsFromRedis = vi
.fn()
.mockRejectedValue(redisError)
await ctx.UserEmailsController.setDefault(ctx.req, ctx.res)
expect(ctx.res.statusCode).to.equal(200)
// give revoke process time to run
await setTimeout(0)
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: redisError }),
'failed revoking secondary sessions after changing default email'
)
})
})
describe('endorse', function () {
beforeEach(function (ctx) {
ctx.email = 'email_to_endorse@bar.com'
ctx.req.body.email = ctx.email
ctx.EmailHelper.parseEmail.mockReturnValue(ctx.email)
})
it('endorses affiliation', async function (ctx) {
expect.assertions(2)
ctx.req.body.role = 'Role'
ctx.req.body.department = 'Department'
await ctx.UserEmailsController.endorse(ctx.req, {
sendStatus: code => {
expect(code).to.equal(204)
expect(ctx.endorseAffiliation).toHaveBeenCalledWith(
ctx.user._id,
ctx.email,
'Role',
'Department',
expect.any(Function)
)
},
})
})
})
describe('confirm', function () {
beforeEach(function (ctx) {
ctx.UserEmailsConfirmationHandler.confirmEmailFromToken = vi
.fn()
.mockImplementation((req, token, callback) => {
callback(null, { userId: ctx.user._id, email: ctx.user.email })
})
ctx.token = 'mock-token'
ctx.req.body = { token: ctx.token }
ctx.req.ip = '0.0.0.0'
ctx.next = vi.fn()
ctx.res = new MockResponse(vi)
})
describe('successfully', function () {
beforeEach(function (ctx) {
ctx.UserEmailsController.confirm(ctx.req, ctx.res, ctx.next)
})
it('should confirm the email from the token', function (ctx) {
expect(
ctx.UserEmailsConfirmationHandler.confirmEmailFromToken
).toHaveBeenCalledWith(ctx.req, ctx.token, expect.any(Function))
})
it('should return a 200 status', function (ctx) {
expect(ctx.res.sendStatus).toHaveBeenCalledWith(200)
})
it('should log the confirmation to the audit log', function (ctx) {
expect(ctx.UserAuditLogHandler.addEntry).toHaveBeenCalledWith(
ctx.user._id,
'confirm-email',
ctx.user._id,
ctx.req.ip,
{
token: ctx.token.substring(0, 10),
email: ctx.user.email,
},
expect.any(Function)
)
})
})
describe('without a token', function () {
beforeEach(function (ctx) {
ctx.req.body.token = null
ctx.UserEmailsController.confirm(ctx.req, ctx.res, ctx.next)
})
it('should return a 422 status', function (ctx) {
expect(ctx.res.status).toHaveBeenCalledWith(422)
})
})
describe('when confirming fails', function () {
beforeEach(function (ctx) {
ctx.UserEmailsConfirmationHandler.confirmEmailFromToken = vi
.fn()
.mockImplementation((req, token, callback) => {
callback(new Errors.NotFoundError('not found'))
})
})
it('should return a 404 error code with a message', function (ctx) {
ctx.UserEmailsController.confirm(ctx.req, ctx.res, ctx.next)
expect(ctx.res.status).toHaveBeenCalledWith(404)
expect(ctx.res.json).toHaveBeenCalledWith({
message: ctx.req.i18n.translate('confirmation_token_invalid'),
})
})
})
})
describe('sendExistingEmailConfirmationCode', function () {
beforeEach(function (ctx) {
ctx.email = 'existing-email@example.com'
ctx.req.body.email = ctx.email
ctx.EmailHelper.parseEmail.mockReturnValue(ctx.email)
ctx.UserGetter.promises.getUserByAnyEmail.mockResolvedValue({
_id: ctx.user._id,
email: ctx.email,
})
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode = vi
.fn()
.mockResolvedValue({
confirmCode: '123456',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('should send confirmation code for existing email', async function (ctx) {
expect.assertions(2)
await ctx.UserEmailsController.sendExistingEmailConfirmationCode(
ctx.req,
{
sendStatus: code => {
expect(code).to.equal(204)
expect(
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
).toHaveBeenCalledWith(ctx.email, false)
},
}
)
})
it('should store confirmation code in session', async function (ctx) {
const confirmCode = '123456'
const confirmCodeExpiresTimestamp = new Date()
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode.mockResolvedValue(
{
confirmCode,
confirmCodeExpiresTimestamp,
}
)
await ctx.UserEmailsController.sendExistingEmailConfirmationCode(
ctx.req,
{ sendStatus: vi.fn() }
)
expect(ctx.req.session.pendingExistingEmail).to.deep.equal({
email: ctx.email,
confirmCode,
confirmCodeExpiresTimestamp,
affiliationOptions: undefined,
})
})
it('should handle invalid email', async function (ctx) {
expect.assertions(2)
ctx.EmailHelper.parseEmail.mockReturnValue(null)
await ctx.UserEmailsController.sendExistingEmailConfirmationCode(
ctx.req,
{
sendStatus: code => {
expect(code).to.equal(400)
expect(
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
).not.toHaveBeenCalled()
},
}
)
})
it('should handle email not belonging to user', async function (ctx) {
expect.assertions(2)
ctx.UserGetter.promises.getUserByAnyEmail.mockResolvedValue({
_id: 'another-user-id',
})
await ctx.UserEmailsController.sendExistingEmailConfirmationCode(
ctx.req,
{
sendStatus: code => {
expect(code).to.equal(422)
expect(
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
).not.toHaveBeenCalled()
},
}
)
})
})
describe('checkExistingEmailConfirmationCode', function () {
beforeEach(function (ctx) {
ctx.email = 'existing-email@example.com'
ctx.req.session.pendingExistingEmail = {
confirmCode: '123456',
email: ctx.email,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
ctx.UserUpdater.promises.confirmEmail.mockResolvedValue(undefined)
ctx.res = new MockResponse(vi)
})
describe('with a valid confirmation code', function () {
beforeEach(function (ctx) {
ctx.req.body = { code: '123456' }
})
it('confirms the email', async function (ctx) {
const mockRes = new MockResponse(vi)
await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
ctx.req,
mockRes
)
expect(ctx.UserUpdater.promises.confirmEmail).toHaveBeenCalledWith(
ctx.user._id,
ctx.email,
undefined
)
})
it('adds audit log entry', async function (ctx) {
await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
ctx.req,
{ json: vi.fn() }
)
expect(ctx.UserAuditLogHandler.promises.addEntry).toHaveBeenCalledWith(
ctx.user._id,
'confirm-email-via-code',
ctx.user._id,
ctx.req.ip,
{ email: ctx.email }
)
})
it('records analytics event', async function (ctx) {
await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
ctx.req,
{ json: vi.fn() }
)
expect(
ctx.AnalyticsManager.recordEventForUserInBackground
).toHaveBeenCalledWith(ctx.user._id, 'email-verified', {
provider: 'email',
verification_type: 'token',
isPrimary: ctx.user.email === ctx.email,
})
})
it('removes pendingExistingEmail from session', async function (ctx) {
await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
ctx.req,
{ json: vi.fn() }
)
expect(ctx.req.session.pendingExistingEmail).to.be.undefined
})
})
describe('with an invalid confirmation code', function () {
beforeEach(function (ctx) {
ctx.req.body = { code: '999999' }
})
it('does not confirm the email', async function (ctx) {
expect.assertions(1)
await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
ctx.req,
{
status: () => {
expect(
ctx.UserUpdater.promises.confirmEmail
).not.toHaveBeenCalled()
return { json: ctx.next }
},
}
)
})
it('responds with a 403', async function (ctx) {
expect.assertions(1)
await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
ctx.req,
{
status: code => {
expect(code).to.equal(403)
return { json: ctx.next }
},
}
)
})
})
describe('with an expired confirmation code', function () {
beforeEach(function (ctx) {
ctx.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp =
new Date(0)
ctx.req.body = { code: '123456' }
})
it('responds with a 403', async function (ctx) {
expect.assertions(1)
await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
ctx.req,
{
status: code => {
expect(code).to.equal(403)
return { json: ctx.next }
},
}
)
})
})
})
describe('resendExistingSecondaryEmailConfirmationCode', function () {
beforeEach(function (ctx) {
ctx.email = 'existing-email@example.com'
ctx.req.session.pendingExistingEmail = {
confirmCode: '123456',
email: ctx.email,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
ctx.res.status = vi.fn().mockReturnValue({ json: vi.fn() })
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode = vi
.fn()
.mockResolvedValue({
confirmCode: '654321',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('should resend confirmation code', async function (ctx) {
expect.assertions(2)
await ctx.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
ctx.req,
{
status: code => {
expect(code).to.equal(200)
expect(
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
).toHaveBeenCalledWith(ctx.email, false)
return { json: vi.fn() }
},
}
)
})
it('should update session with new code', async function (ctx) {
const newCode = '654321'
const newExpiryTime = new Date()
ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode.mockResolvedValue(
{
confirmCode: newCode,
confirmCodeExpiresTimestamp: newExpiryTime,
}
)
await ctx.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
ctx.req,
{ status: () => ({ json: vi.fn() }) }
)
expect(ctx.req.session.pendingExistingEmail.confirmCode).to.equal(newCode)
expect(
ctx.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp
).to.equal(newExpiryTime)
})
it('should add audit log entry', async function (ctx) {
await ctx.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
ctx.req,
{ status: () => ({ json: vi.fn() }) }
)
expect(ctx.UserAuditLogHandler.promises.addEntry).toHaveBeenCalledWith(
ctx.user._id,
'resend-confirm-email-code',
ctx.user._id,
ctx.req.ip,
{ email: ctx.email }
)
})
it('should handle rate limiting', async function (ctx) {
expect.assertions(1)
ctx.rateLimiter.consume.mockRejectedValue({ remainingPoints: 0 })
await ctx.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
ctx.req,
{
status: code => {
expect(code).to.equal(429)
return { json: vi.fn() }
},
}
)
})
})
})

View File

@@ -1,905 +0,0 @@
const sinon = require('sinon')
const assertCalledWith = sinon.assert.calledWith
const assertNotCalled = sinon.assert.notCalled
const { assert, expect } = require('chai')
const modulePath = '../../../../app/src/Features/User/UserEmailsController.js'
const SandboxedModule = require('sandboxed-module')
const MockRequest = require('../helpers/MockRequest')
const MockResponse = require('../helpers/MockResponse')
const Errors = require('../../../../app/src/Features/Errors/Errors')
describe('UserEmailsController', function () {
beforeEach(function () {
this.req = new MockRequest()
this.req.sessionID = Math.random().toString()
this.res = new MockResponse()
this.next = sinon.stub()
this.user = {
_id: 'mock-user-id',
email: 'example@overleaf.com',
emails: [],
}
this.UserGetter = {
getUser: sinon.stub().yields(),
getUserFullEmails: sinon.stub(),
promises: {
ensureUniqueEmailAddress: sinon.stub().resolves(),
getUser: sinon.stub().resolves(this.user),
getUserByAnyEmail: sinon.stub(),
},
}
this.SessionManager = {
getSessionUser: sinon.stub().returns(this.user),
getLoggedInUserId: sinon.stub().returns(this.user._id),
setInSessionUser: sinon.stub(),
}
this.Features = {
hasFeature: sinon.stub(),
}
this.UserSessionsManager = {
promises: { removeSessionsFromRedis: sinon.stub().resolves() },
}
this.UserUpdater = {
addEmailAddress: sinon.stub(),
updateV1AndSetDefaultEmailAddress: sinon.stub(),
promises: {
addEmailAddress: sinon.stub().resolves(),
confirmEmail: sinon.stub().resolves(),
removeEmailAddress: sinon.stub(),
setDefaultEmailAddress: sinon.stub().resolves(),
},
}
this.EmailHelper = { parseEmail: sinon.stub() }
this.endorseAffiliation = sinon.stub().yields()
this.InstitutionsAPI = {
endorseAffiliation: this.endorseAffiliation,
}
this.HttpErrorHandler = { conflict: sinon.stub() }
this.AnalyticsManager = {
recordEventForUserInBackground: sinon.stub(),
}
this.UserAuditLogHandler = {
addEntry: sinon.stub().yields(),
promises: {
addEntry: sinon.stub().resolves(),
},
}
this.rateLimiter = {
consume: sinon.stub().resolves(),
}
this.RateLimiter = {
RateLimiter: sinon.stub().returns(this.rateLimiter),
}
this.AuthenticationController = {
getRedirectFromSession: sinon.stub().returns(null),
}
this.UserEmailsController = SandboxedModule.require(modulePath, {
requires: {
'../Authentication/AuthenticationController':
this.AuthenticationController,
'../Authentication/SessionManager': this.SessionManager,
'../../infrastructure/Features': this.Features,
'./UserSessionsManager': this.UserSessionsManager,
'./UserGetter': this.UserGetter,
'./UserUpdater': this.UserUpdater,
'../Email/EmailHandler': (this.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}),
'../Helpers/EmailHelper': this.EmailHelper,
'./UserEmailsConfirmationHandler': (this.UserEmailsConfirmationHandler =
{
promises: {
sendConfirmationEmail: sinon.stub().resolves(),
},
}),
'../Institutions/InstitutionsAPI': this.InstitutionsAPI,
'../Errors/HttpErrorHandler': this.HttpErrorHandler,
'../Analytics/AnalyticsManager': this.AnalyticsManager,
'./UserAuditLogHandler': this.UserAuditLogHandler,
'../../infrastructure/RateLimiter': this.RateLimiter,
},
})
})
describe('List', function () {
beforeEach(function () {})
it('lists emails', function (done) {
const fullEmails = [{ some: 'data' }]
this.UserGetter.getUserFullEmails.callsArgWith(1, null, fullEmails)
this.UserEmailsController.list(this.req, {
json: response => {
assert.deepEqual(response, fullEmails)
assertCalledWith(this.UserGetter.getUserFullEmails, this.user._id)
done()
},
})
})
})
describe('addWithConfirmationCode', function () {
beforeEach(function () {
this.newEmail = 'new_email@baz.com'
this.req.body = {
email: this.newEmail,
}
this.EmailHelper.parseEmail.returns(this.newEmail)
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
.stub()
.resolves({
confirmCode: '123456',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('sends an email confirmation', function (done) {
this.UserEmailsController.addWithConfirmationCode(this.req, {
sendStatus: code => {
code.should.equal(200)
assertCalledWith(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
this.newEmail,
false
)
done()
},
})
})
it('handles email parse error', function (done) {
this.EmailHelper.parseEmail.returns(null)
this.UserEmailsController.addWithConfirmationCode(this.req, {
sendStatus: code => {
code.should.equal(422)
done()
},
})
})
it('handles when the email already exists', function (done) {
this.UserGetter.promises.ensureUniqueEmailAddress.rejects(
new Errors.EmailExistsError()
)
this.UserEmailsController.addWithConfirmationCode(this.req, {
status: code => {
code.should.equal(409)
return { json: () => done() }
},
})
})
it('should fail to add new emails when the limit has been reached', function (done) {
this.user.emails = []
for (let i = 0; i < 10; i++) {
this.user.emails.push({ email: `example${i}@overleaf.com` })
}
this.UserEmailsController.addWithConfirmationCode(this.req, {
status: code => {
expect(code).to.equal(422)
return {
json: error => {
expect(error.message).to.equal('secondary email limit exceeded')
done()
},
}
},
})
})
})
describe('checkNewSecondaryEmailConfirmationCode', function () {
beforeEach(function () {
this.newEmail = 'new_email@baz.com'
this.req.session.pendingSecondaryEmail = {
confirmCode: '123456',
email: this.newEmail,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
})
describe('with a valid confirmation code', function () {
beforeEach(function () {
this.req.body = {
code: '123456',
}
})
it('adds the email', function (done) {
this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
this.req,
{
json: () => {
assertCalledWith(
this.UserUpdater.promises.addEmailAddress,
this.user._id,
this.newEmail
)
assertCalledWith(
this.UserUpdater.promises.confirmEmail,
this.user._id,
this.newEmail
)
done()
},
}
)
})
it('redirects to /project', function (done) {
this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
this.req,
{
json: ({ redir }) => {
redir.should.equal('/project')
done()
},
}
)
})
it('sends a security alert email', async function () {
this.req.session.pendingSecondaryEmail = {
confirmCode: '123456',
email: this.newEmail,
confirmCodeExpiresTimestamp: new Date(Math.max),
affiliationOptions: {},
}
this.req.body.code = '123456'
await this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
this.req,
{
json: sinon.stub().resolves(),
}
)
const emailCall = this.EmailHandler.promises.sendEmail.getCall(0)
expect(emailCall.args[0]).to.equal('securityAlert')
expect(emailCall.args[1].to).to.equal(this.user.email)
expect(emailCall.args[1].actionDescribed).to.contain(
'a secondary email address'
)
expect(emailCall.args[1].message[0]).to.contain(this.newEmail)
})
})
describe('with an invalid confirmation code', function () {
beforeEach(function () {
this.req.body = {
code: '999999',
}
})
it('does not add the email', function (done) {
this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
this.req,
{
status: () => {
assertNotCalled(this.UserUpdater.promises.addEmailAddress)
assertNotCalled(this.UserUpdater.promises.confirmEmail)
done()
return { json: this.next }
},
}
)
})
it('responds with a 403', function (done) {
this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
this.req,
{
status: code => {
code.should.equal(403)
done()
return { json: this.next }
},
}
)
})
})
})
describe('resendNewSecondaryEmailConfirmationCode', function () {
beforeEach(function () {
this.newEmail = 'new_email@baz.com'
this.req.session.pendingSecondaryEmail = {
confirmCode: '123456',
email: this.newEmail,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
.stub()
.resolves({
confirmCode: '123456',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('should send the email', function (done) {
this.UserEmailsController.resendNewSecondaryEmailConfirmationCode(
this.req,
{
status: code => {
code.should.equal(200)
assertCalledWith(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
this.newEmail,
false
)
done()
return { json: this.next }
},
}
)
})
})
describe('remove', function () {
beforeEach(function () {
this.email = 'email_to_remove@bar.com'
this.req.body.email = this.email
this.EmailHelper.parseEmail.returns(this.email)
})
it('removes email', function (done) {
const auditLog = {
initiatorId: this.user._id,
ipAddress: this.req.ip,
}
this.UserUpdater.promises.removeEmailAddress.resolves()
this.UserEmailsController.remove(this.req, {
sendStatus: code => {
code.should.equal(200)
assertCalledWith(this.EmailHelper.parseEmail, this.email)
assertCalledWith(
this.UserUpdater.promises.removeEmailAddress,
this.user._id,
this.email,
auditLog
)
done()
},
})
})
it('handles email parse error', function (done) {
this.EmailHelper.parseEmail.returns(null)
this.UserEmailsController.remove(this.req, {
sendStatus: code => {
code.should.equal(422)
assertNotCalled(this.UserUpdater.promises.removeEmailAddress)
done()
},
})
})
})
describe('setDefault', function () {
beforeEach(function () {
this.email = 'email_to_set_default@bar.com'
this.req.body.email = this.email
this.EmailHelper.parseEmail.returns(this.email)
this.SessionManager.setInSessionUser.returns(null)
})
it('sets default email', function (done) {
this.UserEmailsController.setDefault(this.req, {
sendStatus: code => {
code.should.equal(200)
assertCalledWith(this.EmailHelper.parseEmail, this.email)
assertCalledWith(
this.SessionManager.setInSessionUser,
this.req.session,
{
email: this.email,
}
)
assertCalledWith(
this.UserUpdater.promises.setDefaultEmailAddress,
this.user._id,
this.email
)
done()
},
})
})
it('deletes unconfirmed primary if delete-unconfirmed-primary is set', function (done) {
this.user.emails = [{ email: 'example@overleaf.com' }]
this.req.query['delete-unconfirmed-primary'] = ''
this.UserEmailsController.setDefault(this.req, {
sendStatus: () => {
assertCalledWith(
this.UserUpdater.promises.removeEmailAddress,
this.user._id,
'example@overleaf.com',
{
initiatorId: this.user._id,
ipAddress: this.req.ip,
extraInfo: {
info: 'removed unconfirmed email after setting new primary',
},
}
)
done()
},
})
})
it('doesnt delete a confirmed primary', function (done) {
this.user.emails = [
{ email: 'example@overleaf.com', confirmedAt: '2000-01-01' },
]
this.req.query['delete-unconfirmed-primary'] = ''
this.UserEmailsController.setDefault(this.req, {
sendStatus: () => {
assertNotCalled(this.UserUpdater.promises.removeEmailAddress)
done()
},
})
})
it('doesnt delete primary if delete-unconfirmed-primary is not set', function (done) {
this.UserEmailsController.setDefault(this.req, {
sendStatus: () => {
assertNotCalled(this.UserUpdater.promises.removeEmailAddress)
done()
},
})
})
it('handles email parse error', function (done) {
this.EmailHelper.parseEmail.returns(null)
this.UserEmailsController.setDefault(this.req, {
sendStatus: code => {
code.should.equal(422)
assertNotCalled(this.UserUpdater.promises.setDefaultEmailAddress)
done()
},
})
})
it('should reset the users other sessions', function (done) {
this.res.callback = () => {
expect(
this.UserSessionsManager.promises.removeSessionsFromRedis
).to.have.been.calledWith(this.user, this.req.sessionID)
done()
}
this.UserEmailsController.setDefault(this.req, this.res, done)
})
it('handles error from revoking sessions and returns 200', function (done) {
const redisError = new Error('redis error')
this.UserSessionsManager.promises.removeSessionsFromRedis = sinon
.stub()
.rejects(redisError)
this.res.callback = () => {
expect(this.res.statusCode).to.equal(200)
// give revoke process time to run
setTimeout(() => {
expect(this.logger.warn).to.have.been.calledWith(
sinon.match({ err: redisError }),
'failed revoking secondary sessions after changing default email'
)
done()
})
}
this.UserEmailsController.setDefault(this.req, this.res, done)
})
})
describe('endorse', function () {
beforeEach(function () {
this.email = 'email_to_endorse@bar.com'
this.req.body.email = this.email
this.EmailHelper.parseEmail.returns(this.email)
})
it('endorses affiliation', function (done) {
this.req.body.role = 'Role'
this.req.body.department = 'Department'
this.UserEmailsController.endorse(this.req, {
sendStatus: code => {
code.should.equal(204)
assertCalledWith(
this.endorseAffiliation,
this.user._id,
this.email,
'Role',
'Department'
)
done()
},
})
})
})
describe('confirm', function () {
beforeEach(function () {
this.UserEmailsConfirmationHandler.confirmEmailFromToken = sinon
.stub()
.yields(null, { userId: this.user._id, email: this.user.email })
this.res = {
sendStatus: sinon.stub(),
json: sinon.stub(),
}
this.res.status = sinon.stub().returns(this.res)
this.next = sinon.stub()
this.token = 'mock-token'
this.req.body = { token: this.token }
this.req.ip = '0.0.0.0'
})
describe('successfully', function () {
beforeEach(function () {
this.UserEmailsController.confirm(this.req, this.res, this.next)
})
it('should confirm the email from the token', function () {
this.UserEmailsConfirmationHandler.confirmEmailFromToken
.calledWith(this.req, this.token)
.should.equal(true)
})
it('should return a 200 status', function () {
this.res.sendStatus.calledWith(200).should.equal(true)
})
it('should log the confirmation to the audit log', function () {
sinon.assert.calledWith(
this.UserAuditLogHandler.addEntry,
this.user._id,
'confirm-email',
this.user._id,
this.req.ip,
{
token: this.token.substring(0, 10),
email: this.user.email,
}
)
})
})
describe('without a token', function () {
beforeEach(function () {
this.req.body.token = null
this.UserEmailsController.confirm(this.req, this.res, this.next)
})
it('should return a 422 status', function () {
this.res.status.calledWith(422).should.equal(true)
})
})
describe('when confirming fails', function () {
beforeEach(function () {
this.UserEmailsConfirmationHandler.confirmEmailFromToken = sinon
.stub()
.yields(new Errors.NotFoundError('not found'))
this.UserEmailsController.confirm(this.req, this.res, this.next)
})
it('should return a 404 error code with a message', function () {
this.res.status.calledWith(404).should.equal(true)
this.res.json
.calledWith({
message: this.req.i18n.translate('confirmation_token_invalid'),
})
.should.equal(true)
})
})
})
describe('sendExistingEmailConfirmationCode', function () {
beforeEach(function () {
this.email = 'existing-email@example.com'
this.req.body.email = this.email
this.EmailHelper.parseEmail.returns(this.email)
this.UserGetter.promises.getUserByAnyEmail.resolves({
_id: this.user._id,
email: this.email,
})
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
.stub()
.resolves({
confirmCode: '123456',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('should send confirmation code for existing email', async function () {
await this.UserEmailsController.sendExistingEmailConfirmationCode(
this.req,
{
sendStatus: code => {
code.should.equal(204)
assertCalledWith(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
this.email,
false
)
},
}
)
})
it('should store confirmation code in session', async function () {
const confirmCode = '123456'
const confirmCodeExpiresTimestamp = new Date()
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode.resolves(
{ confirmCode, confirmCodeExpiresTimestamp }
)
await this.UserEmailsController.sendExistingEmailConfirmationCode(
this.req,
{ sendStatus: sinon.stub() }
)
expect(this.req.session.pendingExistingEmail).to.deep.equal({
email: this.email,
confirmCode,
confirmCodeExpiresTimestamp,
affiliationOptions: undefined,
})
})
it('should handle invalid email', async function () {
this.EmailHelper.parseEmail.returns(null)
await this.UserEmailsController.sendExistingEmailConfirmationCode(
this.req,
{
sendStatus: code => {
code.should.equal(400)
assertNotCalled(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode
)
},
}
)
})
it('should handle email not belonging to user', async function () {
this.UserGetter.promises.getUserByAnyEmail.resolves({
_id: 'another-user-id',
})
await this.UserEmailsController.sendExistingEmailConfirmationCode(
this.req,
{
sendStatus: code => {
code.should.equal(422)
assertNotCalled(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode
)
},
}
)
})
})
describe('checkExistingEmailConfirmationCode', function () {
beforeEach(function () {
this.email = 'existing-email@example.com'
this.req.session.pendingExistingEmail = {
confirmCode: '123456',
email: this.email,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
this.UserUpdater.promises.confirmEmail.resolves()
this.res = {
json: sinon.stub(),
status: sinon.stub().returns({ json: sinon.stub() }),
}
})
describe('with a valid confirmation code', function () {
beforeEach(function () {
this.req.body = { code: '123456' }
})
it('confirms the email', async function () {
await this.UserEmailsController.checkExistingEmailConfirmationCode(
this.req,
{
json: () => {
assertCalledWith(
this.UserUpdater.promises.confirmEmail,
this.user._id,
this.email
)
},
}
)
})
it('adds audit log entry', async function () {
await this.UserEmailsController.checkExistingEmailConfirmationCode(
this.req,
{ json: sinon.stub() }
)
assertCalledWith(
this.UserAuditLogHandler.promises.addEntry,
this.user._id,
'confirm-email-via-code',
this.user._id,
this.req.ip,
{ email: this.email }
)
})
it('records analytics event', async function () {
await this.UserEmailsController.checkExistingEmailConfirmationCode(
this.req,
{ json: sinon.stub() }
)
assertCalledWith(
this.AnalyticsManager.recordEventForUserInBackground,
this.user._id,
'email-verified',
{
provider: 'email',
verification_type: 'token',
isPrimary: this.user.email === this.email,
}
)
})
it('removes pendingExistingEmail from session', async function () {
await this.UserEmailsController.checkExistingEmailConfirmationCode(
this.req,
{ json: sinon.stub() }
)
expect(this.req.session.pendingExistingEmail).to.be.undefined
})
})
describe('with an invalid confirmation code', function () {
beforeEach(function () {
this.req.body = { code: '999999' }
})
it('does not confirm the email', async function () {
await this.UserEmailsController.checkExistingEmailConfirmationCode(
this.req,
{
status: () => {
assertNotCalled(this.UserUpdater.promises.confirmEmail)
return { json: this.next }
},
}
)
})
it('responds with a 403', async function () {
await this.UserEmailsController.checkExistingEmailConfirmationCode(
this.req,
{
status: code => {
code.should.equal(403)
return { json: this.next }
},
}
)
})
})
describe('with an expired confirmation code', function () {
beforeEach(function () {
this.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp =
new Date(0)
this.req.body = { code: '123456' }
})
it('responds with a 403', async function () {
await this.UserEmailsController.checkExistingEmailConfirmationCode(
this.req,
{
status: code => {
code.should.equal(403)
return { json: this.next }
},
}
)
})
})
})
describe('resendExistingSecondaryEmailConfirmationCode', function () {
beforeEach(function () {
this.email = 'existing-email@example.com'
this.req.session.pendingExistingEmail = {
confirmCode: '123456',
email: this.email,
confirmCodeExpiresTimestamp: new Date(Math.max),
}
this.res.status = sinon.stub().returns({ json: sinon.stub() })
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
.stub()
.resolves({
confirmCode: '654321',
confirmCodeExpiresTimestamp: new Date(),
})
})
it('should resend confirmation code', async function () {
await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
this.req,
{
status: code => {
code.should.equal(200)
assertCalledWith(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
this.email,
false
)
return { json: sinon.stub() }
},
}
)
})
it('should update session with new code', async function () {
const newCode = '654321'
const newExpiryTime = new Date()
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode.resolves(
{
confirmCode: newCode,
confirmCodeExpiresTimestamp: newExpiryTime,
}
)
await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
this.req,
{ status: () => ({ json: sinon.stub() }) }
)
expect(this.req.session.pendingExistingEmail.confirmCode).to.equal(
newCode
)
expect(
this.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp
).to.equal(newExpiryTime)
})
it('should add audit log entry', async function () {
await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
this.req,
{ status: () => ({ json: sinon.stub() }) }
)
assertCalledWith(
this.UserAuditLogHandler.promises.addEntry,
this.user._id,
'resend-confirm-email-code',
this.user._id,
this.req.ip,
{ email: this.email }
)
})
it('should handle rate limiting', async function () {
this.rateLimiter.consume.rejects({ remainingPoints: 0 })
await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
this.req,
{
status: code => {
code.should.equal(429)
return { json: sinon.stub() }
},
}
)
})
})
})

View File

@@ -0,0 +1,230 @@
import { vi, expect } from 'vitest'
import sinon from 'sinon'
import MockResponse from '../helpers/MockResponse.js'
import MockRequest from '../helpers/MockRequest.js'
import mongodb from 'mongodb-legacy'
const modulePath = '../../../../app/src/Features/User/UserInfoController.mjs'
const { ObjectId } = mongodb
describe('UserInfoController', function () {
beforeEach(async function (ctx) {
ctx.UserDeleter = { deleteUser: sinon.stub().callsArgWith(1) }
ctx.UserUpdater = { updatePersonalInfo: sinon.stub() }
ctx.UserGetter = {
promises: {
getUserFeatures: sinon.stub(),
},
}
vi.doMock('mongodb-legacy', () => ({
default: { ObjectId },
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
default: ctx.UserUpdater,
}))
vi.doMock('../../../../app/src/Features/User/UserDeleter', () => ({
default: ctx.UserDeleter,
}))
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
default: (ctx.SessionManager = {
getLoggedInUserId: sinon.stub(),
}),
})
)
ctx.UserInfoController = (await import(modulePath)).default
ctx.req = new MockRequest()
ctx.res = new MockResponse()
ctx.next = sinon.stub()
})
describe('getPersonalInfo', function () {
describe('when the user exists with mongo id', function () {
beforeEach(function (ctx) {
ctx.user_id = new ObjectId().toString()
ctx.user = { _id: new ObjectId(ctx.user_id) }
ctx.req.params = { user_id: ctx.user_id }
ctx.UserGetter.getUser = sinon.stub().callsArgWith(2, null, ctx.user)
ctx.UserInfoController.sendFormattedPersonalInfo = sinon.stub()
ctx.UserInfoController.getPersonalInfo(ctx.req, ctx.res, ctx.next)
})
it('should look up the user in the database', function (ctx) {
ctx.UserGetter.getUser
.calledWith(
{ _id: new ObjectId(ctx.user_id) },
{ _id: true, first_name: true, last_name: true, email: true }
)
.should.equal(true)
})
})
describe('when the user exists with overleaf id', function () {
beforeEach(function (ctx) {
ctx.user_id = 12345
ctx.user = {
_id: new ObjectId(),
overleaf: {
id: ctx.user_id,
},
}
ctx.req.params = { user_id: ctx.user_id.toString() }
ctx.UserGetter.getUser = sinon.stub().callsArgWith(2, null, ctx.user)
ctx.UserInfoController.getPersonalInfo(ctx.req, ctx.res, ctx.next)
})
it('should look up the user in the database', function (ctx) {
ctx.UserGetter.getUser
.calledWith(
{ 'overleaf.id': ctx.user_id },
{ _id: true, first_name: true, last_name: true, email: true }
)
.should.equal(true)
})
})
describe('when the user does not exist', function () {
beforeEach(function (ctx) {
ctx.user_id = new ObjectId().toString()
ctx.req.params = { user_id: ctx.user_id }
ctx.UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
ctx.UserInfoController.getPersonalInfo(ctx.req, ctx.res, ctx.next)
})
it('should return 404 to the client', function (ctx) {
ctx.res.statusCode.should.equal(404)
})
})
describe('when the user id is invalid', function () {
beforeEach(function (ctx) {
ctx.user_id = 'invalid'
ctx.req.params = { user_id: ctx.user_id }
ctx.UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
ctx.UserInfoController.getPersonalInfo(ctx.req, ctx.res, ctx.next)
})
it('should return 400 to the client', function (ctx) {
ctx.res.statusCode.should.equal(400)
})
})
})
describe('sendFormattedPersonalInfo', function () {
beforeEach(function (ctx) {
ctx.user = {
_id: new ObjectId(),
first_name: 'Douglas',
last_name: 'Adams',
email: 'doug@overleaf.com',
}
ctx.formattedInfo = {
id: ctx.user._id.toString(),
first_name: ctx.user.first_name,
last_name: ctx.user.last_name,
email: ctx.user.email,
}
ctx.UserInfoController.sendFormattedPersonalInfo(ctx.user, ctx.res)
})
it('should send the formatted details back to the client', function (ctx) {
ctx.res.body.should.equal(JSON.stringify(ctx.formattedInfo))
})
})
describe('formatPersonalInfo', function () {
it('should return the correctly formatted data', function (ctx) {
ctx.user = {
_id: new ObjectId(),
first_name: 'Douglas',
last_name: 'Adams',
email: 'doug@overleaf.com',
password: 'should-not-get-included',
signUpDate: new Date(),
role: 'student',
institution: 'sheffield',
}
expect(ctx.UserInfoController.formatPersonalInfo(ctx.user)).to.deep.equal(
{
id: ctx.user._id.toString(),
first_name: ctx.user.first_name,
last_name: ctx.user.last_name,
email: ctx.user.email,
signUpDate: ctx.user.signUpDate,
role: ctx.user.role,
institution: ctx.user.institution,
}
)
})
})
describe('getUserFeatures', function () {
describe('when the user is logged in', function () {
beforeEach(async function (ctx) {
ctx.user_id = new ObjectId().toString()
ctx.features = {
collaborators: 10,
trackChanges: true,
references: true,
}
ctx.SessionManager.getLoggedInUserId.returns(ctx.user_id)
ctx.UserGetter.promises.getUserFeatures.resolves(ctx.features)
await ctx.UserInfoController.getUserFeatures(ctx.req, ctx.res, ctx.next)
})
it('should fetch the user features', function (ctx) {
expect(ctx.UserGetter.promises.getUserFeatures.callCount).to.equal(1)
expect(
ctx.UserGetter.promises.getUserFeatures.calledWith(ctx.user_id)
).to.equal(true)
})
it('should return the features as JSON', function (ctx) {
expect(ctx.res.json.callCount).to.equal(1)
expect(ctx.res.json.calledWith(ctx.features)).to.equal(true)
})
})
describe('when the user is not logged in', function () {
beforeEach(async function (ctx) {
ctx.SessionManager.getLoggedInUserId.returns(null)
await ctx.UserInfoController.getUserFeatures(ctx.req, ctx.res, ctx.next)
})
it('should call next with an error', function (ctx) {
expect(ctx.next.callCount).to.equal(1)
expect(ctx.next.firstCall.args[0]).to.be.an.instanceof(Error)
expect(ctx.next.firstCall.args[0].message).to.equal(
'User is not logged in'
)
})
})
describe('when fetching features fails', function () {
beforeEach(async function (ctx) {
ctx.user_id = new ObjectId().toString()
ctx.error = new Error('something went wrong')
ctx.SessionManager.getLoggedInUserId.returns(ctx.user_id)
ctx.UserGetter.promises.getUserFeatures.rejects(ctx.error)
await ctx.UserInfoController.getUserFeatures(ctx.req, ctx.res, ctx.next)
})
it('should call next with the error', function (ctx) {
expect(ctx.next.callCount).to.equal(1)
expect(ctx.next.firstCall.args[0]).to.equal(ctx.error)
})
})
})
})

View File

@@ -1,225 +0,0 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/User/UserInfoController.js'
const SandboxedModule = require('sandboxed-module')
const MockResponse = require('../helpers/MockResponse')
const MockRequest = require('../helpers/MockRequest')
const { ObjectId } = require('mongodb-legacy')
describe('UserInfoController', function () {
beforeEach(function () {
this.UserDeleter = { deleteUser: sinon.stub().callsArgWith(1) }
this.UserUpdater = { updatePersonalInfo: sinon.stub() }
this.UserGetter = {
promises: {
getUserFeatures: sinon.stub(),
},
}
this.UserInfoController = SandboxedModule.require(modulePath, {
requires: {
'mongodb-legacy': { ObjectId },
'./UserGetter': this.UserGetter,
'./UserUpdater': this.UserUpdater,
'./UserDeleter': this.UserDeleter,
'../Authentication/SessionManager': (this.SessionManager = {
getLoggedInUserId: sinon.stub(),
}),
},
})
this.req = new MockRequest()
this.res = new MockResponse()
this.next = sinon.stub()
})
describe('getPersonalInfo', function () {
describe('when the user exists with mongo id', function () {
beforeEach(function () {
this.user_id = new ObjectId().toString()
this.user = { _id: new ObjectId(this.user_id) }
this.req.params = { user_id: this.user_id }
this.UserGetter.getUser = sinon.stub().callsArgWith(2, null, this.user)
this.UserInfoController.sendFormattedPersonalInfo = sinon.stub()
this.UserInfoController.getPersonalInfo(this.req, this.res, this.next)
})
it('should look up the user in the database', function () {
this.UserGetter.getUser
.calledWith(
{ _id: new ObjectId(this.user_id) },
{ _id: true, first_name: true, last_name: true, email: true }
)
.should.equal(true)
})
})
describe('when the user exists with overleaf id', function () {
beforeEach(function () {
this.user_id = 12345
this.user = {
_id: new ObjectId(),
overleaf: {
id: this.user_id,
},
}
this.req.params = { user_id: this.user_id.toString() }
this.UserGetter.getUser = sinon.stub().callsArgWith(2, null, this.user)
this.UserInfoController.getPersonalInfo(this.req, this.res, this.next)
})
it('should look up the user in the database', function () {
this.UserGetter.getUser
.calledWith(
{ 'overleaf.id': this.user_id },
{ _id: true, first_name: true, last_name: true, email: true }
)
.should.equal(true)
})
})
describe('when the user does not exist', function () {
beforeEach(function () {
this.user_id = new ObjectId().toString()
this.req.params = { user_id: this.user_id }
this.UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
this.UserInfoController.getPersonalInfo(this.req, this.res, this.next)
})
it('should return 404 to the client', function () {
this.res.statusCode.should.equal(404)
})
})
describe('when the user id is invalid', function () {
beforeEach(function () {
this.user_id = 'invalid'
this.req.params = { user_id: this.user_id }
this.UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
this.UserInfoController.getPersonalInfo(this.req, this.res, this.next)
})
it('should return 400 to the client', function () {
this.res.statusCode.should.equal(400)
})
})
})
describe('sendFormattedPersonalInfo', function () {
beforeEach(function () {
this.user = {
_id: new ObjectId(),
first_name: 'Douglas',
last_name: 'Adams',
email: 'doug@overleaf.com',
}
this.formattedInfo = {
id: this.user._id.toString(),
first_name: this.user.first_name,
last_name: this.user.last_name,
email: this.user.email,
}
this.UserInfoController.sendFormattedPersonalInfo(this.user, this.res)
})
it('should send the formatted details back to the client', function () {
this.res.body.should.equal(JSON.stringify(this.formattedInfo))
})
})
describe('formatPersonalInfo', function () {
it('should return the correctly formatted data', function () {
this.user = {
_id: new ObjectId(),
first_name: 'Douglas',
last_name: 'Adams',
email: 'doug@overleaf.com',
password: 'should-not-get-included',
signUpDate: new Date(),
role: 'student',
institution: 'sheffield',
}
expect(
this.UserInfoController.formatPersonalInfo(this.user)
).to.deep.equal({
id: this.user._id.toString(),
first_name: this.user.first_name,
last_name: this.user.last_name,
email: this.user.email,
signUpDate: this.user.signUpDate,
role: this.user.role,
institution: this.user.institution,
})
})
})
describe('getUserFeatures', function () {
describe('when the user is logged in', function () {
beforeEach(async function () {
this.user_id = new ObjectId().toString()
this.features = {
collaborators: 10,
trackChanges: true,
references: true,
}
this.SessionManager.getLoggedInUserId.returns(this.user_id)
this.UserGetter.promises.getUserFeatures.resolves(this.features)
await this.UserInfoController.getUserFeatures(
this.req,
this.res,
this.next
)
})
it('should fetch the user features', function () {
expect(this.UserGetter.promises.getUserFeatures.callCount).to.equal(1)
expect(
this.UserGetter.promises.getUserFeatures.calledWith(this.user_id)
).to.equal(true)
})
it('should return the features as JSON', function () {
expect(this.res.json.callCount).to.equal(1)
expect(this.res.json.calledWith(this.features)).to.equal(true)
})
})
describe('when the user is not logged in', function () {
beforeEach(async function () {
this.SessionManager.getLoggedInUserId.returns(null)
await this.UserInfoController.getUserFeatures(
this.req,
this.res,
this.next
)
})
it('should call next with an error', function () {
expect(this.next.callCount).to.equal(1)
expect(this.next.firstCall.args[0]).to.be.an.instanceof(Error)
expect(this.next.firstCall.args[0].message).to.equal(
'User is not logged in'
)
})
})
describe('when fetching features fails', function () {
beforeEach(async function () {
this.user_id = new ObjectId().toString()
this.error = new Error('something went wrong')
this.SessionManager.getLoggedInUserId.returns(this.user_id)
this.UserGetter.promises.getUserFeatures.rejects(this.error)
await this.UserInfoController.getUserFeatures(
this.req,
this.res,
this.next
)
})
it('should call next with the error', function () {
expect(this.next.callCount).to.equal(1)
expect(this.next.firstCall.args[0]).to.equal(this.error)
})
})
})
})

View File

@@ -0,0 +1,327 @@
import { vi, expect } from 'vitest'
import assert from 'assert'
import sinon from 'sinon'
import EmailHelper from '../../../../app/src/Features/Helpers/EmailHelper.js'
const modulePath = '../../../../app/src/Features/User/UserRegistrationHandler'
describe('UserRegistrationHandler', function () {
beforeEach(async function (ctx) {
ctx.analyticsId = '123456'
ctx.user = {
_id: (ctx.user_id = '31j2lk21kjl'),
analyticsId: ctx.analyticsId,
}
ctx.User = {
updateOne: sinon.stub().returns({ exec: sinon.stub().resolves() }),
}
ctx.UserGetter = {
promises: {
getUserByAnyEmail: sinon.stub(),
},
}
ctx.UserCreator = {
promises: {
createNewUser: sinon.stub().resolves(ctx.user),
},
}
ctx.AuthenticationManager = {
validateEmail: sinon.stub().returns(null),
validatePassword: sinon.stub().returns(null),
promises: {
setUserPassword: sinon.stub().resolves(ctx.user),
},
}
ctx.NewsLetterManager = {
subscribe: sinon.stub(),
}
ctx.EmailHandler = {
promises: { sendEmail: sinon.stub().resolves() },
}
ctx.OneTimeTokenHandler = { promises: { getNewToken: sinon.stub() } }
vi.doMock('../../../../app/src/models/User', () => ({
User: ctx.User,
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock('../../../../app/src/Features/User/UserCreator', () => ({
default: ctx.UserCreator,
}))
vi.doMock(
'../../../../app/src/Features/Authentication/AuthenticationManager',
() => ({
default: ctx.AuthenticationManager,
})
)
vi.doMock(
'../../../../app/src/Features/Newsletter/NewsletterManager',
() => ({
default: ctx.NewsLetterManager,
})
)
vi.doMock('crypto', () => ({
default: (ctx.crypto = {}),
}))
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
default: ctx.EmailHandler,
}))
vi.doMock(
'../../../../app/src/Features/Security/OneTimeTokenHandler',
() => ({
default: ctx.OneTimeTokenHandler,
})
)
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: (ctx.AnalyticsManager = {
recordEventForUser: sinon.stub(),
setUserPropertyForUser: sinon.stub(),
identifyUser: sinon.stub(),
}),
})
)
vi.doMock('@overleaf/settings', () => ({
default: (ctx.settings = {
siteUrl: 'http://sl.example.com',
}),
}))
vi.doMock('../../../../app/src/Features/Helpers/EmailHelper', () => ({
default: EmailHelper,
}))
ctx.handler = (await import(modulePath)).default
ctx.passingRequest = {
email: 'something@email.com',
password: '123',
analyticsId: ctx.analyticsId,
}
})
describe('validate Register Request', function () {
it('allows passing validation through', function (ctx) {
const result = ctx.handler.promises._registrationRequestIsValid(
ctx.passingRequest
)
result.should.equal(true)
})
describe('failing email validation', function () {
beforeEach(function (ctx) {
ctx.AuthenticationManager.validateEmail.returns({
message: 'email not set',
})
})
it('does not allow through', function (ctx) {
const result = ctx.handler.promises._registrationRequestIsValid(
ctx.passingRequest
)
return result.should.equal(false)
})
})
describe('failing password validation', function () {
beforeEach(function (ctx) {
ctx.AuthenticationManager.validatePassword.returns({
message: 'password is too short',
})
})
it('does not allow through', function (ctx) {
const result = ctx.handler.promises._registrationRequestIsValid(
ctx.passingRequest
)
result.should.equal(false)
})
})
})
describe('registerNewUser', function () {
describe('holdingAccount', function (done) {
beforeEach(function (ctx) {
ctx.user.holdingAccount = true
ctx.handler.promises._registrationRequestIsValid = sinon
.stub()
.returns(true)
ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
})
it('should not create a new user if there is a holding account there', async function (ctx) {
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
ctx.UserCreator.promises.createNewUser.called.should.equal(false)
})
it('should set holding account to false', async function (ctx) {
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
const update = ctx.User.updateOne.args[0]
assert.deepEqual(update[0], { _id: ctx.user._id })
assert.deepEqual(update[1], { $set: { holdingAccount: false } })
})
})
describe('invalidRequest', function () {
it('should not create a new user if the the request is not valid', async function (ctx) {
ctx.handler.promises._registrationRequestIsValid = sinon
.stub()
.returns(false)
expect(ctx.handler.promises.registerNewUser(ctx.passingRequest)).to.be
.rejected
ctx.UserCreator.promises.createNewUser.called.should.equal(false)
})
it('should return email registered in the error if there is a non holdingAccount there', async function (ctx) {
ctx.UserGetter.promises.getUserByAnyEmail.resolves(
(ctx.user = { holdingAccount: false })
)
expect(
ctx.handler.promises.registerNewUser(ctx.passingRequest)
).to.be.rejectedWith('EmailAlreadyRegistered')
})
})
describe('validRequest', function () {
beforeEach(function (ctx) {
ctx.handler.promises._registrationRequestIsValid = sinon
.stub()
.returns(true)
ctx.UserGetter.promises.getUserByAnyEmail.resolves()
})
it('should create a new user', async function (ctx) {
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
sinon.assert.calledWith(ctx.UserCreator.promises.createNewUser, {
email: ctx.passingRequest.email,
holdingAccount: false,
first_name: ctx.passingRequest.first_name,
last_name: ctx.passingRequest.last_name,
analyticsId: ctx.user.analyticsId,
})
})
it('lower case email', async function (ctx) {
ctx.passingRequest.email = 'soMe@eMail.cOm'
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
ctx.UserCreator.promises.createNewUser.args[0][0].email.should.equal(
'some@email.com'
)
})
it('trim white space from email', async function (ctx) {
ctx.passingRequest.email = ' some@email.com '
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
ctx.UserCreator.promises.createNewUser.args[0][0].email.should.equal(
'some@email.com'
)
})
it('should set the password', async function (ctx) {
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
ctx.AuthenticationManager.promises.setUserPassword
.calledWith(ctx.user, ctx.passingRequest.password)
.should.equal(true)
})
it('should add the user to the newsletter if accepted terms', async function (ctx) {
ctx.passingRequest.subscribeToNewsletter = 'true'
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
ctx.NewsLetterManager.subscribe.calledWith(ctx.user).should.equal(true)
})
it('should not add the user to the newsletter if not accepted terms', async function (ctx) {
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
ctx.NewsLetterManager.subscribe.calledWith(ctx.user).should.equal(false)
})
})
})
describe('registerNewUserAndSendActivationEmail', function () {
beforeEach(function (ctx) {
ctx.email = 'Email@example.com'
ctx.crypto.randomBytes = sinon.stub().returns({
toString: () => {
return (ctx.password = 'mock-password')
},
})
ctx.OneTimeTokenHandler.promises.getNewToken.resolves(
(ctx.token = 'mock-token')
)
ctx.handler.promises.registerNewUser = sinon.stub()
})
describe('with a new user', function () {
beforeEach(async function (ctx) {
ctx.user.email = ctx.email.toLowerCase()
ctx.handler.promises.registerNewUser.resolves(ctx.user)
ctx.result =
await ctx.handler.promises.registerNewUserAndSendActivationEmail(
ctx.email
)
})
it('should ask the UserRegistrationHandler to register user', function (ctx) {
sinon.assert.calledWith(ctx.handler.promises.registerNewUser, {
email: ctx.email,
password: ctx.password,
})
})
it('should generate a new password reset token', function (ctx) {
const data = {
user_id: ctx.user._id.toString(),
email: ctx.user.email,
}
ctx.OneTimeTokenHandler.promises.getNewToken
.calledWith('password', data, { expiresIn: 7 * 24 * 60 * 60 })
.should.equal(true)
})
it('should send a registered email', function (ctx) {
ctx.EmailHandler.promises.sendEmail
.calledWith('registered', {
to: ctx.user.email,
setNewPasswordUrl: `${ctx.settings.siteUrl}/user/activate?token=${ctx.token}&user_id=${ctx.user_id}`,
})
.should.equal(true)
})
it('should return the user and new password url', function (ctx) {
const { user, setNewPasswordUrl } = ctx.result
expect(user).to.deep.equal(ctx.user)
expect(setNewPasswordUrl).to.equal(
`${ctx.settings.siteUrl}/user/activate?token=${ctx.token}&user_id=${ctx.user_id}`
)
})
})
describe('with a user that already exists', function () {
beforeEach(async function (ctx) {
ctx.handler.promises.registerNewUser.rejects(
new Error('EmailAlreadyRegistered')
)
ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
await ctx.handler.promises.registerNewUserAndSendActivationEmail(
ctx.email
)
})
it('should still generate a new password token and email', function (ctx) {
ctx.OneTimeTokenHandler.promises.getNewToken.called.should.equal(true)
ctx.EmailHandler.promises.sendEmail.called.should.equal(true)
})
})
})
})

View File

@@ -1,292 +0,0 @@
const SandboxedModule = require('sandboxed-module')
const assert = require('assert')
const path = require('path')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/User/UserRegistrationHandler'
)
const sinon = require('sinon')
const { expect } = require('chai')
const EmailHelper = require('../../../../app/src/Features/Helpers/EmailHelper')
describe('UserRegistrationHandler', function () {
beforeEach(function () {
this.analyticsId = '123456'
this.user = {
_id: (this.user_id = '31j2lk21kjl'),
analyticsId: this.analyticsId,
}
this.User = {
updateOne: sinon.stub().returns({ exec: sinon.stub().resolves() }),
}
this.UserGetter = {
promises: {
getUserByAnyEmail: sinon.stub(),
},
}
this.UserCreator = {
promises: {
createNewUser: sinon.stub().resolves(this.user),
},
}
this.AuthenticationManager = {
validateEmail: sinon.stub().returns(null),
validatePassword: sinon.stub().returns(null),
promises: {
setUserPassword: sinon.stub().resolves(this.user),
},
}
this.NewsLetterManager = {
subscribe: sinon.stub(),
}
this.EmailHandler = {
promises: { sendEmail: sinon.stub().resolves() },
}
this.OneTimeTokenHandler = { promises: { getNewToken: sinon.stub() } }
this.handler = SandboxedModule.require(modulePath, {
requires: {
'../../models/User': { User: this.User },
'./UserGetter': this.UserGetter,
'./UserCreator': this.UserCreator,
'../Authentication/AuthenticationManager': this.AuthenticationManager,
'../Newsletter/NewsletterManager': this.NewsLetterManager,
crypto: (this.crypto = {}),
'../Email/EmailHandler': this.EmailHandler,
'../Security/OneTimeTokenHandler': this.OneTimeTokenHandler,
'../Analytics/AnalyticsManager': (this.AnalyticsManager = {
recordEventForUser: sinon.stub(),
setUserPropertyForUser: sinon.stub(),
identifyUser: sinon.stub(),
}),
'@overleaf/settings': (this.settings = {
siteUrl: 'http://sl.example.com',
}),
'../Helpers/EmailHelper': EmailHelper,
},
})
this.passingRequest = {
email: 'something@email.com',
password: '123',
analyticsId: this.analyticsId,
}
})
describe('validate Register Request', function () {
it('allows passing validation through', function () {
const result = this.handler.promises._registrationRequestIsValid(
this.passingRequest
)
result.should.equal(true)
})
describe('failing email validation', function () {
beforeEach(function () {
this.AuthenticationManager.validateEmail.returns({
message: 'email not set',
})
})
it('does not allow through', function () {
const result = this.handler.promises._registrationRequestIsValid(
this.passingRequest
)
return result.should.equal(false)
})
})
describe('failing password validation', function () {
beforeEach(function () {
this.AuthenticationManager.validatePassword.returns({
message: 'password is too short',
})
})
it('does not allow through', function () {
const result = this.handler.promises._registrationRequestIsValid(
this.passingRequest
)
result.should.equal(false)
})
})
})
describe('registerNewUser', function () {
describe('holdingAccount', function (done) {
beforeEach(function () {
this.user.holdingAccount = true
this.handler.promises._registrationRequestIsValid = sinon
.stub()
.returns(true)
this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
})
it('should not create a new user if there is a holding account there', async function () {
await this.handler.promises.registerNewUser(this.passingRequest)
this.UserCreator.promises.createNewUser.called.should.equal(false)
})
it('should set holding account to false', async function () {
await this.handler.promises.registerNewUser(this.passingRequest)
const update = this.User.updateOne.args[0]
assert.deepEqual(update[0], { _id: this.user._id })
assert.deepEqual(update[1], { $set: { holdingAccount: false } })
})
})
describe('invalidRequest', function () {
it('should not create a new user if the the request is not valid', async function () {
this.handler.promises._registrationRequestIsValid = sinon
.stub()
.returns(false)
expect(this.handler.promises.registerNewUser(this.passingRequest)).to.be
.rejected
this.UserCreator.promises.createNewUser.called.should.equal(false)
})
it('should return email registered in the error if there is a non holdingAccount there', async function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(
(this.user = { holdingAccount: false })
)
expect(
this.handler.promises.registerNewUser(this.passingRequest)
).to.be.rejectedWith('EmailAlreadyRegistered')
})
})
describe('validRequest', function () {
beforeEach(function () {
this.handler.promises._registrationRequestIsValid = sinon
.stub()
.returns(true)
this.UserGetter.promises.getUserByAnyEmail.resolves()
})
it('should create a new user', async function () {
await this.handler.promises.registerNewUser(this.passingRequest)
sinon.assert.calledWith(this.UserCreator.promises.createNewUser, {
email: this.passingRequest.email,
holdingAccount: false,
first_name: this.passingRequest.first_name,
last_name: this.passingRequest.last_name,
analyticsId: this.user.analyticsId,
})
})
it('lower case email', async function () {
this.passingRequest.email = 'soMe@eMail.cOm'
await this.handler.promises.registerNewUser(this.passingRequest)
this.UserCreator.promises.createNewUser.args[0][0].email.should.equal(
'some@email.com'
)
})
it('trim white space from email', async function () {
this.passingRequest.email = ' some@email.com '
await this.handler.promises.registerNewUser(this.passingRequest)
this.UserCreator.promises.createNewUser.args[0][0].email.should.equal(
'some@email.com'
)
})
it('should set the password', async function () {
await this.handler.promises.registerNewUser(this.passingRequest)
this.AuthenticationManager.promises.setUserPassword
.calledWith(this.user, this.passingRequest.password)
.should.equal(true)
})
it('should add the user to the newsletter if accepted terms', async function () {
this.passingRequest.subscribeToNewsletter = 'true'
await this.handler.promises.registerNewUser(this.passingRequest)
this.NewsLetterManager.subscribe
.calledWith(this.user)
.should.equal(true)
})
it('should not add the user to the newsletter if not accepted terms', async function () {
await this.handler.promises.registerNewUser(this.passingRequest)
this.NewsLetterManager.subscribe
.calledWith(this.user)
.should.equal(false)
})
})
})
describe('registerNewUserAndSendActivationEmail', function () {
beforeEach(function () {
this.email = 'Email@example.com'
this.crypto.randomBytes = sinon.stub().returns({
toString: () => {
return (this.password = 'mock-password')
},
})
this.OneTimeTokenHandler.promises.getNewToken.resolves(
(this.token = 'mock-token')
)
this.handler.promises.registerNewUser = sinon.stub()
})
describe('with a new user', function () {
beforeEach(async function () {
this.user.email = this.email.toLowerCase()
this.handler.promises.registerNewUser.resolves(this.user)
this.result =
await this.handler.promises.registerNewUserAndSendActivationEmail(
this.email
)
})
it('should ask the UserRegistrationHandler to register user', function () {
sinon.assert.calledWith(this.handler.promises.registerNewUser, {
email: this.email,
password: this.password,
})
})
it('should generate a new password reset token', function () {
const data = {
user_id: this.user._id.toString(),
email: this.user.email,
}
this.OneTimeTokenHandler.promises.getNewToken
.calledWith('password', data, { expiresIn: 7 * 24 * 60 * 60 })
.should.equal(true)
})
it('should send a registered email', function () {
this.EmailHandler.promises.sendEmail
.calledWith('registered', {
to: this.user.email,
setNewPasswordUrl: `${this.settings.siteUrl}/user/activate?token=${this.token}&user_id=${this.user_id}`,
})
.should.equal(true)
})
it('should return the user and new password url', function () {
const { user, setNewPasswordUrl } = this.result
expect(user).to.deep.equal(this.user)
expect(setNewPasswordUrl).to.equal(
`${this.settings.siteUrl}/user/activate?token=${this.token}&user_id=${this.user_id}`
)
})
})
describe('with a user that already exists', function () {
beforeEach(async function () {
this.handler.promises.registerNewUser.rejects(
new Error('EmailAlreadyRegistered')
)
this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
await this.handler.promises.registerNewUserAndSendActivationEmail(
this.email
)
})
it('should still generate a new password token and email', function () {
this.OneTimeTokenHandler.promises.getNewToken.called.should.equal(true)
this.EmailHandler.promises.sendEmail.called.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,34 @@
class MockRequest {
constructor(vi) {
this.session = { destroy() {} }
this.ip = '42.42.42.42'
this.headers = {}
this.params = {}
this.query = {}
this.body = {}
this._parsedUrl = {}
this.i18n = {
translate(str) {
return str
},
}
this.route = { path: '' }
this.accepts = () => {}
this.setHeader = () => {}
this.logger = {
addFields: vi.fn(),
setLevel: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
}
}
param(param) {
return this.params[param]
}
}
export default MockRequest

View File

@@ -0,0 +1,139 @@
import Path from 'path'
import contentDisposition from 'content-disposition'
class MockResponse {
constructor(vi) {
this.rendered = false
this.redirected = false
this.returned = false
this.headers = {}
this.locals = {}
this.setContentDisposition = vi.fn()
this.contentType = vi.fn(this.contentType)
this.header = vi.fn(this.header)
this.json = vi.fn(this.json)
this.send = vi.fn(this.send)
this.sendStatus = vi.fn(this.sendStatus)
this.status = vi.fn(this.status)
this.render = vi.fn(this.render)
this.redirect = vi.fn(this.redirect)
}
header(field, val) {
this.headers[field] = val
}
render(template, variables) {
this.success = true
this.rendered = true
this.returned = true
this.renderedTemplate = template
this.renderedVariables = variables
this.callback?.()
}
redirect(url) {
this.success = true
this.redirected = true
this.returned = true
this.redirectedTo = url
this.callback?.()
}
sendStatus(status) {
if (typeof status !== 'number') {
status = 200
}
this.statusCode = status
this.returned = true
this.success = status >= 200 && status < 300
this.callback?.()
}
writeHead(status) {
this.statusCode = status
}
send(status, body) {
if (typeof status !== 'number') {
body = status
status = this.statusCode || 200
}
this.statusCode = status
this.returned = true
this.success = status >= 200 && status < 300
if (body) {
this.body = body
}
this.callback?.()
}
json(status, body) {
if (typeof status !== 'number') {
body = status
status = this.statusCode || 200
}
this.statusCode = status
this.returned = true
this.contentType('application/json')
this.success = status >= 200 && status < 300
if (body) {
this.body = JSON.stringify(body)
}
this.callback?.()
}
status(status) {
this.statusCode = status
return this
}
setHeader(header, value) {
this.header(header, value)
}
appendHeader(header, value) {
if (this.headers[header]) {
this.headers[header] += `, ${value}`
} else {
this.headers[header] = value
}
}
setTimeout(timout) {
this.timout = timout
}
end(data, encoding) {
this.callback?.()
}
attachment(filename) {
switch (Path.extname(filename)) {
case '.csv':
this.contentType('text/csv; charset=utf-8')
break
case '.zip':
this.contentType('application/zip')
break
default:
throw new Error('unexpected extension')
}
this.header('Content-Disposition', contentDisposition(filename))
return this
}
contentType(type) {
this.header('Content-Type', type)
this.type = type
return this
}
type(type) {
return this.contentType(type)
}
}
export default MockResponse