Просмотр исходного кода

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

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

GitOrigin-RevId: c0d487082fa4822c68130e1e98c043297d4bedeb
Antoine Clausse 10 месяцев назад
Родитель
Сommit
d7e1ad5588
34 измененных файлов с 2691 добавлено и 2306 удалено
  1. 2 2
      services/web/app/src/Features/Chat/ChatController.mjs
  2. 1 1
      services/web/app/src/Features/Chat/ChatManager.mjs
  3. 1 1
      services/web/app/src/Features/Project/ProjectListController.mjs
  4. 16 14
      services/web/app/src/Features/User/SAMLIdentityManager.mjs
  5. 13 13
      services/web/app/src/Features/User/UserCreator.mjs
  6. 11 11
      services/web/app/src/Features/User/UserEmailsConfirmationHandler.mjs
  7. 24 24
      services/web/app/src/Features/User/UserEmailsController.mjs
  8. 7 5
      services/web/app/src/Features/User/UserInfoController.mjs
  9. 3 3
      services/web/app/src/Features/User/UserInfoManager.mjs
  10. 2 2
      services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.mjs
  11. 14 17
      services/web/app/src/Features/User/UserRegistrationHandler.mjs
  12. 2 2
      services/web/app/src/router.mjs
  13. 1 1
      services/web/modules/launchpad/app/src/LaunchpadController.mjs
  14. 1 1
      services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs
  15. 1 1
      services/web/modules/server-ce-scripts/scripts/create-user.mjs
  16. 1 1
      services/web/modules/user-activate/app/src/UserActivateController.mjs
  17. 1 1
      services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs
  18. 1 1
      services/web/scripts/e2e_test_setup.mjs
  19. 1 1
      services/web/test/acceptance/src/ProjectStructureMongoLockTest.mjs
  20. 1 1
      services/web/test/acceptance/src/helpers/UserHelper.mjs
  21. 2 2
      services/web/test/unit/src/Chat/ChatController.test.mjs
  22. 236 191
      services/web/test/unit/src/User/SAMLIdentityManager.test.mjs
  23. 362 0
      services/web/test/unit/src/User/UserCreator.test.mjs
  24. 0 322
      services/web/test/unit/src/User/UserCreatorTests.js
  25. 290 0
      services/web/test/unit/src/User/UserEmailsConfirmationHandler.test.mjs
  26. 0 266
      services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js
  27. 967 0
      services/web/test/unit/src/User/UserEmailsController.test.mjs
  28. 0 905
      services/web/test/unit/src/User/UserEmailsControllerTests.js
  29. 230 0
      services/web/test/unit/src/User/UserInfoController.test.mjs
  30. 0 225
      services/web/test/unit/src/User/UserInfoControllerTests.js
  31. 327 0
      services/web/test/unit/src/User/UserRegistrationHandler.test.mjs
  32. 0 292
      services/web/test/unit/src/User/UserRegistrationHandlerTests.js
  33. 34 0
      services/web/test/unit/src/helpers/MockRequestVitest.mjs
  34. 139 0
      services/web/test/unit/src/helpers/MockResponseVitest.mjs

+ 2 - 2
services/web/app/src/Features/Chat/ChatController.mjs

