diff --git a/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js b/services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs
similarity index 89%
rename from services/web/app/src/Features/Authorization/AuthorizationMiddleware.js
rename to services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs
index 142264c493..73c69c068e 100644
--- a/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js
+++ b/services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs
@@ -1,21 +1,26 @@
-const AuthorizationManager = require('./AuthorizationManager')
-const logger = require('@overleaf/logger')
-const { ObjectId } = require('mongodb-legacy')
-const Errors = require('../Errors/Errors')
-const HttpErrorHandler = require('../Errors/HttpErrorHandler')
-const AuthenticationController = require('../Authentication/AuthenticationController')
-const SessionManager = require('../Authentication/SessionManager')
-const TokenAccessHandler = require('../TokenAccess/TokenAccessHandler')
-const { expressify } = require('@overleaf/promise-utils')
-const {
- canRedirectToAdminDomain,
-} = require('../Helpers/AdminAuthorizationHelper')
-const { getSafeAdminDomainRedirect } = require('../Helpers/UrlHelper')
+import AuthorizationManager from './AuthorizationManager.js'
+import logger from '@overleaf/logger'
+import mongodb from 'mongodb-legacy'
+
+import Errors from '../Errors/Errors.js'
+import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
+import AuthenticationController from '../Authentication/AuthenticationController.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.js'
+import { expressify } from '@overleaf/promise-utils'
+import AdminAuthorizationHelper from '../Helpers/AdminAuthorizationHelper.js'
+import UrlHelper from '../Helpers/UrlHelper.js'
+
+const { ObjectId } = mongodb
function _handleAdminDomainRedirect(req, res) {
- if (canRedirectToAdminDomain(SessionManager.getSessionUser(req.session))) {
+ if (
+ AdminAuthorizationHelper.canRedirectToAdminDomain(
+ SessionManager.getSessionUser(req.session)
+ )
+ ) {
logger.warn({ req }, 'redirecting admin user to admin domain')
- res.redirect(getSafeAdminDomainRedirect(req.originalUrl))
+ res.redirect(UrlHelper.getSafeAdminDomainRedirect(req.originalUrl))
return true
}
return false
@@ -255,7 +260,7 @@ function restricted(req, res, next) {
res.redirect('/login')
}
-module.exports = {
+export default {
ensureUserCanReadMultipleProjects: expressify(
ensureUserCanReadMultipleProjects
),
diff --git a/services/web/app/src/Features/Authorization/PermissionsController.js b/services/web/app/src/Features/Authorization/PermissionsController.mjs
similarity index 90%
rename from services/web/app/src/Features/Authorization/PermissionsController.js
rename to services/web/app/src/Features/Authorization/PermissionsController.mjs
index 4d884e80cb..0b6454ce5d 100644
--- a/services/web/app/src/Features/Authorization/PermissionsController.js
+++ b/services/web/app/src/Features/Authorization/PermissionsController.mjs
@@ -1,15 +1,9 @@
// @ts-check
-const { ForbiddenError, UserNotFoundError } = require('../Errors/Errors')
-const {
- getUserCapabilities,
- getUserRestrictions,
- combineGroupPolicies,
- combineAllowedProperties,
-} = require('./PermissionsManager')
-const { assertUserPermissions } = require('./PermissionsManager').promises
-const Modules = require('../../infrastructure/Modules')
-const { expressify } = require('@overleaf/promise-utils')
-const Features = require('../../infrastructure/Features')
+import { ForbiddenError, UserNotFoundError } from '../Errors/Errors.js'
+import PermissionsManager from './PermissionsManager.js'
+import Modules from '../../infrastructure/Modules.js'
+import { expressify } from '@overleaf/promise-utils'
+import Features from '../../infrastructure/Features.js'
/**
* @typedef {(import('express').Request)} Request
@@ -18,6 +12,14 @@ const Features = require('../../infrastructure/Features')
* @typedef {import('./PermissionsManager').Capability} Capability
*/
+const {
+ getUserCapabilities,
+ getUserRestrictions,
+ combineGroupPolicies,
+ combineAllowedProperties,
+ promises: { assertUserPermissions },
+} = PermissionsManager
+
/**
* Function that returns middleware to add an `assertPermission` function to the request object to check if the user has a specific capability.
* @returns {() => (req: Request, res: Response, next: NextFunction) => void} The middleware function that adds the `assertPermission` function to the request object.
@@ -116,7 +118,7 @@ function requirePermission(...requiredCapabilities) {
return doRequest
}
-module.exports = {
+export default {
requirePermission,
useCapabilities,
}
diff --git a/services/web/app/src/Features/BrandVariations/BrandVariationsHandler.js b/services/web/app/src/Features/BrandVariations/BrandVariationsHandler.mjs
similarity index 89%
rename from services/web/app/src/Features/BrandVariations/BrandVariationsHandler.js
rename to services/web/app/src/Features/BrandVariations/BrandVariationsHandler.mjs
index 33fa764048..f66c573f2e 100644
--- a/services/web/app/src/Features/BrandVariations/BrandVariationsHandler.js
+++ b/services/web/app/src/Features/BrandVariations/BrandVariationsHandler.mjs
@@ -1,12 +1,12 @@
-const OError = require('@overleaf/o-error')
-const { URL } = require('url')
-const settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const V1Api = require('../V1/V1Api')
-const sanitizeHtml = require('sanitize-html')
-const { promisify } = require('@overleaf/promise-utils')
+import OError from '@overleaf/o-error'
+import { URL } from 'node:url'
+import settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import V1Api from '../V1/V1Api.js'
+import sanitizeHtml from 'sanitize-html'
+import { promisify } from '@overleaf/promise-utils'
-module.exports = {
+export default {
getBrandVariationById,
promises: {
getBrandVariationById: promisify(getBrandVariationById),
diff --git a/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs b/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs
index 3c0cf0eb3a..b28a6d1db8 100644
--- a/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs
+++ b/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs
@@ -3,7 +3,7 @@ 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 DeviceHistory from './DeviceHistory.mjs'
import AuthenticationController from '../Authentication/AuthenticationController.js'
import { expressify } from '@overleaf/promise-utils'
import EmailsHelper from '../Helpers/EmailHelper.js'
diff --git a/services/web/app/src/Features/Captcha/DeviceHistory.js b/services/web/app/src/Features/Captcha/DeviceHistory.mjs
similarity index 93%
rename from services/web/app/src/Features/Captcha/DeviceHistory.js
rename to services/web/app/src/Features/Captcha/DeviceHistory.mjs
index 06b90b2559..f726c76625 100644
--- a/services/web/app/src/Features/Captcha/DeviceHistory.js
+++ b/services/web/app/src/Features/Captcha/DeviceHistory.mjs
@@ -1,7 +1,7 @@
-const crypto = require('crypto')
-const jose = require('jose')
-const Metrics = require('@overleaf/metrics')
-const Settings = require('@overleaf/settings')
+import crypto from 'node:crypto'
+import * as jose from 'jose'
+import Metrics from '@overleaf/metrics'
+import Settings from '@overleaf/settings'
const COOKIE_NAME = Settings.deviceHistory.cookieName
const ENTRY_EXPIRY = Settings.deviceHistory.entryExpiry
@@ -100,4 +100,4 @@ class DeviceHistory {
}
}
-module.exports = DeviceHistory
+export default DeviceHistory
diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs
index d30d51e597..97bfc034ea 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs
@@ -12,7 +12,7 @@ import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils'
import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.js'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
import LimitationsManager from '../Subscription/LimitationsManager.js'
import Features from '../../infrastructure/Features.js'
diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
index db853afac3..c5d8344922 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
@@ -12,7 +12,7 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import SessionManager from '../Authentication/SessionManager.js'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
import { expressify } from '@overleaf/promise-utils'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
import Errors from '../Errors/Errors.js'
import AuthenticationController from '../Authentication/AuthenticationController.js'
import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs
index 02db4dee99..657ecbd7ae 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs
@@ -10,7 +10,7 @@ import ProjectGetter from '../Project/ProjectGetter.js'
import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
import LimitationsManager from '../Subscription/LimitationsManager.js'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
import _ from 'lodash'
const CollaboratorsInviteHandler = {
diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
index 39f710a2c9..664396b926 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
@@ -1,6 +1,6 @@
import CollaboratorsController from './CollaboratorsController.mjs'
import AuthenticationController from '../Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
import CollaboratorsInviteController from './CollaboratorsInviteController.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
diff --git a/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs b/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs
index b2b532bb9c..d468a454dc 100644
--- a/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs
+++ b/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs
@@ -7,7 +7,7 @@ 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 ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import OError from '@overleaf/o-error'
import TagsHandler from '../Tags/TagsHandler.js'
diff --git a/services/web/app/src/Features/Compile/ClsiCacheController.mjs b/services/web/app/src/Features/Compile/ClsiCacheController.mjs
index b98e03eeef..79b3aac827 100644
--- a/services/web/app/src/Features/Compile/ClsiCacheController.mjs
+++ b/services/web/app/src/Features/Compile/ClsiCacheController.mjs
@@ -7,7 +7,7 @@ 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 CompileController from './CompileController.mjs'
import { expressify } from '@overleaf/promise-utils'
import ClsiCacheHandler from './ClsiCacheHandler.js'
import ProjectGetter from '../Project/ProjectGetter.js'
diff --git a/services/web/app/src/Features/Compile/CompileController.js b/services/web/app/src/Features/Compile/CompileController.mjs
similarity index 94%
rename from services/web/app/src/Features/Compile/CompileController.js
rename to services/web/app/src/Features/Compile/CompileController.mjs
index a6fdde5bdc..82486d1bfc 100644
--- a/services/web/app/src/Features/Compile/CompileController.js
+++ b/services/web/app/src/Features/Compile/CompileController.mjs
@@ -1,28 +1,31 @@
-const { URL } = require('url')
-const { pipeline } = require('stream/promises')
-const { Cookie } = require('tough-cookie')
-const OError = require('@overleaf/o-error')
-const Metrics = require('@overleaf/metrics')
-const ProjectGetter = require('../Project/ProjectGetter')
-const CompileManager = require('./CompileManager')
-const ClsiManager = require('./ClsiManager')
-const logger = require('@overleaf/logger')
-const Settings = require('@overleaf/settings')
-const Errors = require('../Errors/Errors')
-const SessionManager = require('../Authentication/SessionManager')
-const { RateLimiter } = require('../../infrastructure/RateLimiter')
-const { z, zz, validateReq } = require('../../infrastructure/Validation')
-const ClsiCookieManager = require('./ClsiCookieManager')(
- Settings.apis.clsi?.backendGroupName
-)
-const Path = require('path')
-const AnalyticsManager = require('../Analytics/AnalyticsManager')
-const SplitTestHandler = require('../SplitTests/SplitTestHandler')
-const { expressify } = require('@overleaf/promise-utils')
-const {
+import { URL } from 'node:url'
+import { pipeline } from 'node:stream/promises'
+import { Cookie } from 'tough-cookie'
+import OError from '@overleaf/o-error'
+import Metrics from '@overleaf/metrics'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import CompileManager from './CompileManager.js'
+import ClsiManager from './ClsiManager.js'
+import logger from '@overleaf/logger'
+import Settings from '@overleaf/settings'
+import Errors from '../Errors/Errors.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import { RateLimiter } from '../../infrastructure/RateLimiter.js'
+import Validation from '../../infrastructure/Validation.js'
+import ClsiCookieManagerFactory from './ClsiCookieManager.js'
+import Path from 'node:path'
+import AnalyticsManager from '../Analytics/AnalyticsManager.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
+import { expressify } from '@overleaf/promise-utils'
+import {
fetchStreamWithResponse,
RequestFailedError,
-} = require('@overleaf/fetch-utils')
+} from '@overleaf/fetch-utils'
+
+const { z, zz, validateReq } = Validation
+const ClsiCookieManager = ClsiCookieManagerFactory(
+ Settings.apis.clsi?.backendGroupName
+)
const COMPILE_TIMEOUT_MS = 10 * 60 * 1000
@@ -744,4 +747,4 @@ const CompileController = {
_proxyToClsiWithLimits: _CompileController._proxyToClsiWithLimits,
}
-module.exports = CompileController
+export default CompileController
diff --git a/services/web/app/src/Features/Editor/EditorRouter.mjs b/services/web/app/src/Features/Editor/EditorRouter.mjs
index 00b9d887c4..ee36c18f88 100644
--- a/services/web/app/src/Features/Editor/EditorRouter.mjs
+++ b/services/web/app/src/Features/Editor/EditorRouter.mjs
@@ -1,6 +1,6 @@
import EditorHttpController from './EditorHttpController.mjs'
import AuthenticationController from '../Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
import { validate, Joi } from '../../infrastructure/Validation.js'
diff --git a/services/web/app/src/Features/Helpers/AuthorizationHelper.js b/services/web/app/src/Features/Helpers/AuthorizationHelper.mjs
similarity index 76%
rename from services/web/app/src/Features/Helpers/AuthorizationHelper.js
rename to services/web/app/src/Features/Helpers/AuthorizationHelper.mjs
index 8369f2d321..9bac0c0769 100644
--- a/services/web/app/src/Features/Helpers/AuthorizationHelper.js
+++ b/services/web/app/src/Features/Helpers/AuthorizationHelper.mjs
@@ -1,6 +1,6 @@
-const { UserSchema } = require('../../models/User')
+import { UserSchema } from '../../models/User.js'
-module.exports = {
+export default {
hasAnyStaffAccess,
}
diff --git a/services/web/app/src/Features/History/HistoryController.mjs b/services/web/app/src/Features/History/HistoryController.mjs
index 6f1767e270..8c50e5b213 100644
--- a/services/web/app/src/Features/History/HistoryController.mjs
+++ b/services/web/app/src/Features/History/HistoryController.mjs
@@ -22,7 +22,7 @@ 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 RestoreManager from './RestoreManager.mjs'
import { prepareZipAttachment } from '../../infrastructure/Response.js'
import Features from '../../infrastructure/Features.js'
diff --git a/services/web/app/src/Features/History/HistoryRouter.mjs b/services/web/app/src/Features/History/HistoryRouter.mjs
index a35761b14b..89d68caf7f 100644
--- a/services/web/app/src/Features/History/HistoryRouter.mjs
+++ b/services/web/app/src/Features/History/HistoryRouter.mjs
@@ -4,7 +4,7 @@ import Settings from '@overleaf/settings'
import { Joi, validate } from '../../infrastructure/Validation.js'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
import AuthenticationController from '../Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
import HistoryController from './HistoryController.mjs'
diff --git a/services/web/app/src/Features/History/RestoreManager.js b/services/web/app/src/Features/History/RestoreManager.mjs
similarity index 90%
rename from services/web/app/src/Features/History/RestoreManager.js
rename to services/web/app/src/Features/History/RestoreManager.mjs
index 8781593bb8..bcd175838c 100644
--- a/services/web/app/src/Features/History/RestoreManager.js
+++ b/services/web/app/src/Features/History/RestoreManager.mjs
@@ -1,23 +1,23 @@
-const Settings = require('@overleaf/settings')
-const Path = require('path')
-const FileWriter = require('../../infrastructure/FileWriter')
-const Metrics = require('../../infrastructure/Metrics')
-const FileSystemImportManager = require('../Uploads/FileSystemImportManager')
-const EditorController = require('../Editor/EditorController')
-const Errors = require('../Errors/Errors')
-const moment = require('moment')
-const { callbackifyAll } = require('@overleaf/promise-utils')
-const { fetchJson } = require('@overleaf/fetch-utils')
-const ProjectLocator = require('../Project/ProjectLocator')
-const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-const ChatApiHandler = require('../Chat/ChatApiHandler')
-const DocstoreManager = require('../Docstore/DocstoreManager')
-const logger = require('@overleaf/logger')
-const EditorRealTimeController = require('../Editor/EditorRealTimeController')
-const ChatManager = require('../Chat/ChatManager')
-const OError = require('@overleaf/o-error')
-const ProjectGetter = require('../Project/ProjectGetter')
-const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
+import Settings from '@overleaf/settings'
+import Path from 'node:path'
+import FileWriter from '../../infrastructure/FileWriter.js'
+import Metrics from '../../infrastructure/Metrics.js'
+import FileSystemImportManager from '../Uploads/FileSystemImportManager.js'
+import EditorController from '../Editor/EditorController.js'
+import Errors from '../Errors/Errors.js'
+import moment from 'moment'
+import { callbackifyAll } from '@overleaf/promise-utils'
+import { fetchJson } from '@overleaf/fetch-utils'
+import ProjectLocator from '../Project/ProjectLocator.js'
+import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
+import ChatApiHandler from '../Chat/ChatApiHandler.js'
+import DocstoreManager from '../Docstore/DocstoreManager.js'
+import logger from '@overleaf/logger'
+import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
+import ChatManager from '../Chat/ChatManager.js'
+import OError from '@overleaf/o-error'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
async function getCommentThreadIds(projectId) {
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
@@ -441,4 +441,4 @@ const RestoreManager = {
},
}
-module.exports = { ...callbackifyAll(RestoreManager), promises: RestoreManager }
+export default { ...callbackifyAll(RestoreManager), promises: RestoreManager }
diff --git a/services/web/app/src/Features/InactiveData/InactiveProjectController.mjs b/services/web/app/src/Features/InactiveData/InactiveProjectController.mjs
index 2e41e80167..825845acc8 100644
--- a/services/web/app/src/Features/InactiveData/InactiveProjectController.mjs
+++ b/services/web/app/src/Features/InactiveData/InactiveProjectController.mjs
@@ -9,7 +9,7 @@
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
-import InactiveProjectManager from './InactiveProjectManager.js'
+import InactiveProjectManager from './InactiveProjectManager.mjs'
export default {
deactivateOldProjects(req, res) {
diff --git a/services/web/app/src/Features/InactiveData/InactiveProjectManager.js b/services/web/app/src/Features/InactiveData/InactiveProjectManager.mjs
similarity index 82%
rename from services/web/app/src/Features/InactiveData/InactiveProjectManager.js
rename to services/web/app/src/Features/InactiveData/InactiveProjectManager.mjs
index 54bd81a500..8a9922a84d 100644
--- a/services/web/app/src/Features/InactiveData/InactiveProjectManager.js
+++ b/services/web/app/src/Features/InactiveData/InactiveProjectManager.mjs
@@ -1,14 +1,14 @@
-const OError = require('@overleaf/o-error')
-const logger = require('@overleaf/logger')
-const DocstoreManager = require('../Docstore/DocstoreManager')
-const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-const ProjectGetter = require('../Project/ProjectGetter')
-const ProjectUpdateHandler = require('../Project/ProjectUpdateHandler')
-const { Project } = require('../../models/Project')
-const Modules = require('../../infrastructure/Modules')
-const { READ_PREFERENCE_SECONDARY } = require('../../infrastructure/mongodb')
-const { callbackifyAll } = require('@overleaf/promise-utils')
-const Metrics = require('@overleaf/metrics')
+import OError from '@overleaf/o-error'
+import logger from '@overleaf/logger'
+import DocstoreManager from '../Docstore/DocstoreManager.js'
+import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import ProjectUpdateHandler from '../Project/ProjectUpdateHandler.js'
+import { Project } from '../../models/Project.js'
+import Modules from '../../infrastructure/Modules.js'
+import { READ_PREFERENCE_SECONDARY } from '../../infrastructure/mongodb.js'
+import { callbackifyAll } from '@overleaf/promise-utils'
+import Metrics from '@overleaf/metrics'
const MILISECONDS_IN_DAY = 86400000
@@ -132,7 +132,7 @@ const InactiveProjectManager = {
},
}
-module.exports = {
+export default {
...callbackifyAll(InactiveProjectManager),
promises: InactiveProjectManager,
findInactiveProjects,
diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
index f6290ef33f..1873d2f3cd 100644
--- a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
+++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
@@ -14,7 +14,7 @@ import SessionManager from '../Authentication/SessionManager.js'
import Settings from '@overleaf/settings'
import _ from 'lodash'
import AnalyticsManager from '../../../../app/src/Features/Analytics/AnalyticsManager.js'
-import LinkedFilesHandler from './LinkedFilesHandler.js'
+import LinkedFilesHandler from './LinkedFilesHandler.mjs'
import {
CompileFailedError,
UrlFetchFailedError,
diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.js b/services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs
similarity index 83%
rename from services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.js
rename to services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs
index a12f27f199..9e42cd069d 100644
--- a/services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.js
+++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs
@@ -1,14 +1,14 @@
-const FileWriter = require('../../infrastructure/FileWriter')
-const EditorController = require('../Editor/EditorController')
-const ProjectLocator = require('../Project/ProjectLocator')
-const { Project } = require('../../models/Project')
-const ProjectGetter = require('../Project/ProjectGetter')
-const {
+import FileWriter from '../../infrastructure/FileWriter.js'
+import EditorController from '../Editor/EditorController.js'
+import ProjectLocator from '../Project/ProjectLocator.js'
+import { Project } from '../../models/Project.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import {
ProjectNotFoundError,
V1ProjectNotFoundError,
BadDataError,
-} = require('./LinkedFilesErrors')
-const { callbackifyAll } = require('@overleaf/promise-utils')
+} from './LinkedFilesErrors.js'
+import { callbackifyAll } from '@overleaf/promise-utils'
const LinkedFilesHandler = {
async getFileById(projectId, fileId) {
@@ -100,7 +100,7 @@ const LinkedFilesHandler = {
},
}
-module.exports = {
+export default {
promises: LinkedFilesHandler,
...callbackifyAll(LinkedFilesHandler, {
multiResult: { getFileById: ['file', 'path', 'parentFolder'] },
diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs
index 53b49d093e..6868244311 100644
--- a/services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs
+++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs
@@ -1,4 +1,4 @@
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
import AuthenticationController from '../Authentication/AuthenticationController.js'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
diff --git a/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs b/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs
index b1d02b147a..76f845e19b 100644
--- a/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs
+++ b/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs
@@ -15,7 +15,7 @@ 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 LinkedFilesHandler from './LinkedFilesHandler.mjs'
import {
BadDataError,
diff --git a/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs b/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs
index f91809f9ff..b99522a5f0 100644
--- a/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs
+++ b/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs
@@ -9,7 +9,7 @@ import {
AccessDeniedError,
} from './LinkedFilesErrors.js'
import { OutputFileFetchFailedError } from '../Errors/Errors.js'
-import LinkedFilesHandler from './LinkedFilesHandler.js'
+import LinkedFilesHandler from './LinkedFilesHandler.mjs'
import { promisify } from '@overleaf/promise-utils'
function _prepare(projectId, linkedFileData, userId, callback) {
diff --git a/services/web/app/src/Features/LinkedFiles/UrlAgent.mjs b/services/web/app/src/Features/LinkedFiles/UrlAgent.mjs
index 12785d7c04..2e78ec7ccc 100644
--- a/services/web/app/src/Features/LinkedFiles/UrlAgent.mjs
+++ b/services/web/app/src/Features/LinkedFiles/UrlAgent.mjs
@@ -1,7 +1,7 @@
import logger from '@overleaf/logger'
import urlValidator from 'valid-url'
import { InvalidUrlError, UrlFetchFailedError } from './LinkedFilesErrors.js'
-import LinkedFilesHandler from './LinkedFilesHandler.js'
+import LinkedFilesHandler from './LinkedFilesHandler.mjs'
import UrlHelper from '../Helpers/UrlHelper.js'
import { fetchStream, RequestFailedError } from '@overleaf/fetch-utils'
import { callbackify } from '@overleaf/promise-utils'
diff --git a/services/web/app/src/Features/Project/ProjectAuditLogHandler.js b/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs
similarity index 84%
rename from services/web/app/src/Features/Project/ProjectAuditLogHandler.js
rename to services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs
index 1c31e1bb66..909e0fd8e1 100644
--- a/services/web/app/src/Features/Project/ProjectAuditLogHandler.js
+++ b/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs
@@ -1,8 +1,8 @@
-const logger = require('@overleaf/logger')
-const { ProjectAuditLogEntry } = require('../../models/ProjectAuditLogEntry')
-const { callbackify } = require('@overleaf/promise-utils')
+import logger from '@overleaf/logger'
+import { ProjectAuditLogEntry } from '../../models/ProjectAuditLogEntry.js'
+import { callbackify } from '@overleaf/promise-utils'
-module.exports = {
+export default {
promises: {
addEntry,
},
diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs
index 681af35e5c..6e58060eba 100644
--- a/services/web/app/src/Features/Project/ProjectController.mjs
+++ b/services/web/app/src/Features/Project/ProjectController.mjs
@@ -7,7 +7,7 @@ import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils'
import mongodb from 'mongodb-legacy'
import ProjectDeleter from './ProjectDeleter.js'
-import ProjectDuplicator from './ProjectDuplicator.js'
+import ProjectDuplicator from './ProjectDuplicator.mjs'
import ProjectCreationHandler from './ProjectCreationHandler.js'
import EditorController from '../Editor/EditorController.js'
import ProjectHelper from './ProjectHelper.js'
@@ -18,7 +18,7 @@ import { isPaidSubscription } from '../Subscription/SubscriptionHelper.js'
import LimitationsManager from '../Subscription/LimitationsManager.js'
import Settings from '@overleaf/settings'
import AuthorizationManager from '../Authorization/AuthorizationManager.js'
-import InactiveProjectManager from '../InactiveData/InactiveProjectManager.js'
+import InactiveProjectManager from '../InactiveData/InactiveProjectManager.mjs'
import ProjectUpdateHandler from './ProjectUpdateHandler.js'
import ProjectGetter from './ProjectGetter.js'
import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
@@ -29,7 +29,7 @@ import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.js'
import ProjectEntityHandler from './ProjectEntityHandler.js'
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js'
import Features from '../../infrastructure/Features.js'
-import BrandVariationsHandler from '../BrandVariations/BrandVariationsHandler.js'
+import BrandVariationsHandler from '../BrandVariations/BrandVariationsHandler.mjs'
import UserController from '../User/UserController.mjs'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
@@ -39,7 +39,7 @@ import SpellingHandler from '../Spelling/SpellingHandler.js'
import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.js'
import InstitutionsGetter from '../Institutions/InstitutionsGetter.js'
-import ProjectAuditLogHandler from './ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from './ProjectAuditLogHandler.mjs'
import PublicAccessLevels from '../Authorization/PublicAccessLevels.js'
import TagsHandler from '../Tags/TagsHandler.js'
import TutorialHandler from '../Tutorial/TutorialHandler.js'
diff --git a/services/web/app/src/Features/Project/ProjectDuplicator.js b/services/web/app/src/Features/Project/ProjectDuplicator.mjs
similarity index 85%
rename from services/web/app/src/Features/Project/ProjectDuplicator.js
rename to services/web/app/src/Features/Project/ProjectDuplicator.mjs
index d9fb914b55..5014eff139 100644
--- a/services/web/app/src/Features/Project/ProjectDuplicator.js
+++ b/services/web/app/src/Features/Project/ProjectDuplicator.mjs
@@ -1,27 +1,27 @@
-const { callbackify } = require('util')
-const Path = require('path')
-const logger = require('@overleaf/logger')
-const OError = require('@overleaf/o-error')
-const { promiseMapWithLimit } = require('@overleaf/promise-utils')
-const { Doc } = require('../../models/Doc')
-const { File } = require('../../models/File')
-const DocstoreManager = require('../Docstore/DocstoreManager')
-const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-const HistoryManager = require('../History/HistoryManager')
-const ProjectCreationHandler = require('./ProjectCreationHandler')
-const ProjectDeleter = require('./ProjectDeleter')
-const ProjectEntityMongoUpdateHandler = require('./ProjectEntityMongoUpdateHandler')
-const ProjectEntityUpdateHandler = require('./ProjectEntityUpdateHandler')
-const ProjectGetter = require('./ProjectGetter')
-const ProjectLocator = require('./ProjectLocator')
-const ProjectOptionsHandler = require('./ProjectOptionsHandler')
-const SafePath = require('./SafePath')
-const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
-const _ = require('lodash')
-const TagsHandler = require('../Tags/TagsHandler')
-const ClsiCacheManager = require('../Compile/ClsiCacheManager')
+import { callbackify } from 'node:util'
+import Path from 'node:path'
+import logger from '@overleaf/logger'
+import OError from '@overleaf/o-error'
+import { promiseMapWithLimit } from '@overleaf/promise-utils'
+import { Doc } from '../../models/Doc.js'
+import { File } from '../../models/File.js'
+import DocstoreManager from '../Docstore/DocstoreManager.js'
+import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
+import HistoryManager from '../History/HistoryManager.js'
+import ProjectCreationHandler from './ProjectCreationHandler.js'
+import ProjectDeleter from './ProjectDeleter.js'
+import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.js'
+import ProjectEntityUpdateHandler from './ProjectEntityUpdateHandler.js'
+import ProjectGetter from './ProjectGetter.js'
+import ProjectLocator from './ProjectLocator.js'
+import ProjectOptionsHandler from './ProjectOptionsHandler.js'
+import SafePath from './SafePath.js'
+import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js'
+import _ from 'lodash'
+import TagsHandler from '../Tags/TagsHandler.js'
+import ClsiCacheManager from '../Compile/ClsiCacheManager.js'
-module.exports = {
+export default {
duplicate: callbackify(duplicate),
promises: {
duplicate,
diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
index 9b7649128c..18ec8331c4 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
@@ -1,5 +1,5 @@
import AuthenticationController from '../Authentication/AuthenticationController.js'
-import PermissionsController from '../Authorization/PermissionsController.js'
+import PermissionsController from '../Authorization/PermissionsController.mjs'
import SubscriptionController from './SubscriptionController.js'
import SubscriptionGroupController from './SubscriptionGroupController.mjs'
import TeamInvitesController from './TeamInvitesController.mjs'
diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs b/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs
index 276524dd02..412678b574 100644
--- a/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs
+++ b/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs
@@ -7,7 +7,7 @@ import OError from '@overleaf/o-error'
import { expressify } from '@overleaf/promise-utils'
import AuthorizationManager from '../Authorization/AuthorizationManager.js'
import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
import CollaboratorsInviteHandler from '../Collaborators/CollaboratorsInviteHandler.mjs'
import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.js'
import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessRouter.mjs b/services/web/app/src/Features/TokenAccess/TokenAccessRouter.mjs
index 3f156505b1..4dfff3c49d 100644
--- a/services/web/app/src/Features/TokenAccess/TokenAccessRouter.mjs
+++ b/services/web/app/src/Features/TokenAccess/TokenAccessRouter.mjs
@@ -1,5 +1,5 @@
import AuthenticationController from '../Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
import TokenAccessController from './TokenAccessController.mjs'
export default {
diff --git a/services/web/app/src/Features/Uploads/UploadsRouter.mjs b/services/web/app/src/Features/Uploads/UploadsRouter.mjs
index 17ff5afc97..3aa477f535 100644
--- a/services/web/app/src/Features/Uploads/UploadsRouter.mjs
+++ b/services/web/app/src/Features/Uploads/UploadsRouter.mjs
@@ -1,4 +1,4 @@
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
import AuthenticationController from '../Authentication/AuthenticationController.js'
import ProjectUploadController from './ProjectUploadController.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs
index 69740e61b6..b856a23d75 100644
--- a/services/web/app/src/router.mjs
+++ b/services/web/app/src/router.mjs
@@ -13,7 +13,7 @@ import UploadsRouter from './Features/Uploads/UploadsRouter.mjs'
import metrics from '@overleaf/metrics'
import ReferalController from './Features/Referal/ReferalController.mjs'
import AuthenticationController from './Features/Authentication/AuthenticationController.js'
-import PermissionsController from './Features/Authorization/PermissionsController.js'
+import PermissionsController from './Features/Authorization/PermissionsController.mjs'
import SessionManager from './Features/Authentication/SessionManager.js'
import TagsController from './Features/Tags/TagsController.mjs'
import NotificationsController from './Features/Notifications/NotificationsController.mjs'
@@ -25,7 +25,7 @@ import UserPagesController from './Features/User/UserPagesController.mjs'
import TutorialController from './Features/Tutorial/TutorialController.mjs'
import DocumentController from './Features/Documents/DocumentController.mjs'
import CompileManager from './Features/Compile/CompileManager.js'
-import CompileController from './Features/Compile/CompileController.js'
+import CompileController from './Features/Compile/CompileController.mjs'
import HealthCheckController from './Features/HealthCheck/HealthCheckController.mjs'
import ProjectDownloadsController from './Features/Downloads/ProjectDownloadsController.mjs'
import FileStoreController from './Features/FileStore/FileStoreController.mjs'
@@ -45,7 +45,7 @@ import RateLimiterMiddleware from './Features/Security/RateLimiterMiddleware.js'
import InactiveProjectController from './Features/InactiveData/InactiveProjectController.mjs'
import ContactRouter from './Features/Contacts/ContactRouter.mjs'
import ReferencesController from './Features/References/ReferencesController.mjs'
-import AuthorizationMiddleware from './Features/Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from './Features/Authorization/AuthorizationMiddleware.mjs'
import BetaProgramController from './Features/BetaProgram/BetaProgramController.mjs'
import AnalyticsRouter from './Features/Analytics/AnalyticsRouter.mjs'
import MetaController from './Features/Metadata/MetaController.mjs'
diff --git a/services/web/modules/launchpad/app/src/LaunchpadRouter.mjs b/services/web/modules/launchpad/app/src/LaunchpadRouter.mjs
index b138c8de6f..b8adcfcbe7 100644
--- a/services/web/modules/launchpad/app/src/LaunchpadRouter.mjs
+++ b/services/web/modules/launchpad/app/src/LaunchpadRouter.mjs
@@ -2,7 +2,7 @@ import logger from '@overleaf/logger'
import LaunchpadController from './LaunchpadController.mjs'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
export default {
apply(webRouter) {
diff --git a/services/web/modules/user-activate/app/src/UserActivateRouter.mjs b/services/web/modules/user-activate/app/src/UserActivateRouter.mjs
index 402d05c562..4d4eedae9a 100644
--- a/services/web/modules/user-activate/app/src/UserActivateRouter.mjs
+++ b/services/web/modules/user-activate/app/src/UserActivateRouter.mjs
@@ -1,7 +1,7 @@
import logger from '@overleaf/logger'
import UserActivateController from './UserActivateController.mjs'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
export default {
apply(webRouter) {
diff --git a/services/web/scripts/check_project_files.js b/services/web/scripts/check_project_files.js
index a019f0bfa6..41d023e72f 100644
--- a/services/web/scripts/check_project_files.js
+++ b/services/web/scripts/check_project_files.js
@@ -70,7 +70,7 @@ function findPathCounts(projectId, docEntries, fileEntries) {
return pathCounts
}
-// copied from services/web/app/src/Features/Project/ProjectDuplicator.js
+// copied from services/web/app/src/Features/Project/ProjectDuplicator.mjs
function _getFolderEntries(folder, folderPath = '/') {
const docEntries = []
const fileEntries = []
diff --git a/services/web/scripts/deactivate_projects.mjs b/services/web/scripts/deactivate_projects.mjs
index b229af649d..cff78801e8 100755
--- a/services/web/scripts/deactivate_projects.mjs
+++ b/services/web/scripts/deactivate_projects.mjs
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import minimist from 'minimist'
import PQueue from 'p-queue'
-import InactiveProjectManager from '../app/src/Features/InactiveData/InactiveProjectManager.js'
+import InactiveProjectManager from '../app/src/Features/InactiveData/InactiveProjectManager.mjs'
import { gracefulShutdown } from '../app/src/infrastructure/GracefulShutdown.js'
import logger from '@overleaf/logger'
diff --git a/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js b/services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs
similarity index 57%
rename from services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js
rename to services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs
index c0ab761f27..ddc412a07e 100644
--- a/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js
+++ b/services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs
@@ -1,26 +1,27 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-const Errors = require('../../../../app/src/Features/Errors/Errors')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+
+const { ObjectId } = mongodb
const MODULE_PATH =
- '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
+ '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
describe('AuthorizationMiddleware', function () {
- beforeEach(function () {
- this.userId = new ObjectId().toString()
- this.project_id = new ObjectId().toString()
- this.doc_id = new ObjectId().toString()
- this.thread_id = new ObjectId().toString()
- this.token = 'some-token'
- this.AuthenticationController = {}
- this.SessionManager = {
+ beforeEach(async function (ctx) {
+ ctx.userId = new ObjectId().toString()
+ ctx.project_id = new ObjectId().toString()
+ ctx.doc_id = new ObjectId().toString()
+ ctx.thread_id = new ObjectId().toString()
+ ctx.token = 'some-token'
+ ctx.AuthenticationController = {}
+ ctx.SessionManager = {
getSessionUser: sinon.stub().returns(null),
- getLoggedInUserId: sinon.stub().returns(this.userId),
+ getLoggedInUserId: sinon.stub().returns(ctx.userId),
isUserLoggedIn: sinon.stub().returns(true),
}
- this.AuthorizationManager = {
+ ctx.AuthorizationManager = {
promises: {
canUserReadProject: sinon.stub(),
canUserWriteProjectSettings: sinon.stub(),
@@ -33,46 +34,88 @@ describe('AuthorizationMiddleware', function () {
isRestrictedUserForProject: sinon.stub(),
},
}
- this.HttpErrorHandler = {
+ ctx.HttpErrorHandler = {
forbidden: sinon.stub(),
}
- this.TokenAccessHandler = {
- getRequestToken: sinon.stub().returns(this.token),
+ ctx.TokenAccessHandler = {
+ getRequestToken: sinon.stub().returns(ctx.token),
}
- this.DocumentUpdaterHandler = {
+ ctx.DocumentUpdaterHandler = {
promises: {
getComment: sinon.stub().resolves(),
},
}
- this.AuthorizationMiddleware = SandboxedModule.require(MODULE_PATH, {
- requires: {
- './AuthorizationManager': this.AuthorizationManager,
- '../Errors/HttpErrorHandler': this.HttpErrorHandler,
- 'mongodb-legacy': { ObjectId },
- '../Authentication/AuthenticationController':
- this.AuthenticationController,
- '../Authentication/SessionManager': this.SessionManager,
- '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
- '../Helpers/AdminAuthorizationHelper': {
+
+ vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({
+ default: Errors,
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/Authorization/AuthorizationManager',
+ () => ({
+ default: ctx.AuthorizationManager,
+ })
+ )
+
+ vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
+ default: ctx.HttpErrorHandler,
+ }))
+
+ vi.doMock('mongodb-legacy', () => ({
+ default: { ObjectId },
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/Authentication/AuthenticationController',
+ () => ({
+ default: ctx.AuthenticationController,
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/Authentication/SessionManager',
+ () => ({
+ default: ctx.SessionManager,
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/TokenAccess/TokenAccessHandler',
+ () => ({
+ default: ctx.TokenAccessHandler,
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/Helpers/AdminAuthorizationHelper',
+ () => ({
+ default: {
canRedirectToAdminDomain: sinon.stub().returns(false),
},
- '../DocumentUpdater/DocumentUpdaterHandler':
- this.DocumentUpdaterHandler,
- },
- })
- this.req = {
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+ () => ({
+ default: ctx.DocumentUpdaterHandler,
+ })
+ )
+
+ ctx.AuthorizationMiddleware = (await import(MODULE_PATH)).default
+ ctx.req = {
params: {
- project_id: this.project_id,
+ project_id: ctx.project_id,
},
body: {},
}
- this.res = {
+ ctx.res = {
redirect: sinon.stub(),
locals: {
currentUrl: '/current/url',
},
}
- this.next = sinon.stub()
+ ctx.next = sinon.stub()
})
describe('ensureCanReadProject', function () {
@@ -94,13 +137,13 @@ describe('AuthorizationMiddleware', function () {
})
describe('ensureUserCanDeleteOrResolveThread', function () {
- beforeEach(function () {
- this.req.params.thread_id = this.thread_id
+ beforeEach(function (ctx) {
+ ctx.req.params.thread_id = ctx.thread_id
})
describe('when user has permission', function () {
- beforeEach(function () {
- this.AuthorizationManager.promises.canUserDeleteOrResolveThread
- .withArgs(this.userId, this.project_id, this.thread_id, this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises.canUserDeleteOrResolveThread
+ .withArgs(ctx.userId, ctx.project_id, ctx.thread_id, ctx.token)
.resolves(true)
})
@@ -109,9 +152,9 @@ describe('AuthorizationMiddleware', function () {
})
describe("when user doesn't have permission", function () {
- beforeEach(function () {
- this.AuthorizationManager.promises.canUserDeleteOrResolveThread
- .withArgs(this.userId, this.project_id, this.thread_id, this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises.canUserDeleteOrResolveThread
+ .withArgs(ctx.userId, ctx.project_id, ctx.thread_id, ctx.token)
.resolves(false)
})
@@ -122,8 +165,8 @@ describe('AuthorizationMiddleware', function () {
describe('ensureUserCanWriteProjectSettings', function () {
describe('when renaming a project', function () {
- beforeEach(function () {
- this.req.body.name = 'new project name'
+ beforeEach(function (ctx) {
+ ctx.req.body.name = 'new project name'
})
testMiddleware(
@@ -133,8 +176,8 @@ describe('AuthorizationMiddleware', function () {
})
describe('when setting another parameter', function () {
- beforeEach(function () {
- this.req.body.compiler = 'texlive-2017'
+ beforeEach(function (ctx) {
+ ctx.req.body.compiler = 'texlive-2017'
})
testMiddleware(
@@ -201,18 +244,18 @@ describe('AuthorizationMiddleware', function () {
})
describe('ensureUserCanReadMultipleProjects', function () {
- beforeEach(function () {
- this.req.query = { project_ids: 'project1,project2' }
+ beforeEach(function (ctx) {
+ ctx.req.query = { project_ids: 'project1,project2' }
})
describe('with logged in user', function () {
describe('when user has permission to access all projects', function () {
- beforeEach(function () {
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(this.userId, 'project1', this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(ctx.userId, 'project1', ctx.token)
.resolves(true)
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(this.userId, 'project2', this.token)
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(ctx.userId, 'project2', ctx.token)
.resolves(true)
})
@@ -221,12 +264,12 @@ describe('AuthorizationMiddleware', function () {
})
describe("when user doesn't have permission to access one of the projects", function () {
- beforeEach(function () {
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(this.userId, 'project1', this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(ctx.userId, 'project1', ctx.token)
.resolves(true)
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(this.userId, 'project2', this.token)
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(ctx.userId, 'project2', ctx.token)
.resolves(false)
})
@@ -238,12 +281,12 @@ describe('AuthorizationMiddleware', function () {
describe('with oauth user', function () {
setupOAuthUser()
- beforeEach(function () {
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(this.userId, 'project1', this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(ctx.userId, 'project1', ctx.token)
.resolves(true)
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(this.userId, 'project2', this.token)
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(ctx.userId, 'project2', ctx.token)
.resolves(true)
})
@@ -256,12 +299,12 @@ describe('AuthorizationMiddleware', function () {
describe('when user has permission', function () {
describe('when user has permission to access all projects', function () {
- beforeEach(function () {
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(null, 'project1', this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(null, 'project1', ctx.token)
.resolves(true)
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(null, 'project2', this.token)
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(null, 'project2', ctx.token)
.resolves(true)
})
@@ -270,12 +313,12 @@ describe('AuthorizationMiddleware', function () {
})
describe("when user doesn't have permission to access one of the projects", function () {
- beforeEach(function () {
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(null, 'project1', this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(null, 'project1', ctx.token)
.resolves(true)
- this.AuthorizationManager.promises.canUserReadProject
- .withArgs(null, 'project2', this.token)
+ ctx.AuthorizationManager.promises.canUserReadProject
+ .withArgs(null, 'project2', ctx.token)
.resolves(false)
})
@@ -350,99 +393,101 @@ function testMiddleware(middleware, permission) {
}
function setupAnonymousUser() {
- beforeEach('set up anonymous user', function () {
- this.SessionManager.getLoggedInUserId.returns(null)
- this.SessionManager.isUserLoggedIn.returns(false)
+ beforeEach(function (ctx) {
+ ctx.SessionManager.getLoggedInUserId.returns(null)
+ ctx.SessionManager.isUserLoggedIn.returns(false)
})
}
function setupOAuthUser() {
- beforeEach('set up oauth user', function () {
- this.SessionManager.getLoggedInUserId.returns(null)
- this.req.oauth_user = { _id: this.userId }
+ beforeEach(function (ctx) {
+ ctx.SessionManager.getLoggedInUserId.returns(null)
+ ctx.req.oauth_user = { _id: ctx.userId }
})
}
function setupPermission(permission, value) {
- beforeEach(`set permission ${permission} to ${value}`, function () {
- this.AuthorizationManager.promises[permission]
- .withArgs(this.userId, this.project_id, this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises[permission]
+ .withArgs(ctx.userId, ctx.project_id, ctx.token)
.resolves(value)
})
}
function setupAnonymousPermission(permission, value) {
- beforeEach(`set anonymous permission ${permission} to ${value}`, function () {
- this.AuthorizationManager.promises[permission]
- .withArgs(null, this.project_id, this.token)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises[permission]
+ .withArgs(null, ctx.project_id, ctx.token)
.resolves(value)
})
}
function setupSiteAdmin(value) {
- beforeEach(`set site admin to ${value}`, function () {
- this.AuthorizationManager.promises.isUserSiteAdmin
- .withArgs(this.userId)
+ beforeEach(function (ctx) {
+ ctx.AuthorizationManager.promises.isUserSiteAdmin
+ .withArgs(ctx.userId)
.resolves(value)
})
}
function setupMissingProjectId() {
- beforeEach('set up missing project id', function () {
- delete this.req.params.project_id
+ beforeEach(function (ctx) {
+ delete ctx.req.params.project_id
})
}
function setupMalformedProjectId() {
- beforeEach('set up malformed project id', function () {
- this.req.params = { project_id: 'bad-project-id' }
+ beforeEach(function (ctx) {
+ ctx.req.params = { project_id: 'bad-project-id' }
})
}
function invokeMiddleware(method) {
- beforeEach(`invoke ${method}`, function (done) {
- this.next.callsFake(() => done())
- this.HttpErrorHandler.forbidden.callsFake(() => done())
- this.res.redirect.callsFake(() => done())
- this.AuthorizationMiddleware[method](this.req, this.res, this.next)
+ beforeEach(async function (ctx) {
+ await new Promise(resolve => {
+ ctx.next.callsFake(() => resolve())
+ ctx.HttpErrorHandler.forbidden.callsFake(() => resolve())
+ ctx.res.redirect.callsFake(() => resolve())
+ ctx.AuthorizationMiddleware[method](ctx.req, ctx.res, ctx.next)
+ })
})
}
function expectNext() {
- it('calls the next middleware', function () {
- expect(this.next).to.have.been.calledWithExactly()
+ it('calls the next middleware', function (ctx) {
+ expect(ctx.next).to.have.been.calledWithExactly()
})
}
function expectError() {
- it('calls the error middleware', function () {
- expect(this.next).to.have.been.calledWith(sinon.match.instanceOf(Error))
+ it('calls the error middleware', function (ctx) {
+ expect(ctx.next).to.have.been.calledWith(sinon.match.instanceOf(Error))
})
}
function expectNotFound() {
- it('raises a 404', function () {
- expect(this.next).to.have.been.calledWith(
+ it('raises a 404', function (ctx) {
+ expect(ctx.next).to.have.been.calledWith(
sinon.match.instanceOf(Errors.NotFoundError)
)
})
}
function expectForbidden() {
- it('raises a 403', function () {
- expect(this.HttpErrorHandler.forbidden).to.have.been.calledWith(
- this.req,
- this.res
+ it('raises a 403', function (ctx) {
+ expect(ctx.HttpErrorHandler.forbidden).to.have.been.calledWith(
+ ctx.req,
+ ctx.res
)
- expect(this.next).not.to.have.been.called
+ expect(ctx.next).not.to.have.been.called
})
}
function expectRedirectToRestricted() {
- it('redirects to restricted', function () {
- expect(this.res.redirect).to.have.been.calledWith(
+ it('redirects to restricted', function (ctx) {
+ expect(ctx.res.redirect).to.have.been.calledWith(
'/restricted?from=%2Fcurrent%2Furl'
)
- expect(this.next).not.to.have.been.called
+ expect(ctx.next).not.to.have.been.called
})
}
diff --git a/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js b/services/web/test/unit/src/BrandVariations/BrandVariationsHandler.test.mjs
similarity index 50%
rename from services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js
rename to services/web/test/unit/src/BrandVariations/BrandVariationsHandler.test.mjs
index 75adfd7e9b..bf49a282bd 100644
--- a/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js
+++ b/services/web/test/unit/src/BrandVariations/BrandVariationsHandler.test.mjs
@@ -1,29 +1,12 @@
-/* eslint-disable
- n/handle-callback-err,
- max-len,
- no-return-assign,
- no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const assert = require('assert')
-const path = require('path')
-const sinon = require('sinon')
-const modulePath = path.join(
- __dirname,
- '../../../../app/src/Features/BrandVariations/BrandVariationsHandler'
-)
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const modulePath =
+ '../../../../app/src/Features/BrandVariations/BrandVariationsHandler.mjs'
describe('BrandVariationsHandler', function () {
- beforeEach(function () {
- this.settings = {
+ beforeEach(async function (ctx) {
+ ctx.settings = {
apis: {
v1: {
publicUrl: 'http://overleaf.example.com',
@@ -40,14 +23,18 @@ describe('BrandVariationsHandler', function () {
},
},
}
- this.V1Api = { request: sinon.stub() }
- this.BrandVariationsHandler = SandboxedModule.require(modulePath, {
- requires: {
- '@overleaf/settings': this.settings,
- '../V1/V1Api': this.V1Api,
- },
- })
- return (this.mockedBrandVariationDetails = {
+ ctx.V1Api = { request: sinon.stub() }
+
+ vi.doMock('@overleaf/settings', () => ({
+ default: ctx.settings,
+ }))
+
+ vi.doMock('../../../../app/src/Features/V1/V1Api', () => ({
+ default: ctx.V1Api,
+ }))
+
+ ctx.BrandVariationsHandler = (await import(modulePath)).default
+ ctx.mockedBrandVariationDetails = {
id: '12',
active: true,
brand_name: 'The journal',
@@ -55,81 +42,81 @@ describe('BrandVariationsHandler', function () {
journal_cover_url: 'http://my.cdn.tld/journal-cover.jpg',
home_url: 'http://www.thejournal.com/',
publish_menu_link_html: 'Submit your paper to the The Journal',
- })
+ }
})
describe('getBrandVariationById', function () {
- it('should reject with an error when the branding variation id is not provided', async function () {
+ it('should reject with an error when the branding variation id is not provided', async function (ctx) {
await expect(
- this.BrandVariationsHandler.promises.getBrandVariationById(null)
+ ctx.BrandVariationsHandler.promises.getBrandVariationById(null)
).to.be.rejected
})
- it('should reject with an error when the request errors', async function () {
- this.V1Api.request.callsArgWith(1, new Error())
+ it('should reject with an error when the request errors', async function (ctx) {
+ ctx.V1Api.request.callsArgWith(1, new Error())
await expect(
- this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
).to.be.rejected
})
- it('should return branding details when request succeeds', async function () {
- this.V1Api.request.callsArgWith(
+ it('should return branding details when request succeeds', async function (ctx) {
+ ctx.V1Api.request.callsArgWith(
1,
null,
{ statusCode: 200 },
- this.mockedBrandVariationDetails
+ ctx.mockedBrandVariationDetails
)
const brandVariationDetails =
- await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ await ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
expect(brandVariationDetails).to.deep.equal(
- this.mockedBrandVariationDetails
+ ctx.mockedBrandVariationDetails
)
})
- it('should transform relative URLs in v1 absolute ones', async function () {
- this.mockedBrandVariationDetails.logo_url = '/journal-logo.png'
- this.V1Api.request.callsArgWith(
+ it('should transform relative URLs in v1 absolute ones', async function (ctx) {
+ ctx.mockedBrandVariationDetails.logo_url = '/journal-logo.png'
+ ctx.V1Api.request.callsArgWith(
1,
null,
{ statusCode: 200 },
- this.mockedBrandVariationDetails
+ ctx.mockedBrandVariationDetails
)
const brandVariationDetails =
- await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ await ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
expect(
brandVariationDetails.logo_url.startsWith(
- this.settings.apis.v1.publicUrl
+ ctx.settings.apis.v1.publicUrl
)
).to.be.true
})
- it("should sanitize 'submit_button_html'", async function () {
- this.mockedBrandVariationDetails.submit_button_html =
+ it("should sanitize 'submit_button_html'", async function (ctx) {
+ ctx.mockedBrandVariationDetails.submit_button_html =
'
AGU Journal'
- this.V1Api.request.callsArgWith(
+ ctx.V1Api.request.callsArgWith(
1,
null,
{ statusCode: 200 },
- this.mockedBrandVariationDetails
+ ctx.mockedBrandVariationDetails
)
const brandVariationDetails =
- await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ await ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
expect(brandVariationDetails.submit_button_html).to.equal(
'
AGU Journalhello'
)
})
- it("should sanitize and remove breaks in 'submit_button_html_no_br'", async function () {
- this.mockedBrandVariationDetails.submit_button_html =
+ it("should sanitize and remove breaks in 'submit_button_html_no_br'", async function (ctx) {
+ ctx.mockedBrandVariationDetails.submit_button_html =
'Submit to
AGU Journal'
- this.V1Api.request.callsArgWith(
+ ctx.V1Api.request.callsArgWith(
1,
null,
{ statusCode: 200 },
- this.mockedBrandVariationDetails
+ ctx.mockedBrandVariationDetails
)
const brandVariationDetails =
- await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+ await ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
expect(brandVariationDetails.submit_button_html_no_br).to.equal(
'Submit to AGU Journalhello'
)
diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs
index f73274b6d9..b8b1500881 100644
--- a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs
+++ b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs
@@ -141,7 +141,7 @@ describe('CollaboratorsController', function () {
)
vi.doMock(
- '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+ '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
() => ({
default: ctx.ProjectAuditLogHandler,
})
diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs
index aafbb0dbc0..35f43f4b10 100644
--- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs
+++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs
@@ -130,7 +130,7 @@ describe('CollaboratorsInviteController', function () {
}))
vi.doMock(
- '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+ '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
() => ({
default: ctx.ProjectAuditLogHandler,
})
diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs
index 5d6690d7c0..00088197e2 100644
--- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs
+++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs
@@ -148,7 +148,7 @@ describe('CollaboratorsInviteHandler', function () {
)
vi.doMock(
- '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+ '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
() => ({
default: ctx.ProjectAuditLogHandler,
})
diff --git a/services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs b/services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs
index 2ce89ae061..b1362901dd 100644
--- a/services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs
+++ b/services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs
@@ -109,7 +109,7 @@ describe('OwnershipTransferHandler', function () {
)
vi.doMock(
- '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+ '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
() => ({
default: ctx.ProjectAuditLogHandler,
})
diff --git a/services/web/test/unit/src/Compile/CompileController.test.mjs b/services/web/test/unit/src/Compile/CompileController.test.mjs
new file mode 100644
index 0000000000..e8203be753
--- /dev/null
+++ b/services/web/test/unit/src/Compile/CompileController.test.mjs
@@ -0,0 +1,1003 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import MockRequest from '../helpers/MockRequest.js'
+import MockResponse from '../helpers/MockResponse.js'
+import { Headers } from 'node-fetch'
+import { ReadableString } from '@overleaf/stream-utils'
+import { RequestFailedError } from '@overleaf/fetch-utils'
+
+const modulePath = '../../../../app/src/Features/Compile/CompileController.mjs'
+
+describe('CompileController', function () {
+ beforeEach(async function (ctx) {
+ ctx.user_id = 'wat'
+ ctx.user = {
+ _id: ctx.user_id,
+ email: 'user@example.com',
+ features: {
+ compileGroup: 'premium',
+ compileTimeout: 100,
+ },
+ }
+ ctx.CompileManager = {
+ promises: {
+ compile: sinon.stub(),
+ getProjectCompileLimits: sinon.stub(),
+ syncTeX: sinon.stub(),
+ },
+ }
+ ctx.ClsiManager = {
+ promises: {},
+ }
+ ctx.UserGetter = { getUser: sinon.stub() }
+ ctx.rateLimiter = {
+ consume: sinon.stub().resolves(),
+ }
+ ctx.RateLimiter = {
+ RateLimiter: sinon.stub().returns(ctx.rateLimiter),
+ }
+ ctx.settings = {
+ apis: {
+ clsi: {
+ url: 'http://clsi.example.com',
+ submissionBackendClass: 'n2d',
+ },
+ clsi_priority: {
+ url: 'http://clsi-priority.example.com',
+ },
+ },
+ defaultFeatures: {
+ compileGroup: 'standard',
+ compileTimeout: 60,
+ },
+ clsiCookie: {
+ key: 'cookie-key',
+ },
+ }
+ ctx.ClsiCookieManager = {
+ promises: {
+ getServerId: sinon.stub().resolves('clsi-server-id-from-redis'),
+ },
+ }
+ ctx.SessionManager = {
+ getLoggedInUserId: sinon.stub().returns(ctx.user_id),
+ getSessionUser: sinon.stub().returns(ctx.user),
+ isUserLoggedIn: sinon.stub().returns(true),
+ }
+ ctx.pipeline = sinon.stub().callsFake(async (stream, res) => {
+ if (res.callback) res.callback()
+ })
+ ctx.clsiStream = new ReadableString('{}')
+ ctx.clsiResponse = {
+ headers: new Headers({
+ 'Content-Length': '2',
+ 'Content-Type': 'application/json',
+ }),
+ }
+ ctx.fetchUtils = {
+ fetchStreamWithResponse: sinon.stub().resolves({
+ stream: ctx.clsiStream,
+ response: ctx.clsiResponse,
+ }),
+ RequestFailedError,
+ }
+
+ vi.doMock('stream/promises', () => ({
+ pipeline: ctx.pipeline,
+ }))
+
+ vi.doMock('@overleaf/settings', () => ({
+ default: ctx.settings,
+ }))
+
+ vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
+
+ vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+ default: (ctx.ProjectGetter = {
+ promises: {},
+ }),
+ }))
+
+ vi.doMock('@overleaf/metrics', () => ({
+ default: (ctx.Metrics = {
+ inc: sinon.stub(),
+ Timer: class {
+ constructor() {
+ this.labels = {}
+ }
+
+ done() {}
+ },
+ }),
+ }))
+
+ vi.doMock('../../../../app/src/Features/Compile/CompileManager', () => ({
+ default: ctx.CompileManager,
+ }))
+
+ vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+ default: ctx.UserGetter,
+ }))
+
+ vi.doMock('../../../../app/src/Features/Compile/ClsiManager', () => ({
+ default: ctx.ClsiManager,
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/Authentication/SessionManager',
+ () => ({
+ default: ctx.SessionManager,
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/infrastructure/RateLimiter.js',
+ () => ctx.RateLimiter
+ )
+
+ vi.doMock('../../../../app/src/Features/Compile/ClsiCookieManager', () => ({
+ default: () => ctx.ClsiCookieManager,
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/SplitTests/SplitTestHandler',
+ () => ({
+ default: {
+ getAssignment: (ctx.getAssignment = sinon.stub().yields(null, {
+ variant: 'default',
+ })),
+ promises: {
+ getAssignment: sinon.stub().resolves({
+ variant: 'default',
+ }),
+ },
+ },
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/Analytics/AnalyticsManager',
+ () => ({
+ default: {
+ recordEventForSession: sinon.stub(),
+ },
+ })
+ )
+
+ ctx.CompileController = (await import(modulePath)).default
+ ctx.projectId = 'abc123def456abc123def456'
+ ctx.build_id = '18fbe9e7564-30dcb2f71250c690'
+ ctx.next = sinon.stub()
+ ctx.req = new MockRequest()
+ ctx.res = new MockResponse()
+ ctx.res = new MockResponse()
+ })
+
+ describe('compile', function () {
+ beforeEach(function (ctx) {
+ ctx.req.params = { Project_id: ctx.projectId }
+ ctx.req.session = {}
+ ctx.CompileManager.promises.compile = sinon.stub().resolves({
+ status: (ctx.status = 'success'),
+ outputFiles: (ctx.outputFiles = [
+ {
+ path: 'output.pdf',
+ url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
+ type: 'pdf',
+ },
+ ]),
+ clsiServerId: undefined,
+ limits: undefined,
+ validationProblems: undefined,
+ stats: undefined,
+ timings: undefined,
+ outputUrlPrefix: undefined,
+ buildId: ctx.build_id,
+ })
+ })
+
+ describe('pdfDownloadDomain', function () {
+ beforeEach(function (ctx) {
+ ctx.settings.pdfDownloadDomain = 'https://compiles.overleaf.test'
+ })
+
+ describe('when clsi does not emit zone prefix', function () {
+ beforeEach(async function (ctx) {
+ await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should add domain verbatim', function (ctx) {
+ ctx.res.statusCode.should.equal(200)
+ ctx.res.body.should.equal(
+ JSON.stringify({
+ status: ctx.status,
+ outputFiles: [
+ {
+ path: 'output.pdf',
+ url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
+ type: 'pdf',
+ },
+ ],
+ outputFilesArchive: {
+ path: 'output.zip',
+ url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
+ type: 'zip',
+ },
+ pdfDownloadDomain: 'https://compiles.overleaf.test',
+ })
+ )
+ })
+ })
+
+ describe('when clsi emits a zone prefix', function () {
+ beforeEach(async function (ctx) {
+ ctx.CompileManager.promises.compile = sinon.stub().resolves({
+ status: (ctx.status = 'success'),
+ outputFiles: (ctx.outputFiles = [
+ {
+ path: 'output.pdf',
+ url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
+ type: 'pdf',
+ },
+ ]),
+ clsiServerId: undefined,
+ limits: undefined,
+ validationProblems: undefined,
+ stats: undefined,
+ timings: undefined,
+ outputUrlPrefix: '/zone/b',
+ buildId: ctx.build_id,
+ })
+ await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should add the zone prefix', function (ctx) {
+ ctx.res.statusCode.should.equal(200)
+ ctx.res.body.should.equal(
+ JSON.stringify({
+ status: ctx.status,
+ outputFiles: [
+ {
+ path: 'output.pdf',
+ url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
+ type: 'pdf',
+ },
+ ],
+ outputFilesArchive: {
+ path: 'output.zip',
+ url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
+ type: 'zip',
+ },
+ outputUrlPrefix: '/zone/b',
+ pdfDownloadDomain: 'https://compiles.overleaf.test/zone/b',
+ })
+ )
+ })
+ })
+ })
+
+ describe('when not an auto compile', function () {
+ beforeEach(async function (ctx) {
+ await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should look up the user id', function (ctx) {
+ ctx.SessionManager.getLoggedInUserId
+ .calledWith(ctx.req.session)
+ .should.equal(true)
+ })
+
+ it('should do the compile without the auto compile flag', function (ctx) {
+ ctx.CompileManager.promises.compile.should.have.been.calledWith(
+ ctx.projectId,
+ ctx.user_id,
+ {
+ isAutoCompile: false,
+ compileFromClsiCache: false,
+ populateClsiCache: false,
+ enablePdfCaching: false,
+ fileLineErrors: false,
+ stopOnFirstError: false,
+ editorId: undefined,
+ }
+ )
+ })
+
+ it('should set the content-type of the response to application/json', function (ctx) {
+ ctx.res.type.should.equal('application/json')
+ })
+
+ it('should send a successful response reporting the status and files', function (ctx) {
+ ctx.res.statusCode.should.equal(200)
+ ctx.res.body.should.equal(
+ JSON.stringify({
+ status: ctx.status,
+ outputFiles: ctx.outputFiles,
+ outputFilesArchive: {
+ path: 'output.zip',
+ url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
+ type: 'zip',
+ },
+ })
+ )
+ })
+ })
+
+ describe('when an auto compile', function () {
+ beforeEach(async function (ctx) {
+ ctx.req.query = { auto_compile: 'true' }
+ await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should do the compile with the auto compile flag', function (ctx) {
+ ctx.CompileManager.promises.compile.should.have.been.calledWith(
+ ctx.projectId,
+ ctx.user_id,
+ {
+ isAutoCompile: true,
+ compileFromClsiCache: false,
+ populateClsiCache: false,
+ enablePdfCaching: false,
+ fileLineErrors: false,
+ stopOnFirstError: false,
+ editorId: undefined,
+ }
+ )
+ })
+ })
+
+ describe('with the draft attribute', function () {
+ beforeEach(async function (ctx) {
+ ctx.req.body = { draft: true }
+ await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should do the compile without the draft compile flag', function (ctx) {
+ ctx.CompileManager.promises.compile.should.have.been.calledWith(
+ ctx.projectId,
+ ctx.user_id,
+ {
+ isAutoCompile: false,
+ compileFromClsiCache: false,
+ populateClsiCache: false,
+ enablePdfCaching: false,
+ draft: true,
+ fileLineErrors: false,
+ stopOnFirstError: false,
+ editorId: undefined,
+ }
+ )
+ })
+ })
+
+ describe('with an editor id', function () {
+ beforeEach(async function (ctx) {
+ ctx.req.body = { editorId: 'the-editor-id' }
+ await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should pass the editor id to the compiler', function (ctx) {
+ ctx.CompileManager.promises.compile.should.have.been.calledWith(
+ ctx.projectId,
+ ctx.user_id,
+ {
+ isAutoCompile: false,
+ compileFromClsiCache: false,
+ populateClsiCache: false,
+ enablePdfCaching: false,
+ fileLineErrors: false,
+ stopOnFirstError: false,
+ editorId: 'the-editor-id',
+ }
+ )
+ })
+ })
+ })
+
+ describe('compileSubmission', function () {
+ beforeEach(function (ctx) {
+ ctx.submission_id = 'sub-1234'
+ ctx.req.params = { submission_id: ctx.submission_id }
+ ctx.req.body = {}
+ ctx.ClsiManager.promises.sendExternalRequest = sinon.stub().resolves({
+ status: (ctx.status = 'success'),
+ outputFiles: (ctx.outputFiles = ['mock-output-files']),
+ clsiServerId: 'mock-server-id',
+ validationProblems: null,
+ })
+ })
+
+ it('should set the content-type of the response to application/json', async function (ctx) {
+ await ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
+ ctx.res.contentType.calledWith('application/json').should.equal(true)
+ })
+
+ it('should send a successful response reporting the status and files', async function (ctx) {
+ await ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
+ ctx.res.statusCode.should.equal(200)
+ ctx.res.body.should.equal(
+ JSON.stringify({
+ status: ctx.status,
+ outputFiles: ctx.outputFiles,
+ clsiServerId: 'mock-server-id',
+ validationProblems: null,
+ })
+ )
+ })
+
+ describe('with compileGroup and timeout', function () {
+ beforeEach(function (ctx) {
+ ctx.req.body = {
+ compileGroup: 'special',
+ timeout: 600,
+ }
+ ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should use the supplied values', function (ctx) {
+ ctx.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
+ ctx.submission_id,
+ { compileGroup: 'special', timeout: 600 },
+ { compileGroup: 'special', compileBackendClass: 'n2d', timeout: 600 }
+ )
+ })
+ })
+
+ describe('with other supported options but not compileGroup and timeout', function () {
+ beforeEach(function (ctx) {
+ ctx.req.body = {
+ rootResourcePath: 'main.tex',
+ compiler: 'lualatex',
+ draft: true,
+ check: 'validate',
+ }
+ ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should use the other options but default values for compileGroup and timeout', function (ctx) {
+ ctx.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
+ ctx.submission_id,
+ {
+ rootResourcePath: 'main.tex',
+ compiler: 'lualatex',
+ draft: true,
+ check: 'validate',
+ },
+ {
+ rootResourcePath: 'main.tex',
+ compiler: 'lualatex',
+ draft: true,
+ check: 'validate',
+ compileGroup: 'standard',
+ compileBackendClass: 'n2d',
+ timeout: 60,
+ }
+ )
+ })
+ })
+ })
+
+ describe('downloadPdf', function () {
+ beforeEach(function (ctx) {
+ ctx.CompileController._proxyToClsi = sinon.stub().resolves()
+ ctx.req.params = { Project_id: ctx.projectId }
+ ctx.project = { name: 'test namè; 1' }
+ ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
+ })
+
+ describe('when downloading for embedding', function () {
+ beforeEach(async function (ctx) {
+ await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should look up the project', function (ctx) {
+ ctx.ProjectGetter.promises.getProject
+ .calledWith(ctx.projectId, { name: 1 })
+ .should.equal(true)
+ })
+
+ it('should set the content-type of the response to application/pdf', function (ctx) {
+ ctx.res.contentType.calledWith('application/pdf').should.equal(true)
+ })
+
+ it('should set the content-disposition header with a safe version of the project name', function (ctx) {
+ ctx.res.setContentDisposition.should.be.calledWith('inline', {
+ filename: 'test_namè__1.pdf',
+ })
+ })
+
+ it('should increment the pdf-downloads metric', function (ctx) {
+ ctx.Metrics.inc.calledWith('pdf-downloads').should.equal(true)
+ })
+
+ it('should proxy the PDF from the CLSI', function (ctx) {
+ ctx.CompileController._proxyToClsi
+ .calledWith(
+ ctx.projectId,
+ 'output-file',
+ `/project/${ctx.projectId}/user/${ctx.user_id}/output/output.pdf`,
+ {},
+ ctx.req,
+ ctx.res
+ )
+ .should.equal(true)
+ })
+ })
+
+ describe('when a build-id is provided', function () {
+ beforeEach(async function (ctx) {
+ ctx.req.params.build_id = ctx.build_id
+ await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should proxy the PDF from the CLSI, with a build-id', function (ctx) {
+ ctx.CompileController._proxyToClsi
+ .calledWith(
+ ctx.projectId,
+ 'output-file',
+ `/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.pdf`,
+ {},
+ ctx.req,
+ ctx.res
+ )
+ .should.equal(true)
+ })
+ })
+
+ describe('when rate-limited', function () {
+ beforeEach(async function (ctx) {
+ ctx.rateLimiter.consume.rejects({
+ msBeforeNext: 250,
+ remainingPoints: 0,
+ consumedPoints: 5,
+ isFirstInDuration: false,
+ })
+ })
+ it('should return 500', async function (ctx) {
+ await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
+ // should it be 429 instead?
+ ctx.res.sendStatus.calledWith(500).should.equal(true)
+ ctx.CompileController._proxyToClsi.should.not.have.been.called
+ })
+ })
+
+ describe('when rate-limit errors', function () {
+ beforeEach(async function (ctx) {
+ ctx.rateLimiter.consume.rejects(new Error('uh oh'))
+ })
+ it('should return 500', async function (ctx) {
+ await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
+ ctx.res.sendStatus.calledWith(500).should.equal(true)
+ ctx.CompileController._proxyToClsi.should.not.have.been.called
+ })
+ })
+ })
+
+ describe('getFileFromClsiWithoutUser', function () {
+ beforeEach(function (ctx) {
+ ctx.submission_id = 'sub-1234'
+ ctx.file = 'output.pdf'
+ ctx.req.params = {
+ submission_id: ctx.submission_id,
+ build_id: ctx.build_id,
+ file: ctx.file,
+ }
+ ctx.req.body = {}
+ ctx.expected_url = `/project/${ctx.submission_id}/build/${ctx.build_id}/output/${ctx.file}`
+ ctx.CompileController._proxyToClsiWithLimits = sinon.stub()
+ })
+
+ describe('without limits specified', function () {
+ beforeEach(async function (ctx) {
+ await ctx.CompileController.getFileFromClsiWithoutUser(
+ ctx.req,
+ ctx.res,
+ ctx.next
+ )
+ })
+
+ it('should proxy to CLSI with correct URL and default limits', function (ctx) {
+ ctx.CompileController._proxyToClsiWithLimits.should.have.been.calledWith(
+ ctx.submission_id,
+ 'output-file',
+ ctx.expected_url,
+ {},
+ { compileGroup: 'standard', compileBackendClass: 'n2d' }
+ )
+ })
+ })
+
+ describe('with limits specified', function () {
+ beforeEach(function (ctx) {
+ ctx.req.body = { compileTimeout: 600, compileGroup: 'special' }
+ ctx.CompileController.getFileFromClsiWithoutUser(
+ ctx.req,
+ ctx.res,
+ ctx.next
+ )
+ })
+
+ it('should proxy to CLSI with correct URL and specified limits', function (ctx) {
+ ctx.CompileController._proxyToClsiWithLimits.should.have.been.calledWith(
+ ctx.submission_id,
+ 'output-file',
+ ctx.expected_url,
+ {},
+ {
+ compileGroup: 'special',
+ compileBackendClass: 'n2d',
+ }
+ )
+ })
+ })
+ })
+ describe('proxySyncCode', function () {
+ let file, line, column, imageName, editorId, buildId, clsiServerId
+
+ beforeEach(async function (ctx) {
+ ctx.req.params = { Project_id: ctx.projectId }
+ clsiServerId = 'clsi-1'
+ file = 'main.tex'
+ line = String(Date.now())
+ column = String(Date.now() + 1)
+ editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
+ buildId = '195b4a3f9e7-03e5be430a9e7796'
+ ctx.req.query = {
+ file,
+ line,
+ column,
+ editorId,
+ buildId,
+ clsiserverid: clsiServerId,
+ }
+
+ imageName = 'foo/bar:tag-0'
+ ctx.ProjectGetter.promises.getProject = sinon
+ .stub()
+ .resolves({ imageName })
+
+ ctx.CompileController._proxyToClsi = sinon.stub().resolves()
+
+ await ctx.CompileController.proxySyncCode(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should parse the parameters', function (ctx) {
+ expect(ctx.CompileManager.promises.syncTeX).to.have.been.calledWith(
+ ctx.projectId,
+ ctx.user_id,
+ {
+ direction: 'code',
+ compileFromClsiCache: false,
+ validatedOptions: {
+ file,
+ line,
+ column,
+ editorId,
+ buildId,
+ },
+ clsiServerId,
+ }
+ )
+ })
+ })
+
+ describe('proxySyncPdf', function () {
+ let page, h, v, imageName, editorId, buildId, clsiServerId
+
+ beforeEach(async function (ctx) {
+ ctx.req.params = { Project_id: ctx.projectId }
+ clsiServerId = 'clsi-1'
+ page = String(Date.now())
+ h = String(Math.random())
+ v = String(Math.random())
+ editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
+ buildId = '195b4a3f9e7-03e5be430a9e7796'
+ ctx.req.query = {
+ page,
+ h,
+ v,
+ editorId,
+ buildId,
+ clsiserverid: clsiServerId,
+ }
+
+ imageName = 'foo/bar:tag-1'
+ ctx.ProjectGetter.promises.getProject = sinon
+ .stub()
+ .resolves({ imageName })
+
+ ctx.CompileController._proxyToClsi = sinon.stub()
+
+ await ctx.CompileController.proxySyncPdf(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should parse the parameters', function (ctx) {
+ expect(ctx.CompileManager.promises.syncTeX).to.have.been.calledWith(
+ ctx.projectId,
+ ctx.user_id,
+ {
+ direction: 'pdf',
+ compileFromClsiCache: false,
+ validatedOptions: {
+ page,
+ h,
+ v,
+ editorId,
+ buildId,
+ },
+ clsiServerId,
+ }
+ )
+ })
+ })
+
+ describe('_proxyToClsi', function () {
+ beforeEach(function (ctx) {
+ ctx.req.method = 'mock-method'
+ ctx.req.headers = {
+ Mock: 'Headers',
+ Range: '123-456',
+ 'If-Range': 'abcdef',
+ 'If-Modified-Since': 'Mon, 15 Dec 2014 15:23:56 GMT',
+ }
+ })
+
+ describe('old pdf viewer', function () {
+ describe('user with standard priority', function () {
+ beforeEach(async function (ctx) {
+ ctx.CompileManager.promises.getProjectCompileLimits = sinon
+ .stub()
+ .resolves({
+ compileGroup: 'standard',
+ compileBackendClass: 'n2d',
+ })
+ await ctx.CompileController._proxyToClsi(
+ ctx.projectId,
+ 'output-file',
+ (ctx.url = '/test'),
+ { query: 'foo' },
+ ctx.req,
+ ctx.res,
+ ctx.next
+ )
+ })
+
+ it('should open a request to the CLSI', function (ctx) {
+ ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+ `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=standard&compileBackendClass=n2d&query=foo`
+ )
+ })
+
+ it('should pass the request on to the client', function (ctx) {
+ ctx.pipeline.should.have.been.calledWith(ctx.clsiStream, ctx.res)
+ })
+ })
+
+ describe('user with priority compile', function () {
+ beforeEach(async function (ctx) {
+ ctx.CompileManager.promises.getProjectCompileLimits = sinon
+ .stub()
+ .resolves({
+ compileGroup: 'priority',
+ compileBackendClass: 'c2d',
+ })
+ await ctx.CompileController._proxyToClsi(
+ ctx.projectId,
+ 'output-file',
+ (ctx.url = '/test'),
+ {},
+ ctx.req,
+ ctx.res,
+ ctx.next
+ )
+ })
+
+ it('should open a request to the CLSI', function (ctx) {
+ ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+ `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=priority&compileBackendClass=c2d`
+ )
+ })
+ })
+
+ describe('user with standard priority via query string', function () {
+ beforeEach(async function (ctx) {
+ ctx.req.query = { compileGroup: 'standard' }
+ ctx.CompileManager.promises.getProjectCompileLimits = sinon
+ .stub()
+ .resolves({
+ compileGroup: 'standard',
+ compileBackendClass: 'n2d',
+ })
+ await ctx.CompileController._proxyToClsi(
+ ctx.projectId,
+ 'output-file',
+ (ctx.url = '/test'),
+ {},
+ ctx.req,
+ ctx.res,
+ ctx.next
+ )
+ })
+
+ it('should open a request to the CLSI', function (ctx) {
+ ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+ `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=standard&compileBackendClass=n2d`
+ )
+ })
+
+ it('should pass the request on to the client', function (ctx) {
+ ctx.pipeline.should.have.been.calledWith(ctx.clsiStream, ctx.res)
+ })
+ })
+
+ describe('user with non-existent priority via query string', function () {
+ beforeEach(async function (ctx) {
+ ctx.req.query = { compileGroup: 'foobar' }
+ ctx.CompileManager.promises.getProjectCompileLimits = sinon
+ .stub()
+ .resolves({
+ compileGroup: 'standard',
+ compileBackendClass: 'n2d',
+ })
+ await ctx.CompileController._proxyToClsi(
+ ctx.projectId,
+ 'output-file',
+ (ctx.url = '/test'),
+ {},
+ ctx.req,
+ ctx.res,
+ ctx.next
+ )
+ })
+
+ it('should proxy to the standard url', function (ctx) {
+ ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+ `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=standard&compileBackendClass=n2d`
+ )
+ })
+ })
+
+ describe('user with build parameter via query string', function () {
+ beforeEach(async function (ctx) {
+ ctx.CompileManager.promises.getProjectCompileLimits = sinon
+ .stub()
+ .resolves({
+ compileGroup: 'standard',
+ compileBackendClass: 'n2d',
+ })
+ ctx.req.query = { build: 1234 }
+ await ctx.CompileController._proxyToClsi(
+ ctx.projectId,
+ 'output-file',
+ (ctx.url = '/test'),
+ {},
+ ctx.req,
+ ctx.res,
+ ctx.next
+ )
+ })
+
+ it('should proxy to the standard url without the build parameter', function (ctx) {
+ ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+ `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=standard&compileBackendClass=n2d`
+ )
+ })
+ })
+ })
+ })
+
+ describe('deleteAuxFiles', function () {
+ beforeEach(async function (ctx) {
+ ctx.CompileManager.promises.deleteAuxFiles = sinon.stub().resolves()
+ ctx.req.params = { Project_id: ctx.projectId }
+ ctx.req.query = { clsiserverid: 'node-1' }
+ ctx.res.sendStatus = sinon.stub()
+ await ctx.CompileController.deleteAuxFiles(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should proxy to the CLSI', function (ctx) {
+ ctx.CompileManager.promises.deleteAuxFiles
+ .calledWith(ctx.projectId, ctx.user_id, 'node-1')
+ .should.equal(true)
+ })
+
+ it('should return a 200', function (ctx) {
+ ctx.res.sendStatus.calledWith(200).should.equal(true)
+ })
+ })
+
+ describe('compileAndDownloadPdf', function () {
+ beforeEach(function (ctx) {
+ ctx.req = {
+ params: {
+ project_id: ctx.projectId,
+ },
+ }
+ ctx.downloadPath = `/project/${ctx.projectId}/build/123/output/output.pdf`
+ ctx.CompileManager.promises.compile.resolves({
+ status: 'success',
+ outputFiles: [{ path: 'output.pdf', url: ctx.downloadPath }],
+ })
+ ctx.CompileController._proxyToClsi = sinon.stub()
+ ctx.res = {
+ send: () => {},
+ sendStatus: sinon.stub(),
+ }
+ })
+
+ it('should call compile in the compile manager', async function (ctx) {
+ await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
+ ctx.CompileManager.promises.compile
+ .calledWith(ctx.projectId)
+ .should.equal(true)
+ })
+
+ it('should proxy the res to the clsi with correct url', async function (ctx) {
+ await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
+ sinon.assert.calledWith(
+ ctx.CompileController._proxyToClsi,
+ ctx.projectId,
+ 'output-file',
+ ctx.downloadPath,
+ {},
+ ctx.req,
+ ctx.res
+ )
+
+ ctx.CompileController._proxyToClsi
+ .calledWith(
+ ctx.projectId,
+ 'output-file',
+ ctx.downloadPath,
+ {},
+ ctx.req,
+ ctx.res
+ )
+ .should.equal(true)
+ })
+
+ it('should not download anything on compilation failures', async function (ctx) {
+ ctx.CompileManager.promises.compile.rejects(new Error('failed'))
+ await ctx.CompileController.compileAndDownloadPdf(
+ ctx.req,
+ ctx.res,
+ ctx.next
+ )
+ ctx.res.sendStatus.should.have.been.calledWith(500)
+ ctx.CompileController._proxyToClsi.should.not.have.been.called
+ })
+
+ it('should not download anything on missing pdf', async function (ctx) {
+ ctx.CompileManager.promises.compile.resolves({
+ status: 'success',
+ outputFiles: [],
+ })
+ await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
+ ctx.res.sendStatus.should.have.been.calledWith(500)
+ ctx.CompileController._proxyToClsi.should.not.have.been.called
+ })
+ })
+
+ describe('wordCount', function () {
+ beforeEach(async function (ctx) {
+ ctx.CompileManager.promises.wordCount = sinon
+ .stub()
+ .resolves({ content: 'body' })
+ ctx.req.params = { Project_id: ctx.projectId }
+ ctx.req.query = { clsiserverid: 'node-42' }
+ ctx.res.json = sinon.stub()
+ ctx.res.contentType = sinon.stub()
+ await ctx.CompileController.wordCount(ctx.req, ctx.res, ctx.next)
+ })
+
+ it('should proxy to the CLSI', function (ctx) {
+ ctx.CompileManager.promises.wordCount
+ .calledWith(ctx.projectId, ctx.user_id, false, 'node-42')
+ .should.equal(true)
+ })
+
+ it('should return a 200 and body', function (ctx) {
+ ctx.res.json.calledWith({ content: 'body' }).should.equal(true)
+ })
+ })
+})
diff --git a/services/web/test/unit/src/Compile/CompileControllerTests.js b/services/web/test/unit/src/Compile/CompileControllerTests.js
deleted file mode 100644
index 25efe0d3d9..0000000000
--- a/services/web/test/unit/src/Compile/CompileControllerTests.js
+++ /dev/null
@@ -1,972 +0,0 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Compile/CompileController.js'
-const SandboxedModule = require('sandboxed-module')
-const MockRequest = require('../helpers/MockRequest')
-const MockResponse = require('../helpers/MockResponse')
-const { Headers } = require('node-fetch')
-const { ReadableString } = require('@overleaf/stream-utils')
-
-describe('CompileController', function () {
- beforeEach(function () {
- this.user_id = 'wat'
- this.user = {
- _id: this.user_id,
- email: 'user@example.com',
- features: {
- compileGroup: 'premium',
- compileTimeout: 100,
- },
- }
- this.CompileManager = {
- promises: {
- compile: sinon.stub(),
- getProjectCompileLimits: sinon.stub(),
- syncTeX: sinon.stub(),
- },
- }
- this.ClsiManager = {
- promises: {},
- }
- this.UserGetter = { getUser: sinon.stub() }
- this.rateLimiter = {
- consume: sinon.stub().resolves(),
- }
- this.RateLimiter = {
- RateLimiter: sinon.stub().returns(this.rateLimiter),
- }
- this.settings = {
- apis: {
- clsi: {
- url: 'http://clsi.example.com',
- submissionBackendClass: 'n2d',
- },
- clsi_priority: {
- url: 'http://clsi-priority.example.com',
- },
- },
- defaultFeatures: {
- compileGroup: 'standard',
- compileTimeout: 60,
- },
- clsiCookie: {
- key: 'cookie-key',
- },
- }
- this.ClsiCookieManager = {
- promises: {
- getServerId: sinon.stub().resolves('clsi-server-id-from-redis'),
- },
- }
- this.SessionManager = {
- getLoggedInUserId: sinon.stub().returns(this.user_id),
- getSessionUser: sinon.stub().returns(this.user),
- isUserLoggedIn: sinon.stub().returns(true),
- }
- this.pipeline = sinon.stub().callsFake(async (stream, res) => {
- if (res.callback) res.callback()
- })
- this.clsiStream = new ReadableString('{}')
- this.clsiResponse = {
- headers: new Headers({
- 'Content-Length': '2',
- 'Content-Type': 'application/json',
- }),
- }
- this.fetchUtils = {
- fetchStreamWithResponse: sinon.stub().resolves({
- stream: this.clsiStream,
- response: this.clsiResponse,
- }),
- }
- this.CompileController = SandboxedModule.require(modulePath, {
- requires: {
- 'stream/promises': { pipeline: this.pipeline },
- '@overleaf/settings': this.settings,
- '@overleaf/fetch-utils': this.fetchUtils,
- '../Project/ProjectGetter': (this.ProjectGetter = {
- promises: {},
- }),
- '@overleaf/metrics': (this.Metrics = {
- inc: sinon.stub(),
- Timer: class {
- constructor() {
- this.labels = {}
- }
-
- done() {}
- },
- }),
- // TODO: remove this once we remove Joi/Celebrate
- celebrate: (this.celebrate = {
- celebrate: sinon.stub(),
- errors: sinon.stub(),
- Joi: {
- any: sinon.stub(),
- extend: sinon.stub(),
- },
- }),
- './CompileManager': this.CompileManager,
- '../User/UserGetter': this.UserGetter,
- './ClsiManager': this.ClsiManager,
- '../Authentication/SessionManager': this.SessionManager,
- '../../infrastructure/RateLimiter': this.RateLimiter,
- './ClsiCookieManager': () => this.ClsiCookieManager,
- '../SplitTests/SplitTestHandler': {
- getAssignment: (this.getAssignment = sinon.stub().yields(null, {
- variant: 'default',
- })),
- promises: {
- getAssignment: sinon.stub().resolves({
- variant: 'default',
- }),
- },
- },
- '../Analytics/AnalyticsManager': {
- recordEventForSession: sinon.stub(),
- },
- },
- })
- this.projectId = 'abc123def456abc123def456'
- this.build_id = '18fbe9e7564-30dcb2f71250c690'
- this.next = sinon.stub()
- this.req = new MockRequest()
- this.res = new MockResponse()
- this.res = new MockResponse()
- })
-
- describe('compile', function () {
- beforeEach(function () {
- this.req.params = { Project_id: this.projectId }
- this.req.session = {}
- this.CompileManager.promises.compile = sinon.stub().resolves({
- status: (this.status = 'success'),
- outputFiles: (this.outputFiles = [
- {
- path: 'output.pdf',
- url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
- type: 'pdf',
- },
- ]),
- clsiServerId: undefined,
- limits: undefined,
- validationProblems: undefined,
- stats: undefined,
- timings: undefined,
- outputUrlPrefix: undefined,
- buildId: this.build_id,
- })
- })
-
- describe('pdfDownloadDomain', function () {
- beforeEach(function () {
- this.settings.pdfDownloadDomain = 'https://compiles.overleaf.test'
- })
-
- describe('when clsi does not emit zone prefix', function () {
- beforeEach(async function () {
- await this.CompileController.compile(this.req, this.res, this.next)
- })
-
- it('should add domain verbatim', function () {
- this.res.statusCode.should.equal(200)
- this.res.body.should.equal(
- JSON.stringify({
- status: this.status,
- outputFiles: [
- {
- path: 'output.pdf',
- url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
- type: 'pdf',
- },
- ],
- outputFilesArchive: {
- path: 'output.zip',
- url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
- type: 'zip',
- },
- pdfDownloadDomain: 'https://compiles.overleaf.test',
- })
- )
- })
- })
-
- describe('when clsi emits a zone prefix', function () {
- beforeEach(async function () {
- this.CompileManager.promises.compile = sinon.stub().resolves({
- status: (this.status = 'success'),
- outputFiles: (this.outputFiles = [
- {
- path: 'output.pdf',
- url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
- type: 'pdf',
- },
- ]),
- clsiServerId: undefined,
- limits: undefined,
- validationProblems: undefined,
- stats: undefined,
- timings: undefined,
- outputUrlPrefix: '/zone/b',
- buildId: this.build_id,
- })
- await this.CompileController.compile(this.req, this.res, this.next)
- })
-
- it('should add the zone prefix', function () {
- this.res.statusCode.should.equal(200)
- this.res.body.should.equal(
- JSON.stringify({
- status: this.status,
- outputFiles: [
- {
- path: 'output.pdf',
- url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
- type: 'pdf',
- },
- ],
- outputFilesArchive: {
- path: 'output.zip',
- url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
- type: 'zip',
- },
- outputUrlPrefix: '/zone/b',
- pdfDownloadDomain: 'https://compiles.overleaf.test/zone/b',
- })
- )
- })
- })
- })
-
- describe('when not an auto compile', function () {
- beforeEach(async function () {
- await this.CompileController.compile(this.req, this.res, this.next)
- })
-
- it('should look up the user id', function () {
- this.SessionManager.getLoggedInUserId
- .calledWith(this.req.session)
- .should.equal(true)
- })
-
- it('should do the compile without the auto compile flag', function () {
- this.CompileManager.promises.compile.should.have.been.calledWith(
- this.projectId,
- this.user_id,
- {
- isAutoCompile: false,
- compileFromClsiCache: false,
- populateClsiCache: false,
- enablePdfCaching: false,
- fileLineErrors: false,
- stopOnFirstError: false,
- editorId: undefined,
- }
- )
- })
-
- it('should set the content-type of the response to application/json', function () {
- this.res.type.should.equal('application/json')
- })
-
- it('should send a successful response reporting the status and files', function () {
- this.res.statusCode.should.equal(200)
- this.res.body.should.equal(
- JSON.stringify({
- status: this.status,
- outputFiles: this.outputFiles,
- outputFilesArchive: {
- path: 'output.zip',
- url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
- type: 'zip',
- },
- })
- )
- })
- })
-
- describe('when an auto compile', function () {
- beforeEach(async function () {
- this.req.query = { auto_compile: 'true' }
- await this.CompileController.compile(this.req, this.res, this.next)
- })
-
- it('should do the compile with the auto compile flag', function () {
- this.CompileManager.promises.compile.should.have.been.calledWith(
- this.projectId,
- this.user_id,
- {
- isAutoCompile: true,
- compileFromClsiCache: false,
- populateClsiCache: false,
- enablePdfCaching: false,
- fileLineErrors: false,
- stopOnFirstError: false,
- editorId: undefined,
- }
- )
- })
- })
-
- describe('with the draft attribute', function () {
- beforeEach(async function () {
- this.req.body = { draft: true }
- await this.CompileController.compile(this.req, this.res, this.next)
- })
-
- it('should do the compile without the draft compile flag', function () {
- this.CompileManager.promises.compile.should.have.been.calledWith(
- this.projectId,
- this.user_id,
- {
- isAutoCompile: false,
- compileFromClsiCache: false,
- populateClsiCache: false,
- enablePdfCaching: false,
- draft: true,
- fileLineErrors: false,
- stopOnFirstError: false,
- editorId: undefined,
- }
- )
- })
- })
-
- describe('with an editor id', function () {
- beforeEach(async function () {
- this.req.body = { editorId: 'the-editor-id' }
- await this.CompileController.compile(this.req, this.res, this.next)
- })
-
- it('should pass the editor id to the compiler', function () {
- this.CompileManager.promises.compile.should.have.been.calledWith(
- this.projectId,
- this.user_id,
- {
- isAutoCompile: false,
- compileFromClsiCache: false,
- populateClsiCache: false,
- enablePdfCaching: false,
- fileLineErrors: false,
- stopOnFirstError: false,
- editorId: 'the-editor-id',
- }
- )
- })
- })
- })
-
- describe('compileSubmission', function () {
- beforeEach(function () {
- this.submission_id = 'sub-1234'
- this.req.params = { submission_id: this.submission_id }
- this.req.body = {}
- this.ClsiManager.promises.sendExternalRequest = sinon.stub().resolves({
- status: (this.status = 'success'),
- outputFiles: (this.outputFiles = ['mock-output-files']),
- clsiServerId: 'mock-server-id',
- validationProblems: null,
- })
- })
-
- it('should set the content-type of the response to application/json', async function () {
- await this.CompileController.compileSubmission(
- this.req,
- this.res,
- this.next
- )
- this.res.contentType.calledWith('application/json').should.equal(true)
- })
-
- it('should send a successful response reporting the status and files', async function () {
- await this.CompileController.compileSubmission(
- this.req,
- this.res,
- this.next
- )
- this.res.statusCode.should.equal(200)
- this.res.body.should.equal(
- JSON.stringify({
- status: this.status,
- outputFiles: this.outputFiles,
- clsiServerId: 'mock-server-id',
- validationProblems: null,
- })
- )
- })
-
- describe('with compileGroup and timeout', function () {
- beforeEach(function () {
- this.req.body = {
- compileGroup: 'special',
- timeout: 600,
- }
- this.CompileController.compileSubmission(this.req, this.res, this.next)
- })
-
- it('should use the supplied values', function () {
- this.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
- this.submission_id,
- { compileGroup: 'special', timeout: 600 },
- { compileGroup: 'special', compileBackendClass: 'n2d', timeout: 600 }
- )
- })
- })
-
- describe('with other supported options but not compileGroup and timeout', function () {
- beforeEach(function () {
- this.req.body = {
- rootResourcePath: 'main.tex',
- compiler: 'lualatex',
- draft: true,
- check: 'validate',
- }
- this.CompileController.compileSubmission(this.req, this.res, this.next)
- })
-
- it('should use the other options but default values for compileGroup and timeout', function () {
- this.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
- this.submission_id,
- {
- rootResourcePath: 'main.tex',
- compiler: 'lualatex',
- draft: true,
- check: 'validate',
- },
- {
- rootResourcePath: 'main.tex',
- compiler: 'lualatex',
- draft: true,
- check: 'validate',
- compileGroup: 'standard',
- compileBackendClass: 'n2d',
- timeout: 60,
- }
- )
- })
- })
- })
-
- describe('downloadPdf', function () {
- beforeEach(function () {
- this.CompileController._proxyToClsi = sinon.stub().resolves()
- this.req.params = { Project_id: this.projectId }
- this.project = { name: 'test namè; 1' }
- this.ProjectGetter.promises.getProject = sinon
- .stub()
- .resolves(this.project)
- })
-
- describe('when downloading for embedding', function () {
- beforeEach(async function () {
- await this.CompileController.downloadPdf(this.req, this.res, this.next)
- })
-
- it('should look up the project', function () {
- this.ProjectGetter.promises.getProject
- .calledWith(this.projectId, { name: 1 })
- .should.equal(true)
- })
-
- it('should set the content-type of the response to application/pdf', function () {
- this.res.contentType.calledWith('application/pdf').should.equal(true)
- })
-
- it('should set the content-disposition header with a safe version of the project name', function () {
- this.res.setContentDisposition.should.be.calledWith('inline', {
- filename: 'test_namè__1.pdf',
- })
- })
-
- it('should increment the pdf-downloads metric', function () {
- this.Metrics.inc.calledWith('pdf-downloads').should.equal(true)
- })
-
- it('should proxy the PDF from the CLSI', function () {
- this.CompileController._proxyToClsi
- .calledWith(
- this.projectId,
- 'output-file',
- `/project/${this.projectId}/user/${this.user_id}/output/output.pdf`,
- {},
- this.req,
- this.res
- )
- .should.equal(true)
- })
- })
-
- describe('when a build-id is provided', function () {
- beforeEach(async function () {
- this.req.params.build_id = this.build_id
- await this.CompileController.downloadPdf(this.req, this.res, this.next)
- })
-
- it('should proxy the PDF from the CLSI, with a build-id', function () {
- this.CompileController._proxyToClsi
- .calledWith(
- this.projectId,
- 'output-file',
- `/project/${this.projectId}/user/${this.user_id}/build/${this.build_id}/output/output.pdf`,
- {},
- this.req,
- this.res
- )
- .should.equal(true)
- })
- })
-
- describe('when rate-limited', function () {
- beforeEach(async function () {
- this.rateLimiter.consume.rejects({
- msBeforeNext: 250,
- remainingPoints: 0,
- consumedPoints: 5,
- isFirstInDuration: false,
- })
- })
- it('should return 500', async function () {
- await this.CompileController.downloadPdf(this.req, this.res, this.next)
- // should it be 429 instead?
- this.res.sendStatus.calledWith(500).should.equal(true)
- this.CompileController._proxyToClsi.should.not.have.been.called
- })
- })
-
- describe('when rate-limit errors', function () {
- beforeEach(async function () {
- this.rateLimiter.consume.rejects(new Error('uh oh'))
- })
- it('should return 500', async function () {
- await this.CompileController.downloadPdf(this.req, this.res, this.next)
- this.res.sendStatus.calledWith(500).should.equal(true)
- this.CompileController._proxyToClsi.should.not.have.been.called
- })
- })
- })
-
- describe('getFileFromClsiWithoutUser', function () {
- beforeEach(function () {
- this.submission_id = 'sub-1234'
- this.file = 'output.pdf'
- this.req.params = {
- submission_id: this.submission_id,
- build_id: this.build_id,
- file: this.file,
- }
- this.req.body = {}
- this.expected_url = `/project/${this.submission_id}/build/${this.build_id}/output/${this.file}`
- this.CompileController._proxyToClsiWithLimits = sinon.stub()
- })
-
- describe('without limits specified', function () {
- beforeEach(async function () {
- await this.CompileController.getFileFromClsiWithoutUser(
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should proxy to CLSI with correct URL and default limits', function () {
- this.CompileController._proxyToClsiWithLimits.should.have.been.calledWith(
- this.submission_id,
- 'output-file',
- this.expected_url,
- {},
- { compileGroup: 'standard', compileBackendClass: 'n2d' }
- )
- })
- })
-
- describe('with limits specified', function () {
- beforeEach(function () {
- this.req.body = { compileTimeout: 600, compileGroup: 'special' }
- this.CompileController.getFileFromClsiWithoutUser(
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should proxy to CLSI with correct URL and specified limits', function () {
- this.CompileController._proxyToClsiWithLimits.should.have.been.calledWith(
- this.submission_id,
- 'output-file',
- this.expected_url,
- {},
- {
- compileGroup: 'special',
- compileBackendClass: 'n2d',
- }
- )
- })
- })
- })
- describe('proxySyncCode', function () {
- let file, line, column, imageName, editorId, buildId, clsiServerId
-
- beforeEach(async function () {
- this.req.params = { Project_id: this.projectId }
- clsiServerId = 'clsi-1'
- file = 'main.tex'
- line = String(Date.now())
- column = String(Date.now() + 1)
- editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
- buildId = '195b4a3f9e7-03e5be430a9e7796'
- this.req.query = {
- file,
- line,
- column,
- editorId,
- buildId,
- clsiserverid: clsiServerId,
- }
-
- imageName = 'foo/bar:tag-0'
- this.ProjectGetter.promises.getProject = sinon
- .stub()
- .resolves({ imageName })
-
- this.CompileController._proxyToClsi = sinon.stub().resolves()
-
- await this.CompileController.proxySyncCode(this.req, this.res, this.next)
- })
-
- it('should parse the parameters', function () {
- expect(this.CompileManager.promises.syncTeX).to.have.been.calledWith(
- this.projectId,
- this.user_id,
- {
- direction: 'code',
- compileFromClsiCache: false,
- validatedOptions: {
- file,
- line,
- column,
- editorId,
- buildId,
- },
- clsiServerId,
- }
- )
- })
- })
-
- describe('proxySyncPdf', function () {
- let page, h, v, imageName, editorId, buildId, clsiServerId
-
- beforeEach(async function () {
- this.req.params = { Project_id: this.projectId }
- clsiServerId = 'clsi-1'
- page = String(Date.now())
- h = String(Math.random())
- v = String(Math.random())
- editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
- buildId = '195b4a3f9e7-03e5be430a9e7796'
- this.req.query = {
- page,
- h,
- v,
- editorId,
- buildId,
- clsiserverid: clsiServerId,
- }
-
- imageName = 'foo/bar:tag-1'
- this.ProjectGetter.promises.getProject = sinon
- .stub()
- .resolves({ imageName })
-
- this.CompileController._proxyToClsi = sinon.stub()
-
- await this.CompileController.proxySyncPdf(this.req, this.res, this.next)
- })
-
- it('should parse the parameters', function () {
- expect(this.CompileManager.promises.syncTeX).to.have.been.calledWith(
- this.projectId,
- this.user_id,
- {
- direction: 'pdf',
- compileFromClsiCache: false,
- validatedOptions: {
- page,
- h,
- v,
- editorId,
- buildId,
- },
- clsiServerId,
- }
- )
- })
- })
-
- describe('_proxyToClsi', function () {
- beforeEach(function () {
- this.req.method = 'mock-method'
- this.req.headers = {
- Mock: 'Headers',
- Range: '123-456',
- 'If-Range': 'abcdef',
- 'If-Modified-Since': 'Mon, 15 Dec 2014 15:23:56 GMT',
- }
- })
-
- describe('old pdf viewer', function () {
- describe('user with standard priority', function () {
- beforeEach(async function () {
- this.CompileManager.promises.getProjectCompileLimits = sinon
- .stub()
- .resolves({
- compileGroup: 'standard',
- compileBackendClass: 'n2d',
- })
- await this.CompileController._proxyToClsi(
- this.projectId,
- 'output-file',
- (this.url = '/test'),
- { query: 'foo' },
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should open a request to the CLSI', function () {
- this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
- `${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=n2d&query=foo`
- )
- })
-
- it('should pass the request on to the client', function () {
- this.pipeline.should.have.been.calledWith(this.clsiStream, this.res)
- })
- })
-
- describe('user with priority compile', function () {
- beforeEach(async function () {
- this.CompileManager.promises.getProjectCompileLimits = sinon
- .stub()
- .resolves({
- compileGroup: 'priority',
- compileBackendClass: 'c2d',
- })
- await this.CompileController._proxyToClsi(
- this.projectId,
- 'output-file',
- (this.url = '/test'),
- {},
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should open a request to the CLSI', function () {
- this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
- `${this.settings.apis.clsi.url}${this.url}?compileGroup=priority&compileBackendClass=c2d`
- )
- })
- })
-
- describe('user with standard priority via query string', function () {
- beforeEach(async function () {
- this.req.query = { compileGroup: 'standard' }
- this.CompileManager.promises.getProjectCompileLimits = sinon
- .stub()
- .resolves({
- compileGroup: 'standard',
- compileBackendClass: 'n2d',
- })
- await this.CompileController._proxyToClsi(
- this.projectId,
- 'output-file',
- (this.url = '/test'),
- {},
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should open a request to the CLSI', function () {
- this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
- `${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=n2d`
- )
- })
-
- it('should pass the request on to the client', function () {
- this.pipeline.should.have.been.calledWith(this.clsiStream, this.res)
- })
- })
-
- describe('user with non-existent priority via query string', function () {
- beforeEach(async function () {
- this.req.query = { compileGroup: 'foobar' }
- this.CompileManager.promises.getProjectCompileLimits = sinon
- .stub()
- .resolves({
- compileGroup: 'standard',
- compileBackendClass: 'n2d',
- })
- await this.CompileController._proxyToClsi(
- this.projectId,
- 'output-file',
- (this.url = '/test'),
- {},
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should proxy to the standard url', function () {
- this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
- `${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=n2d`
- )
- })
- })
-
- describe('user with build parameter via query string', function () {
- beforeEach(async function () {
- this.CompileManager.promises.getProjectCompileLimits = sinon
- .stub()
- .resolves({
- compileGroup: 'standard',
- compileBackendClass: 'n2d',
- })
- this.req.query = { build: 1234 }
- await this.CompileController._proxyToClsi(
- this.projectId,
- 'output-file',
- (this.url = '/test'),
- {},
- this.req,
- this.res,
- this.next
- )
- })
-
- it('should proxy to the standard url without the build parameter', function () {
- this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
- `${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=n2d`
- )
- })
- })
- })
- })
-
- describe('deleteAuxFiles', function () {
- beforeEach(async function () {
- this.CompileManager.promises.deleteAuxFiles = sinon.stub().resolves()
- this.req.params = { Project_id: this.projectId }
- this.req.query = { clsiserverid: 'node-1' }
- this.res.sendStatus = sinon.stub()
- await this.CompileController.deleteAuxFiles(this.req, this.res, this.next)
- })
-
- it('should proxy to the CLSI', function () {
- this.CompileManager.promises.deleteAuxFiles
- .calledWith(this.projectId, this.user_id, 'node-1')
- .should.equal(true)
- })
-
- it('should return a 200', function () {
- this.res.sendStatus.calledWith(200).should.equal(true)
- })
- })
-
- describe('compileAndDownloadPdf', function () {
- beforeEach(function () {
- this.req = {
- params: {
- project_id: this.projectId,
- },
- }
- this.downloadPath = `/project/${this.projectId}/build/123/output/output.pdf`
- this.CompileManager.promises.compile.resolves({
- status: 'success',
- outputFiles: [{ path: 'output.pdf', url: this.downloadPath }],
- })
- this.CompileController._proxyToClsi = sinon.stub()
- this.res = { send: () => {}, sendStatus: sinon.stub() }
- })
-
- it('should call compile in the compile manager', async function () {
- await this.CompileController.compileAndDownloadPdf(this.req, this.res)
- this.CompileManager.promises.compile
- .calledWith(this.projectId)
- .should.equal(true)
- })
-
- it('should proxy the res to the clsi with correct url', async function () {
- await this.CompileController.compileAndDownloadPdf(this.req, this.res)
- sinon.assert.calledWith(
- this.CompileController._proxyToClsi,
- this.projectId,
- 'output-file',
- this.downloadPath,
- {},
- this.req,
- this.res
- )
-
- this.CompileController._proxyToClsi
- .calledWith(
- this.projectId,
- 'output-file',
- this.downloadPath,
- {},
- this.req,
- this.res
- )
- .should.equal(true)
- })
-
- it('should not download anything on compilation failures', async function () {
- this.CompileManager.promises.compile.rejects(new Error('failed'))
- await this.CompileController.compileAndDownloadPdf(
- this.req,
- this.res,
- this.next
- )
- this.res.sendStatus.should.have.been.calledWith(500)
- this.CompileController._proxyToClsi.should.not.have.been.called
- })
-
- it('should not download anything on missing pdf', async function () {
- this.CompileManager.promises.compile.resolves({
- status: 'success',
- outputFiles: [],
- })
- await this.CompileController.compileAndDownloadPdf(this.req, this.res)
- this.res.sendStatus.should.have.been.calledWith(500)
- this.CompileController._proxyToClsi.should.not.have.been.called
- })
- })
-
- describe('wordCount', function () {
- beforeEach(async function () {
- this.CompileManager.promises.wordCount = sinon
- .stub()
- .resolves({ content: 'body' })
- this.req.params = { Project_id: this.projectId }
- this.req.query = { clsiserverid: 'node-42' }
- this.res.json = sinon.stub()
- this.res.contentType = sinon.stub()
- await this.CompileController.wordCount(this.req, this.res, this.next)
- })
-
- it('should proxy to the CLSI', function () {
- this.CompileManager.promises.wordCount
- .calledWith(this.projectId, this.user_id, false, 'node-42')
- .should.equal(true)
- })
-
- it('should return a 200 and body', function () {
- this.res.json.calledWith({ content: 'body' }).should.equal(true)
- })
- })
-})
diff --git a/services/web/test/unit/src/HelperFiles/AuthorizationHelper.test.mjs b/services/web/test/unit/src/HelperFiles/AuthorizationHelper.test.mjs
new file mode 100644
index 0000000000..743a51bd96
--- /dev/null
+++ b/services/web/test/unit/src/HelperFiles/AuthorizationHelper.test.mjs
@@ -0,0 +1,67 @@
+import { vi, expect } from 'vitest'
+
+const modulePath = '../../../../app/src/Features/Helpers/AuthorizationHelper'
+
+describe('AuthorizationHelper', function () {
+ beforeEach(async function (ctx) {
+ vi.doMock('../../../../app/src/models/User', () => ({
+ UserSchema: {
+ obj: {
+ staffAccess: {
+ publisherMetrics: {},
+ publisherManagement: {},
+ institutionMetrics: {},
+ institutionManagement: {},
+ groupMetrics: {},
+ groupManagement: {},
+ adminMetrics: {},
+ },
+ },
+ },
+ }))
+
+ vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+ default: (ctx.ProjectGetter = { promises: {} }),
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/SplitTests/SplitTestHandler',
+ () => ({
+ default: (ctx.SplitTestHandler = {
+ promises: {},
+ }),
+ })
+ )
+
+ ctx.AuthorizationHelper = (await import(modulePath)).default
+ })
+
+ describe('hasAnyStaffAccess', function () {
+ it('with empty user', function (ctx) {
+ const user = {}
+ expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
+ })
+
+ it('with no access user', function (ctx) {
+ const user = { isAdmin: false, staffAccess: { adminMetrics: false } }
+ expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
+ })
+
+ it('with admin user', function (ctx) {
+ const user = { isAdmin: true }
+ expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
+ })
+
+ it('with staff user', function (ctx) {
+ const user = { staffAccess: { adminMetrics: true, somethingElse: false } }
+ expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.true
+ })
+
+ it('with non-staff user with extra attributes', function (ctx) {
+ // make sure that staffAccess attributes not declared on the model don't
+ // give user access
+ const user = { staffAccess: { adminMetrics: false, somethingElse: true } }
+ expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
+ })
+ })
+})
diff --git a/services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js b/services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js
deleted file mode 100644
index a82143dce6..0000000000
--- a/services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js
+++ /dev/null
@@ -1,66 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Helpers/AuthorizationHelper'
-
-describe('AuthorizationHelper', function () {
- beforeEach(function () {
- this.AuthorizationHelper = SandboxedModule.require(modulePath, {
- requires: {
- './AdminAuthorizationHelper': (this.AdminAuthorizationHelper = {
- hasAdminAccess: sinon.stub().returns(false),
- }),
- '../../models/User': {
- UserSchema: {
- obj: {
- staffAccess: {
- publisherMetrics: {},
- publisherManagement: {},
- institutionMetrics: {},
- institutionManagement: {},
- groupMetrics: {},
- groupManagement: {},
- adminMetrics: {},
- },
- },
- },
- },
- '../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
- '../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
- promises: {},
- }),
- },
- })
- })
-
- describe('hasAnyStaffAccess', function () {
- it('with empty user', function () {
- const user = {}
- expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
- })
-
- it('with no access user', function () {
- const user = { isAdmin: false, staffAccess: { adminMetrics: false } }
- expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
- })
-
- it('with admin user', function () {
- const user = { isAdmin: true }
- this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
- expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
- })
-
- it('with staff user', function () {
- const user = { staffAccess: { adminMetrics: true, somethingElse: false } }
- this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
- expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.true
- })
-
- it('with non-staff user with extra attributes', function () {
- // make sure that staffAccess attributes not declared on the model don't
- // give user access
- const user = { staffAccess: { adminMetrics: false, somethingElse: true } }
- expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
- })
- })
-})
diff --git a/services/web/test/unit/src/History/HistoryController.test.mjs b/services/web/test/unit/src/History/HistoryController.test.mjs
index 39cd25f766..38356108cf 100644
--- a/services/web/test/unit/src/History/HistoryController.test.mjs
+++ b/services/web/test/unit/src/History/HistoryController.test.mjs
@@ -106,9 +106,12 @@ describe('HistoryController', function () {
default: (ctx.ProjectGetter = {}),
}))
- vi.doMock('../../../../app/src/Features/History/RestoreManager.js', () => ({
- default: (ctx.RestoreManager = {}),
- }))
+ vi.doMock(
+ '../../../../app/src/Features/History/RestoreManager.mjs',
+ () => ({
+ default: (ctx.RestoreManager = {}),
+ })
+ )
vi.doMock('../../../../app/src/infrastructure/Features.js', () => ({
default: (ctx.Features = sinon.stub().withArgs('saas').returns(true)),
diff --git a/services/web/test/unit/src/History/RestoreManager.test.mjs b/services/web/test/unit/src/History/RestoreManager.test.mjs
new file mode 100644
index 0000000000..22b45ed261
--- /dev/null
+++ b/services/web/test/unit/src/History/RestoreManager.test.mjs
@@ -0,0 +1,1169 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+import tk from 'timekeeper'
+import moment from 'moment'
+
+const modulePath = '../../../../app/src/Features/History/RestoreManager'
+
+function nestedMapWithSetToObject(m) {
+ return Object.fromEntries(
+ Array.from(m.entries()).map(([key, set]) => [key, Array.from(set)])
+ )
+}
+
+describe('RestoreManager', function () {
+ beforeEach(async function (ctx) {
+ tk.freeze(Date.now()) // freeze the time for these tests
+
+ vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({
+ default: Errors,
+ }))
+
+ vi.doMock('../../../../app/src/infrastructure/Metrics.js', () => ({
+ default: {
+ revertFileDurationSeconds: {
+ startTimer: sinon.stub().returns(sinon.stub()),
+ },
+ revertProjectDurationSeconds: {
+ startTimer: sinon.stub().returns(sinon.stub()),
+ },
+ },
+ }))
+
+ vi.doMock('@overleaf/settings', () => ({
+ default: {},
+ }))
+
+ vi.doMock('../../../../app/src/infrastructure/FileWriter', () => ({
+ default: (ctx.FileWriter = { promises: {} }),
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/Uploads/FileSystemImportManager',
+ () => ({
+ default: (ctx.FileSystemImportManager = {
+ promises: {},
+ }),
+ })
+ )
+
+ vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({
+ default: (ctx.EditorController = {
+ promises: {},
+ }),
+ }))
+
+ vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+ default: (ctx.ProjectLocator = { promises: {} }),
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+ () => ({
+ default: (ctx.DocumentUpdaterHandler = {
+ promises: { flushProjectToMongo: sinon.stub().resolves() },
+ }),
+ })
+ )
+
+ vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
+ default: (ctx.DocstoreManager = {
+ promises: { getCommentThreadIds: sinon.stub().resolves({}) },
+ }),
+ }))
+
+ vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler', () => ({
+ default: (ctx.ChatApiHandler = { promises: {} }),
+ }))
+
+ vi.doMock('../../../../app/src/Features/Chat/ChatManager', () => ({
+ default: (ctx.ChatManager = { promises: {} }),
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/Editor/EditorRealTimeController',
+ () => ({
+ default: (ctx.EditorRealTimeController = {}),
+ })
+ )
+
+ vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+ default: (ctx.ProjectGetter = { promises: {} }),
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/Project/ProjectEntityHandler',
+ () => ({
+ default: (ctx.ProjectEntityHandler = {
+ promises: {},
+ }),
+ })
+ )
+
+ ctx.RestoreManager = (await import(modulePath)).default
+ ctx.user_id = 'mock-user-id'
+ ctx.project_id = 'mock-project-id'
+ ctx.version = 42
+ })
+
+ afterEach(function () {
+ tk.reset()
+ })
+
+ describe('restoreFileFromV2', function () {
+ beforeEach(function (ctx) {
+ ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
+ .stub()
+ .resolves((ctx.fsPath = '/tmp/path/on/disk'))
+ ctx.RestoreManager.promises._findOrCreateFolder = sinon
+ .stub()
+ .resolves((ctx.folder_id = 'mock-folder-id'))
+ ctx.FileSystemImportManager.promises.addEntity = sinon
+ .stub()
+ .resolves((ctx.entity = 'mock-entity'))
+ })
+
+ describe('with a file not in a folder', function () {
+ beforeEach(async function (ctx) {
+ ctx.pathname = 'foo.tex'
+ ctx.result = await ctx.RestoreManager.promises.restoreFileFromV2(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should write the file version to disk', function (ctx) {
+ ctx.RestoreManager.promises._writeFileVersionToDisk
+ .calledWith(ctx.project_id, ctx.version, ctx.pathname)
+ .should.equal(true)
+ })
+
+ it('should find the root folder', function (ctx) {
+ ctx.RestoreManager.promises._findOrCreateFolder
+ .calledWith(ctx.project_id, '', ctx.user_id)
+ .should.equal(true)
+ })
+
+ it('should add the entity', function (ctx) {
+ ctx.FileSystemImportManager.promises.addEntity
+ .calledWith(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.folder_id,
+ 'foo.tex',
+ ctx.fsPath,
+ false
+ )
+ .should.equal(true)
+ })
+
+ it('should return the entity', function (ctx) {
+ expect(ctx.result).to.equal(ctx.entity)
+ })
+ })
+
+ describe('with a file in a folder', function () {
+ beforeEach(async function (ctx) {
+ ctx.pathname = 'foo/bar.tex'
+ await ctx.RestoreManager.promises.restoreFileFromV2(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should find the folder', function (ctx) {
+ ctx.RestoreManager.promises._findOrCreateFolder
+ .calledWith(ctx.project_id, 'foo', ctx.user_id)
+ .should.equal(true)
+ })
+
+ it('should add the entity by its basename', function (ctx) {
+ ctx.FileSystemImportManager.promises.addEntity
+ .calledWith(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.folder_id,
+ 'bar.tex',
+ ctx.fsPath,
+ false
+ )
+ .should.equal(true)
+ })
+ })
+ })
+
+ describe('_findOrCreateFolder', function () {
+ beforeEach(async function (ctx) {
+ ctx.EditorController.promises.mkdirp = sinon.stub().resolves({
+ newFolders: [],
+ lastFolder: { _id: (ctx.folder_id = 'mock-folder-id') },
+ })
+ ctx.result = await ctx.RestoreManager.promises._findOrCreateFolder(
+ ctx.project_id,
+ 'folder/name',
+ ctx.user_id
+ )
+ })
+
+ it('should look up or create the folder', function (ctx) {
+ ctx.EditorController.promises.mkdirp
+ .calledWith(ctx.project_id, 'folder/name', ctx.user_id)
+ .should.equal(true)
+ })
+
+ it('should return the folder_id', function (ctx) {
+ expect(ctx.result).to.equal(ctx.folder_id)
+ })
+ })
+
+ describe('_addEntityWithUniqueName', function () {
+ beforeEach(function (ctx) {
+ ctx.addEntityWithName = sinon.stub()
+ ctx.filename = 'foo.tex'
+ })
+
+ describe('with a valid name', function () {
+ beforeEach(async function (ctx) {
+ ctx.addEntityWithName.resolves((ctx.entity = 'mock-entity'))
+ ctx.result = await ctx.RestoreManager.promises._addEntityWithUniqueName(
+ ctx.addEntityWithName,
+ ctx.filename
+ )
+ })
+
+ it('should add the entity', function (ctx) {
+ ctx.addEntityWithName.calledWith(ctx.filename).should.equal(true)
+ })
+
+ it('should return the entity', function (ctx) {
+ expect(ctx.result).to.equal(ctx.entity)
+ })
+ })
+
+ describe('with a duplicate name', function () {
+ beforeEach(async function (ctx) {
+ ctx.addEntityWithName.rejects(new Errors.DuplicateNameError())
+ ctx.addEntityWithName
+ .onSecondCall()
+ .resolves((ctx.entity = 'mock-entity'))
+ ctx.result = await ctx.RestoreManager.promises._addEntityWithUniqueName(
+ ctx.addEntityWithName,
+ ctx.filename
+ )
+ })
+
+ it('should try to add the entity with its original name', function (ctx) {
+ ctx.addEntityWithName.calledWith('foo.tex').should.equal(true)
+ })
+
+ it('should try to add the entity with a unique name', function (ctx) {
+ const date = moment(new Date()).format('Do MMM YY H:mm:ss')
+ ctx.addEntityWithName
+ .calledWith(`foo (Restored on ${date}).tex`)
+ .should.equal(true)
+ })
+
+ it('should return the entity', function (ctx) {
+ expect(ctx.result).to.equal(ctx.entity)
+ })
+ })
+ })
+
+ describe('revertFile', function () {
+ beforeEach(function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
+ ctx.ProjectGetter.promises.getProject.withArgs(ctx.project_id).resolves({
+ overleaf: { history: { rangesSupportEnabled: true } },
+ rootDoc_id: 'root-doc-id',
+ })
+ ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
+ .stub()
+ .resolves((ctx.fsPath = '/tmp/path/on/disk'))
+ ctx.RestoreManager.promises._findOrCreateFolder = sinon
+ .stub()
+ .resolves((ctx.folder_id = 'mock-folder-id'))
+ ctx.FileSystemImportManager.promises.addEntity = sinon
+ .stub()
+ .resolves((ctx.entity = 'mock-entity'))
+ ctx.RestoreManager.promises._getRangesFromHistory = sinon.stub().rejects()
+ ctx.RestoreManager.promises._getMetadataFromHistory = sinon
+ .stub()
+ .resolves({ metadata: undefined })
+ })
+
+ describe('reverting a project without ranges support', function () {
+ beforeEach(function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub().resolves({
+ overleaf: { history: { rangesSupportEnabled: false } },
+ })
+ })
+
+ it('should throw an error', async function (ctx) {
+ await expect(
+ ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ ).to.eventually.be.rejectedWith('project does not have ranges support')
+ })
+ })
+
+ describe('reverting a document with ranges', function () {
+ beforeEach(function (ctx) {
+ ctx.pathname = 'foo.tex'
+ ctx.comments = [
+ {
+ id: 'comment-in-other-doc',
+ op: { t: 'comment-in-other-doc', p: 0, c: 'foo' },
+ },
+ {
+ id: 'single-comment',
+ op: { t: 'single-comment', p: 10, c: 'bar' },
+ },
+ {
+ id: 'deleted-comment',
+ op: { t: 'deleted-comment', p: 20, c: 'baz' },
+ },
+ ]
+ ctx.remappedComments = [
+ {
+ id: 'duplicate-comment',
+ op: { t: 'duplicate-comment', p: 0, c: 'foo' },
+ },
+ {
+ id: 'single-comment',
+ op: { t: 'single-comment', p: 10, c: 'bar' },
+ },
+ ]
+ ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
+ ctx.DocstoreManager.promises.getCommentThreadIds = sinon
+ .stub()
+ .resolves({ 'other-doc': [ctx.comments[0].op.t] })
+ ctx.ChatApiHandler.promises.duplicateCommentThreads = sinon
+ .stub()
+ .resolves({
+ newThreads: {
+ 'comment-in-other-doc': {
+ duplicateId: 'duplicate-comment',
+ },
+ },
+ })
+ ctx.ChatApiHandler.promises.generateThreadData = sinon.stub().resolves(
+ (ctx.threadData = {
+ 'single-comment': {
+ messages: [
+ {
+ content: 'message-content',
+ timestamp: '2024-01-01T00:00:00.000Z',
+ user_id: 'user-1',
+ },
+ ],
+ },
+ 'duplicate-comment': {
+ messages: [
+ {
+ content: 'another message',
+ timestamp: '2024-01-01T00:00:00.000Z',
+ user_id: 'user-1',
+ },
+ ],
+ },
+ })
+ )
+ ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
+ .stub()
+ .resolves(ctx.threadData)
+
+ ctx.EditorRealTimeController.emitToRoom = sinon.stub()
+ ctx.tracked_changes = [
+ {
+ op: { pos: 4, i: 'bar' },
+ metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
+ },
+ {
+ op: { pos: 8, d: 'qux' },
+ metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
+ },
+ ]
+ ctx.FileSystemImportManager.promises.importFile = sinon
+ .stub()
+ .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
+ ctx.RestoreManager.promises._getRangesFromHistory = sinon
+ .stub()
+ .resolves({
+ changes: ctx.tracked_changes,
+ comments: ctx.comments,
+ })
+ ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
+ .stub()
+ .resolves([
+ { toV: ctx.version, meta: { end_ts: (ctx.endTs = new Date()) } },
+ ])
+ ctx.EditorController.promises.addDocWithRanges = sinon
+ .stub()
+ .resolves((ctx.addedFile = { _id: 'mock-doc', type: 'doc' }))
+ })
+
+ describe("when reverting a file that doesn't current exist", function () {
+ beforeEach(async function (ctx) {
+ ctx.data = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should flush the document before fetching ranges', function (ctx) {
+ expect(
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongo
+ ).to.have.been.calledBefore(
+ ctx.DocstoreManager.promises.getCommentThreadIds
+ )
+ })
+
+ it('should import the file', function (ctx) {
+ expect(
+ ctx.EditorController.promises.addDocWithRanges
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ ctx.folder_id,
+ 'foo.tex',
+ ['foo', 'bar', 'baz'],
+ { changes: ctx.tracked_changes, comments: ctx.remappedComments }
+ )
+ })
+
+ it('should return the created entity', function (ctx) {
+ expect(ctx.data).to.deep.equal(ctx.addedFile)
+ })
+
+ it('should look up ranges', function (ctx) {
+ expect(
+ ctx.RestoreManager.promises._getRangesFromHistory
+ ).to.have.been.calledWith(ctx.project_id, ctx.version, ctx.pathname)
+ })
+ })
+
+ describe('with an existing file in the current project', function () {
+ beforeEach(async function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
+ ctx.ProjectGetter.promises.getProject
+ .withArgs(ctx.project_id)
+ .resolves({
+ overleaf: { history: { rangesSupportEnabled: true } },
+ rootDoc_id: 'root-doc-id',
+ })
+ ctx.ProjectLocator.promises.findElementByPath = sinon
+ .stub()
+ .resolves({ type: 'file', element: { _id: 'mock-file-id' } })
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+
+ ctx.data = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should delete the existing file', async function (ctx) {
+ expect(
+ ctx.EditorController.promises.deleteEntity
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ 'mock-file-id',
+ 'file',
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ },
+ ctx.user_id
+ )
+ })
+ })
+
+ describe('with an existing document in the current project', function () {
+ beforeEach(async function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
+ ctx.ProjectGetter.promises.getProject
+ .withArgs(ctx.project_id)
+ .resolves({
+ overleaf: { history: { rangesSupportEnabled: true } },
+ rootDoc_id: 'root-doc-id',
+ })
+ ctx.ProjectLocator.promises.findElementByPath = sinon
+ .stub()
+ .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+
+ ctx.data = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should delete the existing document', async function (ctx) {
+ expect(
+ ctx.EditorController.promises.deleteEntity
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ 'mock-file-id',
+ 'doc',
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ },
+ ctx.user_id
+ )
+ })
+
+ it('should flush the document before fetching ranges', function (ctx) {
+ expect(
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongo
+ ).to.have.been.calledBefore(
+ ctx.DocstoreManager.promises.getCommentThreadIds
+ )
+ })
+
+ it('should import the file', function (ctx) {
+ expect(
+ ctx.EditorController.promises.addDocWithRanges
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ ctx.folder_id,
+ 'foo.tex',
+ ['foo', 'bar', 'baz'],
+ { changes: ctx.tracked_changes, comments: ctx.remappedComments },
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ }
+ )
+ })
+
+ it('should return the created entity', function (ctx) {
+ expect(ctx.data).to.deep.equal(ctx.addedFile)
+ })
+
+ it('should look up ranges', function (ctx) {
+ expect(
+ ctx.RestoreManager.promises._getRangesFromHistory
+ ).to.have.been.calledWith(ctx.project_id, ctx.version, ctx.pathname)
+ })
+ })
+
+ describe('with comments in same doc', function () {
+ // copy of the above, addition: inject and later inspect threadIds set
+ beforeEach(async function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
+ ctx.ProjectGetter.promises.getProject
+ .withArgs(ctx.project_id)
+ .resolves({
+ overleaf: { history: { rangesSupportEnabled: true } },
+ rootDoc_id: 'root-doc-id',
+ })
+ ctx.ProjectLocator.promises.findElementByPath = sinon
+ .stub()
+ .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+ ctx.ChatApiHandler.promises.generateThreadData = sinon
+ .stub()
+ .resolves(
+ (ctx.threadData = {
+ [ctx.comments[0].op.t]: {
+ messages: [
+ {
+ content: 'message',
+ timestamp: '2024-01-01T00:00:00.000Z',
+ user_id: 'user-1',
+ },
+ ],
+ },
+ [ctx.comments[1].op.t]: {
+ messages: [
+ {
+ content: 'other message',
+ timestamp: '2024-01-01T00:00:00.000Z',
+ user_id: 'user-1',
+ },
+ ],
+ },
+ })
+ )
+
+ ctx.threadIds = new Map([
+ [
+ 'mock-file-id',
+ new Set([ctx.comments[0].op.t, ctx.comments[1].op.t]),
+ ],
+ ])
+ // Comments are updated in-place. Look up threads before reverting.
+ ctx.afterThreadIds = {
+ // mock-file-id removed
+ [ctx.addedFile._id]: [ctx.comments[0].op.t, ctx.comments[1].op.t],
+ }
+ ctx.data = await ctx.RestoreManager.promises._revertSingleFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname,
+ ctx.threadIds
+ )
+ })
+
+ it('should import the file with original comments minus the deleted one', function (ctx) {
+ expect(
+ ctx.EditorController.promises.addDocWithRanges
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ ctx.folder_id,
+ 'foo.tex',
+ ['foo', 'bar', 'baz'],
+ {
+ changes: ctx.tracked_changes,
+ comments: ctx.comments.slice(0, 2),
+ },
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ }
+ )
+ })
+
+ it('should add the seen thread ids to the map', function (ctx) {
+ expect(nestedMapWithSetToObject(ctx.threadIds)).to.deep.equal(
+ ctx.afterThreadIds
+ )
+ })
+ })
+
+ describe('with remapped comments during revertProject', function () {
+ // copy of the above, addition: inject and later inspect threadIds set
+ beforeEach(async function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
+ ctx.ProjectGetter.promises.getProject
+ .withArgs(ctx.project_id)
+ .resolves({
+ overleaf: { history: { rangesSupportEnabled: true } },
+ rootDoc_id: 'root-doc-id',
+ })
+ ctx.ProjectLocator.promises.findElementByPath = sinon
+ .stub()
+ .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+
+ ctx.threadIds = new Map([
+ ['other-doc', new Set([ctx.comments[0].op.t])],
+ ])
+ // Comments are updated in-place. Look up threads before reverting.
+ ctx.afterThreadIds = {
+ // mock-file-id removed
+ 'other-doc': [ctx.comments[0].op.t],
+ [ctx.addedFile._id]: [
+ ctx.remappedComments[0].op.t,
+ ctx.remappedComments[1].op.t,
+ ],
+ }
+ ctx.data = await ctx.RestoreManager.promises._revertSingleFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname,
+ ctx.threadIds
+ )
+ })
+
+ it('should import the file', function (ctx) {
+ expect(
+ ctx.EditorController.promises.addDocWithRanges
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ ctx.folder_id,
+ 'foo.tex',
+ ['foo', 'bar', 'baz'],
+ { changes: ctx.tracked_changes, comments: ctx.remappedComments },
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ }
+ )
+ })
+
+ it('should add the seen thread ids to the map', function (ctx) {
+ expect(nestedMapWithSetToObject(ctx.threadIds)).to.deep.equal(
+ ctx.afterThreadIds
+ )
+ })
+ })
+
+ describe('when restored file has the same id as root doc', function () {
+ beforeEach(async function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
+ ctx.ProjectGetter.promises.getProject
+ .withArgs(ctx.project_id)
+ .resolves({
+ overleaf: { history: { rangesSupportEnabled: true } },
+ rootDoc_id: 'root-doc-id',
+ })
+ ctx.ProjectLocator.promises.findElementByPath = sinon
+ .stub()
+ .resolves({ type: 'doc', element: { _id: 'root-doc-id' } })
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+ ctx.EditorController.promises.addDocWithRanges = sinon
+ .stub()
+ .resolves((ctx.addedFile = { _id: 'new-doc-id', type: 'doc' }))
+ ctx.EditorController.promises.setRootDoc = sinon.stub().resolves()
+
+ ctx.data = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should delete the existing root document', async function (ctx) {
+ expect(
+ ctx.EditorController.promises.deleteEntity
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ 'root-doc-id',
+ 'doc',
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ },
+ ctx.user_id
+ )
+ })
+
+ it('should import the file', function (ctx) {
+ expect(
+ ctx.EditorController.promises.addDocWithRanges
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ ctx.folder_id,
+ 'foo.tex',
+ ['foo', 'bar', 'baz'],
+ { changes: ctx.tracked_changes, comments: ctx.remappedComments },
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ }
+ )
+ })
+
+ it('should return the created entity with root doc id', function (ctx) {
+ expect(ctx.data).to.deep.equal(ctx.addedFile)
+ expect(ctx.data._id).to.equal('new-doc-id')
+ })
+
+ it('should set the restored document as the new root doc', function (ctx) {
+ expect(
+ ctx.EditorController.promises.setRootDoc
+ ).to.have.been.calledWith(ctx.project_id, ctx.addedFile._id)
+ })
+ })
+ })
+
+ describe('reverting a file or document with metadata', function () {
+ beforeEach(function (ctx) {
+ ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
+ ctx.EditorController.promises.addDocWithRanges = sinon.stub()
+ ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
+ .stub()
+ .resolves([
+ { toV: ctx.version, meta: { end_ts: (ctx.endTs = new Date()) } },
+ ])
+
+ ctx.EditorController.promises.upsertFile = sinon
+ .stub()
+ .resolves({ _id: 'mock-file-id', type: 'file' })
+ ctx.RestoreManager.promises._getRangesFromHistory = sinon
+ .stub()
+ .resolves({
+ changes: [],
+ comments: [],
+ })
+ ctx.EditorController.promises.addDocWithRanges = sinon
+ .stub()
+ .resolves((ctx.addedFile = { _id: 'mock-doc-id', type: 'doc' }))
+
+ ctx.DocstoreManager.promises.getCommentThreadIds = sinon
+ .stub()
+ .resolves({})
+ ctx.ChatApiHandler.promises.generateThreadData = sinon
+ .stub()
+ .resolves({})
+ ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
+ .stub()
+ .resolves({})
+ ctx.EditorRealTimeController.emitToRoom = sinon.stub()
+ })
+
+ describe('when reverting a linked file', function () {
+ beforeEach(async function (ctx) {
+ ctx.pathname = 'foo.png'
+ ctx.FileSystemImportManager.promises.importFile = sinon
+ .stub()
+ .resolves({ type: 'file' })
+ ctx.RestoreManager.promises._getMetadataFromHistory = sinon
+ .stub()
+ .resolves({ metadata: { provider: 'bar' } })
+ ctx.result = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should revert it as a file', function (ctx) {
+ expect(ctx.result).to.deep.equal({
+ _id: 'mock-file-id',
+ type: 'file',
+ })
+ })
+
+ it('should upload to the project as a file', function (ctx) {
+ expect(
+ ctx.EditorController.promises.upsertFile
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ 'mock-folder-id',
+ 'foo.png',
+ ctx.fsPath,
+ { provider: 'bar' },
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ },
+ ctx.user_id
+ )
+ })
+
+ it('should not look up ranges', function (ctx) {
+ expect(ctx.RestoreManager.promises._getRangesFromHistory).to.not.have
+ .been.called
+ })
+
+ it('should not try to add a document', function (ctx) {
+ expect(ctx.EditorController.promises.addDocWithRanges).to.not.have
+ .been.called
+ })
+ })
+
+ describe('when reverting a linked document with provider', function () {
+ beforeEach(async function (ctx) {
+ ctx.pathname = 'foo.tex'
+ ctx.FileSystemImportManager.promises.importFile = sinon
+ .stub()
+ .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
+ ctx.RestoreManager.promises._getMetadataFromHistory = sinon
+ .stub()
+ .resolves({ metadata: { provider: 'bar' } })
+ ctx.result = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should revert it as a file', function (ctx) {
+ expect(ctx.result).to.deep.equal({
+ _id: 'mock-file-id',
+ type: 'file',
+ })
+ })
+
+ it('should upload to the project as a file', function (ctx) {
+ expect(
+ ctx.EditorController.promises.upsertFile
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ 'mock-folder-id',
+ 'foo.tex',
+ ctx.fsPath,
+ { provider: 'bar' },
+ {
+ kind: 'file-restore',
+ path: ctx.pathname,
+ version: ctx.version,
+ timestamp: new Date(ctx.endTs).toISOString(),
+ },
+ ctx.user_id
+ )
+ })
+
+ it('should not look up ranges', function (ctx) {
+ expect(ctx.RestoreManager.promises._getRangesFromHistory).to.not.have
+ .been.called
+ })
+
+ it('should not try to add a document', function (ctx) {
+ expect(ctx.EditorController.promises.addDocWithRanges).to.not.have
+ .been.called
+ })
+ })
+
+ describe('when reverting a linked document with { main: true }', function () {
+ beforeEach(async function (ctx) {
+ ctx.pathname = 'foo.tex'
+ ctx.FileSystemImportManager.promises.importFile = sinon
+ .stub()
+ .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
+ ctx.RestoreManager.promises._getMetadataFromHistory = sinon
+ .stub()
+ .resolves({ metadata: { main: true } })
+ ctx.result = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+ })
+
+ it('should revert it as a document', function (ctx) {
+ expect(ctx.result).to.deep.equal({
+ _id: 'mock-doc-id',
+ type: 'doc',
+ })
+ })
+
+ it('should not upload to the project as a file', function (ctx) {
+ expect(ctx.EditorController.promises.upsertFile).to.not.have.been
+ .called
+ })
+
+ it('should look up ranges', function (ctx) {
+ expect(ctx.RestoreManager.promises._getRangesFromHistory).to.have.been
+ .called
+ })
+
+ it('should add the document', function (ctx) {
+ expect(
+ ctx.EditorController.promises.addDocWithRanges
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ ctx.folder_id,
+ 'foo.tex',
+ ['foo', 'bar', 'baz'],
+ { changes: [], comments: [] }
+ )
+ })
+ })
+ })
+
+ describe('when reverting a binary file', function () {
+ beforeEach(async function (ctx) {
+ ctx.pathname = 'foo.png'
+ ctx.FileSystemImportManager.promises.importFile = sinon
+ .stub()
+ .resolves({ type: 'file' })
+ ctx.EditorController.promises.upsertFile = sinon
+ .stub()
+ .resolves({ _id: 'mock-file-id', type: 'file' })
+ ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+ ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
+ .stub()
+ .resolves([{ toV: ctx.version, meta: { end_ts: Date.now() } }])
+ })
+
+ it('should return the created entity if file exists', async function (ctx) {
+ ctx.ProjectLocator.promises.findElementByPath = sinon
+ .stub()
+ .resolves({ type: 'file', element: { _id: 'existing-file-id' } })
+
+ const revertRes = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+
+ expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
+ })
+
+ it('should return the created entity if file does not exists', async function (ctx) {
+ ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
+
+ const revertRes = await ctx.RestoreManager.promises.revertFile(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ ctx.pathname
+ )
+
+ expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
+ })
+ })
+ })
+
+ describe('revertProject', function () {
+ beforeEach(function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub()
+ ctx.ProjectGetter.promises.getProject
+ .withArgs(ctx.project_id)
+ .resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
+ ctx.RestoreManager.promises._revertSingleFile = sinon.stub().resolves({
+ _id: 'mock-doc-id',
+ type: 'doc',
+ })
+ ctx.RestoreManager.promises._getProjectPathsAtVersion = sinon
+ .stub()
+ .resolves([])
+ ctx.ProjectEntityHandler.promises.getAllEntities = sinon
+ .stub()
+ .resolves({ docs: [], files: [] })
+ ctx.EditorController.promises.deleteEntityWithPath = sinon
+ .stub()
+ .resolves()
+ ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
+ .stub()
+ .resolves([
+ { toV: ctx.version, meta: { end_ts: (ctx.end_ts = Date.now()) } },
+ ])
+ })
+
+ describe('reverting a project without ranges support', function () {
+ beforeEach(function (ctx) {
+ ctx.ProjectGetter.promises.getProject = sinon.stub().resolves({
+ overleaf: { history: { rangesSupportEnabled: false } },
+ })
+ })
+
+ it('should throw an error', async function (ctx) {
+ await expect(
+ ctx.RestoreManager.promises.revertProject(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version
+ )
+ ).to.eventually.be.rejectedWith('project does not have ranges support')
+ })
+ })
+
+ describe('for a project with overlap in current files and old files', function () {
+ beforeEach(async function (ctx) {
+ ctx.ProjectEntityHandler.promises.getAllEntities = sinon
+ .stub()
+ .resolves({
+ docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
+ files: [{ path: '/figures/image.png' }],
+ })
+ ctx.RestoreManager.promises._getProjectPathsAtVersion = sinon
+ .stub()
+ .resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
+
+ await ctx.RestoreManager.promises.revertProject(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version
+ )
+ ctx.origin = {
+ kind: 'project-restore',
+ version: ctx.version,
+ timestamp: new Date(ctx.end_ts).toISOString(),
+ }
+ })
+
+ it('should delete the old files', function (ctx) {
+ expect(
+ ctx.EditorController.promises.deleteEntityWithPath
+ ).to.have.been.calledWith(
+ ctx.project_id,
+ 'new-file.tex',
+ ctx.origin,
+ ctx.user_id
+ )
+ })
+
+ it('should not delete the current files', function (ctx) {
+ expect(
+ ctx.EditorController.promises.deleteEntityWithPath
+ ).to.not.have.been.calledWith(
+ ctx.project_id,
+ 'main.tex',
+ ctx.origin,
+ ctx.user_id
+ )
+
+ expect(
+ ctx.EditorController.promises.deleteEntityWithPath
+ ).to.not.have.been.calledWith(
+ ctx.project_id,
+ 'figures/image.png',
+ ctx.origin,
+ ctx.user_id
+ )
+ })
+
+ it('should revert the old files', function (ctx) {
+ expect(
+ ctx.RestoreManager.promises._revertSingleFile
+ ).to.have.been.calledWith(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ 'main.tex'
+ )
+
+ expect(
+ ctx.RestoreManager.promises._revertSingleFile
+ ).to.have.been.calledWith(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ 'figures/image.png'
+ )
+
+ expect(
+ ctx.RestoreManager.promises._revertSingleFile
+ ).to.have.been.calledWith(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ 'since-deleted.tex'
+ )
+ })
+
+ it('should not revert the current files', function (ctx) {
+ expect(
+ ctx.RestoreManager.promises._revertSingleFile
+ ).to.not.have.been.calledWith(
+ ctx.user_id,
+ ctx.project_id,
+ ctx.version,
+ 'new-file.tex'
+ )
+ })
+ })
+ })
+})
diff --git a/services/web/test/unit/src/History/RestoreManagerTests.js b/services/web/test/unit/src/History/RestoreManagerTests.js
deleted file mode 100644
index 48d48bcff1..0000000000
--- a/services/web/test/unit/src/History/RestoreManagerTests.js
+++ /dev/null
@@ -1,1130 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const modulePath = require('path').join(
- __dirname,
- '../../../../app/src/Features/History/RestoreManager'
-)
-const Errors = require('../../../../app/src/Features/Errors/Errors')
-const tk = require('timekeeper')
-const moment = require('moment')
-const { expect } = require('chai')
-
-function nestedMapWithSetToObject(m) {
- return Object.fromEntries(
- Array.from(m.entries()).map(([key, set]) => [key, Array.from(set)])
- )
-}
-
-describe('RestoreManager', function () {
- beforeEach(function () {
- tk.freeze(Date.now()) // freeze the time for these tests
- this.RestoreManager = SandboxedModule.require(modulePath, {
- requires: {
- '@overleaf/settings': {},
- '../../infrastructure/FileWriter': (this.FileWriter = { promises: {} }),
- '../Uploads/FileSystemImportManager': (this.FileSystemImportManager = {
- promises: {},
- }),
- '../Editor/EditorController': (this.EditorController = {
- promises: {},
- }),
- '../Project/ProjectLocator': (this.ProjectLocator = { promises: {} }),
- '../DocumentUpdater/DocumentUpdaterHandler':
- (this.DocumentUpdaterHandler = {
- promises: { flushProjectToMongo: sinon.stub().resolves() },
- }),
- '../Docstore/DocstoreManager': (this.DocstoreManager = {
- promises: { getCommentThreadIds: sinon.stub().resolves({}) },
- }),
- '../Chat/ChatApiHandler': (this.ChatApiHandler = { promises: {} }),
- '../Chat/ChatManager': (this.ChatManager = { promises: {} }),
- '../Editor/EditorRealTimeController': (this.EditorRealTimeController =
- {}),
- '../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
- '../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {
- promises: {},
- }),
- },
- })
- this.user_id = 'mock-user-id'
- this.project_id = 'mock-project-id'
- this.version = 42
- })
-
- afterEach(function () {
- tk.reset()
- })
-
- describe('restoreFileFromV2', function () {
- beforeEach(function () {
- this.RestoreManager.promises._writeFileVersionToDisk = sinon
- .stub()
- .resolves((this.fsPath = '/tmp/path/on/disk'))
- this.RestoreManager.promises._findOrCreateFolder = sinon
- .stub()
- .resolves((this.folder_id = 'mock-folder-id'))
- this.FileSystemImportManager.promises.addEntity = sinon
- .stub()
- .resolves((this.entity = 'mock-entity'))
- })
-
- describe('with a file not in a folder', function () {
- beforeEach(async function () {
- this.pathname = 'foo.tex'
- this.result = await this.RestoreManager.promises.restoreFileFromV2(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should write the file version to disk', function () {
- this.RestoreManager.promises._writeFileVersionToDisk
- .calledWith(this.project_id, this.version, this.pathname)
- .should.equal(true)
- })
-
- it('should find the root folder', function () {
- this.RestoreManager.promises._findOrCreateFolder
- .calledWith(this.project_id, '', this.user_id)
- .should.equal(true)
- })
-
- it('should add the entity', function () {
- this.FileSystemImportManager.promises.addEntity
- .calledWith(
- this.user_id,
- this.project_id,
- this.folder_id,
- 'foo.tex',
- this.fsPath,
- false
- )
- .should.equal(true)
- })
-
- it('should return the entity', function () {
- expect(this.result).to.equal(this.entity)
- })
- })
-
- describe('with a file in a folder', function () {
- beforeEach(async function () {
- this.pathname = 'foo/bar.tex'
- await this.RestoreManager.promises.restoreFileFromV2(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should find the folder', function () {
- this.RestoreManager.promises._findOrCreateFolder
- .calledWith(this.project_id, 'foo', this.user_id)
- .should.equal(true)
- })
-
- it('should add the entity by its basename', function () {
- this.FileSystemImportManager.promises.addEntity
- .calledWith(
- this.user_id,
- this.project_id,
- this.folder_id,
- 'bar.tex',
- this.fsPath,
- false
- )
- .should.equal(true)
- })
- })
- })
-
- describe('_findOrCreateFolder', function () {
- beforeEach(async function () {
- this.EditorController.promises.mkdirp = sinon.stub().resolves({
- newFolders: [],
- lastFolder: { _id: (this.folder_id = 'mock-folder-id') },
- })
- this.result = await this.RestoreManager.promises._findOrCreateFolder(
- this.project_id,
- 'folder/name',
- this.user_id
- )
- })
-
- it('should look up or create the folder', function () {
- this.EditorController.promises.mkdirp
- .calledWith(this.project_id, 'folder/name', this.user_id)
- .should.equal(true)
- })
-
- it('should return the folder_id', function () {
- expect(this.result).to.equal(this.folder_id)
- })
- })
-
- describe('_addEntityWithUniqueName', function () {
- beforeEach(function () {
- this.addEntityWithName = sinon.stub()
- this.name = 'foo.tex'
- })
-
- describe('with a valid name', function () {
- beforeEach(async function () {
- this.addEntityWithName.resolves((this.entity = 'mock-entity'))
- this.result =
- await this.RestoreManager.promises._addEntityWithUniqueName(
- this.addEntityWithName,
- this.name
- )
- })
-
- it('should add the entity', function () {
- this.addEntityWithName.calledWith(this.name).should.equal(true)
- })
-
- it('should return the entity', function () {
- expect(this.result).to.equal(this.entity)
- })
- })
-
- describe('with a duplicate name', function () {
- beforeEach(async function () {
- this.addEntityWithName.rejects(new Errors.DuplicateNameError())
- this.addEntityWithName
- .onSecondCall()
- .resolves((this.entity = 'mock-entity'))
- this.result =
- await this.RestoreManager.promises._addEntityWithUniqueName(
- this.addEntityWithName,
- this.name
- )
- })
-
- it('should try to add the entity with its original name', function () {
- this.addEntityWithName.calledWith('foo.tex').should.equal(true)
- })
-
- it('should try to add the entity with a unique name', function () {
- const date = moment(new Date()).format('Do MMM YY H:mm:ss')
- this.addEntityWithName
- .calledWith(`foo (Restored on ${date}).tex`)
- .should.equal(true)
- })
-
- it('should return the entity', function () {
- expect(this.result).to.equal(this.entity)
- })
- })
- })
-
- describe('revertFile', function () {
- beforeEach(function () {
- this.ProjectGetter.promises.getProject = sinon.stub()
- this.ProjectGetter.promises.getProject
- .withArgs(this.project_id)
- .resolves({
- overleaf: { history: { rangesSupportEnabled: true } },
- rootDoc_id: 'root-doc-id',
- })
- this.RestoreManager.promises._writeFileVersionToDisk = sinon
- .stub()
- .resolves((this.fsPath = '/tmp/path/on/disk'))
- this.RestoreManager.promises._findOrCreateFolder = sinon
- .stub()
- .resolves((this.folder_id = 'mock-folder-id'))
- this.FileSystemImportManager.promises.addEntity = sinon
- .stub()
- .resolves((this.entity = 'mock-entity'))
- this.RestoreManager.promises._getRangesFromHistory = sinon
- .stub()
- .rejects()
- this.RestoreManager.promises._getMetadataFromHistory = sinon
- .stub()
- .resolves({ metadata: undefined })
- })
-
- describe('reverting a project without ranges support', function () {
- beforeEach(function () {
- this.ProjectGetter.promises.getProject = sinon.stub().resolves({
- overleaf: { history: { rangesSupportEnabled: false } },
- })
- })
-
- it('should throw an error', async function () {
- await expect(
- this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- ).to.eventually.be.rejectedWith('project does not have ranges support')
- })
- })
-
- describe('reverting a document with ranges', function () {
- beforeEach(function () {
- this.pathname = 'foo.tex'
- this.comments = [
- {
- id: 'comment-in-other-doc',
- op: { t: 'comment-in-other-doc', p: 0, c: 'foo' },
- },
- {
- id: 'single-comment',
- op: { t: 'single-comment', p: 10, c: 'bar' },
- },
- {
- id: 'deleted-comment',
- op: { t: 'deleted-comment', p: 20, c: 'baz' },
- },
- ]
- this.remappedComments = [
- {
- id: 'duplicate-comment',
- op: { t: 'duplicate-comment', p: 0, c: 'foo' },
- },
- {
- id: 'single-comment',
- op: { t: 'single-comment', p: 10, c: 'bar' },
- },
- ]
- this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
- this.DocstoreManager.promises.getCommentThreadIds = sinon
- .stub()
- .resolves({ 'other-doc': [this.comments[0].op.t] })
- this.ChatApiHandler.promises.duplicateCommentThreads = sinon
- .stub()
- .resolves({
- newThreads: {
- 'comment-in-other-doc': {
- duplicateId: 'duplicate-comment',
- },
- },
- })
- this.ChatApiHandler.promises.generateThreadData = sinon.stub().resolves(
- (this.threadData = {
- 'single-comment': {
- messages: [
- {
- content: 'message-content',
- timestamp: '2024-01-01T00:00:00.000Z',
- user_id: 'user-1',
- },
- ],
- },
- 'duplicate-comment': {
- messages: [
- {
- content: 'another message',
- timestamp: '2024-01-01T00:00:00.000Z',
- user_id: 'user-1',
- },
- ],
- },
- })
- )
- this.ChatManager.promises.injectUserInfoIntoThreads = sinon
- .stub()
- .resolves(this.threadData)
-
- this.EditorRealTimeController.emitToRoom = sinon.stub()
- this.tracked_changes = [
- {
- op: { pos: 4, i: 'bar' },
- metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
- },
- {
- op: { pos: 8, d: 'qux' },
- metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
- },
- ]
- this.FileSystemImportManager.promises.importFile = sinon
- .stub()
- .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
- this.RestoreManager.promises._getRangesFromHistory = sinon
- .stub()
- .resolves({
- changes: this.tracked_changes,
- comments: this.comments,
- })
- this.RestoreManager.promises._getUpdatesFromHistory = sinon
- .stub()
- .resolves([
- { toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
- ])
- this.EditorController.promises.addDocWithRanges = sinon
- .stub()
- .resolves((this.addedFile = { _id: 'mock-doc', type: 'doc' }))
- })
-
- describe("when reverting a file that doesn't current exist", function () {
- beforeEach(async function () {
- this.data = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should flush the document before fetching ranges', function () {
- expect(
- this.DocumentUpdaterHandler.promises.flushProjectToMongo
- ).to.have.been.calledBefore(
- this.DocstoreManager.promises.getCommentThreadIds
- )
- })
-
- it('should import the file', function () {
- expect(
- this.EditorController.promises.addDocWithRanges
- ).to.have.been.calledWith(
- this.project_id,
- this.folder_id,
- 'foo.tex',
- ['foo', 'bar', 'baz'],
- { changes: this.tracked_changes, comments: this.remappedComments }
- )
- })
-
- it('should return the created entity', function () {
- expect(this.data).to.deep.equal(this.addedFile)
- })
-
- it('should look up ranges', function () {
- expect(
- this.RestoreManager.promises._getRangesFromHistory
- ).to.have.been.calledWith(
- this.project_id,
- this.version,
- this.pathname
- )
- })
- })
-
- describe('with an existing file in the current project', function () {
- beforeEach(async function () {
- this.ProjectGetter.promises.getProject = sinon.stub()
- this.ProjectGetter.promises.getProject
- .withArgs(this.project_id)
- .resolves({
- overleaf: { history: { rangesSupportEnabled: true } },
- rootDoc_id: 'root-doc-id',
- })
- this.ProjectLocator.promises.findElementByPath = sinon
- .stub()
- .resolves({ type: 'file', element: { _id: 'mock-file-id' } })
- this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-
- this.data = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should delete the existing file', async function () {
- expect(
- this.EditorController.promises.deleteEntity
- ).to.have.been.calledWith(
- this.project_id,
- 'mock-file-id',
- 'file',
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- },
- this.user_id
- )
- })
- })
-
- describe('with an existing document in the current project', function () {
- beforeEach(async function () {
- this.ProjectGetter.promises.getProject = sinon.stub()
- this.ProjectGetter.promises.getProject
- .withArgs(this.project_id)
- .resolves({
- overleaf: { history: { rangesSupportEnabled: true } },
- rootDoc_id: 'root-doc-id',
- })
- this.ProjectLocator.promises.findElementByPath = sinon
- .stub()
- .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
- this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-
- this.data = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should delete the existing document', async function () {
- expect(
- this.EditorController.promises.deleteEntity
- ).to.have.been.calledWith(
- this.project_id,
- 'mock-file-id',
- 'doc',
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- },
- this.user_id
- )
- })
-
- it('should flush the document before fetching ranges', function () {
- expect(
- this.DocumentUpdaterHandler.promises.flushProjectToMongo
- ).to.have.been.calledBefore(
- this.DocstoreManager.promises.getCommentThreadIds
- )
- })
-
- it('should import the file', function () {
- expect(
- this.EditorController.promises.addDocWithRanges
- ).to.have.been.calledWith(
- this.project_id,
- this.folder_id,
- 'foo.tex',
- ['foo', 'bar', 'baz'],
- { changes: this.tracked_changes, comments: this.remappedComments },
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- }
- )
- })
-
- it('should return the created entity', function () {
- expect(this.data).to.deep.equal(this.addedFile)
- })
-
- it('should look up ranges', function () {
- expect(
- this.RestoreManager.promises._getRangesFromHistory
- ).to.have.been.calledWith(
- this.project_id,
- this.version,
- this.pathname
- )
- })
- })
-
- describe('with comments in same doc', function () {
- // copy of the above, addition: inject and later inspect threadIds set
- beforeEach(async function () {
- this.ProjectGetter.promises.getProject = sinon.stub()
- this.ProjectGetter.promises.getProject
- .withArgs(this.project_id)
- .resolves({
- overleaf: { history: { rangesSupportEnabled: true } },
- rootDoc_id: 'root-doc-id',
- })
- this.ProjectLocator.promises.findElementByPath = sinon
- .stub()
- .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
- this.EditorController.promises.deleteEntity = sinon.stub().resolves()
- this.ChatApiHandler.promises.generateThreadData = sinon
- .stub()
- .resolves(
- (this.threadData = {
- [this.comments[0].op.t]: {
- messages: [
- {
- content: 'message',
- timestamp: '2024-01-01T00:00:00.000Z',
- user_id: 'user-1',
- },
- ],
- },
- [this.comments[1].op.t]: {
- messages: [
- {
- content: 'other message',
- timestamp: '2024-01-01T00:00:00.000Z',
- user_id: 'user-1',
- },
- ],
- },
- })
- )
-
- this.threadIds = new Map([
- [
- 'mock-file-id',
- new Set([this.comments[0].op.t, this.comments[1].op.t]),
- ],
- ])
- // Comments are updated in-place. Look up threads before reverting.
- this.afterThreadIds = {
- // mock-file-id removed
- [this.addedFile._id]: [
- this.comments[0].op.t,
- this.comments[1].op.t,
- ],
- }
- this.data = await this.RestoreManager.promises._revertSingleFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname,
- this.threadIds
- )
- })
-
- it('should import the file with original comments minus the deleted one', function () {
- expect(
- this.EditorController.promises.addDocWithRanges
- ).to.have.been.calledWith(
- this.project_id,
- this.folder_id,
- 'foo.tex',
- ['foo', 'bar', 'baz'],
- {
- changes: this.tracked_changes,
- comments: this.comments.slice(0, 2),
- },
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- }
- )
- })
-
- it('should add the seen thread ids to the map', function () {
- expect(nestedMapWithSetToObject(this.threadIds)).to.deep.equal(
- this.afterThreadIds
- )
- })
- })
-
- describe('with remapped comments during revertProject', function () {
- // copy of the above, addition: inject and later inspect threadIds set
- beforeEach(async function () {
- this.ProjectGetter.promises.getProject = sinon.stub()
- this.ProjectGetter.promises.getProject
- .withArgs(this.project_id)
- .resolves({
- overleaf: { history: { rangesSupportEnabled: true } },
- rootDoc_id: 'root-doc-id',
- })
- this.ProjectLocator.promises.findElementByPath = sinon
- .stub()
- .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
- this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-
- this.threadIds = new Map([
- ['other-doc', new Set([this.comments[0].op.t])],
- ])
- // Comments are updated in-place. Look up threads before reverting.
- this.afterThreadIds = {
- // mock-file-id removed
- 'other-doc': [this.comments[0].op.t],
- [this.addedFile._id]: [
- this.remappedComments[0].op.t,
- this.remappedComments[1].op.t,
- ],
- }
- this.data = await this.RestoreManager.promises._revertSingleFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname,
- this.threadIds
- )
- })
-
- it('should import the file', function () {
- expect(
- this.EditorController.promises.addDocWithRanges
- ).to.have.been.calledWith(
- this.project_id,
- this.folder_id,
- 'foo.tex',
- ['foo', 'bar', 'baz'],
- { changes: this.tracked_changes, comments: this.remappedComments },
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- }
- )
- })
-
- it('should add the seen thread ids to the map', function () {
- expect(nestedMapWithSetToObject(this.threadIds)).to.deep.equal(
- this.afterThreadIds
- )
- })
- })
-
- describe('when restored file has the same id as root doc', function () {
- beforeEach(async function () {
- this.ProjectGetter.promises.getProject = sinon.stub()
- this.ProjectGetter.promises.getProject
- .withArgs(this.project_id)
- .resolves({
- overleaf: { history: { rangesSupportEnabled: true } },
- rootDoc_id: 'root-doc-id',
- })
- this.ProjectLocator.promises.findElementByPath = sinon
- .stub()
- .resolves({ type: 'doc', element: { _id: 'root-doc-id' } })
- this.EditorController.promises.deleteEntity = sinon.stub().resolves()
- this.EditorController.promises.addDocWithRanges = sinon
- .stub()
- .resolves((this.addedFile = { _id: 'new-doc-id', type: 'doc' }))
- this.EditorController.promises.setRootDoc = sinon.stub().resolves()
-
- this.data = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should delete the existing root document', async function () {
- expect(
- this.EditorController.promises.deleteEntity
- ).to.have.been.calledWith(
- this.project_id,
- 'root-doc-id',
- 'doc',
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- },
- this.user_id
- )
- })
-
- it('should import the file', function () {
- expect(
- this.EditorController.promises.addDocWithRanges
- ).to.have.been.calledWith(
- this.project_id,
- this.folder_id,
- 'foo.tex',
- ['foo', 'bar', 'baz'],
- { changes: this.tracked_changes, comments: this.remappedComments },
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- }
- )
- })
-
- it('should return the created entity with root doc id', function () {
- expect(this.data).to.deep.equal(this.addedFile)
- expect(this.data._id).to.equal('new-doc-id')
- })
-
- it('should set the restored document as the new root doc', function () {
- expect(
- this.EditorController.promises.setRootDoc
- ).to.have.been.calledWith(this.project_id, this.addedFile._id)
- })
- })
- })
-
- describe('reverting a file or document with metadata', function () {
- beforeEach(function () {
- this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
- this.EditorController.promises.addDocWithRanges = sinon.stub()
- this.RestoreManager.promises._getUpdatesFromHistory = sinon
- .stub()
- .resolves([
- { toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
- ])
-
- this.EditorController.promises.upsertFile = sinon
- .stub()
- .resolves({ _id: 'mock-file-id', type: 'file' })
- this.RestoreManager.promises._getRangesFromHistory = sinon
- .stub()
- .resolves({
- changes: [],
- comments: [],
- })
- this.EditorController.promises.addDocWithRanges = sinon
- .stub()
- .resolves((this.addedFile = { _id: 'mock-doc-id', type: 'doc' }))
-
- this.DocstoreManager.promises.getCommentThreadIds = sinon
- .stub()
- .resolves({})
- this.ChatApiHandler.promises.generateThreadData = sinon
- .stub()
- .resolves({})
- this.ChatManager.promises.injectUserInfoIntoThreads = sinon
- .stub()
- .resolves({})
- this.EditorRealTimeController.emitToRoom = sinon.stub()
- })
-
- describe('when reverting a linked file', function () {
- beforeEach(async function () {
- this.pathname = 'foo.png'
- this.FileSystemImportManager.promises.importFile = sinon
- .stub()
- .resolves({ type: 'file' })
- this.RestoreManager.promises._getMetadataFromHistory = sinon
- .stub()
- .resolves({ metadata: { provider: 'bar' } })
- this.result = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should revert it as a file', function () {
- expect(this.result).to.deep.equal({
- _id: 'mock-file-id',
- type: 'file',
- })
- })
-
- it('should upload to the project as a file', function () {
- expect(
- this.EditorController.promises.upsertFile
- ).to.have.been.calledWith(
- this.project_id,
- 'mock-folder-id',
- 'foo.png',
- this.fsPath,
- { provider: 'bar' },
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- },
- this.user_id
- )
- })
-
- it('should not look up ranges', function () {
- expect(this.RestoreManager.promises._getRangesFromHistory).to.not.have
- .been.called
- })
-
- it('should not try to add a document', function () {
- expect(this.EditorController.promises.addDocWithRanges).to.not.have
- .been.called
- })
- })
-
- describe('when reverting a linked document with provider', function () {
- beforeEach(async function () {
- this.pathname = 'foo.tex'
- this.FileSystemImportManager.promises.importFile = sinon
- .stub()
- .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
- this.RestoreManager.promises._getMetadataFromHistory = sinon
- .stub()
- .resolves({ metadata: { provider: 'bar' } })
- this.result = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should revert it as a file', function () {
- expect(this.result).to.deep.equal({
- _id: 'mock-file-id',
- type: 'file',
- })
- })
-
- it('should upload to the project as a file', function () {
- expect(
- this.EditorController.promises.upsertFile
- ).to.have.been.calledWith(
- this.project_id,
- 'mock-folder-id',
- 'foo.tex',
- this.fsPath,
- { provider: 'bar' },
- {
- kind: 'file-restore',
- path: this.pathname,
- version: this.version,
- timestamp: new Date(this.endTs).toISOString(),
- },
- this.user_id
- )
- })
-
- it('should not look up ranges', function () {
- expect(this.RestoreManager.promises._getRangesFromHistory).to.not.have
- .been.called
- })
-
- it('should not try to add a document', function () {
- expect(this.EditorController.promises.addDocWithRanges).to.not.have
- .been.called
- })
- })
-
- describe('when reverting a linked document with { main: true }', function () {
- beforeEach(async function () {
- this.pathname = 'foo.tex'
- this.FileSystemImportManager.promises.importFile = sinon
- .stub()
- .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
- this.RestoreManager.promises._getMetadataFromHistory = sinon
- .stub()
- .resolves({ metadata: { main: true } })
- this.result = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
- })
-
- it('should revert it as a document', function () {
- expect(this.result).to.deep.equal({
- _id: 'mock-doc-id',
- type: 'doc',
- })
- })
-
- it('should not upload to the project as a file', function () {
- expect(this.EditorController.promises.upsertFile).to.not.have.been
- .called
- })
-
- it('should look up ranges', function () {
- expect(this.RestoreManager.promises._getRangesFromHistory).to.have
- .been.called
- })
-
- it('should add the document', function () {
- expect(
- this.EditorController.promises.addDocWithRanges
- ).to.have.been.calledWith(
- this.project_id,
- this.folder_id,
- 'foo.tex',
- ['foo', 'bar', 'baz'],
- { changes: [], comments: [] }
- )
- })
- })
- })
-
- describe('when reverting a binary file', function () {
- beforeEach(async function () {
- this.pathname = 'foo.png'
- this.FileSystemImportManager.promises.importFile = sinon
- .stub()
- .resolves({ type: 'file' })
- this.EditorController.promises.upsertFile = sinon
- .stub()
- .resolves({ _id: 'mock-file-id', type: 'file' })
- this.EditorController.promises.deleteEntity = sinon.stub().resolves()
- this.RestoreManager.promises._getUpdatesFromHistory = sinon
- .stub()
- .resolves([{ toV: this.version, meta: { end_ts: Date.now() } }])
- })
-
- it('should return the created entity if file exists', async function () {
- this.ProjectLocator.promises.findElementByPath = sinon
- .stub()
- .resolves({ type: 'file', element: { _id: 'existing-file-id' } })
-
- const revertRes = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
-
- expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
- })
-
- it('should return the created entity if file does not exists', async function () {
- this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
-
- const revertRes = await this.RestoreManager.promises.revertFile(
- this.user_id,
- this.project_id,
- this.version,
- this.pathname
- )
-
- expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
- })
- })
- })
-
- describe('revertProject', function () {
- beforeEach(function () {
- this.ProjectGetter.promises.getProject = sinon.stub()
- this.ProjectGetter.promises.getProject
- .withArgs(this.project_id)
- .resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
- this.RestoreManager.promises._revertSingleFile = sinon.stub().resolves({
- _id: 'mock-doc-id',
- type: 'doc',
- })
- this.RestoreManager.promises._getProjectPathsAtVersion = sinon
- .stub()
- .resolves([])
- this.ProjectEntityHandler.promises.getAllEntities = sinon
- .stub()
- .resolves({ docs: [], files: [] })
- this.EditorController.promises.deleteEntityWithPath = sinon
- .stub()
- .resolves()
- this.RestoreManager.promises._getUpdatesFromHistory = sinon
- .stub()
- .resolves([
- { toV: this.version, meta: { end_ts: (this.end_ts = Date.now()) } },
- ])
- })
-
- describe('reverting a project without ranges support', function () {
- beforeEach(function () {
- this.ProjectGetter.promises.getProject = sinon.stub().resolves({
- overleaf: { history: { rangesSupportEnabled: false } },
- })
- })
-
- it('should throw an error', async function () {
- await expect(
- this.RestoreManager.promises.revertProject(
- this.user_id,
- this.project_id,
- this.version
- )
- ).to.eventually.be.rejectedWith('project does not have ranges support')
- })
- })
-
- describe('for a project with overlap in current files and old files', function () {
- beforeEach(async function () {
- this.ProjectEntityHandler.promises.getAllEntities = sinon
- .stub()
- .resolves({
- docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
- files: [{ path: '/figures/image.png' }],
- })
- this.RestoreManager.promises._getProjectPathsAtVersion = sinon
- .stub()
- .resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
-
- await this.RestoreManager.promises.revertProject(
- this.user_id,
- this.project_id,
- this.version
- )
- this.origin = {
- kind: 'project-restore',
- version: this.version,
- timestamp: new Date(this.end_ts).toISOString(),
- }
- })
-
- it('should delete the old files', function () {
- expect(
- this.EditorController.promises.deleteEntityWithPath
- ).to.have.been.calledWith(
- this.project_id,
- 'new-file.tex',
- this.origin,
- this.user_id
- )
- })
-
- it('should not delete the current files', function () {
- expect(
- this.EditorController.promises.deleteEntityWithPath
- ).to.not.have.been.calledWith(
- this.project_id,
- 'main.tex',
- this.origin,
- this.user_id
- )
-
- expect(
- this.EditorController.promises.deleteEntityWithPath
- ).to.not.have.been.calledWith(
- this.project_id,
- 'figures/image.png',
- this.origin,
- this.user_id
- )
- })
-
- it('should revert the old files', function () {
- expect(
- this.RestoreManager.promises._revertSingleFile
- ).to.have.been.calledWith(
- this.user_id,
- this.project_id,
- this.version,
- 'main.tex'
- )
-
- expect(
- this.RestoreManager.promises._revertSingleFile
- ).to.have.been.calledWith(
- this.user_id,
- this.project_id,
- this.version,
- 'figures/image.png'
- )
-
- expect(
- this.RestoreManager.promises._revertSingleFile
- ).to.have.been.calledWith(
- this.user_id,
- this.project_id,
- this.version,
- 'since-deleted.tex'
- )
- })
-
- it('should not revert the current files', function () {
- expect(
- this.RestoreManager.promises._revertSingleFile
- ).to.not.have.been.calledWith(
- this.user_id,
- this.project_id,
- this.version,
- 'new-file.tex'
- )
- })
- })
- })
-})
diff --git a/services/web/test/unit/src/InactiveData/InactiveProjectManager.test.mjs b/services/web/test/unit/src/InactiveData/InactiveProjectManager.test.mjs
new file mode 100644
index 0000000000..cb73a7edda
--- /dev/null
+++ b/services/web/test/unit/src/InactiveData/InactiveProjectManager.test.mjs
@@ -0,0 +1,181 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb, { ReadPreference } from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
+
+const modulePath =
+ '../../../../app/src/Features/InactiveData/InactiveProjectManager'
+
+describe('InactiveProjectManager', function () {
+ beforeEach(async function (ctx) {
+ ctx.settings = {}
+ ctx.metrics = { inc: sinon.stub() }
+ ctx.DocstoreManager = {
+ promises: {
+ unarchiveProject: sinon.stub(),
+ archiveProject: sinon.stub(),
+ },
+ }
+ ctx.DocumentUpdaterHandler = {
+ promises: {
+ flushProjectToMongoAndDelete: sinon.stub(),
+ },
+ }
+ ctx.ProjectUpdateHandler = {
+ promises: {
+ markAsActive: sinon.stub(),
+ markAsInactive: sinon.stub(),
+ },
+ }
+ ctx.ProjectGetter = { promises: { getProject: sinon.stub() } }
+ ctx.Modules = { promises: { hooks: { fire: sinon.stub() } } }
+
+ vi.doMock('mongodb-legacy', () => ({
+ default: { ObjectId },
+ }))
+
+ vi.doMock('@overleaf/settings', () => ({
+ default: ctx.settings,
+ }))
+
+ vi.doMock('@overleaf/metrics', () => ({
+ default: ctx.metrics,
+ }))
+
+ vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
+ default: ctx.DocstoreManager,
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+ () => ({
+ default: ctx.DocumentUpdaterHandler,
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/Project/ProjectUpdateHandler',
+ () => ({
+ default: ctx.ProjectUpdateHandler,
+ })
+ )
+
+ vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+ default: ctx.ProjectGetter,
+ }))
+
+ vi.doMock('../../../../app/src/models/Project', () => ({}))
+
+ vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+ default: ctx.Modules,
+ }))
+
+ vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
+ default: {
+ ObjectId,
+ READ_PREFERENCE_SECONDARY: ReadPreference.secondaryPreferred.mode,
+ },
+ }))
+
+ ctx.InactiveProjectManager = (await import(modulePath)).default
+ ctx.project_id = '1234'
+ })
+
+ describe('reactivateProjectIfRequired', function () {
+ beforeEach(function (ctx) {
+ ctx.project = { active: false }
+ ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
+ ctx.ProjectUpdateHandler.promises.markAsActive.resolves()
+ })
+
+ it('should call unarchiveProject', async function (ctx) {
+ ctx.DocstoreManager.promises.unarchiveProject.resolves()
+ await ctx.InactiveProjectManager.promises.reactivateProjectIfRequired(
+ ctx.project_id
+ )
+
+ ctx.DocstoreManager.promises.unarchiveProject
+ .calledWith(ctx.project_id)
+ .should.equal(true)
+ ctx.ProjectUpdateHandler.promises.markAsActive
+ .calledWith(ctx.project_id)
+ .should.equal(true)
+ })
+
+ it('should not mark project as active if error with unarchiving', async function (ctx) {
+ ctx.DocstoreManager.promises.unarchiveProject.rejects()
+ await expect(
+ ctx.InactiveProjectManager.promises.reactivateProjectIfRequired(
+ ctx.project_id
+ )
+ ).to.be.rejected
+
+ ctx.DocstoreManager.promises.unarchiveProject
+ .calledWith(ctx.project_id)
+ .should.equal(true)
+ ctx.ProjectUpdateHandler.promises.markAsActive
+ .calledWith(ctx.project_id)
+ .should.equal(false)
+ })
+
+ it('should not call unarchiveProject if it is active', async function (ctx) {
+ ctx.project.active = true
+ ctx.DocstoreManager.promises.unarchiveProject.resolves()
+ await ctx.InactiveProjectManager.promises.reactivateProjectIfRequired(
+ ctx.project_id
+ )
+ ctx.DocstoreManager.promises.unarchiveProject
+ .calledWith(ctx.project_id)
+ .should.equal(false)
+ ctx.ProjectUpdateHandler.promises.markAsActive
+ .calledWith(ctx.project_id)
+ .should.equal(false)
+ })
+ })
+
+ describe('deactivateProject', function () {
+ it('should call archiveProject and markAsInactive after flushing', async function (ctx) {
+ ctx.DocstoreManager.promises.archiveProject.resolves()
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
+ ctx.ProjectUpdateHandler.promises.markAsInactive.resolves()
+ ctx.Modules.promises.hooks.fire.resolves()
+
+ await ctx.InactiveProjectManager.promises.deactivateProject(
+ ctx.project_id
+ )
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
+ .calledWith(ctx.project_id)
+ .should.equal(true)
+ ctx.Modules.promises.hooks.fire
+ .calledWith('deactivateProject', ctx.project_id)
+ .should.equal(true)
+ ctx.DocstoreManager.promises.archiveProject
+ .calledWith(ctx.project_id)
+ .should.equal(true)
+ ctx.ProjectUpdateHandler.promises.markAsInactive
+ .calledWith(ctx.project_id)
+ .should.equal(true)
+ })
+
+ it('should not call markAsInactive if there was a problem archiving in docstore', async function (ctx) {
+ ctx.DocstoreManager.promises.archiveProject.rejects()
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
+ ctx.ProjectUpdateHandler.promises.markAsInactive.resolves()
+ ctx.Modules.promises.hooks.fire.resolves()
+
+ await expect(
+ ctx.InactiveProjectManager.promises.deactivateProject(ctx.project_id)
+ ).to.be.rejected
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
+ .calledWith(ctx.project_id)
+ .should.equal(true)
+ ctx.DocstoreManager.promises.archiveProject
+ .calledWith(ctx.project_id)
+ .should.equal(true)
+ ctx.ProjectUpdateHandler.promises.markAsInactive
+ .calledWith(ctx.project_id)
+ .should.equal(false)
+ })
+ })
+})
diff --git a/services/web/test/unit/src/InactiveData/InactiveProjectManagerTests.js b/services/web/test/unit/src/InactiveData/InactiveProjectManagerTests.js
deleted file mode 100644
index 5c0bf71862..0000000000
--- a/services/web/test/unit/src/InactiveData/InactiveProjectManagerTests.js
+++ /dev/null
@@ -1,151 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const modulePath = path.join(
- __dirname,
- '../../../../app/src/Features/InactiveData/InactiveProjectManager'
-)
-const { ObjectId, ReadPreference } = require('mongodb-legacy')
-const { expect } = require('chai')
-
-describe('InactiveProjectManager', function () {
- beforeEach(function () {
- this.settings = {}
- this.metrics = { inc: sinon.stub() }
- this.DocstoreManager = {
- promises: {
- unarchiveProject: sinon.stub(),
- archiveProject: sinon.stub(),
- },
- }
- this.DocumentUpdaterHandler = {
- promises: {
- flushProjectToMongoAndDelete: sinon.stub(),
- },
- }
- this.ProjectUpdateHandler = {
- promises: {
- markAsActive: sinon.stub(),
- markAsInactive: sinon.stub(),
- },
- }
- this.ProjectGetter = { promises: { getProject: sinon.stub() } }
- this.Modules = { promises: { hooks: { fire: sinon.stub() } } }
- this.InactiveProjectManager = SandboxedModule.require(modulePath, {
- requires: {
- 'mongodb-legacy': { ObjectId },
- '@overleaf/settings': this.settings,
- '@overleaf/metrics': this.metrics,
- '../Docstore/DocstoreManager': this.DocstoreManager,
- '../DocumentUpdater/DocumentUpdaterHandler':
- this.DocumentUpdaterHandler,
- '../Project/ProjectUpdateHandler': this.ProjectUpdateHandler,
- '../Project/ProjectGetter': this.ProjectGetter,
- '../../models/Project': {},
- '../../infrastructure/Modules': this.Modules,
- '../../infrastructure/mongodb': {
- ObjectId,
- READ_PREFERENCE_SECONDARY: ReadPreference.secondaryPreferred.mode,
- },
- },
- })
- this.project_id = '1234'
- })
-
- describe('reactivateProjectIfRequired', function () {
- beforeEach(function () {
- this.project = { active: false }
- this.ProjectGetter.promises.getProject.resolves(this.project)
- this.ProjectUpdateHandler.promises.markAsActive.resolves()
- })
-
- it('should call unarchiveProject', async function () {
- this.DocstoreManager.promises.unarchiveProject.resolves()
- await this.InactiveProjectManager.promises.reactivateProjectIfRequired(
- this.project_id
- )
-
- this.DocstoreManager.promises.unarchiveProject
- .calledWith(this.project_id)
- .should.equal(true)
- this.ProjectUpdateHandler.promises.markAsActive
- .calledWith(this.project_id)
- .should.equal(true)
- })
-
- it('should not mark project as active if error with unarchiving', async function () {
- this.DocstoreManager.promises.unarchiveProject.rejects()
- await expect(
- this.InactiveProjectManager.promises.reactivateProjectIfRequired(
- this.project_id
- )
- ).to.be.rejected
-
- this.DocstoreManager.promises.unarchiveProject
- .calledWith(this.project_id)
- .should.equal(true)
- this.ProjectUpdateHandler.promises.markAsActive
- .calledWith(this.project_id)
- .should.equal(false)
- })
-
- it('should not call unarchiveProject if it is active', async function () {
- this.project.active = true
- this.DocstoreManager.promises.unarchiveProject.resolves()
- await this.InactiveProjectManager.promises.reactivateProjectIfRequired(
- this.project_id
- )
- this.DocstoreManager.promises.unarchiveProject
- .calledWith(this.project_id)
- .should.equal(false)
- this.ProjectUpdateHandler.promises.markAsActive
- .calledWith(this.project_id)
- .should.equal(false)
- })
- })
-
- describe('deactivateProject', function () {
- it('should call archiveProject and markAsInactive after flushing', async function () {
- this.DocstoreManager.promises.archiveProject.resolves()
- this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
- this.ProjectUpdateHandler.promises.markAsInactive.resolves()
- this.Modules.promises.hooks.fire.resolves()
-
- await this.InactiveProjectManager.promises.deactivateProject(
- this.project_id
- )
- this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
- .calledWith(this.project_id)
- .should.equal(true)
- this.Modules.promises.hooks.fire
- .calledWith('deactivateProject', this.project_id)
- .should.equal(true)
- this.DocstoreManager.promises.archiveProject
- .calledWith(this.project_id)
- .should.equal(true)
- this.ProjectUpdateHandler.promises.markAsInactive
- .calledWith(this.project_id)
- .should.equal(true)
- })
-
- it('should not call markAsInactive if there was a problem archiving in docstore', async function () {
- this.DocstoreManager.promises.archiveProject.rejects()
- this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
- this.ProjectUpdateHandler.promises.markAsInactive.resolves()
- this.Modules.promises.hooks.fire.resolves()
-
- await expect(
- this.InactiveProjectManager.promises.deactivateProject(this.project_id)
- ).to.be.rejected
- this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
- .calledWith(this.project_id)
- .should.equal(true)
- this.DocstoreManager.promises.archiveProject
- .calledWith(this.project_id)
- .should.equal(true)
- this.ProjectUpdateHandler.promises.markAsInactive
- .calledWith(this.project_id)
- .should.equal(false)
- })
- })
-})
diff --git a/services/web/test/unit/src/Project/ProjectDuplicator.test.mjs b/services/web/test/unit/src/Project/ProjectDuplicator.test.mjs
new file mode 100644
index 0000000000..f7fc10d731
--- /dev/null
+++ b/services/web/test/unit/src/Project/ProjectDuplicator.test.mjs
@@ -0,0 +1,461 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
+
+const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDuplicator.mjs'
+
+describe('ProjectDuplicator', function () {
+ beforeEach(async function (ctx) {
+ ctx.doc0 = { _id: 'doc0_id', name: 'rootDocHere' }
+ ctx.doc1 = { _id: 'doc1_id', name: 'level1folderDocName' }
+ ctx.doc2 = { _id: 'doc2_id', name: 'level2folderDocName' }
+ ctx.doc0Lines = ['zero']
+ ctx.doc1Lines = ['one']
+ ctx.doc2Lines = ['two']
+ ctx.file0 = { name: 'file0', _id: 'file0', hash: 'abcde' }
+ ctx.file1 = { name: 'file1', _id: 'file1', hash: 'fffff' }
+ ctx.file2 = {
+ name: 'file2',
+ _id: 'file2',
+ created: '2024-07-05T14:18:31.401+00:00',
+ linkedFileData: { provider: 'url' },
+ hash: '123456',
+ }
+ ctx.level2folder = {
+ name: 'level2folderName',
+ _id: 'level2folderId',
+ docs: [ctx.doc2, undefined],
+ folders: [],
+ fileRefs: [ctx.file2],
+ }
+ ctx.level1folder = {
+ name: 'level1folder',
+ _id: 'level1folderId',
+ docs: [ctx.doc1],
+ folders: [ctx.level2folder],
+ fileRefs: [ctx.file1, null], // the null is intentional to test null docs/files
+ }
+ ctx.rootFolder = {
+ name: 'rootFolder',
+ _id: 'rootFolderId',
+ docs: [ctx.doc0],
+ folders: [ctx.level1folder, {}],
+ fileRefs: [ctx.file0],
+ }
+ ctx.project = {
+ _id: 'this_is_the_old_project_id',
+ rootDoc_id: ctx.doc0._id,
+ rootFolder: [ctx.rootFolder],
+ compiler: 'this_is_a_Compiler',
+ overleaf: { history: { id: 123456 } },
+ }
+ ctx.doc0Path = '/rootDocHere'
+ ctx.doc1Path = '/level1folder/level1folderDocName'
+ ctx.doc2Path = '/level1folder/level2folderName/level2folderDocName'
+ ctx.file0Path = '/file0'
+ ctx.file1Path = '/level1folder/file1'
+ ctx.file2Path = '/level1folder/level2folderName/file2'
+
+ ctx.docContents = [
+ { _id: ctx.doc0._id, lines: ctx.doc0Lines },
+ { _id: ctx.doc1._id, lines: ctx.doc1Lines },
+ { _id: ctx.doc2._id, lines: ctx.doc2Lines },
+ ]
+
+ ctx.rootDoc = ctx.doc0
+ ctx.rootDocPath = '/rootDocHere'
+ ctx.owner = { _id: 'this_is_the_owner' }
+ ctx.newBlankProject = {
+ _id: 'new_project_id',
+ overleaf: { history: { id: 339123 } },
+ readOnly_refs: [],
+ collaberator_refs: [],
+ rootFolder: [{ _id: 'new_root_folder_id' }],
+ }
+ ctx.newFolder = { _id: 'newFolderId' }
+ ctx.filestoreUrl = 'filestore-url'
+ ctx.newProjectVersion = 2
+
+ ctx.newDocId = new ObjectId()
+ ctx.newFileId = new ObjectId()
+ ctx.newDoc0 = { ...ctx.doc0, _id: ctx.newDocId }
+ ctx.newDoc1 = { ...ctx.doc1, _id: ctx.newDocId }
+ ctx.newDoc2 = { ...ctx.doc2, _id: ctx.newDocId }
+ ctx.newFile0 = { ...ctx.file0, _id: ctx.newFileId }
+ ctx.newFile1 = { ...ctx.file1, _id: ctx.newFileId }
+ ctx.newFile2 = { ...ctx.file2, _id: ctx.newFileId }
+
+ ctx.docEntries = [
+ {
+ path: ctx.doc0Path,
+ doc: ctx.newDoc0,
+ docLines: ctx.doc0Lines.join('\n'),
+ },
+ {
+ path: ctx.doc1Path,
+ doc: ctx.newDoc1,
+ docLines: ctx.doc1Lines.join('\n'),
+ },
+ {
+ path: ctx.doc2Path,
+ doc: ctx.newDoc2,
+ docLines: ctx.doc2Lines.join('\n'),
+ },
+ ]
+ ctx.fileEntries = [
+ {
+ createdBlob: true,
+ path: ctx.file0Path,
+ file: ctx.newFile0,
+ },
+ {
+ createdBlob: true,
+ path: ctx.file1Path,
+ file: ctx.newFile1,
+ },
+ {
+ createdBlob: true,
+ path: ctx.file2Path,
+ file: ctx.newFile2,
+ },
+ ]
+
+ ctx.Doc = sinon.stub().callsFake(props => ({ _id: ctx.newDocId, ...props }))
+ ctx.File = sinon
+ .stub()
+ .callsFake(props => ({ _id: ctx.newFileId, ...props }))
+
+ ctx.DocstoreManager = {
+ promises: {
+ updateDoc: sinon.stub().resolves(),
+ getAllDocs: sinon.stub().resolves(ctx.docContents),
+ },
+ }
+ ctx.DocumentUpdaterHandler = {
+ promises: {
+ flushProjectToMongo: sinon.stub().resolves(),
+ updateProjectStructure: sinon.stub().resolves(),
+ },
+ }
+ ctx.HistoryManager = {
+ promises: {
+ copyBlob: sinon.stub().callsFake((historyId, newHistoryId, hash) => {
+ if (hash === '500') {
+ return Promise.reject(new Error('copy blob error'))
+ }
+ return Promise.resolve()
+ }),
+ },
+ }
+ ctx.TagsHandler = {
+ promises: {
+ addProjectToTags: sinon.stub().resolves({
+ _id: 'project-1',
+ }),
+ countTagsForProject: sinon.stub().resolves(1),
+ },
+ }
+ ctx.ProjectCreationHandler = {
+ promises: {
+ createBlankProject: sinon.stub().resolves(ctx.newBlankProject),
+ },
+ }
+ ctx.ProjectDeleter = {
+ promises: {
+ deleteProject: sinon.stub().resolves(),
+ },
+ }
+ ctx.ProjectEntityMongoUpdateHandler = {
+ promises: {
+ createNewFolderStructure: sinon.stub().resolves(ctx.newProjectVersion),
+ },
+ }
+ ctx.ProjectEntityUpdateHandler = {
+ isPathValidForRootDoc: sinon.stub().returns(true),
+ promises: {
+ setRootDoc: sinon.stub().resolves(),
+ },
+ }
+ ctx.ProjectGetter = {
+ promises: {
+ getProject: sinon
+ .stub()
+ .withArgs(ctx.project._id)
+ .resolves(ctx.project),
+ },
+ }
+ ctx.ProjectLocator = {
+ promises: {
+ findRootDoc: sinon.stub().resolves({
+ element: ctx.rootDoc,
+ path: { fileSystem: ctx.rootDocPath },
+ }),
+ findElementByPath: sinon
+ .stub()
+ .withArgs({
+ project_id: ctx.newBlankProject._id,
+ path: ctx.rootDocPath,
+ exactCaseMatch: true,
+ })
+ .resolves({ element: ctx.doc0 }),
+ },
+ }
+ ctx.ProjectOptionsHandler = {
+ promises: {
+ setCompiler: sinon.stub().resolves(),
+ },
+ }
+ ctx.TpdsProjectFlusher = {
+ promises: {
+ flushProjectToTpds: sinon.stub().resolves(),
+ },
+ }
+
+ vi.doMock('../../../../app/src/models/Doc', () => ({
+ Doc: ctx.Doc,
+ }))
+
+ vi.doMock('../../../../app/src/models/File', () => ({
+ File: ctx.File,
+ }))
+
+ vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
+ default: ctx.DocstoreManager,
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+ () => ({
+ default: ctx.DocumentUpdaterHandler,
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/Project/ProjectCreationHandler',
+ () => ({
+ default: ctx.ProjectCreationHandler,
+ })
+ )
+
+ vi.doMock('../../../../app/src/Features/Project/ProjectDeleter', () => ({
+ default: ctx.ProjectDeleter,
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/Project/ProjectEntityMongoUpdateHandler',
+ () => ({
+ default: ctx.ProjectEntityMongoUpdateHandler,
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/Project/ProjectEntityUpdateHandler',
+ () => ({
+ default: ctx.ProjectEntityUpdateHandler,
+ })
+ )
+
+ vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+ default: ctx.ProjectGetter,
+ }))
+
+ vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+ default: ctx.ProjectLocator,
+ }))
+
+ vi.doMock(
+ '../../../../app/src/Features/Project/ProjectOptionsHandler',
+ () => ({
+ default: ctx.ProjectOptionsHandler,
+ })
+ )
+
+ vi.doMock(
+ '../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher',
+ () => ({
+ default: ctx.TpdsProjectFlusher,
+ })
+ )
+
+ vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({
+ default: ctx.TagsHandler,
+ }))
+
+ vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({
+ default: ctx.HistoryManager,
+ }))
+
+ vi.doMock('../../../../app/src/Features/Compile/ClsiCacheManager', () => ({
+ default: {
+ prepareClsiCache: sinon.stub().rejects(new Error('ignore this')),
+ },
+ }))
+
+ ctx.ProjectDuplicator = (await import(MODULE_PATH)).default
+ })
+
+ describe('when the copy succeeds', function () {
+ beforeEach(async function (ctx) {
+ ctx.newProjectName = 'New project name'
+ ctx.newProject = await ctx.ProjectDuplicator.promises.duplicate(
+ ctx.owner,
+ ctx.project._id,
+ ctx.newProjectName
+ )
+ })
+
+ it('should flush the original project to mongo', function (ctx) {
+ ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith(
+ ctx.project._id
+ )
+ })
+
+ it('should copy docs to docstore', function (ctx) {
+ for (const docLines of [ctx.doc0Lines, ctx.doc1Lines, ctx.doc2Lines]) {
+ ctx.DocstoreManager.promises.updateDoc.should.have.been.calledWith(
+ ctx.newProject._id.toString(),
+ ctx.newDocId.toString(),
+ docLines,
+ 0,
+ {}
+ )
+ }
+ })
+
+ it('should duplicate the files with hashes by copying the blobs in history v1', function (ctx) {
+ for (const file of [ctx.file0, ctx.file1, ctx.file2]) {
+ ctx.HistoryManager.promises.copyBlob.should.have.been.calledWith(
+ ctx.project.overleaf.history.id,
+ ctx.newProject.overleaf.history.id,
+ file.hash
+ )
+ }
+ })
+
+ it('should create a blank project', function (ctx) {
+ ctx.ProjectCreationHandler.promises.createBlankProject.should.have.been.calledWith(
+ ctx.owner._id,
+ ctx.newProjectName
+ )
+ ctx.newProject._id.should.equal(ctx.newBlankProject._id)
+ })
+
+ it('should use the same compiler', function (ctx) {
+ ctx.ProjectOptionsHandler.promises.setCompiler.should.have.been.calledWith(
+ ctx.newProject._id,
+ ctx.project.compiler
+ )
+ })
+
+ it('should use the same root doc', function (ctx) {
+ ctx.ProjectEntityUpdateHandler.promises.setRootDoc.should.have.been.calledWith(
+ ctx.newProject._id,
+ ctx.rootFolder.docs[0]._id
+ )
+ })
+
+ it('should not copy the collaborators or read only refs', function (ctx) {
+ ctx.newProject.collaberator_refs.length.should.equal(0)
+ ctx.newProject.readOnly_refs.length.should.equal(0)
+ })
+
+ it('should copy all documents and files', function (ctx) {
+ ctx.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.should.have.been.calledWith(
+ ctx.newProject._id,
+ ctx.docEntries,
+ ctx.fileEntries
+ )
+ })
+
+ it('should notify document updater of changes', function (ctx) {
+ ctx.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
+ ctx.newProject._id,
+ ctx.newProject.overleaf.history.id,
+ ctx.owner._id,
+ {
+ newDocs: ctx.docEntries,
+ newFiles: ctx.fileEntries,
+ newProject: { version: ctx.newProjectVersion },
+ },
+ null
+ )
+ })
+
+ it('should flush the project to TPDS', function (ctx) {
+ ctx.TpdsProjectFlusher.promises.flushProjectToTpds.should.have.been.calledWith(
+ ctx.newProject._id
+ )
+ })
+ })
+
+ describe('without a root doc', function () {
+ beforeEach(async function (ctx) {
+ ctx.ProjectLocator.promises.findRootDoc.resolves({
+ element: null,
+ path: null,
+ })
+ ctx.newProject = await ctx.ProjectDuplicator.promises.duplicate(
+ ctx.owner,
+ ctx.project._id,
+ 'Copy of project'
+ )
+ })
+
+ it('should not set the root doc on the copy', function (ctx) {
+ ctx.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
+ .called
+ })
+ })
+
+ describe('with an invalid root doc', function () {
+ beforeEach(async function (ctx) {
+ ctx.ProjectEntityUpdateHandler.isPathValidForRootDoc.returns(false)
+ ctx.newProject = await ctx.ProjectDuplicator.promises.duplicate(
+ ctx.owner,
+ ctx.project._id,
+ 'Copy of project'
+ )
+ })
+
+ it('should not set the root doc on the copy', function (ctx) {
+ ctx.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
+ .called
+ })
+ })
+
+ describe('when cloning in history-v1 fails', function () {
+ it('should fail the clone operation', async function (ctx) {
+ ctx.file0.hash = '500'
+ await expect(
+ ctx.ProjectDuplicator.promises.duplicate(
+ ctx.owner,
+ ctx.project._id,
+ 'name'
+ )
+ ).to.be.rejectedWith('copy blob error')
+ })
+ })
+
+ describe('when there is an error', function () {
+ beforeEach(async function (ctx) {
+ ctx.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.rejects()
+ await expect(
+ ctx.ProjectDuplicator.promises.duplicate(ctx.owner, ctx.project._id, '')
+ ).to.be.rejected
+ })
+
+ it('should delete the broken cloned project', function (ctx) {
+ ctx.ProjectDeleter.promises.deleteProject.should.have.been.calledWith(
+ ctx.newBlankProject._id
+ )
+ })
+
+ it('should not delete the original project', function (ctx) {
+ ctx.ProjectDeleter.promises.deleteProject.should.not.have.been.calledWith(
+ ctx.project._id
+ )
+ })
+ })
+})
diff --git a/services/web/test/unit/src/Project/ProjectDuplicatorTests.js b/services/web/test/unit/src/Project/ProjectDuplicatorTests.js
deleted file mode 100644
index 26b48ad2f2..0000000000
--- a/services/web/test/unit/src/Project/ProjectDuplicatorTests.js
+++ /dev/null
@@ -1,408 +0,0 @@
-const { expect } = require('chai')
-const sinon = require('sinon')
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-
-const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDuplicator.js'
-
-describe('ProjectDuplicator', function () {
- beforeEach(function () {
- this.doc0 = { _id: 'doc0_id', name: 'rootDocHere' }
- this.doc1 = { _id: 'doc1_id', name: 'level1folderDocName' }
- this.doc2 = { _id: 'doc2_id', name: 'level2folderDocName' }
- this.doc0Lines = ['zero']
- this.doc1Lines = ['one']
- this.doc2Lines = ['two']
- this.file0 = { name: 'file0', _id: 'file0', hash: 'abcde' }
- this.file1 = { name: 'file1', _id: 'file1', hash: 'fffff' }
- this.file2 = {
- name: 'file2',
- _id: 'file2',
- created: '2024-07-05T14:18:31.401+00:00',
- linkedFileData: { provider: 'url' },
- hash: '123456',
- }
- this.level2folder = {
- name: 'level2folderName',
- _id: 'level2folderId',
- docs: [this.doc2, undefined],
- folders: [],
- fileRefs: [this.file2],
- }
- this.level1folder = {
- name: 'level1folder',
- _id: 'level1folderId',
- docs: [this.doc1],
- folders: [this.level2folder],
- fileRefs: [this.file1, null], // the null is intentional to test null docs/files
- }
- this.rootFolder = {
- name: 'rootFolder',
- _id: 'rootFolderId',
- docs: [this.doc0],
- folders: [this.level1folder, {}],
- fileRefs: [this.file0],
- }
- this.project = {
- _id: 'this_is_the_old_project_id',
- rootDoc_id: this.doc0._id,
- rootFolder: [this.rootFolder],
- compiler: 'this_is_a_Compiler',
- overleaf: { history: { id: 123456 } },
- }
- this.doc0Path = '/rootDocHere'
- this.doc1Path = '/level1folder/level1folderDocName'
- this.doc2Path = '/level1folder/level2folderName/level2folderDocName'
- this.file0Path = '/file0'
- this.file1Path = '/level1folder/file1'
- this.file2Path = '/level1folder/level2folderName/file2'
-
- this.docContents = [
- { _id: this.doc0._id, lines: this.doc0Lines },
- { _id: this.doc1._id, lines: this.doc1Lines },
- { _id: this.doc2._id, lines: this.doc2Lines },
- ]
-
- this.rootDoc = this.doc0
- this.rootDocPath = '/rootDocHere'
- this.owner = { _id: 'this_is_the_owner' }
- this.newBlankProject = {
- _id: 'new_project_id',
- overleaf: { history: { id: 339123 } },
- readOnly_refs: [],
- collaberator_refs: [],
- rootFolder: [{ _id: 'new_root_folder_id' }],
- }
- this.newFolder = { _id: 'newFolderId' }
- this.filestoreUrl = 'filestore-url'
- this.newProjectVersion = 2
-
- this.newDocId = new ObjectId()
- this.newFileId = new ObjectId()
- this.newDoc0 = { ...this.doc0, _id: this.newDocId }
- this.newDoc1 = { ...this.doc1, _id: this.newDocId }
- this.newDoc2 = { ...this.doc2, _id: this.newDocId }
- this.newFile0 = { ...this.file0, _id: this.newFileId }
- this.newFile1 = { ...this.file1, _id: this.newFileId }
- this.newFile2 = { ...this.file2, _id: this.newFileId }
-
- this.docEntries = [
- {
- path: this.doc0Path,
- doc: this.newDoc0,
- docLines: this.doc0Lines.join('\n'),
- },
- {
- path: this.doc1Path,
- doc: this.newDoc1,
- docLines: this.doc1Lines.join('\n'),
- },
- {
- path: this.doc2Path,
- doc: this.newDoc2,
- docLines: this.doc2Lines.join('\n'),
- },
- ]
- this.fileEntries = [
- {
- createdBlob: true,
- path: this.file0Path,
- file: this.newFile0,
- },
- {
- createdBlob: true,
- path: this.file1Path,
- file: this.newFile1,
- },
- {
- createdBlob: true,
- path: this.file2Path,
- file: this.newFile2,
- },
- ]
-
- this.Doc = sinon
- .stub()
- .callsFake(props => ({ _id: this.newDocId, ...props }))
- this.File = sinon
- .stub()
- .callsFake(props => ({ _id: this.newFileId, ...props }))
-
- this.DocstoreManager = {
- promises: {
- updateDoc: sinon.stub().resolves(),
- getAllDocs: sinon.stub().resolves(this.docContents),
- },
- }
- this.DocumentUpdaterHandler = {
- promises: {
- flushProjectToMongo: sinon.stub().resolves(),
- updateProjectStructure: sinon.stub().resolves(),
- },
- }
- this.HistoryManager = {
- promises: {
- copyBlob: sinon.stub().callsFake((historyId, newHistoryId, hash) => {
- if (hash === '500') {
- return Promise.reject(new Error('copy blob error'))
- }
- return Promise.resolve()
- }),
- },
- }
- this.TagsHandler = {
- promises: {
- addProjectToTags: sinon.stub().resolves({
- _id: 'project-1',
- }),
- countTagsForProject: sinon.stub().resolves(1),
- },
- }
- this.ProjectCreationHandler = {
- promises: {
- createBlankProject: sinon.stub().resolves(this.newBlankProject),
- },
- }
- this.ProjectDeleter = {
- promises: {
- deleteProject: sinon.stub().resolves(),
- },
- }
- this.ProjectEntityMongoUpdateHandler = {
- promises: {
- createNewFolderStructure: sinon.stub().resolves(this.newProjectVersion),
- },
- }
- this.ProjectEntityUpdateHandler = {
- isPathValidForRootDoc: sinon.stub().returns(true),
- promises: {
- setRootDoc: sinon.stub().resolves(),
- },
- }
- this.ProjectGetter = {
- promises: {
- getProject: sinon
- .stub()
- .withArgs(this.project._id)
- .resolves(this.project),
- },
- }
- this.ProjectLocator = {
- promises: {
- findRootDoc: sinon.stub().resolves({
- element: this.rootDoc,
- path: { fileSystem: this.rootDocPath },
- }),
- findElementByPath: sinon
- .stub()
- .withArgs({
- project_id: this.newBlankProject._id,
- path: this.rootDocPath,
- exactCaseMatch: true,
- })
- .resolves({ element: this.doc0 }),
- },
- }
- this.ProjectOptionsHandler = {
- promises: {
- setCompiler: sinon.stub().resolves(),
- },
- }
- this.TpdsProjectFlusher = {
- promises: {
- flushProjectToTpds: sinon.stub().resolves(),
- },
- }
-
- this.ProjectDuplicator = SandboxedModule.require(MODULE_PATH, {
- requires: {
- '../../models/Doc': { Doc: this.Doc },
- '../../models/File': { File: this.File },
- '../Docstore/DocstoreManager': this.DocstoreManager,
- '../DocumentUpdater/DocumentUpdaterHandler':
- this.DocumentUpdaterHandler,
- './ProjectCreationHandler': this.ProjectCreationHandler,
- './ProjectDeleter': this.ProjectDeleter,
- './ProjectEntityMongoUpdateHandler':
- this.ProjectEntityMongoUpdateHandler,
- './ProjectEntityUpdateHandler': this.ProjectEntityUpdateHandler,
- './ProjectGetter': this.ProjectGetter,
- './ProjectLocator': this.ProjectLocator,
- './ProjectOptionsHandler': this.ProjectOptionsHandler,
- '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
- '../Tags/TagsHandler': this.TagsHandler,
- '../History/HistoryManager': this.HistoryManager,
- '../Compile/ClsiCacheManager': {
- prepareClsiCache: sinon.stub().rejects(new Error('ignore this')),
- },
- },
- })
- })
-
- describe('when the copy succeeds', function () {
- beforeEach(async function () {
- this.newProjectName = 'New project name'
- this.newProject = await this.ProjectDuplicator.promises.duplicate(
- this.owner,
- this.project._id,
- this.newProjectName
- )
- })
-
- it('should flush the original project to mongo', function () {
- this.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith(
- this.project._id
- )
- })
-
- it('should copy docs to docstore', function () {
- for (const docLines of [this.doc0Lines, this.doc1Lines, this.doc2Lines]) {
- this.DocstoreManager.promises.updateDoc.should.have.been.calledWith(
- this.newProject._id.toString(),
- this.newDocId.toString(),
- docLines,
- 0,
- {}
- )
- }
- })
-
- it('should duplicate the files with hashes by copying the blobs in history v1', function () {
- for (const file of [this.file0, this.file1, this.file2]) {
- this.HistoryManager.promises.copyBlob.should.have.been.calledWith(
- this.project.overleaf.history.id,
- this.newProject.overleaf.history.id,
- file.hash
- )
- }
- })
-
- it('should create a blank project', function () {
- this.ProjectCreationHandler.promises.createBlankProject.should.have.been.calledWith(
- this.owner._id,
- this.newProjectName
- )
- this.newProject._id.should.equal(this.newBlankProject._id)
- })
-
- it('should use the same compiler', function () {
- this.ProjectOptionsHandler.promises.setCompiler.should.have.been.calledWith(
- this.newProject._id,
- this.project.compiler
- )
- })
-
- it('should use the same root doc', function () {
- this.ProjectEntityUpdateHandler.promises.setRootDoc.should.have.been.calledWith(
- this.newProject._id,
- this.rootFolder.docs[0]._id
- )
- })
-
- it('should not copy the collaborators or read only refs', function () {
- this.newProject.collaberator_refs.length.should.equal(0)
- this.newProject.readOnly_refs.length.should.equal(0)
- })
-
- it('should copy all documents and files', function () {
- this.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.should.have.been.calledWith(
- this.newProject._id,
- this.docEntries,
- this.fileEntries
- )
- })
-
- it('should notify document updater of changes', function () {
- this.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
- this.newProject._id,
- this.newProject.overleaf.history.id,
- this.owner._id,
- {
- newDocs: this.docEntries,
- newFiles: this.fileEntries,
- newProject: { version: this.newProjectVersion },
- },
- null
- )
- })
-
- it('should flush the project to TPDS', function () {
- this.TpdsProjectFlusher.promises.flushProjectToTpds.should.have.been.calledWith(
- this.newProject._id
- )
- })
- })
-
- describe('without a root doc', function () {
- beforeEach(async function () {
- this.ProjectLocator.promises.findRootDoc.resolves({
- element: null,
- path: null,
- })
- this.newProject = await this.ProjectDuplicator.promises.duplicate(
- this.owner,
- this.project._id,
- 'Copy of project'
- )
- })
-
- it('should not set the root doc on the copy', function () {
- this.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
- .called
- })
- })
-
- describe('with an invalid root doc', function () {
- beforeEach(async function () {
- this.ProjectEntityUpdateHandler.isPathValidForRootDoc.returns(false)
- this.newProject = await this.ProjectDuplicator.promises.duplicate(
- this.owner,
- this.project._id,
- 'Copy of project'
- )
- })
-
- it('should not set the root doc on the copy', function () {
- this.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
- .called
- })
- })
-
- describe('when cloning in history-v1 fails', function () {
- it('should fail the clone operation', async function () {
- this.file0.hash = '500'
- await expect(
- this.ProjectDuplicator.promises.duplicate(
- this.owner,
- this.project._id,
- 'name'
- )
- ).to.be.rejectedWith('copy blob error')
- })
- })
-
- describe('when there is an error', function () {
- beforeEach(async function () {
- this.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.rejects()
- await expect(
- this.ProjectDuplicator.promises.duplicate(
- this.owner,
- this.project._id,
- ''
- )
- ).to.be.rejected
- })
-
- it('should delete the broken cloned project', function () {
- this.ProjectDeleter.promises.deleteProject.should.have.been.calledWith(
- this.newBlankProject._id
- )
- })
-
- it('should not delete the original project', function () {
- this.ProjectDeleter.promises.deleteProject.should.not.have.been.calledWith(
- this.project._id
- )
- })
- })
-})