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

[web] Convert some Features files to ES modules (part 1)

GitOrigin-RevId: d19b024efad315143e022143e2a2683df8071744
This commit is contained in:
Antoine Clausse
2025-09-08 10:54:37 +02:00
committed by Copybot
parent adc67a8dbe
commit ccf3f13fd2
36 changed files with 2313 additions and 2103 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -102,7 +102,7 @@ describe('CollaboratorsController', function () {
)
vi.doMock(
'../../../../app/src/Features/Collaborators/OwnershipTransferHandler.js',
'../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs',
() => ({
default: ctx.OwnershipTransferHandler,
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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