@@ -3,8 +3,8 @@ import Modules from '../../infrastructure/Modules.js'
 import ChatApiHandler from './ChatApiHandler.js'
 import ChatApiHandler from './ChatApiHandler.js'
 import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
 import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
 import SessionManager from '../Authentication/SessionManager.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'
 import ChatManager from './ChatManager.mjs'
 
 
 async function sendMessage(req, res) {
 async function sendMessage(req, res) {

+ 1 - 1
services/web/app/src/Features/Chat/ChatManager.mjs

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

+ 1 - 1
services/web/app/src/Features/Project/ProjectListController.mjs

@@ -20,7 +20,7 @@ import NotificationsHandler from '../Notifications/NotificationsHandler.js'
 import Modules from '../../infrastructure/Modules.js'
 import Modules from '../../infrastructure/Modules.js'
 import { OError, V1ConnectionError } from '../Errors/Errors.js'
 import { OError, V1ConnectionError } from '../Errors/Errors.js'
 import { User } from '../../models/User.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 UserController from '../User/UserController.mjs'
 import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
 import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
 import GeoIpLookup from '../../infrastructure/GeoIpLookup.mjs'
 import GeoIpLookup from '../../infrastructure/GeoIpLookup.mjs'

+ 16 - 14
services/web/app/src/Features/User/SAMLIdentityManager.js → services/web/app/src/Features/User/SAMLIdentityManager.mjs

@@ -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) {
 async function _addAuditLogEntry(operation, userId, auditLog, extraInfo) {
   await UserAuditLogHandler.promises.addEntry(
   await UserAuditLogHandler.promises.addEntry(
@@ -469,4 +471,4 @@ const SAMLIdentityManager = {
   userHasEntitlement,
   userHasEntitlement,
 }
 }
 
 
-module.exports = SAMLIdentityManager
+export default SAMLIdentityManager

+ 13 - 13
services/web/app/src/Features/User/UserCreator.js → services/web/app/src/Features/User/UserCreator.mjs

@@ -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) {
 async function _addAffiliation(user, affiliationOptions) {
   try {
   try {
@@ -142,4 +142,4 @@ const UserCreator = {
   },
   },
 }
 }
 
 
-module.exports = UserCreator
+export default UserCreator

+ 11 - 11
services/web/app/src/Features/User/UserEmailsConfirmationHandler.js → services/web/app/src/Features/User/UserEmailsConfirmationHandler.mjs

@@ -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
 // Reject email confirmation tokens after 90 days
 const TOKEN_EXPIRY_IN_S = 90 * 24 * 60 * 60
 const TOKEN_EXPIRY_IN_S = 90 * 24 * 60 * 60
@@ -104,4 +104,4 @@ UserEmailsConfirmationHandler.promises = {
   sendConfirmationCode,
   sendConfirmationCode,
 }
 }
 
 
-module.exports = UserEmailsConfirmationHandler
+export default UserEmailsConfirmationHandler

+ 24 - 24
services/web/app/src/Features/User/UserEmailsController.js → services/web/app/src/Features/User/UserEmailsController.mjs

@@ -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
 const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
 
 
@@ -615,7 +615,7 @@ const UserEmailsController = {
       return res.sendStatus(422)
       return res.sendStatus(422)
     }
     }
 
 
-    endorseAffiliation(
+    InstitutionsAPI.endorseAffiliation(
       userId,
       userId,
       email,
       email,
       req.body.role,
       req.body.role,
@@ -729,4 +729,4 @@ const UserEmailsController = {
   },
   },
 }
 }
 
 
-module.exports = UserEmailsController
+export default UserEmailsController

+ 7 - 5
services/web/app/src/Features/User/UserInfoController.js → services/web/app/src/Features/User/UserInfoController.mjs

@@ -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) {
 function getLoggedInUsersPersonalInfo(req, res, next) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const userId = SessionManager.getLoggedInUserId(req.session)
@@ -88,7 +90,7 @@ async function getUserFeatures(req, res, next) {
   return res.json(features)
   return res.json(features)
 }
 }
 
 
