21 Commits

Author SHA1 Message Date
Borja
0409b27311 Writefull to BigQuery new events and clean up (#29191)
GitOrigin-RevId: dc01a585f451513dcb1eb37ce6e5fdf906eb7ef6
2025-10-21 08:06:43 +00:00
Antoine Clausse
677b68a6b7 [web] Promisify UserMembershipController (#29189)
* Promisify UserMembershipController

* Remove nested promisification from UserMembershipHandler

* Move export to the bottom of the file

* Remove manual promises

GitOrigin-RevId: a72b9ee4973da7a04b1ffeb588bcae3db696602f
2025-10-21 08:06:34 +00:00
Antoine Clausse
3a1f3af6a4 [web] Migrate some User and UserMembership files to ESM (#29181)
* Rename files to mjs

* Migrate files to ESM

* Fix imports

* Misc. fixes: Fixup InsititutionsAPI import, ObjectId import, ...

* Rename test files to mjs

* Convert test files to ESM

* Fix tests

* Update UserMembershipErrors imports

* Convert some tests: sinon -> vitest

* Fixup UserMembershipHandler.test.mjs

* Convert UserMembershipErrors to ESM

GitOrigin-RevId: 05d34c7e112a567f9c59398740ae0830ef93d32f
2025-10-21 08:06:30 +00:00
Jakob Ackermann
93526bec96 [web] pre-populate clsi-cache ahead of showing survey (#29221)
* [web] pre-populate clsi-cache ahead of showing survey

* [web] tweak variant checks

Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>

---------

Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>
GitOrigin-RevId: 749a7a1c3b21ef2e46bc86a74631e004069c5806
2025-10-21 08:06:25 +00:00
Jakob Ackermann
db1966b0aa [monorepo] switch from cypress-reports to reports (#29183)
* [monorepo] switch from cypress-reports to reports

* [saas-e2e] collect junit reports from new editor tests

* [saas-e2e] simplify setup for semaphore variable in Jenkins pipeline

We do not retry pipelines anymore.

* [saas-e2e] put new editor tests into their own namespace

GitOrigin-RevId: 1869310bf58f0b5b0081dcae2f16ec1a267caec6
2025-10-21 08:06:13 +00:00
Antoine Clausse
678cef1fa6 Remove migration folder from APP_CODE_PATH in esm-check-migration.mjs
See https://github.com/overleaf/internal/pull/28306

GitOrigin-RevId: db5f7a9362151b55c5c409ebe896534ad8e73cc6
2025-10-21 08:06:01 +00:00
Andrew Rumble
43f6f10d85 Handle files that can't be converted and files with unexpected extension
GitOrigin-RevId: 48a71b5a5fcfd6747692df02178e6aced5882b7a
2025-10-21 08:05:56 +00:00
Andrew Rumble
3d3be18f57 Features ESM conversion
GitOrigin-RevId: d659326723a90ac0789f4f7acc7f00aa9eaa63e2
2025-10-21 08:05:51 +00:00
Andrew Rumble
e909995ce0 Rename features files
GitOrigin-RevId: fc4ce037343b02d002fa07b7cdc283f2d7d4f89b
2025-10-21 08:05:47 +00:00
Andrew Rumble
7de4133d08 Convert to ES modules
GitOrigin-RevId: ec8d55634306caa6a013daa4dc0ce0a3ffbcc3f0
2025-10-21 08:05:42 +00:00
Andrew Rumble
bdf47f7a78 Rename files
GitOrigin-RevId: 5308845fa2a76342dc395ee4d4fcc94cdf03cb3b
2025-10-21 08:05:37 +00:00
Alex Vizcaino
1a94ebca8e Writefull accessibility audit (#29171)
GitOrigin-RevId: 6e50e64cc430a94739de57aacf04252f87695a13
2025-10-21 08:05:26 +00:00
Borja
bb4134dc05 Add support for additional translation languages and track user interest (#29208)
GitOrigin-RevId: 4ab23ef21853ff409945a8c3373b9dbf075d1f1e
2025-10-21 08:05:14 +00:00
Mathias Jakobsen
6b1c14b263 Merge pull request #29196 from overleaf/mj-fix-collaborator-index
[web] Correctly re-index when collaborator changes bib file

GitOrigin-RevId: 21d5416d54c6b89c434bf9c994c6e8d57a5a92e4
2025-10-21 08:05:05 +00:00
Jakob Ackermann
72b8f9b9d2 [server-ce] test: align build Jenkins triggers with saas-e2e (#29198)
GitOrigin-RevId: 55e35ca7f45d2b732aefa04560d5df95d8e8c034
2025-10-20 08:05:54 +00:00
Eric Mc Sween
d66c73a29e Merge pull request #29176 from overleaf/em-clsi-image-timings
CLSI: Capture image processing timings
GitOrigin-RevId: 28c2f73f260f2e82a64751bb46655e7546a458ef
2025-10-20 08:05:42 +00:00
Mathias Jakobsen
d75c5f72fb Merge pull request #29180 from overleaf/mj-linked-files-reindex
[web] Ensure we reindex after refreshing linked file

GitOrigin-RevId: 0c7d561a556525e9db30a8a1cec78b31a4d1d5de
2025-10-20 08:05:37 +00:00
Domagoj Kriskovic
48a3d6a10a Function for processing notification queue (#29072)
* Function for processing notification queue

* update collection name

GitOrigin-RevId: 5de5d1d056acee4257fcdc930ad995345f5e0367
2025-10-20 08:05:25 +00:00
Domagoj Kriskovic
71457d74cb Add notifications web module (#28983)
* Add notifications web module

* implement getThreadMessage in chat

* Save comment mention notification

* check if recipient is a real user

* move commentMentionDelay

* use module-hooks types

* remove router

* updated collection name

GitOrigin-RevId: cf8240c88aac7d7e4de4bf51cfe2608b6b7e7918
2025-10-20 08:05:20 +00:00
Domagoj Kriskovic
387ef81a31 Add migration script for creating emailNotifications collection (#28972)
GitOrigin-RevId: 73845e7e7dfe61f0a7ca19f4f8be94e6b41594c5
2025-10-20 08:05:15 +00:00
Jakob Ackermann
a4e29d5380 [monorepo] don't fight with prettier, do not run on json files (#29135)
I've tried a lots of variants for the ignore rule. It does not work.

GitOrigin-RevId: 08bf3df1d2629dcfc6eccc98df098ce094466d5b
2025-10-20 08:05:11 +00:00
82 changed files with 2916 additions and 2515 deletions

View File

@@ -1,3 +1,2 @@
cypress-reports/
data/
docker-mailtrap/

View File

@@ -7,75 +7,22 @@ patches/**
server-ce/**
server-pro/**
services/clsi/seccomp/**
services/history-v1/api/**
services/history-v1/storage/**
# echo chat clsi contacts docstore document-updater filestore history-v1 notifications project-history real-time references templates | xargs -n1 echo | xargs -I% echo 'services/%/*' 'services/%/app/**' 'services/%/config/*' 'services/%/scripts/**' | xargs -n1 echo | sort
services/chat/*
services/chat/app/**
services/chat/config/*
services/chat/scripts/**
services/clsi/*
services/clsi/app/**
services/clsi/config/*
services/clsi/scripts/**
services/contacts/*
services/contacts/app/**
services/contacts/config/*
services/contacts/scripts/**
services/docstore/*
services/docstore/app/**
services/docstore/config/*
services/docstore/scripts/**
services/document-updater/*
services/document-updater/app/**
services/document-updater/config/*
services/document-updater/scripts/**
services/filestore/*
services/filestore/app/**
services/filestore/config/*
services/filestore/scripts/**
services/history-v1/*
services/history-v1/app/**
services/history-v1/config/*
services/history-v1/scripts/**
services/notifications/*
services/notifications/app/**
services/notifications/config/*
services/notifications/scripts/**
services/project-history/*
services/project-history/app/**
services/project-history/config/*
services/project-history/scripts/**
services/real-time/*
services/real-time/app/**
services/real-time/config/*
services/real-time/scripts/**
services/references/*
services/references/app/**
services/references/config/*
services/references/scripts/**
services/templates/*
services/templates/app/**
services/templates/config/*
services/templates/scripts/**
services/web/*
services/web/app/**
services/web/config/settings.defaults.js
services/web/config/settings.overrides.server-pro.js
services/web/frontend/**
services/web/locales/**
services/web/modules/*/*
services/web/modules/*/app/**
services/web/modules/*/frontend/**
services/web/modules/*/scripts/**
services/web/modules/*/types/**
services/web/public/**
services/web/types/**
services/web/webpack-plugins/**
# echo chat clsi contacts docstore document-updater filestore history-v1 notifications project-history real-time references templates web | xargs -n1 echo | xargs -I% echo 'services/%/**'
# BEGIN GENERATED
services/chat/**
services/clsi/**
services/contacts/**
services/docstore/**
services/document-updater/**
services/filestore/**
services/history-v1/**
services/notifications/**
services/project-history/**
services/real-time/**
services/references/**
services/templates/**
services/web/**
# END GENERATED
tools/migrations/**

View File

@@ -41,22 +41,9 @@ pipeline {
OVERLEAF_PRO_TAG_LATEST = "us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro-internal:main"
}
stages {
// Retries will use the same pipeline instance. Reset the vars.
stage('Reset vars') {
stage('Create reports folder') {
steps {
script {
job_copybara_done = false
job_npm_install_done = false
job_prefetch_custom_done = false
job_prefetch_default_done = false
job_server_ce_build_done = false
job_server_pro_build_done = false
}
// Reset the results folder.
// Use a folder that is not managed by cypress, as cypress will clear its results folder at the start of each individual run.
// I.e. we would loose the test results from finished/running test suites when the last test suite starts.
sh 'rm -rf server-ce/test/cypress-reports/'
sh 'mkdir -p server-ce/test/cypress-reports/'
sh 'mkdir server-ce/test/reports'
}
}
stage('Parallel') {
@@ -350,7 +337,7 @@ pipeline {
post {
// Collect junit test results for both success and failure case.
always {
junit checksName: 'Server Pro E2E test results', testResults: 'server-ce/test/cypress-reports/junit-*.xml'
junit checksName: 'Server Pro E2E test results', testResults: 'server-ce/test/reports/junit-*.xml'
}
// Ensure tear down of test containers, remove CE docker images, then run general Jenkins VM cleanup.
cleanup {

View File

@@ -45,7 +45,7 @@ SHARD_PROJECT_NAMES = \
test-pro-custom-4
CLEAN_SHARDS=$(addprefix clean/,$(SHARD_PROJECT_NAMES))
clean: $(CLEAN_SHARDS)
-docker compose run --no-deps --rm --entrypoint rm host-admin -rf docker-compose.override.yml docker-compose.*_*.yml cypress-reports/ data/
-docker compose run --no-deps --rm --entrypoint rm host-admin -rf docker-compose.override.yml docker-compose.*_*.yml reports/ data/
-docker compose down --remove-orphans --rmi local --timeout 0 --volumes
$(CLEAN_SHARDS): clean/%:

View File

@@ -1,7 +1,7 @@
module.exports = {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
mochaFile: `cypress-reports/junit-${process.env.CYPRESS_SHARD}-[suiteFilename].xml`,
mochaFile: `reports/junit-${process.env.CYPRESS_SHARD}-[suiteFilename].xml`,
includePending: true,
jenkinsMode: true,
jenkinsClassnamePrefix: 'Server Pro E2E tests',

View File

@@ -6,8 +6,8 @@
"scripts": {
"cypress:open": "cypress open --e2e --browser chrome",
"cypress:run": "cypress run --e2e --browser chrome",
"format": "prettier --list-different $PWD/'**/*.{js,mjs,ts,tsx,json}'",
"format:fix": "prettier --write $PWD/'**/*.{js,mjs,ts,tsx,json}'"
"format": "prettier --list-different $PWD/'**/*.{js,mjs,ts,tsx}'",
"format:fix": "prettier --write $PWD/'**/*.{js,mjs,ts,tsx}'"
},
"devDependencies": {
"@isomorphic-git/lightning-fs": "^4.6.0",

View File

@@ -62,6 +62,10 @@ export async function getThread(context) {
return await callMessageHttpController(context, _getThread)
}
export async function getThreadMessage(context) {
return await callMessageHttpController(context, _getThreadMessage)
}
export async function resolveThread(context) {
return await callMessageHttpController(context, _resolveThread)
}
@@ -208,6 +212,30 @@ const _getThread = async (req, res) => {
}
}
const _getThreadMessage = async (req, res) => {
const { projectId, threadId, messageId } = req.params
logger.debug(
{ projectId, threadId, messageId },
'getting single thread message'
)
try {
const room = await ThreadManager.findThread(projectId, threadId)
const message = await MessageManager.getMessage(room._id, messageId)
const formattedMsg = MessageFormatter.formatMessageForClientSide(message)
res.status(200).setBody(formattedMsg)
} catch (error) {
if (
error instanceof ThreadManager.MissingThreadError ||
error instanceof MessageManager.MissingMessageError
) {
res.status(404)
return
}
throw error
}
}
const _resolveThread = async (req, res) => {
const { projectId, threadId } = req.params
const { user_id: userId } = req.body

View File

@@ -243,6 +243,20 @@ paths:
name: messageId
in: path
required: true
get:
summary: Get thread message
tags: []
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Message'
'404':
description: Message not found
operationId: getThreadMessage
description: Get a specific message by message ID from the thread with Thread ID and Project ID provided
delete:
summary: Delete message
operationId: deleteMessage

View File

@@ -766,6 +766,19 @@ function _emitMetrics(request, status, stats, timings) {
}
}
const imgTimings = stats.latexmk?.['latexmk-img-times']
if (imgTimings != null) {
for (const timing of imgTimings) {
ClsiMetrics.imageProcessingDurationSeconds.observe(
{
group: request.compileGroup,
type: timing.type,
},
timing.time_ms / 1000
)
}
}
ClsiMetrics.compilesTotal.inc({
status,
engine: request.compiler,

View File

@@ -22,7 +22,7 @@ const NOTEWORTHY_DEPENDENCIES_REGEXP =
* There are different formats of `latexmk` output depending on the version.
* The parsers attempt to handle these variations gracefully.
*/
const LATEX_MK_METRICS = [
const LATEX_MK_METRICS_STDOUT = [
// Extract individual latexmk rule times as an array of objects, each with 'rule'
// and 'time_ms' properties
[
@@ -100,22 +100,61 @@ const LATEX_MK_METRICS = [
],
]
const LATEX_MK_METRICS_STDERR = [
[
'latexmk-img-times',
s => {
const pngCopyMatches = s.matchAll(/^PNG copy: (.*)$/gm)
const pngCopyFiles = new Set()
for (const match of pngCopyMatches) {
const filename = match[1]
pngCopyFiles.add(filename)
}
const timingMatches = s.matchAll(
/^Image written \((PNG|JPG|JBIG2|PDF), (\d+) ms\): (.*)$/gm
)
const timingsByType = new Map()
for (const match of timingMatches) {
let type = match[1]
const timeMs = parseInt(match[2], 10)
const filename = match[3]
if (type === 'PNG' && pngCopyFiles.has(filename)) {
type = 'PNG-fast-copy'
}
const accumulatedTime = timingsByType.get(type) ?? 0
timingsByType.set(type, accumulatedTime + timeMs)
}
return Array.from(timingsByType.entries()).map(([type, timeMs]) => ({
type,
time_ms: timeMs,
}))
},
],
]
/**
* Parses latexmk stdout for metrics and adds them to the stats object.
* It iterates through a predefined list of metric matchers (LATEX_MK_METRICS),
* applies them to the stdout, and adds any successful matches to the
* `stats.latexmk` object.
*
* @param {{stdout?: string}} output - The output from the latexmk process.
* @param {{stdout?: string, stderr?: string}} output - The output from the latexmk process.
* @param {{latexmk: object}} stats - The statistics object to update. This object is mutated.
*/
function addLatexMkMetrics(output, stats) {
for (const [stat, matcher] of LATEX_MK_METRICS) {
for (const [stat, matcher] of LATEX_MK_METRICS_STDOUT) {
const match = matcher(output?.stdout || '', stats.latexmk)
if (match) {
stats.latexmk[stat] = match
}
}
for (const [stat, matcher] of LATEX_MK_METRICS_STDERR) {
const match = matcher(output?.stderr || '', stats.latexmk)
if (match) {
stats.latexmk[stat] = match
}
}
}
/**

View File

@@ -54,6 +54,13 @@ const latexmkRuleDurationSeconds = new prom.Histogram({
labelNames: ['group', 'rule'],
})
const imageProcessingDurationSeconds = new prom.Histogram({
name: 'clsi_image_processing_duration_seconds',
help: 'Time spent processing images',
buckets: COMPILE_TIME_BUCKETS,
labelNames: ['group', 'type'],
})
module.exports = {
compilesTotal,
compileDurationSeconds,
@@ -61,4 +68,5 @@ module.exports = {
syncResourcesDurationSeconds,
processOutputFilesDurationSeconds,
latexmkRuleDurationSeconds,
imageProcessingDurationSeconds,
}

View File

@@ -249,6 +249,7 @@ describe('LatexRunner', function () {
'makeindex,bibtex,latex,makeindex,bibtex,latex,makeindex,bibtex,latex',
'latexmk-rules-run': 9,
'latexmk-time': { total: 2930 },
'latexmk-img-times': [],
})
})
})
@@ -280,6 +281,7 @@ describe('LatexRunner', function () {
},
'latexmk-clock-time': 4870,
'latexmk-rules-run': 4,
'latexmk-img-times': [],
})
})
})

View File

@@ -8,6 +8,12 @@ async function getThread(projectId, threadId) {
return await fetchJson(chatApiUrl(`/project/${projectId}/thread/${threadId}`))
}
async function getThreadMessage(projectId, threadId, messageId) {
return await fetchJson(
chatApiUrl(`/project/${projectId}/thread/${threadId}/messages/${messageId}`)
)
}
async function getThreads(projectId) {
return await fetchJson(chatApiUrl(`/project/${projectId}/threads`))
}
@@ -161,6 +167,7 @@ function chatApiUrl(path) {
module.exports = {
getThread: callbackify(getThread),
getThreadMessage: callbackify(getThreadMessage),
getThreads: callbackify(getThreads),
destroyProject: callbackify(destroyProject),
sendGlobalMessage: callbackify(sendGlobalMessage),
@@ -180,6 +187,7 @@ module.exports = {
generateThreadData: callbackify(generateThreadData),
promises: {
getThread,
getThreadMessage,
getThreads,
destroyProject,
sendGlobalMessage,

View File

@@ -172,7 +172,14 @@ async function prepareClsiCache(
userId,
'populate-clsi-cache'
)
if (variant !== 'enabled') return
if (variant !== 'enabled') {
// Pre-populate the cache for the users in the split-test for prompts.
const { variant } = await SplitTestHandler.promises.getAssignmentForUser(
userId,
'populate-clsi-cache-for-prompt'
)
if (variant !== 'enabled') return
}
const features = await UserGetter.promises.getUserFeatures(userId)
if (features.compileGroup !== 'priority') return

View File

@@ -1,15 +1,15 @@
const { URL, URLSearchParams } = require('url')
const OError = require('@overleaf/o-error')
const Settings = require('@overleaf/settings')
const {
import { URL, URLSearchParams } from 'node:url'
import OError from '@overleaf/o-error'
import Settings from '@overleaf/settings'
import {
fetchNothing,
fetchStringWithResponse,
RequestFailedError,
} = require('@overleaf/fetch-utils')
const RedisWrapper = require('../../infrastructure/RedisWrapper')
const Cookie = require('cookie')
const logger = require('@overleaf/logger')
const Metrics = require('@overleaf/metrics')
} from '@overleaf/fetch-utils'
import RedisWrapper from '../../infrastructure/RedisWrapper.js'
import Cookie from 'cookie'
import logger from '@overleaf/logger'
import Metrics from '@overleaf/metrics'
const clsiCookiesEnabled = (Settings.clsiCookie?.key ?? '') !== ''
@@ -256,4 +256,4 @@ const ClsiCookieManagerFactory = function (backendGroup) {
return cookieManager
}
module.exports = ClsiCookieManagerFactory
export default ClsiCookieManagerFactory

View File

@@ -1,5 +1,5 @@
const _ = require('lodash')
const settings = require('@overleaf/settings')
import _ from 'lodash'
import settings from '@overleaf/settings'
const ClsiFormatChecker = {
checkRecoursesForProblems(resources) {
@@ -56,4 +56,4 @@ const ClsiFormatChecker = {
},
}
module.exports = ClsiFormatChecker
export default ClsiFormatChecker

View File

@@ -12,10 +12,10 @@ import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import { Cookie } from 'tough-cookie'
import ClsiCookieManagerFactory from './ClsiCookieManager.js'
import ClsiStateManager from './ClsiStateManager.js'
import ClsiCookieManagerFactory from './ClsiCookieManager.mjs'
import ClsiStateManager from './ClsiStateManager.mjs'
import _ from 'lodash'
import ClsiFormatChecker from './ClsiFormatChecker.js'
import ClsiFormatChecker from './ClsiFormatChecker.mjs'
import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
import Metrics from '@overleaf/metrics'
import Errors from '../Errors/Errors.js'

View File

@@ -1,8 +1,3 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
@@ -13,11 +8,8 @@
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
let ClsiStateManager
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const crypto = require('crypto')
const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
import crypto from 'node:crypto'
import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
// The "state" of a project is a hash of the relevant attributes in the
// project object in this case we only need the rootFolder.
@@ -36,7 +28,7 @@ const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
const buildState = s =>
crypto.createHash('sha1').update(s, 'utf8').digest('hex')
module.exports = ClsiStateManager = {
export default {
computeHash(project, options) {
const { docs, files } =
ProjectEntityHandler.getAllEntitiesFromProject(project)

View File

@@ -12,7 +12,7 @@ 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 ClsiCookieManagerFactory from './ClsiCookieManager.mjs'
import Path from 'node:path'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
@@ -73,9 +73,20 @@ async function _getSplitTestOptions(req, res) {
res,
'populate-clsi-cache'
)
const populateClsiCache = populateClsiCacheVariant === 'enabled'
let populateClsiCache = populateClsiCacheVariant === 'enabled'
const compileFromClsiCache = populateClsiCache // use same split-test
if (!populateClsiCache) {
// Pre-populate the cache for the users in the split-test for prompts.
// Keep the compile from cache disabled for now.
const { variant } = await SplitTestHandler.promises.getAssignment(
editorReq,
res,
'populate-clsi-cache-for-prompt'
)
populateClsiCache = variant === 'enabled'
}
const pdfDownloadDomain = Settings.pdfDownloadDomain
if (!req.query.enable_pdf_caching) {

View File

@@ -1,7 +1,7 @@
const { promisify, callbackify } = require('util')
const UserGetter = require('../User/UserGetter')
const UserMembershipsHandler = require('../UserMembership/UserMembershipsHandler')
const UserMembershipEntityConfigs = require('../UserMembership/UserMembershipEntityConfigs')
import { promisify, callbackify } from 'node:util'
import UserGetter from '../User/UserGetter.js'
import UserMembershipsHandler from '../UserMembership/UserMembershipsHandler.js'
import UserMembershipEntityConfigs from '../UserMembership/UserMembershipEntityConfigs.js'
async function getCurrentAffiliations(userId) {
const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
@@ -98,4 +98,4 @@ InstitutionsGetter.promises = {
getManagedInstitutions: promisify(InstitutionsGetter.getManagedInstitutions),
}
module.exports = InstitutionsGetter
export default InstitutionsGetter

View File

@@ -1,21 +1,20 @@
const {
callbackifyAll,
promiseMapWithLimit,
} = require('@overleaf/promise-utils')
const { ObjectId } = require('mongodb-legacy')
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const { fetchJson } = require('@overleaf/fetch-utils')
const InstitutionsAPI = require('./InstitutionsAPI')
const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
const FeaturesHelper = require('../Subscription/FeaturesHelper')
const UserGetter = require('../User/UserGetter')
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
const NotificationsHandler = require('../Notifications/NotificationsHandler')
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
const { Institution } = require('../../models/Institution')
const { Subscription } = require('../../models/Subscription')
const OError = require('@overleaf/o-error')
import { callbackifyAll, promiseMapWithLimit } from '@overleaf/promise-utils'
import mongodb from 'mongodb-legacy'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import { fetchJson } from '@overleaf/fetch-utils'
import InstitutionsAPI from './InstitutionsAPI.js'
import FeaturesUpdater from '../Subscription/FeaturesUpdater.js'
import FeaturesHelper from '../Subscription/FeaturesHelper.js'
import UserGetter from '../User/UserGetter.js'
import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
import NotificationsHandler from '../Notifications/NotificationsHandler.js'
import SubscriptionLocator from '../Subscription/SubscriptionLocator.js'
import { Institution } from '../../models/Institution.js'
import { Subscription } from '../../models/Subscription.js'
import OError from '@overleaf/o-error'
const { ObjectId } = mongodb
const ASYNC_LIMIT = parseInt(process.env.ASYNC_LIMIT, 10) || 5
@@ -355,7 +354,7 @@ async function affiliateUserByReversedHostname(user, reversedHostname) {
)
}
module.exports = {
export default {
...callbackifyAll(InstitutionsManager),
promises: InstitutionsManager,
}

View File

@@ -38,7 +38,7 @@ import FeaturesUpdater from '../Subscription/FeaturesUpdater.js'
import SpellingHandler from '../Spelling/SpellingHandler.mjs'
import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.js'
import InstitutionsGetter from '../Institutions/InstitutionsGetter.js'
import InstitutionsGetter from '../Institutions/InstitutionsGetter.mjs'
import ProjectAuditLogHandler from './ProjectAuditLogHandler.mjs'
import PublicAccessLevels from '../Authorization/PublicAccessLevels.js'
import TagsHandler from '../Tags/TagsHandler.js'

View File

@@ -1,6 +1,6 @@
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import SubscriptionEmailHandler from './SubscriptionEmailHandler.js'
import SubscriptionEmailHandler from './SubscriptionEmailHandler.mjs'
import { AI_ADD_ON_CODE } from './AiHelper.js'
import mongodb from 'mongodb-legacy'

View File

@@ -1,6 +1,6 @@
const EmailBuilder = require('../Email/EmailBuilder')
const EmailMessageHelper = require('../Email/EmailMessageHelper')
const settings = require('@overleaf/settings')
import EmailBuilder from '../Email/EmailBuilder.js'
import EmailMessageHelper from '../Email/EmailMessageHelper.js'
import settings from '@overleaf/settings'
EmailBuilder.templates.trialOnboarding = EmailBuilder.NoCTAEmailTemplate({
subject(opts) {

View File

@@ -1,8 +1,8 @@
const EmailHandler = require('../Email/EmailHandler')
const UserGetter = require('../User/UserGetter')
require('./SubscriptionEmailBuilder')
const PlansLocator = require('./PlansLocator')
const Settings = require('@overleaf/settings')
import EmailHandler from '../Email/EmailHandler.js'
import UserGetter from '../User/UserGetter.js'
import './SubscriptionEmailBuilder.mjs'
import PlansLocator from './PlansLocator.js'
import Settings from '@overleaf/settings'
const SubscriptionEmailHandler = {
async sendTrialOnboardingEmail(userId, planCode) {
@@ -26,4 +26,4 @@ const SubscriptionEmailHandler = {
},
}
module.exports = SubscriptionEmailHandler
export default SubscriptionEmailHandler

View File

@@ -1,4 +1,4 @@
const dateformat = require('dateformat')
import dateformat from 'dateformat'
function formatDateTime(date) {
if (!date) {
@@ -14,7 +14,7 @@ function formatDate(date) {
return dateformat(date, 'mmmm dS, yyyy', true)
}
module.exports = {
export default {
formatDateTime,
formatDate,
}

View File

@@ -4,10 +4,10 @@ import Settings from '@overleaf/settings'
import PlansLocator from './PlansLocator.js'
import { isStandaloneAiAddOnPlanCode } from './AiHelper.js'
import { MEMBERS_LIMIT_ADD_ON_CODE } from './PaymentProviderEntities.js'
import SubscriptionFormatters from './SubscriptionFormatters.js'
import SubscriptionFormatters from './SubscriptionFormatters.mjs'
import SubscriptionLocator from './SubscriptionLocator.js'
import InstitutionsGetter from '../Institutions/InstitutionsGetter.js'
import InstitutionsManager from '../Institutions/InstitutionsManager.js'
import InstitutionsGetter from '../Institutions/InstitutionsGetter.mjs'
import InstitutionsManager from '../Institutions/InstitutionsManager.mjs'
import PublishersGetter from '../Publishers/PublishersGetter.js'
import sanitizeHtml from 'sanitize-html'
import _ from 'lodash'

View File

@@ -7,8 +7,8 @@ import UserDeleter from './UserDeleter.js'
import UserGetter from './UserGetter.js'
import UserUpdater from './UserUpdater.js'
import Analytics from '../Analytics/AnalyticsManager.js'
import UserOnboardingEmailManager from './UserOnboardingEmailManager.js'
import UserPostRegistrationAnalyticsManager from './UserPostRegistrationAnalyticsManager.js'
import UserOnboardingEmailManager from './UserOnboardingEmailManager.mjs'
import UserPostRegistrationAnalyticsManager from './UserPostRegistrationAnalyticsManager.mjs'
import OError from '@overleaf/o-error'
async function _addAffiliation(user, affiliationOptions) {

View File

@@ -1,8 +1,8 @@
const Queues = require('../../infrastructure/Queues')
const EmailHandler = require('../Email/EmailHandler')
const UserUpdater = require('./UserUpdater')
const UserGetter = require('./UserGetter')
const Settings = require('@overleaf/settings')
import Queues from '../../infrastructure/Queues.js'
import EmailHandler from '../Email/EmailHandler.js'
import UserUpdater from './UserUpdater.js'
import UserGetter from './UserGetter.js'
import Settings from '@overleaf/settings'
const ONE_DAY_MS = 24 * 60 * 60 * 1000
@@ -26,4 +26,4 @@ async function sendOnboardingEmail(userId) {
}
}
module.exports = { scheduleOnboardingEmail, sendOnboardingEmail }
export default { scheduleOnboardingEmail, sendOnboardingEmail }

View File

@@ -1,9 +1,7 @@
const Queues = require('../../infrastructure/Queues')
const UserGetter = require('./UserGetter')
const {
promises: InstitutionsAPIPromises,
} = require('../Institutions/InstitutionsAPI')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
import Queues from '../../infrastructure/Queues.js'
import UserGetter from './UserGetter.js'
import InstitutionsAPI from '../Institutions/InstitutionsAPI.js'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
const ONE_DAY_MS = 24 * 60 * 60 * 1000
@@ -25,7 +23,7 @@ async function postRegistrationAnalytics(userId) {
async function checkAffiliations(userId) {
const affiliationsData =
await InstitutionsAPIPromises.getUserAffiliations(userId)
await InstitutionsAPI.promises.getUserAffiliations(userId)
const hasCommonsAccountAffiliation = affiliationsData.some(
affiliationData =>
affiliationData.institution && affiliationData.institution.commonsAccount
@@ -40,7 +38,7 @@ async function checkAffiliations(userId) {
}
}
module.exports = {
export default {
schedulePostRegistrationAnalytics,
postRegistrationAnalytics,
}

View File

@@ -1,9 +1,9 @@
const {
import {
hasAdminCapability,
hasAdminAccess,
} = require('../Helpers/AdminAuthorizationHelper')
const SessionManager = require('../Authentication/SessionManager')
const Settings = require('@overleaf/settings')
} from '../Helpers/AdminAuthorizationHelper.js'
import SessionManager from '../Authentication/SessionManager.js'
import Settings from '@overleaf/settings'
const UserMembershipAuthorization = {
hasStaffAccess(requiredStaffAccess) {
@@ -52,4 +52,4 @@ const UserMembershipAuthorization = {
}
},
}
module.exports = UserMembershipAuthorization
export default UserMembershipAuthorization

View File

@@ -1,20 +1,16 @@
import SessionManager from '../Authentication/SessionManager.js'
import UserMembershipHandler from './UserMembershipHandler.js'
import UserMembershipHandler from './UserMembershipHandler.mjs'
import Errors from '../Errors/Errors.js'
import EmailHelper from '../Helpers/EmailHelper.js'
import { csvAttachment } from '../../infrastructure/Response.js'
import {
UserIsManagerError,
UserAlreadyAddedError,
UserNotFoundError,
} from './UserMembershipErrors.js'
import UserMembershipErrors from './UserMembershipErrors.mjs'
import { SSOConfig } from '../../models/SSOConfig.js'
import { Parser as CSVParser } from 'json2csv'
import { expressify } from '@overleaf/promise-utils'
import PlansLocator from '../Subscription/PlansLocator.js'
import RecurlyClient from '../Subscription/RecurlyClient.js'
import Modules from '../../infrastructure/Modules.js'
import UserMembershipAuthorization from './UserMembershipAuthorization.js'
import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
async function manageGroupMembers(req, res, next) {
const { entity: subscription, entityConfig } = req
@@ -175,93 +171,98 @@ async function exportCsv(req, res) {
csvAttachment(res, csvParser.parse(users), 'Group.csv')
}
async function add(req, res) {
const { entity, entityConfig } = req
const email = EmailHelper.parseEmail(req.body.email)
if (email == null) {
return res.status(400).json({
error: {
code: 'invalid_email',
message: req.i18n.translate('invalid_email'),
},
})
}
if (entityConfig.readOnly) {
throw new Errors.NotFoundError('Cannot add users to entity')
}
let user
try {
user = await UserMembershipHandler.promises.addUser(
entity,
entityConfig,
email
)
} catch (err) {
if (err instanceof UserMembershipErrors.UserAlreadyAddedError) {
return res.status(400).json({
error: {
code: 'user_already_added',
message: req.i18n.translate('user_already_added'),
},
})
}
if (err instanceof UserMembershipErrors.UserNotFoundError) {
return res.status(404).json({
error: {
code: 'user_not_found',
message: req.i18n.translate('add_manager_user_not_found'),
},
})
}
throw err
}
res.json({ user })
}
async function remove(req, res) {
const { entity, entityConfig } = req
const { userId } = req.params
if (entityConfig.readOnly) {
throw new Errors.NotFoundError('Cannot remove users from entity')
}
const loggedInUserId = SessionManager.getLoggedInUserId(req.session)
if (loggedInUserId === userId) {
return res.status(400).json({
error: {
code: 'managers_cannot_remove_self',
message: req.i18n.translate('managers_cannot_remove_self'),
},
})
}
try {
await UserMembershipHandler.promises.removeUser(
entity,
entityConfig,
userId
)
} catch (err) {
if (err instanceof UserMembershipErrors.UserIsManagerError) {
return res.status(400).json({
error: {
code: 'managers_cannot_remove_admin',
message: req.i18n.translate('managers_cannot_remove_admin'),
},
})
}
throw err
}
res.sendStatus(200)
}
async function create(req, res) {
const entityId = req.params.id
const entityConfig = req.entityConfig
await UserMembershipHandler.promises.createEntity(entityId, entityConfig)
res.redirect(entityConfig.pathsFor(entityId).index)
}
export default {
manageGroupMembers: expressify(manageGroupMembers),
manageGroupManagers: expressify(manageGroupManagers),
manageInstitutionManagers: expressify(manageInstitutionManagers),
managePublisherManagers: expressify(managePublisherManagers),
add(req, res, next) {
const { entity, entityConfig } = req
const email = EmailHelper.parseEmail(req.body.email)
if (email == null) {
return res.status(400).json({
error: {
code: 'invalid_email',
message: req.i18n.translate('invalid_email'),
},
})
}
if (entityConfig.readOnly) {
return next(new Errors.NotFoundError('Cannot add users to entity'))
}
UserMembershipHandler.addUser(
entity,
entityConfig,
email,
function (error, user) {
if (error && error instanceof UserAlreadyAddedError) {
return res.status(400).json({
error: {
code: 'user_already_added',
message: req.i18n.translate('user_already_added'),
},
})
}
if (error && error instanceof UserNotFoundError) {
return res.status(404).json({
error: {
code: 'user_not_found',
message: req.i18n.translate('add_manager_user_not_found'),
},
})
}
if (error != null) {
return next(error)
}
res.json({ user })
}
)
},
remove(req, res, next) {
const { entity, entityConfig } = req
const { userId } = req.params
if (entityConfig.readOnly) {
return next(new Errors.NotFoundError('Cannot remove users from entity'))
}
const loggedInUserId = SessionManager.getLoggedInUserId(req.session)
if (loggedInUserId === userId) {
return res.status(400).json({
error: {
code: 'managers_cannot_remove_self',
message: req.i18n.translate('managers_cannot_remove_self'),
},
})
}
UserMembershipHandler.removeUser(
entity,
entityConfig,
userId,
function (error, user) {
if (error && error instanceof UserIsManagerError) {
return res.status(400).json({
error: {
code: 'managers_cannot_remove_admin',
message: req.i18n.translate('managers_cannot_remove_admin'),
},
})
}
if (error != null) {
return next(error)
}
res.sendStatus(200)
}
)
},
add: expressify(add),
remove: expressify(remove),
exportCsv: expressify(exportCsv),
new(req, res, next) {
res.render('user_membership/new', {
@@ -269,19 +270,5 @@ export default {
entityId: req.params.id,
})
},
create(req, res, next) {
const entityId = req.params.id
const entityConfig = req.entityConfig
UserMembershipHandler.createEntity(
entityId,
entityConfig,
function (error, entity) {
if (error != null) {
return next(error)
}
res.redirect(entityConfig.pathsFor(entityId).index)
}
)
},
create: expressify(create),
}

View File

@@ -1,11 +1,13 @@
const OError = require('@overleaf/o-error')
import OError from '@overleaf/o-error'
class UserIsManagerError extends OError {}
class UserNotFoundError extends OError {}
class UserAlreadyAddedError extends OError {}
module.exports = {
const UserMembershipErrors = {
UserIsManagerError,
UserNotFoundError,
UserAlreadyAddedError,
}
export default UserMembershipErrors

View File

@@ -1,17 +1,15 @@
const { ObjectId } = require('mongodb-legacy')
const { promisifyAll, callbackify } = require('@overleaf/promise-utils')
const EntityModels = {
Institution: require('../../models/Institution').Institution,
Subscription: require('../../models/Subscription').Subscription,
Publisher: require('../../models/Publisher').Publisher,
}
const UserMembershipViewModel = require('./UserMembershipViewModel')
const UserGetter = require('../User/UserGetter')
const {
UserIsManagerError,
UserNotFoundError,
UserAlreadyAddedError,
} = require('./UserMembershipErrors')
import mongodb from 'mongodb-legacy'
import { callbackify } from '@overleaf/promise-utils'
import { Institution } from '../../models/Institution.js'
import { Subscription } from '../../models/Subscription.js'
import { Publisher } from '../../models/Publisher.js'
import UserMembershipViewModel from './UserMembershipViewModel.mjs'
import UserGetter from '../User/UserGetter.js'
import UserMembershipErrors from './UserMembershipErrors.mjs'
const { ObjectId } = mongodb
const EntityModels = { Institution, Subscription, Publisher }
const UserMembershipHandler = {
async getEntityWithoutAuthorizationCheck(entityId, entityConfig) {
@@ -34,11 +32,11 @@ const UserMembershipHandler = {
const user = await UserGetter.promises.getUserByAnyEmail(email)
if (!user) {
throw new UserNotFoundError()
throw new UserMembershipErrors.UserNotFoundError()
}
if (entity[attribute].some(managerId => managerId.equals(user._id))) {
throw new UserAlreadyAddedError()
throw new UserMembershipErrors.UserAlreadyAddedError()
}
await addUserToEntity(entity, attribute, user)
@@ -48,24 +46,12 @@ const UserMembershipHandler = {
async removeUser(entity, entityConfig, userId) {
const attribute = entityConfig.fields.write
if (entity.admin_id ? entity.admin_id.equals(userId) : undefined) {
throw new UserIsManagerError()
throw new UserMembershipErrors.UserIsManagerError()
}
return await removeUserFromEntity(entity, attribute, userId)
},
}
UserMembershipHandler.promises = promisifyAll(UserMembershipHandler)
module.exports = {
getEntityWithoutAuthorizationCheck: callbackify(
UserMembershipHandler.getEntityWithoutAuthorizationCheck
),
createEntity: callbackify(UserMembershipHandler.createEntity),
getUsers: callbackify(UserMembershipHandler.getUsers),
addUser: callbackify(UserMembershipHandler.addUser),
removeUser: callbackify(UserMembershipHandler.removeUser),
promises: UserMembershipHandler,
}
async function getPopulatedListOfMembers(entity, attributes) {
const userObjects = []
@@ -114,3 +100,14 @@ function buildEntityQuery(entityId, entityConfig) {
query[entityConfig.fields.primaryKey] = entityId
return query
}
export default {
getEntityWithoutAuthorizationCheck: callbackify(
UserMembershipHandler.getEntityWithoutAuthorizationCheck
),
createEntity: callbackify(UserMembershipHandler.createEntity),
getUsers: callbackify(UserMembershipHandler.getUsers),
addUser: callbackify(UserMembershipHandler.addUser),
removeUser: callbackify(UserMembershipHandler.removeUser),
promises: UserMembershipHandler,
}

View File

@@ -1,16 +1,17 @@
// @ts-check
const { expressify } = require('@overleaf/promise-utils')
const async = require('async')
const UserMembershipAuthorization = require('./UserMembershipAuthorization')
const AuthenticationController = require('../Authentication/AuthenticationController')
const UserMembershipHandler = require('./UserMembershipHandler')
const EntityConfigs = require('./UserMembershipEntityConfigs')
const Errors = require('../Errors/Errors')
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const TemplatesManager = require('../Templates/TemplatesManager')
const { z, zz, validateReq } = require('../../infrastructure/Validation')
const { useAdminCapabilities } = require('../Helpers/AdminAuthorizationHelper')
import { expressify } from '@overleaf/promise-utils'
import async from 'async'
import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
import AuthenticationController from '../Authentication/AuthenticationController.js'
import UserMembershipHandler from './UserMembershipHandler.mjs'
import EntityConfigs from './UserMembershipEntityConfigs.js'
import Errors from '../Errors/Errors.js'
import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
import TemplatesManager from '../Templates/TemplatesManager.js'
import { z, zz, validateReq } from '../../infrastructure/Validation.js'
import { useAdminCapabilities } from '../Helpers/AdminAuthorizationHelper.js'
// set of middleware arrays or functions that checks user access to an entity
// (publisher, institution, group, template, etc.)
@@ -234,7 +235,7 @@ const UserMembershipMiddleware = {
},
}
module.exports = UserMembershipMiddleware
export default UserMembershipMiddleware
// fetch entity config and set it in the request
function fetchEntityConfig(entityName) {

View File

@@ -1,4 +1,4 @@
import UserMembershipMiddleware from './UserMembershipMiddleware.js'
import UserMembershipMiddleware from './UserMembershipMiddleware.mjs'
import UserMembershipController from './UserMembershipController.mjs'
import SubscriptionGroupController from '../Subscription/SubscriptionGroupController.mjs'
import TeamInvitesController from '../Subscription/TeamInvitesController.mjs'

View File

@@ -1,5 +1,5 @@
const UserGetter = require('../User/UserGetter')
const { isObjectIdInstance } = require('../Helpers/Mongo')
import UserGetter from '../User/UserGetter.js'
import { isObjectIdInstance } from '../Helpers/Mongo.js'
const UserMembershipViewModel = {
build(userOrEmail) {
@@ -81,4 +81,4 @@ UserMembershipViewModel.promises = {
buildAsync: UserMembershipViewModel.buildAsync,
}
module.exports = UserMembershipViewModel
export default UserMembershipViewModel

View File

@@ -1,7 +1,7 @@
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 UserOnboardingEmailManager from '../Features/User/UserOnboardingEmailManager.mjs'
import UserPostRegistrationAnalyticsManager from '../Features/User/UserPostRegistrationAnalyticsManager.mjs'
import FeaturesUpdater from '../Features/Subscription/FeaturesUpdater.js'
import {

View File

@@ -54,6 +54,7 @@ const db = {
messages: internalDb.collection('messages'),
migrations: internalDb.collection('migrations'),
notifications: internalDb.collection('notifications'),
emailNotifications: internalDb.collection('emailNotifications'),
oauthAccessTokens: internalDb.collection('oauthAccessTokens'),
oauthApplications: internalDb.collection('oauthApplications'),
oauthAuthorizationCodes: internalDb.collection('oauthAuthorizationCodes'),

View File

@@ -715,6 +715,10 @@ module.exports = {
parseInt(process.env.OVERLEAF_PROJECT_HARD_DELETION_DELAY, 10) ||
1000 * 60 * 60 * 24 * 90, // 90 days
// Delay before sending comment mention notifications
commentMentionDelay:
parseInt(process.env.COMMENT_MENTION_DELAY_MINUTES) || 30 * 60 * 1000, // 30 minutes
// Maximum JSON size in HTTP requests
// We should be able to process twice the max doc length, to allow for
// - the doc content

View File

@@ -15,6 +15,8 @@ import OLButton from '@/shared/components/ol/ol-button'
import { sendMB } from '@/infrastructure/event-tracking'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import clientId from '@/utils/client-id'
import { useReferencesContext } from '@/features/ide-react/context/references-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type FileViewRefreshButtonProps = {
setRefreshError: Dispatch<SetStateAction<Nullable<string>>>
@@ -35,14 +37,17 @@ export default function FileViewRefreshButton({
const { projectId } = useProjectContext()
const [refreshing, setRefreshing] = useState(false)
const isMountedRef = useIsMounted()
const { indexAllReferences } = useReferencesContext()
const clientSideReferences = useFeatureFlag('client-side-references')
const refreshFile = useCallback(
(isTPR: Nullable<boolean>) => {
setRefreshing(true)
// Replacement of the file handled by the file tree
window.expectingLinkedFileRefreshedSocketFor = file.name
const shouldReindexReferences = isTPR || /\.bib$/.test(file.name)
const body = {
shouldReindexReferences: isTPR || /\.bib$/.test(file.name),
shouldReindexReferences,
clientId: clientId.get(),
}
postJSON(`/project/${projectId}/linked_file/${file.id}/refresh`, {
@@ -52,6 +57,9 @@ export default function FileViewRefreshButton({
if (isMountedRef.current) {
setRefreshing(false)
}
if (clientSideReferences && shouldReindexReferences) {
indexAllReferences(false)
}
sendMB('refresh-linked-file', {
provider: file.linkedFileData?.provider,
})
@@ -63,7 +71,14 @@ export default function FileViewRefreshButton({
}
})
},
[file, projectId, setRefreshError, isMountedRef]
[
file,
projectId,
setRefreshError,
isMountedRef,
indexAllReferences,
clientSideReferences,
]
)
if (tprFileViewRefreshButton.length > 0) {

View File

@@ -188,13 +188,13 @@ export const ReferencesProvider: FC<React.PropsWithChildren> = ({
doneInitialIndex.current = true
indexAllReferences(false)
}
}, [projectJoined, indexAllReferences])
useEffect(() => {
const handleProjectJoined = () => {
// We only need to grab the references when the editor first loads,
// not on every reconnect
socket.on('references:keys:updated', (keys, allDocs, refresherId) => {
if (projectJoined && socket) {
const processUpdatedReferenceKeys = (
keys: string[],
allDocs: boolean,
refresherId: string
) => {
if (clientSideReferences) {
if (refresherId === clientId.get()) {
// We asked for this broadcast, so we must have already done the indexing
@@ -206,15 +206,17 @@ export const ReferencesProvider: FC<React.PropsWithChildren> = ({
allDocs ? new Set(keys) : new Set([...oldDocs, ...keys])
)
}
})
}
}
eventEmitter.once('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
socket.on('references:keys:updated', processUpdatedReferenceKeys)
return () => {
socket.removeListener(
'references:keys:updated',
processUpdatedReferenceKeys
)
}
}
}, [eventEmitter, indexAllReferences, socket, clientSideReferences])
}, [projectJoined, indexAllReferences, socket, clientSideReferences])
const searchLocalReferences = useCallback(
async (query: string): Promise<AdvancedReferenceSearchResult> => {

View File

@@ -33,7 +33,12 @@ const en = {
'translate.fr': 'French',
'translate.hi': 'Hindi',
'translate.es': 'Spanish',
'translate.de': 'German',
'translate.ja': 'Japanese',
'translate.other': 'Other',
'translate.more-languages-coming-soon.title': 'More languages coming soon',
'translate.more-languages-coming-soon.body':
'Sorry, we dont currently offer any other languages for Translate. We will be adding more throughout October and November so watch this space!',
'blocked-suggestion-signpost.question':
'Do you want to permanently stop this suggestion from appearing again?',
'blocked-suggestion-signpost.tooltip': 'You can block a suggestion here.',
@@ -214,7 +219,7 @@ const en = {
'Well use this to assess whether we add text to speech functionality to language suggestions in Overleaf.',
'language-model.using-this-feature':
'Using this feature means your text is sent to OpenAIs servers and may be kept there for up to 30 days. It is not used to train OpenAIs models. Writefull does not store or train on your texts.',
'language-model.learn-more': 'Learn more',
'language-model.learn-more': 'Learn more about how your data is used',
'language-model.writefull-automatically-revises':
'Writefull automatically revises your text. You can choose what language model is used for this: Writefulls own model, designed specifically for English research writing, or a GPT-based option, suitable for non-academic texts in any language.',
'language-model.which-language-model':
@@ -247,7 +252,7 @@ const en = {
'settings.manage-plan.freemium': 'Get AI Assist',
'settings.manage-plan.wf-premium': 'Manage Writefull plan',
'settings.manage-plan': 'Manage plan',
'settings.open': 'Open',
'settings.open': 'Open settings dialog',
'settings.writefulls-model': 'Model: Writefull',
'settings.gpt-model': 'Model: GPT',
'settings.with-custom-prompt': ' with custom prompt',
@@ -369,7 +374,12 @@ const es = {
'translate.fr': 'Francés',
'translate.hi': 'Hindi',
'translate.es': 'Español',
'translate.de': 'Alemán',
'translate.ja': 'Japonés',
'translate.other': 'Otro',
'translate.more-languages-coming-soon.title': 'Más idiomas próximamente',
'translate.more-languages-coming-soon.body':
'Lo sentimos, actualmente no ofrecemos otros idiomas para la traducción. Agregaremos más a lo largo de octubre y noviembre, ¡así que mantente atento!',
'blocked-suggestion-signpost.question':
'¿Quieres dejar de ver esta sugerencia permanentemente? Puedes bloquear esta sugerencia aquí.',
'blocked-suggestion-signpost.tooltip':
@@ -598,7 +608,7 @@ const es = {
'settings.manage-plan.freemium': 'Obtener AI Assist',
'settings.manage-plan.wf-premium': 'Gestionar Plan Writefull',
'settings.manage-plan': 'Gestionar plan',
'settings.open': 'Abrir',
'settings.open': 'Abrir diálogo de configuración',
'settings.writefulls-model': 'Modelo de Writefull',
'settings.gpt-model': 'Modelo GPT',
'settings.with-custom-prompt': ' con prompt personalizado',

View File

@@ -3,8 +3,6 @@ export interface WritefullEvents {
method: 'email-password' | 'login-with-overleaf'
isPremium: boolean
}
'writefull-received-suggestions': { numberOfSuggestions: number }
'writefull-register-as-auto-account': { email: string }
'writefull-ai-assist-show-paywall': { origin?: string }
}

View File

@@ -1,18 +1,19 @@
const minimist = require('minimist')
const {
/* eslint-disable @overleaf/require-script-runner */
import minimist from 'minimist'
import {
mkdirSync,
createWriteStream,
existsSync,
unlinkSync,
renameSync,
} = require('fs')
const mongodb = require('../app/src/infrastructure/mongodb')
const DocumentUpdaterHandler = require('../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js')
const ProjectZipStreamManager = require('../app/src/Features/Downloads/ProjectZipStreamManager.js')
const logger = require('logger-sharelatex')
const { Project } = require('../app/src/models/Project.js')
const { User } = require('../app/src/models/User.js')
const readline = require('readline')
} from 'fs'
import mongodb from '../../../app/src/infrastructure/mongodb.js'
import DocumentUpdaterHandler from '../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js'
import ProjectZipStreamManager from '../../../app/src/Features/Downloads/ProjectZipStreamManager.js'
import logger from 'logger-sharelatex'
import { Project } from '../../../app/src/models/Project.js'
import { User } from '../../../app/src/models/User.js'
import readline from 'readline'
function parseArgs() {
return minimist(process.argv.slice(2), {

View File

@@ -45,7 +45,7 @@
"cypress:run-ct": "OVERLEAF_CONFIG=$PWD/config/settings.webpack.js cypress run --component --browser chrome",
"cypress:docker:open-ct": "DOCKER_USER=\"$(id -u):$(id -g)\" docker compose -f docker-compose.cypress.yml run --rm cypress run cypress:open-ct",
"cypress:docker:run-ct": "DOCKER_USER=\"$(id -u):$(id -g)\" docker compose -f docker-compose.cypress.yml run --rm cypress run cypress:run-ct --browser chrome",
"lezer-latex:generate": "node scripts/lezer-latex/generate.js",
"lezer-latex:generate": "node scripts/lezer-latex/generate.mjs",
"lezer-latex:run": "node scripts/lezer-latex/run.mjs",
"lezer-latex:benchmark": "node scripts/lezer-latex/benchmark.mjs",
"lezer-latex:benchmark-incremental": "node scripts/lezer-latex/test-incremental-parser.mjs",

View File

@@ -1,4 +1,4 @@
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js'
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.mjs'
import { ensureRunningOnMongoSecondaryWithTimeout } from './helpers/env_variable_helper.mjs'
// ScriptRunner can not be used when using this assertion

View File

@@ -1,19 +1,22 @@
const Path = require('path')
const DocstoreManager = require('../app/src/Features/Docstore/DocstoreManager')
const DocumentUpdaterHandler = require('../app/src/Features/DocumentUpdater/DocumentUpdaterHandler')
const ProjectGetter = require('../app/src/Features/Project/ProjectGetter')
const ProjectEntityMongoUpdateHandler = require('../app/src/Features/Project/ProjectEntityMongoUpdateHandler')
const { waitForDb, db, ObjectId } = require('../app/src/infrastructure/mongodb')
const HistoryManager = require('../app/src/Features/History/HistoryManager')
const logger = require('@overleaf/logger').logger
import { scriptRunner } from './lib/ScriptRunner.mjs'
import Path from 'node:path'
import DocstoreManager from '../app/src/Features/Docstore/DocstoreManager.js'
import DocumentUpdaterHandler from '../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js'
import ProjectGetter from '../app/src/Features/Project/ProjectGetter.js'
import ProjectEntityMongoUpdateHandler from '../app/src/Features/Project/ProjectEntityMongoUpdateHandler.js'
import { waitForDb, db, ObjectId } from '../app/src/infrastructure/mongodb.js'
import HistoryManager from '../app/src/Features/History/HistoryManager.js'
import logger from '@overleaf/logger'
import minimist from 'minimist'
const args = require('minimist')(process.argv.slice(2), {
const args = minimist(process.argv.slice(2), {
boolean: ['verbose', 'fix'],
})
const verbose = args.verbose
if (!verbose) {
logger.level('error')
logger.logger.level('error')
}
// no remaining arguments, print usage
@@ -254,7 +257,7 @@ async function main() {
}
}
main()
scriptRunner(main, args)
.then(() => {
console.log('DONE')
process.exit(0)

View File

@@ -1,5 +1,5 @@
import { promisify } from 'node:util'
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js'
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.mjs'
import { fileURLToPath } from 'node:url'
import { scriptRunner } from './lib/ScriptRunner.mjs'
const sleep = promisify(setTimeout)

View File

@@ -2,7 +2,51 @@ import fs from 'node:fs'
import path from 'node:path'
import minimist from 'minimist'
const APP_CODE_PATH = ['app', 'modules', 'migrations', 'scripts', 'test']
const APP_CODE_PATH = ['app', 'modules', 'scripts', 'test']
// These have already been converted but don't have a `.mjs` extension
const converted = new Set([
'scripts/ukamf/check-certs.js',
'scripts/ukamf/check-idp-metadata.js',
'scripts/ukamf/metadata-processor.js',
'scripts/ukamf/ukamf-db.js',
'scripts/ukamf/ukamf-entity.js',
'scripts/translations/checkCoverage.js',
'scripts/translations/checkSanitizeOptions.js',
'scripts/translations/checkVariables.js',
'scripts/translations/cleanupUnusedLocales.js',
'scripts/translations/config.js',
'scripts/translations/download.js',
'scripts/translations/insertHTMLFragments.js',
'scripts/translations/replaceLinkFragments.js',
'scripts/translations/sanitize.js',
'scripts/translations/sort.js',
'scripts/translations/transformLocales.js',
'scripts/translations/translateLocales.js',
'scripts/translations/upload.js',
'scripts/translations/uploadNonEnglish.js',
'scripts/translations/utils.js',
])
// These files are not to be converted (e.g. they use CommonJS features that are not available in ES Modules)
const excluded = new Set([
'modules/server-ce-scripts/scripts/create-user.js', // must be CJS for backwards compatibility
'test/acceptance/config/settings.test.saas.js', // must be CJS for @overleaf/settings module
'test/acceptance/config/settings.test.server-pro.js', // must be CJS for @overleaf/settings module
'app/src/infrastructure/PackageVersions.js', // required by webpack
])
function fileIsESM(file) {
const relativePath = file.replace(process.cwd() + '/', '')
return file.endsWith('.mjs') || converted.has(relativePath)
}
function fileCanBeConvertedToESM(file) {
const relativePath = file.replace(process.cwd() + '/', '')
if (fileIsESM(relativePath)) {
return false
}
return !excluded.has(relativePath)
}
const {
_: args,
@@ -149,7 +193,7 @@ function printDirectoriesReport(allFilesAndImports) {
// collect all files that are imported via CommonJS in the entire backend codebase
const filesImportedViaCjs = new Set()
allFilesAndImports.forEach((imports, file) => {
if (!file.endsWith('.mjs')) {
if (!fileIsESM(file)) {
imports.forEach(imprt => filesImportedViaCjs.add(imprt))
}
})
@@ -158,10 +202,8 @@ function printDirectoriesReport(allFilesAndImports) {
const selectedFiles = Array.from(
findJSAndImports(paths.map(dir => path.resolve(dir))).keys()
).filter(file => !file.endsWith('settings.test.js'))
const nonMigratedFiles = selectedFiles.filter(file => !file.endsWith('.mjs'))
const migratedFileCount = selectedFiles.filter(file =>
file.endsWith('.mjs')
).length
const nonMigratedFiles = selectedFiles.filter(fileCanBeConvertedToESM)
const migratedFileCount = selectedFiles.filter(fileIsESM).length
// collect files in the selected paths that are not imported via CommonJs in the entire backend codebase
const filesNotImportedViaCjs = nonMigratedFiles.filter(
@@ -196,7 +238,7 @@ function printDirectoriesReport(allFilesAndImports) {
function printFileReport(allFilesAndImports) {
const filePath = path.resolve(paths[0])
if (filePath.endsWith('.mjs')) {
if (fileIsESM(filePath)) {
console.log(`${filePath} is already migrated to ESM`)
return
}
@@ -204,7 +246,7 @@ function printFileReport(allFilesAndImports) {
const importingFiles = []
allFilesAndImports.forEach((imports, file) => {
if (file.endsWith('.mjs')) {
if (fileIsESM(file)) {
return
}
if (

View File

@@ -1,7 +1,8 @@
const csv = require('csv')
const fs = require('fs')
const minimist = require('minimist')
const { User } = require('../app/src/models/User')
import { scriptRunner } from './lib/ScriptRunner.mjs'
import * as csv from 'csv'
import fs from 'node:fs'
import minimist from 'minimist'
import { User } from '../app/src/models/User.js'
/**
* This script extracts users who churned after day 1 - ie. their last session was within 24 hours of registering
@@ -205,9 +206,9 @@ async function getDay1ChurnUsers({
return allChurnUsers
}
async function runScript() {
const args = parseArgs()
const args = parseArgs()
async function runScript() {
console.log(
`Starting Day 1 churn extraction with lookback period: ${args.lookbackMonths} months`
)
@@ -268,7 +269,7 @@ async function runScript() {
)
}
runScript().catch(err => {
scriptRunner(runScript, args).catch(err => {
console.error('Script failed:', err)
process.exit(1)
})

View File

@@ -1,8 +1,7 @@
const csv = require('csv')
const fs = require('fs')
const {
OnboardingDataCollection,
} = require('../app/src/models/OnboardingDataCollection')
import { scriptRunner } from './lib/ScriptRunner.mjs'
import * as csv from 'csv'
import fs from 'node:fs'
import { OnboardingDataCollection } from '../app/src/models/OnboardingDataCollection.js'
/**
* This script extracts the OnboardingDataCollection collection from the database
@@ -90,7 +89,7 @@ const runScript = async () => {
csvWriter.on('error', err => console.error('CSV Writer Error:', err))
}
runScript().catch(err => {
scriptRunner(runScript).catch(err => {
console.error(err)
process.exit(1)
})

View File

@@ -1,13 +1,12 @@
const csv = require('csv')
const fs = require('fs')
const minimist = require('minimist')
const {
OnboardingDataCollection,
} = require('../app/src/models/OnboardingDataCollection')
const { User } = require('../app/src/models/User')
const SubscriptionLocator = require('../app/src/Features/Subscription/SubscriptionLocator')
const Settings = require('@overleaf/settings')
const { fetchJson } = require('@overleaf/fetch-utils')
import { scriptRunner } from './lib/ScriptRunner.mjs'
import * as csv from 'csv'
import fs from 'node:fs'
import minimist from 'minimist'
import { OnboardingDataCollection } from '../app/src/models/OnboardingDataCollection.js'
import { User } from '../app/src/models/User.js'
import SubscriptionLocator from '../app/src/Features/Subscription/SubscriptionLocator.js'
import Settings from '@overleaf/settings'
import { fetchJson } from '@overleaf/fetch-utils'
/**
* This script extracts ODC data with some extra fields, and filters on registration date and LaTeX experience
@@ -146,12 +145,11 @@ async function getUserCountries(institutions) {
}
return countryCodes
}
const args = parseArgs()
async function runScript() {
const columns = ['email']
const args = parseArgs()
if (args.includeSignUpDate) {
columns.push('signUpDate')
}
@@ -196,7 +194,7 @@ async function runScript() {
)
}
runScript().catch(err => {
scriptRunner(runScript, args).catch(err => {
console.error(err)
process.exit(1)
})

View File

@@ -6,7 +6,7 @@ import cheerio from 'cheerio'
// eslint-disable-next-line import/no-extraneous-dependencies
import prettier from 'prettier'
import sanitizeHtml from 'sanitize-html'
import { sanitizeOptions } from '../../../modules/learn/app/src/sanitizeOptions.js'
import { sanitizeOptions } from '../../../modules/learn/app/src/sanitizeOptions.mjs'
import { fileURLToPath } from 'node:url'
const __dirname = Path.dirname(fileURLToPath(import.meta.url))

View File

@@ -1,33 +1,35 @@
const { buildParserFile } = require('@lezer/generator')
const { writeFileSync, readFileSync } = require('fs')
const path = require('path')
/* eslint-disable @overleaf/require-script-runner */
// This script doesn't work with ScriptRunner because it is run during the build process.
import { buildParserFile } from '@lezer/generator'
import { writeFileSync, readFileSync } from 'node:fs'
import path from 'node:path'
const grammars = [
{
grammarPath: path.resolve(
__dirname,
import.meta.dirname,
'../../frontend/js/features/source-editor/lezer-latex/latex.grammar'
),
parserOutputPath: path.resolve(
__dirname,
import.meta.dirname,
'../../frontend/js/features/source-editor/lezer-latex/latex.mjs'
),
termsOutputPath: path.resolve(
__dirname,
import.meta.dirname,
'../../frontend/js/features/source-editor/lezer-latex/latex.terms.mjs'
),
},
{
grammarPath: path.resolve(
__dirname,
import.meta.dirname,
'../../frontend/js/features/source-editor/lezer-bibtex/bibtex.grammar'
),
parserOutputPath: path.resolve(
__dirname,
import.meta.dirname,
'../../frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs'
),
termsOutputPath: path.resolve(
__dirname,
import.meta.dirname,
'../../frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs'
),
},
@@ -56,9 +58,12 @@ function compile(grammar) {
console.info('Done!')
}
module.exports = { compile, grammars }
export default { compile, grammars }
if (require.main === module) {
if (
import.meta.url === process.argv[1] ||
import.meta.url === `file://${process.argv[1]}`
) {
try {
grammars.forEach(compile)
process.exit(0)

View File

@@ -0,0 +1,17 @@
import { processNotifications } from '../modules/notifications/app/src/ProcessNotifications.mjs'
import { scriptRunner } from './lib/ScriptRunner.mjs'
async function main() {
console.log('Processing notifications...')
await processNotifications()
console.log('Notifications processed successfully.')
}
try {
await scriptRunner(main)
console.log('Done.')
process.exit(0)
} catch (error) {
console.error({ error })
process.exit(1)
}

View File

@@ -1,9 +1,9 @@
// @ts-check
const _ = require('lodash')
const recurly = require('recurly')
const minimist = require('minimist')
const Settings = require('@overleaf/settings')
import { scriptRunner } from '../lib/ScriptRunner.mjs'
import _ from 'lodash'
import recurly from 'recurly'
import minimist from 'minimist'
import Settings from '@overleaf/settings'
const ADD_ON_CODE = 'assistant'
const ADD_ON_NAME = 'AI Assist'
@@ -209,7 +209,7 @@ function getAddOnConfig(prices) {
}
}
main()
scriptRunner(main)
.then(() => {
process.exit(0)
})

View File

@@ -1,5 +1,5 @@
import minimist from 'minimist'
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js'
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.mjs'
import { scriptRunner } from './lib/ScriptRunner.mjs'
const institutionId = parseInt(process.argv[2])

View File

@@ -58,6 +58,20 @@ class MockChatApi extends AbstractMockApi {
res.json(this.getThread(req.params.project_id, req.params.thread_id))
}
)
this.app.get(
'/project/:project_id/thread/:thread_id/messages/:message_id',
(req, res) => {
const projectId = req.params.project_id
const threadId = req.params.thread_id
const messageId = req.params.message_id
const thread = this.getThread(projectId, threadId)
const message = thread.find(msg => msg.id === messageId)
if (!message) {
return res.status(404).json({ error: 'Message not found' })
}
res.json(message)
}
)
this.app.post(
'/project/:project_id/thread/:thread_id/messages',
(req, res) => {

View File

@@ -0,0 +1,355 @@
import sinon from 'sinon'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const modulePath = '../../../../app/src/Features/Compile/ClsiCookieManager.mjs'
describe('ClsiCookieManager', function () {
beforeEach(async function (ctx) {
ctx.redis = {
auth() {},
del: sinon.stub(),
get: sinon.stub(),
setex: sinon.stub().resolves(),
}
ctx.project_id = '123423431321-proj-id'
ctx.user_id = 'abc-user-id'
ctx.fetchUtils = {
fetchNothing: sinon.stub().returns(Promise.resolve()),
fetchStringWithResponse: sinon.stub().returns(Promise.resolve()),
}
ctx.metrics = { inc: sinon.stub() }
ctx.settings = {
redis: {
web: 'redis.something',
},
apis: {
clsi: {
url: 'http://clsi.example.com',
},
},
clsiCookie: {
ttlInSeconds: Math.random().toString(),
ttlInSecondsRegular: Math.random().toString(),
key: 'coooookie',
},
}
vi.doMock('../../../../app/src/infrastructure/RedisWrapper', () => ({
default: (ctx.RedisWrapper = {
client: () => ctx.redis,
}),
}))
vi.doMock('@overleaf/settings', () => ({
default: ctx.settings,
}))
vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
vi.doMock('@overleaf/metrics', () => ({
default: ctx.metrics,
}))
ctx.ClsiCookieManager = (await import(modulePath)).default()
})
describe('getServerId', function () {
it('should call get for the key', async function (ctx) {
ctx.redis.get.resolves('clsi-7')
const serverId = await ctx.ClsiCookieManager.promises.getServerId(
ctx.project_id,
ctx.user_id,
'',
'n2d'
)
ctx.redis.get
.calledWith(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
.should.equal(true)
serverId.should.equal('clsi-7')
})
it('should fallback to old key', async function (ctx) {
ctx.redis.get
.withArgs(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
.resolves(null)
ctx.redis.get
.withArgs(`clsiserver:${ctx.project_id}:${ctx.user_id}`)
.resolves('clsi-7')
const serverId = await ctx.ClsiCookieManager.promises.getServerId(
ctx.project_id,
ctx.user_id,
'',
'n2d'
)
ctx.redis.get
.calledWith(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
.should.equal(true)
ctx.redis.get
.calledWith(`clsiserver:${ctx.project_id}:${ctx.user_id}`)
.should.equal(true)
serverId.should.equal('clsi-7')
})
it('should _populateServerIdViaRequest if no key is found', async function (ctx) {
ctx.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
.stub()
.resolves()
ctx.redis.get.resolves(null)
await ctx.ClsiCookieManager.promises.getServerId(
ctx.project_id,
ctx.user_id,
''
)
ctx.ClsiCookieManager.promises._populateServerIdViaRequest
.calledWith(ctx.project_id, ctx.user_id)
.should.equal(true)
})
it('should _populateServerIdViaRequest if no key is blank', async function (ctx) {
ctx.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
.stub()
.resolves(null)
ctx.redis.get.resolves('')
await ctx.ClsiCookieManager.promises.getServerId(
ctx.project_id,
ctx.user_id,
'',
'n2d'
)
ctx.ClsiCookieManager.promises._populateServerIdViaRequest
.calledWith(ctx.project_id, ctx.user_id)
.should.equal(true)
})
})
describe('_populateServerIdViaRequest', function () {
beforeEach(function (ctx) {
ctx.clsiServerId = 'server-id'
ctx.ClsiCookieManager.promises.setServerId = sinon.stub().resolves()
})
describe('with a server id in the response', function () {
beforeEach(function (ctx) {
ctx.response = {
headers: {
'set-cookie': [
`${ctx.settings.clsiCookie.key}=${ctx.clsiServerId}`,
],
},
}
ctx.fetchUtils.fetchNothing.returns(ctx.response)
})
it('should make a request to the clsi', async function (ctx) {
await ctx.ClsiCookieManager.promises._populateServerIdViaRequest(
ctx.project_id,
ctx.user_id,
'standard',
'n2d'
)
const args = ctx.ClsiCookieManager.promises.setServerId.args[0]
args[0].should.equal(ctx.project_id)
args[1].should.equal(ctx.user_id)
args[2].should.equal('standard')
args[3].should.equal('n2d')
args[4].should.deep.equal(ctx.clsiServerId)
})
it('should return the server id', async function (ctx) {
const serverId =
await ctx.ClsiCookieManager.promises._populateServerIdViaRequest(
ctx.project_id,
ctx.user_id,
'',
'n2d'
)
serverId.should.equal(ctx.clsiServerId)
})
})
describe('without a server id in the response', function () {
beforeEach(function (ctx) {
ctx.response = { headers: {} }
ctx.fetchUtils.fetchNothing.returns(ctx.response)
})
it('should not set the server id there is no server id in the response', async function (ctx) {
ctx.ClsiCookieManager._parseServerIdFromResponse = sinon
.stub()
.returns(null)
await ctx.ClsiCookieManager.promises.setServerId(
ctx.project_id,
ctx.user_id,
'standard',
'n2d',
ctx.clsiServerId,
null
)
ctx.redis.setex.called.should.equal(false)
})
})
})
describe('clearServerId', function () {
it('should clear both keys', async function (ctx) {
await ctx.ClsiCookieManager.promises.clearServerId(
ctx.project_id,
ctx.user_id,
'n2d'
)
ctx.redis.del.should.have.been.calledWith(
`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
`clsiserver:${ctx.project_id}:${ctx.user_id}`
)
})
})
describe('setServerId', function () {
beforeEach(function (ctx) {
ctx.clsiServerId = 'server-id'
ctx.ClsiCookieManager._parseServerIdFromResponse = sinon
.stub()
.returns('clsi-8')
})
it('should set the server id with a ttl', async function (ctx) {
await ctx.ClsiCookieManager.promises.setServerId(
ctx.project_id,
ctx.user_id,
'standard',
'n2d',
ctx.clsiServerId,
null
)
ctx.redis.setex.should.have.been.calledWith(
`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
ctx.settings.clsiCookie.ttlInSeconds,
ctx.clsiServerId
)
})
it('should set the server id with the regular ttl for reg instance', async function (ctx) {
ctx.clsiServerId = 'clsi-reg-8'
await ctx.ClsiCookieManager.promises.setServerId(
ctx.project_id,
ctx.user_id,
'standard',
'n2d',
ctx.clsiServerId,
null
)
expect(ctx.redis.setex).to.have.been.calledWith(
`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
ctx.settings.clsiCookie.ttlInSecondsRegular,
ctx.clsiServerId
)
})
describe('when clsiCookies are not enabled', function (ctx) {
let oldKey
beforeEach(async function (ctx) {
oldKey = ctx.settings.clsiCookie.key
delete ctx.settings.clsiCookie.key
vi.resetModules()
ctx.ClsiCookieManager2 = (await import(modulePath)).default()
})
afterEach(function (ctx) {
ctx.settings.clsiCookie.key = oldKey
})
it('should not set the server id if clsiCookies are not enabled', async function (ctx) {
await ctx.ClsiCookieManager2.promises.setServerId(
ctx.project_id,
ctx.user_id,
'standard',
'n2d',
ctx.clsiServerId,
null
)
ctx.redis.setex.called.should.equal(false)
})
})
it('should also set in the secondary if secondary redis is enabled', async function (ctx) {
ctx.redis_secondary = { setex: sinon.stub().resolves() }
ctx.settings.redis.clsi_cookie_secondary = {}
ctx.RedisWrapper.client = sinon.stub()
ctx.RedisWrapper.client.withArgs('clsi_cookie').returns(ctx.redis)
ctx.RedisWrapper.client
.withArgs('clsi_cookie_secondary')
.returns(ctx.redis_secondary)
vi.resetModules()
ctx.ClsiCookieManager2 = (await import(modulePath)).default()
ctx.ClsiCookieManager2._parseServerIdFromResponse = sinon
.stub()
.returns('clsi-8')
await ctx.ClsiCookieManager2.promises.setServerId(
ctx.project_id,
ctx.user_id,
'standard',
'n2d',
ctx.clsiServerId,
null
)
ctx.redis_secondary.setex.should.have.been.calledWith(
`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
ctx.settings.clsiCookie.ttlInSeconds,
ctx.clsiServerId
)
})
describe('checkIsLoadSheddingEvent', function () {
beforeEach(function (ctx) {
ctx.fetchUtils.fetchStringWithResponse.reset()
ctx.call = async () => {
await ctx.ClsiCookieManager.promises.setServerId(
ctx.project_id,
ctx.user_id,
'standard',
'n2d',
ctx.clsiServerId,
'previous-clsi-server-id'
)
expect(
ctx.fetchUtils.fetchStringWithResponse
).to.have.been.calledWith(
`${ctx.settings.apis.clsi.url}/instance-state?clsiserverid=previous-clsi-server-id&compileGroup=standard&compileBackendClass=n2d`,
{ method: 'GET', signal: sinon.match.instanceOf(AbortSignal) }
)
}
})
it('should report "load-shedding" when previous is UP', async function (ctx) {
ctx.fetchUtils.fetchStringWithResponse.resolves({
response: { status: 200 },
body: 'previous-clsi-server-id,UP\n',
})
await ctx.call()
expect(ctx.metrics.inc).to.have.been.calledWith(
'clsi-lb-switch-backend',
1,
{ status: 'load-shedding' }
)
})
it('should report "cycle" when other is UP', async function (ctx) {
ctx.fetchUtils.fetchStringWithResponse.resolves({
response: { status: 200 },
body: 'other-clsi-server-id,UP\n',
})
await ctx.call()
expect(ctx.metrics.inc).to.have.been.calledWith(
'clsi-lb-switch-backend',
1,
{ status: 'cycle' }
)
})
it('should report "cycle" when previous is 404', async function (ctx) {
ctx.fetchUtils.fetchStringWithResponse.resolves({
response: { status: 404 },
})
await ctx.call()
expect(ctx.metrics.inc).to.have.been.calledWith(
'clsi-lb-switch-backend',
1,
{ status: 'cycle' }
)
})
})
})
})

View File

@@ -1,351 +0,0 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Compile/ClsiCookieManager.js'
const SandboxedModule = require('sandboxed-module')
describe('ClsiCookieManager', function () {
beforeEach(function () {
this.redis = {
auth() {},
del: sinon.stub(),
get: sinon.stub(),
setex: sinon.stub().resolves(),
}
this.project_id = '123423431321-proj-id'
this.user_id = 'abc-user-id'
this.fetchUtils = {
fetchNothing: sinon.stub().returns(Promise.resolve()),
fetchStringWithResponse: sinon.stub().returns(Promise.resolve()),
}
this.metrics = { inc: sinon.stub() }
this.settings = {
redis: {
web: 'redis.something',
},
apis: {
clsi: {
url: 'http://clsi.example.com',
},
},
clsiCookie: {
ttlInSeconds: Math.random().toString(),
ttlInSecondsRegular: Math.random().toString(),
key: 'coooookie',
},
}
this.requires = {
'../../infrastructure/RedisWrapper': (this.RedisWrapper = {
client: () => this.redis,
}),
'@overleaf/settings': this.settings,
'@overleaf/fetch-utils': this.fetchUtils,
'@overleaf/metrics': this.metrics,
}
this.ClsiCookieManager = SandboxedModule.require(modulePath, {
requires: this.requires,
})()
})
describe('getServerId', function () {
it('should call get for the key', async function () {
this.redis.get.resolves('clsi-7')
const serverId = await this.ClsiCookieManager.promises.getServerId(
this.project_id,
this.user_id,
'',
'n2d'
)
this.redis.get
.calledWith(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
.should.equal(true)
serverId.should.equal('clsi-7')
})
it('should fallback to old key', async function () {
this.redis.get
.withArgs(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
.resolves(null)
this.redis.get
.withArgs(`clsiserver:${this.project_id}:${this.user_id}`)
.resolves('clsi-7')
const serverId = await this.ClsiCookieManager.promises.getServerId(
this.project_id,
this.user_id,
'',
'n2d'
)
this.redis.get
.calledWith(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
.should.equal(true)
this.redis.get
.calledWith(`clsiserver:${this.project_id}:${this.user_id}`)
.should.equal(true)
serverId.should.equal('clsi-7')
})
it('should _populateServerIdViaRequest if no key is found', async function () {
this.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
.stub()
.resolves()
this.redis.get.resolves(null)
await this.ClsiCookieManager.promises.getServerId(
this.project_id,
this.user_id,
''
)
this.ClsiCookieManager.promises._populateServerIdViaRequest
.calledWith(this.project_id, this.user_id)
.should.equal(true)
})
it('should _populateServerIdViaRequest if no key is blank', async function () {
this.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
.stub()
.resolves(null)
this.redis.get.resolves('')
await this.ClsiCookieManager.promises.getServerId(
this.project_id,
this.user_id,
'',
'n2d'
)
this.ClsiCookieManager.promises._populateServerIdViaRequest
.calledWith(this.project_id, this.user_id)
.should.equal(true)
})
})
describe('_populateServerIdViaRequest', function () {
beforeEach(function () {
this.clsiServerId = 'server-id'
this.ClsiCookieManager.promises.setServerId = sinon.stub().resolves()
})
describe('with a server id in the response', function () {
beforeEach(function () {
this.response = {
headers: {
'set-cookie': [
`${this.settings.clsiCookie.key}=${this.clsiServerId}`,
],
},
}
this.fetchUtils.fetchNothing.returns(this.response)
})
it('should make a request to the clsi', async function () {
await this.ClsiCookieManager.promises._populateServerIdViaRequest(
this.project_id,
this.user_id,
'standard',
'n2d'
)
const args = this.ClsiCookieManager.promises.setServerId.args[0]
args[0].should.equal(this.project_id)
args[1].should.equal(this.user_id)
args[2].should.equal('standard')
args[3].should.equal('n2d')
args[4].should.deep.equal(this.clsiServerId)
})
it('should return the server id', async function () {
const serverId =
await this.ClsiCookieManager.promises._populateServerIdViaRequest(
this.project_id,
this.user_id,
'',
'n2d'
)
serverId.should.equal(this.clsiServerId)
})
})
describe('without a server id in the response', function () {
beforeEach(function () {
this.response = { headers: {} }
this.fetchUtils.fetchNothing.returns(this.response)
})
it('should not set the server id there is no server id in the response', async function () {
this.ClsiCookieManager._parseServerIdFromResponse = sinon
.stub()
.returns(null)
await this.ClsiCookieManager.promises.setServerId(
this.project_id,
this.user_id,
'standard',
'n2d',
this.clsiServerId,
null
)
this.redis.setex.called.should.equal(false)
})
})
})
describe('clearServerId', function () {
it('should clear both keys', async function () {
await this.ClsiCookieManager.promises.clearServerId(
this.project_id,
this.user_id,
'n2d'
)
this.redis.del.should.have.been.calledWith(
`clsiserver:n2d:${this.project_id}:${this.user_id}`,
`clsiserver:${this.project_id}:${this.user_id}`
)
})
})
describe('setServerId', function () {
beforeEach(function () {
this.clsiServerId = 'server-id'
this.ClsiCookieManager._parseServerIdFromResponse = sinon
.stub()
.returns('clsi-8')
})
it('should set the server id with a ttl', async function () {
await this.ClsiCookieManager.promises.setServerId(
this.project_id,
this.user_id,
'standard',
'n2d',
this.clsiServerId,
null
)
this.redis.setex.should.have.been.calledWith(
`clsiserver:n2d:${this.project_id}:${this.user_id}`,
this.settings.clsiCookie.ttlInSeconds,
this.clsiServerId
)
})
it('should set the server id with the regular ttl for reg instance', async function () {
this.clsiServerId = 'clsi-reg-8'
await this.ClsiCookieManager.promises.setServerId(
this.project_id,
this.user_id,
'standard',
'n2d',
this.clsiServerId,
null
)
expect(this.redis.setex).to.have.been.calledWith(
`clsiserver:n2d:${this.project_id}:${this.user_id}`,
this.settings.clsiCookie.ttlInSecondsRegular,
this.clsiServerId
)
})
it('should not set the server id if clsiCookies are not enabled', async function () {
delete this.settings.clsiCookie.key
this.ClsiCookieManager2 = SandboxedModule.require(modulePath, {
globals: {
console,
},
requires: this.requires,
})()
await this.ClsiCookieManager2.promises.setServerId(
this.project_id,
this.user_id,
'standard',
'n2d',
this.clsiServerId,
null
)
this.redis.setex.called.should.equal(false)
})
it('should also set in the secondary if secondary redis is enabled', async function () {
this.redis_secondary = { setex: sinon.stub().resolves() }
this.settings.redis.clsi_cookie_secondary = {}
this.RedisWrapper.client = sinon.stub()
this.RedisWrapper.client.withArgs('clsi_cookie').returns(this.redis)
this.RedisWrapper.client
.withArgs('clsi_cookie_secondary')
.returns(this.redis_secondary)
this.ClsiCookieManager2 = SandboxedModule.require(modulePath, {
globals: {
console,
},
requires: this.requires,
})()
this.ClsiCookieManager2._parseServerIdFromResponse = sinon
.stub()
.returns('clsi-8')
await this.ClsiCookieManager2.promises.setServerId(
this.project_id,
this.user_id,
'standard',
'n2d',
this.clsiServerId,
null
)
this.redis_secondary.setex.should.have.been.calledWith(
`clsiserver:n2d:${this.project_id}:${this.user_id}`,
this.settings.clsiCookie.ttlInSeconds,
this.clsiServerId
)
})
describe('checkIsLoadSheddingEvent', function () {
beforeEach(function () {
this.fetchUtils.fetchStringWithResponse.reset()
this.call = async () => {
await this.ClsiCookieManager.promises.setServerId(
this.project_id,
this.user_id,
'standard',
'n2d',
this.clsiServerId,
'previous-clsi-server-id'
)
expect(
this.fetchUtils.fetchStringWithResponse
).to.have.been.calledWith(
`${this.settings.apis.clsi.url}/instance-state?clsiserverid=previous-clsi-server-id&compileGroup=standard&compileBackendClass=n2d`,
{ method: 'GET', signal: sinon.match.instanceOf(AbortSignal) }
)
}
})
it('should report "load-shedding" when previous is UP', async function () {
this.fetchUtils.fetchStringWithResponse.resolves({
response: { status: 200 },
body: 'previous-clsi-server-id,UP\n',
})
await this.call()
expect(this.metrics.inc).to.have.been.calledWith(
'clsi-lb-switch-backend',
1,
{ status: 'load-shedding' }
)
})
it('should report "cycle" when other is UP', async function () {
this.fetchUtils.fetchStringWithResponse.resolves({
response: { status: 200 },
body: 'other-clsi-server-id,UP\n',
})
await this.call()
expect(this.metrics.inc).to.have.been.calledWith(
'clsi-lb-switch-backend',
1,
{ status: 'cycle' }
)
})
it('should report "cycle" when previous is 404', async function () {
this.fetchUtils.fetchStringWithResponse.resolves({
response: { status: 404 },
})
await this.call()
expect(this.metrics.inc).to.have.been.calledWith(
'clsi-lb-switch-backend',
1,
{ status: 'cycle' }
)
})
})
})
})

View File

@@ -1,23 +1,23 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Compile/ClsiFormatChecker.js'
const SandboxedModule = require('sandboxed-module')
import { vi, expect } from 'vitest'
import sinon from 'sinon'
const modulePath = '../../../../app/src/Features/Compile/ClsiFormatChecker.mjs'
describe('ClsiFormatChecker', function () {
beforeEach(function () {
this.ClsiFormatChecker = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {
compileBodySizeLimitMb: 5,
}),
},
})
return (this.project_id = 'project-id')
beforeEach(async function (ctx) {
vi.doMock('@overleaf/settings', () => ({
default: (ctx.settings = {
compileBodySizeLimitMb: 5,
}),
}))
ctx.ClsiFormatChecker = (await import(modulePath)).default
ctx.project_id = 'project-id'
})
describe('checkRecoursesForProblems', function () {
beforeEach(function () {
return (this.resources = [
beforeEach(function (ctx) {
ctx.resources = [
{
path: 'main.tex',
content: 'stuff',
@@ -28,65 +28,59 @@ describe('ClsiFormatChecker', function () {
},
{
path: 'stuff/image/image.png',
url: `http:somewhere.com/project/${this.project_id}/file/1234124321312`,
url: `http:somewhere.com/project/${ctx.project_id}/file/1234124321312`,
modified: 'more stuff',
},
])
]
})
it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', async function () {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', async function (ctx) {
ctx.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.returns(null)
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.returns(null)
this.ClsiFormatChecker.checkRecoursesForProblems(this.resources)
this.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(true)
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
await ctx.ClsiFormatChecker.checkRecoursesForProblems(ctx.resources)
ctx.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(true)
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
true
)
})
it('should remove undefined errors', async function () {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.returns([])
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
it('should remove undefined errors', async function (ctx) {
ctx.ClsiFormatChecker._checkForConflictingPaths = sinon.stub().returns([])
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.returns({})
const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
this.resources
const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
ctx.resources
)
expect(problems).to.not.exist
})
it('should keep populated arrays', async function () {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
it('should keep populated arrays', async function (ctx) {
ctx.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.returns([{ path: 'somewhere/main.tex' }])
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.returns({})
const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
this.resources
const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
ctx.resources
)
problems.conflictedPaths[0].path.should.equal('somewhere/main.tex')
expect(problems.sizeCheck).to.not.exist
})
it('should keep populated object', async function () {
this.ClsiFormatChecker._checkForConflictingPaths = sinon
.stub()
.returns([])
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
.stub()
.returns({
resources: [{ 'a.tex': 'a.tex' }, { 'b.tex': 'b.tex' }],
totalSize: 1000000,
})
const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
this.resources
it('should keep populated object', async function (ctx) {
ctx.ClsiFormatChecker._checkForConflictingPaths = sinon.stub().returns([])
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon.stub().returns({
resources: [{ 'a.tex': 'a.tex' }, { 'b.tex': 'b.tex' }],
totalSize: 1000000,
})
const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
ctx.resources
)
problems.sizeCheck.resources.length.should.equal(2)
problems.sizeCheck.totalSize.should.equal(1000000)
@@ -94,93 +88,91 @@ describe('ClsiFormatChecker', function () {
})
describe('_checkForConflictingPaths', function () {
beforeEach(function () {
this.resources.push({
beforeEach(function (ctx) {
ctx.resources.push({
path: 'chapters/chapter1.tex',
content: 'other stuff',
})
return this.resources.push({
ctx.resources.push({
path: 'chapters.tex',
content: 'other stuff',
})
})
it('should flag up when a nested file has folder with same subpath as file elsewhere', async function () {
this.resources.push({
it('should flag up when a nested file has folder with same subpath as file elsewhere', async function (ctx) {
ctx.resources.push({
path: 'stuff/image',
url: 'http://somwhere.com',
})
const conflictPathErrors =
this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
conflictPathErrors.length.should.equal(1)
conflictPathErrors[0].path.should.equal('stuff/image')
})
it('should flag up when a root level file has folder with same subpath as file elsewhere', async function () {
this.resources.push({
it('should flag up when a root level file has folder with same subpath as file elsewhere', async function (ctx) {
ctx.resources.push({
path: 'stuff',
content: 'other stuff',
})
const conflictPathErrors =
this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
conflictPathErrors.length.should.equal(1)
conflictPathErrors[0].path.should.equal('stuff')
})
it('should not flag up when the file is a substring of a path', async function () {
this.resources.push({
it('should not flag up when the file is a substring of a path', async function (ctx) {
ctx.resources.push({
path: 'stuf',
content: 'other stuff',
})
const conflictPathErrors =
this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
conflictPathErrors.length.should.equal(0)
})
})
describe('_checkDocsAreUnderSizeLimit', function () {
it('should error when there is more than 5mb of data', async function () {
this.resources.push({
it('should error when there is more than 5mb of data', async function (ctx) {
ctx.resources.push({
path: 'massive.tex',
content: 'hello world'.repeat(833333), // over 5mb limit
})
while (this.resources.length < 20) {
this.resources.push({
while (ctx.resources.length < 20) {
ctx.resources.push({
path: 'chapters/chapter1.tex',
url: 'http://somwhere.com',
})
}
const sizeError = this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
this.resources
)
const sizeError =
await ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit(ctx.resources)
sizeError.totalSize.should.equal(16 + 833333 * 11) // 16 is for earlier resources
sizeError.resources.length.should.equal(10)
sizeError.resources[0].path.should.equal('massive.tex')
sizeError.resources[0].size.should.equal(833333 * 11)
})
it('should return nothing when project is correct size', async function () {
this.resources.push({
it('should return nothing when project is correct size', async function (ctx) {
ctx.resources.push({
path: 'massive.tex',
content: 'x'.repeat(2 * 1000 * 1000),
})
while (this.resources.length < 20) {
this.resources.push({
while (ctx.resources.length < 20) {
ctx.resources.push({
path: 'chapters/chapter1.tex',
url: 'http://somwhere.com',
})
}
const sizeError = this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
this.resources
)
const sizeError =
await ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit(ctx.resources)
expect(sizeError).to.not.exist
})
})

View File

@@ -0,0 +1,193 @@
import { vi, expect } from 'vitest'
import sinon from 'sinon'
const modulePath = '../../../../app/src/Features/Compile/ClsiStateManager.mjs'
describe('ClsiStateManager', function () {
beforeEach(async function (ctx) {
vi.doMock('@overleaf/settings', () => ({
default: (ctx.settings = {}),
}))
vi.doMock(
'../../../../app/src/Features/Project/ProjectEntityHandler',
() => ({
default: (ctx.ProjectEntityHandler = {}),
})
)
ctx.ClsiStateManager = (await import(modulePath)).default
ctx.project = 'project'
ctx.options = { draft: true, isAutoCompile: false }
ctx.callback = sinon.stub()
})
describe('computeHash', function () {
beforeEach(function (ctx) {
ctx.docs = [
{ path: '/main.tex', doc: { _id: 'doc-id-1' } },
{ path: '/folder/sub.tex', doc: { _id: 'doc-id-2' } },
]
ctx.files = [
{
path: '/figure.pdf',
file: { _id: 'file-id-1', rev: 123, created: 'aaaaaa' },
},
{
path: '/folder/fig2.pdf',
file: { _id: 'file-id-2', rev: 456, created: 'bbbbbb' },
},
]
ctx.ProjectEntityHandler.getAllEntitiesFromProject = sinon
.stub()
.returns({ docs: ctx.docs, files: ctx.files })
ctx.hash0 = ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
})
describe('with a sample project', function () {
beforeEach(function () {})
it('should return a hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).to.equal('21b1ab73aa3892bec452baf8ffa0956179e1880f')
})
})
describe('when the files and docs are in a different order', function () {
beforeEach(function (ctx) {
;[ctx.docs[0], ctx.docs[1]] = [ctx.docs[1], ctx.docs[0]]
;[ctx.files[0], ctx.files[1]] = [ctx.files[1], ctx.files[0]]
})
it('should return the same hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).to.equal(ctx.hash0)
})
})
describe('when a doc is renamed', function () {
beforeEach(function (ctx) {
ctx.docs[0].path = '/new.tex'
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe('when a file is renamed', function () {
beforeEach(function (ctx) {
ctx.files[0].path = '/newfigure.pdf'
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe('when a doc is added', function () {
beforeEach(function (ctx) {
ctx.docs.push({ path: '/newdoc.tex', doc: { _id: 'newdoc-id' } })
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe('when a file is added', function () {
beforeEach(function (ctx) {
ctx.files.push({
path: '/newfile.tex',
file: { _id: 'newfile-id', rev: 123 },
})
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe('when a doc is removed', function () {
beforeEach(function (ctx) {
ctx.docs.pop()
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe('when a file is removed', function () {
beforeEach(function (ctx) {
ctx.files.pop()
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe("when a file's revision is updated", function () {
beforeEach(function (ctx) {
ctx.files[0].file.rev++
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe("when a file's date is updated", function () {
beforeEach(function (ctx) {
ctx.files[0].file.created = 'zzzzzz'
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe('when the compile options are changed', function () {
beforeEach(function (ctx) {
ctx.options.draft = !ctx.options.draft
})
it('should return a different hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).not.to.equal(ctx.hash0)
})
})
describe('when the isAutoCompile option is changed', function () {
beforeEach(function (ctx) {
ctx.options.isAutoCompile = !ctx.options.isAutoCompile
})
it('should return the same hash value', function (ctx) {
expect(
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
).to.equal(ctx.hash0)
})
})
})
})

View File

@@ -1,204 +0,0 @@
/* 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:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/src/Features/Compile/ClsiStateManager.js'
const SandboxedModule = require('sandboxed-module')
describe('ClsiStateManager', function () {
beforeEach(function () {
this.ClsiStateManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {}),
'../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {}),
},
})
this.project = 'project'
this.options = { draft: true, isAutoCompile: false }
return (this.callback = sinon.stub())
})
describe('computeHash', function () {
beforeEach(function () {
this.docs = [
{ path: '/main.tex', doc: { _id: 'doc-id-1' } },
{ path: '/folder/sub.tex', doc: { _id: 'doc-id-2' } },
]
this.files = [
{
path: '/figure.pdf',
file: { _id: 'file-id-1', rev: 123, created: 'aaaaaa' },
},
{
path: '/folder/fig2.pdf',
file: { _id: 'file-id-2', rev: 456, created: 'bbbbbb' },
},
]
this.ProjectEntityHandler.getAllEntitiesFromProject = sinon
.stub()
.returns({ docs: this.docs, files: this.files })
this.hash0 = this.ClsiStateManager.computeHash(this.project, this.options)
})
describe('with a sample project', function () {
beforeEach(function () {})
it('should return a hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).to.equal('21b1ab73aa3892bec452baf8ffa0956179e1880f')
})
})
describe('when the files and docs are in a different order', function () {
beforeEach(function () {
;[this.docs[0], this.docs[1]] = Array.from([this.docs[1], this.docs[0]])
;[this.files[0], this.files[1]] = Array.from([
this.files[1],
this.files[0],
])
})
it('should return the same hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).to.equal(this.hash0)
})
})
describe('when a doc is renamed', function () {
beforeEach(function () {
this.docs[0].path = '/new.tex'
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a file is renamed', function () {
beforeEach(function () {
this.files[0].path = '/newfigure.pdf'
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a doc is added', function () {
beforeEach(function () {
this.docs.push({ path: '/newdoc.tex', doc: { _id: 'newdoc-id' } })
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a file is added', function () {
beforeEach(function () {
this.files.push({
path: '/newfile.tex',
file: { _id: 'newfile-id', rev: 123 },
})
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a doc is removed', function () {
beforeEach(function () {
this.docs.pop()
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when a file is removed', function () {
beforeEach(function () {
this.files.pop()
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe("when a file's revision is updated", function () {
beforeEach(function () {
this.files[0].file.rev++
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe("when a file's date is updated", function () {
beforeEach(function () {
this.files[0].file.created = 'zzzzzz'
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when the compile options are changed', function () {
beforeEach(function () {
this.options.draft = !this.options.draft
})
it('should return a different hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).not.to.equal(this.hash0)
})
})
describe('when the isAutoCompile option is changed', function () {
beforeEach(function () {
this.options.isAutoCompile = !this.options.isAutoCompile
})
it('should return the same hash value', function () {
expect(
this.ClsiStateManager.computeHash(this.project, this.options)
).to.equal(this.hash0)
})
})
})
})

View File

@@ -1,31 +1,39 @@
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = require('path').join(
__dirname,
'../../../../app/src/Features/Institutions/InstitutionsGetter.js'
)
import { vi, expect } from 'vitest'
import sinon from 'sinon'
const modulePath =
'../../../../app/src/Features/Institutions/InstitutionsGetter.mjs'
describe('InstitutionsGetter', function () {
beforeEach(function () {
this.UserGetter = {
beforeEach(async function (ctx) {
ctx.UserGetter = {
getUserFullEmails: sinon.stub(),
promises: {
getUserFullEmails: sinon.stub(),
},
}
this.InstitutionsGetter = SandboxedModule.require(modulePath, {
requires: {
'../User/UserGetter': this.UserGetter,
'../UserMembership/UserMembershipsHandler':
(this.UserMembershipsHandler = {}),
'../UserMembership/UserMembershipEntityConfigs':
(this.UserMembershipEntityConfigs = {}),
},
})
this.userId = '12345abcde'
this.confirmedAffiliation = {
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock(
'../../../../app/src/Features/UserMembership/UserMembershipsHandler',
() => ({
default: (ctx.UserMembershipsHandler = {}),
})
)
vi.doMock(
'../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs',
() => ({
default: (ctx.UserMembershipEntityConfigs = {}),
})
)
ctx.InstitutionsGetter = (await import(modulePath)).default
ctx.userId = '12345abcde'
ctx.confirmedAffiliation = {
confirmedAt: new Date(),
affiliation: {
institution: { id: 456, confirmed: true },
@@ -33,7 +41,7 @@ describe('InstitutionsGetter', function () {
pastReconfirmDate: false,
},
}
this.confirmedAffiliationPastReconfirmation = {
ctx.confirmedAffiliationPastReconfirmation = {
confirmedAt: new Date('2000-01-01'),
affiliation: {
institution: { id: 135, confirmed: true },
@@ -41,7 +49,7 @@ describe('InstitutionsGetter', function () {
pastReconfirmDate: true,
},
}
this.licencedAffiliation = {
ctx.licencedAffiliation = {
confirmedAt: new Date(),
affiliation: {
licence: 'pro_plus',
@@ -50,7 +58,7 @@ describe('InstitutionsGetter', function () {
pastReconfirmDate: false,
},
}
this.licencedAffiliationPastReconfirmation = {
ctx.licencedAffiliationPastReconfirmation = {
confirmedAt: new Date('2000-01-01'),
affiliation: {
licence: 'pro_plus',
@@ -59,7 +67,7 @@ describe('InstitutionsGetter', function () {
pastReconfirmDate: true,
},
}
this.unconfirmedEmailLicensedAffiliation = {
ctx.unconfirmedEmailLicensedAffiliation = {
confirmedAt: null,
affiliation: {
licence: 'pro_plus',
@@ -71,7 +79,7 @@ describe('InstitutionsGetter', function () {
},
},
}
this.unconfirmedDomainLicensedAffiliation = {
ctx.unconfirmedDomainLicensedAffiliation = {
confirmedAt: new Date(),
affiliation: {
licence: 'pro_plus',
@@ -83,7 +91,7 @@ describe('InstitutionsGetter', function () {
},
},
}
this.userEmails = [
ctx.userEmails = [
{
confirmedAt: null,
affiliation: {
@@ -95,9 +103,9 @@ describe('InstitutionsGetter', function () {
},
},
},
this.confirmedAffiliation,
this.confirmedAffiliation,
this.confirmedAffiliationPastReconfirmation,
ctx.confirmedAffiliation,
ctx.confirmedAffiliation,
ctx.confirmedAffiliationPastReconfirmation,
{
confirmedAt: new Date(),
affiliation: null,
@@ -124,41 +132,41 @@ describe('InstitutionsGetter', function () {
},
},
]
this.fullEmailCollection = [
this.licencedAffiliation,
this.licencedAffiliation,
this.licencedAffiliationPastReconfirmation,
this.confirmedAffiliation,
this.confirmedAffiliationPastReconfirmation,
this.unconfirmedDomainLicensedAffiliation,
this.unconfirmedEmailLicensedAffiliation,
ctx.fullEmailCollection = [
ctx.licencedAffiliation,
ctx.licencedAffiliation,
ctx.licencedAffiliationPastReconfirmation,
ctx.confirmedAffiliation,
ctx.confirmedAffiliationPastReconfirmation,
ctx.unconfirmedDomainLicensedAffiliation,
ctx.unconfirmedEmailLicensedAffiliation,
]
})
describe('getCurrentInstitutionIds', function () {
it('filters unconfirmed affiliations, those past reconfirmation, and returns only 1 result per institution', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(this.userEmails)
it('filters unconfirmed affiliations, those past reconfirmation, and returns only 1 result per institution', async function (ctx) {
ctx.UserGetter.promises.getUserFullEmails.resolves(ctx.userEmails)
const institutions =
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
this.userId
await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
ctx.userId
)
expect(institutions.length).to.equal(1)
expect(institutions[0]).to.equal(456)
})
it('handles empty response', async function () {
this.UserGetter.promises.getUserFullEmails.resolves([])
it('handles empty response', async function (ctx) {
ctx.UserGetter.promises.getUserFullEmails.resolves([])
const institutions =
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
this.userId
await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
ctx.userId
)
expect(institutions).to.deep.equal([])
})
it('handles errors', async function () {
this.UserGetter.promises.getUserFullEmails.rejects(new Error('oops'))
it('handles errors', async function (ctx) {
ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('oops'))
let e
try {
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
this.userId
await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
ctx.userId
)
} catch (error) {
e = error
@@ -168,37 +176,37 @@ describe('InstitutionsGetter', function () {
})
describe('getCurrentAndPastAffiliationIds', function () {
it('filters unconfirmed affiliations, preserves those past reconfirmation, and returns only 1 result per institution', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.fullEmailCollection
it('filters unconfirmed affiliations, preserves those past reconfirmation, and returns only 1 result per institution', async function (ctx) {
ctx.UserGetter.promises.getUserFullEmails.resolves(
ctx.fullEmailCollection
)
const institutions =
await this.InstitutionsGetter.promises.getCurrentAndPastAffiliationIds(
this.userId
await ctx.InstitutionsGetter.promises.getCurrentAndPastAffiliationIds(
ctx.userId
)
expect(institutions).to.deep.equal([777, 888, 456, 135])
})
it('handles empty response', async function () {
this.UserGetter.promises.getUserFullEmails.resolves([])
it('handles empty response', async function (ctx) {
ctx.UserGetter.promises.getUserFullEmails.resolves([])
const institutions =
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
this.userId
await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
ctx.userId
)
expect(institutions).to.deep.equal([])
})
})
describe('getCurrentInstitutionsWithLicence', function () {
it('returns one result per institution and filters out affiliations without license', async function () {
this.UserGetter.promises.getUserFullEmails.resolves(
this.fullEmailCollection
it('returns one result per institution and filters out affiliations without license', async function (ctx) {
ctx.UserGetter.promises.getUserFullEmails.resolves(
ctx.fullEmailCollection
)
const institutions =
await this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
this.userId
await ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
ctx.userId
)
expect(institutions.map(institution => institution.id)).to.deep.equal([
this.licencedAffiliation.affiliation.institution.id,
ctx.licencedAffiliation.affiliation.institution.id,
])
})
})

View File

@@ -0,0 +1,506 @@
import { vi, expect } from 'vitest'
import path from 'path'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import Features from '../../../../app/src/infrastructure/Features.js'
const modulePath = path.join(
import.meta.dirname,
'../../../../app/src/Features/Institutions/InstitutionsManager'
)
const { ObjectId } = mongodb
describe('InstitutionsManager', function () {
beforeEach(async function (ctx) {
ctx.institutionId = 123
ctx.user = {}
const lapsedUser = {
_id: '657300a08a14461b3d1aac3e',
features: {},
}
ctx.users = [
lapsedUser,
{ _id: '657300a08a14461b3d1aac3f', features: {} },
{ _id: '657300a08a14461b3d1aac40', features: {} },
{ _id: '657300a08a14461b3d1aac41', features: {} },
]
ctx.ssoUsers = [
{
_id: '657300a08a14461b3d1aac3f',
samlIdentifiers: [{ providerId: ctx.institutionId.toString() }],
},
{
_id: '657300a08a14461b3d1aac40',
samlIdentifiers: [
{
providerId: ctx.institutionId.toString(),
hasEntitlement: true,
},
],
},
{
_id: '657300a08a14461b3d1aac3e',
samlIdentifiers: [{ providerId: ctx.institutionId.toString() }],
hasEntitlement: true,
},
]
ctx.UserGetter = {
promises: {
getUser: sinon.stub().resolves(ctx.user),
getUsers: sinon.stub().resolves(ctx.users),
getUsersByAnyConfirmedEmail: sinon.stub().resolves(),
getSsoUsersAtInstitution: (ctx.getSsoUsersAtInstitution = sinon
.stub()
.resolves(ctx.ssoUsers)),
},
}
ctx.creator = { create: sinon.stub().resolves() }
ctx.NotificationsBuilder = {
promises: {
featuresUpgradedByAffiliation: sinon.stub().returns(ctx.creator),
redundantPersonalSubscription: sinon.stub().returns(ctx.creator),
},
}
ctx.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves(),
},
}
ctx.institutionWithV1Data = { name: 'Wombat University' }
ctx.institution = {
fetchV1DataPromise: sinon.stub().resolves(ctx.institutionWithV1Data),
}
ctx.InstitutionModel = {
Institution: {
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(ctx.institution),
}),
},
}
ctx.subscriptionExec = sinon.stub().resolves()
const SubscriptionModel = {
Subscription: {
find: () => ({
populate: () => ({
exec: ctx.subscriptionExec,
}),
}),
},
}
ctx.Mongo = {
ObjectId,
}
ctx.v1Counts = {
user_ids: ctx.users.map(user => user._id),
current_users_count: 3,
lapsed_user_ids: [lapsedUser._id],
entitled_via_sso: 1, // 2 entitled, but 1 lapsed
with_confirmed_email: 2, // 1 non entitled SSO + 1 email user
}
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsAPI',
() => ({
default: {
promises: {
addAffiliation: (ctx.addAffiliationPromise = sinon
.stub()
.resolves()),
getInstitutionAffiliations: (ctx.getInstitutionAffiliationsPromise =
sinon.stub().resolves(ctx.affiliations)),
getConfirmedInstitutionAffiliations:
(ctx.getConfirmedInstitutionAffiliationsPromise = sinon
.stub()
.resolves(ctx.affiliations)),
getInstitutionAffiliationsCounts:
(ctx.getInstitutionAffiliationsCounts = sinon
.stub()
.resolves(ctx.v1Counts)),
},
},
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/FeaturesUpdater',
() => ({
default: {
promises: {
refreshFeatures: (ctx.refreshFeaturesPromise = sinon
.stub()
.resolves()),
},
},
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/FeaturesHelper',
() => ({
default: {
isFeatureSetBetter: (ctx.isFeatureSetBetter = sinon.stub()),
},
})
)
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock(
'../../../../app/src/Features/Notifications/NotificationsBuilder',
() => ({
default: ctx.NotificationsBuilder,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionLocator',
() => ({
default: ctx.SubscriptionLocator,
})
)
vi.doMock(
'../../../../app/src/models/Institution',
() => ctx.InstitutionModel
)
vi.doMock(
'../../../../app/src/models/Subscription',
() => SubscriptionModel
)
vi.doMock('mongodb-legacy', () => ({
default: ctx.Mongo,
}))
vi.doMock('@overleaf/settings', () => ({
default: {
features: { professional: { 'test-feature': true } },
},
}))
ctx.InstitutionsManager = (await import(modulePath)).default
})
describe('refreshInstitutionUsers', function () {
beforeEach(function (ctx) {
ctx.user1Id = '123abc123abc123abc123abc'
ctx.user2Id = '456def456def456def456def'
ctx.user3Id = '789abd789abd789abd789abd'
ctx.user4Id = '321cba321cba321cba321cba'
ctx.affiliations = [
{ user_id: ctx.user1Id },
{ user_id: ctx.user2Id },
{ user_id: ctx.user3Id },
{ user_id: ctx.user4Id },
]
ctx.user1 = { _id: ctx.user1Id }
ctx.user2 = { _id: ctx.user2Id }
ctx.user3 = { _id: ctx.user3Id }
ctx.user4 = { _id: ctx.user4Id }
ctx.UserGetter.promises.getUser
.withArgs(new ObjectId(ctx.user1Id))
.resolves(ctx.user1)
ctx.UserGetter.promises.getUser
.withArgs(new ObjectId(ctx.user2Id))
.resolves(ctx.user2)
ctx.UserGetter.promises.getUser
.withArgs(new ObjectId(ctx.user3Id))
.resolves(ctx.user3)
ctx.UserGetter.promises.getUser
.withArgs(new ObjectId(ctx.user4Id))
.resolves(ctx.user4)
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user2)
.resolves({
planCode: 'pro',
groupPlan: false,
})
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user3)
.resolves({
planCode: 'collaborator_free_trial_7_days',
groupPlan: false,
})
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user4)
.resolves({
planCode: 'collaborator-annual',
groupPlan: true,
})
ctx.refreshFeaturesPromise.resolves({
newFeatures: {},
featuresChanged: false,
})
ctx.refreshFeaturesPromise
.withArgs(new ObjectId(ctx.user1Id))
.resolves({ newFeatures: {}, featuresChanged: true })
ctx.getInstitutionAffiliationsPromise.resolves(ctx.affiliations)
ctx.getConfirmedInstitutionAffiliationsPromise.resolves(ctx.affiliations)
})
it('refresh all users Features', async function (ctx) {
await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
ctx.institutionId,
false
)
sinon.assert.callCount(ctx.refreshFeaturesPromise, 4)
// expect no notifications
sinon.assert.notCalled(
ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation
)
sinon.assert.notCalled(
ctx.NotificationsBuilder.promises.redundantPersonalSubscription
)
})
it('notifies users if their features have been upgraded', async function (ctx) {
await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
ctx.institutionId,
true
)
sinon.assert.calledOnce(
ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation
)
sinon.assert.calledWith(
ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation,
ctx.affiliations[0],
ctx.user1
)
})
it('notifies users if they have a subscription, or a trial subscription, that should be cancelled', async function (ctx) {
await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
ctx.institutionId,
true
)
sinon.assert.calledTwice(
ctx.NotificationsBuilder.promises.redundantPersonalSubscription
)
sinon.assert.calledWith(
ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
ctx.affiliations[1],
ctx.user2
)
sinon.assert.calledWith(
ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
ctx.affiliations[2],
ctx.user3
)
})
})
describe('checkInstitutionUsers', function () {
it('returns entitled/not, sso/not, lapsed/current, and pro counts', async function (ctx) {
if (Features.hasFeature('saas')) {
ctx.isFeatureSetBetter.returns(true)
const usersSummary =
await ctx.InstitutionsManager.promises.checkInstitutionUsers(
ctx.institutionId
)
expect(usersSummary).to.deep.equal({
emailUsers: {
total: 1,
current: 1,
lapsed: 0,
pro: {
current: 1, // isFeatureSetBetter stubbed to return true for all
lapsed: 0,
},
nonPro: {
current: 0,
lapsed: 0,
},
},
ssoUsers: {
total: 3,
lapsed: 1,
current: {
entitled: 1,
notEntitled: 1,
},
pro: {
current: 2,
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
},
nonPro: {
current: 0,
lapsed: 0,
},
},
})
}
})
it('includes withConfirmedEmailMismatch when v1 and v2 counts do not add up', async function (ctx) {
if (Features.hasFeature('saas')) {
ctx.isFeatureSetBetter.returns(true)
ctx.v1Counts.with_confirmed_email = 100
const usersSummary =
await ctx.InstitutionsManager.promises.checkInstitutionUsers(
ctx.institutionId
)
expect(usersSummary).to.deep.equal({
emailUsers: {
total: 1,
current: 1,
lapsed: 0,
pro: {
current: 1, // isFeatureSetBetter stubbed to return true for all
lapsed: 0,
},
nonPro: {
current: 0,
lapsed: 0,
},
},
ssoUsers: {
total: 3,
lapsed: 1,
current: {
entitled: 1,
notEntitled: 1,
},
pro: {
current: 2,
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
},
nonPro: {
current: 0,
lapsed: 0,
},
},
databaseMismatch: {
withConfirmedEmail: {
v1: 100,
v2: 2,
},
},
})
}
})
})
describe('getInstitutionUsersSubscriptions', function () {
it('returns all institution users subscriptions', async function (ctx) {
const stubbedUsers = [
{ user_id: '123abc123abc123abc123abc' },
{ user_id: '456def456def456def456def' },
{ user_id: '789def789def789def789def' },
]
ctx.getInstitutionAffiliationsPromise.resolves(stubbedUsers)
await ctx.InstitutionsManager.promises.getInstitutionUsersSubscriptions(
ctx.institutionId
)
sinon.assert.calledOnce(ctx.subscriptionExec)
})
})
describe('addAffiliations', function () {
beforeEach(function (ctx) {
ctx.host = 'mit.edu'.split('').reverse().join('')
ctx.stubbedUser1 = {
_id: '6573014d8a14461b3d1aac3f',
name: 'bob',
email: 'hello@world.com',
emails: [
{ email: 'stubb1@mit.edu', reversedHostname: ctx.host },
{ email: 'test@test.com', reversedHostname: 'test.com' },
{ email: 'another@mit.edu', reversedHostname: ctx.host },
],
}
ctx.stubbedUser1DecoratedEmails = [
{
email: 'stubb1@mit.edu',
reversedHostname: ctx.host,
samlIdentifier: { hasEntitlement: false },
},
{ email: 'test@test.com', reversedHostname: 'test.com' },
{
email: 'another@mit.edu',
reversedHostname: ctx.host,
samlIdentifier: { hasEntitlement: true },
},
]
ctx.stubbedUser2 = {
_id: '6573014d8a14461b3d1aac40',
name: 'test',
email: 'hello2@world.com',
emails: [{ email: 'subb2@mit.edu', reversedHostname: ctx.host }],
}
ctx.stubbedUser2DecoratedEmails = [
{
email: 'subb2@mit.edu',
reversedHostname: ctx.host,
},
]
ctx.getInstitutionUsersByHostname = sinon.stub().resolves([
{
_id: ctx.stubbedUser1._id,
emails: ctx.stubbedUser1DecoratedEmails,
},
{
_id: ctx.stubbedUser2._id,
emails: ctx.stubbedUser2DecoratedEmails,
},
])
ctx.UserGetter.promises.getInstitutionUsersByHostname =
ctx.getInstitutionUsersByHostname
})
describe('affiliateUsers', function () {
it('should add affiliations for matching users', async function (ctx) {
await ctx.InstitutionsManager.promises.affiliateUsers('mit.edu')
ctx.getInstitutionUsersByHostname.calledOnce.should.equal(true)
ctx.addAffiliationPromise.calledThrice.should.equal(true)
ctx.addAffiliationPromise
.calledWithMatch(
ctx.stubbedUser1._id,
ctx.stubbedUser1.emails[0].email,
{ entitlement: false }
)
.should.equal(true)
ctx.addAffiliationPromise
.calledWithMatch(
ctx.stubbedUser1._id,
ctx.stubbedUser1.emails[2].email,
{ entitlement: true }
)
.should.equal(true)
ctx.addAffiliationPromise
.calledWithMatch(
ctx.stubbedUser2._id,
ctx.stubbedUser2.emails[0].email,
{ entitlement: undefined }
)
.should.equal(true)
ctx.refreshFeaturesPromise
.calledWith(ctx.stubbedUser1._id)
.should.equal(true)
ctx.refreshFeaturesPromise
.calledWith(ctx.stubbedUser2._id)
.should.equal(true)
ctx.refreshFeaturesPromise.should.have.been.calledTwice
})
it('should return errors if last affiliation cannot be added', async function (ctx) {
ctx.addAffiliationPromise.onCall(2).rejects()
await expect(ctx.InstitutionsManager.promises.affiliateUsers('mit.edu'))
.to.be.rejected
ctx.getInstitutionUsersByHostname.calledOnce.should.equal(true)
})
})
})
})

View File

@@ -1,466 +0,0 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const { ObjectId } = require('mongodb-legacy')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/Institutions/InstitutionsManager'
)
const Features = require('../../../../app/src/infrastructure/Features')
describe('InstitutionsManager', function () {
beforeEach(function () {
this.institutionId = 123
this.user = {}
const lapsedUser = {
_id: '657300a08a14461b3d1aac3e',
features: {},
}
this.users = [
lapsedUser,
{ _id: '657300a08a14461b3d1aac3f', features: {} },
{ _id: '657300a08a14461b3d1aac40', features: {} },
{ _id: '657300a08a14461b3d1aac41', features: {} },
]
this.ssoUsers = [
{
_id: '657300a08a14461b3d1aac3f',
samlIdentifiers: [{ providerId: this.institutionId.toString() }],
},
{
_id: '657300a08a14461b3d1aac40',
samlIdentifiers: [
{
providerId: this.institutionId.toString(),
hasEntitlement: true,
},
],
},
{
_id: '657300a08a14461b3d1aac3e',
samlIdentifiers: [{ providerId: this.institutionId.toString() }],
hasEntitlement: true,
},
]
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(this.user),
getUsers: sinon.stub().resolves(this.users),
getUsersByAnyConfirmedEmail: sinon.stub().resolves(),
getSsoUsersAtInstitution: (this.getSsoUsersAtInstitution = sinon
.stub()
.resolves(this.ssoUsers)),
},
}
this.creator = { create: sinon.stub().resolves() }
this.NotificationsBuilder = {
promises: {
featuresUpgradedByAffiliation: sinon.stub().returns(this.creator),
redundantPersonalSubscription: sinon.stub().returns(this.creator),
},
}
this.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves(),
},
}
this.institutionWithV1Data = { name: 'Wombat University' }
this.institution = {
fetchV1DataPromise: sinon.stub().resolves(this.institutionWithV1Data),
}
this.InstitutionModel = {
Institution: {
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(this.institution),
}),
},
}
this.subscriptionExec = sinon.stub().resolves()
const SubscriptionModel = {
Subscription: {
find: () => ({
populate: () => ({
exec: this.subscriptionExec,
}),
}),
},
}
this.Mongo = {
ObjectId,
}
this.v1Counts = {
user_ids: this.users.map(user => user._id),
current_users_count: 3,
lapsed_user_ids: [lapsedUser._id],
entitled_via_sso: 1, // 2 entitled, but 1 lapsed
with_confirmed_email: 2, // 1 non entitled SSO + 1 email user
}
this.InstitutionsManager = SandboxedModule.require(modulePath, {
requires: {
'./InstitutionsAPI': {
promises: {
addAffiliation: (this.addAffiliationPromise = sinon
.stub()
.resolves()),
getInstitutionAffiliations:
(this.getInstitutionAffiliationsPromise = sinon
.stub()
.resolves(this.affiliations)),
getConfirmedInstitutionAffiliations:
(this.getConfirmedInstitutionAffiliationsPromise = sinon
.stub()
.resolves(this.affiliations)),
getInstitutionAffiliationsCounts:
(this.getInstitutionAffiliationsCounts = sinon
.stub()
.resolves(this.v1Counts)),
},
},
'../Subscription/FeaturesUpdater': {
promises: {
refreshFeatures: (this.refreshFeaturesPromise = sinon
.stub()
.resolves()),
},
},
'../Subscription/FeaturesHelper': {
isFeatureSetBetter: (this.isFeatureSetBetter = sinon.stub()),
},
'../User/UserGetter': this.UserGetter,
'../Notifications/NotificationsBuilder': this.NotificationsBuilder,
'../Subscription/SubscriptionLocator': this.SubscriptionLocator,
'../../models/Institution': this.InstitutionModel,
'../../models/Subscription': SubscriptionModel,
'mongodb-legacy': this.Mongo,
'@overleaf/settings': {
features: { professional: { 'test-feature': true } },
},
},
})
})
describe('refreshInstitutionUsers', function () {
beforeEach(function () {
this.user1Id = '123abc123abc123abc123abc'
this.user2Id = '456def456def456def456def'
this.user3Id = '789abd789abd789abd789abd'
this.user4Id = '321cba321cba321cba321cba'
this.affiliations = [
{ user_id: this.user1Id },
{ user_id: this.user2Id },
{ user_id: this.user3Id },
{ user_id: this.user4Id },
]
this.user1 = { _id: this.user1Id }
this.user2 = { _id: this.user2Id }
this.user3 = { _id: this.user3Id }
this.user4 = { _id: this.user4Id }
this.UserGetter.promises.getUser
.withArgs(new ObjectId(this.user1Id))
.resolves(this.user1)
this.UserGetter.promises.getUser
.withArgs(new ObjectId(this.user2Id))
.resolves(this.user2)
this.UserGetter.promises.getUser
.withArgs(new ObjectId(this.user3Id))
.resolves(this.user3)
this.UserGetter.promises.getUser
.withArgs(new ObjectId(this.user4Id))
.resolves(this.user4)
this.SubscriptionLocator.promises.getUsersSubscription
.withArgs(this.user2)
.resolves({
planCode: 'pro',
groupPlan: false,
})
this.SubscriptionLocator.promises.getUsersSubscription
.withArgs(this.user3)
.resolves({
planCode: 'collaborator_free_trial_7_days',
groupPlan: false,
})
this.SubscriptionLocator.promises.getUsersSubscription
.withArgs(this.user4)
.resolves({
planCode: 'collaborator-annual',
groupPlan: true,
})
this.refreshFeaturesPromise.resolves({
newFeatures: {},
featuresChanged: false,
})
this.refreshFeaturesPromise
.withArgs(new ObjectId(this.user1Id))
.resolves({ newFeatures: {}, featuresChanged: true })
this.getInstitutionAffiliationsPromise.resolves(this.affiliations)
this.getConfirmedInstitutionAffiliationsPromise.resolves(
this.affiliations
)
})
it('refresh all users Features', async function () {
await this.InstitutionsManager.promises.refreshInstitutionUsers(
this.institutionId,
false
)
sinon.assert.callCount(this.refreshFeaturesPromise, 4)
// expect no notifications
sinon.assert.notCalled(
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation
)
sinon.assert.notCalled(
this.NotificationsBuilder.promises.redundantPersonalSubscription
)
})
it('notifies users if their features have been upgraded', async function () {
await this.InstitutionsManager.promises.refreshInstitutionUsers(
this.institutionId,
true
)
sinon.assert.calledOnce(
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation
)
sinon.assert.calledWith(
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation,
this.affiliations[0],
this.user1
)
})
it('notifies users if they have a subscription, or a trial subscription, that should be cancelled', async function () {
await this.InstitutionsManager.promises.refreshInstitutionUsers(
this.institutionId,
true
)
sinon.assert.calledTwice(
this.NotificationsBuilder.promises.redundantPersonalSubscription
)
sinon.assert.calledWith(
this.NotificationsBuilder.promises.redundantPersonalSubscription,
this.affiliations[1],
this.user2
)
sinon.assert.calledWith(
this.NotificationsBuilder.promises.redundantPersonalSubscription,
this.affiliations[2],
this.user3
)
})
})
describe('checkInstitutionUsers', function () {
it('returns entitled/not, sso/not, lapsed/current, and pro counts', async function () {
if (Features.hasFeature('saas')) {
this.isFeatureSetBetter.returns(true)
const usersSummary =
await this.InstitutionsManager.promises.checkInstitutionUsers(
this.institutionId
)
expect(usersSummary).to.deep.equal({
emailUsers: {
total: 1,
current: 1,
lapsed: 0,
pro: {
current: 1, // isFeatureSetBetter stubbed to return true for all
lapsed: 0,
},
nonPro: {
current: 0,
lapsed: 0,
},
},
ssoUsers: {
total: 3,
lapsed: 1,
current: {
entitled: 1,
notEntitled: 1,
},
pro: {
current: 2,
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
},
nonPro: {
current: 0,
lapsed: 0,
},
},
})
}
})
it('includes withConfirmedEmailMismatch when v1 and v2 counts do not add up', async function () {
if (Features.hasFeature('saas')) {
this.isFeatureSetBetter.returns(true)
this.v1Counts.with_confirmed_email = 100
const usersSummary =
await this.InstitutionsManager.promises.checkInstitutionUsers(
this.institutionId
)
expect(usersSummary).to.deep.equal({
emailUsers: {
total: 1,
current: 1,
lapsed: 0,
pro: {
current: 1, // isFeatureSetBetter stubbed to return true for all
lapsed: 0,
},
nonPro: {
current: 0,
lapsed: 0,
},
},
ssoUsers: {
total: 3,
lapsed: 1,
current: {
entitled: 1,
notEntitled: 1,
},
pro: {
current: 2,
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
},
nonPro: {
current: 0,
lapsed: 0,
},
},
databaseMismatch: {
withConfirmedEmail: {
v1: 100,
v2: 2,
},
},
})
}
})
})
describe('getInstitutionUsersSubscriptions', function () {
it('returns all institution users subscriptions', async function () {
const stubbedUsers = [
{ user_id: '123abc123abc123abc123abc' },
{ user_id: '456def456def456def456def' },
{ user_id: '789def789def789def789def' },
]
this.getInstitutionAffiliationsPromise.resolves(stubbedUsers)
await this.InstitutionsManager.promises.getInstitutionUsersSubscriptions(
this.institutionId
)
sinon.assert.calledOnce(this.subscriptionExec)
})
})
describe('addAffiliations', function () {
beforeEach(function () {
this.host = 'mit.edu'.split('').reverse().join('')
this.stubbedUser1 = {
_id: '6573014d8a14461b3d1aac3f',
name: 'bob',
email: 'hello@world.com',
emails: [
{ email: 'stubb1@mit.edu', reversedHostname: this.host },
{ email: 'test@test.com', reversedHostname: 'test.com' },
{ email: 'another@mit.edu', reversedHostname: this.host },
],
}
this.stubbedUser1DecoratedEmails = [
{
email: 'stubb1@mit.edu',
reversedHostname: this.host,
samlIdentifier: { hasEntitlement: false },
},
{ email: 'test@test.com', reversedHostname: 'test.com' },
{
email: 'another@mit.edu',
reversedHostname: this.host,
samlIdentifier: { hasEntitlement: true },
},
]
this.stubbedUser2 = {
_id: '6573014d8a14461b3d1aac40',
name: 'test',
email: 'hello2@world.com',
emails: [{ email: 'subb2@mit.edu', reversedHostname: this.host }],
}
this.stubbedUser2DecoratedEmails = [
{
email: 'subb2@mit.edu',
reversedHostname: this.host,
},
]
this.getInstitutionUsersByHostname = sinon.stub().resolves([
{
_id: this.stubbedUser1._id,
emails: this.stubbedUser1DecoratedEmails,
},
{
_id: this.stubbedUser2._id,
emails: this.stubbedUser2DecoratedEmails,
},
])
this.UserGetter.promises.getInstitutionUsersByHostname =
this.getInstitutionUsersByHostname
})
describe('affiliateUsers', function () {
it('should add affiliations for matching users', async function () {
await this.InstitutionsManager.promises.affiliateUsers('mit.edu')
this.getInstitutionUsersByHostname.calledOnce.should.equal(true)
this.addAffiliationPromise.calledThrice.should.equal(true)
this.addAffiliationPromise
.calledWithMatch(
this.stubbedUser1._id,
this.stubbedUser1.emails[0].email,
{ entitlement: false }
)
.should.equal(true)
this.addAffiliationPromise
.calledWithMatch(
this.stubbedUser1._id,
this.stubbedUser1.emails[2].email,
{ entitlement: true }
)
.should.equal(true)
this.addAffiliationPromise
.calledWithMatch(
this.stubbedUser2._id,
this.stubbedUser2.emails[0].email,
{ entitlement: undefined }
)
.should.equal(true)
this.refreshFeaturesPromise
.calledWith(this.stubbedUser1._id)
.should.equal(true)
this.refreshFeaturesPromise
.calledWith(this.stubbedUser2._id)
.should.equal(true)
this.refreshFeaturesPromise.should.have.been.calledTwice
})
it('should return errors if last affiliation cannot be added', async function () {
this.addAffiliationPromise.onCall(2).rejects()
await expect(
this.InstitutionsManager.promises.affiliateUsers('mit.edu')
).to.be.rejected
this.getInstitutionUsersByHostname.calledOnce.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,81 @@
import { vi, expect } from 'vitest'
import sinon from 'sinon'
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionEmailHandler'
describe('SubscriptionEmailHandler', function () {
beforeEach(async function (ctx) {
ctx.userId = '123456789abcde'
ctx.email = 'test@test.com'
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
default: (ctx.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves({}),
},
}),
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: (ctx.UserGetter = {
promises: {
getUser: sinon
.stub()
.resolves({ _id: ctx.userId, email: 'test@test.com' }),
},
}),
}))
vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
default: (ctx.PlansLocator = {
findLocalPlanInSettings: sinon.stub().returns({
name: 'foo',
features: { collaborators: 42 },
}),
}),
}))
vi.doMock('@overleaf/settings', () => ({
default: (ctx.Settings = {
enableOnboardingEmails: true,
}),
}))
ctx.SubscriptionEmailHandler = (await import(modulePath)).default
})
describe('when onboarding emails are disabled', function () {
beforeEach(function (ctx) {
ctx.Settings.enableOnboardingEmails = false
})
it('does not send a trial onboarding email', async function (ctx) {
await ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail(
ctx.userId,
'foo-plan-code'
)
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
})
})
describe('when onboarding emails are enabled', function () {
it('sends trial onboarding email', async function (ctx) {
await ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail(
ctx.userId,
'foo-plan-code'
)
expect(ctx.PlansLocator.findLocalPlanInSettings).to.have.been.calledWith(
'foo-plan-code'
)
expect(ctx.EmailHandler.promises.sendEmail.lastCall.args).to.deep.equal([
'trialOnboarding',
{
to: ctx.email,
sendingUser_id: ctx.userId,
planName: 'foo',
features: { collaborators: 42 },
},
])
})
})
})

View File

@@ -1,73 +0,0 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionEmailHandler'
describe('SubscriptionEmailHandler', function () {
beforeEach(function () {
this.userId = '123456789abcde'
this.email = 'test@test.com'
this.SubscriptionEmailHandler = SandboxedModule.require(modulePath, {
requires: {
'../Email/EmailHandler': (this.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves({}),
},
}),
'../User/UserGetter': (this.UserGetter = {
promises: {
getUser: sinon
.stub()
.resolves({ _id: this.userId, email: 'test@test.com' }),
},
}),
'./PlansLocator': (this.PlansLocator = {
findLocalPlanInSettings: sinon.stub().returns({
name: 'foo',
features: { collaborators: 42 },
}),
}),
'@overleaf/settings': (this.Settings = {
enableOnboardingEmails: true,
}),
},
})
})
describe('when onboarding emails are disabled', function () {
beforeEach(function () {
this.Settings.enableOnboardingEmails = false
})
it('does not send a trial onboarding email', async function () {
await this.SubscriptionEmailHandler.sendTrialOnboardingEmail(
this.userId,
'foo-plan-code'
)
expect(this.EmailHandler.promises.sendEmail).to.not.have.been.called
})
})
describe('when onboarding emails are enabled', function () {
it('sends trial onboarding email', async function () {
await this.SubscriptionEmailHandler.sendTrialOnboardingEmail(
this.userId,
'foo-plan-code'
)
expect(this.PlansLocator.findLocalPlanInSettings).to.have.been.calledWith(
'foo-plan-code'
)
expect(this.EmailHandler.promises.sendEmail.lastCall.args).to.deep.equal([
'trialOnboarding',
{
to: this.email,
sendingUser_id: this.userId,
planName: 'foo',
features: { collaborators: 42 },
},
])
})
})
})

View File

@@ -1,7 +1,5 @@
const chai = require('chai')
const SubscriptionFormatters = require('../../../../app/src/Features/Subscription/SubscriptionFormatters')
const { expect } = chai
import { expect } from 'vitest'
import SubscriptionFormatters from '../../../../app/src/Features/Subscription/SubscriptionFormatters.mjs'
describe('SubscriptionFormatters', function () {
describe('formatDateTime', function () {

View File

@@ -0,0 +1,114 @@
import { vi, expect } from 'vitest'
import sinon from 'sinon'
const MODULE_PATH =
'../../../../app/src/Features/User/UserOnboardingEmailManager'
describe('UserOnboardingEmailManager', function () {
beforeEach(async function (ctx) {
ctx.fakeUserId = '123abc'
ctx.fakeUserEmail = 'frog@overleaf.com'
ctx.onboardingEmailsQueue = {
add: sinon.stub().resolves(),
process: callback => {
ctx.queueProcessFunction = callback
},
}
ctx.Queues = {
createScheduledJob: sinon.stub().resolves(),
}
ctx.UserGetter = {
promises: {
getUser: sinon.stub().resolves(null),
},
}
ctx.UserGetter.promises.getUser.withArgs({ _id: ctx.fakeUserId }).resolves({
_id: ctx.fakeUserId,
email: ctx.fakeUserEmail,
})
ctx.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}
ctx.UserUpdater = {
promises: {
updateUser: sinon.stub().resolves(),
},
}
vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
default: ctx.Queues,
}))
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
default: ctx.EmailHandler,
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
default: ctx.UserUpdater,
}))
vi.doMock('@overleaf/settings', () => ({
default: (ctx.Settings = {
enableOnboardingEmails: true,
}),
}))
ctx.UserOnboardingEmailManager = (await import(MODULE_PATH)).default
})
describe('scheduleOnboardingEmail', function () {
it('should schedule delayed job on queue', async function (ctx) {
await ctx.UserOnboardingEmailManager.scheduleOnboardingEmail({
_id: ctx.fakeUserId,
})
sinon.assert.calledWith(
ctx.Queues.createScheduledJob,
'emails-onboarding',
{ data: { userId: ctx.fakeUserId } },
24 * 60 * 60 * 1000
)
})
})
describe('sendOnboardingEmail', function () {
describe('when onboarding emails are disabled', function () {
beforeEach(function (ctx) {
ctx.Settings.enableOnboardingEmails = false
})
it('should not send onboarding email', async function (ctx) {
await ctx.UserOnboardingEmailManager.sendOnboardingEmail(ctx.fakeUserId)
expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
expect(ctx.UserUpdater.promises.updateUser).not.to.have.been.called
})
})
describe('when onboarding emails are enabled', function () {
it('should send onboarding email and update user', async function (ctx) {
await ctx.UserOnboardingEmailManager.sendOnboardingEmail(ctx.fakeUserId)
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'userOnboardingEmail',
{
to: ctx.fakeUserEmail,
}
)
expect(ctx.UserUpdater.promises.updateUser).to.have.been.calledWith(
ctx.fakeUserId,
{ $set: { onboardingEmailSentAt: sinon.match.date } }
)
})
it('should stop if user is not found', async function (ctx) {
await ctx.UserOnboardingEmailManager.sendOnboardingEmail({
data: { userId: 'deleted-user' },
})
expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
expect(ctx.UserUpdater.promises.updateUser).not.to.have.been.called
})
})
})
})

View File

@@ -1,112 +0,0 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/User/UserOnboardingEmailManager'
)
describe('UserOnboardingEmailManager', function () {
beforeEach(function () {
this.fakeUserId = '123abc'
this.fakeUserEmail = 'frog@overleaf.com'
this.onboardingEmailsQueue = {
add: sinon.stub().resolves(),
process: callback => {
this.queueProcessFunction = callback
},
}
this.Queues = {
createScheduledJob: sinon.stub().resolves(),
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(null),
},
}
this.UserGetter.promises.getUser
.withArgs({ _id: this.fakeUserId })
.resolves({
_id: this.fakeUserId,
email: this.fakeUserEmail,
})
this.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}
this.UserUpdater = {
promises: {
updateUser: sinon.stub().resolves(),
},
}
this.UserOnboardingEmailManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'../../infrastructure/Queues': this.Queues,
'../Email/EmailHandler': this.EmailHandler,
'./UserGetter': this.UserGetter,
'./UserUpdater': this.UserUpdater,
'@overleaf/settings': (this.Settings = {
enableOnboardingEmails: true,
}),
},
})
})
describe('scheduleOnboardingEmail', function () {
it('should schedule delayed job on queue', async function () {
await this.UserOnboardingEmailManager.scheduleOnboardingEmail({
_id: this.fakeUserId,
})
sinon.assert.calledWith(
this.Queues.createScheduledJob,
'emails-onboarding',
{ data: { userId: this.fakeUserId } },
24 * 60 * 60 * 1000
)
})
})
describe('sendOnboardingEmail', function () {
describe('when onboarding emails are disabled', function () {
beforeEach(function () {
this.Settings.enableOnboardingEmails = false
})
it('should not send onboarding email', async function () {
await this.UserOnboardingEmailManager.sendOnboardingEmail(
this.fakeUserId
)
expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
expect(this.UserUpdater.promises.updateUser).not.to.have.been.called
})
})
describe('when onboarding emails are enabled', function () {
it('should send onboarding email and update user', async function () {
await this.UserOnboardingEmailManager.sendOnboardingEmail(
this.fakeUserId
)
expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'userOnboardingEmail',
{
to: this.fakeUserEmail,
}
)
expect(this.UserUpdater.promises.updateUser).to.have.been.calledWith(
this.fakeUserId,
{ $set: { onboardingEmailSentAt: sinon.match.date } }
)
})
it('should stop if user is not found', async function () {
await this.UserOnboardingEmailManager.sendOnboardingEmail({
data: { userId: 'deleted-user' },
})
expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
expect(this.UserUpdater.promises.updateUser).not.to.have.been.called
})
})
})
})

View File

@@ -0,0 +1,125 @@
import { vi, expect } from 'vitest'
import sinon from 'sinon'
const MODULE_PATH =
'../../../../app/src/Features/User/UserPostRegistrationAnalyticsManager'
describe('UserPostRegistrationAnalyticsManager', function () {
beforeEach(async function (ctx) {
ctx.fakeUserId = '123abc'
ctx.Queues = {
createScheduledJob: sinon.stub().resolves(),
}
ctx.UserGetter = {
promises: {
getUser: sinon.stub().resolves(),
},
}
ctx.UserGetter.promises.getUser
.withArgs({ _id: ctx.fakeUserId })
.resolves({ _id: ctx.fakeUserId })
ctx.InstitutionsAPI = {
promises: {
getUserAffiliations: sinon.stub().resolves([]),
},
}
ctx.AnalyticsManager = {
setUserPropertyForUser: sinon.stub().resolves(),
}
vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
default: ctx.Queues,
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsAPI',
() => ({
default: ctx.InstitutionsAPI,
})
)
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: ctx.AnalyticsManager,
})
)
ctx.UserPostRegistrationAnalyticsManager = (
await import(MODULE_PATH)
).default
})
describe('schedulePostRegistrationAnalytics', function () {
it('should schedule delayed job on queue', async function (ctx) {
await ctx.UserPostRegistrationAnalyticsManager.schedulePostRegistrationAnalytics(
{
_id: ctx.fakeUserId,
}
)
sinon.assert.calledWith(
ctx.Queues.createScheduledJob,
'post-registration-analytics',
{ data: { userId: ctx.fakeUserId } },
24 * 60 * 60 * 1000
)
})
})
describe('postRegistrationAnalytics', function () {
it('stops without errors if user is not found', async function (ctx) {
await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
'not-a-user'
)
expect(ctx.InstitutionsAPI.promises.getUserAffiliations).not.to.have.been
.called
expect(ctx.AnalyticsManager.setUserPropertyForUser).not.to.have.been
.called
})
it('sets user property if user has commons account affiliationd', async function (ctx) {
ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([
{},
{
institution: {
commonsAccount: true,
},
},
{
institution: {
commonsAccount: false,
},
},
])
await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
ctx.fakeUserId
)
expect(
ctx.AnalyticsManager.setUserPropertyForUser
).to.have.been.calledWith(
ctx.fakeUserId,
'registered-from-commons-account',
true
)
})
it('does not set user property if user has no commons account affiliation', async function (ctx) {
ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([
{
institution: {
commonsAccount: false,
},
},
])
await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
ctx.fakeUserId
)
expect(ctx.AnalyticsManager.setUserPropertyForUser).not.to.have.been
.called
})
})
})

View File

@@ -1,114 +0,0 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/User/UserPostRegistrationAnalyticsManager'
)
describe('UserPostRegistrationAnalyticsManager', function () {
beforeEach(function () {
this.fakeUserId = '123abc'
this.Queues = {
createScheduledJob: sinon.stub().resolves(),
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(),
},
}
this.UserGetter.promises.getUser
.withArgs({ _id: this.fakeUserId })
.resolves({ _id: this.fakeUserId })
this.InstitutionsAPI = {
promises: {
getUserAffiliations: sinon.stub().resolves([]),
},
}
this.AnalyticsManager = {
setUserPropertyForUser: sinon.stub().resolves(),
}
this.UserPostRegistrationAnalyticsManager = SandboxedModule.require(
MODULE_PATH,
{
requires: {
'../../infrastructure/Queues': this.Queues,
'./UserGetter': this.UserGetter,
'../Institutions/InstitutionsAPI': this.InstitutionsAPI,
'../Analytics/AnalyticsManager': this.AnalyticsManager,
},
}
)
})
describe('schedulePostRegistrationAnalytics', function () {
it('should schedule delayed job on queue', async function () {
await this.UserPostRegistrationAnalyticsManager.schedulePostRegistrationAnalytics(
{
_id: this.fakeUserId,
}
)
sinon.assert.calledWith(
this.Queues.createScheduledJob,
'post-registration-analytics',
{ data: { userId: this.fakeUserId } },
24 * 60 * 60 * 1000
)
})
})
describe('postRegistrationAnalytics', function () {
it('stops without errors if user is not found', async function () {
await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
'not-a-user'
)
expect(this.InstitutionsAPI.promises.getUserAffiliations).not.to.have.been
.called
expect(this.AnalyticsManager.setUserPropertyForUser).not.to.have.been
.called
})
it('sets user property if user has commons account affiliationd', async function () {
this.InstitutionsAPI.promises.getUserAffiliations.resolves([
{},
{
institution: {
commonsAccount: true,
},
},
{
institution: {
commonsAccount: false,
},
},
])
await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
this.fakeUserId
)
expect(
this.AnalyticsManager.setUserPropertyForUser
).to.have.been.calledWith(
this.fakeUserId,
'registered-from-commons-account',
true
)
})
it('does not set user property if user has no commons account affiliation', async function () {
this.InstitutionsAPI.promises.getUserAffiliations.resolves([
{
institution: {
commonsAccount: false,
},
},
])
await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
this.fakeUserId
)
expect(this.AnalyticsManager.setUserPropertyForUser).not.to.have.been
.called
})
})
})

View File

@@ -1,25 +1,18 @@
import { expect, vi } from 'vitest'
import sinon from 'sinon'
import MockRequest from '../helpers/MockRequest.js'
import MockResponse from '../helpers/MockResponse.js'
import { expect, vi, describe, it, beforeEach } from 'vitest'
import MockRequest from '../helpers/MockRequestVitest.mjs'
import MockResponse from '../helpers/MockResponseVitest.mjs'
import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import {
UserIsManagerError,
UserNotFoundError,
UserAlreadyAddedError,
} from '../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
const assertCalledWith = sinon.assert.calledWith
import UserMembershipErrors from '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
const modulePath =
'../../../../app/src/Features/UserMembership/UserMembershipController.mjs'
vi.mock(
'../../../../app/src/Features/UserMembership/UserMembershipErrors.js',
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs',
() =>
vi.importActual(
'../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
)
)
@@ -27,9 +20,9 @@ vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('UserMembershipController', function () {
beforeEach(async function (ctx) {
ctx.req = new MockRequest()
describe('UserMembershipController', () => {
beforeEach(async ctx => {
ctx.req = new MockRequest(vi)
ctx.req.params.id = 'mock-entity-id'
ctx.user = { _id: 'mock-user-id' }
ctx.newUser = { _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' }
@@ -37,16 +30,16 @@ describe('UserMembershipController', function () {
_id: 'mock-subscription-id',
admin_id: 'mock-admin-id',
manager_ids: ['mock-admin-id'],
fetchV1Data: callback => callback(null, ctx.subscription),
fetchV1Data: vi.fn(callback => callback(null, ctx.subscription)),
}
ctx.institution = {
_id: 'mock-institution-id',
v1Id: 123,
fetchV1Data: callback => {
const institution = Object.assign({}, ctx.institution)
fetchV1Data: vi.fn(callback => {
const institution = { ...ctx.institution }
institution.name = 'Test Institution Name'
callback(null, institution)
},
}),
managerIds: ['mock-member-id-1'],
}
ctx.users = [
@@ -113,45 +106,51 @@ describe('UserMembershipController', function () {
}
ctx.SessionManager = {
getSessionUser: sinon.stub().returns(ctx.user),
getLoggedInUserId: sinon.stub().returns(ctx.user._id),
getSessionUser: vi.fn().mockReturnValue(ctx.user),
getLoggedInUserId: vi.fn().mockReturnValue(ctx.user._id),
}
ctx.SSOConfig = {
findById: sinon
.stub()
.returns({ exec: sinon.stub().resolves({ enabled: true }) }),
findById: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue({ enabled: true }),
}),
}
ctx.UserMembershipHandler = {
getEntity: sinon.stub().yields(null, ctx.subscription),
createEntity: sinon.stub().yields(null, ctx.institution),
getUsers: sinon.stub().yields(null, ctx.users),
addUser: sinon.stub().yields(null, ctx.newUser),
removeUser: sinon.stub().yields(null),
getEntity: vi.fn((_entity, _options, callback) =>
callback(null, ctx.subscription)
),
createEntity: vi.fn((_entity, _options, callback) =>
callback(null, ctx.institution)
),
getUsers: vi.fn((_entity, _options, callback) =>
callback(null, ctx.users)
),
addUser: vi.fn((_entity, _options, _email, callback) =>
callback(null, ctx.newUser)
),
removeUser: vi.fn((_entity, _options, _userId, callback) =>
callback(null)
),
promises: {
getUsers: sinon.stub().resolves(ctx.users),
getUsers: vi.fn().mockResolvedValue(ctx.users),
addUser: vi.fn().mockResolvedValue(ctx.newUser),
removeUser: vi.fn().mockResolvedValue(),
createEntity: vi.fn().mockResolvedValue(ctx.institution),
},
}
ctx.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
getAssignment: vi.fn().mockResolvedValue({ variant: 'default' }),
},
getAssignment: sinon.stub().yields(null, { variant: 'default' }),
getAssignment: vi.fn((_testName, _userId, callback) =>
callback(null, { variant: 'default' })
),
}
ctx.RecurlyClient = {
promises: {
getSubscription: sinon.stub().resolves({}),
getSubscription: vi.fn().mockResolvedValue({}),
},
}
vi.doMock(
'../../../../app/src/Features/UserMembership/UserMembershipErrors',
() => ({
UserIsManagerError,
UserNotFoundError,
UserAlreadyAddedError,
})
)
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
@@ -191,7 +190,7 @@ describe('UserMembershipController', function () {
ctx.Modules = {
promises: {
hooks: {
fire: sinon.stub(),
fire: vi.fn(),
},
},
}
@@ -202,55 +201,90 @@ describe('UserMembershipController', function () {
ctx.UserMembershipController = (await import(modulePath)).default
})
describe('index', function () {
beforeEach(function (ctx) {
describe('index', () => {
beforeEach(ctx => {
ctx.req.user = ctx.user
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.group
ctx.Modules.promises.hooks.fire.resolves([])
ctx.Modules.promises.hooks.fire.mockResolvedValue([])
})
it('get users', async function (ctx) {
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
it('get users', async ({
UserMembershipController,
req,
UserMembershipHandler,
subscription,
}) => {
expect.assertions(1)
await UserMembershipController.manageGroupMembers(req, {
render: () => {
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.promises.getUsers,
ctx.subscription,
{ modelName: 'Subscription' }
expect(UserMembershipHandler.promises.getUsers).toHaveBeenCalledWith(
subscription,
{
modelName: 'Subscription',
baseQuery: { groupPlan: true },
fields: {
access: 'manager_ids',
membership: 'member_ids',
name: 'teamName',
primaryKey: '_id',
read: ['invited_emails', 'teamInvites', 'member_ids'],
write: null,
},
hasMembersLimit: true,
readOnly: true,
}
)
},
})
})
it('render group view', async function (ctx) {
ctx.subscription.managedUsersEnabled = false
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
it('render group view', async ({
UserMembershipController,
req,
subscription,
users,
}) => {
expect.assertions(4)
subscription.managedUsersEnabled = false
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-members-react')
expect(viewParams.users).to.deep.equal(ctx.users)
expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
expect(viewParams.users).to.deep.equal(users)
expect(viewParams.groupSize).to.equal(subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(false)
},
})
})
it('render group view with managed users', async function (ctx) {
ctx.subscription.managedUsersEnabled = true
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
it('render group view with managed users', async ({
UserMembershipController,
req,
subscription,
users,
}) => {
expect.assertions(5)
subscription.managedUsersEnabled = true
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-members-react')
expect(viewParams.users).to.deep.equal(ctx.users)
expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
expect(viewParams.users).to.deep.equal(users)
expect(viewParams.groupSize).to.equal(subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(true)
expect(viewParams.isUserGroupManager).to.equal(false)
},
})
})
it('render group managers view', async function (ctx) {
ctx.req.user = ctx.user
ctx.req.entityConfig = EntityConfigs.groupManagers
await ctx.UserMembershipController.manageGroupManagers(ctx.req, {
it('render group managers view', async ({
UserMembershipController,
req,
user,
}) => {
expect.assertions(2)
req.user = user
req.entityConfig = EntityConfigs.groupManagers
await UserMembershipController.manageGroupManagers(req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-managers-react')
expect(viewParams.groupSize).to.equal(undefined)
@@ -258,11 +292,17 @@ describe('UserMembershipController', function () {
})
})
it('render institution view', async function (ctx) {
ctx.req.user = ctx.user
ctx.req.entity = ctx.institution
ctx.req.entityConfig = EntityConfigs.institution
await ctx.UserMembershipController.manageInstitutionManagers(ctx.req, {
it('render institution view', async ({
UserMembershipController,
req,
user,
institution,
}) => {
expect.assertions(3)
req.user = user
req.entity = institution
req.entityConfig = EntityConfigs.institution
await UserMembershipController.manageInstitutionManagers(req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal(
'user_membership/institution-managers-react'
@@ -274,294 +314,354 @@ describe('UserMembershipController', function () {
})
})
describe('add', function () {
beforeEach(function (ctx) {
describe('add', () => {
beforeEach(ctx => {
ctx.req.body.email = ctx.newUser.email
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
})
it('add user', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.add(ctx.req, {
json: () => {
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.addUser,
ctx.subscription,
{ modelName: 'Subscription' },
ctx.newUser.email
)
resolve()
},
})
it('add user', async ({
UserMembershipController,
req,
UserMembershipHandler,
subscription,
newUser,
}) => {
expect.assertions(1)
await UserMembershipController.add(req, {
json: () => {
expect(UserMembershipHandler.promises.addUser).toHaveBeenCalledWith(
subscription,
{
modelName: 'Subscription',
baseQuery: { groupPlan: true },
fields: {
access: 'manager_ids',
membership: 'member_ids',
name: 'teamName',
primaryKey: '_id',
read: ['manager_ids'],
write: 'manager_ids',
},
},
newUser.email
)
},
})
})
it('return user object', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.add(ctx.req, {
json: payload => {
payload.user.should.equal(ctx.newUser)
resolve()
},
})
it('return user object', async ({
UserMembershipController,
req,
newUser,
}) => {
expect.assertions(1)
await UserMembershipController.add(req, {
json: payload => {
expect(payload.user).to.equal(newUser)
},
})
})
it('handle readOnly entity', async function (ctx) {
await new Promise(resolve => {
ctx.req.entityConfig = EntityConfigs.group
ctx.UserMembershipController.add(ctx.req, null, error => {
expect(error).to.exist
expect(error).to.be.an.instanceof(Errors.NotFoundError)
resolve()
})
it('handle readOnly entity', async ({ UserMembershipController, req }) => {
expect.assertions(2)
req.entityConfig = EntityConfigs.group
await UserMembershipController.add(req, null, error => {
expect(error).to.exist
expect(error).to.be.an.instanceof(Errors.NotFoundError)
})
})
it('handle user already added', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError())
ctx.UserMembershipController.add(ctx.req, {
it('handle user already added', async ({
UserMembershipController,
req,
UserMembershipHandler,
}) => {
expect.assertions(1)
UserMembershipHandler.promises.addUser.mockRejectedValue(
new UserMembershipErrors.UserAlreadyAddedError()
)
await UserMembershipController.add(
req,
{
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('user_already_added')
resolve()
},
}),
})
},
() => {}
)
})
it('handle user not found', async ({
UserMembershipController,
req,
UserMembershipHandler,
}) => {
expect.assertions(1)
UserMembershipHandler.promises.addUser.mockRejectedValue(
new UserMembershipErrors.UserNotFoundError()
)
await UserMembershipController.add(req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('user_not_found')
},
}),
})
})
it('handle user not found', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipHandler.addUser.yields(new UserNotFoundError())
ctx.UserMembershipController.add(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('user_not_found')
resolve()
},
}),
})
})
})
it('handle invalid email', async function (ctx) {
await new Promise(resolve => {
ctx.req.body.email = 'not_valid_email'
ctx.UserMembershipController.add(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('invalid_email')
resolve()
},
}),
})
it('handle invalid email', async ({ UserMembershipController, req }) => {
expect.assertions(1)
req.body.email = 'not_valid_email'
await UserMembershipController.add(req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('invalid_email')
},
}),
})
})
})
describe('remove', function () {
beforeEach(function (ctx) {
describe('remove', () => {
beforeEach(ctx => {
ctx.req.params.userId = ctx.newUser._id
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
})
it('remove user', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.remove(ctx.req, {
sendStatus: () => {
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.removeUser,
ctx.subscription,
{ modelName: 'Subscription' },
ctx.newUser._id
)
resolve()
it('remove user', async ({
UserMembershipController,
req,
UserMembershipHandler,
subscription,
newUser,
}) => {
expect.assertions(1)
await UserMembershipController.remove(req, {
sendStatus: () => {
expect(
UserMembershipHandler.promises.removeUser
).toHaveBeenCalledWith(
subscription,
{
modelName: 'Subscription',
baseQuery: {
groupPlan: true,
},
fields: {
access: 'manager_ids',
membership: 'member_ids',
name: 'teamName',
primaryKey: '_id',
read: ['manager_ids'],
write: 'manager_ids',
},
},
newUser._id
)
},
})
})
it('handle readOnly entity', async ({ UserMembershipController, req }) => {
expect.assertions(2)
req.entityConfig = EntityConfigs.group
await UserMembershipController.remove(req, null, error => {
expect(error).to.exist
expect(error).to.be.an.instanceof(Errors.NotFoundError)
})
})
it('prevent self removal', async ({
UserMembershipController,
req,
user,
}) => {
expect.assertions(1)
req.params.userId = user._id
await UserMembershipController.remove(req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('managers_cannot_remove_self')
},
})
}),
})
})
it('handle readOnly entity', async function (ctx) {
await new Promise(resolve => {
ctx.req.entityConfig = EntityConfigs.group
ctx.UserMembershipController.remove(ctx.req, null, error => {
expect(error).to.exist
expect(error).to.be.an.instanceof(Errors.NotFoundError)
resolve()
})
})
})
it('prevent self removal', async function (ctx) {
await new Promise(resolve => {
ctx.req.params.userId = ctx.user._id
ctx.UserMembershipController.remove(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('managers_cannot_remove_self')
resolve()
},
}),
})
})
})
it('prevent admin removal', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipHandler.removeUser.yields(new UserIsManagerError())
ctx.UserMembershipController.remove(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal(
'managers_cannot_remove_admin'
)
resolve()
},
}),
})
it('prevent admin removal', async ({
UserMembershipController,
req,
UserMembershipHandler,
}) => {
expect.assertions(1)
UserMembershipHandler.promises.removeUser.mockRejectedValue(
new UserMembershipErrors.UserIsManagerError()
)
await UserMembershipController.remove(req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('managers_cannot_remove_admin')
},
}),
})
})
})
describe('exportCsv', function () {
beforeEach(function (ctx) {
describe('exportCsv', () => {
beforeEach(ctx => {
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.res = new MockResponse()
ctx.res = new MockResponse(vi)
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('get users', function (ctx) {
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.promises.getUsers,
ctx.subscription,
{ modelName: 'Subscription' }
it('get users', ({ UserMembershipHandler, subscription }) => {
expect(UserMembershipHandler.promises.getUsers).toHaveBeenCalledWith(
subscription,
{
modelName: 'Subscription',
baseQuery: { groupPlan: true },
fields: {
access: 'manager_ids',
membership: 'member_ids',
name: 'teamName',
primaryKey: '_id',
read: ['manager_ids'],
write: 'manager_ids',
},
}
)
})
it('should set the correct content type on the request', function (ctx) {
assertCalledWith(ctx.res.contentType, 'text/csv; charset=utf-8')
it('should set the correct content type on the request', ({ res }) => {
expect(res.contentType).toHaveBeenCalledWith('text/csv; charset=utf-8')
})
it('should name the exported csv file', function (ctx) {
assertCalledWith(
ctx.res.header,
it('should name the exported csv file', ({ res }) => {
expect(res.header).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="Group.csv"'
)
})
it('should export the correct csv', function (ctx) {
assertCalledWith(
ctx.res.send,
it('should export the correct csv', ({ res }) => {
expect(res.send).toHaveBeenCalledWith(
'"email","last_logged_in_at","last_active_at"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z"\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z"\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z"\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z"\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z"\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z"'
)
})
})
describe('exportCsv when group is managed', function () {
beforeEach(function (ctx) {
describe('exportCsv when group is managed', () => {
beforeEach(ctx => {
ctx.req.entity = Object.assign(
{ managedUsersEnabled: true },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.res = new MockResponse()
ctx.res = new MockResponse(vi)
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', function (ctx) {
assertCalledWith(
ctx.res.send,
it('should export the correct csv', ({ res }) => {
expect(res.send).toHaveBeenCalledWith(
'"email","last_logged_in_at","last_active_at","managed"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true'
)
})
})
describe('exportCsv when group has SSO', function () {
beforeEach(function (ctx) {
describe('exportCsv when group has SSO', () => {
beforeEach(ctx => {
ctx.req.entity = Object.assign(
{ ssoConfig: 'sso-config-id' },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.Modules.promises.hooks.fire.resolves([true])
ctx.res = new MockResponse()
ctx.Modules.promises.hooks.fire.mockResolvedValue([true])
ctx.res = new MockResponse(vi)
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', function (ctx) {
assertCalledWith(
ctx.res.send,
it('should export the correct csv', ({ res }) => {
expect(res.send).toHaveBeenCalledWith(
'"email","last_logged_in_at","last_active_at","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true'
)
})
})
describe('exportCsv when group has SSO and managed users enabled', function () {
beforeEach(function (ctx) {
describe('exportCsv when group has SSO and managed users enabled', () => {
beforeEach(ctx => {
ctx.req.entity = Object.assign(
{ managedUsersEnabled: true },
{ ssoConfig: 'sso-config-id' },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.Modules.promises.hooks.fire.resolves([true])
ctx.res = new MockResponse()
ctx.Modules.promises.hooks.fire.mockResolvedValue([true])
ctx.res = new MockResponse(vi)
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', function (ctx) {
assertCalledWith(
ctx.res.send,
it('should export the correct csv', ({ res }) => {
expect(res.send).toHaveBeenCalledWith(
'"email","last_logged_in_at","last_active_at","managed","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false,false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false,false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false,false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true,false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false,true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true,true'
)
})
})
describe('new', function () {
beforeEach(function (ctx) {
describe('new', () => {
beforeEach(ctx => {
ctx.req.params.name = 'publisher'
ctx.req.params.id = 'abc'
})
it('renders view', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.new(ctx.req, {
render: (viewPath, data) => {
expect(data.entityName).to.eq('publisher')
expect(data.entityId).to.eq('abc')
resolve()
},
})
it('renders view', async ({ UserMembershipController, req }) => {
expect.assertions(2)
await UserMembershipController.new(req, {
render: (viewPath, data) => {
expect(data.entityName).to.eq('publisher')
expect(data.entityId).to.eq('abc')
},
})
})
})
describe('create', function () {
beforeEach(function (ctx) {
describe('create', () => {
beforeEach(ctx => {
ctx.req.params.name = 'institution'
ctx.req.entityConfig = EntityConfigs.institution
ctx.req.params.id = 123
})
it('creates institution', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.create(ctx.req, {
redirect: path => {
expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.createEntity,
123,
{ modelName: 'Institution' }
)
resolve()
},
})
it('creates institution', async ({
UserMembershipController,
req,
UserMembershipHandler,
}) => {
expect.assertions(2)
await UserMembershipController.create(req, {
redirect: path => {
expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
expect(
UserMembershipHandler.promises.createEntity
).toHaveBeenCalledWith(123, {
fields: {
access: 'managerIds',
membership: 'member_ids',
name: 'name',
primaryKey: 'v1Id',
read: ['managerIds'],
write: 'managerIds',
},
modelName: 'Institution',
pathsFor: EntityConfigs.institution.pathsFor,
})
},
})
})
})

View File

@@ -0,0 +1,287 @@
import { vi, expect } from 'vitest'
import mongodb from 'mongodb-legacy'
import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
import UserMembershipErrors from '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
const { ObjectId } = mongodb
const modulePath =
'../../../../app/src/Features/UserMembership/UserMembershipHandler'
const serializeIds = ids =>
ids.map(id => (id instanceof ObjectId ? `objectId-${id.toString()}` : id))
vi.mock(
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs',
() =>
vi.importActual(
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
)
)
describe('UserMembershipHandler', function () {
beforeEach(async function (ctx) {
ctx.user = { _id: new ObjectId() }
ctx.newUser = { _id: new ObjectId(), email: 'new-user-email@foo.bar' }
ctx.fakeEntityId = new ObjectId()
ctx.subscription = {
_id: 'mock-subscription-id',
groupPlan: true,
membersLimit: 10,
member_ids: [new ObjectId(), new ObjectId()],
manager_ids: [new ObjectId()],
invited_emails: ['mock-email-1@foo.com'],
teamInvites: [{ email: 'mock-email-1@bar.com' }],
update: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue(),
}),
}
ctx.institution = {
_id: 'mock-institution-id',
v1Id: 123,
managerIds: [new ObjectId(), new ObjectId(), new ObjectId()],
updateOne: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue(),
}),
}
ctx.publisher = {
_id: 'mock-publisher-id',
slug: 'slug',
managerIds: [new ObjectId(), new ObjectId()],
updateOne: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue(),
}),
}
ctx.UserMembershipViewModel = {
promises: {
buildAsync: vi.fn().mockResolvedValue([{ _id: 'mock-member-id' }]),
},
build: vi.fn().mockReturnValue(ctx.newUser),
}
ctx.UserGetter = {
promises: {
getUserByAnyEmail: vi.fn().mockResolvedValue(ctx.newUser),
},
}
ctx.Institution = {
findOne: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue(ctx.institution),
}),
}
ctx.Subscription = {
findOne: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue(ctx.subscription),
}),
}
ctx.Publisher = {
findOne: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue(ctx.publisher),
}),
create: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue(ctx.publisher),
}),
}
vi.doMock('mongodb-legacy', () => ({
default: { ObjectId },
}))
vi.doMock(
'../../../../app/src/Features/UserMembership/UserMembershipViewModel',
() => ({
default: ctx.UserMembershipViewModel,
})
)
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock('../../../../app/src/models/Institution', () => ({
Institution: ctx.Institution,
}))
vi.doMock('../../../../app/src/models/Subscription', () => ({
Subscription: ctx.Subscription,
}))
vi.doMock('../../../../app/src/models/Publisher', () => ({
Publisher: ctx.Publisher,
}))
ctx.UserMembershipHandler = (await import(modulePath)).default
})
describe('getEntityWithoutAuthorizationCheck', function () {
it('get publisher', async function (ctx) {
const subscription =
await ctx.UserMembershipHandler.promises.getEntityWithoutAuthorizationCheck(
ctx.fakeEntityId,
EntityConfigs.publisher
)
const expectedQuery = { slug: ctx.fakeEntityId }
expect(ctx.Publisher.findOne).toHaveBeenCalledWith(expectedQuery)
expect(subscription).to.equal(ctx.publisher)
})
})
describe('getUsers', function () {
describe('group', function () {
it('build view model for all users', async function (ctx) {
await ctx.UserMembershipHandler.promises.getUsers(
ctx.subscription,
EntityConfigs.group
)
expect(
serializeIds(
ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
)
).toEqual(
serializeIds(
ctx.subscription.invited_emails.concat(
ctx.subscription.teamInvites[0].email,
ctx.subscription.member_ids
)
)
)
})
})
describe('group managers', function () {
it('build view model for all managers', async function (ctx) {
await ctx.UserMembershipHandler.promises.getUsers(
ctx.subscription,
EntityConfigs.groupManagers
)
expect(
serializeIds(
ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
)
).toEqual(serializeIds(ctx.subscription.manager_ids))
})
})
describe('institution', function () {
it('build view model for all managers', async function (ctx) {
await ctx.UserMembershipHandler.promises.getUsers(
ctx.institution,
EntityConfigs.institution
)
expect(
serializeIds(
ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
)
).toEqual(serializeIds(ctx.institution.managerIds))
})
})
})
describe('createEntity', function () {
it('creates publisher', async function (ctx) {
await ctx.UserMembershipHandler.promises.createEntity(
ctx.fakeEntityId,
EntityConfigs.publisher
)
expect(ctx.Publisher.create).toHaveBeenCalledWith({
slug: ctx.fakeEntityId,
})
})
})
describe('addUser', function () {
beforeEach(function (ctx) {
ctx.email = ctx.newUser.email
})
describe('institution', function () {
it('get user', async function (ctx) {
await ctx.UserMembershipHandler.promises.addUser(
ctx.institution,
EntityConfigs.institution,
ctx.email
)
expect(ctx.UserGetter.promises.getUserByAnyEmail).toHaveBeenCalledWith(
ctx.email
)
})
it('handle user not found', async function (ctx) {
ctx.UserGetter.promises.getUserByAnyEmail.mockResolvedValue(null)
try {
await ctx.UserMembershipHandler.promises.addUser(
ctx.institution,
EntityConfigs.institution,
ctx.email
)
expect.fail('Expected addUser to throw')
} catch (err) {
expect(err).toBeInstanceOf(UserMembershipErrors.UserNotFoundError)
}
})
it('handle user already added', async function (ctx) {
ctx.institution.managerIds.push(ctx.newUser._id)
try {
await ctx.UserMembershipHandler.promises.addUser(
ctx.institution,
EntityConfigs.institution,
ctx.email
)
expect.fail('Expected addUser to throw')
} catch (err) {
expect(err).toBeInstanceOf(UserMembershipErrors.UserAlreadyAddedError)
}
})
it('add user to institution', async function (ctx) {
await ctx.UserMembershipHandler.promises.addUser(
ctx.institution,
EntityConfigs.institution,
ctx.email
)
expect(ctx.institution.updateOne).toHaveBeenCalledWith({
$addToSet: { managerIds: ctx.newUser._id },
})
})
it('return user view', async function (ctx) {
const user = await ctx.UserMembershipHandler.promises.addUser(
ctx.institution,
EntityConfigs.institution,
ctx.email
)
expect(user).to.equal(ctx.newUser)
})
})
})
describe('removeUser', function () {
describe('institution', function () {
it('remove user from institution', async function (ctx) {
await ctx.UserMembershipHandler.promises.removeUser(
ctx.institution,
EntityConfigs.institution,
ctx.newUser._id
)
expect(ctx.institution.updateOne).toHaveBeenCalledWith({
$pull: { managerIds: ctx.newUser._id },
})
})
it('handle admin', async function (ctx) {
ctx.subscription.admin_id = ctx.newUser._id
try {
await ctx.UserMembershipHandler.promises.removeUser(
ctx.subscription,
EntityConfigs.groupManagers,
ctx.newUser._id
)
expect.fail('Expected removeUser to throw')
} catch (err) {
expect(err).toBeInstanceOf(UserMembershipErrors.UserIsManagerError)
}
})
})
})
})

View File

@@ -1,251 +0,0 @@
const { expect } = require('chai')
const sinon = require('sinon')
const assertCalledWith = sinon.assert.calledWith
const { ObjectId } = require('mongodb-legacy')
const modulePath =
'../../../../app/src/Features/UserMembership/UserMembershipHandler'
const SandboxedModule = require('sandboxed-module')
const EntityConfigs = require('../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs')
const {
UserIsManagerError,
UserNotFoundError,
UserAlreadyAddedError,
} = require('../../../../app/src/Features/UserMembership/UserMembershipErrors')
describe('UserMembershipHandler', function () {
beforeEach(function () {
this.user = { _id: new ObjectId() }
this.newUser = { _id: new ObjectId(), email: 'new-user-email@foo.bar' }
this.fakeEntityId = new ObjectId()
this.subscription = {
_id: 'mock-subscription-id',
groupPlan: true,
membersLimit: 10,
member_ids: [new ObjectId(), new ObjectId()],
manager_ids: [new ObjectId()],
invited_emails: ['mock-email-1@foo.com'],
teamInvites: [{ email: 'mock-email-1@bar.com' }],
update: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
}
this.institution = {
_id: 'mock-institution-id',
v1Id: 123,
managerIds: [new ObjectId(), new ObjectId(), new ObjectId()],
updateOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
}
this.publisher = {
_id: 'mock-publisher-id',
slug: 'slug',
managerIds: [new ObjectId(), new ObjectId()],
updateOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
}
this.UserMembershipViewModel = {
promises: {
buildAsync: sinon.stub().resolves([{ _id: 'mock-member-id' }]),
},
build: sinon.stub().returns(this.newUser),
}
this.UserGetter = {
promises: {
getUserByAnyEmail: sinon.stub().resolves(this.newUser),
},
}
this.Institution = {
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(this.institution),
}),
}
this.Subscription = {
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(this.subscription),
}),
}
this.Publisher = {
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(this.publisher),
}),
create: sinon.stub().returns({
exec: sinon.stub().resolves(this.publisher),
}),
}
this.UserMembershipHandler = SandboxedModule.require(modulePath, {
requires: {
'mongodb-legacy': { ObjectId },
'./UserMembershipErrors': {
UserIsManagerError,
UserNotFoundError,
UserAlreadyAddedError,
},
'./UserMembershipViewModel': this.UserMembershipViewModel,
'../User/UserGetter': this.UserGetter,
'../../models/Institution': {
Institution: this.Institution,
},
'../../models/Subscription': {
Subscription: this.Subscription,
},
'../../models/Publisher': {
Publisher: this.Publisher,
},
},
})
})
describe('getEntityWithoutAuthorizationCheck', function () {
it('get publisher', async function () {
const subscription =
await this.UserMembershipHandler.promises.getEntityWithoutAuthorizationCheck(
this.fakeEntityId,
EntityConfigs.publisher
)
const expectedQuery = { slug: this.fakeEntityId }
assertCalledWith(this.Publisher.findOne, expectedQuery)
expect(subscription).to.equal(this.publisher)
})
})
describe('getUsers', function () {
describe('group', function () {
it('build view model for all users', async function () {
await this.UserMembershipHandler.promises.getUsers(
this.subscription,
EntityConfigs.group
)
expect(
this.UserMembershipViewModel.promises.buildAsync
).to.be.calledOnceWith(
this.subscription.invited_emails.concat(
this.subscription.teamInvites[0].email,
this.subscription.member_ids
)
)
})
})
describe('group managers', function () {
it('build view model for all managers', async function () {
await this.UserMembershipHandler.promises.getUsers(
this.subscription,
EntityConfigs.groupManagers
)
expect(
this.UserMembershipViewModel.promises.buildAsync
).to.be.calledOnceWith(this.subscription.manager_ids)
})
})
describe('institution', function () {
it('build view model for all managers', async function () {
await this.UserMembershipHandler.promises.getUsers(
this.institution,
EntityConfigs.institution
)
expect(
this.UserMembershipViewModel.promises.buildAsync
).to.be.calledOnceWith(this.institution.managerIds)
})
})
})
describe('createEntity', function () {
it('creates publisher', async function () {
await this.UserMembershipHandler.promises.createEntity(
this.fakeEntityId,
EntityConfigs.publisher
)
assertCalledWith(this.Publisher.create, { slug: this.fakeEntityId })
})
})
describe('addUser', function () {
beforeEach(function () {
this.email = this.newUser.email
})
describe('institution', function () {
it('get user', async function () {
await this.UserMembershipHandler.promises.addUser(
this.institution,
EntityConfigs.institution,
this.email
)
assertCalledWith(this.UserGetter.promises.getUserByAnyEmail, this.email)
})
it('handle user not found', async function () {
this.UserGetter.promises.getUserByAnyEmail.resolves(null)
expect(
this.UserMembershipHandler.promises.addUser(
this.institution,
EntityConfigs.institution,
this.email
)
).to.be.rejectedWith(UserNotFoundError)
})
it('handle user already added', async function () {
this.institution.managerIds.push(this.newUser._id)
expect(
this.UserMembershipHandler.promises.addUser(
this.institution,
EntityConfigs.institution,
this.email
)
).to.be.rejectedWith(UserAlreadyAddedError)
})
it('add user to institution', async function () {
await this.UserMembershipHandler.promises.addUser(
this.institution,
EntityConfigs.institution,
this.email
)
assertCalledWith(this.institution.updateOne, {
$addToSet: { managerIds: this.newUser._id },
})
})
it('return user view', async function () {
const user = await this.UserMembershipHandler.promises.addUser(
this.institution,
EntityConfigs.institution,
this.email
)
user.should.equal(this.newUser)
})
})
})
describe('removeUser', function () {
describe('institution', function () {
it('remove user from institution', async function () {
await this.UserMembershipHandler.promises.removeUser(
this.institution,
EntityConfigs.institution,
this.newUser._id
)
assertCalledWith(this.institution.updateOne, {
$pull: { managerIds: this.newUser._id },
})
})
it('handle admin', async function () {
this.subscription.admin_id = this.newUser._id
expect(
this.UserMembershipHandler.promises.removeUser(
this.subscription,
EntityConfigs.groupManagers,
this.newUser._id
)
).to.be.rejectedWith(UserIsManagerError)
})
})
})
})

View File

@@ -0,0 +1,128 @@
import { vi, expect } from 'vitest'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import {
isObjectIdInstance,
normalizeQuery,
} from '../../../../app/src/Features/Helpers/Mongo.js'
const assertCalledWith = sinon.assert.calledWith
const assertNotCalled = sinon.assert.notCalled
const { ObjectId } = mongodb
const modulePath =
'../../../../app/src/Features/UserMembership/UserMembershipViewModel'
describe('UserMembershipViewModel', function () {
beforeEach(async function (ctx) {
ctx.UserGetter = { promises: { getUsers: sinon.stub() } }
vi.doMock('mongodb-legacy', () => ({
default: { ObjectId },
}))
vi.doMock('../../../../app/src/Features/Helpers/Mongo', () => ({
isObjectIdInstance,
normalizeQuery,
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
ctx.UserMembershipViewModel = (await import(modulePath)).default
ctx.email = 'mock-email@bar.com'
ctx.user = {
_id: 'mock-user-id',
email: 'mock-email@baz.com',
first_name: 'Name',
lastLoggedIn: '2020-05-20T10:41:11.407Z',
enrollment: {
managedBy: 'mock-group-id',
enrolledAt: new Date(),
sso: {
groupId: 'abc123abc123',
linkedAt: new Date(),
primary: true,
},
},
}
})
describe('build', function () {
it('build email', function (ctx) {
const viewModel = ctx.UserMembershipViewModel.build(ctx.email)
expect(viewModel).to.deep.equal({
email: ctx.email,
invite: true,
last_active_at: null,
last_logged_in_at: null,
first_name: null,
last_name: null,
_id: null,
enrollment: undefined,
})
})
it('build user', function (ctx) {
const viewModel = ctx.UserMembershipViewModel.build(ctx.user)
expect(viewModel).to.deep.equal({
email: ctx.user.email,
invite: false,
last_active_at: ctx.user.lastLoggedIn,
last_logged_in_at: ctx.user.lastLoggedIn,
first_name: ctx.user.first_name,
last_name: null,
_id: ctx.user._id,
enrollment: ctx.user.enrollment,
})
})
})
describe('build async', function () {
beforeEach(function (ctx) {
ctx.UserMembershipViewModel.build = sinon.stub()
})
it('build email', async function (ctx) {
ctx.UserGetter.promises.getUsers.resolves([])
await ctx.UserMembershipViewModel.buildAsync([ctx.email])
assertCalledWith(ctx.UserMembershipViewModel.build, ctx.email)
})
it('build user', async function (ctx) {
ctx.UserGetter.promises.getUsers.resolves([])
await ctx.UserMembershipViewModel.buildAsync([ctx.user])
assertCalledWith(ctx.UserMembershipViewModel.build, ctx.user)
})
it('build user id', async function (ctx) {
const user = {
...ctx.user,
_id: new ObjectId(),
}
ctx.UserGetter.promises.getUsers.resolves([user])
const [viewModel] = await ctx.UserMembershipViewModel.buildAsync([
user._id,
])
assertNotCalled(ctx.UserMembershipViewModel.build)
expect(viewModel._id.toString()).to.equal(user._id.toString())
expect(viewModel.email).to.equal(user.email)
expect(viewModel.first_name).to.equal(user.first_name)
expect(viewModel.invite).to.equal(false)
expect(viewModel.email).to.exist
expect(viewModel.enrollment).to.exist
expect(viewModel.enrollment).to.deep.equal(user.enrollment)
})
it('build user id with error', async function (ctx) {
ctx.UserGetter.promises.getUsers.rejects(new Error('nope'))
const userId = new ObjectId()
const [viewModel] = await ctx.UserMembershipViewModel.buildAsync([userId])
assertNotCalled(ctx.UserMembershipViewModel.build)
expect(viewModel._id).to.equal(userId.toString())
expect(viewModel.email).not.to.exist
})
})
})

View File

@@ -1,119 +0,0 @@
const { expect } = require('chai')
const sinon = require('sinon')
const assertCalledWith = sinon.assert.calledWith
const assertNotCalled = sinon.assert.notCalled
const { ObjectId } = require('mongodb-legacy')
const modulePath =
'../../../../app/src/Features/UserMembership/UserMembershipViewModel'
const SandboxedModule = require('sandboxed-module')
const {
isObjectIdInstance,
normalizeQuery,
} = require('../../../../app/src/Features/Helpers/Mongo')
describe('UserMembershipViewModel', function () {
beforeEach(function () {
this.UserGetter = { promises: { getUsers: sinon.stub() } }
this.UserMembershipViewModel = SandboxedModule.require(modulePath, {
requires: {
'mongodb-legacy': { ObjectId },
'../Helpers/Mongo': { isObjectIdInstance, normalizeQuery },
'../User/UserGetter': this.UserGetter,
},
})
this.email = 'mock-email@bar.com'
this.user = {
_id: 'mock-user-id',
email: 'mock-email@baz.com',
first_name: 'Name',
lastLoggedIn: '2020-05-20T10:41:11.407Z',
enrollment: {
managedBy: 'mock-group-id',
enrolledAt: new Date(),
sso: {
groupId: 'abc123abc123',
linkedAt: new Date(),
primary: true,
},
},
}
})
describe('build', function () {
it('build email', function () {
const viewModel = this.UserMembershipViewModel.build(this.email)
expect(viewModel).to.deep.equal({
email: this.email,
invite: true,
last_active_at: null,
last_logged_in_at: null,
first_name: null,
last_name: null,
_id: null,
enrollment: undefined,
})
})
it('build user', function () {
const viewModel = this.UserMembershipViewModel.build(this.user)
expect(viewModel).to.deep.equal({
email: this.user.email,
invite: false,
last_active_at: this.user.lastLoggedIn,
last_logged_in_at: this.user.lastLoggedIn,
first_name: this.user.first_name,
last_name: null,
_id: this.user._id,
enrollment: this.user.enrollment,
})
})
})
describe('build async', function () {
beforeEach(function () {
this.UserMembershipViewModel.build = sinon.stub()
})
it('build email', async function () {
this.UserGetter.promises.getUsers.resolves([])
await this.UserMembershipViewModel.buildAsync([this.email])
assertCalledWith(this.UserMembershipViewModel.build, this.email)
})
it('build user', async function () {
this.UserGetter.promises.getUsers.resolves([])
await this.UserMembershipViewModel.buildAsync([this.user])
assertCalledWith(this.UserMembershipViewModel.build, this.user)
})
it('build user id', async function () {
const user = {
...this.user,
_id: new ObjectId(),
}
this.UserGetter.promises.getUsers.resolves([user])
const [viewModel] = await this.UserMembershipViewModel.buildAsync([
user._id,
])
assertNotCalled(this.UserMembershipViewModel.build)
expect(viewModel._id.toString()).to.equal(user._id.toString())
expect(viewModel.email).to.equal(user.email)
expect(viewModel.first_name).to.equal(user.first_name)
expect(viewModel.invite).to.equal(false)
expect(viewModel.email).to.exist
expect(viewModel.enrollment).to.exist
expect(viewModel.enrollment).to.deep.equal(user.enrollment)
})
it('build user id with error', async function () {
this.UserGetter.promises.getUsers.rejects(new Error('nope'))
const userId = new ObjectId()
const [viewModel] = await this.UserMembershipViewModel.buildAsync([
userId,
])
assertNotCalled(this.UserMembershipViewModel.build)
expect(viewModel._id).to.equal(userId.toString())
expect(viewModel.email).not.to.exist
})
})
})

View File

@@ -0,0 +1,10 @@
/**
* Types for module hook events fired across the application
*/
export type CommentAddedEvent = {
projectId: string
userId: string
threadId: string
messageId: string
}

View File

@@ -1,10 +1,13 @@
const fs = require('fs')
const path = require('path')
const modulePath = path.resolve(__dirname, '../scripts/lezer-latex/generate.js')
const modulePath = path.resolve(
__dirname,
'../scripts/lezer-latex/generate.mjs'
)
try {
fs.accessSync(modulePath, fs.constants.W_OK)
const { compile, grammars } = require(modulePath)
const { compile, grammars } = require(modulePath).default
const PLUGIN_NAME = 'lezer-grammar-compiler'
class LezerGrammarCompilerPlugin {
apply(compiler) {

View File

@@ -0,0 +1,42 @@
/* eslint-disable no-unused-vars */
import Helpers from './lib/helpers.mjs'
import { getCollectionInternal } from './lib/mongodb.mjs'
const tags = ['server-pro', 'saas']
const indexes = [
{
key: {
scheduledAt: 1,
},
name: 'scheduledAt_1',
expireAfterSeconds: 60 * 60 * 24, // expire after 24 hours
},
{
// used for querying notifications to find possible duplicates
unique: false,
key: {
user_id: 1,
recipient_id: 1,
project_id: 1,
},
name: 'user_id_1_recipient_id_1_project_id_1',
},
]
const migrate = async () => {
const emailNotifications = await getCollectionInternal('emailNotifications')
await Helpers.addIndexesToCollection(emailNotifications, indexes)
}
const rollback = async () => {
const emailNotifications = await getCollectionInternal('emailNotifications')
await Helpers.dropIndexesFromCollection(emailNotifications, indexes)
}
export default {
tags,
migrate,
rollback,
}