diff --git a/services/web/app/src/Features/Chat/ChatController.mjs b/services/web/app/src/Features/Chat/ChatController.mjs index 1db9cef89f..37aad846c1 100644 --- a/services/web/app/src/Features/Chat/ChatController.mjs +++ b/services/web/app/src/Features/Chat/ChatController.mjs @@ -3,8 +3,8 @@ import Modules from '../../infrastructure/Modules.js' import ChatApiHandler from './ChatApiHandler.js' import EditorRealTimeController from '../Editor/EditorRealTimeController.js' import SessionManager from '../Authentication/SessionManager.js' -import UserInfoManager from '../User/UserInfoManager.js' -import UserInfoController from '../User/UserInfoController.js' +import UserInfoManager from '../User/UserInfoManager.mjs' +import UserInfoController from '../User/UserInfoController.mjs' import ChatManager from './ChatManager.mjs' async function sendMessage(req, res) { diff --git a/services/web/app/src/Features/Chat/ChatManager.mjs b/services/web/app/src/Features/Chat/ChatManager.mjs index abf7941638..546513360e 100644 --- a/services/web/app/src/Features/Chat/ChatManager.mjs +++ b/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 { callbackify } from '@overleaf/promise-utils' diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index a56fde2d05..6c4742d6b3 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/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 { OError, V1ConnectionError } from '../Errors/Errors.js' import { User } from '../../models/User.js' -import UserPrimaryEmailCheckHandler from '../User/UserPrimaryEmailCheckHandler.js' +import UserPrimaryEmailCheckHandler from '../User/UserPrimaryEmailCheckHandler.mjs' import UserController from '../User/UserController.mjs' import NotificationsBuilder from '../Notifications/NotificationsBuilder.js' import GeoIpLookup from '../../infrastructure/GeoIpLookup.mjs' diff --git a/services/web/app/src/Features/User/SAMLIdentityManager.js b/services/web/app/src/Features/User/SAMLIdentityManager.mjs similarity index 93% rename from services/web/app/src/Features/User/SAMLIdentityManager.js rename to services/web/app/src/Features/User/SAMLIdentityManager.mjs index 0d3c382775..e6eb8a03dd 100644 --- a/services/web/app/src/Features/User/SAMLIdentityManager.js +++ b/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) { await UserAuditLogHandler.promises.addEntry( @@ -469,4 +471,4 @@ const SAMLIdentityManager = { userHasEntitlement, } -module.exports = SAMLIdentityManager +export default SAMLIdentityManager diff --git a/services/web/app/src/Features/User/UserCreator.js b/services/web/app/src/Features/User/UserCreator.mjs similarity index 83% rename from services/web/app/src/Features/User/UserCreator.js rename to services/web/app/src/Features/User/UserCreator.mjs index 390d925a7f..558d8db56f 100644 --- a/services/web/app/src/Features/User/UserCreator.js +++ b/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) { try { @@ -142,4 +142,4 @@ const UserCreator = { }, } -module.exports = UserCreator +export default UserCreator diff --git a/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js b/services/web/app/src/Features/User/UserEmailsConfirmationHandler.mjs similarity index 83% rename from services/web/app/src/Features/User/UserEmailsConfirmationHandler.js rename to services/web/app/src/Features/User/UserEmailsConfirmationHandler.mjs index cefae54e36..82fe4a79c2 100644 --- a/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js +++ b/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 const TOKEN_EXPIRY_IN_S = 90 * 24 * 60 * 60 @@ -104,4 +104,4 @@ UserEmailsConfirmationHandler.promises = { sendConfirmationCode, } -module.exports = UserEmailsConfirmationHandler +export default UserEmailsConfirmationHandler diff --git a/services/web/app/src/Features/User/UserEmailsController.js b/services/web/app/src/Features/User/UserEmailsController.mjs similarity index 93% rename from services/web/app/src/Features/User/UserEmailsController.js rename to services/web/app/src/Features/User/UserEmailsController.mjs index 99e5684b70..66d1c768e7 100644 --- a/services/web/app/src/Features/User/UserEmailsController.js +++ b/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 @@ -615,7 +615,7 @@ const UserEmailsController = { return res.sendStatus(422) } - endorseAffiliation( + InstitutionsAPI.endorseAffiliation( userId, email, req.body.role, @@ -729,4 +729,4 @@ const UserEmailsController = { }, } -module.exports = UserEmailsController +export default UserEmailsController diff --git a/services/web/app/src/Features/User/UserInfoController.js b/services/web/app/src/Features/User/UserInfoController.mjs similarity index 89% rename from services/web/app/src/Features/User/UserInfoController.js rename to services/web/app/src/Features/User/UserInfoController.mjs index c95bc45af5..1aaa1dbeb3 100644 --- a/services/web/app/src/Features/User/UserInfoController.js +++ b/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) { const userId = SessionManager.getLoggedInUserId(req.session) @@ -88,7 +90,7 @@ async function getUserFeatures(req, res, next) { return res.json(features) } -module.exports = { +export default { getLoggedInUsersPersonalInfo, getPersonalInfo, sendFormattedPersonalInfo, diff --git a/services/web/app/src/Features/User/UserInfoManager.js b/services/web/app/src/Features/User/UserInfoManager.mjs similarity index 68% rename from services/web/app/src/Features/User/UserInfoManager.js rename to services/web/app/src/Features/User/UserInfoManager.mjs index 7133cf0695..5023aa40c4 100644 --- a/services/web/app/src/Features/User/UserInfoManager.js +++ b/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) { return UserGetter.promises.getUser(userId, { @@ -10,7 +10,7 @@ async function getPersonalInfo(userId) { }) } -module.exports = { +export default { getPersonalInfo: callbackify(getPersonalInfo), promises: { getPersonalInfo, diff --git a/services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.js b/services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.mjs similarity index 90% rename from services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.js rename to services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.mjs index be1d045d01..d6ef203a6f 100644 --- a/services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.js +++ b/services/web/app/src/Features/User/UserPrimaryEmailCheckHandler.mjs @@ -1,4 +1,4 @@ -const Settings = require('@overleaf/settings') +import Settings from '@overleaf/settings' function requiresPrimaryEmailCheck({ email, @@ -28,6 +28,6 @@ function requiresPrimaryEmailCheck({ } } -module.exports = { +export default { requiresPrimaryEmailCheck, } diff --git a/services/web/app/src/Features/User/UserRegistrationHandler.js b/services/web/app/src/Features/User/UserRegistrationHandler.mjs similarity index 82% rename from services/web/app/src/Features/User/UserRegistrationHandler.js rename to services/web/app/src/Features/User/UserRegistrationHandler.mjs index 02c52f73fd..c30c716924 100644 --- a/services/web/app/src/Features/User/UserRegistrationHandler.js +++ b/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 = { _registrationRequestIsValid(body) { @@ -126,7 +123,7 @@ const UserRegistrationHandler = { }, } -module.exports = { +export default { registerNewUser: callbackify(UserRegistrationHandler.registerNewUser), registerNewUserAndSendActivationEmail: callbackifyMultiResult( UserRegistrationHandler.registerNewUserAndSendActivationEmail, diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 760589460a..359334e23e 100644 --- a/services/web/app/src/router.mjs +++ b/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 NotificationsController from './Features/Notifications/NotificationsController.mjs' import CollaboratorsRouter from './Features/Collaborators/CollaboratorsRouter.mjs' -import UserInfoController from './Features/User/UserInfoController.js' +import UserInfoController from './Features/User/UserInfoController.mjs' import UserController from './Features/User/UserController.mjs' -import UserEmailsController from './Features/User/UserEmailsController.js' +import UserEmailsController from './Features/User/UserEmailsController.mjs' import UserPagesController from './Features/User/UserPagesController.mjs' import TutorialController from './Features/Tutorial/TutorialController.mjs' import DocumentController from './Features/Documents/DocumentController.mjs' diff --git a/services/web/modules/launchpad/app/src/LaunchpadController.mjs b/services/web/modules/launchpad/app/src/LaunchpadController.mjs index b626e0176e..b20f5bb796 100644 --- a/services/web/modules/launchpad/app/src/LaunchpadController.mjs +++ b/services/web/modules/launchpad/app/src/LaunchpadController.mjs @@ -4,7 +4,7 @@ import Settings from '@overleaf/settings' import Path from 'node:path' import { fileURLToPath } from 'node:url' import logger from '@overleaf/logger' -import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.js' +import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs' import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.js' import UserGetter from '../../../../app/src/Features/User/UserGetter.js' import { User } from '../../../../app/src/models/User.js' diff --git a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs index a92fca04de..0f1f395167 100644 --- a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs +++ b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs @@ -34,7 +34,7 @@ describe('LaunchpadController', function () { })) vi.doMock( - '../../../../../app/src/Features/User/UserRegistrationHandler.js', + '../../../../../app/src/Features/User/UserRegistrationHandler.mjs', () => ({ default: (ctx.UserRegistrationHandler = { promises: {}, diff --git a/services/web/modules/server-ce-scripts/scripts/create-user.mjs b/services/web/modules/server-ce-scripts/scripts/create-user.mjs index 7c29ca7f5f..698631d204 100644 --- a/services/web/modules/server-ce-scripts/scripts/create-user.mjs +++ b/services/web/modules/server-ce-scripts/scripts/create-user.mjs @@ -1,6 +1,6 @@ import minimist from 'minimist' import { db } from '../../../app/src/infrastructure/mongodb.js' -import UserRegistrationHandler from '../../../app/src/Features/User/UserRegistrationHandler.js' +import UserRegistrationHandler from '../../../app/src/Features/User/UserRegistrationHandler.mjs' import { fileURLToPath } from 'url' const filename = fileURLToPath(import.meta.url) diff --git a/services/web/modules/user-activate/app/src/UserActivateController.mjs b/services/web/modules/user-activate/app/src/UserActivateController.mjs index 980bc3696c..1b712479c2 100644 --- a/services/web/modules/user-activate/app/src/UserActivateController.mjs +++ b/services/web/modules/user-activate/app/src/UserActivateController.mjs @@ -1,7 +1,7 @@ import Path from 'node:path' import { fileURLToPath } from 'node:url' import UserGetter from '../../../../app/src/Features/User/UserGetter.js' -import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.js' +import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs' import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs' import { expressify } from '@overleaf/promise-utils' diff --git a/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs b/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs index 33d7c64a5a..217af268cd 100644 --- a/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs +++ b/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs @@ -35,7 +35,7 @@ describe('UserActivateController', function () { })) vi.doMock( - '../../../../../app/src/Features/User/UserRegistrationHandler.js', + '../../../../../app/src/Features/User/UserRegistrationHandler.mjs', () => ({ default: ctx.UserRegistrationHandler, }) diff --git a/services/web/scripts/e2e_test_setup.mjs b/services/web/scripts/e2e_test_setup.mjs index 01574a8e9b..dd1d720fad 100644 --- a/services/web/scripts/e2e_test_setup.mjs +++ b/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 SplitTestManager from '../app/src/Features/SplitTests/SplitTestManager.js' import UserDeleter from '../app/src/Features/User/UserDeleter.js' -import UserRegistrationHandler from '../app/src/Features/User/UserRegistrationHandler.js' +import UserRegistrationHandler from '../app/src/Features/User/UserRegistrationHandler.mjs' const MONOREPO = Path.dirname( Path.dirname(Path.dirname(Path.dirname(fileURLToPath(import.meta.url)))) diff --git a/services/web/test/acceptance/src/ProjectStructureMongoLockTest.mjs b/services/web/test/acceptance/src/ProjectStructureMongoLockTest.mjs index e414f392f9..bc01ffbe65 100644 --- a/services/web/test/acceptance/src/ProjectStructureMongoLockTest.mjs +++ b/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 ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js' import ProjectEntityMongoUpdateHandler from '../../../app/src/Features/Project/ProjectEntityMongoUpdateHandler.js' -import UserCreator from '../../../app/src/Features/User/UserCreator.js' +import UserCreator from '../../../app/src/Features/User/UserCreator.mjs' import { expect } from 'chai' import _ from 'lodash' diff --git a/services/web/test/acceptance/src/helpers/UserHelper.mjs b/services/web/test/acceptance/src/helpers/UserHelper.mjs index 05fef85604..b60f25f67c 100644 --- a/services/web/test/acceptance/src/helpers/UserHelper.mjs +++ b/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 Settings from '@overleaf/settings' import InstitutionsAPI from '../../../../app/src/Features/Institutions/InstitutionsAPI.js' -import UserCreator from '../../../../app/src/Features/User/UserCreator.js' +import UserCreator from '../../../../app/src/Features/User/UserCreator.mjs' import UserGetter from '../../../../app/src/Features/User/UserGetter.js' import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js' import moment from 'moment' diff --git a/services/web/test/unit/src/Chat/ChatController.test.mjs b/services/web/test/unit/src/Chat/ChatController.test.mjs index 1be6c78122..f7ceee52f0 100644 --- a/services/web/test/unit/src/Chat/ChatController.test.mjs +++ b/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, })) vi.doMock( - '../../../../app/src/Features/User/UserInfoController.js', + '../../../../app/src/Features/User/UserInfoController.mjs', () => ({ default: ctx.UserInfoController, }) diff --git a/services/web/test/unit/src/User/SAMLIdentityManagerTests.js b/services/web/test/unit/src/User/SAMLIdentityManager.test.mjs similarity index 62% rename from services/web/test/unit/src/User/SAMLIdentityManagerTests.js rename to services/web/test/unit/src/User/SAMLIdentityManager.test.mjs index 3413c6c61a..d4412f56d6 100644 --- a/services/web/test/unit/src/User/SAMLIdentityManagerTests.js +++ b/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 () { const linkedEmail = 'another@example.com' - beforeEach(function () { - this.userId = '6005c75b12cbcaf771f4a105' - this.user = { - _id: this.userId, + beforeEach(async function (ctx) { + ctx.userId = '6005c75b12cbcaf771f4a105' + ctx.user = { + _id: ctx.userId, email: 'not-linked@overleaf.com', emails: [{ email: 'not-linked@overleaf.com' }], samlIdentifiers: [], } - this.auditLog = { - initiatorId: this.userId, + ctx.auditLog = { + initiatorId: ctx.userId, ipAddress: '0:0:0:0', } - this.userAlreadyLinked = { + ctx.userAlreadyLinked = { _id: '6005c7a012cbcaf771f4a106', email: 'linked@overleaf.com', emails: [{ email: 'linked@overleaf.com', samlProviderId: '1' }], samlIdentifiers: [{ externalUserId: 'linked-id', providerId: '1' }], } - this.userEmailExists = { + ctx.userEmailExists = { _id: '6005c7a012cbcaf771f4a107', email: 'exists@overleaf.com', emails: [{ email: 'exists@overleaf.com' }], samlIdentifiers: [], } - this.institution = { + ctx.institution = { name: 'Overleaf University', } - this.InstitutionsAPI = { + ctx.InstitutionsAPI = { promises: { addEntitlement: sinon.stub().resolves(), removeEntitlement: sinon.stub().resolves(), }, } - this.SAMLIdentityManager = SandboxedModule.require(modulePath, { - requires: { - 'mongodb-legacy': { ObjectId }, - '../Email/EmailHandler': (this.EmailHandler = { - sendEmail: sinon.stub().yields(), - }), - '../Notifications/NotificationsBuilder': (this.NotificationsBuilder = { + + ctx.logger = { + error: sinon.stub(), + } + vi.doMock('@overleaf/logger', () => ({ + default: ctx.logger, + })) + + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) + + vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({ + default: (ctx.EmailHandler = { + sendEmail: sinon.stub().yields(), + }), + })) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsBuilder', + () => ({ + default: (ctx.NotificationsBuilder = { promises: { redundantPersonalSubscription: sinon .stub() .returns({ create: sinon.stub().resolves() }), }, }), - '../Subscription/SubscriptionLocator': (this.SubscriptionLocator = { + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: (ctx.SubscriptionLocator = { promises: { getUserIndividualSubscription: sinon.stub().resolves(), }, }), - '../../models/User': { - User: (this.User = { - findOneAndUpdate: sinon.stub().returns({ - exec: sinon.stub().resolves(this.user), - }), - findOne: sinon.stub().returns({ - exec: sinon.stub().resolves(), - }), - updateOne: sinon.stub().returns({ - exec: sinon.stub().resolves(), - }), - }), + }) + ) + + vi.doMock('../../../../app/src/models/User', () => ({ + User: (ctx.User = { + findOneAndUpdate: sinon.stub().returns({ + exec: sinon.stub().resolves(ctx.user), + }), + findOne: sinon.stub().returns({ + exec: sinon.stub().resolves(), + }), + updateOne: sinon.stub().returns({ + exec: sinon.stub().resolves(), + }), + }), + })) + + vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ + default: (ctx.UserAuditLogHandler = { + promises: { + addEntry: sinon.stub().resolves(), }, - '../User/UserAuditLogHandler': (this.UserAuditLogHandler = { - promises: { - addEntry: sinon.stub().resolves(), - }, - }), - '../User/UserGetter': (this.UserGetter = { - getUser: sinon.stub(), - promises: { - getUser: sinon.stub().resolves(this.user), - getUserByAnyEmail: sinon.stub().resolves(), - getUserFullEmails: sinon.stub().resolves(), - }, - }), - '../User/UserUpdater': (this.UserUpdater = { - addEmailAddress: sinon.stub(), - promises: { - addEmailAddress: sinon.stub().resolves(), - confirmEmail: sinon.stub().resolves(), - updateUser: sinon.stub().resolves(), - }, - }), - '../Institutions/InstitutionsAPI': this.InstitutionsAPI, - }, - }) + }), + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = { + getUser: sinon.stub(), + promises: { + getUser: sinon.stub().resolves(ctx.user), + getUserByAnyEmail: sinon.stub().resolves(), + getUserFullEmails: sinon.stub().resolves(), + }, + }), + })) + + vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({ + default: (ctx.UserUpdater = { + addEmailAddress: sinon.stub(), + promises: { + addEmailAddress: sinon.stub().resolves(), + confirmEmail: sinon.stub().resolves(), + updateUser: sinon.stub().resolves(), + }, + }), + })) + + vi.doMock( + '../../../../app/src/Features/Institutions/InstitutionsAPI', + () => ({ + default: ctx.InstitutionsAPI, + }) + ) + + ctx.SAMLIdentityManager = (await import(modulePath)).default }) describe('getUser', function () { - it('should throw an error if missing all of: provider ID, external user ID, attribute', async function () { + it('should throw an error if missing all of: provider ID, external user ID, attribute', async function (ctx) { let error try { - await this.SAMLIdentityManager.getUser(undefined, undefined, undefined) + await ctx.SAMLIdentityManager.getUser(undefined, undefined, undefined) } catch (e) { error = e } finally { @@ -112,10 +157,10 @@ describe('SAMLIdentityManager', function () { ) } }) - it('should throw an error if missing provider ID', async function () { + it('should throw an error if missing provider ID', async function (ctx) { let error try { - await this.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr') + await ctx.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr') } catch (e) { error = e } finally { @@ -125,20 +170,20 @@ describe('SAMLIdentityManager', function () { ) } }) - it('should throw an error if missing external user ID', async function () { + it('should throw an error if missing external user ID', async function (ctx) { let error try { - await this.SAMLIdentityManager.getUser('123', null, 'someAttr') + await ctx.SAMLIdentityManager.getUser('123', null, 'someAttr') } catch (e) { error = e } finally { expect(error).to.exist } }) - it('should throw an error if missing attribute', async function () { + it('should throw an error if missing attribute', async function (ctx) { let error try { - await this.SAMLIdentityManager.getUser('123', 'id123', undefined) + await ctx.SAMLIdentityManager.getUser('123', 'id123', undefined) } catch (e) { error = e } finally { @@ -152,16 +197,16 @@ describe('SAMLIdentityManager', function () { describe('linkAccounts', function () { describe('errors', function () { - beforeEach(function () { + beforeEach(function (ctx) { // first call is to get userWithProvider; should be falsy - this.UserGetter.promises.getUser.onFirstCall().resolves() - this.UserGetter.promises.getUser.onSecondCall().resolves(this.user) + ctx.UserGetter.promises.getUser.onFirstCall().resolves() + ctx.UserGetter.promises.getUser.onSecondCall().resolves(ctx.user) }) - it('should throw an error if missing all data', async function () { + it('should throw an error if missing all data', async function (ctx) { let error try { - await this.SAMLIdentityManager.linkAccounts(null, null, null) + await ctx.SAMLIdentityManager.linkAccounts(null, null, null) } catch (e) { error = e } finally { @@ -180,9 +225,9 @@ describe('SAMLIdentityManager', function () { const testData = { ...requiredData } delete testData[data] let error - it(`should throw an error when missing ${data}`, async function () { + it(`should throw an error when missing ${data}`, async function (ctx) { try { - await this.SAMLIdentityManager.linkAccounts('123', testData, {}) + await ctx.SAMLIdentityManager.linkAccounts('123', testData, {}) } catch (e) { error = e } finally { @@ -196,17 +241,17 @@ describe('SAMLIdentityManager', function () { }) describe('when email is already associated with another Overleaf account', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByAnyEmail.resolves( - this.userEmailExists + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves( + ctx.userEmailExists ) }) - it('should throw an EmailExistsError error', async function () { + it('should throw an EmailExistsError error', async function (ctx) { let error try { - await this.SAMLIdentityManager.linkAccounts( + await ctx.SAMLIdentityManager.linkAccounts( '6005c75b12cbcaf771f4a105', { externalUserId: 'not-linked-id', @@ -225,25 +270,25 @@ describe('SAMLIdentityManager', function () { error = e } finally { expect(error).to.be.instanceof(Errors.EmailExistsError) - expect(this.User.findOneAndUpdate).to.not.have.been.called + expect(ctx.User.findOneAndUpdate).to.not.have.been.called } }) }) describe('when email is not affiliated', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) - this.UserGetter.promises.getUserFullEmails.resolves([ + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user) + ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'not-affiliated@overleaf.com', }, ]) }) - it('should throw SAMLEmailNotAffiliatedError', async function () { + it('should throw SAMLEmailNotAffiliatedError', async function (ctx) { let error try { - await this.SAMLIdentityManager.linkAccounts( + await ctx.SAMLIdentityManager.linkAccounts( '6005c75b12cbcaf771f4a105', { externalUserId: 'not-linked-id', @@ -262,15 +307,15 @@ describe('SAMLIdentityManager', function () { error = e } finally { expect(error).to.be.instanceof(Errors.SAMLEmailNotAffiliatedError) - expect(this.User.findOneAndUpdate).to.not.have.been.called + expect(ctx.User.findOneAndUpdate).to.not.have.been.called } }) }) describe('when email is affiliated with another institution', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) - this.UserGetter.promises.getUserFullEmails.resolves([ + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user) + ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'affiliated@overleaf.com', affiliation: { institution: { id: '987' } }, @@ -278,10 +323,10 @@ describe('SAMLIdentityManager', function () { ]) }) - it('should throw SAMLEmailAffiliatedWithAnotherInstitutionError', async function () { + it('should throw SAMLEmailAffiliatedWithAnotherInstitutionError', async function (ctx) { let error try { - await this.SAMLIdentityManager.linkAccounts( + await ctx.SAMLIdentityManager.linkAccounts( '6005c75b12cbcaf771f4a105', { externalUserId: 'not-linked-id', @@ -302,22 +347,22 @@ describe('SAMLIdentityManager', function () { expect(error).to.be.instanceof( Errors.SAMLEmailAffiliatedWithAnotherInstitutionError ) - expect(this.User.findOneAndUpdate).to.not.have.been.called + expect(ctx.User.findOneAndUpdate).to.not.have.been.called } }) }) describe('when institution identifier is already associated with another Overleaf account', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByAnyEmail.resolves( - this.userAlreadyLinked + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves( + ctx.userAlreadyLinked ) }) - it('should throw an SAMLIdentityExistsError error', async function () { + it('should throw an SAMLIdentityExistsError error', async function (ctx) { let error try { - await this.SAMLIdentityManager.linkAccounts( + await ctx.SAMLIdentityManager.linkAccounts( '6005c75b12cbcaf771f4a105', { externalUserId: 'already-linked-id', @@ -336,21 +381,21 @@ describe('SAMLIdentityManager', function () { error = e } finally { expect(error).to.be.instanceof(Errors.SAMLIdentityExistsError) - expect(this.User.findOneAndUpdate).to.not.have.been.called + expect(ctx.User.findOneAndUpdate).to.not.have.been.called } }) }) describe('when institution provider is already associated with the user', function () { - beforeEach(function () { + beforeEach(function (ctx) { // first call is to get userWithProvider; resolves with any user - this.UserGetter.promises.getUser.onFirstCall().resolves(this.user) + ctx.UserGetter.promises.getUser.onFirstCall().resolves(ctx.user) }) - it('should throw an SAMLAlreadyLinkedError error', async function () { + it('should throw an SAMLAlreadyLinkedError error', async function (ctx) { let error try { - await this.SAMLIdentityManager.linkAccounts( + await ctx.SAMLIdentityManager.linkAccounts( '6005c75b12cbcaf771f4a105', { externalUserId: 'already-linked-id', @@ -369,27 +414,27 @@ describe('SAMLIdentityManager', function () { error = e } finally { expect( - this.UserGetter.promises.getUser + ctx.UserGetter.promises.getUser ).to.have.been.calledWithMatch({ _id: new ObjectId('6005c75b12cbcaf771f4a105'), 'samlIdentifiers.providerId': '123456', }) expect(error).to.be.instanceof(Errors.SAMLAlreadyLinkedError) - expect(this.User.findOneAndUpdate).to.not.have.been.called + expect(ctx.User.findOneAndUpdate).to.not.have.been.called } }) }) - it('should pass back errors via UserAuditLogHandler', async function () { + it('should pass back errors via UserAuditLogHandler', async function (ctx) { let error const anError = new Error('oops') - this.UserAuditLogHandler.promises.addEntry.rejects(anError) + ctx.UserAuditLogHandler.promises.addEntry.rejects(anError) try { - await this.SAMLIdentityManager.linkAccounts( - this.user._id, + await ctx.SAMLIdentityManager.linkAccounts( + ctx.user._id, { externalUserId: 'externalUserId', - institutionEmail: this.user.email, + institutionEmail: ctx.user.email, universityId: '1', universityName: 'Overleaf University', hasEntitlement: false, @@ -405,29 +450,29 @@ describe('SAMLIdentityManager', function () { } finally { expect(error).to.exist expect(error).to.equal(anError) - expect(this.EmailHandler.sendEmail).to.not.have.been.called - expect(this.User.updateOne).to.not.have.been.called + expect(ctx.EmailHandler.sendEmail).to.not.have.been.called + expect(ctx.User.updateOne).to.not.have.been.called } }) }) describe('success', function () { - beforeEach(function () { + beforeEach(function (ctx) { // first call is to get userWithProvider; should be falsy - this.UserGetter.promises.getUser.onFirstCall().resolves() - this.UserGetter.promises.getUser.onSecondCall().resolves(this.user) + ctx.UserGetter.promises.getUser.onFirstCall().resolves() + ctx.UserGetter.promises.getUser.onSecondCall().resolves(ctx.user) }) - it('should update the user audit log', async function () { + it('should update the user audit log', async function (ctx) { const auditLog = { initiatorId: '6005c75b12cbcaf771f4a105', ipAddress: '0:0:0:0', } - await this.SAMLIdentityManager.linkAccounts( - this.user._id, + await ctx.SAMLIdentityManager.linkAccounts( + ctx.user._id, { externalUserId: 'externalUserId', - institutionEmail: this.user.email, + institutionEmail: ctx.user.email, universityId: '1', universityName: 'Overleaf University', hasEntitlement: false, @@ -437,14 +482,14 @@ describe('SAMLIdentityManager', function () { ) expect( - this.UserAuditLogHandler.promises.addEntry + ctx.UserAuditLogHandler.promises.addEntry ).to.have.been.calledWith( - this.user._id, + ctx.user._id, 'link-institution-sso', auditLog.initiatorId, auditLog.ipAddress, { - institutionEmail: this.user.email, + institutionEmail: ctx.user.email, providerId: '1', providerName: 'Overleaf University', userIdAttribute: 'uniqueId', @@ -453,12 +498,12 @@ describe('SAMLIdentityManager', function () { ) }) - it('should send an email notification', async function () { - await this.SAMLIdentityManager.linkAccounts( - this.user._id, + it('should send an email notification', async function (ctx) { + await ctx.SAMLIdentityManager.linkAccounts( + ctx.user._id, { externalUserId: 'externalUserId', - institutionEmail: this.user.email, + institutionEmail: ctx.user.email, universityId: '1', universityName: 'Overleaf University', hasEntitlement: false, @@ -470,35 +515,35 @@ describe('SAMLIdentityManager', function () { } ) - expect(this.User.findOneAndUpdate).to.have.been.called - expect(this.EmailHandler.sendEmail).to.have.been.calledOnce - const emailArgs = this.EmailHandler.sendEmail.lastCall.args + expect(ctx.User.findOneAndUpdate).to.have.been.called + expect(ctx.EmailHandler.sendEmail).to.have.been.calledOnce + const emailArgs = ctx.EmailHandler.sendEmail.lastCall.args expect(emailArgs[0]).to.equal('securityAlert') - expect(emailArgs[1].to).to.equal(this.user.email) + expect(emailArgs[1].to).to.equal(ctx.user.email) expect(emailArgs[1].actionDescribed).to.contain('was linked') expect(emailArgs[1].message[0]).to.contain('Linked') - expect(emailArgs[1].message[0]).to.contain(this.user.email) + expect(emailArgs[1].message[0]).to.contain(ctx.user.email) }) }) }) describe('unlinkAccounts', function () { - it('should update the audit log', async function () { - await this.SAMLIdentityManager.unlinkAccounts( - this.user._id, + it('should update the audit log', async function (ctx) { + await ctx.SAMLIdentityManager.unlinkAccounts( + ctx.user._id, linkedEmail, - this.user.email, + ctx.user.email, '1', 'Overleaf University', - this.auditLog + ctx.auditLog ) expect( - this.UserAuditLogHandler.promises.addEntry + ctx.UserAuditLogHandler.promises.addEntry ).to.have.been.calledOnce.and.calledWithMatch( - this.user._id, + ctx.user._id, 'unlink-institution-sso', - this.auditLog.initiatorId, - this.auditLog.ipAddress, + ctx.auditLog.initiatorId, + ctx.auditLog.ipAddress, { institutionEmail: linkedEmail, providerId: '1', @@ -506,17 +551,17 @@ describe('SAMLIdentityManager', function () { } ) }) - it('should remove the identifier', async function () { - await this.SAMLIdentityManager.unlinkAccounts( - this.user._id, + it('should remove the identifier', async function (ctx) { + await ctx.SAMLIdentityManager.unlinkAccounts( + ctx.user._id, linkedEmail, - this.user.email, + ctx.user.email, '1', 'Overleaf University', - this.auditLog + ctx.auditLog ) const query = { - _id: this.user._id, + _id: ctx.user._id, } const update = { $pull: { @@ -525,94 +570,94 @@ describe('SAMLIdentityManager', function () { }, }, } - expect(this.User.updateOne).to.have.been.calledOnce.and.calledWithMatch( + expect(ctx.User.updateOne).to.have.been.calledOnce.and.calledWithMatch( query, update ) }) - it('should send an email notification', async function () { - await this.SAMLIdentityManager.unlinkAccounts( - this.user._id, + it('should send an email notification', async function (ctx) { + await ctx.SAMLIdentityManager.unlinkAccounts( + ctx.user._id, linkedEmail, - this.user.email, + ctx.user.email, '1', 'Overleaf University', - this.auditLog + ctx.auditLog ) - expect(this.User.updateOne).to.have.been.called - expect(this.EmailHandler.sendEmail).to.have.been.calledOnce - const emailArgs = this.EmailHandler.sendEmail.lastCall.args + expect(ctx.User.updateOne).to.have.been.called + expect(ctx.EmailHandler.sendEmail).to.have.been.calledOnce + const emailArgs = ctx.EmailHandler.sendEmail.lastCall.args expect(emailArgs[0]).to.equal('securityAlert') - expect(emailArgs[1].to).to.equal(this.user.email) + expect(emailArgs[1].to).to.equal(ctx.user.email) expect(emailArgs[1].actionDescribed).to.contain('was unlinked') expect(emailArgs[1].message[0]).to.contain('No longer linked') expect(emailArgs[1].message[0]).to.contain(linkedEmail) }) describe('errors', function () { - it('should pass back errors via UserAuditLogHandler', async function () { + it('should pass back errors via UserAuditLogHandler', async function (ctx) { let error const anError = new Error('oops') - this.UserAuditLogHandler.promises.addEntry.rejects(anError) + ctx.UserAuditLogHandler.promises.addEntry.rejects(anError) try { - await this.SAMLIdentityManager.unlinkAccounts( - this.user._id, + await ctx.SAMLIdentityManager.unlinkAccounts( + ctx.user._id, linkedEmail, - this.user.email, + ctx.user.email, '1', 'Overleaf University', - this.auditLog + ctx.auditLog ) } catch (e) { error = e } finally { expect(error).to.exist expect(error).to.equal(anError) - expect(this.EmailHandler.sendEmail).to.not.have.been.called - expect(this.User.updateOne).to.not.have.been.called + expect(ctx.EmailHandler.sendEmail).to.not.have.been.called + expect(ctx.User.updateOne).to.not.have.been.called } }) }) }) describe('entitlementAttributeMatches', function () { - it('should return true when entitlement matches on string', function () { - this.SAMLIdentityManager.entitlementAttributeMatches( + it('should return true when entitlement matches on string', function (ctx) { + ctx.SAMLIdentityManager.entitlementAttributeMatches( 'foo bar', 'bar' ).should.equal(true) }) - it('should return false when entitlement does not match on string', function () { - this.SAMLIdentityManager.entitlementAttributeMatches( + it('should return false when entitlement does not match on string', function (ctx) { + ctx.SAMLIdentityManager.entitlementAttributeMatches( 'foo bar', 'bam' ).should.equal(false) }) - it('should return false on an invalid matcher', function () { - this.SAMLIdentityManager.entitlementAttributeMatches( + it('should return false on an invalid matcher', function (ctx) { + ctx.SAMLIdentityManager.entitlementAttributeMatches( 'foo bar', '(' ).should.equal(false) }) - it('should log error on an invalid matcher', function () { - this.SAMLIdentityManager.entitlementAttributeMatches('foo bar', '(') - this.logger.error.firstCall.args[0].err.message.should.equal( + it('should log error on an invalid matcher', function (ctx) { + ctx.SAMLIdentityManager.entitlementAttributeMatches('foo bar', '(') + ctx.logger.error.firstCall.args[0].err.message.should.equal( 'Invalid regular expression: /(/: Unterminated group' ) }) - it('should return true when entitlement matches on array', function () { - this.SAMLIdentityManager.entitlementAttributeMatches( + it('should return true when entitlement matches on array', function (ctx) { + ctx.SAMLIdentityManager.entitlementAttributeMatches( ['foo', 'bar'], 'bar' ).should.equal(true) }) - it('should return false when entitlement does not match array', function () { - this.SAMLIdentityManager.entitlementAttributeMatches( + it('should return false when entitlement does not match array', function (ctx) { + ctx.SAMLIdentityManager.entitlementAttributeMatches( ['foo', 'bar'], 'bam' ).should.equal(false) @@ -636,18 +681,18 @@ describe('SAMLIdentityManager', function () { } const userIdAttribute = 'newUniqueId' - it('should remove the old identifier and add the new identifier', async function () { - this.UserGetter.promises.getUser.resolves() - this.UserGetter.promises.getUserByAnyEmail + it('should remove the old identifier and add the new identifier', async function (ctx) { + ctx.UserGetter.promises.getUser.resolves() + ctx.UserGetter.promises.getUserByAnyEmail .withArgs(institutionEmail) .resolves({ _id: userId, emails: [{ email: institutionEmail }] }) - this.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([ + ctx.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([ { email: institutionEmail, affiliation: { institution: { id: providerId } }, }, ]) - await this.SAMLIdentityManager.migrateIdentifier( + await ctx.SAMLIdentityManager.migrateIdentifier( userId, externalUserId, providerId, @@ -658,7 +703,7 @@ describe('SAMLIdentityManager', function () { userIdAttribute ) - expect(this.User.updateOne).to.have.been.calledOnce + expect(ctx.User.updateOne).to.have.been.calledOnce const query = { _id: userId, 'samlIdentifiers.providerId': providerId.toString(), @@ -671,7 +716,7 @@ describe('SAMLIdentityManager', function () { }, } - expect(this.User.updateOne.lastCall.args).to.deep.equal([query, update]) + expect(ctx.User.updateOne.lastCall.args).to.deep.equal([query, update]) }) }) @@ -684,22 +729,22 @@ describe('SAMLIdentityManager', function () { ipAddress: 'N/A', } - it('should remove the identifier om samlIdentifiers and samlProviderId on the email', async function () { - this.User.findOneAndUpdate = sinon.stub().returns({ + it('should remove the identifier om samlIdentifiers and samlProviderId on the email', async function (ctx) { + ctx.User.findOneAndUpdate = sinon.stub().returns({ exec: sinon.stub().resolves({ _id: userId, emails: [{ email: institutionEmail, samlProviderId: providerId }], }), }) - await this.SAMLIdentityManager.unlinkNotMigrated( + await ctx.SAMLIdentityManager.unlinkNotMigrated( userId, providerId, providerName, auditLog ) - expect(this.User.findOneAndUpdate).to.have.been.calledOnce + expect(ctx.User.findOneAndUpdate).to.have.been.calledOnce const query = { _id: userId, 'emails.samlProviderId': providerId, @@ -714,14 +759,14 @@ describe('SAMLIdentityManager', function () { 'emails.$.samlProviderId': 1, }, } - expect(this.User.findOneAndUpdate.lastCall.args).to.deep.equal([ + expect(ctx.User.findOneAndUpdate.lastCall.args).to.deep.equal([ query, update, ]) - expect(this.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce + expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce expect( - this.UserAuditLogHandler.promises.addEntry.lastCall.args + ctx.UserAuditLogHandler.promises.addEntry.lastCall.args ).to.deep.equal([ userId, 'unlink-institution-sso-not-migrated', @@ -730,10 +775,10 @@ describe('SAMLIdentityManager', function () { { providerId, providerName }, ]) - expect(this.InstitutionsAPI.promises.removeEntitlement).to.have.been + expect(ctx.InstitutionsAPI.promises.removeEntitlement).to.have.been .calledOnce expect( - this.InstitutionsAPI.promises.removeEntitlement.lastCall.args + ctx.InstitutionsAPI.promises.removeEntitlement.lastCall.args ).to.deep.equal([userId, institutionEmail]) }) }) diff --git a/services/web/test/unit/src/User/UserCreator.test.mjs b/services/web/test/unit/src/User/UserCreator.test.mjs new file mode 100644 index 0000000000..594c284740 --- /dev/null +++ b/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 + ) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/User/UserCreatorTests.js b/services/web/test/unit/src/User/UserCreatorTests.js deleted file mode 100644 index f9075ed121..0000000000 --- a/services/web/test/unit/src/User/UserCreatorTests.js +++ /dev/null @@ -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 - ) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/User/UserEmailsConfirmationHandler.test.mjs b/services/web/test/unit/src/User/UserEmailsConfirmationHandler.test.mjs new file mode 100644 index 0000000000..9c23805a9c --- /dev/null +++ b/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) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js b/services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js deleted file mode 100644 index aa447fd1e4..0000000000 --- a/services/web/test/unit/src/User/UserEmailsConfirmationHandlerTests.js +++ /dev/null @@ -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) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/User/UserEmailsController.test.mjs b/services/web/test/unit/src/User/UserEmailsController.test.mjs new file mode 100644 index 0000000000..aea104fbe7 --- /dev/null +++ b/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() } + }, + } + ) + }) + }) +}) diff --git a/services/web/test/unit/src/User/UserEmailsControllerTests.js b/services/web/test/unit/src/User/UserEmailsControllerTests.js deleted file mode 100644 index b82229ad69..0000000000 --- a/services/web/test/unit/src/User/UserEmailsControllerTests.js +++ /dev/null @@ -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() } - }, - } - ) - }) - }) -}) diff --git a/services/web/test/unit/src/User/UserInfoController.test.mjs b/services/web/test/unit/src/User/UserInfoController.test.mjs new file mode 100644 index 0000000000..9c78e90e5e --- /dev/null +++ b/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) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/User/UserInfoControllerTests.js b/services/web/test/unit/src/User/UserInfoControllerTests.js deleted file mode 100644 index dd90ac3b00..0000000000 --- a/services/web/test/unit/src/User/UserInfoControllerTests.js +++ /dev/null @@ -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) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/User/UserRegistrationHandler.test.mjs b/services/web/test/unit/src/User/UserRegistrationHandler.test.mjs new file mode 100644 index 0000000000..f8e7e50826 --- /dev/null +++ b/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) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/User/UserRegistrationHandlerTests.js b/services/web/test/unit/src/User/UserRegistrationHandlerTests.js deleted file mode 100644 index 6cbb92af15..0000000000 --- a/services/web/test/unit/src/User/UserRegistrationHandlerTests.js +++ /dev/null @@ -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) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/helpers/MockRequestVitest.mjs b/services/web/test/unit/src/helpers/MockRequestVitest.mjs new file mode 100644 index 0000000000..d8ca79244b --- /dev/null +++ b/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 diff --git a/services/web/test/unit/src/helpers/MockResponseVitest.mjs b/services/web/test/unit/src/helpers/MockResponseVitest.mjs new file mode 100644 index 0000000000..cc82ced870 --- /dev/null +++ b/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