-module.exports = {
+export default {
   getLoggedInUsersPersonalInfo,
   getLoggedInUsersPersonalInfo,
   getPersonalInfo,
   getPersonalInfo,
   sendFormattedPersonalInfo,
   sendFormattedPersonalInfo,

+ 3 - 3
services/web/app/src/Features/User/UserInfoManager.js → services/web/app/src/Features/User/UserInfoManager.mjs

@@ -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) {
 async function getPersonalInfo(userId) {
   return UserGetter.promises.getUser(userId, {
   return UserGetter.promises.getUser(userId, {
@@ -10,7 +10,7 @@ async function getPersonalInfo(userId) {
   })
   })
 }
 }
 
 
-module.exports = {
+export default {
   getPersonalInfo: callbackify(getPersonalInfo),
   getPersonalInfo: callbackify(getPersonalInfo),
   promises: {
   promises: {
     getPersonalInfo,
     getPersonalInfo,

+ 2 - 2
services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.js → services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.mjs

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

+ 14 - 17
services/web/app/src/Features/User/UserRegistrationHandler.js → services/web/app/src/Features/User/UserRegistrationHandler.mjs

@@ -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 = {
 const UserRegistrationHandler = {
   _registrationRequestIsValid(body) {
   _registrationRequestIsValid(body) {
@@ -126,7 +123,7 @@ const UserRegistrationHandler = {
   },
   },
 }
 }
 
 
-module.exports = {
+export default {
   registerNewUser: callbackify(UserRegistrationHandler.registerNewUser),
   registerNewUser: callbackify(UserRegistrationHandler.registerNewUser),
   registerNewUserAndSendActivationEmail: callbackifyMultiResult(
   registerNewUserAndSendActivationEmail: callbackifyMultiResult(
     UserRegistrationHandler.registerNewUserAndSendActivationEmail,
     UserRegistrationHandler.registerNewUserAndSendActivationEmail,

+ 2 - 2
services/web/app/src/router.mjs

@@ -18,9 +18,9 @@ import SessionManager from './Features/Authentication/SessionManager.js'
 import TagsController from './Features/Tags/TagsController.mjs'
 import TagsController from './Features/Tags/TagsController.mjs'
 import NotificationsController from './Features/Notifications/NotificationsController.mjs'
 import NotificationsController from './Features/Notifications/NotificationsController.mjs'
 import CollaboratorsRouter from './Features/Collaborators/CollaboratorsRouter.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 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 UserPagesController from './Features/User/UserPagesController.mjs'
 import TutorialController from './Features/Tutorial/TutorialController.mjs'
 import TutorialController from './Features/Tutorial/TutorialController.mjs'
 import DocumentController from './Features/Documents/DocumentController.mjs'
 import DocumentController from './Features/Documents/DocumentController.mjs'

+ 1 - 1
services/web/modules/launchpad/app/src/LaunchpadController.mjs

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

+ 1 - 1
services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs

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

+ 1 - 1
services/web/modules/server-ce-scripts/scripts/create-user.mjs

@@ -1,6 +1,6 @@
 import minimist from 'minimist'
 import minimist from 'minimist'
 import { db } from '../../../app/src/infrastructure/mongodb.js'
 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'
 import { fileURLToPath } from 'url'
 
 
 const filename = fileURLToPath(import.meta.url)
 const filename = fileURLToPath(import.meta.url)

+ 1 - 1
services/web/modules/user-activate/app/src/UserActivateController.mjs

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

+ 1 - 1
services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs

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

+ 1 - 1
services/web/scripts/e2e_test_setup.mjs

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

+ 1 - 1
services/web/test/acceptance/src/ProjectStructureMongoLockTest.mjs

@@ -17,7 +17,7 @@ import LockManager from '../../../app/src/infrastructure/LockManager.js'
 import ProjectCreationHandler from '../../../app/src/Features/Project/ProjectCreationHandler.js'
 import ProjectCreationHandler from '../../../app/src/Features/Project/ProjectCreationHandler.js'
 import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
 import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
 import ProjectEntityMongoUpdateHandler from '../../../app/src/Features/Project/ProjectEntityMongoUpdateHandler.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 { expect } from 'chai'
 import _ from 'lodash'
 import _ from 'lodash'
 
 

+ 1 - 1
services/web/test/acceptance/src/helpers/UserHelper.mjs

@@ -2,7 +2,7 @@ import { CookieJar } from 'tough-cookie'
 import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.js'
 import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.js'
 import Settings from '@overleaf/settings'
 import Settings from '@overleaf/settings'
 import InstitutionsAPI from '../../../../app/src/Features/Institutions/InstitutionsAPI.js'
 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 UserGetter from '../../../../app/src/Features/User/UserGetter.js'
 import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js'
 import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js'
 import moment from 'moment'
 import moment from 'moment'

+ 2 - 2
services/web/test/unit/src/Chat/ChatController.test.mjs

@@ -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,
       default: ctx.UserInfoManager,
     }))
     }))
 
 
     vi.doMock(
     vi.doMock(
-      '../../../../app/src/Features/User/UserInfoController.js',
+      '../../../../app/src/Features/User/UserInfoController.mjs',
       () => ({
       () => ({
         default: ctx.UserInfoController,
         default: ctx.UserInfoController,
       })
       })

+ 236 - 191
services/web/test/unit/src/User/SAMLIdentityManagerTests.js → services/web/test/unit/src/User/SAMLIdentityManager.test.mjs

@@ -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 () {
 describe('SAMLIdentityManager', function () {
   const linkedEmail = 'another@example.com'
   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',
       email: 'not-linked@overleaf.com',
       emails: [{ email: 'not-linked@overleaf.com' }],
       emails: [{ email: 'not-linked@overleaf.com' }],
       samlIdentifiers: [],
       samlIdentifiers: [],
     }
     }
-    this.auditLog = {
-      initiatorId: this.userId,
+    ctx.auditLog = {
+      initiatorId: ctx.userId,
       ipAddress: '0:0:0:0',
       ipAddress: '0:0:0:0',
     }
     }
-    this.userAlreadyLinked = {
+    ctx.userAlreadyLinked = {
       _id: '6005c7a012cbcaf771f4a106',
       _id: '6005c7a012cbcaf771f4a106',
       email: 'linked@overleaf.com',
       email: 'linked@overleaf.com',
       emails: [{ email: 'linked@overleaf.com', samlProviderId: '1' }],
       emails: [{ email: 'linked@overleaf.com', samlProviderId: '1' }],
       samlIdentifiers: [{ externalUserId: 'linked-id', providerId: '1' }],
       samlIdentifiers: [{ externalUserId: 'linked-id', providerId: '1' }],
     }
     }
-    this.userEmailExists = {
+    ctx.userEmailExists = {
       _id: '6005c7a012cbcaf771f4a107',
       _id: '6005c7a012cbcaf771f4a107',
       email: 'exists@overleaf.com',
       email: 'exists@overleaf.com',
       emails: [{ email: 'exists@overleaf.com' }],
       emails: [{ email: 'exists@overleaf.com' }],
       samlIdentifiers: [],
       samlIdentifiers: [],
     }
     }
-    this.institution = {
+    ctx.institution = {
       name: 'Overleaf University',
       name: 'Overleaf University',
     }
     }
-    this.InstitutionsAPI = {
+    ctx.InstitutionsAPI = {
       promises: {
       promises: {
         addEntitlement: sinon.stub().resolves(),
         addEntitlement: sinon.stub().resolves(),
         removeEntitlement: 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: {
           promises: {
             redundantPersonalSubscription: sinon
             redundantPersonalSubscription: sinon
               .stub()
               .stub()
               .returns({ create: sinon.stub().resolves() }),
               .returns({ create: sinon.stub().resolves() }),
           },
           },
         }),
         }),
-        '../Subscription/SubscriptionLocator': (this.SubscriptionLocator = {
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: (ctx.SubscriptionLocator = {
           promises: {
           promises: {
             getUserIndividualSubscription: sinon.stub().resolves(),
             getUserIndividualSubscription: sinon.stub().resolves(),
           },
           },
         }),
         }),
-        '../../models/User': {
-          User: (this.User = {
-            findOneAndUpdate: sinon.stub().returns({
-              exec: sinon.stub().resolves(this.user),
-            }),
-            findOne: sinon.stub().returns({
-              exec: sinon.stub().resolves(),
-            }),
-            updateOne: sinon.stub().returns({
-              exec: sinon.stub().resolves(),
-            }),
-          }),
-        },
-        '../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
-          promises: {
-            addEntry: sinon.stub().resolves(),
-          },
+      })
+    )
+
+    vi.doMock('../../../../app/src/models/User', () => ({
+      User: (ctx.User = {
+        findOneAndUpdate: sinon.stub().returns({
+          exec: sinon.stub().resolves(ctx.user),
         }),
         }),
-        '../User/UserGetter': (this.UserGetter = {
-          getUser: sinon.stub(),
-          promises: {
-            getUser: sinon.stub().resolves(this.user),
-            getUserByAnyEmail: sinon.stub().resolves(),
-            getUserFullEmails: sinon.stub().resolves(),
-          },
+        findOne: sinon.stub().returns({
+          exec: sinon.stub().resolves(),
         }),
         }),
-        '../User/UserUpdater': (this.UserUpdater = {
-          addEmailAddress: sinon.stub(),
-          promises: {
-            addEmailAddress: sinon.stub().resolves(),
-            confirmEmail: sinon.stub().resolves(),
-            updateUser: sinon.stub().resolves(),
-          },
+        updateOne: sinon.stub().returns({
+          exec: sinon.stub().resolves(),
         }),
         }),
-        '../Institutions/InstitutionsAPI': this.InstitutionsAPI,
-      },
-    })
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
+      default: (ctx.UserAuditLogHandler = {
+        promises: {
+          addEntry: sinon.stub().resolves(),
+        },
+      }),
+    }))
+
+    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 () {
   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
       let error
       try {
       try {
-        await this.SAMLIdentityManager.getUser(undefined, undefined, undefined)
+        await ctx.SAMLIdentityManager.getUser(undefined, undefined, undefined)
       } catch (e) {
       } catch (e) {
         error = e
         error = e
       } finally {
       } 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
       let error
       try {
       try {
-        await this.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr')
+        await ctx.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr')
       } catch (e) {
       } catch (e) {
         error = e
         error = e
       } finally {
       } 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
       let error
       try {
       try {
-        await this.SAMLIdentityManager.getUser('123', null, 'someAttr')
+        await ctx.SAMLIdentityManager.getUser('123', null, 'someAttr')
       } catch (e) {
       } catch (e) {
         error = e
         error = e
       } finally {
       } finally {
         expect(error).to.exist
         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
       let error
       try {
       try {
-        await this.SAMLIdentityManager.getUser('123', 'id123', undefined)
+        await ctx.SAMLIdentityManager.getUser('123', 'id123', undefined)
       } catch (e) {
       } catch (e) {
         error = e
         error = e
       } finally {
       } finally {
@@ -152,16 +197,16 @@ describe('SAMLIdentityManager', function () {
 
 
   describe('linkAccounts', function () {
   describe('linkAccounts', function () {
     describe('errors', function () {
     describe('errors', function () {
-      beforeEach(function () {
+      beforeEach(function (ctx) {
         // first call is to get userWithProvider; should be falsy
         // 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
         let error
         try {
         try {
-          await this.SAMLIdentityManager.linkAccounts(null, null, null)
+          await ctx.SAMLIdentityManager.linkAccounts(null, null, null)
         } catch (e) {
         } catch (e) {
           error = e
           error = e
         } finally {
         } finally {
@@ -180,9 +225,9 @@ describe('SAMLIdentityManager', function () {
           const testData = { ...requiredData }
           const testData = { ...requiredData }
           delete testData[data]
           delete testData[data]
           let error
           let error
-          it(`should throw an error when missing ${data}`, async function () {
+          it(`should throw an error when missing ${data}`, async function (ctx) {
             try {
             try {
-              await this.SAMLIdentityManager.linkAccounts('123', testData, {})
+              await ctx.SAMLIdentityManager.linkAccounts('123', testData, {})
             } catch (e) {
             } catch (e) {
               error = e
               error = e
             } finally {
             } finally {
@@ -196,17 +241,17 @@ describe('SAMLIdentityManager', function () {
       })
       })
 
 
       describe('when email is already associated with another Overleaf account', 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
           let error
 
 
           try {
           try {
-            await this.SAMLIdentityManager.linkAccounts(
+            await ctx.SAMLIdentityManager.linkAccounts(
               '6005c75b12cbcaf771f4a105',
               '6005c75b12cbcaf771f4a105',
               {
               {
                 externalUserId: 'not-linked-id',
                 externalUserId: 'not-linked-id',
@@ -225,25 +270,25 @@ describe('SAMLIdentityManager', function () {
             error = e
             error = e
           } finally {
           } finally {
             expect(error).to.be.instanceof(Errors.EmailExistsError)
             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 () {
       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',
               email: 'not-affiliated@overleaf.com',
             },
             },
           ])
           ])
         })
         })
 
 
-        it('should throw SAMLEmailNotAffiliatedError', async function () {
+        it('should throw SAMLEmailNotAffiliatedError', async function (ctx) {
           let error
           let error
           try {
           try {
-            await this.SAMLIdentityManager.linkAccounts(
+            await ctx.SAMLIdentityManager.linkAccounts(
               '6005c75b12cbcaf771f4a105',
               '6005c75b12cbcaf771f4a105',
               {
               {
                 externalUserId: 'not-linked-id',
                 externalUserId: 'not-linked-id',
@@ -262,15 +307,15 @@ describe('SAMLIdentityManager', function () {
             error = e
             error = e
           } finally {
           } finally {
             expect(error).to.be.instanceof(Errors.SAMLEmailNotAffiliatedError)
             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 () {
       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',
               email: 'affiliated@overleaf.com',
               affiliation: { institution: { id: '987' } },
               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
           let error
           try {
           try {
-            await this.SAMLIdentityManager.linkAccounts(
+            await ctx.SAMLIdentityManager.linkAccounts(
               '6005c75b12cbcaf771f4a105',
               '6005c75b12cbcaf771f4a105',
               {
               {
                 externalUserId: 'not-linked-id',
                 externalUserId: 'not-linked-id',
@@ -302,22 +347,22 @@ describe('SAMLIdentityManager', function () {
             expect(error).to.be.instanceof(
             expect(error).to.be.instanceof(
               Errors.SAMLEmailAffiliatedWithAnotherInstitutionError
               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 () {
       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
           let error
           try {
           try {
-            await this.SAMLIdentityManager.linkAccounts(
+            await ctx.SAMLIdentityManager.linkAccounts(
               '6005c75b12cbcaf771f4a105',
               '6005c75b12cbcaf771f4a105',
               {
               {
                 externalUserId: 'already-linked-id',
                 externalUserId: 'already-linked-id',
@@ -336,21 +381,21 @@ describe('SAMLIdentityManager', function () {
             error = e
             error = e
           } finally {
           } finally {
             expect(error).to.be.instanceof(Errors.SAMLIdentityExistsError)
             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 () {
       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
           // 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
           let error
           try {
           try {
-            await this.SAMLIdentityManager.linkAccounts(
+            await ctx.SAMLIdentityManager.linkAccounts(
               '6005c75b12cbcaf771f4a105',
               '6005c75b12cbcaf771f4a105',
               {
               {
                 externalUserId: 'already-linked-id',
                 externalUserId: 'already-linked-id',
@@ -369,27 +414,27 @@ describe('SAMLIdentityManager', function () {
             error = e
             error = e
           } finally {
           } finally {
             expect(
             expect(
-              this.UserGetter.promises.getUser
+              ctx.UserGetter.promises.getUser
             ).to.have.been.calledWithMatch({
             ).to.have.been.calledWithMatch({
               _id: new ObjectId('6005c75b12cbcaf771f4a105'),
               _id: new ObjectId('6005c75b12cbcaf771f4a105'),
               'samlIdentifiers.providerId': '123456',
               'samlIdentifiers.providerId': '123456',
             })
             })
             expect(error).to.be.instanceof(Errors.SAMLAlreadyLinkedError)
             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
         let error
         const anError = new Error('oops')
         const anError = new Error('oops')
-        this.UserAuditLogHandler.promises.addEntry.rejects(anError)
+        ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
         try {
         try {
-          await this.SAMLIdentityManager.linkAccounts(
-            this.user._id,
+          await ctx.SAMLIdentityManager.linkAccounts(
+            ctx.user._id,
             {
             {
               externalUserId: 'externalUserId',
               externalUserId: 'externalUserId',
-              institutionEmail: this.user.email,
+              institutionEmail: ctx.user.email,
               universityId: '1',
               universityId: '1',
               universityName: 'Overleaf University',
               universityName: 'Overleaf University',
               hasEntitlement: false,
               hasEntitlement: false,
@@ -405,29 +450,29 @@ describe('SAMLIdentityManager', function () {
         } finally {
         } finally {
           expect(error).to.exist
           expect(error).to.exist
           expect(error).to.equal(anError)
           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 () {
     describe('success', function () {
-      beforeEach(function () {
+      beforeEach(function (ctx) {
         // first call is to get userWithProvider; should be falsy
         // 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 = {
         const auditLog = {
           initiatorId: '6005c75b12cbcaf771f4a105',
           initiatorId: '6005c75b12cbcaf771f4a105',
           ipAddress: '0:0:0:0',
           ipAddress: '0:0:0:0',
         }
         }
-        await this.SAMLIdentityManager.linkAccounts(
-          this.user._id,
+        await ctx.SAMLIdentityManager.linkAccounts(
+          ctx.user._id,
           {
           {
             externalUserId: 'externalUserId',
             externalUserId: 'externalUserId',
-            institutionEmail: this.user.email,
+            institutionEmail: ctx.user.email,
             universityId: '1',
             universityId: '1',
             universityName: 'Overleaf University',
             universityName: 'Overleaf University',
             hasEntitlement: false,
             hasEntitlement: false,
@@ -437,14 +482,14 @@ describe('SAMLIdentityManager', function () {
         )
         )
 
 
         expect(
         expect(
-          this.UserAuditLogHandler.promises.addEntry
+          ctx.UserAuditLogHandler.promises.addEntry
         ).to.have.been.calledWith(
         ).to.have.been.calledWith(
-          this.user._id,
+          ctx.user._id,
           'link-institution-sso',
           'link-institution-sso',
           auditLog.initiatorId,
           auditLog.initiatorId,
           auditLog.ipAddress,
           auditLog.ipAddress,
           {
           {
-            institutionEmail: this.user.email,
+            institutionEmail: ctx.user.email,
             providerId: '1',
             providerId: '1',
             providerName: 'Overleaf University',
             providerName: 'Overleaf University',
             userIdAttribute: 'uniqueId',
             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',
             externalUserId: 'externalUserId',
-            institutionEmail: this.user.email,
+            institutionEmail: ctx.user.email,
             universityId: '1',
             universityId: '1',
             universityName: 'Overleaf University',
             universityName: 'Overleaf University',
             hasEntitlement: false,
             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[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].actionDescribed).to.contain('was linked')
         expect(emailArgs[1].message[0]).to.contain('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 () {
   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,
         linkedEmail,
-        this.user.email,
+        ctx.user.email,
         '1',
         '1',
         'Overleaf University',
         'Overleaf University',
-        this.auditLog
+        ctx.auditLog
       )
       )
       expect(
       expect(
-        this.UserAuditLogHandler.promises.addEntry
+        ctx.UserAuditLogHandler.promises.addEntry
       ).to.have.been.calledOnce.and.calledWithMatch(
       ).to.have.been.calledOnce.and.calledWithMatch(
-        this.user._id,
+        ctx.user._id,
         'unlink-institution-sso',
         'unlink-institution-sso',
-        this.auditLog.initiatorId,
-        this.auditLog.ipAddress,
+        ctx.auditLog.initiatorId,
+        ctx.auditLog.ipAddress,
         {
         {
           institutionEmail: linkedEmail,
           institutionEmail: linkedEmail,
           providerId: '1',
           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,
         linkedEmail,
-        this.user.email,
+        ctx.user.email,
         '1',
         '1',
         'Overleaf University',
         'Overleaf University',
-        this.auditLog
+        ctx.auditLog
       )
       )
       const query = {
       const query = {
-        _id: this.user._id,
+        _id: ctx.user._id,
       }
       }
       const update = {
       const update = {
         $pull: {
         $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,
         query,
         update
         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,
         linkedEmail,
-        this.user.email,
+        ctx.user.email,
         '1',
         '1',
         'Overleaf University',
         '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[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].actionDescribed).to.contain('was unlinked')
       expect(emailArgs[1].message[0]).to.contain('No longer linked')
       expect(emailArgs[1].message[0]).to.contain('No longer linked')
       expect(emailArgs[1].message[0]).to.contain(linkedEmail)
       expect(emailArgs[1].message[0]).to.contain(linkedEmail)
     })
     })
 
 
     describe('errors', function () {
     describe('errors', function () {
-      it('should pass back errors via UserAuditLogHandler', async function () {
+      it('should pass back errors via UserAuditLogHandler', async function (ctx) {
         let error
         let error
         const anError = new Error('oops')
         const anError = new Error('oops')
-        this.UserAuditLogHandler.promises.addEntry.rejects(anError)
+        ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
         try {
         try {
-          await this.SAMLIdentityManager.unlinkAccounts(
-            this.user._id,
+          await ctx.SAMLIdentityManager.unlinkAccounts(
+            ctx.user._id,
             linkedEmail,
             linkedEmail,
-            this.user.email,
+            ctx.user.email,
             '1',
             '1',
             'Overleaf University',
             'Overleaf University',
-            this.auditLog
+            ctx.auditLog
           )
           )
         } catch (e) {
         } catch (e) {
           error = e
           error = e
         } finally {
         } finally {
           expect(error).to.exist
           expect(error).to.exist
           expect(error).to.equal(anError)
           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 () {
   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',
         'foo bar',
         'bar'
         'bar'
       ).should.equal(true)
       ).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',
         'foo bar',
         'bam'
         'bam'
       ).should.equal(false)
       ).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',
         'foo bar',
         '('
         '('
       ).should.equal(false)
       ).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'
         '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'],
         ['foo', 'bar'],
         'bar'
         'bar'
       ).should.equal(true)
       ).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'],
         ['foo', 'bar'],
         'bam'
         'bam'
       ).should.equal(false)
       ).should.equal(false)
@@ -636,18 +681,18 @@ describe('SAMLIdentityManager', function () {
     }
     }
     const userIdAttribute = 'newUniqueId'
     const userIdAttribute = 'newUniqueId'
 
 
-    it('should remove the old identifier and add the new identifier', async function () {
-      this.UserGetter.promises.getUser.resolves()
-      this.UserGetter.promises.getUserByAnyEmail
+    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)
         .withArgs(institutionEmail)
         .resolves({ _id: userId, emails: [{ email: institutionEmail }] })
         .resolves({ _id: userId, emails: [{ email: institutionEmail }] })
-      this.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([
+      ctx.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([
         {
         {
           email: institutionEmail,
           email: institutionEmail,
           affiliation: { institution: { id: providerId } },
           affiliation: { institution: { id: providerId } },
         },
         },
       ])
       ])
-      await this.SAMLIdentityManager.migrateIdentifier(
+      await ctx.SAMLIdentityManager.migrateIdentifier(
         userId,
         userId,
         externalUserId,
         externalUserId,
         providerId,
         providerId,
@@ -658,7 +703,7 @@ describe('SAMLIdentityManager', function () {
         userIdAttribute
         userIdAttribute
       )
       )
 
 
-      expect(this.User.updateOne).to.have.been.calledOnce
+      expect(ctx.User.updateOne).to.have.been.calledOnce
       const query = {
       const query = {
         _id: userId,
         _id: userId,
         'samlIdentifiers.providerId': providerId.toString(),
         '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',
       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({
         exec: sinon.stub().resolves({
           _id: userId,
           _id: userId,
           emails: [{ email: institutionEmail, samlProviderId: providerId }],
           emails: [{ email: institutionEmail, samlProviderId: providerId }],
         }),
         }),
       })
       })
 
 
-      await this.SAMLIdentityManager.unlinkNotMigrated(
+      await ctx.SAMLIdentityManager.unlinkNotMigrated(
         userId,
         userId,
         providerId,
         providerId,
         providerName,
         providerName,
         auditLog
         auditLog
       )
       )
 
 
-      expect(this.User.findOneAndUpdate).to.have.been.calledOnce
+      expect(ctx.User.findOneAndUpdate).to.have.been.calledOnce
       const query = {
       const query = {
         _id: userId,
         _id: userId,
         'emails.samlProviderId': providerId,
         'emails.samlProviderId': providerId,
@@ -714,14 +759,14 @@ describe('SAMLIdentityManager', function () {
           'emails.$.samlProviderId': 1,
           'emails.$.samlProviderId': 1,
         },
         },
       }
       }
-      expect(this.User.findOneAndUpdate.lastCall.args).to.deep.equal([
+      expect(ctx.User.findOneAndUpdate.lastCall.args).to.deep.equal([
         query,
         query,
         update,
         update,
       ])
       ])
 
 
-      expect(this.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce
+      expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce
       expect(
       expect(
-        this.UserAuditLogHandler.promises.addEntry.lastCall.args
+        ctx.UserAuditLogHandler.promises.addEntry.lastCall.args
       ).to.deep.equal([
       ).to.deep.equal([
         userId,
         userId,
         'unlink-institution-sso-not-migrated',
         'unlink-institution-sso-not-migrated',
@@ -730,10 +775,10 @@ describe('SAMLIdentityManager', function () {
         { providerId, providerName },
         { providerId, providerName },
       ])
       ])
 
 
-      expect(this.InstitutionsAPI.promises.removeEntitlement).to.have.been
+      expect(ctx.InstitutionsAPI.promises.removeEntitlement).to.have.been
         .calledOnce
         .calledOnce
       expect(
       expect(
-        this.InstitutionsAPI.promises.removeEntitlement.lastCall.args
+        ctx.InstitutionsAPI.promises.removeEntitlement.lastCall.args
       ).to.deep.equal([userId, institutionEmail])
       ).to.deep.equal([userId, institutionEmail])
     })
     })
   })
   })

+ 362 - 0
services/web/test/unit/src/User/UserCreator.test.mjs

@@ -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
+        )
+      })
+    })
+  })
+})

+ 0 - 322
services/web/test/unit/src/User/UserCreatorTests.js

@@ -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
-        )
-      })
-    })
-  })
-})

+ 290 - 0
services/web/test/unit/src/User/UserEmailsConfirmationHandler.test.mjs

@@ -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)
+      })
+    })
+  })
+})

+ 0 - 266
services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js

@@ -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)
-      })
-    })
-  })
-})

+ 967 - 0
services/web/test/unit/src/User/UserEmailsController.test.mjs

@@ -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() }
+          },
+        }
+      )
+    })
+  })
+})

+ 0 - 905
services/web/test/unit/src/User/UserEmailsControllerTests.js

@@ -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() }
-          },
-        }
-      )
-    })
-  })
-})

+ 230 - 0
services/web/test/unit/src/User/UserInfoController.test.mjs

@@ -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)
+      })
+    })
+  })
+})

+ 0 - 225
services/web/test/unit/src/User/UserInfoControllerTests.js

@@ -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)
-      })
-    })
-  })
-})

+ 327 - 0
services/web/test/unit/src/User/UserRegistrationHandler.test.mjs

@@ -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)
+      })
+    })
+  })
+})

+ 0 - 292
services/web/test/unit/src/User/UserRegistrationHandlerTests.js

@@ -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)
-      })
-    })
-  })
-})

+ 34 - 0
services/web/test/unit/src/helpers/MockRequestVitest.mjs

@@ -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

+ 139 - 0
services/web/test/unit/src/helpers/MockResponseVitest.mjs

@@ -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