فهرست منبع

Convert to ESM

GitOrigin-RevId: bc407a128b024792a65b62c2eaeacefa48ecfe8d
Andrew Rumble 10 ماه پیش
والد
کامیت
d5296783a4

+ 3 - 3
services/web/app/src/infrastructure/BodyParserWrapper.mjs

@@ -1,5 +1,5 @@
-const bodyParser = require('body-parser')
-const HttpErrorHandler = require('../Features/Errors/HttpErrorHandler')
+import bodyParser from 'body-parser'
+import HttpErrorHandler from '../Features/Errors/HttpErrorHandler.js'
 
 function isBodyParserError(nextArg) {
   if (nextArg instanceof Error) {
@@ -29,7 +29,7 @@ const wrapBodyParser = method => opts => {
   }
 }
 
-module.exports = {
+export default {
   urlencoded: wrapBodyParser('urlencoded'),
   json: wrapBodyParser('json'),
 }

+ 7 - 11
services/web/app/src/infrastructure/CSP.mjs

@@ -1,7 +1,7 @@
-const crypto = require('crypto')
-const path = require('path')
+import crypto from 'node:crypto'
+import path from 'node:path'
 
-module.exports = function ({
+export default function ({
   reportUri,
   reportPercentage,
   reportOnly = false,
@@ -57,7 +57,7 @@ module.exports = function ({
   }
 }
 
-const buildDefaultPolicy = (reportUri, styleSrc) => {
+export const buildDefaultPolicy = (reportUri, styleSrc) => {
   const directives = [
     `base-uri 'none'`, // forbid setting a "base" element
     `default-src 'none'`, // forbid loading anything from a "src" attribute
@@ -104,7 +104,7 @@ const buildViewPolicy = (
   return directives.join('; ')
 }
 
-const webRoot = path.resolve(__dirname, '..', '..', '..')
+const webRoot = path.resolve(import.meta.dirname, '..', '..', '..')
 
 // build the view path relative to the web root
 function relativeViewPath(view) {
@@ -113,7 +113,7 @@ function relativeViewPath(view) {
     : path.join('app', 'views', view)
 }
 
-function removeCSPHeaders(res) {
+export function removeCSPHeaders(res) {
   res.removeHeader('Content-Security-Policy')
   res.removeHeader('Content-Security-Policy-Report-Only')
 }
@@ -122,13 +122,9 @@ function removeCSPHeaders(res) {
  * WARNING: allowing inline styles can open a security hole;
  * this is intended only for use in specific circumstances, such as Safari's built-in PDF viewer.
  */
-function allowUnsafeInlineStyles(res) {
+export function allowUnsafeInlineStyles(res) {
   res.set(
     'Content-Security-Policy',
     buildDefaultPolicy(undefined, "'unsafe-inline'")
   )
 }
-
-module.exports.buildDefaultPolicy = buildDefaultPolicy
-module.exports.removeCSPHeaders = removeCSPHeaders
-module.exports.allowUnsafeInlineStyles = allowUnsafeInlineStyles

+ 3 - 3
services/web/app/src/infrastructure/CookieMetrics.mjs

@@ -1,5 +1,5 @@
-const Settings = require('@overleaf/settings')
-const metrics = require('@overleaf/metrics')
+import Settings from '@overleaf/settings'
+import metrics from '@overleaf/metrics'
 
 /**
  * Middleware function to record session cookie metrics.  This allows us to
@@ -27,4 +27,4 @@ function middleware(req, res, next) {
   next()
 }
 
-module.exports = { middleware }
+export default { middleware }

+ 13 - 30
services/web/app/src/infrastructure/Csrf.mjs

@@ -1,22 +1,9 @@
-/* eslint-disable
-    max-len,
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
+import csurf from 'csurf'
+import { promisify } from 'node:util'
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
 
-const csurf = require('csurf')
 const csrf = csurf()
-const { promisify } = require('util')
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
 
 // Wrapper for `csurf` middleware that provides a list of routes that can be excluded from csrf checks.
 //
@@ -53,7 +40,7 @@ class Csrf {
     if (!this.excluded_routes[route]) {
       this.excluded_routes[route] = {}
     }
-    return (this.excluded_routes[route][method] = 1)
+    this.excluded_routes[route][method] = 1
   }
 
   middleware(req, res, next) {
@@ -62,21 +49,17 @@ class Csrf {
     // token' error from csurf and continue on...
 
     // check whether the request method is excluded for the specified route
-    if (
-      (this.excluded_routes[req.path] != null
-        ? this.excluded_routes[req.path][req.method]
-        : undefined) === 1
-    ) {
+    if (this.excluded_routes[req.path]?.[req.method] === 1) {
       // ignore the error if it's due to a bad csrf token, and continue
-      return csrf(req, res, err => {
+      csrf(req, res, err => {
         if (err && err.code !== 'EBADCSRFTOKEN') {
-          return next(err)
+          next(err)
         } else {
-          return next()
+          next()
         }
       })
     } else {
-      return csrf(req, res, next)
+      csrf(req, res, next)
     }
   }
 
@@ -85,7 +68,7 @@ class Csrf {
     if (cb == null) {
       cb = function (valid) {}
     }
-    return csrf(req, null, err => cb(err))
+    csrf(req, null, err => cb(err))
   }
 
   static validateToken(token, session, cb) {
@@ -102,7 +85,7 @@ class Csrf {
       method: 'POST',
       session,
     }
-    return Csrf.validateRequest(req, cb)
+    Csrf.validateRequest(req, cb)
   }
 }
 
@@ -111,4 +94,4 @@ Csrf.promises = {
   validateToken: promisify(Csrf.validateToken),
 }
 
-module.exports = Csrf
+export default Csrf

+ 12 - 11
services/web/app/src/infrastructure/CustomSessionStore.mjs

@@ -1,10 +1,11 @@
-const session = require('express-session')
-const RedisStore = require('connect-redis')(session)
-const metrics = require('@overleaf/metrics')
-const logger = require('@overleaf/logger')
-const Settings = require('@overleaf/settings')
-const SessionManager = require('../Features/Authentication/SessionManager')
-const Metrics = require('@overleaf/metrics')
+import session from 'express-session'
+import RedisStoreFactory from 'connect-redis'
+import logger from '@overleaf/logger'
+import Settings from '@overleaf/settings'
+import SessionManager from '../Features/Authentication/SessionManager.js'
+import Metrics from '@overleaf/metrics'
+
+const RedisStore = RedisStoreFactory(session)
 
 const MAX_SESSION_SIZE_THRESHOLD = 4096
 
@@ -37,14 +38,14 @@ class CustomSessionStore extends RedisStore {
     }
     const size = sess ? JSON.stringify(sess).length : 0
     // record the number of redis session operations
-    metrics.inc('session.store.count', 1, {
+    Metrics.inc('session.store.count', 1, {
       method,
       type,
       status: size > MAX_SESSION_SIZE_THRESHOLD ? 'oversize' : 'normal',
     })
     // record the redis session bandwidth for get/set operations
     if (method === 'get' || method === 'set') {
-      metrics.count('session.store.bytes', size, { method, type })
+      Metrics.count('session.store.bytes', size, { method, type })
     }
     // log the largest anonymous session seen so far
     if (type === 'anonymous' && size > CustomSessionStore.largestSessionSize) {
@@ -153,7 +154,7 @@ class CustomSetRedisClient {
   set(args, cb) {
     args.push(this.#flag)
     this.#client.set(args, (err, ok) => {
-      metrics.inc('session.store.set', 1, {
+      Metrics.inc('session.store.set', 1, {
         path: this.#flag,
         status: err ? 'error' : ok ? 'success' : 'failure',
       })
@@ -162,4 +163,4 @@ class CustomSetRedisClient {
   }
 }
 
-module.exports = CustomSessionStore
+export default CustomSessionStore

+ 39 - 28
services/web/app/src/infrastructure/ExpressLocals.mjs

@@ -1,36 +1,43 @@
-const logger = require('@overleaf/logger')
-const Metrics = require('@overleaf/metrics')
-const Settings = require('@overleaf/settings')
-const _ = require('lodash')
-const { URL } = require('url')
-const Path = require('path')
-const moment = require('moment')
-const { fetchJson } = require('@overleaf/fetch-utils')
-const contentDisposition = require('content-disposition')
-const Features = require('./Features')
-const SessionManager = require('../Features/Authentication/SessionManager')
-const PackageVersions = require('./PackageVersions')
-const Modules = require('./Modules')
-const Errors = require('../Features/Errors/Errors')
-const {
+import logger from '@overleaf/logger'
+import Metrics from '@overleaf/metrics'
+import Settings from '@overleaf/settings'
+import _ from 'lodash'
+import { URL } from 'node:url'
+import Path from 'node:path'
+import moment from 'moment'
+import { fetchJson } from '@overleaf/fetch-utils'
+import contentDisposition from 'content-disposition'
+import Features from './Features.js'
+import SessionManager from '../Features/Authentication/SessionManager.js'
+import PackageVersions from './PackageVersions.js'
+import Modules from './Modules.js'
+import Errors from '../Features/Errors/Errors.js'
+
+import {
   canRedirectToAdminDomain,
   hasAdminAccess,
   useAdminCapabilities,
   useHasAdminCapability,
-} = require('../Features/Helpers/AdminAuthorizationHelper')
-const {
-  addOptionalCleanupHandlerAfterDrainingConnections,
-} = require('./GracefulShutdown')
-const { sanitizeSessionUserForFrontEnd } = require('./FrontEndUser')
+} from '../Features/Helpers/AdminAuthorizationHelper.js'
+
+import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.js'
+import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
+import { expressify } from '@overleaf/promise-utils'
 
 const IEEE_BRAND_ID = Settings.ieeeBrandId
 
 let webpackManifest
-function loadManifest() {
+async function loadManifest() {
   switch (process.env.NODE_ENV) {
     case 'production':
+      /* eslint-disable import/no-unresolved */
       // Only load webpack manifest file in production.
-      webpackManifest = require('../../../public/manifest.json')
+      webpackManifest = (
+        await import('../../../public/manifest.json', {
+          with: { type: 'json' },
+        })
+      ).default
+      /* eslint-enable import/no-unresolved */
       break
     case 'development': {
       // In dev, fetch the manifest from the webpack container.
@@ -76,8 +83,8 @@ function getWebpackAssets(entrypoint, section) {
   return webpackManifest.entrypoints[entrypoint].assets[section] || []
 }
 
-module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
-  loadManifest()
+export default async function (webRouter, privateApiRouter, publicApiRouter) {
+  await loadManifest()
   if (process.env.NODE_ENV === 'development') {
     // In the dev-env, delay requests until we fetched the manifest once.
     webRouter.use(function (req, res, next) {
@@ -253,10 +260,14 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
     next()
   })
 
-  webRouter.use(function (req, res, next) {
-    res.locals.StringHelper = require('../Features/Helpers/StringHelper')
-    next()
-  })
+  webRouter.use(
+    expressify(async function (req, res, next) {
+      res.locals.StringHelper = (
+        await import('../Features/Helpers/StringHelper.js')
+      ).default
+      next()
+    })
+  )
 
   webRouter.use(function (req, res, next) {
     res.locals.csrfToken = req != null ? req.csrfToken() : undefined

+ 1 - 5
services/web/app/src/infrastructure/FrontEndUser.mjs

@@ -1,4 +1,4 @@
-function sanitizeSessionUserForFrontEnd(sessionUser) {
+export function sanitizeSessionUserForFrontEnd(sessionUser) {
   if (sessionUser != null) {
     return {
       email: sessionUser.email,
@@ -9,7 +9,3 @@ function sanitizeSessionUserForFrontEnd(sessionUser) {
 
   return null
 }
-
-module.exports = {
-  sanitizeSessionUserForFrontEnd,
-}

+ 4 - 4
services/web/app/src/infrastructure/GeoIpLookup.mjs

@@ -1,6 +1,6 @@
-const settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const { fetchJson } = require('@overleaf/fetch-utils')
+import settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import { fetchJson } from '@overleaf/fetch-utils'
 
 const DEFAULT_CURRENCY_CODE = 'USD'
 
@@ -104,7 +104,7 @@ async function getCurrencyCode(ip) {
   return { currencyCode, countryCode }
 }
 
-module.exports = {
+export default {
   isValidCurrencyParam,
   promises: {
     getDetails,

+ 4 - 4
services/web/app/src/infrastructure/JsonWebToken.mjs

@@ -1,6 +1,6 @@
-const { callbackify, promisify } = require('util')
-const JWT = require('jsonwebtoken')
-const Settings = require('@overleaf/settings')
+import { callbackify, promisify } from 'node:util'
+import JWT from 'jsonwebtoken'
+import Settings from '@overleaf/settings'
 
 const jwtSign = promisify(JWT.sign)
 
@@ -20,7 +20,7 @@ function getDecoded(token) {
   return decoded
 }
 
-module.exports = {
+export default {
   sign: callbackify(sign),
   getDecoded,
   promises: {

+ 1 - 1
services/web/app/src/infrastructure/Keys.mjs

@@ -1,6 +1,6 @@
 // TODO: This file was created by bulk-decaffeinate.
 // Sanity-check the conversion and remove this comment.
-module.exports = {
+export default {
   queue: {
     web_to_tpds_http_requests: 'web_to_tpds_http_requests',
     tpds_to_web_http_requests: 'tpds_to_web_http_requests',

+ 1 - 1
services/web/app/src/infrastructure/LoggerSerializers.mjs

@@ -6,7 +6,7 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-module.exports = {
+export default {
   user(user) {
     if (user == null) {
       return null

+ 14 - 12
services/web/app/src/infrastructure/QueueWorkers.mjs

@@ -1,16 +1,18 @@
-const Features = require('./Features')
-const Queues = require('./Queues')
-const UserOnboardingEmailManager = require('../Features/User/UserOnboardingEmailManager')
-const UserPostRegistrationAnalyticsManager = require('../Features/User/UserPostRegistrationAnalyticsManager')
-const FeaturesUpdater = require('../Features/Subscription/FeaturesUpdater')
-const {
+import Features from './Features.js'
+import Queues from './Queues.js'
+import UserOnboardingEmailManager from '../Features/User/UserOnboardingEmailManager.js'
+import UserPostRegistrationAnalyticsManager from '../Features/User/UserPostRegistrationAnalyticsManager.js'
+import FeaturesUpdater from '../Features/Subscription/FeaturesUpdater.js'
+
+import {
   addOptionalCleanupHandlerBeforeStoppingTraffic,
   addRequiredCleanupHandlerBeforeDrainingConnections,
-} = require('./GracefulShutdown')
-const EmailHandler = require('../Features/Email/EmailHandler')
-const logger = require('@overleaf/logger')
-const OError = require('@overleaf/o-error')
-const Modules = require('./Modules')
+} from './GracefulShutdown.js'
+
+import EmailHandler from '../Features/Email/EmailHandler.js'
+import logger from '@overleaf/logger'
+import OError from '@overleaf/o-error'
+import Modules from './Modules.js'
 
 /**
  * @typedef {{
@@ -124,4 +126,4 @@ function registerCleanup(queue) {
   // Disconnect from redis is scheduled in queue setup.
 }
 
-module.exports = { start, registerQueue }
+export default { start, registerQueue }

+ 4 - 3
services/web/app/src/infrastructure/RedirectManager.mjs

@@ -12,11 +12,12 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
+import settings from '@overleaf/settings'
+import { URL } from 'node:url'
+
 let RedirectManager
-const settings = require('@overleaf/settings')
-const { URL } = require('url')
 
-module.exports = RedirectManager = {
+export default RedirectManager = {
   apply(webRouter) {
     return (() => {
       const result = []

+ 3 - 3
services/web/app/src/infrastructure/Server.mjs

@@ -3,7 +3,7 @@ import Settings from '@overleaf/settings'
 import logger from '@overleaf/logger'
 import metrics from '@overleaf/metrics'
 import Validation from './Validation.js'
-import csp from './CSP.mjs'
+import csp, { removeCSPHeaders } from './CSP.mjs'
 import Router from '../router.mjs'
 import helmet from 'helmet'
 import UserSessionsRedis from '../Features/User/UserSessionsRedis.js'
@@ -125,7 +125,7 @@ webRouter.use(
     fileURLToPath(new URL('../../../public', import.meta.url)),
     {
       maxAge: STATIC_CACHE_AGE,
-      setHeaders: csp.removeCSPHeaders,
+      setHeaders: removeCSPHeaders,
     }
   )
 )
@@ -257,7 +257,7 @@ if (Settings.cookieRollingSession) {
 }
 
 webRouter.use(ReferalConnect.use)
-expressLocals(webRouter, privateApiRouter, publicApiRouter)
+await expressLocals(webRouter, privateApiRouter, publicApiRouter)
 webRouter.use(SessionAutostartMiddleware.invokeCallbackMiddleware)
 
 webRouter.use(function checkIfSiteClosed(req, res, next) {

+ 3 - 3
services/web/app/src/infrastructure/SessionAutostartMiddleware.mjs

@@ -1,5 +1,5 @@
-const Settings = require('@overleaf/settings')
-const OError = require('@overleaf/o-error')
+import Settings from '@overleaf/settings'
+import OError from '@overleaf/o-error'
 
 const botUserAgents = [
   'kube-probe',
@@ -125,4 +125,4 @@ class SessionAutostartMiddleware {
   }
 }
 
-module.exports = SessionAutostartMiddleware
+export default SessionAutostartMiddleware

+ 11 - 9
services/web/app/src/infrastructure/SiteAdminHandler.mjs

@@ -1,13 +1,15 @@
-const logger = require('@overleaf/logger')
-const settings = require('@overleaf/settings')
-const fs = require('fs')
-const {
+import logger from '@overleaf/logger'
+import settings from '@overleaf/settings'
+import fs from 'node:fs'
+
+import {
   addOptionalCleanupHandlerAfterDrainingConnections,
   addRequiredCleanupHandlerBeforeDrainingConnections,
-} = require('./GracefulShutdown')
-const Features = require('./Features')
-const UserHandler = require('../Features/User/UserHandler')
-const metrics = require('@overleaf/metrics')
+} from './GracefulShutdown.js'
+
+import Features from './Features.js'
+import UserHandler from '../Features/User/UserHandler.js'
+import metrics from '@overleaf/metrics'
 
 // Monitor a site maintenance file (e.g. /etc/site_status) periodically and
 // close the site if the file contents contain the string "closed".
@@ -56,7 +58,7 @@ function publishActiveUsersMetric() {
     .catch(error => logger.error({ error }, 'error counting active users'))
 }
 
-module.exports = {
+export default {
   initialise() {
     if (settings.enabledServices.includes('web') && statusFile) {
       logger.debug(

+ 12 - 12
services/web/app/src/infrastructure/Translations.mjs

@@ -1,12 +1,12 @@
-const i18n = require('i18next')
-const fsBackend = require('i18next-fs-backend')
-const middleware = require('i18next-http-middleware')
-const path = require('path')
-const Settings = require('@overleaf/settings')
-const { URL } = require('url')
-const pug = require('pug-runtime')
-const logger = require('@overleaf/logger')
-const SafeHTMLSubstitution = require('../Features/Helpers/SafeHTMLSubstitution')
+import i18n from 'i18next'
+import fsBackend from 'i18next-fs-backend'
+import middleware from 'i18next-http-middleware'
+import path from 'node:path'
+import Settings from '@overleaf/settings'
+import { URL } from 'node:url'
+import pug from 'pug-runtime'
+import logger from '@overleaf/logger'
+import SafeHTMLSubstitution from '../Features/Helpers/SafeHTMLSubstitution.js'
 
 const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
 const availableLanguageCodes = []
@@ -35,7 +35,7 @@ if (!availableLanguageCodes.includes(fallbackLanguageCode)) {
 if (process.argv.includes('--watch-locales')) {
   // Dummy imports for setting up watching of locales files.
   for (const lngCode of availableLanguageCodes) {
-    require(`../../../locales/${lngCode}.json`)
+    await import(`../../../locales/${lngCode}.json`, { with: { type: 'json' } })
   }
 }
 
@@ -44,7 +44,7 @@ i18n
   .use(middleware.LanguageDetector)
   .init({
     backend: {
-      loadPath: path.join(__dirname, '../../../locales/__lng__.json'),
+      loadPath: path.join(import.meta.dirname, '../../../locales/__lng__.json'),
     },
 
     // still using the v3 plural suffixes
@@ -140,7 +140,7 @@ function setLangBasedOnDomainMiddleware(req, res, next) {
 // in direct usage
 i18n.translate = i18n.t
 
-module.exports = {
+export default {
   i18nMiddleware: middleware.handle(i18n),
   setLangBasedOnDomainMiddleware,
   i18n,

+ 5 - 5
services/web/app/src/infrastructure/UnsupportedBrowserMiddleware.mjs

@@ -1,7 +1,7 @@
-const Bowser = require('bowser')
-const Settings = require('@overleaf/settings')
-const Url = require('url')
-const { getSafeRedirectPath } = require('../Features/Helpers/UrlHelper')
+import Bowser from 'bowser'
+import Settings from '@overleaf/settings'
+import Url from 'node:url'
+import { getSafeRedirectPath } from '../Features/Helpers/UrlHelper.js'
 
 function unsupportedBrowserMiddleware(req, res, next) {
   if (!Settings.unsupportedBrowsers) return next()
@@ -44,7 +44,7 @@ function renderUnsupportedBrowserPage(req, res) {
   res.render('general/unsupported-browser', { fromURL })
 }
 
-module.exports = {
+export default {
   renderUnsupportedBrowserPage,
   unsupportedBrowserMiddleware,
 }

+ 101 - 132
services/web/test/unit/src/infrastructure/Csrf.test.mjs

@@ -1,192 +1,161 @@
-/* eslint-disable
-    max-len,
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const { assert, expect } = require('chai')
-const sinon = require('sinon')
-const modulePath = '../../../../app/src/infrastructure/Csrf.js'
-const SandboxedModule = require('sandboxed-module')
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import sinon from 'sinon'
+const modulePath = '../../../../app/src/infrastructure/Csrf.mjs'
 
 describe('Csrf', function () {
-  beforeEach(function () {
-    this.csurf_csrf = sinon
+  beforeEach(async function (ctx) {
+    ctx.csurf_csrf = sinon
       .stub()
-      .callsArgWith(2, (this.err = { code: 'EBADCSRFTOKEN' }))
-    this.Csrf = SandboxedModule.require(modulePath, {
-      requires: {
-        csurf: sinon.stub().returns(this.csurf_csrf),
-      },
-    })
-    this.csrf = new this.Csrf()
-    this.next = sinon.stub()
-    this.path = '/foo/bar'
-    this.req = {
-      path: this.path,
+      .callsArgWith(2, (ctx.err = { code: 'EBADCSRFTOKEN' }))
+
+    vi.doMock('csurf', () => ({
+      default: sinon.stub().returns(ctx.csurf_csrf),
+    }))
+
+    ctx.Csrf = (await import(modulePath)).default
+    ctx.csrf = new ctx.Csrf()
+    ctx.next = sinon.stub()
+    ctx.path = '/foo/bar'
+    ctx.req = {
+      path: ctx.path,
       method: 'POST',
     }
-    return (this.res = {})
+    ctx.res = {}
   })
 
   describe('the middleware', function () {
     describe('when there are no excluded routes', function () {
-      it('passes the csrf error on', function () {
-        this.csrf.middleware(this.req, this.res, this.next)
-        return expect(this.next.calledWith(this.err)).to.equal(true)
+      it('passes the csrf error on', function (ctx) {
+        ctx.csrf.middleware(ctx.req, ctx.res, ctx.next)
+        return expect(ctx.next.calledWith(ctx.err)).to.equal(true)
       })
     })
 
     describe('when the route is excluded', function () {
-      it('does not pass the csrf error on', function () {
-        this.csrf.disableDefaultCsrfProtection(this.path, 'POST')
-        this.csrf.middleware(this.req, this.res, this.next)
-        return expect(this.next.calledWith(this.err)).to.equal(false)
+      it('does not pass the csrf error on', function (ctx) {
+        ctx.csrf.disableDefaultCsrfProtection(ctx.path, 'POST')
+        ctx.csrf.middleware(ctx.req, ctx.res, ctx.next)
+        return expect(ctx.next.calledWith(ctx.err)).to.equal(false)
       })
     })
 
     describe('when there is a partial route match', function () {
-      it('passes the csrf error on when the match is too short', function () {
-        this.csrf.disableDefaultCsrfProtection('/foo', 'POST')
-        this.csrf.middleware(this.req, this.res, this.next)
-        return expect(this.next.calledWith(this.err)).to.equal(true)
+      it('passes the csrf error on when the match is too short', function (ctx) {
+        ctx.csrf.disableDefaultCsrfProtection('/foo', 'POST')
+        ctx.csrf.middleware(ctx.req, ctx.res, ctx.next)
+        return expect(ctx.next.calledWith(ctx.err)).to.equal(true)
       })
 
-      it('passes the csrf error on when the match is too long', function () {
-        this.csrf.disableDefaultCsrfProtection('/foo/bar/baz', 'POST')
-        this.csrf.middleware(this.req, this.res, this.next)
-        return expect(this.next.calledWith(this.err)).to.equal(true)
+      it('passes the csrf error on when the match is too long', function (ctx) {
+        ctx.csrf.disableDefaultCsrfProtection('/foo/bar/baz', 'POST')
+        ctx.csrf.middleware(ctx.req, ctx.res, ctx.next)
+        return expect(ctx.next.calledWith(ctx.err)).to.equal(true)
       })
     })
 
     describe('when there are multiple exclusions', function () {
-      it('does not pass the csrf error on when the match is present', function () {
-        this.csrf.disableDefaultCsrfProtection(this.path, 'POST')
-        this.csrf.disableDefaultCsrfProtection('/test', 'POST')
-        this.csrf.disableDefaultCsrfProtection('/a/b/c', 'POST')
-        this.csrf.middleware(this.req, this.res, this.next)
-        return expect(this.next.calledWith(this.err)).to.equal(false)
+      it('does not pass the csrf error on when the match is present', function (ctx) {
+        ctx.csrf.disableDefaultCsrfProtection(ctx.path, 'POST')
+        ctx.csrf.disableDefaultCsrfProtection('/test', 'POST')
+        ctx.csrf.disableDefaultCsrfProtection('/a/b/c', 'POST')
+        ctx.csrf.middleware(ctx.req, ctx.res, ctx.next)
+        return expect(ctx.next.calledWith(ctx.err)).to.equal(false)
       })
 
-      it('passes the csrf error on when the match is not present', function () {
-        this.csrf.disableDefaultCsrfProtection('/url', 'POST')
-        this.csrf.disableDefaultCsrfProtection('/test', 'POST')
-        this.csrf.disableDefaultCsrfProtection('/a/b/c', 'POST')
-        this.csrf.middleware(this.req, this.res, this.next)
-        return expect(this.next.calledWith(this.err)).to.equal(true)
+      it('passes the csrf error on when the match is not present', function (ctx) {
+        ctx.csrf.disableDefaultCsrfProtection('/url', 'POST')
+        ctx.csrf.disableDefaultCsrfProtection('/test', 'POST')
+        ctx.csrf.disableDefaultCsrfProtection('/a/b/c', 'POST')
+        ctx.csrf.middleware(ctx.req, ctx.res, ctx.next)
+        return expect(ctx.next.calledWith(ctx.err)).to.equal(true)
       })
     })
 
     describe('when the method does not match', function () {
-      it('passes the csrf error on', function () {
-        this.csrf.disableDefaultCsrfProtection(this.path, 'POST')
-        this.req.method = 'GET'
-        this.csrf.middleware(this.req, this.res, this.next)
-        return expect(this.next.calledWith(this.err)).to.equal(true)
+      it('passes the csrf error on', function (ctx) {
+        ctx.csrf.disableDefaultCsrfProtection(ctx.path, 'POST')
+        ctx.req.method = 'GET'
+        ctx.csrf.middleware(ctx.req, ctx.res, ctx.next)
+        return expect(ctx.next.calledWith(ctx.err)).to.equal(true)
       })
     })
 
     describe('when the route is excluded, but the error is not a bad-csrf-token error', function () {
-      it('passes the error on', function () {
-        let err
-        this.Csrf = SandboxedModule.require(modulePath, {
-          globals: {
-            console,
-          },
-          requires: {
-            csurf: (this.csurf = sinon
-              .stub()
-              .returns(
-                (this.csurf_csrf = sinon
-                  .stub()
-                  .callsArgWith(2, (err = { code: 'EOTHER' })))
-              )),
-          },
-        })
-        this.csrf = new this.Csrf()
-        this.csrf.disableDefaultCsrfProtection(this.path, 'POST')
-        this.csrf.middleware(this.req, this.res, this.next)
-        expect(this.next.calledWith(err)).to.equal(true)
-        return expect(this.next.calledWith(this.err)).to.equal(false)
+      it('passes the error on', async function (ctx) {
+        const err = { code: 'EOTHER' }
+
+        ctx.csurf_csrf.callsArgWith(2, err)
+
+        const csrf = new ctx.Csrf()
+        csrf.disableDefaultCsrfProtection(ctx.path, 'POST')
+        csrf.middleware(ctx.req, ctx.res, ctx.next)
+        expect(ctx.next.calledWith(err)).to.equal(true)
+        expect(ctx.next.calledWith(ctx.err)).to.equal(false)
       })
     })
   })
 
   describe('validateRequest', function () {
     describe('when the request is invalid', function () {
-      it('calls the callback with error', function () {
-        this.cb = sinon.stub()
-        this.Csrf.validateRequest(this.req, this.cb)
-        return expect(this.cb.calledWith(this.err)).to.equal(true)
+      it('calls the callback with error', function (ctx) {
+        ctx.cb = sinon.stub()
+        ctx.Csrf.validateRequest(ctx.req, ctx.cb)
+        expect(ctx.cb.calledWith(ctx.err)).to.equal(true)
       })
     })
 
     describe('when the request is valid', function () {
-      it('calls the callback without an error', function () {
-        this.Csrf = SandboxedModule.require(modulePath, {
-          globals: {
-            console,
-          },
-          requires: {
-            csurf: (this.csurf = sinon
-              .stub()
-              .returns((this.csurf_csrf = sinon.stub().callsArg(2)))),
-          },
-        })
-        this.cb = sinon.stub()
-        this.Csrf.validateRequest(this.req, this.cb)
-        return expect(this.cb.calledWith()).to.equal(true)
+      it('calls the callback without an error', async function (ctx) {
+        vi.doMock('csurf', () => ({
+          default: (ctx.csurf = sinon
+            .stub()
+            .returns((ctx.csurf_csrf = sinon.stub().callsArg(2)))),
+        }))
+
+        ctx.Csrf = (await import(modulePath)).default
+        ctx.cb = sinon.stub()
+        ctx.Csrf.validateRequest(ctx.req, ctx.cb)
+        expect(ctx.cb.calledWith()).to.equal(true)
       })
     })
   })
 
   describe('validateToken', function () {
     describe('when the request is invalid', function () {
-      it('calls the callback with `false`', function () {
-        this.cb = sinon.stub()
-        this.Csrf.validateToken('token', {}, this.cb)
-        expect(this.cb.calledWith(this.err)).to.equal(true)
+      it('calls the callback with `false`', function (ctx) {
+        ctx.cb = sinon.stub()
+        ctx.Csrf.validateToken('token', {}, ctx.cb)
+        expect(ctx.cb.calledWith(ctx.err)).to.equal(true)
       })
     })
 
     describe('when the request is valid', function () {
-      it('calls the callback with `true`', function () {
-        this.Csrf = SandboxedModule.require(modulePath, {
-          globals: {
-            console,
-          },
-          requires: {
-            csurf: (this.csurf = sinon
-              .stub()
-              .returns((this.csurf_csrf = sinon.stub().callsArg(2)))),
-          },
-        })
-        this.cb = sinon.stub()
-        this.Csrf.validateToken('goodtoken', {}, this.cb)
-        return expect(this.cb.calledWith()).to.equal(true)
+      it('calls the callback with `true`', async function (ctx) {
+        vi.doMock('csurf', () => ({
+          default: (ctx.csurf = sinon
+            .stub()
+            .returns((ctx.csurf_csrf = sinon.stub().callsArg(2)))),
+        }))
+
+        ctx.Csrf = (await import(modulePath)).default
+        ctx.cb = sinon.stub()
+        ctx.Csrf.validateToken('goodtoken', {}, ctx.cb)
+        expect(ctx.cb.calledWith()).to.equal(true)
       })
     })
 
     describe('when there is no token', function () {
-      it('calls the callback with an error', function () {
-        this.Csrf = SandboxedModule.require(modulePath, {
-          globals: {
-            console,
-          },
-          requires: {
-            csurf: (this.csurf = sinon
-              .stub()
-              .returns((this.csurf_csrf = sinon.stub().callsArg(2)))),
-          },
-        })
-        this.cb = sinon.stub()
-        this.Csrf.validateToken(null, {}, error => {
+      it('calls the callback with an error', async function (ctx) {
+        vi.doMock('csurf', () => ({
+          default: (ctx.csurf = sinon
+            .stub()
+            .returns((ctx.csurf_csrf = sinon.stub().callsArg(2)))),
+        }))
+
+        ctx.Csrf = (await import(modulePath)).default
+        ctx.cb = sinon.stub()
+        ctx.Csrf.validateToken(null, {}, error => {
           expect(error).to.exist
         })
       })

+ 66 - 63
services/web/test/unit/src/infrastructure/GeoIpLookup.test.mjs

@@ -1,19 +1,18 @@
-const SandboxedModule = require('sandboxed-module')
-const assert = require('assert')
-const path = require('path')
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { assert, describe, beforeEach, it, vi, expect } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
+
 const modulePath = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/infrastructure/GeoIpLookup'
 )
 
 describe('GeoIpLookup', function () {
-  beforeEach(function () {
-    this.ipAddress = '12.34.56.78'
+  beforeEach(async function (ctx) {
+    ctx.ipAddress = '12.34.56.78'
 
-    this.stubbedResponse = {
-      ip: this.ipAddress,
+    ctx.stubbedResponse = {
+      ip: ctx.ipAddress,
       country_code: 'GB',
       country_name: 'United Kingdom',
       region_code: 'H9',
@@ -25,63 +24,67 @@ describe('GeoIpLookup', function () {
       metro_code: '',
       area_code: '',
     }
-    this.fetchUtils = {
-      fetchJson: sinon.stub().resolves(this.stubbedResponse),
+    ctx.fetchUtils = {
+      fetchJson: sinon.stub().resolves(ctx.stubbedResponse),
     }
-    this.settings = {
+    ctx.settings = {
       apis: {
         geoIpLookup: {
           url: 'http://lookup.com/',
         },
       },
     }
-    this.GeoIpLookup = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/fetch-utils': this.fetchUtils,
-        '@overleaf/settings': this.settings,
-      },
-    })
+
+    vi.doMock('@overleaf/fetch-utils', () => ({
+      ...ctx.fetchUtils,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    ctx.GeoIpLookup = (await import(modulePath)).default
   })
 
   describe('isValidCurrencyParam', function () {
-    it('should reject invalid currency codes', function () {
-      expect(this.GeoIpLookup.isValidCurrencyParam('GBP')).to.equal(true)
-      expect(this.GeoIpLookup.isValidCurrencyParam('USD')).to.equal(true)
-      expect(this.GeoIpLookup.isValidCurrencyParam('AUD')).to.equal(true)
-      expect(this.GeoIpLookup.isValidCurrencyParam('EUR')).to.equal(true)
-      expect(this.GeoIpLookup.isValidCurrencyParam('SGD')).to.equal(true)
-      expect(this.GeoIpLookup.isValidCurrencyParam('WAT')).to.equal(false)
-      expect(this.GeoIpLookup.isValidCurrencyParam('NON')).to.equal(false)
-      expect(this.GeoIpLookup.isValidCurrencyParam('LOL')).to.equal(false)
+    it('should reject invalid currency codes', function (ctx) {
+      expect(ctx.GeoIpLookup.isValidCurrencyParam('GBP')).to.equal(true)
+      expect(ctx.GeoIpLookup.isValidCurrencyParam('USD')).to.equal(true)
+      expect(ctx.GeoIpLookup.isValidCurrencyParam('AUD')).to.equal(true)
+      expect(ctx.GeoIpLookup.isValidCurrencyParam('EUR')).to.equal(true)
+      expect(ctx.GeoIpLookup.isValidCurrencyParam('SGD')).to.equal(true)
+      expect(ctx.GeoIpLookup.isValidCurrencyParam('WAT')).to.equal(false)
+      expect(ctx.GeoIpLookup.isValidCurrencyParam('NON')).to.equal(false)
+      expect(ctx.GeoIpLookup.isValidCurrencyParam('LOL')).to.equal(false)
     })
   })
 
   describe('getDetails', function () {
-    beforeEach(function () {
-      this.fetchUtils.fetchJson.resolves(this.stubbedResponse)
+    beforeEach(function (ctx) {
+      ctx.fetchUtils.fetchJson.resolves(ctx.stubbedResponse)
     })
 
     describe('async', function () {
-      it('should request the details using the ip', async function () {
-        await this.GeoIpLookup.promises.getDetails(this.ipAddress)
-        this.fetchUtils.fetchJson.should.have.been.calledWith(
-          new URL(this.settings.apis.geoIpLookup.url + this.ipAddress)
+      it('should request the details using the ip', async function (ctx) {
+        await ctx.GeoIpLookup.promises.getDetails(ctx.ipAddress)
+        ctx.fetchUtils.fetchJson.should.have.been.calledWith(
+          new URL(ctx.settings.apis.geoIpLookup.url + ctx.ipAddress)
         )
       })
 
-      it('should return the ip details', async function () {
-        const returnedDetails = await this.GeoIpLookup.promises.getDetails(
-          this.ipAddress
+      it('should return the ip details', async function (ctx) {
+        const returnedDetails = await ctx.GeoIpLookup.promises.getDetails(
+          ctx.ipAddress
         )
-        assert.deepEqual(returnedDetails, this.stubbedResponse)
+        assert.deepEqual(returnedDetails, ctx.stubbedResponse)
       })
 
-      it('should take the first ip in the string', async function () {
-        await this.GeoIpLookup.promises.getDetails(
-          ` ${this.ipAddress} 123.123.123.123 234.234.234.234`
+      it('should take the first ip in the string', async function (ctx) {
+        await ctx.GeoIpLookup.promises.getDetails(
+          ` ${ctx.ipAddress} 123.123.123.123 234.234.234.234`
         )
-        this.fetchUtils.fetchJson.should.have.been.calledWith(
-          new URL(this.settings.apis.geoIpLookup.url + this.ipAddress)
+        ctx.fetchUtils.fetchJson.should.have.been.calledWith(
+          new URL(ctx.settings.apis.geoIpLookup.url + ctx.ipAddress)
         )
       })
     })
@@ -89,57 +92,57 @@ describe('GeoIpLookup', function () {
 
   describe('getCurrencyCode', function () {
     describe('async', function () {
-      it('should return GBP for GB country', async function () {
+      it('should return GBP for GB country', async function (ctx) {
         const { currencyCode, countryCode } =
-          await this.GeoIpLookup.promises.getCurrencyCode(this.ipAddress)
+          await ctx.GeoIpLookup.promises.getCurrencyCode(ctx.ipAddress)
         currencyCode.should.equal('GBP')
         countryCode.should.equal('GB')
       })
 
-      it('should return GBP for gb country', async function () {
-        this.stubbedResponse.country_code = 'gb'
+      it('should return GBP for gb country', async function (ctx) {
+        ctx.stubbedResponse.country_code = 'gb'
         const { currencyCode, countryCode } =
-          await this.GeoIpLookup.promises.getCurrencyCode(this.ipAddress)
+          await ctx.GeoIpLookup.promises.getCurrencyCode(ctx.ipAddress)
         currencyCode.should.equal('GBP')
         countryCode.should.equal('GB')
       })
 
-      it('should return USD for US', async function () {
-        this.stubbedResponse.country_code = 'US'
+      it('should return USD for US', async function (ctx) {
+        ctx.stubbedResponse.country_code = 'US'
         const { currencyCode, countryCode } =
-          await this.GeoIpLookup.promises.getCurrencyCode(this.ipAddress)
+          await ctx.GeoIpLookup.promises.getCurrencyCode(ctx.ipAddress)
         currencyCode.should.equal('USD')
         countryCode.should.equal('US')
       })
 
-      it('should return EUR for DE', async function () {
-        this.stubbedResponse.country_code = 'DE'
+      it('should return EUR for DE', async function (ctx) {
+        ctx.stubbedResponse.country_code = 'DE'
         const { currencyCode, countryCode } =
-          await this.GeoIpLookup.promises.getCurrencyCode(this.ipAddress)
+          await ctx.GeoIpLookup.promises.getCurrencyCode(ctx.ipAddress)
         currencyCode.should.equal('EUR')
         countryCode.should.equal('DE')
       })
 
-      it('should default to USD if there is an error', async function () {
-        this.fetchUtils.fetchJson.rejects(new Error('foo'))
+      it('should default to USD if there is an error', async function (ctx) {
+        ctx.fetchUtils.fetchJson.rejects(new Error('foo'))
         const { currencyCode, countryCode } =
-          await this.GeoIpLookup.promises.getCurrencyCode(this.ipAddress)
+          await ctx.GeoIpLookup.promises.getCurrencyCode(ctx.ipAddress)
         currencyCode.should.equal('USD')
         expect(countryCode).to.be.undefined
       })
 
-      it('should default to USD if there are no details', async function () {
-        this.fetchUtils.fetchJson.resolves({})
+      it('should default to USD if there are no details', async function (ctx) {
+        ctx.fetchUtils.fetchJson.resolves({})
         const { currencyCode, countryCode } =
-          await this.GeoIpLookup.promises.getCurrencyCode(this.ipAddress)
+          await ctx.GeoIpLookup.promises.getCurrencyCode(ctx.ipAddress)
         currencyCode.should.equal('USD')
         expect(countryCode).to.be.undefined
       })
 
-      it('should default to USD if there is no match for their country', async function () {
-        this.stubbedResponse.country_code = 'Non existant'
+      it('should default to USD if there is no match for their country', async function (ctx) {
+        ctx.stubbedResponse.country_code = 'Non existant'
         const { currencyCode, countryCode } =
-          await this.GeoIpLookup.promises.getCurrencyCode(this.ipAddress)
+          await ctx.GeoIpLookup.promises.getCurrencyCode(ctx.ipAddress)
         currencyCode.should.equal('USD')
         countryCode.should.equal('NON EXISTANT')
       })

+ 42 - 41
services/web/test/unit/src/infrastructure/Translations.test.mjs

@@ -1,31 +1,38 @@
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
+import { describe, expect, it, vi } from 'vitest'
 
-const MODULE_PATH = '../../../../app/src/infrastructure/Translations.js'
+const MODULE_PATH = '../../../../app/src/infrastructure/Translations.mjs'
 
 describe('Translations', function () {
   let req, res, translations
-  function runMiddlewares(cb) {
-    translations.i18nMiddleware(req, res, () => {
-      translations.setLangBasedOnDomainMiddleware(req, res, cb)
-    })
+  async function runMiddlewares(cb) {
+    return await new Promise((resolve, reject) =>
+      translations.i18nMiddleware(req, res, () => {
+        translations.setLangBasedOnDomainMiddleware(req, res, (err, result) => {
+          if (err) {
+            reject(err)
+          } else {
+            resolve(result)
+          }
+        })
+      })
+    )
   }
 
-  beforeEach(function () {
-    translations = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '@overleaf/settings': {
-          i18n: {
-            escapeHTMLInVars: false,
-            subdomainLang: {
-              www: { lngCode: 'en', url: 'https://www.overleaf.com' },
-              fr: { lngCode: 'fr', url: 'https://fr.overleaf.com' },
-              da: { lngCode: 'da', url: 'https://da.overleaf.com' },
-            },
+  beforeEach(async function () {
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        i18n: {
+          escapeHTMLInVars: false,
+          subdomainLang: {
+            www: { lngCode: 'en', url: 'https://www.overleaf.com' },
+            fr: { lngCode: 'fr', url: 'https://fr.overleaf.com' },
+            da: { lngCode: 'da', url: 'https://da.overleaf.com' },
           },
         },
       },
-    })
+    }))
+
+    translations = (await import(MODULE_PATH)).default
 
     req = {
       url: '/',
@@ -41,8 +48,8 @@ describe('Translations', function () {
   })
 
   describe('translate', function () {
-    beforeEach(function (done) {
-      runMiddlewares(done)
+    beforeEach(async function () {
+      await runMiddlewares()
     })
 
     it('works', function () {
@@ -55,8 +62,8 @@ describe('Translations', function () {
   })
 
   describe('interpolation', function () {
-    beforeEach(function (done) {
-      runMiddlewares(done)
+    beforeEach(async function () {
+      await runMiddlewares()
     })
 
     it('works', function () {
@@ -94,34 +101,28 @@ describe('Translations', function () {
   })
 
   describe('setLangBasedOnDomainMiddleware', function () {
-    it('should set the lang to french if the domain is fr', function (done) {
+    it('should set the lang to french if the domain is fr', async function () {
       req.headers.host = 'fr.overleaf.com'
-      runMiddlewares(() => {
-        expect(req.lng).to.equal('fr')
-        done()
-      })
+      await runMiddlewares()
+      expect(req.lng).to.equal('fr')
     })
 
     describe('suggestedLanguageSubdomainConfig', function () {
-      it('should set suggestedLanguageSubdomainConfig if the detected lang is different to subdomain lang', function (done) {
+      it('should set suggestedLanguageSubdomainConfig if the detected lang is different to subdomain lang', async function () {
         req.headers['accept-language'] = 'da, en-gb;q=0.8, en;q=0.7'
         req.headers.host = 'fr.overleaf.com'
-        runMiddlewares(() => {
-          expect(res.locals.suggestedLanguageSubdomainConfig).to.exist
-          expect(res.locals.suggestedLanguageSubdomainConfig.lngCode).to.equal(
-            'da'
-          )
-          done()
-        })
+        await runMiddlewares()
+        expect(res.locals.suggestedLanguageSubdomainConfig).to.exist
+        expect(res.locals.suggestedLanguageSubdomainConfig.lngCode).to.equal(
+          'da'
+        )
       })
 
-      it('should not set suggestedLanguageSubdomainConfig if the detected lang is the same as subdomain lang', function (done) {
+      it('should not set suggestedLanguageSubdomainConfig if the detected lang is the same as subdomain lang', async function () {
         req.headers['accept-language'] = 'da, en-gb;q=0.8, en;q=0.7'
         req.headers.host = 'da.overleaf.com'
-        runMiddlewares(() => {
-          expect(res.locals.suggestedLanguageSubdomainConfig).to.not.exist
-          done()
-        })
+        await runMiddlewares()
+        expect(res.locals.suggestedLanguageSubdomainConfig).to.not.exist
       })
     })
   })