From 009bc4463df73e8cb57e583b9d3cd725f75653cb Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 8 Sep 2025 10:54:37 +0200 Subject: [PATCH] Merge pull request #28273 from overleaf/ac-some-web-esm-migration [web] Convert some Features files to ES modules (part 1) GitOrigin-RevId: d19b024efad315143e022143e2a2683df8071744 --- ...chaMiddleware.js => CaptchaMiddleware.mjs} | 20 +- .../{ChatController.js => ChatController.mjs} | 18 +- .../Collaborators/CollaboratorsController.mjs | 2 +- .../Collaborators/CollaboratorsGetter.js | 75 +- .../Collaborators/CollaboratorsRouter.mjs | 2 +- ...andler.js => OwnershipTransferHandler.mjs} | 30 +- ...eController.js => ClsiCacheController.mjs} | 28 +- ...Controller.js => EditorHttpController.mjs} | 30 +- .../app/src/Features/Editor/EditorRouter.mjs | 2 +- ...ErrorController.js => ErrorController.mjs} | 16 +- ...icsManager.js => GlobalMetricsManager.mjs} | 4 +- ...oryController.js => HistoryController.mjs} | 40 +- .../src/Features/History/HistoryRouter.mjs | 2 +- .../LinkedFiles/LinkedFilesController.mjs | 2 +- ...ojectFileAgent.js => ProjectFileAgent.mjs} | 27 +- .../LinkedFiles/ProjectOutputFileAgent.mjs | 2 +- .../PasswordReset/PasswordResetRouter.mjs | 2 +- ...s => ProjectCollabratecDetailsHandler.mjs} | 11 +- .../Features/StaticPages/HomeController.mjs | 2 +- .../Subscription/TeamInvitesController.mjs | 2 +- .../web/app/src/infrastructure/Server.mjs | 2 +- services/web/app/src/router.mjs | 8 +- .../scripts/transfer-all-projects-to-user.mjs | 2 +- .../app/src/UserActivateController.mjs | 2 +- .../unit/src/UserActivateController.test.mjs | 2 +- .../unit/src/Chat/ChatController.test.mjs | 152 ++++ .../test/unit/src/Chat/ChatControllerTests.js | 125 --- .../CollaboratorsController.test.mjs | 2 +- .../OwnershipTransferHandler.test.mjs | 532 +++++++++++++ .../OwnershipTransferHandlerTests.js | 494 ------------ .../src/Editor/EditorHttpController.test.mjs | 735 ++++++++++++++++++ .../src/Editor/EditorHttpControllerTests.js | 630 --------------- .../src/History/HistoryController.test.mjs | 297 +++++++ .../src/History/HistoryControllerTests.js | 265 ------- .../ProjectCollabratecDetails.test.mjs | 425 ++++++++++ .../Project/ProjectCollabratecDetailsTest.js | 426 ---------- 36 files changed, 2313 insertions(+), 2103 deletions(-) rename services/web/app/src/Features/Captcha/{CaptchaMiddleware.js => CaptchaMiddleware.mjs} (87%) rename services/web/app/src/Features/Chat/{ChatController.js => ChatController.mjs} (70%) rename services/web/app/src/Features/Collaborators/{OwnershipTransferHandler.js => OwnershipTransferHandler.mjs} (87%) rename services/web/app/src/Features/Compile/{ClsiCacheController.js => ClsiCacheController.mjs} (84%) rename services/web/app/src/Features/Editor/{EditorHttpController.js => EditorHttpController.mjs} (89%) rename services/web/app/src/Features/Errors/{ErrorController.js => ErrorController.mjs} (89%) rename services/web/app/src/Features/GlobalMetrics/{GlobalMetricsManager.js => GlobalMetricsManager.mjs} (92%) rename services/web/app/src/Features/History/{HistoryController.js => HistoryController.mjs} (93%) rename services/web/app/src/Features/LinkedFiles/{ProjectFileAgent.js => ProjectFileAgent.mjs} (92%) rename services/web/app/src/Features/Project/{ProjectCollabratecDetailsHandler.js => ProjectCollabratecDetailsHandler.mjs} (92%) create mode 100644 services/web/test/unit/src/Chat/ChatController.test.mjs delete mode 100644 services/web/test/unit/src/Chat/ChatControllerTests.js create mode 100644 services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs delete mode 100644 services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js create mode 100644 services/web/test/unit/src/Editor/EditorHttpController.test.mjs delete mode 100644 services/web/test/unit/src/Editor/EditorHttpControllerTests.js create mode 100644 services/web/test/unit/src/History/HistoryController.test.mjs delete mode 100644 services/web/test/unit/src/History/HistoryControllerTests.js create mode 100644 services/web/test/unit/src/Project/ProjectCollabratecDetails.test.mjs delete mode 100644 services/web/test/unit/src/Project/ProjectCollabratecDetailsTest.js diff --git a/services/web/app/src/Features/Captcha/CaptchaMiddleware.js b/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs similarity index 87% rename from services/web/app/src/Features/Captcha/CaptchaMiddleware.js rename to services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs index 645738c2c7..3c0cf0eb3a 100644 --- a/services/web/app/src/Features/Captcha/CaptchaMiddleware.js +++ b/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs @@ -1,12 +1,12 @@ -const { fetchJson } = require('@overleaf/fetch-utils') -const logger = require('@overleaf/logger') -const Settings = require('@overleaf/settings') -const Metrics = require('@overleaf/metrics') -const OError = require('@overleaf/o-error') -const DeviceHistory = require('./DeviceHistory') -const AuthenticationController = require('../Authentication/AuthenticationController') -const { expressify } = require('@overleaf/promise-utils') -const EmailsHelper = require('../Helpers/EmailHelper') +import { fetchJson } from '@overleaf/fetch-utils' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import Metrics from '@overleaf/metrics' +import OError from '@overleaf/o-error' +import DeviceHistory from './DeviceHistory.js' +import AuthenticationController from '../Authentication/AuthenticationController.js' +import { expressify } from '@overleaf/promise-utils' +import EmailsHelper from '../Helpers/EmailHelper.js' function respondInvalidCaptcha(req, res) { res.status(400).json({ @@ -114,7 +114,7 @@ function validateCaptcha(action) { }) } -module.exports = { +export default { respondInvalidCaptcha, validateCaptcha, canSkipCaptcha: expressify(canSkipCaptcha), diff --git a/services/web/app/src/Features/Chat/ChatController.js b/services/web/app/src/Features/Chat/ChatController.mjs similarity index 70% rename from services/web/app/src/Features/Chat/ChatController.js rename to services/web/app/src/Features/Chat/ChatController.mjs index fabd32fef2..f341b6154c 100644 --- a/services/web/app/src/Features/Chat/ChatController.js +++ b/services/web/app/src/Features/Chat/ChatController.mjs @@ -1,11 +1,11 @@ -const { expressify } = require('@overleaf/promise-utils') -const Modules = require('../../infrastructure/Modules') -const ChatApiHandler = require('./ChatApiHandler') -const EditorRealTimeController = require('../Editor/EditorRealTimeController') -const SessionManager = require('../Authentication/SessionManager') -const UserInfoManager = require('../User/UserInfoManager') -const UserInfoController = require('../User/UserInfoController') -const ChatManager = require('./ChatManager') +import { expressify } from '@overleaf/promise-utils' +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 ChatManager from './ChatManager.js' async function sendMessage(req, res) { const { project_id: projectId } = req.params @@ -48,7 +48,7 @@ async function getMessages(req, res) { res.json(messages) } -module.exports = { +export default { sendMessage: expressify(sendMessage), getMessages: expressify(getMessages), } diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs index cd5b586ea8..d30d51e597 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs @@ -3,7 +3,7 @@ import HttpErrorHandler from '../../Features/Errors/HttpErrorHandler.js' import mongodb from 'mongodb-legacy' import CollaboratorsHandler from './CollaboratorsHandler.js' import CollaboratorsGetter from './CollaboratorsGetter.js' -import OwnershipTransferHandler from './OwnershipTransferHandler.js' +import OwnershipTransferHandler from './OwnershipTransferHandler.mjs' import SessionManager from '../Authentication/SessionManager.js' import EditorRealTimeController from '../Editor/EditorRealTimeController.js' import TagsHandler from '../Tags/TagsHandler.js' diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index 14f2eab4ca..f5ea94c2a5 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -12,42 +12,6 @@ const ProjectEditorHandler = require('../Project/ProjectEditorHandler') const Sources = require('../Authorization/Sources') const PrivilegeLevels = require('../Authorization/PrivilegeLevels') -module.exports = { - getMemberIdsWithPrivilegeLevels: callbackify(getMemberIdsWithPrivilegeLevels), - getMemberIds: callbackify(getMemberIds), - getInvitedMemberIds: callbackify(getInvitedMemberIds), - getInvitedMembersWithPrivilegeLevelsFromFields: callbackify( - getInvitedMembersWithPrivilegeLevelsFromFields - ), - getMemberIdPrivilegeLevel: callbackify(getMemberIdPrivilegeLevel), - getProjectsUserIsMemberOf: callbackify(getProjectsUserIsMemberOf), - dangerouslyGetAllProjectsUserIsMemberOf: callbackify( - dangerouslyGetAllProjectsUserIsMemberOf - ), - isUserInvitedMemberOfProject: callbackify(isUserInvitedMemberOfProject), - getPublicShareTokens: callbackify(getPublicShareTokens), - userIsTokenMember: callbackify(userIsTokenMember), - getAllInvitedMembers: callbackify(getAllInvitedMembers), - promises: { - getProjectAccess, - getMemberIdsWithPrivilegeLevels, - getMemberIds, - getInvitedMemberIds, - getInvitedMembersWithPrivilegeLevelsFromFields, - getMemberIdPrivilegeLevel, - getInvitedEditCollaboratorCount, - getInvitedPendingEditorCount, - getProjectsUserIsMemberOf, - dangerouslyGetAllProjectsUserIsMemberOf, - isUserInvitedMemberOfProject, - isUserInvitedReadWriteMemberOfProject, - getPublicShareTokens, - userIsTokenMember, - userIsReadWriteTokenMember, - getAllInvitedMembers, - }, -} - /** * @typedef ProjectMember * @property {string} id @@ -241,8 +205,6 @@ class ProjectAccess { } } -module.exports.ProjectAccess = ProjectAccess - async function getProjectAccess(projectId) { const project = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1, @@ -577,3 +539,40 @@ async function _loadMembers(members) { }) .filter(r => r != null) } + +module.exports = { + getMemberIdsWithPrivilegeLevels: callbackify(getMemberIdsWithPrivilegeLevels), + getMemberIds: callbackify(getMemberIds), + getInvitedMemberIds: callbackify(getInvitedMemberIds), + getInvitedMembersWithPrivilegeLevelsFromFields: callbackify( + getInvitedMembersWithPrivilegeLevelsFromFields + ), + getMemberIdPrivilegeLevel: callbackify(getMemberIdPrivilegeLevel), + getProjectsUserIsMemberOf: callbackify(getProjectsUserIsMemberOf), + dangerouslyGetAllProjectsUserIsMemberOf: callbackify( + dangerouslyGetAllProjectsUserIsMemberOf + ), + isUserInvitedMemberOfProject: callbackify(isUserInvitedMemberOfProject), + getPublicShareTokens: callbackify(getPublicShareTokens), + userIsTokenMember: callbackify(userIsTokenMember), + getAllInvitedMembers: callbackify(getAllInvitedMembers), + promises: { + getProjectAccess, + getMemberIdsWithPrivilegeLevels, + getMemberIds, + getInvitedMemberIds, + getInvitedMembersWithPrivilegeLevelsFromFields, + getMemberIdPrivilegeLevel, + getInvitedEditCollaboratorCount, + getInvitedPendingEditorCount, + getProjectsUserIsMemberOf, + dangerouslyGetAllProjectsUserIsMemberOf, + isUserInvitedMemberOfProject, + isUserInvitedReadWriteMemberOfProject, + getPublicShareTokens, + userIsTokenMember, + userIsReadWriteTokenMember, + getAllInvitedMembers, + }, + ProjectAccess, +} diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs index 63a88c10e2..39f710a2c9 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs @@ -5,7 +5,7 @@ import PrivilegeLevels from '../Authorization/PrivilegeLevels.js' import CollaboratorsInviteController from './CollaboratorsInviteController.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' -import CaptchaMiddleware from '../Captcha/CaptchaMiddleware.js' +import CaptchaMiddleware from '../Captcha/CaptchaMiddleware.mjs' import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js' import { Joi, validate } from '../../infrastructure/Validation.js' diff --git a/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js b/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs similarity index 87% rename from services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js rename to services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs index 81ec5ccb0a..b2b532bb9c 100644 --- a/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js +++ b/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs @@ -1,19 +1,19 @@ -const logger = require('@overleaf/logger') -const { Project } = require('../../models/Project') -const ProjectGetter = require('../Project/ProjectGetter') -const UserGetter = require('../User/UserGetter') -const CollaboratorsHandler = require('./CollaboratorsHandler') -const EmailHandler = require('../Email/EmailHandler') -const Errors = require('../Errors/Errors') -const PrivilegeLevels = require('../Authorization/PrivilegeLevels') -const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher') -const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler') -const AnalyticsManager = require('../Analytics/AnalyticsManager') -const OError = require('@overleaf/o-error') -const TagsHandler = require('../Tags/TagsHandler') -const { promiseMapWithLimit } = require('@overleaf/promise-utils') +import logger from '@overleaf/logger' +import { Project } from '../../models/Project.js' +import ProjectGetter from '../Project/ProjectGetter.js' +import UserGetter from '../User/UserGetter.js' +import CollaboratorsHandler from './CollaboratorsHandler.js' +import EmailHandler from '../Email/EmailHandler.js' +import Errors from '../Errors/Errors.js' +import PrivilegeLevels from '../Authorization/PrivilegeLevels.js' +import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js' +import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js' +import AnalyticsManager from '../Analytics/AnalyticsManager.js' +import OError from '@overleaf/o-error' +import TagsHandler from '../Tags/TagsHandler.js' +import { promiseMapWithLimit } from '@overleaf/promise-utils' -module.exports = { +export default { promises: { transferOwnership, transferAllProjectsToUser, diff --git a/services/web/app/src/Features/Compile/ClsiCacheController.js b/services/web/app/src/Features/Compile/ClsiCacheController.mjs similarity index 84% rename from services/web/app/src/Features/Compile/ClsiCacheController.js rename to services/web/app/src/Features/Compile/ClsiCacheController.mjs index 6065b25578..b98e03eeef 100644 --- a/services/web/app/src/Features/Compile/ClsiCacheController.js +++ b/services/web/app/src/Features/Compile/ClsiCacheController.mjs @@ -1,18 +1,18 @@ -const { NotFoundError, ResourceGoneError } = require('../Errors/Errors') -const { +import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js' +import { fetchStreamWithResponse, RequestFailedError, -} = require('@overleaf/fetch-utils') -const Path = require('path') -const { pipeline } = require('stream/promises') -const logger = require('@overleaf/logger') -const ClsiCacheManager = require('./ClsiCacheManager') -const CompileController = require('./CompileController') -const { expressify } = require('@overleaf/promise-utils') -const ClsiCacheHandler = require('./ClsiCacheHandler') -const ProjectGetter = require('../Project/ProjectGetter') -const { MeteredStream } = require('@overleaf/stream-utils') -const Metrics = require('@overleaf/metrics') +} from '@overleaf/fetch-utils' +import Path from 'node:path' +import { pipeline } from 'node:stream/promises' +import logger from '@overleaf/logger' +import ClsiCacheManager from './ClsiCacheManager.js' +import CompileController from './CompileController.js' +import { expressify } from '@overleaf/promise-utils' +import ClsiCacheHandler from './ClsiCacheHandler.js' +import ProjectGetter from '../Project/ProjectGetter.js' +import { MeteredStream } from '@overleaf/stream-utils' +import Metrics from '@overleaf/metrics' /** * Download a file from a specific build on the clsi-cache. @@ -150,7 +150,7 @@ async function getLatestBuildFromCache(req, res) { } } -module.exports = { +export default { downloadFromCache: expressify(downloadFromCache), getLatestBuildFromCache: expressify(getLatestBuildFromCache), } diff --git a/services/web/app/src/Features/Editor/EditorHttpController.js b/services/web/app/src/Features/Editor/EditorHttpController.mjs similarity index 89% rename from services/web/app/src/Features/Editor/EditorHttpController.js rename to services/web/app/src/Features/Editor/EditorHttpController.mjs index f44b57f069..bbcc69c52a 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.js +++ b/services/web/app/src/Features/Editor/EditorHttpController.mjs @@ -1,18 +1,20 @@ -const ProjectDeleter = require('../Project/ProjectDeleter') -const EditorController = require('./EditorController') -const ProjectGetter = require('../Project/ProjectGetter') -const AuthorizationManager = require('../Authorization/AuthorizationManager') -const ProjectEditorHandler = require('../Project/ProjectEditorHandler') -const Metrics = require('@overleaf/metrics') -const CollaboratorsInviteGetter = require('../Collaborators/CollaboratorsInviteGetter') -const PrivilegeLevels = require('../Authorization/PrivilegeLevels') -const SessionManager = require('../Authentication/SessionManager') -const Errors = require('../Errors/Errors') -const { expressify } = require('@overleaf/promise-utils') -const Settings = require('@overleaf/settings') -const { ProjectAccess } = require('../Collaborators/CollaboratorsGetter') +import ProjectDeleter from '../Project/ProjectDeleter.js' +import EditorController from './EditorController.js' +import ProjectGetter from '../Project/ProjectGetter.js' +import AuthorizationManager from '../Authorization/AuthorizationManager.js' +import ProjectEditorHandler from '../Project/ProjectEditorHandler.js' +import Metrics from '@overleaf/metrics' +import CollaboratorsInviteGetter from '../Collaborators/CollaboratorsInviteGetter.js' +import PrivilegeLevels from '../Authorization/PrivilegeLevels.js' +import SessionManager from '../Authentication/SessionManager.js' +import Errors from '../Errors/Errors.js' +import { expressify } from '@overleaf/promise-utils' +import Settings from '@overleaf/settings' +import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.js' -module.exports = { +const ProjectAccess = CollaboratorsGetter.ProjectAccess + +export default { joinProject: expressify(joinProject), addDoc: expressify(addDoc), addFolder: expressify(addFolder), diff --git a/services/web/app/src/Features/Editor/EditorRouter.mjs b/services/web/app/src/Features/Editor/EditorRouter.mjs index 4a75c19ff3..00b9d887c4 100644 --- a/services/web/app/src/Features/Editor/EditorRouter.mjs +++ b/services/web/app/src/Features/Editor/EditorRouter.mjs @@ -1,4 +1,4 @@ -import EditorHttpController from './EditorHttpController.js' +import EditorHttpController from './EditorHttpController.mjs' import AuthenticationController from '../Authentication/AuthenticationController.js' import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js' import { RateLimiter } from '../../infrastructure/RateLimiter.js' diff --git a/services/web/app/src/Features/Errors/ErrorController.js b/services/web/app/src/Features/Errors/ErrorController.mjs similarity index 89% rename from services/web/app/src/Features/Errors/ErrorController.js rename to services/web/app/src/Features/Errors/ErrorController.mjs index bb5059c530..908c9e02db 100644 --- a/services/web/app/src/Features/Errors/ErrorController.js +++ b/services/web/app/src/Features/Errors/ErrorController.mjs @@ -1,10 +1,10 @@ -const { isZodErrorLike, fromZodError } = require('zod-validation-error') -const Errors = require('./Errors') -const SessionManager = require('../Authentication/SessionManager') -const SamlLogHandler = require('../SamlLog/SamlLogHandler') -const HttpErrorHandler = require('./HttpErrorHandler') -const { plainTextResponse } = require('../../infrastructure/Response') -const { expressifyErrorHandler } = require('@overleaf/promise-utils') +import { isZodErrorLike, fromZodError } from 'zod-validation-error' +import Errors from './Errors.js' +import SessionManager from '../Authentication/SessionManager.js' +import SamlLogHandler from '../SamlLog/SamlLogHandler.js' +import HttpErrorHandler from './HttpErrorHandler.js' +import { plainTextResponse } from '../../infrastructure/Response.js' +import { expressifyErrorHandler } from '@overleaf/promise-utils' function notFound(req, res) { res.status(404) @@ -135,7 +135,7 @@ function handleApiError(err, req, res, next) { } } -module.exports = { +export default { notFound, forbidden, serverError, diff --git a/services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.js b/services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.mjs similarity index 92% rename from services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.js rename to services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.mjs index 75cbca1965..d8991614d7 100644 --- a/services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.js +++ b/services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.mjs @@ -1,4 +1,4 @@ -const { GlobalMetric } = require('../../models/GlobalMetric') +import { GlobalMetric } from '../../models/GlobalMetric.js' /** * A Generic collection used to track metrics shared across the entirety of the application * examples: @@ -31,7 +31,7 @@ async function incrementMetric(key, value = 1) { ) } -module.exports = { +export default { getMetric, setMetric, incrementMetric, diff --git a/services/web/app/src/Features/History/HistoryController.js b/services/web/app/src/Features/History/HistoryController.mjs similarity index 93% rename from services/web/app/src/Features/History/HistoryController.js rename to services/web/app/src/Features/History/HistoryController.mjs index d0d7c8aa07..6f1767e270 100644 --- a/services/web/app/src/Features/History/HistoryController.js +++ b/services/web/app/src/Features/History/HistoryController.mjs @@ -1,28 +1,30 @@ // @ts-check -const { setTimeout } = require('timers/promises') -const { pipeline } = require('stream/promises') -const OError = require('@overleaf/o-error') -const logger = require('@overleaf/logger') -const { expressify } = require('@overleaf/promise-utils') -const { +import { setTimeout } from 'node:timers/promises' +import { pipeline } from 'node:stream/promises' +import OError from '@overleaf/o-error' +import logger from '@overleaf/logger' +import { expressify } from '@overleaf/promise-utils' + +import { fetchStream, fetchStreamWithResponse, fetchJson, fetchNothing, RequestFailedError, -} = require('@overleaf/fetch-utils') -const settings = require('@overleaf/settings') -const SessionManager = require('../Authentication/SessionManager') -const UserGetter = require('../User/UserGetter') -const ProjectGetter = require('../Project/ProjectGetter') -const Errors = require('../Errors/Errors') -const HistoryManager = require('./HistoryManager') -const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler') -const ProjectEntityUpdateHandler = require('../Project/ProjectEntityUpdateHandler') -const RestoreManager = require('./RestoreManager') -const { prepareZipAttachment } = require('../../infrastructure/Response') -const Features = require('../../infrastructure/Features') +} from '@overleaf/fetch-utils' + +import settings from '@overleaf/settings' +import SessionManager from '../Authentication/SessionManager.js' +import UserGetter from '../User/UserGetter.js' +import ProjectGetter from '../Project/ProjectGetter.js' +import Errors from '../Errors/Errors.js' +import HistoryManager from './HistoryManager.js' +import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.js' +import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.js' +import RestoreManager from './RestoreManager.js' +import { prepareZipAttachment } from '../../infrastructure/Response.js' +import Features from '../../infrastructure/Features.js' // Number of seconds after which the browser should send a request to revalidate // blobs @@ -508,7 +510,7 @@ function isPrematureClose(err) { ) } -module.exports = { +export default { getBlob: expressify(getBlob), headBlob: expressify(headBlob), proxyToHistoryApi: expressify(proxyToHistoryApi), diff --git a/services/web/app/src/Features/History/HistoryRouter.mjs b/services/web/app/src/Features/History/HistoryRouter.mjs index d5c7b46804..a35761b14b 100644 --- a/services/web/app/src/Features/History/HistoryRouter.mjs +++ b/services/web/app/src/Features/History/HistoryRouter.mjs @@ -6,7 +6,7 @@ import { RateLimiter } from '../../infrastructure/RateLimiter.js' import AuthenticationController from '../Authentication/AuthenticationController.js' import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js' import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' -import HistoryController from './HistoryController.js' +import HistoryController from './HistoryController.mjs' const rateLimiters = { downloadProjectRevision: new RateLimiter('download-project-revision', { diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs index a749feca70..f6290ef33f 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs @@ -41,7 +41,7 @@ import ReferencesHandler from '../References/ReferencesHandler.mjs' import EditorRealTimeController from '../Editor/EditorRealTimeController.js' import { expressify } from '@overleaf/promise-utils' import ProjectOutputFileAgent from './ProjectOutputFileAgent.mjs' -import ProjectFileAgent from './ProjectFileAgent.js' +import ProjectFileAgent from './ProjectFileAgent.mjs' import UrlAgent from './UrlAgent.mjs' let LinkedFilesController diff --git a/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.js b/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs similarity index 92% rename from services/web/app/src/Features/LinkedFiles/ProjectFileAgent.js rename to services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs index be4d0df801..b1d02b147a 100644 --- a/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.js +++ b/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs @@ -10,23 +10,26 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -let ProjectFileAgent -const AuthorizationManager = require('../Authorization/AuthorizationManager') -const ProjectLocator = require('../Project/ProjectLocator') -const DocstoreManager = require('../Docstore/DocstoreManager') -const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') -const _ = require('lodash') -const LinkedFilesHandler = require('./LinkedFilesHandler') -const { +import AuthorizationManager from '../Authorization/AuthorizationManager.js' +import ProjectLocator from '../Project/ProjectLocator.js' +import DocstoreManager from '../Docstore/DocstoreManager.js' +import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js' +import _ from 'lodash' +import LinkedFilesHandler from './LinkedFilesHandler.js' + +import { BadDataError, AccessDeniedError, BadEntityTypeError, SourceFileNotFoundError, -} = require('./LinkedFilesErrors') -const { promisify } = require('@overleaf/promise-utils') -const HistoryManager = require('../History/HistoryManager') +} from './LinkedFilesErrors.js' -module.exports = ProjectFileAgent = { +import { promisify } from '@overleaf/promise-utils' +import HistoryManager from '../History/HistoryManager.js' + +let ProjectFileAgent + +export default ProjectFileAgent = { createLinkedFile( projectId, linkedFileData, diff --git a/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs b/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs index 0af62b2d1c..f91809f9ff 100644 --- a/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs +++ b/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs @@ -1,7 +1,7 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.js' import CompileManager from '../Compile/CompileManager.js' import ClsiManager from '../Compile/ClsiManager.js' -import ProjectFileAgent from './ProjectFileAgent.js' +import ProjectFileAgent from './ProjectFileAgent.mjs' import _ from 'lodash' import { CompileFailedError, diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs index e750d1131e..79cbeaf3f8 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs @@ -1,6 +1,6 @@ import PasswordResetController from './PasswordResetController.mjs' import AuthenticationController from '../Authentication/AuthenticationController.js' -import CaptchaMiddleware from '../../Features/Captcha/CaptchaMiddleware.js' +import CaptchaMiddleware from '../../Features/Captcha/CaptchaMiddleware.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' import { Joi, validate } from '../../infrastructure/Validation.js' diff --git a/services/web/app/src/Features/Project/ProjectCollabratecDetailsHandler.js b/services/web/app/src/Features/Project/ProjectCollabratecDetailsHandler.mjs similarity index 92% rename from services/web/app/src/Features/Project/ProjectCollabratecDetailsHandler.js rename to services/web/app/src/Features/Project/ProjectCollabratecDetailsHandler.mjs index 59547e7b40..55d19a4bd6 100644 --- a/services/web/app/src/Features/Project/ProjectCollabratecDetailsHandler.js +++ b/services/web/app/src/Features/Project/ProjectCollabratecDetailsHandler.mjs @@ -1,6 +1,9 @@ -const { ObjectId } = require('mongodb-legacy') -const { Project } = require('../../models/Project') -const { callbackifyAll } = require('@overleaf/promise-utils') +import mongodb from 'mongodb-legacy' + +import { Project } from '../../models/Project.js' +import { callbackifyAll } from '@overleaf/promise-utils' + +const { ObjectId } = mongodb const ProjectCollabratecDetailsHandler = { async initializeCollabratecProject( @@ -94,7 +97,7 @@ const ProjectCollabratecDetailsHandler = { }, } -module.exports = { +export default { ...callbackifyAll(ProjectCollabratecDetailsHandler), promises: ProjectCollabratecDetailsHandler, } diff --git a/services/web/app/src/Features/StaticPages/HomeController.mjs b/services/web/app/src/Features/StaticPages/HomeController.mjs index 9b0b0fb563..53575a0aa4 100644 --- a/services/web/app/src/Features/StaticPages/HomeController.mjs +++ b/services/web/app/src/Features/StaticPages/HomeController.mjs @@ -2,7 +2,7 @@ import Features from '../../infrastructure/Features.js' import AnalyticsManager from '../Analytics/AnalyticsManager.js' import Path from 'node:path' import fs from 'node:fs' -import ErrorController from '../Errors/ErrorController.js' +import ErrorController from '../Errors/ErrorController.mjs' import SessionManager from '../Authentication/SessionManager.js' import { expressify } from '@overleaf/promise-utils' import logger from '@overleaf/logger' diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index 1eb9ac2907..70e48e9b05 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -5,7 +5,7 @@ import TeamInvitesHandler from './TeamInvitesHandler.js' import SessionManager from '../Authentication/SessionManager.js' import SubscriptionLocator from './SubscriptionLocator.js' import SubscriptionHelper from './SubscriptionHelper.js' -import ErrorController from '../Errors/ErrorController.js' +import ErrorController from '../Errors/ErrorController.mjs' import EmailHelper from '../Helpers/EmailHelper.js' import UserGetter from '../User/UserGetter.js' import { expressify } from '@overleaf/promise-utils' diff --git a/services/web/app/src/infrastructure/Server.mjs b/services/web/app/src/infrastructure/Server.mjs index 6aba16e682..1709c4cc4d 100644 --- a/services/web/app/src/infrastructure/Server.mjs +++ b/services/web/app/src/infrastructure/Server.mjs @@ -25,7 +25,7 @@ import RedirectManager from './RedirectManager.js' import translations from './Translations.js' import Views from './Views.js' import Features from './Features.js' -import ErrorController from '../Features/Errors/ErrorController.js' +import ErrorController from '../Features/Errors/ErrorController.mjs' import HttpErrorHandler from '../Features/Errors/HttpErrorHandler.js' import UserSessionsManager from '../Features/User/UserSessionsManager.js' import AuthenticationController from '../Features/Authentication/AuthenticationController.js' diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 4e33532f7e..500ec4c691 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -1,5 +1,5 @@ import AdminController from './Features/ServerAdmin/AdminController.js' -import ErrorController from './Features/Errors/ErrorController.js' +import ErrorController from './Features/Errors/ErrorController.mjs' import Features from './infrastructure/Features.js' import ProjectController from './Features/Project/ProjectController.js' import ProjectApiController from './Features/Project/ProjectApiController.mjs' @@ -34,7 +34,7 @@ import HistoryRouter from './Features/History/HistoryRouter.mjs' import ExportsController from './Features/Exports/ExportsController.mjs' import PasswordResetRouter from './Features/PasswordReset/PasswordResetRouter.mjs' import StaticPagesRouter from './Features/StaticPages/StaticPagesRouter.mjs' -import ChatController from './Features/Chat/ChatController.js' +import ChatController from './Features/Chat/ChatController.mjs' import Modules from './infrastructure/Modules.js' import { RateLimiter, @@ -57,7 +57,7 @@ import UserMembershipRouter from './Features/UserMembership/UserMembershipRouter import SystemMessageController from './Features/SystemMessages/SystemMessageController.js' import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.js' import AnalyticsUTMTrackingMiddleware from './Features/Analytics/AnalyticsUTMTrackingMiddleware.mjs' -import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.js' +import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.mjs' import { Joi, validate } from './infrastructure/Validation.js' import UnsupportedBrowserMiddleware from './infrastructure/UnsupportedBrowserMiddleware.js' import logger from '@overleaf/logger' @@ -65,7 +65,7 @@ import _ from 'lodash' import { plainTextResponse } from './infrastructure/Response.js' import PublicAccessLevels from './Features/Authorization/PublicAccessLevels.js' import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs' -import ClsiCacheController from './Features/Compile/ClsiCacheController.js' +import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs' import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.js' const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } = diff --git a/services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs b/services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs index 8c59513344..010658a2f4 100644 --- a/services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs +++ b/services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs @@ -1,6 +1,6 @@ import { ObjectId } from '../../../app/src/infrastructure/mongodb.js' import minimist from 'minimist' -import OwnershipTransferHandler from '../../../app/src/Features/Collaborators/OwnershipTransferHandler.js' +import OwnershipTransferHandler from '../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs' import UserGetter from '../../../app/src/Features/User/UserGetter.js' import EmailHelper from '../../../app/src/Features/Helpers/EmailHelper.js' diff --git a/services/web/modules/user-activate/app/src/UserActivateController.mjs b/services/web/modules/user-activate/app/src/UserActivateController.mjs index e8ce258ba7..980bc3696c 100644 --- a/services/web/modules/user-activate/app/src/UserActivateController.mjs +++ b/services/web/modules/user-activate/app/src/UserActivateController.mjs @@ -2,7 +2,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 ErrorController from '../../../../app/src/Features/Errors/ErrorController.js' +import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs' import { expressify } from '@overleaf/promise-utils' const __dirname = Path.dirname(fileURLToPath(import.meta.url)) 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 be59eecf97..33d7c64a5a 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 @@ -42,7 +42,7 @@ describe('UserActivateController', function () { ) vi.doMock( - '../../../../../app/src/Features/Errors/ErrorController.js', + '../../../../../app/src/Features/Errors/ErrorController.mjs', () => ({ default: ctx.ErrorController, }) diff --git a/services/web/test/unit/src/Chat/ChatController.test.mjs b/services/web/test/unit/src/Chat/ChatController.test.mjs new file mode 100644 index 0000000000..8d3872e368 --- /dev/null +++ b/services/web/test/unit/src/Chat/ChatController.test.mjs @@ -0,0 +1,152 @@ +import { vi } from 'vitest' +import sinon from 'sinon' + +const MODULE_PATH = '../../../../app/src/Features/Chat/ChatController.mjs' + +describe('ChatController', function () { + beforeEach(async function (ctx) { + ctx.user_id = 'mock-user-id' + ctx.settings = {} + ctx.ChatApiHandler = { promises: {} } + ctx.ChatManager = { promises: {} } + ctx.EditorRealTimeController = { emitToRoom: sinon.stub() } + ctx.SessionManager = { + getLoggedInUserId: sinon.stub().returns(ctx.user_id), + } + ctx.UserInfoManager = { + promises: {}, + } + ctx.UserInfoController = {} + ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub().resolves(), + }, + }, + } + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler.js', () => ({ + default: ctx.ChatApiHandler, + })) + + vi.doMock('../../../../app/src/Features/Chat/ChatManager.js', () => ({ + default: ctx.ChatManager, + })) + + vi.doMock( + '../../../../app/src/Features/Editor/EditorRealTimeController.js', + () => ({ + default: ctx.EditorRealTimeController, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager.js', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserInfoManager.js', () => ({ + default: ctx.UserInfoManager, + })) + + vi.doMock( + '../../../../app/src/Features/User/UserInfoController.js', + () => ({ + default: ctx.UserInfoController, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({ + default: ctx.Modules, + })) + + ctx.ChatController = (await import(MODULE_PATH)).default + ctx.req = { + params: { + project_id: ctx.project_id, + }, + } + ctx.res = { + json: sinon.stub(), + send: sinon.stub(), + sendStatus: sinon.stub(), + } + }) + + describe('sendMessage', function () { + beforeEach(async function (ctx) { + ctx.req.body = { content: (ctx.content = 'message-content') } + ctx.UserInfoManager.promises.getPersonalInfo = sinon + .stub() + .resolves((ctx.user = { unformatted: 'user' })) + ctx.UserInfoController.formatPersonalInfo = sinon + .stub() + .returns((ctx.formatted_user = { formatted: 'user' })) + ctx.ChatApiHandler.promises.sendGlobalMessage = sinon + .stub() + .resolves((ctx.message = { mock: 'message', user_id: ctx.user_id })) + await ctx.ChatController.sendMessage(ctx.req, ctx.res) + }) + + it('should look up the user', function (ctx) { + ctx.UserInfoManager.promises.getPersonalInfo + .calledWith(ctx.user_id) + .should.equal(true) + }) + + it('should format and inject the user into the message', function (ctx) { + ctx.UserInfoController.formatPersonalInfo + .calledWith(ctx.user) + .should.equal(true) + ctx.message.user.should.deep.equal(ctx.formatted_user) + }) + + it('should tell the chat handler about the message', function (ctx) { + ctx.ChatApiHandler.promises.sendGlobalMessage + .calledWith(ctx.project_id, ctx.user_id, ctx.content) + .should.equal(true) + }) + + it('should tell the editor real time controller about the update with the data from the chat handler', function (ctx) { + ctx.EditorRealTimeController.emitToRoom + .calledWith(ctx.project_id, 'new-chat-message', ctx.message) + .should.equal(true) + }) + + it('should return a 204 status code', function (ctx) { + ctx.res.sendStatus.calledWith(204).should.equal(true) + }) + }) + + describe('getMessages', function () { + beforeEach(async function (ctx) { + ctx.req.query = { + limit: (ctx.limit = '30'), + before: (ctx.before = '12345'), + } + ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon + .stub() + .resolves() + ctx.ChatApiHandler.promises.getGlobalMessages = sinon + .stub() + .resolves((ctx.messages = ['mock', 'messages'])) + await ctx.ChatController.getMessages(ctx.req, ctx.res) + }) + + it('should ask the chat handler about the request', function (ctx) { + ctx.ChatApiHandler.promises.getGlobalMessages + .calledWith(ctx.project_id, ctx.limit, ctx.before) + .should.equal(true) + }) + + it('should return the messages', function (ctx) { + ctx.res.json.calledWith(ctx.messages).should.equal(true) + }) + }) +}) diff --git a/services/web/test/unit/src/Chat/ChatControllerTests.js b/services/web/test/unit/src/Chat/ChatControllerTests.js deleted file mode 100644 index ab35eb21f4..0000000000 --- a/services/web/test/unit/src/Chat/ChatControllerTests.js +++ /dev/null @@ -1,125 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const path = require('path') -const sinon = require('sinon') - -const MODULE_PATH = path.join( - __dirname, - '../../../../app/src/Features/Chat/ChatController' -) - -describe('ChatController', function () { - beforeEach(function () { - this.user_id = 'mock-user-id' - this.settings = {} - this.ChatApiHandler = { promises: {} } - this.ChatManager = { promises: {} } - this.EditorRealTimeController = { emitToRoom: sinon.stub() } - this.SessionManager = { - getLoggedInUserId: sinon.stub().returns(this.user_id), - } - this.UserInfoManager = { - promises: {}, - } - this.UserInfoController = {} - this.Modules = { - promises: { - hooks: { - fire: sinon.stub().resolves(), - }, - }, - } - this.ChatController = SandboxedModule.require(MODULE_PATH, { - requires: { - '@overleaf/settings': this.settings, - './ChatApiHandler': this.ChatApiHandler, - './ChatManager': this.ChatManager, - '../Editor/EditorRealTimeController': this.EditorRealTimeController, - '../Authentication/SessionManager': this.SessionManager, - '../User/UserInfoManager': this.UserInfoManager, - '../User/UserInfoController': this.UserInfoController, - '../../infrastructure/Modules': this.Modules, - }, - }) - this.req = { - params: { - project_id: this.project_id, - }, - } - this.res = { - json: sinon.stub(), - send: sinon.stub(), - sendStatus: sinon.stub(), - } - }) - - describe('sendMessage', function () { - beforeEach(async function () { - this.req.body = { content: (this.content = 'message-content') } - this.UserInfoManager.promises.getPersonalInfo = sinon - .stub() - .resolves((this.user = { unformatted: 'user' })) - this.UserInfoController.formatPersonalInfo = sinon - .stub() - .returns((this.formatted_user = { formatted: 'user' })) - this.ChatApiHandler.promises.sendGlobalMessage = sinon - .stub() - .resolves((this.message = { mock: 'message', user_id: this.user_id })) - await this.ChatController.sendMessage(this.req, this.res) - }) - - it('should look up the user', function () { - this.UserInfoManager.promises.getPersonalInfo - .calledWith(this.user_id) - .should.equal(true) - }) - - it('should format and inject the user into the message', function () { - this.UserInfoController.formatPersonalInfo - .calledWith(this.user) - .should.equal(true) - this.message.user.should.deep.equal(this.formatted_user) - }) - - it('should tell the chat handler about the message', function () { - this.ChatApiHandler.promises.sendGlobalMessage - .calledWith(this.project_id, this.user_id, this.content) - .should.equal(true) - }) - - it('should tell the editor real time controller about the update with the data from the chat handler', function () { - this.EditorRealTimeController.emitToRoom - .calledWith(this.project_id, 'new-chat-message', this.message) - .should.equal(true) - }) - - it('should return a 204 status code', function () { - this.res.sendStatus.calledWith(204).should.equal(true) - }) - }) - - describe('getMessages', function () { - beforeEach(async function () { - this.req.query = { - limit: (this.limit = '30'), - before: (this.before = '12345'), - } - this.ChatManager.promises.injectUserInfoIntoThreads = sinon - .stub() - .resolves() - this.ChatApiHandler.promises.getGlobalMessages = sinon - .stub() - .resolves((this.messages = ['mock', 'messages'])) - await this.ChatController.getMessages(this.req, this.res) - }) - - it('should ask the chat handler about the request', function () { - this.ChatApiHandler.promises.getGlobalMessages - .calledWith(this.project_id, this.limit, this.before) - .should.equal(true) - }) - - it('should return the messages', function () { - this.res.json.calledWith(this.messages).should.equal(true) - }) - }) -}) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs index 85beb949b7..f73274b6d9 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs @@ -102,7 +102,7 @@ describe('CollaboratorsController', function () { ) vi.doMock( - '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.js', + '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs', () => ({ default: ctx.OwnershipTransferHandler, }) diff --git a/services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs b/services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs new file mode 100644 index 0000000000..2ce89ae061 --- /dev/null +++ b/services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs @@ -0,0 +1,532 @@ +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.js' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import mongodb from 'mongodb-legacy' + +const { ObjectId } = mongodb + +const MODULE_PATH = + '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs' + +describe('OwnershipTransferHandler', function () { + beforeEach(async function (ctx) { + ctx.user = { _id: new ObjectId(), email: 'owner@example.com' } + ctx.collaborator = { + _id: new ObjectId(), + email: 'collaborator@example.com', + } + ctx.readOnlyCollaborator = { + _id: new ObjectId(), + email: 'readonly@example.com', + } + ctx.reviewer = { + _id: new ObjectId(), + email: 'reviewer@example.com', + } + ctx.project = { + _id: new ObjectId(), + name: 'project', + owner_ref: ctx.user._id, + collaberator_refs: [ctx.collaborator._id], + readOnly_refs: [ctx.readOnlyCollaborator._id], + reviewer_refs: [ctx.reviewer._id], + } + ctx.ProjectGetter = { + promises: { + getProject: sinon.stub().resolves(ctx.project), + }, + } + ctx.ProjectModel = { + find: sinon.stub().resolves([]), + updateOne: sinon.stub().returns({ + exec: sinon.stub().resolves(), + }), + } + ctx.UserGetter = { + promises: { + getUser: sinon.stub().resolves(), + }, + } + ctx.TpdsUpdateSender = { + promises: { + moveEntity: sinon.stub().resolves(), + }, + } + ctx.TpdsProjectFlusher = { + promises: { + flushProjectToTpds: sinon.stub().resolves(), + }, + } + ctx.CollaboratorsHandler = { + promises: { + removeUserFromProject: sinon.stub().resolves(), + addUserIdToProject: sinon.stub().resolves(), + }, + } + ctx.EmailHandler = { + promises: { + sendEmail: sinon.stub().resolves(), + }, + } + ctx.ProjectAuditLogHandler = { + promises: { + addEntry: sinon.stub().resolves(), + }, + } + ctx.TagsHandler = { + promises: { + createTag: sinon.stub().resolves(), + addProjectsToTag: sinon.stub().resolves(), + }, + } + + vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock('../../../../app/src/models/Project.js', () => ({ + Project: ctx.ProjectModel, + })) + + vi.doMock('../../../../app/src/Features/Tags/TagsHandler.js', () => ({ + default: ctx.TagsHandler, + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher.js', + () => ({ + default: ctx.TpdsProjectFlusher, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectAuditLogHandler.js', + () => ({ + default: ctx.ProjectAuditLogHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Email/EmailHandler.js', () => ({ + default: ctx.EmailHandler, + })) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js', + () => ({ + default: ctx.CollaboratorsHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: { + recordEventForUserInBackground: (ctx.recordEventForUserInBackground = + sinon.stub()), + }, + }) + ) + + ctx.handler = (await import(MODULE_PATH)).default + }) + + describe('transferOwnership', function () { + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user) + ctx.UserGetter.promises.getUser + .withArgs(ctx.collaborator._id) + .resolves(ctx.collaborator) + ctx.UserGetter.promises.getUser + .withArgs(ctx.readOnlyCollaborator._id) + .resolves(ctx.readOnlyCollaborator) + ctx.UserGetter.promises.getUser + .withArgs(ctx.reviewer._id) + .resolves(ctx.reviewer) + }) + + it("should return a not found error if the project can't be found", async function (ctx) { + ctx.ProjectGetter.promises.getProject.resolves(null) + await expect( + ctx.handler.promises.transferOwnership('abc', ctx.collaborator._id) + ).to.be.rejectedWith(Errors.ProjectNotFoundError) + }) + + it("should return a not found error if the user can't be found", async function (ctx) { + ctx.UserGetter.promises.getUser + .withArgs(ctx.collaborator._id) + .resolves(null) + await expect( + ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id + ) + ).to.be.rejectedWith(Errors.UserNotFoundError) + }) + + it('should return an error if user cannot be removed as collaborator ', async function (ctx) { + ctx.CollaboratorsHandler.promises.removeUserFromProject.rejects( + new Error('user-cannot-be-removed') + ) + await expect( + ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id + ) + ).to.be.rejected + }) + + it('should transfer ownership of the project', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id + ) + expect(ctx.ProjectModel.updateOne).to.have.been.calledWith( + { _id: ctx.project._id }, + sinon.match({ $set: { owner_ref: ctx.collaborator._id } }) + ) + }) + + it('should transfer ownership of the project to a read-only collaborator', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.readOnlyCollaborator._id + ) + expect(ctx.ProjectModel.updateOne).to.have.been.calledWith( + { _id: ctx.project._id }, + sinon.match({ $set: { owner_ref: ctx.readOnlyCollaborator._id } }) + ) + }) + + it('gives old owner read-only permissions if new owner was previously a viewer', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.readOnlyCollaborator._id + ) + expect( + ctx.CollaboratorsHandler.promises.addUserIdToProject + ).to.have.been.calledWith( + ctx.project._id, + ctx.readOnlyCollaborator._id, + ctx.user._id, + PrivilegeLevels.READ_ONLY + ) + }) + + it('should do nothing if transferring back to the owner', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.user._id + ) + expect(ctx.ProjectModel.updateOne).not.to.have.been.called + }) + + it("should remove the user from the project's collaborators", async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id + ) + expect( + ctx.CollaboratorsHandler.promises.removeUserFromProject + ).to.have.been.calledWith(ctx.project._id, ctx.collaborator._id) + }) + + it('should add the former project owner as a read/write collaborator', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id + ) + expect( + ctx.CollaboratorsHandler.promises.addUserIdToProject + ).to.have.been.calledWith( + ctx.project._id, + ctx.collaborator._id, + ctx.user._id, + PrivilegeLevels.READ_AND_WRITE + ) + }) + + it('should transfer ownership of the project to a reviewer', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.reviewer._id + ) + expect(ctx.ProjectModel.updateOne).to.have.been.calledWith( + { _id: ctx.project._id }, + sinon.match({ $set: { owner_ref: ctx.reviewer._id } }) + ) + }) + + it('gives old owner reviewer permissions if new owner was previously a reviewer', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.reviewer._id + ) + expect( + ctx.CollaboratorsHandler.promises.addUserIdToProject + ).to.have.been.calledWith( + ctx.project._id, + ctx.reviewer._id, + ctx.user._id, + PrivilegeLevels.REVIEW + ) + }) + + it('should flush the project to tpds', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id + ) + expect( + ctx.TpdsProjectFlusher.promises.flushProjectToTpds + ).to.have.been.calledWith(ctx.project._id) + }) + + it('should send an email notification', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id + ) + expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith( + 'ownershipTransferConfirmationPreviousOwner', + { + to: ctx.user.email, + project: ctx.project, + newOwner: ctx.collaborator, + } + ) + expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith( + 'ownershipTransferConfirmationNewOwner', + { + to: ctx.collaborator.email, + project: ctx.project, + previousOwner: ctx.user, + } + ) + }) + + it('should not send an email notification with the skipEmails option', async function (ctx) { + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id, + { skipEmails: true } + ) + expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called + }) + + it('should track the change in BigQuery', async function (ctx) { + const sessionUserId = new ObjectId() + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id, + { sessionUserId } + ) + expect(ctx.recordEventForUserInBackground).to.have.been.calledWith( + ctx.user._id, + 'project-ownership-transfer', + { + projectId: ctx.project._id, + newOwnerId: ctx.collaborator._id, + } + ) + }) + + it('should write an entry in the audit log', async function (ctx) { + const sessionUserId = new ObjectId() + const ipAddress = '1.2.3.4' + await ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id, + { sessionUserId, ipAddress } + ) + expect( + ctx.ProjectAuditLogHandler.promises.addEntry + ).to.have.been.calledWith( + ctx.project._id, + 'transfer-ownership', + sessionUserId, + ipAddress, + { + previousOwnerId: ctx.user._id, + newOwnerId: ctx.collaborator._id, + } + ) + }) + + it('should decline to transfer ownership to a non-collaborator', async function (ctx) { + ctx.project.collaberator_refs = [] + ctx.project.readOnly_refs = [] + await expect( + ctx.handler.promises.transferOwnership( + ctx.project._id, + ctx.collaborator._id + ) + ).to.be.rejectedWith(Errors.UserNotCollaboratorError) + }) + }) + + describe('transferAllProjectsToUser', function () { + const fromUserEmail = 'user.one@example.com' + const ipAddress = '1.2.3.4' + let fromUserId, toUserId + beforeEach(function () { + fromUserId = new ObjectId().toString() + toUserId = new ObjectId().toString() + }) + + describe('with missing user', function () { + it('should throw an error', async function (ctx) { + ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null) + ctx.UserGetter.promises.getUser + .withArgs(toUserId) + .resolves({ _id: new ObjectId(toUserId) }) + await expect( + ctx.handler.promises.transferAllProjectsToUser({ + toUserId, + fromUserId, + ipAddress, + }) + ).to.be.rejectedWith(/missing source user/) + + ctx.UserGetter.promises.getUser + .withArgs(fromUserId) + .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail }) + ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves(null) + await expect( + ctx.handler.promises.transferAllProjectsToUser({ + fromUserId, + toUserId, + ipAddress, + }) + ).to.be.rejectedWith(/missing destination user/) + }) + }) + + describe('with the same id', function () { + it('should throw an error', async function (ctx) { + ctx.UserGetter.promises.getUser + .withArgs(fromUserId) + .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail }) + await expect( + ctx.handler.promises.transferAllProjectsToUser({ + fromUserId, + toUserId: fromUserId, + ipAddress, + }) + ).to.be.rejectedWith(/rejecting transfer between identical users/) + }) + }) + + describe('happy path', function () { + let tag, fromUserEmail, projects + + beforeEach(function (ctx) { + tag = { + _id: new ObjectId(), + name: 'some-tag-name', + } + projects = [ + { _id: 'project-1' }, + { _id: 'project-2' }, + { _id: 'project-3' }, + ] + + ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves({ + _id: new ObjectId(fromUserId), + email: fromUserEmail, + }) + ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves({ + _id: new ObjectId(toUserId), + }) + ctx.ProjectModel.find.resolves(projects) + ctx.TagsHandler.promises.createTag.resolves({ + _id: tag._id, + name: 'some-tag-name', + }) + ctx.TagsHandler.promises.addProjectsToTag.resolves() + }) + + it('creates a tag', async function (ctx) { + await ctx.handler.promises.transferAllProjectsToUser({ + fromUserId, + toUserId, + ipAddress, + }) + expect(ctx.TagsHandler.promises.createTag).to.have.been.calledWith( + toUserId, + `transferred-from-${fromUserEmail}`, + '#434AF0', + { truncate: true } + ) + }) + + it('returns a projectCount, and tag name', async function (ctx) { + const result = await ctx.handler.promises.transferAllProjectsToUser({ + fromUserId, + toUserId, + ipAddress, + }) + expect(result.projectCount).to.equal(projects.length) + expect(result.newTagName).to.equal('some-tag-name') + }) + + it('gets the user records', async function (ctx) { + await ctx.handler.promises.transferAllProjectsToUser({ + fromUserId, + toUserId, + ipAddress, + }) + expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith( + fromUserId + ) + expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith( + toUserId + ) + }) + + it('gets the list of affected projects', async function (ctx) { + await ctx.handler.promises.transferAllProjectsToUser({ + fromUserId, + toUserId, + ipAddress, + }) + expect(ctx.ProjectModel.find).to.have.been.calledWith({ + owner_ref: fromUserId, + }) + }) + + it('transfers all of the projects', async function (ctx) { + await ctx.handler.promises.transferAllProjectsToUser({ + fromUserId, + toUserId, + ipAddress, + }) + + expect(ctx.ProjectModel.updateOne.callCount).to.equal(3) + expect(ctx.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1) + + for (const project of projects) { + expect(ctx.ProjectModel.updateOne).to.have.been.calledWith( + { _id: project._id }, + sinon.match({ $set: { owner_ref: toUserId } }) + ) + } + expect( + ctx.TagsHandler.promises.addProjectsToTag + ).to.have.been.calledWith( + toUserId, + tag._id, + projects.map(p => p._id) + ) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js b/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js deleted file mode 100644 index 1e0b38ddc9..0000000000 --- a/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js +++ /dev/null @@ -1,494 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { expect } = require('chai') -const PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels') -const Errors = require('../../../../app/src/Features/Errors/Errors') -const { ObjectId } = require('mongodb-legacy') - -const MODULE_PATH = - '../../../../app/src/Features/Collaborators/OwnershipTransferHandler' - -describe('OwnershipTransferHandler', function () { - beforeEach(function () { - this.user = { _id: new ObjectId(), email: 'owner@example.com' } - this.collaborator = { - _id: new ObjectId(), - email: 'collaborator@example.com', - } - this.readOnlyCollaborator = { - _id: new ObjectId(), - email: 'readonly@example.com', - } - this.reviewer = { - _id: new ObjectId(), - email: 'reviewer@example.com', - } - this.project = { - _id: new ObjectId(), - name: 'project', - owner_ref: this.user._id, - collaberator_refs: [this.collaborator._id], - readOnly_refs: [this.readOnlyCollaborator._id], - reviewer_refs: [this.reviewer._id], - } - this.ProjectGetter = { - promises: { - getProject: sinon.stub().resolves(this.project), - }, - } - this.ProjectModel = { - find: sinon.stub().resolves([]), - updateOne: sinon.stub().returns({ - exec: sinon.stub().resolves(), - }), - } - this.UserGetter = { - promises: { - getUser: sinon.stub().resolves(), - }, - } - this.TpdsUpdateSender = { - promises: { - moveEntity: sinon.stub().resolves(), - }, - } - this.TpdsProjectFlusher = { - promises: { - flushProjectToTpds: sinon.stub().resolves(), - }, - } - this.CollaboratorsHandler = { - promises: { - removeUserFromProject: sinon.stub().resolves(), - addUserIdToProject: sinon.stub().resolves(), - }, - } - this.EmailHandler = { - promises: { - sendEmail: sinon.stub().resolves(), - }, - } - this.ProjectAuditLogHandler = { - promises: { - addEntry: sinon.stub().resolves(), - }, - } - this.TagsHandler = { - promises: { - createTag: sinon.stub().resolves(), - addProjectsToTag: sinon.stub().resolves(), - }, - } - this.handler = SandboxedModule.require(MODULE_PATH, { - requires: { - '../Project/ProjectGetter': this.ProjectGetter, - '../../models/Project': { - Project: this.ProjectModel, - }, - '../Tags/TagsHandler': this.TagsHandler, - '../User/UserGetter': this.UserGetter, - '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher, - '../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler, - '../Email/EmailHandler': this.EmailHandler, - './CollaboratorsHandler': this.CollaboratorsHandler, - '../Analytics/AnalyticsManager': { - recordEventForUserInBackground: (this.recordEventForUserInBackground = - sinon.stub()), - }, - }, - }) - }) - - describe('transferOwnership', function () { - beforeEach(function () { - this.UserGetter.promises.getUser - .withArgs(this.user._id) - .resolves(this.user) - this.UserGetter.promises.getUser - .withArgs(this.collaborator._id) - .resolves(this.collaborator) - this.UserGetter.promises.getUser - .withArgs(this.readOnlyCollaborator._id) - .resolves(this.readOnlyCollaborator) - this.UserGetter.promises.getUser - .withArgs(this.reviewer._id) - .resolves(this.reviewer) - }) - - it("should return a not found error if the project can't be found", async function () { - this.ProjectGetter.promises.getProject.resolves(null) - await expect( - this.handler.promises.transferOwnership('abc', this.collaborator._id) - ).to.be.rejectedWith(Errors.ProjectNotFoundError) - }) - - it("should return a not found error if the user can't be found", async function () { - this.UserGetter.promises.getUser - .withArgs(this.collaborator._id) - .resolves(null) - await expect( - this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id - ) - ).to.be.rejectedWith(Errors.UserNotFoundError) - }) - - it('should return an error if user cannot be removed as collaborator ', async function () { - this.CollaboratorsHandler.promises.removeUserFromProject.rejects( - new Error('user-cannot-be-removed') - ) - await expect( - this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id - ) - ).to.be.rejected - }) - - it('should transfer ownership of the project', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id - ) - expect(this.ProjectModel.updateOne).to.have.been.calledWith( - { _id: this.project._id }, - sinon.match({ $set: { owner_ref: this.collaborator._id } }) - ) - }) - - it('should transfer ownership of the project to a read-only collaborator', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.readOnlyCollaborator._id - ) - expect(this.ProjectModel.updateOne).to.have.been.calledWith( - { _id: this.project._id }, - sinon.match({ $set: { owner_ref: this.readOnlyCollaborator._id } }) - ) - }) - - it('gives old owner read-only permissions if new owner was previously a viewer', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.readOnlyCollaborator._id - ) - expect( - this.CollaboratorsHandler.promises.addUserIdToProject - ).to.have.been.calledWith( - this.project._id, - this.readOnlyCollaborator._id, - this.user._id, - PrivilegeLevels.READ_ONLY - ) - }) - - it('should do nothing if transferring back to the owner', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.user._id - ) - expect(this.ProjectModel.updateOne).not.to.have.been.called - }) - - it("should remove the user from the project's collaborators", async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id - ) - expect( - this.CollaboratorsHandler.promises.removeUserFromProject - ).to.have.been.calledWith(this.project._id, this.collaborator._id) - }) - - it('should add the former project owner as a read/write collaborator', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id - ) - expect( - this.CollaboratorsHandler.promises.addUserIdToProject - ).to.have.been.calledWith( - this.project._id, - this.collaborator._id, - this.user._id, - PrivilegeLevels.READ_AND_WRITE - ) - }) - - it('should transfer ownership of the project to a reviewer', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.reviewer._id - ) - expect(this.ProjectModel.updateOne).to.have.been.calledWith( - { _id: this.project._id }, - sinon.match({ $set: { owner_ref: this.reviewer._id } }) - ) - }) - - it('gives old owner reviewer permissions if new owner was previously a reviewer', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.reviewer._id - ) - expect( - this.CollaboratorsHandler.promises.addUserIdToProject - ).to.have.been.calledWith( - this.project._id, - this.reviewer._id, - this.user._id, - PrivilegeLevels.REVIEW - ) - }) - - it('should flush the project to tpds', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id - ) - expect( - this.TpdsProjectFlusher.promises.flushProjectToTpds - ).to.have.been.calledWith(this.project._id) - }) - - it('should send an email notification', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id - ) - expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith( - 'ownershipTransferConfirmationPreviousOwner', - { - to: this.user.email, - project: this.project, - newOwner: this.collaborator, - } - ) - expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith( - 'ownershipTransferConfirmationNewOwner', - { - to: this.collaborator.email, - project: this.project, - previousOwner: this.user, - } - ) - }) - - it('should not send an email notification with the skipEmails option', async function () { - await this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id, - { skipEmails: true } - ) - expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called - }) - - it('should track the change in BigQuery', async function () { - const sessionUserId = new ObjectId() - await this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id, - { sessionUserId } - ) - expect(this.recordEventForUserInBackground).to.have.been.calledWith( - this.user._id, - 'project-ownership-transfer', - { - projectId: this.project._id, - newOwnerId: this.collaborator._id, - } - ) - }) - - it('should write an entry in the audit log', async function () { - const sessionUserId = new ObjectId() - const ipAddress = '1.2.3.4' - await this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id, - { sessionUserId, ipAddress } - ) - expect( - this.ProjectAuditLogHandler.promises.addEntry - ).to.have.been.calledWith( - this.project._id, - 'transfer-ownership', - sessionUserId, - ipAddress, - { - previousOwnerId: this.user._id, - newOwnerId: this.collaborator._id, - } - ) - }) - - it('should decline to transfer ownership to a non-collaborator', async function () { - this.project.collaberator_refs = [] - this.project.readOnly_refs = [] - await expect( - this.handler.promises.transferOwnership( - this.project._id, - this.collaborator._id - ) - ).to.be.rejectedWith(Errors.UserNotCollaboratorError) - }) - }) - - describe('transferAllProjectsToUser', function () { - const fromUserEmail = 'user.one@example.com' - const ipAddress = '1.2.3.4' - let fromUserId, toUserId - beforeEach(function () { - fromUserId = new ObjectId().toString() - toUserId = new ObjectId().toString() - }) - - describe('with missing user', function () { - it('should throw an error', async function () { - this.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null) - this.UserGetter.promises.getUser - .withArgs(toUserId) - .resolves({ _id: new ObjectId(toUserId) }) - await expect( - this.handler.promises.transferAllProjectsToUser({ - toUserId, - fromUserId, - ipAddress, - }) - ).to.be.rejectedWith(/missing source user/) - - this.UserGetter.promises.getUser - .withArgs(fromUserId) - .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail }) - this.UserGetter.promises.getUser.withArgs(toUserId).resolves(null) - await expect( - this.handler.promises.transferAllProjectsToUser({ - fromUserId, - toUserId, - ipAddress, - }) - ).to.be.rejectedWith(/missing destination user/) - }) - }) - - describe('with the same id', function () { - it('should throw an error', async function () { - this.UserGetter.promises.getUser - .withArgs(fromUserId) - .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail }) - await expect( - this.handler.promises.transferAllProjectsToUser({ - fromUserId, - toUserId: fromUserId, - ipAddress, - }) - ).to.be.rejectedWith(/rejecting transfer between identical users/) - }) - }) - - describe('happy path', function () { - let tag, fromUserEmail, projects - - beforeEach(function () { - tag = { - _id: new ObjectId(), - name: 'some-tag-name', - } - projects = [ - { _id: 'project-1' }, - { _id: 'project-2' }, - { _id: 'project-3' }, - ] - - this.UserGetter.promises.getUser.withArgs(fromUserId).resolves({ - _id: new ObjectId(fromUserId), - email: fromUserEmail, - }) - this.UserGetter.promises.getUser.withArgs(toUserId).resolves({ - _id: new ObjectId(toUserId), - }) - this.ProjectModel.find.resolves(projects) - this.TagsHandler.promises.createTag.resolves({ - _id: tag._id, - name: 'some-tag-name', - }) - this.TagsHandler.promises.addProjectsToTag.resolves() - }) - - it('creates a tag', async function () { - await this.handler.promises.transferAllProjectsToUser({ - fromUserId, - toUserId, - ipAddress, - }) - expect(this.TagsHandler.promises.createTag).to.have.been.calledWith( - toUserId, - `transferred-from-${fromUserEmail}`, - '#434AF0', - { truncate: true } - ) - }) - - it('returns a projectCount, and tag name', async function () { - const result = await this.handler.promises.transferAllProjectsToUser({ - fromUserId, - toUserId, - ipAddress, - }) - expect(result.projectCount).to.equal(projects.length) - expect(result.newTagName).to.equal('some-tag-name') - }) - - it('gets the user records', async function () { - await this.handler.promises.transferAllProjectsToUser({ - fromUserId, - toUserId, - ipAddress, - }) - expect(this.UserGetter.promises.getUser).to.have.been.calledWith( - fromUserId - ) - expect(this.UserGetter.promises.getUser).to.have.been.calledWith( - toUserId - ) - }) - - it('gets the list of affected projects', async function () { - await this.handler.promises.transferAllProjectsToUser({ - fromUserId, - toUserId, - ipAddress, - }) - expect(this.ProjectModel.find).to.have.been.calledWith({ - owner_ref: fromUserId, - }) - }) - - it('transfers all of the projects', async function () { - await this.handler.promises.transferAllProjectsToUser({ - fromUserId, - toUserId, - ipAddress, - }) - - expect(this.ProjectModel.updateOne.callCount).to.equal(3) - expect(this.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1) - - for (const project of projects) { - expect(this.ProjectModel.updateOne).to.have.been.calledWith( - { _id: project._id }, - sinon.match({ $set: { owner_ref: toUserId } }) - ) - } - expect( - this.TagsHandler.promises.addProjectsToTag - ).to.have.been.calledWith( - toUserId, - tag._id, - projects.map(p => p._id) - ) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/Editor/EditorHttpController.test.mjs b/services/web/test/unit/src/Editor/EditorHttpController.test.mjs new file mode 100644 index 0000000000..5e6662deaf --- /dev/null +++ b/services/web/test/unit/src/Editor/EditorHttpController.test.mjs @@ -0,0 +1,735 @@ +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import MockRequest from '../helpers/MockRequest.js' +import MockResponse from '../helpers/MockResponse.js' + +const { ObjectId } = mongodb + +const MODULE_PATH = + '../../../../app/src/Features/Editor/EditorHttpController.mjs' + +describe('EditorHttpController', function () { + beforeEach(async function (ctx) { + ctx.ownerId = new ObjectId() + ctx.project = { + _id: new ObjectId(), + owner_ref: ctx.ownerId, + } + ctx.user = { + _id: new ObjectId(), + projects: {}, + } + ctx.members = [ + { user: { _id: 'owner', features: {} }, privilegeLevel: 'owner' }, + { user: { _id: 'one' }, privilegeLevel: 'readOnly' }, + ] + ctx.ownerMember = ctx.members[0] + ctx.invites = [{ _id: 'three' }, { _id: 'four' }] + ctx.projectView = { + _id: ctx.project._id, + owner: { + _id: 'owner', + email: 'owner@example.com', + other_property: true, + }, + members: [ + { _id: 'owner', privileges: 'owner' }, + { _id: 'one', privileges: 'readOnly' }, + ], + invites: [{ three: 3 }, { four: 4 }], + } + ctx.reducedProjectView = { + _id: ctx.projectView._id, + owner: { _id: ctx.projectView.owner._id }, + members: [], + invites: [], + } + ctx.doc = { _id: new ObjectId(), name: 'excellent-original-idea.tex' } + ctx.file = { _id: new ObjectId() } + ctx.folder = { _id: new ObjectId() } + ctx.source = 'editor' + + ctx.parentFolderId = 'mock-folder-id' + ctx.req = new MockRequest() + ctx.res = new MockResponse() + ctx.next = sinon.stub() + ctx.token = null + ctx.docLines = ['hello', 'overleaf'] + + ctx.AuthorizationManager = { + isRestrictedUser: sinon.stub().returns(false), + promises: { + getPrivilegeLevelForProjectWithProjectAccess: sinon + .stub() + .resolves('owner'), + }, + } + const members = ctx.members + const ownerMember = ctx.ownerMember + ctx.CollaboratorsGetter = { + ProjectAccess: class { + loadOwnerAndInvitedMembers() { + return { members, ownerMember } + } + + loadOwner() { + return ownerMember + } + + isUserTokenMember() { + return false + } + + isUserInvitedMember() { + return false + } + }, + promises: { + isUserInvitedMemberOfProject: sinon.stub().resolves(false), + }, + } + ctx.CollaboratorsHandler = { + promises: { + userIsTokenMember: sinon.stub().resolves(false), + }, + } + ctx.invites = [ + { + _id: 'invite_one', + email: 'user-one@example.com', + privileges: 'readOnly', + projectId: ctx.project._id, + }, + { + _id: 'invite_two', + email: 'user-two@example.com', + privileges: 'readOnly', + projectId: ctx.project._id, + }, + ] + ctx.CollaboratorsInviteGetter = { + promises: { + getAllInvites: sinon.stub().resolves(ctx.invites), + }, + } + ctx.EditorController = { + promises: { + addDoc: sinon.stub().resolves(ctx.doc), + addFile: sinon.stub().resolves(ctx.file), + addFolder: sinon.stub().resolves(ctx.folder), + renameEntity: sinon.stub().resolves(), + moveEntity: sinon.stub().resolves(), + deleteEntity: sinon.stub().resolves(), + }, + } + ctx.ProjectDeleter = { + promises: { + unmarkAsDeletedByExternalSource: sinon.stub().resolves(), + }, + } + ctx.ProjectGetter = { + promises: { + getProjectWithoutDocLines: sinon.stub().resolves(ctx.project), + }, + } + ctx.ProjectEditorHandler = { + buildProjectModelView: sinon.stub().returns(ctx.projectView), + } + ctx.Metrics = { inc: sinon.stub() } + ctx.TokenAccessHandler = { + getRequestToken: sinon.stub().returns(ctx.token), + } + ctx.SessionManager = { + getLoggedInUserId: sinon.stub().returns(ctx.user._id), + } + ctx.ProjectEntityUpdateHandler = { + promises: { + convertDocToFile: sinon.stub().resolves(ctx.file), + }, + } + ctx.DocstoreManager = { + promises: { + getAllDeletedDocs: sinon.stub().resolves([]), + }, + } + ctx.HttpErrorHandler = { + notFound: sinon.stub(), + unprocessableEntity: sinon.stub(), + } + ctx.SplitTestHandler = { + promises: { + getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), + }, + } + ctx.UserGetter = { promises: { getUser: sinon.stub().resolves(null, {}) } } + + vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') + ) + vi.doMock('../../../../app/src/Features/Project/ProjectDeleter.js', () => ({ + default: ctx.ProjectDeleter, + })) + vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({ + default: ctx.ProjectGetter, + })) + vi.doMock( + '../../../../app/src/Features/Authorization/AuthorizationManager.js', + () => ({ + default: ctx.AuthorizationManager, + }) + ) + vi.doMock( + '../../../../app/src/Features/Project/ProjectEditorHandler.js', + () => ({ + default: ctx.ProjectEditorHandler, + }) + ) + vi.doMock( + '../../../../app/src/Features/Editor/EditorController.js', + () => ({ + default: ctx.EditorController, + }) + ) + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.Metrics, + })) + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsGetter.js', + () => ({ + default: ctx.CollaboratorsGetter, + }) + ) + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js', + () => ({ + default: ctx.CollaboratorsHandler, + }) + ) + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.js', + () => ({ + default: ctx.CollaboratorsInviteGetter, + }) + ) + vi.doMock( + '../../../../app/src/Features/TokenAccess/TokenAccessHandler.js', + () => ({ + default: ctx.TokenAccessHandler, + }) + ) + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager.js', + () => ({ + default: ctx.SessionManager, + }) + ) + vi.doMock('../../../../app/src/infrastructure/FileWriter.js', () => ({ + default: ctx.FileWriter, + })) + vi.doMock( + '../../../../app/src/Features/Project/ProjectEntityUpdateHandler.js', + () => ({ + default: ctx.ProjectEntityUpdateHandler, + }) + ) + vi.doMock( + '../../../../app/src/Features/Docstore/DocstoreManager.js', + () => ({ + default: ctx.DocstoreManager, + }) + ) + vi.doMock( + '../../../../app/src/Features/Errors/HttpErrorHandler.js', + () => ({ + default: ctx.HttpErrorHandler, + }) + ) + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler.js', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + vi.doMock('../../../../app/src/Features/Compile/CompileManager.js', () => ({ + default: {}, + })) + vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({ + default: ctx.UserGetter, + })) + + ctx.EditorHttpController = (await import(MODULE_PATH)).default + }) + + describe('joinProject', function () { + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.project._id } + ctx.req.query = { user_id: ctx.user._id } + ctx.req.body = { userId: ctx.user._id } + }) + + describe('successfully', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + sinon + .stub( + ctx.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserInvitedMember' + ) + .returns(true) + ctx.res.callback = resolve + ctx.EditorHttpController.joinProject(ctx.req, ctx.res) + }) + }) + + it('should request a full view', function (ctx) { + expect( + ctx.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith( + ctx.project, + ctx.ownerMember, + ctx.members, + ctx.invites, + false + ) + }) + + it('should return the project and privilege level', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith({ + project: ctx.projectView, + privilegeLevel: 'owner', + isRestrictedUser: false, + isTokenMember: false, + isInvitedMember: true, + }) + }) + + it('should not try to unmark the project as deleted', function (ctx) { + expect(ctx.ProjectDeleter.promises.unmarkAsDeletedByExternalSource).not + .to.have.been.called + }) + + it('should send an inc metric', function (ctx) { + expect(ctx.Metrics.inc).to.have.been.calledWith('editor.join-project') + }) + }) + + describe('when the project is marked as deleted', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.projectView.deletedByExternalDataSource = true + ctx.res.callback = resolve + ctx.EditorHttpController.joinProject(ctx.req, ctx.res) + }) + }) + + it('should unmark the project as deleted', function (ctx) { + expect( + ctx.ProjectDeleter.promises.unmarkAsDeletedByExternalSource + ).to.have.been.calledWith(ctx.project._id) + }) + }) + + describe('with a restricted user', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.ProjectEditorHandler.buildProjectModelView.returns( + ctx.reducedProjectView + ) + ctx.AuthorizationManager.isRestrictedUser.returns(true) + ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( + 'readOnly' + ) + ctx.res.callback = resolve + ctx.EditorHttpController.joinProject(ctx.req, ctx.res) + }) + }) + + it('should request a restricted view', function (ctx) { + expect( + ctx.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith(ctx.project, ctx.ownerMember, [], [], true) + }) + + it('should mark the user as restricted, and hide details of owner', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith({ + project: ctx.reducedProjectView, + privilegeLevel: 'readOnly', + isRestrictedUser: true, + isTokenMember: false, + isInvitedMember: false, + }) + }) + }) + + describe('when not authorized', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( + null + ) + ctx.res.callback = resolve + ctx.EditorHttpController.joinProject(ctx.req, ctx.res) + }) + }) + + it('should send a 403 response', function (ctx) { + expect(ctx.res.statusCode).to.equal(403) + }) + }) + + describe('with an anonymous user', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.token = 'token' + ctx.TokenAccessHandler.getRequestToken.returns(ctx.token) + ctx.ProjectEditorHandler.buildProjectModelView.returns( + ctx.reducedProjectView + ) + ctx.req.body = { + userId: 'anonymous-user', + anonymousAccessToken: ctx.token, + } + ctx.res.callback = resolve + ctx.AuthorizationManager.isRestrictedUser + .withArgs(null, 'readOnly', false, false) + .returns(true) + ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess + .withArgs(null, ctx.project._id, ctx.token) + .resolves('readOnly') + ctx.EditorHttpController.joinProject(ctx.req, ctx.res) + }) + }) + + it('should request a restricted view', function (ctx) { + expect( + ctx.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith(ctx.project, ctx.ownerMember, [], [], true) + }) + + it('should mark the user as restricted', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith({ + project: ctx.reducedProjectView, + privilegeLevel: 'readOnly', + isRestrictedUser: true, + isTokenMember: false, + isInvitedMember: false, + }) + }) + }) + + describe('with a token access user', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + sinon + .stub( + ctx.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserInvitedMember' + ) + .returns(false) + sinon + .stub( + ctx.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserTokenMember' + ) + .returns(true) + ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( + 'readAndWrite' + ) + ctx.res.callback = resolve + ctx.EditorHttpController.joinProject(ctx.req, ctx.res) + }) + }) + + it('should mark the user as being a token-access member', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith({ + project: ctx.projectView, + privilegeLevel: 'readAndWrite', + isRestrictedUser: false, + isTokenMember: true, + isInvitedMember: false, + }) + }) + }) + + describe('when project is not found', function () { + beforeEach(async function (ctx) { + ctx.ProjectGetter.promises.getProjectWithoutDocLines.resolves(null) + await new Promise(resolve => { + ctx.next.callsFake(() => resolve()) + ctx.EditorHttpController.joinProject(ctx.req, ctx.res, ctx.next) + }) + }) + + it('should handle return not found error', function (ctx) { + expect(ctx.next).to.have.been.calledWith( + sinon.match.instanceOf(Errors.NotFoundError) + ) + }) + }) + }) + + describe('addDoc', function () { + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.project._id } + ctx.req.body = { + name: (ctx.docName = 'doc-name'), + parent_folder_id: ctx.parentFolderId, + } + }) + + describe('successfully', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.res.callback = resolve + ctx.EditorHttpController.addDoc(ctx.req, ctx.res) + }) + }) + + it('should call EditorController.addDoc', function (ctx) { + expect(ctx.EditorController.promises.addDoc).to.have.been.calledWith( + ctx.project._id, + ctx.parentFolderId, + ctx.docName, + [], + 'editor', + ctx.user._id + ) + }) + + it('should send the doc back as JSON', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith(ctx.doc) + }) + }) + + describe('unsuccesfully', function () { + it('handle name too short', async function (ctx) { + await new Promise(resolve => { + ctx.req.body.name = '' + ctx.res.callback = () => { + expect(ctx.res.statusCode).to.equal(400) + resolve() + } + ctx.EditorHttpController.addDoc(ctx.req, ctx.res) + }) + }) + + it('handle too many files', async function (ctx) { + ctx.EditorController.promises.addDoc.rejects( + new Error('project_has_too_many_files') + ) + await new Promise(resolve => { + ctx.res.callback = () => { + expect(ctx.res.body).to.equal('"project_has_too_many_files"') + expect(ctx.res.status).to.have.been.calledWith(400) + resolve() + } + ctx.EditorHttpController.addDoc(ctx.req, ctx.res) + }) + }) + }) + }) + + describe('addFolder', function () { + beforeEach(function (ctx) { + ctx.folderName = 'folder-name' + ctx.req.params = { Project_id: ctx.project._id } + ctx.req.body = { + name: ctx.folderName, + parent_folder_id: ctx.parentFolderId, + } + }) + + describe('successfully', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.res.callback = resolve + ctx.EditorHttpController.addFolder(ctx.req, ctx.res) + }) + }) + + it('should call EditorController.addFolder', function (ctx) { + expect(ctx.EditorController.promises.addFolder).to.have.been.calledWith( + ctx.project._id, + ctx.parentFolderId, + ctx.folderName, + 'editor' + ) + }) + + it('should send the folder back as JSON', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith(ctx.folder) + }) + }) + + describe('unsuccesfully', function () { + it('handle name too short', async function (ctx) { + await new Promise(resolve => { + ctx.req.body.name = '' + ctx.res.callback = () => { + expect(ctx.res.statusCode).to.equal(400) + resolve() + } + ctx.EditorHttpController.addFolder(ctx.req, ctx.res) + }) + }) + + it('handle too many files', async function (ctx) { + await new Promise(resolve => { + ctx.EditorController.promises.addFolder.rejects( + new Error('project_has_too_many_files') + ) + ctx.res.callback = () => { + expect(ctx.res.body).to.equal('"project_has_too_many_files"') + expect(ctx.res.statusCode).to.equal(400) + resolve() + } + ctx.EditorHttpController.addFolder(ctx.req, ctx.res) + }) + }) + + it('handle invalid element name', async function (ctx) { + await new Promise(resolve => { + ctx.EditorController.promises.addFolder.rejects( + new Error('invalid element name') + ) + ctx.res.callback = () => { + expect(ctx.res.body).to.equal('"invalid_file_name"') + expect(ctx.res.statusCode).to.equal(400) + resolve() + } + ctx.EditorHttpController.addFolder(ctx.req, ctx.res) + }) + }) + }) + }) + + describe('renameEntity', function () { + beforeEach(function (ctx) { + ctx.entityId = 'entity-id-123' + ctx.entityType = 'entity-type' + ctx.req.params = { + Project_id: ctx.project._id, + entity_id: ctx.entityId, + entity_type: ctx.entityType, + } + }) + + describe('successfully', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.newName = 'new-name' + ctx.req.body = { name: ctx.newName, source: ctx.source } + ctx.res.callback = resolve + ctx.EditorHttpController.renameEntity(ctx.req, ctx.res) + }) + }) + + it('should call EditorController.renameEntity', function (ctx) { + expect( + ctx.EditorController.promises.renameEntity + ).to.have.been.calledWith( + ctx.project._id, + ctx.entityId, + ctx.entityType, + ctx.newName, + ctx.user._id, + ctx.source + ) + }) + + it('should send back a success response', function (ctx) { + expect(ctx.res.sendStatus).to.have.been.calledWith(204) + }) + }) + describe('with long name', function () { + beforeEach(function (ctx) { + ctx.newName = 'long'.repeat(100) + ctx.req.body = { name: ctx.newName, source: ctx.source } + ctx.EditorHttpController.renameEntity(ctx.req, ctx.res) + }) + + it('should send back a bad request status code', function (ctx) { + expect(ctx.res.statusCode).to.equal(400) + }) + }) + + describe('with 0 length name', function () { + beforeEach(function (ctx) { + ctx.newName = '' + ctx.req.body = { name: ctx.newName, source: ctx.source } + ctx.EditorHttpController.renameEntity(ctx.req, ctx.res) + }) + + it('should send back a bad request status code', function (ctx) { + expect(ctx.res.statusCode).to.equal(400) + }) + }) + }) + + describe('moveEntity', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.entityId = 'entity-id-123' + ctx.entityType = 'entity-type' + ctx.folderId = 'folder-id-123' + ctx.req.params = { + Project_id: ctx.project._id, + entity_id: ctx.entityId, + entity_type: ctx.entityType, + } + ctx.req.body = { folder_id: ctx.folderId, source: ctx.source } + ctx.res.callback = resolve + ctx.EditorHttpController.moveEntity(ctx.req, ctx.res) + }) + }) + + it('should call EditorController.moveEntity', function (ctx) { + expect(ctx.EditorController.promises.moveEntity).to.have.been.calledWith( + ctx.project._id, + ctx.entityId, + ctx.folderId, + ctx.entityType, + ctx.user._id, + ctx.source + ) + }) + + it('should send back a success response', function (ctx) { + expect(ctx.res.statusCode).to.equal(204) + }) + }) + + describe('deleteEntity', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.entityId = 'entity-id-123' + ctx.entityType = 'entity-type' + ctx.req.params = { + Project_id: ctx.project._id, + entity_id: ctx.entityId, + entity_type: ctx.entityType, + } + ctx.res.callback = resolve + ctx.EditorHttpController.deleteEntity(ctx.req, ctx.res) + }) + }) + + it('should call EditorController.deleteEntity', function (ctx) { + expect( + ctx.EditorController.promises.deleteEntity + ).to.have.been.calledWith( + ctx.project._id, + ctx.entityId, + ctx.entityType, + 'editor', + ctx.user._id + ) + }) + + it('should send back a success response', function (ctx) { + expect(ctx.res.statusCode).to.equal(204) + }) + }) +}) diff --git a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js deleted file mode 100644 index 7fc08c45d3..0000000000 --- a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js +++ /dev/null @@ -1,630 +0,0 @@ -/* eslint-disable mocha/handle-done-callback */ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { expect } = require('chai') -const { ObjectId } = require('mongodb-legacy') -const Errors = require('../../../../app/src/Features/Errors/Errors') -const MockRequest = require('../helpers/MockRequest') -const MockResponse = require('../helpers/MockResponse') - -const MODULE_PATH = '../../../../app/src/Features/Editor/EditorHttpController' - -describe('EditorHttpController', function () { - beforeEach(function () { - this.ownerId = new ObjectId() - this.project = { - _id: new ObjectId(), - owner_ref: this.ownerId, - } - this.user = { - _id: new ObjectId(), - projects: {}, - } - this.members = [ - { user: { _id: 'owner', features: {} }, privilegeLevel: 'owner' }, - { user: { _id: 'one' }, privilegeLevel: 'readOnly' }, - ] - this.ownerMember = this.members[0] - this.invites = [{ _id: 'three' }, { _id: 'four' }] - this.projectView = { - _id: this.project._id, - owner: { - _id: 'owner', - email: 'owner@example.com', - other_property: true, - }, - members: [ - { _id: 'owner', privileges: 'owner' }, - { _id: 'one', privileges: 'readOnly' }, - ], - invites: [{ three: 3 }, { four: 4 }], - } - this.reducedProjectView = { - _id: this.projectView._id, - owner: { _id: this.projectView.owner._id }, - members: [], - invites: [], - } - this.doc = { _id: new ObjectId(), name: 'excellent-original-idea.tex' } - this.file = { _id: new ObjectId() } - this.folder = { _id: new ObjectId() } - this.source = 'editor' - - this.parentFolderId = 'mock-folder-id' - this.req = new MockRequest() - this.res = new MockResponse() - this.next = sinon.stub() - this.token = null - this.docLines = ['hello', 'overleaf'] - - this.AuthorizationManager = { - isRestrictedUser: sinon.stub().returns(false), - promises: { - getPrivilegeLevelForProjectWithProjectAccess: sinon - .stub() - .resolves('owner'), - }, - } - const members = this.members - const ownerMember = this.ownerMember - this.CollaboratorsGetter = { - ProjectAccess: class { - loadOwnerAndInvitedMembers() { - return { members, ownerMember } - } - - loadOwner() { - return ownerMember - } - - isUserTokenMember() { - return false - } - - isUserInvitedMember() { - return false - } - }, - promises: { - isUserInvitedMemberOfProject: sinon.stub().resolves(false), - }, - } - this.CollaboratorsHandler = { - promises: { - userIsTokenMember: sinon.stub().resolves(false), - }, - } - this.invites = [ - { - _id: 'invite_one', - email: 'user-one@example.com', - privileges: 'readOnly', - projectId: this.project._id, - }, - { - _id: 'invite_two', - email: 'user-two@example.com', - privileges: 'readOnly', - projectId: this.project._id, - }, - ] - this.CollaboratorsInviteGetter = { - promises: { - getAllInvites: sinon.stub().resolves(this.invites), - }, - } - this.EditorController = { - promises: { - addDoc: sinon.stub().resolves(this.doc), - addFile: sinon.stub().resolves(this.file), - addFolder: sinon.stub().resolves(this.folder), - renameEntity: sinon.stub().resolves(), - moveEntity: sinon.stub().resolves(), - deleteEntity: sinon.stub().resolves(), - }, - } - this.ProjectDeleter = { - promises: { - unmarkAsDeletedByExternalSource: sinon.stub().resolves(), - }, - } - this.ProjectGetter = { - promises: { - getProjectWithoutDocLines: sinon.stub().resolves(this.project), - }, - } - this.ProjectEditorHandler = { - buildProjectModelView: sinon.stub().returns(this.projectView), - } - this.Metrics = { inc: sinon.stub() } - this.TokenAccessHandler = { - getRequestToken: sinon.stub().returns(this.token), - } - this.SessionManager = { - getLoggedInUserId: sinon.stub().returns(this.user._id), - } - this.ProjectEntityUpdateHandler = { - promises: { - convertDocToFile: sinon.stub().resolves(this.file), - }, - } - this.DocstoreManager = { - promises: { - getAllDeletedDocs: sinon.stub().resolves([]), - }, - } - this.HttpErrorHandler = { - notFound: sinon.stub(), - unprocessableEntity: sinon.stub(), - } - this.SplitTestHandler = { - promises: { - getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), - }, - } - this.UserGetter = { promises: { getUser: sinon.stub().resolves(null, {}) } } - this.EditorHttpController = SandboxedModule.require(MODULE_PATH, { - requires: { - '../Project/ProjectDeleter': this.ProjectDeleter, - '../Project/ProjectGetter': this.ProjectGetter, - '../Authorization/AuthorizationManager': this.AuthorizationManager, - '../Project/ProjectEditorHandler': this.ProjectEditorHandler, - './EditorController': this.EditorController, - '@overleaf/metrics': this.Metrics, - '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter, - '../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler, - '../Collaborators/CollaboratorsInviteGetter': - this.CollaboratorsInviteGetter, - '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler, - '../Authentication/SessionManager': this.SessionManager, - '../../infrastructure/FileWriter': this.FileWriter, - '../Project/ProjectEntityUpdateHandler': - this.ProjectEntityUpdateHandler, - '../Docstore/DocstoreManager': this.DocstoreManager, - '../Errors/HttpErrorHandler': this.HttpErrorHandler, - '../SplitTests/SplitTestHandler': this.SplitTestHandler, - '../Compile/CompileManager': {}, - '../User/UserGetter': this.UserGetter, - }, - }) - }) - - describe('joinProject', function () { - beforeEach(function () { - this.req.params = { Project_id: this.project._id } - this.req.query = { user_id: this.user._id } - this.req.body = { userId: this.user._id } - }) - - describe('successfully', function () { - beforeEach(function (done) { - sinon - .stub( - this.CollaboratorsGetter.ProjectAccess.prototype, - 'isUserInvitedMember' - ) - .returns(true) - this.res.callback = done - this.EditorHttpController.joinProject(this.req, this.res) - }) - - it('should request a full view', function () { - expect( - this.ProjectEditorHandler.buildProjectModelView - ).to.have.been.calledWith( - this.project, - this.ownerMember, - this.members, - this.invites, - false - ) - }) - - it('should return the project and privilege level', function () { - expect(this.res.json).to.have.been.calledWith({ - project: this.projectView, - privilegeLevel: 'owner', - isRestrictedUser: false, - isTokenMember: false, - isInvitedMember: true, - }) - }) - - it('should not try to unmark the project as deleted', function () { - expect(this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource).not - .to.have.been.called - }) - - it('should send an inc metric', function () { - expect(this.Metrics.inc).to.have.been.calledWith('editor.join-project') - }) - }) - - describe('when the project is marked as deleted', function () { - beforeEach(function (done) { - this.projectView.deletedByExternalDataSource = true - this.res.callback = done - this.EditorHttpController.joinProject(this.req, this.res) - }) - - it('should unmark the project as deleted', function () { - expect( - this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource - ).to.have.been.calledWith(this.project._id) - }) - }) - - describe('with a restricted user', function () { - beforeEach(function (done) { - this.ProjectEditorHandler.buildProjectModelView.returns( - this.reducedProjectView - ) - this.AuthorizationManager.isRestrictedUser.returns(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( - 'readOnly' - ) - this.res.callback = done - this.EditorHttpController.joinProject(this.req, this.res) - }) - - it('should request a restricted view', function () { - expect( - this.ProjectEditorHandler.buildProjectModelView - ).to.have.been.calledWith(this.project, this.ownerMember, [], [], true) - }) - - it('should mark the user as restricted, and hide details of owner', function () { - expect(this.res.json).to.have.been.calledWith({ - project: this.reducedProjectView, - privilegeLevel: 'readOnly', - isRestrictedUser: true, - isTokenMember: false, - isInvitedMember: false, - }) - }) - }) - - describe('when not authorized', function () { - beforeEach(function (done) { - this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( - null - ) - this.res.callback = done - this.EditorHttpController.joinProject(this.req, this.res) - }) - - it('should send a 403 response', function () { - expect(this.res.statusCode).to.equal(403) - }) - }) - - describe('with an anonymous user', function () { - beforeEach(function (done) { - this.token = 'token' - this.TokenAccessHandler.getRequestToken.returns(this.token) - this.ProjectEditorHandler.buildProjectModelView.returns( - this.reducedProjectView - ) - this.req.body = { - userId: 'anonymous-user', - anonymousAccessToken: this.token, - } - this.res.callback = done - this.AuthorizationManager.isRestrictedUser - .withArgs(null, 'readOnly', false, false) - .returns(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess - .withArgs(null, this.project._id, this.token) - .resolves('readOnly') - this.EditorHttpController.joinProject(this.req, this.res) - }) - - it('should request a restricted view', function () { - expect( - this.ProjectEditorHandler.buildProjectModelView - ).to.have.been.calledWith(this.project, this.ownerMember, [], [], true) - }) - - it('should mark the user as restricted', function () { - expect(this.res.json).to.have.been.calledWith({ - project: this.reducedProjectView, - privilegeLevel: 'readOnly', - isRestrictedUser: true, - isTokenMember: false, - isInvitedMember: false, - }) - }) - }) - - describe('with a token access user', function () { - beforeEach(function (done) { - sinon - .stub( - this.CollaboratorsGetter.ProjectAccess.prototype, - 'isUserInvitedMember' - ) - .returns(false) - sinon - .stub( - this.CollaboratorsGetter.ProjectAccess.prototype, - 'isUserTokenMember' - ) - .returns(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( - 'readAndWrite' - ) - this.res.callback = done - this.EditorHttpController.joinProject(this.req, this.res) - }) - - it('should mark the user as being a token-access member', function () { - expect(this.res.json).to.have.been.calledWith({ - project: this.projectView, - privilegeLevel: 'readAndWrite', - isRestrictedUser: false, - isTokenMember: true, - isInvitedMember: false, - }) - }) - }) - - describe('when project is not found', function () { - beforeEach(function (done) { - this.ProjectGetter.promises.getProjectWithoutDocLines.resolves(null) - this.next.callsFake(() => done()) - this.EditorHttpController.joinProject(this.req, this.res, this.next) - }) - - it('should handle return not found error', function () { - expect(this.next).to.have.been.calledWith( - sinon.match.instanceOf(Errors.NotFoundError) - ) - }) - }) - }) - - describe('addDoc', function () { - beforeEach(function () { - this.req.params = { Project_id: this.project._id } - this.req.body = { - name: (this.name = 'doc-name'), - parent_folder_id: this.parentFolderId, - } - }) - - describe('successfully', function () { - beforeEach(function (done) { - this.res.callback = done - this.EditorHttpController.addDoc(this.req, this.res) - }) - - it('should call EditorController.addDoc', function () { - expect(this.EditorController.promises.addDoc).to.have.been.calledWith( - this.project._id, - this.parentFolderId, - this.name, - [], - 'editor', - this.user._id - ) - }) - - it('should send the doc back as JSON', function () { - expect(this.res.json).to.have.been.calledWith(this.doc) - }) - }) - - describe('unsuccesfully', function () { - it('handle name too short', function (done) { - this.req.body.name = '' - this.res.callback = () => { - expect(this.res.statusCode).to.equal(400) - done() - } - this.EditorHttpController.addDoc(this.req, this.res) - }) - - it('handle too many files', function (done) { - this.EditorController.promises.addDoc.rejects( - new Error('project_has_too_many_files') - ) - this.res.callback = () => { - expect(this.res.body).to.equal('"project_has_too_many_files"') - expect(this.res.status).to.have.been.calledWith(400) - done() - } - this.EditorHttpController.addDoc(this.req, this.res) - }) - }) - }) - - describe('addFolder', function () { - beforeEach(function () { - this.folderName = 'folder-name' - this.req.params = { Project_id: this.project._id } - this.req.body = { - name: this.folderName, - parent_folder_id: this.parentFolderId, - } - }) - - describe('successfully', function () { - beforeEach(function (done) { - this.res.callback = done - this.EditorHttpController.addFolder(this.req, this.res) - }) - - it('should call EditorController.addFolder', function () { - expect( - this.EditorController.promises.addFolder - ).to.have.been.calledWith( - this.project._id, - this.parentFolderId, - this.folderName, - 'editor' - ) - }) - - it('should send the folder back as JSON', function () { - expect(this.res.json).to.have.been.calledWith(this.folder) - }) - }) - - describe('unsuccesfully', function () { - it('handle name too short', function (done) { - this.req.body.name = '' - this.res.callback = () => { - expect(this.res.statusCode).to.equal(400) - done() - } - this.EditorHttpController.addFolder(this.req, this.res) - }) - - it('handle too many files', function (done) { - this.EditorController.promises.addFolder.rejects( - new Error('project_has_too_many_files') - ) - this.res.callback = () => { - expect(this.res.body).to.equal('"project_has_too_many_files"') - expect(this.res.statusCode).to.equal(400) - done() - } - this.EditorHttpController.addFolder(this.req, this.res) - }) - - it('handle invalid element name', function (done) { - this.EditorController.promises.addFolder.rejects( - new Error('invalid element name') - ) - this.res.callback = () => { - expect(this.res.body).to.equal('"invalid_file_name"') - expect(this.res.statusCode).to.equal(400) - done() - } - this.EditorHttpController.addFolder(this.req, this.res) - }) - }) - }) - - describe('renameEntity', function () { - beforeEach(function () { - this.entityId = 'entity-id-123' - this.entityType = 'entity-type' - this.req.params = { - Project_id: this.project._id, - entity_id: this.entityId, - entity_type: this.entityType, - } - }) - - describe('successfully', function () { - beforeEach(function (done) { - this.newName = 'new-name' - this.req.body = { name: this.newName, source: this.source } - this.res.callback = done - this.EditorHttpController.renameEntity(this.req, this.res) - }) - - it('should call EditorController.renameEntity', function () { - expect( - this.EditorController.promises.renameEntity - ).to.have.been.calledWith( - this.project._id, - this.entityId, - this.entityType, - this.newName, - this.user._id, - this.source - ) - }) - - it('should send back a success response', function () { - expect(this.res.sendStatus).to.have.been.calledWith(204) - }) - }) - describe('with long name', function () { - beforeEach(function () { - this.newName = 'long'.repeat(100) - this.req.body = { name: this.newName, source: this.source } - this.EditorHttpController.renameEntity(this.req, this.res) - }) - - it('should send back a bad request status code', function () { - expect(this.res.statusCode).to.equal(400) - }) - }) - - describe('with 0 length name', function () { - beforeEach(function () { - this.newName = '' - this.req.body = { name: this.newName, source: this.source } - this.EditorHttpController.renameEntity(this.req, this.res) - }) - - it('should send back a bad request status code', function () { - expect(this.res.statusCode).to.equal(400) - }) - }) - }) - - describe('moveEntity', function () { - beforeEach(function (done) { - this.entityId = 'entity-id-123' - this.entityType = 'entity-type' - this.folderId = 'folder-id-123' - this.req.params = { - Project_id: this.project._id, - entity_id: this.entityId, - entity_type: this.entityType, - } - this.req.body = { folder_id: this.folderId, source: this.source } - this.res.callback = done - this.EditorHttpController.moveEntity(this.req, this.res) - }) - - it('should call EditorController.moveEntity', function () { - expect(this.EditorController.promises.moveEntity).to.have.been.calledWith( - this.project._id, - this.entityId, - this.folderId, - this.entityType, - this.user._id, - this.source - ) - }) - - it('should send back a success response', function () { - expect(this.res.statusCode).to.equal(204) - }) - }) - - describe('deleteEntity', function () { - beforeEach(function (done) { - this.entityId = 'entity-id-123' - this.entityType = 'entity-type' - this.req.params = { - Project_id: this.project._id, - entity_id: this.entityId, - entity_type: this.entityType, - } - this.res.callback = done - this.EditorHttpController.deleteEntity(this.req, this.res) - }) - - it('should call EditorController.deleteEntity', function () { - expect( - this.EditorController.promises.deleteEntity - ).to.have.been.calledWith( - this.project._id, - this.entityId, - this.entityType, - 'editor', - this.user._id - ) - }) - - it('should send back a success response', function () { - expect(this.res.statusCode).to.equal(204) - }) - }) -}) diff --git a/services/web/test/unit/src/History/HistoryController.test.mjs b/services/web/test/unit/src/History/HistoryController.test.mjs new file mode 100644 index 0000000000..39cd25f766 --- /dev/null +++ b/services/web/test/unit/src/History/HistoryController.test.mjs @@ -0,0 +1,297 @@ +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import { RequestFailedError } from '@overleaf/fetch-utils' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import mongodb from 'mongodb-legacy' + +const { ObjectId } = mongodb + +const modulePath = '../../../../app/src/Features/History/HistoryController.mjs' + +describe('HistoryController', function () { + beforeEach(async function (ctx) { + ctx.callback = sinon.stub() + ctx.user_id = 'user-id-123' + ctx.project_id = 'mock-project-id' + ctx.stream = sinon.stub() + ctx.fetchResponse = { + headers: { + get: sinon.stub(), + }, + } + ctx.next = sinon.stub() + + ctx.SessionManager = { + getLoggedInUserId: sinon.stub().returns(ctx.user_id), + } + + ctx.Stream = { + pipeline: sinon.stub().resolves(), + } + + ctx.HistoryManager = { + promises: { + injectUserDetails: sinon.stub(), + }, + } + + ctx.ProjectEntityUpdateHandler = { + promises: { + resyncProjectHistory: sinon.stub().resolves(), + }, + } + + ctx.fetchJson = sinon.stub() + ctx.fetchStream = sinon.stub().resolves(ctx.stream) + ctx.fetchStreamWithResponse = sinon + .stub() + .resolves({ stream: ctx.stream, response: ctx.fetchResponse }) + ctx.fetchNothing = sinon.stub().resolves() + + vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') + ) + + vi.doMock('stream/promises', () => ctx.Stream) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = {}), + })) + + vi.doMock('@overleaf/fetch-utils', () => ({ + fetchJson: ctx.fetchJson, + fetchStream: ctx.fetchStream, + fetchStreamWithResponse: ctx.fetchStreamWithResponse, + fetchNothing: ctx.fetchNothing, + })) + + vi.doMock('@overleaf/Metrics', () => ({ + default: {}, + })) + + vi.doMock('../../../../app/src/infrastructure/mongodb.js', () => ({ + default: { ObjectId }, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager.js', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock('../../../../app/src/Features/History/HistoryManager.js', () => ({ + default: ctx.HistoryManager, + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectDetailsHandler.js', + () => ({ + default: (ctx.ProjectDetailsHandler = {}), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectEntityUpdateHandler.js', + () => ({ + default: ctx.ProjectEntityUpdateHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({ + default: (ctx.UserGetter = {}), + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({ + default: (ctx.ProjectGetter = {}), + })) + + vi.doMock('../../../../app/src/Features/History/RestoreManager.js', () => ({ + default: (ctx.RestoreManager = {}), + })) + + vi.doMock('../../../../app/src/infrastructure/Features.js', () => ({ + default: (ctx.Features = sinon.stub().withArgs('saas').returns(true)), + })) + + ctx.HistoryController = (await import(modulePath)).default + ctx.settings.apis = { + project_history: { + url: 'http://project_history.example.com', + }, + } + }) + + describe('proxyToHistoryApi', function () { + beforeEach(async function (ctx) { + ctx.req = { url: '/mock/url', method: 'POST', session: sinon.stub() } + ctx.res = { + set: sinon.stub(), + } + ctx.contentType = 'application/json' + ctx.contentLength = 212 + ctx.fetchResponse.headers.get + .withArgs('Content-Type') + .returns(ctx.contentType) + ctx.fetchResponse.headers.get + .withArgs('Content-Length') + .returns(ctx.contentLength) + await ctx.HistoryController.proxyToHistoryApi(ctx.req, ctx.res, ctx.next) + }) + + it('should get the user id', function (ctx) { + ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith( + ctx.req.session + ) + }) + + it('should call the project history api', function (ctx) { + ctx.fetchStreamWithResponse.should.have.been.calledWith( + `${ctx.settings.apis.project_history.url}${ctx.req.url}`, + { + method: ctx.req.method, + headers: { + 'X-User-Id': ctx.user_id, + }, + } + ) + }) + + it('should pipe the response to the client', function (ctx) { + expect(ctx.Stream.pipeline).to.have.been.calledWith(ctx.stream, ctx.res) + }) + + it('should propagate the appropriate headers', function (ctx) { + expect(ctx.res.set).to.have.been.calledWith( + 'Content-Type', + ctx.contentType + ) + expect(ctx.res.set).to.have.been.calledWith( + 'Content-Length', + ctx.contentLength + ) + }) + }) + + describe('proxyToHistoryApiAndInjectUserDetails', function () { + beforeEach(async function (ctx) { + ctx.req = { url: '/mock/url', method: 'POST' } + ctx.res = { json: sinon.stub() } + ctx.data = 'mock-data' + ctx.dataWithUsers = 'mock-injected-data' + ctx.fetchJson.resolves(ctx.data) + ctx.HistoryManager.promises.injectUserDetails.resolves(ctx.dataWithUsers) + await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails( + ctx.req, + ctx.res, + ctx.next + ) + }) + + it('should get the user id', function (ctx) { + ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith( + ctx.req.session + ) + }) + + it('should call the project history api', function (ctx) { + ctx.fetchJson.should.have.been.calledWith( + `${ctx.settings.apis.project_history.url}${ctx.req.url}`, + { + method: ctx.req.method, + headers: { + 'X-User-Id': ctx.user_id, + }, + } + ) + }) + + it('should inject the user data', function (ctx) { + ctx.HistoryManager.promises.injectUserDetails.should.have.been.calledWith( + ctx.data + ) + }) + + it('should return the data with users to the client', function (ctx) { + ctx.res.json.should.have.been.calledWith(ctx.dataWithUsers) + }) + }) + + describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () { + beforeEach(async function (ctx) { + ctx.url = '/mock/url' + ctx.req = { url: ctx.url, method: 'POST' } + ctx.res = { json: sinon.stub() } + ctx.err = new RequestFailedError(ctx.url, {}, { status: 500 }) + ctx.fetchJson.rejects(ctx.err) + await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails( + ctx.req, + ctx.res, + ctx.next + ) + }) + + it('should not inject the user data', function (ctx) { + ctx.HistoryManager.promises.injectUserDetails.should.not.have.been.called + }) + + it('should not return the data with users to the client', function (ctx) { + ctx.res.json.should.not.have.been.called + }) + + it('should throw an error', function (ctx) { + ctx.next.should.have.been.calledWith(ctx.err) + }) + }) + + describe('resyncProjectHistory', function () { + describe('for a project without project-history enabled', function () { + beforeEach(async function (ctx) { + ctx.req = { params: { Project_id: ctx.project_id }, body: {} } + ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() } + + ctx.error = new Errors.ProjectHistoryDisabledError() + ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects( + ctx.error + ) + + await ctx.HistoryController.resyncProjectHistory( + ctx.req, + ctx.res, + ctx.next + ) + }) + + it('response with a 404', function (ctx) { + ctx.res.sendStatus.should.have.been.calledWith(404) + }) + }) + + describe('for a project with project-history enabled', function () { + beforeEach(async function (ctx) { + ctx.req = { params: { Project_id: ctx.project_id }, body: {} } + ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() } + + await ctx.HistoryController.resyncProjectHistory( + ctx.req, + ctx.res, + ctx.next + ) + }) + + it('sets an extended response timeout', function (ctx) { + ctx.res.setTimeout.should.have.been.calledWith(6 * 60 * 1000) + }) + + it('resyncs the project', function (ctx) { + ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith( + ctx.project_id + ) + }) + + it('responds with a 204', function (ctx) { + ctx.res.sendStatus.should.have.been.calledWith(204) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/History/HistoryControllerTests.js b/services/web/test/unit/src/History/HistoryControllerTests.js deleted file mode 100644 index f575859073..0000000000 --- a/services/web/test/unit/src/History/HistoryControllerTests.js +++ /dev/null @@ -1,265 +0,0 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const { RequestFailedError } = require('@overleaf/fetch-utils') - -const Errors = require('../../../../app/src/Features/Errors/Errors') - -const modulePath = '../../../../app/src/Features/History/HistoryController' -const SandboxedModule = require('sandboxed-module') -const { ObjectId } = require('mongodb-legacy') - -describe('HistoryController', function () { - beforeEach(function () { - this.callback = sinon.stub() - this.user_id = 'user-id-123' - this.project_id = 'mock-project-id' - this.stream = sinon.stub() - this.fetchResponse = { - headers: { - get: sinon.stub(), - }, - } - this.next = sinon.stub() - - this.SessionManager = { - getLoggedInUserId: sinon.stub().returns(this.user_id), - } - - this.Stream = { - pipeline: sinon.stub().resolves(), - } - - this.HistoryManager = { - promises: { - injectUserDetails: sinon.stub(), - }, - } - - this.ProjectEntityUpdateHandler = { - promises: { - resyncProjectHistory: sinon.stub().resolves(), - }, - } - - this.fetchJson = sinon.stub() - this.fetchStream = sinon.stub().resolves(this.stream) - this.fetchStreamWithResponse = sinon - .stub() - .resolves({ stream: this.stream, response: this.fetchResponse }) - this.fetchNothing = sinon.stub().resolves() - - this.HistoryController = SandboxedModule.require(modulePath, { - requires: { - 'stream/promises': this.Stream, - '@overleaf/settings': (this.settings = {}), - '@overleaf/fetch-utils': { - fetchJson: this.fetchJson, - fetchStream: this.fetchStream, - fetchStreamWithResponse: this.fetchStreamWithResponse, - fetchNothing: this.fetchNothing, - }, - '@overleaf/Metrics': {}, - '../../infrastructure/mongodb': { ObjectId }, - '../Authentication/SessionManager': this.SessionManager, - './HistoryManager': this.HistoryManager, - '../Project/ProjectDetailsHandler': (this.ProjectDetailsHandler = {}), - '../Project/ProjectEntityUpdateHandler': - this.ProjectEntityUpdateHandler, - '../User/UserGetter': (this.UserGetter = {}), - '../Project/ProjectGetter': (this.ProjectGetter = {}), - './RestoreManager': (this.RestoreManager = {}), - '../../infrastructure/Features': (this.Features = sinon - .stub() - .withArgs('saas') - .returns(true)), - }, - }) - this.settings.apis = { - project_history: { - url: 'http://project_history.example.com', - }, - } - }) - - describe('proxyToHistoryApi', function () { - beforeEach(async function () { - this.req = { url: '/mock/url', method: 'POST', session: sinon.stub() } - this.res = { - set: sinon.stub(), - } - this.contentType = 'application/json' - this.contentLength = 212 - this.fetchResponse.headers.get - .withArgs('Content-Type') - .returns(this.contentType) - this.fetchResponse.headers.get - .withArgs('Content-Length') - .returns(this.contentLength) - await this.HistoryController.proxyToHistoryApi( - this.req, - this.res, - this.next - ) - }) - - it('should get the user id', function () { - this.SessionManager.getLoggedInUserId.should.have.been.calledWith( - this.req.session - ) - }) - - it('should call the project history api', function () { - this.fetchStreamWithResponse.should.have.been.calledWith( - `${this.settings.apis.project_history.url}${this.req.url}`, - { - method: this.req.method, - headers: { - 'X-User-Id': this.user_id, - }, - } - ) - }) - - it('should pipe the response to the client', function () { - expect(this.Stream.pipeline).to.have.been.calledWith( - this.stream, - this.res - ) - }) - - it('should propagate the appropriate headers', function () { - expect(this.res.set).to.have.been.calledWith( - 'Content-Type', - this.contentType - ) - expect(this.res.set).to.have.been.calledWith( - 'Content-Length', - this.contentLength - ) - }) - }) - - describe('proxyToHistoryApiAndInjectUserDetails', function () { - beforeEach(async function () { - this.req = { url: '/mock/url', method: 'POST' } - this.res = { json: sinon.stub() } - this.data = 'mock-data' - this.dataWithUsers = 'mock-injected-data' - this.fetchJson.resolves(this.data) - this.HistoryManager.promises.injectUserDetails.resolves( - this.dataWithUsers - ) - await this.HistoryController.proxyToHistoryApiAndInjectUserDetails( - this.req, - this.res, - this.next - ) - }) - - it('should get the user id', function () { - this.SessionManager.getLoggedInUserId.should.have.been.calledWith( - this.req.session - ) - }) - - it('should call the project history api', function () { - this.fetchJson.should.have.been.calledWith( - `${this.settings.apis.project_history.url}${this.req.url}`, - { - method: this.req.method, - headers: { - 'X-User-Id': this.user_id, - }, - } - ) - }) - - it('should inject the user data', function () { - this.HistoryManager.promises.injectUserDetails.should.have.been.calledWith( - this.data - ) - }) - - it('should return the data with users to the client', function () { - this.res.json.should.have.been.calledWith(this.dataWithUsers) - }) - }) - - describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () { - beforeEach(async function () { - this.url = '/mock/url' - this.req = { url: this.url, method: 'POST' } - this.res = { json: sinon.stub() } - this.err = new RequestFailedError(this.url, {}, { status: 500 }) - this.fetchJson.rejects(this.err) - await this.HistoryController.proxyToHistoryApiAndInjectUserDetails( - this.req, - this.res, - this.next - ) - }) - - it('should not inject the user data', function () { - this.HistoryManager.promises.injectUserDetails.should.not.have.been.called - }) - - it('should not return the data with users to the client', function () { - this.res.json.should.not.have.been.called - }) - - it('should throw an error', function () { - this.next.should.have.been.calledWith(this.err) - }) - }) - - describe('resyncProjectHistory', function () { - describe('for a project without project-history enabled', function () { - beforeEach(async function () { - this.req = { params: { Project_id: this.project_id }, body: {} } - this.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() } - - this.error = new Errors.ProjectHistoryDisabledError() - this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects( - this.error - ) - - await this.HistoryController.resyncProjectHistory( - this.req, - this.res, - this.next - ) - }) - - it('response with a 404', function () { - this.res.sendStatus.should.have.been.calledWith(404) - }) - }) - - describe('for a project with project-history enabled', function () { - beforeEach(async function () { - this.req = { params: { Project_id: this.project_id }, body: {} } - this.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() } - - await this.HistoryController.resyncProjectHistory( - this.req, - this.res, - this.next - ) - }) - - it('sets an extended response timeout', function () { - this.res.setTimeout.should.have.been.calledWith(6 * 60 * 1000) - }) - - it('resyncs the project', function () { - this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith( - this.project_id - ) - }) - - it('responds with a 204', function () { - this.res.sendStatus.should.have.been.calledWith(204) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/Project/ProjectCollabratecDetails.test.mjs b/services/web/test/unit/src/Project/ProjectCollabratecDetails.test.mjs new file mode 100644 index 0000000000..68ab92e3c5 --- /dev/null +++ b/services/web/test/unit/src/Project/ProjectCollabratecDetails.test.mjs @@ -0,0 +1,425 @@ +import { vi, expect } from 'vitest' +import mongodb from 'mongodb-legacy' +import sinon from 'sinon' + +const { ObjectId } = mongodb + +const MODULE_PATH = + '../../../../app/src/Features/Project/ProjectCollabratecDetailsHandler.mjs' + +describe('ProjectCollabratecDetailsHandler', function () { + beforeEach(async function (ctx) { + ctx.projectId = new ObjectId('5bea8747c7bba6012fcaceb3') + ctx.userId = new ObjectId('5be316a9c7f6aa03802ea8fb') + ctx.userId2 = new ObjectId('5c1794b3f0e89b1d1c577eca') + ctx.ProjectModel = {} + + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) + + vi.doMock('../../../../app/src/models/Project.js', () => ({ + Project: ctx.ProjectModel, + })) + + ctx.ProjectCollabratecDetailsHandler = (await import(MODULE_PATH)).default + }) + + describe('initializeCollabratecProject', function () { + describe('when update succeeds', function () { + beforeEach(async function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + await ctx.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject( + ctx.projectId, + ctx.userId, + 'collabratec-document-id', + 'collabratec-private-group-id' + ) + }) + + it('should update project model', function (ctx) { + const update = { + $set: { + collabratecUsers: [ + { + user_id: ctx.userId, + collabratec_document_id: 'collabratec-document-id', + collabratec_privategroup_id: 'collabratec-private-group-id', + }, + ], + }, + } + expect(ctx.ProjectModel.updateOne).to.have.been.calledWith( + { _id: ctx.projectId }, + update + ) + }) + }) + + describe('when update has error', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().rejects() }) + }) + + it('should be rejected', async function (ctx) { + await expect( + ctx.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject( + ctx.projectId, + ctx.userId, + 'collabratec-document-id', + 'collabratec-private-group-id' + ) + ).to.be.rejected + }) + }) + + describe('with invalid args', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + ctx.resultPromise = + ctx.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject( + 'bad-project-id', + 'bad-user-id', + 'collabratec-document-id', + 'collabratec-private-group-id' + ) + }) + + it('should be rejected without updating', async function (ctx) { + await expect(ctx.resultPromise).to.be.rejected + expect(ctx.ProjectModel.updateOne).not.to.have.been.called + }) + }) + }) + + describe('isLinkedCollabratecUserProject', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.findOne = sinon.stub().resolves() + }) + + describe('when find succeeds', function () { + describe('when user project found', function () { + beforeEach(async function (ctx) { + ctx.ProjectModel.findOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves('project') }) + ctx.result = + await ctx.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject( + ctx.projectId, + ctx.userId + ) + }) + + it('should call find with project and user id', function (ctx) { + expect(ctx.ProjectModel.findOne).to.have.been.calledWithMatch({ + _id: new ObjectId(ctx.projectId), + collabratecUsers: { + $elemMatch: { + user_id: new ObjectId(ctx.userId), + }, + }, + }) + }) + + it('should return true', function (ctx) { + expect(ctx.result).to.equal(true) + }) + }) + + describe('when user project is not found', function () { + beforeEach(async function (ctx) { + ctx.ProjectModel.findOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves(null) }) + ctx.result = + await ctx.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject( + ctx.projectId, + ctx.userId + ) + }) + + it('should return false', function (ctx) { + expect(ctx.result).to.equal(false) + }) + }) + }) + + describe('when find has error', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.findOne = sinon + .stub() + .returns({ exec: sinon.stub().rejects() }) + }) + + it('should be rejected', async function (ctx) { + await expect( + ctx.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject( + ctx.projectId, + ctx.userId + ) + ).to.be.rejected + }) + }) + + describe('with invalid args', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.findOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + ctx.resultPromise = + ctx.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject( + 'bad-project-id', + 'bad-user-id' + ) + }) + + it('should be rejected without updating', async function (ctx) { + await expect(ctx.resultPromise).to.be.rejected + expect(ctx.ProjectModel.findOne).not.to.have.been.called + }) + }) + }) + + describe('linkCollabratecUserProject', function () { + describe('when update succeeds', function () { + beforeEach(async function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + await ctx.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject( + ctx.projectId, + ctx.userId, + 'collabratec-document-id' + ) + }) + + it('should update project model', function (ctx) { + const query = { + _id: ctx.projectId, + collabratecUsers: { + $not: { + $elemMatch: { + collabratec_document_id: 'collabratec-document-id', + user_id: ctx.userId, + }, + }, + }, + } + const update = { + $push: { + collabratecUsers: { + collabratec_document_id: 'collabratec-document-id', + user_id: ctx.userId, + }, + }, + } + expect(ctx.ProjectModel.updateOne).to.have.been.calledWith( + query, + update + ) + }) + }) + + describe('when update has error', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().rejects() }) + }) + + it('should be rejected', async function (ctx) { + await expect( + ctx.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject( + ctx.projectId, + ctx.userId, + 'collabratec-document-id' + ) + ).to.be.rejected + }) + }) + + describe('with invalid args', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + ctx.resultPromise = + ctx.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject( + 'bad-project-id', + 'bad-user-id', + 'collabratec-document-id' + ) + }) + + it('should be rejected without updating', async function (ctx) { + await expect(ctx.resultPromise).to.be.rejected + expect(ctx.ProjectModel.updateOne).not.to.have.been.called + }) + }) + }) + + describe('setCollabratecUsers', function () { + beforeEach(function (ctx) { + ctx.collabratecUsers = [ + { + user_id: ctx.userId, + collabratec_document_id: 'collabratec-document-id-1', + collabratec_privategroup_id: 'collabratec-private-group-id-1', + }, + { + user_id: ctx.userId2, + collabratec_document_id: 'collabratec-document-id-2', + collabratec_privategroup_id: 'collabratec-private-group-id-2', + }, + ] + }) + + describe('when update succeeds', function () { + beforeEach(async function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + await ctx.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers( + ctx.projectId, + ctx.collabratecUsers + ) + }) + + it('should update project model', function (ctx) { + const update = { + $set: { + collabratecUsers: ctx.collabratecUsers, + }, + } + expect(ctx.ProjectModel.updateOne).to.have.been.calledWith( + { _id: ctx.projectId }, + update + ) + }) + }) + + describe('when update has error', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().rejects() }) + }) + + it('should be rejected', async function (ctx) { + await expect( + ctx.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers( + ctx.projectId, + ctx.collabratecUsers + ) + ).to.be.rejected + }) + }) + + describe('with invalid project_id', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + ctx.resultPromise = + ctx.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers( + 'bad-project-id', + ctx.collabratecUsers + ) + }) + + it('should be rejected without updating', async function (ctx) { + await expect(ctx.resultPromise).to.be.rejected + expect(ctx.ProjectModel.updateOne).not.to.have.been.called + }) + }) + + describe('with invalid user_id', function () { + beforeEach(function (ctx) { + ctx.collabratecUsers[1].user_id = 'bad-user-id' + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + ctx.resultPromise = + ctx.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers( + ctx.projectId, + ctx.collabratecUsers + ) + }) + + it('should be rejected without updating', async function (ctx) { + await expect(ctx.resultPromise).to.be.rejected + expect(ctx.ProjectModel.updateOne).not.to.have.been.called + }) + }) + }) + + describe('unlinkCollabratecUserProject', function () { + describe('when update succeeds', function () { + beforeEach(async function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + await ctx.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject( + ctx.projectId, + ctx.userId + ) + }) + + it('should update project model', function (ctx) { + const query = { _id: ctx.projectId } + const update = { + $pull: { + collabratecUsers: { + user_id: ctx.userId, + }, + }, + } + expect(ctx.ProjectModel.updateOne).to.have.been.calledWith( + query, + update + ) + }) + }) + + describe('when update has error', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().rejects() }) + }) + + it('should be rejected', async function (ctx) { + await expect( + ctx.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject( + ctx.projectId, + ctx.userId + ) + ).to.be.rejected + }) + }) + + describe('with invalid args', function () { + beforeEach(function (ctx) { + ctx.ProjectModel.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().resolves() }) + ctx.resultPromise = + ctx.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject( + 'bad-project-id', + 'bad-user-id' + ) + }) + + it('should be rejected without updating', async function (ctx) { + await expect(ctx.resultPromise).to.be.rejected + expect(ctx.ProjectModel.updateOne).not.to.have.been.called + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Project/ProjectCollabratecDetailsTest.js b/services/web/test/unit/src/Project/ProjectCollabratecDetailsTest.js deleted file mode 100644 index d2de69ec91..0000000000 --- a/services/web/test/unit/src/Project/ProjectCollabratecDetailsTest.js +++ /dev/null @@ -1,426 +0,0 @@ -const { ObjectId } = require('mongodb-legacy') -const Path = require('path') -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const sinon = require('sinon') - -const modulePath = Path.join( - __dirname, - '../../../../app/src/Features/Project/ProjectCollabratecDetailsHandler' -) - -describe('ProjectCollabratecDetailsHandler', function () { - beforeEach(function () { - this.projectId = new ObjectId('5bea8747c7bba6012fcaceb3') - this.userId = new ObjectId('5be316a9c7f6aa03802ea8fb') - this.userId2 = new ObjectId('5c1794b3f0e89b1d1c577eca') - this.ProjectModel = {} - this.ProjectCollabratecDetailsHandler = SandboxedModule.require( - modulePath, - { - requires: { - 'mongodb-legacy': { ObjectId }, - '../../models/Project': { Project: this.ProjectModel }, - }, - } - ) - }) - - describe('initializeCollabratecProject', function () { - describe('when update succeeds', function () { - beforeEach(async function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - await this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject( - this.projectId, - this.userId, - 'collabratec-document-id', - 'collabratec-private-group-id' - ) - }) - - it('should update project model', function () { - const update = { - $set: { - collabratecUsers: [ - { - user_id: this.userId, - collabratec_document_id: 'collabratec-document-id', - collabratec_privategroup_id: 'collabratec-private-group-id', - }, - ], - }, - } - expect(this.ProjectModel.updateOne).to.have.been.calledWith( - { _id: this.projectId }, - update - ) - }) - }) - - describe('when update has error', function () { - beforeEach(function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().rejects() }) - }) - - it('should be rejected', async function () { - await expect( - this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject( - this.projectId, - this.userId, - 'collabratec-document-id', - 'collabratec-private-group-id' - ) - ).to.be.rejected - }) - }) - - describe('with invalid args', function () { - beforeEach(function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - this.resultPromise = - this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject( - 'bad-project-id', - 'bad-user-id', - 'collabratec-document-id', - 'collabratec-private-group-id' - ) - }) - - it('should be rejected without updating', async function () { - await expect(this.resultPromise).to.be.rejected - expect(this.ProjectModel.updateOne).not.to.have.been.called - }) - }) - }) - - describe('isLinkedCollabratecUserProject', function () { - beforeEach(function () { - this.ProjectModel.findOne = sinon.stub().resolves() - }) - - describe('when find succeeds', function () { - describe('when user project found', function () { - beforeEach(async function () { - this.ProjectModel.findOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves('project') }) - this.result = - await this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject( - this.projectId, - this.userId - ) - }) - - it('should call find with project and user id', function () { - expect(this.ProjectModel.findOne).to.have.been.calledWithMatch({ - _id: new ObjectId(this.projectId), - collabratecUsers: { - $elemMatch: { - user_id: new ObjectId(this.userId), - }, - }, - }) - }) - - it('should return true', function () { - expect(this.result).to.equal(true) - }) - }) - - describe('when user project is not found', function () { - beforeEach(async function () { - this.ProjectModel.findOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves(null) }) - this.result = - await this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject( - this.projectId, - this.userId - ) - }) - - it('should return false', function () { - expect(this.result).to.equal(false) - }) - }) - }) - - describe('when find has error', function () { - beforeEach(function () { - this.ProjectModel.findOne = sinon - .stub() - .returns({ exec: sinon.stub().rejects() }) - }) - - it('should be rejected', async function () { - await expect( - this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject( - this.projectId, - this.userId - ) - ).to.be.rejected - }) - }) - - describe('with invalid args', function () { - beforeEach(function () { - this.ProjectModel.findOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - this.resultPromise = - this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject( - 'bad-project-id', - 'bad-user-id' - ) - }) - - it('should be rejected without updating', async function () { - await expect(this.resultPromise).to.be.rejected - expect(this.ProjectModel.findOne).not.to.have.been.called - }) - }) - }) - - describe('linkCollabratecUserProject', function () { - describe('when update succeeds', function () { - beforeEach(async function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - await this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject( - this.projectId, - this.userId, - 'collabratec-document-id' - ) - }) - - it('should update project model', function () { - const query = { - _id: this.projectId, - collabratecUsers: { - $not: { - $elemMatch: { - collabratec_document_id: 'collabratec-document-id', - user_id: this.userId, - }, - }, - }, - } - const update = { - $push: { - collabratecUsers: { - collabratec_document_id: 'collabratec-document-id', - user_id: this.userId, - }, - }, - } - expect(this.ProjectModel.updateOne).to.have.been.calledWith( - query, - update - ) - }) - }) - - describe('when update has error', function () { - beforeEach(function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().rejects() }) - }) - - it('should be rejected', async function () { - await expect( - this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject( - this.projectId, - this.userId, - 'collabratec-document-id' - ) - ).to.be.rejected - }) - }) - - describe('with invalid args', function () { - beforeEach(function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - this.resultPromise = - this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject( - 'bad-project-id', - 'bad-user-id', - 'collabratec-document-id' - ) - }) - - it('should be rejected without updating', async function () { - await expect(this.resultPromise).to.be.rejected - expect(this.ProjectModel.updateOne).not.to.have.been.called - }) - }) - }) - - describe('setCollabratecUsers', function () { - beforeEach(function () { - this.collabratecUsers = [ - { - user_id: this.userId, - collabratec_document_id: 'collabratec-document-id-1', - collabratec_privategroup_id: 'collabratec-private-group-id-1', - }, - { - user_id: this.userId2, - collabratec_document_id: 'collabratec-document-id-2', - collabratec_privategroup_id: 'collabratec-private-group-id-2', - }, - ] - }) - - describe('when update succeeds', function () { - beforeEach(async function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - await this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers( - this.projectId, - this.collabratecUsers - ) - }) - - it('should update project model', function () { - const update = { - $set: { - collabratecUsers: this.collabratecUsers, - }, - } - expect(this.ProjectModel.updateOne).to.have.been.calledWith( - { _id: this.projectId }, - update - ) - }) - }) - - describe('when update has error', function () { - beforeEach(function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().rejects() }) - }) - - it('should be rejected', async function () { - await expect( - this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers( - this.projectId, - this.collabratecUsers - ) - ).to.be.rejected - }) - }) - - describe('with invalid project_id', function () { - beforeEach(function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - this.resultPromise = - this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers( - 'bad-project-id', - this.collabratecUsers - ) - }) - - it('should be rejected without updating', async function () { - await expect(this.resultPromise).to.be.rejected - expect(this.ProjectModel.updateOne).not.to.have.been.called - }) - }) - - describe('with invalid user_id', function () { - beforeEach(function () { - this.collabratecUsers[1].user_id = 'bad-user-id' - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - this.resultPromise = - this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers( - this.projectId, - this.collabratecUsers - ) - }) - - it('should be rejected without updating', async function () { - await expect(this.resultPromise).to.be.rejected - expect(this.ProjectModel.updateOne).not.to.have.been.called - }) - }) - }) - - describe('unlinkCollabratecUserProject', function () { - describe('when update succeeds', function () { - beforeEach(async function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - await this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject( - this.projectId, - this.userId - ) - }) - - it('should update project model', function () { - const query = { _id: this.projectId } - const update = { - $pull: { - collabratecUsers: { - user_id: this.userId, - }, - }, - } - expect(this.ProjectModel.updateOne).to.have.been.calledWith( - query, - update - ) - }) - }) - - describe('when update has error', function () { - beforeEach(function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().rejects() }) - }) - - it('should be rejected', async function () { - await expect( - this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject( - this.projectId, - this.userId - ) - ).to.be.rejected - }) - }) - - describe('with invalid args', function () { - beforeEach(function () { - this.ProjectModel.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves() }) - this.resultPromise = - this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject( - 'bad-project-id', - 'bad-user-id' - ) - }) - - it('should be rejected without updating', async function () { - await expect(this.resultPromise).to.be.rejected - expect(this.ProjectModel.updateOne).not.to.have.been.called - }) - }) - }) -})