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