From 11b94593c2ceab973c6a9ea4f2e724e0d2bdaa4f Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 22 May 2025 10:15:52 +0100 Subject: [PATCH 001/259] [web] remove deledFiles collection (#25750) * [history-v1] remove processing of deleted files when back-filling hashes * [web] remove deledFiles collection GitOrigin-RevId: 7c080e564f7d7acb33ebe7ebe012f415a847d0df --- .../storage/scripts/back_fill_file_hash.mjs | 109 +-------- .../js/storage/back_fill_file_hash.test.mjs | 225 +++++++----------- .../acceptance/js/storage/support/cleanup.js | 1 - .../src/Features/Project/ProjectDeleter.js | 42 ---- .../ProjectEntityMongoUpdateHandler.js | 15 -- .../Project/ProjectEntityUpdateHandler.js | 9 - .../web/app/src/infrastructure/mongodb.js | 1 - services/web/app/src/models/DeletedFile.js | 21 -- services/web/app/src/models/Project.js | 13 - ...25_create_deletedFiles_projectId_index.mjs | 16 +- ...727123346_ce_sp_backfill_deleted_files.mjs | 10 +- .../20250519101127_drop_deletedFiles.mjs | 50 ++++ .../web/scripts/back_fill_deleted_files.mjs | 133 ----------- .../web/scripts/count_files_in_projects.mjs | 1 - services/web/scripts/count_project_size.mjs | 1 - .../src/BackFillDeletedFilesTests.mjs | 176 -------------- .../web/test/acceptance/src/DeletionTests.mjs | 75 ------ .../unit/src/Project/ProjectDeleterTests.js | 4 - .../ProjectEntityMongoUpdateHandlerTests.js | 39 --- .../ProjectEntityUpdateHandlerTests.js | 20 -- .../unit/src/helpers/models/DeletedFile.js | 3 - 21 files changed, 155 insertions(+), 809 deletions(-) delete mode 100644 services/web/app/src/models/DeletedFile.js create mode 100644 services/web/migrations/20250519101127_drop_deletedFiles.mjs delete mode 100644 services/web/scripts/back_fill_deleted_files.mjs delete mode 100644 services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs delete mode 100644 services/web/test/unit/src/helpers/models/DeletedFile.js diff --git a/services/history-v1/storage/scripts/back_fill_file_hash.mjs b/services/history-v1/storage/scripts/back_fill_file_hash.mjs index 96dfd79e38..ba3e0d4359 100644 --- a/services/history-v1/storage/scripts/back_fill_file_hash.mjs +++ b/services/history-v1/storage/scripts/back_fill_file_hash.mjs @@ -89,14 +89,13 @@ ObjectId.cacheHexString = true */ /** - * @return {{PROJECT_IDS_FROM: string, PROCESS_HASHED_FILES: boolean, PROCESS_DELETED_FILES: boolean, LOGGING_IDENTIFIER: string, BATCH_RANGE_START: string, PROCESS_BLOBS: boolean, BATCH_RANGE_END: string, PROCESS_NON_DELETED_PROJECTS: boolean, PROCESS_DELETED_PROJECTS: boolean, COLLECT_BACKED_UP_BLOBS: boolean}} + * @return {{PROJECT_IDS_FROM: string, PROCESS_HASHED_FILES: boolean, LOGGING_IDENTIFIER: string, BATCH_RANGE_START: string, PROCESS_BLOBS: boolean, BATCH_RANGE_END: string, PROCESS_NON_DELETED_PROJECTS: boolean, PROCESS_DELETED_PROJECTS: boolean, COLLECT_BACKED_UP_BLOBS: boolean}} */ function parseArgs() { const PUBLIC_LAUNCH_DATE = new Date('2012-01-01T00:00:00Z') const args = commandLineArgs([ { name: 'processNonDeletedProjects', type: String, defaultValue: 'false' }, { name: 'processDeletedProjects', type: String, defaultValue: 'false' }, - { name: 'processDeletedFiles', type: String, defaultValue: 'false' }, { name: 'processHashedFiles', type: String, defaultValue: 'false' }, { name: 'processBlobs', type: String, defaultValue: 'true' }, { name: 'projectIdsFrom', type: String, defaultValue: '' }, @@ -131,7 +130,6 @@ function parseArgs() { PROCESS_NON_DELETED_PROJECTS: boolVal('processNonDeletedProjects'), PROCESS_DELETED_PROJECTS: boolVal('processDeletedProjects'), PROCESS_BLOBS: boolVal('processBlobs'), - PROCESS_DELETED_FILES: boolVal('processDeletedFiles'), PROCESS_HASHED_FILES: boolVal('processHashedFiles'), COLLECT_BACKED_UP_BLOBS: boolVal('collectBackedUpBlobs'), BATCH_RANGE_START, @@ -145,7 +143,6 @@ const { PROCESS_NON_DELETED_PROJECTS, PROCESS_DELETED_PROJECTS, PROCESS_BLOBS, - PROCESS_DELETED_FILES, PROCESS_HASHED_FILES, COLLECT_BACKED_UP_BLOBS, BATCH_RANGE_START, @@ -188,7 +185,6 @@ const typedProjectsCollection = db.collection('projects') const deletedProjectsCollection = db.collection('deletedProjects') /** @type {DeletedProjectsCollection} */ const typedDeletedProjectsCollection = db.collection('deletedProjects') -const deletedFilesCollection = db.collection('deletedFiles') const concurrencyLimit = pLimit(CONCURRENCY) @@ -647,22 +643,15 @@ async function queueNextBatch(batch, prefix = 'rootFolder.0') { * @return {Promise} */ async function processBatch(batch, prefix = 'rootFolder.0') { - const [deletedFiles, { nBlobs, blobs }, { nBackedUpBlobs, backedUpBlobs }] = - await Promise.all([ - collectDeletedFiles(batch), - collectProjectBlobs(batch), - collectBackedUpBlobs(batch), - ]) - const files = Array.from( - findFileInBatch(batch, prefix, deletedFiles, blobs, backedUpBlobs) - ) + const [{ nBlobs, blobs }, { nBackedUpBlobs, backedUpBlobs }] = + await Promise.all([collectProjectBlobs(batch), collectBackedUpBlobs(batch)]) + const files = Array.from(findFileInBatch(batch, prefix, blobs, backedUpBlobs)) STATS.projects += batch.length STATS.blobs += nBlobs STATS.backedUpBlobs += nBackedUpBlobs // GC batch.length = 0 - deletedFiles.clear() blobs.clear() backedUpBlobs.clear() @@ -713,9 +702,7 @@ async function handleDeletedFileTreeBatch(batch) { * @return {Promise} */ async function tryUpdateFileRefInMongo(entry) { - if (entry.path === MONGO_PATH_DELETED_FILE) { - return await tryUpdateDeletedFileRefInMongo(entry) - } else if (entry.path.startsWith('project.')) { + if (entry.path.startsWith('project.')) { return await tryUpdateFileRefInMongoInDeletedProject(entry) } @@ -732,22 +719,6 @@ async function tryUpdateFileRefInMongo(entry) { return result.matchedCount === 1 } -/** - * @param {QueueEntry} entry - * @return {Promise} - */ -async function tryUpdateDeletedFileRefInMongo(entry) { - STATS.mongoUpdates++ - const result = await deletedFilesCollection.updateOne( - { - _id: new ObjectId(entry.fileId), - projectId: entry.ctx.projectId, - }, - { $set: { hash: entry.hash } } - ) - return result.matchedCount === 1 -} - /** * @param {QueueEntry} entry * @return {Promise} @@ -812,7 +783,6 @@ async function updateFileRefInMongo(entry) { break } if (!found) { - if (await tryUpdateDeletedFileRefInMongo(entry)) return STATS.fileHardDeleted++ console.warn('bug: file hard-deleted while processing', projectId, fileId) return @@ -905,49 +875,22 @@ function* findFiles(ctx, folder, path, isInputLoop = false) { /** * @param {Array} projects * @param {string} prefix - * @param {Map>} deletedFiles * @param {Map>} blobs * @param {Map>} backedUpBlobs * @return Generator */ -function* findFileInBatch( - projects, - prefix, - deletedFiles, - blobs, - backedUpBlobs -) { +function* findFileInBatch(projects, prefix, blobs, backedUpBlobs) { for (const project of projects) { const projectIdS = project._id.toString() const historyIdS = project.overleaf.history.id.toString() const projectBlobs = blobs.get(historyIdS) || [] const projectBackedUpBlobs = new Set(backedUpBlobs.get(projectIdS) || []) - const projectDeletedFiles = deletedFiles.get(projectIdS) || [] const ctx = new ProjectContext( project._id, historyIdS, projectBlobs, projectBackedUpBlobs ) - for (const fileRef of projectDeletedFiles) { - const fileId = fileRef._id.toString() - if (fileRef.hash) { - if (ctx.canSkipProcessingHashedFile(fileRef.hash)) continue - ctx.remainingQueueEntries++ - STATS.filesWithHash++ - yield { - ctx, - cacheKey: fileRef.hash, - fileId, - hash: fileRef.hash, - path: MONGO_PATH_SKIP_WRITE_HASH_TO_FILE_TREE, - } - } else { - ctx.remainingQueueEntries++ - STATS.filesWithoutHash++ - yield { ctx, cacheKey: fileId, fileId, path: MONGO_PATH_DELETED_FILE } - } - } for (const blob of projectBlobs) { if (projectBackedUpBlobs.has(blob.getHash())) continue ctx.remainingQueueEntries++ @@ -981,41 +924,6 @@ async function collectProjectBlobs(batch) { return await getProjectBlobsBatch(batch.map(p => p.overleaf.history.id)) } -/** - * @param {Array} projects - * @return {Promise>>} - */ -async function collectDeletedFiles(projects) { - const deletedFiles = new Map() - if (!PROCESS_DELETED_FILES) return deletedFiles - - const cursor = deletedFilesCollection.find( - { - projectId: { $in: projects.map(p => p._id) }, - ...(PROCESS_HASHED_FILES - ? {} - : { - hash: { $exists: false }, - }), - }, - { - projection: { _id: 1, projectId: 1, hash: 1 }, - readPreference: READ_PREFERENCE_SECONDARY, - sort: { projectId: 1 }, - } - ) - for await (const deletedFileRef of cursor) { - const projectId = deletedFileRef.projectId.toString() - const found = deletedFiles.get(projectId) - if (found) { - found.push(deletedFileRef) - } else { - deletedFiles.set(projectId, [deletedFileRef]) - } - } - return deletedFiles -} - /** * @param {Array} projects * @return {Promise<{nBackedUpBlobs:number,backedUpBlobs:Map>}>} @@ -1043,7 +951,6 @@ async function collectBackedUpBlobs(projects) { const BATCH_HASH_WRITES = 1_000 const BATCH_FILE_UPDATES = 100 -const MONGO_PATH_DELETED_FILE = 'deleted-file' const MONGO_PATH_SKIP_WRITE_HASH_TO_FILE_TREE = 'skip-write-to-file-tree' class ProjectContext { @@ -1264,9 +1171,7 @@ class ProjectContext { const projectEntries = [] const deletedProjectEntries = [] for (const entry of this.#pendingFileWrites) { - if (entry.path === MONGO_PATH_DELETED_FILE) { - individualUpdates.push(entry) - } else if (entry.path.startsWith('project.')) { + if (entry.path.startsWith('project.')) { deletedProjectEntries.push(entry) } else { projectEntries.push(entry) diff --git a/services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs b/services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs index fad87b4703..fd39369a71 100644 --- a/services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs @@ -35,7 +35,6 @@ const { tieringStorageClass } = config.get('backupPersistor') const projectsCollection = db.collection('projects') const deletedProjectsCollection = db.collection('deletedProjects') -const deletedFilesCollection = db.collection('deletedFiles') const FILESTORE_PERSISTOR = ObjectPersistor({ backend: 'gcs', @@ -130,11 +129,8 @@ describe('back_fill_file_hash script', function () { const fileId7 = objectIdFromTime('2017-02-01T00:07:00Z') const fileId8 = objectIdFromTime('2017-02-01T00:08:00Z') const fileId9 = objectIdFromTime('2017-02-01T00:09:00Z') - const fileIdDeleted1 = objectIdFromTime('2017-03-01T00:01:00Z') - const fileIdDeleted2 = objectIdFromTime('2017-03-01T00:02:00Z') - const fileIdDeleted3 = objectIdFromTime('2017-03-01T00:03:00Z') - const fileIdDeleted4 = objectIdFromTime('2024-03-01T00:04:00Z') - const fileIdDeleted5 = objectIdFromTime('2024-03-01T00:05:00Z') + const fileId10 = objectIdFromTime('2017-02-01T00:10:00Z') + const fileId11 = objectIdFromTime('2017-02-01T00:11:00Z') const contentTextBlob0 = Buffer.from('Hello 0') const hashTextBlob0 = gitBlobHashBuffer(contentTextBlob0) const contentTextBlob1 = Buffer.from('Hello 1') @@ -161,7 +157,6 @@ describe('back_fill_file_hash script', function () { hash: hashFile7, content: contentFile7, }, - { projectId: projectId0, historyId: historyId0, fileId: fileIdDeleted5 }, { projectId: projectId0, historyId: historyId0, @@ -181,7 +176,6 @@ describe('back_fill_file_hash script', function () { content: contentTextBlob2, }, { projectId: projectId1, historyId: historyId1, fileId: fileId1 }, - { projectId: projectId1, historyId: historyId1, fileId: fileIdDeleted1 }, { projectId: projectId2, historyId: historyId2, @@ -189,23 +183,28 @@ describe('back_fill_file_hash script', function () { hasHash: true, }, { projectId: projectId3, historyId: historyId3, fileId: fileId3 }, + // fileId10 is dupe of fileId3, without a hash + { + projectId: projectId3, + historyId: historyId3, + fileId: fileId10, + content: Buffer.from(fileId3.toString()), + hash: gitBlobHash(fileId3), + }, + // fileId11 is dupe of fileId3, but with a hash + { + projectId: projectId3, + historyId: historyId3, + fileId: fileId11, + content: Buffer.from(fileId3.toString()), + hash: gitBlobHash(fileId3), + hasHash: true, + }, { projectId: projectIdDeleted0, historyId: historyIdDeleted0, fileId: fileId4, }, - { - projectId: projectIdDeleted0, - historyId: historyIdDeleted0, - fileId: fileIdDeleted2, - }, - // { historyId: historyIdDeleted0, fileId:fileIdDeleted3 }, // fileIdDeleted3 is dupe of fileIdDeleted2 - { - projectId: projectIdDeleted0, - historyId: historyIdDeleted0, - fileId: fileIdDeleted4, - hasHash: true, - }, { projectId: projectIdDeleted1, historyId: historyIdDeleted1, @@ -233,10 +232,6 @@ describe('back_fill_file_hash script', function () { fileId4, fileId5, fileId6, - fileIdDeleted1, - fileIdDeleted2, - fileIdDeleted3, - fileIdDeleted4, } console.log({ projectId0, @@ -328,7 +323,11 @@ describe('back_fill_file_hash script', function () { fileRefs: [], folders: [ { - fileRefs: [{ _id: fileId3 }], + fileRefs: [ + { _id: fileId3 }, + { _id: fileId10 }, + { _id: fileId11, hash: gitBlobHash(fileId3) }, + ], folders: [], }, ], @@ -446,17 +445,6 @@ describe('back_fill_file_hash script', function () { }, }, ]) - await deletedFilesCollection.insertMany([ - { _id: fileIdDeleted1, projectId: projectId1 }, - { _id: fileIdDeleted2, projectId: projectIdDeleted0 }, - { _id: fileIdDeleted3, projectId: projectIdDeleted0 }, - { - _id: fileIdDeleted4, - projectId: projectIdDeleted0, - hash: gitBlobHash(fileIdDeleted4), - }, - { _id: fileIdDeleted5, projectId: projectId0 }, - ]) } async function populateHistoryV1() { @@ -499,11 +487,6 @@ describe('back_fill_file_hash script', function () { `${projectId0}/${fileId7}`, Stream.Readable.from([contentFile7]) ) - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectId0}/${fileIdDeleted5}`, - Stream.Readable.from([fileIdDeleted5.toString()]) - ) await FILESTORE_PERSISTOR.sendStream( USER_FILES_BUCKET_NAME, `${projectId1}/${fileId1}`, @@ -519,6 +502,18 @@ describe('back_fill_file_hash script', function () { `${projectId3}/${fileId3}`, Stream.Readable.from([fileId3.toString()]) ) + await FILESTORE_PERSISTOR.sendStream( + USER_FILES_BUCKET_NAME, + `${projectId3}/${fileId10}`, + // fileId10 is dupe of fileId3 + Stream.Readable.from([fileId3.toString()]) + ) + await FILESTORE_PERSISTOR.sendStream( + USER_FILES_BUCKET_NAME, + `${projectId3}/${fileId11}`, + // fileId11 is dupe of fileId3 + Stream.Readable.from([fileId3.toString()]) + ) await FILESTORE_PERSISTOR.sendStream( USER_FILES_BUCKET_NAME, `${projectIdDeleted0}/${fileId4}`, @@ -529,27 +524,6 @@ describe('back_fill_file_hash script', function () { `${projectIdDeleted1}/${fileId5}`, Stream.Readable.from([fileId5.toString()]) ) - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectId1}/${fileIdDeleted1}`, - Stream.Readable.from([fileIdDeleted1.toString()]) - ) - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectIdDeleted0}/${fileIdDeleted2}`, - Stream.Readable.from([fileIdDeleted2.toString()]) - ) - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectIdDeleted0}/${fileIdDeleted3}`, - // same content as 2, deduplicate - Stream.Readable.from([fileIdDeleted2.toString()]) - ) - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectIdDeleted0}/${fileIdDeleted4}`, - Stream.Readable.from([fileIdDeleted4.toString()]) - ) await FILESTORE_PERSISTOR.sendStream( USER_FILES_BUCKET_NAME, `${projectIdBadFileTree3}/${fileId9}`, @@ -579,7 +553,6 @@ describe('back_fill_file_hash script', function () { 'storage/scripts/back_fill_file_hash.mjs', '--processNonDeletedProjects=true', '--processDeletedProjects=true', - '--processDeletedFiles=true', ...args, ], { @@ -741,6 +714,8 @@ describe('back_fill_file_hash script', function () { { fileRefs: [ { _id: fileId3, hash: gitBlobHash(fileId3) }, + { _id: fileId10, hash: gitBlobHash(fileId3) }, + { _id: fileId11, hash: gitBlobHash(fileId3) }, ], folders: [], }, @@ -868,34 +843,6 @@ describe('back_fill_file_hash script', function () { }, }, ]) - expect(await deletedFilesCollection.find({}).toArray()).to.deep.equal([ - { - _id: fileIdDeleted1, - projectId: projectId1, - hash: gitBlobHash(fileIdDeleted1), - }, - { - _id: fileIdDeleted2, - projectId: projectIdDeleted0, - hash: gitBlobHash(fileIdDeleted2), - }, - { - _id: fileIdDeleted3, - projectId: projectIdDeleted0, - // uses the same content as fileIdDeleted2 - hash: gitBlobHash(fileIdDeleted2), - }, - { - _id: fileIdDeleted4, - projectId: projectIdDeleted0, - hash: gitBlobHash(fileIdDeleted4), - }, - { - _id: fileIdDeleted5, - projectId: projectId0, - hash: gitBlobHash(fileIdDeleted5), - }, - ]) expect( (await backedUpBlobs.find({}, { sort: { _id: 1 } }).toArray()).map( entry => { @@ -910,7 +857,6 @@ describe('back_fill_file_hash script', function () { blobs: [ binaryForGitBlobHash(gitBlobHash(fileId0)), binaryForGitBlobHash(hashFile7), - binaryForGitBlobHash(gitBlobHash(fileIdDeleted5)), binaryForGitBlobHash(hashTextBlob0), ].sort(), }, @@ -918,7 +864,6 @@ describe('back_fill_file_hash script', function () { _id: projectId1, blobs: [ binaryForGitBlobHash(gitBlobHash(fileId1)), - binaryForGitBlobHash(gitBlobHash(fileIdDeleted1)), binaryForGitBlobHash(hashTextBlob1), ].sort(), }, @@ -934,16 +879,7 @@ describe('back_fill_file_hash script', function () { }, { _id: projectIdDeleted0, - blobs: [ - binaryForGitBlobHash(gitBlobHash(fileId4)), - binaryForGitBlobHash(gitBlobHash(fileIdDeleted2)), - ] - .concat( - processHashedFiles - ? [binaryForGitBlobHash(gitBlobHash(fileIdDeleted4))] - : [] - ) - .sort(), + blobs: [binaryForGitBlobHash(gitBlobHash(fileId4))].sort(), }, { _id: projectId3, @@ -971,11 +907,15 @@ describe('back_fill_file_hash script', function () { expect(tieringStorageClass).to.exist const blobs = await listS3Bucket(projectBlobsBucket, tieringStorageClass) expect(blobs.sort()).to.deep.equal( - writtenBlobs - .map(({ historyId, fileId, hash }) => - makeProjectKey(historyId, hash || gitBlobHash(fileId)) + Array.from( + new Set( + writtenBlobs + .map(({ historyId, fileId, hash }) => + makeProjectKey(historyId, hash || gitBlobHash(fileId)) + ) + .sort() ) - .sort() + ) ) for (let { historyId, fileId, hash, content } of writtenBlobs) { hash = hash || gitBlobHash(fileId.toString()) @@ -1037,15 +977,15 @@ describe('back_fill_file_hash script', function () { ...STATS_ALL_ZERO, // We still need to iterate over all the projects and blobs. projects: 10, - blobs: 13, - backedUpBlobs: 13, + blobs: 10, + backedUpBlobs: 10, badFileTrees: 4, } if (processHashedFiles) { stats = sumStats(stats, { ...STATS_ALL_ZERO, - blobs: 3, - backedUpBlobs: 3, + blobs: 2, + backedUpBlobs: 2, }) } expect(rerun.stats).deep.equal(stats) @@ -1101,7 +1041,7 @@ describe('back_fill_file_hash script', function () { blobs: 2, backedUpBlobs: 0, filesWithHash: 0, - filesWithoutHash: 7, + filesWithoutHash: 5, filesDuplicated: 1, filesRetries: 0, filesFailed: 0, @@ -1112,24 +1052,24 @@ describe('back_fill_file_hash script', function () { projectHardDeleted: 0, fileHardDeleted: 0, badFileTrees: 0, - mongoUpdates: 6, + mongoUpdates: 4, deduplicatedWriteToAWSLocalCount: 0, deduplicatedWriteToAWSLocalEgress: 0, deduplicatedWriteToAWSRemoteCount: 0, deduplicatedWriteToAWSRemoteEgress: 0, - readFromGCSCount: 8, - readFromGCSIngress: 4000134, - writeToAWSCount: 7, - writeToAWSEgress: 4086, - writeToGCSCount: 5, - writeToGCSEgress: 4000096, + readFromGCSCount: 6, + readFromGCSIngress: 4000086, + writeToAWSCount: 5, + writeToAWSEgress: 4026, + writeToGCSCount: 3, + writeToGCSEgress: 4000048, } const STATS_UP_FROM_PROJECT1_ONWARD = { projects: 8, blobs: 2, backedUpBlobs: 0, filesWithHash: 0, - filesWithoutHash: 5, + filesWithoutHash: 4, filesDuplicated: 0, filesRetries: 0, filesFailed: 0, @@ -1140,28 +1080,28 @@ describe('back_fill_file_hash script', function () { projectHardDeleted: 0, fileHardDeleted: 0, badFileTrees: 4, - mongoUpdates: 10, + mongoUpdates: 8, deduplicatedWriteToAWSLocalCount: 1, deduplicatedWriteToAWSLocalEgress: 30, deduplicatedWriteToAWSRemoteCount: 0, deduplicatedWriteToAWSRemoteEgress: 0, - readFromGCSCount: 7, - readFromGCSIngress: 134, - writeToAWSCount: 6, - writeToAWSEgress: 173, - writeToGCSCount: 4, - writeToGCSEgress: 96, + readFromGCSCount: 6, + readFromGCSIngress: 110, + writeToAWSCount: 5, + writeToAWSEgress: 143, + writeToGCSCount: 3, + writeToGCSEgress: 72, } const STATS_FILES_HASHED_EXTRA = { ...STATS_ALL_ZERO, - filesWithHash: 3, - mongoUpdates: 1, - readFromGCSCount: 3, - readFromGCSIngress: 72, - writeToAWSCount: 3, - writeToAWSEgress: 89, - writeToGCSCount: 3, - writeToGCSEgress: 72, + filesWithHash: 2, + mongoUpdates: 2, + readFromGCSCount: 2, + readFromGCSIngress: 48, + writeToAWSCount: 2, + writeToAWSEgress: 60, + writeToGCSCount: 2, + writeToGCSEgress: 48, } function sumStats(a, b) { @@ -1331,10 +1271,9 @@ describe('back_fill_file_hash script', function () { expect(output2.stats).deep.equal({ ...STATS_FILES_HASHED_EXTRA, projects: 10, - blobs: 13, - backedUpBlobs: 13, + blobs: 10, + backedUpBlobs: 10, badFileTrees: 4, - mongoUpdates: 3, }) }) commonAssertions(true) @@ -1376,7 +1315,15 @@ describe('back_fill_file_hash script', function () { }) it('should print stats', function () { expect(output.stats).deep.equal( - sumStats(STATS_ALL, STATS_FILES_HASHED_EXTRA) + sumStats(STATS_ALL, { + ...STATS_FILES_HASHED_EXTRA, + readFromGCSCount: 3, + readFromGCSIngress: 72, + deduplicatedWriteToAWSLocalCount: 1, + deduplicatedWriteToAWSLocalEgress: 30, + mongoUpdates: 1, + filesWithHash: 3, + }) ) }) commonAssertions(true) diff --git a/services/history-v1/test/acceptance/js/storage/support/cleanup.js b/services/history-v1/test/acceptance/js/storage/support/cleanup.js index 632cc96c04..4df985d613 100644 --- a/services/history-v1/test/acceptance/js/storage/support/cleanup.js +++ b/services/history-v1/test/acceptance/js/storage/support/cleanup.js @@ -17,7 +17,6 @@ const MONGO_COLLECTIONS = [ 'projectHistoryChunks', // back_fill_file_hash.test.mjs - 'deletedFiles', 'deletedProjects', 'projects', 'projectHistoryBackedUpBlobs', diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js index c5dcafd335..e5764bab86 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.js +++ b/services/web/app/src/Features/Project/ProjectDeleter.js @@ -324,19 +324,6 @@ async function undeleteProject(projectId, options = {}) { }) restored.deletedDocs = [] } - if (restored.deletedFiles && restored.deletedFiles.length > 0) { - filterDuplicateDeletedFilesInPlace(restored) - const deletedFiles = restored.deletedFiles.map(file => { - // break free from the model - file = file.toObject() - - // add projectId - file.projectId = projectId - return file - }) - await db.deletedFiles.insertMany(deletedFiles) - restored.deletedFiles = [] - } // we can't use Mongoose to re-insert the project, as it won't // create a new document with an _id already specified. We need to @@ -388,7 +375,6 @@ async function expireDeletedProject(projectId) { ), FilestoreHandler.promises.deleteProject(deletedProject.project._id), ChatApiHandler.promises.destroyProject(deletedProject.project._id), - hardDeleteDeletedFiles(deletedProject.project._id), ProjectAuditLogEntry.deleteMany({ projectId }), Modules.promises.hooks.fire('projectExpired', deletedProject.project._id), ]) @@ -409,31 +395,3 @@ async function expireDeletedProject(projectId) { throw error } } - -function filterDuplicateDeletedFilesInPlace(project) { - const fileIds = new Set() - project.deletedFiles = project.deletedFiles.filter(file => { - const id = file._id.toString() - if (fileIds.has(id)) return false - fileIds.add(id) - return true - }) -} - -let deletedFilesProjectIdIndexExist -async function doesDeletedFilesProjectIdIndexExist() { - if (typeof deletedFilesProjectIdIndexExist !== 'boolean') { - // Resolve this about once. No need for locking or retry handling. - deletedFilesProjectIdIndexExist = - await db.deletedFiles.indexExists('projectId_1') - } - return deletedFilesProjectIdIndexExist -} - -async function hardDeleteDeletedFiles(projectId) { - if (!(await doesDeletedFilesProjectIdIndexExist())) { - // Running the deletion command w/o index would kill mongo performance - return - } - return db.deletedFiles.deleteMany({ projectId }) -} diff --git a/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js b/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js index 84002f1a38..895350bf37 100644 --- a/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js +++ b/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js @@ -15,7 +15,6 @@ const ProjectGetter = require('./ProjectGetter') const ProjectLocator = require('./ProjectLocator') const FolderStructureBuilder = require('./FolderStructureBuilder') const SafePath = require('./SafePath') -const { DeletedFile } = require('../../models/DeletedFile') const { iterablePaths } = require('./IterablePath') const LOCK_NAMESPACE = 'mongoTransaction' @@ -72,7 +71,6 @@ module.exports = { 'changes', ]), createNewFolderStructure: callbackify(wrapWithLock(createNewFolderStructure)), - _insertDeletedFileReference: callbackify(_insertDeletedFileReference), _putElement: callbackifyMultiResult(_putElement, ['result', 'project']), _confirmFolder, promises: { @@ -87,7 +85,6 @@ module.exports = { deleteEntity: wrapWithLock(deleteEntity), renameEntity: wrapWithLock(renameEntity), createNewFolderStructure: wrapWithLock(createNewFolderStructure), - _insertDeletedFileReference, _putElement, }, } @@ -162,7 +159,6 @@ async function replaceFileWithNew(projectId, fileId, newFileRef, userId) { element_id: fileId, type: 'file', }) - await _insertDeletedFileReference(projectId, fileRef) const newProject = await Project.findOneAndUpdate( { _id: project._id, [path.mongo]: { $exists: true } }, { @@ -480,17 +476,6 @@ async function renameEntity(projectId, entityId, entityType, newName, userId) { } } -async function _insertDeletedFileReference(projectId, fileRef) { - await DeletedFile.create({ - projectId, - _id: fileRef._id, - name: fileRef.name, - linkedFileData: fileRef.linkedFileData, - hash: fileRef.hash, - deletedAt: new Date(), - }) -} - async function _removeElementFromMongoArray( modelId, path, diff --git a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js index 585c2d2698..d03cb7f95a 100644 --- a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js +++ b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js @@ -1627,8 +1627,6 @@ const ProjectEntityUpdateHandler = { entry.path, userId ) - } else if (entry.type === 'file') { - await ProjectEntityUpdateHandler._cleanUpFile(project, entry.entity) } } return subtreeListing @@ -1679,13 +1677,6 @@ const ProjectEntityUpdateHandler = { return await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId) }, - - async _cleanUpFile(project, file) { - return await ProjectEntityMongoUpdateHandler.promises._insertDeletedFileReference( - project._id, - file - ) - }, } /** diff --git a/services/web/app/src/infrastructure/mongodb.js b/services/web/app/src/infrastructure/mongodb.js index 7fc1039140..840122791b 100644 --- a/services/web/app/src/infrastructure/mongodb.js +++ b/services/web/app/src/infrastructure/mongodb.js @@ -33,7 +33,6 @@ addConnectionDrainer('mongodb', async () => { const internalDb = mongoClient.db() const db = { contacts: internalDb.collection('contacts'), - deletedFiles: internalDb.collection('deletedFiles'), deletedProjects: internalDb.collection('deletedProjects'), deletedSubscriptions: internalDb.collection('deletedSubscriptions'), deletedUsers: internalDb.collection('deletedUsers'), diff --git a/services/web/app/src/models/DeletedFile.js b/services/web/app/src/models/DeletedFile.js deleted file mode 100644 index 45d30d8099..0000000000 --- a/services/web/app/src/models/DeletedFile.js +++ /dev/null @@ -1,21 +0,0 @@ -const mongoose = require('../infrastructure/Mongoose') -const { Schema } = mongoose - -const DeletedFileSchema = new Schema( - { - name: String, - projectId: Schema.ObjectId, - created: { - type: Date, - }, - linkedFileData: { type: Schema.Types.Mixed }, - hash: { - type: String, - }, - deletedAt: { type: Date }, - }, - { collection: 'deletedFiles', minimize: false } -) - -exports.DeletedFile = mongoose.model('DeletedFile', DeletedFileSchema) -exports.DeletedFileSchema = DeletedFileSchema diff --git a/services/web/app/src/models/Project.js b/services/web/app/src/models/Project.js index 145c8f9023..69db145038 100644 --- a/services/web/app/src/models/Project.js +++ b/services/web/app/src/models/Project.js @@ -12,18 +12,6 @@ const DeletedDocSchema = new Schema({ deletedAt: { type: Date }, }) -const DeletedFileSchema = new Schema({ - name: String, - created: { - type: Date, - }, - linkedFileData: { type: Schema.Types.Mixed }, - hash: { - type: String, - }, - deletedAt: { type: Date }, -}) - const ProjectSchema = new Schema( { name: { type: String, default: 'new project' }, @@ -54,7 +42,6 @@ const ProjectSchema = new Schema( archived: { type: Schema.Types.Mixed }, trashed: [{ type: ObjectId, ref: 'User' }], deletedDocs: [DeletedDocSchema], - deletedFiles: [DeletedFileSchema], imageName: { type: String }, brandVariationId: { type: String }, track_changes: { type: Object }, diff --git a/services/web/migrations/20210310111225_create_deletedFiles_projectId_index.mjs b/services/web/migrations/20210310111225_create_deletedFiles_projectId_index.mjs index 543f794b09..57dcb4e21f 100644 --- a/services/web/migrations/20210310111225_create_deletedFiles_projectId_index.mjs +++ b/services/web/migrations/20210310111225_create_deletedFiles_projectId_index.mjs @@ -1,4 +1,5 @@ import Helpers from './lib/helpers.mjs' +import { getCollectionInternal } from '../app/src/infrastructure/mongodb.js' const tags = ['server-ce', 'server-pro', 'saas'] @@ -11,14 +12,17 @@ const indexes = [ }, ] -const migrate = async client => { - const { db } = client - await Helpers.addIndexesToCollection(db.deletedFiles, indexes) +async function getCollection() { + // NOTE: The deletedFiles collection is not available to the application as it has been retired. Fetch it here. + return await getCollectionInternal('deletedFiles') } -const rollback = async client => { - const { db } = client - await Helpers.dropIndexesFromCollection(db.deletedFiles, indexes) +const migrate = async () => { + await Helpers.addIndexesToCollection(await getCollection(), indexes) +} + +const rollback = async () => { + await Helpers.dropIndexesFromCollection(await getCollection(), indexes) } export default { diff --git a/services/web/migrations/20210727123346_ce_sp_backfill_deleted_files.mjs b/services/web/migrations/20210727123346_ce_sp_backfill_deleted_files.mjs index 105627088f..310b7c12e6 100644 --- a/services/web/migrations/20210727123346_ce_sp_backfill_deleted_files.mjs +++ b/services/web/migrations/20210727123346_ce_sp_backfill_deleted_files.mjs @@ -1,14 +1,8 @@ -import runScript from '../scripts/back_fill_deleted_files.mjs' - const tags = ['server-ce', 'server-pro', 'saas'] const migrate = async client => { - const options = { - performCleanup: true, - letUserDoubleCheckInputsFor: 10, - fixPartialInserts: true, - } - await runScript(options) + // Skip back-filling. The deletedFiles collection will be deleted in a following migration. + // The projects.deletedFiles array will be purged as part of the later migration as well. } const rollback = async client => {} diff --git a/services/web/migrations/20250519101127_drop_deletedFiles.mjs b/services/web/migrations/20250519101127_drop_deletedFiles.mjs new file mode 100644 index 0000000000..87f63f4405 --- /dev/null +++ b/services/web/migrations/20250519101127_drop_deletedFiles.mjs @@ -0,0 +1,50 @@ +import Helpers from './lib/helpers.mjs' +import { getCollectionInternal, db } from '../app/src/infrastructure/mongodb.js' +import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' + +const tags = ['server-ce', 'server-pro', 'saas'] + +const indexes = [ + { + key: { + projectId: 1, + }, + name: 'projectId_1', + }, +] + +async function getCollection() { + // NOTE: The deletedFiles collection is not available to the application as it has been retired. Fetch it here. + return await getCollectionInternal('deletedFiles') +} + +const migrate = async () => { + const collection = await getCollection() + + // Purge legacy deletedFiles array from project records. + await batchedUpdate( + db.projects, + { deletedFiles: { $exists: true } }, + { $unset: { deletedFiles: 1 } } + ) + + // Purge legacy deletedFiles array from soft-deleted project records. + await batchedUpdate( + db.deletedProjects, + { 'project.deletedFiles': { $exists: true } }, + { $unset: { 'project.deletedFiles': 1 } } + ) + + // Drop historic deletedFiles records + await collection.drop() +} + +const rollback = async () => { + await Helpers.addIndexesToCollection(await getCollection(), indexes) +} + +export default { + tags, + migrate, + rollback, +} diff --git a/services/web/scripts/back_fill_deleted_files.mjs b/services/web/scripts/back_fill_deleted_files.mjs deleted file mode 100644 index e84e11d78a..0000000000 --- a/services/web/scripts/back_fill_deleted_files.mjs +++ /dev/null @@ -1,133 +0,0 @@ -import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' -import { promiseMapWithLimit, promisify } from '@overleaf/promise-utils' -import { db } from '../app/src/infrastructure/mongodb.js' -import _ from 'lodash' -import { fileURLToPath } from 'node:url' - -const sleep = promisify(setTimeout) - -async function main(options) { - if (!options) { - options = {} - } - _.defaults(options, { - writeConcurrency: parseInt(process.env.WRITE_CONCURRENCY, 10) || 10, - performCleanup: process.argv.includes('--perform-cleanup'), - fixPartialInserts: process.argv.includes('--fix-partial-inserts'), - letUserDoubleCheckInputsFor: parseInt( - process.env.LET_USER_DOUBLE_CHECK_INPUTS_FOR || 10 * 1000, - 10 - ), - }) - - await letUserDoubleCheckInputs(options) - - await batchedUpdate( - db.projects, - // array is not empty ~ array has one item - { 'deletedFiles.0': { $exists: true } }, - async projects => { - await processBatch(projects, options) - }, - { _id: 1, deletedFiles: 1 } - ) -} - -async function processBatch(projects, options) { - await promiseMapWithLimit( - options.writeConcurrency, - projects, - async project => { - await processProject(project, options) - } - ) -} - -async function processProject(project, options) { - await backFillFiles(project, options) - - if (options.performCleanup) { - await cleanupProject(project) - } -} - -async function backFillFiles(project, options) { - const projectId = project._id - filterDuplicatesInPlace(project) - project.deletedFiles.forEach(file => { - file.projectId = projectId - }) - - if (options.fixPartialInserts) { - await fixPartialInserts(project) - } else { - await db.deletedFiles.insertMany(project.deletedFiles) - } -} - -function filterDuplicatesInPlace(project) { - const fileIds = new Set() - project.deletedFiles = project.deletedFiles.filter(file => { - const id = file._id.toString() - if (fileIds.has(id)) return false - fileIds.add(id) - return true - }) -} - -async function fixPartialInserts(project) { - const seenFileIds = new Set( - ( - await db.deletedFiles - .find( - { _id: { $in: project.deletedFiles.map(file => file._id) } }, - { projection: { _id: 1 } } - ) - .toArray() - ).map(file => file._id.toString()) - ) - project.deletedFiles = project.deletedFiles.filter(file => { - const id = file._id.toString() - if (seenFileIds.has(id)) return false - seenFileIds.add(id) - return true - }) - if (project.deletedFiles.length > 0) { - await db.deletedFiles.insertMany(project.deletedFiles) - } -} - -async function cleanupProject(project) { - await db.projects.updateOne( - { _id: project._id }, - { $set: { deletedFiles: [] } } - ) -} - -async function letUserDoubleCheckInputs(options) { - if (options.performCleanup) { - console.error('BACK FILLING AND PERFORMING CLEANUP') - } else { - console.error( - 'BACK FILLING ONLY - You will need to rerun with --perform-cleanup' - ) - } - console.error( - 'Waiting for you to double check inputs for', - options.letUserDoubleCheckInputsFor, - 'ms' - ) - await sleep(options.letUserDoubleCheckInputsFor) -} - -export default main - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - try { - await main() - process.exit(0) - } catch (error) { - console.error({ error }) - process.exit(1) - } -} diff --git a/services/web/scripts/count_files_in_projects.mjs b/services/web/scripts/count_files_in_projects.mjs index 437e0d50f1..59b49bbdf3 100644 --- a/services/web/scripts/count_files_in_projects.mjs +++ b/services/web/scripts/count_files_in_projects.mjs @@ -19,7 +19,6 @@ async function countFiles() { console.error( projectId, files.length, - (project.deletedFiles && project.deletedFiles.length) || 0, docs.length, (project.deletedDocs && project.deletedDocs.length) || 0 ) diff --git a/services/web/scripts/count_project_size.mjs b/services/web/scripts/count_project_size.mjs index 02f3dea836..fbb1a08281 100644 --- a/services/web/scripts/count_project_size.mjs +++ b/services/web/scripts/count_project_size.mjs @@ -43,7 +43,6 @@ async function countProjectFiles() { console.error( projectId, files.length, - (project.deletedFiles && project.deletedFiles.length) || 0, docs.length, (project.deletedDocs && project.deletedDocs.length) || 0, fileSize, diff --git a/services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs b/services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs deleted file mode 100644 index 7c49d973ba..0000000000 --- a/services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs +++ /dev/null @@ -1,176 +0,0 @@ -import { exec } from 'node:child_process' -import { promisify } from 'node:util' -import { expect } from 'chai' -import logger from '@overleaf/logger' -import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js' -import UserHelper from './helpers/User.mjs' - -const User = UserHelper.promises - -async function getDeletedFiles(projectId) { - return (await db.projects.findOne({ _id: projectId })).deletedFiles -} - -async function setDeletedFiles(projectId, deletedFiles) { - await db.projects.updateOne({ _id: projectId }, { $set: { deletedFiles } }) -} - -async function unsetDeletedFiles(projectId) { - await db.projects.updateOne( - { _id: projectId }, - { $unset: { deletedFiles: 1 } } - ) -} - -describe('BackFillDeletedFiles', function () { - let user, projectId1, projectId2, projectId3, projectId4, projectId5 - - beforeEach('create projects', async function () { - user = new User() - await user.login() - - projectId1 = new ObjectId(await user.createProject('project1')) - projectId2 = new ObjectId(await user.createProject('project2')) - projectId3 = new ObjectId(await user.createProject('project3')) - projectId4 = new ObjectId(await user.createProject('project4')) - projectId5 = new ObjectId(await user.createProject('project5')) - }) - - let fileId1, fileId2, fileId3, fileId4 - beforeEach('create files', function () { - // take a short cut and just allocate file ids - fileId1 = new ObjectId() - fileId2 = new ObjectId() - fileId3 = new ObjectId() - fileId4 = new ObjectId() - }) - const otherFileDetails = { - name: 'universe.jpg', - linkedFileData: null, - hash: 'ed19e7d6779b47d8c63f6fa5a21954dcfb6cac00', - deletedAt: new Date(), - __v: 0, - } - let deletedFiles1, deletedFiles2, deletedFiles3 - beforeEach('set deletedFiles details', async function () { - deletedFiles1 = [ - { _id: fileId1, ...otherFileDetails }, - { _id: fileId2, ...otherFileDetails }, - ] - deletedFiles2 = [{ _id: fileId3, ...otherFileDetails }] - await setDeletedFiles(projectId1, deletedFiles1) - await setDeletedFiles(projectId2, deletedFiles2) - - // a project without deletedFiles entries - await setDeletedFiles(projectId3, []) - // a project without deletedFiles array - await unsetDeletedFiles(projectId4) - // duplicate entry - deletedFiles3 = [ - { _id: fileId4, ...otherFileDetails }, - { _id: fileId4, ...otherFileDetails }, - ] - await setDeletedFiles(projectId5, deletedFiles3) - }) - - async function runScript(args = []) { - let result - try { - result = await promisify(exec)( - ['LET_USER_DOUBLE_CHECK_INPUTS_FOR=1', 'VERBOSE_LOGGING=true'] - .concat(['node', 'scripts/back_fill_deleted_files.mjs']) - .concat(args) - .join(' ') - ) - } catch (error) { - // dump details like exit code, stdErr and stdOut - logger.error({ error }, 'script failed') - throw error - } - const { stdout: stdOut } = result - - expect(stdOut).to.match( - new RegExp(`Running update on batch with ids .+${projectId1}`) - ) - expect(stdOut).to.match( - new RegExp(`Running update on batch with ids .+${projectId2}`) - ) - expect(stdOut).to.not.match( - new RegExp(`Running update on batch with ids .+${projectId3}`) - ) - expect(stdOut).to.not.match( - new RegExp(`Running update on batch with ids .+${projectId4}`) - ) - expect(stdOut).to.match( - new RegExp(`Running update on batch with ids .+${projectId5}`) - ) - } - - function checkAreFilesBackFilled() { - it('should back fill file and set projectId', async function () { - const docs = await db.deletedFiles - .find({}, { sort: { _id: 1 } }) - .toArray() - expect(docs).to.deep.equal([ - { _id: fileId1, projectId: projectId1, ...otherFileDetails }, - { _id: fileId2, projectId: projectId1, ...otherFileDetails }, - { _id: fileId3, projectId: projectId2, ...otherFileDetails }, - { _id: fileId4, projectId: projectId5, ...otherFileDetails }, - ]) - }) - } - - describe('back fill only', function () { - beforeEach('run script', runScript) - - checkAreFilesBackFilled() - - it('should leave the deletedFiles as is', async function () { - expect(await getDeletedFiles(projectId1)).to.deep.equal(deletedFiles1) - expect(await getDeletedFiles(projectId2)).to.deep.equal(deletedFiles2) - expect(await getDeletedFiles(projectId5)).to.deep.equal(deletedFiles3) - }) - }) - - describe('back fill and cleanup', function () { - beforeEach('run script with cleanup flag', async function () { - await runScript(['--perform-cleanup']) - }) - - checkAreFilesBackFilled() - - it('should cleanup the deletedFiles', async function () { - expect(await getDeletedFiles(projectId1)).to.deep.equal([]) - expect(await getDeletedFiles(projectId2)).to.deep.equal([]) - expect(await getDeletedFiles(projectId5)).to.deep.equal([]) - }) - }) - - describe('fix partial inserts and cleanup', function () { - beforeEach('simulate one missing insert', async function () { - await setDeletedFiles(projectId1, [deletedFiles1[0]]) - }) - beforeEach('run script with cleanup flag', async function () { - await runScript(['--perform-cleanup']) - }) - beforeEach('add case for one missing file', async function () { - await setDeletedFiles(projectId1, deletedFiles1) - }) - beforeEach('add cases for no more files to insert', async function () { - await setDeletedFiles(projectId2, deletedFiles2) - await setDeletedFiles(projectId5, deletedFiles3) - }) - - beforeEach('fixing partial insert and cleanup', async function () { - await runScript(['--fix-partial-inserts', '--perform-cleanup']) - }) - - checkAreFilesBackFilled() - - it('should cleanup the deletedFiles', async function () { - expect(await getDeletedFiles(projectId1)).to.deep.equal([]) - expect(await getDeletedFiles(projectId2)).to.deep.equal([]) - expect(await getDeletedFiles(projectId5)).to.deep.equal([]) - }) - }) -}) diff --git a/services/web/test/acceptance/src/DeletionTests.mjs b/services/web/test/acceptance/src/DeletionTests.mjs index 34e7bca200..121c2580d8 100644 --- a/services/web/test/acceptance/src/DeletionTests.mjs +++ b/services/web/test/acceptance/src/DeletionTests.mjs @@ -252,81 +252,6 @@ describe('Deleting a project', function () { }) }) - describe('when the project has deleted files', function () { - beforeEach('get rootFolder id', function (done) { - this.user.getProject(this.projectId, (error, project) => { - if (error) return done(error) - this.rootFolder = project.rootFolder[0]._id - done() - }) - }) - - let allFileIds - beforeEach('reset allFileIds', function () { - allFileIds = [] - }) - function createAndDeleteFile(name) { - let fileId - beforeEach(`create file ${name}`, function (done) { - this.user.uploadExampleFileInProject( - this.projectId, - this.rootFolder, - name, - (error, theFileId) => { - fileId = theFileId - allFileIds.push(theFileId) - done(error) - } - ) - }) - beforeEach(`delete file ${name}`, function (done) { - this.user.deleteItemInProject(this.projectId, 'file', fileId, done) - }) - } - for (const name of ['a.png', 'another.png']) { - createAndDeleteFile(name) - } - - it('should have two deleteFiles entries', async function () { - const files = await db.deletedFiles - .find({}, { sort: { _id: 1 } }) - .toArray() - expect(files).to.have.length(2) - expect(files.map(file => file._id.toString())).to.deep.equal(allFileIds) - }) - - describe('When the deleted project is expired', function () { - beforeEach('soft delete the project', function (done) { - this.user.deleteProject(this.projectId, done) - }) - beforeEach('hard delete the project', function (done) { - request.post( - `/internal/project/${this.projectId}/expire-deleted-project`, - { - auth: { - user: settings.apis.web.user, - pass: settings.apis.web.pass, - sendImmediately: true, - }, - }, - (error, res) => { - expect(error).not.to.exist - expect(res.statusCode).to.equal(200) - - done() - } - ) - }) - - it('should cleanup the deleteFiles', async function () { - const files = await db.deletedFiles - .find({}, { sort: { _id: 1 } }) - .toArray() - expect(files).to.deep.equal([]) - }) - }) - }) - describe('When the project has docs', function () { beforeEach(function (done) { this.user.getProject(this.projectId, (error, project) => { diff --git a/services/web/test/unit/src/Project/ProjectDeleterTests.js b/services/web/test/unit/src/Project/ProjectDeleterTests.js index 9e05a0f1a0..20f8cf2ead 100644 --- a/services/web/test/unit/src/Project/ProjectDeleterTests.js +++ b/services/web/test/unit/src/Project/ProjectDeleterTests.js @@ -99,10 +99,6 @@ describe('ProjectDeleter', function () { } this.db = { - deletedFiles: { - indexExists: sinon.stub().resolves(false), - deleteMany: sinon.stub(), - }, projects: { insertOne: sinon.stub().resolves(), }, diff --git a/services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js b/services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js index b1b29c5145..ce6fa4ccc6 100644 --- a/services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js +++ b/services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js @@ -4,7 +4,6 @@ const tk = require('timekeeper') const Errors = require('../../../../app/src/Features/Errors/Errors') const { ObjectId } = require('mongodb-legacy') const SandboxedModule = require('sandboxed-module') -const { DeletedFile } = require('../helpers/models/DeletedFile') const { Project } = require('../helpers/models/Project') const MODULE_PATH = @@ -77,7 +76,6 @@ describe('ProjectEntityMongoUpdateHandler', function () { } this.FolderModel = sinon.stub() - this.DeletedFileMock = sinon.mock(DeletedFile) this.ProjectMock = sinon.mock(Project) this.ProjectEntityHandler = { getAllEntitiesFromProject: sinon.stub(), @@ -197,7 +195,6 @@ describe('ProjectEntityMongoUpdateHandler', function () { '../Cooldown/CooldownManager': this.CooldownManager, '../../models/Folder': { Folder: this.FolderModel }, '../../infrastructure/LockManager': this.LockManager, - '../../models/DeletedFile': { DeletedFile }, '../../models/Project': { Project }, './ProjectEntityHandler': this.ProjectEntityHandler, './ProjectLocator': this.ProjectLocator, @@ -208,7 +205,6 @@ describe('ProjectEntityMongoUpdateHandler', function () { }) afterEach(function () { - this.DeletedFileMock.restore() this.ProjectMock.restore() tk.reset() }) @@ -374,17 +370,6 @@ describe('ProjectEntityMongoUpdateHandler', function () { linkedFileData: { some: 'data' }, hash: 'some-hash', } - // Add a deleted file record - this.DeletedFileMock.expects('create') - .withArgs({ - projectId: this.project._id, - _id: this.file._id, - name: this.file.name, - linkedFileData: this.file.linkedFileData, - hash: this.file.hash, - deletedAt: sinon.match.date, - }) - .resolves() // Update the file in place this.ProjectMock.expects('findOneAndUpdate') .withArgs( @@ -421,7 +406,6 @@ describe('ProjectEntityMongoUpdateHandler', function () { }) it('updates the database', function () { - this.DeletedFileMock.verify() this.ProjectMock.verify() }) }) @@ -1059,29 +1043,6 @@ describe('ProjectEntityMongoUpdateHandler', function () { }) }) - describe('_insertDeletedFileReference', function () { - beforeEach(async function () { - this.DeletedFileMock.expects('create') - .withArgs({ - projectId: this.project._id, - _id: this.file._id, - name: this.file.name, - linkedFileData: this.file.linkedFileData, - hash: this.file.hash, - deletedAt: sinon.match.date, - }) - .resolves() - await this.subject.promises._insertDeletedFileReference( - this.project._id, - this.file - ) - }) - - it('should update the database', function () { - this.DeletedFileMock.verify() - }) - }) - describe('createNewFolderStructure', function () { beforeEach(function () { this.mockRootFolder = 'MOCK_ROOT_FOLDER' diff --git a/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js b/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js index 6cfe01e206..72c5080d62 100644 --- a/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js +++ b/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js @@ -133,7 +133,6 @@ describe('ProjectEntityUpdateHandler', function () { addFolder: sinon.stub(), _confirmFolder: sinon.stub(), _putElement: sinon.stub(), - _insertDeletedFileReference: sinon.stub(), replaceFileWithNew: sinon.stub(), mkdirp: sinon.stub(), moveEntity: sinon.stub(), @@ -2572,7 +2571,6 @@ describe('ProjectEntityUpdateHandler', function () { this.ProjectEntityUpdateHandler.promises.unsetRootDoc = sinon .stub() .resolves() - this.ProjectEntityMongoUpdateHandler.promises._insertDeletedFileReference.resolves() }) describe('a file', function () { @@ -2592,12 +2590,6 @@ describe('ProjectEntityUpdateHandler', function () { ) }) - it('should insert the file into the deletedFiles collection', function () { - this.ProjectEntityMongoUpdateHandler.promises._insertDeletedFileReference - .calledWith(this.project._id, this.entity) - .should.equal(true) - }) - it('should not delete the file from FileStoreHandler', function () { this.FileStoreHandler.promises.deleteFile .calledWith(projectId, this.entityId) @@ -2696,7 +2688,6 @@ describe('ProjectEntityUpdateHandler', function () { } this.ProjectEntityUpdateHandler._cleanUpDoc = sinon.stub().resolves() - this.ProjectEntityUpdateHandler._cleanUpFile = sinon.stub().resolves() const path = '/folder' this.newProject = 'new-project' this.subtreeListing = @@ -2711,17 +2702,6 @@ describe('ProjectEntityUpdateHandler', function () { ) }) - it('should clean up all sub files', function () { - this.ProjectEntityUpdateHandler._cleanUpFile.should.have.been.calledWith( - this.project, - this.file1 - ) - this.ProjectEntityUpdateHandler._cleanUpFile.should.have.been.calledWith( - this.project, - this.file2 - ) - }) - it('should clean up all sub docs', function () { this.ProjectEntityUpdateHandler._cleanUpDoc .calledWith( diff --git a/services/web/test/unit/src/helpers/models/DeletedFile.js b/services/web/test/unit/src/helpers/models/DeletedFile.js deleted file mode 100644 index 8e0b6a43b8..0000000000 --- a/services/web/test/unit/src/helpers/models/DeletedFile.js +++ /dev/null @@ -1,3 +0,0 @@ -const mockModel = require('../MockModel') - -module.exports = mockModel('DeletedFile') From 290bf71659b6ee9a39e3ee666e346037ebfb31fb Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Thu, 22 May 2025 11:12:34 +0100 Subject: [PATCH 002/259] Merge pull request #25788 from overleaf/td-layout-react Use correct layout for React pages GitOrigin-RevId: 0dbf3146273c0ac2f1549f67be374595e1b8403e --- services/web/app/views/layout-react.pug | 94 ++++++++----------- .../project/editor/socket_diagnostics.pug | 2 +- .../app/views/project/token/access-react.pug | 2 +- .../views/project/token/sharing-updates.pug | 3 +- .../web/app/views/subscriptions/add-seats.pug | 3 +- .../manually-collected-subscription.pug | 3 +- .../missing-billing-information.pug | 3 +- .../views/subscriptions/preview-change.pug | 3 +- .../subscriptions/subtotal-limit-exceeded.pug | 3 +- .../upgrade-group-subscription-react.pug | 3 +- .../app/views/user/compromised_password.pug | 2 +- .../app/views/user/confirmSecondaryEmail.pug | 2 +- .../user_membership/group-managers-react.pug | 3 +- .../user_membership/group-members-react.pug | 3 +- .../institution-managers-react.pug | 3 +- .../publisher-managers-react.pug | 3 +- .../web/frontend/js/pages/sharing-updates.tsx | 1 + .../user-activate/app/views/user/register.pug | 5 +- 18 files changed, 69 insertions(+), 72 deletions(-) diff --git a/services/web/app/views/layout-react.pug b/services/web/app/views/layout-react.pug index f3dc8e6a06..be875b29f8 100644 --- a/services/web/app/views/layout-react.pug +++ b/services/web/app/views/layout-react.pug @@ -12,72 +12,54 @@ block isApplicationPageVar - isApplicationPage = true block append meta - if bootstrapVersion === 5 - - const canDisplayAdminMenu = hasAdminAccess() - - const canDisplayAdminRedirect = canRedirectToAdminDomain() - - const sessionUser = getSessionUser() - - const staffAccess = sessionUser?.staffAccess - - const canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || staffAccess?.splitTestMetrics || staffAccess?.splitTestManagement) - - const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu - - const canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu - - const enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on') - - const showSignUpLink = hasFeature('registration-page') + - const canDisplayAdminMenu = hasAdminAccess() + - const canDisplayAdminRedirect = canRedirectToAdminDomain() + - const sessionUser = getSessionUser() + - const staffAccess = sessionUser?.staffAccess + - const canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || staffAccess?.splitTestMetrics || staffAccess?.splitTestManagement) + - const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu + - const canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu + - const enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on') + - const showSignUpLink = hasFeature('registration-page') - meta(name="ol-navbar" data-type="json" content={ - customLogo: settings.nav.custom_logo, - title: nav.title, - canDisplayAdminMenu, - canDisplayAdminRedirect, - canDisplaySplitTestMenu, - canDisplaySurveyMenu, - canDisplayScriptLogMenu, - enableUpgradeButton, - suppressNavbarRight: !!suppressNavbarRight, - suppressNavContentLinks: !!suppressNavContentLinks, - showSubscriptionLink: nav.showSubscriptionLink, - showSignUpLink: showSignUpLink, - currentUrl: currentUrl, - sessionUser: sessionUser ? { email: sessionUser.email} : undefined, - adminUrl: settings.adminUrl, - items: cloneAndTranslateText(nav.header_extras) - }) - meta(name="ol-footer" data-type="json" content={ - showThinFooter: showThinFooter, - showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by, - subdomainLang: settings.i18n.subdomainLang, - translatedLanguages: settings.translatedLanguages, - leftItems: cloneAndTranslateText(settings.nav.left_footer), - rightItems: settings.nav.right_footer - }) + meta(name="ol-navbar" data-type="json" content={ + customLogo: settings.nav.custom_logo, + title: nav.title, + canDisplayAdminMenu, + canDisplayAdminRedirect, + canDisplaySplitTestMenu, + canDisplaySurveyMenu, + canDisplayScriptLogMenu, + enableUpgradeButton, + suppressNavbarRight: !!suppressNavbarRight, + suppressNavContentLinks: !!suppressNavContentLinks, + showSubscriptionLink: nav.showSubscriptionLink, + showSignUpLink: showSignUpLink, + currentUrl: currentUrl, + sessionUser: sessionUser ? { email: sessionUser.email} : undefined, + adminUrl: settings.adminUrl, + items: cloneAndTranslateText(nav.header_extras) + }) + meta(name="ol-footer" data-type="json" content={ + showThinFooter: showThinFooter, + showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by, + subdomainLang: settings.i18n.subdomainLang, + translatedLanguages: settings.translatedLanguages, + leftItems: cloneAndTranslateText(settings.nav.left_footer), + rightItems: settings.nav.right_footer + }) block body if (typeof suppressNavbar === "undefined") - if bootstrapVersion === 5 - include layout/navbar-marketing-react-bootstrap-5 - else - include layout/navbar-marketing + include layout/navbar-marketing-react-bootstrap-5 block content if (typeof suppressFooter === "undefined") if showThinFooter - if bootstrapVersion === 5 - include layout/thin-footer-bootstrap-5 - else - include layout/thin-footer + include layout/thin-footer-bootstrap-5 else - if bootstrapVersion === 5 - include layout/fat-footer-react-bootstrap-5 - else - include layout/fat-footer + include layout/fat-footer-react-bootstrap-5 if (typeof suppressCookieBanner === "undefined") include _cookie_banner - - if bootstrapVersion === 3 - != moduleIncludes("contactModal-marketing", locals) - -block prepend foot-scripts - //- Only include Bootstrap JS if using Bootstrap 3 - if bootstrapVersion === 3 - +bootstrap-js(3) diff --git a/services/web/app/views/project/editor/socket_diagnostics.pug b/services/web/app/views/project/editor/socket_diagnostics.pug index 6876e7e39b..b288361e33 100644 --- a/services/web/app/views/project/editor/socket_diagnostics.pug +++ b/services/web/app/views/project/editor/socket_diagnostics.pug @@ -1,4 +1,4 @@ -extends ../../layout-marketing +extends ../../layout-react block vars - var suppressNavbar = true diff --git a/services/web/app/views/project/token/access-react.pug b/services/web/app/views/project/token/access-react.pug index 83e9f79b61..eabfd18eb6 100644 --- a/services/web/app/views/project/token/access-react.pug +++ b/services/web/app/views/project/token/access-react.pug @@ -1,4 +1,4 @@ -extends ../../layout-marketing +extends ../../layout-react block entrypointVar - entrypoint = 'pages/token-access' diff --git a/services/web/app/views/project/token/sharing-updates.pug b/services/web/app/views/project/token/sharing-updates.pug index a0afb0c621..66d8ac9077 100644 --- a/services/web/app/views/project/token/sharing-updates.pug +++ b/services/web/app/views/project/token/sharing-updates.pug @@ -1,4 +1,4 @@ -extends ../../layout-marketing +extends ../../layout-react block entrypointVar - entrypoint = 'pages/sharing-updates' @@ -9,6 +9,7 @@ block vars - var suppressSkipToContent = true block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-project_id" data-type="string" content=projectId) block content diff --git a/services/web/app/views/subscriptions/add-seats.pug b/services/web/app/views/subscriptions/add-seats.pug index 697a554c97..bcbf5be666 100644 --- a/services/web/app/views/subscriptions/add-seats.pug +++ b/services/web/app/views/subscriptions/add-seats.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/add-seats' block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-groupName", data-type="string", content=groupName) meta(name="ol-subscriptionId", data-type="string", content=subscriptionId) meta(name="ol-totalLicenses", data-type="number", content=totalLicenses) diff --git a/services/web/app/views/subscriptions/manually-collected-subscription.pug b/services/web/app/views/subscriptions/manually-collected-subscription.pug index 1555ac2ea1..ba6bf73473 100644 --- a/services/web/app/views/subscriptions/manually-collected-subscription.pug +++ b/services/web/app/views/subscriptions/manually-collected-subscription.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/manually-collected-subscription' block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-groupName", data-type="string", content=groupName) block content diff --git a/services/web/app/views/subscriptions/missing-billing-information.pug b/services/web/app/views/subscriptions/missing-billing-information.pug index 67d13f8e89..416bac65f5 100644 --- a/services/web/app/views/subscriptions/missing-billing-information.pug +++ b/services/web/app/views/subscriptions/missing-billing-information.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/missing-billing-information' block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-groupName", data-type="string", content=groupName) block content diff --git a/services/web/app/views/subscriptions/preview-change.pug b/services/web/app/views/subscriptions/preview-change.pug index 663bbe30d2..5330eb8684 100644 --- a/services/web/app/views/subscriptions/preview-change.pug +++ b/services/web/app/views/subscriptions/preview-change.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/preview-change' block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview) meta(name="ol-purchaseReferrer" data-type="string" content=purchaseReferrer) diff --git a/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug b/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug index 15f79488fa..4457383e93 100644 --- a/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug +++ b/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/subtotal-limit-exceeded' block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-groupName", data-type="string", content=groupName) block content diff --git a/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug b/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug index c482629463..4347a2a633 100644 --- a/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug +++ b/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/upgrade-group-subscription' block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview) meta(name="ol-totalLicenses", data-type="number", content=totalLicenses) meta(name="ol-groupName", data-type="string", content=groupName) diff --git a/services/web/app/views/user/compromised_password.pug b/services/web/app/views/user/compromised_password.pug index e56ffd9841..c66a07415a 100644 --- a/services/web/app/views/user/compromised_password.pug +++ b/services/web/app/views/user/compromised_password.pug @@ -1,4 +1,4 @@ -extends ../layout-marketing +extends ../layout-react block vars - var suppressNavbar = true diff --git a/services/web/app/views/user/confirmSecondaryEmail.pug b/services/web/app/views/user/confirmSecondaryEmail.pug index 4d0c59e9db..181e58e4ce 100644 --- a/services/web/app/views/user/confirmSecondaryEmail.pug +++ b/services/web/app/views/user/confirmSecondaryEmail.pug @@ -1,4 +1,4 @@ -extends ../layout-marketing +extends ../layout-react block vars - var suppressNavbar = true diff --git a/services/web/app/views/user_membership/group-managers-react.pug b/services/web/app/views/user_membership/group-managers-react.pug index f4d8c0e973..d227a7a511 100644 --- a/services/web/app/views/user_membership/group-managers-react.pug +++ b/services/web/app/views/user_membership/group-managers-react.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/group-managers' block append meta + meta(name="ol-user", data-type="json", content=user) meta(name="ol-users", data-type="json", content=users) meta(name="ol-groupId", data-type="string", content=groupId) meta(name="ol-groupName", data-type="string", content=name) diff --git a/services/web/app/views/user_membership/group-members-react.pug b/services/web/app/views/user_membership/group-members-react.pug index 314a332489..5e8971172d 100644 --- a/services/web/app/views/user_membership/group-members-react.pug +++ b/services/web/app/views/user_membership/group-members-react.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/group-members' block append meta + meta(name="ol-user", data-type="json", content=user) meta(name="ol-users", data-type="json", content=users) meta(name="ol-groupId", data-type="string", content=groupId) meta(name="ol-groupName", data-type="string", content=name) diff --git a/services/web/app/views/user_membership/institution-managers-react.pug b/services/web/app/views/user_membership/institution-managers-react.pug index 690e8409f2..ee62fcd430 100644 --- a/services/web/app/views/user_membership/institution-managers-react.pug +++ b/services/web/app/views/user_membership/institution-managers-react.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/institution-managers' block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-users", data-type="json", content=users) meta(name="ol-groupId", data-type="string", content=groupId) meta(name="ol-groupName", data-type="string", content=name) diff --git a/services/web/app/views/user_membership/publisher-managers-react.pug b/services/web/app/views/user_membership/publisher-managers-react.pug index 793bdf9602..a956e30c35 100644 --- a/services/web/app/views/user_membership/publisher-managers-react.pug +++ b/services/web/app/views/user_membership/publisher-managers-react.pug @@ -1,9 +1,10 @@ -extends ../layout-marketing +extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/publisher-managers' block append meta + meta(name="ol-user" data-type="json" content=user) meta(name="ol-users", data-type="json", content=users) meta(name="ol-groupId", data-type="string", content=groupId) meta(name="ol-groupName", data-type="string", content=name) diff --git a/services/web/frontend/js/pages/sharing-updates.tsx b/services/web/frontend/js/pages/sharing-updates.tsx index ec4c974ea0..7f1a097e8c 100644 --- a/services/web/frontend/js/pages/sharing-updates.tsx +++ b/services/web/frontend/js/pages/sharing-updates.tsx @@ -1,6 +1,7 @@ import './../utils/meta' import '../utils/webpack-public-path' import './../infrastructure/error-reporter' +import './../features/header-footer-react' import '@/i18n' import { createRoot } from 'react-dom/client' import SharingUpdatesRoot from '../features/token-access/components/sharing-updates-root' diff --git a/services/web/modules/user-activate/app/views/user/register.pug b/services/web/modules/user-activate/app/views/user/register.pug index 213fff7f3f..0f3e5f2f91 100644 --- a/services/web/modules/user-activate/app/views/user/register.pug +++ b/services/web/modules/user-activate/app/views/user/register.pug @@ -1,8 +1,11 @@ -extends ../../../../../app/views/layout-marketing +extends ../../../../../app/views/layout-react block entrypointVar - entrypoint = 'modules/user-activate/pages/user-activate-page' +block append meta + meta(name="ol-user" data-type="json" content=user) + block content .content.content-alt#main-content .container From a77f218a7783268d2589f3de72f2f2d5e4d21049 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Thu, 22 May 2025 11:13:02 +0100 Subject: [PATCH 003/259] Merge pull request #25805 from overleaf/td-bs5-rename-auth-pages-feature-flag Change auth pages feature flag GitOrigin-RevId: 091b2cde7cc4f91e2ce7533d610db773fc622bb5 --- .../src/Features/PasswordReset/PasswordResetController.mjs | 4 ++-- services/web/app/src/Features/User/UserEmailsController.js | 2 +- services/web/app/src/Features/User/UserPagesController.mjs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs index 40e3a06e44..b7fc2da9c8 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -148,7 +148,7 @@ async function renderSetPasswordForm(req, res, next) { const { variant } = await SplitTestHandler.promises.getAssignment( req, res, - 'auth-pages-bs5' + 'bs5-auth-pages' ) if (req.query.passwordResetToken != null) { @@ -217,7 +217,7 @@ async function renderRequestResetForm(req, res) { const { variant } = await SplitTestHandler.promises.getAssignment( req, res, - 'auth-pages-bs5' + 'bs5-auth-pages' ) const template = diff --git a/services/web/app/src/Features/User/UserEmailsController.js b/services/web/app/src/Features/User/UserEmailsController.js index 54ace10cb0..8a7c2bbeb4 100644 --- a/services/web/app/src/Features/User/UserEmailsController.js +++ b/services/web/app/src/Features/User/UserEmailsController.js @@ -530,7 +530,7 @@ async function primaryEmailCheckPage(req, res) { const { variant } = await SplitTestHandler.promises.getAssignment( req, res, - 'auth-pages-bs5' + 'bs5-auth-pages' ) const template = diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs index 6f7bb7802d..29fc505a7c 100644 --- a/services/web/app/src/Features/User/UserPagesController.mjs +++ b/services/web/app/src/Features/User/UserPagesController.mjs @@ -195,7 +195,7 @@ async function reconfirmAccountPage(req, res) { const { variant } = await SplitTestHandler.promises.getAssignment( req, res, - 'auth-pages-bs5' + 'bs5-auth-pages' ) const template = From d0010217cd98c1289ca06730324431ae059ab6cb Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Thu, 22 May 2025 11:13:42 +0100 Subject: [PATCH 004/259] Merge pull request #25613 from overleaf/td-bs5-sp-login Migrate SP/CE login page to Bootstrap 5 GitOrigin-RevId: 37fc7cbb453bfef93abde2080faaa0a88116d1f4 --- .../web/app/views/_mixins/formMessages.pug | 37 ++++---- services/web/app/views/user/login.pug | 88 +++++++++---------- .../js/features/form-helpers/create-icon.js | 7 ++ .../js/features/form-helpers/hydrate-form.js | 13 ++- .../features/form-helpers/input-validator.js | 20 ++++- .../bootstrap-5/components/notifications.scss | 5 ++ 6 files changed, 99 insertions(+), 71 deletions(-) create mode 100644 services/web/frontend/js/features/form-helpers/create-icon.js diff --git a/services/web/app/views/_mixins/formMessages.pug b/services/web/app/views/_mixins/formMessages.pug index 9ea239277a..9a76d118c7 100644 --- a/services/web/app/views/_mixins/formMessages.pug +++ b/services/web/app/views/_mixins/formMessages.pug @@ -4,11 +4,12 @@ mixin formMessages() role="alert" ) -mixin formMessagesNewStyle() +mixin formMessagesNewStyle(extraClass = 'form-messages-bottom-margin') + - const attrs = extraClass ? { 'class': extraClass } : {} div( data-ol-form-messages-new-style='', role="alert" - ) + )&attributes(attrs) mixin customFormMessage(key, kind) if kind === 'success' @@ -36,20 +37,23 @@ mixin customFormMessage(key, kind) ) block -mixin customFormMessageNewStyle(key, kind) +mixin customFormMessageNewStyle(key, kind, extraClass = 'mb-3') + - extraClass = extraClass ? ' ' + extraClass : '' if kind === 'success' - div.notification.notification-type-success( - hidden, - data-ol-custom-form-message=key, - role="alert" - aria-live="polite" - ) + div( + class="notification notification-type-success" + extraClass, + hidden, + data-ol-custom-form-message=key, + role="alert" + aria-live="polite" + ) div.notification-icon span.material-symbols(aria-hidden="true") check_circle div.notification-content.text-left block else if kind === 'danger' - div.notification.notification-type-error( + div( + class="notification notification-type-error" + extraClass, hidden, data-ol-custom-form-message=key, role="alert" @@ -60,12 +64,13 @@ mixin customFormMessageNewStyle(key, kind) div.notification-content.text-left block else - div.notification.notification-type-warning( - hidden, - data-ol-custom-form-message=key, - role="alert" - aria-live="polite" - ) + div( + class="notification notification-type-warning" + extraClass, + hidden, + data-ol-custom-form-message=key, + role="alert" + aria-live="polite" + ) div.notification-icon span.material-symbols(aria-hidden="true") warning div.notification-content.text-left diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 9185b0b14b..1ad77cb8b4 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -1,52 +1,50 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container .row - .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4 + .col-lg-6.offset-lg-3.col-xl-4.offset-xl-4 .card - .page-header - if login_support_title - h1 !{login_support_title} - else - h1 #{translate("log_in")} - form(data-ol-async-form, name="loginForm", action='/login', method="POST") - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() - +customFormMessage('invalid-password-retry-or-reset', 'danger') - | !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])} - span.sr-only(id='resetPasswordDescription') - | #{translate('reset_password_link')} - +customValidationMessage('password-compromised') - | !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}. - .form-group - input.form-control( - type='email', - name='email', - required, - placeholder='email@example.com', - autofocus="true" - ) - .form-group - input.form-control( - type='password', - name='password', - required, - placeholder='********', - ) - .actions - button.btn-primary.btn( - type='submit', - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("login")} - span(hidden data-ol-inflight="pending") #{translate("logging_in")}… - a.pull-right(href='/user/password/reset') #{translate("forgot_your_password")}? - if login_support_text - hr - p.text-center !{login_support_text} - + .card-body + .page-header + if login_support_title + h1 !{login_support_title} + else + h1 #{translate("log_in")} + form(data-ol-async-form, name="loginForm", action='/login', method="POST") + input(name='_csrf', type='hidden', value=csrfToken) + +formMessagesNewStyle() + +customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger') + | !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])} + span.visually-hidden(id='resetPasswordDescription') + | #{translate('reset_password_link')} + +customFormMessageNewStyle('password-compromised') + | !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}. + .form-group + input.form-control( + type='email', + name='email', + required, + placeholder='email@example.com', + autofocus="true" + ) + .form-group + input.form-control( + type='password', + name='password', + required, + placeholder='********', + ) + .actions + button.btn-primary.btn( + type='submit', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("login")} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}? + if login_support_text + hr + p.text-center !{login_support_text} + diff --git a/services/web/frontend/js/features/form-helpers/create-icon.js b/services/web/frontend/js/features/form-helpers/create-icon.js new file mode 100644 index 0000000000..fc26724bee --- /dev/null +++ b/services/web/frontend/js/features/form-helpers/create-icon.js @@ -0,0 +1,7 @@ +export default function createIcon(type) { + const icon = document.createElement('span') + icon.className = 'material-symbols' + icon.setAttribute('aria-hidden', 'true') + icon.textContent = type + return icon +} diff --git a/services/web/frontend/js/features/form-helpers/hydrate-form.js b/services/web/frontend/js/features/form-helpers/hydrate-form.js index ed7b9fc26e..89bd1a657d 100644 --- a/services/web/frontend/js/features/form-helpers/hydrate-form.js +++ b/services/web/frontend/js/features/form-helpers/hydrate-form.js @@ -4,6 +4,7 @@ import { canSkipCaptcha, validateCaptchaV2 } from './captcha' import inputValidator from './input-validator' import { disableElement, enableElement } from '../utils/disableElement' import { isBootstrap5 } from '@/features/utils/bootstrap-5' +import createIcon from '@/features/form-helpers/create-icon' // Form helper(s) to handle: // - Attaching to the relevant form elements @@ -164,10 +165,7 @@ function createNotificationFromMessageBS5(message) { if (materialIcon) { const iconEl = document.createElement('div') iconEl.className = 'notification-icon' - const iconSpan = document.createElement('span') - iconSpan.className = 'material-symbols' - iconSpan.setAttribute('aria-hidden', 'true') - iconSpan.textContent = materialIcon + const iconSpan = createIcon(materialIcon) iconEl.append(iconSpan) messageEl.append(iconEl) } @@ -315,10 +313,9 @@ function showMessagesNewStyle(formEl, messageBag) { } // create the left icon - const icon = document.createElement('span') - icon.className = 'material-symbols' - icon.setAttribute('aria-hidden', 'true') - icon.innerText = message.type === 'error' ? 'error' : 'check_circle' + const icon = createIcon( + message.type === 'error' ? 'error' : 'check_circle' + ) const messageIcon = document.createElement('div') messageIcon.className = 'notification-icon' messageIcon.appendChild(icon) diff --git a/services/web/frontend/js/features/form-helpers/input-validator.js b/services/web/frontend/js/features/form-helpers/input-validator.js index 411c6c0e83..f01c4af3da 100644 --- a/services/web/frontend/js/features/form-helpers/input-validator.js +++ b/services/web/frontend/js/features/form-helpers/input-validator.js @@ -1,9 +1,25 @@ +import { isBootstrap5 } from '@/features/utils/bootstrap-5' +import createIcon from '@/features/form-helpers/create-icon' + export default function inputValidator(inputEl) { const messageEl = document.createElement('div') messageEl.className = inputEl.getAttribute('data-ol-validation-message-classes') || - 'small text-danger mt-2' + 'small text-danger mt-2 form-text' messageEl.hidden = true + + const messageInnerEl = messageEl.appendChild(document.createElement('span')) + messageInnerEl.className = 'form-text-inner' + + const messageTextNode = document.createTextNode('') + + // In Bootstrap 5, add an icon + if (isBootstrap5()) { + const iconEl = createIcon('error') + messageInnerEl.append(iconEl) + } + messageInnerEl.append(messageTextNode) + inputEl.insertAdjacentElement('afterend', messageEl) // Hide messages until the user leaves the input field or submits the form. @@ -54,7 +70,7 @@ export default function inputValidator(inputEl) { // Require another blur before displaying errors again. canDisplayErrorMessages = false } else { - messageEl.textContent = inputEl.validationMessage + messageTextNode.data = inputEl.validationMessage messageEl.hidden = false } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss b/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss index 2dc789ad44..ece1a465a4 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss @@ -257,3 +257,8 @@ } } } + +// Only apply bottom margin to form messages when it is non-empty +.form-messages-bottom-margin > :last-child { + margin-bottom: var(--spacing-06); +} From 2f87db9c0d9260ec05a2c14c30ecca0dee0123dd Mon Sep 17 00:00:00 2001 From: Liangjun Song <146005915+adai26@users.noreply.github.com> Date: Thu, 22 May 2025 11:51:53 +0100 Subject: [PATCH 005/259] Merge pull request #24790 from overleaf/ls-use-script-runner Update some scripts to use Script Runner GitOrigin-RevId: aaa11f94dcfd328c158bb02d1b9fb2adfb1bb146 --- libraries/mongo-utils/batchedUpdate.js | 12 ++++-- .../add_salesforce_data_to_subscriptions.mjs | 37 ++++++++++--------- .../web/scripts/add_user_count_to_csv.mjs | 3 +- ...ckfill_recurly_to_subscription_mapping.mjs | 6 ++- .../sync_group_subscription_memberships.mjs | 3 +- .../back_fill_doc_name_for_deleted_docs.mjs | 11 ++++-- .../web/scripts/back_fill_dummy_doc_meta.mjs | 3 +- .../web/scripts/back_fill_staff_access.mjs | 3 +- ...g_user_personal_and_group_subscription.mjs | 20 +++++++--- services/web/scripts/check_docs.mjs | 3 +- .../web/scripts/check_institution_users.mjs | 3 +- services/web/scripts/clear_admin_sessions.mjs | 3 +- .../clear_institution_notifications.mjs | 3 +- services/web/scripts/clear_project_tokens.mjs | 3 +- services/web/scripts/clear_sessions_2fa.mjs | 3 +- services/web/scripts/convert_doc_to_file.mjs | 3 +- .../scripts/count_encrypted_access_tokens.mjs | 3 +- services/web/scripts/count_image_files.mjs | 3 +- .../web/scripts/delete_dangling_file_refs.mjs | 3 +- .../delete_orphaned_doc_comment_ranges.mjs | 3 +- .../delete_orphaned_docs_online_check.mjs | 3 +- services/web/scripts/disconnect_all_users.mjs | 3 +- services/web/scripts/e2e_test_setup.mjs | 3 +- .../web/scripts/find_malformed_filetrees.mjs | 14 +++++-- .../fix_group_invite_emails_to_lowercase.mjs | 14 +++++-- .../web/scripts/fix_malformed_filetree.mjs | 3 +- services/web/scripts/fix_oversized_docs.mjs | 3 +- services/web/scripts/force_doc_flush.mjs | 3 +- .../scripts/history/clean_sl_history_data.mjs | 3 +- .../history/migrate_ranges_support.mjs | 3 +- .../web/scripts/learn/checkSanitize/index.mjs | 3 +- services/web/scripts/lib/ScriptRunner.mjs | 25 ++++++++----- .../lowercase_institution_user_ids.mjs | 3 +- .../merge_group_subscription_members.mjs | 3 +- services/web/scripts/migrate_audit_logs.mjs | 15 ++++++-- .../scripts/oauth/backfill_hashed_secrets.mjs | 3 +- services/web/scripts/oauth/create_token.mjs | 3 +- .../web/scripts/oauth/register_client.mjs | 3 +- services/web/scripts/oauth/remove_client.mjs | 3 +- .../web/scripts/recover_docs_from_redis.mjs | 3 +- .../recurly/change_prices_at_renewal.mjs | 3 +- .../collect_paypal_past_due_invoice.mjs | 3 +- .../scripts/recurly/generate_addon_prices.mjs | 3 +- .../get_manually_billed_users_details.mjs | 3 +- .../recurly/get_recurly_group_prices.mjs | 3 +- ...d_conditions_for_manually_billed_users.mjs | 3 +- .../web/scripts/refresh_institution_users.mjs | 3 +- .../scripts/remove_feature_from_all_users.mjs | 3 +- .../web/scripts/remove_oauth_application.mjs | 3 +- .../web/scripts/remove_unconfirmed_emails.mjs | 13 ++++--- .../web/scripts/restore_orphaned_docs.mjs | 3 +- .../web/scripts/restore_soft_deleted_docs.mjs | 3 +- services/web/scripts/set_tex_live_image.mjs | 3 +- services/web/scripts/soft_delete_project.mjs | 3 +- .../web/scripts/sso_id_migration_check.mjs | 3 +- .../scripts/sso_id_remove_not_migrated.mjs | 3 +- .../web/scripts/undelete_project_to_user.mjs | 3 +- .../web/scripts/unlink_third_party_id.mjs | 3 +- .../back_fill_hiding_ai_features.mjs | 10 +++-- .../enable_wf_autoCreatedAccount.mjs | 3 +- ...e_writefull_without_autoCreatedAccount.mjs | 3 +- .../split_writefull_disabled_from_unset.mjs | 10 +++-- 62 files changed, 225 insertions(+), 112 deletions(-) diff --git a/libraries/mongo-utils/batchedUpdate.js b/libraries/mongo-utils/batchedUpdate.js index 7e3ad677db..41af41f0d4 100644 --- a/libraries/mongo-utils/batchedUpdate.js +++ b/libraries/mongo-utils/batchedUpdate.js @@ -35,6 +35,7 @@ let BATCHED_UPDATE_RUNNING = false * @property {string} [BATCH_RANGE_START] * @property {string} [BATCH_SIZE] * @property {string} [VERBOSE_LOGGING] + * @property {(progress: string) => Promise} [trackProgress] */ /** @@ -210,7 +211,7 @@ async function batchedUpdate( update, projection, findOptions, - batchedUpdateOptions + batchedUpdateOptions = {} ) { // only a single batchedUpdate can run at a time due to global variables if (BATCHED_UPDATE_RUNNING) { @@ -226,6 +227,8 @@ async function batchedUpdate( return 0 } refreshGlobalOptionsForBatchedUpdate(batchedUpdateOptions) + const { trackProgress = async progress => console.warn(progress) } = + batchedUpdateOptions findOptions = findOptions || {} findOptions.readPreference = READ_PREFERENCE_SECONDARY @@ -255,9 +258,10 @@ async function batchedUpdate( nextBatch.map(entry => entry._id) )}` ) - } else { - console.error(`Running update on batch ending ${renderObjectId(end)}`) } + await trackProgress( + `Running update on batch ending ${renderObjectId(end)}` + ) if (typeof update === 'function') { await update(nextBatch) @@ -265,7 +269,7 @@ async function batchedUpdate( await performUpdate(collection, nextBatch, update) } } - console.error(`Completed batch ending ${renderObjectId(end)}`) + await trackProgress(`Completed batch ending ${renderObjectId(end)}`) start = end } return updated diff --git a/services/web/scripts/add_salesforce_data_to_subscriptions.mjs b/services/web/scripts/add_salesforce_data_to_subscriptions.mjs index 02ef02689f..264640f587 100755 --- a/services/web/scripts/add_salesforce_data_to_subscriptions.mjs +++ b/services/web/scripts/add_salesforce_data_to_subscriptions.mjs @@ -4,6 +4,7 @@ import { parse } from 'csv' import Stream from 'node:stream/promises' import { ObjectId } from '../app/src/infrastructure/mongodb.js' import { Subscription } from '../app/src/models/Subscription.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' function usage() { console.log( @@ -76,20 +77,22 @@ const stats = { }, } -function showStats() { - console.log('Stats:') - console.log(` Total rows: ${stats.totalRows}`) - console.log(` Processed rows: ${stats.processedRows}`) - console.log(` Skipped (no subscription ID): ${stats.subscriptionIDMissing}`) - console.log(` Used V1 ID: ${stats.usedV1ID}`) - console.log(` Used Salesforce ID: ${stats.usedSalesforceID}`) - if (commit) { - console.log('Database operations:') - console.log(` Errors: ${stats.db.errors}`) - console.log(` Matched: ${stats.db.matched}`) - console.log(` Updated: ${stats.db.updated}`) - console.log(` Update attempted: ${stats.db.updateAttempted}`) - } +function generateStats() { + return `Stats: + Total rows: ${stats.totalRows} + Processed rows: ${stats.processedRows} + Skipped (no subscription ID): ${stats.subscriptionIDMissing} + Used V1 ID: ${stats.usedV1ID} + Used Salesforce ID: ${stats.usedSalesforceID}${ + commit + ? ` +Database operations: + Errors: ${stats.db.errors} + Matched: ${stats.db.matched} + Updated: ${stats.db.updated} + Update attempted: ${stats.db.updateAttempted}` + : '' + }` } function pickRelevantColumns(row) { @@ -161,7 +164,7 @@ async function processRows(rows) { } } -async function main() { +async function main(trackProgress) { await Stream.pipeline( fs.createReadStream(filename), parse({ @@ -191,6 +194,7 @@ async function main() { }), processRows ) + await trackProgress(generateStats()) } if (!commit) { @@ -199,6 +203,5 @@ if (!commit) { console.log('Committing changes to the database') } -await main() -showStats() +await scriptRunner(main) process.exit() diff --git a/services/web/scripts/add_user_count_to_csv.mjs b/services/web/scripts/add_user_count_to_csv.mjs index 04709df5b2..5447ec9c73 100644 --- a/services/web/scripts/add_user_count_to_csv.mjs +++ b/services/web/scripts/add_user_count_to_csv.mjs @@ -9,6 +9,7 @@ import minimist from 'minimist' import UserGetter from '../app/src/Features/User/UserGetter.js' import { db } from '../app/src/infrastructure/mongodb.js' import _ from 'lodash' +import { scriptRunner } from './lib/ScriptRunner.mjs' const argv = minimist(process.argv.slice(2), { string: ['domain', 'output'], @@ -86,7 +87,7 @@ async function getUsersByHostnameWithSubdomain(domain, projection) { } try { - await main() + await scriptRunner(main) console.log('Done') process.exit(0) } catch (error) { diff --git a/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs b/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs index 16f5abe0ad..9ad583844c 100644 --- a/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs +++ b/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs @@ -19,6 +19,7 @@ import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappin import { registerAccountMapping } from '../../app/src/Features/Analytics/AnalyticsManager.js' import { triggerGracefulShutdown } from '../../app/src/infrastructure/GracefulShutdown.js' import Validation from '../../app/src/infrastructure/Validation.js' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const paramsSchema = Validation.Joi.object({ endDate: Validation.Joi.string().isoDate(), @@ -59,7 +60,7 @@ function registerMapping(subscription) { } } -async function main() { +async function main(trackProgress) { const additionalBatchedUpdateOptions = {} if (endDate) { @@ -83,6 +84,7 @@ async function main() { { verboseLogging: verbose, ...additionalBatchedUpdateOptions, + trackProgress, } ) @@ -109,7 +111,7 @@ if (error) { triggerGracefulShutdown(done => done(1)) } else { logger.info({ verbose, commit, endDate }, commit ? 'COMMITTING' : 'DRY RUN') - await main() + await scriptRunner(main) triggerGracefulShutdown({ close(done) { diff --git a/services/web/scripts/analytics/sync_group_subscription_memberships.mjs b/services/web/scripts/analytics/sync_group_subscription_memberships.mjs index 5bca95eef4..f687f43460 100644 --- a/services/web/scripts/analytics/sync_group_subscription_memberships.mjs +++ b/services/web/scripts/analytics/sync_group_subscription_memberships.mjs @@ -5,6 +5,7 @@ import { DeletedSubscription } from '../../app/src/models/DeletedSubscription.js import minimist from 'minimist' import _ from 'lodash' import mongodb from 'mongodb-legacy' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const { ObjectId } = mongodb @@ -272,7 +273,7 @@ const setup = () => { setup() try { - await main() + await scriptRunner(main) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/back_fill_doc_name_for_deleted_docs.mjs b/services/web/scripts/back_fill_doc_name_for_deleted_docs.mjs index d22bb3ea7e..b311176184 100644 --- a/services/web/scripts/back_fill_doc_name_for_deleted_docs.mjs +++ b/services/web/scripts/back_fill_doc_name_for_deleted_docs.mjs @@ -3,10 +3,11 @@ import { promiseMapWithLimit, promisify } from '@overleaf/promise-utils' import { db } from '../app/src/infrastructure/mongodb.js' import { fileURLToPath } from 'node:url' import _ from 'lodash' +import { scriptRunner } from './lib/ScriptRunner.mjs' const sleep = promisify(setTimeout) -async function main(options) { +async function main(options, trackProgress) { if (!options) { options = {} } @@ -28,7 +29,9 @@ async function main(options) { async projects => { await processBatch(projects, options) }, - { _id: 1, deletedDocs: 1 } + { _id: 1, deletedDocs: 1 }, + undefined, + { trackProgress } ) } @@ -83,7 +86,9 @@ export default main if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner( + async trackProgress => await main(undefined, trackProgress) + ) process.exit(0) } catch (error) { console.error({ error }) diff --git a/services/web/scripts/back_fill_dummy_doc_meta.mjs b/services/web/scripts/back_fill_dummy_doc_meta.mjs index 0fccdc46a7..e0684ea0be 100644 --- a/services/web/scripts/back_fill_dummy_doc_meta.mjs +++ b/services/web/scripts/back_fill_dummy_doc_meta.mjs @@ -7,6 +7,7 @@ import { import _ from 'lodash' import LRUCache from 'lru-cache' import { fileURLToPath } from 'node:url' +import { scriptRunner } from './lib/ScriptRunner.mjs' const { ObjectId } = mongodb const sleep = promisify(setTimeout) @@ -151,7 +152,7 @@ export default main if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner(async () => await main()) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/back_fill_staff_access.mjs b/services/web/scripts/back_fill_staff_access.mjs index c26958e8d9..76d17fba83 100644 --- a/services/web/scripts/back_fill_staff_access.mjs +++ b/services/web/scripts/back_fill_staff_access.mjs @@ -3,6 +3,7 @@ import { READ_PREFERENCE_SECONDARY, } from '../app/src/infrastructure/mongodb.js' import UserSessionsManager from '../app/src/Features/User/UserSessionsManager.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const COMMIT = process.argv.includes('--commit') const KEEP_SESSIONS = process.argv.includes('--keep-sessions') @@ -84,7 +85,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/back_fill_warning_user_personal_and_group_subscription.mjs b/services/web/scripts/back_fill_warning_user_personal_and_group_subscription.mjs index efa5a79197..f9b5388e45 100644 --- a/services/web/scripts/back_fill_warning_user_personal_and_group_subscription.mjs +++ b/services/web/scripts/back_fill_warning_user_personal_and_group_subscription.mjs @@ -1,6 +1,7 @@ import NotificationsBuilder from '../app/src/Features/Notifications/NotificationsBuilder.js' import { db } from '../app/src/infrastructure/mongodb.js' import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const DRY_RUN = !process.argv.includes('--dry-run=false') @@ -55,14 +56,23 @@ async function processBatch(groupSubscriptionsBatch) { } } -async function main() { - await batchedUpdate(db.subscriptions, { groupPlan: true }, processBatch, { - member_ids: 1, - }) +async function main(trackProgress) { + await batchedUpdate( + db.subscriptions, + { groupPlan: true }, + processBatch, + { + member_ids: 1, + }, + undefined, + { + trackProgress, + } + ) } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/check_docs.mjs b/services/web/scripts/check_docs.mjs index d6fccd5db3..699738f75c 100644 --- a/services/web/scripts/check_docs.mjs +++ b/services/web/scripts/check_docs.mjs @@ -9,6 +9,7 @@ import { } from '../app/src/infrastructure/mongodb.js' import DocstoreManager from '../app/src/Features/Docstore/DocstoreManager.js' import { NotFoundError } from '../app/src/Features/Errors/Errors.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const OPTS = parseArgs() @@ -213,7 +214,7 @@ function docsHaveTrackedChanges(docs) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (err) { console.error(err) diff --git a/services/web/scripts/check_institution_users.mjs b/services/web/scripts/check_institution_users.mjs index e89c0d114b..5cb4fb7973 100644 --- a/services/web/scripts/check_institution_users.mjs +++ b/services/web/scripts/check_institution_users.mjs @@ -1,5 +1,6 @@ import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js' import { ensureRunningOnMongoSecondaryWithTimeout } from './helpers/env_variable_helper.mjs' +import { scriptRunner } from './lib/ScriptRunner.mjs' ensureRunningOnMongoSecondaryWithTimeout(300000) @@ -18,7 +19,7 @@ async function main() { } try { - await main() + await scriptRunner(main) } catch (error) { console.error(error) process.exit(1) diff --git a/services/web/scripts/clear_admin_sessions.mjs b/services/web/scripts/clear_admin_sessions.mjs index 3d5623c864..c3f5b28c4b 100644 --- a/services/web/scripts/clear_admin_sessions.mjs +++ b/services/web/scripts/clear_admin_sessions.mjs @@ -3,6 +3,7 @@ import { READ_PREFERENCE_SECONDARY, } from '../app/src/infrastructure/mongodb.js' import UserSessionsManager from '../app/src/Features/User/UserSessionsManager.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const COMMIT = process.argv.includes('--commit') const LOG_SESSIONS = !process.argv.includes('--log-sessions=false') @@ -58,7 +59,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/clear_institution_notifications.mjs b/services/web/scripts/clear_institution_notifications.mjs index b97c20d5ff..29a77de5db 100644 --- a/services/web/scripts/clear_institution_notifications.mjs +++ b/services/web/scripts/clear_institution_notifications.mjs @@ -1,6 +1,7 @@ import { promisify } from 'node:util' import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js' import { fileURLToPath } from 'node:url' +import { scriptRunner } from './lib/ScriptRunner.mjs' const sleep = promisify(setTimeout) async function main() { @@ -39,7 +40,7 @@ async function main() { if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner(main) console.log('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/clear_project_tokens.mjs b/services/web/scripts/clear_project_tokens.mjs index ab3d3c5a84..798e67aded 100644 --- a/services/web/scripts/clear_project_tokens.mjs +++ b/services/web/scripts/clear_project_tokens.mjs @@ -1,4 +1,5 @@ import ProjectDetailsHandler from '../app/src/Features/Project/ProjectDetailsHandler.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const projectId = process.argv[2] if (!/^(?=[a-f\d]{24}$)(\d+[a-f]|[a-f]+\d)/.test(projectId)) { @@ -21,7 +22,7 @@ function main() { } try { - await main() + await scriptRunner(main) } catch (error) { console.error(error) process.exit(1) diff --git a/services/web/scripts/clear_sessions_2fa.mjs b/services/web/scripts/clear_sessions_2fa.mjs index e6058d357c..d6557faa62 100644 --- a/services/web/scripts/clear_sessions_2fa.mjs +++ b/services/web/scripts/clear_sessions_2fa.mjs @@ -1,6 +1,7 @@ import { promisify, promiseMapWithLimit } from '@overleaf/promise-utils' import UserSessionsRedis from '../app/src/Features/User/UserSessionsRedis.js' import minimist from 'minimist' +import { scriptRunner } from './lib/ScriptRunner.mjs' const rClient = UserSessionsRedis.client() @@ -76,7 +77,7 @@ async function main() { } try { - await main() + await scriptRunner(main) } catch (error) { console.error(error) process.exit(1) diff --git a/services/web/scripts/convert_doc_to_file.mjs b/services/web/scripts/convert_doc_to_file.mjs index db4cb8b309..a1d401dc23 100644 --- a/services/web/scripts/convert_doc_to_file.mjs +++ b/services/web/scripts/convert_doc_to_file.mjs @@ -2,6 +2,7 @@ import minimist from 'minimist' import { ObjectId } from '../app/src/infrastructure/mongodb.js' import ProjectEntityUpdateHandler from '../app/src/Features/Project/ProjectEntityUpdateHandler.js' import Errors from '../app/src/Features/Errors/Errors.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' async function main() { const argv = minimist(process.argv.slice(2)) @@ -35,7 +36,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.log('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/count_encrypted_access_tokens.mjs b/services/web/scripts/count_encrypted_access_tokens.mjs index 003179f3eb..e989d49d15 100644 --- a/services/web/scripts/count_encrypted_access_tokens.mjs +++ b/services/web/scripts/count_encrypted_access_tokens.mjs @@ -5,6 +5,7 @@ import { import _ from 'lodash' import { formatTokenUsageStats } from '@overleaf/access-token-encryptor/scripts/helpers/format-usage-stats.js' import { ensureMongoTimeout } from './helpers/env_variable_helper.mjs' +import { scriptRunner } from './lib/ScriptRunner.mjs' if (!process.env.MONGO_SOCKET_TIMEOUT) { const TEN_MINUTES = 1000 * 60 * 10 @@ -65,7 +66,7 @@ async function main() { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/count_image_files.mjs b/services/web/scripts/count_image_files.mjs index 17ad4b84ab..33a9bcc1fd 100644 --- a/services/web/scripts/count_image_files.mjs +++ b/services/web/scripts/count_image_files.mjs @@ -3,6 +3,7 @@ import { READ_PREFERENCE_SECONDARY, } from '../app/src/infrastructure/mongodb.js' import { extname } from 'node:path' +import { scriptRunner } from './lib/ScriptRunner.mjs' const FILE_TYPES = [ '.jpg', @@ -71,7 +72,7 @@ function countFiles(folder, result) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/delete_dangling_file_refs.mjs b/services/web/scripts/delete_dangling_file_refs.mjs index 39383bed79..50dde9e174 100644 --- a/services/web/scripts/delete_dangling_file_refs.mjs +++ b/services/web/scripts/delete_dangling_file_refs.mjs @@ -10,6 +10,7 @@ import Errors from '../app/src/Features/Errors/Errors.js' import FileStoreHandler from '../app/src/Features/FileStore/FileStoreHandler.js' import ProjectEntityMongoUpdateHandler from '../app/src/Features/Project/ProjectEntityMongoUpdateHandler.js' import { iterablePaths } from '../app/src/Features/Project/IterablePath.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const { ObjectId } = mongodb @@ -123,7 +124,7 @@ async function deleteFile(projectId, fileId) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error({ error }) diff --git a/services/web/scripts/delete_orphaned_doc_comment_ranges.mjs b/services/web/scripts/delete_orphaned_doc_comment_ranges.mjs index 5b9a39714c..c99bfe5b9d 100644 --- a/services/web/scripts/delete_orphaned_doc_comment_ranges.mjs +++ b/services/web/scripts/delete_orphaned_doc_comment_ranges.mjs @@ -3,6 +3,7 @@ import ChatApiHandler from '../app/src/Features/Chat/ChatApiHandler.js' import DocstoreManager from '../app/src/Features/Docstore/DocstoreManager.js' import DocumentUpdaterHandler from '../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js' import { promiseMapWithLimit } from '@overleaf/promise-utils' +import { scriptRunner } from './lib/ScriptRunner.mjs' const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY, 10) || 10 @@ -43,7 +44,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.log('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/delete_orphaned_docs_online_check.mjs b/services/web/scripts/delete_orphaned_docs_online_check.mjs index 2affae4cf9..5ab8e17d45 100644 --- a/services/web/scripts/delete_orphaned_docs_online_check.mjs +++ b/services/web/scripts/delete_orphaned_docs_online_check.mjs @@ -8,6 +8,7 @@ import { } from '../app/src/infrastructure/mongodb.js' import { promiseMapWithLimit } from '@overleaf/promise-utils' import DeleteOrphanedDataHelper from './delete_orphaned_data_helper.mjs' +import { scriptRunner } from './lib/ScriptRunner.mjs' const { ObjectId } = mongodb const sleep = promisify(setTimeout) @@ -170,7 +171,7 @@ async function letUserDoubleCheckInputs() { } try { - await main() + await scriptRunner(main) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/disconnect_all_users.mjs b/services/web/scripts/disconnect_all_users.mjs index 41736ac69f..f8e43ad849 100644 --- a/services/web/scripts/disconnect_all_users.mjs +++ b/services/web/scripts/disconnect_all_users.mjs @@ -3,6 +3,7 @@ import Settings from '@overleaf/settings' import AdminController from '../app/src/Features/ServerAdmin/AdminController.js' import minimist from 'minimist' import { fileURLToPath } from 'node:url' +import { scriptRunner } from './lib/ScriptRunner.mjs' const args = minimist(process.argv.slice(2), { string: ['confirm-site-url', 'delay-in-seconds'], @@ -60,7 +61,7 @@ async function main() { if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner(main) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/e2e_test_setup.mjs b/services/web/scripts/e2e_test_setup.mjs index 7f0e8e3fef..4d529ccfe9 100644 --- a/services/web/scripts/e2e_test_setup.mjs +++ b/services/web/scripts/e2e_test_setup.mjs @@ -9,6 +9,7 @@ import ProjectDeleter from '../app/src/Features/Project/ProjectDeleter.js' import SplitTestManager from '../app/src/Features/SplitTests/SplitTestManager.js' import UserDeleter from '../app/src/Features/User/UserDeleter.js' import UserRegistrationHandler from '../app/src/Features/User/UserRegistrationHandler.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const MONOREPO = Path.dirname( Path.dirname(Path.dirname(Path.dirname(fileURLToPath(import.meta.url)))) @@ -158,7 +159,7 @@ async function main() { await provisionSplitTests() } -await main() +await scriptRunner(main) await GracefulShutdown.gracefulShutdown( { close(cb) { diff --git a/services/web/scripts/find_malformed_filetrees.mjs b/services/web/scripts/find_malformed_filetrees.mjs index 25b4a77a59..2614c7d622 100644 --- a/services/web/scripts/find_malformed_filetrees.mjs +++ b/services/web/scripts/find_malformed_filetrees.mjs @@ -1,6 +1,7 @@ // @ts-check import { db, ObjectId } from '../app/src/infrastructure/mongodb.js' import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' /** * @typedef {Object} Doc @@ -30,7 +31,12 @@ import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' * @property {Array} rootFolder */ -async function main() { +/** + * @param {(progress: string) => Promise} trackProgress + * @returns {Promise} + * @async + */ +async function main(trackProgress) { let projectsProcessed = 0 await batchedUpdate( db.projects, @@ -59,7 +65,9 @@ async function main() { } } }, - { _id: 1, rootFolder: 1 } + { _id: 1, rootFolder: 1 }, + undefined, + { trackProgress } ) } @@ -161,7 +169,7 @@ function* findBadPaths(folder) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/fix_group_invite_emails_to_lowercase.mjs b/services/web/scripts/fix_group_invite_emails_to_lowercase.mjs index 83024be6e5..a9bc0636d7 100644 --- a/services/web/scripts/fix_group_invite_emails_to_lowercase.mjs +++ b/services/web/scripts/fix_group_invite_emails_to_lowercase.mjs @@ -1,5 +1,6 @@ import { db } from '../app/src/infrastructure/mongodb.js' import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const DRY_RUN = process.env.DRY_RUN !== 'false' @@ -44,7 +45,7 @@ async function processBatch(subscriptions) { } } -async function main() { +async function main(trackProgress) { const projection = { _id: 1, teamInvites: 1, @@ -54,11 +55,18 @@ async function main() { $exists: true, }, } - await batchedUpdate(db.subscriptions, query, processBatch, projection) + await batchedUpdate( + db.subscriptions, + query, + processBatch, + projection, + undefined, + { trackProgress } + ) } try { - await main() + await scriptRunner(main) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/fix_malformed_filetree.mjs b/services/web/scripts/fix_malformed_filetree.mjs index 2358bed850..ff838d15c6 100644 --- a/services/web/scripts/fix_malformed_filetree.mjs +++ b/services/web/scripts/fix_malformed_filetree.mjs @@ -15,6 +15,7 @@ import minimist from 'minimist' import readline from 'node:readline' import fs from 'node:fs' import logger from '@overleaf/logger' +import { scriptRunner } from './lib/ScriptRunner.mjs' const { ObjectId } = mongodb const lastUpdated = new Date() @@ -287,7 +288,7 @@ function findUniqueName(existingFilenames) { try { try { - await main() + await scriptRunner(main) } finally { logStats() } diff --git a/services/web/scripts/fix_oversized_docs.mjs b/services/web/scripts/fix_oversized_docs.mjs index 9f2e250b92..1fe7b8337a 100644 --- a/services/web/scripts/fix_oversized_docs.mjs +++ b/services/web/scripts/fix_oversized_docs.mjs @@ -8,6 +8,7 @@ import ProjectEntityMongoUpdateHandler from '../app/src/Features/Project/Project import ProjectLocator from '../app/src/Features/Project/ProjectLocator.js' import RedisWrapper from '@overleaf/redis-wrapper' import Settings from '@overleaf/settings' +import { scriptRunner } from './lib/ScriptRunner.mjs' const opts = parseArgs() const redis = RedisWrapper.createClient(Settings.redis.web) @@ -155,7 +156,7 @@ async function deleteDocFromRedis(projectId, docId) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/force_doc_flush.mjs b/services/web/scripts/force_doc_flush.mjs index 399816e859..b6791710b0 100644 --- a/services/web/scripts/force_doc_flush.mjs +++ b/services/web/scripts/force_doc_flush.mjs @@ -1,6 +1,7 @@ import mongodb from 'mongodb-legacy' import { db } from '../app/src/infrastructure/mongodb.js' import DocumentUpdaterHandler from '../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const { ObjectId } = mongodb const PROJECT_ID = process.env.PROJECT_ID @@ -67,7 +68,7 @@ function getDocument() { } try { - await main() + await scriptRunner(main) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/history/clean_sl_history_data.mjs b/services/web/scripts/history/clean_sl_history_data.mjs index 0f8e93661a..8eb541e078 100644 --- a/services/web/scripts/history/clean_sl_history_data.mjs +++ b/services/web/scripts/history/clean_sl_history_data.mjs @@ -1,5 +1,6 @@ import { db } from '../../app/src/infrastructure/mongodb.js' import { ensureMongoTimeout } from '../helpers/env_variable_helper.mjs' +import { scriptRunner } from '../lib/ScriptRunner.mjs' // Ensure default mongo query timeout has been increased 1h if (!process.env.MONGO_SOCKET_TIMEOUT) { ensureMongoTimeout(360000) @@ -66,7 +67,7 @@ async function gracefullyDropCollection(collection) { } try { - await main() + await scriptRunner(main) } catch (err) { console.error(err) process.exit(1) diff --git a/services/web/scripts/history/migrate_ranges_support.mjs b/services/web/scripts/history/migrate_ranges_support.mjs index f62628f9ad..d0bde55a0b 100644 --- a/services/web/scripts/history/migrate_ranges_support.mjs +++ b/services/web/scripts/history/migrate_ranges_support.mjs @@ -1,5 +1,6 @@ import HistoryRangesSupportMigration from '../../app/src/Features/History/HistoryRangesSupportMigration.mjs' import minimist from 'minimist' +import { scriptRunner } from '../lib/ScriptRunner.mjs' async function main() { const { @@ -111,7 +112,7 @@ function arrayOpt(value) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/learn/checkSanitize/index.mjs b/services/web/scripts/learn/checkSanitize/index.mjs index 1c608cb2fe..55ad2caa38 100644 --- a/services/web/scripts/learn/checkSanitize/index.mjs +++ b/services/web/scripts/learn/checkSanitize/index.mjs @@ -1,6 +1,7 @@ import checkSanitizeOptions from './checkSanitizeOptions.mjs' import Scrape from './scrape.mjs' import { fileURLToPath } from 'node:url' +import { scriptRunner } from '../../lib/ScriptRunner.mjs' const { getAllPagesAndCache, scrapeAndCachePage } = Scrape @@ -32,7 +33,7 @@ async function main() { if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/lib/ScriptRunner.mjs b/services/web/scripts/lib/ScriptRunner.mjs index 1708fa9310..478a71815b 100644 --- a/services/web/scripts/lib/ScriptRunner.mjs +++ b/services/web/scripts/lib/ScriptRunner.mjs @@ -1,23 +1,28 @@ import { ScriptLog } from '../../app/src/models/ScriptLog.mjs' import Settings from '@overleaf/settings' +const UNKNOWN = 'unknown' + async function beforeScriptExecution(canonicalName, vars, scriptPath) { let log = new ScriptLog({ canonicalName, filePathAtVersion: scriptPath, - podName: process.env.OL_POD_NAME, - username: process.env.OL_USERNAME, - imageVersion: process.env.OL_IMAGE_VERSION, + podName: process.env.OL_POD_NAME ?? UNKNOWN, + username: process.env.OL_USERNAME ?? UNKNOWN, + imageVersion: process.env.OL_IMAGE_VERSION ?? UNKNOWN, vars, }) log = await log.save() - console.log( - '\n==================================' + - '\n✨ Your script is running!' + - '\n📊 Track progress at:' + - `\n${Settings.adminUrl}/admin/script-log/${log._id}` + - '\n==================================\n' - ) + // Print Script Log link if ran by a user + if (process.env.OL_USERNAME) { + console.log( + '\n==================================' + + '\n✨ Your script is running!' + + '\n📊 Track progress at:' + + `\n${Settings.adminUrl}/admin/script-log/${log._id}` + + '\n==================================\n' + ) + } return log._id } diff --git a/services/web/scripts/lowercase_institution_user_ids.mjs b/services/web/scripts/lowercase_institution_user_ids.mjs index e670d10615..e72bff87ef 100644 --- a/services/web/scripts/lowercase_institution_user_ids.mjs +++ b/services/web/scripts/lowercase_institution_user_ids.mjs @@ -2,6 +2,7 @@ import { db } from '../app/src/infrastructure/mongodb.js' import minimist from 'minimist' import UserGetter from '../app/src/Features/User/UserGetter.js' import fs from 'node:fs' +import { scriptRunner } from './lib/ScriptRunner.mjs' function usage() { console.log( @@ -112,7 +113,7 @@ async function main() { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/merge_group_subscription_members.mjs b/services/web/scripts/merge_group_subscription_members.mjs index 0d4d6acb6c..d8201c95ea 100644 --- a/services/web/scripts/merge_group_subscription_members.mjs +++ b/services/web/scripts/merge_group_subscription_members.mjs @@ -10,6 +10,7 @@ import { db, ObjectId } from '../app/src/infrastructure/mongodb.js' import SubscriptionUpdater from '../app/src/Features/Subscription/SubscriptionUpdater.js' import minimist from 'minimist' +import { scriptRunner } from './lib/ScriptRunner.mjs' const argv = minimist(process.argv.slice(2), { string: ['target', 'source'], boolean: ['commit'], @@ -93,7 +94,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.error('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/migrate_audit_logs.mjs b/services/web/scripts/migrate_audit_logs.mjs index 558c20cd06..9591275fca 100644 --- a/services/web/scripts/migrate_audit_logs.mjs +++ b/services/web/scripts/migrate_audit_logs.mjs @@ -3,10 +3,11 @@ import { promiseMapWithLimit, promisify } from '@overleaf/promise-utils' import { db, ObjectId } from '../app/src/infrastructure/mongodb.js' import _ from 'lodash' import { fileURLToPath } from 'node:url' +import { scriptRunner } from './lib/ScriptRunner.mjs' const sleep = promisify(setTimeout) -async function main(options) { +async function main(options, trackProgress) { if (!options) { options = {} } @@ -54,7 +55,9 @@ async function main(options) { async users => { await processUsersBatch(users, options) }, - { _id: 1, auditLog: 1 } + { _id: 1, auditLog: 1 }, + undefined, + { trackProgress } ) } @@ -67,7 +70,9 @@ async function main(options) { async projects => { await processProjectsBatch(projects, options) }, - { _id: 1, auditLog: 1 } + { _id: 1, auditLog: 1 }, + undefined, + { trackProgress } ) } } @@ -152,7 +157,9 @@ export default main if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner( + async trackProgress => await main(undefined, trackProgress) + ) console.log('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/oauth/backfill_hashed_secrets.mjs b/services/web/scripts/oauth/backfill_hashed_secrets.mjs index e3352ea932..6e0f8c66e9 100644 --- a/services/web/scripts/oauth/backfill_hashed_secrets.mjs +++ b/services/web/scripts/oauth/backfill_hashed_secrets.mjs @@ -3,6 +3,7 @@ import { READ_PREFERENCE_SECONDARY, } from '../../app/src/infrastructure/mongodb.js' import { hashSecret } from '../../modules/oauth2-server/app/src/SecretsHelper.js' +import { scriptRunner } from '../lib/ScriptRunner.mjs' async function main() { console.log('Hashing client secrets...') @@ -35,7 +36,7 @@ async function hashSecrets(collection, field) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/oauth/create_token.mjs b/services/web/scripts/oauth/create_token.mjs index cf833e1be2..4d43981a19 100644 --- a/services/web/scripts/oauth/create_token.mjs +++ b/services/web/scripts/oauth/create_token.mjs @@ -1,6 +1,7 @@ import minimist from 'minimist' import { db } from '../../app/src/infrastructure/mongodb.js' import { hashSecret } from '../../modules/oauth2-server/app/src/SecretsHelper.js' +import { scriptRunner } from '../lib/ScriptRunner.mjs' async function main() { const opts = parseArgs() @@ -88,7 +89,7 @@ Options: } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/oauth/register_client.mjs b/services/web/scripts/oauth/register_client.mjs index 8ca97f7321..65248f3464 100644 --- a/services/web/scripts/oauth/register_client.mjs +++ b/services/web/scripts/oauth/register_client.mjs @@ -2,6 +2,7 @@ import minimist from 'minimist' import mongodb from 'mongodb-legacy' import { db } from '../../app/src/infrastructure/mongodb.js' import { hashSecret } from '../../modules/oauth2-server/app/src/SecretsHelper.js' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const { ObjectId } = mongodb @@ -142,7 +143,7 @@ function toArray(value) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/oauth/remove_client.mjs b/services/web/scripts/oauth/remove_client.mjs index 700c19ac70..24080491b2 100644 --- a/services/web/scripts/oauth/remove_client.mjs +++ b/services/web/scripts/oauth/remove_client.mjs @@ -3,6 +3,7 @@ import { db, READ_PREFERENCE_SECONDARY, } from '../../app/src/infrastructure/mongodb.js' +import { scriptRunner } from '../lib/ScriptRunner.mjs' async function main() { const opts = parseArgs() @@ -113,7 +114,7 @@ Options: } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/recover_docs_from_redis.mjs b/services/web/scripts/recover_docs_from_redis.mjs index ad69b5469f..85709b471b 100644 --- a/services/web/scripts/recover_docs_from_redis.mjs +++ b/services/web/scripts/recover_docs_from_redis.mjs @@ -8,6 +8,7 @@ import ProjectEntityRestoreHandler from '../app/src/Features/Project/ProjectEnti import RedisWrapper from '@overleaf/redis-wrapper' import Settings from '@overleaf/settings' import logger from '@overleaf/logger' +import { scriptRunner } from './lib/ScriptRunner.mjs' const opts = parseArgs() const redis = RedisWrapper.createClient(Settings.redis.web) @@ -173,7 +174,7 @@ async function deleteDocFromRedis(projectId, docId) { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/recurly/change_prices_at_renewal.mjs b/services/web/scripts/recurly/change_prices_at_renewal.mjs index ad891f1b9d..53016acc7d 100644 --- a/services/web/scripts/recurly/change_prices_at_renewal.mjs +++ b/services/web/scripts/recurly/change_prices_at_renewal.mjs @@ -4,6 +4,7 @@ import * as csv from 'csv' import minimist from 'minimist' import recurly from 'recurly' import Settings from '@overleaf/settings' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey) @@ -223,7 +224,7 @@ class ReportError extends Error { } try { - await main() + await scriptRunner(main) } catch (error) { console.error(error) process.exit(1) diff --git a/services/web/scripts/recurly/collect_paypal_past_due_invoice.mjs b/services/web/scripts/recurly/collect_paypal_past_due_invoice.mjs index b735ea17ed..2bf827bae8 100644 --- a/services/web/scripts/recurly/collect_paypal_past_due_invoice.mjs +++ b/services/web/scripts/recurly/collect_paypal_past_due_invoice.mjs @@ -2,6 +2,7 @@ import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.j import minimist from 'minimist' import logger from '@overleaf/logger' import { fileURLToPath } from 'node:url' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const waitMs = fileURLToPath(import.meta.url) === process.argv[1] @@ -119,7 +120,7 @@ const main = async () => { if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner(main) logger.info('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/recurly/generate_addon_prices.mjs b/services/web/scripts/recurly/generate_addon_prices.mjs index 37378e6baf..6ae9d1846d 100644 --- a/services/web/scripts/recurly/generate_addon_prices.mjs +++ b/services/web/scripts/recurly/generate_addon_prices.mjs @@ -1,6 +1,7 @@ // @ts-check import settings from '@overleaf/settings' import recurly from 'recurly' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const ADD_ON_CODE = process.argv[2] @@ -54,4 +55,4 @@ async function getPlan(planCode) { return await recurlyClient.getPlan(`code-${planCode}`) } -await main() +await scriptRunner(main) diff --git a/services/web/scripts/recurly/get_manually_billed_users_details.mjs b/services/web/scripts/recurly/get_manually_billed_users_details.mjs index dafec9f8d6..0f7711652b 100644 --- a/services/web/scripts/recurly/get_manually_billed_users_details.mjs +++ b/services/web/scripts/recurly/get_manually_billed_users_details.mjs @@ -5,6 +5,7 @@ import { setTimeout } from 'node:timers/promises' import minimist from 'minimist' import * as csv from 'csv' import Stream from 'node:stream/promises' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const recurlyApiKey = Settings.apis.recurly.apiKey if (!recurlyApiKey) { @@ -95,7 +96,7 @@ async function main() { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/recurly/get_recurly_group_prices.mjs b/services/web/scripts/recurly/get_recurly_group_prices.mjs index e00a1e4c66..db734ead31 100644 --- a/services/web/scripts/recurly/get_recurly_group_prices.mjs +++ b/services/web/scripts/recurly/get_recurly_group_prices.mjs @@ -7,6 +7,7 @@ import recurly from 'recurly' import Settings from '@overleaf/settings' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const recurlySettings = Settings.apis.recurly const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined @@ -39,7 +40,7 @@ async function main() { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error({ error }) diff --git a/services/web/scripts/recurly/update_terms_and_conditions_for_manually_billed_users.mjs b/services/web/scripts/recurly/update_terms_and_conditions_for_manually_billed_users.mjs index f47ee6a2f0..ae8f19423b 100644 --- a/services/web/scripts/recurly/update_terms_and_conditions_for_manually_billed_users.mjs +++ b/services/web/scripts/recurly/update_terms_and_conditions_for_manually_billed_users.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs' import minimist from 'minimist' import * as csv from 'csv' import { setTimeout } from 'node:timers/promises' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const recurlyApiKey = Settings.apis.recurly.apiKey if (!recurlyApiKey) { @@ -95,7 +96,7 @@ async function main() { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/refresh_institution_users.mjs b/services/web/scripts/refresh_institution_users.mjs index 6edb523108..e67bd3c346 100644 --- a/services/web/scripts/refresh_institution_users.mjs +++ b/services/web/scripts/refresh_institution_users.mjs @@ -1,5 +1,6 @@ import minimist from 'minimist' import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const institutionId = parseInt(process.argv[2]) if (isNaN(institutionId)) throw new Error('No institution id') @@ -31,7 +32,7 @@ function main() { } try { - await main() + await scriptRunner(main) } catch (error) { console.error(error) process.exit(1) diff --git a/services/web/scripts/remove_feature_from_all_users.mjs b/services/web/scripts/remove_feature_from_all_users.mjs index fe53bda39a..f138501f0e 100644 --- a/services/web/scripts/remove_feature_from_all_users.mjs +++ b/services/web/scripts/remove_feature_from_all_users.mjs @@ -3,6 +3,7 @@ import { READ_PREFERENCE_SECONDARY, } from '../app/src/infrastructure/mongodb.js' import parseArgs from 'minimist' +import { scriptRunner } from './lib/ScriptRunner.mjs' async function _removeFeatureFromAllUsers(feature, commit) { let removals = 0 @@ -44,7 +45,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.log('Done') process.exit(0) } catch (error) { diff --git a/services/web/scripts/remove_oauth_application.mjs b/services/web/scripts/remove_oauth_application.mjs index 601a68a171..fd37ca374c 100644 --- a/services/web/scripts/remove_oauth_application.mjs +++ b/services/web/scripts/remove_oauth_application.mjs @@ -1,6 +1,7 @@ import { OauthApplication } from '../app/src/models/OauthApplication.js' import parseArgs from 'minimist' import OError from '@overleaf/o-error' +import { scriptRunner } from './lib/ScriptRunner.mjs' async function _removeOauthApplication(appId) { if (!appId) { @@ -24,7 +25,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.log('Done') process.exit(0) } catch (error) { diff --git a/services/web/scripts/remove_unconfirmed_emails.mjs b/services/web/scripts/remove_unconfirmed_emails.mjs index e86540dba6..246334315a 100644 --- a/services/web/scripts/remove_unconfirmed_emails.mjs +++ b/services/web/scripts/remove_unconfirmed_emails.mjs @@ -8,6 +8,7 @@ import fs from 'node:fs/promises' import * as csv from 'csv' import { promisify } from 'node:util' import _ from 'lodash' +import { scriptRunner } from './lib/ScriptRunner.mjs' const CSV_FILENAME = '/tmp/remove_unconfirmed_emails.csv' /** @@ -38,7 +39,7 @@ const { generate, consume, commit, help } = minimist(process.argv.slice(2), { default: { generate: false, consume: false, commit: false }, }) -async function generateCsvFile() { +async function generateCsvFile(trackProgress) { console.time('generate_csv') let processedUsersCount = 0 @@ -92,7 +93,9 @@ async function generateCsvFile() { totalEmailsToRemove += unconfirmedSecondaries.length } }, - { _id: 1, signUpDate: 1, emails: 1, email: 1 } + { _id: 1, signUpDate: 1, emails: 1, email: 1 }, + undefined, + { trackProgress } ) const csvContent = await stringifyAsync(records) @@ -226,7 +229,7 @@ async function consumeCsvFile() { console.log() } -async function main() { +async function main(trackProgress) { if (help) { return usage() } @@ -247,14 +250,14 @@ async function main() { } if (generate) { - await generateCsvFile() + await generateCsvFile(trackProgress) } else if (consume) { await consumeCsvFile() } } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/restore_orphaned_docs.mjs b/services/web/scripts/restore_orphaned_docs.mjs index ebbe0d5e4b..7b4df055a9 100644 --- a/services/web/scripts/restore_orphaned_docs.mjs +++ b/services/web/scripts/restore_orphaned_docs.mjs @@ -1,6 +1,7 @@ import ProjectEntityRestoreHandler from '../app/src/Features/Project/ProjectEntityRestoreHandler.js' import ProjectEntityHandler from '../app/src/Features/Project/ProjectEntityHandler.js' import DocstoreManager from '../app/src/Features/Docstore/DocstoreManager.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const ARGV = process.argv.slice(2) const DEVELOPER_USER_ID = ARGV.shift() @@ -35,7 +36,7 @@ async function main() { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/restore_soft_deleted_docs.mjs b/services/web/scripts/restore_soft_deleted_docs.mjs index 3e4575fa2b..5faf85559e 100644 --- a/services/web/scripts/restore_soft_deleted_docs.mjs +++ b/services/web/scripts/restore_soft_deleted_docs.mjs @@ -1,5 +1,6 @@ import ProjectEntityRestoreHandler from '../app/src/Features/Project/ProjectEntityRestoreHandler.js' import DocstoreManager from '../app/src/Features/Docstore/DocstoreManager.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const ARGV = process.argv.slice(2) const DEVELOPER_USER_ID = ARGV.shift() @@ -24,7 +25,7 @@ async function main() { } try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/set_tex_live_image.mjs b/services/web/scripts/set_tex_live_image.mjs index 63b67fb4ca..a0d48dd9f3 100644 --- a/services/web/scripts/set_tex_live_image.mjs +++ b/services/web/scripts/set_tex_live_image.mjs @@ -1,6 +1,7 @@ import Settings from '@overleaf/settings' import mongodb from 'mongodb-legacy' import { Project } from '../app/src/models/Project.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' const { ObjectId } = mongodb @@ -61,7 +62,7 @@ async function updateImage(image, projectIds) { } try { - await main() + await scriptRunner(main) process.exit() } catch (error) { console.error(error) diff --git a/services/web/scripts/soft_delete_project.mjs b/services/web/scripts/soft_delete_project.mjs index 801bcc9a70..f530040980 100644 --- a/services/web/scripts/soft_delete_project.mjs +++ b/services/web/scripts/soft_delete_project.mjs @@ -1,5 +1,6 @@ import minimist from 'minimist' import ProjectDeleter from '../app/src/Features/Project/ProjectDeleter.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' async function main() { const argv = minimist(process.argv.slice(2)) @@ -14,7 +15,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.log('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/sso_id_migration_check.mjs b/services/web/scripts/sso_id_migration_check.mjs index 8f8f037b49..9638f6b960 100644 --- a/services/web/scripts/sso_id_migration_check.mjs +++ b/services/web/scripts/sso_id_migration_check.mjs @@ -1,5 +1,6 @@ import SAMLUserIdMigrationHandler from '../modules/saas-authentication/app/src/SAML/SAMLUserIdMigrationHandler.mjs' import { ensureRunningOnMongoSecondaryWithTimeout } from './helpers/env_variable_helper.mjs' +import { scriptRunner } from './lib/ScriptRunner.mjs' ensureRunningOnMongoSecondaryWithTimeout(300000) @@ -10,7 +11,7 @@ const emitUsers = process.argv.includes('--emit-users') console.log('Checking SSO user ID migration for institution:', institutionId) try { - await main() + await scriptRunner(main) } catch (error) { console.error(error) process.exit(1) diff --git a/services/web/scripts/sso_id_remove_not_migrated.mjs b/services/web/scripts/sso_id_remove_not_migrated.mjs index cf836f4796..cc7a5a874e 100644 --- a/services/web/scripts/sso_id_remove_not_migrated.mjs +++ b/services/web/scripts/sso_id_remove_not_migrated.mjs @@ -1,5 +1,6 @@ import SAMLUserIdMigrationHandler from '../modules/saas-authentication/app/src/SAML/SAMLUserIdMigrationHandler.mjs' import { ensureMongoTimeout } from './helpers/env_variable_helper.mjs' +import { scriptRunner } from './lib/ScriptRunner.mjs' ensureMongoTimeout(300000) @@ -30,7 +31,7 @@ async function main() { } try { - await main() + await scriptRunner(main) } catch (error) { console.error(error) process.exit(1) diff --git a/services/web/scripts/undelete_project_to_user.mjs b/services/web/scripts/undelete_project_to_user.mjs index ee837a4190..0863ad5f23 100644 --- a/services/web/scripts/undelete_project_to_user.mjs +++ b/services/web/scripts/undelete_project_to_user.mjs @@ -1,5 +1,6 @@ import minimist from 'minimist' import ProjectDeleter from '../app/src/Features/Project/ProjectDeleter.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' async function main() { const argv = minimist(process.argv.slice(2)) @@ -14,7 +15,7 @@ async function main() { } try { - await main() + await scriptRunner(main) console.log('Done.') process.exit(0) } catch (error) { diff --git a/services/web/scripts/unlink_third_party_id.mjs b/services/web/scripts/unlink_third_party_id.mjs index 8511deb403..eb76995bc2 100644 --- a/services/web/scripts/unlink_third_party_id.mjs +++ b/services/web/scripts/unlink_third_party_id.mjs @@ -1,6 +1,7 @@ import minimist from 'minimist' import ThirdPartyIdentityManager from '../app/src/Features/User/ThirdPartyIdentityManager.js' import UserGetter from '../app/src/Features/User/UserGetter.js' +import { scriptRunner } from './lib/ScriptRunner.mjs' /** * This script is used to remove a linked third party identity from a user account. @@ -79,7 +80,7 @@ async function main() { setup() try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) diff --git a/services/web/scripts/writefull/back_fill_hiding_ai_features.mjs b/services/web/scripts/writefull/back_fill_hiding_ai_features.mjs index 2b188a812b..e40e065d51 100644 --- a/services/web/scripts/writefull/back_fill_hiding_ai_features.mjs +++ b/services/web/scripts/writefull/back_fill_hiding_ai_features.mjs @@ -1,7 +1,8 @@ import { db } from '../../app/src/infrastructure/mongodb.js' import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' +import { scriptRunner } from '../lib/ScriptRunner.mjs' -async function main() { +async function main(trackProgress) { // update all applicable user models await batchedUpdate( db.users, @@ -12,7 +13,10 @@ async function main() { $set: { 'aiErrorAssistant.enabled': false, }, - } + }, + undefined, + undefined, + { trackProgress } ) console.log('completed syncing writefull state with error assist') } @@ -20,7 +24,7 @@ async function main() { export default main try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error({ error }) diff --git a/services/web/scripts/writefull/enable_wf_autoCreatedAccount.mjs b/services/web/scripts/writefull/enable_wf_autoCreatedAccount.mjs index 190af04ef0..bdae9d8f26 100644 --- a/services/web/scripts/writefull/enable_wf_autoCreatedAccount.mjs +++ b/services/web/scripts/writefull/enable_wf_autoCreatedAccount.mjs @@ -3,6 +3,7 @@ import mongodb from 'mongodb-legacy' import fs from 'node:fs' import { fileURLToPath } from 'node:url' import { chunkArray } from '../helpers/chunkArray.mjs' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const { ObjectId } = mongodb @@ -37,7 +38,7 @@ export default main if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error({ error }) diff --git a/services/web/scripts/writefull/enable_writefull_without_autoCreatedAccount.mjs b/services/web/scripts/writefull/enable_writefull_without_autoCreatedAccount.mjs index 60a20626da..8f76ad666d 100644 --- a/services/web/scripts/writefull/enable_writefull_without_autoCreatedAccount.mjs +++ b/services/web/scripts/writefull/enable_writefull_without_autoCreatedAccount.mjs @@ -2,6 +2,7 @@ import { db } from '../../app/src/infrastructure/mongodb.js' import mongodb from 'mongodb-legacy' import fs from 'node:fs' import { fileURLToPath } from 'node:url' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const { ObjectId } = mongodb @@ -36,7 +37,7 @@ export default main if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error({ error }) diff --git a/services/web/scripts/writefull/split_writefull_disabled_from_unset.mjs b/services/web/scripts/writefull/split_writefull_disabled_from_unset.mjs index 85b6cf438c..dc86f8897b 100644 --- a/services/web/scripts/writefull/split_writefull_disabled_from_unset.mjs +++ b/services/web/scripts/writefull/split_writefull_disabled_from_unset.mjs @@ -4,10 +4,11 @@ import mongodb from 'mongodb-legacy' import fs from 'node:fs' import { fileURLToPath } from 'node:url' import { chunkArray } from '../helpers/chunkArray.mjs' +import { scriptRunner } from '../lib/ScriptRunner.mjs' const { ObjectId } = mongodb -async function main() { +async function main(trackProgress) { // search for file of users who already explicitly opted out first const optOutPath = process.argv[2] const optedOutFile = fs.readFileSync(optOutPath, 'utf8') @@ -20,7 +21,10 @@ async function main() { await batchedUpdate( db.users, { 'writefull.enabled': false }, // and is false - { $set: { 'writefull.enabled': null } } + { $set: { 'writefull.enabled': null } }, + undefined, + undefined, + { trackProgress } ) const chunks = chunkArray(optedOutList) @@ -41,7 +45,7 @@ export default main if (fileURLToPath(import.meta.url) === process.argv[1]) { try { - await main() + await scriptRunner(main) process.exit(0) } catch (error) { console.error({ error }) From 64984ee86a071902caca695402df46b0f37a3191 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 22 May 2025 12:29:53 +0100 Subject: [PATCH 006/259] [history-ot] flush history for projects with short queues ASAP (#25776) * [document-updater] flush history for projects with short queues ASAP * [k8s] document-updater: enable short history queue for history-ot demo * [project-history] flush history for projects with short queues ASAP * [project-history] wait for mongo before running acceptance tests * [k8s] project-history: enable short history queue for history-ot demo * [project-history] change wait-for-mongo step in tests Co-authored-by: Eric Mc Sween --------- Co-authored-by: Eric Mc Sween GitOrigin-RevId: 3e989c409e8e9887655b35f2659ce0829e61b357 --- .../document-updater/app/js/HistoryManager.js | 4 +- .../document-updater/app/js/ProjectManager.js | 1 + .../config/settings.defaults.js | 4 + .../js/HistoryManager/HistoryManagerTests.js | 19 ++++- .../project-history/app/js/FlushManager.js | 8 ++ .../config/settings.defaults.cjs | 4 + services/project-history/scripts/flush_old.js | 7 +- .../test/acceptance/js/FlushManagerTests.js | 85 ++++++++++++++++++- .../js/helpers/ProjectHistoryApp.js | 18 ++-- 9 files changed, 137 insertions(+), 13 deletions(-) diff --git a/services/document-updater/app/js/HistoryManager.js b/services/document-updater/app/js/HistoryManager.js index 3963431925..d9a8459525 100644 --- a/services/document-updater/app/js/HistoryManager.js +++ b/services/document-updater/app/js/HistoryManager.js @@ -62,6 +62,7 @@ const HistoryManager = { // record updates for project history if ( HistoryManager.shouldFlushHistoryOps( + projectId, projectOpsLength, ops.length, HistoryManager.FLUSH_PROJECT_EVERY_N_OPS @@ -77,7 +78,8 @@ const HistoryManager = { } }, - shouldFlushHistoryOps(length, opsLength, threshold) { + shouldFlushHistoryOps(projectId, length, opsLength, threshold) { + if (Settings.shortHistoryQueues.includes(projectId)) return true if (!length) { return false } // don't flush unless we know the length diff --git a/services/document-updater/app/js/ProjectManager.js b/services/document-updater/app/js/ProjectManager.js index 781ed0e168..cdd4c11482 100644 --- a/services/document-updater/app/js/ProjectManager.js +++ b/services/document-updater/app/js/ProjectManager.js @@ -317,6 +317,7 @@ function updateProjectWithLocks( } if ( HistoryManager.shouldFlushHistoryOps( + projectId, projectOpsLength, updates.length, HistoryManager.FLUSH_PROJECT_EVERY_N_OPS diff --git a/services/document-updater/config/settings.defaults.js b/services/document-updater/config/settings.defaults.js index 0cd29d325b..9ed59de6c4 100755 --- a/services/document-updater/config/settings.defaults.js +++ b/services/document-updater/config/settings.defaults.js @@ -184,4 +184,8 @@ module.exports = { smoothingOffset: process.env.SMOOTHING_OFFSET || 1000, // milliseconds gracefulShutdownDelayInMs: parseInt(process.env.GRACEFUL_SHUTDOWN_DELAY_SECONDS ?? '10', 10) * 1000, + + shortHistoryQueues: (process.env.SHORT_HISTORY_QUEUES || '') + .split(',') + .filter(s => !!s), } diff --git a/services/document-updater/test/unit/js/HistoryManager/HistoryManagerTests.js b/services/document-updater/test/unit/js/HistoryManager/HistoryManagerTests.js index 2fd019d4c2..2a5fb29b6d 100644 --- a/services/document-updater/test/unit/js/HistoryManager/HistoryManagerTests.js +++ b/services/document-updater/test/unit/js/HistoryManager/HistoryManagerTests.js @@ -14,6 +14,7 @@ describe('HistoryManager', function () { requires: { request: (this.request = {}), '@overleaf/settings': (this.Settings = { + shortHistoryQueues: [], apis: { project_history: { url: 'http://project_history.example.com', @@ -118,7 +119,7 @@ describe('HistoryManager', function () { beforeEach(function () { this.HistoryManager.shouldFlushHistoryOps = sinon.stub() this.HistoryManager.shouldFlushHistoryOps - .withArgs(this.project_ops_length) + .withArgs(this.project_id, this.project_ops_length) .returns(true) this.HistoryManager.recordAndFlushHistoryOps( @@ -139,7 +140,7 @@ describe('HistoryManager', function () { beforeEach(function () { this.HistoryManager.shouldFlushHistoryOps = sinon.stub() this.HistoryManager.shouldFlushHistoryOps - .withArgs(this.project_ops_length) + .withArgs(this.project_id, this.project_ops_length) .returns(false) this.HistoryManager.recordAndFlushHistoryOps( @@ -157,6 +158,7 @@ describe('HistoryManager', function () { describe('shouldFlushHistoryOps', function () { it('should return false if the number of ops is not known', function () { this.HistoryManager.shouldFlushHistoryOps( + this.project_id, null, ['a', 'b', 'c'].length, 1 @@ -168,6 +170,7 @@ describe('HistoryManager', function () { // Previously we were on 11 ops // We didn't pass over a multiple of 5 this.HistoryManager.shouldFlushHistoryOps( + this.project_id, 14, ['a', 'b', 'c'].length, 5 @@ -178,6 +181,7 @@ describe('HistoryManager', function () { // Previously we were on 12 ops // We've reached a new multiple of 5 this.HistoryManager.shouldFlushHistoryOps( + this.project_id, 15, ['a', 'b', 'c'].length, 5 @@ -189,11 +193,22 @@ describe('HistoryManager', function () { // Previously we were on 16 ops // We didn't pass over a multiple of 5 this.HistoryManager.shouldFlushHistoryOps( + this.project_id, 17, ['a', 'b', 'c'].length, 5 ).should.equal(true) }) + + it('should return true if the project has a short queue', function () { + this.Settings.shortHistoryQueues = [this.project_id] + this.HistoryManager.shouldFlushHistoryOps( + this.project_id, + 14, + ['a', 'b', 'c'].length, + 5 + ).should.equal(true) + }) }) }) diff --git a/services/project-history/app/js/FlushManager.js b/services/project-history/app/js/FlushManager.js index 6df3b20a87..455a4f56f7 100644 --- a/services/project-history/app/js/FlushManager.js +++ b/services/project-history/app/js/FlushManager.js @@ -11,6 +11,7 @@ import async from 'async' import logger from '@overleaf/logger' import OError from '@overleaf/o-error' import metrics from '@overleaf/metrics' +import Settings from '@overleaf/settings' import _ from 'lodash' import * as RedisManager from './RedisManager.js' import * as UpdatesProcessor from './UpdatesProcessor.js' @@ -37,6 +38,13 @@ export function flushIfOld(projectId, cutoffTime, callback) { ) metrics.inc('flush-old-updates', 1, { status: 'flushed' }) return UpdatesProcessor.processUpdatesForProject(projectId, callback) + } else if (Settings.shortHistoryQueues.includes(projectId)) { + logger.debug( + { projectId, firstOpTimestamp, cutoffTime }, + 'flushing project with short queue' + ) + metrics.inc('flush-old-updates', 1, { status: 'short-queue' }) + return UpdatesProcessor.processUpdatesForProject(projectId, callback) } else { metrics.inc('flush-old-updates', 1, { status: 'skipped' }) return callback() diff --git a/services/project-history/config/settings.defaults.cjs b/services/project-history/config/settings.defaults.cjs index 9e5a39868a..d259d070b9 100644 --- a/services/project-history/config/settings.defaults.cjs +++ b/services/project-history/config/settings.defaults.cjs @@ -106,4 +106,8 @@ module.exports = { }, maxFileSizeInBytes: 100 * 1024 * 1024, // 100 megabytes + + shortHistoryQueues: (process.env.SHORT_HISTORY_QUEUES || '') + .split(',') + .filter(s => !!s), } diff --git a/services/project-history/scripts/flush_old.js b/services/project-history/scripts/flush_old.js index 6dc140196e..7ac13b757a 100644 --- a/services/project-history/scripts/flush_old.js +++ b/services/project-history/scripts/flush_old.js @@ -124,11 +124,14 @@ async function main() { .map((projectId, idx) => { return { projectId, timestamp: timestamps[idx] } }) - .filter(({ timestamp }) => { + .filter(({ projectId, timestamp }) => { if (!timestamp) { nullCount++ + return true // Unknown age } - return timestamp ? olderThan(maxAge, timestamp) : true + if (olderThan(maxAge, timestamp)) return true // Older than threshold + if (Settings.shortHistoryQueues.includes(projectId)) return true // Short queue + return false // Do not flush }) collectedProjects.push(...newProjects) } diff --git a/services/project-history/test/acceptance/js/FlushManagerTests.js b/services/project-history/test/acceptance/js/FlushManagerTests.js index d11346d9a3..8d4432d3ef 100644 --- a/services/project-history/test/acceptance/js/FlushManagerTests.js +++ b/services/project-history/test/acceptance/js/FlushManagerTests.js @@ -6,6 +6,7 @@ import assert from 'node:assert' import mongodb from 'mongodb-legacy' import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' +import Settings from '@overleaf/settings' const { ObjectId } = mongodb const MockHistoryStore = () => nock('http://127.0.0.1:3100') @@ -127,7 +128,7 @@ describe('Flushing old queues', function () { 'made calls to history service to store updates in the background' ) done() - }, 100) + }, 1_000) } ) }) @@ -183,6 +184,88 @@ describe('Flushing old queues', function () { }) }) + describe('when the update is newer than the cutoff and project has short queue', function () { + beforeEach(function () { + Settings.shortHistoryQueues.push(this.projectId) + }) + afterEach(function () { + Settings.shortHistoryQueues.length = 0 + }) + beforeEach(function (done) { + this.flushCall = MockHistoryStore() + .put( + `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb` + ) + .reply(201) + .post(`/api/projects/${historyId}/legacy_changes?end_version=0`) + .reply(200) + const update = { + pathname: '/main.tex', + docLines: 'a\nb', + doc: this.docId, + meta: { user_id: this.user_id, ts: new Date() }, + } + async.series( + [ + cb => + ProjectHistoryClient.pushRawUpdate(this.projectId, update, cb), + cb => + ProjectHistoryClient.setFirstOpTimestamp( + this.projectId, + Date.now() - 60 * 1000, + cb + ), + ], + done + ) + }) + + it('flushes the project history queue', function (done) { + request.post( + { + url: `http://127.0.0.1:3054/flush/old?maxAge=${3 * 3600}`, + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + assert( + this.flushCall.isDone(), + 'made calls to history service to store updates' + ) + done() + } + ) + }) + + it('flushes the project history queue in the background when requested', function (done) { + request.post( + { + url: `http://127.0.0.1:3054/flush/old?maxAge=${3 * 3600}&background=1`, + }, + (error, res, body) => { + if (error) { + return done(error) + } + expect(res.statusCode).to.equal(200) + expect(body).to.equal('{"message":"running flush in background"}') + assert( + !this.flushCall.isDone(), + 'did not make calls to history service to store updates in the foreground' + ) + setTimeout(() => { + assert( + this.flushCall.isDone(), + 'made calls to history service to store updates in the background' + ) + done() + }, 1_000) + } + ) + }) + }) + describe('when the update does not have a timestamp', function () { beforeEach(function (done) { this.flushCall = MockHistoryStore() diff --git a/services/project-history/test/acceptance/js/helpers/ProjectHistoryApp.js b/services/project-history/test/acceptance/js/helpers/ProjectHistoryApp.js index ae453b74f9..6a81221840 100644 --- a/services/project-history/test/acceptance/js/helpers/ProjectHistoryApp.js +++ b/services/project-history/test/acceptance/js/helpers/ProjectHistoryApp.js @@ -9,6 +9,7 @@ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ import { app } from '../../../../app/js/server.js' +import { mongoClient } from '../../../../app/js/mongodb.js' let running = false let initing = false @@ -29,13 +30,16 @@ export function ensureRunning(callback) { if (error != null) { throw error } - running = true - return (() => { - const result = [] - for (callback of Array.from(callbacks)) { - result.push(callback()) + + // Wait for mongo + mongoClient.connect(error => { + if (error != null) { + throw error } - return result - })() + running = true + for (callback of Array.from(callbacks)) { + callback() + } + }) }) } From f69b9f857ed81fdf442da85f0a002ab0602680f8 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Thu, 22 May 2025 07:56:33 -0400 Subject: [PATCH 007/259] Merge pull request #25825 from overleaf/em-accept-edit-operations Accept all EditOperations in history-ot type GitOrigin-RevId: b3bc710c92c4aa31dfeec16d422e42f0a6bb8cdb --- .../editor/share-js-history-ot-type.ts | 92 ++++++++++--------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index cec1983037..fde66d89a1 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -1,6 +1,7 @@ import EventEmitter from '@/utils/EventEmitter' import { EditOperationBuilder, + EditOperationTransformer, InsertOp, RemoveOp, RetainOp, @@ -9,14 +10,6 @@ import { } from 'overleaf-editor-core' import { RawEditOperation } from 'overleaf-editor-core/lib/types' -function loadTextOperation(raw: RawEditOperation): TextOperation { - const operation = EditOperationBuilder.fromJSON(raw) - if (!(operation instanceof TextOperation)) { - throw new Error(`operation not supported: ${operation.constructor.name}`) - } - return operation -} - export class HistoryOTType extends EventEmitter { // stub interface, these are actually on the Doc api: HistoryOTType @@ -29,15 +22,15 @@ export class HistoryOTType extends EventEmitter { } transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { - const [a, b] = TextOperation.transform( - loadTextOperation(raw1[0]), - loadTextOperation(raw2[0]) + const [a, b] = EditOperationTransformer.transform( + EditOperationBuilder.fromJSON(raw1[0]), + EditOperationBuilder.fromJSON(raw2[0]) ) return [[a.toJSON()], [b.toJSON()]] } apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { - const operation = loadTextOperation(rawEditOperation[0]) + const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) const afterFile = StringFileData.fromRaw(snapshot.toRaw()) afterFile.edit(operation) this.snapshot = afterFile @@ -46,7 +39,9 @@ export class HistoryOTType extends EventEmitter { compose(op1: RawEditOperation[], op2: RawEditOperation[]) { return [ - loadTextOperation(op1[0]).compose(loadTextOperation(op2[0])).toJSON(), + EditOperationBuilder.fromJSON(op1[0]) + .compose(EditOperationBuilder.fromJSON(op2[0])) + .toJSON(), ] } @@ -88,40 +83,47 @@ export class HistoryOTType extends EventEmitter { this.on( 'remoteop', (rawEditOperation: RawEditOperation[], oldSnapshot: StringFileData) => { - const operation = loadTextOperation(rawEditOperation[0]) - const str = oldSnapshot.getContent() - if (str.length !== operation.baseLength) - throw new TextOperation.ApplyError( - "The operation's base length must be equal to the string's length.", - operation, - str - ) - - let outputCursor = 0 - let inputCursor = 0 - for (const op of operation.ops) { - if (op instanceof RetainOp) { - inputCursor += op.length - outputCursor += op.length - } else if (op instanceof InsertOp) { - this.emit('insert', outputCursor, op.insertion, op.insertion.length) - outputCursor += op.insertion.length - } else if (op instanceof RemoveOp) { - this.emit( - 'delete', - outputCursor, - str.slice(inputCursor, inputCursor + op.length) + const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) + if (operation instanceof TextOperation) { + const str = oldSnapshot.getContent() + if (str.length !== operation.baseLength) + throw new TextOperation.ApplyError( + "The operation's base length must be equal to the string's length.", + operation, + str ) - inputCursor += op.length - } - } - if (inputCursor !== str.length) - throw new TextOperation.ApplyError( - "The operation didn't operate on the whole string.", - operation, - str - ) + let outputCursor = 0 + let inputCursor = 0 + for (const op of operation.ops) { + if (op instanceof RetainOp) { + inputCursor += op.length + outputCursor += op.length + } else if (op instanceof InsertOp) { + this.emit( + 'insert', + outputCursor, + op.insertion, + op.insertion.length + ) + outputCursor += op.insertion.length + } else if (op instanceof RemoveOp) { + this.emit( + 'delete', + outputCursor, + str.slice(inputCursor, inputCursor + op.length) + ) + inputCursor += op.length + } + } + + if (inputCursor !== str.length) + throw new TextOperation.ApplyError( + "The operation didn't operate on the whole string.", + operation, + str + ) + } } ) } From 2d66b9751af68cdb9670430ff6aa58188d12b3c5 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Thu, 22 May 2025 13:27:22 +0100 Subject: [PATCH 008/259] Merge pull request #25784 from overleaf/dp-backend-reviewer-role-cleanup Remove references to `reviewer-role` feature flag in the backend GitOrigin-RevId: 4d2088e4c2815d3221817a182a0a66b5a60b3532 --- .../Features/Helpers/AuthorizationHelper.js | 28 -------- .../src/Features/Project/ProjectController.js | 10 --- .../web/app/views/project/editor/_meta.pug | 1 - .../HelperFiles/AuthorizationHelperTests.js | 68 ------------------- 4 files changed, 107 deletions(-) diff --git a/services/web/app/src/Features/Helpers/AuthorizationHelper.js b/services/web/app/src/Features/Helpers/AuthorizationHelper.js index f193398b87..8369f2d321 100644 --- a/services/web/app/src/Features/Helpers/AuthorizationHelper.js +++ b/services/web/app/src/Features/Helpers/AuthorizationHelper.js @@ -1,14 +1,7 @@ const { UserSchema } = require('../../models/User') -const SplitTestHandler = require('../SplitTests/SplitTestHandler') -const ProjectGetter = require('../Project/ProjectGetter') -const { callbackify } = require('@overleaf/promise-utils') module.exports = { hasAnyStaffAccess, - isReviewerRoleEnabled: callbackify(isReviewerRoleEnabled), - promises: { - isReviewerRoleEnabled, - }, } function hasAnyStaffAccess(user) { @@ -21,24 +14,3 @@ function hasAnyStaffAccess(user) { } return false } - -async function isReviewerRoleEnabled(projectId) { - const project = await ProjectGetter.promises.getProject(projectId, { - reviewer_refs: 1, - owner_ref: 1, - }) - - // if there are reviewers, it means the role is enabled - if (Object.keys(project.reviewer_refs || {}).length > 0) { - return true - } - - // if there are no reviewers, check split test from project owner - const reviewerRoleAssigment = - await SplitTestHandler.promises.getAssignmentForUser( - project.owner_ref, - 'reviewer-role' - ) - - return reviewerRoleAssigment.variant === 'enabled' -} diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 160914db81..ec128ffd54 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -347,7 +347,6 @@ const _ProjectController = { 'track-pdf-download', !anonymous && 'writefull-oauth-promotion', 'hotjar', - 'reviewer-role', 'editor-redesign', 'paywall-change-compile-timeout', 'overleaf-assist-bundle', @@ -482,12 +481,6 @@ const _ProjectController = { anonRequestToken ) - const reviewerRoleAssignment = - await SplitTestHandler.promises.getAssignmentForUser( - project.owner_ref, - 'reviewer-role' - ) - await Modules.promises.hooks.fire('enforceCollaboratorLimit', projectId) if (isTokenMember) { // Check explicitly that the user is in read write token refs, while this could be inferred @@ -883,9 +876,6 @@ const _ProjectController = { : null, isSaas: Features.hasFeature('saas'), shouldLoadHotjar: splitTestAssignments.hotjar?.variant === 'enabled', - isReviewerRoleEnabled: - reviewerRoleAssignment?.variant === 'enabled' || - Object.keys(project.reviewer_refs || {}).length > 0, isPaywallChangeCompileTimeoutEnabled, isOverleafAssistBundleEnabled, paywallPlans, diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index ab31cef89e..e8539e1d76 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -40,7 +40,6 @@ meta(name="ol-projectTags" data-type="json" content=projectTags) meta(name="ol-ro-mirror-on-client-no-local-storage" data-type="boolean" content=roMirrorOnClientNoLocalStorage) meta(name="ol-isSaas" data-type="boolean" content=isSaas) meta(name="ol-shouldLoadHotjar" data-type="boolean" content=shouldLoadHotjar) -meta(name="ol-isReviewerRoleEnabled" data-type="boolean" content=isReviewerRoleEnabled) meta(name="ol-odcRole" data-type="string" content=odcRole) meta(name="ol-isPaywallChangeCompileTimeoutEnabled" data-type="boolean" content=isPaywallChangeCompileTimeoutEnabled) meta(name='ol-customerIoEnabled' data-type="boolean" content=customerIoEnabled) diff --git a/services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js b/services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js index ef8b5fcc6a..a82143dce6 100644 --- a/services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js +++ b/services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js @@ -63,72 +63,4 @@ describe('AuthorizationHelper', function () { expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false }) }) - - describe('isReviewerRoleEnabled', function () { - it('with no reviewers and no split test', async function () { - this.ProjectGetter.promises.getProject = sinon.stub().resolves({ - reviewer_refs: {}, - owner_ref: 'ownerId', - }) - this.SplitTestHandler.promises.getAssignmentForUser = sinon - .stub() - .resolves({ - variant: 'disabled', - }) - expect( - await this.AuthorizationHelper.promises.isReviewerRoleEnabled( - 'projectId' - ) - ).to.be.false - }) - - it('with no reviewers and enabled split test', async function () { - this.ProjectGetter.promises.getProject = sinon.stub().resolves({ - reviewer_refs: {}, - owner_ref: 'userId', - }) - this.SplitTestHandler.promises.getAssignmentForUser = sinon - .stub() - .resolves({ - variant: 'enabled', - }) - expect( - await this.AuthorizationHelper.promises.isReviewerRoleEnabled( - 'projectId' - ) - ).to.be.true - }) - - it('with reviewers and disabled split test', async function () { - this.ProjectGetter.promises.getProject = sinon.stub().resolves({ - reviewer_refs: [{ $oid: 'userId' }], - }) - this.SplitTestHandler.promises.getAssignmentForUser = sinon - .stub() - .resolves({ - variant: 'default', - }) - expect( - await this.AuthorizationHelper.promises.isReviewerRoleEnabled( - 'projectId' - ) - ).to.be.true - }) - - it('with reviewers and enabled split test', async function () { - this.ProjectGetter.promises.getProject = sinon.stub().resolves({ - reviewer_refs: [{ $oid: 'userId' }], - }) - this.SplitTestHandler.promises.getAssignmentForUser = sinon - .stub() - .resolves({ - variant: 'enabled', - }) - expect( - await this.AuthorizationHelper.promises.isReviewerRoleEnabled( - 'projectId' - ) - ).to.be.true - }) - }) }) From c4500946594e4688e6b89586df5bc8f6eecea9ac Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Thu, 22 May 2025 13:27:31 +0100 Subject: [PATCH 009/259] Merge pull request #25804 from overleaf/dp-on-for-guests Remove references to removed track changes onForGuests option GitOrigin-RevId: c251ad41633df33f0d963dbc3c2e5cb62920a5e1 --- .../context/track-changes-state-context.tsx | 14 +++++--------- .../js/shared/context/types/project-context.tsx | 2 ++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx index 73ffe78b5d..b621ac8ed8 100644 --- a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx @@ -20,7 +20,6 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions- export type TrackChangesState = { onForEveryone: boolean - onForGuests: boolean onForMembers: Record } @@ -31,7 +30,6 @@ export const TrackChangesStateContext = createContext< type SaveTrackChangesRequestBody = { on?: boolean on_for?: Record - on_for_guests?: boolean } type TrackChangesStateActions = { @@ -62,22 +60,20 @@ export const TrackChangesStateProvider: FC = ({ useEffect(() => { setWantTrackChanges( trackChangesValue === true || - (trackChangesValue !== false && - trackChangesValue[user.id ?? '__guests__']) + (trackChangesValue !== false && !!user.id && trackChangesValue[user.id]) ) }, [setWantTrackChanges, trackChangesValue, user.id]) const trackChangesIsObject = trackChangesValue !== true && trackChangesValue !== false const onForEveryone = trackChangesValue === true - const onForGuests = - onForEveryone || - (trackChangesIsObject && trackChangesValue.__guests__ === true) const onForMembers = useMemo(() => { const onForMembers: Record = {} if (trackChangesIsObject) { for (const key of Object.keys(trackChangesValue)) { + // TODO: Remove this check when we have converted + // all projects to the current format. if (key !== '__guests__') { onForMembers[key as UserId] = trackChangesValue[key as UserId] } @@ -145,8 +141,8 @@ export const TrackChangesStateProvider: FC = ({ ) const value = useMemo( - () => ({ onForEveryone, onForGuests, onForMembers }), - [onForEveryone, onForGuests, onForMembers] + () => ({ onForEveryone, onForMembers }), + [onForEveryone, onForMembers] ) return ( diff --git a/services/web/frontend/js/shared/context/types/project-context.tsx b/services/web/frontend/js/shared/context/types/project-context.tsx index 4e1abdc420..9f68f5e7a3 100644 --- a/services/web/frontend/js/shared/context/types/project-context.tsx +++ b/services/web/frontend/js/shared/context/types/project-context.tsx @@ -45,6 +45,8 @@ export type ProjectContextValue = { signUpDate: string } tags: Tag[] + // TODO: Remove __guests__ and boolean options when we have converted + // all projects to the current format. trackChangesState: boolean | Record projectSnapshot: ProjectSnapshot joinedOnce: boolean From 6bb074eec3d659263108430827db0c5c8b830149 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 22 May 2025 13:30:16 +0100 Subject: [PATCH 010/259] Merge pull request #25836 from overleaf/mj-ide-settings-padding [web] Add border padding to rail to reduce link text overlap GitOrigin-RevId: f0a49b51dccb6618ca991f2074845796f2b95933 --- .../web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss index b285dc084e..a3aa9ddbb4 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss @@ -148,6 +148,7 @@ display: flex; flex-direction: column; gap: var(--spacing-02); + padding-bottom: var(--spacing-04); } .ide-rail-tab-dropdown { From 3274235ac616d578b2ccf607f7f4b3ecc89d1b14 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 22 May 2025 13:30:34 +0100 Subject: [PATCH 011/259] Merge pull request #25832 from overleaf/mj-ide-hide-editor-actions-when-unavailable [web] Editor redesign: Hide editor options in menu bar when editor is not visible GitOrigin-RevId: c4d158f80821afbc5f7ff7d13dac8ff5ecff6315 --- .../use-toolbar-menu-editor-commands.tsx | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx b/services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx index e2bbae35ff..250a12c776 100644 --- a/services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx +++ b/services/web/frontend/js/features/ide-redesign/hooks/use-toolbar-menu-editor-commands.tsx @@ -38,7 +38,7 @@ export const useToolbarMenuBarEditorCommands = () => { const newEditor = useIsNewEditorEnabled() useCommandProvider(() => { - if (!newEditor) { + if (!newEditor || !editorIsVisible) { return } @@ -53,7 +53,7 @@ export const useToolbarMenuBarEditorCommands = () => { undo(view) view.focus() }, - disabled: !editorIsVisible || !trackedWrite, + disabled: !trackedWrite, }, { id: 'redo', @@ -62,7 +62,7 @@ export const useToolbarMenuBarEditorCommands = () => { redo(view) view.focus() }, - disabled: !editorIsVisible || !trackedWrite, + disabled: !trackedWrite, }, { id: 'find', @@ -70,7 +70,6 @@ export const useToolbarMenuBarEditorCommands = () => { handler: () => { openSearchPanel(view) }, - disabled: !editorIsVisible, }, { id: 'select-all', @@ -79,14 +78,13 @@ export const useToolbarMenuBarEditorCommands = () => { selectAll(view) view.focus() }, - disabled: !editorIsVisible, }, ] }, [editorIsVisible, t, view, trackedWrite, newEditor]) // LaTeX commands useCommandProvider(() => { - if (!newEditor) { + if (!newEditor || !editorIsVisible) { return } if (!isTeXFile || !trackedWrite) { @@ -104,7 +102,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.wrapInInlineMath(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'insert-display-math', @@ -113,7 +110,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.wrapInDisplayMath(view) view.focus() }, - disabled: !editorIsVisible, }, { label: t('upload_from_computer'), @@ -121,7 +117,6 @@ export const useToolbarMenuBarEditorCommands = () => { handler: () => { openFigureModal(FigureModalSource.FILE_UPLOAD) }, - disabled: !editorIsVisible, }, { label: t('from_project_files'), @@ -129,7 +124,6 @@ export const useToolbarMenuBarEditorCommands = () => { handler: () => { openFigureModal(FigureModalSource.FILE_TREE) }, - disabled: !editorIsVisible, }, { label: t('from_another_project'), @@ -137,7 +131,6 @@ export const useToolbarMenuBarEditorCommands = () => { handler: () => { openFigureModal(FigureModalSource.OTHER_PROJECT) }, - disabled: !editorIsVisible, }, { label: t('from_url'), @@ -145,7 +138,6 @@ export const useToolbarMenuBarEditorCommands = () => { handler: () => { openFigureModal(FigureModalSource.FROM_URL) }, - disabled: !editorIsVisible, }, { id: 'insert-table', @@ -154,7 +146,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.insertTable(view, 3, 3) view.focus() }, - disabled: !editorIsVisible, }, { id: 'insert-citation', @@ -163,7 +154,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.insertCite(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'insert-link', @@ -172,7 +162,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.wrapInHref(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'insert-cross-reference', @@ -181,7 +170,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.insertRef(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'comment', @@ -189,7 +177,6 @@ export const useToolbarMenuBarEditorCommands = () => { handler: () => { commands.addComment() }, - disabled: !editorIsVisible, }, /************************************ * Format menu @@ -201,7 +188,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.toggleBold(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-italics', @@ -210,7 +196,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.toggleItalic(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-bullet-list', @@ -219,7 +204,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.toggleBulletList(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-numbered-list', @@ -228,7 +212,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.toggleNumberedList(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-increase-indentation', @@ -237,7 +220,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.indentIncrease(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-decrease-indentation', @@ -246,7 +228,6 @@ export const useToolbarMenuBarEditorCommands = () => { commands.indentDecrease(view) view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-style-normal', @@ -255,7 +236,6 @@ export const useToolbarMenuBarEditorCommands = () => { setSectionHeadingLevel(view, 'text') view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-style-section', @@ -264,7 +244,6 @@ export const useToolbarMenuBarEditorCommands = () => { setSectionHeadingLevel(view, 'section') view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-style-subsection', @@ -273,7 +252,6 @@ export const useToolbarMenuBarEditorCommands = () => { setSectionHeadingLevel(view, 'subsection') view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-style-subsubsection', @@ -282,7 +260,6 @@ export const useToolbarMenuBarEditorCommands = () => { setSectionHeadingLevel(view, 'subsubsection') view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-style-paragraph', @@ -291,7 +268,6 @@ export const useToolbarMenuBarEditorCommands = () => { setSectionHeadingLevel(view, 'paragraph') view.focus() }, - disabled: !editorIsVisible, }, { id: 'format-style-subparagraph', @@ -300,7 +276,6 @@ export const useToolbarMenuBarEditorCommands = () => { setSectionHeadingLevel(view, 'subparagraph') view.focus() }, - disabled: !editorIsVisible, }, ] }, [ @@ -316,6 +291,10 @@ export const useToolbarMenuBarEditorCommands = () => { const { toggleSymbolPalette } = useEditorContext() const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable') useCommandProvider(() => { + if (!newEditor || !editorIsVisible) { + return + } + if (!symbolPaletteAvailable) { return } @@ -331,7 +310,6 @@ export const useToolbarMenuBarEditorCommands = () => { handler: () => { toggleSymbolPalette?.() }, - disabled: !editorIsVisible, }, ] }, [ @@ -341,5 +319,6 @@ export const useToolbarMenuBarEditorCommands = () => { editorIsVisible, isTeXFile, trackedWrite, + newEditor, ]) } From 978086c6587fa8b15265aeb9187dc90e56d2303d Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Fri, 23 May 2025 12:44:58 +0200 Subject: [PATCH 012/259] Merge pull request #25885 from overleaf/jpa-msm-tls-email [web] fix nodemailer config for tls GitOrigin-RevId: 6470a57bc66a89d463ca11f0f27e864a8cd3f61a --- services/web/app/src/Features/Email/EmailSender.js | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/app/src/Features/Email/EmailSender.js b/services/web/app/src/Features/Email/EmailSender.js index c11369cb93..bb9374c2bb 100644 --- a/services/web/app/src/Features/Email/EmailSender.js +++ b/services/web/app/src/Features/Email/EmailSender.js @@ -48,6 +48,7 @@ function getClient() { 'secure', 'auth', 'ignoreTLS', + 'tls', 'logger', 'name' ) From 01dc0a4b45c52034e3c669fdbcd67d00a12d063e Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Fri, 23 May 2025 12:45:07 +0200 Subject: [PATCH 013/259] Merge pull request #25882 from overleaf/jpa-sp-mongo-6 [web] bump minimum mongo version for Server Pro to 6.0 GitOrigin-RevId: 57821a0610b640880e3801e597f78103d580ee40 --- .../web/modules/server-ce-scripts/scripts/check-mongodb.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/modules/server-ce-scripts/scripts/check-mongodb.mjs b/services/web/modules/server-ce-scripts/scripts/check-mongodb.mjs index be5fecf6ce..7f17ae210f 100644 --- a/services/web/modules/server-ce-scripts/scripts/check-mongodb.mjs +++ b/services/web/modules/server-ce-scripts/scripts/check-mongodb.mjs @@ -6,7 +6,7 @@ import { const { ObjectId } = mongodb -const MIN_MONGO_VERSION = [5, 0] +const MIN_MONGO_VERSION = [6, 0] async function main() { let mongoClient From 3cf436c89ed591f9483910593475ad54fa6c06d9 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Fri, 23 May 2025 12:45:12 +0200 Subject: [PATCH 014/259] Merge pull request #25886 from overleaf/msm-add-skip-email-to-delete-user [CE] Add `--skip-email` to `delete-user` script GitOrigin-RevId: d0f5ced26930060df1e9f40dee97839076743bbd --- .../web/app/src/Features/User/UserDeleter.js | 8 ++++++-- .../server-ce-scripts/scripts/delete-user.mjs | 20 +++++++++++++++++-- .../test/unit/src/User/UserDeleterTests.js | 9 +++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/services/web/app/src/Features/User/UserDeleter.js b/services/web/app/src/Features/User/UserDeleter.js index 721943b163..662c51ca65 100644 --- a/services/web/app/src/Features/User/UserDeleter.js +++ b/services/web/app/src/Features/User/UserDeleter.js @@ -60,8 +60,12 @@ async function deleteUser(userId, options) { await _createDeletedUser(user, options) logger.info({ userId }, 'deleting user projects') await ProjectDeleter.promises.deleteUsersProjects(user._id) - logger.info({ userId }, 'sending deletion email to user') - await _sendDeleteEmail(user, options.force) + if (options.skipEmail) { + logger.info({ userId }, 'skipping sending deletion email to user') + } else { + logger.info({ userId }, 'sending deletion email to user') + await _sendDeleteEmail(user, options.force) + } logger.info({ userId }, 'deleting user record') await deleteMongoUser(user._id) logger.info({ userId }, 'user deletion complete') diff --git a/services/web/modules/server-ce-scripts/scripts/delete-user.mjs b/services/web/modules/server-ce-scripts/scripts/delete-user.mjs index 9b7b4592a3..1cb8aebdef 100644 --- a/services/web/modules/server-ce-scripts/scripts/delete-user.mjs +++ b/services/web/modules/server-ce-scripts/scripts/delete-user.mjs @@ -1,13 +1,28 @@ import UserGetter from '../../../app/src/Features/User/UserGetter.js' import UserDeleter from '../../../app/src/Features/User/UserDeleter.js' import { fileURLToPath } from 'url' +import minimist from 'minimist' const filename = fileURLToPath(import.meta.url) async function main() { - const email = (process.argv.slice(2).pop() || '').replace(/^--email=/, '') + const argv = minimist(process.argv.slice(2), { + string: ['email'], + boolean: ['skip-email'], + }) + + const { email, 'skip-email': skipEmail } = argv if (!email) { - console.error(`Usage: node ${filename} --email=joe@example.com`) + console.error( + `Usage: node ${filename} [--skip-email] --email=joe@example.com + +Deletes a user. All users' projects will also be deleted. + +Options: + --email email address of the user being deleted + --skip-email (optional) when present, the user is not notified of the deletion via email +` + ) process.exit(1) } @@ -25,6 +40,7 @@ async function main() { const options = { ipAddress: '0.0.0.0', force: true, + skipEmail, } UserDeleter.deleteUser(user._id, options, function (err) { if (err) { diff --git a/services/web/test/unit/src/User/UserDeleterTests.js b/services/web/test/unit/src/User/UserDeleterTests.js index 7ffaaede55..0c5e00c0f5 100644 --- a/services/web/test/unit/src/User/UserDeleterTests.js +++ b/services/web/test/unit/src/User/UserDeleterTests.js @@ -314,6 +314,15 @@ describe('UserDeleter', function () { ).to.have.been.calledWith('securityAlert', emailOptions) }) + it('should not email the user with skipEmail === true', async function () { + await this.UserDeleter.promises.deleteUser(this.userId, { + ipAddress: this.ipAddress, + skipEmail: true, + }) + expect(this.EmailHandler.promises.sendEmail).not.to.have.been + .called + }) + it('should fail when the email service fails', async function () { this.EmailHandler.promises.sendEmail = sinon .stub() From eaf71be07ca53c70bd96cc57e65199748ac673f2 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Fri, 23 May 2025 11:45:31 +0100 Subject: [PATCH 015/259] [references] move redis config to common path in settings (#25883) * [references] move redis config to common path in settings --------- Co-authored-by: mserranom GitOrigin-RevId: a5bf258bb71ff40344b53deb8c07dae849b4d00e --- server-ce/config/settings.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js index 164d8b0196..a7e8219858 100644 --- a/server-ce/config/settings.js +++ b/server-ce/config/settings.js @@ -140,6 +140,7 @@ const settings = { api: redisConfig, pubsub: redisConfig, project_history: redisConfig, + references: redisConfig, project_history_migration: { host: redisConfig.host, From 923708f9f9da9423fe7d98fee92b3ae3653dd5ff Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Fri, 23 May 2025 12:46:16 +0200 Subject: [PATCH 016/259] Merge pull request #25889 from overleaf/jpa-web-wait-for-mongo [web] wait for DB before fetching global blobs GitOrigin-RevId: 2beefd39ae4be4d233e2aac018d471bf949faea2 --- services/web/app/src/Features/History/HistoryManager.js | 3 ++- services/web/app/src/infrastructure/mongodb.js | 5 +++++ services/web/test/unit/src/History/HistoryManagerTests.js | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/services/web/app/src/Features/History/HistoryManager.js b/services/web/app/src/Features/History/HistoryManager.js index fe9e6e86a7..42d7e229bf 100644 --- a/services/web/app/src/Features/History/HistoryManager.js +++ b/services/web/app/src/Features/History/HistoryManager.js @@ -11,7 +11,7 @@ const OError = require('@overleaf/o-error') const UserGetter = require('../User/UserGetter') const ProjectGetter = require('../Project/ProjectGetter') const HistoryBackupDeletionHandler = require('./HistoryBackupDeletionHandler') -const { db, ObjectId } = require('../../infrastructure/mongodb') +const { db, ObjectId, waitForDb } = require('../../infrastructure/mongodb') const Metrics = require('@overleaf/metrics') const logger = require('@overleaf/logger') const { NotFoundError } = require('../Errors/Errors') @@ -50,6 +50,7 @@ function getBlobLocation(projectId, hash) { } async function loadGlobalBlobs() { + await waitForDb() // CHANGE FROM SOURCE: wait for db before running query. const blobs = db.projectHistoryGlobalBlobs.find() for await (const blob of blobs) { GLOBAL_BLOBS.add(blob._id) // CHANGE FROM SOURCE: only store hashes. diff --git a/services/web/app/src/infrastructure/mongodb.js b/services/web/app/src/infrastructure/mongodb.js index 840122791b..a3342c6575 100644 --- a/services/web/app/src/infrastructure/mongodb.js +++ b/services/web/app/src/infrastructure/mongodb.js @@ -130,10 +130,15 @@ async function getCollectionInternal(name) { return internalDb.collection(name) } +async function waitForDb() { + await connectionPromise +} + module.exports = { db, ObjectId, connectionPromise, + waitForDb, getCollectionNames, getCollectionInternal, cleanupTestDatabase, diff --git a/services/web/test/unit/src/History/HistoryManagerTests.js b/services/web/test/unit/src/History/HistoryManagerTests.js index 6b83d15ed0..aa59cda4e6 100644 --- a/services/web/test/unit/src/History/HistoryManagerTests.js +++ b/services/web/test/unit/src/History/HistoryManagerTests.js @@ -3,9 +3,9 @@ const sinon = require('sinon') const SandboxedModule = require('sandboxed-module') const { ObjectId } = require('mongodb-legacy') const { - connectionPromise, cleanupTestDatabase, db, + waitForDb, } = require('../../../../app/src/infrastructure/mongodb') const MODULE_PATH = '../../../../app/src/Features/History/HistoryManager' @@ -19,7 +19,7 @@ const GLOBAL_BLOBS = { describe('HistoryManager', function () { before(async function () { - await connectionPromise + await waitForDb() }) before(cleanupTestDatabase) before(async function () { @@ -90,7 +90,7 @@ describe('HistoryManager', function () { this.HistoryManager = SandboxedModule.require(MODULE_PATH, { requires: { - '../../infrastructure/mongodb': { ObjectId, db }, + '../../infrastructure/mongodb': { ObjectId, db, waitForDb }, '@overleaf/fetch-utils': this.FetchUtils, '@overleaf/settings': this.settings, '../User/UserGetter': this.UserGetter, From 25577379fc4c3c1d6aa4569f1fa8547c541c848f Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Fri, 23 May 2025 14:19:32 +0100 Subject: [PATCH 017/259] [clsi] add env var override for seccomp profile (#25894) GitOrigin-RevId: 6ef9a5c1f9149147641abb9fe1798b1b41a14c05 --- services/clsi/config/settings.defaults.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/clsi/config/settings.defaults.js b/services/clsi/config/settings.defaults.js index d187fe273e..1d82258a8e 100644 --- a/services/clsi/config/settings.defaults.js +++ b/services/clsi/config/settings.defaults.js @@ -141,9 +141,11 @@ if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') { let seccompProfilePath try { seccompProfilePath = Path.resolve(__dirname, '../seccomp/clsi-profile.json') - module.exports.clsi.docker.seccomp_profile = JSON.stringify( - JSON.parse(require('node:fs').readFileSync(seccompProfilePath)) - ) + module.exports.clsi.docker.seccomp_profile = + process.env.SECCOMP_PROFILE || + JSON.stringify( + JSON.parse(require('node:fs').readFileSync(seccompProfilePath)) + ) } catch (error) { console.error( error, From 1c6ee3f930cdd35fc82450178123a40b73cb8175 Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Sat, 24 May 2025 01:33:31 +0000 Subject: [PATCH 018/259] auto update translation GitOrigin-RevId: 4e2e3d1e7ca70f76f13f905753ba1ca2c945b72f --- services/web/locales/da.json | 1 - services/web/locales/de.json | 1 - services/web/locales/es.json | 1 - services/web/locales/fr.json | 1 - services/web/locales/it.json | 1 - services/web/locales/ja.json | 1 - services/web/locales/ko.json | 1 - services/web/locales/nl.json | 1 - services/web/locales/no.json | 1 - services/web/locales/pt.json | 1 - services/web/locales/ru.json | 1 - services/web/locales/sv.json | 1 - services/web/locales/tr.json | 1 - services/web/locales/zh-CN.json | 4 ---- 14 files changed, 17 deletions(-) diff --git a/services/web/locales/da.json b/services/web/locales/da.json index 86911ff083..3fc8910d50 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -5,7 +5,6 @@ "3_4_width": "¾ bredde", "About": "Om", "Account": "Konto", - "Account Settings": "Kontoindstillinger", "Documentation": "Dokumentation", "Projects": "Projekter", "Security": "Sikkerhed", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 928c95499e..c336215ee8 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -4,7 +4,6 @@ "3_4_width": "¾ Breite", "About": "Über uns", "Account": "Konto", - "Account Settings": "Kontoeinstellungen", "Documentation": "Dokumentation", "Projects": "Projekte", "Security": "Sicherheit", diff --git a/services/web/locales/es.json b/services/web/locales/es.json index 0565d9d541..2c9f5b1fbe 100644 --- a/services/web/locales/es.json +++ b/services/web/locales/es.json @@ -4,7 +4,6 @@ "3_4_width": "¾ ancho", "About": "Quiénes somos", "Account": "Cuenta", - "Account Settings": "Opciones de la cuenta", "Documentation": "Documentación", "Projects": "Proyectos", "Security": "Seguridad", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index b60de8ed5a..a47a785740 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -4,7 +4,6 @@ "3_4_width": "¾ largeur", "About": "À propos", "Account": "Compte", - "Account Settings": "Paramètres du compte", "Documentation": "Documentation", "Projects": "Projets", "Security": "Sécurité", diff --git a/services/web/locales/it.json b/services/web/locales/it.json index 2c5c880ad7..d8e0a1fa62 100644 --- a/services/web/locales/it.json +++ b/services/web/locales/it.json @@ -1,7 +1,6 @@ { "About": "About", "Account": "Account", - "Account Settings": "Impostazioni Account", "Documentation": "Documentazione", "Projects": "Progetti", "Security": "Sicurezza", diff --git a/services/web/locales/ja.json b/services/web/locales/ja.json index a6ddefe117..b96b4f5b75 100644 --- a/services/web/locales/ja.json +++ b/services/web/locales/ja.json @@ -1,7 +1,6 @@ { "About": "概要", "Account": "アカウント", - "Account Settings": "アカウントの設定", "Documentation": "ドキュメンテーション", "Projects": "プロジェクト", "Security": "セキュリティ", diff --git a/services/web/locales/ko.json b/services/web/locales/ko.json index cb320bd996..d3fdd36242 100644 --- a/services/web/locales/ko.json +++ b/services/web/locales/ko.json @@ -1,7 +1,6 @@ { "About": "소개", "Account": "계정", - "Account Settings": "계정 설정", "Documentation": "참고 문서", "Projects": "프로젝트", "Security": "보안", diff --git a/services/web/locales/nl.json b/services/web/locales/nl.json index 14e0d7b703..f3a60aa417 100644 --- a/services/web/locales/nl.json +++ b/services/web/locales/nl.json @@ -1,7 +1,6 @@ { "About": "Over", "Account": "Account", - "Account Settings": "Accountinstellingen", "Documentation": "Documentatie", "Projects": "Projecten", "Security": "Beveiliging", diff --git a/services/web/locales/no.json b/services/web/locales/no.json index 306970b1b3..73e98199ad 100644 --- a/services/web/locales/no.json +++ b/services/web/locales/no.json @@ -1,7 +1,6 @@ { "About": "Om", "Account": "Konto", - "Account Settings": "Kontoinnstillinger", "Documentation": "Dokumentasjon", "Projects": "Prosjekter", "Security": "Sikkerhet", diff --git a/services/web/locales/pt.json b/services/web/locales/pt.json index 8d2455c369..27add8a65b 100644 --- a/services/web/locales/pt.json +++ b/services/web/locales/pt.json @@ -1,7 +1,6 @@ { "About": "Sobre", "Account": "Conta", - "Account Settings": "Configurações da Conta", "Documentation": "Documentação", "Projects": "Projetos", "Security": "Segurança", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index ed77938c53..13975aadf2 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -6,7 +6,6 @@ "3_4_width": "¾ ширины", "About": "О сайте", "Account": "Аккаунт", - "Account Settings": "Настройки аккаунта", "Documentation": "Документация", "Projects": "Проекты", "Security": "Безопасность", diff --git a/services/web/locales/sv.json b/services/web/locales/sv.json index da03604b4b..9304c00da6 100644 --- a/services/web/locales/sv.json +++ b/services/web/locales/sv.json @@ -1,7 +1,6 @@ { "About": "Om", "Account": "Konto", - "Account Settings": "Kontoinställningar", "Documentation": "Dokumentation", "Projects": "Projekt", "Security": "Säkerhet", diff --git a/services/web/locales/tr.json b/services/web/locales/tr.json index 163264953a..322497e9ee 100644 --- a/services/web/locales/tr.json +++ b/services/web/locales/tr.json @@ -1,7 +1,6 @@ { "About": "Hakkında", "Account": "Hesap", - "Account Settings": "Hesap Ayarları", "Documentation": "Dökümantasyon", "Projects": "Projeler", "Security": "Güvenlik", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index 6611d0f31d..e69edbbd6d 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -6,7 +6,6 @@ "3_4_width": "¾ 宽度", "About": "关于", "Account": "账户", - "Account Settings": "账户设置", "Documentation": "文档", "Projects": "项目", "Security": "安全性", @@ -1862,7 +1861,6 @@ "saml_response": "SAML 响应:", "save": "保存", "save_20_percent": "节省 20%", - "save_20_percent_when_you_switch_to_annual": "切换到年度计划可节省 20%", "save_or_cancel-cancel": "取消", "save_or_cancel-or": "或者", "save_or_cancel-save": "保存", @@ -2127,7 +2125,6 @@ "sure_you_want_to_delete": "您确定要永久删除以下文件吗?", "sure_you_want_to_leave_group": "您确定要退出该群吗?", "sv": "瑞典语", - "switch_back_to_monthly_pay_20_more": "切换回按月付费(增加 20%)", "switch_compile_mode_for_faster_draft_compilation": "切换编译模式以加快草稿编译速度", "switch_to_editor": "切换到编辑器", "switch_to_new_editor": "切换到新编辑器", @@ -2586,7 +2583,6 @@ "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "您是由 <1>__adminEmail__ 管理的 <1>__groupName__ 团队的、<0>__planName__ 计划的 <1>管理员", "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you": "您是<1>您 (__adminEmail__) 管理的<0>__planName__团体订阅<1>__groupName__的<1>管理员。", "you_are_currently_logged_in_as": "您当前以 __email__ 身份登录。", - "you_are_now_saving_20_percent": "您现在节省 20%", "you_are_on_a_paid_plan_contact_support_to_find_out_more": "您使用的是 __appName__ 付费计划。 <0>联系支持人员以了解更多信息。", "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "您作为 <1>__institutionName__ 的<1>确认成员加入了我们的<0>__planName__计划", "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "您作为<1>__groupName__群组订阅的<1>成员加入了我们的<0>__planName__计划,该群组订阅由<1>__adminEmail__管理", From 43563158d377350909cfa5d9db8690d2d64d4148 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Tue, 27 May 2025 09:00:05 +0100 Subject: [PATCH 019/259] Merge pull request #25779 from overleaf/dp-recompile-button Update Recompile button to match figma designs GitOrigin-RevId: c3614fe2e621a64eb35dd4989b86c68a89bea342 --- .../components/pdf-compile-button.tsx | 4 +- .../bootstrap-5/pages/editor/pdf.scss | 49 +++++++++++++------ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx index b2b78d9e19..d693fe071f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx @@ -75,11 +75,13 @@ function PdfCompileButton() { 'btn-striped-animated': hasChanges, }, 'no-left-border', - 'dropdown-button-toggle' + 'dropdown-button-toggle', + 'compile-dropdown-toggle' ) const buttonClassName = classNames( 'align-items-center py-0 no-left-radius px-3', + 'compile-button', { 'btn-striped-animated': hasChanges, } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss index df5c9e2b77..31000b0478 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss @@ -12,6 +12,40 @@ } } +.ide-redesign-main { + --pdf-bg: var(--bg-dark-secondary); + + .pdf-viewer { + .pdfjs-viewer { + .page { + box-shadow: + 0 5px 5px 0 #23282f0d, + 0 3px 14px 0 #23282f08, + 0 8px 10px 0 #23282f14; + } + } + } + + .toolbar-pdf-left { + .compile-button-group { + height: 24px; + border-radius: 12px; + margin-left: var(--spacing-02); + } + + .dropdown > .compile-button { + border-top-left-radius: 12px; + border-bottom-left-radius: 12px; + font-size: var(--font-size-02); + } + + .dropdown > .compile-dropdown-toggle { + width: 26px; + padding: var(--spacing-01); + } + } +} + .pdf .toolbar.toolbar-pdf { @include toolbar-sm-height; @include toolbar-alt-bg; @@ -158,21 +192,6 @@ top: var(--toolbar-small-height); } -.ide-redesign-main { - --pdf-bg: var(--bg-dark-secondary); - - .pdf-viewer { - .pdfjs-viewer { - .page { - box-shadow: - 0 5px 5px 0 #23282f0d, - 0 3px 14px 0 #23282f08, - 0 8px 10px 0 #23282f14; - } - } - } -} - .pdf-viewer { isolation: isolate; From 9000a3b70c5872de944e033b773f5d5b91c6cca9 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Tue, 27 May 2025 09:00:16 +0100 Subject: [PATCH 020/259] Merge pull request #25923 from overleaf/dp-view-dropdown Update UI of view dropdown GitOrigin-RevId: 2d689a73886e0821eaa21e6666092e9414528e55 --- .../features/ide-redesign/components/toolbar/menu-bar.tsx | 7 ++++++- .../js/shared/components/menu-bar/menu-bar-option.tsx | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx index e046eafde3..ed0ebd77f8 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx @@ -1,6 +1,7 @@ import { DropdownDivider, DropdownHeader, + DropdownItem, } from '@/features/ui/components/bootstrap-5/dropdown-menu' import { MenuBar } from '@/shared/components/menu-bar/menu-bar' import { MenuBarDropdown } from '@/shared/components/menu-bar/menu-bar-dropdown' @@ -209,13 +210,17 @@ export const ToolbarMenuBar = () => { className="ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued" > + Editor settings + } onClick={toggleMathPreview} /> + setSelected(null)} onClick={onClick} disabled={disabled} + leadingIcon={leadingIcon} trailingIcon={trailingIcon} href={href} rel={rel} From f7b6246d41887b7d29ab569ffd03092c7a24f80f Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 27 May 2025 10:02:13 +0200 Subject: [PATCH 021/259] [web] Use 6-digits verification in project-list notifications (bis) (#25847) * Pull email context outside of `ResendConfirmationCodeModal` * Use `loading` prop of button instead of deprecated Icon * Swap notification order to clarify priority (no change in behaviour) * Replace confirmation link action by confirmationCodeModal, and simplify code * Change to secondary button variant in the Notification * Display errors within the modal * Increase ratelimit for resend-confirmation * Copy changes * Add stories on email confirmation notifications * Fix other Notification stories * Update tests * Update services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> * Remove placeholder on 6-digit code input --------- Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> GitOrigin-RevId: dad8bfd79505a2e7d065fd48791fd57c8a31e071 --- services/web/app/src/router.mjs | 2 +- .../web/frontend/extracted-translations.json | 8 +- .../notifications/groups/confirm-email.tsx | 251 +++++++----------- .../components/emails/confirm-email-form.tsx | 31 ++- .../settings/components/emails/email.tsx | 15 +- .../emails/resend-confirmation-code-modal.tsx | 56 ++-- .../frontend/stories/hooks/use-fetch-mock.tsx | 1 + .../project-list/notifications.stories.tsx | 9 + .../notifications/confirm-email.stories.tsx | 66 +++++ services/web/locales/en.json | 8 +- .../components/notifications.test.tsx | 78 +++--- .../components/emails/emails-section.test.tsx | 22 +- 12 files changed, 282 insertions(+), 265 deletions(-) create mode 100644 services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index f87297c35c..a7e8d5e05f 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -182,7 +182,7 @@ const rateLimiters = { duration: 60, }), sendConfirmation: new RateLimiter('send-confirmation', { - points: 1, + points: 2, duration: 60, }), sendChatMessage: new RateLimiter('send-chat-message', { diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index c64817b94c..9862e47817 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -519,7 +519,6 @@ "enabling": "", "end_of_document": "", "ensure_recover_account": "", - "enter_6_digit_code": "", "enter_any_size_including_units_or_valid_latex_command": "", "enter_image_url": "", "enter_the_code": "", @@ -1224,8 +1223,8 @@ "please_check_your_inbox_to_confirm": "", "please_compile_pdf_before_download": "", "please_compile_pdf_before_word_count": "", - "please_confirm_primary_email": "", - "please_confirm_secondary_email": "", + "please_confirm_primary_email_or_edit": "", + "please_confirm_secondary_email_or_edit": "", "please_confirm_your_email_before_making_it_default": "", "please_contact_support_to_makes_change_to_your_plan": "", "please_enter_confirmation_code": "", @@ -1375,7 +1374,6 @@ "remote_service_error": "", "remove": "", "remove_access": "", - "remove_email_address": "", "remove_from_group": "", "remove_link": "", "remove_manager": "", @@ -1408,7 +1406,6 @@ "resend_link_sso": "", "resend_managed_user_invite": "", "resending_confirmation_code": "", - "resending_confirmation_email": "", "resize": "", "resolve_comment": "", "resolve_comment_error_message": "", @@ -1520,6 +1517,7 @@ "select_user": "", "selected": "", "selection_deleted": "", + "send_confirmation_code": "", "send_first_message": "", "send_message": "", "send_request": "", diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx index ca73d87a0c..364e60fd3a 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx @@ -1,16 +1,10 @@ import { Trans, useTranslation } from 'react-i18next' import Notification from '../notification' import getMeta from '../../../../../utils/meta' -import useAsync from '../../../../../shared/hooks/use-async' import { useProjectListContext } from '../../../context/project-list-context' -import { - postJSON, - getUserFacingMessage, -} from '../../../../../infrastructure/fetch-json' import { UserEmailData } from '../../../../../../../types/user-email' -import { debugConsole } from '@/utils/debugging' -import OLButton from '@/features/ui/components/ol/ol-button' -import LoadingSpinner from '@/shared/components/loading-spinner' +import ResendConfirmationCodeModal from '@/features/settings/components/emails/resend-confirmation-code-modal' +import { ReactNode, useState } from 'react' const ssoAvailable = ({ samlProviderId, affiliation }: UserEmailData) => { const { hasSamlFeature, hasSamlBeta } = getMeta('ol-ExposedSettings') @@ -114,12 +108,17 @@ function getEmailDeletionDate(emailData: UserEmailData, signUpDate: string) { function ConfirmEmailNotification({ userEmail, signUpDate, + setIsLoading, + isLoading, }: { userEmail: UserEmailData signUpDate: string + setIsLoading: (loading: boolean) => void + isLoading: boolean }) { const { t } = useTranslation() - const { isLoading, isSuccess, isError, error, runAsync } = useAsync() + const [isSuccess, setIsSuccess] = useState(false) + const emailAddress = userEmail.email // We consider secondary emails added on or after 22.03.2024 to be trusted for account recovery // https://github.com/overleaf/internal/pull/17572 @@ -127,6 +126,7 @@ function ConfirmEmailNotification({ const emailDeletionDate = getEmailDeletionDate(userEmail, signUpDate) const isPrimary = userEmail.default + const isEmailConfirmed = !!userEmail.lastConfirmedAt const isEmailTrusted = userEmail.lastConfirmedAt && new Date(userEmail.lastConfirmedAt) >= emailTrustCutoffDate @@ -134,163 +134,97 @@ function ConfirmEmailNotification({ const shouldShowCommonsNotification = emailHasLicenceAfterConfirming(userEmail) && isOnFreeOrIndividualPlan() - const handleResendConfirmationEmail = ({ email }: UserEmailData) => { - runAsync( - postJSON('/user/emails/resend_confirmation', { - body: { email }, - }) - ).catch(debugConsole.error) - } - if (isSuccess) { return null } - if (!userEmail.lastConfirmedAt && !shouldShowCommonsNotification) { - return ( - - {isLoading ? ( -
- -
- ) : isError ? ( -
{getUserFacingMessage(error)}
- ) : ( - <> -

- {isPrimary - ? t('please_confirm_primary_email', { - emailAddress: userEmail.email, - }) - : t('please_confirm_secondary_email', { - emailAddress: userEmail.email, - })} -

- {emailDeletionDate && ( -

- {t('email_remove_by_date', { date: emailDeletionDate })} -

- )} - - )} - - } - action={ - <> - handleResendConfirmationEmail(userEmail)} - > - {t('resend_confirmation_email')} - - - {isPrimary - ? t('change_primary_email') - : t('remove_email_address')} - - - } - /> - ) - } + const confirmationCodeModal = ( + setIsSuccess(true)} + setGroupLoading={setIsLoading} + groupLoading={isLoading} + triggerVariant="secondary" + /> + ) - if (!isEmailTrusted && !isPrimary && !shouldShowCommonsNotification) { - return ( - - {isLoading ? ( -
- -
- ) : isError ? ( -
{getUserFacingMessage(error)}
- ) : ( - <> -

- {t('confirm_secondary_email')} -

-

- {t('reconfirm_secondary_email', { - emailAddress: userEmail.email, - })} -

-

{t('ensure_recover_account')}

- - )} - - } - action={ - <> - handleResendConfirmationEmail(userEmail)} - > - {t('resend_confirmation_email')} - - - {t('remove_email_address')} - - - } - /> - ) - } + let notificationType: 'info' | 'warning' | undefined + let notificationBody: ReactNode | undefined - // Only show the notification if a) a commons license is available and b) the - // user is on a free or individual plan. Users on a group or Commons plan - // already have premium features. if (shouldShowCommonsNotification) { + notificationType = 'info' + notificationBody = ( + <> + ]} // eslint-disable-line react/jsx-key + /> +
+ ]} // eslint-disable-line react/jsx-key + /> + + ) + } else if (!isEmailConfirmed) { + notificationType = 'warning' + notificationBody = ( + <> +

+ {isPrimary ? ( + , + ]} + /> + ) : ( + , + ]} + /> + )} +

+ {emailDeletionDate && ( +

{t('email_remove_by_date', { date: emailDeletionDate })}

+ )} + + ) + } else if (!isEmailTrusted && !isPrimary) { + notificationType = 'warning' + notificationBody = ( + <> +

+ {t('confirm_secondary_email')} +

+

{t('reconfirm_secondary_email', { emailAddress })}

+

{t('ensure_recover_account')}

+ + ) + } + + if (notificationType) { return ( - {isLoading ? ( - - ) : isError ? ( -
{getUserFacingMessage(error)}
- ) : ( - <> - ]} // eslint-disable-line react/jsx-key - /> -
- ]} // eslint-disable-line react/jsx-key - /> - - )} - - } - action={ - handleResendConfirmationEmail(userEmail)} - > - {t('resend_email')} - - } + type={notificationType} + content={notificationBody} + action={confirmationCodeModal} /> ) } @@ -302,6 +236,7 @@ function ConfirmEmail() { const { totalProjectsCount } = useProjectListContext() const userEmails = getMeta('ol-userEmails') || [] const signUpDate = getMeta('ol-user')?.signUpDate + const [isLoading, setIsLoading] = useState(false) if (!totalProjectsCount || !userEmails.length || !signUpDate) { return null @@ -315,6 +250,8 @@ function ConfirmEmail() { key={`confirm-email-${userEmail.email}`} userEmail={userEmail} signUpDate={signUpDate} + isLoading={isLoading} + setIsLoading={setIsLoading} /> ) : null })} diff --git a/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx index 66aaee9cce..d82a43315c 100644 --- a/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx +++ b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx @@ -2,7 +2,7 @@ import { postJSON } from '@/infrastructure/fetch-json' import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' import Notification from '@/shared/components/notification' import getMeta from '@/utils/meta' -import { FormEvent, MouseEventHandler, useState } from 'react' +import { FormEvent, MouseEventHandler, ReactNode, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import LoadingSpinner from '@/shared/components/loading-spinner' import MaterialIcon from '@/shared/components/material-icon' @@ -27,6 +27,7 @@ type ConfirmEmailFormProps = { interstitial: boolean isModal?: boolean onCancel?: () => void + outerError?: string } export function ConfirmEmailForm({ @@ -40,15 +41,17 @@ export function ConfirmEmailForm({ interstitial, isModal, onCancel, + outerError, }: ConfirmEmailFormProps) { const { t } = useTranslation() const [confirmationCode, setConfirmationCode] = useState('') const [feedback, setFeedback] = useState(null) const [isConfirming, setIsConfirming] = useState(false) const [isResending, setIsResending] = useState(false) + const [hasResent, setHasResent] = useState(false) const [successRedirectPath, setSuccessRedirectPath] = useState('') const { isReady } = useWaitForI18n() - + const outerErrorDisplay = (!hasResent && outerError) || null const errorHandler = (err: any, actionType?: string) => { let errorName = err?.data?.message?.key || 'generic_something_went_wrong' @@ -131,6 +134,7 @@ export function ConfirmEmailForm({ }) .finally(() => { setIsResending(false) + setHasResent(true) }) sendMB('email-verification-click', { @@ -158,8 +162,15 @@ export function ConfirmEmailForm({ ) } - let intro =
{t('confirm_your_email')}
- if (isModal) intro =
{t('we_sent_code')}
+ let intro: ReactNode | null = ( +
{t('confirm_your_email')}
+ ) + if (isModal) + intro = outerErrorDisplay ? ( +
+ ) : ( +

{outerErrorDisplay ? null : t('we_sent_code')}

+ ) if (interstitial) intro = (

{t('confirm_your_email')}

@@ -172,12 +183,14 @@ export function ConfirmEmailForm({ className="confirm-email-form" >
- {feedback?.type === 'alert' && ( + {(feedback?.type === 'alert' || outerErrorDisplay) && ( } + type={outerErrorDisplay ? 'error' : feedback!.style} + content={ + outerErrorDisplay || + } /> )} @@ -191,7 +204,6 @@ export function ConfirmEmailForm({
{feedback?.type === 'input' && ( @@ -214,7 +227,7 @@ export function ConfirmEmailForm({
{t('unconfirmed')}.
{!ssoAvailable && ( - + )}
)} diff --git a/services/web/frontend/js/features/settings/components/emails/resend-confirmation-code-modal.tsx b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-code-modal.tsx index 0c7b1394fe..b17337924e 100644 --- a/services/web/frontend/js/features/settings/components/emails/resend-confirmation-code-modal.tsx +++ b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-code-modal.tsx @@ -1,10 +1,8 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import Icon from '../../../../shared/components/icon' import { FetchError, postJSON } from '@/infrastructure/fetch-json' import useAsync from '../../../../shared/hooks/use-async' import { UserEmailData } from '../../../../../../types/user-email' -import { useUserEmailsContext } from '../../context/user-email-context' import OLButton from '@/features/ui/components/ol/ol-button' import OLModal, { OLModalBody, @@ -16,39 +14,33 @@ import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm- type ResendConfirmationEmailButtonProps = { email: UserEmailData['email'] + groupLoading: boolean + setGroupLoading: (loading: boolean) => void + onSuccess: () => void + triggerVariant: 'link' | 'secondary' } function ResendConfirmationCodeModal({ email, + groupLoading, + setGroupLoading, + onSuccess, + triggerVariant, }: ResendConfirmationEmailButtonProps) { const { t } = useTranslation() const { error, isLoading, isError, runAsync } = useAsync() - const { - state, - setLoading: setUserEmailsContextLoading, - getEmails, - } = useUserEmailsContext() const [modalVisible, setModalVisible] = useState(false) - // Update global isLoading prop useEffect(() => { - setUserEmailsContextLoading(isLoading) - }, [setUserEmailsContextLoading, isLoading]) + setGroupLoading(isLoading) + }, [isLoading, setGroupLoading]) const handleResendConfirmationEmail = async () => { await runAsync( postJSON('/user/emails/send-confirmation-code', { body: { email } }) ) - .then(() => setModalVisible(true)) .catch(() => {}) - } - - if (isLoading) { - return ( - <> - {t('sending')}… - - ) + .finally(() => setModalVisible(true)) } const rateLimited = @@ -77,9 +69,16 @@ function ResendConfirmationCodeModal({ confirmationEndpoint="/user/emails/confirm-code" email={email} onSuccessfulConfirmation={() => { - getEmails() + onSuccess() setModalVisible(false) }} + outerError={ + isError + ? rateLimited + ? t('too_many_requests') + : t('generic_something_went_wrong') + : undefined + } /> @@ -94,21 +93,14 @@ function ResendConfirmationCodeModal({ )} - {t('resend_confirmation_code')} + {t('send_confirmation_code')} -
- {isError && ( -
- {rateLimited - ? t('too_many_requests') - : t('generic_something_went_wrong')} -
- )} ) } diff --git a/services/web/frontend/stories/hooks/use-fetch-mock.tsx b/services/web/frontend/stories/hooks/use-fetch-mock.tsx index 7f00118aac..304d9e0273 100644 --- a/services/web/frontend/stories/hooks/use-fetch-mock.tsx +++ b/services/web/frontend/stories/hooks/use-fetch-mock.tsx @@ -10,6 +10,7 @@ export default function useFetchMock( fetchMock.mockGlobal() useLayoutEffect(() => { + fetchMock.mockGlobal() callback(fetchMock) return () => { fetchMock.removeRoutes() diff --git a/services/web/frontend/stories/project-list/notifications.stories.tsx b/services/web/frontend/stories/project-list/notifications.stories.tsx index aaabe2ba5b..90fa82bfa5 100644 --- a/services/web/frontend/stories/project-list/notifications.stories.tsx +++ b/services/web/frontend/stories/project-list/notifications.stories.tsx @@ -14,6 +14,8 @@ import { setReconfirmationMeta, } from './helpers/emails' import { useMeta } from '../hooks/use-meta' +import { SplitTestProvider } from '@/shared/context/split-test-context' +import React, { ComponentType } from 'react' export const ProjectInvite = (args: any) => { useFetchMock(commonSetupMocks) @@ -343,4 +345,11 @@ export const ReconfirmedAffiliationSuccess = (args: any) => { export default { title: 'Project List / Notifications', component: UserNotifications, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], } diff --git a/services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx b/services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx new file mode 100644 index 0000000000..5bbcbcf8fb --- /dev/null +++ b/services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx @@ -0,0 +1,66 @@ +import { SplitTestProvider } from '@/shared/context/split-test-context' +import UserNotifications from '../../../js/features/project-list/components/notifications/user-notifications' +import { ProjectListProvider } from '../../../js/features/project-list/context/project-list-context' +import { useMeta } from '../../hooks/use-meta' +import useFetchMock from '../../hooks/use-fetch-mock' +import ConfirmEmailNotification from '@/features/project-list/components/notifications/groups/confirm-email' + +export const ConfirmEmail = (args: any) => { + useMeta({ + 'ol-userEmails': [ + { + email: 'erika.mustermann+unconfirmed-primary@example.com', + default: true, + }, + { email: 'erika.mustermann+unconfirmed@example.com' }, + { + email: 'erika.mustermann+untrusted@example.com', + lastConfirmedAt: '2019-01-01', + confirmedAt: '2019-01-01', + }, + { + email: 'erika.mustermann+mit@example.com', + affiliation: { + institution: { + id: 123, + name: 'Massachusetts Institute of Technology', + confirmed: true, + commonsAccount: true, + }, + }, + }, + ], + 'ol-user': { signUpDate: '2021-01-01' }, + 'ol-usersBestSubscription': { type: 'free' }, + 'ol-prefetchedProjectsBlob': { totalSize: 20 }, + }) + useFetchMock(fetchMock => { + fetchMock.post('/user/emails/send-confirmation-code', args.statusCode, { + delay: 500, + }) + fetchMock.post('/user/emails/confirm-code', args.statusCode, { + delay: 500, + }) + fetchMock.post('/user/emails/resend-confirmation-code', args.statusCode, { + delay: 500, + }) + }) + return ( + + + + + + ) +} + +export default { + title: 'Project List / Notifications', + component: ConfirmEmailNotification, + args: { + statusCode: 200, + }, + argTypes: { + statusCode: { type: 'select', options: [200, 400, 429] }, + }, +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 4729f54756..910621f51a 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -669,7 +669,6 @@ "enabling": "Enabling", "end_of_document": "End of document", "ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.", - "enter_6_digit_code": "Enter 6-digit code", "enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command", "enter_image_url": "Enter image URL", "enter_the_code": "Enter the 6-digit code sent to __email__.", @@ -1627,8 +1626,8 @@ "please_compile_pdf_before_download": "Please compile your project before downloading the PDF", "please_compile_pdf_before_word_count": "Please compile your project before performing a word count", "please_confirm_email": "Please confirm your email __emailAddress__ by clicking on the link in the confirmation email ", - "please_confirm_primary_email": "Please confirm your primary email address __emailAddress__ by clicking on the link in the confirmation email.", - "please_confirm_secondary_email": "Please confirm your secondary email address __emailAddress__ by clicking on the link in the confirmation email.", + "please_confirm_primary_email_or_edit": "Please confirm your primary email address __emailAddress__. To edit it, go to <0>Account settings.", + "please_confirm_secondary_email_or_edit": "Please confirm your secondary email address __emailAddress__. To edit it, go to <0>Account settings.", "please_confirm_your_email_before_making_it_default": "Please confirm your email before making it the primary.", "please_contact_support_to_makes_change_to_your_plan": "Please <0>contact Support to make changes to your plan", "please_contact_us_if_you_think_this_is_in_error": "Please <0>contact us if you think this is in error.", @@ -1810,7 +1809,6 @@ "remote_service_error": "The remote service produced an error", "remove": "Remove", "remove_access": "Remove access", - "remove_email_address": "Remove email address", "remove_from_group": "Remove from group", "remove_link": "Remove link", "remove_manager": "Remove manager", @@ -1853,7 +1851,6 @@ "resend_link_sso": "Resend SSO invite", "resend_managed_user_invite": "Resend managed user invite", "resending_confirmation_code": "Resending confirmation code", - "resending_confirmation_email": "Resending confirmation email", "reset_password": "Reset Password", "reset_password_link": "Click this link to reset your password", "reset_password_sentence_case": "Reset password", @@ -1987,6 +1984,7 @@ "selected_by_overleaf_staff": "Selected by Overleaf staff", "selection_deleted": "Selection deleted", "send": "Send", + "send_confirmation_code": "Send confirmation code", "send_first_message": "Send your first message to your collaborators", "send_message": "Send message", "send_request": "Send request", diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx index 7197ddb365..78c732ebe3 100644 --- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx +++ b/services/web/test/frontend/features/project-list/components/notifications.test.tsx @@ -5,7 +5,6 @@ import { render, screen, waitForElementToBeRemoved, - within, } from '@testing-library/react' import fetchMock from 'fetch-mock' import { merge, cloneDeep } from 'lodash' @@ -672,32 +671,37 @@ describe('', function () { renderWithinProjectListProvider(ConfirmEmail) await fetchMock.callHistory.flush(true) - fetchMock.post('/user/emails/resend_confirmation', 200) + fetchMock.post('/user/emails/send-confirmation-code', 200) const email = userEmails[0].email - const notificationBody = await screen.findByTestId( - 'pro-notification-body' - ) + const alert = await screen.findByRole('alert') if (isPrimary) { - expect(notificationBody.textContent).to.contain( - `Please confirm your primary email address ${email} by clicking on the link in the confirmation email.` + expect(alert.textContent).to.contain( + `Please confirm your primary email address ${email}. To edit it, go to ` ) } else { - expect(notificationBody.textContent).to.contain( - `Please confirm your secondary email address ${email} by clicking on the link in the confirmation email.` + expect(alert.textContent).to.contain( + `Please confirm your secondary email address ${email}. To edit it, go to ` ) } - const resendButton = screen.getByRole('button', { name: /resend/i }) - fireEvent.click(resendButton) + expect( + screen + .getByRole('button', { name: 'Send confirmation code' }) + .classList.contains('button-loading') + ).to.be.false - await waitForElementToBeRemoved(() => - screen.queryByRole('button', { name: /resend/i }) - ) + expect(screen.queryByRole('dialog')).to.be.null + + const sendCodeButton = await screen.findByRole('button', { + name: 'Send confirmation code', + }) + fireEvent.click(sendCodeButton) + + await screen.findByRole('dialog') expect(fetchMock.callHistory.called()).to.be.true - expect(screen.queryByRole('alert')).to.be.null }) } @@ -716,25 +720,22 @@ describe('', function () { renderWithinProjectListProvider(ConfirmEmail) await fetchMock.callHistory.flush(true) - fetchMock.post('/user/emails/resend_confirmation', 200) + fetchMock.post('/user/emails/send-confirmation-code', 200) const email = untrustedUserData.email - const notificationBody = await screen.findByTestId( - 'not-trusted-notification-body' - ) - expect(notificationBody.textContent).to.contain( + const alert = await screen.findByRole('alert') + expect(alert.textContent).to.contain( `To enhance the security of your Overleaf account, please reconfirm your secondary email address ${email}.` ) - const resendButton = screen.getByRole('button', { name: /resend/i }) + const resendButton = screen.getByRole('button', { + name: 'Send confirmation code', + }) fireEvent.click(resendButton) - await waitForElementToBeRemoved(() => - screen.getByRole('button', { name: /resend/i }) - ) + await screen.findByRole('dialog') expect(fetchMock.callHistory.called()).to.be.true - expect(screen.queryByRole('alert')).to.be.null }) it('fails to send', async function () { @@ -742,20 +743,15 @@ describe('', function () { renderWithinProjectListProvider(ConfirmEmail) await fetchMock.callHistory.flush(true) - fetchMock.post('/user/emails/resend_confirmation', 500) + fetchMock.post('/user/emails/send-confirmation-code', 500) const resendButtons = await screen.findAllByRole('button', { - name: /resend/i, + name: 'Send confirmation code', }) const resendButton = resendButtons[0] fireEvent.click(resendButton) - const notificationBody = screen.getByTestId('pro-notification-body') - await waitForElementToBeRemoved(() => - within(notificationBody).getByTestId( - 'loading-resending-confirmation-email' - ) - ) + await screen.findByRole('dialog') expect(fetchMock.callHistory.called()).to.be.true screen.getByText(/something went wrong/i) @@ -773,11 +769,10 @@ describe('', function () { const alert = await screen.findByRole('alert') const email = unconfirmedCommonsUserData.email - const notificationBody = within(alert).getByTestId('notification-body') - expect(notificationBody.textContent).to.contain( + expect(alert.textContent).to.contain( 'You are one step away from accessing Overleaf Professional features' ) - expect(notificationBody.textContent).to.contain( + expect(alert.textContent).to.contain( `Overleaf has an Overleaf subscription. Click the confirmation link sent to ${email} to upgrade to Overleaf Professional` ) }) @@ -794,17 +789,14 @@ describe('', function () { const alert = await screen.findByRole('alert') const email = unconfirmedCommonsUserData.email - const notificationBody = within(alert).getByTestId( - 'pro-notification-body' - ) const isPrimary = unconfirmedCommonsUserData.default if (isPrimary) { - expect(notificationBody.textContent).to.contain( - `Please confirm your primary email address ${email} by clicking on the link in the confirmation email` + expect(alert.textContent).to.contain( + `Please confirm your primary email address ${email}.` ) } else { - expect(notificationBody.textContent).to.contain( - `Please confirm your secondary email address ${email} by clicking on the link in the confirmation email` + expect(alert.textContent).to.contain( + `Please confirm your secondary email address ${email}.` ) } }) diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx index e784f6aaac..55c833df1c 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx @@ -99,7 +99,7 @@ describe('', function () { fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) render() - await screen.findByRole('button', { name: /resend confirmation code/i }) + await screen.findByRole('button', { name: 'Send confirmation code' }) }) it('renders professional label', async function () { @@ -121,24 +121,24 @@ describe('', function () { fetchMock.post('/user/emails/send-confirmation-code', 200) const button = screen.getByRole('button', { - name: /resend confirmation code/i, + name: 'Send confirmation code', }) fireEvent.click(button) expect( screen.queryByRole('button', { - name: /resend confirmation code/i, + name: 'Send confirmation code', }) ).to.be.null - await waitForElementToBeRemoved(() => screen.getByText(/sending/i)) + await screen.findByRole('dialog') expect( screen.queryByText(/an error has occurred while performing your request/i) ).to.be.null await screen.findAllByRole('button', { - name: /resend confirmation code/i, + name: 'Resend confirmation code', }) }) @@ -151,17 +151,17 @@ describe('', function () { fetchMock.post('/user/emails/send-confirmation-code', 503) const button = screen.getByRole('button', { - name: /resend confirmation code/i, + name: 'Send confirmation code', }) fireEvent.click(button) - expect(screen.queryByRole('button', { name: /resend confirmation code/i })) - .to.be.null + expect(screen.queryByRole('button', { name: 'Send confirmation code' })).to + .be.null - await waitForElementToBeRemoved(() => screen.getByText(/sending/i)) + await screen.findByRole('dialog') - screen.getByText(/sorry, something went wrong/i) - screen.getByRole('button', { name: /resend confirmation code/i }) + await screen.findByText(/sorry, something went wrong/i) + screen.getByRole('button', { name: 'Resend confirmation code' }) }) it('sorts emails with primary first, then confirmed, then unconfirmed', async function () { From 344405cdcb54f891de6ac86909efe345fa8ff0b7 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 27 May 2025 10:03:06 +0200 Subject: [PATCH 022/259] Revert case-insensitivity in e2e tests (#25828) * Revert case-insensitivity in e2e tests * Use `{ exact: false }` to filter createProject type * Update server-ce/test/helpers/project.ts Co-authored-by: Jakob Ackermann --------- Co-authored-by: Jakob Ackermann GitOrigin-RevId: b8b2f8439a55e9527358b13d9292779dc3509e9d --- server-ce/test/git-bridge.spec.ts | 2 +- server-ce/test/helpers/project.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server-ce/test/git-bridge.spec.ts b/server-ce/test/git-bridge.spec.ts index 447f28bfd2..1f114574ac 100644 --- a/server-ce/test/git-bridge.spec.ts +++ b/server-ce/test/git-bridge.spec.ts @@ -107,7 +107,7 @@ describe('git-bridge', function () { cy.get('code').contains(`git clone ${gitURL(id.toString())}`) }) cy.findByText('Generate token').should('not.exist') - cy.findByText(/generate a new one in Account settings/i) + cy.findByText(/generate a new one in Account settings/) cy.findByText('Go to settings') .should('have.attr', 'target', '_blank') .and('have.attr', 'href', '/user/settings') diff --git a/server-ce/test/helpers/project.ts b/server-ce/test/helpers/project.ts index 8fb6aa2404..abcce3f9b2 100644 --- a/server-ce/test/helpers/project.ts +++ b/server-ce/test/helpers/project.ts @@ -37,7 +37,8 @@ export function createProject( } cy.findAllByRole('button').contains(newProjectButtonMatcher).click() // FIXME: This should only look in the left menu - cy.findAllByText(new RegExp(type, 'i')).first().click() + // The upgrading tests create projects in older versions of Server Pro which used different casing of the project type. Use case-insensitive match. + cy.findAllByText(type, { exact: false }).first().click() cy.findByRole('dialog').within(() => { cy.get('input').type(name) cy.findByText('Create').click() From 4315777638691776154ca80b077c05a4d54087cd Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Tue, 27 May 2025 11:44:39 +0200 Subject: [PATCH 023/259] Merge pull request #25916 from overleaf/msm-git-bridge-bump-jgit [git-bridge] bump `jgit` to `6.10.1` GitOrigin-RevId: a1ffaa68a2eaca278c48acaf8e9d72b06c0cf29a --- services/git-bridge/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/git-bridge/pom.xml b/services/git-bridge/pom.xml index 7b2c5b8e55..840eb57721 100644 --- a/services/git-bridge/pom.xml +++ b/services/git-bridge/pom.xml @@ -19,7 +19,7 @@ 9.4.57.v20241219 2.9.0 3.0.1 - 6.6.1.202309021850-r + 6.10.1.202505221210-r 3.41.2.2 2.9.9 1.37.0 From 13fa735da05d2bd6925c82b0c2c22afb0d1926d9 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 27 May 2025 08:03:08 -0400 Subject: [PATCH 024/259] Merge pull request #25869 from overleaf/em-split-editor-facade Split EditorFacade functionality for history OT GitOrigin-RevId: 1e415e1d058c0de0b27271a9a5d7208b4a8a689b --- .../features/ide-react/editor/share-js-doc.ts | 2 +- .../source-editor/extensions/realtime.ts | 73 ++++++++++++++----- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index 96e866afec..a773684dcb 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -365,7 +365,7 @@ export class ShareJsDoc extends EventEmitter { attachToCM6(cm6: EditorFacade) { this.attachToEditor(cm6, () => { - cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength')) + cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'), this.type) }) } diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 36d9956a76..44c28cb9ad 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -5,6 +5,7 @@ import RangesTracker from '@overleaf/ranges-tracker' import { ShareDoc } from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' +import { OTType } from '@/features/ide-react/editor/share-js-doc' /* * Integrate CodeMirror 6 with the real-time system, via ShareJS. @@ -76,15 +77,22 @@ export const realtime = ( return Prec.highest([realtimePlugin, ensureRealtimePlugin]) } +type OTAdapter = { + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ): void + attachShareJs(): void +} + export class EditorFacade extends EventEmitter { - public shareDoc: ShareDoc | null + private otAdapter: OTAdapter | null public events: EventEmitter - private maxDocLength?: number constructor(public view: EditorView) { super() this.view = view - this.shareDoc = null + this.otAdapter = null this.events = new EventEmitter() } @@ -118,23 +126,56 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } + attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) { + this.otAdapter = + type === 'history-ot' + ? new HistoryOTAdapter(this, shareDoc, maxDocLength) + : new ShareLatexOTAdapter(this, shareDoc, maxDocLength) + this.otAdapter.attachShareJs() + } + + detachShareJs() { + this.otAdapter = null + } + + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ) { + if (this.otAdapter == null) { + throw new Error('Trying to process updates with no otAdapter') + } + + this.otAdapter.handleUpdateFromCM(transactions, ranges) + } +} + +class ShareLatexOTAdapter { + constructor( + public editor: EditorFacade, + private shareDoc: ShareDoc, + private maxDocLength?: number + ) { + this.editor = editor + this.shareDoc = shareDoc + this.maxDocLength = maxDocLength + } + // Connect to ShareJS, passing changes to the CodeMirror view // as new transactions. // This is a broad immitation of helper functions supplied in // the sharejs library. (See vendor/libs/sharejs, in particular // the 'attach_ace' helper) - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { - this.shareDoc = shareDoc - this.maxDocLength = maxDocLength - + attachShareJs() { + const shareDoc = this.shareDoc const check = () => { // run in a timeout so it checks the editor content once this update has been applied window.setTimeout(() => { - const editorText = this.getValue() + const editorText = this.editor.getValue() const otText = shareDoc.getText() if (editorText !== otText) { - shareDoc.emit('error', 'Text does not match in CodeMirror 6') + this.shareDoc.emit('error', 'Text does not match in CodeMirror 6') debugConsole.error('Text does not match!') debugConsole.error('editor: ' + editorText) debugConsole.error('ot: ' + otText) @@ -143,12 +184,12 @@ export class EditorFacade extends EventEmitter { } const onInsert = (pos: number, text: string) => { - this.cmInsert(pos, text, 'remote') + this.editor.cmInsert(pos, text, 'remote') check() } const onDelete = (pos: number, text: string) => { - this.cmDelete(pos, text, 'remote') + this.editor.cmDelete(pos, text, 'remote') check() } @@ -161,7 +202,7 @@ export class EditorFacade extends EventEmitter { shareDoc.removeListener('insert', onInsert) shareDoc.removeListener('delete', onDelete) delete shareDoc.detach_cm6 - this.shareDoc = null + this.editor.detachShareJs() } } @@ -175,10 +216,6 @@ export class EditorFacade extends EventEmitter { const trackedDeletesLength = ranges != null ? ranges.getTrackedDeletesLength() : 0 - if (!shareDoc) { - throw new Error('Trying to process updates with no shareDoc') - } - for (const transaction of transactions) { if (transaction.docChanged) { const origin = chooseOrigin(transaction) @@ -234,7 +271,7 @@ export class EditorFacade extends EventEmitter { removed, } - this.emit('change', this, changeDescription) + this.editor.emit('change', this, changeDescription) } ) } @@ -242,6 +279,8 @@ export class EditorFacade extends EventEmitter { } } +class HistoryOTAdapter extends ShareLatexOTAdapter {} + export const trackChangesAnnotation = Annotation.define() const chooseOrigin = (transaction: Transaction) => { From 25adb7e3039a77d5216611949c74dff862c4a663 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 27 May 2025 08:25:30 -0400 Subject: [PATCH 025/259] Merge pull request #25949 from overleaf/revert-25869-em-split-editor-facade Revert "Split EditorFacade functionality for history OT" GitOrigin-RevId: a55328e08776fa0f59071fca955ba73ef130984d --- .../features/ide-react/editor/share-js-doc.ts | 2 +- .../source-editor/extensions/realtime.ts | 73 +++++-------------- 2 files changed, 18 insertions(+), 57 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index a773684dcb..96e866afec 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -365,7 +365,7 @@ export class ShareJsDoc extends EventEmitter { attachToCM6(cm6: EditorFacade) { this.attachToEditor(cm6, () => { - cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'), this.type) + cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength')) }) } diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 44c28cb9ad..36d9956a76 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -5,7 +5,6 @@ import RangesTracker from '@overleaf/ranges-tracker' import { ShareDoc } from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' -import { OTType } from '@/features/ide-react/editor/share-js-doc' /* * Integrate CodeMirror 6 with the real-time system, via ShareJS. @@ -77,22 +76,15 @@ export const realtime = ( return Prec.highest([realtimePlugin, ensureRealtimePlugin]) } -type OTAdapter = { - handleUpdateFromCM( - transactions: readonly Transaction[], - ranges?: RangesTracker - ): void - attachShareJs(): void -} - export class EditorFacade extends EventEmitter { - private otAdapter: OTAdapter | null + public shareDoc: ShareDoc | null public events: EventEmitter + private maxDocLength?: number constructor(public view: EditorView) { super() this.view = view - this.otAdapter = null + this.shareDoc = null this.events = new EventEmitter() } @@ -126,56 +118,23 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) { - this.otAdapter = - type === 'history-ot' - ? new HistoryOTAdapter(this, shareDoc, maxDocLength) - : new ShareLatexOTAdapter(this, shareDoc, maxDocLength) - this.otAdapter.attachShareJs() - } - - detachShareJs() { - this.otAdapter = null - } - - handleUpdateFromCM( - transactions: readonly Transaction[], - ranges?: RangesTracker - ) { - if (this.otAdapter == null) { - throw new Error('Trying to process updates with no otAdapter') - } - - this.otAdapter.handleUpdateFromCM(transactions, ranges) - } -} - -class ShareLatexOTAdapter { - constructor( - public editor: EditorFacade, - private shareDoc: ShareDoc, - private maxDocLength?: number - ) { - this.editor = editor - this.shareDoc = shareDoc - this.maxDocLength = maxDocLength - } - // Connect to ShareJS, passing changes to the CodeMirror view // as new transactions. // This is a broad immitation of helper functions supplied in // the sharejs library. (See vendor/libs/sharejs, in particular // the 'attach_ace' helper) - attachShareJs() { - const shareDoc = this.shareDoc + attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { + this.shareDoc = shareDoc + this.maxDocLength = maxDocLength + const check = () => { // run in a timeout so it checks the editor content once this update has been applied window.setTimeout(() => { - const editorText = this.editor.getValue() + const editorText = this.getValue() const otText = shareDoc.getText() if (editorText !== otText) { - this.shareDoc.emit('error', 'Text does not match in CodeMirror 6') + shareDoc.emit('error', 'Text does not match in CodeMirror 6') debugConsole.error('Text does not match!') debugConsole.error('editor: ' + editorText) debugConsole.error('ot: ' + otText) @@ -184,12 +143,12 @@ class ShareLatexOTAdapter { } const onInsert = (pos: number, text: string) => { - this.editor.cmInsert(pos, text, 'remote') + this.cmInsert(pos, text, 'remote') check() } const onDelete = (pos: number, text: string) => { - this.editor.cmDelete(pos, text, 'remote') + this.cmDelete(pos, text, 'remote') check() } @@ -202,7 +161,7 @@ class ShareLatexOTAdapter { shareDoc.removeListener('insert', onInsert) shareDoc.removeListener('delete', onDelete) delete shareDoc.detach_cm6 - this.editor.detachShareJs() + this.shareDoc = null } } @@ -216,6 +175,10 @@ class ShareLatexOTAdapter { const trackedDeletesLength = ranges != null ? ranges.getTrackedDeletesLength() : 0 + if (!shareDoc) { + throw new Error('Trying to process updates with no shareDoc') + } + for (const transaction of transactions) { if (transaction.docChanged) { const origin = chooseOrigin(transaction) @@ -271,7 +234,7 @@ class ShareLatexOTAdapter { removed, } - this.editor.emit('change', this, changeDescription) + this.emit('change', this, changeDescription) } ) } @@ -279,8 +242,6 @@ class ShareLatexOTAdapter { } } -class HistoryOTAdapter extends ShareLatexOTAdapter {} - export const trackChangesAnnotation = Annotation.define() const chooseOrigin = (transaction: Transaction) => { From 93a1996491376978fde5d5e1f7f4efa3ad070276 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Tue, 27 May 2025 14:54:48 +0200 Subject: [PATCH 026/259] Show add-on list for non-personal subscription (#25901) GitOrigin-RevId: ba23158f51a7183fabc61c16b19809f58cf15323 --- .../components/dashboard/subscription-dashboard.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx b/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx index 972e268597..8cb07181cf 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx @@ -14,6 +14,7 @@ import OLPageContentCard from '@/features/ui/components/ol/ol-page-content-card' import OLRow from '@/features/ui/components/ol/ol-row' import OLCol from '@/features/ui/components/ol/ol-col' import OLNotification from '@/features/ui/components/ol/ol-notification' +import WritefullManagedBundleAddOn from './states/active/change-plan/modals/writefull-bundle-management-modal' function SubscriptionDashboard() { const { t } = useTranslation() @@ -24,6 +25,7 @@ function SubscriptionDashboard() { personalSubscription, } = useSubscriptionDashboardContext() + const hasAiAssistViaWritefull = getMeta('ol-hasAiAssistViaWritefull') const fromPlansPage = getMeta('ol-fromPlansPage') return ( @@ -50,6 +52,12 @@ function SubscriptionDashboard() { + {!personalSubscription && hasAiAssistViaWritefull && ( +
+

{t('add_ons')}

+ +
+ )} {hasValidActiveSubscription && ( )} From 881db9b4725741f624f70e01fa16c54b404bd2fa Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Tue, 27 May 2025 09:38:53 -0500 Subject: [PATCH 027/259] Merge pull request #25011 from overleaf/jel-group-audit-logs-part-2 [web] Update group audit log when user enrolls in managed users GitOrigin-RevId: 15d79854007ac3334a2bb66bcf73230bf42c68ce --- .../app/src/Features/Subscription/TeamInvitesController.mjs | 3 ++- .../web/app/src/Features/Subscription/TeamInvitesHandler.js | 5 +++-- services/web/test/acceptance/src/helpers/Subscription.mjs | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index ca508755e6..2da67c3010 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -197,7 +197,8 @@ async function acceptInvite(req, res, next) { const subscription = await TeamInvitesHandler.promises.acceptInvite( token, - userId + userId, + { initiatorId: userId, ipAddress: req.ip } ) const groupSSOActive = ( await Modules.promises.hooks.fire('hasGroupSSOEnabled', subscription) diff --git a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js index 45a0495353..7312266ddf 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js +++ b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js @@ -64,7 +64,7 @@ async function importInvite(subscription, inviterName, email, token, sentAt) { return subscription.save() } -async function acceptInvite(token, userId) { +async function acceptInvite(token, userId, auditLog) { const { invite, subscription } = await getInvite(token) if (!invite) { throw new Errors.NotFoundError('invite not found') @@ -76,7 +76,8 @@ async function acceptInvite(token, userId) { await Modules.promises.hooks.fire( 'enrollInManagedSubscription', userId, - subscription + subscription, + auditLog ) } if (subscription.ssoConfig) { diff --git a/services/web/test/acceptance/src/helpers/Subscription.mjs b/services/web/test/acceptance/src/helpers/Subscription.mjs index db5c9c5898..a9adc113ae 100644 --- a/services/web/test/acceptance/src/helpers/Subscription.mjs +++ b/services/web/test/acceptance/src/helpers/Subscription.mjs @@ -126,7 +126,11 @@ class PromisifiedSubscription { return await Modules.promises.hooks.fire( 'enrollInManagedSubscription', user._id, - subscription + subscription, + { + initiatorId: user._id, + ipAddress: '0:0:0:0', + } ) } From dcd520d7ebb4d78c2abf2fa4c82e895dd01d6a9e Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Tue, 27 May 2025 09:39:02 -0500 Subject: [PATCH 028/259] Merge pull request #25360 from overleaf/jel-group-audit-log-join [web] Update group audit log when user joins GitOrigin-RevId: 81c0d5003cdde384cb5ff90b57f6aa8b8dae0ee2 --- .../Subscription/SubscriptionUpdater.js | 34 ++++++++++++++--- .../Subscription/TeamInvitesController.mjs | 7 +++- .../Subscription/TeamInvitesHandler.js | 10 ++++- .../Subscription/SubscriptionUpdaterTests.js | 38 +++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index 482d81ff41..7b57e32619 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js @@ -12,6 +12,8 @@ const Features = require('../../infrastructure/Features') const UserAuditLogHandler = require('../User/UserAuditLogHandler') const AccountMappingHelper = require('../Analytics/AccountMappingHelper') const { SSOConfig } = require('../../models/SSOConfig') +const mongoose = require('../../infrastructure/Mongoose') +const Modules = require('../../infrastructure/Modules') /** * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription @@ -65,7 +67,9 @@ async function syncSubscription( ) } -async function addUserToGroup(subscriptionId, userId) { +async function addUserToGroup(subscriptionId, userId, auditLog) { + const session = await mongoose.startSession() + await UserAuditLogHandler.promises.addEntry( userId, 'join-group-subscription', @@ -73,10 +77,30 @@ async function addUserToGroup(subscriptionId, userId) { undefined, { subscriptionId } ) - await Subscription.updateOne( - { _id: subscriptionId }, - { $addToSet: { member_ids: userId } } - ).exec() + + try { + await session.withTransaction(async () => { + await Subscription.updateOne( + { _id: subscriptionId }, + { $addToSet: { member_ids: userId } }, + { session } + ).exec() + + await Modules.promises.hooks.fire( + 'addGroupAuditLogEntry', + { + initiatorId: auditLog?.initiatorId, + ipAddress: auditLog?.ipAddress, + groupId: subscriptionId, + operation: 'join-group', + }, + session + ) + }) + } finally { + await session.endSession() + } + await FeaturesUpdater.promises.refreshFeatures(userId, 'add-to-group') await _sendUserGroupPlanCodeUserProperty(userId) await _sendSubscriptionEvent( diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index 2da67c3010..b2c9840de4 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -36,10 +36,15 @@ async function createInvite(req, res, next) { } try { + const auditLog = { + initiatorId: teamManagerId, + ipAddress: req.ip, + } const invitedUserData = await TeamInvitesHandler.promises.createInvite( teamManagerId, subscription, - email + email, + auditLog ) return res.json({ user: invitedUserData }) } catch (err) { diff --git a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js index 7312266ddf..a89f0612f2 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js +++ b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js @@ -70,7 +70,11 @@ async function acceptInvite(token, userId, auditLog) { throw new Errors.NotFoundError('invite not found') } - await SubscriptionUpdater.promises.addUserToGroup(subscription._id, userId) + await SubscriptionUpdater.promises.addUserToGroup( + subscription._id, + userId, + auditLog + ) if (subscription.managedUsersEnabled) { await Modules.promises.hooks.fire( @@ -147,9 +151,11 @@ async function _createInvite(subscription, email, inviter) { emailData => emailData.email === email ) if (isInvitingSelf) { + const auditLog = { initiatorId: inviter._id } await SubscriptionUpdater.promises.addUserToGroup( subscription._id, - inviter._id + inviter._id, + auditLog ) // legacy: remove any invite that might have been created in the past diff --git a/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js b/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js index 09644bc7b1..a122f0e4b2 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js @@ -120,6 +120,18 @@ describe('SubscriptionUpdater', function () { }, }, ], + mongo: { + options: { + appname: 'web', + maxPoolSize: 100, + serverSelectionTimeoutMS: 60000, + socketTimeoutMS: 60000, + monitorCommands: true, + family: 4, + }, + url: 'mongodb://mongo/test-overleaf', + hasSecondaries: false, + }, } this.UserFeaturesUpdater = { @@ -181,6 +193,13 @@ describe('SubscriptionUpdater', function () { }), '../../infrastructure/Features': this.Features, '../User/UserAuditLogHandler': this.UserAuditLogHandler, + '../../infrastructure/Modules': (this.Modules = { + promises: { + hooks: { + fire: sinon.stub().resolves(), + }, + }, + }), }, }) }) @@ -486,6 +505,7 @@ describe('SubscriptionUpdater', function () { this.SubscriptionModel.updateOne .calledWith(searchOps, insertOperation) .should.equal(true) + expect(this.SubscriptionModel.updateOne.lastCall.args[2].session).to.exist sinon.assert.calledWith( this.AnalyticsManager.recordEventForUserInBackground, this.otherUserId, @@ -571,6 +591,24 @@ describe('SubscriptionUpdater', function () { } ) }) + + it('should add an entry to the group audit log when joining a group', async function () { + await this.SubscriptionUpdater.promises.addUserToGroup( + this.subscription._id, + this.otherUserId, + { ipAddress: '0:0:0:0', initiatorId: 'user123' } + ) + + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'addGroupAuditLogEntry', + { + groupId: this.subscription._id, + initiatorId: 'user123', + ipAddress: '0:0:0:0', + operation: 'join-group', + } + ) + }) }) describe('removeUserFromGroup', function () { From ce67a27c974545db1b48a360a84853d4b9db32af Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Tue, 27 May 2025 09:39:21 -0500 Subject: [PATCH 029/259] Merge pull request #25556 from overleaf/jel-group-audit-log-remove-from-group [web] Log when user leaves or is removed from group GitOrigin-RevId: 8a5042b21cbf4eb622d5ca35cc095d94fe5a8539 --- .../SubscriptionGroupController.mjs | 8 ++- .../Subscription/SubscriptionGroupHandler.js | 9 ++- .../Subscription/SubscriptionUpdater.js | 72 ++++++++++++------- .../SubscriptionGroupControllerTests.mjs | 11 ++- .../SubscriptionGroupHandlerTests.js | 10 ++- .../types/group-management/group-audit-log.ts | 7 ++ 6 files changed, 83 insertions(+), 34 deletions(-) create mode 100644 services/web/types/group-management/group-audit-log.ts diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index ce1207cded..90ecd51091 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -108,10 +108,16 @@ async function _removeUserFromGroup( }) } + const groupAuditLog = { + initiatorId: loggedInUserId, + ipAddress: req.ip, + } + try { await SubscriptionGroupHandler.promises.removeUserFromGroup( subscriptionId, - userToRemoveId + userToRemoveId, + groupAuditLog ) } catch (error) { logger.err( diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index 5772946b8a..c717b2eec6 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -22,10 +22,11 @@ const { const EmailHelper = require('../Helpers/EmailHelper') const { InvalidEmailError } = require('../Errors/Errors') -async function removeUserFromGroup(subscriptionId, userIdToRemove) { +async function removeUserFromGroup(subscriptionId, userIdToRemove, auditLog) { await SubscriptionUpdater.promises.removeUserFromGroup( subscriptionId, - userIdToRemove + userIdToRemove, + auditLog ) } @@ -463,7 +464,9 @@ async function updateGroupMembersBulk( ) } for (const user of membersToRemove) { - await removeUserFromGroup(subscription._id, user._id) + await removeUserFromGroup(subscription._id, user._id, { + initiatorId: inviterId, + }) } } diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index 7b57e32619..15f61b6160 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js @@ -18,8 +18,31 @@ const Modules = require('../../infrastructure/Modules') /** * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription * @typedef {import('../../../../types/subscription/dashboard/subscription').PaymentProvider} PaymentProvider + * @typedef {import('../../../../types/group-management/group-audit-log').GroupAuditLog} GroupAuditLog */ +/** + * + * @param {GroupAuditLog} auditLog + */ +async function subscriptionUpdateWithAuditLog(dbFilter, dbUpdate, auditLog) { + const session = await mongoose.startSession() + + try { + await session.withTransaction(async () => { + await Subscription.updateOne(dbFilter, dbUpdate, { session }).exec() + + await Modules.promises.hooks.fire( + 'addGroupAuditLogEntry', + auditLog, + session + ) + }) + } finally { + await session.endSession() + } +} + /** * Change the admin of the given subscription. * @@ -68,8 +91,6 @@ async function syncSubscription( } async function addUserToGroup(subscriptionId, userId, auditLog) { - const session = await mongoose.startSession() - await UserAuditLogHandler.promises.addEntry( userId, 'join-group-subscription', @@ -78,28 +99,16 @@ async function addUserToGroup(subscriptionId, userId, auditLog) { { subscriptionId } ) - try { - await session.withTransaction(async () => { - await Subscription.updateOne( - { _id: subscriptionId }, - { $addToSet: { member_ids: userId } }, - { session } - ).exec() - - await Modules.promises.hooks.fire( - 'addGroupAuditLogEntry', - { - initiatorId: auditLog?.initiatorId, - ipAddress: auditLog?.ipAddress, - groupId: subscriptionId, - operation: 'join-group', - }, - session - ) - }) - } finally { - await session.endSession() - } + await subscriptionUpdateWithAuditLog( + { _id: subscriptionId }, + { $addToSet: { member_ids: userId } }, + { + initiatorId: auditLog?.initiatorId, + ipAddress: auditLog?.ipAddress, + groupId: subscriptionId, + operation: 'join-group', + } + ) await FeaturesUpdater.promises.refreshFeatures(userId, 'add-to-group') await _sendUserGroupPlanCodeUserProperty(userId) @@ -110,7 +119,7 @@ async function addUserToGroup(subscriptionId, userId, auditLog) { ) } -async function removeUserFromGroup(subscriptionId, userId) { +async function removeUserFromGroup(subscriptionId, userId, auditLog) { await UserAuditLogHandler.promises.addEntry( userId, 'leave-group-subscription', @@ -118,6 +127,19 @@ async function removeUserFromGroup(subscriptionId, userId) { undefined, { subscriptionId } ) + + await subscriptionUpdateWithAuditLog( + { _id: subscriptionId }, + { $pull: { member_ids: userId } }, + { + initiatorId: auditLog?.initiatorId, + ipAddress: auditLog?.ipAddress, + groupId: subscriptionId, + operation: 'leave-group', + info: { userIdRemoved: userId }, + } + ) + await Subscription.updateOne( { _id: subscriptionId }, { $pull: { member_ids: userId } } diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs index 4376e752e7..c1ce6733ca 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs @@ -185,7 +185,10 @@ describe('SubscriptionGroupController', function () { const res = { sendStatus: () => { this.SubscriptionGroupHandler.promises.removeUserFromGroup - .calledWith(this.subscriptionId, userIdToRemove) + .calledWith(this.subscriptionId, userIdToRemove, { + initiatorId: this.req.session.user._id, + ipAddress: this.req.ip, + }) .should.equal(true) done() }, @@ -277,7 +280,11 @@ describe('SubscriptionGroupController', function () { sinon.assert.calledWith( this.SubscriptionGroupHandler.promises.removeUserFromGroup, this.subscriptionId, - memberUserIdToremove + memberUserIdToremove, + { + initiatorId: this.req.session.user._id, + ipAddress: this.req.ip, + } ) done() }, diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index 0c47db3e14..1c314458da 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -233,13 +233,15 @@ describe('SubscriptionGroupHandler', function () { describe('removeUserFromGroup', function () { it('should call the subscription updater to remove the user', async function () { + const auditLog = { ipAddress: '0:0:0:0', initiatorId: this.user._id } await this.Handler.promises.removeUserFromGroup( this.adminUser_id, - this.user._id + this.user._id, + auditLog ) this.SubscriptionUpdater.promises.removeUserFromGroup - .calledWith(this.adminUser_id, this.user._id) + .calledWith(this.adminUser_id, this.user._id, auditLog) .should.equal(true) }) }) @@ -1149,7 +1151,9 @@ describe('SubscriptionGroupHandler', function () { expect( this.SubscriptionUpdater.promises.removeUserFromGroup - ).to.have.been.calledWith(this.subscription._id, members[2]._id) + ).to.have.been.calledWith(this.subscription._id, members[2]._id, { + initiatorId: inviterId, + }) expect( this.TeamInvitesHandler.promises.createInvite.callCount diff --git a/services/web/types/group-management/group-audit-log.ts b/services/web/types/group-management/group-audit-log.ts new file mode 100644 index 0000000000..c96c12e7cd --- /dev/null +++ b/services/web/types/group-management/group-audit-log.ts @@ -0,0 +1,7 @@ +export type GroupAuditLog = { + groupId: string + operation: string + ipAddress?: string + initiatorId?: string + info?: object +} From 0d3025b8cfd29c69255bde2d725aa786bd722d09 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Mon, 28 Apr 2025 11:25:12 +0100 Subject: [PATCH 030/259] Add vitest and configuration GitOrigin-RevId: 1262f9f32a0db6a29d3feedd8158b8dd04e48b6a --- package-lock.json | 737 ++++++++++++++++++++ services/web/.eslintrc.js | 24 + services/web/Makefile | 10 + services/web/bin/test_unit_run_dir | 45 ++ services/web/docker-compose.ci.yml | 1 + services/web/package.json | 6 +- services/web/test/unit/bootstrap.js | 25 +- services/web/test/unit/common_bootstrap.js | 24 + services/web/test/unit/vitest_bootstrap.mjs | 29 + services/web/vitest.config.js | 12 + 10 files changed, 888 insertions(+), 25 deletions(-) create mode 100755 services/web/bin/test_unit_run_dir create mode 100644 services/web/test/unit/common_bootstrap.js create mode 100644 services/web/test/unit/vitest_bootstrap.mjs create mode 100644 services/web/vitest.config.js diff --git a/package-lock.json b/package-lock.json index 4a14efb544..146ba3255d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44850,6 +44850,7 @@ "@uppy/react": "^3.2.1", "@uppy/utils": "^5.7.0", "@uppy/xhr-upload": "^3.6.0", + "@vitest/eslint-plugin": "1.1.44", "5to6-codemod": "^1.8.0", "abort-controller": "^3.0.0", "acorn": "^7.1.1", @@ -44956,6 +44957,7 @@ "tty-browserify": "^0.0.1", "typescript": "^5.0.4", "uuid": "^9.0.1", + "vitest": "^3.1.2", "w3c-keyname": "^2.2.8", "webpack": "^5.98.0", "webpack-assets-manifest": "^5.2.1", @@ -44965,6 +44967,26 @@ "yup": "^0.32.11" } }, + "services/web/node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, "services/web/node_modules/@google-cloud/bigquery": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-6.0.3.tgz", @@ -45161,6 +45183,18 @@ "dev": true, "license": "MIT" }, + "services/web/node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/ms": "*" + } + }, "services/web/node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -45179,6 +45213,143 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, + "services/web/node_modules/@typescript-eslint/scope-manager": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", + "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "services/web/node_modules/@typescript-eslint/types": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", + "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "services/web/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", + "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "services/web/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "services/web/node_modules/@typescript-eslint/utils": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", + "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "services/web/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", + "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "services/web/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "services/web/node_modules/@uppy/core": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/@uppy/core/-/core-3.8.0.tgz", @@ -45332,6 +45503,130 @@ "@uppy/core": "^3.8.0" } }, + "services/web/node_modules/@vitest/eslint-plugin": { + "version": "1.1.44", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.1.44.tgz", + "integrity": "sha512-m4XeohMT+Dj2RZfxnbiFR+Cv5dEC0H7C6TlxRQT7GK2556solm99kxgzJp/trKrZvanZcOFyw7aABykUTfWyrg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/utils": ">= 8.24.0", + "eslint": ">= 8.57.0", + "typescript": ">= 5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "services/web/node_modules/@vitest/expect": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz", + "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.1.4", + "@vitest/utils": "3.1.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/web/node_modules/@vitest/expect/node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "services/web/node_modules/@vitest/pretty-format": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz", + "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/web/node_modules/@vitest/runner": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz", + "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/web/node_modules/@vitest/snapshot": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz", + "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/web/node_modules/@vitest/spy": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz", + "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/web/node_modules/@vitest/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.4", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "services/web/node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -45368,6 +45663,16 @@ "ajv": "^8.8.2" } }, + "services/web/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "services/web/node_modules/base-x": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", @@ -45405,6 +45710,16 @@ "ieee754": "^1.2.1" } }, + "services/web/node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "services/web/node_modules/csv": { "version": "6.2.5", "resolved": "https://registry.npmjs.org/csv/-/csv-6.2.5.tgz", @@ -45451,6 +45766,16 @@ } } }, + "services/web/node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "services/web/node_modules/duplexify": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", @@ -45462,6 +45787,20 @@ "stream-shift": "^1.0.0" } }, + "services/web/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "services/web/node_modules/esmock": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/esmock/-/esmock-2.6.7.tgz", @@ -45479,6 +45818,21 @@ "node": ">=0.8.x" } }, + "services/web/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "services/web/node_modules/fetch-mock": { "version": "12.5.2", "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.2.tgz", @@ -45598,6 +45952,18 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, + "services/web/node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "services/web/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -45609,6 +45975,13 @@ "integrity": "sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==", "dev": true }, + "services/web/node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" + }, "services/web/node_modules/lru-cache": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.10.1.tgz", @@ -45735,6 +46108,29 @@ "isarray": "0.0.1" } }, + "services/web/node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "services/web/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "services/web/node_modules/retry-request": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-5.0.2.tgz", @@ -45778,6 +46174,20 @@ "url": "https://opencollective.com/webpack" } }, + "services/web/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "services/web/node_modules/sinon": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.5.0.tgz", @@ -45941,6 +46351,30 @@ } } }, + "services/web/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "services/web/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "services/web/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -45953,6 +46387,294 @@ "uuid": "dist/bin/uuid" } }, + "services/web/node_modules/vite-node": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz", + "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/web/node_modules/vite-node/node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "services/web/node_modules/vitest": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz", + "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.1.4", + "@vitest/mocker": "3.1.4", + "@vitest/pretty-format": "^3.1.4", + "@vitest/runner": "3.1.4", + "@vitest/snapshot": "3.1.4", + "@vitest/spy": "3.1.4", + "@vitest/utils": "3.1.4", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.1.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.1.4", + "@vitest/ui": "3.1.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "services/web/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz", + "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "services/web/node_modules/vitest/node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "services/web/node_modules/vitest/node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "services/web/node_modules/xml-crypto": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-2.1.6.tgz", @@ -45980,6 +46702,21 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "services/web/node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "tools/saas-e2e": { "name": "@overleaf/saas-e2e", "devDependencies": { diff --git a/services/web/.eslintrc.js b/services/web/.eslintrc.js index 3c672de7e7..b505080b98 100644 --- a/services/web/.eslintrc.js +++ b/services/web/.eslintrc.js @@ -64,6 +64,10 @@ module.exports = { { // Test specific rules files: ['**/test/**/*.*'], + excludedFiles: [ + '**/test/unit/src/**/*.test.mjs', + 'test/unit/vitest_bootstrap.mjs', + ], // exclude vitest files plugins: ['mocha', 'chai-expect', 'chai-friendly'], env: { mocha: true, @@ -95,6 +99,26 @@ module.exports = { '@typescript-eslint/no-unused-expressions': 'off', }, }, + { + files: [ + '**/test/unit/src/**/*.test.mjs', + 'test/unit/vitest_bootstrap.mjs', + ], + env: { + jest: true, // best match for vitest API etc. + }, + plugins: ['@vitest', 'chai-expect', 'chai-friendly'], // still using chai for now + rules: { + // Swap the no-unused-expressions rule with a more chai-friendly one + 'no-unused-expressions': 'off', + 'chai-friendly/no-unused-expressions': 'error', + + // chai-specific rules + 'chai-expect/missing-assertion': 'error', + 'chai-expect/terminating-properties': 'error', + '@typescript-eslint/no-unused-expressions': 'off', + }, + }, { // ES specific rules files: [ diff --git a/services/web/Makefile b/services/web/Makefile index c6916048d6..58323058b8 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -83,6 +83,16 @@ test_unit_app: $(DOCKER_COMPOSE) run --name unit_test_$(BUILD_DIR_NAME) --rm test_unit $(DOCKER_COMPOSE) down -v -t 0 +test_unit_esm: export COMPOSE_PROJECT_NAME=unit_test_esm_$(BUILD_DIR_NAME) +test_unit_esm: + $(DOCKER_COMPOSE) run --rm test_unit npm run test:unit:esm + $(DOCKER_COMPOSE) down -v -t 0 + +test_unit_esm_watch: export COMPOSE_PROJECT_NAME=unit_test_esm_watch_$(BUILD_DIR_NAME) +test_unit_esm_watch: + $(DOCKER_COMPOSE) run --rm test_unit npm run test:unit:esm:watch + $(DOCKER_COMPOSE) down -v -t 0 + TEST_SUITES = $(sort $(filter-out \ $(wildcard test/unit/src/helpers/*), \ $(wildcard test/unit/src/*/*))) diff --git a/services/web/bin/test_unit_run_dir b/services/web/bin/test_unit_run_dir new file mode 100755 index 0000000000..20f580cf06 --- /dev/null +++ b/services/web/bin/test_unit_run_dir @@ -0,0 +1,45 @@ +#!/bin/bash + +TARGET_DIR=$1 + + +declare -a vitest_args=("$TARGET_DIR") + +if [[ -n "$MOCHA_GREP" ]]; then + vitest_args+=("--testNamePattern" "$MOCHA_GREP") +fi + +if [[ -n "$VITEST_NO_CACHE" ]]; then + echo "Disabling cache for vitest." + vitest_args+=("--no-cache") +fi + +echo "Running unit tests in directory: $TARGET_DIR" + +npm run test:unit:esm -- "${vitest_args[@]}" + +vitest_status=$? + +if find "$TARGET_DIR" -type f -name "*.js" -print -quit | grep -q '.'; then + mocha --recursive --timeout 25000 --exit --grep="$MOCHA_GREP" --require test/unit/bootstrap.js --extension=js "$TARGET_DIR" + mocha_status=$? +else + echo "No mocha tests found in $TARGET_DIR, skipping mocha step." + mocha_status=0 +fi + +if [ $mocha_status -eq 0 ] && [ $vitest_status -eq 0 ]; then + exit 0 +fi + +# Report status briefly at the end for failures + +if [ $mocha_status -ne 0 ]; then + echo "Mocha tests failed with status: $mocha_status" +fi + +if [ $vitest_status -ne 0 ]; then + echo "Vitest tests failed with status: $vitest_status" +fi + +exit 1 diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml index 164cc22c5a..5cffe19810 100644 --- a/services/web/docker-compose.ci.yml +++ b/services/web/docker-compose.ci.yml @@ -21,6 +21,7 @@ services: OVERLEAF_CONFIG: NODE_ENV: test NODE_OPTIONS: "--unhandled-rejections=strict" + VITEST_NO_CACHE: true depends_on: - mongo diff --git a/services/web/package.json b/services/web/package.json index 5080813d55..ee5f81d4f8 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -9,10 +9,12 @@ "scripts": { "test:acceptance:run_dir": "mocha --recursive --timeout 25000 --grep=$MOCHA_GREP --require test/acceptance/bootstrap.js", "test:acceptance:app": "npm run test:acceptance:run_dir -- test/acceptance/src", - "test:unit:run_dir": "mocha --recursive --timeout 25000 --exit --grep=$MOCHA_GREP --require test/unit/bootstrap.js", + "test:unit:run_dir": "bin/test_unit_run_dir", "test:unit:all": "npm run test:unit:run_dir -- test/unit/src modules/*/test/unit/src", "test:unit:all:silent": "npm run test:unit:all -- --reporter dot", "test:unit:app": "npm run test:unit:run_dir -- test/unit/src", + "test:unit:esm": "vitest run", + "test:unit:esm:watch": "vitest", "test:frontend": "NODE_ENV=test TZ=GMT mocha --recursive --timeout 5000 --exit --extension js,jsx,mjs,ts,tsx --grep=$MOCHA_GREP --require test/frontend/bootstrap.js --ignore '**/*.spec.{js,jsx,ts,tsx}' --ignore '**/helpers/**/*.{js,jsx,ts,tsx}' test/frontend modules/*/test/frontend", "test:frontend:coverage": "c8 --all --include 'frontend/js' --include 'modules/*/frontend/js' --exclude 'frontend/js/vendor' --reporter=lcov --reporter=text-summary npm run test:frontend", "start": "node app.mjs", @@ -250,6 +252,7 @@ "@uppy/react": "^3.2.1", "@uppy/utils": "^5.7.0", "@uppy/xhr-upload": "^3.6.0", + "@vitest/eslint-plugin": "1.1.44", "5to6-codemod": "^1.8.0", "abort-controller": "^3.0.0", "acorn": "^7.1.1", @@ -356,6 +359,7 @@ "tty-browserify": "^0.0.1", "typescript": "^5.0.4", "uuid": "^9.0.1", + "vitest": "^3.1.2", "w3c-keyname": "^2.2.8", "webpack": "^5.98.0", "webpack-assets-manifest": "^5.2.1", diff --git a/services/web/test/unit/bootstrap.js b/services/web/test/unit/bootstrap.js index ee4a022c15..f3d3f382f2 100644 --- a/services/web/test/unit/bootstrap.js +++ b/services/web/test/unit/bootstrap.js @@ -1,29 +1,6 @@ const Path = require('path') -const chai = require('chai') const sinon = require('sinon') - -/* - * Chai configuration - */ - -// add chai.should() -chai.should() - -// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc') -// has a nicer failure messages -chai.use(require('sinon-chai')) - -// Load promise support for chai -chai.use(require('chai-as-promised')) - -// Do not truncate assertion errors -chai.config.truncateThreshold = 0 - -// add support for mongoose in sinon -require('sinon-mongoose') - -// ensure every ObjectId has the id string as a property for correct comparisons -require('mongodb-legacy').ObjectId.cacheHexString = true +require('./common_bootstrap') /* * Global stubs diff --git a/services/web/test/unit/common_bootstrap.js b/services/web/test/unit/common_bootstrap.js new file mode 100644 index 0000000000..d74fee60b2 --- /dev/null +++ b/services/web/test/unit/common_bootstrap.js @@ -0,0 +1,24 @@ +const chai = require('chai') + +/* + * Chai configuration + */ + +// add chai.should() +chai.should() + +// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc') +// has a nicer failure messages +chai.use(require('sinon-chai')) + +// Load promise support for chai +chai.use(require('chai-as-promised')) + +// Do not truncate assertion errors +chai.config.truncateThreshold = 0 + +// add support for mongoose in sinon +require('sinon-mongoose') + +// ensure every ObjectId has the id string as a property for correct comparisons +require('mongodb-legacy').ObjectId.cacheHexString = true diff --git a/services/web/test/unit/vitest_bootstrap.mjs b/services/web/test/unit/vitest_bootstrap.mjs new file mode 100644 index 0000000000..fc4d883b1a --- /dev/null +++ b/services/web/test/unit/vitest_bootstrap.mjs @@ -0,0 +1,29 @@ +import { vi } from 'vitest' +import './common_bootstrap.js' +import sinon from 'sinon' +import logger from '@overleaf/logger' + +vi.mock('@overleaf/logger', async () => { + const sinon = (await import('sinon')).default + return { + default: { + debug: sinon.stub(), + info: sinon.stub(), + log: sinon.stub(), + warn: sinon.stub(), + err: sinon.stub(), + error: sinon.stub(), + fatal: sinon.stub(), + }, + } +}) + +beforeEach(ctx => { + ctx.logger = logger +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + sinon.restore() +}) diff --git a/services/web/vitest.config.js b/services/web/vitest.config.js new file mode 100644 index 0000000000..3b84690447 --- /dev/null +++ b/services/web/vitest.config.js @@ -0,0 +1,12 @@ +const { defineConfig } = require('vitest/config') + +module.exports = defineConfig({ + test: { + include: [ + 'modules/*/test/unit/**/*.test.mjs', + 'test/unit/src/**/*.test.mjs', + ], + setupFiles: ['./test/unit/vitest_bootstrap.mjs'], + globals: true, + }, +}) From 51dcc88f276bd7c506d6bbaef949d26de028ef1c Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Mon, 28 Apr 2025 11:27:57 +0100 Subject: [PATCH 031/259] Rename test files for vitest GitOrigin-RevId: f8792c0ce5eeb4843a534d3ff83e011d25fb65e0 --- ...{LaunchpadControllerTests.mjs => LaunchpadController.test.mjs} | 0 ...ctivateControllerTests.mjs => UserActivateController.test.mjs} | 0 ...{AnalyticsControllerTests.mjs => AnalyticsController.test.mjs} | 0 ...iddlewareTests.mjs => AnalyticsUTMTrackingMiddleware.test.mjs} | 0 ...aProgramControllerTests.mjs => BetaProgramController.test.mjs} | 0 .../{BetaProgramHandlerTests.mjs => BetaProgramHandler.test.mjs} | 0 ...ratorsControllerTests.mjs => CollaboratorsController.test.mjs} | 0 ...ControllerTests.mjs => CollaboratorsInviteController.test.mjs} | 0 ...InviteHandlerTests.mjs => CollaboratorsInviteHandler.test.mjs} | 0 .../{ContactControllerTests.mjs => ContactController.test.mjs} | 0 .../{CooldownMiddlewareTests.mjs => CooldownMiddleware.test.mjs} | 0 ...aterControllerTests.mjs => DocumentUpdaterController.test.mjs} | 0 .../{DocumentControllerTests.mjs => DocumentController.test.mjs} | 0 ...adsControllerTests.mjs => ProjectDownloadsController.test.mjs} | 0 ...ZipStreamManagerTests.mjs => ProjectZipStreamManager.test.mjs} | 0 .../{ExportsControllerTests.mjs => ExportsController.test.mjs} | 0 .../Exports/{ExportsHandlerTests.mjs => ExportsHandler.test.mjs} | 0 ...{FileStoreControllerTests.mjs => FileStoreController.test.mjs} | 0 ...kedFilesControllerTests.mjs => LinkedFilesController.test.mjs} | 0 .../Metadata/{MetaControllerTests.mjs => MetaController.test.mjs} | 0 .../src/Metadata/{MetaHandlerTests.mjs => MetaHandler.test.mjs} | 0 ...ationsControllerTests.mjs => NotificationsController.test.mjs} | 0 ...dResetControllerTests.mjs => PasswordResetController.test.mjs} | 0 ...asswordResetHandlerTests.mjs => PasswordResetHandler.test.mjs} | 0 .../{DocLinesComparitorTests.mjs => DocLinesComparitor.test.mjs} | 0 ...rojectApiControllerTests.mjs => ProjectApiController.test.mjs} | 0 ...jectListControllerTests.mjs => ProjectListController.test.mjs} | 0 .../Referal/{ReferalConnectTests.mjs => ReferalConnect.test.mjs} | 0 .../{ReferalControllerTests.mjs => ReferalController.test.mjs} | 0 .../Referal/{ReferalHandlerTests.mjs => ReferalHandler.test.mjs} | 0 ...eferencesControllerTests.mjs => ReferencesController.test.mjs} | 0 .../{ReferencesHandlerTests.mjs => ReferencesHandler.test.mjs} | 0 ...upControllerTests.mjs => SubscriptionGroupController.test.mjs} | 0 ...mInvitesControllerTests.mjs => TeamInvitesController.test.mjs} | 0 .../src/Tags/{TagsControllerTests.mjs => TagsController.test.mjs} | 0 .../{TpdsControllerTests.mjs => TpdsController.test.mjs} | 0 .../{TpdsUpdateHandlerTests.mjs => TpdsUpdateHandler.test.mjs} | 0 ...enAccessControllerTests.mjs => TokenAccessController.test.mjs} | 0 ...UploadControllerTests.mjs => ProjectUploadController.test.mjs} | 0 ...{UserPagesControllerTests.mjs => UserPagesController.test.mjs} | 0 ...rshipControllerTests.mjs => UserMembershipController.test.mjs} | 0 .../{ServeStaticWrapperTests.mjs => ServeStaticWrapper.test.mjs} | 0 42 files changed, 0 insertions(+), 0 deletions(-) rename services/web/modules/launchpad/test/unit/src/{LaunchpadControllerTests.mjs => LaunchpadController.test.mjs} (100%) rename services/web/modules/user-activate/test/unit/src/{UserActivateControllerTests.mjs => UserActivateController.test.mjs} (100%) rename services/web/test/unit/src/Analytics/{AnalyticsControllerTests.mjs => AnalyticsController.test.mjs} (100%) rename services/web/test/unit/src/Analytics/{AnalyticsUTMTrackingMiddlewareTests.mjs => AnalyticsUTMTrackingMiddleware.test.mjs} (100%) rename services/web/test/unit/src/BetaProgram/{BetaProgramControllerTests.mjs => BetaProgramController.test.mjs} (100%) rename services/web/test/unit/src/BetaProgram/{BetaProgramHandlerTests.mjs => BetaProgramHandler.test.mjs} (100%) rename services/web/test/unit/src/Collaborators/{CollaboratorsControllerTests.mjs => CollaboratorsController.test.mjs} (100%) rename services/web/test/unit/src/Collaborators/{CollaboratorsInviteControllerTests.mjs => CollaboratorsInviteController.test.mjs} (100%) rename services/web/test/unit/src/Collaborators/{CollaboratorsInviteHandlerTests.mjs => CollaboratorsInviteHandler.test.mjs} (100%) rename services/web/test/unit/src/Contact/{ContactControllerTests.mjs => ContactController.test.mjs} (100%) rename services/web/test/unit/src/Cooldown/{CooldownMiddlewareTests.mjs => CooldownMiddleware.test.mjs} (100%) rename services/web/test/unit/src/DocumentUpdater/{DocumentUpdaterControllerTests.mjs => DocumentUpdaterController.test.mjs} (100%) rename services/web/test/unit/src/Documents/{DocumentControllerTests.mjs => DocumentController.test.mjs} (100%) rename services/web/test/unit/src/Downloads/{ProjectDownloadsControllerTests.mjs => ProjectDownloadsController.test.mjs} (100%) rename services/web/test/unit/src/Downloads/{ProjectZipStreamManagerTests.mjs => ProjectZipStreamManager.test.mjs} (100%) rename services/web/test/unit/src/Exports/{ExportsControllerTests.mjs => ExportsController.test.mjs} (100%) rename services/web/test/unit/src/Exports/{ExportsHandlerTests.mjs => ExportsHandler.test.mjs} (100%) rename services/web/test/unit/src/FileStore/{FileStoreControllerTests.mjs => FileStoreController.test.mjs} (100%) rename services/web/test/unit/src/LinkedFiles/{LinkedFilesControllerTests.mjs => LinkedFilesController.test.mjs} (100%) rename services/web/test/unit/src/Metadata/{MetaControllerTests.mjs => MetaController.test.mjs} (100%) rename services/web/test/unit/src/Metadata/{MetaHandlerTests.mjs => MetaHandler.test.mjs} (100%) rename services/web/test/unit/src/Notifications/{NotificationsControllerTests.mjs => NotificationsController.test.mjs} (100%) rename services/web/test/unit/src/PasswordReset/{PasswordResetControllerTests.mjs => PasswordResetController.test.mjs} (100%) rename services/web/test/unit/src/PasswordReset/{PasswordResetHandlerTests.mjs => PasswordResetHandler.test.mjs} (100%) rename services/web/test/unit/src/Project/{DocLinesComparitorTests.mjs => DocLinesComparitor.test.mjs} (100%) rename services/web/test/unit/src/Project/{ProjectApiControllerTests.mjs => ProjectApiController.test.mjs} (100%) rename services/web/test/unit/src/Project/{ProjectListControllerTests.mjs => ProjectListController.test.mjs} (100%) rename services/web/test/unit/src/Referal/{ReferalConnectTests.mjs => ReferalConnect.test.mjs} (100%) rename services/web/test/unit/src/Referal/{ReferalControllerTests.mjs => ReferalController.test.mjs} (100%) rename services/web/test/unit/src/Referal/{ReferalHandlerTests.mjs => ReferalHandler.test.mjs} (100%) rename services/web/test/unit/src/References/{ReferencesControllerTests.mjs => ReferencesController.test.mjs} (100%) rename services/web/test/unit/src/References/{ReferencesHandlerTests.mjs => ReferencesHandler.test.mjs} (100%) rename services/web/test/unit/src/Subscription/{SubscriptionGroupControllerTests.mjs => SubscriptionGroupController.test.mjs} (100%) rename services/web/test/unit/src/Subscription/{TeamInvitesControllerTests.mjs => TeamInvitesController.test.mjs} (100%) rename services/web/test/unit/src/Tags/{TagsControllerTests.mjs => TagsController.test.mjs} (100%) rename services/web/test/unit/src/ThirdPartyDataStore/{TpdsControllerTests.mjs => TpdsController.test.mjs} (100%) rename services/web/test/unit/src/ThirdPartyDataStore/{TpdsUpdateHandlerTests.mjs => TpdsUpdateHandler.test.mjs} (100%) rename services/web/test/unit/src/TokenAccess/{TokenAccessControllerTests.mjs => TokenAccessController.test.mjs} (100%) rename services/web/test/unit/src/Uploads/{ProjectUploadControllerTests.mjs => ProjectUploadController.test.mjs} (100%) rename services/web/test/unit/src/User/{UserPagesControllerTests.mjs => UserPagesController.test.mjs} (100%) rename services/web/test/unit/src/UserMembership/{UserMembershipControllerTests.mjs => UserMembershipController.test.mjs} (100%) rename services/web/test/unit/src/infrastructure/{ServeStaticWrapperTests.mjs => ServeStaticWrapper.test.mjs} (100%) diff --git a/services/web/modules/launchpad/test/unit/src/LaunchpadControllerTests.mjs b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs similarity index 100% rename from services/web/modules/launchpad/test/unit/src/LaunchpadControllerTests.mjs rename to services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs diff --git a/services/web/modules/user-activate/test/unit/src/UserActivateControllerTests.mjs b/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs similarity index 100% rename from services/web/modules/user-activate/test/unit/src/UserActivateControllerTests.mjs rename to services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs diff --git a/services/web/test/unit/src/Analytics/AnalyticsControllerTests.mjs b/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs similarity index 100% rename from services/web/test/unit/src/Analytics/AnalyticsControllerTests.mjs rename to services/web/test/unit/src/Analytics/AnalyticsController.test.mjs diff --git a/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddlewareTests.mjs b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs similarity index 100% rename from services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddlewareTests.mjs rename to services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramControllerTests.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs similarity index 100% rename from services/web/test/unit/src/BetaProgram/BetaProgramControllerTests.mjs rename to services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramHandlerTests.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs similarity index 100% rename from services/web/test/unit/src/BetaProgram/BetaProgramHandlerTests.mjs rename to services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsControllerTests.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs similarity index 100% rename from services/web/test/unit/src/Collaborators/CollaboratorsControllerTests.mjs rename to services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs similarity index 100% rename from services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs rename to services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs similarity index 100% rename from services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.mjs rename to services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs diff --git a/services/web/test/unit/src/Contact/ContactControllerTests.mjs b/services/web/test/unit/src/Contact/ContactController.test.mjs similarity index 100% rename from services/web/test/unit/src/Contact/ContactControllerTests.mjs rename to services/web/test/unit/src/Contact/ContactController.test.mjs diff --git a/services/web/test/unit/src/Cooldown/CooldownMiddlewareTests.mjs b/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs similarity index 100% rename from services/web/test/unit/src/Cooldown/CooldownMiddlewareTests.mjs rename to services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs diff --git a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterControllerTests.mjs b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs similarity index 100% rename from services/web/test/unit/src/DocumentUpdater/DocumentUpdaterControllerTests.mjs rename to services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs diff --git a/services/web/test/unit/src/Documents/DocumentControllerTests.mjs b/services/web/test/unit/src/Documents/DocumentController.test.mjs similarity index 100% rename from services/web/test/unit/src/Documents/DocumentControllerTests.mjs rename to services/web/test/unit/src/Documents/DocumentController.test.mjs diff --git a/services/web/test/unit/src/Downloads/ProjectDownloadsControllerTests.mjs b/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs similarity index 100% rename from services/web/test/unit/src/Downloads/ProjectDownloadsControllerTests.mjs rename to services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs diff --git a/services/web/test/unit/src/Downloads/ProjectZipStreamManagerTests.mjs b/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs similarity index 100% rename from services/web/test/unit/src/Downloads/ProjectZipStreamManagerTests.mjs rename to services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs diff --git a/services/web/test/unit/src/Exports/ExportsControllerTests.mjs b/services/web/test/unit/src/Exports/ExportsController.test.mjs similarity index 100% rename from services/web/test/unit/src/Exports/ExportsControllerTests.mjs rename to services/web/test/unit/src/Exports/ExportsController.test.mjs diff --git a/services/web/test/unit/src/Exports/ExportsHandlerTests.mjs b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs similarity index 100% rename from services/web/test/unit/src/Exports/ExportsHandlerTests.mjs rename to services/web/test/unit/src/Exports/ExportsHandler.test.mjs diff --git a/services/web/test/unit/src/FileStore/FileStoreControllerTests.mjs b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs similarity index 100% rename from services/web/test/unit/src/FileStore/FileStoreControllerTests.mjs rename to services/web/test/unit/src/FileStore/FileStoreController.test.mjs diff --git a/services/web/test/unit/src/LinkedFiles/LinkedFilesControllerTests.mjs b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs similarity index 100% rename from services/web/test/unit/src/LinkedFiles/LinkedFilesControllerTests.mjs rename to services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs diff --git a/services/web/test/unit/src/Metadata/MetaControllerTests.mjs b/services/web/test/unit/src/Metadata/MetaController.test.mjs similarity index 100% rename from services/web/test/unit/src/Metadata/MetaControllerTests.mjs rename to services/web/test/unit/src/Metadata/MetaController.test.mjs diff --git a/services/web/test/unit/src/Metadata/MetaHandlerTests.mjs b/services/web/test/unit/src/Metadata/MetaHandler.test.mjs similarity index 100% rename from services/web/test/unit/src/Metadata/MetaHandlerTests.mjs rename to services/web/test/unit/src/Metadata/MetaHandler.test.mjs diff --git a/services/web/test/unit/src/Notifications/NotificationsControllerTests.mjs b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs similarity index 100% rename from services/web/test/unit/src/Notifications/NotificationsControllerTests.mjs rename to services/web/test/unit/src/Notifications/NotificationsController.test.mjs diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetControllerTests.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs similarity index 100% rename from services/web/test/unit/src/PasswordReset/PasswordResetControllerTests.mjs rename to services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetHandlerTests.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs similarity index 100% rename from services/web/test/unit/src/PasswordReset/PasswordResetHandlerTests.mjs rename to services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs diff --git a/services/web/test/unit/src/Project/DocLinesComparitorTests.mjs b/services/web/test/unit/src/Project/DocLinesComparitor.test.mjs similarity index 100% rename from services/web/test/unit/src/Project/DocLinesComparitorTests.mjs rename to services/web/test/unit/src/Project/DocLinesComparitor.test.mjs diff --git a/services/web/test/unit/src/Project/ProjectApiControllerTests.mjs b/services/web/test/unit/src/Project/ProjectApiController.test.mjs similarity index 100% rename from services/web/test/unit/src/Project/ProjectApiControllerTests.mjs rename to services/web/test/unit/src/Project/ProjectApiController.test.mjs diff --git a/services/web/test/unit/src/Project/ProjectListControllerTests.mjs b/services/web/test/unit/src/Project/ProjectListController.test.mjs similarity index 100% rename from services/web/test/unit/src/Project/ProjectListControllerTests.mjs rename to services/web/test/unit/src/Project/ProjectListController.test.mjs diff --git a/services/web/test/unit/src/Referal/ReferalConnectTests.mjs b/services/web/test/unit/src/Referal/ReferalConnect.test.mjs similarity index 100% rename from services/web/test/unit/src/Referal/ReferalConnectTests.mjs rename to services/web/test/unit/src/Referal/ReferalConnect.test.mjs diff --git a/services/web/test/unit/src/Referal/ReferalControllerTests.mjs b/services/web/test/unit/src/Referal/ReferalController.test.mjs similarity index 100% rename from services/web/test/unit/src/Referal/ReferalControllerTests.mjs rename to services/web/test/unit/src/Referal/ReferalController.test.mjs diff --git a/services/web/test/unit/src/Referal/ReferalHandlerTests.mjs b/services/web/test/unit/src/Referal/ReferalHandler.test.mjs similarity index 100% rename from services/web/test/unit/src/Referal/ReferalHandlerTests.mjs rename to services/web/test/unit/src/Referal/ReferalHandler.test.mjs diff --git a/services/web/test/unit/src/References/ReferencesControllerTests.mjs b/services/web/test/unit/src/References/ReferencesController.test.mjs similarity index 100% rename from services/web/test/unit/src/References/ReferencesControllerTests.mjs rename to services/web/test/unit/src/References/ReferencesController.test.mjs diff --git a/services/web/test/unit/src/References/ReferencesHandlerTests.mjs b/services/web/test/unit/src/References/ReferencesHandler.test.mjs similarity index 100% rename from services/web/test/unit/src/References/ReferencesHandlerTests.mjs rename to services/web/test/unit/src/References/ReferencesHandler.test.mjs diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs similarity index 100% rename from services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs rename to services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs diff --git a/services/web/test/unit/src/Subscription/TeamInvitesControllerTests.mjs b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs similarity index 100% rename from services/web/test/unit/src/Subscription/TeamInvitesControllerTests.mjs rename to services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs diff --git a/services/web/test/unit/src/Tags/TagsControllerTests.mjs b/services/web/test/unit/src/Tags/TagsController.test.mjs similarity index 100% rename from services/web/test/unit/src/Tags/TagsControllerTests.mjs rename to services/web/test/unit/src/Tags/TagsController.test.mjs diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsControllerTests.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs similarity index 100% rename from services/web/test/unit/src/ThirdPartyDataStore/TpdsControllerTests.mjs rename to services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandlerTests.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs similarity index 100% rename from services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandlerTests.mjs rename to services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.mjs b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs similarity index 100% rename from services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.mjs rename to services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs diff --git a/services/web/test/unit/src/Uploads/ProjectUploadControllerTests.mjs b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs similarity index 100% rename from services/web/test/unit/src/Uploads/ProjectUploadControllerTests.mjs rename to services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs diff --git a/services/web/test/unit/src/User/UserPagesControllerTests.mjs b/services/web/test/unit/src/User/UserPagesController.test.mjs similarity index 100% rename from services/web/test/unit/src/User/UserPagesControllerTests.mjs rename to services/web/test/unit/src/User/UserPagesController.test.mjs diff --git a/services/web/test/unit/src/UserMembership/UserMembershipControllerTests.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs similarity index 100% rename from services/web/test/unit/src/UserMembership/UserMembershipControllerTests.mjs rename to services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs diff --git a/services/web/test/unit/src/infrastructure/ServeStaticWrapperTests.mjs b/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs similarity index 100% rename from services/web/test/unit/src/infrastructure/ServeStaticWrapperTests.mjs rename to services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs From 873068a1875f50c8f64c80385022fa3d311c571f Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 30 Apr 2025 09:08:59 +0100 Subject: [PATCH 032/259] Update test files with vitest compat changes GitOrigin-RevId: 494f906089d250268a5ff8c8a2150ff2692c37e2 --- .../unit/src/LaunchpadController.test.mjs | 1152 +++++------ .../unit/src/UserActivateController.test.mjs | 178 +- .../Analytics/AnalyticsController.test.mjs | 125 +- .../AnalyticsUTMTrackingMiddleware.test.mjs | 149 +- .../BetaProgramController.test.mjs | 253 ++- .../BetaProgram/BetaProgramHandler.test.mjs | 169 +- .../CollaboratorsController.test.mjs | 597 +++--- .../CollaboratorsInviteController.test.mjs | 1787 +++++++++-------- .../CollaboratorsInviteHandler.test.mjs | 852 ++++---- .../src/Contact/ContactController.test.mjs | 169 +- .../src/Cooldown/CooldownMiddleware.test.mjs | 140 +- .../DocumentUpdaterController.test.mjs | 100 +- .../src/Documents/DocumentController.test.mjs | 223 +- .../ProjectDownloadsController.test.mjs | 161 +- .../ProjectZipStreamManager.test.mjs | 514 ++--- .../src/Exports/ExportsController.test.mjs | 278 +-- .../unit/src/Exports/ExportsHandler.test.mjs | 933 ++++----- .../FileStore/FileStoreController.test.mjs | 229 ++- .../LinkedFilesController.test.mjs | 260 ++- .../unit/src/Metadata/MetaController.test.mjs | 77 +- .../unit/src/Metadata/MetaHandler.test.mjs | 95 +- .../NotificationsController.test.mjs | 80 +- .../PasswordResetController.test.mjs | 752 ++++--- .../PasswordResetHandler.test.mjs | 720 ++++--- .../src/Project/DocLinesComparitor.test.mjs | 42 +- .../src/Project/ProjectApiController.test.mjs | 76 +- .../Project/ProjectListController.test.mjs | 873 ++++---- .../unit/src/Referal/ReferalConnect.test.mjs | 203 +- .../src/Referal/ReferalController.test.mjs | 12 +- .../unit/src/Referal/ReferalHandler.test.mjs | 54 +- .../References/ReferencesController.test.mjs | 249 +-- .../src/References/ReferencesHandler.test.mjs | 440 ++-- .../SubscriptionGroupController.test.mjs | 1231 ++++++------ .../TeamInvitesController.test.mjs | 308 +-- .../unit/src/Tags/TagsController.test.mjs | 431 ++-- .../TpdsController.test.mjs | 723 ++++--- .../TpdsUpdateHandler.test.mjs | 389 ++-- .../TokenAccessController.test.mjs | 1340 ++++++------ .../Uploads/ProjectUploadController.test.mjs | 337 ++-- .../src/User/UserPagesController.test.mjs | 684 ++++--- .../UserMembershipController.test.mjs | 478 +++-- .../ServeStaticWrapper.test.mjs | 43 +- 42 files changed, 9712 insertions(+), 8194 deletions(-) diff --git a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs index e1ca25a75a..89bc165305 100644 --- a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs +++ b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs @@ -1,187 +1,215 @@ -import { fileURLToPath } from 'node:url' +import { vi } from 'vitest' import * as path from 'node:path' -import { strict as esmock } from 'esmock' import { expect } from 'chai' import sinon from 'sinon' -import Settings from '@overleaf/settings' import MockResponse from '../../../../../test/unit/src/helpers/MockResponse.js' -const __dirname = fileURLToPath(new URL('.', import.meta.url)) const modulePath = path.join( - __dirname, + import.meta.dirname, '../../../app/src/LaunchpadController.mjs' ) describe('LaunchpadController', function () { // esmock doesn't work well with CommonJS dependencies, global imports for // @overleaf/settings aren't working until that module is migrated to ESM. In the - // meantime, the workaroung is to set and restore settings values - let oldSettingsAdminPrivilegeAvailable + // meantime, the workaround is to set and restore settings values - beforeEach(async function () { - this.user = { + beforeEach(async function (ctx) { + ctx.user = { _id: '323123', first_name: 'fn', last_name: 'ln', save: sinon.stub().callsArgWith(0), } - oldSettingsAdminPrivilegeAvailable = Settings.adminPrivilegeAvailable - Settings.adminPrivilegeAvailable = true + ctx.User = {} - this.User = {} - this.LaunchpadController = await esmock(modulePath, { - '@overleaf/metrics': (this.Metrics = {}), - '../../../../../app/src/Features/User/UserRegistrationHandler.js': - (this.UserRegistrationHandler = { + ctx.Settings = { + adminPrivilegeAvailable: true, + } + + vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings })) + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.Metrics = {}), + })) + + vi.doMock( + '../../../../../app/src/Features/User/UserRegistrationHandler.js', + () => ({ + default: (ctx.UserRegistrationHandler = { promises: {}, }), - '../../../../../app/src/Features/Email/EmailHandler.js': - (this.EmailHandler = { promises: {} }), - '../../../../../app/src/Features/User/UserGetter.js': (this.UserGetter = { + }) + ) + + vi.doMock('../../../../../app/src/Features/Email/EmailHandler.js', () => ({ + default: (ctx.EmailHandler = { promises: {} }), + })) + + vi.doMock('../../../../../app/src/Features/User/UserGetter.js', () => ({ + default: (ctx.UserGetter = { promises: {}, }), - '../../../../../app/src/models/User.js': { User: this.User }, - '../../../../../app/src/Features/Authentication/AuthenticationController.js': - (this.AuthenticationController = {}), - '../../../../../app/src/Features/Authentication/AuthenticationManager.js': - (this.AuthenticationManager = {}), - '../../../../../app/src/Features/Authentication/SessionManager.js': - (this.SessionManager = { + })) + + vi.doMock('../../../../../app/src/models/User.js', () => ({ + User: ctx.User, + })) + + vi.doMock( + '../../../../../app/src/Features/Authentication/AuthenticationController.js', + () => ({ + default: (ctx.AuthenticationController = {}), + }) + ) + + vi.doMock( + '../../../../../app/src/Features/Authentication/AuthenticationManager.js', + () => ({ + default: (ctx.AuthenticationManager = {}), + }) + ) + + vi.doMock( + '../../../../../app/src/Features/Authentication/SessionManager.js', + () => ({ + default: (ctx.SessionManager = { getSessionUser: sinon.stub(), }), - }) + }) + ) - this.email = 'bob@smith.com' + ctx.LaunchpadController = (await import(modulePath)).default - this.req = { + ctx.email = 'bob@smith.com' + + ctx.req = { query: {}, body: {}, session: {}, } - this.res = new MockResponse() - this.res.locals = { + ctx.res = new MockResponse() + ctx.res.locals = { translate(key) { return key }, } - this.next = sinon.stub() - }) - - afterEach(function () { - Settings.adminPrivilegeAvailable = oldSettingsAdminPrivilegeAvailable + ctx.next = sinon.stub() }) describe('launchpadPage', function () { - beforeEach(function () { - this.LaunchpadController._mocks._atLeastOneAdminExists = sinon.stub() - this._atLeastOneAdminExists = - this.LaunchpadController._mocks._atLeastOneAdminExists - this.AuthenticationController.setRedirectInSession = sinon.stub() + beforeEach(function (ctx) { + ctx.LaunchpadController._mocks._atLeastOneAdminExists = sinon.stub() + ctx._atLeastOneAdminExists = + ctx.LaunchpadController._mocks._atLeastOneAdminExists + ctx.AuthenticationController.setRedirectInSession = sinon.stub() }) describe('when the user is not logged in', function () { - beforeEach(function () { - this.SessionManager.getSessionUser = sinon.stub().returns(null) + beforeEach(function (ctx) { + ctx.SessionManager.getSessionUser = sinon.stub().returns(null) }) describe('when there are no admins', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - await this.LaunchpadController.launchpadPage( - this.req, - this.res, - this.next + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + await ctx.LaunchpadController.launchpadPage( + ctx.req, + ctx.res, + ctx.next ) }) - it('should render the launchpad page', function () { - const viewPath = path.join(__dirname, '../../../app/views/launchpad') - this.res.render.callCount.should.equal(1) - this.res.render - .calledWith(viewPath, { - adminUserExists: false, - authMethod: 'local', - }) - .should.equal(true) + it('should render the launchpad page', function (ctx) { + const viewPath = path.join( + import.meta.dirname, + '../../../app/views/launchpad' + ) + ctx.res.render.callCount.should.equal(1) + expect(ctx.res.render).to.have.been.calledWith(viewPath, { + adminUserExists: false, + authMethod: 'local', + }) }) }) describe('when there is at least one admin', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(true) - await this.LaunchpadController.launchpadPage( - this.req, - this.res, - this.next + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(true) + await ctx.LaunchpadController.launchpadPage( + ctx.req, + ctx.res, + ctx.next ) }) - it('should redirect to login page', function () { - this.AuthenticationController.setRedirectInSession.callCount.should.equal( + it('should redirect to login page', function (ctx) { + ctx.AuthenticationController.setRedirectInSession.callCount.should.equal( 1 ) - this.res.redirect.calledWith('/login').should.equal(true) + ctx.res.redirect.calledWith('/login').should.equal(true) }) - it('should not render the launchpad page', function () { - this.res.render.callCount.should.equal(0) + it('should not render the launchpad page', function (ctx) { + ctx.res.render.callCount.should.equal(0) }) }) }) describe('when the user is logged in', function () { - beforeEach(function () { - this.user = { + beforeEach(function (ctx) { + ctx.user = { _id: 'abcd', email: 'abcd@example.com', } - this.SessionManager.getSessionUser.returns(this.user) - this._atLeastOneAdminExists.resolves(true) + ctx.SessionManager.getSessionUser.returns(ctx.user) + ctx._atLeastOneAdminExists.resolves(true) }) describe('when the user is an admin', function () { - beforeEach(async function () { - this.UserGetter.promises.getUser = sinon + beforeEach(async function (ctx) { + ctx.UserGetter.promises.getUser = sinon .stub() .resolves({ isAdmin: true }) - await this.LaunchpadController.launchpadPage( - this.req, - this.res, - this.next + await ctx.LaunchpadController.launchpadPage( + ctx.req, + ctx.res, + ctx.next ) }) - it('should render the launchpad page', function () { - const viewPath = path.join(__dirname, '../../../app/views/launchpad') - this.res.render.callCount.should.equal(1) - this.res.render - .calledWith(viewPath, { - wsUrl: undefined, - adminUserExists: true, - authMethod: 'local', - }) - .should.equal(true) + it('should render the launchpad page', function (ctx) { + const viewPath = path.join( + import.meta.dirname, + '../../../app/views/launchpad' + ) + ctx.res.render.callCount.should.equal(1) + expect(ctx.res.render).to.have.been.calledWith(viewPath, { + wsUrl: undefined, + adminUserExists: true, + authMethod: 'local', + }) }) }) describe('when the user is not an admin', function () { - beforeEach(async function () { - this.UserGetter.promises.getUser = sinon + beforeEach(async function (ctx) { + ctx.UserGetter.promises.getUser = sinon .stub() .resolves({ isAdmin: false }) - await this.LaunchpadController.launchpadPage( - this.req, - this.res, - this.next + await ctx.LaunchpadController.launchpadPage( + ctx.req, + ctx.res, + ctx.next ) }) - it('should redirect to restricted page', function () { - this.res.redirect.callCount.should.equal(1) - this.res.redirect.calledWith('/restricted').should.equal(true) + it('should redirect to restricted page', function (ctx) { + ctx.res.redirect.callCount.should.equal(1) + ctx.res.redirect.calledWith('/restricted').should.equal(true) }) }) }) @@ -189,100 +217,92 @@ describe('LaunchpadController', function () { describe('_atLeastOneAdminExists', function () { describe('when there are no admins', function () { - beforeEach(function () { - this.UserGetter.promises.getUser = sinon.stub().resolves(null) + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUser = sinon.stub().resolves(null) }) - it('should callback with false', async function () { - const exists = await this.LaunchpadController._atLeastOneAdminExists() + it('should callback with false', async function (ctx) { + const exists = await ctx.LaunchpadController._atLeastOneAdminExists() expect(exists).to.equal(false) }) }) describe('when there are some admins', function () { - beforeEach(function () { - this.UserGetter.promises.getUser = sinon - .stub() - .resolves({ _id: 'abcd' }) + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUser = sinon.stub().resolves({ _id: 'abcd' }) }) - it('should callback with true', async function () { - const exists = await this.LaunchpadController._atLeastOneAdminExists() + it('should callback with true', async function (ctx) { + const exists = await ctx.LaunchpadController._atLeastOneAdminExists() expect(exists).to.equal(true) }) }) describe('when getUser produces an error', function () { - beforeEach(function () { - this.UserGetter.promises.getUser = sinon + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUser = sinon .stub() .rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.LaunchpadController._atLeastOneAdminExists()).rejected + it('should produce an error', async function (ctx) { + await expect(ctx.LaunchpadController._atLeastOneAdminExists()).rejected }) }) }) describe('sendTestEmail', function () { - beforeEach(function () { - this.EmailHandler.promises.sendEmail = sinon.stub().resolves() - this.req.body.email = 'someone@example.com' + beforeEach(function (ctx) { + ctx.EmailHandler.promises.sendEmail = sinon.stub().resolves() + ctx.req.body.email = 'someone@example.com' }) - it('should produce a 200 response', async function () { - await this.LaunchpadController.sendTestEmail( - this.req, - this.res, - this.next - ) - this.res.json.calledWith({ message: 'email_sent' }).should.equal(true) + it('should produce a 200 response', async function (ctx) { + await ctx.LaunchpadController.sendTestEmail(ctx.req, ctx.res, ctx.next) + ctx.res.json.calledWith({ message: 'email_sent' }).should.equal(true) }) - it('should not call next with an error', function () { - this.LaunchpadController.sendTestEmail(this.req, this.res, this.next) - this.next.callCount.should.equal(0) + it('should not call next with an error', function (ctx) { + ctx.LaunchpadController.sendTestEmail(ctx.req, ctx.res, ctx.next) + ctx.next.callCount.should.equal(0) }) - it('should have called sendEmail', async function () { - await this.LaunchpadController.sendTestEmail( - this.req, - this.res, - this.next - ) - this.EmailHandler.promises.sendEmail.callCount.should.equal(1) - this.EmailHandler.promises.sendEmail + it('should have called sendEmail', async function (ctx) { + await ctx.LaunchpadController.sendTestEmail(ctx.req, ctx.res, ctx.next) + ctx.EmailHandler.promises.sendEmail.callCount.should.equal(1) + ctx.EmailHandler.promises.sendEmail .calledWith('testEmail') .should.equal(true) }) describe('when sendEmail produces an error', function () { - beforeEach(function () { - this.EmailHandler.promises.sendEmail = sinon + beforeEach(function (ctx) { + ctx.EmailHandler.promises.sendEmail = sinon .stub() .rejects(new Error('woops')) }) - it('should call next with an error', function (done) { - this.next = sinon.stub().callsFake(err => { - expect(err).to.be.instanceof(Error) - this.next.callCount.should.equal(1) - done() + it('should call next with an error', function (ctx) { + return new Promise(resolve => { + ctx.next = sinon.stub().callsFake(err => { + expect(err).to.be.instanceof(Error) + ctx.next.callCount.should.equal(1) + resolve() + }) + ctx.LaunchpadController.sendTestEmail(ctx.req, ctx.res, ctx.next) }) - this.LaunchpadController.sendTestEmail(this.req, this.res, this.next) }) }) describe('when no email address is supplied', function () { - beforeEach(function () { - this.req.body.email = undefined + beforeEach(function (ctx) { + ctx.req.body.email = undefined }) - it('should produce a 400 response', function () { - this.LaunchpadController.sendTestEmail(this.req, this.res, this.next) - this.res.status.calledWith(400).should.equal(true) - this.res.json + it('should produce a 400 response', function (ctx) { + ctx.LaunchpadController.sendTestEmail(ctx.req, ctx.res, ctx.next) + ctx.res.status.calledWith(400).should.equal(true) + ctx.res.json .calledWith({ message: 'no email address supplied', }) @@ -292,67 +312,63 @@ describe('LaunchpadController', function () { }) describe('registerAdmin', function () { - beforeEach(function () { - this.LaunchpadController._mocks._atLeastOneAdminExists = sinon.stub() - this._atLeastOneAdminExists = - this.LaunchpadController._mocks._atLeastOneAdminExists + beforeEach(function (ctx) { + ctx.LaunchpadController._mocks._atLeastOneAdminExists = sinon.stub() + ctx._atLeastOneAdminExists = + ctx.LaunchpadController._mocks._atLeastOneAdminExists }) describe('when all goes well', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.password = 'a_really_bad_password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.password = 'a_really_bad_password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon .stub() - .resolves(this.user) - this.User.updateOne = sinon + .resolves(ctx.user) + ctx.User.updateOne = sinon .stub() .returns({ exec: sinon.stub().resolves() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - this.AuthenticationManager.validateEmail = sinon.stub().returns(null) - this.AuthenticationManager.validatePassword = sinon.stub().returns(null) - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + ctx.AuthenticationManager.validateEmail = sinon.stub().returns(null) + ctx.AuthenticationManager.validatePassword = sinon.stub().returns(null) + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should send back a json response', function () { - this.res.json.callCount.should.equal(1) - expect(this.res.json).to.have.been.calledWith({ redir: '/launchpad' }) + it('should send back a json response', function (ctx) { + ctx.res.json.callCount.should.equal(1) + expect(ctx.res.json).to.have.been.calledWith({ redir: '/launchpad' }) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should have called registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should have called registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 1 ) - this.UserRegistrationHandler.promises.registerNewUser - .calledWith({ email: this.email, password: this.password }) + ctx.UserRegistrationHandler.promises.registerNewUser + .calledWith({ email: ctx.email, password: ctx.password }) .should.equal(true) }) - it('should have updated the user to make them an admin', function () { - this.User.updateOne.callCount.should.equal(1) - this.User.updateOne + it('should have updated the user to make them an admin', function (ctx) { + ctx.User.updateOne.callCount.should.equal(1) + ctx.User.updateOne .calledWithMatch( - { _id: this.user._id }, + { _id: ctx.user._id }, { $set: { isAdmin: true, emails: [ - { email: this.user.email, reversedHostname: 'moc.elpmaxe' }, + { email: ctx.user.email, reversedHostname: 'moc.elpmaxe' }, ], }, } @@ -362,390 +378,345 @@ describe('LaunchpadController', function () { }) describe('when no email is supplied', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = undefined - this.password = 'a_really_bad_password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = undefined + ctx.password = 'a_really_bad_password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should send a 400 response', function () { - this.res.sendStatus.callCount.should.equal(1) - this.res.sendStatus.calledWith(400).should.equal(true) + it('should send a 400 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(1) + ctx.res.sendStatus.calledWith(400).should.equal(true) }) - it('should not check for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(0) + it('should not check for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(0) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when no password is supplied', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.password = undefined - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.password = undefined + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should send a 400 response', function () { - this.res.sendStatus.callCount.should.equal(1) - this.res.sendStatus.calledWith(400).should.equal(true) + it('should send a 400 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(1) + ctx.res.sendStatus.calledWith(400).should.equal(true) }) - it('should not check for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(0) + it('should not check for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(0) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when an invalid email is supplied', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.password = 'invalid password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.password = 'invalid password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - this.AuthenticationManager.validateEmail = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + ctx.AuthenticationManager.validateEmail = sinon .stub() .returns(new Error('bad email')) - this.AuthenticationManager.validatePassword = sinon.stub().returns(null) - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.AuthenticationManager.validatePassword = sinon.stub().returns(null) + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should send a 400 response', function () { - this.res.status.callCount.should.equal(1) - this.res.status.calledWith(400).should.equal(true) - this.res.json.calledWith({ + it('should send a 400 response', function (ctx) { + ctx.res.status.callCount.should.equal(1) + ctx.res.status.calledWith(400).should.equal(true) + ctx.res.json.calledWith({ message: { type: 'error', text: 'bad email' }, }) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when an invalid password is supplied', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.password = 'invalid password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.password = 'invalid password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - this.AuthenticationManager.validateEmail = sinon.stub().returns(null) - this.AuthenticationManager.validatePassword = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + ctx.AuthenticationManager.validateEmail = sinon.stub().returns(null) + ctx.AuthenticationManager.validatePassword = sinon .stub() .returns(new Error('bad password')) - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should send a 400 response', function () { - this.res.status.callCount.should.equal(1) - this.res.status.calledWith(400).should.equal(true) - this.res.json.calledWith({ + it('should send a 400 response', function (ctx) { + ctx.res.status.callCount.should.equal(1) + ctx.res.status.calledWith(400).should.equal(true) + ctx.res.json.calledWith({ message: { type: 'error', text: 'bad password' }, }) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when there are already existing admins', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(true) - this.email = 'someone@example.com' - this.password = 'a_really_bad_password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(true) + ctx.email = 'someone@example.com' + ctx.password = 'a_really_bad_password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - this.AuthenticationManager.validateEmail = sinon.stub().returns(null) - this.AuthenticationManager.validatePassword = sinon.stub().returns(null) - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + ctx.AuthenticationManager.validateEmail = sinon.stub().returns(null) + ctx.AuthenticationManager.validatePassword = sinon.stub().returns(null) + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should send a 403 response', function () { - this.res.status.callCount.should.equal(1) - this.res.status.calledWith(403).should.equal(true) + it('should send a 403 response', function (ctx) { + ctx.res.status.callCount.should.equal(1) + ctx.res.status.calledWith(403).should.equal(true) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when checking admins produces an error', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.rejects(new Error('woops')) - this.email = 'someone@example.com' - this.password = 'a_really_bad_password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.rejects(new Error('woops')) + ctx.email = 'someone@example.com' + ctx.password = 'a_really_bad_password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.lastCall.args[0]).to.be.instanceof(Error) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when registerNewUser produces an error', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.password = 'a_really_bad_password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.password = 'a_really_bad_password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon .stub() .rejects(new Error('woops')) - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - this.AuthenticationManager.validateEmail = sinon.stub().returns(null) - this.AuthenticationManager.validatePassword = sinon.stub().returns(null) - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + ctx.AuthenticationManager.validateEmail = sinon.stub().returns(null) + ctx.AuthenticationManager.validatePassword = sinon.stub().returns(null) + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.lastCall.args[0]).to.be.instanceof(Error) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should have called registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should have called registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 1 ) - this.UserRegistrationHandler.promises.registerNewUser - .calledWith({ email: this.email, password: this.password }) + ctx.UserRegistrationHandler.promises.registerNewUser + .calledWith({ email: ctx.email, password: ctx.password }) .should.equal(true) }) - it('should not call update', function () { - this.User.updateOne.callCount.should.equal(0) + it('should not call update', function (ctx) { + ctx.User.updateOne.callCount.should.equal(0) }) }) describe('when user update produces an error', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.password = 'a_really_bad_password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.password = 'a_really_bad_password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon .stub() - .resolves(this.user) - this.User.updateOne = sinon.stub().returns({ + .resolves(ctx.user) + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub().rejects(new Error('woops')), }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - this.AuthenticationManager.validateEmail = sinon.stub().returns(null) - this.AuthenticationManager.validatePassword = sinon.stub().returns(null) - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + ctx.AuthenticationManager.validateEmail = sinon.stub().returns(null) + ctx.AuthenticationManager.validatePassword = sinon.stub().returns(null) + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.lastCall.args[0]).to.be.instanceof(Error) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should have called registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should have called registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 1 ) - this.UserRegistrationHandler.promises.registerNewUser - .calledWith({ email: this.email, password: this.password }) + ctx.UserRegistrationHandler.promises.registerNewUser + .calledWith({ email: ctx.email, password: ctx.password }) .should.equal(true) }) }) describe('when overleaf', function () { - let oldSettingsOverleaf - - beforeEach(async function () { - oldSettingsOverleaf = Settings.overleaf - Settings.overleaf = { one: 1 } - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.password = 'a_really_bad_password' - this.req.body.email = this.email - this.req.body.password = this.password - this.user = { + beforeEach(async function (ctx) { + ctx.Settings.overleaf = { one: 1 } + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.password = 'a_really_bad_password' + ctx.req.body.email = ctx.email + ctx.req.body.password = ctx.password + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon .stub() - .resolves(this.user) - this.User.updateOne = sinon + .resolves(ctx.user) + ctx.User.updateOne = sinon .stub() .returns({ exec: sinon.stub().resolves() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - this.AuthenticationManager.validateEmail = sinon.stub().returns(null) - this.AuthenticationManager.validatePassword = sinon.stub().returns(null) - this.UserGetter.promises.getUser = sinon - .stub() - .resolves({ _id: '1234' }) - await this.LaunchpadController.registerAdmin( - this.req, - this.res, - this.next - ) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + ctx.AuthenticationManager.validateEmail = sinon.stub().returns(null) + ctx.AuthenticationManager.validatePassword = sinon.stub().returns(null) + ctx.UserGetter.promises.getUser = sinon.stub().resolves({ _id: '1234' }) + await ctx.LaunchpadController.registerAdmin(ctx.req, ctx.res, ctx.next) }) - afterEach(async function () { - Settings.overleaf = oldSettingsOverleaf + it('should send back a json response', function (ctx) { + ctx.res.json.callCount.should.equal(1) + expect(ctx.res.json).to.have.been.calledWith({ redir: '/launchpad' }) }) - it('should send back a json response', function () { - this.res.json.callCount.should.equal(1) - expect(this.res.json).to.have.been.calledWith({ redir: '/launchpad' }) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) - }) - - it('should have called registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should have called registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 1 ) - this.UserRegistrationHandler.promises.registerNewUser - .calledWith({ email: this.email, password: this.password }) + ctx.UserRegistrationHandler.promises.registerNewUser + .calledWith({ email: ctx.email, password: ctx.password }) .should.equal(true) }) - it('should have updated the user to make them an admin', function () { - this.User.updateOne + it('should have updated the user to make them an admin', function (ctx) { + ctx.User.updateOne .calledWith( - { _id: this.user._id }, + { _id: ctx.user._id }, { $set: { isAdmin: true, emails: [ - { email: this.user.email, reversedHostname: 'moc.elpmaxe' }, + { email: ctx.user.email, reversedHostname: 'moc.elpmaxe' }, ], }, } @@ -756,76 +727,69 @@ describe('LaunchpadController', function () { }) describe('registerExternalAuthAdmin', function () { - let oldSettingsLDAP - - beforeEach(function () { - oldSettingsLDAP = Settings.ldap - Settings.ldap = { one: 1 } - this.LaunchpadController._mocks._atLeastOneAdminExists = sinon.stub() - this._atLeastOneAdminExists = - this.LaunchpadController._mocks._atLeastOneAdminExists - }) - - afterEach(function () { - Settings.ldap = oldSettingsLDAP + beforeEach(function (ctx) { + ctx.Settings.ldap = { one: 1 } + ctx.LaunchpadController._mocks._atLeastOneAdminExists = sinon.stub() + ctx._atLeastOneAdminExists = + ctx.LaunchpadController._mocks._atLeastOneAdminExists }) describe('when all goes well', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.req.body.email = this.email - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.req.body.email = ctx.email + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon .stub() - .resolves(this.user) - this.User.updateOne = sinon + .resolves(ctx.user) + ctx.User.updateOne = sinon .stub() .returns({ exec: sinon.stub().resolves() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerExternalAuthAdmin('ldap')( - this.req, - this.res, - this.next + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerExternalAuthAdmin('ldap')( + ctx.req, + ctx.res, + ctx.next ) }) - it('should send back a json response', function () { - this.res.json.callCount.should.equal(1) - expect(this.res.json.lastCall.args[0].email).to.equal(this.email) + it('should send back a json response', function (ctx) { + ctx.res.json.callCount.should.equal(1) + expect(ctx.res.json.lastCall.args[0].email).to.equal(ctx.email) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should have called registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should have called registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 1 ) - this.UserRegistrationHandler.promises.registerNewUser + ctx.UserRegistrationHandler.promises.registerNewUser .calledWith({ - email: this.email, + email: ctx.email, password: 'password_here', - first_name: this.email, + first_name: ctx.email, last_name: '', }) .should.equal(true) }) - it('should have updated the user to make them an admin', function () { - this.User.updateOne.callCount.should.equal(1) - this.User.updateOne + it('should have updated the user to make them an admin', function (ctx) { + ctx.User.updateOne.callCount.should.equal(1) + ctx.User.updateOne .calledWith( - { _id: this.user._id }, + { _id: ctx.user._id }, { $set: { isAdmin: true, emails: [ - { email: this.user.email, reversedHostname: 'moc.elpmaxe' }, + { email: ctx.user.email, reversedHostname: 'moc.elpmaxe' }, ], }, } @@ -833,240 +797,240 @@ describe('LaunchpadController', function () { .should.equal(true) }) - it('should have set a redirect in session', function () { - this.AuthenticationController.setRedirectInSession.callCount.should.equal( + it('should have set a redirect in session', function (ctx) { + ctx.AuthenticationController.setRedirectInSession.callCount.should.equal( 1 ) - this.AuthenticationController.setRedirectInSession - .calledWith(this.req, '/launchpad') + ctx.AuthenticationController.setRedirectInSession + .calledWith(ctx.req, '/launchpad') .should.equal(true) }) }) describe('when the authMethod is invalid', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = undefined - this.req.body.email = this.email - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = undefined + ctx.req.body.email = ctx.email + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerExternalAuthAdmin( + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerExternalAuthAdmin( 'NOTAVALIDAUTHMETHOD' - )(this.req, this.res, this.next) + )(ctx.req, ctx.res, ctx.next) }) - it('should send a 403 response', function () { - this.res.sendStatus.callCount.should.equal(1) - this.res.sendStatus.calledWith(403).should.equal(true) + it('should send a 403 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(1) + ctx.res.sendStatus.calledWith(403).should.equal(true) }) - it('should not check for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(0) + it('should not check for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(0) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when no email is supplied', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = undefined - this.req.body.email = this.email - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = undefined + ctx.req.body.email = ctx.email + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerExternalAuthAdmin('ldap')( - this.req, - this.res, - this.next + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerExternalAuthAdmin('ldap')( + ctx.req, + ctx.res, + ctx.next ) }) - it('should send a 400 response', function () { - this.res.sendStatus.callCount.should.equal(1) - this.res.sendStatus.calledWith(400).should.equal(true) + it('should send a 400 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(1) + ctx.res.sendStatus.calledWith(400).should.equal(true) }) - it('should not check for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(0) + it('should not check for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(0) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when there are already existing admins', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(true) - this.email = 'someone@example.com' - this.req.body.email = this.email - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(true) + ctx.email = 'someone@example.com' + ctx.req.body.email = ctx.email + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerExternalAuthAdmin('ldap')( - this.req, - this.res, - this.next + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerExternalAuthAdmin('ldap')( + ctx.req, + ctx.res, + ctx.next ) }) - it('should send a 403 response', function () { - this.res.sendStatus.callCount.should.equal(1) - this.res.sendStatus.calledWith(403).should.equal(true) + it('should send a 403 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(1) + ctx.res.sendStatus.calledWith(403).should.equal(true) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when checking admins produces an error', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.rejects(new Error('woops')) - this.email = 'someone@example.com' - this.req.body.email = this.email - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.rejects(new Error('woops')) + ctx.email = 'someone@example.com' + ctx.req.body.email = ctx.email + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon.stub() - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerExternalAuthAdmin('ldap')( - this.req, - this.res, - this.next + ctx.UserRegistrationHandler.promises.registerNewUser = sinon.stub() + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerExternalAuthAdmin('ldap')( + ctx.req, + ctx.res, + ctx.next ) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.lastCall.args[0]).to.be.instanceof(Error) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should not call registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should not call registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 0 ) }) }) describe('when registerNewUser produces an error', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.req.body.email = this.email - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.req.body.email = ctx.email + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon .stub() .rejects(new Error('woops')) - this.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerExternalAuthAdmin('ldap')( - this.req, - this.res, - this.next + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub() }) + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerExternalAuthAdmin('ldap')( + ctx.req, + ctx.res, + ctx.next ) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.lastCall.args[0]).to.be.instanceof(Error) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should have called registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should have called registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 1 ) - this.UserRegistrationHandler.promises.registerNewUser + ctx.UserRegistrationHandler.promises.registerNewUser .calledWith({ - email: this.email, + email: ctx.email, password: 'password_here', - first_name: this.email, + first_name: ctx.email, last_name: '', }) .should.equal(true) }) - it('should not call update', function () { - this.User.updateOne.callCount.should.equal(0) + it('should not call update', function (ctx) { + ctx.User.updateOne.callCount.should.equal(0) }) }) describe('when user update produces an error', function () { - beforeEach(async function () { - this._atLeastOneAdminExists.resolves(false) - this.email = 'someone@example.com' - this.req.body.email = this.email - this.user = { + beforeEach(async function (ctx) { + ctx._atLeastOneAdminExists.resolves(false) + ctx.email = 'someone@example.com' + ctx.req.body.email = ctx.email + ctx.user = { _id: 'abcdef', - email: this.email, + email: ctx.email, } - this.UserRegistrationHandler.promises.registerNewUser = sinon + ctx.UserRegistrationHandler.promises.registerNewUser = sinon .stub() - .resolves(this.user) - this.User.updateOne = sinon.stub().returns({ + .resolves(ctx.user) + ctx.User.updateOne = sinon.stub().returns({ exec: sinon.stub().rejects(new Error('woops')), }) - this.AuthenticationController.setRedirectInSession = sinon.stub() - await this.LaunchpadController.registerExternalAuthAdmin('ldap')( - this.req, - this.res, - this.next + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + await ctx.LaunchpadController.registerExternalAuthAdmin('ldap')( + ctx.req, + ctx.res, + ctx.next ) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.lastCall.args[0]).to.be.instanceof(Error) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error) }) - it('should have checked for existing admins', function () { - this._atLeastOneAdminExists.callCount.should.equal(1) + it('should have checked for existing admins', function (ctx) { + ctx._atLeastOneAdminExists.callCount.should.equal(1) }) - it('should have called registerNewUser', function () { - this.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( + it('should have called registerNewUser', function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUser.callCount.should.equal( 1 ) - this.UserRegistrationHandler.promises.registerNewUser + ctx.UserRegistrationHandler.promises.registerNewUser .calledWith({ - email: this.email, + email: ctx.email, password: 'password_here', - first_name: this.email, + first_name: ctx.email, last_name: '', }) .should.equal(true) diff --git a/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs b/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs index 7c4382a720..9019e525d7 100644 --- a/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs +++ b/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs @@ -1,132 +1,162 @@ +import { vi } from 'vitest' import Path from 'node:path' -import { fileURLToPath } from 'node:url' -import { strict as esmock } from 'esmock' import sinon from 'sinon' -const __dirname = Path.dirname(fileURLToPath(import.meta.url)) - const MODULE_PATH = '../../../app/src/UserActivateController.mjs' -const VIEW_PATH = Path.join(__dirname, '../../../app/views/user/activate') +const VIEW_PATH = Path.join( + import.meta.dirname, + '../../../app/views/user/activate' +) describe('UserActivateController', function () { - beforeEach(async function () { - this.user = { - _id: (this.user_id = 'kwjewkl'), + beforeEach(async function (ctx) { + ctx.user = { + _id: (ctx.user_id = 'kwjewkl'), features: {}, email: 'joe@example.com', } - this.UserGetter = { + ctx.UserGetter = { promises: { getUser: sinon.stub(), }, } - this.UserRegistrationHandler = { promises: {} } - this.ErrorController = { notFound: sinon.stub() } - this.SplitTestHandler = { + ctx.UserRegistrationHandler = { promises: {} } + ctx.ErrorController = { notFound: sinon.stub() } + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({ variant: 'default' }), }, } - this.UserActivateController = await esmock(MODULE_PATH, { - '../../../../../app/src/Features/User/UserGetter.js': this.UserGetter, - '../../../../../app/src/Features/User/UserRegistrationHandler.js': - this.UserRegistrationHandler, - '../../../../../app/src/Features/Errors/ErrorController.js': - this.ErrorController, - '../../../../../app/src/Features/SplitTests/SplitTestHandler': - this.SplitTestHandler, - }) - this.req = { + + vi.doMock('../../../../../app/src/Features/User/UserGetter.js', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../../app/src/Features/User/UserRegistrationHandler.js', + () => ({ + default: ctx.UserRegistrationHandler, + }) + ) + + vi.doMock( + '../../../../../app/src/Features/Errors/ErrorController.js', + () => ({ + default: ctx.ErrorController, + }) + ) + + vi.doMock( + '../../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + ctx.UserActivateController = (await import(MODULE_PATH)).default + ctx.req = { body: {}, query: {}, session: { - user: this.user, + user: ctx.user, }, } - this.res = { + ctx.res = { json: sinon.stub(), } }) describe('activateAccountPage', function () { - beforeEach(function () { - this.UserGetter.promises.getUser = sinon.stub().resolves(this.user) - this.req.query.user_id = this.user_id - this.req.query.token = this.token = 'mock-token-123' + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.user) + ctx.req.query.user_id = ctx.user_id + ctx.req.query.token = ctx.token = 'mock-token-123' }) - it('should 404 without a user_id', async function (done) { - delete this.req.query.user_id - this.ErrorController.notFound = () => done() - this.UserActivateController.activateAccountPage(this.req, this.res) + it('should 404 without a user_id', async function (ctx) { + delete ctx.req.query.user_id + return new Promise(resolve => { + ctx.ErrorController.notFound = () => resolve() + ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) + }) }) - it('should 404 without a token', function (done) { - delete this.req.query.token - this.ErrorController.notFound = () => done() - this.UserActivateController.activateAccountPage(this.req, this.res) + it('should 404 without a token', function (ctx) { + return new Promise(resolve => { + delete ctx.req.query.token + ctx.ErrorController.notFound = resolve + ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) + }) }) - it('should 404 without a valid user_id', function (done) { - this.UserGetter.promises.getUser = sinon.stub().resolves(null) - this.ErrorController.notFound = () => done() - this.UserActivateController.activateAccountPage(this.req, this.res) + it('should 404 without a valid user_id', function (ctx) { + return new Promise(resolve => { + ctx.UserGetter.promises.getUser = sinon.stub().resolves(null) + ctx.ErrorController.notFound = resolve + ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) + }) }) - it('should 403 for complex user_id', function (done) { - this.ErrorController.forbidden = () => done() - this.req.query.user_id = { first_name: 'X' } - this.UserActivateController.activateAccountPage(this.req, this.res) + it('should 403 for complex user_id', function (ctx) { + return new Promise(resolve => { + ctx.ErrorController.forbidden = resolve + ctx.req.query.user_id = { first_name: 'X' } + ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) + }) }) - it('should redirect activated users to login', function (done) { - this.user.loginCount = 1 - this.res.redirect = url => { - sinon.assert.calledWith(this.UserGetter.promises.getUser, this.user_id) - url.should.equal('/login') - return done() - } - this.UserActivateController.activateAccountPage(this.req, this.res) + it('should redirect activated users to login', function (ctx) { + return new Promise(resolve => { + ctx.user.loginCount = 1 + ctx.res.redirect = url => { + sinon.assert.calledWith(ctx.UserGetter.promises.getUser, ctx.user_id) + url.should.equal('/login') + resolve() + } + ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) + }) }) - it('render the activation page if the user has not logged in before', function (done) { - this.user.loginCount = 0 - this.res.render = (page, opts) => { - page.should.equal(VIEW_PATH) - opts.email.should.equal(this.user.email) - opts.token.should.equal(this.token) - return done() - } - this.UserActivateController.activateAccountPage(this.req, this.res) + it('render the activation page if the user has not logged in before', function (ctx) { + return new Promise(resolve => { + ctx.user.loginCount = 0 + ctx.res.render = (page, opts) => { + page.should.equal(VIEW_PATH) + opts.email.should.equal(ctx.user.email) + opts.token.should.equal(ctx.token) + resolve() + } + ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) + }) }) }) describe('register', function () { - beforeEach(async function () { - this.UserRegistrationHandler.promises.registerNewUserAndSendActivationEmail = + beforeEach(async function (ctx) { + ctx.UserRegistrationHandler.promises.registerNewUserAndSendActivationEmail = sinon.stub().resolves({ - user: this.user, - setNewPasswordUrl: (this.url = 'mock/url'), + user: ctx.user, + setNewPasswordUrl: (ctx.url = 'mock/url'), }) - this.req.body.email = this.user.email = this.email = 'email@example.com' - await this.UserActivateController.register(this.req, this.res) + ctx.req.body.email = ctx.user.email = ctx.email = 'email@example.com' + await ctx.UserActivateController.register(ctx.req, ctx.res) }) - it('should register the user and send them an email', function () { + it('should register the user and send them an email', function (ctx) { sinon.assert.calledWith( - this.UserRegistrationHandler.promises + ctx.UserRegistrationHandler.promises .registerNewUserAndSendActivationEmail, - this.email + ctx.email ) }) - it('should return the user and activation url', function () { - this.res.json + it('should return the user and activation url', function (ctx) { + ctx.res.json .calledWith({ - email: this.email, - setNewPasswordUrl: this.url, + email: ctx.email, + setNewPasswordUrl: ctx.url, }) .should.equal(true) }) diff --git a/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs b/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs index cba0e935db..4019f2bce9 100644 --- a/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs +++ b/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import MockResponse from '../helpers/MockResponse.js' const modulePath = new URL( @@ -7,37 +7,52 @@ const modulePath = new URL( ).pathname describe('AnalyticsController', function () { - beforeEach(async function () { - this.SessionManager = { getLoggedInUserId: sinon.stub() } + beforeEach(async function (ctx) { + ctx.SessionManager = { getLoggedInUserId: sinon.stub() } - this.AnalyticsManager = { + ctx.AnalyticsManager = { updateEditingSession: sinon.stub(), recordEventForSession: sinon.stub(), } - this.Features = { + ctx.Features = { hasFeature: sinon.stub().returns(true), } - this.controller = await esmock.strict(modulePath, { - '../../../../app/src/Features/Analytics/AnalyticsManager.js': - this.AnalyticsManager, - '../../../../app/src/Features/Authentication/SessionManager.js': - this.SessionManager, - '../../../../app/src/infrastructure/Features.js': this.Features, - '../../../../app/src/infrastructure/GeoIpLookup.js': (this.GeoIpLookup = { + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager.js', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager.js', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Features.js', () => ({ + default: ctx.Features, + })) + + vi.doMock('../../../../app/src/infrastructure/GeoIpLookup.js', () => ({ + default: (ctx.GeoIpLookup = { promises: { getDetails: sinon.stub().resolves(), }, }), - }) + })) - this.res = new MockResponse() + ctx.controller = (await import(modulePath)).default + + ctx.res = new MockResponse() }) describe('updateEditingSession', function () { - beforeEach(function () { - this.req = { + beforeEach(function (ctx) { + ctx.req = { params: { projectId: 'a project id', }, @@ -48,34 +63,36 @@ describe('AnalyticsController', function () { }, }, } - this.GeoIpLookup.promises.getDetails = sinon + ctx.GeoIpLookup.promises.getDetails = sinon .stub() .resolves({ country_code: 'XY' }) }) - it('delegates to the AnalyticsManager', function (done) { - this.SessionManager.getLoggedInUserId.returns('1234') - this.res.callback = () => { - sinon.assert.calledWith( - this.AnalyticsManager.updateEditingSession, - '1234', - 'a project id', - 'XY', - { editorType: 'abc' } - ) - done() - } - this.controller.updateEditingSession(this.req, this.res) + it('delegates to the AnalyticsManager', function (ctx) { + return new Promise(resolve => { + ctx.SessionManager.getLoggedInUserId.returns('1234') + ctx.res.callback = () => { + sinon.assert.calledWith( + ctx.AnalyticsManager.updateEditingSession, + '1234', + 'a project id', + 'XY', + { editorType: 'abc' } + ) + resolve() + } + ctx.controller.updateEditingSession(ctx.req, ctx.res) + }) }) }) describe('recordEvent', function () { - beforeEach(function () { + beforeEach(function (ctx) { const body = { foo: 'stuff', _csrf: 'atoken123', } - this.req = { + ctx.req = { params: { event: 'i_did_something', }, @@ -84,30 +101,34 @@ describe('AnalyticsController', function () { session: {}, } - this.expectedData = Object.assign({}, body) - delete this.expectedData._csrf + ctx.expectedData = Object.assign({}, body) + delete ctx.expectedData._csrf }) - it('should use the session', function (done) { - this.controller.recordEvent(this.req, this.res) - sinon.assert.calledWith( - this.AnalyticsManager.recordEventForSession, - this.req.session, - this.req.params.event, - this.expectedData - ) - done() + it('should use the session', function (ctx) { + return new Promise(resolve => { + ctx.controller.recordEvent(ctx.req, ctx.res) + sinon.assert.calledWith( + ctx.AnalyticsManager.recordEventForSession, + ctx.req.session, + ctx.req.params.event, + ctx.expectedData + ) + resolve() + }) }) - it('should remove the CSRF token before sending to the manager', function (done) { - this.controller.recordEvent(this.req, this.res) - sinon.assert.calledWith( - this.AnalyticsManager.recordEventForSession, - this.req.session, - this.req.params.event, - this.expectedData - ) - done() + it('should remove the CSRF token before sending to the manager', function (ctx) { + return new Promise(resolve => { + ctx.controller.recordEvent(ctx.req, ctx.res) + sinon.assert.calledWith( + ctx.AnalyticsManager.recordEventForSession, + ctx.req.session, + ctx.req.params.event, + ctx.expectedData + ) + resolve() + }) }) }) }) diff --git a/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs index 461a2a70d1..fff5224b48 100644 --- a/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs +++ b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' @@ -10,83 +10,90 @@ const MODULE_PATH = new URL( ).pathname describe('AnalyticsUTMTrackingMiddleware', function () { - beforeEach(async function () { - this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55' - this.userId = '61795fcb013504bb7b663092' + beforeEach(async function (ctx) { + ctx.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55' + ctx.userId = '61795fcb013504bb7b663092' - this.req = new MockRequest() - this.res = new MockResponse() - this.next = sinon.stub().returns() - this.req.session = { + ctx.req = new MockRequest() + ctx.res = new MockResponse() + ctx.next = sinon.stub().returns() + ctx.req.session = { user: { - _id: this.userId, - analyticsId: this.analyticsId, + _id: ctx.userId, + analyticsId: ctx.analyticsId, }, } - this.AnalyticsUTMTrackingMiddleware = await esmock.strict(MODULE_PATH, { - '../../../../app/src/Features/Analytics/AnalyticsManager.js': - (this.AnalyticsManager = { + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager.js', + () => ({ + default: (ctx.AnalyticsManager = { recordEventForSession: sinon.stub().resolves(), setUserPropertyForSessionInBackground: sinon.stub(), }), - '@overleaf/settings': { + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: { siteUrl: 'https://www.overleaf.com', }, - }) + })) - this.middleware = this.AnalyticsUTMTrackingMiddleware.recordUTMTags() + ctx.AnalyticsUTMTrackingMiddleware = (await import(MODULE_PATH)).default + + ctx.middleware = ctx.AnalyticsUTMTrackingMiddleware.recordUTMTags() }) describe('without UTM tags in query', function () { - beforeEach(function () { - this.req.url = '/project' - this.middleware(this.req, this.res, this.next) + beforeEach(function (ctx) { + ctx.req.url = '/project' + ctx.middleware(ctx.req, ctx.res, ctx.next) }) - it('user is not redirected', function () { - assert.isFalse(this.res.redirected) + it('user is not redirected', function (ctx) { + assert.isFalse(ctx.res.redirected) }) - it('next middleware is executed', function () { - sinon.assert.calledOnce(this.next) + it('next middleware is executed', function (ctx) { + sinon.assert.calledOnce(ctx.next) }) - it('no event or user property is recorded', function () { - sinon.assert.notCalled(this.AnalyticsManager.recordEventForSession) + it('no event or user property is recorded', function (ctx) { + sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForSession) sinon.assert.notCalled( - this.AnalyticsManager.setUserPropertyForSessionInBackground + ctx.AnalyticsManager.setUserPropertyForSessionInBackground ) }) }) describe('with all UTM tags in query', function () { - beforeEach(function () { - this.req.url = + beforeEach(function (ctx) { + ctx.req.url = '/project?utm_source=Organic&utm_medium=Facebook&utm_campaign=Some%20Campaign&utm_content=foo-bar&utm_term=overridden' - this.req.query = { + ctx.req.query = { utm_source: 'Organic', utm_medium: 'Facebook', utm_campaign: 'Some Campaign', utm_content: 'foo-bar', utm_term: 'overridden', } - this.middleware(this.req, this.res, this.next) + ctx.middleware(ctx.req, ctx.res, ctx.next) }) - it('user is redirected', function () { - assert.isTrue(this.res.redirected) - assert.equal('/project', this.res.redirectedTo) + it('user is redirected', function (ctx) { + assert.isTrue(ctx.res.redirected) + assert.equal('/project', ctx.res.redirectedTo) }) - it('next middleware is not executed', function () { - sinon.assert.notCalled(this.next) + it('next middleware is not executed', function (ctx) { + sinon.assert.notCalled(ctx.next) }) - it('page-view event is recorded for session', function () { + it('page-view event is recorded for session', function (ctx) { sinon.assert.calledWith( - this.AnalyticsManager.recordEventForSession, - this.req.session, + ctx.AnalyticsManager.recordEventForSession, + ctx.req.session, 'page-view', { path: '/project', @@ -99,10 +106,10 @@ describe('AnalyticsUTMTrackingMiddleware', function () { ) }) - it('utm-tags user property is set for session', function () { + it('utm-tags user property is set for session', function (ctx) { sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForSessionInBackground, - this.req.session, + ctx.AnalyticsManager.setUserPropertyForSessionInBackground, + ctx.req.session, 'utm-tags', 'Organic;Facebook;Some Campaign;foo-bar' ) @@ -110,30 +117,30 @@ describe('AnalyticsUTMTrackingMiddleware', function () { }) describe('with some UTM tags in query', function () { - beforeEach(function () { - this.req.url = + beforeEach(function (ctx) { + ctx.req.url = '/project?utm_medium=Facebook&utm_campaign=Some%20Campaign&utm_term=foo' - this.req.query = { + ctx.req.query = { utm_medium: 'Facebook', utm_campaign: 'Some Campaign', utm_term: 'foo', } - this.middleware(this.req, this.res, this.next) + ctx.middleware(ctx.req, ctx.res, ctx.next) }) - it('user is redirected', function () { - assert.isTrue(this.res.redirected) - assert.equal('/project', this.res.redirectedTo) + it('user is redirected', function (ctx) { + assert.isTrue(ctx.res.redirected) + assert.equal('/project', ctx.res.redirectedTo) }) - it('next middleware is not executed', function () { - sinon.assert.notCalled(this.next) + it('next middleware is not executed', function (ctx) { + sinon.assert.notCalled(ctx.next) }) - it('page-view event is recorded for session', function () { + it('page-view event is recorded for session', function (ctx) { sinon.assert.calledWith( - this.AnalyticsManager.recordEventForSession, - this.req.session, + ctx.AnalyticsManager.recordEventForSession, + ctx.req.session, 'page-view', { path: '/project', @@ -144,10 +151,10 @@ describe('AnalyticsUTMTrackingMiddleware', function () { ) }) - it('utm-tags user property is set for session', function () { + it('utm-tags user property is set for session', function (ctx) { sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForSessionInBackground, - this.req.session, + ctx.AnalyticsManager.setUserPropertyForSessionInBackground, + ctx.req.session, 'utm-tags', 'N/A;Facebook;Some Campaign;foo' ) @@ -155,30 +162,30 @@ describe('AnalyticsUTMTrackingMiddleware', function () { }) describe('with some UTM tags and additional parameters in query', function () { - beforeEach(function () { - this.req.url = + beforeEach(function (ctx) { + ctx.req.url = '/project?utm_medium=Facebook&utm_campaign=Some%20Campaign&other_param=some-value' - this.req.query = { + ctx.req.query = { utm_medium: 'Facebook', utm_campaign: 'Some Campaign', other_param: 'some-value', } - this.middleware(this.req, this.res, this.next) + ctx.middleware(ctx.req, ctx.res, ctx.next) }) - it('user is redirected', function () { - assert.isTrue(this.res.redirected) - assert.equal('/project?other_param=some-value', this.res.redirectedTo) + it('user is redirected', function (ctx) { + assert.isTrue(ctx.res.redirected) + assert.equal('/project?other_param=some-value', ctx.res.redirectedTo) }) - it('next middleware is not executed', function () { - sinon.assert.notCalled(this.next) + it('next middleware is not executed', function (ctx) { + sinon.assert.notCalled(ctx.next) }) - it('page-view event is recorded for session', function () { + it('page-view event is recorded for session', function (ctx) { sinon.assert.calledWith( - this.AnalyticsManager.recordEventForSession, - this.req.session, + ctx.AnalyticsManager.recordEventForSession, + ctx.req.session, 'page-view', { path: '/project', @@ -188,10 +195,10 @@ describe('AnalyticsUTMTrackingMiddleware', function () { ) }) - it('utm-tags user property is set for session', function () { + it('utm-tags user property is set for session', function (ctx) { sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForSessionInBackground, - this.req.session, + ctx.AnalyticsManager.setUserPropertyForSessionInBackground, + ctx.req.session, 'utm-tags', 'N/A;Facebook;Some Campaign;N/A' ) diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs index 78747b8880..e2160cca08 100644 --- a/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs +++ b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import path from 'node:path' import sinon from 'sinon' import { expect } from 'chai' @@ -13,185 +13,228 @@ const modulePath = path.join( ) describe('BetaProgramController', function () { - beforeEach(async function () { - this.user = { - _id: (this.user_id = 'a_simple_id'), + beforeEach(async function (ctx) { + ctx.user = { + _id: (ctx.user_id = 'a_simple_id'), email: 'user@example.com', features: {}, betaProgram: false, } - this.req = { + ctx.req = { query: {}, session: { - user: this.user, + user: ctx.user, }, } - this.SplitTestSessionHandler = { + ctx.SplitTestSessionHandler = { promises: { sessionMaintenance: sinon.stub(), }, } - this.BetaProgramController = await esmock.strict(modulePath, { - '../../../../app/src/Features/SplitTests/SplitTestSessionHandler': - this.SplitTestSessionHandler, - '../../../../app/src/Features/BetaProgram/BetaProgramHandler': - (this.BetaProgramHandler = { + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestSessionHandler', + () => ({ + default: ctx.SplitTestSessionHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/BetaProgram/BetaProgramHandler', + () => ({ + default: (ctx.BetaProgramHandler = { promises: { optIn: sinon.stub().resolves(), optOut: sinon.stub().resolves(), }, }), - '../../../../app/src/Features/User/UserGetter': (this.UserGetter = { + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = { promises: { getUser: sinon.stub().resolves(), }, }), - '@overleaf/settings': (this.settings = { + })) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { languages: {}, }), - '../../../../app/src/Features/Authentication/AuthenticationController': - (this.AuthenticationController = { - getLoggedInUserId: sinon.stub().returns(this.user._id), + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController', + () => ({ + default: (ctx.AuthenticationController = { + getLoggedInUserId: sinon.stub().returns(ctx.user._id), }), - }) - this.res = new MockResponse() - this.next = sinon.stub() + }) + ) + + ctx.BetaProgramController = (await import(modulePath)).default + ctx.res = new MockResponse() + ctx.next = sinon.stub() }) describe('optIn', function () { - it("should redirect to '/beta/participate'", function (done) { - this.res.callback = () => { - this.res.redirectedTo.should.equal('/beta/participate') - done() - } - this.BetaProgramController.optIn(this.req, this.res, done) + it("should redirect to '/beta/participate'", function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.redirectedTo.should.equal('/beta/participate') + resolve() + } + ctx.BetaProgramController.optIn(ctx.req, ctx.res, resolve) + }) }) - it('should not call next with an error', function () { - this.BetaProgramController.optIn(this.req, this.res, this.next) - this.next.callCount.should.equal(0) + it('should not call next with an error', function (ctx) { + ctx.BetaProgramController.optIn(ctx.req, ctx.res, ctx.next) + ctx.next.callCount.should.equal(0) }) - it('should call BetaProgramHandler.optIn', function () { - this.BetaProgramController.optIn(this.req, this.res, this.next) - this.BetaProgramHandler.promises.optIn.callCount.should.equal(1) + it('should call BetaProgramHandler.optIn', function (ctx) { + ctx.BetaProgramController.optIn(ctx.req, ctx.res, ctx.next) + ctx.BetaProgramHandler.promises.optIn.callCount.should.equal(1) }) - it('should invoke the session maintenance', function (done) { - this.res.callback = () => { - this.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( - this.req - ) - done() - } - this.BetaProgramController.optIn(this.req, this.res, done) + it('should invoke the session maintenance', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( + ctx.req + ) + resolve() + } + ctx.BetaProgramController.optIn(ctx.req, ctx.res, resolve) + }) }) describe('when BetaProgramHandler.opIn produces an error', function () { - beforeEach(function () { - this.BetaProgramHandler.promises.optIn.throws(new Error('woops')) + beforeEach(function (ctx) { + ctx.BetaProgramHandler.promises.optIn.throws(new Error('woops')) }) - it("should not redirect to '/beta/participate'", function () { - this.BetaProgramController.optIn(this.req, this.res, this.next) - this.res.redirect.callCount.should.equal(0) + it("should not redirect to '/beta/participate'", function (ctx) { + ctx.BetaProgramController.optIn(ctx.req, ctx.res, ctx.next) + ctx.res.redirect.callCount.should.equal(0) }) - it('should produce an error', function (done) { - this.BetaProgramController.optIn(this.req, this.res, err => { - expect(err).to.be.instanceof(Error) - done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.BetaProgramController.optIn(ctx.req, ctx.res, err => { + expect(err).to.be.instanceof(Error) + resolve() + }) }) }) }) }) describe('optOut', function () { - it("should redirect to '/beta/participate'", function (done) { - this.res.callback = () => { - expect(this.res.redirectedTo).to.equal('/beta/participate') - done() - } - this.BetaProgramController.optOut(this.req, this.res, done) + it("should redirect to '/beta/participate'", function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + expect(ctx.res.redirectedTo).to.equal('/beta/participate') + resolve() + } + ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve) + }) }) - it('should not call next with an error', function (done) { - this.res.callback = () => { - this.next.callCount.should.equal(0) - done() - } - this.BetaProgramController.optOut(this.req, this.res, done) + it('should not call next with an error', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.next.callCount.should.equal(0) + resolve() + } + ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve) + }) }) - it('should call BetaProgramHandler.optOut', function (done) { - this.res.callback = () => { - this.BetaProgramHandler.promises.optOut.callCount.should.equal(1) - done() - } - this.BetaProgramController.optOut(this.req, this.res, done) + it('should call BetaProgramHandler.optOut', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.BetaProgramHandler.promises.optOut.callCount.should.equal(1) + resolve() + } + ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve) + }) }) - it('should invoke the session maintenance', function (done) { - this.res.callback = () => { - this.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( - this.req, - null - ) - done() - } - this.BetaProgramController.optOut(this.req, this.res, done) + it('should invoke the session maintenance', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( + ctx.req, + null + ) + resolve() + } + ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve) + }) }) describe('when BetaProgramHandler.optOut produces an error', function () { - beforeEach(function () { - this.BetaProgramHandler.promises.optOut.throws(new Error('woops')) + beforeEach(function (ctx) { + ctx.BetaProgramHandler.promises.optOut.throws(new Error('woops')) }) - it("should not redirect to '/beta/participate'", function (done) { - this.BetaProgramController.optOut(this.req, this.res, error => { - expect(error).to.exist - expect(this.res.redirected).to.equal(false) - done() + it("should not redirect to '/beta/participate'", function (ctx) { + return new Promise(resolve => { + ctx.BetaProgramController.optOut(ctx.req, ctx.res, error => { + expect(error).to.exist + expect(ctx.res.redirected).to.equal(false) + resolve() + }) }) }) - it('should produce an error', function (done) { - this.BetaProgramController.optOut(this.req, this.res, error => { - expect(error).to.exist - done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.BetaProgramController.optOut(ctx.req, ctx.res, error => { + expect(error).to.exist + resolve() + }) }) }) }) }) describe('optInPage', function () { - beforeEach(function () { - this.UserGetter.promises.getUser.resolves(this.user) + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUser.resolves(ctx.user) }) - it('should render the opt-in page', function (done) { - this.res.callback = () => { - expect(this.res.renderedTemplate).to.equal('beta_program/opt_in') - done() - } - this.BetaProgramController.optInPage(this.req, this.res, done) + it('should render the opt-in page', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + expect(ctx.res.renderedTemplate).to.equal('beta_program/opt_in') + resolve() + } + ctx.BetaProgramController.optInPage(ctx.req, ctx.res, resolve) + }) }) describe('when UserGetter.getUser produces an error', function () { - beforeEach(function () { - this.UserGetter.promises.getUser.throws(new Error('woops')) + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUser.throws(new Error('woops')) }) - it('should not render the opt-in page', function () { - this.BetaProgramController.optInPage(this.req, this.res, this.next) - this.res.render.callCount.should.equal(0) + it('should not render the opt-in page', function (ctx) { + ctx.BetaProgramController.optInPage(ctx.req, ctx.res, ctx.next) + ctx.res.render.callCount.should.equal(0) }) - it('should produce an error', function (done) { - this.BetaProgramController.optInPage(this.req, this.res, error => { - expect(error).to.exist - expect(error).to.be.instanceof(Error) - done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.BetaProgramController.optInPage(ctx.req, ctx.res, error => { + expect(error).to.exist + expect(error).to.be.instanceof(Error) + resolve() + }) }) }) }) diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs index 2b72271fd5..14438a8ed7 100644 --- a/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs +++ b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import path from 'node:path' import sinon from 'sinon' @@ -13,128 +13,155 @@ const modulePath = path.join( ) describe('BetaProgramHandler', function () { - beforeEach(async function () { - this.user_id = 'some_id' - this.user = { - _id: this.user_id, + beforeEach(async function (ctx) { + ctx.user_id = 'some_id' + ctx.user = { + _id: ctx.user_id, email: 'user@example.com', features: {}, betaProgram: false, save: sinon.stub().callsArgWith(0, null), } - this.handler = await esmock.strict(modulePath, { - '@overleaf/metrics': { + + vi.doMock('@overleaf/metrics', () => ({ + default: { inc: sinon.stub(), }, - '../../../../app/src/Features/User/UserUpdater': (this.UserUpdater = { + })) + + vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({ + default: (ctx.UserUpdater = { promises: { updateUser: sinon.stub().resolves(), }, }), - '../../../../app/src/Features/Analytics/AnalyticsManager': - (this.AnalyticsManager = { + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: (ctx.AnalyticsManager = { setUserPropertyForUserInBackground: sinon.stub(), }), - }) + }) + ) + + ctx.handler = (await import(modulePath)).default }) describe('optIn', function () { - beforeEach(function () { - this.user.betaProgram = false - this.call = callback => { - this.handler.optIn(this.user_id, callback) + beforeEach(function (ctx) { + ctx.user.betaProgram = false + ctx.call = callback => { + ctx.handler.optIn(ctx.user_id, callback) } }) - it('should call userUpdater', function (done) { - this.call(err => { - expect(err).to.not.exist - this.UserUpdater.promises.updateUser.callCount.should.equal(1) - done() + it('should call userUpdater', function (ctx) { + return new Promise(resolve => { + ctx.call(err => { + expect(err).to.not.exist + ctx.UserUpdater.promises.updateUser.callCount.should.equal(1) + resolve() + }) }) }) - it('should set beta-program user property to true', function (done) { - this.call(err => { - expect(err).to.not.exist - sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.user_id, - 'beta-program', - true - ) - done() + it('should set beta-program user property to true', function (ctx) { + return new Promise(resolve => { + ctx.call(err => { + expect(err).to.not.exist + sinon.assert.calledWith( + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.user_id, + 'beta-program', + true + ) + resolve() + }) }) }) - it('should not produce an error', function (done) { - this.call(err => { - expect(err).to.not.exist - done() + it('should not produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call(err => { + expect(err).to.not.exist + resolve() + }) }) }) describe('when userUpdater produces an error', function () { - beforeEach(function () { - this.UserUpdater.promises.updateUser.rejects() + beforeEach(function (ctx) { + ctx.UserUpdater.promises.updateUser.rejects() }) - it('should produce an error', function (done) { - this.call(err => { - expect(err).to.exist - expect(err).to.be.instanceof(Error) - done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call(err => { + expect(err).to.exist + expect(err).to.be.instanceof(Error) + resolve() + }) }) }) }) }) describe('optOut', function () { - beforeEach(function () { - this.user.betaProgram = true - this.call = callback => { - this.handler.optOut(this.user_id, callback) + beforeEach(function (ctx) { + ctx.user.betaProgram = true + ctx.call = callback => { + ctx.handler.optOut(ctx.user_id, callback) } }) - it('should call userUpdater', function (done) { - this.call(err => { - expect(err).to.not.exist - this.UserUpdater.promises.updateUser.callCount.should.equal(1) - done() + it('should call userUpdater', function (ctx) { + return new Promise(resolve => { + ctx.call(err => { + expect(err).to.not.exist + ctx.UserUpdater.promises.updateUser.callCount.should.equal(1) + resolve() + }) }) }) - it('should set beta-program user property to false', function (done) { - this.call(err => { - expect(err).to.not.exist - sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.user_id, - 'beta-program', - false - ) - done() + it('should set beta-program user property to false', function (ctx) { + return new Promise(resolve => { + ctx.call(err => { + expect(err).to.not.exist + sinon.assert.calledWith( + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.user_id, + 'beta-program', + false + ) + resolve() + }) }) }) - it('should not produce an error', function (done) { - this.call(err => { - expect(err).to.not.exist - done() + it('should not produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call(err => { + expect(err).to.not.exist + resolve() + }) }) }) describe('when userUpdater produces an error', function () { - beforeEach(function () { - this.UserUpdater.promises.updateUser.rejects() + beforeEach(function (ctx) { + ctx.UserUpdater.promises.updateUser.rejects() }) - it('should produce an error', function (done) { - this.call(err => { - expect(err).to.exist - expect(err).to.be.instanceof(Error) - done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call(err => { + expect(err).to.exist + expect(err).to.be.instanceof(Error) + resolve() + }) }) }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs index 27460da148..9bb9c4b3c0 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs @@ -1,6 +1,6 @@ +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' -import esmock from 'esmock' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockRequest from '../helpers/MockRequest.js' @@ -11,425 +11,510 @@ const ObjectId = mongodb.ObjectId const MODULE_PATH = '../../../../app/src/Features/Collaborators/CollaboratorsController.mjs' +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + describe('CollaboratorsController', function () { - beforeEach(async function () { - this.res = new MockResponse() - this.req = new MockRequest() + beforeEach(async function (ctx) { + ctx.res = new MockResponse() + ctx.req = new MockRequest() - this.user = { _id: new ObjectId() } - this.projectId = new ObjectId() - this.callback = sinon.stub() + ctx.user = { _id: new ObjectId() } + ctx.projectId = new ObjectId() + ctx.callback = sinon.stub() - this.CollaboratorsHandler = { + ctx.CollaboratorsHandler = { promises: { removeUserFromProject: sinon.stub().resolves(), setCollaboratorPrivilegeLevel: sinon.stub().resolves(), }, createTokenHashPrefix: sinon.stub().returns('abc123'), } - this.CollaboratorsGetter = { + ctx.CollaboratorsGetter = { promises: { getAllInvitedMembers: sinon.stub(), }, } - this.EditorRealTimeController = { + ctx.EditorRealTimeController = { emitToRoom: sinon.stub(), } - this.HttpErrorHandler = { + ctx.HttpErrorHandler = { forbidden: sinon.stub(), notFound: sinon.stub(), } - this.TagsHandler = { + ctx.TagsHandler = { promises: { removeProjectFromAllTags: sinon.stub().resolves(), }, } - this.SessionManager = { - getSessionUser: sinon.stub().returns(this.user), - getLoggedInUserId: sinon.stub().returns(this.user._id), + ctx.SessionManager = { + getSessionUser: sinon.stub().returns(ctx.user), + getLoggedInUserId: sinon.stub().returns(ctx.user._id), } - this.OwnershipTransferHandler = { + ctx.OwnershipTransferHandler = { promises: { transferOwnership: sinon.stub().resolves(), }, } - this.TokenAccessHandler = { + ctx.TokenAccessHandler = { getRequestToken: sinon.stub().returns('access-token'), } - this.ProjectAuditLogHandler = { + ctx.ProjectAuditLogHandler = { addEntryInBackground: sinon.stub(), } - this.ProjectGetter = { + ctx.ProjectGetter = { promises: { - getProject: sinon.stub().resolves({ owner_ref: this.user._id }), + getProject: sinon.stub().resolves({ owner_ref: ctx.user._id }), }, } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), }, } - this.LimitationsManager = { + ctx.LimitationsManager = { promises: { canAddXEditCollaborators: sinon.stub().resolves(), canChangeCollaboratorPrivilegeLevel: sinon.stub().resolves(true), }, } - this.CollaboratorsController = await esmock.strict(MODULE_PATH, { - 'mongodb-legacy': { ObjectId }, - '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js': - this.CollaboratorsHandler, - '../../../../app/src/Features/Collaborators/CollaboratorsGetter.js': - this.CollaboratorsGetter, - '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.js': - this.OwnershipTransferHandler, - '../../../../app/src/Features/Editor/EditorRealTimeController': - this.EditorRealTimeController, - '../../../../app/src/Features/Errors/HttpErrorHandler.js': - this.HttpErrorHandler, - '../../../../app/src/Features/Tags/TagsHandler.js': this.TagsHandler, - '../../../../app/src/Features/Authentication/SessionManager.js': - this.SessionManager, - '../../../../app/src/Features/TokenAccess/TokenAccessHandler.js': - this.TokenAccessHandler, - '../../../../app/src/Features/Project/ProjectAuditLogHandler.js': - this.ProjectAuditLogHandler, - '../../../../app/src/Features/Project/ProjectGetter.js': - this.ProjectGetter, - '../../../../app/src/Features/SplitTests/SplitTestHandler.js': - this.SplitTestHandler, - '../../../../app/src/Features/Subscription/LimitationsManager.js': - this.LimitationsManager, - }) + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js', + () => ({ + default: ctx.CollaboratorsHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsGetter.js', + () => ({ + default: ctx.CollaboratorsGetter, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.js', + () => ({ + default: ctx.OwnershipTransferHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Editor/EditorRealTimeController', + () => ({ + default: ctx.EditorRealTimeController, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Errors/HttpErrorHandler.js', + () => ({ + default: ctx.HttpErrorHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Tags/TagsHandler.js', () => ({ + default: ctx.TagsHandler, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager.js', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/TokenAccess/TokenAccessHandler.js', + () => ({ + default: ctx.TokenAccessHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectAuditLogHandler.js', + () => ({ + default: ctx.ProjectAuditLogHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler.js', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/LimitationsManager.js', + () => ({ + default: ctx.LimitationsManager, + }) + ) + + ctx.CollaboratorsController = (await import(MODULE_PATH)).default }) describe('removeUserFromProject', function () { - beforeEach(function (done) { - this.req.params = { - Project_id: this.projectId, - user_id: this.user._id, - } - this.res.sendStatus = sinon.spy(() => { - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.req.params = { + Project_id: ctx.projectId, + user_id: ctx.user._id, + } + ctx.res.sendStatus = sinon.spy(() => { + resolve() + }) + ctx.CollaboratorsController.removeUserFromProject(ctx.req, ctx.res) }) - this.CollaboratorsController.removeUserFromProject(this.req, this.res) }) - it('should from the user from the project', function () { + it('should from the user from the project', function (ctx) { expect( - this.CollaboratorsHandler.promises.removeUserFromProject - ).to.have.been.calledWith(this.projectId, this.user._id) + ctx.CollaboratorsHandler.promises.removeUserFromProject + ).to.have.been.calledWith(ctx.projectId, ctx.user._id) }) - it('should emit a userRemovedFromProject event to the proejct', function () { - expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith( - this.projectId, + it('should emit a userRemovedFromProject event to the proejct', function (ctx) { + expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( + ctx.projectId, 'userRemovedFromProject', - this.user._id + ctx.user._id ) }) - it('should send the back a success response', function () { - this.res.sendStatus.calledWith(204).should.equal(true) + it('should send the back a success response', function (ctx) { + ctx.res.sendStatus.calledWith(204).should.equal(true) }) - it('should have called emitToRoom', function () { - expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith( - this.projectId, + it('should have called emitToRoom', function (ctx) { + expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( + ctx.projectId, 'project:membership:changed' ) }) - it('should write a project audit log', function () { - this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( - this.projectId, + it('should write a project audit log', function (ctx) { + ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( + ctx.projectId, 'remove-collaborator', - this.user._id, - this.req.ip, - { userId: this.user._id } + ctx.user._id, + ctx.req.ip, + { userId: ctx.user._id } ) }) }) describe('removeSelfFromProject', function () { - beforeEach(function (done) { - this.req.params = { Project_id: this.projectId } - this.res.sendStatus = sinon.spy(() => { - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.req.params = { Project_id: ctx.projectId } + ctx.res.sendStatus = sinon.spy(() => { + resolve() + }) + ctx.CollaboratorsController.removeSelfFromProject(ctx.req, ctx.res) }) - this.CollaboratorsController.removeSelfFromProject(this.req, this.res) }) - it('should remove the logged in user from the project', function () { + it('should remove the logged in user from the project', function (ctx) { expect( - this.CollaboratorsHandler.promises.removeUserFromProject - ).to.have.been.calledWith(this.projectId, this.user._id) + ctx.CollaboratorsHandler.promises.removeUserFromProject + ).to.have.been.calledWith(ctx.projectId, ctx.user._id) }) - it('should emit a userRemovedFromProject event to the proejct', function () { - expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith( - this.projectId, + it('should emit a userRemovedFromProject event to the proejct', function (ctx) { + expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( + ctx.projectId, 'userRemovedFromProject', - this.user._id + ctx.user._id ) }) - it('should remove the project from all tags', function () { + it('should remove the project from all tags', function (ctx) { expect( - this.TagsHandler.promises.removeProjectFromAllTags - ).to.have.been.calledWith(this.user._id, this.projectId) + ctx.TagsHandler.promises.removeProjectFromAllTags + ).to.have.been.calledWith(ctx.user._id, ctx.projectId) }) - it('should return a success code', function () { - this.res.sendStatus.calledWith(204).should.equal(true) + it('should return a success code', function (ctx) { + ctx.res.sendStatus.calledWith(204).should.equal(true) }) - it('should write a project audit log', function () { - this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( - this.projectId, + it('should write a project audit log', function (ctx) { + ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( + ctx.projectId, 'leave-project', - this.user._id, - this.req.ip + ctx.user._id, + ctx.req.ip ) }) }) describe('getAllMembers', function () { - beforeEach(function (done) { - this.req.params = { Project_id: this.projectId } - this.res.json = sinon.spy(() => { - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.req.params = { Project_id: ctx.projectId } + ctx.res.json = sinon.spy(() => { + resolve() + }) + ctx.next = sinon.stub() + ctx.members = [{ a: 1 }] + ctx.CollaboratorsGetter.promises.getAllInvitedMembers.resolves( + ctx.members + ) + ctx.CollaboratorsController.getAllMembers(ctx.req, ctx.res, ctx.next) }) - this.next = sinon.stub() - this.members = [{ a: 1 }] - this.CollaboratorsGetter.promises.getAllInvitedMembers.resolves( - this.members - ) - this.CollaboratorsController.getAllMembers(this.req, this.res, this.next) }) - it('should not produce an error', function () { - this.next.callCount.should.equal(0) + it('should not produce an error', function (ctx) { + ctx.next.callCount.should.equal(0) }) - it('should produce a json response', function () { - this.res.json.callCount.should.equal(1) - this.res.json.calledWith({ members: this.members }).should.equal(true) + it('should produce a json response', function (ctx) { + ctx.res.json.callCount.should.equal(1) + ctx.res.json.calledWith({ members: ctx.members }).should.equal(true) }) - it('should call CollaboratorsGetter.getAllInvitedMembers', function () { - expect(this.CollaboratorsGetter.promises.getAllInvitedMembers).to.have - .been.calledOnce + it('should call CollaboratorsGetter.getAllInvitedMembers', function (ctx) { + expect(ctx.CollaboratorsGetter.promises.getAllInvitedMembers).to.have.been + .calledOnce }) describe('when CollaboratorsGetter.getAllInvitedMembers produces an error', function () { - beforeEach(function (done) { - this.res.json = sinon.stub() - this.next = sinon.spy(() => { - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.json = sinon.stub() + ctx.next = sinon.spy(() => { + resolve() + }) + ctx.CollaboratorsGetter.promises.getAllInvitedMembers.rejects( + new Error('woops') + ) + ctx.CollaboratorsController.getAllMembers(ctx.req, ctx.res, ctx.next) }) - this.CollaboratorsGetter.promises.getAllInvitedMembers.rejects( - new Error('woops') - ) - this.CollaboratorsController.getAllMembers( - this.req, - this.res, - this.next - ) }) - it('should produce an error', function () { - expect(this.next).to.have.been.calledOnce - expect(this.next).to.have.been.calledWithMatch( + it('should produce an error', function (ctx) { + expect(ctx.next).to.have.been.calledOnce + expect(ctx.next).to.have.been.calledWithMatch( sinon.match.instanceOf(Error) ) }) - it('should not produce a json response', function () { - this.res.json.callCount.should.equal(0) + it('should not produce a json response', function (ctx) { + ctx.res.json.callCount.should.equal(0) }) }) }) describe('setCollaboratorInfo', function () { - beforeEach(function () { - this.req.params = { - Project_id: this.projectId, - user_id: this.user._id, + beforeEach(function (ctx) { + ctx.req.params = { + Project_id: ctx.projectId, + user_id: ctx.user._id, } - this.req.body = { privilegeLevel: 'readOnly' } + ctx.req.body = { privilegeLevel: 'readOnly' } }) - it('should set the collaborator privilege level', function (done) { - this.res.sendStatus = status => { - expect(status).to.equal(204) - expect( - this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel - ).to.have.been.calledWith(this.projectId, this.user._id, 'readOnly') - done() - } - this.CollaboratorsController.setCollaboratorInfo(this.req, this.res) - }) - - it('should return a 404 when the project or collaborator is not found', function (done) { - this.HttpErrorHandler.notFound = sinon.spy((req, res) => { - expect(req).to.equal(this.req) - expect(res).to.equal(this.res) - done() + it('should set the collaborator privilege level', function (ctx) { + return new Promise(resolve => { + ctx.res.sendStatus = status => { + expect(status).to.equal(204) + expect( + ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel + ).to.have.been.calledWith(ctx.projectId, ctx.user._id, 'readOnly') + resolve() + } + ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res) }) - - this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects( - new Errors.NotFoundError() - ) - this.CollaboratorsController.setCollaboratorInfo(this.req, this.res) }) - it('should pass the error to the next handler when setting the privilege level fails', function (done) { - this.next = sinon.spy(err => { - expect(err).instanceOf(Error) - done() - }) + it('should return a 404 when the project or collaborator is not found', function (ctx) { + return new Promise(resolve => { + ctx.HttpErrorHandler.notFound = sinon.spy((req, res) => { + expect(req).to.equal(ctx.req) + expect(res).to.equal(ctx.res) + resolve() + }) - this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects( - new Error() - ) - this.CollaboratorsController.setCollaboratorInfo( - this.req, - this.res, - this.next - ) + ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects( + new Errors.NotFoundError() + ) + ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res) + }) + }) + + it('should pass the error to the next handler when setting the privilege level fails', function (ctx) { + return new Promise(resolve => { + ctx.next = sinon.spy(err => { + expect(err).instanceOf(Error) + resolve() + }) + + ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects( + new Error() + ) + ctx.CollaboratorsController.setCollaboratorInfo( + ctx.req, + ctx.res, + ctx.next + ) + }) }) describe('when setting privilege level to readAndWrite', function () { - beforeEach(function () { - this.req.body = { privilegeLevel: 'readAndWrite' } + beforeEach(function (ctx) { + ctx.req.body = { privilegeLevel: 'readAndWrite' } }) describe('when owner can add new edit collaborators', function () { - it('should set privilege level after checking collaborators can be added', function (done) { - this.res.sendStatus = status => { - expect(status).to.equal(204) - expect( - this.LimitationsManager.promises - .canChangeCollaboratorPrivilegeLevel - ).to.have.been.calledWith( - this.projectId, - this.user._id, - 'readAndWrite' - ) - done() - } - this.CollaboratorsController.setCollaboratorInfo(this.req, this.res) + it('should set privilege level after checking collaborators can be added', function (ctx) { + return new Promise(resolve => { + ctx.res.sendStatus = status => { + expect(status).to.equal(204) + expect( + ctx.LimitationsManager.promises + .canChangeCollaboratorPrivilegeLevel + ).to.have.been.calledWith( + ctx.projectId, + ctx.user._id, + 'readAndWrite' + ) + resolve() + } + ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res) + }) }) }) describe('when owner cannot add edit collaborators', function () { - beforeEach(function () { - this.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves( + beforeEach(function (ctx) { + ctx.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves( false ) }) - it('should return a 403 if trying to set a new edit collaborator', function (done) { - this.HttpErrorHandler.forbidden = sinon.spy((req, res) => { - expect(req).to.equal(this.req) - expect(res).to.equal(this.res) - expect( - this.LimitationsManager.promises - .canChangeCollaboratorPrivilegeLevel - ).to.have.been.calledWith( - this.projectId, - this.user._id, - 'readAndWrite' - ) - expect( - this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel - ).to.not.have.been.called - done() + it('should return a 403 if trying to set a new edit collaborator', function (ctx) { + return new Promise(resolve => { + ctx.HttpErrorHandler.forbidden = sinon.spy((req, res) => { + expect(req).to.equal(ctx.req) + expect(res).to.equal(ctx.res) + expect( + ctx.LimitationsManager.promises + .canChangeCollaboratorPrivilegeLevel + ).to.have.been.calledWith( + ctx.projectId, + ctx.user._id, + 'readAndWrite' + ) + expect( + ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel + ).to.not.have.been.called + resolve() + }) + ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res) }) - this.CollaboratorsController.setCollaboratorInfo(this.req, this.res) }) }) }) describe('when setting privilege level to readOnly', function () { - beforeEach(function () { - this.req.body = { privilegeLevel: 'readOnly' } + beforeEach(function (ctx) { + ctx.req.body = { privilegeLevel: 'readOnly' } }) describe('when owner cannot add edit collaborators', function () { - beforeEach(function () { - this.LimitationsManager.promises.canAddXEditCollaborators.resolves( + beforeEach(function (ctx) { + ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves( false ) }) - it('should always allow setting a collaborator to viewer even if user cant add edit collaborators', function (done) { - this.res.sendStatus = status => { - expect(status).to.equal(204) - expect(this.LimitationsManager.promises.canAddXEditCollaborators).to - .not.have.been.called - expect( - this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel - ).to.have.been.calledWith(this.projectId, this.user._id, 'readOnly') - done() - } - this.CollaboratorsController.setCollaboratorInfo(this.req, this.res) + it('should always allow setting a collaborator to viewer even if user cant add edit collaborators', function (ctx) { + return new Promise(resolve => { + ctx.res.sendStatus = status => { + expect(status).to.equal(204) + expect(ctx.LimitationsManager.promises.canAddXEditCollaborators) + .to.not.have.been.called + expect( + ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel + ).to.have.been.calledWith(ctx.projectId, ctx.user._id, 'readOnly') + resolve() + } + ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res) + }) }) }) }) }) describe('transferOwnership', function () { - beforeEach(function () { - this.req.body = { user_id: this.user._id.toString() } + beforeEach(function (ctx) { + ctx.req.body = { user_id: ctx.user._id.toString() } }) - it('returns 204 on success', function (done) { - this.res.sendStatus = status => { - expect(status).to.equal(204) - done() - } - this.CollaboratorsController.transferOwnership(this.req, this.res) - }) - - it('returns 404 if the project does not exist', function (done) { - this.HttpErrorHandler.notFound = sinon.spy((req, res, message) => { - expect(req).to.equal(this.req) - expect(res).to.equal(this.res) - expect(message).to.match(/project not found/) - done() + it('returns 204 on success', function (ctx) { + return new Promise(resolve => { + ctx.res.sendStatus = status => { + expect(status).to.equal(204) + resolve() + } + ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res) }) - this.OwnershipTransferHandler.promises.transferOwnership.rejects( - new Errors.ProjectNotFoundError() - ) - this.CollaboratorsController.transferOwnership(this.req, this.res) }) - it('returns 404 if the user does not exist', function (done) { - this.HttpErrorHandler.notFound = sinon.spy((req, res, message) => { - expect(req).to.equal(this.req) - expect(res).to.equal(this.res) - expect(message).to.match(/user not found/) - done() + it('returns 404 if the project does not exist', function (ctx) { + return new Promise(resolve => { + ctx.HttpErrorHandler.notFound = sinon.spy((req, res, message) => { + expect(req).to.equal(ctx.req) + expect(res).to.equal(ctx.res) + expect(message).to.match(/project not found/) + resolve() + }) + ctx.OwnershipTransferHandler.promises.transferOwnership.rejects( + new Errors.ProjectNotFoundError() + ) + ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res) }) - this.OwnershipTransferHandler.promises.transferOwnership.rejects( - new Errors.UserNotFoundError() - ) - this.CollaboratorsController.transferOwnership(this.req, this.res) }) - it('invokes HTTP forbidden error handler if the user is not a collaborator', function (done) { - this.HttpErrorHandler.forbidden = sinon.spy(() => done()) - this.OwnershipTransferHandler.promises.transferOwnership.rejects( - new Errors.UserNotCollaboratorError() - ) - this.CollaboratorsController.transferOwnership(this.req, this.res) + it('returns 404 if the user does not exist', function (ctx) { + return new Promise(resolve => { + ctx.HttpErrorHandler.notFound = sinon.spy((req, res, message) => { + expect(req).to.equal(ctx.req) + expect(res).to.equal(ctx.res) + expect(message).to.match(/user not found/) + resolve() + }) + ctx.OwnershipTransferHandler.promises.transferOwnership.rejects( + new Errors.UserNotFoundError() + ) + ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res) + }) + }) + + it('invokes HTTP forbidden error handler if the user is not a collaborator', function (ctx) { + return new Promise(resolve => { + ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve()) + ctx.OwnershipTransferHandler.promises.transferOwnership.rejects( + new Errors.UserNotCollaboratorError() + ) + ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res) + }) }) }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs index 3e7d4c3daa..d948e69ed4 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs @@ -1,6 +1,6 @@ +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' -import esmock from 'esmock' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import mongodb from 'mongodb-legacy' @@ -12,419 +12,488 @@ const ObjectId = mongodb.ObjectId const MODULE_PATH = '../../../../app/src/Features/Collaborators/CollaboratorsInviteController.mjs' +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + describe('CollaboratorsInviteController', function () { - beforeEach(async function () { - this.projectId = 'project-id-123' - this.token = 'some-opaque-token' - this.tokenHmac = 'some-hmac-token' - this.targetEmail = 'user@example.com' - this.privileges = 'readAndWrite' - this.projectOwner = { + beforeEach(async function (ctx) { + ctx.projectId = 'project-id-123' + ctx.token = 'some-opaque-token' + ctx.tokenHmac = 'some-hmac-token' + ctx.targetEmail = 'user@example.com' + ctx.privileges = 'readAndWrite' + ctx.projectOwner = { _id: 'project-owner-id', email: 'project-owner@example.com', } - this.currentUser = { + ctx.currentUser = { _id: 'current-user-id', email: 'current-user@example.com', } - this.invite = { + ctx.invite = { _id: new ObjectId(), - token: this.token, - tokenHmac: this.tokenHmac, - sendingUserId: this.currentUser._id, - projectId: this.projectId, - email: this.targetEmail, - privileges: this.privileges, + token: ctx.token, + tokenHmac: ctx.tokenHmac, + sendingUserId: ctx.currentUser._id, + projectId: ctx.projectId, + email: ctx.targetEmail, + privileges: ctx.privileges, createdAt: new Date(), } - this.inviteReducedData = _.pick(this.invite, ['_id', 'email', 'privileges']) - this.project = { - _id: this.projectId, - owner_ref: this.projectOwner._id, + ctx.inviteReducedData = _.pick(ctx.invite, ['_id', 'email', 'privileges']) + ctx.project = { + _id: ctx.projectId, + owner_ref: ctx.projectOwner._id, } - this.SessionManager = { - getSessionUser: sinon.stub().returns(this.currentUser), + ctx.SessionManager = { + getSessionUser: sinon.stub().returns(ctx.currentUser), } - this.AnalyticsManger = { recordEventForUserInBackground: sinon.stub() } + ctx.AnalyticsManger = { recordEventForUserInBackground: sinon.stub() } - this.rateLimiter = { + ctx.rateLimiter = { consume: sinon.stub().resolves(), } - this.RateLimiter = { - RateLimiter: sinon.stub().returns(this.rateLimiter), + ctx.RateLimiter = { + RateLimiter: sinon.stub().returns(ctx.rateLimiter), } - this.LimitationsManager = { + ctx.LimitationsManager = { promises: { allowedNumberOfCollaboratorsForUser: sinon.stub(), canAddXEditCollaborators: sinon.stub().resolves(true), }, } - this.UserGetter = { + ctx.UserGetter = { promises: { getUserByAnyEmail: sinon.stub(), getUser: sinon.stub(), }, } - this.ProjectGetter = { + ctx.ProjectGetter = { promises: { getProject: sinon.stub(), }, } - this.CollaboratorsGetter = { + ctx.CollaboratorsGetter = { promises: { isUserInvitedMemberOfProject: sinon.stub(), }, } - this.CollaboratorsInviteHandler = { + ctx.CollaboratorsInviteHandler = { promises: { - inviteToProject: sinon.stub().resolves(this.inviteReducedData), - generateNewInvite: sinon.stub().resolves(this.invite), - revokeInvite: sinon.stub().resolves(this.invite), + inviteToProject: sinon.stub().resolves(ctx.inviteReducedData), + generateNewInvite: sinon.stub().resolves(ctx.invite), + revokeInvite: sinon.stub().resolves(ctx.invite), acceptInvite: sinon.stub(), }, } - this.CollaboratorsInviteGetter = { + ctx.CollaboratorsInviteGetter = { promises: { getAllInvites: sinon.stub(), - getInviteByToken: sinon.stub().resolves(this.invite), + getInviteByToken: sinon.stub().resolves(ctx.invite), }, } - this.EditorRealTimeController = { + ctx.EditorRealTimeController = { emitToRoom: sinon.stub(), } - this.settings = {} + ctx.settings = {} - this.ProjectAuditLogHandler = { + ctx.ProjectAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, addEntryInBackground: sinon.stub(), } - this.AuthenticationController = { + ctx.AuthenticationController = { setRedirectInSession: sinon.stub(), } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({ variant: 'default' }), getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), }, } - this.CollaboratorsInviteController = await esmock.strict(MODULE_PATH, { - '../../../../app/src/Features/Project/ProjectGetter.js': - this.ProjectGetter, - '../../../../app/src/Features/Project/ProjectAuditLogHandler.js': - this.ProjectAuditLogHandler, - '../../../../app/src/Features/Subscription/LimitationsManager.js': - this.LimitationsManager, - '../../../../app/src/Features/User/UserGetter.js': this.UserGetter, - '../../../../app/src/Features/Collaborators/CollaboratorsGetter.js': - this.CollaboratorsGetter, - '../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs': - this.CollaboratorsInviteHandler, - '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.js': - this.CollaboratorsInviteGetter, - '../../../../app/src/Features/Editor/EditorRealTimeController.js': - this.EditorRealTimeController, - '../../../../app/src/Features/Analytics/AnalyticsManager.js': - this.AnalyticsManger, - '../../../../app/src/Features/Authentication/SessionManager.js': - this.SessionManager, - '@overleaf/settings': this.settings, - '../../../../app/src/infrastructure/RateLimiter': this.RateLimiter, - '../../../../app/src/Features/Authentication/AuthenticationController': - this.AuthenticationController, - '../../../../app/src/Features/SplitTests/SplitTestHandler': - this.SplitTestHandler, - }) + vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({ + default: ctx.ProjectGetter, + })) - this.res = new MockResponse() - this.req = new MockRequest() - this.next = sinon.stub() + vi.doMock( + '../../../../app/src/Features/Project/ProjectAuditLogHandler.js', + () => ({ + default: ctx.ProjectAuditLogHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/LimitationsManager.js', + () => ({ + default: ctx.LimitationsManager, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsGetter.js', + () => ({ + default: ctx.CollaboratorsGetter, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs', + () => ({ + default: ctx.CollaboratorsInviteHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.js', + () => ({ + default: ctx.CollaboratorsInviteGetter, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Editor/EditorRealTimeController.js', + () => ({ + default: ctx.EditorRealTimeController, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager.js', + () => ({ + default: ctx.AnalyticsManger, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager.js', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock( + '../../../../app/src/infrastructure/RateLimiter', + () => ctx.RateLimiter + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController', + () => ({ + default: ctx.AuthenticationController, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + ctx.CollaboratorsInviteController = (await import(MODULE_PATH)).default + + ctx.res = new MockResponse() + ctx.req = new MockRequest() + ctx.next = sinon.stub() }) describe('getAllInvites', function () { - beforeEach(function () { - this.fakeInvites = [ + beforeEach(function (ctx) { + ctx.fakeInvites = [ { _id: new ObjectId(), one: 1 }, { _id: new ObjectId(), two: 2 }, ] - this.req.params = { Project_id: this.projectId } + ctx.req.params = { Project_id: ctx.projectId } }) describe('when all goes well', function () { - beforeEach(function (done) { - this.CollaboratorsInviteGetter.promises.getAllInvites.resolves( - this.fakeInvites - ) - this.res.callback = () => done() - this.CollaboratorsInviteController.getAllInvites( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getAllInvites.resolves( + ctx.fakeInvites + ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.getAllInvites( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should not produce an error', function () { - this.next.callCount.should.equal(0) + it('should not produce an error', function (ctx) { + ctx.next.callCount.should.equal(0) }) - it('should produce a list of invite objects', function () { - this.res.json.callCount.should.equal(1) - this.res.json - .calledWith({ invites: this.fakeInvites }) - .should.equal(true) + it('should produce a list of invite objects', function (ctx) { + ctx.res.json.callCount.should.equal(1) + ctx.res.json.calledWith({ invites: ctx.fakeInvites }).should.equal(true) }) - it('should have called CollaboratorsInviteHandler.getAllInvites', function () { - this.CollaboratorsInviteGetter.promises.getAllInvites.callCount.should.equal( + it('should have called CollaboratorsInviteHandler.getAllInvites', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getAllInvites.callCount.should.equal( 1 ) - this.CollaboratorsInviteGetter.promises.getAllInvites - .calledWith(this.projectId) + ctx.CollaboratorsInviteGetter.promises.getAllInvites + .calledWith(ctx.projectId) .should.equal(true) }) }) describe('when CollaboratorsInviteHandler.getAllInvites produces an error', function () { - beforeEach(function (done) { - this.CollaboratorsInviteGetter.promises.getAllInvites.rejects( - new Error('woops') - ) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.getAllInvites( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getAllInvites.rejects( + new Error('woops') + ) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.getAllInvites( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should produce an error', function () { - this.next.callCount.should.equal(1) - this.next.firstCall.args[0].should.be.instanceof(Error) + it('should produce an error', function (ctx) { + ctx.next.callCount.should.equal(1) + ctx.next.firstCall.args[0].should.be.instanceof(Error) }) }) }) describe('inviteToProject', function () { - beforeEach(function () { - this.req.params = { Project_id: this.projectId } - this.req.body = { - email: this.targetEmail, - privileges: this.privileges, + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.projectId } + ctx.req.body = { + email: ctx.targetEmail, + privileges: ctx.privileges, } - this.ProjectGetter.promises.getProject.resolves({ - owner_ref: this.project.owner_ref, + ctx.ProjectGetter.promises.getProject.resolves({ + owner_ref: ctx.project.owner_ref, }) }) describe('when all goes well', function (done) { - beforeEach(async function () { - this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + beforeEach(async function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon .stub() .resolves(true) - this.CollaboratorsInviteController._checkRateLimit = sinon + ctx.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) - await this.CollaboratorsInviteController.inviteToProject( - this.req, - this.res + await ctx.CollaboratorsInviteController.inviteToProject( + ctx.req, + ctx.res ) }) - it('should produce json response', function () { - this.res.json.callCount.should.equal(1) - expect(this.res.json.firstCall.args[0]).to.deep.equal({ - invite: this.inviteReducedData, + it('should produce json response', function (ctx) { + ctx.res.json.callCount.should.equal(1) + expect(ctx.res.json.firstCall.args[0]).to.deep.equal({ + invite: ctx.inviteReducedData, }) }) - it('should have called canAddXEditCollaborators', function () { - this.LimitationsManager.promises.canAddXEditCollaborators.callCount.should.equal( + it('should have called canAddXEditCollaborators', function (ctx) { + ctx.LimitationsManager.promises.canAddXEditCollaborators.callCount.should.equal( 1 ) - this.LimitationsManager.promises.canAddXEditCollaborators - .calledWith(this.projectId) + ctx.LimitationsManager.promises.canAddXEditCollaborators + .calledWith(ctx.projectId) .should.equal(true) }) - it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( + it('should have called _checkShouldInviteEmail', function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController._checkShouldInviteEmail - .calledWith(this.targetEmail) + ctx.CollaboratorsInviteController._checkShouldInviteEmail + .calledWith(ctx.targetEmail) .should.equal(true) }) - it('should have called inviteToProject', function () { - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should have called inviteToProject', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises.inviteToProject + ctx.CollaboratorsInviteHandler.promises.inviteToProject .calledWith( - this.projectId, - this.currentUser, - this.targetEmail, - this.privileges + ctx.projectId, + ctx.currentUser, + ctx.targetEmail, + ctx.privileges ) .should.equal(true) }) - it('should have called emitToRoom', function () { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(1) - this.EditorRealTimeController.emitToRoom - .calledWith(this.projectId, 'project:membership:changed') + it('should have called emitToRoom', function (ctx) { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(1) + ctx.EditorRealTimeController.emitToRoom + .calledWith(ctx.projectId, 'project:membership:changed') .should.equal(true) }) - it('adds a project audit log entry', function () { - this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( - this.projectId, + it('adds a project audit log entry', function (ctx) { + ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( + ctx.projectId, 'send-invite', - this.currentUser._id, - this.req.ip, + ctx.currentUser._id, + ctx.req.ip, { - inviteId: this.invite._id, - privileges: this.privileges, + inviteId: ctx.invite._id, + privileges: ctx.privileges, } ) }) }) describe('when the user is not allowed to add more edit collaborators', function () { - beforeEach(function () { - this.LimitationsManager.promises.canAddXEditCollaborators.resolves( - false - ) + beforeEach(function (ctx) { + ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(false) }) describe('readAndWrite collaborator', function () { - beforeEach(function (done) { - this.privileges = 'readAndWrite' - this.CollaboratorsInviteController._checkShouldInviteEmail = sinon - .stub() - .resolves(true) - this.CollaboratorsInviteController._checkRateLimit = sinon - .stub() - .resolves(true) - this.res.callback = () => done() - this.CollaboratorsInviteController.inviteToProject( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.privileges = 'readAndWrite' + ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + ctx.CollaboratorsInviteController._checkRateLimit = sinon + .stub() + .resolves(true) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.inviteToProject( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should produce json response without an invite', function () { - this.res.json.callCount.should.equal(1) - expect(this.res.json.firstCall.args[0]).to.deep.equal({ + it('should produce json response without an invite', function (ctx) { + ctx.res.json.callCount.should.equal(1) + expect(ctx.res.json.firstCall.args[0]).to.deep.equal({ invite: null, }) }) - it('should not have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( + it('should not have called _checkShouldInviteEmail', function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 0 ) - this.CollaboratorsInviteController._checkShouldInviteEmail - .calledWith(this.currentUser, this.targetEmail) + ctx.CollaboratorsInviteController._checkShouldInviteEmail + .calledWith(ctx.currentUser, ctx.targetEmail) .should.equal(false) }) - it('should not have called inviteToProject', function () { - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should not have called inviteToProject', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 0 ) }) }) describe('readOnly collaborator (always allowed)', function () { - beforeEach(function (done) { - this.req.body = { - email: this.targetEmail, - privileges: (this.privileges = 'readOnly'), - } - this.CollaboratorsInviteController._checkShouldInviteEmail = sinon - .stub() - .resolves(true) - this.CollaboratorsInviteController._checkRateLimit = sinon - .stub() - .resolves(true) - this.res.callback = () => done() - this.CollaboratorsInviteController.inviteToProject( - this.req, - this.res, - this.next - ) - }) - - it('should produce json response', function () { - this.res.json.callCount.should.equal(1) - expect(this.res.json.firstCall.args[0]).to.deep.equal({ - invite: this.inviteReducedData, + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.req.body = { + email: ctx.targetEmail, + privileges: (ctx.privileges = 'readOnly'), + } + ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + ctx.CollaboratorsInviteController._checkRateLimit = sinon + .stub() + .resolves(true) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.inviteToProject( + ctx.req, + ctx.res, + ctx.next + ) }) }) - it('should not have called canAddXEditCollaborators', function () { - this.LimitationsManager.promises.canAddXEditCollaborators.callCount.should.equal( + it('should produce json response', function (ctx) { + ctx.res.json.callCount.should.equal(1) + expect(ctx.res.json.firstCall.args[0]).to.deep.equal({ + invite: ctx.inviteReducedData, + }) + }) + + it('should not have called canAddXEditCollaborators', function (ctx) { + ctx.LimitationsManager.promises.canAddXEditCollaborators.callCount.should.equal( 0 ) }) - it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( + it('should have called _checkShouldInviteEmail', function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController._checkShouldInviteEmail - .calledWith(this.targetEmail) + ctx.CollaboratorsInviteController._checkShouldInviteEmail + .calledWith(ctx.targetEmail) .should.equal(true) }) - it('should have called inviteToProject', function () { - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should have called inviteToProject', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises.inviteToProject + ctx.CollaboratorsInviteHandler.promises.inviteToProject .calledWith( - this.projectId, - this.currentUser, - this.targetEmail, - this.privileges + ctx.projectId, + ctx.currentUser, + ctx.targetEmail, + ctx.privileges ) .should.equal(true) }) - it('should have called emitToRoom', function () { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(1) - this.EditorRealTimeController.emitToRoom - .calledWith(this.projectId, 'project:membership:changed') + it('should have called emitToRoom', function (ctx) { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(1) + ctx.EditorRealTimeController.emitToRoom + .calledWith(ctx.projectId, 'project:membership:changed') .should.equal(true) }) - it('adds a project audit log entry', function () { - this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( - this.projectId, + it('adds a project audit log entry', function (ctx) { + ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( + ctx.projectId, 'send-invite', - this.currentUser._id, - this.req.ip, + ctx.currentUser._id, + ctx.req.ip, { - inviteId: this.invite._id, - privileges: this.privileges, + inviteId: ctx.invite._id, + privileges: ctx.privileges, } ) }) @@ -432,808 +501,834 @@ describe('CollaboratorsInviteController', function () { }) describe('when inviteToProject produces an error', function () { - beforeEach(function (done) { - this.CollaboratorsInviteController._checkShouldInviteEmail = sinon - .stub() - .resolves(true) - this.CollaboratorsInviteController._checkRateLimit = sinon - .stub() - .resolves(true) - this.CollaboratorsInviteHandler.promises.inviteToProject.rejects( - new Error('woops') - ) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.inviteToProject( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + ctx.CollaboratorsInviteController._checkRateLimit = sinon + .stub() + .resolves(true) + ctx.CollaboratorsInviteHandler.promises.inviteToProject.rejects( + new Error('woops') + ) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.inviteToProject( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - expect(this.next).to.have.been.calledWith(sinon.match.instanceOf(Error)) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next).to.have.been.calledWith(sinon.match.instanceOf(Error)) }) - it('should have called canAddXEditCollaborators', function () { - this.LimitationsManager.promises.canAddXEditCollaborators.callCount.should.equal( + it('should have called canAddXEditCollaborators', function (ctx) { + ctx.LimitationsManager.promises.canAddXEditCollaborators.callCount.should.equal( 1 ) - this.LimitationsManager.promises.canAddXEditCollaborators - .calledWith(this.projectId) + ctx.LimitationsManager.promises.canAddXEditCollaborators + .calledWith(ctx.projectId) .should.equal(true) }) - it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( + it('should have called _checkShouldInviteEmail', function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController._checkShouldInviteEmail - .calledWith(this.targetEmail) + ctx.CollaboratorsInviteController._checkShouldInviteEmail + .calledWith(ctx.targetEmail) .should.equal(true) }) - it('should have called inviteToProject', function () { - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should have called inviteToProject', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises.inviteToProject + ctx.CollaboratorsInviteHandler.promises.inviteToProject .calledWith( - this.projectId, - this.currentUser, - this.targetEmail, - this.privileges + ctx.projectId, + ctx.currentUser, + ctx.targetEmail, + ctx.privileges ) .should.equal(true) }) }) describe('when _checkShouldInviteEmail disallows the invite', function () { - beforeEach(function (done) { - this.CollaboratorsInviteController._checkShouldInviteEmail = sinon - .stub() - .resolves(false) - this.CollaboratorsInviteController._checkRateLimit = sinon - .stub() - .resolves(true) - this.res.callback = () => done() - this.CollaboratorsInviteController.inviteToProject( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(false) + ctx.CollaboratorsInviteController._checkRateLimit = sinon + .stub() + .resolves(true) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.inviteToProject( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should produce json response with no invite, and an error property', function () { - this.res.json.callCount.should.equal(1) - expect(this.res.json.firstCall.args[0]).to.deep.equal({ + it('should produce json response with no invite, and an error property', function (ctx) { + ctx.res.json.callCount.should.equal(1) + expect(ctx.res.json.firstCall.args[0]).to.deep.equal({ invite: null, error: 'cannot_invite_non_user', }) }) - it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( + it('should have called _checkShouldInviteEmail', function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController._checkShouldInviteEmail - .calledWith(this.targetEmail) + ctx.CollaboratorsInviteController._checkShouldInviteEmail + .calledWith(ctx.targetEmail) .should.equal(true) }) - it('should not have called inviteToProject', function () { - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should not have called inviteToProject', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 0 ) }) }) describe('when _checkShouldInviteEmail produces an error', function () { - beforeEach(function (done) { - this.CollaboratorsInviteController._checkShouldInviteEmail = sinon - .stub() - .rejects(new Error('woops')) - this.CollaboratorsInviteController._checkRateLimit = sinon - .stub() - .resolves(true) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.inviteToProject( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .rejects(new Error('woops')) + ctx.CollaboratorsInviteController._checkRateLimit = sinon + .stub() + .resolves(true) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.inviteToProject( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) - it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( + it('should have called _checkShouldInviteEmail', function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController._checkShouldInviteEmail - .calledWith(this.targetEmail) + ctx.CollaboratorsInviteController._checkShouldInviteEmail + .calledWith(ctx.targetEmail) .should.equal(true) }) - it('should not have called inviteToProject', function () { - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should not have called inviteToProject', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 0 ) }) }) describe('when the user invites themselves to the project', function () { - beforeEach(function () { - this.req.body.email = this.currentUser.email - this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + beforeEach(function (ctx) { + ctx.req.body.email = ctx.currentUser.email + ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon .stub() .resolves(true) - this.CollaboratorsInviteController._checkRateLimit = sinon + ctx.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) - this.CollaboratorsInviteController.inviteToProject( - this.req, - this.res, - this.next + ctx.CollaboratorsInviteController.inviteToProject( + ctx.req, + ctx.res, + ctx.next ) }) - it('should reject action, return json response with error code', function () { - this.res.json.callCount.should.equal(1) - expect(this.res.json.firstCall.args[0]).to.deep.equal({ + it('should reject action, return json response with error code', function (ctx) { + ctx.res.json.callCount.should.equal(1) + expect(ctx.res.json.firstCall.args[0]).to.deep.equal({ invite: null, error: 'cannot_invite_self', }) }) - it('should not have called canAddXEditCollaborators', function () { - this.LimitationsManager.promises.canAddXEditCollaborators.callCount.should.equal( + it('should not have called canAddXEditCollaborators', function (ctx) { + ctx.LimitationsManager.promises.canAddXEditCollaborators.callCount.should.equal( 0 ) }) - it('should not have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( + it('should not have called _checkShouldInviteEmail', function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 0 ) }) - it('should not have called inviteToProject', function () { - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should not have called inviteToProject', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 0 ) }) - it('should not have called emitToRoom', function () { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(0) + it('should not have called emitToRoom', function (ctx) { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(0) }) }) describe('when _checkRateLimit returns false', function () { - beforeEach(async function () { - this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + beforeEach(async function (ctx) { + ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon .stub() .resolves(true) - this.CollaboratorsInviteController._checkRateLimit = sinon + ctx.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(false) - await this.CollaboratorsInviteController.inviteToProject( - this.req, - this.res, - this.next + await ctx.CollaboratorsInviteController.inviteToProject( + ctx.req, + ctx.res, + ctx.next ) }) - it('should send a 429 response', function () { - this.res.sendStatus.calledWith(429).should.equal(true) + it('should send a 429 response', function (ctx) { + ctx.res.sendStatus.calledWith(429).should.equal(true) }) - it('should not call inviteToProject', function () { - this.CollaboratorsInviteHandler.promises.inviteToProject.called.should.equal( + it('should not call inviteToProject', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.inviteToProject.called.should.equal( false ) }) - it('should not call emitToRoom', function () { - this.EditorRealTimeController.emitToRoom.called.should.equal(false) + it('should not call emitToRoom', function (ctx) { + ctx.EditorRealTimeController.emitToRoom.called.should.equal(false) }) }) }) describe('viewInvite', function () { - beforeEach(function () { - this.req.params = { - Project_id: this.projectId, - token: this.token, + beforeEach(function (ctx) { + ctx.req.params = { + Project_id: ctx.projectId, + token: ctx.token, } - this.fakeProject = { - _id: this.projectId, + ctx.fakeProject = { + _id: ctx.projectId, name: 'some project', - owner_ref: this.invite.sendingUserId, + owner_ref: ctx.invite.sendingUserId, collaberator_refs: [], readOnly_refs: [], } - this.owner = { - _id: this.fakeProject.owner_ref, + ctx.owner = { + _id: ctx.fakeProject.owner_ref, first_name: 'John', last_name: 'Doe', email: 'john@example.com', } - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( false ) - this.CollaboratorsInviteGetter.promises.getInviteByToken.resolves( - this.invite + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves( + ctx.invite ) - this.ProjectGetter.promises.getProject.resolves(this.fakeProject) - this.UserGetter.promises.getUser.resolves(this.owner) + ctx.ProjectGetter.promises.getProject.resolves(ctx.fakeProject) + ctx.UserGetter.promises.getUser.resolves(ctx.owner) }) describe('when the token is valid', function () { - beforeEach(function (done) { - this.res.callback = () => done() - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should render the view template', function () { - this.res.render.callCount.should.equal(1) - this.res.render.calledWith('project/invite/show').should.equal(true) + it('should render the view template', function (ctx) { + ctx.res.render.callCount.should.equal(1) + ctx.res.render.calledWith('project/invite/show').should.equal(true) }) - it('should not call next', function () { - this.next.callCount.should.equal(0) + it('should not call next', function (ctx) { + ctx.next.callCount.should.equal(0) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) - this.CollaboratorsInviteGetter.promises.getInviteByToken - .calledWith(this.fakeProject._id, this.invite.token) + ctx.CollaboratorsInviteGetter.promises.getInviteByToken + .calledWith(ctx.fakeProject._id, ctx.invite.token) .should.equal(true) }) - it('should call User.getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(1) - this.UserGetter.promises.getUser - .calledWith({ _id: this.fakeProject.owner_ref }) + it('should call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(1) + ctx.UserGetter.promises.getUser + .calledWith({ _id: ctx.fakeProject.owner_ref }) .should.equal(true) }) - it('should call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(1) - this.ProjectGetter.promises.getProject - .calledWith(this.projectId) + it('should call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(1) + ctx.ProjectGetter.promises.getProject + .calledWith(ctx.projectId) .should.equal(true) }) }) describe('when not logged in', function () { - beforeEach(function (done) { - this.SessionManager.getSessionUser.returns(null) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.SessionManager.getSessionUser.returns(null) - this.res.callback = () => done() - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should not check member status', function () { - expect(this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject) - .to.not.have.been.called + it('should not check member status', function (ctx) { + expect(ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject).to + .not.have.been.called }) - it('should set redirect back to invite', function () { + it('should set redirect back to invite', function (ctx) { expect( - this.AuthenticationController.setRedirectInSession - ).to.have.been.calledWith(this.req) + ctx.AuthenticationController.setRedirectInSession + ).to.have.been.calledWith(ctx.req) }) - it('should redirect to the register page', function () { - expect(this.res.render).to.not.have.been.called - expect(this.res.redirect).to.have.been.calledOnce - expect(this.res.redirect).to.have.been.calledWith('/register') + it('should redirect to the register page', function (ctx) { + expect(ctx.res.render).to.not.have.been.called + expect(ctx.res.redirect).to.have.been.calledOnce + expect(ctx.res.redirect).to.have.been.calledWith('/register') }) }) describe('when user is already a member of the project', function () { - beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - true - ) - this.res.callback = () => done() - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + true + ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should redirect to the project page', function () { - this.res.redirect.callCount.should.equal(1) - this.res.redirect - .calledWith(`/project/${this.projectId}`) + it('should redirect to the project page', function (ctx) { + ctx.res.redirect.callCount.should.equal(1) + ctx.res.redirect + .calledWith(`/project/${ctx.projectId}`) .should.equal(true) }) - it('should not call next with an error', function () { - this.next.callCount.should.equal(0) + it('should not call next with an error', function (ctx) { + ctx.next.callCount.should.equal(0) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should not call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should not call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 0 ) }) - it('should not call User.getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(0) + it('should not call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(0) }) - it('should not call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(0) + it('should not call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(0) }) }) describe('when isUserInvitedMemberOfProject produces an error', function () { - beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.rejects( - new Error('woops') - ) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.rejects( + new Error('woops') + ) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should call next with an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.firstCall.args[0]).to.be.instanceof(Error) + it('should call next with an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.firstCall.args[0]).to.be.instanceof(Error) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should not call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should not call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 0 ) }) - it('should not call User.getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(0) + it('should not call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(0) }) - it('should not call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(0) + it('should not call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(0) }) }) describe('when the getInviteByToken produces an error', function () { - beforeEach(function (done) { - this.CollaboratorsInviteGetter.promises.getInviteByToken.rejects( - new Error('woops') - ) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.rejects( + new Error('woops') + ) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should call next with the error', function () { - this.next.callCount.should.equal(1) - this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) + it('should call next with the error', function (ctx) { + ctx.next.callCount.should.equal(1) + ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should not call User.getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(0) + it('should not call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(0) }) - it('should not call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(0) + it('should not call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(0) }) }) describe('when the getInviteByToken does not produce an invite', function () { - beforeEach(function (done) { - this.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) - this.res.callback = () => done() - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should render the not-valid view template', function () { - this.res.render.callCount.should.equal(1) - this.res.render - .calledWith('project/invite/not-valid') - .should.equal(true) + it('should render the not-valid view template', function (ctx) { + ctx.res.render.callCount.should.equal(1) + ctx.res.render.calledWith('project/invite/not-valid').should.equal(true) }) - it('should not call next', function () { - this.next.callCount.should.equal(0) + it('should not call next', function (ctx) { + ctx.next.callCount.should.equal(0) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should not call User.getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(0) + it('should not call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(0) }) - it('should not call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(0) + it('should not call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(0) }) }) describe('when User.getUser produces an error', function () { - beforeEach(function (done) { - this.UserGetter.promises.getUser.rejects(new Error('woops')) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.UserGetter.promises.getUser.rejects(new Error('woops')) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should produce an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.firstCall.args[0]).to.be.instanceof(Error) + it('should produce an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.firstCall.args[0]).to.be.instanceof(Error) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) }) - it('should call User.getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(1) - this.UserGetter.promises.getUser - .calledWith({ _id: this.fakeProject.owner_ref }) + it('should call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(1) + ctx.UserGetter.promises.getUser + .calledWith({ _id: ctx.fakeProject.owner_ref }) .should.equal(true) }) - it('should not call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(0) + it('should not call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(0) }) }) describe('when User.getUser does not find a user', function () { - beforeEach(function (done) { - this.UserGetter.promises.getUser.resolves(null) - this.res.callback = () => done() - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.UserGetter.promises.getUser.resolves(null) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should render the not-valid view template', function () { - this.res.render.callCount.should.equal(1) - this.res.render - .calledWith('project/invite/not-valid') - .should.equal(true) + it('should render the not-valid view template', function (ctx) { + ctx.res.render.callCount.should.equal(1) + ctx.res.render.calledWith('project/invite/not-valid').should.equal(true) }) - it('should not call next', function () { - this.next.callCount.should.equal(0) + it('should not call next', function (ctx) { + ctx.next.callCount.should.equal(0) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) }) - it('should call User.getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(1) - this.UserGetter.promises.getUser - .calledWith({ _id: this.fakeProject.owner_ref }) + it('should call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(1) + ctx.UserGetter.promises.getUser + .calledWith({ _id: ctx.fakeProject.owner_ref }) .should.equal(true) }) - it('should not call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(0) + it('should not call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(0) }) }) describe('when getProject produces an error', function () { - beforeEach(function (done) { - this.ProjectGetter.promises.getProject.rejects(new Error('woops')) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ProjectGetter.promises.getProject.rejects(new Error('woops')) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should produce an error', function () { - this.next.callCount.should.equal(1) - expect(this.next.firstCall.args[0]).to.be.instanceof(Error) + it('should produce an error', function (ctx) { + ctx.next.callCount.should.equal(1) + expect(ctx.next.firstCall.args[0]).to.be.instanceof(Error) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) }) - it('should call User.getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(1) - this.UserGetter.promises.getUser - .calledWith({ _id: this.fakeProject.owner_ref }) + it('should call User.getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(1) + ctx.UserGetter.promises.getUser + .calledWith({ _id: ctx.fakeProject.owner_ref }) .should.equal(true) }) - it('should call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(1) + it('should call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(1) }) }) describe('when Project.getUser does not find a user', function () { - beforeEach(function (done) { - this.ProjectGetter.promises.getProject.resolves(null) - this.res.callback = () => done() - this.CollaboratorsInviteController.viewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ProjectGetter.promises.getProject.resolves(null) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.viewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should render the not-valid view template', function () { - this.res.render.callCount.should.equal(1) - this.res.render - .calledWith('project/invite/not-valid') - .should.equal(true) + it('should render the not-valid view template', function (ctx) { + ctx.res.render.callCount.should.equal(1) + ctx.res.render.calledWith('project/invite/not-valid').should.equal(true) }) - it('should not call next', function () { - this.next.callCount.should.equal(0) + it('should not call next', function (ctx) { + ctx.next.callCount.should.equal(0) }) - it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function () { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( + it('should call CollaboratorsGetter.isUserInvitedMemberOfProject', function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.callCount.should.equal( 1 ) - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject - .calledWith(this.currentUser._id, this.projectId) + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject + .calledWith(ctx.currentUser._id, ctx.projectId) .should.equal(true) }) - it('should call getInviteByToken', function () { - this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( + it('should call getInviteByToken', function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) }) - it('should call getUser', function () { - this.UserGetter.promises.getUser.callCount.should.equal(1) - this.UserGetter.promises.getUser - .calledWith({ _id: this.fakeProject.owner_ref }) + it('should call getUser', function (ctx) { + ctx.UserGetter.promises.getUser.callCount.should.equal(1) + ctx.UserGetter.promises.getUser + .calledWith({ _id: ctx.fakeProject.owner_ref }) .should.equal(true) }) - it('should call ProjectGetter.getProject', function () { - this.ProjectGetter.promises.getProject.callCount.should.equal(1) + it('should call ProjectGetter.getProject', function (ctx) { + ctx.ProjectGetter.promises.getProject.callCount.should.equal(1) }) }) }) describe('generateNewInvite', function () { - beforeEach(function () { - this.req.params = { - Project_id: this.projectId, - invite_id: this.invite._id.toString(), + beforeEach(function (ctx) { + ctx.req.params = { + Project_id: ctx.projectId, + invite_id: ctx.invite._id.toString(), } - this.CollaboratorsInviteController._checkRateLimit = sinon + ctx.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) }) describe('when generateNewInvite does not produce an error', function () { describe('and returns an invite object', function () { - beforeEach(function (done) { - this.res.callback = () => done() - this.CollaboratorsInviteController.generateNewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.generateNewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should produce a 201 response', function () { - this.res.sendStatus.callCount.should.equal(1) - this.res.sendStatus.calledWith(201).should.equal(true) + it('should produce a 201 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(1) + ctx.res.sendStatus.calledWith(201).should.equal(true) }) - it('should have called generateNewInvite', function () { - this.CollaboratorsInviteHandler.promises.generateNewInvite.callCount.should.equal( + it('should have called generateNewInvite', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.generateNewInvite.callCount.should.equal( 1 ) }) - it('should have called emitToRoom', function () { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(1) - this.EditorRealTimeController.emitToRoom - .calledWith(this.projectId, 'project:membership:changed') + it('should have called emitToRoom', function (ctx) { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(1) + ctx.EditorRealTimeController.emitToRoom + .calledWith(ctx.projectId, 'project:membership:changed') .should.equal(true) }) - it('should check the rate limit', function () { - this.CollaboratorsInviteController._checkRateLimit.callCount.should.equal( + it('should check the rate limit', function (ctx) { + ctx.CollaboratorsInviteController._checkRateLimit.callCount.should.equal( 1 ) }) - it('should add a project audit log entry', function () { - this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( - this.projectId, + it('should add a project audit log entry', function (ctx) { + ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( + ctx.projectId, 'resend-invite', - this.currentUser._id, - this.req.ip, + ctx.currentUser._id, + ctx.req.ip, { - inviteId: this.invite._id, - privileges: this.privileges, + inviteId: ctx.invite._id, + privileges: ctx.privileges, } ) }) }) describe('and returns a null invite', function () { - beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.generateNewInvite.resolves( - null - ) - this.res.callback = () => done() - this.CollaboratorsInviteController.generateNewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteHandler.promises.generateNewInvite.resolves( + null + ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.generateNewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should have called emitToRoom', function () { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(1) - this.EditorRealTimeController.emitToRoom - .calledWith(this.projectId, 'project:membership:changed') + it('should have called emitToRoom', function (ctx) { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(1) + ctx.EditorRealTimeController.emitToRoom + .calledWith(ctx.projectId, 'project:membership:changed') .should.equal(true) }) - it('should produce a 404 response when invite is null', function () { - this.res.sendStatus.callCount.should.equal(1) - this.res.sendStatus.should.have.been.calledWith(404) + it('should produce a 404 response when invite is null', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(1) + ctx.res.sendStatus.should.have.been.calledWith(404) }) }) }) describe('when generateNewInvite produces an error', function () { - beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.generateNewInvite.rejects( - new Error('woops') - ) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.generateNewInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteHandler.promises.generateNewInvite.rejects( + new Error('woops') + ) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.generateNewInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should not produce a 201 response', function () { - this.res.sendStatus.callCount.should.equal(0) + it('should not produce a 201 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(0) }) - it('should call next with the error', function () { - this.next.callCount.should.equal(1) - this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) + it('should call next with the error', function (ctx) { + ctx.next.callCount.should.equal(1) + ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) - it('should have called generateNewInvite', function () { - this.CollaboratorsInviteHandler.promises.generateNewInvite.callCount.should.equal( + it('should have called generateNewInvite', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.generateNewInvite.callCount.should.equal( 1 ) }) @@ -1241,79 +1336,83 @@ describe('CollaboratorsInviteController', function () { }) describe('revokeInvite', function () { - beforeEach(function () { - this.req.params = { - Project_id: this.projectId, - invite_id: this.invite._id.toString(), + beforeEach(function (ctx) { + ctx.req.params = { + Project_id: ctx.projectId, + invite_id: ctx.invite._id.toString(), } }) describe('when revokeInvite does not produce an error', function () { - beforeEach(function (done) { - this.res.callback = () => done() - this.CollaboratorsInviteController.revokeInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.revokeInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should produce a 204 response', function () { - this.res.sendStatus.callCount.should.equal(1) - this.res.sendStatus.should.have.been.calledWith(204) + it('should produce a 204 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(1) + ctx.res.sendStatus.should.have.been.calledWith(204) }) - it('should have called revokeInvite', function () { - this.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( + it('should have called revokeInvite', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( 1 ) }) - it('should have called emitToRoom', function () { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(1) - this.EditorRealTimeController.emitToRoom - .calledWith(this.projectId, 'project:membership:changed') + it('should have called emitToRoom', function (ctx) { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(1) + ctx.EditorRealTimeController.emitToRoom + .calledWith(ctx.projectId, 'project:membership:changed') .should.equal(true) }) - it('should add a project audit log entry', function () { - this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( - this.projectId, + it('should add a project audit log entry', function (ctx) { + ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith( + ctx.projectId, 'revoke-invite', - this.currentUser._id, - this.req.ip, + ctx.currentUser._id, + ctx.req.ip, { - inviteId: this.invite._id, - privileges: this.privileges, + inviteId: ctx.invite._id, + privileges: ctx.privileges, } ) }) }) describe('when revokeInvite produces an error', function () { - beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.revokeInvite.rejects( - new Error('woops') - ) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.revokeInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteHandler.promises.revokeInvite.rejects( + new Error('woops') + ) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.revokeInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should not produce a 201 response', function () { - this.res.sendStatus.callCount.should.equal(0) + it('should not produce a 201 response', function (ctx) { + ctx.res.sendStatus.callCount.should.equal(0) }) - it('should call next with the error', function () { - this.next.callCount.should.equal(1) - this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) + it('should call next with the error', function (ctx) { + ctx.next.callCount.should.equal(1) + ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) - it('should have called revokeInvite', function () { - this.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( + it('should have called revokeInvite', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( 1 ) }) @@ -1321,188 +1420,196 @@ describe('CollaboratorsInviteController', function () { }) describe('acceptInvite', function () { - beforeEach(function () { - this.req.params = { - Project_id: this.projectId, - token: this.token, + beforeEach(function (ctx) { + ctx.req.params = { + Project_id: ctx.projectId, + token: ctx.token, } }) describe('when acceptInvite does not produce an error', function () { - beforeEach(function (done) { - this.res.callback = () => done() - this.CollaboratorsInviteController.acceptInvite( - this.req, - this.res, - this.next + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.acceptInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) + }) + + it('should redirect to project page', function (ctx) { + ctx.res.redirect.should.have.been.calledOnce + ctx.res.redirect.should.have.been.calledWith( + `/project/${ctx.projectId}` ) }) - it('should redirect to project page', function () { - this.res.redirect.should.have.been.calledOnce - this.res.redirect.should.have.been.calledWith( - `/project/${this.projectId}` + it('should have called acceptInvite', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.acceptInvite.should.have.been.calledWith( + ctx.invite, + ctx.projectId, + ctx.currentUser ) }) - it('should have called acceptInvite', function () { - this.CollaboratorsInviteHandler.promises.acceptInvite.should.have.been.calledWith( - this.invite, - this.projectId, - this.currentUser - ) - }) - - it('should have called emitToRoom', function () { - this.EditorRealTimeController.emitToRoom.should.have.been.calledOnce - this.EditorRealTimeController.emitToRoom.should.have.been.calledWith( - this.projectId, + it('should have called emitToRoom', function (ctx) { + ctx.EditorRealTimeController.emitToRoom.should.have.been.calledOnce + ctx.EditorRealTimeController.emitToRoom.should.have.been.calledWith( + ctx.projectId, 'project:membership:changed' ) }) - it('should add a project audit log entry', function () { - this.ProjectAuditLogHandler.promises.addEntry.should.have.been.calledWith( - this.projectId, + it('should add a project audit log entry', function (ctx) { + ctx.ProjectAuditLogHandler.promises.addEntry.should.have.been.calledWith( + ctx.projectId, 'accept-invite', - this.currentUser._id, - this.req.ip, + ctx.currentUser._id, + ctx.req.ip, { - inviteId: this.invite._id, - privileges: this.privileges, + inviteId: ctx.invite._id, + privileges: ctx.privileges, } ) }) }) describe('when the invite is not found', function () { - beforeEach(function (done) { - this.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.acceptInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.acceptInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('throws a NotFoundError', function () { - expect(this.next).to.have.been.calledWith( + it('throws a NotFoundError', function (ctx) { + expect(ctx.next).to.have.been.calledWith( sinon.match.instanceOf(Errors.NotFoundError) ) }) }) describe('when acceptInvite produces an error', function () { - beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.acceptInvite.rejects( - new Error('woops') - ) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.acceptInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsInviteHandler.promises.acceptInvite.rejects( + new Error('woops') + ) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.acceptInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should not redirect to project page', function () { - this.res.redirect.callCount.should.equal(0) + it('should not redirect to project page', function (ctx) { + ctx.res.redirect.callCount.should.equal(0) }) - it('should call next with the error', function () { - this.next.callCount.should.equal(1) - this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) + it('should call next with the error', function (ctx) { + ctx.next.callCount.should.equal(1) + ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) - it('should have called acceptInvite', function () { - this.CollaboratorsInviteHandler.promises.acceptInvite.callCount.should.equal( + it('should have called acceptInvite', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.acceptInvite.callCount.should.equal( 1 ) }) }) describe('when the project audit log entry fails', function () { - beforeEach(function (done) { - this.ProjectAuditLogHandler.promises.addEntry.rejects(new Error('oops')) - this.next.callsFake(() => done()) - this.CollaboratorsInviteController.acceptInvite( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ProjectAuditLogHandler.promises.addEntry.rejects( + new Error('oops') + ) + ctx.next.callsFake(() => resolve()) + ctx.CollaboratorsInviteController.acceptInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('should not accept the invite', function () { - this.CollaboratorsInviteHandler.promises.acceptInvite.should.not.have + it('should not accept the invite', function (ctx) { + ctx.CollaboratorsInviteHandler.promises.acceptInvite.should.not.have .been.called }) }) }) describe('_checkShouldInviteEmail', function () { - beforeEach(function () { - this.email = 'user@example.com' + beforeEach(function (ctx) { + ctx.email = 'user@example.com' }) describe('when we should be restricting to existing accounts', function () { - beforeEach(function () { - this.settings.restrictInvitesToExistingAccounts = true - this.call = () => - this.CollaboratorsInviteController._checkShouldInviteEmail(this.email) + beforeEach(function (ctx) { + ctx.settings.restrictInvitesToExistingAccounts = true + ctx.call = () => + ctx.CollaboratorsInviteController._checkShouldInviteEmail(ctx.email) }) describe('when user account is present', function () { - beforeEach(function () { - this.user = { _id: new ObjectId().toString() } - this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) + beforeEach(function (ctx) { + ctx.user = { _id: new ObjectId().toString() } + ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user) }) - it('should callback with `true`', async function () { + it('should callback with `true`', async function (ctx) { const shouldAllow = - await this.CollaboratorsInviteController._checkShouldInviteEmail( - this.email + await ctx.CollaboratorsInviteController._checkShouldInviteEmail( + ctx.email ) expect(shouldAllow).to.equal(true) }) }) describe('when user account is absent', function () { - beforeEach(function () { - this.user = null - this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) + beforeEach(function (ctx) { + ctx.user = null + ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user) }) - it('should callback with `false`', async function () { + it('should callback with `false`', async function (ctx) { const shouldAllow = - await this.CollaboratorsInviteController._checkShouldInviteEmail( - this.email + await ctx.CollaboratorsInviteController._checkShouldInviteEmail( + ctx.email ) expect(shouldAllow).to.equal(false) }) - it('should have called getUser', async function () { - await this.CollaboratorsInviteController._checkShouldInviteEmail( - this.email + it('should have called getUser', async function (ctx) { + await ctx.CollaboratorsInviteController._checkShouldInviteEmail( + ctx.email ) - this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) - this.UserGetter.promises.getUserByAnyEmail - .calledWith(this.email, { _id: 1 }) + ctx.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) + ctx.UserGetter.promises.getUserByAnyEmail + .calledWith(ctx.email, { _id: 1 }) .should.equal(true) }) }) describe('when getUser produces an error', function () { - beforeEach(function () { - this.user = null - this.UserGetter.promises.getUserByAnyEmail.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.user = null + ctx.UserGetter.promises.getUserByAnyEmail.rejects(new Error('woops')) }) - it('should callback with an error', async function () { + it('should callback with an error', async function (ctx) { await expect( - this.CollaboratorsInviteController._checkShouldInviteEmail( - this.email - ) + ctx.CollaboratorsInviteController._checkShouldInviteEmail(ctx.email) ).to.be.rejected }) }) @@ -1510,67 +1617,57 @@ describe('CollaboratorsInviteController', function () { }) describe('_checkRateLimit', function () { - beforeEach(function () { - this.settings.restrictInvitesToExistingAccounts = false - this.currentUserId = '32312313' - this.LimitationsManager.promises.allowedNumberOfCollaboratorsForUser - .withArgs(this.currentUserId) + beforeEach(function (ctx) { + ctx.settings.restrictInvitesToExistingAccounts = false + ctx.currentUserId = '32312313' + ctx.LimitationsManager.promises.allowedNumberOfCollaboratorsForUser + .withArgs(ctx.currentUserId) .resolves(17) }) - it('should callback with `true` when rate limit under', async function () { - const result = await this.CollaboratorsInviteController._checkRateLimit( - this.currentUserId - ) - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId + it('should callback with `true` when rate limit under', async function (ctx) { + const result = await ctx.CollaboratorsInviteController._checkRateLimit( + ctx.currentUserId ) + expect(ctx.rateLimiter.consume).to.have.been.calledWith(ctx.currentUserId) result.should.equal(true) }) - it('should callback with `false` when rate limit hit', async function () { - this.rateLimiter.consume.rejects({ remainingPoints: 0 }) - const result = await this.CollaboratorsInviteController._checkRateLimit( - this.currentUserId - ) - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId + it('should callback with `false` when rate limit hit', async function (ctx) { + ctx.rateLimiter.consume.rejects({ remainingPoints: 0 }) + const result = await ctx.CollaboratorsInviteController._checkRateLimit( + ctx.currentUserId ) + expect(ctx.rateLimiter.consume).to.have.been.calledWith(ctx.currentUserId) result.should.equal(false) }) - it('should allow 10x the collaborators', async function () { - await this.CollaboratorsInviteController._checkRateLimit( - this.currentUserId - ) - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId, + it('should allow 10x the collaborators', async function (ctx) { + await ctx.CollaboratorsInviteController._checkRateLimit(ctx.currentUserId) + expect(ctx.rateLimiter.consume).to.have.been.calledWith( + ctx.currentUserId, Math.floor(40000 / 170) ) }) - it('should allow 200 requests when collaborators is -1', async function () { - this.LimitationsManager.promises.allowedNumberOfCollaboratorsForUser - .withArgs(this.currentUserId) + it('should allow 200 requests when collaborators is -1', async function (ctx) { + ctx.LimitationsManager.promises.allowedNumberOfCollaboratorsForUser + .withArgs(ctx.currentUserId) .resolves(-1) - await this.CollaboratorsInviteController._checkRateLimit( - this.currentUserId - ) - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId, + await ctx.CollaboratorsInviteController._checkRateLimit(ctx.currentUserId) + expect(ctx.rateLimiter.consume).to.have.been.calledWith( + ctx.currentUserId, Math.floor(40000 / 200) ) }) - it('should allow 10 requests when user has no collaborators set', async function () { - this.LimitationsManager.promises.allowedNumberOfCollaboratorsForUser - .withArgs(this.currentUserId) + it('should allow 10 requests when user has no collaborators set', async function (ctx) { + ctx.LimitationsManager.promises.allowedNumberOfCollaboratorsForUser + .withArgs(ctx.currentUserId) .resolves(null) - await this.CollaboratorsInviteController._checkRateLimit( - this.currentUserId - ) - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId, + await ctx.CollaboratorsInviteController._checkRateLimit(ctx.currentUserId) + expect(ctx.rateLimiter.consume).to.have.been.calledWith( + ctx.currentUserId, Math.floor(40000 / 10) ) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs index f386648552..ec8f453536 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs @@ -1,6 +1,6 @@ +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' -import esmock from 'esmock' import mongodb from 'mongodb-legacy' import Crypto from 'crypto' @@ -10,8 +10,8 @@ const MODULE_PATH = '../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs' describe('CollaboratorsInviteHandler', function () { - beforeEach(async function () { - this.ProjectInvite = class ProjectInvite { + beforeEach(async function (ctx) { + ctx.ProjectInvite = class ProjectInvite { constructor(options) { if (options == null) { options = {} @@ -23,120 +23,174 @@ describe('CollaboratorsInviteHandler', function () { } } } - this.ProjectInvite.prototype.save = sinon.stub() - this.ProjectInvite.findOne = sinon.stub() - this.ProjectInvite.find = sinon.stub() - this.ProjectInvite.deleteOne = sinon.stub() - this.ProjectInvite.findOneAndDelete = sinon.stub() - this.ProjectInvite.countDocuments = sinon.stub() + ctx.ProjectInvite.prototype.save = sinon.stub() + ctx.ProjectInvite.findOne = sinon.stub() + ctx.ProjectInvite.find = sinon.stub() + ctx.ProjectInvite.deleteOne = sinon.stub() + ctx.ProjectInvite.findOneAndDelete = sinon.stub() + ctx.ProjectInvite.countDocuments = sinon.stub() - this.Crypto = { + ctx.Crypto = { randomBytes: sinon.stub().callsFake(Crypto.randomBytes), } - this.settings = {} - this.CollaboratorsEmailHandler = { promises: {} } - this.CollaboratorsHandler = { + ctx.settings = {} + ctx.CollaboratorsEmailHandler = { promises: {} } + ctx.CollaboratorsHandler = { promises: { addUserIdToProject: sinon.stub(), }, } - this.UserGetter = { promises: { getUser: sinon.stub() } } - this.ProjectGetter = { promises: { getProject: sinon.stub().resolves() } } - this.NotificationsBuilder = { promises: {} } - this.tokenHmac = 'jkhajkefhaekjfhkfg' - this.CollaboratorsInviteHelper = { - generateToken: sinon.stub().returns(this.Crypto.randomBytes(24)), - hashInviteToken: sinon.stub().returns(this.tokenHmac), + ctx.UserGetter = { promises: { getUser: sinon.stub() } } + ctx.ProjectGetter = { promises: { getProject: sinon.stub().resolves() } } + ctx.NotificationsBuilder = { promises: {} } + ctx.tokenHmac = 'jkhajkefhaekjfhkfg' + ctx.CollaboratorsInviteHelper = { + generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)), + hashInviteToken: sinon.stub().returns(ctx.tokenHmac), } - this.CollaboratorsInviteGetter = { + ctx.CollaboratorsInviteGetter = { promises: { getAllInvites: sinon.stub(), }, } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignmentForUser: sinon.stub().resolves(), }, } - this.LimitationsManager = { + ctx.LimitationsManager = { promises: { canAcceptEditCollaboratorInvite: sinon.stub().resolves(), }, } - this.ProjectAuditLogHandler = { + ctx.ProjectAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, addEntryInBackground: sinon.stub(), } - this.logger = { + ctx.logger = { debug: sinon.stub(), warn: sinon.stub(), err: sinon.stub(), } - this.CollaboratorsInviteHandler = await esmock.strict(MODULE_PATH, { - '@overleaf/settings': this.settings, - '../../../../app/src/models/ProjectInvite.js': { - ProjectInvite: this.ProjectInvite, - }, - '@overleaf/logger': this.logger, - '../../../../app/src/Features/Collaborators/CollaboratorsEmailHandler.mjs': - this.CollaboratorsEmailHandler, - '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js': - this.CollaboratorsHandler, - '../../../../app/src/Features/User/UserGetter.js': this.UserGetter, - '../../../../app/src/Features/Project/ProjectGetter.js': - this.ProjectGetter, - '../../../../app/src/Features/Notifications/NotificationsBuilder.js': - this.NotificationsBuilder, - '../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.js': - this.CollaboratorsInviteHelper, - '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter': - this.CollaboratorsInviteGetter, - '../../../../app/src/Features/SplitTests/SplitTestHandler.js': - this.SplitTestHandler, - '../../../../app/src/Features/Subscription/LimitationsManager.js': - this.LimitationsManager, - '../../../../app/src/Features/Project/ProjectAuditLogHandler.js': - this.ProjectAuditLogHandler, - crypto: this.CryptogetAssignmentForUser, - }) + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) - this.projectId = new ObjectId() - this.sendingUserId = new ObjectId() - this.sendingUser = { - _id: this.sendingUserId, + vi.doMock('../../../../app/src/models/ProjectInvite.js', () => ({ + ProjectInvite: ctx.ProjectInvite, + })) + + vi.doMock('@overleaf/logger', () => ({ + default: ctx.logger, + })) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsEmailHandler.mjs', + () => ({ + default: ctx.CollaboratorsEmailHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js', + () => ({ + default: ctx.CollaboratorsHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsBuilder.js', + () => ({ + default: ctx.NotificationsBuilder, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.js', + () => ({ + default: ctx.CollaboratorsInviteHelper, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter', + () => ({ + default: ctx.CollaboratorsInviteGetter, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler.js', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/LimitationsManager.js', + () => ({ + default: ctx.LimitationsManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectAuditLogHandler.js', + () => ({ + default: ctx.ProjectAuditLogHandler, + }) + ) + + vi.doMock('crypto', () => ({ + default: ctx.CryptogetAssignmentForUser, + })) + + ctx.CollaboratorsInviteHandler = (await import(MODULE_PATH)).default + + ctx.projectId = new ObjectId() + ctx.sendingUserId = new ObjectId() + ctx.sendingUser = { + _id: ctx.sendingUserId, name: 'Bob', } - this.email = 'user@example.com' - this.userId = new ObjectId() - this.user = { - _id: this.userId, + ctx.email = 'user@example.com' + ctx.userId = new ObjectId() + ctx.user = { + _id: ctx.userId, email: 'someone@example.com', } - this.inviteId = new ObjectId() - this.token = 'hnhteaosuhtaeosuahs' - this.privileges = 'readAndWrite' - this.fakeInvite = { - _id: this.inviteId, - email: this.email, - token: this.token, - tokenHmac: this.tokenHmac, - sendingUserId: this.sendingUserId, - projectId: this.projectId, - privileges: this.privileges, + ctx.inviteId = new ObjectId() + ctx.token = 'hnhteaosuhtaeosuahs' + ctx.privileges = 'readAndWrite' + ctx.fakeInvite = { + _id: ctx.inviteId, + email: ctx.email, + token: ctx.token, + tokenHmac: ctx.tokenHmac, + sendingUserId: ctx.sendingUserId, + projectId: ctx.projectId, + privileges: ctx.privileges, createdAt: new Date(), } }) describe('inviteToProject', function () { - beforeEach(function () { - this.ProjectInvite.prototype.save.callsFake(async function () { + beforeEach(function (ctx) { + ctx.ProjectInvite.prototype.save.callsFake(async function () { Object.defineProperty(this, 'toObject', { value: function () { return this @@ -147,191 +201,193 @@ describe('CollaboratorsInviteHandler', function () { }) return this }) - this.CollaboratorsInviteHandler.promises._sendMessages = sinon + ctx.CollaboratorsInviteHandler.promises._sendMessages = sinon .stub() .resolves() - this.call = async () => { - return await this.CollaboratorsInviteHandler.promises.inviteToProject( - this.projectId, - this.sendingUser, - this.email, - this.privileges + ctx.call = async () => { + return await ctx.CollaboratorsInviteHandler.promises.inviteToProject( + ctx.projectId, + ctx.sendingUser, + ctx.email, + ctx.privileges ) } }) describe('when all goes well', function () { - it('should produce the invite object', async function () { - const invite = await this.call() + it('should produce the invite object', async function (ctx) { + const invite = await ctx.call() expect(invite).to.not.equal(null) expect(invite).to.not.equal(undefined) expect(invite).to.be.instanceof(Object) expect(invite).to.have.all.keys(['_id', 'email', 'privileges']) }) - it('should have generated a random token', async function () { - await this.call() - this.Crypto.randomBytes.callCount.should.equal(1) + it('should have generated a random token', async function (ctx) { + await ctx.call() + ctx.Crypto.randomBytes.callCount.should.equal(1) }) - it('should have generated a HMAC token', async function () { - await this.call() - this.CollaboratorsInviteHelper.hashInviteToken.callCount.should.equal(1) + it('should have generated a HMAC token', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHelper.hashInviteToken.callCount.should.equal(1) }) - it('should have called ProjectInvite.save', async function () { - await this.call() - this.ProjectInvite.prototype.save.callCount.should.equal(1) + it('should have called ProjectInvite.save', async function (ctx) { + await ctx.call() + ctx.ProjectInvite.prototype.save.callCount.should.equal(1) }) - it('should have called _sendMessages', async function () { - await this.call() - this.CollaboratorsInviteHandler.promises._sendMessages.callCount.should.equal( + it('should have called _sendMessages', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHandler.promises._sendMessages.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises._sendMessages - .calledWith(this.projectId, this.sendingUser) + ctx.CollaboratorsInviteHandler.promises._sendMessages + .calledWith(ctx.projectId, ctx.sendingUser) .should.equal(true) }) }) describe('when saving model produces an error', function () { - beforeEach(function () { - this.ProjectInvite.prototype.save.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.ProjectInvite.prototype.save.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) }) }) describe('_sendMessages', function () { - beforeEach(function () { - this.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite = sinon + beforeEach(function (ctx) { + ctx.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite = sinon .stub() .resolves() - this.CollaboratorsInviteHandler.promises._trySendInviteNotification = - sinon.stub().resolves() - this.call = async () => { - await this.CollaboratorsInviteHandler.promises._sendMessages( - this.projectId, - this.sendingUser, - this.fakeInvite + ctx.CollaboratorsInviteHandler.promises._trySendInviteNotification = sinon + .stub() + .resolves() + ctx.call = async () => { + await ctx.CollaboratorsInviteHandler.promises._sendMessages( + ctx.projectId, + ctx.sendingUser, + ctx.fakeInvite ) } }) describe('when all goes well', function () { - it('should call CollaboratorsEmailHandler.notifyUserOfProjectInvite', async function () { - await this.call() - this.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite.callCount.should.equal( + it('should call CollaboratorsEmailHandler.notifyUserOfProjectInvite', async function (ctx) { + await ctx.call() + ctx.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite.callCount.should.equal( 1 ) - this.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite - .calledWith(this.projectId, this.fakeInvite.email, this.fakeInvite) + ctx.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite + .calledWith(ctx.projectId, ctx.fakeInvite.email, ctx.fakeInvite) .should.equal(true) }) - it('should call _trySendInviteNotification', async function () { - await this.call() - this.CollaboratorsInviteHandler.promises._trySendInviteNotification.callCount.should.equal( + it('should call _trySendInviteNotification', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHandler.promises._trySendInviteNotification.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises._trySendInviteNotification - .calledWith(this.projectId, this.sendingUser, this.fakeInvite) + ctx.CollaboratorsInviteHandler.promises._trySendInviteNotification + .calledWith(ctx.projectId, ctx.sendingUser, ctx.fakeInvite) .should.equal(true) }) }) describe('when CollaboratorsEmailHandler.notifyUserOfProjectInvite produces an error', function () { - beforeEach(function () { - this.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite = - sinon.stub().rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.CollaboratorsEmailHandler.promises.notifyUserOfProjectInvite = sinon + .stub() + .rejects(new Error('woops')) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled - expect(this.logger.err).to.be.calledOnce + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled + expect(ctx.logger.err).to.be.calledOnce }) }) describe('when _trySendInviteNotification produces an error', function () { - beforeEach(function () { - this.CollaboratorsInviteHandler.promises._trySendInviteNotification = + beforeEach(function (ctx) { + ctx.CollaboratorsInviteHandler.promises._trySendInviteNotification = sinon.stub().rejects(new Error('woops')) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled - expect(this.logger.err).to.be.calledOnce + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled + expect(ctx.logger.err).to.be.calledOnce }) }) }) describe('revokeInviteForUser', function () { - beforeEach(function () { - this.targetInvite = { + beforeEach(function (ctx) { + ctx.targetInvite = { _id: new ObjectId(), email: 'fake2@example.org', two: 2, } - this.fakeInvites = [ + ctx.fakeInvites = [ { _id: new ObjectId(), email: 'fake1@example.org', one: 1 }, - this.targetInvite, + ctx.targetInvite, ] - this.fakeInvitesWithoutUser = [ + ctx.fakeInvitesWithoutUser = [ { _id: new ObjectId(), email: 'fake1@example.org', one: 1 }, { _id: new ObjectId(), email: 'fake3@example.org', two: 2 }, ] - this.targetEmail = [{ email: 'fake2@example.org' }] + ctx.targetEmail = [{ email: 'fake2@example.org' }] - this.CollaboratorsInviteGetter.promises.getAllInvites.resolves( - this.fakeInvites + ctx.CollaboratorsInviteGetter.promises.getAllInvites.resolves( + ctx.fakeInvites ) - this.CollaboratorsInviteHandler.promises.revokeInvite = sinon + ctx.CollaboratorsInviteHandler.promises.revokeInvite = sinon .stub() - .resolves(this.targetInvite) + .resolves(ctx.targetInvite) - this.call = async () => { - return await this.CollaboratorsInviteHandler.promises.revokeInviteForUser( - this.projectId, - this.targetEmail + ctx.call = async () => { + return await ctx.CollaboratorsInviteHandler.promises.revokeInviteForUser( + ctx.projectId, + ctx.targetEmail ) } }) describe('for a valid user', function () { - it('should have called CollaboratorsInviteGetter.getAllInvites', async function () { - await this.call() - this.CollaboratorsInviteGetter.promises.getAllInvites.callCount.should.equal( + it('should have called CollaboratorsInviteGetter.getAllInvites', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteGetter.promises.getAllInvites.callCount.should.equal( 1 ) - this.CollaboratorsInviteGetter.promises.getAllInvites - .calledWith(this.projectId) + ctx.CollaboratorsInviteGetter.promises.getAllInvites + .calledWith(ctx.projectId) .should.equal(true) }) - it('should have called revokeInvite', async function () { - await this.call() - this.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( + it('should have called revokeInvite', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises.revokeInvite - .calledWith(this.projectId, this.targetInvite._id) + ctx.CollaboratorsInviteHandler.promises.revokeInvite + .calledWith(ctx.projectId, ctx.targetInvite._id) .should.equal(true) }) }) describe('for a user without an invite in the project', function () { - beforeEach(function () { - this.CollaboratorsInviteGetter.promises.getAllInvites.resolves( - this.fakeInvitesWithoutUser + beforeEach(function (ctx) { + ctx.CollaboratorsInviteGetter.promises.getAllInvites.resolves( + ctx.fakeInvitesWithoutUser ) }) - it('should not have called CollaboratorsInviteHandler.revokeInvite', async function () { - await this.call() - this.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( + it('should not have called CollaboratorsInviteHandler.revokeInvite', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( 0 ) }) @@ -339,142 +395,142 @@ describe('CollaboratorsInviteHandler', function () { }) describe('revokeInvite', function () { - beforeEach(function () { - this.ProjectInvite.findOneAndDelete.returns({ - exec: sinon.stub().resolves(this.fakeInvite), + beforeEach(function (ctx) { + ctx.ProjectInvite.findOneAndDelete.returns({ + exec: sinon.stub().resolves(ctx.fakeInvite), }) - this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification = + ctx.CollaboratorsInviteHandler.promises._tryCancelInviteNotification = sinon.stub().resolves() - this.call = async () => { - return await this.CollaboratorsInviteHandler.promises.revokeInvite( - this.projectId, - this.inviteId + ctx.call = async () => { + return await ctx.CollaboratorsInviteHandler.promises.revokeInvite( + ctx.projectId, + ctx.inviteId ) } }) describe('when all goes well', function () { - it('should call ProjectInvite.findOneAndDelete', async function () { - await this.call() - this.ProjectInvite.findOneAndDelete.should.have.been.calledOnce - this.ProjectInvite.findOneAndDelete.should.have.been.calledWith({ - projectId: this.projectId, - _id: this.inviteId, + it('should call ProjectInvite.findOneAndDelete', async function (ctx) { + await ctx.call() + ctx.ProjectInvite.findOneAndDelete.should.have.been.calledOnce + ctx.ProjectInvite.findOneAndDelete.should.have.been.calledWith({ + projectId: ctx.projectId, + _id: ctx.inviteId, }) }) - it('should call _tryCancelInviteNotification', async function () { - await this.call() - this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification.callCount.should.equal( + it('should call _tryCancelInviteNotification', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHandler.promises._tryCancelInviteNotification.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification - .calledWith(this.inviteId) + ctx.CollaboratorsInviteHandler.promises._tryCancelInviteNotification + .calledWith(ctx.inviteId) .should.equal(true) }) - it('should return the deleted invite', async function () { - const invite = await this.call() - expect(invite).to.deep.equal(this.fakeInvite) + it('should return the deleted invite', async function (ctx) { + const invite = await ctx.call() + expect(invite).to.deep.equal(ctx.fakeInvite) }) }) describe('when remove produces an error', function () { - beforeEach(function () { - this.ProjectInvite.findOneAndDelete.returns({ + beforeEach(function (ctx) { + ctx.ProjectInvite.findOneAndDelete.returns({ exec: sinon.stub().rejects(new Error('woops')), }) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) }) }) describe('generateNewInvite', function () { - beforeEach(function () { - this.fakeInviteToProjectObject = { + beforeEach(function (ctx) { + ctx.fakeInviteToProjectObject = { _id: new ObjectId(), - email: this.email, - privileges: this.privileges, + email: ctx.email, + privileges: ctx.privileges, } - this.CollaboratorsInviteHandler.promises.revokeInvite = sinon + ctx.CollaboratorsInviteHandler.promises.revokeInvite = sinon .stub() - .resolves(this.fakeInvite) - this.CollaboratorsInviteHandler.promises.inviteToProject = sinon + .resolves(ctx.fakeInvite) + ctx.CollaboratorsInviteHandler.promises.inviteToProject = sinon .stub() - .resolves(this.fakeInviteToProjectObject) - this.call = async () => { - return await this.CollaboratorsInviteHandler.promises.generateNewInvite( - this.projectId, - this.sendingUser, - this.inviteId + .resolves(ctx.fakeInviteToProjectObject) + ctx.call = async () => { + return await ctx.CollaboratorsInviteHandler.promises.generateNewInvite( + ctx.projectId, + ctx.sendingUser, + ctx.inviteId ) } }) describe('when all goes well', function () { - it('should call revokeInvite', async function () { - await this.call() - this.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( + it('should call revokeInvite', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHandler.promises.revokeInvite.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises.revokeInvite - .calledWith(this.projectId, this.inviteId) + ctx.CollaboratorsInviteHandler.promises.revokeInvite + .calledWith(ctx.projectId, ctx.inviteId) .should.equal(true) }) - it('should have called inviteToProject', async function () { - await this.call() - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should have called inviteToProject', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises.inviteToProject + ctx.CollaboratorsInviteHandler.promises.inviteToProject .calledWith( - this.projectId, - this.sendingUser, - this.fakeInvite.email, - this.fakeInvite.privileges + ctx.projectId, + ctx.sendingUser, + ctx.fakeInvite.email, + ctx.fakeInvite.privileges ) .should.equal(true) }) - it('should return the invite', async function () { - const invite = await this.call() - expect(invite).to.deep.equal(this.fakeInviteToProjectObject) + it('should return the invite', async function (ctx) { + const invite = await ctx.call() + expect(invite).to.deep.equal(ctx.fakeInviteToProjectObject) }) }) describe('when revokeInvite produces an error', function () { - beforeEach(function () { - this.CollaboratorsInviteHandler.promises.revokeInvite = sinon + beforeEach(function (ctx) { + ctx.CollaboratorsInviteHandler.promises.revokeInvite = sinon .stub() .rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) - it('should not have called inviteToProject', async function () { - await expect(this.call()).to.be.rejected - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should not have called inviteToProject', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 0 ) }) }) describe('when findOne does not find an invite', function () { - beforeEach(function () { - this.CollaboratorsInviteHandler.promises.revokeInvite = sinon + beforeEach(function (ctx) { + ctx.CollaboratorsInviteHandler.promises.revokeInvite = sinon .stub() .resolves(null) }) - it('should not have called inviteToProject', async function () { - await this.call() - this.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( + it('should not have called inviteToProject', async function (ctx) { + await ctx.call() + ctx.CollaboratorsInviteHandler.promises.inviteToProject.callCount.should.equal( 0 ) }) @@ -482,91 +538,91 @@ describe('CollaboratorsInviteHandler', function () { }) describe('acceptInvite', function () { - beforeEach(function () { - this.fakeProject = { - _id: this.projectId, - owner_ref: this.sendingUserId, + beforeEach(function (ctx) { + ctx.fakeProject = { + _id: ctx.projectId, + owner_ref: ctx.sendingUserId, } - this.ProjectGetter.promises.getProject = sinon + ctx.ProjectGetter.promises.getProject = sinon .stub() - .resolves(this.fakeProject) - this.CollaboratorsHandler.promises.addUserIdToProject.resolves() - this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification = + .resolves(ctx.fakeProject) + ctx.CollaboratorsHandler.promises.addUserIdToProject.resolves() + ctx.CollaboratorsInviteHandler.promises._tryCancelInviteNotification = sinon.stub().resolves() - this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( true ) - this.ProjectInvite.deleteOne.returns({ exec: sinon.stub().resolves() }) - this.call = async () => { - await this.CollaboratorsInviteHandler.promises.acceptInvite( - this.fakeInvite, - this.projectId, - this.user + ctx.ProjectInvite.deleteOne.returns({ exec: sinon.stub().resolves() }) + ctx.call = async () => { + await ctx.CollaboratorsInviteHandler.promises.acceptInvite( + ctx.fakeInvite, + ctx.projectId, + ctx.user ) } }) describe('when all goes well', function () { - it('should add readAndWrite invitees to the project as normal', async function () { - await this.call() - this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith( - this.projectId, - this.sendingUserId, - this.userId, - this.fakeInvite.privileges + it('should add readAndWrite invitees to the project as normal', async function (ctx) { + await ctx.call() + ctx.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith( + ctx.projectId, + ctx.sendingUserId, + ctx.userId, + ctx.fakeInvite.privileges ) }) - it('should have called ProjectInvite.deleteOne', async function () { - await this.call() - this.ProjectInvite.deleteOne.callCount.should.equal(1) - this.ProjectInvite.deleteOne - .calledWith({ _id: this.inviteId }) + it('should have called ProjectInvite.deleteOne', async function (ctx) { + await ctx.call() + ctx.ProjectInvite.deleteOne.callCount.should.equal(1) + ctx.ProjectInvite.deleteOne + .calledWith({ _id: ctx.inviteId }) .should.equal(true) }) }) describe('when the invite is for readOnly access', function () { - beforeEach(function () { - this.fakeInvite.privileges = 'readOnly' + beforeEach(function (ctx) { + ctx.fakeInvite.privileges = 'readOnly' }) - it('should have called CollaboratorsHandler.addUserIdToProject', async function () { - await this.call() - this.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal( + it('should have called CollaboratorsHandler.addUserIdToProject', async function (ctx) { + await ctx.call() + ctx.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal( 1 ) - this.CollaboratorsHandler.promises.addUserIdToProject + ctx.CollaboratorsHandler.promises.addUserIdToProject .calledWith( - this.projectId, - this.sendingUserId, - this.userId, - this.fakeInvite.privileges + ctx.projectId, + ctx.sendingUserId, + ctx.userId, + ctx.fakeInvite.privileges ) .should.equal(true) }) }) describe('when the project has no more edit collaborator slots', function () { - beforeEach(function () { - this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + beforeEach(function (ctx) { + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( false ) }) - it('should add readAndWrite invitees to the project as readOnly (pendingEditor) users', async function () { - await this.call() - this.ProjectAuditLogHandler.promises.addEntry.should.have.been.calledWith( - this.projectId, + it('should add readAndWrite invitees to the project as readOnly (pendingEditor) users', async function (ctx) { + await ctx.call() + ctx.ProjectAuditLogHandler.promises.addEntry.should.have.been.calledWith( + ctx.projectId, 'editor-moved-to-pending', null, null, - { userId: this.userId.toString(), role: 'editor' } + { userId: ctx.userId.toString(), role: 'editor' } ) - this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith( - this.projectId, - this.sendingUserId, - this.userId, + ctx.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith( + ctx.projectId, + ctx.sendingUserId, + ctx.userId, 'readOnly', { pendingEditor: true } ) @@ -574,139 +630,139 @@ describe('CollaboratorsInviteHandler', function () { }) describe('when addUserIdToProject produces an error', function () { - beforeEach(function () { - this.CollaboratorsHandler.promises.addUserIdToProject.callsArgWith( + beforeEach(function (ctx) { + ctx.CollaboratorsHandler.promises.addUserIdToProject.callsArgWith( 4, new Error('woops') ) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) - it('should have called CollaboratorsHandler.addUserIdToProject', async function () { - await expect(this.call()).to.be.rejected - this.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal( + it('should have called CollaboratorsHandler.addUserIdToProject', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal( 1 ) - this.CollaboratorsHandler.promises.addUserIdToProject + ctx.CollaboratorsHandler.promises.addUserIdToProject .calledWith( - this.projectId, - this.sendingUserId, - this.userId, - this.fakeInvite.privileges + ctx.projectId, + ctx.sendingUserId, + ctx.userId, + ctx.fakeInvite.privileges ) .should.equal(true) }) - it('should not have called ProjectInvite.deleteOne', async function () { - await expect(this.call()).to.be.rejected - this.ProjectInvite.deleteOne.callCount.should.equal(0) + it('should not have called ProjectInvite.deleteOne', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.ProjectInvite.deleteOne.callCount.should.equal(0) }) }) describe('when ProjectInvite.deleteOne produces an error', function () { - beforeEach(function () { - this.ProjectInvite.deleteOne.returns({ + beforeEach(function (ctx) { + ctx.ProjectInvite.deleteOne.returns({ exec: sinon.stub().rejects(new Error('woops')), }) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) - it('should have called CollaboratorsHandler.addUserIdToProject', async function () { - await expect(this.call()).to.be.rejected - this.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal( + it('should have called CollaboratorsHandler.addUserIdToProject', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.CollaboratorsHandler.promises.addUserIdToProject.callCount.should.equal( 1 ) - this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith( - this.projectId, - this.sendingUserId, - this.userId, - this.fakeInvite.privileges + ctx.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith( + ctx.projectId, + ctx.sendingUserId, + ctx.userId, + ctx.fakeInvite.privileges ) }) - it('should have called ProjectInvite.deleteOne', async function () { - await expect(this.call()).to.be.rejected - this.ProjectInvite.deleteOne.callCount.should.equal(1) + it('should have called ProjectInvite.deleteOne', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.ProjectInvite.deleteOne.callCount.should.equal(1) }) }) }) describe('_tryCancelInviteNotification', function () { - beforeEach(function () { - this.inviteId = new ObjectId() - this.currentUser = { _id: new ObjectId() } - this.notification = { read: sinon.stub().resolves() } - this.NotificationsBuilder.promises.projectInvite = sinon + beforeEach(function (ctx) { + ctx.inviteId = new ObjectId() + ctx.currentUser = { _id: new ObjectId() } + ctx.notification = { read: sinon.stub().resolves() } + ctx.NotificationsBuilder.promises.projectInvite = sinon .stub() - .returns(this.notification) - this.call = async () => { - await this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification( - this.inviteId + .returns(ctx.notification) + ctx.call = async () => { + await ctx.CollaboratorsInviteHandler.promises._tryCancelInviteNotification( + ctx.inviteId ) } }) - it('should call notification.read', async function () { - await this.call() - this.notification.read.callCount.should.equal(1) + it('should call notification.read', async function (ctx) { + await ctx.call() + ctx.notification.read.callCount.should.equal(1) }) describe('when notification.read produces an error', function () { - beforeEach(function () { - this.notification = { + beforeEach(function (ctx) { + ctx.notification = { read: sinon.stub().rejects(new Error('woops')), } - this.NotificationsBuilder.promises.projectInvite = sinon + ctx.NotificationsBuilder.promises.projectInvite = sinon .stub() - .returns(this.notification) + .returns(ctx.notification) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejected + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejected }) }) }) describe('_trySendInviteNotification', function () { - beforeEach(function () { - this.invite = { + beforeEach(function (ctx) { + ctx.invite = { _id: new ObjectId(), token: 'some_token', sendingUserId: new ObjectId(), - projectId: this.project_id, + projectId: ctx.project_id, targetEmail: 'user@example.com', createdAt: new Date(), } - this.sendingUser = { + ctx.sendingUser = { _id: new ObjectId(), first_name: 'jim', } - this.existingUser = { _id: new ObjectId() } - this.UserGetter.promises.getUserByAnyEmail = sinon + ctx.existingUser = { _id: new ObjectId() } + ctx.UserGetter.promises.getUserByAnyEmail = sinon .stub() - .resolves(this.existingUser) - this.fakeProject = { - _id: this.project_id, + .resolves(ctx.existingUser) + ctx.fakeProject = { + _id: ctx.project_id, name: 'some project', } - this.ProjectGetter.promises.getProject = sinon + ctx.ProjectGetter.promises.getProject = sinon .stub() - .resolves(this.fakeProject) - this.notification = { create: sinon.stub().resolves() } - this.NotificationsBuilder.promises.projectInvite = sinon + .resolves(ctx.fakeProject) + ctx.notification = { create: sinon.stub().resolves() } + ctx.NotificationsBuilder.promises.projectInvite = sinon .stub() - .returns(this.notification) - this.call = async () => { - await this.CollaboratorsInviteHandler.promises._trySendInviteNotification( - this.project_id, - this.sendingUser, - this.invite + .returns(ctx.notification) + ctx.call = async () => { + await ctx.CollaboratorsInviteHandler.promises._trySendInviteNotification( + ctx.project_id, + ctx.sendingUser, + ctx.invite ) } }) @@ -714,119 +770,119 @@ describe('CollaboratorsInviteHandler', function () { describe('when the user exists', function () { beforeEach(function () {}) - it('should call getUser', async function () { - await this.call() - this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) - this.UserGetter.promises.getUserByAnyEmail - .calledWith(this.invite.email) + it('should call getUser', async function (ctx) { + await ctx.call() + ctx.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) + ctx.UserGetter.promises.getUserByAnyEmail + .calledWith(ctx.invite.email) .should.equal(true) }) - it('should call getProject', async function () { - await this.call() - this.ProjectGetter.promises.getProject.callCount.should.equal(1) - this.ProjectGetter.promises.getProject - .calledWith(this.project_id) + it('should call getProject', async function (ctx) { + await ctx.call() + ctx.ProjectGetter.promises.getProject.callCount.should.equal(1) + ctx.ProjectGetter.promises.getProject + .calledWith(ctx.project_id) .should.equal(true) }) - it('should call NotificationsBuilder.projectInvite.create', async function () { - await this.call() - this.NotificationsBuilder.promises.projectInvite.callCount.should.equal( + it('should call NotificationsBuilder.projectInvite.create', async function (ctx) { + await ctx.call() + ctx.NotificationsBuilder.promises.projectInvite.callCount.should.equal( 1 ) - this.notification.create.callCount.should.equal(1) + ctx.notification.create.callCount.should.equal(1) }) describe('when getProject produces an error', function () { - beforeEach(function () { - this.ProjectGetter.promises.getProject.callsArgWith( + beforeEach(function (ctx) { + ctx.ProjectGetter.promises.getProject.callsArgWith( 2, new Error('woops') ) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) - it('should not call NotificationsBuilder.projectInvite.create', async function () { - await expect(this.call()).to.be.rejected - this.NotificationsBuilder.promises.projectInvite.callCount.should.equal( + it('should not call NotificationsBuilder.projectInvite.create', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.NotificationsBuilder.promises.projectInvite.callCount.should.equal( 0 ) - this.notification.create.callCount.should.equal(0) + ctx.notification.create.callCount.should.equal(0) }) }) describe('when projectInvite.create produces an error', function () { - beforeEach(function () { - this.notification.create.callsArgWith(0, new Error('woops')) + beforeEach(function (ctx) { + ctx.notification.create.callsArgWith(0, new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) }) }) describe('when the user does not exist', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByAnyEmail = sinon.stub().resolves(null) + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail = sinon.stub().resolves(null) }) - it('should call getUser', async function () { - await this.call() - this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) - this.UserGetter.promises.getUserByAnyEmail - .calledWith(this.invite.email) + it('should call getUser', async function (ctx) { + await ctx.call() + ctx.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) + ctx.UserGetter.promises.getUserByAnyEmail + .calledWith(ctx.invite.email) .should.equal(true) }) - it('should not call getProject', async function () { - await this.call() - this.ProjectGetter.promises.getProject.callCount.should.equal(0) + it('should not call getProject', async function (ctx) { + await ctx.call() + ctx.ProjectGetter.promises.getProject.callCount.should.equal(0) }) - it('should not call NotificationsBuilder.projectInvite.create', async function () { - await this.call() - this.NotificationsBuilder.promises.projectInvite.callCount.should.equal( + it('should not call NotificationsBuilder.projectInvite.create', async function (ctx) { + await ctx.call() + ctx.NotificationsBuilder.promises.projectInvite.callCount.should.equal( 0 ) - this.notification.create.callCount.should.equal(0) + ctx.notification.create.callCount.should.equal(0) }) }) describe('when the getUser produces an error', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByAnyEmail = sinon + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail = sinon .stub() .rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) - it('should call getUser', async function () { - await expect(this.call()).to.be.rejected - this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) - this.UserGetter.promises.getUserByAnyEmail - .calledWith(this.invite.email) + it('should call getUser', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) + ctx.UserGetter.promises.getUserByAnyEmail + .calledWith(ctx.invite.email) .should.equal(true) }) - it('should not call getProject', async function () { - await expect(this.call()).to.be.rejected - this.ProjectGetter.promises.getProject.callCount.should.equal(0) + it('should not call getProject', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.ProjectGetter.promises.getProject.callCount.should.equal(0) }) - it('should not call NotificationsBuilder.projectInvite.create', async function () { - await expect(this.call()).to.be.rejected - this.NotificationsBuilder.promises.projectInvite.callCount.should.equal( + it('should not call NotificationsBuilder.projectInvite.create', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.NotificationsBuilder.promises.projectInvite.callCount.should.equal( 0 ) - this.notification.create.callCount.should.equal(0) + ctx.notification.create.callCount.should.equal(0) }) }) }) diff --git a/services/web/test/unit/src/Contact/ContactController.test.mjs b/services/web/test/unit/src/Contact/ContactController.test.mjs index ea5a1d0220..2defc2c3a7 100644 --- a/services/web/test/unit/src/Contact/ContactController.test.mjs +++ b/services/web/test/unit/src/Contact/ContactController.test.mjs @@ -1,34 +1,47 @@ +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' -import esmock from 'esmock' import MockResponse from '../helpers/MockResponse.js' const modulePath = '../../../../app/src/Features/Contacts/ContactController.mjs' describe('ContactController', function () { - beforeEach(async function () { - this.SessionManager = { getLoggedInUserId: sinon.stub() } - this.ContactController = await esmock.strict(modulePath, { - '../../../../app/src/Features/User/UserGetter': (this.UserGetter = { + beforeEach(async function (ctx) { + ctx.SessionManager = { getLoggedInUserId: sinon.stub() } + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = { promises: {}, }), - '../../../../app/src/Features/Contacts/ContactManager': - (this.ContactManager = { promises: {} }), - '../../../../app/src/Features/Authentication/SessionManager': - (this.SessionManager = {}), - '../../../../app/src/infrastructure/Modules': (this.Modules = { + })) + + vi.doMock('../../../../app/src/Features/Contacts/ContactManager', () => ({ + default: (ctx.ContactManager = { promises: {} }), + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: (ctx.SessionManager = {}), + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: (ctx.Modules = { promises: { hooks: {} }, }), - }) + })) - this.req = {} - this.res = new MockResponse() + ctx.ContactController = (await import(modulePath)).default + + ctx.req = {} + ctx.res = new MockResponse() }) describe('getContacts', function () { - beforeEach(function () { - this.user_id = 'mock-user-id' - this.contact_ids = ['contact-1', 'contact-2', 'contact-3'] - this.contacts = [ + beforeEach(function (ctx) { + ctx.user_id = 'mock-user-id' + ctx.contact_ids = ['contact-1', 'contact-2', 'contact-3'] + ctx.contacts = [ { _id: 'contact-1', email: 'joe@example.com', @@ -52,78 +65,84 @@ describe('ContactController', function () { unsued: 'foo', }, ] - this.SessionManager.getLoggedInUserId = sinon.stub().returns(this.user_id) - this.ContactManager.promises.getContactIds = sinon + ctx.SessionManager.getLoggedInUserId = sinon.stub().returns(ctx.user_id) + ctx.ContactManager.promises.getContactIds = sinon .stub() - .resolves(this.contact_ids) - this.UserGetter.promises.getUsers = sinon.stub().resolves(this.contacts) - this.Modules.promises.hooks.fire = sinon.stub() + .resolves(ctx.contact_ids) + ctx.UserGetter.promises.getUsers = sinon.stub().resolves(ctx.contacts) + ctx.Modules.promises.hooks.fire = sinon.stub() }) - it('should look up the logged in user id', async function () { - this.ContactController.getContacts(this.req, this.res) - this.SessionManager.getLoggedInUserId - .calledWith(this.req.session) + it('should look up the logged in user id', async function (ctx) { + ctx.ContactController.getContacts(ctx.req, ctx.res) + ctx.SessionManager.getLoggedInUserId + .calledWith(ctx.req.session) .should.equal(true) }) - it('should get the users contact ids', async function () { - this.res.callback = () => { + it('should get the users contact ids', async function (ctx) { + ctx.res.callback = () => { expect( - this.ContactManager.promises.getContactIds - ).to.have.been.calledWith(this.user_id, { limit: 50 }) + ctx.ContactManager.promises.getContactIds + ).to.have.been.calledWith(ctx.user_id, { limit: 50 }) } - this.ContactController.getContacts(this.req, this.res) + ctx.ContactController.getContacts(ctx.req, ctx.res) }) - it('should populate the users contacts ids', function (done) { - this.res.callback = () => { - expect(this.UserGetter.promises.getUsers).to.have.been.calledWith( - this.contact_ids, - { - email: 1, - first_name: 1, - last_name: 1, - holdingAccount: 1, - } - ) - done() - } - this.ContactController.getContacts(this.req, this.res, done) + it('should populate the users contacts ids', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + expect(ctx.UserGetter.promises.getUsers).to.have.been.calledWith( + ctx.contact_ids, + { + email: 1, + first_name: 1, + last_name: 1, + holdingAccount: 1, + } + ) + resolve() + } + ctx.ContactController.getContacts(ctx.req, ctx.res, resolve) + }) }) - it('should fire the getContact module hook', function (done) { - this.res.callback = () => { - expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( - 'getContacts', - this.user_id - ) - done() - } - this.ContactController.getContacts(this.req, this.res, done) + it('should fire the getContact module hook', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'getContacts', + ctx.user_id + ) + resolve() + } + ctx.ContactController.getContacts(ctx.req, ctx.res, resolve) + }) }) - it('should return a formatted list of contacts in contact list order, without holding accounts', function (done) { - this.res.callback = () => { - this.res.json.args[0][0].contacts.should.deep.equal([ - { - id: 'contact-1', - email: 'joe@example.com', - first_name: 'Joe', - last_name: 'Example', - type: 'user', - }, - { - id: 'contact-3', - email: 'jim@example.com', - first_name: 'Jim', - last_name: 'Example', - type: 'user', - }, - ]) - done() - } - this.ContactController.getContacts(this.req, this.res, done) + it('should return a formatted list of contacts in contact list order, without holding accounts', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.json.args[0][0].contacts.should.deep.equal([ + { + id: 'contact-1', + email: 'joe@example.com', + first_name: 'Joe', + last_name: 'Example', + type: 'user', + }, + { + id: 'contact-3', + email: 'jim@example.com', + first_name: 'Jim', + last_name: 'Example', + type: 'user', + }, + ]) + resolve() + } + ctx.ContactController.getContacts(ctx.req, ctx.res, resolve) + }) }) }) }) diff --git a/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs b/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs index 22d05fba56..2bb1ed81dd 100644 --- a/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs +++ b/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs @@ -1,15 +1,4 @@ -/* eslint-disable - max-len, - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' const modulePath = new URL( @@ -18,116 +7,119 @@ const modulePath = new URL( ).pathname describe('CooldownMiddleware', function () { - beforeEach(async function () { - this.CooldownManager = { isProjectOnCooldown: sinon.stub() } - return (this.CooldownMiddleware = await esmock.strict(modulePath, { - '../../../../app/src/Features/Cooldown/CooldownManager.js': - this.CooldownManager, - })) + beforeEach(async function (ctx) { + ctx.CooldownManager = { isProjectOnCooldown: sinon.stub() } + + vi.doMock( + '../../../../app/src/Features/Cooldown/CooldownManager.js', + () => ({ + default: ctx.CooldownManager, + }) + ) + + ctx.CooldownMiddleware = (await import(modulePath)).default }) describe('freezeProject', function () { describe('when project is on cooldown', function () { - beforeEach(function () { - this.CooldownManager.isProjectOnCooldown = sinon + beforeEach(function (ctx) { + ctx.CooldownManager.isProjectOnCooldown = sinon .stub() .callsArgWith(1, null, true) - this.req = { params: { Project_id: 'abc' } } - this.res = { sendStatus: sinon.stub() } - return (this.next = sinon.stub()) + ctx.req = { params: { Project_id: 'abc' } } + ctx.res = { sendStatus: sinon.stub() } + return (ctx.next = sinon.stub()) }) - it('should call CooldownManager.isProjectOnCooldown', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1) - return this.CooldownManager.isProjectOnCooldown + it('should call CooldownManager.isProjectOnCooldown', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + ctx.CooldownManager.isProjectOnCooldown.callCount.should.equal(1) + return ctx.CooldownManager.isProjectOnCooldown .calledWith('abc') .should.equal(true) }) - it('should not produce an error', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - return this.next.callCount.should.equal(0) + it('should not produce an error', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + return ctx.next.callCount.should.equal(0) }) - it('should send a 429 status', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - this.res.sendStatus.callCount.should.equal(1) - return this.res.sendStatus.calledWith(429).should.equal(true) + it('should send a 429 status', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + ctx.res.sendStatus.callCount.should.equal(1) + return ctx.res.sendStatus.calledWith(429).should.equal(true) }) }) describe('when project is not on cooldown', function () { - beforeEach(function () { - this.CooldownManager.isProjectOnCooldown = sinon + beforeEach(function (ctx) { + ctx.CooldownManager.isProjectOnCooldown = sinon .stub() .callsArgWith(1, null, false) - this.req = { params: { Project_id: 'abc' } } - this.res = { sendStatus: sinon.stub() } - return (this.next = sinon.stub()) + ctx.req = { params: { Project_id: 'abc' } } + ctx.res = { sendStatus: sinon.stub() } + return (ctx.next = sinon.stub()) }) - it('should call CooldownManager.isProjectOnCooldown', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1) - return this.CooldownManager.isProjectOnCooldown + it('should call CooldownManager.isProjectOnCooldown', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + ctx.CooldownManager.isProjectOnCooldown.callCount.should.equal(1) + return ctx.CooldownManager.isProjectOnCooldown .calledWith('abc') .should.equal(true) }) - it('call next with no arguments', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - this.next.callCount.should.equal(1) - return expect(this.next.lastCall.args.length).to.equal(0) + it('call next with no arguments', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + ctx.next.callCount.should.equal(1) + return expect(ctx.next.lastCall.args.length).to.equal(0) }) }) describe('when isProjectOnCooldown produces an error', function () { - beforeEach(function () { - this.CooldownManager.isProjectOnCooldown = sinon + beforeEach(function (ctx) { + ctx.CooldownManager.isProjectOnCooldown = sinon .stub() .callsArgWith(1, new Error('woops')) - this.req = { params: { Project_id: 'abc' } } - this.res = { sendStatus: sinon.stub() } - return (this.next = sinon.stub()) + ctx.req = { params: { Project_id: 'abc' } } + ctx.res = { sendStatus: sinon.stub() } + return (ctx.next = sinon.stub()) }) - it('should call CooldownManager.isProjectOnCooldown', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1) - return this.CooldownManager.isProjectOnCooldown + it('should call CooldownManager.isProjectOnCooldown', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + ctx.CooldownManager.isProjectOnCooldown.callCount.should.equal(1) + return ctx.CooldownManager.isProjectOnCooldown .calledWith('abc') .should.equal(true) }) - it('call next with an error', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - this.next.callCount.should.equal(1) - return expect(this.next.lastCall.args[0]).to.be.instanceof(Error) + it('call next with an error', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + ctx.next.callCount.should.equal(1) + return expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error) }) }) describe('when projectId is not part of route', function () { - beforeEach(function () { - this.CooldownManager.isProjectOnCooldown = sinon + beforeEach(function (ctx) { + ctx.CooldownManager.isProjectOnCooldown = sinon .stub() .callsArgWith(1, null, true) - this.req = { params: { lol: 'abc' } } - this.res = { sendStatus: sinon.stub() } - return (this.next = sinon.stub()) + ctx.req = { params: { lol: 'abc' } } + ctx.res = { sendStatus: sinon.stub() } + return (ctx.next = sinon.stub()) }) - it('call next with an error', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - this.next.callCount.should.equal(1) - return expect(this.next.lastCall.args[0]).to.be.instanceof(Error) + it('call next with an error', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + ctx.next.callCount.should.equal(1) + return expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error) }) - it('should not call CooldownManager.isProjectOnCooldown', function () { - this.CooldownMiddleware.freezeProject(this.req, this.res, this.next) - return this.CooldownManager.isProjectOnCooldown.callCount.should.equal( - 0 - ) + it('should not call CooldownManager.isProjectOnCooldown', function (ctx) { + ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next) + return ctx.CooldownManager.isProjectOnCooldown.callCount.should.equal(0) }) }) }) diff --git a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs index 6a783d452e..095e598d39 100644 --- a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs +++ b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs @@ -1,92 +1,102 @@ +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' -import esmock from 'esmock' import MockResponse from '../helpers/MockResponse.js' const MODULE_PATH = '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterController.mjs' describe('DocumentUpdaterController', function () { - beforeEach(async function () { - this.DocumentUpdaterHandler = { + beforeEach(async function (ctx) { + ctx.DocumentUpdaterHandler = { promises: { getDocument: sinon.stub(), }, } - this.ProjectLocator = { + ctx.ProjectLocator = { promises: { findElement: sinon.stub(), }, } - this.controller = await esmock.strict(MODULE_PATH, { - '@overleaf/settings': this.settings, - '../../../../app/src/Features/Project/ProjectLocator.js': - this.ProjectLocator, - '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js': - this.DocumentUpdaterHandler, - }) - this.projectId = '2k3j1lk3j21lk3j' - this.fileId = '12321kklj1lk3jk12' - this.req = { + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectLocator.js', () => ({ + default: ctx.ProjectLocator, + })) + + vi.doMock( + '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js', + () => ({ + default: ctx.DocumentUpdaterHandler, + }) + ) + + ctx.controller = (await import(MODULE_PATH)).default + ctx.projectId = '2k3j1lk3j21lk3j' + ctx.fileId = '12321kklj1lk3jk12' + ctx.req = { params: { - Project_id: this.projectId, - Doc_id: this.docId, + Project_id: ctx.projectId, + Doc_id: ctx.docId, }, get(key) { return undefined }, } - this.lines = ['test', '', 'testing'] - this.res = new MockResponse() - this.next = sinon.stub() - this.doc = { name: 'myfile.tex' } + ctx.lines = ['test', '', 'testing'] + ctx.res = new MockResponse() + ctx.next = sinon.stub() + ctx.doc = { name: 'myfile.tex' } }) describe('getDoc', function () { - beforeEach(function () { - this.DocumentUpdaterHandler.promises.getDocument.resolves({ - lines: this.lines, + beforeEach(function (ctx) { + ctx.DocumentUpdaterHandler.promises.getDocument.resolves({ + lines: ctx.lines, }) - this.ProjectLocator.promises.findElement.resolves({ - element: this.doc, + ctx.ProjectLocator.promises.findElement.resolves({ + element: ctx.doc, }) - this.res = new MockResponse() + ctx.res = new MockResponse() }) - it('should call the document updater handler with the project_id and doc_id', async function () { - await this.controller.getDoc(this.req, this.res, this.next) + it('should call the document updater handler with the project_id and doc_id', async function (ctx) { + await ctx.controller.getDoc(ctx.req, ctx.res, ctx.next) expect( - this.DocumentUpdaterHandler.promises.getDocument + ctx.DocumentUpdaterHandler.promises.getDocument ).to.have.been.calledOnceWith( - this.req.params.Project_id, - this.req.params.Doc_id, + ctx.req.params.Project_id, + ctx.req.params.Doc_id, -1 ) }) - it('should return the content', async function () { - await this.controller.getDoc(this.req, this.res) - expect(this.next).to.not.have.been.called - expect(this.res.statusCode).to.equal(200) - expect(this.res.body).to.equal('test\n\ntesting') + it('should return the content', async function (ctx) { + await ctx.controller.getDoc(ctx.req, ctx.res) + expect(ctx.next).to.not.have.been.called + expect(ctx.res.statusCode).to.equal(200) + expect(ctx.res.body).to.equal('test\n\ntesting') }) - it('should find the doc in the project', async function () { - await this.controller.getDoc(this.req, this.res) + it('should find the doc in the project', async function (ctx) { + await ctx.controller.getDoc(ctx.req, ctx.res) expect( - this.ProjectLocator.promises.findElement + ctx.ProjectLocator.promises.findElement ).to.have.been.calledOnceWith({ - project_id: this.projectId, - element_id: this.docId, + project_id: ctx.projectId, + element_id: ctx.docId, type: 'doc', }) }) - it('should set the Content-Disposition header', async function () { - await this.controller.getDoc(this.req, this.res) - expect(this.res.setContentDisposition).to.have.been.calledWith( + it('should set the Content-Disposition header', async function (ctx) { + await ctx.controller.getDoc(ctx.req, ctx.res) + expect(ctx.res.setContentDisposition).to.have.been.calledWith( 'attachment', - { filename: this.doc.name } + { filename: ctx.doc.name } ) }) }) diff --git a/services/web/test/unit/src/Documents/DocumentController.test.mjs b/services/web/test/unit/src/Documents/DocumentController.test.mjs index 813e8d65f3..06c971be91 100644 --- a/services/web/test/unit/src/Documents/DocumentController.test.mjs +++ b/services/web/test/unit/src/Documents/DocumentController.test.mjs @@ -1,5 +1,5 @@ +import { vi } from 'vitest' import sinon from 'sinon' -import esmock from 'esmock' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import Errors from '../../../../app/src/Features/Errors/Errors.js' @@ -8,14 +8,14 @@ const MODULE_PATH = '../../../../app/src/Features/Documents/DocumentController.mjs' describe('DocumentController', function () { - beforeEach(async function () { - this.res = new MockResponse() - this.req = new MockRequest() - this.next = sinon.stub() - this.doc = { _id: 'doc-id-123' } - this.doc_lines = ['one', 'two', 'three'] - this.version = 42 - this.ranges = { + beforeEach(async function (ctx) { + ctx.res = new MockResponse() + ctx.req = new MockRequest() + ctx.next = sinon.stub() + ctx.doc = { _id: 'doc-id-123' } + ctx.doc_lines = ['one', 'two', 'three'] + ctx.version = 42 + ctx.ranges = { comments: [ { id: 'comment1', @@ -35,11 +35,11 @@ describe('DocumentController', function () { }, ], } - this.pathname = '/a/b/c/file.tex' - this.lastUpdatedAt = new Date().getTime() - this.lastUpdatedBy = 'fake-last-updater-id' - this.rev = 5 - this.project = { + ctx.pathname = '/a/b/c/file.tex' + ctx.lastUpdatedAt = new Date().getTime() + ctx.lastUpdatedBy = 'fake-last-updater-id' + ctx.rev = 5 + ctx.project = { _id: 'project-id-123', overleaf: { history: { @@ -48,81 +48,100 @@ describe('DocumentController', function () { }, }, } - this.resolvedThreadIds = [ + ctx.resolvedThreadIds = [ 'comment2', 'comment4', // Comment in project but not in doc ] - this.ProjectGetter = { + ctx.ProjectGetter = { promises: { - getProject: sinon.stub().resolves(this.project), + getProject: sinon.stub().resolves(ctx.project), }, } - this.ProjectLocator = { + ctx.ProjectLocator = { promises: { findElement: sinon .stub() - .resolves({ element: this.doc, path: { fileSystem: this.pathname } }), + .resolves({ element: ctx.doc, path: { fileSystem: ctx.pathname } }), }, } - this.ProjectEntityHandler = { + ctx.ProjectEntityHandler = { promises: { getDoc: sinon.stub().resolves({ - lines: this.doc_lines, - rev: this.rev, - version: this.version, - ranges: this.ranges, + lines: ctx.doc_lines, + rev: ctx.rev, + version: ctx.version, + ranges: ctx.ranges, }), }, } - this.ProjectEntityUpdateHandler = { + ctx.ProjectEntityUpdateHandler = { promises: { updateDocLines: sinon.stub().resolves(), }, } - this.ChatApiHandler = { + ctx.ChatApiHandler = { promises: { - getResolvedThreadIds: sinon.stub().resolves(this.resolvedThreadIds), + getResolvedThreadIds: sinon.stub().resolves(ctx.resolvedThreadIds), }, } - this.DocumentController = await esmock.strict(MODULE_PATH, { - '../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter, - '../../../../app/src/Features/Project/ProjectLocator': - this.ProjectLocator, - '../../../../app/src/Features/Project/ProjectEntityHandler': - this.ProjectEntityHandler, - '../../../../app/src/Features/Project/ProjectEntityUpdateHandler': - this.ProjectEntityUpdateHandler, - '../../../../app/src/Features/Chat/ChatApiHandler': this.ChatApiHandler, - }) + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({ + default: ctx.ProjectLocator, + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectEntityHandler', + () => ({ + default: ctx.ProjectEntityHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectEntityUpdateHandler', + () => ({ + default: ctx.ProjectEntityUpdateHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler', () => ({ + default: ctx.ChatApiHandler, + })) + + ctx.DocumentController = (await import(MODULE_PATH)).default }) describe('getDocument', function () { - beforeEach(function () { - this.req.params = { - Project_id: this.project._id, - doc_id: this.doc._id, + beforeEach(function (ctx) { + ctx.req.params = { + Project_id: ctx.project._id, + doc_id: ctx.doc._id, } }) describe('when project exists with project history enabled', function () { - beforeEach(function (done) { - this.res.callback = err => { - done(err) - } - this.DocumentController.getDocument(this.req, this.res, this.next) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.callback = err => { + resolve(err) + } + ctx.DocumentController.getDocument(ctx.req, ctx.res, ctx.next) + }) }) - it('should return the history id and display setting to the client as JSON', function () { - this.res.type.should.equal('application/json') - JSON.parse(this.res.body).should.deep.equal({ - lines: this.doc_lines, - version: this.version, - ranges: this.ranges, - pathname: this.pathname, - projectHistoryId: this.project.overleaf.history.id, + it('should return the history id and display setting to the client as JSON', function (ctx) { + ctx.res.type.should.equal('application/json') + JSON.parse(ctx.res.body).should.deep.equal({ + lines: ctx.doc_lines, + version: ctx.version, + ranges: ctx.ranges, + pathname: ctx.pathname, + projectHistoryId: ctx.project.overleaf.history.id, projectHistoryType: 'project-history', resolvedCommentIds: ['comment2'], historyRangesSupport: false, @@ -132,75 +151,81 @@ describe('DocumentController', function () { }) describe('when the project does not exist', function () { - beforeEach(function (done) { - this.ProjectGetter.promises.getProject.resolves(null) - this.res.callback = err => { - done(err) - } - this.DocumentController.getDocument(this.req, this.res, this.next) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ProjectGetter.promises.getProject.resolves(null) + ctx.res.callback = err => { + resolve(err) + } + ctx.DocumentController.getDocument(ctx.req, ctx.res, ctx.next) + }) }) - it('returns a 404', function () { - this.res.statusCode.should.equal(404) + it('returns a 404', function (ctx) { + ctx.res.statusCode.should.equal(404) }) }) }) describe('setDocument', function () { - beforeEach(function () { - this.req.params = { - Project_id: this.project._id, - doc_id: this.doc._id, + beforeEach(function (ctx) { + ctx.req.params = { + Project_id: ctx.project._id, + doc_id: ctx.doc._id, } }) describe('when the document exists', function () { - beforeEach(function (done) { - this.req.body = { - lines: this.doc_lines, - version: this.version, - ranges: this.ranges, - lastUpdatedAt: this.lastUpdatedAt, - lastUpdatedBy: this.lastUpdatedBy, - } - this.res.callback = err => { - done(err) - } - this.DocumentController.setDocument(this.req, this.res, this.next) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.req.body = { + lines: ctx.doc_lines, + version: ctx.version, + ranges: ctx.ranges, + lastUpdatedAt: ctx.lastUpdatedAt, + lastUpdatedBy: ctx.lastUpdatedBy, + } + ctx.res.callback = err => { + resolve(err) + } + ctx.DocumentController.setDocument(ctx.req, ctx.res, ctx.next) + }) }) - it('should update the document in Mongo', function () { + it('should update the document in Mongo', function (ctx) { sinon.assert.calledWith( - this.ProjectEntityUpdateHandler.promises.updateDocLines, - this.project._id, - this.doc._id, - this.doc_lines, - this.version, - this.ranges, - this.lastUpdatedAt, - this.lastUpdatedBy + ctx.ProjectEntityUpdateHandler.promises.updateDocLines, + ctx.project._id, + ctx.doc._id, + ctx.doc_lines, + ctx.version, + ctx.ranges, + ctx.lastUpdatedAt, + ctx.lastUpdatedBy ) }) - it('should return a successful response', function () { - this.res.success.should.equal(true) + it('should return a successful response', function (ctx) { + ctx.res.success.should.equal(true) }) }) describe("when the document doesn't exist", function () { - beforeEach(function (done) { - this.ProjectEntityUpdateHandler.promises.updateDocLines.rejects( - new Errors.NotFoundError('document does not exist') - ) - this.req.body = { lines: this.doc_lines } - this.next.callsFake(() => { - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ProjectEntityUpdateHandler.promises.updateDocLines.rejects( + new Errors.NotFoundError('document does not exist') + ) + ctx.req.body = { lines: ctx.doc_lines } + ctx.next.callsFake(() => { + resolve() + }) + ctx.DocumentController.setDocument(ctx.req, ctx.res, ctx.next) }) - this.DocumentController.setDocument(this.req, this.res, this.next) }) - it('should call next with the NotFoundError', function () { - this.next + it('should call next with the NotFoundError', function (ctx) { + ctx.next .calledWith(sinon.match.instanceOf(Errors.NotFoundError)) .should.equal(true) }) diff --git a/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs b/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs index db9cf19df7..1e339097fa 100644 --- a/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs +++ b/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs @@ -1,3 +1,4 @@ +import { vi } from 'vitest' // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. /* @@ -6,136 +7,150 @@ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ import sinon from 'sinon' -import esmock from 'esmock' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' const modulePath = '../../../../app/src/Features/Downloads/ProjectDownloadsController.mjs' describe('ProjectDownloadsController', function () { - beforeEach(async function () { - this.project_id = 'project-id-123' - this.req = new MockRequest() - this.res = new MockResponse() - this.next = sinon.stub() - this.DocumentUpdaterHandler = sinon.stub() - return (this.ProjectDownloadsController = await esmock.strict(modulePath, { - '../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs': - (this.ProjectZipStreamManager = {}), - '../../../../app/src/Features/Project/ProjectGetter.js': - (this.ProjectGetter = {}), - '@overleaf/metrics': (this.metrics = {}), - '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js': - this.DocumentUpdaterHandler, + beforeEach(async function (ctx) { + ctx.project_id = 'project-id-123' + ctx.req = new MockRequest() + ctx.res = new MockResponse() + ctx.next = sinon.stub() + ctx.DocumentUpdaterHandler = sinon.stub() + + vi.doMock( + '../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs', + () => ({ + default: (ctx.ProjectZipStreamManager = {}), + }) + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({ + default: (ctx.ProjectGetter = {}), })) + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.metrics = {}), + })) + + vi.doMock( + '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js', + () => ({ + default: ctx.DocumentUpdaterHandler, + }) + ) + + ctx.ProjectDownloadsController = (await import(modulePath)).default }) describe('downloadProject', function () { - beforeEach(function () { - this.stream = { pipe: sinon.stub() } - this.ProjectZipStreamManager.createZipStreamForProject = sinon + beforeEach(function (ctx) { + ctx.stream = { pipe: sinon.stub() } + ctx.ProjectZipStreamManager.createZipStreamForProject = sinon .stub() - .callsArgWith(1, null, this.stream) - this.req.params = { Project_id: this.project_id } - this.project_name = 'project name with accênts' - this.ProjectGetter.getProject = sinon + .callsArgWith(1, null, ctx.stream) + ctx.req.params = { Project_id: ctx.project_id } + ctx.project_name = 'project name with accênts' + ctx.ProjectGetter.getProject = sinon .stub() - .callsArgWith(2, null, { name: this.project_name }) - this.DocumentUpdaterHandler.flushProjectToMongo = sinon + .callsArgWith(2, null, { name: ctx.project_name }) + ctx.DocumentUpdaterHandler.flushProjectToMongo = sinon .stub() .callsArgWith(1) - this.metrics.inc = sinon.stub() - return this.ProjectDownloadsController.downloadProject( - this.req, - this.res, - this.next + ctx.metrics.inc = sinon.stub() + return ctx.ProjectDownloadsController.downloadProject( + ctx.req, + ctx.res, + ctx.next ) }) - it('should create a zip from the project', function () { - return this.ProjectZipStreamManager.createZipStreamForProject - .calledWith(this.project_id) + it('should create a zip from the project', function (ctx) { + return ctx.ProjectZipStreamManager.createZipStreamForProject + .calledWith(ctx.project_id) .should.equal(true) }) - it('should stream the zip to the request', function () { - return this.stream.pipe.calledWith(this.res).should.equal(true) + it('should stream the zip to the request', function (ctx) { + return ctx.stream.pipe.calledWith(ctx.res).should.equal(true) }) - it('should set the correct content type on the request', function () { - return this.res.contentType + it('should set the correct content type on the request', function (ctx) { + return ctx.res.contentType .calledWith('application/zip') .should.equal(true) }) - it('should flush the project to mongo', function () { - return this.DocumentUpdaterHandler.flushProjectToMongo - .calledWith(this.project_id) + it('should flush the project to mongo', function (ctx) { + return ctx.DocumentUpdaterHandler.flushProjectToMongo + .calledWith(ctx.project_id) .should.equal(true) }) - it("should look up the project's name", function () { - return this.ProjectGetter.getProject - .calledWith(this.project_id, { name: true }) + it("should look up the project's name", function (ctx) { + return ctx.ProjectGetter.getProject + .calledWith(ctx.project_id, { name: true }) .should.equal(true) }) - it('should name the downloaded file after the project', function () { - this.res.headers.should.deep.equal({ - 'Content-Disposition': `attachment; filename="${this.project_name}.zip"`, + it('should name the downloaded file after the project', function (ctx) { + ctx.res.headers.should.deep.equal({ + 'Content-Disposition': `attachment; filename="${ctx.project_name}.zip"`, 'Content-Type': 'application/zip', 'X-Content-Type-Options': 'nosniff', }) }) - it('should record the action via Metrics', function () { - return this.metrics.inc.calledWith('zip-downloads').should.equal(true) + it('should record the action via Metrics', function (ctx) { + return ctx.metrics.inc.calledWith('zip-downloads').should.equal(true) }) }) describe('downloadMultipleProjects', function () { - beforeEach(function () { - this.stream = { pipe: sinon.stub() } - this.ProjectZipStreamManager.createZipStreamForMultipleProjects = sinon + beforeEach(function (ctx) { + ctx.stream = { pipe: sinon.stub() } + ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects = sinon .stub() - .callsArgWith(1, null, this.stream) - this.project_ids = ['project-1', 'project-2'] - this.req.query = { project_ids: this.project_ids.join(',') } - this.DocumentUpdaterHandler.flushMultipleProjectsToMongo = sinon + .callsArgWith(1, null, ctx.stream) + ctx.project_ids = ['project-1', 'project-2'] + ctx.req.query = { project_ids: ctx.project_ids.join(',') } + ctx.DocumentUpdaterHandler.flushMultipleProjectsToMongo = sinon .stub() .callsArgWith(1) - this.metrics.inc = sinon.stub() - return this.ProjectDownloadsController.downloadMultipleProjects( - this.req, - this.res, - this.next + ctx.metrics.inc = sinon.stub() + return ctx.ProjectDownloadsController.downloadMultipleProjects( + ctx.req, + ctx.res, + ctx.next ) }) - it('should create a zip from the project', function () { - return this.ProjectZipStreamManager.createZipStreamForMultipleProjects - .calledWith(this.project_ids) + it('should create a zip from the project', function (ctx) { + return ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects + .calledWith(ctx.project_ids) .should.equal(true) }) - it('should stream the zip to the request', function () { - return this.stream.pipe.calledWith(this.res).should.equal(true) + it('should stream the zip to the request', function (ctx) { + return ctx.stream.pipe.calledWith(ctx.res).should.equal(true) }) - it('should set the correct content type on the request', function () { - return this.res.contentType + it('should set the correct content type on the request', function (ctx) { + return ctx.res.contentType .calledWith('application/zip') .should.equal(true) }) - it('should flush the projects to mongo', function () { - return this.DocumentUpdaterHandler.flushMultipleProjectsToMongo - .calledWith(this.project_ids) + it('should flush the projects to mongo', function (ctx) { + return ctx.DocumentUpdaterHandler.flushMultipleProjectsToMongo + .calledWith(ctx.project_ids) .should.equal(true) }) - it('should name the downloaded file after the project', function () { - this.res.headers.should.deep.equal({ + it('should name the downloaded file after the project', function (ctx) { + ctx.res.headers.should.deep.equal({ 'Content-Disposition': 'attachment; filename="Overleaf Projects (2 items).zip"', 'Content-Type': 'application/zip', @@ -143,8 +158,8 @@ describe('ProjectDownloadsController', function () { }) }) - it('should record the action via Metrics', function () { - return this.metrics.inc + it('should record the action via Metrics', function (ctx) { + return ctx.metrics.inc .calledWith('zip-downloads-multiple') .should.equal(true) }) diff --git a/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs b/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs index f86b99bd96..df7486e11d 100644 --- a/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs +++ b/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs @@ -1,3 +1,4 @@ +import { vi } from 'vitest' // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. /* @@ -9,120 +10,143 @@ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ import sinon from 'sinon' -import esmock from 'esmock' import { EventEmitter } from 'events' const modulePath = '../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs' describe('ProjectZipStreamManager', function () { - beforeEach(async function () { - this.project_id = 'project-id-123' - this.callback = sinon.stub() - this.archive = { + beforeEach(async function (ctx) { + ctx.project_id = 'project-id-123' + ctx.callback = sinon.stub() + ctx.archive = { on() {}, append: sinon.stub(), } - this.logger = { + ctx.logger = { error: sinon.stub(), info: sinon.stub(), debug: sinon.stub(), } - return (this.ProjectZipStreamManager = await esmock.strict(modulePath, { - archiver: (this.archiver = sinon.stub().returns(this.archive)), - '@overleaf/logger': this.logger, - '../../../../app/src/Features/Project/ProjectEntityHandler': - (this.ProjectEntityHandler = {}), - '../../../../app/src/Features/History/HistoryManager.js': - (this.HistoryManager = {}), - '../../../../app/src/Features/Project/ProjectGetter': - (this.ProjectGetter = {}), - '../../../../app/src/Features/FileStore/FileStoreHandler': - (this.FileStoreHandler = {}), - '../../../../app/src/infrastructure/Features': (this.Features = { + vi.doMock('archiver', () => ({ + default: (ctx.archiver = sinon.stub().returns(ctx.archive)), + })) + + vi.doMock('@overleaf/logger', () => ({ + default: ctx.logger, + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectEntityHandler', + () => ({ + default: (ctx.ProjectEntityHandler = {}), + }) + ) + + vi.doMock('../../../../app/src/Features/History/HistoryManager.js', () => ({ + default: (ctx.HistoryManager = {}), + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: (ctx.ProjectGetter = {}), + })) + + vi.doMock( + '../../../../app/src/Features/FileStore/FileStoreHandler', + () => ({ + default: (ctx.FileStoreHandler = {}), + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: (ctx.Features = { hasFeature: sinon .stub() .withArgs('project-history-blobs') .returns(true), }), })) + + ctx.ProjectZipStreamManager = (await import(modulePath)).default }) describe('createZipStreamForMultipleProjects', function () { describe('successfully', function () { - beforeEach(function (done) { - this.project_ids = ['project-1', 'project-2'] - this.zip_streams = { - 'project-1': new EventEmitter(), - 'project-2': new EventEmitter(), - } - - this.project_names = { - 'project-1': 'Project One Name', - 'project-2': 'Project Two Name', - } - - this.ProjectZipStreamManager.createZipStreamForProject = ( - projectId, - callback - ) => { - callback(null, this.zip_streams[projectId]) - setTimeout(() => { - return this.zip_streams[projectId].emit('end') - }) - return 0 - } - sinon.spy(this.ProjectZipStreamManager, 'createZipStreamForProject') - - this.ProjectGetter.getProject = (projectId, fields, callback) => { - return callback(null, { name: this.project_names[projectId] }) - } - sinon.spy(this.ProjectGetter, 'getProject') - - this.ProjectZipStreamManager.createZipStreamForMultipleProjects( - this.project_ids, - (...args) => { - return this.callback(...Array.from(args || [])) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.project_ids = ['project-1', 'project-2'] + ctx.zip_streams = { + 'project-1': new EventEmitter(), + 'project-2': new EventEmitter(), } - ) - return (this.archive.finalize = () => done()) + ctx.project_names = { + 'project-1': 'Project One Name', + 'project-2': 'Project Two Name', + } + + ctx.ProjectZipStreamManager.createZipStreamForProject = ( + projectId, + callback + ) => { + callback(null, ctx.zip_streams[projectId]) + setTimeout(() => { + return ctx.zip_streams[projectId].emit('end') + }) + return 0 + } + sinon.spy(ctx.ProjectZipStreamManager, 'createZipStreamForProject') + + ctx.ProjectGetter.getProject = (projectId, fields, callback) => { + return callback(null, { name: ctx.project_names[projectId] }) + } + sinon.spy(ctx.ProjectGetter, 'getProject') + + ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects( + ctx.project_ids, + (...args) => { + return ctx.callback(...Array.from(args || [])) + } + ) + + return (ctx.archive.finalize = () => resolve()) + }) }) - it('should create a zip archive', function () { - return this.archiver.calledWith('zip').should.equal(true) + it('should create a zip archive', function (ctx) { + return ctx.archiver.calledWith('zip').should.equal(true) }) - it('should return a stream before any processing is done', function () { - this.callback - .calledWith(sinon.match.falsy, this.archive) + it('should return a stream before any processing is done', function (ctx) { + ctx.callback + .calledWith(sinon.match.falsy, ctx.archive) .should.equal(true) - return this.callback - .calledBefore(this.ProjectZipStreamManager.createZipStreamForProject) + return ctx.callback + .calledBefore(ctx.ProjectZipStreamManager.createZipStreamForProject) .should.equal(true) }) - it('should get a zip stream for all of the projects', function () { - return Array.from(this.project_ids).map(projectId => - this.ProjectZipStreamManager.createZipStreamForProject + it('should get a zip stream for all of the projects', function (ctx) { + return Array.from(ctx.project_ids).map(projectId => + ctx.ProjectZipStreamManager.createZipStreamForProject .calledWith(projectId) .should.equal(true) ) }) - it('should get the names of each project', function () { - return Array.from(this.project_ids).map(projectId => - this.ProjectGetter.getProject + it('should get the names of each project', function (ctx) { + return Array.from(ctx.project_ids).map(projectId => + ctx.ProjectGetter.getProject .calledWith(projectId, { name: true }) .should.equal(true) ) }) - it('should add all of the projects to the zip', function () { - return Array.from(this.project_ids).map(projectId => - this.archive.append - .calledWith(this.zip_streams[projectId], { - name: this.project_names[projectId] + '.zip', + it('should add all of the projects to the zip', function (ctx) { + return Array.from(ctx.project_ids).map(projectId => + ctx.archive.append + .calledWith(ctx.zip_streams[projectId], { + name: ctx.project_names[projectId] + '.zip', }) .should.equal(true) ) @@ -130,75 +154,77 @@ describe('ProjectZipStreamManager', function () { }) describe('with a project not existing', function () { - beforeEach(function (done) { - this.project_ids = ['project-1', 'wrong-id'] - this.project_names = { - 'project-1': 'Project One Name', - } - this.zip_streams = { - 'project-1': new EventEmitter(), - } + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.project_ids = ['project-1', 'wrong-id'] + ctx.project_names = { + 'project-1': 'Project One Name', + } + ctx.zip_streams = { + 'project-1': new EventEmitter(), + } - this.ProjectZipStreamManager.createZipStreamForProject = ( - projectId, - callback - ) => { - callback(null, this.zip_streams[projectId]) - setTimeout(() => { - this.zip_streams[projectId].emit('end') - }) - } - sinon.spy(this.ProjectZipStreamManager, 'createZipStreamForProject') + ctx.ProjectZipStreamManager.createZipStreamForProject = ( + projectId, + callback + ) => { + callback(null, ctx.zip_streams[projectId]) + setTimeout(() => { + ctx.zip_streams[projectId].emit('end') + }) + } + sinon.spy(ctx.ProjectZipStreamManager, 'createZipStreamForProject') - this.ProjectGetter.getProject = (projectId, fields, callback) => { - const name = this.project_names[projectId] - callback(null, name ? { name } : undefined) - } - sinon.spy(this.ProjectGetter, 'getProject') + ctx.ProjectGetter.getProject = (projectId, fields, callback) => { + const name = ctx.project_names[projectId] + callback(null, name ? { name } : undefined) + } + sinon.spy(ctx.ProjectGetter, 'getProject') - this.ProjectZipStreamManager.createZipStreamForMultipleProjects( - this.project_ids, - this.callback - ) + ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects( + ctx.project_ids, + ctx.callback + ) - this.archive.finalize = () => done() + ctx.archive.finalize = () => resolve() + }) }) - it('should create a zip archive', function () { - this.archiver.calledWith('zip').should.equal(true) + it('should create a zip archive', function (ctx) { + ctx.archiver.calledWith('zip').should.equal(true) }) - it('should return a stream before any processing is done', function () { - this.callback - .calledWith(sinon.match.falsy, this.archive) + it('should return a stream before any processing is done', function (ctx) { + ctx.callback + .calledWith(sinon.match.falsy, ctx.archive) .should.equal(true) - this.callback - .calledBefore(this.ProjectZipStreamManager.createZipStreamForProject) + ctx.callback + .calledBefore(ctx.ProjectZipStreamManager.createZipStreamForProject) .should.equal(true) }) - it('should get the names of each project', function () { - this.project_ids.map(projectId => - this.ProjectGetter.getProject + it('should get the names of each project', function (ctx) { + ctx.project_ids.map(projectId => + ctx.ProjectGetter.getProject .calledWith(projectId, { name: true }) .should.equal(true) ) }) - it('should get a zip stream only for the existing project', function () { - this.ProjectZipStreamManager.createZipStreamForProject + it('should get a zip stream only for the existing project', function (ctx) { + ctx.ProjectZipStreamManager.createZipStreamForProject .calledWith('project-1') .should.equal(true) - this.ProjectZipStreamManager.createZipStreamForProject + ctx.ProjectZipStreamManager.createZipStreamForProject .calledWith('wrong-id') .should.equal(false) }) - it('should only add the existing project to the zip', function () { - sinon.assert.calledOnce(this.archive.append) - this.archive.append - .calledWith(this.zip_streams['project-1'], { - name: this.project_names['project-1'] + '.zip', + it('should only add the existing project to the zip', function (ctx) { + sinon.assert.calledOnce(ctx.archive.append) + ctx.archive.append + .calledWith(ctx.zip_streams['project-1'], { + name: ctx.project_names['project-1'] + '.zip', }) .should.equal(true) }) @@ -207,160 +233,162 @@ describe('ProjectZipStreamManager', function () { describe('createZipStreamForProject', function () { describe('successfully', function () { - beforeEach(function () { - this.ProjectZipStreamManager.addAllDocsToArchive = sinon + beforeEach(function (ctx) { + ctx.ProjectZipStreamManager.addAllDocsToArchive = sinon .stub() .callsArg(2) - this.ProjectZipStreamManager.addAllFilesToArchive = sinon + ctx.ProjectZipStreamManager.addAllFilesToArchive = sinon .stub() .callsArg(2) - this.archive.finalize = sinon.stub() - return this.ProjectZipStreamManager.createZipStreamForProject( - this.project_id, - this.callback + ctx.archive.finalize = sinon.stub() + return ctx.ProjectZipStreamManager.createZipStreamForProject( + ctx.project_id, + ctx.callback ) }) - it('should create a zip archive', function () { - return this.archiver.calledWith('zip').should.equal(true) + it('should create a zip archive', function (ctx) { + return ctx.archiver.calledWith('zip').should.equal(true) }) - it('should return a stream before any processing is done', function () { - this.callback - .calledWith(sinon.match.falsy, this.archive) + it('should return a stream before any processing is done', function (ctx) { + ctx.callback + .calledWith(sinon.match.falsy, ctx.archive) .should.equal(true) - this.callback - .calledBefore(this.ProjectZipStreamManager.addAllDocsToArchive) + ctx.callback + .calledBefore(ctx.ProjectZipStreamManager.addAllDocsToArchive) .should.equal(true) - return this.callback - .calledBefore(this.ProjectZipStreamManager.addAllFilesToArchive) + return ctx.callback + .calledBefore(ctx.ProjectZipStreamManager.addAllFilesToArchive) .should.equal(true) }) - it('should add all of the project docs to the zip', function () { - return this.ProjectZipStreamManager.addAllDocsToArchive - .calledWith(this.project_id, this.archive) + it('should add all of the project docs to the zip', function (ctx) { + return ctx.ProjectZipStreamManager.addAllDocsToArchive + .calledWith(ctx.project_id, ctx.archive) .should.equal(true) }) - it('should add all of the project files to the zip', function () { - return this.ProjectZipStreamManager.addAllFilesToArchive - .calledWith(this.project_id, this.archive) + it('should add all of the project files to the zip', function (ctx) { + return ctx.ProjectZipStreamManager.addAllFilesToArchive + .calledWith(ctx.project_id, ctx.archive) .should.equal(true) }) - it('should finalise the stream', function () { - return this.archive.finalize.called.should.equal(true) + it('should finalise the stream', function (ctx) { + return ctx.archive.finalize.called.should.equal(true) }) }) describe('with an error adding docs', function () { - beforeEach(function () { - this.ProjectZipStreamManager.addAllDocsToArchive = sinon + beforeEach(function (ctx) { + ctx.ProjectZipStreamManager.addAllDocsToArchive = sinon .stub() .callsArgWith(2, new Error('something went wrong')) - this.ProjectZipStreamManager.addAllFilesToArchive = sinon + ctx.ProjectZipStreamManager.addAllFilesToArchive = sinon .stub() .callsArg(2) - this.archive.finalize = sinon.stub() - this.ProjectZipStreamManager.createZipStreamForProject( - this.project_id, - this.callback + ctx.archive.finalize = sinon.stub() + ctx.ProjectZipStreamManager.createZipStreamForProject( + ctx.project_id, + ctx.callback ) }) - it('should log out an error', function () { - return this.logger.error + it('should log out an error', function (ctx) { + return ctx.logger.error .calledWith(sinon.match.any, 'error adding docs to zip stream') .should.equal(true) }) - it('should continue with the process', function () { - this.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal( + it('should continue with the process', function (ctx) { + ctx.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal( true ) - this.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal( + ctx.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal( true ) - return this.archive.finalize.called.should.equal(true) + return ctx.archive.finalize.called.should.equal(true) }) }) describe('with an error adding files', function () { - beforeEach(function () { - this.ProjectZipStreamManager.addAllDocsToArchive = sinon + beforeEach(function (ctx) { + ctx.ProjectZipStreamManager.addAllDocsToArchive = sinon .stub() .callsArg(2) - this.ProjectZipStreamManager.addAllFilesToArchive = sinon + ctx.ProjectZipStreamManager.addAllFilesToArchive = sinon .stub() .callsArgWith(2, new Error('something went wrong')) - this.archive.finalize = sinon.stub() - return this.ProjectZipStreamManager.createZipStreamForProject( - this.project_id, - this.callback + ctx.archive.finalize = sinon.stub() + return ctx.ProjectZipStreamManager.createZipStreamForProject( + ctx.project_id, + ctx.callback ) }) - it('should log out an error', function () { - return this.logger.error + it('should log out an error', function (ctx) { + return ctx.logger.error .calledWith(sinon.match.any, 'error adding files to zip stream') .should.equal(true) }) - it('should continue with the process', function () { - this.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal( + it('should continue with the process', function (ctx) { + ctx.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal( true ) - this.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal( + ctx.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal( true ) - return this.archive.finalize.called.should.equal(true) + return ctx.archive.finalize.called.should.equal(true) }) }) }) describe('addAllDocsToArchive', function () { - beforeEach(function (done) { - this.docs = { - '/main.tex': { - lines: [ - '\\documentclass{article}', - '\\begin{document}', - 'Hello world', - '\\end{document}', - ], - }, - '/chapters/chapter1.tex': { - lines: ['chapter1', 'content'], - }, - } - this.ProjectEntityHandler.getAllDocs = sinon - .stub() - .callsArgWith(1, null, this.docs) - return this.ProjectZipStreamManager.addAllDocsToArchive( - this.project_id, - this.archive, - error => { - this.callback(error) - return done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.docs = { + '/main.tex': { + lines: [ + '\\documentclass{article}', + '\\begin{document}', + 'Hello world', + '\\end{document}', + ], + }, + '/chapters/chapter1.tex': { + lines: ['chapter1', 'content'], + }, } - ) + ctx.ProjectEntityHandler.getAllDocs = sinon + .stub() + .callsArgWith(1, null, ctx.docs) + return ctx.ProjectZipStreamManager.addAllDocsToArchive( + ctx.project_id, + ctx.archive, + error => { + ctx.callback(error) + return resolve() + } + ) + }) }) - it('should get the docs for the project', function () { - return this.ProjectEntityHandler.getAllDocs - .calledWith(this.project_id) + it('should get the docs for the project', function (ctx) { + return ctx.ProjectEntityHandler.getAllDocs + .calledWith(ctx.project_id) .should.equal(true) }) - it('should add each doc to the archive', function () { + it('should add each doc to the archive', function (ctx) { return (() => { const result = [] - for (let path in this.docs) { - const doc = this.docs[path] + for (let path in ctx.docs) { + const doc = ctx.docs[path] path = path.slice(1) // remove "/" result.push( - this.archive.append + ctx.archive.append .calledWith(doc.lines.join('\n'), { name: path }) .should.equal(true) ) @@ -371,8 +399,8 @@ describe('ProjectZipStreamManager', function () { }) describe('addAllFilesToArchive', function () { - beforeEach(function () { - this.files = { + beforeEach(function (ctx) { + ctx.files = { '/image.png': { _id: 'file-id-1', hash: 'abc', @@ -382,93 +410,91 @@ describe('ProjectZipStreamManager', function () { hash: 'def', }, } - this.streams = { + ctx.streams = { 'file-id-1': new EventEmitter(), 'file-id-2': new EventEmitter(), } - this.ProjectEntityHandler.getAllFiles = sinon + ctx.ProjectEntityHandler.getAllFiles = sinon .stub() - .callsArgWith(1, null, this.files) + .callsArgWith(1, null, ctx.files) }) describe('with project-history-blobs feature enabled', function () { - beforeEach(function () { - this.HistoryManager.requestBlobWithFallback = ( + beforeEach(function (ctx) { + ctx.HistoryManager.requestBlobWithFallback = ( projectId, hash, fileId, callback ) => { - return callback(null, { stream: this.streams[fileId] }) + return callback(null, { stream: ctx.streams[fileId] }) } - sinon.spy(this.HistoryManager, 'requestBlobWithFallback') - this.ProjectZipStreamManager.addAllFilesToArchive( - this.project_id, - this.archive, - this.callback + sinon.spy(ctx.HistoryManager, 'requestBlobWithFallback') + ctx.ProjectZipStreamManager.addAllFilesToArchive( + ctx.project_id, + ctx.archive, + ctx.callback ) - for (const path in this.streams) { - const stream = this.streams[path] + for (const path in ctx.streams) { + const stream = ctx.streams[path] stream.emit('end') } }) - it('should get the files for the project', function () { - return this.ProjectEntityHandler.getAllFiles - .calledWith(this.project_id) + it('should get the files for the project', function (ctx) { + return ctx.ProjectEntityHandler.getAllFiles + .calledWith(ctx.project_id) .should.equal(true) }) - it('should get a stream for each file', function () { - for (const path in this.files) { - const file = this.files[path] + it('should get a stream for each file', function (ctx) { + for (const path in ctx.files) { + const file = ctx.files[path] - this.HistoryManager.requestBlobWithFallback - .calledWith(this.project_id, file.hash, file._id) + ctx.HistoryManager.requestBlobWithFallback + .calledWith(ctx.project_id, file.hash, file._id) .should.equal(true) } }) - it('should add each file to the archive', function () { - for (let path in this.files) { - const file = this.files[path] + it('should add each file to the archive', function (ctx) { + for (let path in ctx.files) { + const file = ctx.files[path] path = path.slice(1) // remove "/" - this.archive.append - .calledWith(this.streams[file._id], { name: path }) + ctx.archive.append + .calledWith(ctx.streams[file._id], { name: path }) .should.equal(true) } }) }) describe('with project-history-blobs feature disabled', function () { - beforeEach(function () { - this.FileStoreHandler.getFileStream = ( + beforeEach(function (ctx) { + ctx.FileStoreHandler.getFileStream = ( projectId, fileId, query, callback - ) => callback(null, this.streams[fileId]) + ) => callback(null, ctx.streams[fileId]) - sinon.spy(this.FileStoreHandler, 'getFileStream') - this.Features.hasFeature - .withArgs('project-history-blobs') - .returns(false) - this.ProjectZipStreamManager.addAllFilesToArchive( - this.project_id, - this.archive, - this.callback + sinon.spy(ctx.FileStoreHandler, 'getFileStream') + ctx.Features.hasFeature.withArgs('project-history-blobs').returns(false) + ctx.ProjectZipStreamManager.addAllFilesToArchive( + ctx.project_id, + ctx.archive, + ctx.callback ) - for (const path in this.streams) { - const stream = this.streams[path] + for (const path in ctx.streams) { + const stream = ctx.streams[path] stream.emit('end') } }) - it('should get a stream for each file', function () { - for (const path in this.files) { - const file = this.files[path] + it('should get a stream for each file', function (ctx) { + for (const path in ctx.files) { + const file = ctx.files[path] - this.FileStoreHandler.getFileStream - .calledWith(this.project_id, file._id) + ctx.FileStoreHandler.getFileStream + .calledWith(ctx.project_id, file._id) .should.equal(true) } }) diff --git a/services/web/test/unit/src/Exports/ExportsController.test.mjs b/services/web/test/unit/src/Exports/ExportsController.test.mjs index 65e6e16d27..af9c1483fb 100644 --- a/services/web/test/unit/src/Exports/ExportsController.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsController.test.mjs @@ -1,11 +1,4 @@ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -import esmock from 'esmock' +import { vi } from 'vitest' import { expect } from 'chai' import sinon from 'sinon' const modulePath = new URL( @@ -25,9 +18,9 @@ describe('ExportsController', function () { const license = 'other' const showSource = true - beforeEach(async function () { - this.handler = { getUserNotifications: sinon.stub().callsArgWith(1) } - this.req = { + beforeEach(async function (ctx) { + ctx.handler = { getUserNotifications: sinon.stub().callsArgWith(1) } + ctx.req = { params: { project_id: projectId, brand_variation_id: brandVariationId, @@ -45,152 +38,179 @@ describe('ExportsController', function () { translate() {}, }, } - this.res = { + ctx.res = { json: sinon.stub(), status: sinon.stub(), } - this.res.status.returns(this.res) - this.next = sinon.stub() - this.AuthenticationController = { - getLoggedInUserId: sinon.stub().returns(this.req.session.user._id), + ctx.res.status.returns(ctx.res) + ctx.next = sinon.stub() + ctx.AuthenticationController = { + getLoggedInUserId: sinon.stub().returns(ctx.req.session.user._id), } - return (this.controller = await esmock.strict(modulePath, { - '../../../../app/src/Features/Exports/ExportsHandler.mjs': this.handler, - '../../../../app/src/Features/Authentication/AuthenticationController.js': - this.AuthenticationController, - })) + + vi.doMock( + '../../../../app/src/Features/Exports/ExportsHandler.mjs', + () => ({ + default: ctx.handler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController.js', + () => ({ + default: ctx.AuthenticationController, + }) + ) + + ctx.controller = (await import(modulePath)).default }) describe('without gallery fields', function () { - it('should ask the handler to perform the export', function (done) { - this.handler.exportProject = sinon - .stub() - .yields(null, { iAmAnExport: true, v1_id: 897 }) - const expected = { - project_id: projectId, - user_id: userId, - brand_variation_id: brandVariationId, - first_name: firstName, - last_name: lastName, - } - return this.controller.exportProject(this.req, { - json: body => { - expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected) - expect(body).to.deep.equal({ export_v1_id: 897, message: undefined }) - return done() - }, + it('should ask the handler to perform the export', function (ctx) { + return new Promise(resolve => { + ctx.handler.exportProject = sinon + .stub() + .yields(null, { iAmAnExport: true, v1_id: 897 }) + const expected = { + project_id: projectId, + user_id: userId, + brand_variation_id: brandVariationId, + first_name: firstName, + last_name: lastName, + } + return ctx.controller.exportProject(ctx.req, { + json: body => { + expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected) + expect(body).to.deep.equal({ + export_v1_id: 897, + message: undefined, + }) + return resolve() + }, + }) }) }) }) describe('with a message from v1', function () { - it('should ask the handler to perform the export', function (done) { - this.handler.exportProject = sinon.stub().yields(null, { - iAmAnExport: true, - v1_id: 897, - message: 'RESUBMISSION', - }) - const expected = { - project_id: projectId, - user_id: userId, - brand_variation_id: brandVariationId, - first_name: firstName, - last_name: lastName, - } - return this.controller.exportProject(this.req, { - json: body => { - expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected) - expect(body).to.deep.equal({ - export_v1_id: 897, - message: 'RESUBMISSION', - }) - return done() - }, + it('should ask the handler to perform the export', function (ctx) { + return new Promise(resolve => { + ctx.handler.exportProject = sinon.stub().yields(null, { + iAmAnExport: true, + v1_id: 897, + message: 'RESUBMISSION', + }) + const expected = { + project_id: projectId, + user_id: userId, + brand_variation_id: brandVariationId, + first_name: firstName, + last_name: lastName, + } + return ctx.controller.exportProject(ctx.req, { + json: body => { + expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected) + expect(body).to.deep.equal({ + export_v1_id: 897, + message: 'RESUBMISSION', + }) + return resolve() + }, + }) }) }) }) describe('with gallery fields', function () { - beforeEach(function () { - this.req.body.title = title - this.req.body.description = description - this.req.body.author = author - this.req.body.license = license - return (this.req.body.showSource = true) + beforeEach(function (ctx) { + ctx.req.body.title = title + ctx.req.body.description = description + ctx.req.body.author = author + ctx.req.body.license = license + return (ctx.req.body.showSource = true) }) - it('should ask the handler to perform the export', function (done) { - this.handler.exportProject = sinon - .stub() - .yields(null, { iAmAnExport: true, v1_id: 897 }) - const expected = { - project_id: projectId, - user_id: userId, - brand_variation_id: brandVariationId, - first_name: firstName, - last_name: lastName, - title, - description, - author, - license, - show_source: showSource, - } - return this.controller.exportProject(this.req, { - json: body => { - expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected) - expect(body).to.deep.equal({ export_v1_id: 897, message: undefined }) - return done() - }, + it('should ask the handler to perform the export', function (ctx) { + return new Promise(resolve => { + ctx.handler.exportProject = sinon + .stub() + .yields(null, { iAmAnExport: true, v1_id: 897 }) + const expected = { + project_id: projectId, + user_id: userId, + brand_variation_id: brandVariationId, + first_name: firstName, + last_name: lastName, + title, + description, + author, + license, + show_source: showSource, + } + return ctx.controller.exportProject(ctx.req, { + json: body => { + expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected) + expect(body).to.deep.equal({ + export_v1_id: 897, + message: undefined, + }) + return resolve() + }, + }) }) }) }) describe('with an error return from v1 to forward to the publish modal', function () { - it('should forward the response onward', function (done) { - this.error_json = { status: 422, message: 'nope' } - this.handler.exportProject = sinon - .stub() - .yields({ forwardResponse: this.error_json }) - this.controller.exportProject(this.req, this.res, this.next) - expect(this.res.json.args[0][0]).to.deep.equal(this.error_json) - expect(this.res.status.args[0][0]).to.equal(this.error_json.status) - return done() + it('should forward the response onward', function (ctx) { + return new Promise(resolve => { + ctx.error_json = { status: 422, message: 'nope' } + ctx.handler.exportProject = sinon + .stub() + .yields({ forwardResponse: ctx.error_json }) + ctx.controller.exportProject(ctx.req, ctx.res, ctx.next) + expect(ctx.res.json.args[0][0]).to.deep.equal(ctx.error_json) + expect(ctx.res.status.args[0][0]).to.equal(ctx.error_json.status) + return resolve() + }) }) }) - it('should ask the handler to return the status of an export', function (done) { - this.handler.fetchExport = sinon.stub().yields( - null, - `{ -"id":897, -"status_summary":"completed", -"status_detail":"all done", -"partner_submission_id":"abc123", -"v2_user_email":"la@tex.com", -"v2_user_first_name":"Arthur", -"v2_user_last_name":"Author", -"title":"my project", -"token":"token" -}` - ) + it('should ask the handler to return the status of an export', function (ctx) { + return new Promise(resolve => { + ctx.handler.fetchExport = sinon.stub().yields( + null, + `{ + "id":897, + "status_summary":"completed", + "status_detail":"all done", + "partner_submission_id":"abc123", + "v2_user_email":"la@tex.com", + "v2_user_first_name":"Arthur", + "v2_user_last_name":"Author", + "title":"my project", + "token":"token" + }` + ) - this.req.params = { project_id: projectId, export_id: 897 } - return this.controller.exportStatus(this.req, { - json: body => { - expect(body).to.deep.equal({ - export_json: { - status_summary: 'completed', - status_detail: 'all done', - partner_submission_id: 'abc123', - v2_user_email: 'la@tex.com', - v2_user_first_name: 'Arthur', - v2_user_last_name: 'Author', - title: 'my project', - token: 'token', - }, - }) - return done() - }, + ctx.req.params = { project_id: projectId, export_id: 897 } + return ctx.controller.exportStatus(ctx.req, { + json: body => { + expect(body).to.deep.equal({ + export_json: { + status_summary: 'completed', + status_detail: 'all done', + partner_submission_id: 'abc123', + v2_user_email: 'la@tex.com', + v2_user_first_name: 'Arthur', + v2_user_last_name: 'Author', + title: 'my project', + token: 'token', + }, + }) + return resolve() + }, + }) }) }) }) diff --git a/services/web/test/unit/src/Exports/ExportsHandler.test.mjs b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs index 1a7f985250..0eb8a98e26 100644 --- a/services/web/test/unit/src/Exports/ExportsHandler.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs @@ -1,697 +1,736 @@ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ +import { vi } from 'vitest' import sinon from 'sinon' -import esmock from 'esmock' import { expect } from 'chai' const modulePath = '../../../../app/src/Features/Exports/ExportsHandler.mjs' describe('ExportsHandler', function () { - beforeEach(async function () { - this.stubRequest = {} - this.request = { + beforeEach(async function (ctx) { + ctx.stubRequest = {} + ctx.request = { defaults: () => { - return this.stubRequest + return ctx.stubRequest }, } - this.ExportsHandler = await esmock.strict(modulePath, { - '../../../../app/src/Features/Project/ProjectGetter': - (this.ProjectGetter = {}), - '../../../../app/src/Features/Project/ProjectHistoryHandler': - (this.ProjectHistoryHandler = {}), - '../../../../app/src/Features/Project/ProjectLocator': - (this.ProjectLocator = {}), - '../../../../app/src/Features/Project/ProjectRootDocManager': - (this.ProjectRootDocManager = {}), - '../../../../app/src/Features/User/UserGetter': (this.UserGetter = {}), - '@overleaf/settings': (this.settings = {}), - request: this.request, - }) - this.project_id = 'project-id-123' - this.project_history_id = 987 - this.user_id = 'user-id-456' - this.brand_variation_id = 789 - this.title = 'title' - this.description = 'description' - this.author = 'author' - this.license = 'other' - this.show_source = true - this.export_params = { - project_id: this.project_id, - brand_variation_id: this.brand_variation_id, - user_id: this.user_id, - title: this.title, - description: this.description, - author: this.author, - license: this.license, - show_source: this.show_source, + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: (ctx.ProjectGetter = {}), + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectHistoryHandler', + () => ({ + default: (ctx.ProjectHistoryHandler = {}), + }) + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({ + default: (ctx.ProjectLocator = {}), + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectRootDocManager', + () => ({ + default: (ctx.ProjectRootDocManager = {}), + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = {}), + })) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = {}), + })) + + vi.doMock('request', () => ({ + default: ctx.request, + })) + + ctx.ExportsHandler = (await import(modulePath)).default + ctx.project_id = 'project-id-123' + ctx.project_history_id = 987 + ctx.user_id = 'user-id-456' + ctx.brand_variation_id = 789 + ctx.title = 'title' + ctx.description = 'description' + ctx.author = 'author' + ctx.license = 'other' + ctx.show_source = true + ctx.export_params = { + project_id: ctx.project_id, + brand_variation_id: ctx.brand_variation_id, + user_id: ctx.user_id, + title: ctx.title, + description: ctx.description, + author: ctx.author, + license: ctx.license, + show_source: ctx.show_source, } - return (this.callback = sinon.stub()) + ctx.callback = sinon.stub() }) describe('exportProject', function () { - beforeEach(function () { - this.export_data = { iAmAnExport: true } - this.response_body = { iAmAResponseBody: true } - this.ExportsHandler._buildExport = sinon + beforeEach(function (ctx) { + ctx.export_data = { iAmAnExport: true } + ctx.response_body = { iAmAResponseBody: true } + ctx.ExportsHandler._buildExport = sinon .stub() - .yields(null, this.export_data) - return (this.ExportsHandler._requestExport = sinon + .yields(null, ctx.export_data) + ctx.ExportsHandler._requestExport = sinon .stub() - .yields(null, this.response_body)) + .yields(null, ctx.response_body) }) describe('when all goes well', function () { - beforeEach(function (done) { - return this.ExportsHandler.exportProject( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ExportsHandler.exportProject( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should build the export', function () { - return this.ExportsHandler._buildExport - .calledWith(this.export_params) + it('should build the export', function (ctx) { + ctx.ExportsHandler._buildExport + .calledWith(ctx.export_params) .should.equal(true) }) - it('should request the export', function () { - return this.ExportsHandler._requestExport - .calledWith(this.export_data) + it('should request the export', function (ctx) { + ctx.ExportsHandler._requestExport + .calledWith(ctx.export_data) .should.equal(true) }) - it('should return the export', function () { - return this.callback - .calledWith(null, this.export_data) - .should.equal(true) + it('should return the export', function (ctx) { + ctx.callback.calledWith(null, ctx.export_data).should.equal(true) }) }) describe("when request can't be built", function () { - beforeEach(function (done) { - this.ExportsHandler._buildExport = sinon - .stub() - .yields(new Error('cannot export project without root doc')) - return this.ExportsHandler.exportProject( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ExportsHandler._buildExport = sinon + .stub() + .yields(new Error('cannot export project without root doc')) + ctx.ExportsHandler.exportProject( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should return an error', function () { - return (this.callback.args[0][0] instanceof Error).should.equal(true) + it('should return an error', function (ctx) { + expect(ctx.callback.args[0][0]).to.be.instanceOf(Error) }) }) describe('when export request returns an error to forward to the user', function () { - beforeEach(function (done) { - this.error_json = { status: 422, message: 'nope' } - this.ExportsHandler._requestExport = sinon - .stub() - .yields(null, { forwardResponse: this.error_json }) - return this.ExportsHandler.exportProject( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.error_json = { status: 422, message: 'nope' } + ctx.ExportsHandler._requestExport = sinon + .stub() + .yields(null, { forwardResponse: ctx.error_json }) + ctx.ExportsHandler.exportProject( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should return success and the response to forward', function () { - ;(this.callback.args[0][0] instanceof Error).should.equal(false) - return this.callback.calledWith(null, { - forwardResponse: this.error_json, + it('should return success and the response to forward', function (ctx) { + expect(ctx.callback.args[0][0]).not.to.be.instanceOf(Error) + ctx.callback.calledWith(null, { + forwardResponse: ctx.error_json, }) }) }) }) describe('_buildExport', function () { - beforeEach(function (done) { - this.project = { - id: this.project_id, - rootDoc_id: 'doc1_id', - compiler: 'pdflatex', - imageName: 'mock-image-name', - overleaf: { - id: this.project_history_id, // for projects imported from v1 - history: { - id: this.project_history_id, + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.project = { + id: ctx.project_id, + rootDoc_id: 'doc1_id', + compiler: 'pdflatex', + imageName: 'mock-image-name', + overleaf: { + id: ctx.project_history_id, // for projects imported from v1 + history: { + id: ctx.project_history_id, + }, }, - }, - } - this.user = { - id: this.user_id, - first_name: 'Arthur', - last_name: 'Author', - email: 'arthur.author@arthurauthoring.org', - overleaf: { - id: 876, - }, - } - this.rootDocPath = 'main.tex' - this.historyVersion = 777 - this.ProjectGetter.getProject = sinon.stub().yields(null, this.project) - this.ProjectHistoryHandler.ensureHistoryExistsForProject = sinon - .stub() - .yields(null) - this.ProjectLocator.findRootDoc = sinon - .stub() - .yields(null, [null, { fileSystem: 'main.tex' }]) - this.ProjectRootDocManager.ensureRootDocumentIsValid = sinon - .stub() - .callsArgWith(1, null) - this.UserGetter.getUser = sinon.stub().yields(null, this.user) - this.ExportsHandler._requestVersion = sinon - .stub() - .yields(null, this.historyVersion) - return done() + } + ctx.user = { + id: ctx.user_id, + first_name: 'Arthur', + last_name: 'Author', + email: 'arthur.author@arthurauthoring.org', + overleaf: { + id: 876, + }, + } + ctx.rootDocPath = 'main.tex' + ctx.historyVersion = 777 + ctx.ProjectGetter.getProject = sinon.stub().yields(null, ctx.project) + ctx.ProjectHistoryHandler.ensureHistoryExistsForProject = sinon + .stub() + .yields(null) + ctx.ProjectLocator.findRootDoc = sinon + .stub() + .yields(null, [null, { fileSystem: 'main.tex' }]) + ctx.ProjectRootDocManager.ensureRootDocumentIsValid = sinon + .stub() + .callsArgWith(1, null) + ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user) + ctx.ExportsHandler._requestVersion = sinon + .stub() + .yields(null, ctx.historyVersion) + resolve() + }) }) describe('when all goes well', function () { - beforeEach(function (done) { - return this.ExportsHandler._buildExport( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ExportsHandler._buildExport( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should ensure the project has history', function () { - return this.ProjectHistoryHandler.ensureHistoryExistsForProject.called.should.equal( + it('should ensure the project has history', function (ctx) { + ctx.ProjectHistoryHandler.ensureHistoryExistsForProject.called.should.equal( true ) }) - it('should request the project history version', function () { - return this.ExportsHandler._requestVersion.called.should.equal(true) + it('should request the project history version', function (ctx) { + ctx.ExportsHandler._requestVersion.called.should.equal(true) }) - it('should return export data', function () { + it('should return export data', function (ctx) { const expectedExportData = { project: { - id: this.project_id, - rootDocPath: this.rootDocPath, - historyId: this.project_history_id, - historyVersion: this.historyVersion, - v1ProjectId: this.project_history_id, + id: ctx.project_id, + rootDocPath: ctx.rootDocPath, + historyId: ctx.project_history_id, + historyVersion: ctx.historyVersion, + v1ProjectId: ctx.project_history_id, metadata: { compiler: 'pdflatex', imageName: 'mock-image-name', - title: this.title, - description: this.description, - author: this.author, - license: this.license, - showSource: this.show_source, + title: ctx.title, + description: ctx.description, + author: ctx.author, + license: ctx.license, + showSource: ctx.show_source, }, }, user: { - id: this.user_id, - firstName: this.user.first_name, - lastName: this.user.last_name, - email: this.user.email, + id: ctx.user_id, + firstName: ctx.user.first_name, + lastName: ctx.user.last_name, + email: ctx.user.email, orcidId: null, v1UserId: 876, }, destination: { - brandVariationId: this.brand_variation_id, + brandVariationId: ctx.brand_variation_id, }, options: { callbackUrl: null, }, } - return this.callback - .calledWith(null, expectedExportData) - .should.equal(true) + ctx.callback.calledWith(null, expectedExportData).should.equal(true) }) }) describe('when we send replacement user first and last name', function () { - beforeEach(function (done) { - this.custom_first_name = 'FIRST' - this.custom_last_name = 'LAST' - this.export_params.first_name = this.custom_first_name - this.export_params.last_name = this.custom_last_name - return this.ExportsHandler._buildExport( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.custom_first_name = 'FIRST' + ctx.custom_last_name = 'LAST' + ctx.export_params.first_name = ctx.custom_first_name + ctx.export_params.last_name = ctx.custom_last_name + ctx.ExportsHandler._buildExport( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should send the data from the user input', function () { + it('should send the data from the user input', function (ctx) { const expectedExportData = { project: { - id: this.project_id, - rootDocPath: this.rootDocPath, - historyId: this.project_history_id, - historyVersion: this.historyVersion, - v1ProjectId: this.project_history_id, + id: ctx.project_id, + rootDocPath: ctx.rootDocPath, + historyId: ctx.project_history_id, + historyVersion: ctx.historyVersion, + v1ProjectId: ctx.project_history_id, metadata: { compiler: 'pdflatex', imageName: 'mock-image-name', - title: this.title, - description: this.description, - author: this.author, - license: this.license, - showSource: this.show_source, + title: ctx.title, + description: ctx.description, + author: ctx.author, + license: ctx.license, + showSource: ctx.show_source, }, }, user: { - id: this.user_id, - firstName: this.custom_first_name, - lastName: this.custom_last_name, - email: this.user.email, + id: ctx.user_id, + firstName: ctx.custom_first_name, + lastName: ctx.custom_last_name, + email: ctx.user.email, orcidId: null, v1UserId: 876, }, destination: { - brandVariationId: this.brand_variation_id, + brandVariationId: ctx.brand_variation_id, }, options: { callbackUrl: null, }, } - return this.callback - .calledWith(null, expectedExportData) - .should.equal(true) + ctx.callback.calledWith(null, expectedExportData).should.equal(true) }) }) describe('when project is not found', function () { - beforeEach(function (done) { - this.ProjectGetter.getProject = sinon - .stub() - .yields(new Error('project not found')) - return this.ExportsHandler._buildExport( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ProjectGetter.getProject = sinon + .stub() + .yields(new Error('project not found')) + ctx.ExportsHandler._buildExport( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should return an error', function () { - return (this.callback.args[0][0] instanceof Error).should.equal(true) + it('should return an error', function (ctx) { + expect(ctx.callback.args[0][0]).to.be.instanceOf(Error) }) }) describe('when project has no root doc', function () { describe('when a root doc can be set automatically', function () { - beforeEach(function (done) { - this.project.rootDoc_id = null - this.ProjectLocator.findRootDoc = sinon - .stub() - .yields(null, [null, { fileSystem: 'other.tex' }]) - return this.ExportsHandler._buildExport( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.project.rootDoc_id = null + ctx.ProjectLocator.findRootDoc = sinon + .stub() + .yields(null, [null, { fileSystem: 'other.tex' }]) + ctx.ExportsHandler._buildExport( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should set a root doc', function () { - return this.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal( + it('should set a root doc', function (ctx) { + ctx.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal( true ) }) - it('should return export data', function () { + it('should return export data', function (ctx) { const expectedExportData = { project: { - id: this.project_id, + id: ctx.project_id, rootDocPath: 'other.tex', - historyId: this.project_history_id, - historyVersion: this.historyVersion, - v1ProjectId: this.project_history_id, + historyId: ctx.project_history_id, + historyVersion: ctx.historyVersion, + v1ProjectId: ctx.project_history_id, metadata: { compiler: 'pdflatex', imageName: 'mock-image-name', - title: this.title, - description: this.description, - author: this.author, - license: this.license, - showSource: this.show_source, + title: ctx.title, + description: ctx.description, + author: ctx.author, + license: ctx.license, + showSource: ctx.show_source, }, }, user: { - id: this.user_id, - firstName: this.user.first_name, - lastName: this.user.last_name, - email: this.user.email, + id: ctx.user_id, + firstName: ctx.user.first_name, + lastName: ctx.user.last_name, + email: ctx.user.email, orcidId: null, v1UserId: 876, }, destination: { - brandVariationId: this.brand_variation_id, + brandVariationId: ctx.brand_variation_id, }, options: { callbackUrl: null, }, } - return this.callback - .calledWith(null, expectedExportData) - .should.equal(true) + ctx.callback.calledWith(null, expectedExportData).should.equal(true) }) }) }) describe('when project has an invalid root doc', function () { describe('when a new root doc can be set automatically', function () { - beforeEach(function (done) { - this.fakeDoc_id = '1a2b3c4d5e6f' - this.project.rootDoc_id = this.fakeDoc_id - this.ProjectLocator.findRootDoc = sinon - .stub() - .yields(null, [null, { fileSystem: 'other.tex' }]) - return this.ExportsHandler._buildExport( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.fakeDoc_id = '1a2b3c4d5e6f' + ctx.project.rootDoc_id = ctx.fakeDoc_id + ctx.ProjectLocator.findRootDoc = sinon + .stub() + .yields(null, [null, { fileSystem: 'other.tex' }]) + ctx.ExportsHandler._buildExport( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should set a valid root doc', function () { - return this.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal( + it('should set a valid root doc', function (ctx) { + ctx.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal( true ) }) - it('should return export data', function () { + it('should return export data', function (ctx) { const expectedExportData = { project: { - id: this.project_id, + id: ctx.project_id, rootDocPath: 'other.tex', - historyId: this.project_history_id, - historyVersion: this.historyVersion, - v1ProjectId: this.project_history_id, + historyId: ctx.project_history_id, + historyVersion: ctx.historyVersion, + v1ProjectId: ctx.project_history_id, metadata: { compiler: 'pdflatex', imageName: 'mock-image-name', - title: this.title, - description: this.description, - author: this.author, - license: this.license, - showSource: this.show_source, + title: ctx.title, + description: ctx.description, + author: ctx.author, + license: ctx.license, + showSource: ctx.show_source, }, }, user: { - id: this.user_id, - firstName: this.user.first_name, - lastName: this.user.last_name, - email: this.user.email, + id: ctx.user_id, + firstName: ctx.user.first_name, + lastName: ctx.user.last_name, + email: ctx.user.email, orcidId: null, v1UserId: 876, }, destination: { - brandVariationId: this.brand_variation_id, + brandVariationId: ctx.brand_variation_id, }, options: { callbackUrl: null, }, } - return this.callback - .calledWith(null, expectedExportData) - .should.equal(true) + ctx.callback.calledWith(null, expectedExportData).should.equal(true) }) }) describe('when no root doc can be identified', function () { - beforeEach(function (done) { - this.ProjectLocator.findRootDoc = sinon - .stub() - .yields(null, [null, null]) - return this.ExportsHandler._buildExport( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ProjectLocator.findRootDoc = sinon + .stub() + .yields(null, [null, null]) + ctx.ExportsHandler._buildExport( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should return an error', function () { - return (this.callback.args[0][0] instanceof Error).should.equal(true) + it('should return an error', function (ctx) { + expect(ctx.callback.args[0][0]).to.be.instanceOf(Error) }) }) }) describe('when user is not found', function () { - beforeEach(function (done) { - this.UserGetter.getUser = sinon - .stub() - .yields(new Error('user not found')) - return this.ExportsHandler._buildExport( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.UserGetter.getUser = sinon + .stub() + .yields(new Error('user not found')) + ctx.ExportsHandler._buildExport( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should return an error', function () { - return (this.callback.args[0][0] instanceof Error).should.equal(true) + it('should return an error', function (ctx) { + expect(ctx.callback.args[0][0]).to.be.instanceOf(Error) }) }) describe('when project history request fails', function () { - beforeEach(function (done) { - this.ExportsHandler._requestVersion = sinon - .stub() - .yields(new Error('project history call failed')) - return this.ExportsHandler._buildExport( - this.export_params, - (error, exportData) => { - this.callback(error, exportData) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.ExportsHandler._requestVersion = sinon + .stub() + .yields(new Error('project history call failed')) + ctx.ExportsHandler._buildExport( + ctx.export_params, + (error, exportData) => { + ctx.callback(error, exportData) + resolve() + } + ) + }) }) - it('should return an error', function () { - return (this.callback.args[0][0] instanceof Error).should.equal(true) + it('should return an error', function (ctx) { + expect(ctx.callback.args[0][0]).to.be.instanceOf(Error) }) }) }) describe('_requestExport', function () { - beforeEach(function (done) { - this.settings.apis = { - v1: { - url: 'http://127.0.0.1:5000', - user: 'overleaf', - pass: 'pass', - timeout: 15000, - }, - } - this.export_data = { iAmAnExport: true } - this.export_id = 4096 - this.stubPost = sinon - .stub() - .yields(null, { statusCode: 200 }, { exportId: this.export_id }) - return done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.settings.apis = { + v1: { + url: 'http://127.0.0.1:5000', + user: 'overleaf', + pass: 'pass', + timeout: 15000, + }, + } + ctx.export_data = { iAmAnExport: true } + ctx.export_id = 4096 + ctx.stubPost = sinon + .stub() + .yields(null, { statusCode: 200 }, { exportId: ctx.export_id }) + resolve() + }) }) describe('when all goes well', function () { - beforeEach(function (done) { - this.stubRequest.post = this.stubPost - return this.ExportsHandler._requestExport( - this.export_data, - (error, exportV1Id) => { - this.callback(error, exportV1Id) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.stubRequest.post = ctx.stubPost + ctx.ExportsHandler._requestExport( + ctx.export_data, + (error, exportV1Id) => { + ctx.callback(error, exportV1Id) + resolve() + } + ) + }) }) - it('should issue the request', function () { - return expect(this.stubPost.getCall(0).args[0]).to.deep.equal({ - url: this.settings.apis.v1.url + '/api/v1/overleaf/exports', + it('should issue the request', function (ctx) { + expect(ctx.stubPost.getCall(0).args[0]).to.deep.equal({ + url: ctx.settings.apis.v1.url + '/api/v1/overleaf/exports', auth: { - user: this.settings.apis.v1.user, - pass: this.settings.apis.v1.pass, + user: ctx.settings.apis.v1.user, + pass: ctx.settings.apis.v1.pass, }, - json: this.export_data, + json: ctx.export_data, timeout: 15000, }) }) - it('should return the body with v1 export id', function () { - return this.callback - .calledWith(null, { exportId: this.export_id }) + it('should return the body with v1 export id', function (ctx) { + ctx.callback + .calledWith(null, { exportId: ctx.export_id }) .should.equal(true) }) }) describe('when the request fails', function () { - beforeEach(function (done) { - this.stubRequest.post = sinon - .stub() - .yields(new Error('export request failed')) - return this.ExportsHandler._requestExport( - this.export_data, - (error, exportV1Id) => { - this.callback(error, exportV1Id) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.stubRequest.post = sinon + .stub() + .yields(new Error('export request failed')) + ctx.ExportsHandler._requestExport( + ctx.export_data, + (error, exportV1Id) => { + ctx.callback(error, exportV1Id) + resolve() + } + ) + }) }) - it('should return an error', function () { - return (this.callback.args[0][0] instanceof Error).should.equal(true) + it('should return an error', function (ctx) { + expect(ctx.callback.args[0][0]).to.be.instanceOf(Error) }) }) describe('when the request returns an error response to forward', function () { - beforeEach(function (done) { - this.error_code = 422 - this.error_json = { status: this.error_code, message: 'nope' } - this.stubRequest.post = sinon + beforeEach(function (ctx) { + ctx.error_code = 422 + ctx.error_json = { status: ctx.error_code, message: 'nope' } + ctx.stubRequest.post = sinon .stub() - .yields(null, { statusCode: this.error_code }, this.error_json) - return this.ExportsHandler._requestExport( - this.export_data, - (error, exportV1Id) => { - this.callback(error, exportV1Id) - return done() - } - ) + .yields(null, { statusCode: ctx.error_code }, ctx.error_json) + return new Promise(resolve => { + ctx.ExportsHandler._requestExport( + ctx.export_data, + (error, exportV1Id) => { + ctx.callback(error, exportV1Id) + resolve() + } + ) + }) }) - it('should return success and the response to forward', function () { - ;(this.callback.args[0][0] instanceof Error).should.equal(false) - return this.callback.calledWith(null, { - forwardResponse: this.error_json, + it('should return success and the response to forward', function (ctx) { + expect(ctx.callback.args[0][0]).not.to.be.instanceOf(Error) + ctx.callback.calledWith(null, { + forwardResponse: ctx.error_json, }) }) }) }) describe('fetchExport', function () { - beforeEach(function (done) { - this.settings.apis = { - v1: { - url: 'http://127.0.0.1:5000', - user: 'overleaf', - pass: 'pass', - timeout: 15000, - }, - } - this.export_id = 897 - this.body = '{"id":897, "status_summary":"completed"}' - this.stubGet = sinon - .stub() - .yields(null, { statusCode: 200 }, { body: this.body }) - return done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.settings.apis = { + v1: { + url: 'http://127.0.0.1:5000', + user: 'overleaf', + pass: 'pass', + timeout: 15000, + }, + } + ctx.export_id = 897 + ctx.body = '{"id":897, "status_summary":"completed"}' + ctx.stubGet = sinon + .stub() + .yields(null, { statusCode: 200 }, { body: ctx.body }) + resolve() + }) }) describe('when all goes well', function () { - beforeEach(function (done) { - this.stubRequest.get = this.stubGet - return this.ExportsHandler.fetchExport( - this.export_id, - (error, body) => { - this.callback(error, body) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.stubRequest.get = ctx.stubGet + ctx.ExportsHandler.fetchExport(ctx.export_id, (error, body) => { + ctx.callback(error, body) + resolve() + }) + }) }) - it('should issue the request', function () { - return expect(this.stubGet.getCall(0).args[0]).to.deep.equal({ + it('should issue the request', function (ctx) { + expect(ctx.stubGet.getCall(0).args[0]).to.deep.equal({ url: - this.settings.apis.v1.url + + ctx.settings.apis.v1.url + '/api/v1/overleaf/exports/' + - this.export_id, + ctx.export_id, auth: { - user: this.settings.apis.v1.user, - pass: this.settings.apis.v1.pass, + user: ctx.settings.apis.v1.user, + pass: ctx.settings.apis.v1.pass, }, timeout: 15000, }) }) - it('should return the v1 export id', function () { - return this.callback - .calledWith(null, { body: this.body }) - .should.equal(true) + it('should return the v1 export id', function (ctx) { + ctx.callback.calledWith(null, { body: ctx.body }).should.equal(true) }) }) }) describe('fetchDownload', function () { - beforeEach(function (done) { - this.settings.apis = { - v1: { - url: 'http://127.0.0.1:5000', - user: 'overleaf', - pass: 'pass', - timeout: 15000, - }, - } - this.export_id = 897 - this.body = - 'https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee' - this.stubGet = sinon - .stub() - .yields(null, { statusCode: 200 }, { body: this.body }) - return done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.settings.apis = { + v1: { + url: 'http://127.0.0.1:5000', + user: 'overleaf', + pass: 'pass', + timeout: 15000, + }, + } + ctx.export_id = 897 + ctx.body = + 'https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee' + ctx.stubGet = sinon + .stub() + .yields(null, { statusCode: 200 }, { body: ctx.body }) + resolve() + }) }) describe('when all goes well', function () { - beforeEach(function (done) { - this.stubRequest.get = this.stubGet - return this.ExportsHandler.fetchDownload( - this.export_id, - 'zip', - (error, body) => { - this.callback(error, body) - return done() - } - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.stubRequest.get = ctx.stubGet + ctx.ExportsHandler.fetchDownload( + ctx.export_id, + 'zip', + (error, body) => { + ctx.callback(error, body) + resolve() + } + ) + }) }) - it('should issue the request', function () { - return expect(this.stubGet.getCall(0).args[0]).to.deep.equal({ + it('should issue the request', function (ctx) { + expect(ctx.stubGet.getCall(0).args[0]).to.deep.equal({ url: - this.settings.apis.v1.url + + ctx.settings.apis.v1.url + '/api/v1/overleaf/exports/' + - this.export_id + + ctx.export_id + '/zip_url', auth: { - user: this.settings.apis.v1.user, - pass: this.settings.apis.v1.pass, + user: ctx.settings.apis.v1.user, + pass: ctx.settings.apis.v1.pass, }, timeout: 15000, }) }) - it('should return the v1 export id', function () { - return this.callback - .calledWith(null, { body: this.body }) - .should.equal(true) + it('should return the v1 export id', function (ctx) { + ctx.callback.calledWith(null, { body: ctx.body }).should.equal(true) }) }) }) diff --git a/services/web/test/unit/src/FileStore/FileStoreController.test.mjs b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs index 2758068ce3..5c46e516a0 100644 --- a/services/web/test/unit/src/FileStore/FileStoreController.test.mjs +++ b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs @@ -1,6 +1,6 @@ +import { vi } from 'vitest' import { expect } from 'chai' import sinon from 'sinon' -import esmock from 'esmock' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockResponse from '../helpers/MockResponse.js' @@ -12,34 +12,51 @@ const expectedFileHeaders = { 'X-Served-By': 'filestore', } +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + describe('FileStoreController', function () { - beforeEach(async function () { - this.FileStoreHandler = { + beforeEach(async function (ctx) { + ctx.FileStoreHandler = { promises: { getFileStream: sinon.stub(), getFileSize: sinon.stub(), }, } - this.ProjectLocator = { promises: { findElement: sinon.stub() } } - this.Stream = { pipeline: sinon.stub().resolves() } - this.HistoryManager = {} - this.controller = await esmock.strict(MODULE_PATH, { - 'node:stream/promises': this.Stream, - '@overleaf/settings': this.settings, - '../../../../app/src/Features/Project/ProjectLocator': - this.ProjectLocator, - '../../../../app/src/Features/FileStore/FileStoreHandler': - this.FileStoreHandler, - '../../../../app/src/Features/History/HistoryManager': - this.HistoryManager, - }) - this.stream = {} - this.projectId = '2k3j1lk3j21lk3j' - this.fileId = '12321kklj1lk3jk12' - this.req = { + ctx.ProjectLocator = { promises: { findElement: sinon.stub() } } + ctx.Stream = { pipeline: sinon.stub().resolves() } + ctx.HistoryManager = {} + + vi.doMock('node:stream/promises', () => ctx.Stream) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({ + default: ctx.ProjectLocator, + })) + + vi.doMock( + '../../../../app/src/Features/FileStore/FileStoreHandler', + () => ({ + default: ctx.FileStoreHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({ + default: ctx.HistoryManager, + })) + + ctx.controller = (await import(MODULE_PATH)).default + ctx.stream = {} + ctx.projectId = '2k3j1lk3j21lk3j' + ctx.fileId = '12321kklj1lk3jk12' + ctx.req = { params: { - Project_id: this.projectId, - File_id: this.fileId, + Project_id: ctx.projectId, + File_id: ctx.fileId, }, query: 'query string here', get(key) { @@ -49,61 +66,61 @@ describe('FileStoreController', function () { addFields: sinon.stub(), }, } - this.res = new MockResponse() - this.next = sinon.stub() - this.file = { name: 'myfile.png' } + ctx.res = new MockResponse() + ctx.next = sinon.stub() + ctx.file = { name: 'myfile.png' } }) describe('getFile', function () { - beforeEach(function () { - this.FileStoreHandler.promises.getFileStream.resolves(this.stream) - this.ProjectLocator.promises.findElement.resolves({ element: this.file }) + beforeEach(function (ctx) { + ctx.FileStoreHandler.promises.getFileStream.resolves(ctx.stream) + ctx.ProjectLocator.promises.findElement.resolves({ element: ctx.file }) }) - it('should call the file store handler with the project_id file_id and any query string', async function () { - await this.controller.getFile(this.req, this.res) - this.FileStoreHandler.promises.getFileStream.should.have.been.calledWith( - this.req.params.Project_id, - this.req.params.File_id, - this.req.query + it('should call the file store handler with the project_id file_id and any query string', async function (ctx) { + await ctx.controller.getFile(ctx.req, ctx.res) + ctx.FileStoreHandler.promises.getFileStream.should.have.been.calledWith( + ctx.req.params.Project_id, + ctx.req.params.File_id, + ctx.req.query ) }) - it('should pipe to res', async function () { - await this.controller.getFile(this.req, this.res) - this.Stream.pipeline.should.have.been.calledWith(this.stream, this.res) + it('should pipe to res', async function (ctx) { + await ctx.controller.getFile(ctx.req, ctx.res) + ctx.Stream.pipeline.should.have.been.calledWith(ctx.stream, ctx.res) }) - it('should get the file from the db', async function () { - await this.controller.getFile(this.req, this.res) - this.ProjectLocator.promises.findElement.should.have.been.calledWith({ - project_id: this.projectId, - element_id: this.fileId, + it('should get the file from the db', async function (ctx) { + await ctx.controller.getFile(ctx.req, ctx.res) + ctx.ProjectLocator.promises.findElement.should.have.been.calledWith({ + project_id: ctx.projectId, + element_id: ctx.fileId, type: 'file', }) }) - it('should set the Content-Disposition header', async function () { - await this.controller.getFile(this.req, this.res) - this.res.setContentDisposition.should.be.calledWith('attachment', { - filename: this.file.name, + it('should set the Content-Disposition header', async function (ctx) { + await ctx.controller.getFile(ctx.req, ctx.res) + ctx.res.setContentDisposition.should.be.calledWith('attachment', { + filename: ctx.file.name, }) }) - it('should return a 404 when not found', async function () { - this.ProjectLocator.promises.findElement.rejects( + it('should return a 404 when not found', async function (ctx) { + ctx.ProjectLocator.promises.findElement.rejects( new Errors.NotFoundError() ) - await this.controller.getFile(this.req, this.res) - expect(this.res.statusCode).to.equal(404) + await ctx.controller.getFile(ctx.req, ctx.res) + expect(ctx.res.statusCode).to.equal(404) }) // Test behaviour around handling html files ;['.html', '.htm', '.xhtml'].forEach(extension => { describe(`with a '${extension}' file extension`, function () { - beforeEach(function () { - this.file.name = `bad${extension}` - this.req.get = key => { + beforeEach(function (ctx) { + ctx.file.name = `bad${extension}` + ctx.req.get = key => { if (key === 'User-Agent') { return 'A generic browser' } @@ -111,26 +128,26 @@ describe('FileStoreController', function () { }) describe('from a non-ios browser', function () { - it('should not set Content-Type', async function () { - await this.controller.getFile(this.req, this.res) - this.res.headers.should.deep.equal({ + it('should not set Content-Type', async function (ctx) { + await ctx.controller.getFile(ctx.req, ctx.res) + ctx.res.headers.should.deep.equal({ ...expectedFileHeaders, }) }) }) describe('from an iPhone', function () { - beforeEach(function () { - this.req.get = key => { + beforeEach(function (ctx) { + ctx.req.get = key => { if (key === 'User-Agent') { return 'An iPhone browser' } } }) - it("should set Content-Type to 'text/plain'", async function () { - await this.controller.getFile(this.req, this.res) - this.res.headers.should.deep.equal({ + it("should set Content-Type to 'text/plain'", async function (ctx) { + await ctx.controller.getFile(ctx.req, ctx.res) + ctx.res.headers.should.deep.equal({ ...expectedFileHeaders, 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff', @@ -139,17 +156,17 @@ describe('FileStoreController', function () { }) describe('from an iPad', function () { - beforeEach(function () { - this.req.get = key => { + beforeEach(function (ctx) { + ctx.req.get = key => { if (key === 'User-Agent') { return 'An iPad browser' } } }) - it("should set Content-Type to 'text/plain'", async function () { - await this.controller.getFile(this.req, this.res) - this.res.headers.should.deep.equal({ + it("should set Content-Type to 'text/plain'", async function (ctx) { + await ctx.controller.getFile(ctx.req, ctx.res) + ctx.res.headers.should.deep.equal({ ...expectedFileHeaders, 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff', @@ -166,24 +183,24 @@ describe('FileStoreController', function () { 'somefile', ].forEach(filename => { describe(`with filename as '${filename}'`, function () { - beforeEach(function () { - this.user_agent = 'A generic browser' - this.file.name = filename - this.req.get = key => { + beforeEach(function (ctx) { + ctx.user_agent = 'A generic browser' + ctx.file.name = filename + ctx.req.get = key => { if (key === 'User-Agent') { - return this.user_agent + return ctx.user_agent } } }) ;['iPhone', 'iPad', 'Firefox', 'Chrome'].forEach(browser => { describe(`downloaded from ${browser}`, function () { - beforeEach(function () { - this.user_agent = `Some ${browser} thing` + beforeEach(function (ctx) { + ctx.user_agent = `Some ${browser} thing` }) - it('Should not set the Content-type', async function () { - await this.controller.getFile(this.req, this.res) - this.res.headers.should.deep.equal({ + it('Should not set the Content-type', async function (ctx) { + await ctx.controller.getFile(ctx.req, ctx.res) + ctx.res.headers.should.deep.equal({ ...expectedFileHeaders, }) }) @@ -194,42 +211,46 @@ describe('FileStoreController', function () { }) describe('getFileHead', function () { - beforeEach(function () { - this.ProjectLocator.promises.findElement.resolves({ element: this.file }) + beforeEach(function (ctx) { + ctx.ProjectLocator.promises.findElement.resolves({ element: ctx.file }) }) - it('reports the file size', function (done) { - const expectedFileSize = 99393 - this.FileStoreHandler.promises.getFileSize.rejects( - new Error('getFileSize: unexpected arguments') - ) - this.FileStoreHandler.promises.getFileSize - .withArgs(this.projectId, this.fileId) - .resolves(expectedFileSize) + it('reports the file size', function (ctx) { + return new Promise(resolve => { + const expectedFileSize = 99393 + ctx.FileStoreHandler.promises.getFileSize.rejects( + new Error('getFileSize: unexpected arguments') + ) + ctx.FileStoreHandler.promises.getFileSize + .withArgs(ctx.projectId, ctx.fileId) + .resolves(expectedFileSize) - this.res.end = () => { - expect(this.res.status.lastCall.args).to.deep.equal([200]) - expect(this.res.header.lastCall.args).to.deep.equal([ - 'Content-Length', - expectedFileSize, - ]) - done() - } + ctx.res.end = () => { + expect(ctx.res.status.lastCall.args).to.deep.equal([200]) + expect(ctx.res.header.lastCall.args).to.deep.equal([ + 'Content-Length', + expectedFileSize, + ]) + resolve() + } - this.controller.getFileHead(this.req, this.res) + ctx.controller.getFileHead(ctx.req, ctx.res) + }) }) - it('returns 404 on NotFoundError', function (done) { - this.FileStoreHandler.promises.getFileSize.rejects( - new Errors.NotFoundError() - ) + it('returns 404 on NotFoundError', function (ctx) { + return new Promise(resolve => { + ctx.FileStoreHandler.promises.getFileSize.rejects( + new Errors.NotFoundError() + ) - this.res.end = () => { - expect(this.res.status.lastCall.args).to.deep.equal([404]) - done() - } + ctx.res.end = () => { + expect(ctx.res.status.lastCall.args).to.deep.equal([404]) + resolve() + } - this.controller.getFileHead(this.req, this.res) + ctx.controller.getFileHead(ctx.req, ctx.res) + }) }) }) }) diff --git a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs index f1b7b58c10..b29d10bba4 100644 --- a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs +++ b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs @@ -1,155 +1,205 @@ +import { vi } from 'vitest' import { expect } from 'chai' -import esmock from 'esmock' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs' describe('LinkedFilesController', function () { - beforeEach(function () { - this.fakeTime = new Date() - this.clock = sinon.useFakeTimers(this.fakeTime.getTime()) + beforeEach(function (ctx) { + ctx.fakeTime = new Date() + ctx.clock = sinon.useFakeTimers(ctx.fakeTime.getTime()) }) - afterEach(function () { - this.clock.restore() + afterEach(function (ctx) { + ctx.clock.restore() }) - beforeEach(async function () { - this.userId = 'user-id' - this.Agent = { + beforeEach(async function (ctx) { + ctx.userId = 'user-id' + ctx.Agent = { promises: { createLinkedFile: sinon.stub().resolves(), refreshLinkedFile: sinon.stub().resolves(), }, } - this.projectId = 'projectId' - this.provider = 'provider' - this.name = 'linked-file-name' - this.data = { customAgentData: 'foo' } - this.LinkedFilesHandler = { + ctx.projectId = 'projectId' + ctx.provider = 'provider' + ctx.fileName = 'linked-file-name' + ctx.data = { customAgentData: 'foo' } + ctx.LinkedFilesHandler = { promises: { getFileById: sinon.stub(), }, } - this.AnalyticsManager = {} - this.SessionManager = { - getLoggedInUserId: sinon.stub().returns(this.userId), + ctx.AnalyticsManager = {} + ctx.SessionManager = { + getLoggedInUserId: sinon.stub().returns(ctx.userId), } - this.EditorRealTimeController = {} - this.ReferencesHandler = {} - this.UrlAgent = {} - this.ProjectFileAgent = {} - this.ProjectOutputFileAgent = {} - this.EditorController = {} - this.ProjectLocator = {} - this.logger = { + ctx.EditorRealTimeController = {} + ctx.ReferencesHandler = {} + ctx.UrlAgent = {} + ctx.ProjectFileAgent = {} + ctx.ProjectOutputFileAgent = {} + ctx.EditorController = {} + ctx.ProjectLocator = {} + ctx.logger = { error: sinon.stub(), } - this.settings = { enabledLinkedFileTypes: [] } - this.LinkedFilesController = await esmock.strict(modulePath, { - '.../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/Features/Analytics/AnalyticsManager': - this.AnalyticsManager, - '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler': - this.LinkedFilesHandler, - '../../../../app/src/Features/Editor/EditorRealTimeController': - this.EditorRealTimeController, - '../../../../app/src/Features/References/ReferencesHandler': - this.ReferencesHandler, - '../../../../app/src/Features/LinkedFiles/UrlAgent': this.UrlAgent, - '../../../../app/src/Features/LinkedFiles/ProjectFileAgent': - this.ProjectFileAgent, - '../../../../app/src/Features/LinkedFiles/ProjectOutputFileAgent': - this.ProjectOutputFileAgent, - '../../../../app/src/Features/Editor/EditorController': - this.EditorController, - '../../../../app/src/Features/Project/ProjectLocator': - this.ProjectLocator, - '@overleaf/logger': this.logger, - '@overleaf/settings': this.settings, - }) - this.LinkedFilesController._getAgent = sinon.stub().resolves(this.Agent) + ctx.settings = { enabledLinkedFileTypes: [] } + + vi.doMock( + '.../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler', + () => ({ + default: ctx.LinkedFilesHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Editor/EditorRealTimeController', + () => ({ + default: ctx.EditorRealTimeController, + }) + ) + + vi.doMock( + '../../../../app/src/Features/References/ReferencesHandler', + () => ({ + default: ctx.ReferencesHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/LinkedFiles/UrlAgent', () => ({ + default: ctx.UrlAgent, + })) + + vi.doMock( + '../../../../app/src/Features/LinkedFiles/ProjectFileAgent', + () => ({ + default: ctx.ProjectFileAgent, + }) + ) + + vi.doMock( + '../../../../app/src/Features/LinkedFiles/ProjectOutputFileAgent', + () => ({ + default: ctx.ProjectOutputFileAgent, + }) + ) + + vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({ + default: ctx.EditorController, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({ + default: ctx.ProjectLocator, + })) + + vi.doMock('@overleaf/logger', () => ({ + default: ctx.logger, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + ctx.LinkedFilesController = (await import(modulePath)).default + ctx.LinkedFilesController._getAgent = sinon.stub().resolves(ctx.Agent) }) describe('createLinkedFile', function () { - beforeEach(function () { - this.req = { - params: { project_id: this.projectId }, + beforeEach(function (ctx) { + ctx.req = { + params: { project_id: ctx.projectId }, body: { - name: this.name, - provider: this.provider, - data: this.data, + name: ctx.fileName, + provider: ctx.provider, + data: ctx.data, }, } - this.next = sinon.stub() + ctx.next = sinon.stub() }) - it('sets importedAt timestamp on linkedFileData', function (done) { - this.next = sinon.stub().callsFake(() => done('unexpected error')) - this.res = { - json: () => { - expect(this.Agent.promises.createLinkedFile).to.have.been.calledWith( - this.projectId, - { ...this.data, importedAt: this.fakeTime.toISOString() }, - this.name, - undefined, - this.userId - ) - done() - }, - } - this.LinkedFilesController.createLinkedFile(this.req, this.res, this.next) + it('sets importedAt timestamp on linkedFileData', function (ctx) { + return new Promise(resolve => { + ctx.next = sinon.stub().callsFake(() => resolve('unexpected error')) + ctx.res = { + json: () => { + expect(ctx.Agent.promises.createLinkedFile).to.have.been.calledWith( + ctx.projectId, + { ...ctx.data, importedAt: ctx.fakeTime.toISOString() }, + ctx.fileName, + undefined, + ctx.userId + ) + resolve() + }, + } + ctx.LinkedFilesController.createLinkedFile(ctx.req, ctx.res, ctx.next) + }) }) }) describe('refreshLinkedFiles', function () { - beforeEach(function () { - this.data.provider = this.provider - this.file = { - name: this.name, + beforeEach(function (ctx) { + ctx.data.provider = ctx.provider + ctx.file = { + name: ctx.fileName, linkedFileData: { - ...this.data, + ...ctx.data, importedAt: new Date(2020, 1, 1).toISOString(), }, } - this.LinkedFilesHandler.promises.getFileById - .withArgs(this.projectId, 'file-id') + ctx.LinkedFilesHandler.promises.getFileById + .withArgs(ctx.projectId, 'file-id') .resolves({ - file: this.file, + file: ctx.file, path: 'fake-path', parentFolder: { _id: 'parent-folder-id', }, }) - this.req = { - params: { project_id: this.projectId, file_id: 'file-id' }, + ctx.req = { + params: { project_id: ctx.projectId, file_id: 'file-id' }, body: {}, } - this.next = sinon.stub() + ctx.next = sinon.stub() }) - it('resets importedAt timestamp on linkedFileData', function (done) { - this.next = sinon.stub().callsFake(() => done('unexpected error')) - this.res = { - json: () => { - expect(this.Agent.promises.refreshLinkedFile).to.have.been.calledWith( - this.projectId, - { - ...this.data, - importedAt: this.fakeTime.toISOString(), - }, - this.name, - 'parent-folder-id', - this.userId - ) - done() - }, - } - this.LinkedFilesController.refreshLinkedFile( - this.req, - this.res, - this.next - ) + it('resets importedAt timestamp on linkedFileData', function (ctx) { + return new Promise(resolve => { + ctx.next = sinon.stub().callsFake(() => resolve('unexpected error')) + ctx.res = { + json: () => { + expect( + ctx.Agent.promises.refreshLinkedFile + ).to.have.been.calledWith( + ctx.projectId, + { + ...ctx.data, + importedAt: ctx.fakeTime.toISOString(), + }, + ctx.name, + 'parent-folder-id', + ctx.userId + ) + resolve() + }, + } + ctx.LinkedFilesController.refreshLinkedFile(ctx.req, ctx.res, ctx.next) + }) }) }) }) diff --git a/services/web/test/unit/src/Metadata/MetaController.test.mjs b/services/web/test/unit/src/Metadata/MetaController.test.mjs index 5695d289f7..00b3568ae2 100644 --- a/services/web/test/unit/src/Metadata/MetaController.test.mjs +++ b/services/web/test/unit/src/Metadata/MetaController.test.mjs @@ -1,31 +1,38 @@ +import { vi } from 'vitest' import { expect } from 'chai' import sinon from 'sinon' -import esmock from 'esmock' import MockResponse from '../helpers/MockResponse.js' const modulePath = '../../../../app/src/Features/Metadata/MetaController.mjs' describe('MetaController', function () { - beforeEach(async function () { - this.EditorRealTimeController = { + beforeEach(async function (ctx) { + ctx.EditorRealTimeController = { emitToRoom: sinon.stub(), } - this.MetaHandler = { + ctx.MetaHandler = { promises: { getAllMetaForProject: sinon.stub(), getMetaForDoc: sinon.stub(), }, } - this.MetadataController = await esmock.strict(modulePath, { - '../../../../app/src/Features/Editor/EditorRealTimeController': - this.EditorRealTimeController, - '../../../../app/src/Features/Metadata/MetaHandler': this.MetaHandler, - }) + vi.doMock( + '../../../../app/src/Features/Editor/EditorRealTimeController', + () => ({ + default: ctx.EditorRealTimeController, + }) + ) + + vi.doMock('../../../../app/src/Features/Metadata/MetaHandler', () => ({ + default: ctx.MetaHandler, + })) + + ctx.MetadataController = (await import(modulePath)).default }) describe('getMetadata', function () { - it('should respond with json', async function () { + it('should respond with json', async function (ctx) { const projectMeta = { 'doc-id': { labels: ['foo'], @@ -34,7 +41,7 @@ describe('MetaController', function () { }, } - this.MetaHandler.promises.getAllMetaForProject = sinon + ctx.MetaHandler.promises.getAllMetaForProject = sinon .stub() .resolves(projectMeta) @@ -42,9 +49,9 @@ describe('MetaController', function () { const res = new MockResponse() const next = sinon.stub() - await this.MetadataController.getMetadata(req, res, next) + await ctx.MetadataController.getMetadata(req, res, next) - this.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith( + ctx.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith( 'project-id' ) res.json.should.have.been.calledOnceWith({ @@ -54,8 +61,8 @@ describe('MetaController', function () { next.should.not.have.been.called }) - it('should handle an error', async function () { - this.MetaHandler.promises.getAllMetaForProject = sinon + it('should handle an error', async function (ctx) { + ctx.MetaHandler.promises.getAllMetaForProject = sinon .stub() .throws(new Error('woops')) @@ -63,9 +70,9 @@ describe('MetaController', function () { const res = new MockResponse() const next = sinon.stub() - await this.MetadataController.getMetadata(req, res, next) + await ctx.MetadataController.getMetadata(req, res, next) - this.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith( + ctx.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith( 'project-id' ) res.json.should.not.have.been.called @@ -74,14 +81,14 @@ describe('MetaController', function () { }) describe('broadcastMetadataForDoc', function () { - it('should broadcast on broadcast:true ', async function () { - this.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves({ + it('should broadcast on broadcast:true ', async function (ctx) { + ctx.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves({ labels: ['foo'], packages: { a: { commands: [] } }, packageNames: ['a'], }) - this.EditorRealTimeController.emitToRoom = sinon.stub() + ctx.EditorRealTimeController.emitToRoom = sinon.stub() const req = { params: { project_id: 'project-id', doc_id: 'doc-id' }, @@ -90,32 +97,32 @@ describe('MetaController', function () { const res = new MockResponse() const next = sinon.stub() - await this.MetadataController.broadcastMetadataForDoc(req, res, next) + await ctx.MetadataController.broadcastMetadataForDoc(req, res, next) - this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith( + ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith( 'project-id' ) res.json.should.not.have.been.called res.sendStatus.should.have.been.calledOnceWith(200) next.should.not.have.been.called - this.EditorRealTimeController.emitToRoom.should.have.been.calledOnce - const { lastCall } = this.EditorRealTimeController.emitToRoom + ctx.EditorRealTimeController.emitToRoom.should.have.been.calledOnce + const { lastCall } = ctx.EditorRealTimeController.emitToRoom expect(lastCall.args[0]).to.equal('project-id') expect(lastCall.args[1]).to.equal('broadcastDocMeta') expect(lastCall.args[2]).to.have.all.keys(['docId', 'meta']) }) - it('should return json on broadcast:false ', async function () { + it('should return json on broadcast:false ', async function (ctx) { const docMeta = { labels: ['foo'], packages: { a: [] }, packageNames: ['a'], } - this.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves(docMeta) + ctx.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves(docMeta) - this.EditorRealTimeController.emitToRoom = sinon.stub() + ctx.EditorRealTimeController.emitToRoom = sinon.stub() const req = { params: { project_id: 'project-id', doc_id: 'doc-id' }, @@ -124,12 +131,12 @@ describe('MetaController', function () { const res = new MockResponse() const next = sinon.stub() - await this.MetadataController.broadcastMetadataForDoc(req, res, next) + await ctx.MetadataController.broadcastMetadataForDoc(req, res, next) - this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith( + ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith( 'project-id' ) - this.EditorRealTimeController.emitToRoom.should.not.have.been.called + ctx.EditorRealTimeController.emitToRoom.should.not.have.been.called res.json.should.have.been.calledOnceWith({ docId: 'doc-id', meta: docMeta, @@ -137,12 +144,12 @@ describe('MetaController', function () { next.should.not.have.been.called }) - it('should handle an error', async function () { - this.MetaHandler.promises.getMetaForDoc = sinon + it('should handle an error', async function (ctx) { + ctx.MetaHandler.promises.getMetaForDoc = sinon .stub() .throws(new Error('woops')) - this.EditorRealTimeController.emitToRoom = sinon.stub() + ctx.EditorRealTimeController.emitToRoom = sinon.stub() const req = { params: { project_id: 'project-id', doc_id: 'doc-id' }, @@ -151,9 +158,9 @@ describe('MetaController', function () { const res = new MockResponse() const next = sinon.stub() - await this.MetadataController.broadcastMetadataForDoc(req, res, next) + await ctx.MetadataController.broadcastMetadataForDoc(req, res, next) - this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith( + ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith( 'project-id' ) res.json.should.not.have.been.called diff --git a/services/web/test/unit/src/Metadata/MetaHandler.test.mjs b/services/web/test/unit/src/Metadata/MetaHandler.test.mjs index 289fd0b164..c6009a2dd6 100644 --- a/services/web/test/unit/src/Metadata/MetaHandler.test.mjs +++ b/services/web/test/unit/src/Metadata/MetaHandler.test.mjs @@ -1,15 +1,15 @@ +import { vi } from 'vitest' import { expect } from 'chai' import sinon from 'sinon' -import esmock from 'esmock' const modulePath = '../../../../app/src/Features/Metadata/MetaHandler.mjs' describe('MetaHandler', function () { - beforeEach(async function () { - this.projectId = 'someprojectid' - this.docId = 'somedocid' + beforeEach(async function (ctx) { + ctx.projectId = 'someprojectid' + ctx.docId = 'somedocid' - this.lines = [ + ctx.lines = [ '\\usepackage{ foo, bar }', '\\usepackage{baz}', 'one', @@ -23,28 +23,28 @@ describe('MetaHandler', function () { '\\begin{lstlisting}[label={lst:foo},caption={Test}]', // lst:foo should be in the returned labels ] - this.docs = { - [this.docId]: { - _id: this.docId, - lines: this.lines, + ctx.docs = { + [ctx.docId]: { + _id: ctx.docId, + lines: ctx.lines, }, } - this.ProjectEntityHandler = { + ctx.ProjectEntityHandler = { promises: { - getAllDocs: sinon.stub().resolves(this.docs), - getDoc: sinon.stub().resolves(this.docs[this.docId]), + getAllDocs: sinon.stub().resolves(ctx.docs), + getDoc: sinon.stub().resolves(ctx.docs[ctx.docId]), }, } - this.DocumentUpdaterHandler = { + ctx.DocumentUpdaterHandler = { promises: { flushDocToMongo: sinon.stub().resolves(), flushProjectToMongo: sinon.stub().resolves(), }, } - this.packageMapping = { + ctx.packageMapping = { foo: [ { caption: '\\bar', @@ -69,47 +69,58 @@ describe('MetaHandler', function () { ], } - this.MetaHandler = await esmock.strict(modulePath, { - '../../../../app/src/Features/Project/ProjectEntityHandler': - this.ProjectEntityHandler, - '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler': - this.DocumentUpdaterHandler, - '../../../../app/src/Features/Metadata/packageMapping': - this.packageMapping, - }) + vi.doMock( + '../../../../app/src/Features/Project/ProjectEntityHandler', + () => ({ + default: ctx.ProjectEntityHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler', + () => ({ + default: ctx.DocumentUpdaterHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Metadata/packageMapping', () => ({ + default: ctx.packageMapping, + })) + + ctx.MetaHandler = (await import(modulePath)).default }) describe('getMetaForDoc', function () { - it('should extract all the labels and packages', async function () { - const result = await this.MetaHandler.promises.getMetaForDoc( - this.projectId, - this.docId + it('should extract all the labels and packages', async function (ctx) { + const result = await ctx.MetaHandler.promises.getMetaForDoc( + ctx.projectId, + ctx.docId ) expect(result).to.deep.equal({ labels: ['aaa', 'ccc', 'ddd', 'e,f,g', 'foo', 'lst:foo'], packages: { - foo: this.packageMapping.foo, - baz: this.packageMapping.baz, + foo: ctx.packageMapping.foo, + baz: ctx.packageMapping.baz, }, packageNames: ['foo', 'bar', 'baz'], }) - this.DocumentUpdaterHandler.promises.flushDocToMongo.should.be.calledWith( - this.projectId, - this.docId + ctx.DocumentUpdaterHandler.promises.flushDocToMongo.should.be.calledWith( + ctx.projectId, + ctx.docId ) - this.ProjectEntityHandler.promises.getDoc.should.be.calledWith( - this.projectId, - this.docId + ctx.ProjectEntityHandler.promises.getDoc.should.be.calledWith( + ctx.projectId, + ctx.docId ) }) }) describe('getAllMetaForProject', function () { - it('should extract all metadata', async function () { - this.ProjectEntityHandler.promises.getAllDocs = sinon.stub().resolves({ + it('should extract all metadata', async function (ctx) { + ctx.ProjectEntityHandler.promises.getAllDocs = sinon.stub().resolves({ doc_one: { _id: 'id_one', lines: ['one', '\\label{aaa} two', 'three'], @@ -142,8 +153,8 @@ describe('MetaHandler', function () { }, }) - const result = await this.MetaHandler.promises.getAllMetaForProject( - this.projectId + const result = await ctx.MetaHandler.promises.getAllMetaForProject( + ctx.projectId ) expect(result).to.deep.equal({ @@ -206,12 +217,12 @@ describe('MetaHandler', function () { }, }) - this.DocumentUpdaterHandler.promises.flushProjectToMongo.should.be.calledWith( - this.projectId + ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.be.calledWith( + ctx.projectId ) - this.ProjectEntityHandler.promises.getAllDocs.should.be.calledWith( - this.projectId + ctx.ProjectEntityHandler.promises.getAllDocs.should.be.calledWith( + ctx.projectId ) }) }) diff --git a/services/web/test/unit/src/Notifications/NotificationsController.test.mjs b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs index 0e22b228c5..6e1f9177c0 100644 --- a/services/web/test/unit/src/Notifications/NotificationsController.test.mjs +++ b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' const modulePath = new URL( @@ -10,12 +10,12 @@ describe('NotificationsController', function () { const userId = '123nd3ijdks' const notificationId = '123njdskj9jlk' - beforeEach(async function () { - this.handler = { + beforeEach(async function (ctx) { + ctx.handler = { getUserNotifications: sinon.stub().callsArgWith(1), markAsRead: sinon.stub().callsArgWith(2), } - this.req = { + ctx.req = { params: { notificationId, }, @@ -28,39 +28,53 @@ describe('NotificationsController', function () { translate() {}, }, } - this.AuthenticationController = { - getLoggedInUserId: sinon.stub().returns(this.req.session.user._id), + ctx.AuthenticationController = { + getLoggedInUserId: sinon.stub().returns(ctx.req.session.user._id), } - this.controller = await esmock.strict(modulePath, { - '../../../../app/src/Features/Notifications/NotificationsHandler': - this.handler, - '../../../../app/src/Features/Authentication/AuthenticationController': - this.AuthenticationController, + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsHandler', + () => ({ + default: ctx.handler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController', + () => ({ + default: ctx.AuthenticationController, + }) + ) + + ctx.controller = (await import(modulePath)).default + }) + + it('should ask the handler for all unread notifications', function (ctx) { + return new Promise(resolve => { + const allNotifications = [{ _id: notificationId, user_id: userId }] + ctx.handler.getUserNotifications = sinon + .stub() + .callsArgWith(1, null, allNotifications) + ctx.controller.getAllUnreadNotifications(ctx.req, { + json: body => { + body.should.deep.equal(allNotifications) + ctx.handler.getUserNotifications.calledWith(userId).should.equal(true) + resolve() + }, + }) }) }) - it('should ask the handler for all unread notifications', function (done) { - const allNotifications = [{ _id: notificationId, user_id: userId }] - this.handler.getUserNotifications = sinon - .stub() - .callsArgWith(1, null, allNotifications) - this.controller.getAllUnreadNotifications(this.req, { - json: body => { - body.should.deep.equal(allNotifications) - this.handler.getUserNotifications.calledWith(userId).should.equal(true) - done() - }, - }) - }) - - it('should send a delete request when a delete has been received to mark a notification', function (done) { - this.controller.markNotificationAsRead(this.req, { - sendStatus: () => { - this.handler.markAsRead - .calledWith(userId, notificationId) - .should.equal(true) - done() - }, + it('should send a delete request when a delete has been received to mark a notification', function (ctx) { + return new Promise(resolve => { + ctx.controller.markNotificationAsRead(ctx.req, { + sendStatus: () => { + ctx.handler.markAsRead + .calledWith(userId, notificationId) + .should.equal(true) + resolve() + }, + }) }) }) }) diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs index 6df3c765b1..e4cf6e569f 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' @@ -9,16 +9,16 @@ const MODULE_PATH = new URL( ).pathname describe('PasswordResetController', function () { - beforeEach(async function () { - this.email = 'bob@bob.com' - this.user_id = 'mock-user-id' - this.token = 'my security token that was emailed to me' - this.password = 'my new password' - this.req = { + beforeEach(async function (ctx) { + ctx.email = 'bob@bob.com' + ctx.user_id = 'mock-user-id' + ctx.token = 'my security token that was emailed to me' + ctx.password = 'my new password' + ctx.req = { body: { - email: this.email, - passwordResetToken: this.token, - password: this.password, + email: ctx.email, + passwordResetToken: ctx.token, + password: ctx.password, }, i18n: { translate() { @@ -28,456 +28,540 @@ describe('PasswordResetController', function () { session: {}, query: {}, } - this.res = new MockResponse() + ctx.res = new MockResponse() - this.settings = {} - this.PasswordResetHandler = { + ctx.settings = {} + ctx.PasswordResetHandler = { generateAndEmailResetToken: sinon.stub(), promises: { generateAndEmailResetToken: sinon.stub(), setNewUserPassword: sinon.stub().resolves({ found: true, reset: true, - userID: this.user_id, + userID: ctx.user_id, mustReconfirm: true, }), getUserForPasswordResetToken: sinon .stub() - .withArgs(this.token) + .withArgs(ctx.token) .resolves({ - user: { _id: this.user_id }, + user: { _id: ctx.user_id }, remainingPeeks: 1, }), }, } - this.UserSessionsManager = { + ctx.UserSessionsManager = { promises: { removeSessionsFromRedis: sinon.stub().resolves(), }, } - this.UserUpdater = { + ctx.UserUpdater = { promises: { removeReconfirmFlag: sinon.stub().resolves(), }, } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves('default'), }, } - this.PasswordResetController = await esmock.strict(MODULE_PATH, { - '@overleaf/settings': this.settings, - '../../../../app/src/Features/PasswordReset/PasswordResetHandler': - this.PasswordResetHandler, - '../../../../app/src/Features/Authentication/AuthenticationManager': { - validatePassword: sinon.stub().returns(null), - }, - '../../../../app/src/Features/Authentication/AuthenticationController': - (this.AuthenticationController = { + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock( + '../../../../app/src/Features/PasswordReset/PasswordResetHandler', + () => ({ + default: ctx.PasswordResetHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationManager', + () => ({ + default: { + validatePassword: sinon.stub().returns(null), + }, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController', + () => ({ + default: (ctx.AuthenticationController = { getLoggedInUserId: sinon.stub(), finishLogin: sinon.stub(), setAuditInfo: sinon.stub(), }), - '../../../../app/src/Features/User/UserGetter': (this.UserGetter = { + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = { promises: { getUser: sinon.stub(), }, }), - '../../../../app/src/Features/User/UserSessionsManager': - this.UserSessionsManager, - '../../../../app/src/Features/User/UserUpdater': this.UserUpdater, - '../../../../app/src/Features/SplitTests/SplitTestHandler': - this.SplitTestHandler, - }) + })) + + vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({ + default: ctx.UserSessionsManager, + })) + + vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({ + default: ctx.UserUpdater, + })) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + ctx.PasswordResetController = (await import(MODULE_PATH)).default }) describe('requestReset', function () { - it('should tell the handler to process that email', function (done) { - this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( - 'primary' - ) - this.res.callback = () => { - this.res.statusCode.should.equal(200) - this.res.json.calledWith(sinon.match.has('message')).should.equal(true) - expect( - this.PasswordResetHandler.promises.generateAndEmailResetToken.lastCall - .args[0] - ).equal(this.email) - done() - } - this.PasswordResetController.requestReset(this.req, this.res) - }) - - it('should send a 500 if there is an error', function (done) { - this.PasswordResetHandler.promises.generateAndEmailResetToken.rejects( - new Error('error') - ) - this.PasswordResetController.requestReset(this.req, this.res, error => { - expect(error).to.exist - done() + it('should tell the handler to process that email', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( + 'primary' + ) + ctx.res.callback = () => { + ctx.res.statusCode.should.equal(200) + ctx.res.json.calledWith(sinon.match.has('message')).should.equal(true) + expect( + ctx.PasswordResetHandler.promises.generateAndEmailResetToken + .lastCall.args[0] + ).equal(ctx.email) + resolve() + } + ctx.PasswordResetController.requestReset(ctx.req, ctx.res) }) }) - it("should send a 404 if the email doesn't exist", function (done) { - this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( - null - ) - this.res.callback = () => { - this.res.statusCode.should.equal(404) - this.res.json.calledWith(sinon.match.has('message')).should.equal(true) - done() - } - this.PasswordResetController.requestReset(this.req, this.res) + it('should send a 500 if there is an error', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.promises.generateAndEmailResetToken.rejects( + new Error('error') + ) + ctx.PasswordResetController.requestReset(ctx.req, ctx.res, error => { + expect(error).to.exist + resolve() + }) + }) }) - it('should send a 404 if the email is registered as a secondard email', function (done) { - this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( - 'secondary' - ) - this.res.callback = () => { - this.res.statusCode.should.equal(404) - this.res.json.calledWith(sinon.match.has('message')).should.equal(true) - done() - } - this.PasswordResetController.requestReset(this.req, this.res) + it("should send a 404 if the email doesn't exist", function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( + null + ) + ctx.res.callback = () => { + ctx.res.statusCode.should.equal(404) + ctx.res.json.calledWith(sinon.match.has('message')).should.equal(true) + resolve() + } + ctx.PasswordResetController.requestReset(ctx.req, ctx.res) + }) }) - it('should normalize the email address', function (done) { - this.email = ' UPperCaseEMAILWithSpacesAround@example.Com ' - this.req.body.email = this.email - this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( - 'primary' - ) - this.res.callback = () => { - this.res.statusCode.should.equal(200) - this.res.json.calledWith(sinon.match.has('message')).should.equal(true) - done() - } - this.PasswordResetController.requestReset(this.req, this.res) + it('should send a 404 if the email is registered as a secondard email', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( + 'secondary' + ) + ctx.res.callback = () => { + ctx.res.statusCode.should.equal(404) + ctx.res.json.calledWith(sinon.match.has('message')).should.equal(true) + resolve() + } + ctx.PasswordResetController.requestReset(ctx.req, ctx.res) + }) + }) + + it('should normalize the email address', function (ctx) { + return new Promise(resolve => { + ctx.email = ' UPperCaseEMAILWithSpacesAround@example.Com ' + ctx.req.body.email = ctx.email + ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( + 'primary' + ) + ctx.res.callback = () => { + ctx.res.statusCode.should.equal(200) + ctx.res.json.calledWith(sinon.match.has('message')).should.equal(true) + resolve() + } + ctx.PasswordResetController.requestReset(ctx.req, ctx.res) + }) }) }) describe('setNewUserPassword', function () { - beforeEach(function () { - this.req.session.resetToken = this.token + beforeEach(function (ctx) { + ctx.req.session.resetToken = ctx.token }) - it('should tell the user handler to reset the password', function (done) { - this.res.sendStatus = code => { - code.should.equal(200) - this.PasswordResetHandler.promises.setNewUserPassword - .calledWith(this.token, this.password) - .should.equal(true) - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) - }) - - it('should preserve spaces in the password', function (done) { - this.password = this.req.body.password = ' oh! clever! spaces around! ' - this.res.sendStatus = code => { - code.should.equal(200) - this.PasswordResetHandler.promises.setNewUserPassword.should.have.been.calledWith( - this.token, - this.password - ) - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) - }) - - it('should send 404 if the token was not found', function (done) { - this.PasswordResetHandler.promises.setNewUserPassword.resolves({ - found: false, - reset: false, - userId: this.user_id, + it('should tell the user handler to reset the password', function (ctx) { + return new Promise(resolve => { + ctx.res.sendStatus = code => { + code.should.equal(200) + ctx.PasswordResetHandler.promises.setNewUserPassword + .calledWith(ctx.token, ctx.password) + .should.equal(true) + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) }) - this.res.status = code => { - code.should.equal(404) - return this.res - } - this.res.json = data => { - data.message.key.should.equal('token-expired') - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) }) - it('should return 500 if not reset', function (done) { - this.PasswordResetHandler.promises.setNewUserPassword.resolves({ - found: true, - reset: false, - userId: this.user_id, + it('should preserve spaces in the password', function (ctx) { + return new Promise(resolve => { + ctx.password = ctx.req.body.password = ' oh! clever! spaces around! ' + ctx.res.sendStatus = code => { + code.should.equal(200) + ctx.PasswordResetHandler.promises.setNewUserPassword.should.have.been.calledWith( + ctx.token, + ctx.password + ) + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) }) - this.res.status = code => { - code.should.equal(500) - return this.res - } - this.res.json = data => { - expect(data.message).to.exist - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) }) - it('should return 400 (Bad Request) if there is no password', function (done) { - this.req.body.password = '' - this.res.status = code => { - code.should.equal(400) - return this.res - } - this.res.json = data => { - data.message.key.should.equal('invalid-password') - this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal( - false - ) - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) + it('should send 404 if the token was not found', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.promises.setNewUserPassword.resolves({ + found: false, + reset: false, + userId: ctx.user_id, + }) + ctx.res.status = code => { + code.should.equal(404) + return ctx.res + } + ctx.res.json = data => { + data.message.key.should.equal('token-expired') + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) }) - it('should return 400 (Bad Request) if there is no passwordResetToken', function (done) { - this.req.body.passwordResetToken = '' - this.res.status = code => { - code.should.equal(400) - return this.res - } - this.res.json = data => { - data.message.key.should.equal('invalid-password') - this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal( - false - ) - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) + it('should return 500 if not reset', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.promises.setNewUserPassword.resolves({ + found: true, + reset: false, + userId: ctx.user_id, + }) + ctx.res.status = code => { + code.should.equal(500) + return ctx.res + } + ctx.res.json = data => { + expect(data.message).to.exist + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) }) - it('should return 400 (Bad Request) if the password is invalid', function (done) { - this.req.body.password = 'correct horse battery staple' - const err = new Error('bad') - err.name = 'InvalidPasswordError' - this.PasswordResetHandler.promises.setNewUserPassword.rejects(err) - this.res.status = code => { - code.should.equal(400) - return this.res - } - this.res.json = data => { - data.message.key.should.equal('invalid-password') - this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal( - true - ) - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) + it('should return 400 (Bad Request) if there is no password', function (ctx) { + return new Promise(resolve => { + ctx.req.body.password = '' + ctx.res.status = code => { + code.should.equal(400) + return ctx.res + } + ctx.res.json = data => { + data.message.key.should.equal('invalid-password') + ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal( + false + ) + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) }) - it('should clear sessions', function (done) { - this.res.sendStatus = code => { - this.UserSessionsManager.promises.removeSessionsFromRedis.callCount.should.equal( - 1 - ) - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) + it('should return 400 (Bad Request) if there is no passwordResetToken', function (ctx) { + return new Promise(resolve => { + ctx.req.body.passwordResetToken = '' + ctx.res.status = code => { + code.should.equal(400) + return ctx.res + } + ctx.res.json = data => { + data.message.key.should.equal('invalid-password') + ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal( + false + ) + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) }) - it('should call removeReconfirmFlag if user.must_reconfirm', function (done) { - this.res.sendStatus = code => { - this.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1) - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) + it('should return 400 (Bad Request) if the password is invalid', function (ctx) { + return new Promise(resolve => { + ctx.req.body.password = 'correct horse battery staple' + const err = new Error('bad') + err.name = 'InvalidPasswordError' + ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(err) + ctx.res.status = code => { + code.should.equal(400) + return ctx.res + } + ctx.res.json = data => { + data.message.key.should.equal('invalid-password') + ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal( + true + ) + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) + }) + + it('should clear sessions', function (ctx) { + return new Promise(resolve => { + ctx.res.sendStatus = code => { + ctx.UserSessionsManager.promises.removeSessionsFromRedis.callCount.should.equal( + 1 + ) + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) + }) + + it('should call removeReconfirmFlag if user.must_reconfirm', function (ctx) { + return new Promise(resolve => { + ctx.res.sendStatus = code => { + ctx.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1) + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) }) describe('catch errors', function () { - it('should return 404 for NotFoundError', function (done) { - const anError = new Error('oops') - anError.name = 'NotFoundError' - this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) - this.res.status = code => { - code.should.equal(404) - return this.res - } - this.res.json = data => { - data.message.key.should.equal('token-expired') - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) + it('should return 404 for NotFoundError', function (ctx) { + return new Promise(resolve => { + const anError = new Error('oops') + anError.name = 'NotFoundError' + ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) + ctx.res.status = code => { + code.should.equal(404) + return ctx.res + } + ctx.res.json = data => { + data.message.key.should.equal('token-expired') + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) }) - it('should return 400 for InvalidPasswordError', function (done) { - const anError = new Error('oops') - anError.name = 'InvalidPasswordError' - this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) - this.res.status = code => { - code.should.equal(400) - return this.res - } - this.res.json = data => { - data.message.key.should.equal('invalid-password') - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) + it('should return 400 for InvalidPasswordError', function (ctx) { + return new Promise(resolve => { + const anError = new Error('oops') + anError.name = 'InvalidPasswordError' + ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) + ctx.res.status = code => { + code.should.equal(400) + return ctx.res + } + ctx.res.json = data => { + data.message.key.should.equal('invalid-password') + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) }) - it('should return 500 for other errors', function (done) { - const anError = new Error('oops') - this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) - this.res.status = code => { - code.should.equal(500) - return this.res - } - this.res.json = data => { - expect(data.message).to.exist - done() - } - this.res.sendStatus = code => { - code.should.equal(500) - done() - } - this.PasswordResetController.setNewUserPassword(this.req, this.res) + it('should return 500 for other errors', function (ctx) { + return new Promise(resolve => { + const anError = new Error('oops') + ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) + ctx.res.status = code => { + code.should.equal(500) + return ctx.res + } + ctx.res.json = data => { + expect(data.message).to.exist + resolve() + } + ctx.res.sendStatus = code => { + code.should.equal(500) + resolve() + } + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) + }) }) }) describe('when doLoginAfterPasswordReset is set', function () { - beforeEach(function () { - this.user = { - _id: this.userId, + beforeEach(function (ctx) { + ctx.user = { + _id: ctx.userId, email: 'joe@example.com', } - this.UserGetter.promises.getUser.resolves(this.user) - this.req.session.doLoginAfterPasswordReset = 'true' + ctx.UserGetter.promises.getUser.resolves(ctx.user) + ctx.req.session.doLoginAfterPasswordReset = 'true' }) - it('should login user', function (done) { - this.AuthenticationController.finishLogin.callsFake((...args) => { - expect(args[0]).to.equal(this.user) - done() + it('should login user', function (ctx) { + return new Promise(resolve => { + ctx.AuthenticationController.finishLogin.callsFake((...args) => { + expect(args[0]).to.equal(ctx.user) + resolve() + }) + ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) }) - this.PasswordResetController.setNewUserPassword(this.req, this.res) }) }) }) describe('renderSetPasswordForm', function () { describe('with token in query-string', function () { - beforeEach(function () { - this.req.query.passwordResetToken = this.token + beforeEach(function (ctx) { + ctx.req.query.passwordResetToken = ctx.token }) - it('should set session.resetToken and redirect', function (done) { - this.req.session.should.not.have.property('resetToken') - this.res.redirect = path => { - path.should.equal('/user/password/set') - this.req.session.resetToken.should.equal(this.token) - done() - } - this.PasswordResetController.renderSetPasswordForm(this.req, this.res) + it('should set session.resetToken and redirect', function (ctx) { + return new Promise(resolve => { + ctx.req.session.should.not.have.property('resetToken') + ctx.res.redirect = path => { + path.should.equal('/user/password/set') + ctx.req.session.resetToken.should.equal(ctx.token) + resolve() + } + ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res) + }) }) }) describe('with expired token in query', function () { - beforeEach(function () { - this.req.query.passwordResetToken = this.token - this.PasswordResetHandler.promises.getUserForPasswordResetToken = sinon + beforeEach(function (ctx) { + ctx.req.query.passwordResetToken = ctx.token + ctx.PasswordResetHandler.promises.getUserForPasswordResetToken = sinon .stub() - .withArgs(this.token) - .resolves({ user: { _id: this.user_id }, remainingPeeks: 0 }) + .withArgs(ctx.token) + .resolves({ user: { _id: ctx.user_id }, remainingPeeks: 0 }) }) - it('should redirect to the reset request page with an error message', function (done) { - this.res.redirect = path => { - path.should.equal('/user/password/reset?error=token_expired') - this.req.session.should.not.have.property('resetToken') - done() - } - this.res.render = (templatePath, options) => { - done('should not render') - } - this.PasswordResetController.renderSetPasswordForm(this.req, this.res) + it('should redirect to the reset request page with an error message', function (ctx) { + return new Promise(resolve => { + ctx.res.redirect = path => { + path.should.equal('/user/password/reset?error=token_expired') + ctx.req.session.should.not.have.property('resetToken') + resolve() + } + ctx.res.render = (templatePath, options) => { + resolve('should not render') + } + ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res) + }) }) }) describe('with token and email in query-string', function () { - beforeEach(function () { - this.req.query.passwordResetToken = this.token - this.req.query.email = 'foo@bar.com' + beforeEach(function (ctx) { + ctx.req.query.passwordResetToken = ctx.token + ctx.req.query.email = 'foo@bar.com' }) - it('should set session.resetToken and redirect with email', function (done) { - this.req.session.should.not.have.property('resetToken') - this.res.redirect = path => { - path.should.equal('/user/password/set?email=foo%40bar.com') - this.req.session.resetToken.should.equal(this.token) - done() - } - this.PasswordResetController.renderSetPasswordForm(this.req, this.res) + it('should set session.resetToken and redirect with email', function (ctx) { + return new Promise(resolve => { + ctx.req.session.should.not.have.property('resetToken') + ctx.res.redirect = path => { + path.should.equal('/user/password/set?email=foo%40bar.com') + ctx.req.session.resetToken.should.equal(ctx.token) + resolve() + } + ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res) + }) }) }) describe('with token and invalid email in query-string', function () { - beforeEach(function () { - this.req.query.passwordResetToken = this.token - this.req.query.email = 'not-an-email' + beforeEach(function (ctx) { + ctx.req.query.passwordResetToken = ctx.token + ctx.req.query.email = 'not-an-email' }) - it('should set session.resetToken and redirect without email', function (done) { - this.req.session.should.not.have.property('resetToken') - this.res.redirect = path => { - path.should.equal('/user/password/set') - this.req.session.resetToken.should.equal(this.token) - done() - } - this.PasswordResetController.renderSetPasswordForm(this.req, this.res) + it('should set session.resetToken and redirect without email', function (ctx) { + return new Promise(resolve => { + ctx.req.session.should.not.have.property('resetToken') + ctx.res.redirect = path => { + path.should.equal('/user/password/set') + ctx.req.session.resetToken.should.equal(ctx.token) + resolve() + } + ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res) + }) }) }) describe('with token and non-string email in query-string', function () { - beforeEach(function () { - this.req.query.passwordResetToken = this.token - this.req.query.email = { foo: 'bar' } + beforeEach(function (ctx) { + ctx.req.query.passwordResetToken = ctx.token + ctx.req.query.email = { foo: 'bar' } }) - it('should set session.resetToken and redirect without email', function (done) { - this.req.session.should.not.have.property('resetToken') - this.res.redirect = path => { - path.should.equal('/user/password/set') - this.req.session.resetToken.should.equal(this.token) - done() - } - this.PasswordResetController.renderSetPasswordForm(this.req, this.res) + it('should set session.resetToken and redirect without email', function (ctx) { + return new Promise(resolve => { + ctx.req.session.should.not.have.property('resetToken') + ctx.res.redirect = path => { + path.should.equal('/user/password/set') + ctx.req.session.resetToken.should.equal(ctx.token) + resolve() + } + ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res) + }) }) }) describe('without a token in query-string', function () { describe('with token in session', function () { - beforeEach(function () { - this.req.session.resetToken = this.token + beforeEach(function (ctx) { + ctx.req.session.resetToken = ctx.token }) - it('should render the page, passing the reset token', function (done) { - this.res.render = (templatePath, options) => { - options.passwordResetToken.should.equal(this.token) - done() - } - this.PasswordResetController.renderSetPasswordForm(this.req, this.res) + it('should render the page, passing the reset token', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (templatePath, options) => { + options.passwordResetToken.should.equal(ctx.token) + resolve() + } + ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res) + }) }) - it('should clear the req.session.resetToken', function (done) { - this.res.render = (templatePath, options) => { - this.req.session.should.not.have.property('resetToken') - done() - } - this.PasswordResetController.renderSetPasswordForm(this.req, this.res) + it('should clear the req.session.resetToken', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (templatePath, options) => { + ctx.req.session.should.not.have.property('resetToken') + resolve() + } + ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res) + }) }) }) describe('without a token in session', function () { - it('should redirect to the reset request page', function (done) { - this.res.redirect = path => { - path.should.equal('/user/password/reset') - this.req.session.should.not.have.property('resetToken') - done() - } - this.PasswordResetController.renderSetPasswordForm(this.req, this.res) + it('should redirect to the reset request page', function (ctx) { + return new Promise(resolve => { + ctx.res.redirect = path => { + path.should.equal('/user/password/reset') + ctx.req.session.should.not.have.property('resetToken') + resolve() + } + ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res) + }) }) }) }) diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs index b99cc527e2..25d664b795 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' const modulePath = new URL( @@ -7,9 +7,9 @@ const modulePath = new URL( ).pathname describe('PasswordResetHandler', function () { - beforeEach(async function () { - this.settings = { siteUrl: 'https://www.overleaf.com' } - this.OneTimeTokenHandler = { + beforeEach(async function (ctx) { + ctx.settings = { siteUrl: 'https://www.overleaf.com' } + ctx.OneTimeTokenHandler = { promises: { getNewToken: sinon.stub(), peekValueFromToken: sinon.stub(), @@ -17,7 +17,7 @@ describe('PasswordResetHandler', function () { peekValueFromToken: sinon.stub(), expireToken: sinon.stub(), } - this.UserGetter = { + ctx.UserGetter = { getUserByMainEmail: sinon.stub(), getUser: sinon.stub(), promises: { @@ -25,123 +25,153 @@ describe('PasswordResetHandler', function () { getUserByMainEmail: sinon.stub(), }, } - this.EmailHandler = { promises: { sendEmail: sinon.stub() } } - this.AuthenticationManager = { + ctx.EmailHandler = { promises: { sendEmail: sinon.stub() } } + ctx.AuthenticationManager = { setUserPasswordInV2: sinon.stub(), promises: { setUserPassword: sinon.stub().resolves(), }, } - this.PasswordResetHandler = await esmock.strict(modulePath, { - '../../../../app/src/Features/User/UserAuditLogHandler': - (this.UserAuditLogHandler = { - promises: { - addEntry: sinon.stub().resolves(), - }, - }), - '../../../../app/src/Features/User/UserGetter': this.UserGetter, - '../../../../app/src/Features/Security/OneTimeTokenHandler': - this.OneTimeTokenHandler, - '../../../../app/src/Features/Email/EmailHandler': this.EmailHandler, - '../../../../app/src/Features/Authentication/AuthenticationManager': - this.AuthenticationManager, - '@overleaf/settings': this.settings, - '../../../../app/src/Features/Authorization/PermissionsManager': - (this.PermissionsManager = { + + vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ + default: (ctx.UserAuditLogHandler = { + promises: { + addEntry: sinon.stub().resolves(), + }, + }), + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Security/OneTimeTokenHandler', + () => ({ + default: ctx.OneTimeTokenHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({ + default: ctx.EmailHandler, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationManager', + () => ({ + default: ctx.AuthenticationManager, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock( + '../../../../app/src/Features/Authorization/PermissionsManager', + () => ({ + default: (ctx.PermissionsManager = { promises: { assertUserPermissions: sinon.stub(), }, }), - }) - this.token = '12312321i' - this.user_id = 'user_id_here' - this.user = { email: (this.email = 'bob@bob.com'), _id: this.user_id } - this.password = 'my great secret password' - this.callback = sinon.stub() + }) + ) + + ctx.PasswordResetHandler = (await import(modulePath)).default + ctx.token = '12312321i' + ctx.user_id = 'user_id_here' + ctx.user = { email: (ctx.email = 'bob@bob.com'), _id: ctx.user_id } + ctx.password = 'my great secret password' + ctx.callback = sinon.stub() // this should not have any effect now - this.settings.overleaf = true + ctx.settings.overleaf = true }) - afterEach(function () { - this.settings.overleaf = false + afterEach(function (ctx) { + ctx.settings.overleaf = false }) describe('generateAndEmailResetToken', function () { - it('should check the user exists', function () { - this.UserGetter.promises.getUserByAnyEmail.resolves() - this.PasswordResetHandler.generateAndEmailResetToken( - this.user.email, - this.callback + it('should check the user exists', function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves() + ctx.PasswordResetHandler.generateAndEmailResetToken( + ctx.user.email, + ctx.callback ) - this.UserGetter.promises.getUserByAnyEmail.should.have.been.calledWith( - this.user.email + ctx.UserGetter.promises.getUserByAnyEmail.should.have.been.calledWith( + ctx.user.email ) }) - it('should send the email with the token', function (done) { - this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) - this.OneTimeTokenHandler.promises.getNewToken.resolves(this.token) - this.EmailHandler.promises.sendEmail.resolves() - this.PasswordResetHandler.generateAndEmailResetToken( - this.user.email, - (err, status) => { - expect(err).to.not.exist - this.EmailHandler.promises.sendEmail.called.should.equal(true) - status.should.equal('primary') - const args = this.EmailHandler.promises.sendEmail.args[0] - args[0].should.equal('passwordResetRequested') - args[1].setNewPasswordUrl.should.equal( - `${this.settings.siteUrl}/user/password/set?passwordResetToken=${ - this.token - }&email=${encodeURIComponent(this.user.email)}` - ) - done() - } - ) + it('should send the email with the token', function (ctx) { + return new Promise(resolve => { + ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user) + ctx.OneTimeTokenHandler.promises.getNewToken.resolves(ctx.token) + ctx.EmailHandler.promises.sendEmail.resolves() + ctx.PasswordResetHandler.generateAndEmailResetToken( + ctx.user.email, + (err, status) => { + expect(err).to.not.exist + ctx.EmailHandler.promises.sendEmail.called.should.equal(true) + status.should.equal('primary') + const args = ctx.EmailHandler.promises.sendEmail.args[0] + args[0].should.equal('passwordResetRequested') + args[1].setNewPasswordUrl.should.equal( + `${ctx.settings.siteUrl}/user/password/set?passwordResetToken=${ + ctx.token + }&email=${encodeURIComponent(ctx.user.email)}` + ) + resolve() + } + ) + }) }) - it('should return errors from getUserByAnyEmail', function (done) { - const err = new Error('oops') - this.UserGetter.promises.getUserByAnyEmail.rejects(err) - this.PasswordResetHandler.generateAndEmailResetToken( - this.user.email, - err => { - expect(err).to.equal(err) - done() - } - ) + it('should return errors from getUserByAnyEmail', function (ctx) { + return new Promise(resolve => { + const err = new Error('oops') + ctx.UserGetter.promises.getUserByAnyEmail.rejects(err) + ctx.PasswordResetHandler.generateAndEmailResetToken( + ctx.user.email, + err => { + expect(err).to.equal(err) + resolve() + } + ) + }) }) describe('when the email exists', function () { let result - beforeEach(async function () { - this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) - this.OneTimeTokenHandler.promises.getNewToken.resolves(this.token) - this.EmailHandler.promises.sendEmail.resolves() + beforeEach(async function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user) + ctx.OneTimeTokenHandler.promises.getNewToken.resolves(ctx.token) + ctx.EmailHandler.promises.sendEmail.resolves() result = - await this.PasswordResetHandler.promises.generateAndEmailResetToken( - this.email + await ctx.PasswordResetHandler.promises.generateAndEmailResetToken( + ctx.email ) }) - it('should set the password token data to the user id and email', function () { - this.OneTimeTokenHandler.promises.getNewToken.should.have.been.calledWith( + it('should set the password token data to the user id and email', function (ctx) { + ctx.OneTimeTokenHandler.promises.getNewToken.should.have.been.calledWith( 'password', { - email: this.email, - user_id: this.user._id, + email: ctx.email, + user_id: ctx.user._id, } ) }) - it('should send an email with the token', function () { - this.EmailHandler.promises.sendEmail.called.should.equal(true) - const args = this.EmailHandler.promises.sendEmail.args[0] + it('should send an email with the token', function (ctx) { + ctx.EmailHandler.promises.sendEmail.called.should.equal(true) + const args = ctx.EmailHandler.promises.sendEmail.args[0] args[0].should.equal('passwordResetRequested') args[1].setNewPasswordUrl.should.equal( - `${this.settings.siteUrl}/user/password/set?passwordResetToken=${ - this.token - }&email=${encodeURIComponent(this.user.email)}` + `${ctx.settings.siteUrl}/user/password/set?passwordResetToken=${ + ctx.token + }&email=${encodeURIComponent(ctx.user.email)}` ) }) @@ -152,20 +182,20 @@ describe('PasswordResetHandler', function () { describe("when the email doesn't exist", function () { let result - beforeEach(async function () { - this.UserGetter.promises.getUserByAnyEmail.resolves(null) + beforeEach(async function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves(null) result = - await this.PasswordResetHandler.promises.generateAndEmailResetToken( - this.email + await ctx.PasswordResetHandler.promises.generateAndEmailResetToken( + ctx.email ) }) - it('should not set the password token data', function () { - this.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false) + it('should not set the password token data', function (ctx) { + ctx.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false) }) - it('should send an email with the token', function () { - this.EmailHandler.promises.sendEmail.called.should.equal(false) + it('should send an email with the token', function (ctx) { + ctx.EmailHandler.promises.sendEmail.called.should.equal(false) }) it('should return status == null', function () { @@ -175,20 +205,20 @@ describe('PasswordResetHandler', function () { describe('when the email is a secondary email', function () { let result - beforeEach(async function () { - this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) + beforeEach(async function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user) result = - await this.PasswordResetHandler.promises.generateAndEmailResetToken( + await ctx.PasswordResetHandler.promises.generateAndEmailResetToken( 'secondary@email.com' ) }) - it('should not set the password token data', function () { - this.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false) + it('should not set the password token data', function (ctx) { + ctx.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false) }) - it('should not send an email with the token', function () { - this.EmailHandler.promises.sendEmail.called.should.equal(false) + it('should not send an email with the token', function (ctx) { + ctx.EmailHandler.promises.sendEmail.called.should.equal(false) }) it('should return status == secondary', function () { @@ -198,19 +228,19 @@ describe('PasswordResetHandler', function () { }) describe('setNewUserPassword', function () { - beforeEach(function () { - this.auditLog = { ip: '0:0:0:0' } + beforeEach(function (ctx) { + ctx.auditLog = { ip: '0:0:0:0' } }) describe('when no data is found', function () { - beforeEach(function () { - this.OneTimeTokenHandler.promises.peekValueFromToken.resolves(null) + beforeEach(function (ctx) { + ctx.OneTimeTokenHandler.promises.peekValueFromToken.resolves(null) }) - it('should return found == false and reset == false', function () { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, + it('should return found == false and reset == false', function (ctx) { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, (error, result) => { expect(error).to.not.exist expect(result).to.deep.equal({ @@ -224,202 +254,220 @@ describe('PasswordResetHandler', function () { }) describe('when the token has a user_id and email', function () { - beforeEach(function () { - this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({ + beforeEach(function (ctx) { + ctx.OneTimeTokenHandler.promises.peekValueFromToken.resolves({ data: { - user_id: this.user._id, - email: this.email, + user_id: ctx.user._id, + email: ctx.email, }, }) - this.AuthenticationManager.promises.setUserPassword - .withArgs(this.user, this.password) + ctx.AuthenticationManager.promises.setUserPassword + .withArgs(ctx.user, ctx.password) .resolves(true) - this.OneTimeTokenHandler.expireToken = sinon - .stub() - .callsArgWith(2, null) + ctx.OneTimeTokenHandler.expireToken = sinon.stub().callsArgWith(2, null) }) describe('when no user is found with this email', function () { - beforeEach(function () { - this.UserGetter.getUserByMainEmail - .withArgs(this.email) + beforeEach(function (ctx) { + ctx.UserGetter.getUserByMainEmail + .withArgs(ctx.email) .yields(null, null) }) - it('should return found == false and reset == false', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (err, result) => { - const { found, reset } = result - expect(err).to.not.exist - expect(found).to.be.false - expect(reset).to.be.false - expect(this.OneTimeTokenHandler.expireToken.callCount).to.equal(0) - done() - } - ) + it('should return found == false and reset == false', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (err, result) => { + const { found, reset } = result + expect(err).to.not.exist + expect(found).to.be.false + expect(reset).to.be.false + expect(ctx.OneTimeTokenHandler.expireToken.callCount).to.equal( + 0 + ) + resolve() + } + ) + }) }) }) describe("when the email and user don't match", function () { - beforeEach(function () { - this.UserGetter.getUserByMainEmail - .withArgs(this.email) - .yields(null, { _id: 'not-the-same', email: this.email }) - this.OneTimeTokenHandler.expireToken.callsArgWith(2, null) + beforeEach(function (ctx) { + ctx.UserGetter.getUserByMainEmail + .withArgs(ctx.email) + .yields(null, { _id: 'not-the-same', email: ctx.email }) + ctx.OneTimeTokenHandler.expireToken.callsArgWith(2, null) }) - it('should return found == false and reset == false', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (err, result) => { - const { found, reset } = result - expect(err).to.not.exist - expect(found).to.be.false - expect(reset).to.be.false - expect(this.OneTimeTokenHandler.expireToken.callCount).to.equal(0) - done() - } - ) + it('should return found == false and reset == false', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (err, result) => { + const { found, reset } = result + expect(err).to.not.exist + expect(found).to.be.false + expect(reset).to.be.false + expect(ctx.OneTimeTokenHandler.expireToken.callCount).to.equal( + 0 + ) + resolve() + } + ) + }) }) }) describe('when the email and user match', function () { describe('success', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByMainEmail.resolves(this.user) - this.OneTimeTokenHandler.expireToken = sinon + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByMainEmail.resolves(ctx.user) + ctx.OneTimeTokenHandler.expireToken = sinon .stub() .callsArgWith(2, null) }) - it('should update the user audit log', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (error, result) => { - sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - this.user_id, - 'reset-password', - undefined, - this.auditLog.ip, - { token: this.token.substring(0, 10) } - ) - expect(error).to.not.exist - done() - } - ) + it('should update the user audit log', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (error, result) => { + sinon.assert.calledWith( + ctx.UserAuditLogHandler.promises.addEntry, + ctx.user_id, + 'reset-password', + undefined, + ctx.auditLog.ip, + { token: ctx.token.substring(0, 10) } + ) + expect(error).to.not.exist + resolve() + } + ) + }) }) - it('should return reset == true and the user id', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (err, result) => { - const { reset, userId } = result - expect(err).to.not.exist - expect(reset).to.be.true - expect(userId).to.equal(this.user._id) - done() - } - ) + it('should return reset == true and the user id', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (err, result) => { + const { reset, userId } = result + expect(err).to.not.exist + expect(reset).to.be.true + expect(userId).to.equal(ctx.user._id) + resolve() + } + ) + }) }) - it('should expire the token', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (_err, _result) => { - expect(this.OneTimeTokenHandler.expireToken.called).to.equal( - true - ) - done() - } - ) + it('should expire the token', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (_err, _result) => { + expect(ctx.OneTimeTokenHandler.expireToken.called).to.equal( + true + ) + resolve() + } + ) + }) }) describe('when logged in', function () { - beforeEach(function () { - this.auditLog.initiatorId = this.user_id + beforeEach(function (ctx) { + ctx.auditLog.initiatorId = ctx.user_id }) - it('should update the user audit log with initiatorId', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (error, result) => { - expect(error).to.not.exist - sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - this.user_id, - 'reset-password', - this.user_id, - this.auditLog.ip, - { token: this.token.substring(0, 10) } - ) - done() - } - ) + it('should update the user audit log with initiatorId', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (error, result) => { + expect(error).to.not.exist + sinon.assert.calledWith( + ctx.UserAuditLogHandler.promises.addEntry, + ctx.user_id, + 'reset-password', + ctx.user_id, + ctx.auditLog.ip, + { token: ctx.token.substring(0, 10) } + ) + resolve() + } + ) + }) }) }) }) describe('errors', function () { describe('via setUserPassword', function () { - beforeEach(function () { - this.PasswordResetHandler.promises.getUserForPasswordResetToken = - sinon.stub().withArgs(this.token).resolves({ user: this.user }) - this.AuthenticationManager.promises.setUserPassword - .withArgs(this.user, this.password) + beforeEach(function (ctx) { + ctx.PasswordResetHandler.promises.getUserForPasswordResetToken = + sinon.stub().withArgs(ctx.token).resolves({ user: ctx.user }) + ctx.AuthenticationManager.promises.setUserPassword + .withArgs(ctx.user, ctx.password) .rejects() }) - it('should return the error', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (error, _result) => { - expect(error).to.exist - expect( - this.UserAuditLogHandler.promises.addEntry.callCount - ).to.equal(1) - done() - } - ) + it('should return the error', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (error, _result) => { + expect(error).to.exist + expect( + ctx.UserAuditLogHandler.promises.addEntry.callCount + ).to.equal(1) + resolve() + } + ) + }) }) }) describe('via UserAuditLogHandler', function () { - beforeEach(function () { - this.PasswordResetHandler.promises.getUserForPasswordResetToken = - sinon.stub().withArgs(this.token).resolves({ user: this.user }) - this.UserAuditLogHandler.promises.addEntry.rejects( + beforeEach(function (ctx) { + ctx.PasswordResetHandler.promises.getUserForPasswordResetToken = + sinon.stub().withArgs(ctx.token).resolves({ user: ctx.user }) + ctx.UserAuditLogHandler.promises.addEntry.rejects( new Error('oops') ) }) - it('should return the error', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (error, _result) => { - expect(error).to.exist - expect( - this.UserAuditLogHandler.promises.addEntry.callCount - ).to.equal(1) - expect(this.AuthenticationManager.promises.setUserPassword).to - .not.have.been.called - done() - } - ) + it('should return the error', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (error, _result) => { + expect(error).to.exist + expect( + ctx.UserAuditLogHandler.promises.addEntry.callCount + ).to.equal(1) + expect(ctx.AuthenticationManager.promises.setUserPassword) + .to.not.have.been.called + resolve() + } + ) + }) }) }) }) @@ -427,120 +475,126 @@ describe('PasswordResetHandler', function () { }) describe('when the token has a v1_user_id and email', function () { - beforeEach(function () { - this.user.overleaf = { id: 184 } - this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({ + beforeEach(function (ctx) { + ctx.user.overleaf = { id: 184 } + ctx.OneTimeTokenHandler.promises.peekValueFromToken.resolves({ data: { - v1_user_id: this.user.overleaf.id, - email: this.email, + v1_user_id: ctx.user.overleaf.id, + email: ctx.email, }, }) - this.AuthenticationManager.promises.setUserPassword - .withArgs(this.user, this.password) + ctx.AuthenticationManager.promises.setUserPassword + .withArgs(ctx.user, ctx.password) .resolves(true) - this.OneTimeTokenHandler.expireToken = sinon - .stub() - .callsArgWith(2, null) + ctx.OneTimeTokenHandler.expireToken = sinon.stub().callsArgWith(2, null) }) describe('when no user is reset with this email', function () { - beforeEach(function () { - this.UserGetter.getUserByMainEmail - .withArgs(this.email) + beforeEach(function (ctx) { + ctx.UserGetter.getUserByMainEmail + .withArgs(ctx.email) .yields(null, null) }) - it('should return reset == false', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (err, result) => { - const { reset } = result - expect(err).to.not.exist - expect(reset).to.be.false - expect(this.OneTimeTokenHandler.expireToken.called).to.equal( - false - ) - done() - } - ) + it('should return reset == false', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (err, result) => { + const { reset } = result + expect(err).to.not.exist + expect(reset).to.be.false + expect(ctx.OneTimeTokenHandler.expireToken.called).to.equal( + false + ) + resolve() + } + ) + }) }) }) describe("when the email and user don't match", function () { - beforeEach(function () { - this.UserGetter.getUserByMainEmail.withArgs(this.email).yields(null, { - _id: this.user._id, - email: this.email, + beforeEach(function (ctx) { + ctx.UserGetter.getUserByMainEmail.withArgs(ctx.email).yields(null, { + _id: ctx.user._id, + email: ctx.email, overleaf: { id: 'not-the-same' }, }) }) - it('should return reset == false', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (err, result) => { - const { reset } = result - expect(err).to.not.exist - expect(reset).to.be.false - expect(this.OneTimeTokenHandler.expireToken.called).to.equal( - false - ) - done() - } - ) + it('should return reset == false', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (err, result) => { + const { reset } = result + expect(err).to.not.exist + expect(reset).to.be.false + expect(ctx.OneTimeTokenHandler.expireToken.called).to.equal( + false + ) + resolve() + } + ) + }) }) }) describe('when the email and user match', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByMainEmail.resolves(this.user) + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByMainEmail.resolves(ctx.user) }) - it('should return reset == true and the user id', function (done) { - this.PasswordResetHandler.setNewUserPassword( - this.token, - this.password, - this.auditLog, - (err, result) => { - const { reset, userId } = result - expect(err).to.not.exist - expect(reset).to.be.true - expect(userId).to.equal(this.user._id) - expect(this.OneTimeTokenHandler.expireToken.called).to.equal(true) - done() - } - ) + it('should return reset == true and the user id', function (ctx) { + return new Promise(resolve => { + ctx.PasswordResetHandler.setNewUserPassword( + ctx.token, + ctx.password, + ctx.auditLog, + (err, result) => { + const { reset, userId } = result + expect(err).to.not.exist + expect(reset).to.be.true + expect(userId).to.equal(ctx.user._id) + expect(ctx.OneTimeTokenHandler.expireToken.called).to.equal( + true + ) + resolve() + } + ) + }) }) }) }) }) describe('getUserForPasswordResetToken', function () { - beforeEach(function () { - this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({ + beforeEach(function (ctx) { + ctx.OneTimeTokenHandler.promises.peekValueFromToken.resolves({ data: { - user_id: this.user._id, - email: this.email, + user_id: ctx.user._id, + email: ctx.email, }, remainingPeeks: 1, }) - this.UserGetter.promises.getUserByMainEmail.resolves({ - _id: this.user._id, - email: this.email, + ctx.UserGetter.promises.getUserByMainEmail.resolves({ + _id: ctx.user._id, + email: ctx.email, }) }) - it('should returns errors from user permissions', async function () { + it('should returns errors from user permissions', async function (ctx) { let error const err = new Error('nope') - this.PermissionsManager.promises.assertUserPermissions.rejects(err) + ctx.PermissionsManager.promises.assertUserPermissions.rejects(err) try { - await this.PasswordResetHandler.promises.getUserForPasswordResetToken( + await ctx.PasswordResetHandler.promises.getUserForPasswordResetToken( 'abc123' ) } catch (e) { @@ -549,13 +603,13 @@ describe('PasswordResetHandler', function () { expect(error).to.deep.equal(error) }) - it('returns user when user has permissions and remaining peaks', async function () { + it('returns user when user has permissions and remaining peaks', async function (ctx) { const result = - await this.PasswordResetHandler.promises.getUserForPasswordResetToken( + await ctx.PasswordResetHandler.promises.getUserForPasswordResetToken( 'abc123' ) expect(result).to.deep.equal({ - user: { _id: this.user._id, email: this.email }, + user: { _id: ctx.user._id, email: ctx.email }, remainingPeeks: 1, }) }) diff --git a/services/web/test/unit/src/Project/DocLinesComparitor.test.mjs b/services/web/test/unit/src/Project/DocLinesComparitor.test.mjs index 4f1f3b4f5f..55c4187f83 100644 --- a/services/web/test/unit/src/Project/DocLinesComparitor.test.mjs +++ b/services/web/test/unit/src/Project/DocLinesComparitor.test.mjs @@ -1,16 +1,14 @@ -import esmock from 'esmock' - const modulePath = '../../../../app/src/Features/Project/DocLinesComparitor.mjs' describe('doc lines comparitor', function () { - beforeEach(async function () { - this.comparitor = await esmock.strict(modulePath, {}) + beforeEach(async function (ctx) { + ctx.comparitor = (await import(modulePath)).default }) - it('should return true when the lines are the same', function () { + it('should return true when the lines are the same', function (ctx) { const lines1 = ['hello', 'world'] const lines2 = ['hello', 'world'] - const result = this.comparitor.areSame(lines1, lines2) + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(true) }) ;[ @@ -23,58 +21,58 @@ describe('doc lines comparitor', function () { lines2: ['hello', 'wrld'], }, ].forEach(({ lines1, lines2 }) => { - it('should return false when the lines are different', function () { - const result = this.comparitor.areSame(lines1, lines2) + it('should return false when the lines are different', function (ctx) { + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(false) }) }) - it('should return true when the lines are same', function () { + it('should return true when the lines are same', function (ctx) { const lines1 = ['hello', 'world'] const lines2 = ['hello', 'world'] - const result = this.comparitor.areSame(lines1, lines2) + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(true) }) - it('should return false if the doc lines are different in length', function () { + it('should return false if the doc lines are different in length', function (ctx) { const lines1 = ['hello', 'world'] const lines2 = ['hello', 'world', 'please'] - const result = this.comparitor.areSame(lines1, lines2) + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(false) }) - it('should return false if the first array is undefined', function () { + it('should return false if the first array is undefined', function (ctx) { const lines1 = undefined const lines2 = ['hello', 'world'] - const result = this.comparitor.areSame(lines1, lines2) + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(false) }) - it('should return false if the second array is undefined', function () { + it('should return false if the second array is undefined', function (ctx) { const lines1 = ['hello'] const lines2 = undefined - const result = this.comparitor.areSame(lines1, lines2) + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(false) }) - it('should return false if the second array is not an array', function () { + it('should return false if the second array is not an array', function (ctx) { const lines1 = ['hello'] const lines2 = '' - const result = this.comparitor.areSame(lines1, lines2) + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(false) }) - it('should return true when comparing equal orchard docs', function () { + it('should return true when comparing equal orchard docs', function (ctx) { const lines1 = [{ text: 'hello world' }] const lines2 = [{ text: 'hello world' }] - const result = this.comparitor.areSame(lines1, lines2) + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(true) }) - it('should return false when comparing different orchard docs', function () { + it('should return false when comparing different orchard docs', function (ctx) { const lines1 = [{ text: 'goodbye world' }] const lines2 = [{ text: 'hello world' }] - const result = this.comparitor.areSame(lines1, lines2) + const result = ctx.comparitor.areSame(lines1, lines2) result.should.equal(false) }) }) diff --git a/services/web/test/unit/src/Project/ProjectApiController.test.mjs b/services/web/test/unit/src/Project/ProjectApiController.test.mjs index bda54a932c..c73f327cd2 100644 --- a/services/web/test/unit/src/Project/ProjectApiController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectApiController.test.mjs @@ -1,57 +1,57 @@ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Project/ProjectApiController' describe('Project api controller', function () { - beforeEach(async function () { - this.ProjectDetailsHandler = { getDetails: sinon.stub() } - this.controller = await esmock.strict(modulePath, { - '../../../../app/src/Features/Project/ProjectDetailsHandler': - this.ProjectDetailsHandler, - }) - this.project_id = '321l3j1kjkjl' - this.req = { + beforeEach(async function (ctx) { + ctx.ProjectDetailsHandler = { getDetails: sinon.stub() } + + vi.doMock( + '../../../../app/src/Features/Project/ProjectDetailsHandler', + () => ({ + default: ctx.ProjectDetailsHandler, + }) + ) + + ctx.controller = (await import(modulePath)).default + ctx.project_id = '321l3j1kjkjl' + ctx.req = { params: { - project_id: this.project_id, + project_id: ctx.project_id, }, session: { destroy: sinon.stub(), }, } - this.res = {} - this.next = sinon.stub() - return (this.projDetails = { name: 'something' }) + ctx.res = {} + ctx.next = sinon.stub() + return (ctx.projDetails = { name: 'something' }) }) describe('getProjectDetails', function () { - it('should ask the project details handler for proj details', function (done) { - this.ProjectDetailsHandler.getDetails.callsArgWith( - 1, - null, - this.projDetails - ) - this.res.json = data => { - this.ProjectDetailsHandler.getDetails - .calledWith(this.project_id) - .should.equal(true) - data.should.deep.equal(this.projDetails) - return done() - } - return this.controller.getProjectDetails(this.req, this.res) + it('should ask the project details handler for proj details', function (ctx) { + return new Promise(resolve => { + ctx.ProjectDetailsHandler.getDetails.callsArgWith( + 1, + null, + ctx.projDetails + ) + ctx.res.json = data => { + ctx.ProjectDetailsHandler.getDetails + .calledWith(ctx.project_id) + .should.equal(true) + data.should.deep.equal(ctx.projDetails) + return resolve() + } + return ctx.controller.getProjectDetails(ctx.req, ctx.res) + }) }) - it('should send a 500 if there is an error', function () { - this.ProjectDetailsHandler.getDetails.callsArgWith(1, 'error') - this.controller.getProjectDetails(this.req, this.res, this.next) - return this.next.calledWith('error').should.equal(true) + it('should send a 500 if there is an error', function (ctx) { + ctx.ProjectDetailsHandler.getDetails.callsArgWith(1, 'error') + ctx.controller.getProjectDetails(ctx.req, ctx.res, ctx.next) + return ctx.next.calledWith('error').should.equal(true) }) }) }) diff --git a/services/web/test/unit/src/Project/ProjectListController.test.mjs b/services/web/test/unit/src/Project/ProjectListController.test.mjs index 827d16b737..a051382279 100644 --- a/services/web/test/unit/src/Project/ProjectListController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectListController.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' import mongodb from 'mongodb-legacy' @@ -12,10 +12,10 @@ const MODULE_PATH = new URL( ).pathname describe('ProjectListController', function () { - beforeEach(async function () { - this.project_id = new ObjectId('abcdefabcdefabcdefabcdef') + beforeEach(async function (ctx) { + ctx.project_id = new ObjectId('abcdefabcdefabcdefabcdef') - this.user = { + ctx.user = { _id: new ObjectId('123456123456123456123456'), email: 'test@overleaf.com', first_name: 'bjkdsjfk', @@ -23,7 +23,7 @@ describe('ProjectListController', function () { emails: [{ email: 'test@overleaf.com' }], lastLoginIp: '111.111.111.112', } - this.users = { + ctx.users = { 'user-1': { first_name: 'James', }, @@ -31,17 +31,17 @@ describe('ProjectListController', function () { first_name: 'Henry', }, } - this.users[this.user._id] = this.user // Owner - this.usersArr = Object.entries(this.users).map(([key, value]) => ({ + ctx.users[ctx.user._id] = ctx.user // Owner + ctx.usersArr = Object.entries(ctx.users).map(([key, value]) => ({ _id: key, ...value, })) - this.tags = [ + ctx.tags = [ { name: 1, project_ids: ['1', '2', '3'] }, { name: 2, project_ids: ['a', '1'] }, { name: 3, project_ids: ['a', 'b', 'c', 'd'] }, ] - this.notifications = [ + ctx.notifications = [ { _id: '1', user_id: '2', @@ -50,63 +50,63 @@ describe('ProjectListController', function () { key: '5', }, ] - this.settings = { + ctx.settings = { siteUrl: 'https://overleaf.com', } - this.TagsHandler = { + ctx.TagsHandler = { promises: { - getAllTags: sinon.stub().resolves(this.tags), + getAllTags: sinon.stub().resolves(ctx.tags), }, } - this.NotificationsHandler = { + ctx.NotificationsHandler = { promises: { - getUserNotifications: sinon.stub().resolves(this.notifications), + getUserNotifications: sinon.stub().resolves(ctx.notifications), }, } - this.UserModel = { - findById: sinon.stub().resolves(this.user), + ctx.UserModel = { + findById: sinon.stub().resolves(ctx.user), } - this.UserPrimaryEmailCheckHandler = { + ctx.UserPrimaryEmailCheckHandler = { requiresPrimaryEmailCheck: sinon.stub().returns(false), } - this.ProjectGetter = { + ctx.ProjectGetter = { promises: { findAllUsersProjects: sinon.stub(), }, } - this.ProjectHelper = { + ctx.ProjectHelper = { isArchived: sinon.stub(), isTrashed: sinon.stub(), } - this.SessionManager = { - getLoggedInUserId: sinon.stub().returns(this.user._id), + ctx.SessionManager = { + getLoggedInUserId: sinon.stub().returns(ctx.user._id), } - this.UserController = { + ctx.UserController = { logout: sinon.stub(), } - this.UserGetter = { + ctx.UserGetter = { promises: { - getUsers: sinon.stub().resolves(this.usersArr), + getUsers: sinon.stub().resolves(ctx.usersArr), getUserFullEmails: sinon.stub().resolves([]), }, } - this.Features = { + ctx.Features = { hasFeature: sinon.stub(), } - this.Metrics = { + ctx.Metrics = { inc: sinon.stub(), } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({ variant: 'default' }), }, } - this.SplitTestSessionHandler = { + ctx.SplitTestSessionHandler = { promises: { sessionMaintenance: sinon.stub().resolves(), }, } - this.SubscriptionViewModelBuilder = { + ctx.SubscriptionViewModelBuilder = { promises: { getUsersSubscriptionDetails: sinon.stub().resolves({ bestSubscription: { type: 'free' }, @@ -115,17 +115,17 @@ describe('ProjectListController', function () { }), }, } - this.SurveyHandler = { + ctx.SurveyHandler = { promises: { getSurvey: sinon.stub().resolves({}), }, } - this.NotificationBuilder = { + ctx.NotificationBuilder = { promises: { ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }), }, } - this.GeoIpLookup = { + ctx.GeoIpLookup = { promises: { getCurrencyCode: sinon.stub().resolves({ countryCode: 'US', @@ -133,11 +133,11 @@ describe('ProjectListController', function () { }), }, } - this.TutorialHandler = { + ctx.TutorialHandler = { getInactiveTutorials: sinon.stub().returns([]), } - this.Modules = { + ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves([]), @@ -145,58 +145,133 @@ describe('ProjectListController', function () { }, } - this.ProjectListController = await esmock.strict(MODULE_PATH, { - 'mongodb-legacy': { ObjectId }, - '@overleaf/settings': this.settings, - '@overleaf/metrics': this.Metrics, - '../../../../app/src/Features/SplitTests/SplitTestHandler': - this.SplitTestHandler, - '../../../../app/src/Features/SplitTests/SplitTestSessionHandler': - this.SplitTestSessionHandler, - '../../../../app/src/Features/User/UserController': this.UserController, - '../../../../app/src/Features/Project/ProjectHelper': this.ProjectHelper, - '../../../../app/src/Features/Tags/TagsHandler': this.TagsHandler, - '../../../../app/src/Features/Notifications/NotificationsHandler': - this.NotificationsHandler, - '../../../../app/src/models/User': { User: this.UserModel }, - '../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/infrastructure/Features': this.Features, - '../../../../app/src/Features/User/UserGetter': this.UserGetter, - '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder': - this.SubscriptionViewModelBuilder, - '../../../../app/src/infrastructure/Modules': this.Modules, - '../../../../app/src/Features/Survey/SurveyHandler': this.SurveyHandler, - '../../../../app/src/Features/User/UserPrimaryEmailCheckHandler': - this.UserPrimaryEmailCheckHandler, - '../../../../app/src/Features/Notifications/NotificationsBuilder': - this.NotificationBuilder, - '../../../../app/src/infrastructure/GeoIpLookup': this.GeoIpLookup, - '../../../../app/src/Features/Tutorial/TutorialHandler': - this.TutorialHandler, - }) + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) - this.req = { + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.Metrics, + })) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestSessionHandler', + () => ({ + default: ctx.SplitTestSessionHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserController', () => ({ + default: ctx.UserController, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectHelper', () => ({ + default: ctx.ProjectHelper, + })) + + vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({ + default: ctx.TagsHandler, + })) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsHandler', + () => ({ + default: ctx.NotificationsHandler, + }) + ) + + vi.doMock('../../../../app/src/models/User', () => ({ + User: ctx.UserModel, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: ctx.Features, + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder', + () => ({ + default: ctx.SubscriptionViewModelBuilder, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: ctx.Modules, + })) + + vi.doMock('../../../../app/src/Features/Survey/SurveyHandler', () => ({ + default: ctx.SurveyHandler, + })) + + vi.doMock( + '../../../../app/src/Features/User/UserPrimaryEmailCheckHandler', + () => ({ + default: ctx.UserPrimaryEmailCheckHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsBuilder', + () => ({ + default: ctx.NotificationBuilder, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({ + default: ctx.GeoIpLookup, + })) + + vi.doMock('../../../../app/src/Features/Tutorial/TutorialHandler', () => ({ + default: ctx.TutorialHandler, + })) + + ctx.ProjectListController = (await import(MODULE_PATH)).default + + ctx.req = { query: {}, params: { - Project_id: this.project_id, + Project_id: ctx.project_id, }, headers: {}, session: { - user: this.user, + user: ctx.user, }, body: {}, i18n: { translate() {}, }, } - this.res = {} + ctx.res = {} }) describe('projectListPage', function () { - beforeEach(function () { - this.projects = [ + beforeEach(function (ctx) { + ctx.projects = [ { _id: 1, lastUpdated: 1, owner_ref: 'user-1' }, { _id: 2, @@ -205,184 +280,206 @@ describe('ProjectListController', function () { lastUpdatedBy: 'user-1', }, ] - this.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }] - this.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }] - this.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }] - this.tokenReadOnly = [{ _id: 7, lastUpdated: 4, owner_ref: 'user-5' }] - this.review = [{ _id: 8, lastUpdated: 4, owner_ref: 'user-6' }] - this.allProjects = { - owned: this.projects, - readAndWrite: this.readAndWrite, - readOnly: this.readOnly, - tokenReadAndWrite: this.tokenReadAndWrite, - tokenReadOnly: this.tokenReadOnly, - review: this.review, + ctx.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }] + ctx.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }] + ctx.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }] + ctx.tokenReadOnly = [{ _id: 7, lastUpdated: 4, owner_ref: 'user-5' }] + ctx.review = [{ _id: 8, lastUpdated: 4, owner_ref: 'user-6' }] + ctx.allProjects = { + owned: ctx.projects, + readAndWrite: ctx.readAndWrite, + readOnly: ctx.readOnly, + tokenReadAndWrite: ctx.tokenReadAndWrite, + tokenReadOnly: ctx.tokenReadOnly, + review: ctx.review, } - this.ProjectGetter.promises.findAllUsersProjects.resolves( - this.allProjects - ) + ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects) }) - it('should render the project/list-react page', function (done) { - this.res.render = (pageName, opts) => { - pageName.should.equal('project/list-react') - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it('should invoke the session maintenance', function (done) { - this.Features.hasFeature.withArgs('saas').returns(true) - this.res.render = () => { - this.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( - this.req, - this.user - ) - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it('should send the tags', function (done) { - this.res.render = (pageName, opts) => { - opts.tags.length.should.equal(this.tags.length) - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it('should create trigger ip matcher notifications', function (done) { - this.settings.overleaf = true - this.req.ip = '111.111.111.111' - this.res.render = (pageName, opts) => { - this.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal( - true - ) - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it('should send the projects', function (done) { - this.res.render = (pageName, opts) => { - opts.prefetchedProjectsBlob.projects.length.should.equal( - this.projects.length + - this.readAndWrite.length + - this.readOnly.length + - this.tokenReadAndWrite.length + - this.tokenReadOnly.length + - this.review.length - ) - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it('should send the user', function (done) { - this.res.render = (pageName, opts) => { - opts.user.should.deep.equal(this.user) - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it('should inject the users', function (done) { - this.res.render = (pageName, opts) => { - const projects = opts.prefetchedProjectsBlob.projects - - projects - .filter(p => p.id === '1')[0] - .owner.firstName.should.equal( - this.users[this.projects.filter(p => p._id === 1)[0].owner_ref] - .first_name - ) - projects - .filter(p => p.id === '2')[0] - .owner.firstName.should.equal( - this.users[this.projects.filter(p => p._id === 2)[0].owner_ref] - .first_name - ) - projects - .filter(p => p.id === '2')[0] - .lastUpdatedBy.firstName.should.equal( - this.users[this.projects.filter(p => p._id === 2)[0].lastUpdatedBy] - .first_name - ) - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it("should send the user's best subscription when saas feature present", function (done) { - this.Features.hasFeature.withArgs('saas').returns(true) - this.res.render = (pageName, opts) => { - expect(opts.usersBestSubscription).to.deep.include({ type: 'free' }) - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it('should not return a best subscription without saas feature', function (done) { - this.Features.hasFeature.withArgs('saas').returns(false) - this.res.render = (pageName, opts) => { - expect(opts.usersBestSubscription).to.be.undefined - done() - } - this.ProjectListController.projectListPage(this.req, this.res) - }) - - it('should show INR Banner for Indian users with free account', function (done) { - // usersBestSubscription is only available when saas feature is present - this.Features.hasFeature.withArgs('saas').returns(true) - this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( - { - bestSubscription: { - type: 'free', - }, + it('should render the project/list-react page', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (pageName, opts) => { + pageName.should.equal('project/list-react') + resolve() } - ) - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'IN', + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - this.res.render = (pageName, opts) => { - expect(opts.showInrGeoBanner).to.be.true - done() - } - this.ProjectListController.projectListPage(this.req, this.res) }) - it('should not show INR Banner for Indian users with premium account', function (done) { - // usersBestSubscription is only available when saas feature is present - this.Features.hasFeature.withArgs('saas').returns(true) - this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( - { - bestSubscription: { - type: 'individual', - }, + it('should invoke the session maintenance', function (ctx) { + return new Promise(resolve => { + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.res.render = () => { + ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( + ctx.req, + ctx.user + ) + resolve() } - ) - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'IN', + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it('should send the tags', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (pageName, opts) => { + opts.tags.length.should.equal(ctx.tags.length) + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it('should create trigger ip matcher notifications', function (ctx) { + return new Promise(resolve => { + ctx.settings.overleaf = true + ctx.req.ip = '111.111.111.111' + ctx.res.render = (pageName, opts) => { + ctx.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal( + true + ) + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it('should send the projects', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (pageName, opts) => { + opts.prefetchedProjectsBlob.projects.length.should.equal( + ctx.projects.length + + ctx.readAndWrite.length + + ctx.readOnly.length + + ctx.tokenReadAndWrite.length + + ctx.tokenReadOnly.length + + ctx.review.length + ) + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it('should send the user', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (pageName, opts) => { + opts.user.should.deep.equal(ctx.user) + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it('should inject the users', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (pageName, opts) => { + const projects = opts.prefetchedProjectsBlob.projects + + projects + .filter(p => p.id === '1')[0] + .owner.firstName.should.equal( + ctx.users[ctx.projects.filter(p => p._id === 1)[0].owner_ref] + .first_name + ) + projects + .filter(p => p.id === '2')[0] + .owner.firstName.should.equal( + ctx.users[ctx.projects.filter(p => p._id === 2)[0].owner_ref] + .first_name + ) + projects + .filter(p => p.id === '2')[0] + .lastUpdatedBy.firstName.should.equal( + ctx.users[ctx.projects.filter(p => p._id === 2)[0].lastUpdatedBy] + .first_name + ) + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it("should send the user's best subscription when saas feature present", function (ctx) { + return new Promise(resolve => { + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.res.render = (pageName, opts) => { + expect(opts.usersBestSubscription).to.deep.include({ type: 'free' }) + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it('should not return a best subscription without saas feature', function (ctx) { + return new Promise(resolve => { + ctx.Features.hasFeature.withArgs('saas').returns(false) + ctx.res.render = (pageName, opts) => { + expect(opts.usersBestSubscription).to.be.undefined + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it('should show INR Banner for Indian users with free account', function (ctx) { + return new Promise(resolve => { + // usersBestSubscription is only available when saas feature is present + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { + bestSubscription: { + type: 'free', + }, + } + ) + ctx.GeoIpLookup.promises.getCurrencyCode.resolves({ + countryCode: 'IN', + }) + ctx.res.render = (pageName, opts) => { + expect(opts.showInrGeoBanner).to.be.true + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) + }) + + it('should not show INR Banner for Indian users with premium account', function (ctx) { + return new Promise(resolve => { + // usersBestSubscription is only available when saas feature is present + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { + bestSubscription: { + type: 'individual', + }, + } + ) + ctx.GeoIpLookup.promises.getCurrencyCode.resolves({ + countryCode: 'IN', + }) + ctx.res.render = (pageName, opts) => { + expect(opts.showInrGeoBanner).to.be.false + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - this.res.render = (pageName, opts) => { - expect(opts.showInrGeoBanner).to.be.false - done() - } - this.ProjectListController.projectListPage(this.req, this.res) }) describe('With Institution SSO feature', function () { - beforeEach(function (done) { - this.institutionEmail = 'test@overleaf.com' - this.institutionName = 'Overleaf' - this.Features.hasFeature.withArgs('saml').returns(true) - this.Features.hasFeature.withArgs('affiliations').returns(true) - this.Features.hasFeature.withArgs('saas').returns(true) - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.institutionEmail = 'test@overleaf.com' + ctx.institutionName = 'Overleaf' + ctx.Features.hasFeature.withArgs('saml').returns(true) + ctx.Features.hasFeature.withArgs('affiliations').returns(true) + ctx.Features.hasFeature.withArgs('saas').returns(true) + resolve() + }) }) - it('should show institution SSO available notification for confirmed domains', function () { - this.UserGetter.promises.getUserFullEmails.resolves([ + it('should show institution SSO available notification for confirmed domains', function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'test@overleaf.com', affiliation: { @@ -396,64 +493,64 @@ describe('ProjectListController', function () { }, }, ]) - this.res.render = (pageName, opts) => { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.include({ - email: this.institutionEmail, + email: ctx.institutionEmail, institutionId: 1, - institutionName: this.institutionName, + institutionName: ctx.institutionName, templateKey: 'notification_institution_sso_available', }) } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show a linked notification', function () { - this.req.session.saml = { - institutionEmail: this.institutionEmail, + it('should show a linked notification', function (ctx) { + ctx.req.session.saml = { + institutionEmail: ctx.institutionEmail, linked: { hasEntitlement: false, - universityName: this.institutionName, + universityName: ctx.institutionName, }, } - this.res.render = (pageName, opts) => { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.include({ - email: this.institutionEmail, - institutionName: this.institutionName, + email: ctx.institutionEmail, + institutionName: ctx.institutionName, templateKey: 'notification_institution_sso_linked', }) } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show a linked another email notification', function () { + it('should show a linked another email notification', function (ctx) { // when they request to link an email but the institution returns // a different email - this.res.render = (pageName, opts) => { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.include({ - institutionEmail: this.institutionEmail, + institutionEmail: ctx.institutionEmail, requestedEmail: 'requested@overleaf.com', templateKey: 'notification_institution_sso_non_canonical', }) } - this.req.session.saml = { - emailNonCanonical: this.institutionEmail, - institutionEmail: this.institutionEmail, + ctx.req.session.saml = { + emailNonCanonical: ctx.institutionEmail, + institutionEmail: ctx.institutionEmail, requestedEmail: 'requested@overleaf.com', linked: { hasEntitlement: false, - universityName: this.institutionName, + universityName: ctx.institutionName, }, } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show a notification when intent was to register via SSO but account existed', function () { - this.res.render = (pageName, opts) => { + it('should show a notification when intent was to register via SSO but account existed', function (ctx) { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.include({ - email: this.institutionEmail, + email: ctx.institutionEmail, templateKey: 'notification_institution_sso_already_registered', }) } - this.req.session.saml = { - institutionEmail: this.institutionEmail, + ctx.req.session.saml = { + institutionEmail: ctx.institutionEmail, linked: { hasEntitlement: false, universityName: 'Overleaf', @@ -463,29 +560,29 @@ describe('ProjectListController', function () { name: 'Example University', }, } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should not show a register notification if the flow was abandoned', function () { + it('should not show a register notification if the flow was abandoned', function (ctx) { // could initially start to register with an SSO email and then // abandon flow and login with an existing non-institution SSO email - this.res.render = (pageName, opts) => { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.not.include({ email: 'test@overleaf.com', templateKey: 'notification_institution_sso_already_registered', }) } - this.req.session.saml = { + ctx.req.session.saml = { registerIntercept: { id: 1, name: 'Example University', }, } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show error notification', function () { - this.res.render = (pageName, opts) => { + it('should show error notification', function (ctx) { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution.length).to.equal(1) expect(opts.notificationsInstitution[0].templateKey).to.equal( 'notification_institution_sso_error' @@ -494,81 +591,85 @@ describe('ProjectListController', function () { Errors.SAMLAlreadyLinkedError ) } - this.req.session.saml = { - institutionEmail: this.institutionEmail, + ctx.req.session.saml = { + institutionEmail: ctx.institutionEmail, error: new Errors.SAMLAlreadyLinkedError(), } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) describe('for an unconfirmed domain for an SSO institution', function () { - beforeEach(function (done) { - this.UserGetter.promises.getUserFullEmails.resolves([ - { - email: 'test@overleaf-uncofirmed.com', - affiliation: { - institution: { - id: 1, - confirmed: false, - name: 'Overleaf', - ssoBeta: false, - ssoEnabled: true, + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'test@overleaf-uncofirmed.com', + affiliation: { + institution: { + id: 1, + confirmed: false, + name: 'Overleaf', + ssoBeta: false, + ssoEnabled: true, + }, }, }, - }, - ]) - done() + ]) + resolve() + }) }) - it('should not show institution SSO available notification', function () { - this.res.render = (pageName, opts) => { + it('should not show institution SSO available notification', function (ctx) { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution.length).to.equal(0) } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) describe('when linking/logging in initiated on institution side', function () { - it('should not show a linked another email notification', function () { + it('should not show a linked another email notification', function (ctx) { // this is only used when initated on Overleaf, // because we keep track of the requested email they tried to link - this.res.render = (pageName, opts) => { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.not.deep.include({ - institutionEmail: this.institutionEmail, + institutionEmail: ctx.institutionEmail, requestedEmail: undefined, templateKey: 'notification_institution_sso_non_canonical', }) } - this.req.session.saml = { - emailNonCanonical: this.institutionEmail, - institutionEmail: this.institutionEmail, + ctx.req.session.saml = { + emailNonCanonical: ctx.institutionEmail, + institutionEmail: ctx.institutionEmail, linked: { hasEntitlement: false, - universityName: this.institutionName, + universityName: ctx.institutionName, }, } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) describe('Institution with SSO beta testable', function () { - beforeEach(function (done) { - this.UserGetter.promises.getUserFullEmails.resolves([ - { - email: 'beta@beta.com', - affiliation: { - institution: { - id: 2, - confirmed: true, - name: 'Beta University', - ssoBeta: true, - ssoEnabled: false, + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'beta@beta.com', + affiliation: { + institution: { + id: 2, + confirmed: true, + name: 'Beta University', + ssoBeta: true, + ssoEnabled: false, + }, }, }, - }, - ]) - done() + ]) + resolve() + }) }) - it('should show institution SSO available notification when on a beta testing session', function () { - this.req.session.samlBeta = true - this.res.render = (pageName, opts) => { + it('should show institution SSO available notification when on a beta testing session', function (ctx) { + ctx.req.session.samlBeta = true + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.include({ email: 'beta@beta.com', institutionId: 2, @@ -576,11 +677,11 @@ describe('ProjectListController', function () { templateKey: 'notification_institution_sso_available', }) } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should not show institution SSO available notification when not on a beta testing session', function () { - this.req.session.samlBeta = false - this.res.render = (pageName, opts) => { + it('should not show institution SSO available notification when not on a beta testing session', function (ctx) { + ctx.req.session.samlBeta = false + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.not.include({ email: 'test@overleaf.com', institutionId: 1, @@ -588,18 +689,20 @@ describe('ProjectListController', function () { templateKey: 'notification_institution_sso_available', }) } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) }) describe('Without Institution SSO feature', function () { - beforeEach(function (done) { - this.Features.hasFeature.withArgs('saml').returns(false) - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.Features.hasFeature.withArgs('saml').returns(false) + resolve() + }) }) - it('should not show institution sso available notification', function () { - this.res.render = (pageName, opts) => { + it('should not show institution sso available notification', function (ctx) { + ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.not.include({ email: 'test@overleaf.com', institutionId: 1, @@ -607,35 +710,33 @@ describe('ProjectListController', function () { templateKey: 'notification_institution_sso_available', }) } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) describe('enterprise banner', function () { - beforeEach(function (done) { - this.Features.hasFeature.withArgs('saas').returns(true) - this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + beforeEach(function (ctx) { + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( { memberGroupSubscriptions: [] } ) - this.UserGetter.promises.getUserFullEmails.resolves([ + ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'test@test-domain.com', }, ]) - - done() }) describe('normal enterprise banner', function () { - it('shows banner', function () { - this.res.render = (pageName, opts) => { + it('shows banner', function (ctx) { + ctx.res.render = (pageName, opts) => { expect(opts.showGroupsAndEnterpriseBanner).to.be.true } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('does not show banner if user is part of any affiliation', function () { - this.UserGetter.promises.getUserFullEmails.resolves([ + it('does not show banner if user is part of any affiliation', function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'test@overleaf.com', affiliation: { @@ -651,36 +752,36 @@ describe('ProjectListController', function () { }, ]) - this.res.render = (pageName, opts) => { + ctx.res.render = (pageName, opts) => { expect(opts.showGroupsAndEnterpriseBanner).to.be.false } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('does not show banner if user is part of any group subscription', function () { - this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + it('does not show banner if user is part of any group subscription', function (ctx) { + ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( { memberGroupSubscriptions: [{}] } ) - this.res.render = (pageName, opts) => { + ctx.res.render = (pageName, opts) => { expect(opts.showGroupsAndEnterpriseBanner).to.be.false } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('have a banner variant of "FOMO" or "on-premise"', function () { - this.res.render = (pageName, opts) => { + it('have a banner variant of "FOMO" or "on-premise"', function (ctx) { + ctx.res.render = (pageName, opts) => { expect(opts.groupsAndEnterpriseBannerVariant).to.be.oneOf([ 'FOMO', 'on-premise', ]) } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) describe('US government enterprise banner', function () { - it('does not show enterprise banner if US government enterprise banner is shown', function () { + it('does not show enterprise banner if US government enterprise banner is shown', function (ctx) { const emails = [ { email: 'test@test.mil', @@ -688,8 +789,8 @@ describe('ProjectListController', function () { }, ] - this.UserGetter.promises.getUserFullEmails.resolves(emails) - this.Modules.promises.hooks.fire + ctx.UserGetter.promises.getUserFullEmails.resolves(emails) + ctx.Modules.promises.hooks.fire .withArgs('getUSGovBanner', emails, false, []) .resolves([ { @@ -697,66 +798,68 @@ describe('ProjectListController', function () { usGovBannerVariant: 'variant', }, ]) - this.res.render = (pageName, opts) => { + ctx.res.render = (pageName, opts) => { expect(opts.showGroupsAndEnterpriseBanner).to.be.false expect(opts.showUSGovBanner).to.be.true } - this.ProjectListController.projectListPage(this.req, this.res) + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) }) }) describe('projectListReactPage with duplicate projects', function () { - beforeEach(function () { - this.projects = [ + beforeEach(function (ctx) { + ctx.projects = [ { _id: 1, lastUpdated: 1, owner_ref: 'user-1' }, { _id: 2, lastUpdated: 2, owner_ref: 'user-2' }, ] - this.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }] - this.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }] - this.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }] - this.tokenReadOnly = [ + ctx.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }] + ctx.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }] + ctx.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }] + ctx.tokenReadOnly = [ { _id: 6, lastUpdated: 5, owner_ref: 'user-4' }, // Also in tokenReadAndWrite { _id: 7, lastUpdated: 4, owner_ref: 'user-5' }, ] - this.review = [{ _id: 8, lastUpdated: 5, owner_ref: 'user-6' }] - this.allProjects = { - owned: this.projects, - readAndWrite: this.readAndWrite, - readOnly: this.readOnly, - tokenReadAndWrite: this.tokenReadAndWrite, - tokenReadOnly: this.tokenReadOnly, - review: this.review, + ctx.review = [{ _id: 8, lastUpdated: 5, owner_ref: 'user-6' }] + ctx.allProjects = { + owned: ctx.projects, + readAndWrite: ctx.readAndWrite, + readOnly: ctx.readOnly, + tokenReadAndWrite: ctx.tokenReadAndWrite, + tokenReadOnly: ctx.tokenReadOnly, + review: ctx.review, } - this.ProjectGetter.promises.findAllUsersProjects.resolves( - this.allProjects - ) + ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects) }) - it('should render the project/list-react page', function (done) { - this.res.render = (pageName, opts) => { - pageName.should.equal('project/list-react') - done() - } - this.ProjectListController.projectListPage(this.req, this.res) + it('should render the project/list-react page', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (pageName, opts) => { + pageName.should.equal('project/list-react') + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) }) - it('should omit one of the projects', function (done) { - this.res.render = (pageName, opts) => { - opts.prefetchedProjectsBlob.projects.length.should.equal( - this.projects.length + - this.readAndWrite.length + - this.readOnly.length + - this.tokenReadAndWrite.length + - this.tokenReadOnly.length + - this.review.length - - 1 - ) - done() - } - this.ProjectListController.projectListPage(this.req, this.res) + it('should omit one of the projects', function (ctx) { + return new Promise(resolve => { + ctx.res.render = (pageName, opts) => { + opts.prefetchedProjectsBlob.projects.length.should.equal( + ctx.projects.length + + ctx.readAndWrite.length + + ctx.readOnly.length + + ctx.tokenReadAndWrite.length + + ctx.tokenReadOnly.length + + ctx.review.length - + 1 + ) + resolve() + } + ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + }) }) }) }) diff --git a/services/web/test/unit/src/Referal/ReferalConnect.test.mjs b/services/web/test/unit/src/Referal/ReferalConnect.test.mjs index c6e56c3c6a..33e6c6816e 100644 --- a/services/web/test/unit/src/Referal/ReferalConnect.test.mjs +++ b/services/web/test/unit/src/Referal/ReferalConnect.test.mjs @@ -1,132 +1,153 @@ -import esmock from 'esmock' const modulePath = new URL( '../../../../app/src/Features/Referal/ReferalConnect.mjs', import.meta.url ).pathname describe('Referal connect middle wear', function () { - beforeEach(async function () { - this.connect = await esmock.strict(modulePath, {}) + beforeEach(async function (ctx) { + ctx.connect = (await import(modulePath)).default }) - it('should take a referal query string and put it on the session if it exists', function (done) { - const req = { - query: { referal: '12345' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_id.should.equal(req.query.referal) - done() + it('should take a referal query string and put it on the session if it exists', function (ctx) { + return new Promise(resolve => { + const req = { + query: { referal: '12345' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_id.should.equal(req.query.referal) + resolve() + }) }) }) - it('should not change the referal_id on the session if not in query', function (done) { - const req = { - query: {}, - session: { referal_id: 'same' }, - } - this.connect.use(req, {}, () => { - req.session.referal_id.should.equal('same') - done() + it('should not change the referal_id on the session if not in query', function (ctx) { + return new Promise(resolve => { + const req = { + query: {}, + session: { referal_id: 'same' }, + } + ctx.connect.use(req, {}, () => { + req.session.referal_id.should.equal('same') + resolve() + }) }) }) - it('should take a facebook referal query string and put it on the session if it exists', function (done) { - const req = { - query: { fb_ref: '12345' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_id.should.equal(req.query.fb_ref) - done() + it('should take a facebook referal query string and put it on the session if it exists', function (ctx) { + return new Promise(resolve => { + const req = { + query: { fb_ref: '12345' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_id.should.equal(req.query.fb_ref) + resolve() + }) }) }) - it('should map the facebook medium into the session', function (done) { - const req = { - query: { rm: 'fb' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_medium.should.equal('facebook') - done() + it('should map the facebook medium into the session', function (ctx) { + return new Promise(resolve => { + const req = { + query: { rm: 'fb' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_medium.should.equal('facebook') + resolve() + }) }) }) - it('should map the twitter medium into the session', function (done) { - const req = { - query: { rm: 't' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_medium.should.equal('twitter') - done() + it('should map the twitter medium into the session', function (ctx) { + return new Promise(resolve => { + const req = { + query: { rm: 't' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_medium.should.equal('twitter') + resolve() + }) }) }) - it('should map the google plus medium into the session', function (done) { - const req = { - query: { rm: 'gp' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_medium.should.equal('google_plus') - done() + it('should map the google plus medium into the session', function (ctx) { + return new Promise(resolve => { + const req = { + query: { rm: 'gp' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_medium.should.equal('google_plus') + resolve() + }) }) }) - it('should map the email medium into the session', function (done) { - const req = { - query: { rm: 'e' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_medium.should.equal('email') - done() + it('should map the email medium into the session', function (ctx) { + return new Promise(resolve => { + const req = { + query: { rm: 'e' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_medium.should.equal('email') + resolve() + }) }) }) - it('should map the direct medium into the session', function (done) { - const req = { - query: { rm: 'd' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_medium.should.equal('direct') - done() + it('should map the direct medium into the session', function (ctx) { + return new Promise(resolve => { + const req = { + query: { rm: 'd' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_medium.should.equal('direct') + resolve() + }) }) }) - it('should map the bonus source into the session', function (done) { - const req = { - query: { rs: 'b' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_source.should.equal('bonus') - done() + it('should map the bonus source into the session', function (ctx) { + return new Promise(resolve => { + const req = { + query: { rs: 'b' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_source.should.equal('bonus') + resolve() + }) }) }) - it('should map the public share source into the session', function (done) { - const req = { - query: { rs: 'ps' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_source.should.equal('public_share') - done() + it('should map the public share source into the session', function (ctx) { + return new Promise(resolve => { + const req = { + query: { rs: 'ps' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_source.should.equal('public_share') + resolve() + }) }) }) - it('should map the collaborator invite into the session', function (done) { - const req = { - query: { rs: 'ci' }, - session: {}, - } - this.connect.use(req, {}, () => { - req.session.referal_source.should.equal('collaborator_invite') - done() + it('should map the collaborator invite into the session', function (ctx) { + return new Promise(resolve => { + const req = { + query: { rs: 'ci' }, + session: {}, + } + ctx.connect.use(req, {}, () => { + req.session.referal_source.should.equal('collaborator_invite') + resolve() + }) }) }) }) diff --git a/services/web/test/unit/src/Referal/ReferalController.test.mjs b/services/web/test/unit/src/Referal/ReferalController.test.mjs index 523fd23728..0a7b8aa87d 100644 --- a/services/web/test/unit/src/Referal/ReferalController.test.mjs +++ b/services/web/test/unit/src/Referal/ReferalController.test.mjs @@ -1,11 +1,7 @@ -import esmock from 'esmock' -const modulePath = new URL( - '../../../../app/src/Features/Referal/ReferalController.js', - import.meta.url -).pathname +const modulePath = '../../../../app/src/Features/Referal/ReferalController.js' -describe('Referal controller', function () { - beforeEach(async function () { - this.controller = await esmock.strict(modulePath, {}) +describe.skip('Referal controller', function () { + beforeEach(async function (ctx) { + ctx.controller = (await import(modulePath)).default }) }) diff --git a/services/web/test/unit/src/Referal/ReferalHandler.test.mjs b/services/web/test/unit/src/Referal/ReferalHandler.test.mjs index 6fd58a6569..5174918bd7 100644 --- a/services/web/test/unit/src/Referal/ReferalHandler.test.mjs +++ b/services/web/test/unit/src/Referal/ReferalHandler.test.mjs @@ -1,88 +1,86 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import { expect } from 'chai' import sinon from 'sinon' -const modulePath = new URL( - '../../../../app/src/Features/Referal/ReferalHandler.mjs', - import.meta.url -).pathname +const modulePath = '../../../../app/src/Features/Referal/ReferalHandler.mjs' describe('Referal handler', function () { - beforeEach(async function () { - this.User = { + beforeEach(async function (ctx) { + ctx.User = { findById: sinon.stub().returns({ exec: sinon.stub(), }), } - this.handler = await esmock.strict(modulePath, { - '../../../../app/src/models/User': { - User: this.User, - }, - }) - this.user_id = '12313' + + vi.doMock('../../../../app/src/models/User', () => ({ + User: ctx.User, + })) + + ctx.handler = (await import(modulePath)).default + ctx.user_id = '12313' }) describe('getting refered user_ids', function () { - it('should get the user from mongo and return the refered users array', async function () { + it('should get the user from mongo and return the refered users array', async function (ctx) { const user = { refered_users: ['1234', '312312', '3213129'], refered_user_count: 3, } - this.User.findById.returns({ + ctx.User.findById.returns({ exec: sinon.stub().resolves(user), }) const { referedUsers: passedReferedUserIds, referedUserCount: passedReferedUserCount, - } = await this.handler.promises.getReferedUsers(this.user_id) + } = await ctx.handler.promises.getReferedUsers(ctx.user_id) passedReferedUserIds.should.deep.equal(user.refered_users) passedReferedUserCount.should.equal(3) }) - it('should return an empty array if it is not set', async function () { + it('should return an empty array if it is not set', async function (ctx) { const user = {} - this.User.findById.returns({ + ctx.User.findById.returns({ exec: sinon.stub().resolves(user), }) const { referedUsers: passedReferedUserIds } = - await this.handler.promises.getReferedUsers(this.user_id) + await ctx.handler.promises.getReferedUsers(ctx.user_id) passedReferedUserIds.length.should.equal(0) }) - it('should return a zero count if neither it or the array are set', async function () { + it('should return a zero count if neither it or the array are set', async function (ctx) { const user = {} - this.User.findById.returns({ + ctx.User.findById.returns({ exec: sinon.stub().resolves(user), }) const { referedUserCount: passedReferedUserCount } = - await this.handler.promises.getReferedUsers(this.user_id) + await ctx.handler.promises.getReferedUsers(ctx.user_id) passedReferedUserCount.should.equal(0) }) - it('should return the array length if count is not set', async function () { + it('should return the array length if count is not set', async function (ctx) { const user = { refered_users: ['1234', '312312', '3213129'] } - this.User.findById.returns({ + ctx.User.findById.returns({ exec: sinon.stub().resolves(user), }) const { referedUserCount: passedReferedUserCount } = - await this.handler.promises.getReferedUsers(this.user_id) + await ctx.handler.promises.getReferedUsers(ctx.user_id) passedReferedUserCount.should.equal(3) }) - it('should error if finding the user fails', async function () { - this.User.findById.returns({ + it('should error if finding the user fails', async function (ctx) { + ctx.User.findById.returns({ exec: sinon.stub().rejects(new Error('user not found')), }) expect( - this.handler.promises.getReferedUsers(this.user_id) + ctx.handler.promises.getReferedUsers(ctx.user_id) ).to.be.rejectedWith('user not found') }) }) diff --git a/services/web/test/unit/src/References/ReferencesController.test.mjs b/services/web/test/unit/src/References/ReferencesController.test.mjs index fca2acea12..679e835840 100644 --- a/services/web/test/unit/src/References/ReferencesController.test.mjs +++ b/services/web/test/unit/src/References/ReferencesController.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' @@ -6,182 +6,207 @@ const modulePath = '../../../../app/src/Features/References/ReferencesController' describe('ReferencesController', function () { - beforeEach(async function () { - this.projectId = '2222' - this.controller = await esmock.strict(modulePath, { - '@overleaf/settings': (this.settings = { + beforeEach(async function (ctx) { + ctx.projectId = '2222' + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { apis: { web: { url: 'http://some.url' } }, }), - '../../../../app/src/Features/References/ReferencesHandler': - (this.ReferencesHandler = { + })) + + vi.doMock( + '../../../../app/src/Features/References/ReferencesHandler', + () => ({ + default: (ctx.ReferencesHandler = { index: sinon.stub(), indexAll: sinon.stub(), }), - '../../../../app/src/Features/Editor/EditorRealTimeController': - (this.EditorRealTimeController = { + }) + ) + + vi.doMock( + '../../../../app/src/Features/Editor/EditorRealTimeController', + () => ({ + default: (ctx.EditorRealTimeController = { emitToRoom: sinon.stub(), }), - }) - this.req = new MockRequest() - this.req.params.Project_id = this.projectId - this.req.body = { - docIds: (this.docIds = ['aaa', 'bbb']), + }) + ) + + ctx.controller = (await import(modulePath)).default + ctx.req = new MockRequest() + ctx.req.params.Project_id = ctx.projectId + ctx.req.body = { + docIds: (ctx.docIds = ['aaa', 'bbb']), shouldBroadcast: false, } - this.res = new MockResponse() - this.res.json = sinon.stub() - this.res.sendStatus = sinon.stub() - this.next = sinon.stub() - this.fakeResponseData = { - projectId: this.projectId, + ctx.res = new MockResponse() + ctx.res.json = sinon.stub() + ctx.res.sendStatus = sinon.stub() + ctx.next = sinon.stub() + ctx.fakeResponseData = { + projectId: ctx.projectId, keys: ['one', 'two', 'three'], } }) describe('indexAll', function () { - beforeEach(function () { - this.req.body = { shouldBroadcast: false } - this.ReferencesHandler.indexAll.callsArgWith( - 1, - null, - this.fakeResponseData - ) - this.call = callback => { - this.controller.indexAll(this.req, this.res, this.next) + beforeEach(function (ctx) { + ctx.req.body = { shouldBroadcast: false } + ctx.ReferencesHandler.indexAll.callsArgWith(1, null, ctx.fakeResponseData) + ctx.call = callback => { + ctx.controller.indexAll(ctx.req, ctx.res, ctx.next) return callback() } }) - it('should not produce an error', function (done) { - this.call(() => { - this.res.sendStatus.callCount.should.equal(0) - this.res.sendStatus.calledWith(500).should.equal(false) - this.res.sendStatus.calledWith(400).should.equal(false) - done() + it('should not produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.res.sendStatus.callCount.should.equal(0) + ctx.res.sendStatus.calledWith(500).should.equal(false) + ctx.res.sendStatus.calledWith(400).should.equal(false) + resolve() + }) }) }) - it('should return data', function (done) { - this.call(() => { - this.res.json.callCount.should.equal(1) - this.res.json.calledWith(this.fakeResponseData).should.equal(true) - done() + it('should return data', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.res.json.callCount.should.equal(1) + ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true) + resolve() + }) }) }) - it('should call ReferencesHandler.indexAll', function (done) { - this.call(() => { - this.ReferencesHandler.indexAll.callCount.should.equal(1) - this.ReferencesHandler.indexAll - .calledWith(this.projectId) - .should.equal(true) - done() + it('should call ReferencesHandler.indexAll', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.ReferencesHandler.indexAll.callCount.should.equal(1) + ctx.ReferencesHandler.indexAll + .calledWith(ctx.projectId) + .should.equal(true) + resolve() + }) }) }) describe('when shouldBroadcast is true', function () { - beforeEach(function () { - this.ReferencesHandler.index.callsArgWith( - 2, - null, - this.fakeResponseData - ) - this.req.body.shouldBroadcast = true + beforeEach(function (ctx) { + ctx.ReferencesHandler.index.callsArgWith(2, null, ctx.fakeResponseData) + ctx.req.body.shouldBroadcast = true }) - it('should call EditorRealTimeController.emitToRoom', function (done) { - this.call(() => { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(1) - done() + it('should call EditorRealTimeController.emitToRoom', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(1) + resolve() + }) }) }) - it('should not produce an error', function (done) { - this.call(() => { - this.res.sendStatus.callCount.should.equal(0) - this.res.sendStatus.calledWith(500).should.equal(false) - this.res.sendStatus.calledWith(400).should.equal(false) - done() + it('should not produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.res.sendStatus.callCount.should.equal(0) + ctx.res.sendStatus.calledWith(500).should.equal(false) + ctx.res.sendStatus.calledWith(400).should.equal(false) + resolve() + }) }) }) - it('should still return data', function (done) { - this.call(() => { - this.res.json.callCount.should.equal(1) - this.res.json.calledWith(this.fakeResponseData).should.equal(true) - done() + it('should still return data', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.res.json.callCount.should.equal(1) + ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true) + resolve() + }) }) }) }) describe('when shouldBroadcast is false', function () { - beforeEach(function () { - this.ReferencesHandler.index.callsArgWith( - 2, - null, - this.fakeResponseData - ) - this.req.body.shouldBroadcast = false + beforeEach(function (ctx) { + ctx.ReferencesHandler.index.callsArgWith(2, null, ctx.fakeResponseData) + ctx.req.body.shouldBroadcast = false }) - it('should not call EditorRealTimeController.emitToRoom', function (done) { - this.call(() => { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(0) - done() + it('should not call EditorRealTimeController.emitToRoom', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(0) + resolve() + }) }) }) - it('should not produce an error', function (done) { - this.call(() => { - this.res.sendStatus.callCount.should.equal(0) - this.res.sendStatus.calledWith(500).should.equal(false) - this.res.sendStatus.calledWith(400).should.equal(false) - done() + it('should not produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.res.sendStatus.callCount.should.equal(0) + ctx.res.sendStatus.calledWith(500).should.equal(false) + ctx.res.sendStatus.calledWith(400).should.equal(false) + resolve() + }) }) }) - it('should still return data', function (done) { - this.call(() => { - this.res.json.callCount.should.equal(1) - this.res.json.calledWith(this.fakeResponseData).should.equal(true) - done() + it('should still return data', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.res.json.callCount.should.equal(1) + ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true) + resolve() + }) }) }) }) }) describe('there is no data', function () { - beforeEach(function () { - this.ReferencesHandler.indexAll.callsArgWith(1) - this.call = callback => { - this.controller.indexAll(this.req, this.res, this.next) + beforeEach(function (ctx) { + ctx.ReferencesHandler.indexAll.callsArgWith(1) + ctx.call = callback => { + ctx.controller.indexAll(ctx.req, ctx.res, ctx.next) callback() } }) - it('should not call EditorRealTimeController.emitToRoom', function (done) { - this.call(() => { - this.EditorRealTimeController.emitToRoom.callCount.should.equal(0) - done() + it('should not call EditorRealTimeController.emitToRoom', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(0) + resolve() + }) }) }) - it('should not produce an error', function (done) { - this.call(() => { - this.res.sendStatus.callCount.should.equal(0) - this.res.sendStatus.calledWith(500).should.equal(false) - this.res.sendStatus.calledWith(400).should.equal(false) - done() + it('should not produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.res.sendStatus.callCount.should.equal(0) + ctx.res.sendStatus.calledWith(500).should.equal(false) + ctx.res.sendStatus.calledWith(400).should.equal(false) + resolve() + }) }) }) - it('should send a response with an empty keys list', function (done) { - this.call(() => { - this.res.json.called.should.equal(true) - this.res.json - .calledWith({ projectId: this.projectId, keys: [] }) - .should.equal(true) - done() + it('should send a response with an empty keys list', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.res.json.called.should.equal(true) + ctx.res.json + .calledWith({ projectId: ctx.projectId, keys: [] }) + .should.equal(true) + resolve() + }) }) }) }) diff --git a/services/web/test/unit/src/References/ReferencesHandler.test.mjs b/services/web/test/unit/src/References/ReferencesHandler.test.mjs index 57570dcf12..ae7b86822a 100644 --- a/services/web/test/unit/src/References/ReferencesHandler.test.mjs +++ b/services/web/test/unit/src/References/ReferencesHandler.test.mjs @@ -1,11 +1,4 @@ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -import esmock from 'esmock' +import { vi } from 'vitest' import { expect } from 'chai' import sinon from 'sinon' @@ -13,13 +6,17 @@ import Errors from '../../../../app/src/Features/Errors/Errors.js' const modulePath = '../../../../app/src/Features/References/ReferencesHandler.mjs' +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + describe('ReferencesHandler', function () { - beforeEach(async function () { - this.projectId = '222' - this.historyId = 42 - this.fakeProject = { - _id: this.projectId, - owner_ref: (this.fakeOwner = { + beforeEach(async function (ctx) { + ctx.projectId = '222' + ctx.historyId = 42 + ctx.fakeProject = { + _id: ctx.projectId, + owner_ref: (ctx.fakeOwner = { _id: 'some_owner', features: { references: false, @@ -43,11 +40,12 @@ describe('ReferencesHandler', function () { ], }, ], - overleaf: { history: { id: this.historyId } }, + overleaf: { history: { id: ctx.historyId } }, } - this.docIds = ['aaa', 'ccc'] - this.handler = await esmock.strict(modulePath, { - '@overleaf/settings': (this.settings = { + ctx.docIds = ['aaa', 'ccc'] + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { apis: { references: { url: 'http://some.url/references' }, docstore: { url: 'http://some.url/docstore' }, @@ -56,228 +54,278 @@ describe('ReferencesHandler', function () { }, enableProjectHistoryBlobs: true, }), - request: (this.request = { + })) + + vi.doMock('request', () => ({ + default: (ctx.request = { get: sinon.stub(), post: sinon.stub(), }), - '../../../../app/src/Features/Project/ProjectGetter': - (this.ProjectGetter = { - getProject: sinon.stub().callsArgWith(2, null, this.fakeProject), - }), - '../../../../app/src/Features/User/UserGetter': (this.UserGetter = { + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: (ctx.ProjectGetter = { + getProject: sinon.stub().callsArgWith(2, null, ctx.fakeProject), + }), + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = { getUser: sinon.stub(), }), - '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler': - (this.DocumentUpdaterHandler = { + })) + + vi.doMock( + '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler', + () => ({ + default: (ctx.DocumentUpdaterHandler = { flushDocToMongo: sinon.stub().callsArgWith(2, null), }), - '../../../../app/src/infrastructure/Features': (this.Features = { + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: (ctx.Features = { hasFeature: sinon.stub().returns(true), }), - }) - this.fakeResponseData = { - projectId: this.projectId, + })) + + ctx.handler = (await import(modulePath)).default + ctx.fakeResponseData = { + projectId: ctx.projectId, keys: ['k1', 'k2'], } }) describe('indexAll', function () { - beforeEach(function () { - sinon.stub(this.handler, '_findBibDocIds').returns(['aaa', 'ccc']) + beforeEach(function (ctx) { + sinon.stub(ctx.handler, '_findBibDocIds').returns(['aaa', 'ccc']) sinon - .stub(this.handler, '_findBibFileRefs') + .stub(ctx.handler, '_findBibFileRefs') .returns([{ _id: 'fff' }, { _id: 'ggg', hash: 'hash' }]) - sinon.stub(this.handler, '_isFullIndex').callsArgWith(1, null, true) - this.request.post.callsArgWith( + sinon.stub(ctx.handler, '_isFullIndex').callsArgWith(1, null, true) + ctx.request.post.callsArgWith( 1, null, { statusCode: 200 }, - this.fakeResponseData + ctx.fakeResponseData ) - return (this.call = callback => { - return this.handler.indexAll(this.projectId, callback) + return (ctx.call = callback => { + return ctx.handler.indexAll(ctx.projectId, callback) }) }) - it('should call _findBibDocIds', function (done) { - return this.call((err, data) => { - expect(err).to.be.null - this.handler._findBibDocIds.callCount.should.equal(1) - this.handler._findBibDocIds - .calledWith(this.fakeProject) - .should.equal(true) - return done() + it('should call _findBibDocIds', function (ctx) { + return new Promise(resolve => { + return ctx.call((err, data) => { + expect(err).to.be.null + ctx.handler._findBibDocIds.callCount.should.equal(1) + ctx.handler._findBibDocIds + .calledWith(ctx.fakeProject) + .should.equal(true) + return resolve() + }) }) }) - it('should call _findBibFileRefs', function (done) { - return this.call((err, data) => { - expect(err).to.be.null - this.handler._findBibDocIds.callCount.should.equal(1) - this.handler._findBibDocIds - .calledWith(this.fakeProject) - .should.equal(true) - return done() + it('should call _findBibFileRefs', function (ctx) { + return new Promise(resolve => { + return ctx.call((err, data) => { + expect(err).to.be.null + ctx.handler._findBibDocIds.callCount.should.equal(1) + ctx.handler._findBibDocIds + .calledWith(ctx.fakeProject) + .should.equal(true) + return resolve() + }) }) }) - it('should call DocumentUpdaterHandler.flushDocToMongo', function (done) { - return this.call((err, data) => { - expect(err).to.be.null - this.DocumentUpdaterHandler.flushDocToMongo.callCount.should.equal(2) - return done() + it('should call DocumentUpdaterHandler.flushDocToMongo', function (ctx) { + return new Promise(resolve => { + return ctx.call((err, data) => { + expect(err).to.be.null + ctx.DocumentUpdaterHandler.flushDocToMongo.callCount.should.equal(2) + return resolve() + }) }) }) - it('should make a request to references service', function (done) { - return this.call((err, data) => { - expect(err).to.be.null - this.request.post.callCount.should.equal(1) - const arg = this.request.post.firstCall.args[0] - expect(arg.json).to.have.all.keys('docUrls', 'sourceURLs', 'fullIndex') - expect(arg.json.docUrls.length).to.equal(4) - expect(arg.json.docUrls).to.deep.equal([ - `${this.settings.apis.docstore.url}/project/${this.projectId}/doc/aaa/raw`, - `${this.settings.apis.docstore.url}/project/${this.projectId}/doc/ccc/raw`, - `${this.settings.apis.filestore.url}/project/${this.projectId}/file/fff?from=bibFileUrls`, - `${this.settings.apis.filestore.url}/project/${this.projectId}/file/ggg?from=bibFileUrls`, - ]) - expect(arg.json.sourceURLs.length).to.equal(4) - expect(arg.json.sourceURLs).to.deep.equal([ - { - url: `${this.settings.apis.docstore.url}/project/${this.projectId}/doc/aaa/raw`, - }, - { - url: `${this.settings.apis.docstore.url}/project/${this.projectId}/doc/ccc/raw`, - }, - { - url: `${this.settings.apis.filestore.url}/project/${this.projectId}/file/fff?from=bibFileUrls`, - }, - { - url: `${this.settings.apis.project_history.url}/project/${this.historyId}/blob/hash`, - fallbackURL: `${this.settings.apis.filestore.url}/project/${this.projectId}/file/ggg?from=bibFileUrls`, - }, - ]) - expect(arg.json.fullIndex).to.equal(true) - return done() + it('should make a request to references service', function (ctx) { + return new Promise(resolve => { + return ctx.call((err, data) => { + expect(err).to.be.null + ctx.request.post.callCount.should.equal(1) + const arg = ctx.request.post.firstCall.args[0] + expect(arg.json).to.have.all.keys( + 'docUrls', + 'sourceURLs', + 'fullIndex' + ) + expect(arg.json.docUrls.length).to.equal(4) + expect(arg.json.docUrls).to.deep.equal([ + `${ctx.settings.apis.docstore.url}/project/${ctx.projectId}/doc/aaa/raw`, + `${ctx.settings.apis.docstore.url}/project/${ctx.projectId}/doc/ccc/raw`, + `${ctx.settings.apis.filestore.url}/project/${ctx.projectId}/file/fff?from=bibFileUrls`, + `${ctx.settings.apis.filestore.url}/project/${ctx.projectId}/file/ggg?from=bibFileUrls`, + ]) + expect(arg.json.sourceURLs.length).to.equal(4) + expect(arg.json.sourceURLs).to.deep.equal([ + { + url: `${ctx.settings.apis.docstore.url}/project/${ctx.projectId}/doc/aaa/raw`, + }, + { + url: `${ctx.settings.apis.docstore.url}/project/${ctx.projectId}/doc/ccc/raw`, + }, + { + url: `${ctx.settings.apis.filestore.url}/project/${ctx.projectId}/file/fff?from=bibFileUrls`, + }, + { + url: `${ctx.settings.apis.project_history.url}/project/${ctx.historyId}/blob/hash`, + fallbackURL: `${ctx.settings.apis.filestore.url}/project/${ctx.projectId}/file/ggg?from=bibFileUrls`, + }, + ]) + expect(arg.json.fullIndex).to.equal(true) + return resolve() + }) }) }) - it('should not produce an error', function (done) { - return this.call((err, data) => { - expect(err).to.equal(null) - return done() + it('should not produce an error', function (ctx) { + return new Promise(resolve => { + return ctx.call((err, data) => { + expect(err).to.equal(null) + return resolve() + }) }) }) - it('should return data', function (done) { - return this.call((err, data) => { - expect(err).to.be.null - expect(data).to.not.equal(null) - expect(data).to.not.equal(undefined) - expect(data).to.equal(this.fakeResponseData) - return done() + it('should return data', function (ctx) { + return new Promise(resolve => { + return ctx.call((err, data) => { + expect(err).to.be.null + expect(data).to.not.equal(null) + expect(data).to.not.equal(undefined) + expect(data).to.equal(ctx.fakeResponseData) + return resolve() + }) }) }) describe('when ProjectGetter.getProject produces an error', function () { - beforeEach(function () { - return this.ProjectGetter.getProject.callsArgWith(2, new Error('woops')) + beforeEach(function (ctx) { + ctx.ProjectGetter.getProject.callsArgWith(2, new Error('woops')) }) - it('should produce an error', function (done) { - return this.call((err, data) => { - expect(err).to.not.equal(null) - expect(err).to.be.instanceof(Error) - expect(data).to.equal(undefined) - return done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call((err, data) => { + expect(err).to.not.equal(null) + expect(err).to.be.instanceof(Error) + expect(data).to.equal(undefined) + resolve() + }) }) }) - it('should not send request', function (done) { - return this.call(() => { - this.request.post.callCount.should.equal(0) - return done() + it('should not send request', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.request.post.callCount.should.equal(0) + resolve() + }) }) }) }) describe('when ProjectGetter.getProject returns null', function () { - beforeEach(function () { - return this.ProjectGetter.getProject.callsArgWith(2, null) + beforeEach(function (ctx) { + ctx.ProjectGetter.getProject.callsArgWith(2, null) }) - it('should produce an error', function (done) { - return this.call((err, data) => { - expect(err).to.not.equal(null) - expect(err).to.be.instanceof(Errors.NotFoundError) - expect(data).to.equal(undefined) - return done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call((err, data) => { + expect(err).to.not.equal(null) + expect(err).to.be.instanceof(Errors.NotFoundError) + expect(data).to.equal(undefined) + resolve() + }) }) }) - it('should not send request', function (done) { - return this.call(() => { - this.request.post.callCount.should.equal(0) - return done() + it('should not send request', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.request.post.callCount.should.equal(0) + resolve() + }) }) }) }) describe('when _isFullIndex produces an error', function () { - beforeEach(function () { - this.ProjectGetter.getProject.callsArgWith(2, null, this.fakeProject) - return this.handler._isFullIndex.callsArgWith(1, new Error('woops')) + beforeEach(function (ctx) { + ctx.ProjectGetter.getProject.callsArgWith(2, null, ctx.fakeProject) + ctx.handler._isFullIndex.callsArgWith(1, new Error('woops')) }) - it('should produce an error', function (done) { - return this.call((err, data) => { - expect(err).to.not.equal(null) - expect(err).to.be.instanceof(Error) - expect(data).to.equal(undefined) - return done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call((err, data) => { + expect(err).to.not.equal(null) + expect(err).to.be.instanceof(Error) + expect(data).to.equal(undefined) + resolve() + }) }) }) - it('should not send request', function (done) { - return this.call(() => { - this.request.post.callCount.should.equal(0) - return done() + it('should not send request', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.request.post.callCount.should.equal(0) + resolve() + }) }) }) }) describe('when flushDocToMongo produces an error', function () { - beforeEach(function () { - this.ProjectGetter.getProject.callsArgWith(2, null, this.fakeProject) - this.handler._isFullIndex.callsArgWith(1, false) - return this.DocumentUpdaterHandler.flushDocToMongo.callsArgWith( + beforeEach(function (ctx) { + ctx.ProjectGetter.getProject.callsArgWith(2, null, ctx.fakeProject) + ctx.handler._isFullIndex.callsArgWith(1, false) + ctx.DocumentUpdaterHandler.flushDocToMongo.callsArgWith( 2, new Error('woops') ) }) - it('should produce an error', function (done) { - return this.call((err, data) => { - expect(err).to.not.equal(null) - expect(err).to.be.instanceof(Error) - expect(data).to.equal(undefined) - return done() + it('should produce an error', function (ctx) { + return new Promise(resolve => { + ctx.call((err, data) => { + expect(err).to.not.equal(null) + expect(err).to.be.instanceof(Error) + expect(data).to.equal(undefined) + resolve() + }) }) }) - it('should not send request', function (done) { - return this.call(() => { - this.request.post.callCount.should.equal(0) - return done() + it('should not send request', function (ctx) { + return new Promise(resolve => { + ctx.call(() => { + ctx.request.post.callCount.should.equal(0) + resolve() + }) }) }) }) }) describe('_findBibDocIds', function () { - beforeEach(function () { - this.fakeProject = { + beforeEach(function (ctx) { + ctx.fakeProject = { rootFolder: [ { docs: [ @@ -290,24 +338,24 @@ describe('ReferencesHandler', function () { }, ], } - return (this.expectedIds = ['aaa', 'ccc']) + ctx.expectedIds = ['aaa', 'ccc'] }) - it('should select the correct docIds', function () { - const result = this.handler._findBibDocIds(this.fakeProject) - return expect(result).to.deep.equal(this.expectedIds) + it('should select the correct docIds', function (ctx) { + const result = ctx.handler._findBibDocIds(ctx.fakeProject) + expect(result).to.deep.equal(ctx.expectedIds) }) - it('should not error with a non array of folders from dirty data', function () { - this.fakeProject.rootFolder[0].folders[0].folders = {} - const result = this.handler._findBibDocIds(this.fakeProject) - return expect(result).to.deep.equal(this.expectedIds) + it('should not error with a non array of folders from dirty data', function (ctx) { + ctx.fakeProject.rootFolder[0].folders[0].folders = {} + const result = ctx.handler._findBibDocIds(ctx.fakeProject) + expect(result).to.deep.equal(ctx.expectedIds) }) }) describe('_findBibFileRefs', function () { - beforeEach(function () { - this.fakeProject = { + beforeEach(function (ctx) { + ctx.fakeProject = { rootFolder: [ { docs: [ @@ -325,73 +373,73 @@ describe('ReferencesHandler', function () { }, ], } - this.expectedIds = [ - this.fakeProject.rootFolder[0].fileRefs[0], - this.fakeProject.rootFolder[0].folders[0].fileRefs[0], + ctx.expectedIds = [ + ctx.fakeProject.rootFolder[0].fileRefs[0], + ctx.fakeProject.rootFolder[0].folders[0].fileRefs[0], ] }) - it('should select the correct docIds', function () { - const result = this.handler._findBibFileRefs(this.fakeProject) - return expect(result).to.deep.equal(this.expectedIds) + it('should select the correct docIds', function (ctx) { + const result = ctx.handler._findBibFileRefs(ctx.fakeProject) + expect(result).to.deep.equal(ctx.expectedIds) }) }) describe('_isFullIndex', function () { - beforeEach(function () { - this.fakeProject = { owner_ref: (this.owner_ref = 'owner-ref-123') } - this.owner = { + beforeEach(function (ctx) { + ctx.fakeProject = { owner_ref: (ctx.owner_ref = 'owner-ref-123') } + ctx.owner = { features: { references: false, }, } - this.UserGetter.getUser = sinon.stub() - this.UserGetter.getUser - .withArgs(this.owner_ref, { features: true }) - .yields(null, this.owner) - return (this.call = callback => { - return this.handler._isFullIndex(this.fakeProject, callback) - }) + ctx.UserGetter.getUser = sinon.stub() + ctx.UserGetter.getUser + .withArgs(ctx.owner_ref, { features: true }) + .yields(null, ctx.owner) + ctx.call = callback => { + ctx.handler._isFullIndex(ctx.fakeProject, callback) + } }) describe('with references feature on', function () { - beforeEach(function () { - return (this.owner.features.references = true) + beforeEach(function (ctx) { + ctx.owner.features.references = true }) - it('should return true', function () { - return this.call((err, isFullIndex) => { + it('should return true', function (ctx) { + ctx.call((err, isFullIndex) => { expect(err).to.equal(null) - return expect(isFullIndex).to.equal(true) + expect(isFullIndex).to.equal(true) }) }) }) describe('with references feature off', function () { - beforeEach(function () { - return (this.owner.features.references = false) + beforeEach(function (ctx) { + ctx.owner.features.references = false }) - it('should return false', function () { - return this.call((err, isFullIndex) => { + it('should return false', function (ctx) { + ctx.call((err, isFullIndex) => { expect(err).to.equal(null) - return expect(isFullIndex).to.equal(false) + expect(isFullIndex).to.equal(false) }) }) }) describe('with referencesSearch', function () { - beforeEach(function () { - return (this.owner.features = { + beforeEach(function (ctx) { + ctx.owner.features = { referencesSearch: true, references: false, - }) + } }) - it('should return true', function () { - return this.call((err, isFullIndex) => { + it('should return true', function (ctx) { + ctx.call((err, isFullIndex) => { expect(err).to.equal(null) - return expect(isFullIndex).to.equal(true) + expect(isFullIndex).to.equal(true) }) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs index c1ce6733ca..30301ec8cc 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs @@ -1,68 +1,68 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Subscription/SubscriptionGroupController' describe('SubscriptionGroupController', function () { - beforeEach(async function () { - this.user = { _id: '!@312431', email: 'user@email.com' } - this.adminUserId = '123jlkj' - this.subscriptionId = '123434325412' - this.user_email = 'bob@gmail.com' - this.req = { + beforeEach(async function (ctx) { + ctx.user = { _id: '!@312431', email: 'user@email.com' } + ctx.adminUserId = '123jlkj' + ctx.subscriptionId = '123434325412' + ctx.user_email = 'bob@gmail.com' + ctx.req = { session: { user: { - _id: this.adminUserId, - email: this.user_email, + _id: ctx.adminUserId, + email: ctx.user_email, }, }, params: { - subscriptionId: this.subscriptionId, + subscriptionId: ctx.subscriptionId, }, query: {}, } - this.subscription = { - _id: this.subscriptionId, + ctx.subscription = { + _id: ctx.subscriptionId, teamName: 'Cool group', groupPlan: true, membersLimit: 5, } - this.plan = { + ctx.plan = { canUseFlexibleLicensing: true, } - this.recurlySubscription = { + ctx.recurlySubscription = { get isCollectionMethodManual() { return true }, } - this.previewSubscriptionChangeData = { + ctx.previewSubscriptionChangeData = { change: {}, currency: 'USD', } - this.createSubscriptionChangeData = { adding: 1 } + ctx.createSubscriptionChangeData = { adding: 1 } - this.paymentMethod = { cardType: 'Visa', lastFour: '1111' } + ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' } - this.SubscriptionGroupHandler = { + ctx.SubscriptionGroupHandler = { promises: { removeUserFromGroup: sinon.stub().resolves(), getUsersGroupSubscriptionDetails: sinon.stub().resolves({ - subscription: this.subscription, - plan: this.plan, - recurlySubscription: this.recurlySubscription, + subscription: ctx.subscription, + plan: ctx.plan, + recurlySubscription: ctx.recurlySubscription, }), previewAddSeatsSubscriptionChange: sinon .stub() - .resolves(this.previewSubscriptionChangeData), + .resolves(ctx.previewSubscriptionChangeData), createAddSeatsSubscriptionChange: sinon .stub() - .resolves(this.createSubscriptionChangeData), + .resolves(ctx.createSubscriptionChangeData), ensureFlexibleLicensingEnabled: sinon.stub().resolves(), ensureSubscriptionIsActive: sinon.stub().resolves(), ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(), @@ -70,19 +70,19 @@ describe('SubscriptionGroupController', function () { ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(), getGroupPlanUpgradePreview: sinon .stub() - .resolves(this.previewSubscriptionChangeData), - checkBillingInfoExistence: sinon.stub().resolves(this.paymentMethod), + .resolves(ctx.previewSubscriptionChangeData), + checkBillingInfoExistence: sinon.stub().resolves(ctx.paymentMethod), updateSubscriptionPaymentTerms: sinon.stub().resolves(), }, } - this.SubscriptionLocator = { + ctx.SubscriptionLocator = { promises: { - getSubscription: sinon.stub().resolves(this.subscription), + getSubscription: sinon.stub().resolves(ctx.subscription), }, } - this.SessionManager = { + ctx.SessionManager = { getLoggedInUserId(session) { return session.user._id }, @@ -91,13 +91,13 @@ describe('SubscriptionGroupController', function () { }, } - this.UserAuditLogHandler = { + ctx.UserAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, } - this.Modules = { + ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves(), @@ -105,35 +105,35 @@ describe('SubscriptionGroupController', function () { }, } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({ variant: 'enabled' }), }, } - this.UserGetter = { + ctx.UserGetter = { promises: { - getUserEmail: sinon.stub().resolves(this.user.email), + getUserEmail: sinon.stub().resolves(ctx.user.email), }, } - this.paymentMethod = { cardType: 'Visa', lastFour: '1111' } + ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' } - this.RecurlyClient = { + ctx.RecurlyClient = { promises: { - getPaymentMethod: sinon.stub().resolves(this.paymentMethod), + getPaymentMethod: sinon.stub().resolves(ctx.paymentMethod), }, } - this.SubscriptionController = {} + ctx.SubscriptionController = {} - this.SubscriptionModel = { Subscription: {} } + ctx.SubscriptionModel = { Subscription: {} } - this.PlansHelper = { + ctx.PlansHelper = { isProfessionalGroupPlan: sinon.stub().returns(false), } - this.Errors = { + ctx.Errors = { MissingBillingInfoError: class extends Error {}, ManuallyCollectedError: class extends Error {}, PendingChangeError: class extends Error {}, @@ -142,632 +142,743 @@ describe('SubscriptionGroupController', function () { HasPastDueInvoiceError: class extends Error {}, } - this.Controller = await esmock.strict(modulePath, { - '../../../../app/src/Features/Subscription/SubscriptionGroupHandler': - this.SubscriptionGroupHandler, - '../../../../app/src/Features/Subscription/SubscriptionLocator': - this.SubscriptionLocator, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/Features/User/UserAuditLogHandler': - this.UserAuditLogHandler, - '../../../../app/src/infrastructure/Modules': this.Modules, - '../../../../app/src/Features/SplitTests/SplitTestHandler': - this.SplitTestHandler, - '../../../../app/src/Features/User/UserGetter': this.UserGetter, - '../../../../app/src/Features/Errors/ErrorController': - (this.ErrorController = { - notFound: sinon.stub(), - }), - '../../../../app/src/Features/Subscription/SubscriptionController': - this.SubscriptionController, - '../../../../app/src/Features/Subscription/RecurlyClient': - this.RecurlyClient, - '../../../../app/src/Features/Subscription/PlansHelper': this.PlansHelper, - '../../../../app/src/Features/Subscription/Errors': this.Errors, - '../../../../app/src/models/Subscription': this.SubscriptionModel, - '@overleaf/logger': { + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionGroupHandler', + () => ({ + default: ctx.SubscriptionGroupHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: ctx.SubscriptionLocator, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ + default: ctx.UserAuditLogHandler, + })) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: ctx.Modules, + })) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({ + default: (ctx.ErrorController = { + notFound: sinon.stub(), + }), + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionController', + () => ({ + default: ctx.SubscriptionController, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/RecurlyClient', + () => ({ + default: ctx.RecurlyClient, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/PlansHelper', + () => ctx.PlansHelper + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/Errors', + () => ctx.Errors + ) + + vi.doMock( + '../../../../app/src/models/Subscription', + () => ctx.SubscriptionModel + ) + + vi.doMock('@overleaf/logger', () => ({ + default: { err: sinon.stub(), error: sinon.stub(), warn: sinon.stub(), log: sinon.stub(), debug: sinon.stub(), }, - }) + })) + + ctx.Controller = (await import(modulePath)).default }) describe('removeUserFromGroup', function () { - it('should use the subscription id for the logged in user and take the user id from the params', function (done) { - const userIdToRemove = '31231' - this.req.params = { user_id: userIdToRemove } - this.req.entity = this.subscription + it('should use the subscription id for the logged in user and take the user id from the params', function (ctx) { + return new Promise(resolve => { + const userIdToRemove = '31231' + ctx.req.params = { user_id: userIdToRemove } + ctx.req.entity = ctx.subscription - const res = { - sendStatus: () => { - this.SubscriptionGroupHandler.promises.removeUserFromGroup - .calledWith(this.subscriptionId, userIdToRemove, { - initiatorId: this.req.session.user._id, - ipAddress: this.req.ip, - }) - .should.equal(true) - done() - }, - } - this.Controller.removeUserFromGroup(this.req, res, done) + const res = { + sendStatus: () => { + ctx.SubscriptionGroupHandler.promises.removeUserFromGroup + .calledWith(ctx.subscriptionId, userIdToRemove, { + initiatorId: ctx.req.session.user._id, + ipAddress: ctx.req.ip, + }) + .should.equal(true) + resolve() + }, + } + ctx.Controller.removeUserFromGroup(ctx.req, res, resolve) + }) }) - it('should log that the user has been removed', function (done) { - const userIdToRemove = '31231' - this.req.params = { user_id: userIdToRemove } - this.req.entity = this.subscription + it('should log that the user has been removed', function (ctx) { + return new Promise(resolve => { + const userIdToRemove = '31231' + ctx.req.params = { user_id: userIdToRemove } + ctx.req.entity = ctx.subscription - const res = { - sendStatus: () => { - sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - userIdToRemove, - 'remove-from-group-subscription', - this.adminUserId, - this.req.ip, - { subscriptionId: this.subscriptionId } - ) - done() - }, - } - this.Controller.removeUserFromGroup(this.req, res, done) - }) - - it('should call the group SSO hooks with group SSO enabled', function (done) { - const userIdToRemove = '31231' - this.req.params = { user_id: userIdToRemove } - this.req.entity = this.subscription - this.Modules.promises.hooks.fire - .withArgs('hasGroupSSOEnabled', this.subscription) - .resolves([true]) - - const res = { - sendStatus: () => { - this.Modules.promises.hooks.fire - .calledWith('hasGroupSSOEnabled', this.subscription) - .should.equal(true) - this.Modules.promises.hooks.fire - .calledWith( - 'unlinkUserFromGroupSSO', + const res = { + sendStatus: () => { + sinon.assert.calledWith( + ctx.UserAuditLogHandler.promises.addEntry, userIdToRemove, - this.subscriptionId + 'remove-from-group-subscription', + ctx.adminUserId, + ctx.req.ip, + { subscriptionId: ctx.subscriptionId } ) - .should.equal(true) - sinon.assert.calledTwice(this.Modules.promises.hooks.fire) - done() - }, - } - this.Controller.removeUserFromGroup(this.req, res, done) + resolve() + }, + } + ctx.Controller.removeUserFromGroup(ctx.req, res, resolve) + }) }) - it('should call the group SSO hooks with group SSO disabled', function (done) { - const userIdToRemove = '31231' - this.req.params = { user_id: userIdToRemove } - this.req.entity = this.subscription - this.Modules.promises.hooks.fire - .withArgs('hasGroupSSOEnabled', this.subscription) - .resolves([false]) + it('should call the group SSO hooks with group SSO enabled', function (ctx) { + return new Promise(resolve => { + const userIdToRemove = '31231' + ctx.req.params = { user_id: userIdToRemove } + ctx.req.entity = ctx.subscription + ctx.Modules.promises.hooks.fire + .withArgs('hasGroupSSOEnabled', ctx.subscription) + .resolves([true]) - const res = { - sendStatus: () => { - this.Modules.promises.hooks.fire - .calledWith('hasGroupSSOEnabled', this.subscription) - .should.equal(true) - sinon.assert.calledOnce(this.Modules.promises.hooks.fire) - done() - }, - } - this.Controller.removeUserFromGroup(this.req, res, done) + const res = { + sendStatus: () => { + ctx.Modules.promises.hooks.fire + .calledWith('hasGroupSSOEnabled', ctx.subscription) + .should.equal(true) + ctx.Modules.promises.hooks.fire + .calledWith( + 'unlinkUserFromGroupSSO', + userIdToRemove, + ctx.subscriptionId + ) + .should.equal(true) + sinon.assert.calledTwice(ctx.Modules.promises.hooks.fire) + resolve() + }, + } + ctx.Controller.removeUserFromGroup(ctx.req, res, resolve) + }) + }) + + it('should call the group SSO hooks with group SSO disabled', function (ctx) { + return new Promise(resolve => { + const userIdToRemove = '31231' + ctx.req.params = { user_id: userIdToRemove } + ctx.req.entity = ctx.subscription + ctx.Modules.promises.hooks.fire + .withArgs('hasGroupSSOEnabled', ctx.subscription) + .resolves([false]) + + const res = { + sendStatus: () => { + ctx.Modules.promises.hooks.fire + .calledWith('hasGroupSSOEnabled', ctx.subscription) + .should.equal(true) + sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire) + resolve() + }, + } + ctx.Controller.removeUserFromGroup(ctx.req, res, resolve) + }) }) }) describe('removeSelfFromGroup', function () { - it('gets subscription and remove user', function (done) { - this.req.query = { subscriptionId: this.subscriptionId } - const memberUserIdToremove = 123456789 - this.req.session.user._id = memberUserIdToremove + it('gets subscription and remove user', function (ctx) { + return new Promise(resolve => { + ctx.req.query = { subscriptionId: ctx.subscriptionId } + const memberUserIdToremove = 123456789 + ctx.req.session.user._id = memberUserIdToremove - const res = { - sendStatus: () => { - sinon.assert.calledWith( - this.SubscriptionLocator.promises.getSubscription, - this.subscriptionId - ) - sinon.assert.calledWith( - this.SubscriptionGroupHandler.promises.removeUserFromGroup, - this.subscriptionId, - memberUserIdToremove, - { - initiatorId: this.req.session.user._id, - ipAddress: this.req.ip, - } - ) - done() - }, - } - this.Controller.removeSelfFromGroup(this.req, res, done) - }) - - it('should log that the user has left the subscription', function (done) { - this.req.query = { subscriptionId: this.subscriptionId } - const memberUserIdToremove = '123456789' - this.req.session.user._id = memberUserIdToremove - - const res = { - sendStatus: () => { - sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - memberUserIdToremove, - 'remove-from-group-subscription', - memberUserIdToremove, - this.req.ip, - { subscriptionId: this.subscriptionId } - ) - done() - }, - } - this.Controller.removeSelfFromGroup(this.req, res, done) - }) - - it('should call the group SSO hooks with group SSO enabled', function (done) { - this.req.query = { subscriptionId: this.subscriptionId } - const memberUserIdToremove = '123456789' - this.req.session.user._id = memberUserIdToremove - - this.Modules.promises.hooks.fire - .withArgs('hasGroupSSOEnabled', this.subscription) - .resolves([true]) - - const res = { - sendStatus: () => { - this.Modules.promises.hooks.fire - .calledWith('hasGroupSSOEnabled', this.subscription) - .should.equal(true) - this.Modules.promises.hooks.fire - .calledWith( - 'unlinkUserFromGroupSSO', - memberUserIdToremove, - this.subscriptionId + const res = { + sendStatus: () => { + sinon.assert.calledWith( + ctx.SubscriptionLocator.promises.getSubscription, + ctx.subscriptionId ) - .should.equal(true) - sinon.assert.calledTwice(this.Modules.promises.hooks.fire) - done() - }, - } - this.Controller.removeSelfFromGroup(this.req, res, done) + sinon.assert.calledWith( + ctx.SubscriptionGroupHandler.promises.removeUserFromGroup, + ctx.subscriptionId, + memberUserIdToremove, + { + initiatorId: ctx.req.session.user._id, + ipAddress: ctx.req.ip, + } + ) + resolve() + }, + } + ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve) + }) }) - it('should call the group SSO hooks with group SSO disabled', function (done) { - const userIdToRemove = '31231' - this.req.session.user._id = userIdToRemove - this.req.params = { user_id: userIdToRemove } - this.req.entity = this.subscription - this.Modules.promises.hooks.fire - .withArgs('hasGroupSSOEnabled', this.subscription) - .resolves([false]) + it('should log that the user has left the subscription', function (ctx) { + return new Promise(resolve => { + ctx.req.query = { subscriptionId: ctx.subscriptionId } + const memberUserIdToremove = '123456789' + ctx.req.session.user._id = memberUserIdToremove - const res = { - sendStatus: () => { - this.Modules.promises.hooks.fire - .calledWith('hasGroupSSOEnabled', this.subscription) - .should.equal(true) - sinon.assert.calledOnce(this.Modules.promises.hooks.fire) - done() - }, - } - this.Controller.removeSelfFromGroup(this.req, res, done) + const res = { + sendStatus: () => { + sinon.assert.calledWith( + ctx.UserAuditLogHandler.promises.addEntry, + memberUserIdToremove, + 'remove-from-group-subscription', + memberUserIdToremove, + ctx.req.ip, + { subscriptionId: ctx.subscriptionId } + ) + resolve() + }, + } + ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve) + }) + }) + + it('should call the group SSO hooks with group SSO enabled', function (ctx) { + return new Promise(resolve => { + ctx.req.query = { subscriptionId: ctx.subscriptionId } + const memberUserIdToremove = '123456789' + ctx.req.session.user._id = memberUserIdToremove + + ctx.Modules.promises.hooks.fire + .withArgs('hasGroupSSOEnabled', ctx.subscription) + .resolves([true]) + + const res = { + sendStatus: () => { + ctx.Modules.promises.hooks.fire + .calledWith('hasGroupSSOEnabled', ctx.subscription) + .should.equal(true) + ctx.Modules.promises.hooks.fire + .calledWith( + 'unlinkUserFromGroupSSO', + memberUserIdToremove, + ctx.subscriptionId + ) + .should.equal(true) + sinon.assert.calledTwice(ctx.Modules.promises.hooks.fire) + resolve() + }, + } + ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve) + }) + }) + + it('should call the group SSO hooks with group SSO disabled', function (ctx) { + return new Promise(resolve => { + const userIdToRemove = '31231' + ctx.req.session.user._id = userIdToRemove + ctx.req.params = { user_id: userIdToRemove } + ctx.req.entity = ctx.subscription + ctx.Modules.promises.hooks.fire + .withArgs('hasGroupSSOEnabled', ctx.subscription) + .resolves([false]) + + const res = { + sendStatus: () => { + ctx.Modules.promises.hooks.fire + .calledWith('hasGroupSSOEnabled', ctx.subscription) + .should.equal(true) + sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire) + resolve() + }, + } + ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve) + }) }) }) describe('addSeatsToGroupSubscription', function () { - it('should render the "add seats" page', function (done) { - const res = { - render: (page, props) => { - this.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails - .calledWith(this.req.session.user._id) - .should.equal(true) - this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled - .calledWith(this.plan) - .should.equal(true) - this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges - .calledWith(this.recurlySubscription) - .should.equal(true) - this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive - .calledWith(this.subscription) - .should.equal(true) - this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice - .calledWith(this.subscription) - .should.equal(true) - this.SubscriptionGroupHandler.promises.checkBillingInfoExistence - .calledWith(this.recurlySubscription, this.adminUserId) - .should.equal(true) - page.should.equal('subscriptions/add-seats') - props.subscriptionId.should.equal(this.subscriptionId) - props.groupName.should.equal(this.subscription.teamName) - props.totalLicenses.should.equal(this.subscription.membersLimit) - props.isProfessional.should.equal(false) - props.isCollectionMethodManual.should.equal(true) - done() - }, - } + it('should render the "add seats" page', function (ctx) { + return new Promise((resolve, reject) => { + const res = { + render: (page, props) => { + ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails + .calledWith(ctx.req.session.user._id) + .should.equal(true) + ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled + .calledWith(ctx.plan) + .should.equal(true) + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges + .calledWith(ctx.recurlySubscription) + .should.equal(true) + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive + .calledWith(ctx.subscription) + .should.equal(true) + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice + .calledWith(ctx.subscription) + .should.equal(true) + ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence + .calledWith(ctx.recurlySubscription, ctx.adminUserId) + .should.equal(true) + page.should.equal('subscriptions/add-seats') + props.subscriptionId.should.equal(ctx.subscriptionId) + props.groupName.should.equal(ctx.subscription.teamName) + props.totalLicenses.should.equal(ctx.subscription.membersLimit) + props.isProfessional.should.equal(false) + props.isCollectionMethodManual.should.equal(true) + resolve() + }, + } - this.Controller.addSeatsToGroupSubscription(this.req, res) + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) }) - it('should redirect to subscription page when getting subscription details fails', function (done) { - this.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails = + it('should redirect to subscription page when getting subscription details fails', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails = + sinon.stub().rejects() + + const res = { + redirect: url => { + url.should.equal('/user/subscription') + resolve() + }, + } + + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) + }) + + it('should redirect to subscription page when flexible licensing is not enabled', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled = + sinon.stub().rejects() + + const res = { + redirect: url => { + url.should.equal('/user/subscription') + resolve() + }, + } + + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) + }) + + it('should redirect to missing billing information page when billing information is missing', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon + .stub() + .throws(new ctx.Errors.MissingBillingInfoError()) + + const res = { + redirect: url => { + url.should.equal( + '/user/subscription/group/missing-billing-information' + ) + resolve() + }, + } + + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) + }) + + it('should redirect to subscription page when there is a pending change', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges = + sinon.stub().throws(new ctx.Errors.PendingChangeError()) + + const res = { + redirect: url => { + url.should.equal('/user/subscription') + resolve() + }, + } + + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) + }) + + it('should redirect to subscription page when subscription is not active', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon + .stub() + .rejects() + + const res = { + redirect: url => { + url.should.equal('/user/subscription') + resolve() + }, + } + + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) + }) + + it('should redirect to subscription page when subscription has pending invoice', function (ctx) { + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice = sinon.stub().rejects() + return new Promise(resolve => { + const res = { + redirect: url => { + url.should.equal('/user/subscription') + resolve() + }, + } - const res = { - redirect: url => { - url.should.equal('/user/subscription') - done() - }, - } - - this.Controller.addSeatsToGroupSubscription(this.req, res) - }) - - it('should redirect to subscription page when flexible licensing is not enabled', function (done) { - this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled = - sinon.stub().rejects() - - const res = { - redirect: url => { - url.should.equal('/user/subscription') - done() - }, - } - - this.Controller.addSeatsToGroupSubscription(this.req, res) - }) - - it('should redirect to missing billing information page when billing information is missing', function (done) { - this.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon - .stub() - .throws(new this.Errors.MissingBillingInfoError()) - - const res = { - redirect: url => { - url.should.equal( - '/user/subscription/group/missing-billing-information' - ) - done() - }, - } - - this.Controller.addSeatsToGroupSubscription(this.req, res) - }) - - it('should redirect to subscription page when there is a pending change', function (done) { - this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges = - sinon.stub().throws(new this.Errors.PendingChangeError()) - - const res = { - redirect: url => { - url.should.equal('/user/subscription') - done() - }, - } - - this.Controller.addSeatsToGroupSubscription(this.req, res) - }) - - it('should redirect to subscription page when subscription is not active', function (done) { - this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon - .stub() - .rejects() - - const res = { - redirect: url => { - url.should.equal('/user/subscription') - done() - }, - } - - this.Controller.addSeatsToGroupSubscription(this.req, res) - }) - - it('should redirect to subscription page when subscription has pending invoice', function (done) { - this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice = - sinon.stub().rejects() - - const res = { - redirect: url => { - url.should.equal('/user/subscription') - done() - }, - } - - this.Controller.addSeatsToGroupSubscription(this.req, res) + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) }) }) describe('previewAddSeatsSubscriptionChange', function () { - it('should preview "add seats" change', function (done) { - this.req.body = { adding: 2 } + it('should preview "add seats" change', function (ctx) { + return new Promise(resolve => { + ctx.req.body = { adding: 2 } - const res = { - json: data => { - this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange - .calledWith(this.req.session.user._id, this.req.body.adding) - .should.equal(true) - data.should.deep.equal(this.previewSubscriptionChangeData) - done() - }, - } + const res = { + json: data => { + ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange + .calledWith(ctx.req.session.user._id, ctx.req.body.adding) + .should.equal(true) + data.should.deep.equal(ctx.previewSubscriptionChangeData) + resolve() + }, + } - this.Controller.previewAddSeatsSubscriptionChange(this.req, res) + ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res) + }) }) - it('should fail previewing "add seats" change', function (done) { - this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = - sinon.stub().rejects() + it('should fail previewing "add seats" change', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = + sinon.stub().rejects() - const res = { - status: statusCode => { - statusCode.should.equal(500) + const res = { + status: statusCode => { + statusCode.should.equal(500) - return { - end: () => { - done() - }, - } - }, - } + return { + end: () => { + resolve() + }, + } + }, + } - this.Controller.previewAddSeatsSubscriptionChange(this.req, res) + ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res) + }) }) - it('should fail previewing "add seats" change with SubtotalLimitExceededError', function (done) { - this.req.body = { adding: 2 } - this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = - sinon.stub().throws(new this.Errors.SubtotalLimitExceededError()) + it('should fail previewing "add seats" change with SubtotalLimitExceededError', function (ctx) { + return new Promise(resolve => { + ctx.req.body = { adding: 2 } + ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = + sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError()) - const res = { - status: statusCode => { - statusCode.should.equal(422) + const res = { + status: statusCode => { + statusCode.should.equal(422) - return { - json: data => { - data.should.deep.equal({ - code: 'subtotal_limit_exceeded', - adding: this.req.body.adding, - }) - done() - }, - } - }, - } + return { + json: data => { + data.should.deep.equal({ + code: 'subtotal_limit_exceeded', + adding: ctx.req.body.adding, + }) + resolve() + }, + } + }, + } - this.Controller.previewAddSeatsSubscriptionChange(this.req, res) + ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res) + }) }) }) describe('createAddSeatsSubscriptionChange', function () { - it('should apply "add seats" change', function (done) { - this.req.body = { adding: 2 } + it('should apply "add seats" change', function (ctx) { + return new Promise(resolve => { + ctx.req.body = { adding: 2 } - const res = { - json: data => { - this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange - .calledWith(this.req.session.user._id, this.req.body.adding) - .should.equal(true) - data.should.deep.equal(this.createSubscriptionChangeData) - done() - }, - } + const res = { + json: data => { + ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange + .calledWith(ctx.req.session.user._id, ctx.req.body.adding) + .should.equal(true) + data.should.deep.equal(ctx.createSubscriptionChangeData) + resolve() + }, + } - this.Controller.createAddSeatsSubscriptionChange(this.req, res) + ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) + }) }) - it('should fail applying "add seats" change', function (done) { - this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = - sinon.stub().rejects() + it('should fail applying "add seats" change', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = + sinon.stub().rejects() - const res = { - status: statusCode => { - statusCode.should.equal(500) + const res = { + status: statusCode => { + statusCode.should.equal(500) - return { - end: () => { - done() - }, - } - }, - } + return { + end: () => { + resolve() + }, + } + }, + } - this.Controller.createAddSeatsSubscriptionChange(this.req, res) + ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) + }) }) - it('should fail applying "add seats" change with SubtotalLimitExceededError', function (done) { - this.req.body = { adding: 2 } - this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = - sinon.stub().throws(new this.Errors.SubtotalLimitExceededError()) + it('should fail applying "add seats" change with SubtotalLimitExceededError', function (ctx) { + return new Promise(resolve => { + ctx.req.body = { adding: 2 } + ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = + sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError()) - const res = { - status: statusCode => { - statusCode.should.equal(422) + const res = { + status: statusCode => { + statusCode.should.equal(422) - return { - json: data => { - data.should.deep.equal({ - code: 'subtotal_limit_exceeded', - adding: this.req.body.adding, - }) - done() - }, - } - }, - } + return { + json: data => { + data.should.deep.equal({ + code: 'subtotal_limit_exceeded', + adding: ctx.req.body.adding, + }) + resolve() + }, + } + }, + } - this.Controller.createAddSeatsSubscriptionChange(this.req, res) + ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) + }) }) }) describe('submitForm', function () { - it('should build and pass the request body to the sales submit handler', function (done) { - const adding = 100 - const poNumber = 'PO123456' - this.req.body = { adding, poNumber } + it('should build and pass the request body to the sales submit handler', function (ctx) { + return new Promise(resolve => { + const adding = 100 + const poNumber = 'PO123456' + ctx.req.body = { adding, poNumber } - const res = { - sendStatus: code => { - this.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms( - this.adminUserId, - this.recurlySubscription, - poNumber - ) - this.Modules.promises.hooks.fire - .calledWith('sendSupportRequest', { - email: this.user.email, - subject: 'Sales Contact Form', - message: - '\n' + - '**Overleaf Sales Contact Form:**\n' + - '\n' + - '**Subject:** Self-Serve Group User Increase Request\n' + - '\n' + - `**Estimated Number of Users:** ${adding}\n` + - '\n' + - `**PO Number:** ${poNumber}\n` + - '\n' + - `**Message:** This email has been generated on behalf of user with email **${this.user.email}** to request an increase in the total number of users for their subscription.`, - inbox: 'sales', - }) - .should.equal(true) - sinon.assert.calledOnce(this.Modules.promises.hooks.fire) - code.should.equal(204) - done() - }, - } - this.Controller.submitForm(this.req, res, done) + const res = { + sendStatus: code => { + ctx.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms( + ctx.adminUserId, + ctx.recurlySubscription, + poNumber + ) + ctx.Modules.promises.hooks.fire + .calledWith('sendSupportRequest', { + email: ctx.user.email, + subject: 'Sales Contact Form', + message: + '\n' + + '**Overleaf Sales Contact Form:**\n' + + '\n' + + '**Subject:** Self-Serve Group User Increase Request\n' + + '\n' + + `**Estimated Number of Users:** ${adding}\n` + + '\n' + + `**PO Number:** ${poNumber}\n` + + '\n' + + `**Message:** This email has been generated on behalf of user with email **${ctx.user.email}** to request an increase in the total number of users for their subscription.`, + inbox: 'sales', + }) + .should.equal(true) + sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire) + code.should.equal(204) + resolve() + }, + } + ctx.Controller.submitForm(ctx.req, res, resolve) + }) }) }) describe('subscriptionUpgradePage', function () { - it('should render "subscription upgrade" page', function (done) { - const olSubscription = { membersLimit: 1, teamName: 'test team' } - this.SubscriptionModel.Subscription.findOne = () => { - return { - exec: () => olSubscription, + it('should render "subscription upgrade" page', function (ctx) { + return new Promise(resolve => { + const olSubscription = { membersLimit: 1, teamName: 'test team' } + ctx.SubscriptionModel.Subscription.findOne = () => { + return { + exec: () => olSubscription, + } } - } - const res = { - render: (page, data) => { - this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview - .calledWith(this.req.session.user._id) - .should.equal(true) - page.should.equal('subscriptions/upgrade-group-subscription-react') - data.totalLicenses.should.equal(olSubscription.membersLimit) - data.groupName.should.equal(olSubscription.teamName) - data.changePreview.should.equal(this.previewSubscriptionChangeData) - done() - }, - } + const res = { + render: (page, data) => { + ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview + .calledWith(ctx.req.session.user._id) + .should.equal(true) + page.should.equal('subscriptions/upgrade-group-subscription-react') + data.totalLicenses.should.equal(olSubscription.membersLimit) + data.groupName.should.equal(olSubscription.teamName) + data.changePreview.should.equal(ctx.previewSubscriptionChangeData) + resolve() + }, + } - this.Controller.subscriptionUpgradePage(this.req, res) + ctx.Controller.subscriptionUpgradePage(ctx.req, res) + }) }) - it('should redirect if failed to generate preview', function (done) { - this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon - .stub() - .rejects() + it('should redirect if failed to generate preview', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon + .stub() + .rejects() - const res = { - redirect: url => { - url.should.equal('/user/subscription') - done() - }, - } + const res = { + redirect: url => { + url.should.equal('/user/subscription') + resolve() + }, + } - this.Controller.subscriptionUpgradePage(this.req, res) + ctx.Controller.subscriptionUpgradePage(ctx.req, res) + }) }) - it('should redirect to missing billing information page when billing information is missing', function (done) { - this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon - .stub() - .throws(new this.Errors.MissingBillingInfoError()) + it('should redirect to missing billing information page when billing information is missing', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon + .stub() + .throws(new ctx.Errors.MissingBillingInfoError()) - const res = { - redirect: url => { - url.should.equal( - '/user/subscription/group/missing-billing-information' - ) - done() - }, - } + const res = { + redirect: url => { + url.should.equal( + '/user/subscription/group/missing-billing-information' + ) + resolve() + }, + } - this.Controller.subscriptionUpgradePage(this.req, res) + ctx.Controller.subscriptionUpgradePage(ctx.req, res) + }) }) - it('should redirect to manually collected subscription error page when collection method is manual', function (done) { - this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon - .stub() - .throws(new this.Errors.ManuallyCollectedError()) + it('should redirect to manually collected subscription error page when collection method is manual', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon + .stub() + .throws(new ctx.Errors.ManuallyCollectedError()) - const res = { - redirect: url => { - url.should.equal( - '/user/subscription/group/manually-collected-subscription' - ) - done() - }, - } + const res = { + redirect: url => { + url.should.equal( + '/user/subscription/group/manually-collected-subscription' + ) + resolve() + }, + } - this.Controller.subscriptionUpgradePage(this.req, res) + ctx.Controller.subscriptionUpgradePage(ctx.req, res) + }) }) - it('should redirect to subtotal limit exceeded page', function (done) { - this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon - .stub() - .throws(new this.Errors.SubtotalLimitExceededError()) + it('should redirect to subtotal limit exceeded page', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon + .stub() + .throws(new ctx.Errors.SubtotalLimitExceededError()) - const res = { - redirect: url => { - url.should.equal('/user/subscription/group/subtotal-limit-exceeded') - done() - }, - } + const res = { + redirect: url => { + url.should.equal('/user/subscription/group/subtotal-limit-exceeded') + resolve() + }, + } - this.Controller.subscriptionUpgradePage(this.req, res) + ctx.Controller.subscriptionUpgradePage(ctx.req, res) + }) }) }) describe('upgradeSubscription', function () { - it('should send 200 response', function (done) { - this.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon - .stub() - .resolves() + it('should send 200 response', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon + .stub() + .resolves() - const res = { - sendStatus: code => { - code.should.equal(200) - done() - }, - } + const res = { + sendStatus: code => { + code.should.equal(200) + resolve() + }, + } - this.Controller.upgradeSubscription(this.req, res) + ctx.Controller.upgradeSubscription(ctx.req, res) + }) }) - it('should send 500 response', function (done) { - this.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon - .stub() - .rejects() + it('should send 500 response', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon + .stub() + .rejects() - const res = { - sendStatus: code => { - code.should.equal(500) - done() - }, - } + const res = { + sendStatus: code => { + code.should.equal(500) + resolve() + }, + } - this.Controller.upgradeSubscription(this.req, res) + ctx.Controller.upgradeSubscription(ctx.req, res) + }) }) }) }) diff --git a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs index 3a1e8c3462..b72a406ac0 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs +++ b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs @@ -1,20 +1,20 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' const modulePath = '../../../../app/src/Features/Subscription/TeamInvitesController' describe('TeamInvitesController', function () { - beforeEach(async function () { - this.user = { _id: '!@312431', email: 'user@email.com' } - this.adminUserId = '123jlkj' - this.subscriptionId = '123434325412' - this.user_email = 'bob@gmail.com' - this.req = { + beforeEach(async function (ctx) { + ctx.user = { _id: '!@312431', email: 'user@email.com' } + ctx.adminUserId = '123jlkj' + ctx.subscriptionId = '123434325412' + ctx.user_email = 'bob@gmail.com' + ctx.req = { session: { user: { - _id: this.adminUserId, - email: this.user_email, + _id: ctx.adminUserId, + email: ctx.user_email, }, }, params: {}, @@ -22,33 +22,33 @@ describe('TeamInvitesController', function () { ip: '0.0.0.0', } - this.subscription = { - _id: this.subscriptionId, + ctx.subscription = { + _id: ctx.subscriptionId, } - this.TeamInvitesHandler = { + ctx.TeamInvitesHandler = { promises: { - acceptInvite: sinon.stub().resolves(this.subscription), + acceptInvite: sinon.stub().resolves(ctx.subscription), getInvite: sinon.stub().resolves({ invite: { - email: this.user.email, + email: ctx.user.email, token: 'token123', - inviterName: this.user_email, + inviterName: ctx.user_email, }, - subscription: this.subscription, + subscription: ctx.subscription, }), }, } - this.SubscriptionLocator = { + ctx.SubscriptionLocator = { promises: { hasSSOEnabled: sinon.stub().resolves(true), getUsersSubscription: sinon.stub().resolves(), }, } - this.ErrorController = { notFound: sinon.stub() } + ctx.ErrorController = { notFound: sinon.stub() } - this.SessionManager = { + ctx.SessionManager = { getLoggedInUserId(session) { return session.user?._id }, @@ -57,74 +57,112 @@ describe('TeamInvitesController', function () { }, } - this.UserAuditLogHandler = { + ctx.UserAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, } - this.UserGetter = { + ctx.UserGetter = { promises: { - getUser: sinon.stub().resolves(this.user), - getUserByMainEmail: sinon.stub().resolves(this.user), - getUserByAnyEmail: sinon.stub().resolves(this.user), + getUser: sinon.stub().resolves(ctx.user), + getUserByMainEmail: sinon.stub().resolves(ctx.user), + getUserByAnyEmail: sinon.stub().resolves(ctx.user), }, } - this.EmailHandler = { + ctx.EmailHandler = { sendDeferredEmail: sinon.stub().resolves(), } - this.RateLimiter = { + ctx.RateLimiter = { RateLimiter: class {}, } - this.Controller = await esmock.strict(modulePath, { - '../../../../app/src/Features/Subscription/TeamInvitesHandler': - this.TeamInvitesHandler, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/Features/Subscription/SubscriptionLocator': - this.SubscriptionLocator, - '../../../../app/src/Features/User/UserAuditLogHandler': - this.UserAuditLogHandler, - '../../../../app/src/Features/Errors/ErrorController': - this.ErrorController, - '../../../../app/src/Features/User/UserGetter': this.UserGetter, - '../../../../app/src/Features/Email/EmailHandler': this.EmailHandler, - '../../../../app/src/infrastructure/RateLimiter': this.RateLimiter, - '../../../../app/src/infrastructure/Modules': (this.Modules = { + vi.doMock( + '../../../../app/src/Features/Subscription/TeamInvitesHandler', + () => ({ + default: ctx.TeamInvitesHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: ctx.SubscriptionLocator, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ + default: ctx.UserAuditLogHandler, + })) + + vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({ + default: ctx.ErrorController, + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({ + default: ctx.EmailHandler, + })) + + vi.doMock( + '../../../../app/src/infrastructure/RateLimiter', + () => ctx.RateLimiter + ) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: (ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves([]), }, }, }), - '../../../../app/src/Features/SplitTests/SplitTestHandler': - (this.SplitTestHandler = { + })) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: (ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({}), }, }), - }) + }) + ) + + ctx.Controller = (await import(modulePath)).default }) describe('acceptInvite', function () { - it('should add an audit log entry', function (done) { - this.req.params.token = 'foo' - this.req.session.user = this.user - const res = { - json: () => { - sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - this.user._id, - 'accept-group-invitation', - this.user._id, - this.req.ip, - { subscriptionId: this.subscriptionId } - ) - done() - }, - } - this.Controller.acceptInvite(this.req, res) + it('should add an audit log entry', function (ctx) { + return new Promise(resolve => { + ctx.req.params.token = 'foo' + ctx.req.session.user = ctx.user + const res = { + json: () => { + sinon.assert.calledWith( + ctx.UserAuditLogHandler.promises.addEntry, + ctx.user._id, + 'accept-group-invitation', + ctx.user._id, + ctx.req.ip, + { subscriptionId: ctx.subscriptionId } + ) + resolve() + }, + } + ctx.Controller.acceptInvite(ctx.req, res) + }) }) }) @@ -138,90 +176,102 @@ describe('TeamInvitesController', function () { } describe('hasIndividualRecurlySubscription', function () { - it('is true for personal subscription', function (done) { - this.SubscriptionLocator.promises.getUsersSubscription.resolves({ - recurlySubscription_id: 'subscription123', - groupPlan: false, + it('is true for personal subscription', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({ + recurlySubscription_id: 'subscription123', + groupPlan: false, + }) + const res = { + render: (template, data) => { + expect(data.hasIndividualRecurlySubscription).to.be.true + resolve() + }, + } + ctx.Controller.viewInvite(req, res) }) - const res = { - render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.true - done() - }, - } - this.Controller.viewInvite(req, res) }) - it('is true for group subscriptions', function (done) { - this.SubscriptionLocator.promises.getUsersSubscription.resolves({ - recurlySubscription_id: 'subscription123', - groupPlan: true, + it('is true for group subscriptions', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({ + recurlySubscription_id: 'subscription123', + groupPlan: true, + }) + const res = { + render: (template, data) => { + expect(data.hasIndividualRecurlySubscription).to.be.false + resolve() + }, + } + ctx.Controller.viewInvite(req, res) }) - const res = { - render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.false - done() - }, - } - this.Controller.viewInvite(req, res) }) - it('is false for canceled subscriptions', function (done) { - this.SubscriptionLocator.promises.getUsersSubscription.resolves({ - recurlySubscription_id: 'subscription123', - groupPlan: false, - recurlyStatus: { - state: 'canceled', - }, + it('is false for canceled subscriptions', function (ctx) { + return new Promise(resolve => { + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({ + recurlySubscription_id: 'subscription123', + groupPlan: false, + recurlyStatus: { + state: 'canceled', + }, + }) + const res = { + render: (template, data) => { + expect(data.hasIndividualRecurlySubscription).to.be.false + resolve() + }, + } + ctx.Controller.viewInvite(req, res) }) - const res = { - render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.false - done() - }, - } - this.Controller.viewInvite(req, res) }) }) describe('when user is logged out', function () { - it('renders logged out invite page', function (done) { - const res = { - render: (template, data) => { - expect(template).to.equal('subscriptions/team/invite_logged_out') - expect(data.groupSSOActive).to.be.undefined - done() - }, - } - this.Controller.viewInvite( - { params: { token: 'token123' }, session: {} }, - res - ) + it('renders logged out invite page', function (ctx) { + return new Promise(resolve => { + const res = { + render: (template, data) => { + expect(template).to.equal('subscriptions/team/invite_logged_out') + expect(data.groupSSOActive).to.be.undefined + resolve() + }, + } + ctx.Controller.viewInvite( + { params: { token: 'token123' }, session: {} }, + res + ) + }) }) - it('includes groupSSOActive flag when the group has SSO enabled', function (done) { - this.Modules.promises.hooks.fire = sinon.stub().resolves([true]) - const res = { - render: (template, data) => { - expect(data.groupSSOActive).to.be.true - done() - }, - } - this.Controller.viewInvite( - { params: { token: 'token123' }, session: {} }, - res - ) + it('includes groupSSOActive flag when the group has SSO enabled', function (ctx) { + return new Promise(resolve => { + ctx.Modules.promises.hooks.fire = sinon.stub().resolves([true]) + const res = { + render: (template, data) => { + expect(data.groupSSOActive).to.be.true + resolve() + }, + } + ctx.Controller.viewInvite( + { params: { token: 'token123' }, session: {} }, + res + ) + }) }) }) - it('renders the view', function (done) { - const res = { - render: template => { - expect(template).to.equal('subscriptions/team/invite') - done() - }, - } - this.Controller.viewInvite(req, res) + it('renders the view', function (ctx) { + return new Promise(resolve => { + const res = { + render: template => { + expect(template).to.equal('subscriptions/team/invite') + resolve() + }, + } + ctx.Controller.viewInvite(req, res) + }) }) }) }) diff --git a/services/web/test/unit/src/Tags/TagsController.test.mjs b/services/web/test/unit/src/Tags/TagsController.test.mjs index 4474ba0d38..927c6283a5 100644 --- a/services/web/test/unit/src/Tags/TagsController.test.mjs +++ b/services/web/test/unit/src/Tags/TagsController.test.mjs @@ -1,17 +1,14 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import { assert } from 'chai' -const modulePath = new URL( - '../../../../app/src/Features/Tags/TagsController.mjs', - import.meta.url -).pathname +const modulePath = '../../../../app/src/Features/Tags/TagsController.mjs' describe('TagsController', function () { const userId = '123nd3ijdks' const projectId = '123njdskj9jlk' - beforeEach(async function () { - this.TagsHandler = { + beforeEach(async function (ctx) { + ctx.TagsHandler = { promises: { addProjectToTag: sinon.stub().resolves(), addProjectsToTag: sinon.stub().resolves(), @@ -23,17 +20,25 @@ describe('TagsController', function () { createTag: sinon.stub().resolves(), }, } - this.SessionManager = { + ctx.SessionManager = { getLoggedInUserId: session => { return session.user._id }, } - this.TagsController = await esmock.strict(modulePath, { - '../../../../app/src/Features/Tags/TagsHandler': this.TagsHandler, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - }) - this.req = { + + vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({ + default: ctx.TagsHandler, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + ctx.TagsController = (await import(modulePath)).default + ctx.req = { params: { projectId, }, @@ -45,149 +50,235 @@ describe('TagsController', function () { body: {}, } - this.res = {} - this.res.status = sinon.stub().returns(this.res) - this.res.end = sinon.stub() - this.res.json = sinon.stub() + ctx.res = {} + ctx.res.status = sinon.stub().returns(ctx.res) + ctx.res.end = sinon.stub() + ctx.res.json = sinon.stub() }) - it('get all tags', function (done) { - const allTags = [{ name: 'tag', projects: ['123423', '423423'] }] - this.TagsHandler.promises.getAllTags = sinon.stub().resolves(allTags) - this.TagsController.getAllTags(this.req, { - json: body => { - body.should.equal(allTags) - sinon.assert.calledWith(this.TagsHandler.promises.getAllTags, userId) - done() - return { - end: () => {}, - } - }, + it('get all tags', function (ctx) { + return new Promise(resolve => { + const allTags = [{ name: 'tag', projects: ['123423', '423423'] }] + ctx.TagsHandler.promises.getAllTags = sinon.stub().resolves(allTags) + ctx.TagsController.getAllTags(ctx.req, { + json: body => { + body.should.equal(allTags) + sinon.assert.calledWith(ctx.TagsHandler.promises.getAllTags, userId) + resolve() + return { + end: () => {}, + } + }, + }) }) }) describe('create a tag', function (done) { - it('without a color', function (done) { - this.tag = { mock: 'tag' } - this.TagsHandler.promises.createTag = sinon.stub().resolves(this.tag) - this.req.session.user._id = this.userId = 'user-id-123' - this.req.body = { name: (this.name = 'tag-name') } - this.TagsController.createTag(this.req, { - json: () => { - sinon.assert.calledWith( - this.TagsHandler.promises.createTag, - this.userId, - this.name - ) - done() - return { - end: () => {}, - } - }, + it('without a color', function (ctx) { + return new Promise(resolve => { + ctx.tag = { mock: 'tag' } + ctx.TagsHandler.promises.createTag = sinon.stub().resolves(ctx.tag) + ctx.req.session.user._id = ctx.userId = 'user-id-123' + ctx.req.body = { name: (ctx.tagName = 'tag-name') } + ctx.TagsController.createTag(ctx.req, { + json: () => { + sinon.assert.calledWith( + ctx.TagsHandler.promises.createTag, + ctx.userId, + ctx.tagName + ) + resolve() + return { + end: () => {}, + } + }, + }) }) }) - it('with a color', function (done) { - this.tag = { mock: 'tag' } - this.TagsHandler.promises.createTag = sinon.stub().resolves(this.tag) - this.req.session.user._id = this.userId = 'user-id-123' - this.req.body = { - name: (this.name = 'tag-name'), - color: (this.color = '#123456'), - } - this.TagsController.createTag(this.req, { - json: () => { - sinon.assert.calledWith( - this.TagsHandler.promises.createTag, - this.userId, - this.name, - this.color - ) - done() - return { - end: () => {}, - } - }, + it('with a color', function (ctx) { + return new Promise(resolve => { + ctx.tag = { mock: 'tag' } + ctx.TagsHandler.promises.createTag = sinon.stub().resolves(ctx.tag) + ctx.req.session.user._id = ctx.userId = 'user-id-123' + ctx.req.body = { + name: (ctx.tagName = 'tag-name'), + color: (ctx.color = '#123456'), + } + ctx.TagsController.createTag(ctx.req, { + json: () => { + sinon.assert.calledWith( + ctx.TagsHandler.promises.createTag, + ctx.userId, + ctx.tagName, + ctx.color + ) + resolve() + return { + end: () => {}, + } + }, + }) }) }) }) - it('delete a tag', function (done) { - this.req.params.tagId = this.tagId = 'tag-id-123' - this.req.session.user._id = this.userId = 'user-id-123' - this.TagsController.deleteTag(this.req, { - status: code => { - assert.equal(code, 204) - sinon.assert.calledWith( - this.TagsHandler.promises.deleteTag, - this.userId, - this.tagId - ) - done() - return { - end: () => {}, - } - }, + it('delete a tag', function (ctx) { + return new Promise(resolve => { + ctx.req.params.tagId = ctx.tagId = 'tag-id-123' + ctx.req.session.user._id = ctx.userId = 'user-id-123' + ctx.TagsController.deleteTag(ctx.req, { + status: code => { + assert.equal(code, 204) + sinon.assert.calledWith( + ctx.TagsHandler.promises.deleteTag, + ctx.userId, + ctx.tagId + ) + resolve() + return { + end: () => {}, + } + }, + }) }) }) describe('edit a tag', function () { - beforeEach(function () { - this.req.params.tagId = this.tagId = 'tag-id-123' - this.req.session.user._id = this.userId = 'user-id-123' + beforeEach(function (ctx) { + ctx.req.params.tagId = ctx.tagId = 'tag-id-123' + ctx.req.session.user._id = ctx.userId = 'user-id-123' }) - it('with a name and no color', function (done) { - this.req.body = { - name: (this.name = 'new-name'), - } - this.TagsController.editTag(this.req, { + it('with a name and no color', function (ctx) { + return new Promise(resolve => { + ctx.req.body = { + name: (ctx.tagName = 'new-name'), + } + ctx.TagsController.editTag(ctx.req, { + status: code => { + assert.equal(code, 204) + sinon.assert.calledWith( + ctx.TagsHandler.promises.editTag, + ctx.userId, + ctx.tagId, + ctx.tagName + ) + resolve() + return { + end: () => {}, + } + }, + }) + }) + }) + + it('with a name and color', function (ctx) { + return new Promise(resolve => { + ctx.req.body = { + name: (ctx.tagName = 'new-name'), + color: (ctx.color = '#FF0011'), + } + ctx.TagsController.editTag(ctx.req, { + status: code => { + assert.equal(code, 204) + sinon.assert.calledWith( + ctx.TagsHandler.promises.editTag, + ctx.userId, + ctx.tagId, + ctx.tagName, + ctx.color + ) + resolve() + return { + end: () => {}, + } + }, + }) + }) + }) + + it('without a name', function (ctx) { + return new Promise(resolve => { + ctx.req.body = { name: undefined } + ctx.TagsController.renameTag(ctx.req, { + status: code => { + assert.equal(code, 400) + sinon.assert.notCalled(ctx.TagsHandler.promises.renameTag) + resolve() + return { + end: () => {}, + } + }, + }) + }) + }) + }) + + it('add a project to a tag', function (ctx) { + return new Promise(resolve => { + ctx.req.params.tagId = ctx.tagId = 'tag-id-123' + ctx.req.params.projectId = ctx.projectId = 'project-id-123' + ctx.req.session.user._id = ctx.userId = 'user-id-123' + ctx.TagsController.addProjectToTag(ctx.req, { status: code => { assert.equal(code, 204) sinon.assert.calledWith( - this.TagsHandler.promises.editTag, - this.userId, - this.tagId, - this.name + ctx.TagsHandler.promises.addProjectToTag, + ctx.userId, + ctx.tagId, + ctx.projectId ) - done() + resolve() return { end: () => {}, } }, }) }) + }) - it('with a name and color', function (done) { - this.req.body = { - name: (this.name = 'new-name'), - color: (this.color = '#FF0011'), - } - this.TagsController.editTag(this.req, { + it('add projects to a tag', function (ctx) { + return new Promise(resolve => { + ctx.req.params.tagId = ctx.tagId = 'tag-id-123' + ctx.req.body.projectIds = ctx.projectIds = [ + 'project-id-123', + 'project-id-234', + ] + ctx.req.session.user._id = ctx.userId = 'user-id-123' + ctx.TagsController.addProjectsToTag(ctx.req, { status: code => { assert.equal(code, 204) sinon.assert.calledWith( - this.TagsHandler.promises.editTag, - this.userId, - this.tagId, - this.name, - this.color + ctx.TagsHandler.promises.addProjectsToTag, + ctx.userId, + ctx.tagId, + ctx.projectIds ) - done() + resolve() return { end: () => {}, } }, }) }) + }) - it('without a name', function (done) { - this.req.body = { name: undefined } - this.TagsController.renameTag(this.req, { + it('remove a project from a tag', function (ctx) { + return new Promise(resolve => { + ctx.req.params.tagId = ctx.tagId = 'tag-id-123' + ctx.req.params.projectId = ctx.projectId = 'project-id-123' + ctx.req.session.user._id = ctx.userId = 'user-id-123' + ctx.TagsController.removeProjectFromTag(ctx.req, { status: code => { - assert.equal(code, 400) - sinon.assert.notCalled(this.TagsHandler.promises.renameTag) - done() + assert.equal(code, 204) + sinon.assert.calledWith( + ctx.TagsHandler.promises.removeProjectFromTag, + ctx.userId, + ctx.tagId, + ctx.projectId + ) + resolve() return { end: () => {}, } @@ -196,93 +287,29 @@ describe('TagsController', function () { }) }) - it('add a project to a tag', function (done) { - this.req.params.tagId = this.tagId = 'tag-id-123' - this.req.params.projectId = this.projectId = 'project-id-123' - this.req.session.user._id = this.userId = 'user-id-123' - this.TagsController.addProjectToTag(this.req, { - status: code => { - assert.equal(code, 204) - sinon.assert.calledWith( - this.TagsHandler.promises.addProjectToTag, - this.userId, - this.tagId, - this.projectId - ) - done() - return { - end: () => {}, - } - }, - }) - }) - - it('add projects to a tag', function (done) { - this.req.params.tagId = this.tagId = 'tag-id-123' - this.req.body.projectIds = this.projectIds = [ - 'project-id-123', - 'project-id-234', - ] - this.req.session.user._id = this.userId = 'user-id-123' - this.TagsController.addProjectsToTag(this.req, { - status: code => { - assert.equal(code, 204) - sinon.assert.calledWith( - this.TagsHandler.promises.addProjectsToTag, - this.userId, - this.tagId, - this.projectIds - ) - done() - return { - end: () => {}, - } - }, - }) - }) - - it('remove a project from a tag', function (done) { - this.req.params.tagId = this.tagId = 'tag-id-123' - this.req.params.projectId = this.projectId = 'project-id-123' - this.req.session.user._id = this.userId = 'user-id-123' - this.TagsController.removeProjectFromTag(this.req, { - status: code => { - assert.equal(code, 204) - sinon.assert.calledWith( - this.TagsHandler.promises.removeProjectFromTag, - this.userId, - this.tagId, - this.projectId - ) - done() - return { - end: () => {}, - } - }, - }) - }) - - it('remove projects from a tag', function (done) { - this.req.params.tagId = this.tagId = 'tag-id-123' - this.req.body.projectIds = this.projectIds = [ - 'project-id-123', - 'project-id-234', - ] - this.req.session.user._id = this.userId = 'user-id-123' - this.TagsController.removeProjectsFromTag(this.req, { - status: code => { - assert.equal(code, 204) - sinon.assert.calledWith( - this.TagsHandler.promises.removeProjectsFromTag, - this.userId, - this.tagId, - this.projectIds - ) - done() - return { - end: () => {}, - } - }, + it('remove projects from a tag', function (ctx) { + return new Promise(resolve => { + ctx.req.params.tagId = ctx.tagId = 'tag-id-123' + ctx.req.body.projectIds = ctx.projectIds = [ + 'project-id-123', + 'project-id-234', + ] + ctx.req.session.user._id = ctx.userId = 'user-id-123' + ctx.TagsController.removeProjectsFromTag(ctx.req, { + status: code => { + assert.equal(code, 204) + sinon.assert.calledWith( + ctx.TagsHandler.promises.removeProjectsFromTag, + ctx.userId, + ctx.tagId, + ctx.projectIds + ) + resolve() + return { + end: () => {}, + } + }, + }) }) }) }) diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs index 4dd72b117f..313f2d2456 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs @@ -1,6 +1,6 @@ +import { vi } from 'vitest' import mongodb from 'mongodb-legacy' import { expect } from 'chai' -import esmock from 'esmock' import sinon from 'sinon' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockResponse from '../helpers/MockResponse.js' @@ -12,498 +12,557 @@ const MODULE_PATH = '../../../../app/src/Features/ThirdPartyDataStore/TpdsController.mjs' describe('TpdsController', function () { - beforeEach(async function () { - this.metadata = { + beforeEach(async function (ctx) { + ctx.metadata = { projectId: new ObjectId(), entityId: new ObjectId(), folderId: new ObjectId(), entityType: 'doc', rev: 2, } - this.TpdsUpdateHandler = { + ctx.TpdsUpdateHandler = { promises: { - newUpdate: sinon.stub().resolves(this.metadata), - deleteUpdate: sinon.stub().resolves(this.metadata.entityId), + newUpdate: sinon.stub().resolves(ctx.metadata), + deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId), createFolder: sinon.stub().resolves(), }, } - this.UpdateMerger = { + ctx.UpdateMerger = { promises: { - mergeUpdate: sinon.stub().resolves(this.metadata), - deleteUpdate: sinon.stub().resolves(this.metadata.entityId), + mergeUpdate: sinon.stub().resolves(ctx.metadata), + deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId), }, } - this.NotificationsBuilder = { + ctx.NotificationsBuilder = { tpdsFileLimit: sinon.stub().returns({ create: sinon.stub() }), } - this.SessionManager = { + ctx.SessionManager = { getLoggedInUserId: sinon.stub().returns('user-id'), } - this.TpdsQueueManager = { + ctx.TpdsQueueManager = { promises: { getQueues: sinon.stub().resolves('queues'), }, } - this.HttpErrorHandler = { + ctx.HttpErrorHandler = { conflict: sinon.stub(), } - this.newProject = { _id: new ObjectId() } - this.ProjectCreationHandler = { - promises: { createBlankProject: sinon.stub().resolves(this.newProject) }, + ctx.newProject = { _id: new ObjectId() } + ctx.ProjectCreationHandler = { + promises: { createBlankProject: sinon.stub().resolves(ctx.newProject) }, } - this.ProjectDetailsHandler = { + ctx.ProjectDetailsHandler = { promises: { generateUniqueName: sinon.stub().resolves('unique'), }, } - this.TpdsController = await esmock.strict(MODULE_PATH, { - '../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler': - this.TpdsUpdateHandler, - '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger': - this.UpdateMerger, - '../../../../app/src/Features/Notifications/NotificationsBuilder': - this.NotificationsBuilder, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/Features/Errors/HttpErrorHandler': - this.HttpErrorHandler, - '../../../../app/src/Features/ThirdPartyDataStore/TpdsQueueManager': - this.TpdsQueueManager, - '../../../../app/src/Features/Project/ProjectCreationHandler': - this.ProjectCreationHandler, - '../../../../app/src/Features/Project/ProjectDetailsHandler': - this.ProjectDetailsHandler, - }) - this.user_id = 'dsad29jlkjas' + vi.doMock( + '../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler', + () => ({ + default: ctx.TpdsUpdateHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger', + () => ({ + default: ctx.UpdateMerger, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsBuilder', + () => ({ + default: ctx.NotificationsBuilder, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({ + default: ctx.HttpErrorHandler, + })) + + vi.doMock( + '../../../../app/src/Features/ThirdPartyDataStore/TpdsQueueManager', + () => ({ + default: ctx.TpdsQueueManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectCreationHandler', + () => ({ + default: ctx.ProjectCreationHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectDetailsHandler', + () => ({ + default: ctx.ProjectDetailsHandler, + }) + ) + + ctx.TpdsController = (await import(MODULE_PATH)).default + + ctx.user_id = 'dsad29jlkjas' }) describe('creating a project', function () { - it('should yield the new projects id', function (done) { - const res = new MockResponse() - const req = new MockRequest() - req.params.user_id = this.user_id - req.body = { projectName: 'foo' } - res.callback = err => { - if (err) done(err) - expect(res.body).to.equal( - JSON.stringify({ projectId: this.newProject._id.toString() }) - ) - expect( - this.ProjectDetailsHandler.promises.generateUniqueName - ).to.have.been.calledWith(this.user_id, 'foo') - expect( - this.ProjectCreationHandler.promises.createBlankProject - ).to.have.been.calledWith( - this.user_id, - 'unique', - {}, - { skipCreatingInTPDS: true } - ) - done() - } - this.TpdsController.createProject(req, res) + it('should yield the new projects id', function (ctx) { + return new Promise(resolve => { + const res = new MockResponse() + const req = new MockRequest() + req.params.user_id = ctx.user_id + req.body = { projectName: 'foo' } + res.callback = err => { + if (err) resolve(err) + expect(res.body).to.equal( + JSON.stringify({ projectId: ctx.newProject._id.toString() }) + ) + expect( + ctx.ProjectDetailsHandler.promises.generateUniqueName + ).to.have.been.calledWith(ctx.user_id, 'foo') + expect( + ctx.ProjectCreationHandler.promises.createBlankProject + ).to.have.been.calledWith( + ctx.user_id, + 'unique', + {}, + { skipCreatingInTPDS: true } + ) + resolve() + } + ctx.TpdsController.createProject(req, res) + }) }) }) describe('getting an update', function () { - beforeEach(function () { - this.projectName = 'projectName' - this.path = '/here.txt' - this.req = { + beforeEach(function (ctx) { + ctx.projectName = 'projectName' + ctx.path = '/here.txt' + ctx.req = { params: { - 0: `${this.projectName}${this.path}`, - user_id: this.user_id, + 0: `${ctx.projectName}${ctx.path}`, + user_id: ctx.user_id, project_id: '', }, headers: { - 'x-update-source': (this.source = 'dropbox'), + 'x-update-source': (ctx.source = 'dropbox'), }, } }) - it('should process the update with the update receiver by name', function (done) { - const res = { - json: payload => { - expect(payload).to.deep.equal({ - status: 'applied', - projectId: this.metadata.projectId.toString(), - entityId: this.metadata.entityId.toString(), - folderId: this.metadata.folderId.toString(), - entityType: this.metadata.entityType, - rev: this.metadata.rev.toString(), - }) - this.TpdsUpdateHandler.promises.newUpdate - .calledWith( - this.user_id, - '', // projectId - this.projectName, - this.path, - this.req, - this.source + it('should process the update with the update receiver by name', function (ctx) { + return new Promise(resolve => { + const res = { + json: payload => { + expect(payload).to.deep.equal({ + status: 'applied', + projectId: ctx.metadata.projectId.toString(), + entityId: ctx.metadata.entityId.toString(), + folderId: ctx.metadata.folderId.toString(), + entityType: ctx.metadata.entityType, + rev: ctx.metadata.rev.toString(), + }) + ctx.TpdsUpdateHandler.promises.newUpdate + .calledWith( + ctx.user_id, + '', // projectId + ctx.projectName, + ctx.path, + ctx.req, + ctx.source + ) + .should.equal(true) + resolve() + }, + } + ctx.TpdsController.mergeUpdate(ctx.req, res) + }) + }) + + it('should indicate in the response when the update was rejected', function (ctx) { + return new Promise(resolve => { + ctx.TpdsUpdateHandler.promises.newUpdate.resolves(null) + const res = { + json: payload => { + expect(payload).to.deep.equal({ status: 'rejected' }) + resolve() + }, + } + ctx.TpdsController.mergeUpdate(ctx.req, res) + }) + }) + + it('should process the update with the update receiver by id', function (ctx) { + return new Promise(resolve => { + const path = '/here.txt' + const req = { + pause() {}, + params: { 0: path, user_id: ctx.user_id, project_id: '123' }, + session: { + destroy() {}, + }, + headers: { + 'x-update-source': (ctx.source = 'dropbox'), + }, + } + const res = { + json: () => { + ctx.TpdsUpdateHandler.promises.newUpdate.should.have.been.calledWith( + ctx.user_id, + '123', + '', // projectName + '/here.txt', + req, + ctx.source ) - .should.equal(true) - done() - }, - } - this.TpdsController.mergeUpdate(this.req, res) - }) - - it('should indicate in the response when the update was rejected', function (done) { - this.TpdsUpdateHandler.promises.newUpdate.resolves(null) - const res = { - json: payload => { - expect(payload).to.deep.equal({ status: 'rejected' }) - done() - }, - } - this.TpdsController.mergeUpdate(this.req, res) - }) - - it('should process the update with the update receiver by id', function (done) { - const path = '/here.txt' - const req = { - pause() {}, - params: { 0: path, user_id: this.user_id, project_id: '123' }, - session: { - destroy() {}, - }, - headers: { - 'x-update-source': (this.source = 'dropbox'), - }, - } - const res = { - json: () => { - this.TpdsUpdateHandler.promises.newUpdate.should.have.been.calledWith( - this.user_id, - '123', - '', // projectName - '/here.txt', - req, - this.source - ) - done() - }, - } - this.TpdsController.mergeUpdate(req, res) - }) - - it('should return a 500 error when the update receiver fails', function (done) { - this.TpdsUpdateHandler.promises.newUpdate.rejects(new Error()) - const res = { - json: sinon.stub(), - } - this.TpdsController.mergeUpdate(this.req, res, err => { - expect(err).to.exist - expect(res.json).not.to.have.been.called - done() + resolve() + }, + } + ctx.TpdsController.mergeUpdate(req, res) }) }) - it('should return a 400 error when the project is too big', function (done) { - this.TpdsUpdateHandler.promises.newUpdate.rejects({ - message: 'project_has_too_many_files', + it('should return a 500 error when the update receiver fails', function (ctx) { + return new Promise(resolve => { + ctx.TpdsUpdateHandler.promises.newUpdate.rejects(new Error()) + const res = { + json: sinon.stub(), + } + ctx.TpdsController.mergeUpdate(ctx.req, res, err => { + expect(err).to.exist + expect(res.json).not.to.have.been.called + resolve() + }) }) - const res = { - sendStatus: status => { - expect(status).to.equal(400) - this.NotificationsBuilder.tpdsFileLimit.should.have.been.calledWith( - this.user_id - ) - done() - }, - } - this.TpdsController.mergeUpdate(this.req, res) }) - it('should return a 429 error when the update receiver fails due to too many requests error', function (done) { - this.TpdsUpdateHandler.promises.newUpdate.rejects( - new Errors.TooManyRequestsError('project on cooldown') - ) - const res = { - sendStatus: status => { - expect(status).to.equal(429) - done() - }, - } - this.TpdsController.mergeUpdate(this.req, res) + it('should return a 400 error when the project is too big', function (ctx) { + return new Promise(resolve => { + ctx.TpdsUpdateHandler.promises.newUpdate.rejects({ + message: 'project_has_too_many_files', + }) + const res = { + sendStatus: status => { + expect(status).to.equal(400) + ctx.NotificationsBuilder.tpdsFileLimit.should.have.been.calledWith( + ctx.user_id + ) + resolve() + }, + } + ctx.TpdsController.mergeUpdate(ctx.req, res) + }) + }) + + it('should return a 429 error when the update receiver fails due to too many requests error', function (ctx) { + return new Promise(resolve => { + ctx.TpdsUpdateHandler.promises.newUpdate.rejects( + new Errors.TooManyRequestsError('project on cooldown') + ) + const res = { + sendStatus: status => { + expect(status).to.equal(429) + resolve() + }, + } + ctx.TpdsController.mergeUpdate(ctx.req, res) + }) }) }) describe('getting a delete update', function () { - it('should process the delete with the update receiver by name', function (done) { - const path = '/projectName/here.txt' - const req = { - params: { 0: path, user_id: this.user_id, project_id: '' }, - session: { - destroy() {}, - }, - headers: { - 'x-update-source': (this.source = 'dropbox'), - }, - } - const res = { - sendStatus: () => { - this.TpdsUpdateHandler.promises.deleteUpdate - .calledWith( - this.user_id, - '', - 'projectName', - '/here.txt', - this.source - ) - .should.equal(true) - done() - }, - } - this.TpdsController.deleteUpdate(req, res) + it('should process the delete with the update receiver by name', function (ctx) { + return new Promise(resolve => { + const path = '/projectName/here.txt' + const req = { + params: { 0: path, user_id: ctx.user_id, project_id: '' }, + session: { + destroy() {}, + }, + headers: { + 'x-update-source': (ctx.source = 'dropbox'), + }, + } + const res = { + sendStatus: () => { + ctx.TpdsUpdateHandler.promises.deleteUpdate + .calledWith( + ctx.user_id, + '', + 'projectName', + '/here.txt', + ctx.source + ) + .should.equal(true) + resolve() + }, + } + ctx.TpdsController.deleteUpdate(req, res) + }) }) - it('should process the delete with the update receiver by id', function (done) { - const path = '/here.txt' - const req = { - params: { 0: path, user_id: this.user_id, project_id: '123' }, - session: { - destroy() {}, - }, - headers: { - 'x-update-source': (this.source = 'dropbox'), - }, - } - const res = { - sendStatus: () => { - this.TpdsUpdateHandler.promises.deleteUpdate.should.have.been.calledWith( - this.user_id, - '123', - '', // projectName - '/here.txt', - this.source - ) - done() - }, - } - this.TpdsController.deleteUpdate(req, res) + it('should process the delete with the update receiver by id', function (ctx) { + return new Promise(resolve => { + const path = '/here.txt' + const req = { + params: { 0: path, user_id: ctx.user_id, project_id: '123' }, + session: { + destroy() {}, + }, + headers: { + 'x-update-source': (ctx.source = 'dropbox'), + }, + } + const res = { + sendStatus: () => { + ctx.TpdsUpdateHandler.promises.deleteUpdate.should.have.been.calledWith( + ctx.user_id, + '123', + '', // projectName + '/here.txt', + ctx.source + ) + resolve() + }, + } + ctx.TpdsController.deleteUpdate(req, res) + }) }) }) describe('updateFolder', function () { - beforeEach(function () { - this.req = { - body: { userId: this.user_id, path: '/abc/def/ghi.txt' }, + beforeEach(function (ctx) { + ctx.req = { + body: { userId: ctx.user_id, path: '/abc/def/ghi.txt' }, } - this.res = { + ctx.res = { json: sinon.stub(), } }) - it("creates a folder if it doesn't exist", function (done) { - const metadata = { - folderId: new ObjectId(), - projectId: new ObjectId(), - path: '/def/ghi.txt', - parentFolderId: new ObjectId(), - } - this.TpdsUpdateHandler.promises.createFolder.resolves(metadata) - this.res.json.callsFake(body => { - expect(body).to.deep.equal({ - entityId: metadata.folderId.toString(), - projectId: metadata.projectId.toString(), - path: metadata.path, - folderId: metadata.parentFolderId.toString(), + it("creates a folder if it doesn't exist", function (ctx) { + return new Promise(resolve => { + const metadata = { + folderId: new ObjectId(), + projectId: new ObjectId(), + path: '/def/ghi.txt', + parentFolderId: new ObjectId(), + } + ctx.TpdsUpdateHandler.promises.createFolder.resolves(metadata) + ctx.res.json.callsFake(body => { + expect(body).to.deep.equal({ + entityId: metadata.folderId.toString(), + projectId: metadata.projectId.toString(), + path: metadata.path, + folderId: metadata.parentFolderId.toString(), + }) + resolve() }) - done() + ctx.TpdsController.updateFolder(ctx.req, ctx.res) }) - this.TpdsController.updateFolder(this.req, this.res) }) - it('supports top level folders', function (done) { - const metadata = { - folderId: new ObjectId(), - projectId: new ObjectId(), - path: '/', - parentFolderId: null, - } - this.TpdsUpdateHandler.promises.createFolder.resolves(metadata) - this.res.json.callsFake(body => { - expect(body).to.deep.equal({ - entityId: metadata.folderId.toString(), - projectId: metadata.projectId.toString(), - path: metadata.path, - folderId: null, + it('supports top level folders', function (ctx) { + return new Promise(resolve => { + const metadata = { + folderId: new ObjectId(), + projectId: new ObjectId(), + path: '/', + parentFolderId: null, + } + ctx.TpdsUpdateHandler.promises.createFolder.resolves(metadata) + ctx.res.json.callsFake(body => { + expect(body).to.deep.equal({ + entityId: metadata.folderId.toString(), + projectId: metadata.projectId.toString(), + path: metadata.path, + folderId: null, + }) + resolve() }) - done() + ctx.TpdsController.updateFolder(ctx.req, ctx.res) }) - this.TpdsController.updateFolder(this.req, this.res) }) - it("returns a 409 if the folder couldn't be created", function (done) { - this.TpdsUpdateHandler.promises.createFolder.resolves(null) - this.HttpErrorHandler.conflict.callsFake((req, res) => { - expect(req).to.equal(this.req) - expect(res).to.equal(this.res) - done() + it("returns a 409 if the folder couldn't be created", function (ctx) { + return new Promise(resolve => { + ctx.TpdsUpdateHandler.promises.createFolder.resolves(null) + ctx.HttpErrorHandler.conflict.callsFake((req, res) => { + expect(req).to.equal(ctx.req) + expect(res).to.equal(ctx.res) + resolve() + }) + ctx.TpdsController.updateFolder(ctx.req, ctx.res) }) - this.TpdsController.updateFolder(this.req, this.res) }) }) describe('parseParams', function () { - it('should take the project name off the start and replace with slash', function () { + it('should take the project name off the start and replace with slash', function (ctx) { const path = 'noSlashHere' - const req = { params: { 0: path, user_id: this.user_id } } - const result = this.TpdsController.parseParams(req) - result.userId.should.equal(this.user_id) + const req = { params: { 0: path, user_id: ctx.user_id } } + const result = ctx.TpdsController.parseParams(req) + result.userId.should.equal(ctx.user_id) result.filePath.should.equal('/') result.projectName.should.equal(path) }) - it('should take the project name off the start and it with no slashes in', function () { + it('should take the project name off the start and it with no slashes in', function (ctx) { const path = '/project/file.tex' - const req = { params: { 0: path, user_id: this.user_id } } - const result = this.TpdsController.parseParams(req) - result.userId.should.equal(this.user_id) + const req = { params: { 0: path, user_id: ctx.user_id } } + const result = ctx.TpdsController.parseParams(req) + result.userId.should.equal(ctx.user_id) result.filePath.should.equal('/file.tex') result.projectName.should.equal('project') }) - it('should take the project name of and return a slash for the file path', function () { + it('should take the project name of and return a slash for the file path', function (ctx) { const path = '/project_name' - const req = { params: { 0: path, user_id: this.user_id } } - const result = this.TpdsController.parseParams(req) + const req = { params: { 0: path, user_id: ctx.user_id } } + const result = ctx.TpdsController.parseParams(req) result.projectName.should.equal('project_name') result.filePath.should.equal('/') }) }) describe('updateProjectContents', function () { - beforeEach(async function () { - this.req = { + beforeEach(async function (ctx) { + ctx.req = { params: { - 0: (this.path = 'chapters/main.tex'), - project_id: (this.project_id = 'project-id-123'), + 0: (ctx.path = 'chapters/main.tex'), + project_id: (ctx.project_id = 'project-id-123'), }, session: { destroy: sinon.stub(), }, headers: { - 'x-update-source': (this.source = 'github'), + 'x-update-source': (ctx.source = 'github'), }, } - this.res = { + ctx.res = { json: sinon.stub(), sendStatus: sinon.stub(), } - await this.TpdsController.promises.updateProjectContents( - this.req, - this.res - ) + await ctx.TpdsController.promises.updateProjectContents(ctx.req, ctx.res) }) - it('should merge the update', function () { - this.UpdateMerger.promises.mergeUpdate.should.be.calledWith( + it('should merge the update', function (ctx) { + ctx.UpdateMerger.promises.mergeUpdate.should.be.calledWith( null, - this.project_id, - `/${this.path}`, - this.req, - this.source + ctx.project_id, + `/${ctx.path}`, + ctx.req, + ctx.source ) }) - it('should return a success', function () { - this.res.json.should.be.calledWith({ - entityId: this.metadata.entityId.toString(), - rev: this.metadata.rev, + it('should return a success', function (ctx) { + ctx.res.json.should.be.calledWith({ + entityId: ctx.metadata.entityId.toString(), + rev: ctx.metadata.rev, }) }) }) describe('deleteProjectContents', function () { - beforeEach(async function () { - this.req = { + beforeEach(async function (ctx) { + ctx.req = { params: { - 0: (this.path = 'chapters/main.tex'), - project_id: (this.project_id = 'project-id-123'), + 0: (ctx.path = 'chapters/main.tex'), + project_id: (ctx.project_id = 'project-id-123'), }, session: { destroy: sinon.stub(), }, headers: { - 'x-update-source': (this.source = 'github'), + 'x-update-source': (ctx.source = 'github'), }, } - this.res = { + ctx.res = { sendStatus: sinon.stub(), json: sinon.stub(), } - await this.TpdsController.promises.deleteProjectContents( - this.req, - this.res - ) + await ctx.TpdsController.promises.deleteProjectContents(ctx.req, ctx.res) }) - it('should delete the file', function () { - this.UpdateMerger.promises.deleteUpdate.should.be.calledWith( + it('should delete the file', function (ctx) { + ctx.UpdateMerger.promises.deleteUpdate.should.be.calledWith( null, - this.project_id, - `/${this.path}`, - this.source + ctx.project_id, + `/${ctx.path}`, + ctx.source ) }) - it('should return a success', function () { - this.res.json.should.be.calledWith({ - entityId: this.metadata.entityId, + it('should return a success', function (ctx) { + ctx.res.json.should.be.calledWith({ + entityId: ctx.metadata.entityId, }) }) }) describe('getQueues', function () { - beforeEach(function () { - this.req = {} - this.res = { json: sinon.stub() } - this.next = sinon.stub() + beforeEach(function (ctx) { + ctx.req = {} + ctx.res = { json: sinon.stub() } + ctx.next = sinon.stub() }) describe('success', function () { - beforeEach(function (done) { - this.res.json.callsFake(() => { - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.json.callsFake(() => { + resolve() + }) + ctx.TpdsController.getQueues(ctx.req, ctx.res, ctx.next) }) - this.TpdsController.getQueues(this.req, this.res, this.next) }) - it('should use userId from session', function () { - this.SessionManager.getLoggedInUserId.should.have.been.calledOnce - this.TpdsQueueManager.promises.getQueues.should.have.been.calledWith( + it('should use userId from session', function (ctx) { + ctx.SessionManager.getLoggedInUserId.should.have.been.calledOnce + ctx.TpdsQueueManager.promises.getQueues.should.have.been.calledWith( 'user-id' ) }) - it('should call json with response', function () { - this.res.json.should.have.been.calledWith('queues') - this.next.should.not.have.been.called + it('should call json with response', function (ctx) { + ctx.res.json.should.have.been.calledWith('queues') + ctx.next.should.not.have.been.called }) }) describe('error', function () { - beforeEach(function (done) { - this.err = new Error() - this.TpdsQueueManager.promises.getQueues = sinon - .stub() - .rejects(this.err) - this.next.callsFake(() => { - done() + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.err = new Error() + ctx.TpdsQueueManager.promises.getQueues = sinon + .stub() + .rejects(ctx.err) + ctx.next.callsFake(() => { + resolve() + }) + ctx.TpdsController.getQueues(ctx.req, ctx.res, ctx.next) }) - this.TpdsController.getQueues(this.req, this.res, this.next) }) - it('should call next with error', function () { - this.res.json.should.not.have.been.called - this.next.should.have.been.calledWith(this.err) + it('should call next with error', function (ctx) { + ctx.res.json.should.not.have.been.called + ctx.next.should.have.been.calledWith(ctx.err) }) }) }) diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs index a5ca099b5b..96cc22279e 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' import mongodb from 'mongodb-legacy' @@ -9,120 +9,158 @@ const ObjectId = mongodb.ObjectId const MODULE_PATH = '../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs' +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + describe('TpdsUpdateHandler', function () { - beforeEach(async function () { - this.projectName = 'My recipes' - this.projects = { - active1: { _id: new ObjectId(), name: this.projectName }, - active2: { _id: new ObjectId(), name: this.projectName }, + beforeEach(async function (ctx) { + ctx.projectName = 'My recipes' + ctx.projects = { + active1: { _id: new ObjectId(), name: ctx.projectName }, + active2: { _id: new ObjectId(), name: ctx.projectName }, archived1: { _id: new ObjectId(), - name: this.projectName, - archived: [this.userId], + name: ctx.projectName, + archived: [ctx.userId], }, archived2: { _id: new ObjectId(), - name: this.projectName, - archived: [this.userId], + name: ctx.projectName, + archived: [ctx.userId], }, } - this.userId = new ObjectId() - this.source = 'dropbox' - this.path = `/some/file` - this.update = {} - this.folderPath = '/some/folder' - this.folder = { + ctx.userId = new ObjectId() + ctx.source = 'dropbox' + ctx.path = `/some/file` + ctx.update = {} + ctx.folderPath = '/some/folder' + ctx.folder = { _id: new ObjectId(), parentFolder_id: new ObjectId(), } - this.CooldownManager = { + ctx.CooldownManager = { promises: { isProjectOnCooldown: sinon.stub().resolves(false), }, } - this.FileTypeManager = { + ctx.FileTypeManager = { promises: { shouldIgnore: sinon.stub().resolves(false), }, } - this.Modules = { + ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves() }, }, } - this.notification = { + ctx.notification = { create: sinon.stub().resolves(), } - this.NotificationsBuilder = { + ctx.NotificationsBuilder = { promises: { - dropboxDuplicateProjectNames: sinon.stub().returns(this.notification), + dropboxDuplicateProjectNames: sinon.stub().returns(ctx.notification), }, } - this.ProjectCreationHandler = { + ctx.ProjectCreationHandler = { promises: { - createBlankProject: sinon.stub().resolves(this.projects.active1), + createBlankProject: sinon.stub().resolves(ctx.projects.active1), }, } - this.ProjectDeleter = { + ctx.ProjectDeleter = { promises: { markAsDeletedByExternalSource: sinon.stub().resolves(), }, } - this.ProjectGetter = { + ctx.ProjectGetter = { promises: { findUsersProjectsByName: sinon.stub(), findAllUsersProjects: sinon .stub() - .resolves({ owned: [this.projects.active1], readAndWrite: [] }), + .resolves({ owned: [ctx.projects.active1], readAndWrite: [] }), }, } - this.ProjectHelper = { + ctx.ProjectHelper = { isArchivedOrTrashed: sinon.stub().returns(false), } - this.ProjectHelper.isArchivedOrTrashed - .withArgs(this.projects.archived1, this.userId) + ctx.ProjectHelper.isArchivedOrTrashed + .withArgs(ctx.projects.archived1, ctx.userId) .returns(true) - this.ProjectHelper.isArchivedOrTrashed - .withArgs(this.projects.archived2, this.userId) + ctx.ProjectHelper.isArchivedOrTrashed + .withArgs(ctx.projects.archived2, ctx.userId) .returns(true) - this.RootDocManager = { + ctx.RootDocManager = { setRootDocAutomaticallyInBackground: sinon.stub(), } - this.UpdateMerger = { + ctx.UpdateMerger = { promises: { deleteUpdate: sinon.stub().resolves(), mergeUpdate: sinon.stub().resolves(), - createFolder: sinon.stub().resolves(this.folder), + createFolder: sinon.stub().resolves(ctx.folder), }, } - this.TpdsUpdateHandler = await esmock.strict(MODULE_PATH, { - '.../../../../app/src/Features/Cooldown/CooldownManager': - this.CooldownManager, - '../../../../app/src/Features/Uploads/FileTypeManager': - this.FileTypeManager, - '../../../../app/src/infrastructure/Modules': this.Modules, - '../../../../app/src/Features/Notifications/NotificationsBuilder': - this.NotificationsBuilder, - '../../../../app/src/Features/Project/ProjectCreationHandler': - this.ProjectCreationHandler, - '../../../../app/src/Features/Project/ProjectDeleter': - this.ProjectDeleter, - '../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter, - '../../../../app/src/Features/Project/ProjectHelper': this.ProjectHelper, - '../../../../app/src/Features/Project/ProjectRootDocManager': - this.RootDocManager, - '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger': - this.UpdateMerger, - }) + vi.doMock('../../../../app/src/Features/Cooldown/CooldownManager', () => ({ + default: ctx.CooldownManager, + })) + + vi.doMock('../../../../app/src/Features/Uploads/FileTypeManager', () => ({ + default: ctx.FileTypeManager, + })) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: ctx.Modules, + })) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsBuilder', + () => ({ + default: ctx.NotificationsBuilder, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectCreationHandler', + () => ({ + default: ctx.ProjectCreationHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectDeleter', () => ({ + default: ctx.ProjectDeleter, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectHelper', () => ({ + default: ctx.ProjectHelper, + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectRootDocManager', + () => ({ + default: ctx.RootDocManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger', + () => ({ + default: ctx.UpdateMerger, + }) + ) + + ctx.TpdsUpdateHandler = (await import(MODULE_PATH)).default }) describe('getting an update', function () { describe('byId', function () { describe('with no matching project', function () { - beforeEach(function () { - this.projectId = new ObjectId().toString() + beforeEach(function (ctx) { + ctx.projectId = new ObjectId().toString() }) receiveUpdateById() expectProjectNotCreated() @@ -130,8 +168,8 @@ describe('TpdsUpdateHandler', function () { }) describe('with one matching active project', function () { - beforeEach(function () { - this.projectId = this.projects.active1._id.toString() + beforeEach(function (ctx) { + ctx.projectId = ctx.projects.active1._id.toString() }) receiveUpdateById() expectProjectNotCreated() @@ -187,8 +225,8 @@ describe('TpdsUpdateHandler', function () { describe('update to a file that should be ignored', async function () { setupMatchingProjects(['active1']) - beforeEach(function () { - this.FileTypeManager.promises.shouldIgnore.resolves(true) + beforeEach(function (ctx) { + ctx.FileTypeManager.promises.shouldIgnore.resolves(true) }) receiveUpdate() expectProjectNotCreated() @@ -199,15 +237,15 @@ describe('TpdsUpdateHandler', function () { describe('update to a project on cooldown', async function () { setupMatchingProjects(['active1']) setupProjectOnCooldown() - beforeEach(async function () { + beforeEach(async function (ctx) { await expect( - this.TpdsUpdateHandler.promises.newUpdate( - this.userId, + ctx.TpdsUpdateHandler.promises.newUpdate( + ctx.userId, '', // projectId - this.projectName, - this.path, - this.update, - this.source + ctx.projectName, + ctx.path, + ctx.update, + ctx.source ) ).to.be.rejectedWith(Errors.TooManyRequestsError) }) @@ -218,8 +256,8 @@ describe('TpdsUpdateHandler', function () { describe('getting a file delete', function () { describe('byId', function () { describe('with no matching project', function () { - beforeEach(function () { - this.projectId = new ObjectId().toString() + beforeEach(function (ctx) { + ctx.projectId = new ObjectId().toString() }) receiveFileDeleteById() expectDeleteNotProcessed() @@ -227,8 +265,8 @@ describe('TpdsUpdateHandler', function () { }) describe('with one matching active project', function () { - beforeEach(function () { - this.projectId = this.projects.active1._id.toString() + beforeEach(function (ctx) { + ctx.projectId = ctx.projects.active1._id.toString() }) receiveFileDeleteById() expectDeleteProcessed() @@ -379,13 +417,13 @@ describe('TpdsUpdateHandler', function () { describe('update to a project on cooldown', async function () { setupMatchingProjects(['active1']) setupProjectOnCooldown() - beforeEach(async function () { + beforeEach(async function (ctx) { await expect( - this.TpdsUpdateHandler.promises.createFolder( - this.userId, - this.projectId, - this.projectName, - this.path + ctx.TpdsUpdateHandler.promises.createFolder( + ctx.userId, + ctx.projectId, + ctx.projectName, + ctx.path ) ).to.be.rejectedWith(Errors.TooManyRequestsError) }) @@ -397,18 +435,18 @@ describe('TpdsUpdateHandler', function () { /* Setup helpers */ function setupMatchingProjects(projectKeys) { - beforeEach(function () { - const projects = projectKeys.map(key => this.projects[key]) - this.ProjectGetter.promises.findUsersProjectsByName - .withArgs(this.userId, this.projectName) + beforeEach(function (ctx) { + const projects = projectKeys.map(key => ctx.projects[key]) + ctx.ProjectGetter.promises.findUsersProjectsByName + .withArgs(ctx.userId, ctx.projectName) .resolves(projects) }) } function setupProjectOnCooldown() { - beforeEach(function () { - this.CooldownManager.promises.isProjectOnCooldown - .withArgs(this.projects.active1._id) + beforeEach(function (ctx) { + ctx.CooldownManager.promises.isProjectOnCooldown + .withArgs(ctx.projects.active1._id) .resolves(true) }) } @@ -416,76 +454,77 @@ function setupProjectOnCooldown() { /* Test helpers */ function receiveUpdate() { - beforeEach(async function () { - await this.TpdsUpdateHandler.promises.newUpdate( - this.userId, + beforeEach(async function (ctx) { + await ctx.TpdsUpdateHandler.promises.newUpdate( + ctx.userId, '', // projectId - this.projectName, - this.path, - this.update, - this.source + ctx.projectName, + ctx.path, + ctx.update, + ctx.source ) }) } function receiveUpdateById() { - beforeEach(function (done) { - this.TpdsUpdateHandler.newUpdate( - this.userId, - this.projectId, + beforeEach(async function (ctx) { + await ctx.TpdsUpdateHandler.promises.newUpdate( + ctx.userId, + ctx.projectId, '', // projectName - this.path, - this.update, - this.source, - done + ctx.path, + ctx.update, + ctx.source ) }) } function receiveFileDelete() { - beforeEach(async function () { - await this.TpdsUpdateHandler.promises.deleteUpdate( - this.userId, + beforeEach(async function (ctx) { + await ctx.TpdsUpdateHandler.promises.deleteUpdate( + ctx.userId, '', // projectId - this.projectName, - this.path, - this.source + ctx.projectName, + ctx.path, + ctx.source ) }) } function receiveFileDeleteById() { - beforeEach(function (done) { - this.TpdsUpdateHandler.deleteUpdate( - this.userId, - this.projectId, - '', // projectName - this.path, - this.source, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.TpdsUpdateHandler.deleteUpdate( + ctx.userId, + ctx.projectId, + '', // projectName + ctx.path, + ctx.source, + resolve + ) + }) }) } function receiveProjectDelete() { - beforeEach(async function () { - await this.TpdsUpdateHandler.promises.deleteUpdate( - this.userId, + beforeEach(async function (ctx) { + await ctx.TpdsUpdateHandler.promises.deleteUpdate( + ctx.userId, '', // projectId - this.projectName, + ctx.projectName, '/', - this.source + ctx.source ) }) } function receiveFolderUpdate() { - beforeEach(async function () { - await this.TpdsUpdateHandler.promises.createFolder( - this.userId, - this.projectId, - this.projectName, - this.folderPath + beforeEach(async function (ctx) { + await ctx.TpdsUpdateHandler.promises.createFolder( + ctx.userId, + ctx.projectId, + ctx.projectName, + ctx.folderPath ) }) } @@ -493,121 +532,121 @@ function receiveFolderUpdate() { /* Expectations */ function expectProjectCreated() { - it('creates a project', function () { + it('creates a project', function (ctx) { expect( - this.ProjectCreationHandler.promises.createBlankProject - ).to.have.been.calledWith(this.userId, this.projectName) + ctx.ProjectCreationHandler.promises.createBlankProject + ).to.have.been.calledWith(ctx.userId, ctx.projectName) }) - it('sets the root doc', function () { + it('sets the root doc', function (ctx) { expect( - this.RootDocManager.setRootDocAutomaticallyInBackground - ).to.have.been.calledWith(this.projects.active1._id) + ctx.RootDocManager.setRootDocAutomaticallyInBackground + ).to.have.been.calledWith(ctx.projects.active1._id) }) } function expectProjectNotCreated() { - it('does not create a project', function () { - expect(this.ProjectCreationHandler.promises.createBlankProject).not.to.have + it('does not create a project', function (ctx) { + expect(ctx.ProjectCreationHandler.promises.createBlankProject).not.to.have .been.called }) - it('does not set the root doc', function () { - expect(this.RootDocManager.setRootDocAutomaticallyInBackground).not.to.have + it('does not set the root doc', function (ctx) { + expect(ctx.RootDocManager.setRootDocAutomaticallyInBackground).not.to.have .been.called }) } function expectUpdateProcessed() { - it('processes the update', function () { - expect(this.UpdateMerger.promises.mergeUpdate).to.have.been.calledWith( - this.userId, - this.projects.active1._id, - this.path, - this.update, - this.source + it('processes the update', function (ctx) { + expect(ctx.UpdateMerger.promises.mergeUpdate).to.have.been.calledWith( + ctx.userId, + ctx.projects.active1._id, + ctx.path, + ctx.update, + ctx.source ) }) } function expectUpdateNotProcessed() { - it('does not process the update', function () { - expect(this.UpdateMerger.promises.mergeUpdate).not.to.have.been.called + it('does not process the update', function (ctx) { + expect(ctx.UpdateMerger.promises.mergeUpdate).not.to.have.been.called }) } function expectFolderUpdateProcessed() { - it('processes the folder update', function () { - expect(this.UpdateMerger.promises.createFolder).to.have.been.calledWith( - this.projects.active1._id, - this.folderPath, - this.userId + it('processes the folder update', function (ctx) { + expect(ctx.UpdateMerger.promises.createFolder).to.have.been.calledWith( + ctx.projects.active1._id, + ctx.folderPath, + ctx.userId ) }) } function expectFolderUpdateNotProcessed() { - it("doesn't process the folder update", function () { - expect(this.UpdateMerger.promises.createFolder).not.to.have.been.called + it("doesn't process the folder update", function (ctx) { + expect(ctx.UpdateMerger.promises.createFolder).not.to.have.been.called }) } function expectDropboxUnlinked() { - it('unlinks Dropbox', function () { - expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + it('unlinks Dropbox', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'removeDropbox', - this.userId, + ctx.userId, 'duplicate-projects' ) }) - it('creates a notification that dropbox was unlinked', function () { + it('creates a notification that dropbox was unlinked', function (ctx) { expect( - this.NotificationsBuilder.promises.dropboxDuplicateProjectNames - ).to.have.been.calledWith(this.userId) - expect(this.notification.create).to.have.been.calledWith(this.projectName) + ctx.NotificationsBuilder.promises.dropboxDuplicateProjectNames + ).to.have.been.calledWith(ctx.userId) + expect(ctx.notification.create).to.have.been.calledWith(ctx.projectName) }) } function expectDropboxNotUnlinked() { - it('does not unlink Dropbox', function () { - expect(this.Modules.promises.hooks.fire).not.to.have.been.called + it('does not unlink Dropbox', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).not.to.have.been.called }) - it('does not create a notification that dropbox was unlinked', function () { - expect(this.NotificationsBuilder.promises.dropboxDuplicateProjectNames).not + it('does not create a notification that dropbox was unlinked', function (ctx) { + expect(ctx.NotificationsBuilder.promises.dropboxDuplicateProjectNames).not .to.have.been.called }) } function expectDeleteProcessed() { - it('processes the delete', function () { - expect(this.UpdateMerger.promises.deleteUpdate).to.have.been.calledWith( - this.userId, - this.projects.active1._id, - this.path, - this.source + it('processes the delete', function (ctx) { + expect(ctx.UpdateMerger.promises.deleteUpdate).to.have.been.calledWith( + ctx.userId, + ctx.projects.active1._id, + ctx.path, + ctx.source ) }) } function expectDeleteNotProcessed() { - it('does not process the delete', function () { - expect(this.UpdateMerger.promises.deleteUpdate).not.to.have.been.called + it('does not process the delete', function (ctx) { + expect(ctx.UpdateMerger.promises.deleteUpdate).not.to.have.been.called }) } function expectProjectDeleted() { - it('deletes the project', function () { + it('deletes the project', function (ctx) { expect( - this.ProjectDeleter.promises.markAsDeletedByExternalSource - ).to.have.been.calledWith(this.projects.active1._id) + ctx.ProjectDeleter.promises.markAsDeletedByExternalSource + ).to.have.been.calledWith(ctx.projects.active1._id) }) } function expectProjectNotDeleted() { - it('does not delete the project', function () { - expect(this.ProjectDeleter.promises.markAsDeletedByExternalSource).not.to + it('does not delete the project', function (ctx) { + expect(ctx.ProjectDeleter.promises.markAsDeletedByExternalSource).not.to .have.been.called }) } diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs index 8097218076..3408c3bb32 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs +++ b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs @@ -1,4 +1,4 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' import mongodb from 'mongodb-legacy' @@ -13,27 +13,27 @@ const MODULE_PATH = '../../../../app/src/Features/TokenAccess/TokenAccessController' describe('TokenAccessController', function () { - beforeEach(async function () { - this.token = 'abc123' - this.user = { _id: new ObjectId() } - this.project = { + beforeEach(async function (ctx) { + ctx.token = 'abc123' + ctx.user = { _id: new ObjectId() } + ctx.project = { _id: new ObjectId(), - owner_ref: this.user._id, + owner_ref: ctx.user._id, name: 'test', tokenAccessReadAndWrite_refs: [], tokenAccessReadOnly_refs: [], } - this.req = new MockRequest() - this.res = new MockResponse() - this.next = sinon.stub().returns() + ctx.req = new MockRequest() + ctx.res = new MockResponse() + ctx.next = sinon.stub().returns() - this.Settings = { + ctx.Settings = { siteUrl: 'https://www.dev-overleaf.com', adminPrivilegeAvailable: false, adminUrl: 'https://admin.dev-overleaf.com', adminDomains: ['overleaf.com'], } - this.TokenAccessHandler = { + ctx.TokenAccessHandler = { TOKEN_TYPES: { READ_ONLY: 'readOnly', READ_AND_WRITE: 'readAndWrite', @@ -46,7 +46,7 @@ describe('TokenAccessController', function () { grantSessionTokenAccess: sinon.stub(), promises: { addReadOnlyUserToProject: sinon.stub().resolves(), - getProjectByToken: sinon.stub().resolves(this.project), + getProjectByToken: sinon.stub().resolves(ctx.project), getV1DocPublishedInfo: sinon.stub().resolves({ allow: true }), getV1DocInfo: sinon.stub(), removeReadAndWriteUserFromProject: sinon.stub().resolves(), @@ -54,16 +54,16 @@ describe('TokenAccessController', function () { }, } - this.SessionManager = { - getLoggedInUserId: sinon.stub().returns(this.user._id), - getSessionUser: sinon.stub().returns(this.user._id), + ctx.SessionManager = { + getLoggedInUserId: sinon.stub().returns(ctx.user._id), + getSessionUser: sinon.stub().returns(ctx.user._id), } - this.AuthenticationController = { + ctx.AuthenticationController = { setRedirectInSession: sinon.stub(), } - this.AuthorizationManager = { + ctx.AuthorizationManager = { promises: { getPrivilegeLevelForProject: sinon .stub() @@ -71,35 +71,35 @@ describe('TokenAccessController', function () { }, } - this.AuthorizationMiddleware = {} + ctx.AuthorizationMiddleware = {} - this.ProjectAuditLogHandler = { + ctx.ProjectAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({ variant: 'default' }), getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), }, } - this.CollaboratorsInviteHandler = { + ctx.CollaboratorsInviteHandler = { promises: { revokeInviteForUser: sinon.stub().resolves(), }, } - this.CollaboratorsHandler = { + ctx.CollaboratorsHandler = { promises: { addUserIdToProject: sinon.stub().resolves(), setCollaboratorPrivilegeLevel: sinon.stub().resolves(), }, } - this.CollaboratorsGetter = { + ctx.CollaboratorsGetter = { promises: { userIsReadWriteTokenMember: sinon.stub().resolves(), isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(), @@ -107,24 +107,24 @@ describe('TokenAccessController', function () { }, } - this.EditorRealTimeController = { emitToRoom: sinon.stub() } + ctx.EditorRealTimeController = { emitToRoom: sinon.stub() } - this.ProjectGetter = { + ctx.ProjectGetter = { promises: { - getProject: sinon.stub().resolves(this.project), + getProject: sinon.stub().resolves(ctx.project), }, } - this.AnalyticsManager = { + ctx.AnalyticsManager = { recordEventForSession: sinon.stub(), recordEventForUserInBackground: sinon.stub(), } - this.UserGetter = { + ctx.UserGetter = { promises: { getUser: sinon.stub().callsFake(async (userId, filter) => { - if (userId === this.userId) { - return this.user + if (userId === ctx.userId) { + return ctx.user } else { return null } @@ -134,322 +134,415 @@ describe('TokenAccessController', function () { }, } - this.LimitationsManager = { + ctx.LimitationsManager = { promises: { canAcceptEditCollaboratorInvite: sinon.stub().resolves(), }, } - this.TokenAccessController = await esmock.strict(MODULE_PATH, { - '@overleaf/settings': this.Settings, - '../../../../app/src/Features/TokenAccess/TokenAccessHandler': - this.TokenAccessHandler, - '../../../../app/src/Features/Authentication/AuthenticationController': - this.AuthenticationController, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/Features/Authorization/AuthorizationManager': - this.AuthorizationManager, - '../../../../app/src/Features/Authorization/AuthorizationMiddleware': - this.AuthorizationMiddleware, - '../../../../app/src/Features/Project/ProjectAuditLogHandler': - this.ProjectAuditLogHandler, - '../../../../app/src/Features/SplitTests/SplitTestHandler': - this.SplitTestHandler, - '../../../../app/src/Features/Errors/Errors': (this.Errors = { + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock( + '../../../../app/src/Features/TokenAccess/TokenAccessHandler', + () => ({ + default: ctx.TokenAccessHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController', + () => ({ + default: ctx.AuthenticationController, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authorization/AuthorizationManager', + () => ({ + default: ctx.AuthorizationManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authorization/AuthorizationMiddleware', + () => ({ + default: ctx.AuthorizationMiddleware, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectAuditLogHandler', + () => ({ + default: ctx.ProjectAuditLogHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Errors/Errors', () => ({ + default: (ctx.Errors = { NotFoundError: sinon.stub(), }), - '../../../../app/src/Features/Collaborators/CollaboratorsHandler': - this.CollaboratorsHandler, - '../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler': - this.CollaboratorsInviteHandler, - '../../../../app/src/Features/Collaborators/CollaboratorsGetter': - this.CollaboratorsGetter, - '../../../../app/src/Features/Editor/EditorRealTimeController': - this.EditorRealTimeController, - '../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter, - '../../../../app/src/Features/Helpers/AsyncFormHelper': - (this.AsyncFormHelper = { - redirect: sinon.stub(), - }), - '../../../../app/src/Features/Helpers/AdminAuthorizationHelper': - (this.AdminAuthorizationHelper = { - canRedirectToAdminDomain: sinon.stub(), - }), - '../../../../app/src/Features/Helpers/UrlHelper': (this.UrlHelper = { - getSafeAdminDomainRedirect: sinon - .stub() - .callsFake( - path => `${this.Settings.adminUrl}${getSafeRedirectPath(path)}` - ), + })) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsHandler', + () => ({ + default: ctx.CollaboratorsHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler', + () => ({ + default: ctx.CollaboratorsInviteHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsGetter', + () => ({ + default: ctx.CollaboratorsGetter, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Editor/EditorRealTimeController', + () => ({ + default: ctx.EditorRealTimeController, + }) + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock('../../../../app/src/Features/Helpers/AsyncFormHelper', () => ({ + default: (ctx.AsyncFormHelper = { + redirect: sinon.stub(), }), - '../../../../app/src/Features/Analytics/AnalyticsManager': - this.AnalyticsManager, - '../../../../app/src/Features/User/UserGetter': this.UserGetter, - '../../../../app/src/Features/Subscription/LimitationsManager': - this.LimitationsManager, - }) + })) + + vi.doMock( + '../../../../app/src/Features/Helpers/AdminAuthorizationHelper', + () => + (ctx.AdminAuthorizationHelper = { + canRedirectToAdminDomain: sinon.stub(), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Helpers/UrlHelper', + () => + (ctx.UrlHelper = { + getSafeAdminDomainRedirect: sinon + .stub() + .callsFake( + path => `${ctx.Settings.adminUrl}${getSafeRedirectPath(path)}` + ), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/LimitationsManager', + () => ({ + default: ctx.LimitationsManager, + }) + ) + + ctx.TokenAccessController = (await import(MODULE_PATH)).default }) describe('grantTokenAccessReadAndWrite', function () { - beforeEach(function () { - this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + beforeEach(function (ctx) { + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( true ) }) describe('normal case (edit slot available)', function () { - beforeEach(function (done) { - this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( - true - ) - this.req.params = { token: this.token } - this.req.body = { - confirmedByUser: true, - tokenHashPrefix: '#prefix', - } - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + true + ) + ctx.req.params = { token: ctx.token } + ctx.req.body = { + confirmedByUser: true, + tokenHashPrefix: '#prefix', + } + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('adds the user as a read and write invited member', function () { + it('adds the user as a read and write invited member', function (ctx) { expect( - this.CollaboratorsHandler.promises.addUserIdToProject + ctx.CollaboratorsHandler.promises.addUserIdToProject ).to.have.been.calledWith( - this.project._id, + ctx.project._id, undefined, - this.user._id, + ctx.user._id, PrivilegeLevels.READ_AND_WRITE ) }) - it('writes a project audit log', function () { + it('writes a project audit log', function (ctx) { expect( - this.ProjectAuditLogHandler.promises.addEntry + ctx.ProjectAuditLogHandler.promises.addEntry ).to.have.been.calledWith( - this.project._id, + ctx.project._id, 'accept-via-link-sharing', - this.user._id, - this.req.ip, + ctx.user._id, + ctx.req.ip, { privileges: 'readAndWrite' } ) }) - it('records a project-joined event for the user', function () { + it('records a project-joined event for the user', function (ctx) { expect( - this.AnalyticsManager.recordEventForUserInBackground - ).to.have.been.calledWith(this.user._id, 'project-joined', { + ctx.AnalyticsManager.recordEventForUserInBackground + ).to.have.been.calledWith(ctx.user._id, 'project-joined', { mode: 'edit', - projectId: this.project._id.toString(), - ownerId: this.project.owner_ref.toString(), + projectId: ctx.project._id.toString(), + ownerId: ctx.project.owner_ref.toString(), role: PrivilegeLevels.READ_AND_WRITE, source: 'link-sharing', }) }) - it('emits a project membership changed event', function () { - expect( - this.EditorRealTimeController.emitToRoom - ).to.have.been.calledWith( - this.project._id, + it('emits a project membership changed event', function (ctx) { + expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( + ctx.project._id, 'project:membership:changed', { members: true, invites: true } ) }) - it('checks token hash', function () { + it('checks token hash', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, '#prefix', 'readAndWrite', - this.user._id, - { projectId: this.project._id, action: 'continue' } + ctx.user._id, + { projectId: ctx.project._id, action: 'continue' } ) }) }) describe('when there are no edit collaborator slots available', function () { - beforeEach(function (done) { - this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( - false - ) - this.req.params = { token: this.token } - this.req.body = { - confirmedByUser: true, - tokenHashPrefix: '#prefix', - } - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + false + ) + ctx.req.params = { token: ctx.token } + ctx.req.body = { + confirmedByUser: true, + tokenHashPrefix: '#prefix', + } + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('adds the user as a read only invited member instead (pendingEditor)', function () { + it('adds the user as a read only invited member instead (pendingEditor)', function (ctx) { expect( - this.CollaboratorsHandler.promises.addUserIdToProject + ctx.CollaboratorsHandler.promises.addUserIdToProject ).to.have.been.calledWith( - this.project._id, + ctx.project._id, undefined, - this.user._id, + ctx.user._id, PrivilegeLevels.READ_ONLY, { pendingEditor: true } ) }) - it('writes a project audit log', function () { + it('writes a project audit log', function (ctx) { expect( - this.ProjectAuditLogHandler.promises.addEntry + ctx.ProjectAuditLogHandler.promises.addEntry ).to.have.been.calledWith( - this.project._id, + ctx.project._id, 'accept-via-link-sharing', - this.user._id, - this.req.ip, + ctx.user._id, + ctx.req.ip, { privileges: 'readOnly', pendingEditor: true } ) }) - it('records a project-joined event for the user', function () { + it('records a project-joined event for the user', function (ctx) { expect( - this.AnalyticsManager.recordEventForUserInBackground - ).to.have.been.calledWith(this.user._id, 'project-joined', { + ctx.AnalyticsManager.recordEventForUserInBackground + ).to.have.been.calledWith(ctx.user._id, 'project-joined', { mode: 'view', - projectId: this.project._id.toString(), + projectId: ctx.project._id.toString(), pendingEditor: true, - ownerId: this.project.owner_ref.toString(), + ownerId: ctx.project.owner_ref.toString(), role: PrivilegeLevels.READ_ONLY, source: 'link-sharing', }) }) - it('emits a project membership changed event', function () { - expect( - this.EditorRealTimeController.emitToRoom - ).to.have.been.calledWith( - this.project._id, + it('emits a project membership changed event', function (ctx) { + expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( + ctx.project._id, 'project:membership:changed', { members: true, invites: true } ) }) - it('checks token hash', function () { + it('checks token hash', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, '#prefix', 'readAndWrite', - this.user._id, - { projectId: this.project._id, action: 'continue' } + ctx.user._id, + { projectId: ctx.project._id, action: 'continue' } ) }) }) describe('when the access was already granted', function () { - beforeEach(function (done) { - this.project.tokenAccessReadAndWrite_refs.push(this.user._id) - this.req.params = { token: this.token } - this.req.body = { confirmedByUser: true } - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.project.tokenAccessReadAndWrite_refs.push(ctx.user._id) + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true } + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('writes a project audit log', function () { + it('writes a project audit log', function (ctx) { expect( - this.ProjectAuditLogHandler.promises.addEntry + ctx.ProjectAuditLogHandler.promises.addEntry ).to.have.been.calledWith( - this.project._id, + ctx.project._id, 'accept-via-link-sharing', - this.user._id, - this.req.ip, + ctx.user._id, + ctx.req.ip, { privileges: 'readAndWrite' } ) }) - it('checks token hash', function () { + it('checks token hash', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, undefined, 'readAndWrite', - this.user._id, - { projectId: this.project._id, action: 'continue' } + ctx.user._id, + { projectId: ctx.project._id, action: 'continue' } ) }) }) describe('hash prefix missing in request', function () { - beforeEach(function (done) { - this.req.params = { token: this.token } - this.req.body = { confirmedByUser: true } - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true } + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('adds the user as a read and write invited member', function () { + it('adds the user as a read and write invited member', function (ctx) { expect( - this.CollaboratorsHandler.promises.addUserIdToProject + ctx.CollaboratorsHandler.promises.addUserIdToProject ).to.have.been.calledWith( - this.project._id, + ctx.project._id, undefined, - this.user._id, + ctx.user._id, PrivilegeLevels.READ_AND_WRITE ) }) - it('checks the hash prefix', function () { + it('checks the hash prefix', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, undefined, 'readAndWrite', - this.user._id, - { projectId: this.project._id, action: 'continue' } + ctx.user._id, + { projectId: ctx.project._id, action: 'continue' } ) }) }) describe('user is owner of project', function () { - beforeEach(function (done) { - this.AuthorizationManager.promises.getPrivilegeLevelForProject.returns( - PrivilegeLevels.OWNER - ) - this.req.params = { token: this.token } - this.req.body = {} - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.returns( + PrivilegeLevels.OWNER + ) + ctx.req.params = { token: ctx.token } + ctx.req.body = {} + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('checks token hash and includes log data', function () { + it('checks token hash and includes log data', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, undefined, 'readAndWrite', - this.user._id, + ctx.user._id, { - projectId: this.project._id, + projectId: ctx.project._id, action: 'user already has higher or same privilege', } ) @@ -457,33 +550,35 @@ describe('TokenAccessController', function () { }) describe('when user is not logged in', function () { - beforeEach(function () { - this.SessionManager.getLoggedInUserId.returns(null) - this.req.params = { token: this.token } - this.req.body = { tokenHashPrefix: '#prefix' } + beforeEach(function (ctx) { + ctx.SessionManager.getLoggedInUserId.returns(null) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } }) describe('ANONYMOUS_READ_AND_WRITE_ENABLED is undefined', function () { - beforeEach(function (done) { - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('redirects to restricted', function () { - expect(this.res.json).to.have.been.calledWith({ + it('redirects to restricted', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith({ redirect: '/restricted', anonWriteAccessDenied: true, }) }) - it('checks the hash prefix and includes log data', function () { + it('checks the hash prefix and includes log data', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, '#prefix', 'readAndWrite', null, @@ -493,42 +588,44 @@ describe('TokenAccessController', function () { ) }) - it('saves redirect URL with URL fragment', function () { + it('saves redirect URL with URL fragment', function (ctx) { expect( - this.AuthenticationController.setRedirectInSession.lastCall.args[1] + ctx.AuthenticationController.setRedirectInSession.lastCall.args[1] ).to.equal('/#prefix') }) }) describe('ANONYMOUS_READ_AND_WRITE_ENABLED is true', function () { - beforeEach(function (done) { - this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = true - this.res.callback = done + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = true + ctx.res.callback = resolve - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('redirects to project', function () { - expect(this.res.json).to.have.been.calledWith({ - redirect: `/project/${this.project._id}`, + it('redirects to project', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith({ + redirect: `/project/${ctx.project._id}`, grantAnonymousAccess: 'readAndWrite', }) }) - it('checks the hash prefix and includes log data', function () { + it('checks the hash prefix and includes log data', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, '#prefix', 'readAndWrite', null, { - projectId: this.project._id, + projectId: ctx.project._id, action: 'granting read-write anonymous access', } ) @@ -537,44 +634,48 @@ describe('TokenAccessController', function () { }) describe('when Overleaf SaaS', function () { - beforeEach(function () { - this.Settings.overleaf = {} + beforeEach(function (ctx) { + ctx.Settings.overleaf = {} }) describe('when token is for v1 project', function () { - beforeEach(function (done) { - this.TokenAccessHandler.promises.getProjectByToken.resolves(undefined) - this.TokenAccessHandler.promises.getV1DocInfo.resolves({ - exists: true, - has_owner: true, + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.TokenAccessHandler.promises.getProjectByToken.resolves( + undefined + ) + ctx.TokenAccessHandler.promises.getV1DocInfo.resolves({ + exists: true, + has_owner: true, + }) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) }) - this.req.params = { token: this.token } - this.req.body = { tokenHashPrefix: '#prefix' } - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) }) - it('returns v1 import data', function () { - expect(this.res.json).to.have.been.calledWith({ + it('returns v1 import data', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith({ v1Import: { status: 'canDownloadZip', - projectId: this.token, + projectId: ctx.token, hasOwner: true, name: 'Untitled', brandInfo: undefined, }, }) }) - it('checks the hash prefix and includes log data', function () { + it('checks the hash prefix and includes log data', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, '#prefix', 'readAndWrite', - this.user._id, + ctx.user._id, { action: 'import v1', } @@ -583,31 +684,35 @@ describe('TokenAccessController', function () { }) describe('when token is not for a v1 or v2 project', function () { - beforeEach(function (done) { - this.TokenAccessHandler.promises.getProjectByToken.resolves(undefined) - this.TokenAccessHandler.promises.getV1DocInfo.resolves({ - exists: false, + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.TokenAccessHandler.promises.getProjectByToken.resolves( + undefined + ) + ctx.TokenAccessHandler.promises.getV1DocInfo.resolves({ + exists: false, + }) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + resolve + ) }) - this.req.params = { token: this.token } - this.req.body = { tokenHashPrefix: '#prefix' } - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - done - ) }) - it('returns 404', function () { - expect(this.res.sendStatus).to.have.been.calledWith(404) + it('returns 404', function (ctx) { + expect(ctx.res.sendStatus).to.have.been.calledWith(404) }) - it('checks the hash prefix and includes log data', function () { + it('checks the hash prefix and includes log data', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, '#prefix', 'readAndWrite', - this.user._id, + ctx.user._id, { action: '404', } @@ -617,62 +722,67 @@ describe('TokenAccessController', function () { }) describe('not Overleaf SaaS', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getProjectByToken.resolves(undefined) - this.req.params = { token: this.token } - this.req.body = { tokenHashPrefix: '#prefix' } + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.getProjectByToken.resolves(undefined) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } }) - it('passes Errors.NotFoundError to next when project not found and still checks token hash', function (done) { - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - args => { - expect(args).to.be.instanceof(this.Errors.NotFoundError) + it('passes Errors.NotFoundError to next when project not found and still checks token hash', function (ctx) { + return new Promise(resolve => { + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + args => { + expect(args).to.be.instanceof(ctx.Errors.NotFoundError) - expect( - this.TokenAccessHandler.checkTokenHashPrefix - ).to.have.been.calledWith( - this.token, - '#prefix', - 'readAndWrite', - this.user._id, - { - action: '404', - } - ) + expect( + ctx.TokenAccessHandler.checkTokenHashPrefix + ).to.have.been.calledWith( + ctx.token, + '#prefix', + 'readAndWrite', + ctx.user._id, + { + action: '404', + } + ) - done() - } - ) + resolve() + } + ) + }) }) }) describe('when user is admin', function () { const admin = { _id: new ObjectId(), isAdmin: true } - beforeEach(function () { - this.SessionManager.getLoggedInUserId.returns(admin._id) - this.SessionManager.getSessionUser.returns(admin) - this.AdminAuthorizationHelper.canRedirectToAdminDomain.returns(true) - this.req.params = { token: this.token } - this.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' } + beforeEach(function (ctx) { + ctx.SessionManager.getLoggedInUserId.returns(admin._id) + ctx.SessionManager.getSessionUser.returns(admin) + ctx.AdminAuthorizationHelper.canRedirectToAdminDomain.returns(true) + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' } }) - it('redirects if project owner is non-admin', function () { - this.UserGetter.promises.getUserConfirmedEmails = sinon + it('redirects if project owner is non-admin', function (ctx) { + ctx.UserGetter.promises.getUserConfirmedEmails = sinon .stub() .resolves([{ email: 'test@not-overleaf.com' }]) - this.res.callback = () => { - expect(this.res.json).to.have.been.calledWith({ - redirect: `${this.Settings.adminUrl}/#prefix`, - }) - } - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res - ) + return new Promise(resolve => { + ctx.res.callback = () => { + expect(ctx.res.json).to.have.been.calledWith({ + redirect: `${ctx.Settings.adminUrl}/#prefix`, + }) + resolve() + } + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res + ) + }) }) - it('grants access if project owner is an internal staff', function () { + it('grants access if project owner is an internal staff', function (ctx) { const internalStaff = { _id: new ObjectId(), isAdmin: true } const projectFromInternalStaff = { _id: new ObjectId(), @@ -681,16 +791,16 @@ describe('TokenAccessController', function () { tokenAccessReadOnly_refs: [], owner_ref: internalStaff._id, } - this.UserGetter.promises.getUser = sinon.stub().resolves(internalStaff) - this.UserGetter.promises.getUserConfirmedEmails = sinon + ctx.UserGetter.promises.getUser = sinon.stub().resolves(internalStaff) + ctx.UserGetter.promises.getUserConfirmedEmails = sinon .stub() .resolves([{ email: 'test@overleaf.com' }]) - this.TokenAccessHandler.promises.getProjectByToken = sinon + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() .resolves(projectFromInternalStaff) - this.res.callback = () => { + ctx.res.callback = () => { expect( - this.CollaboratorsHandler.promises.addUserIdToProject + ctx.CollaboratorsHandler.promises.addUserIdToProject ).to.have.been.calledWith( projectFromInternalStaff._id, undefined, @@ -698,327 +808,345 @@ describe('TokenAccessController', function () { PrivilegeLevels.READ_AND_WRITE ) } - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res + ctx.TokenAccessController.grantTokenAccessReadAndWrite(ctx.req, ctx.res) + }) + }) + + it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', function (ctx) { + return new Promise(resolve => { + ctx.TokenAccessHandler.tokenAccessEnabledForProject.returns(false) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } + ctx.TokenAccessController.grantTokenAccessReadAndWrite( + ctx.req, + ctx.res, + args => { + expect(args).to.be.instanceof(ctx.Errors.NotFoundError) + + expect( + ctx.TokenAccessHandler.checkTokenHashPrefix + ).to.have.been.calledWith( + ctx.token, + '#prefix', + 'readAndWrite', + ctx.user._id, + { + projectId: ctx.project._id, + action: 'token access not enabled', + } + ) + + resolve() + } ) }) }) - it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', function (done) { - this.TokenAccessHandler.tokenAccessEnabledForProject.returns(false) - this.req.params = { token: this.token } - this.req.body = { tokenHashPrefix: '#prefix' } - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res, - args => { - expect(args).to.be.instanceof(this.Errors.NotFoundError) - - expect( - this.TokenAccessHandler.checkTokenHashPrefix - ).to.have.been.calledWith( - this.token, - '#prefix', - 'readAndWrite', - this.user._id, - { - projectId: this.project._id, - action: 'token access not enabled', - } - ) - - done() - } - ) - }) - - it('returns 400 when not using a read write token', function () { - this.TokenAccessHandler.isReadAndWriteToken.returns(false) - this.req.params = { token: this.token } - this.req.body = { tokenHashPrefix: '#prefix' } - this.TokenAccessController.grantTokenAccessReadAndWrite( - this.req, - this.res - ) - expect(this.res.sendStatus).to.have.been.calledWith(400) + it('returns 400 when not using a read write token', function (ctx) { + ctx.TokenAccessHandler.isReadAndWriteToken.returns(false) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } + ctx.TokenAccessController.grantTokenAccessReadAndWrite(ctx.req, ctx.res) + expect(ctx.res.sendStatus).to.have.been.calledWith(400) }) }) describe('grantTokenAccessReadOnly', function () { describe('normal case', function () { - beforeEach(function (done) { - this.req.params = { token: this.token } - this.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' } - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadOnly( - this.req, - this.res, - done + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' } + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadOnly( + ctx.req, + ctx.res, + resolve + ) + }) + }) + + it('grants read-only access', function (ctx) { + expect( + ctx.TokenAccessHandler.promises.addReadOnlyUserToProject + ).to.have.been.calledWith( + ctx.user._id, + ctx.project._id, + ctx.project.owner_ref ) }) - it('grants read-only access', function () { + it('writes a project audit log', function (ctx) { expect( - this.TokenAccessHandler.promises.addReadOnlyUserToProject + ctx.ProjectAuditLogHandler.promises.addEntry ).to.have.been.calledWith( - this.user._id, - this.project._id, - this.project.owner_ref - ) - }) - - it('writes a project audit log', function () { - expect( - this.ProjectAuditLogHandler.promises.addEntry - ).to.have.been.calledWith( - this.project._id, + ctx.project._id, 'join-via-token', - this.user._id, - this.req.ip, + ctx.user._id, + ctx.req.ip, { privileges: 'readOnly' } ) }) - it('checks if hash prefix matches', function () { + it('checks if hash prefix matches', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, '#prefix', 'readOnly', - this.user._id, - { projectId: this.project._id, action: 'continue' } + ctx.user._id, + { projectId: ctx.project._id, action: 'continue' } ) }) }) describe('when the access was already granted', function () { - beforeEach(function (done) { - this.project.tokenAccessReadOnly_refs.push(this.user._id) - this.req.params = { token: this.token } - this.req.body = { confirmedByUser: true } - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadOnly( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.project.tokenAccessReadOnly_refs.push(ctx.user._id) + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true } + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadOnly( + ctx.req, + ctx.res, + resolve + ) + }) }) - it("doesn't write a project audit log", function () { - expect(this.ProjectAuditLogHandler.promises.addEntry).to.not.have.been + it("doesn't write a project audit log", function (ctx) { + expect(ctx.ProjectAuditLogHandler.promises.addEntry).to.not.have.been .called }) - it('still checks if hash prefix matches', function () { + it('still checks if hash prefix matches', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, undefined, 'readOnly', - this.user._id, - { projectId: this.project._id, action: 'continue' } + ctx.user._id, + { projectId: ctx.project._id, action: 'continue' } ) }) }) - it('returns 400 when not using a read only token', function () { - this.TokenAccessHandler.isReadOnlyToken.returns(false) - this.req.params = { token: this.token } - this.req.body = { tokenHashPrefix: '#prefix' } - this.TokenAccessController.grantTokenAccessReadOnly(this.req, this.res) - expect(this.res.sendStatus).to.have.been.calledWith(400) + it('returns 400 when not using a read only token', function (ctx) { + ctx.TokenAccessHandler.isReadOnlyToken.returns(false) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } + ctx.TokenAccessController.grantTokenAccessReadOnly(ctx.req, ctx.res) + expect(ctx.res.sendStatus).to.have.been.calledWith(400) }) describe('anonymous users', function () { - beforeEach(function (done) { - this.req.params = { token: this.token } - this.SessionManager.getLoggedInUserId.returns(null) - this.res.callback = done + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.req.params = { token: ctx.token } + ctx.SessionManager.getLoggedInUserId.returns(null) + ctx.res.callback = resolve - this.TokenAccessController.grantTokenAccessReadOnly( - this.req, - this.res, - done - ) + ctx.TokenAccessController.grantTokenAccessReadOnly( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('allows anonymous users and checks the token hash', function () { - expect(this.res.json).to.have.been.calledWith({ - redirect: `/project/${this.project._id}`, + it('allows anonymous users and checks the token hash', function (ctx) { + expect(ctx.res.json).to.have.been.calledWith({ + redirect: `/project/${ctx.project._id}`, grantAnonymousAccess: 'readOnly', }) expect( - this.TokenAccessHandler.checkTokenHashPrefix - ).to.have.been.calledWith(this.token, undefined, 'readOnly', null, { - projectId: this.project._id, + ctx.TokenAccessHandler.checkTokenHashPrefix + ).to.have.been.calledWith(ctx.token, undefined, 'readOnly', null, { + projectId: ctx.project._id, action: 'granting read-only anonymous access', }) }) }) describe('user is owner of project', function () { - beforeEach(function (done) { - this.AuthorizationManager.promises.getPrivilegeLevelForProject.returns( - PrivilegeLevels.OWNER - ) - this.req.params = { token: this.token } - this.req.body = {} - this.res.callback = done - this.TokenAccessController.grantTokenAccessReadOnly( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.returns( + PrivilegeLevels.OWNER + ) + ctx.req.params = { token: ctx.token } + ctx.req.body = {} + ctx.res.callback = resolve + ctx.TokenAccessController.grantTokenAccessReadOnly( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('checks token hash and includes log data', function () { + it('checks token hash and includes log data', function (ctx) { expect( - this.TokenAccessHandler.checkTokenHashPrefix + ctx.TokenAccessHandler.checkTokenHashPrefix ).to.have.been.calledWith( - this.token, + ctx.token, undefined, 'readOnly', - this.user._id, + ctx.user._id, { - projectId: this.project._id, + projectId: ctx.project._id, action: 'user already has higher or same privilege', } ) }) }) - it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', function (done) { - this.TokenAccessHandler.tokenAccessEnabledForProject.returns(false) - this.req.params = { token: this.token } - this.req.body = { tokenHashPrefix: '#prefix' } - this.TokenAccessController.grantTokenAccessReadOnly( - this.req, - this.res, - args => { - expect(args).to.be.instanceof(this.Errors.NotFoundError) + it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', function (ctx) { + return new Promise(resolve => { + ctx.TokenAccessHandler.tokenAccessEnabledForProject.returns(false) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } + ctx.TokenAccessController.grantTokenAccessReadOnly( + ctx.req, + ctx.res, + args => { + expect(args).to.be.instanceof(ctx.Errors.NotFoundError) - expect( - this.TokenAccessHandler.checkTokenHashPrefix - ).to.have.been.calledWith( - this.token, - '#prefix', - 'readOnly', - this.user._id, - { - projectId: this.project._id, - action: 'token access not enabled', - } - ) + expect( + ctx.TokenAccessHandler.checkTokenHashPrefix + ).to.have.been.calledWith( + ctx.token, + '#prefix', + 'readOnly', + ctx.user._id, + { + projectId: ctx.project._id, + action: 'token access not enabled', + } + ) - done() - } - ) + resolve() + } + ) + }) }) }) describe('ensureUserCanUseSharingUpdatesConsentPage', function () { - beforeEach(function () { - this.req.params = { Project_id: this.project._id } + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.project._id } }) describe('when not in link sharing changes test', function () { - beforeEach(function (done) { - this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done()) - this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.AsyncFormHelper.redirect = sinon.stub().callsFake(() => resolve()) + ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('redirects to the project/editor', function () { - expect(this.AsyncFormHelper.redirect).to.have.been.calledWith( - this.req, - this.res, - `/project/${this.project._id}` + it('redirects to the project/editor', function (ctx) { + expect(ctx.AsyncFormHelper.redirect).to.have.been.calledWith( + ctx.req, + ctx.res, + `/project/${ctx.project._id}` ) }) }) describe('when link sharing changes test active', function () { - beforeEach(function () { - this.SplitTestHandler.promises.getAssignmentForUser.resolves({ + beforeEach(function (ctx) { + ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({ variant: 'active', }) }) describe('when user is not an invited editor and is a read write token member', function () { - beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( - false - ) - this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( - true - ) - this.next.callsFake(() => done()) - this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( - this.req, - this.res, - this.next - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( + false + ) + ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( + true + ) + ctx.next.callsFake(() => resolve()) + ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( + ctx.req, + ctx.res, + ctx.next + ) + }) }) - it('calls next', function () { + it('calls next', function (ctx) { expect( - this.CollaboratorsGetter.promises + ctx.CollaboratorsGetter.promises .isUserInvitedReadWriteMemberOfProject - ).to.have.been.calledWith(this.user._id, this.project._id) + ).to.have.been.calledWith(ctx.user._id, ctx.project._id) expect( - this.CollaboratorsGetter.promises.userIsReadWriteTokenMember - ).to.have.been.calledWith(this.user._id, this.project._id) - expect(this.next).to.have.been.calledOnce - expect(this.next.firstCall.args[0]).to.not.exist + ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember + ).to.have.been.calledWith(ctx.user._id, ctx.project._id) + expect(ctx.next).to.have.been.calledOnce + expect(ctx.next.firstCall.args[0]).to.not.exist }) }) describe('when user is already an invited editor', function () { - beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( - true - ) - this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done()) - this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( + true + ) + ctx.AsyncFormHelper.redirect = sinon + .stub() + .callsFake(() => resolve()) + ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('redirects to the project/editor', function () { - expect(this.AsyncFormHelper.redirect).to.have.been.calledWith( - this.req, - this.res, - `/project/${this.project._id}` + it('redirects to the project/editor', function (ctx) { + expect(ctx.AsyncFormHelper.redirect).to.have.been.calledWith( + ctx.req, + ctx.res, + `/project/${ctx.project._id}` ) }) }) describe('when user not a read write token member', function () { - beforeEach(function (done) { - this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( - false - ) - this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done()) - this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( + false + ) + ctx.AsyncFormHelper.redirect = sinon + .stub() + .callsFake(() => resolve()) + ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('redirects to the project/editor', function () { - expect(this.AsyncFormHelper.redirect).to.have.been.calledWith( - this.req, - this.res, - `/project/${this.project._id}` + it('redirects to the project/editor', function (ctx) { + expect(ctx.AsyncFormHelper.redirect).to.have.been.calledWith( + ctx.req, + ctx.res, + `/project/${ctx.project._id}` ) }) }) @@ -1026,116 +1154,122 @@ describe('TokenAccessController', function () { }) describe('moveReadWriteToCollaborators', function () { - beforeEach(function () { - this.req.params = { Project_id: this.project._id } + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.project._id } }) describe('when there are collaborator slots available', function () { - beforeEach(function () { - this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + beforeEach(function (ctx) { + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( true ) }) describe('previously joined token access user moving to named collaborator', function () { - beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - false - ) - this.res.callback = done - this.TokenAccessController.moveReadWriteToCollaborators( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + false + ) + ctx.res.callback = resolve + ctx.TokenAccessController.moveReadWriteToCollaborators( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('sets the privilege level to read and write for the invited viewer', function () { + it('sets the privilege level to read and write for the invited viewer', function (ctx) { expect( - this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject - ).to.have.been.calledWith(this.user._id, this.project._id) + ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject + ).to.have.been.calledWith(ctx.user._id, ctx.project._id) expect( - this.CollaboratorsHandler.promises.addUserIdToProject + ctx.CollaboratorsHandler.promises.addUserIdToProject ).to.have.been.calledWith( - this.project._id, + ctx.project._id, undefined, - this.user._id, + ctx.user._id, PrivilegeLevels.READ_AND_WRITE ) - expect(this.res.sendStatus).to.have.been.calledWith(204) + expect(ctx.res.sendStatus).to.have.been.calledWith(204) }) }) }) describe('when there are no edit collaborator slots available', function () { - beforeEach(function () { - this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + beforeEach(function (ctx) { + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( false ) }) describe('previously joined token access user moving to named collaborator', function () { - beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - false - ) - this.res.callback = done - this.TokenAccessController.moveReadWriteToCollaborators( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + false + ) + ctx.res.callback = resolve + ctx.TokenAccessController.moveReadWriteToCollaborators( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('sets the privilege level to read only for the invited viewer (pendingEditor)', function () { + it('sets the privilege level to read only for the invited viewer (pendingEditor)', function (ctx) { expect( - this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject - ).to.have.been.calledWith(this.user._id, this.project._id) + ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject + ).to.have.been.calledWith(ctx.user._id, ctx.project._id) expect( - this.CollaboratorsHandler.promises.addUserIdToProject + ctx.CollaboratorsHandler.promises.addUserIdToProject ).to.have.been.calledWith( - this.project._id, + ctx.project._id, undefined, - this.user._id, + ctx.user._id, PrivilegeLevels.READ_ONLY, { pendingEditor: true } ) - expect(this.res.sendStatus).to.have.been.calledWith(204) + expect(ctx.res.sendStatus).to.have.been.calledWith(204) }) }) }) }) describe('moveReadWriteToReadOnly', function () { - beforeEach(function () { - this.req.params = { Project_id: this.project._id } + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.project._id } }) describe('previously joined token access user moving to anonymous viewer', function () { - beforeEach(function (done) { - this.res.callback = done - this.TokenAccessController.moveReadWriteToReadOnly( - this.req, - this.res, - done - ) + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.res.callback = resolve + ctx.TokenAccessController.moveReadWriteToReadOnly( + ctx.req, + ctx.res, + resolve + ) + }) }) - it('removes them from read write token access refs and adds them to read only token access refs', function () { + it('removes them from read write token access refs and adds them to read only token access refs', function (ctx) { expect( - this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly - ).to.have.been.calledWith(this.user._id, this.project._id) - expect(this.res.sendStatus).to.have.been.calledWith(204) + ctx.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly + ).to.have.been.calledWith(ctx.user._id, ctx.project._id) + expect(ctx.res.sendStatus).to.have.been.calledWith(204) }) - it('writes a project audit log', function () { + it('writes a project audit log', function (ctx) { expect( - this.ProjectAuditLogHandler.promises.addEntry + ctx.ProjectAuditLogHandler.promises.addEntry ).to.have.been.calledWith( - this.project._id, + ctx.project._id, 'readonly-via-sharing-updates', - this.user._id, - this.req.ip + ctx.user._id, + ctx.req.ip ) }) }) diff --git a/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs index 35682f346c..1f6fd7adb9 100644 --- a/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs +++ b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs @@ -2,15 +2,12 @@ // Fix any style issues and re-enable lint. /* * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns * DS206: Consider reworking classes to avoid initClass * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ +import { vi } from 'vitest' import sinon from 'sinon' - import { expect } from 'chai' - -import esmock from 'esmock' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import ArchiveErrors from '../../../../app/src/Features/Uploads/ArchiveErrors.js' @@ -19,12 +16,12 @@ const modulePath = '../../../../app/src/Features/Uploads/ProjectUploadController.mjs' describe('ProjectUploadController', function () { - beforeEach(async function () { + beforeEach(async function (ctx) { let Timer - this.req = new MockRequest() - this.res = new MockResponse() - this.user_id = 'user-id-123' - this.metrics = { + ctx.req = new MockRequest() + ctx.res = new MockResponse() + ctx.user_id = 'user-id-123' + ctx.metrics = { Timer: (Timer = (function () { Timer = class Timer { static initClass() { @@ -35,262 +32,298 @@ describe('ProjectUploadController', function () { return Timer })()), } - this.SessionManager = { - getLoggedInUserId: sinon.stub().returns(this.user_id), + ctx.SessionManager = { + getLoggedInUserId: sinon.stub().returns(ctx.user_id), } - this.ProjectLocator = { + ctx.ProjectLocator = { promises: {}, } - this.EditorController = { + ctx.EditorController = { promises: {}, } - return (this.ProjectUploadController = await esmock.strict(modulePath, { - multer: sinon.stub(), - '@overleaf/settings': { path: {} }, - '../../../../app/src/Features/Uploads/ProjectUploadManager': - (this.ProjectUploadManager = {}), - '../../../../app/src/Features/Uploads/FileSystemImportManager': - (this.FileSystemImportManager = {}), - '@overleaf/metrics': this.metrics, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/Features/Uploads/ArchiveErrors': ArchiveErrors, - '../../../../app/src/Features/Project/ProjectLocator': - this.ProjectLocator, - '../../../../app/src/Features/Editor/EditorController': - this.EditorController, - fs: (this.fs = {}), + vi.doMock('multer', () => ({ + default: sinon.stub(), })) + + vi.doMock('@overleaf/settings', () => ({ + default: { path: {} }, + })) + + vi.doMock( + '../../../../app/src/Features/Uploads/ProjectUploadManager', + () => ({ + default: (ctx.ProjectUploadManager = {}), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Uploads/FileSystemImportManager', + () => ({ + default: (ctx.FileSystemImportManager = {}), + }) + ) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.metrics, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Uploads/ArchiveErrors', + () => ArchiveErrors + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({ + default: ctx.ProjectLocator, + })) + + vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({ + default: ctx.EditorController, + })) + + vi.doMock('fs', () => ({ + default: (ctx.fs = {}), + })) + + ctx.ProjectUploadController = (await import(modulePath)).default }) describe('uploadProject', function () { - beforeEach(function () { - this.path = '/path/to/file/on/disk.zip' - this.name = 'filename.zip' - this.req.file = { - path: this.path, + beforeEach(function (ctx) { + ctx.path = '/path/to/file/on/disk.zip' + ctx.fileName = 'filename.zip' + ctx.req.file = { + path: ctx.path, } - this.req.body = { - name: this.name, + ctx.req.body = { + name: ctx.fileName, } - this.req.session = { + ctx.req.session = { user: { - _id: this.user_id, + _id: ctx.user_id, }, } - this.project = { _id: (this.project_id = 'project-id-123') } + ctx.project = { _id: (ctx.project_id = 'project-id-123') } - return (this.fs.unlink = sinon.stub()) + ctx.fs.unlink = sinon.stub() }) describe('successfully', function () { - beforeEach(function () { - this.ProjectUploadManager.createProjectFromZipArchive = sinon + beforeEach(function (ctx) { + ctx.ProjectUploadManager.createProjectFromZipArchive = sinon .stub() - .callsArgWith(3, null, this.project) - return this.ProjectUploadController.uploadProject(this.req, this.res) + .callsArgWith(3, null, ctx.project) + ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res) }) - it('should create a project owned by the logged in user', function () { - return this.ProjectUploadManager.createProjectFromZipArchive - .calledWith(this.user_id) + it('should create a project owned by the logged in user', function (ctx) { + ctx.ProjectUploadManager.createProjectFromZipArchive + .calledWith(ctx.user_id) .should.equal(true) }) - it('should create a project with the same name as the zip archive', function () { - return this.ProjectUploadManager.createProjectFromZipArchive + it('should create a project with the same name as the zip archive', function (ctx) { + ctx.ProjectUploadManager.createProjectFromZipArchive .calledWith(sinon.match.any, 'filename', sinon.match.any) .should.equal(true) }) - it('should create a project from the zip archive', function () { - return this.ProjectUploadManager.createProjectFromZipArchive - .calledWith(sinon.match.any, sinon.match.any, this.path) + it('should create a project from the zip archive', function (ctx) { + ctx.ProjectUploadManager.createProjectFromZipArchive + .calledWith(sinon.match.any, sinon.match.any, ctx.path) .should.equal(true) }) - it('should return a successful response to the FileUploader client', function () { - return expect(this.res.body).to.deep.equal( + it('should return a successful response to the FileUploader client', function (ctx) { + expect(ctx.res.body).to.deep.equal( JSON.stringify({ success: true, - project_id: this.project_id, + project_id: ctx.project_id, }) ) }) - it('should record the time taken to do the upload', function () { - return this.metrics.Timer.prototype.done.called.should.equal(true) + it('should record the time taken to do the upload', function (ctx) { + ctx.metrics.Timer.prototype.done.called.should.equal(true) }) - it('should remove the uploaded file', function () { - return this.fs.unlink.calledWith(this.path).should.equal(true) + it('should remove the uploaded file', function (ctx) { + ctx.fs.unlink.calledWith(ctx.path).should.equal(true) }) }) describe('when ProjectUploadManager.createProjectFromZipArchive fails', function () { - beforeEach(function () { - this.ProjectUploadManager.createProjectFromZipArchive = sinon + beforeEach(function (ctx) { + ctx.ProjectUploadManager.createProjectFromZipArchive = sinon .stub() - .callsArgWith(3, new Error('Something went wrong'), this.project) - return this.ProjectUploadController.uploadProject(this.req, this.res) + .callsArgWith(3, new Error('Something went wrong'), ctx.project) + ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res) }) - it('should return a failed response to the FileUploader client', function () { - return expect(this.res.body).to.deep.equal( + it('should return a failed response to the FileUploader client', function (ctx) { + expect(ctx.res.body).to.deep.equal( JSON.stringify({ success: false, error: 'upload_failed' }) ) }) }) describe('when ProjectUploadManager.createProjectFromZipArchive reports the file as invalid', function () { - beforeEach(function () { - this.ProjectUploadManager.createProjectFromZipArchive = sinon + beforeEach(function (ctx) { + ctx.ProjectUploadManager.createProjectFromZipArchive = sinon .stub() .callsArgWith( 3, new ArchiveErrors.ZipContentsTooLargeError(), - this.project + ctx.project ) - return this.ProjectUploadController.uploadProject(this.req, this.res) + ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res) }) - it('should return the reported error to the FileUploader client', function () { - expect(JSON.parse(this.res.body)).to.deep.equal({ + it('should return the reported error to the FileUploader client', function (ctx) { + expect(JSON.parse(ctx.res.body)).to.deep.equal({ success: false, error: 'zip_contents_too_large', }) }) - it("should return an 'unprocessable entity' status code", function () { - return expect(this.res.statusCode).to.equal(422) + it("should return an 'unprocessable entity' status code", function (ctx) { + expect(ctx.res.statusCode).to.equal(422) }) }) }) describe('uploadFile', function () { - beforeEach(function () { - this.project_id = 'project-id-123' - this.folder_id = 'folder-id-123' - this.path = '/path/to/file/on/disk.png' - this.name = 'filename.png' - this.req.file = { - path: this.path, + beforeEach(function (ctx) { + ctx.project_id = 'project-id-123' + ctx.folder_id = 'folder-id-123' + ctx.path = '/path/to/file/on/disk.png' + ctx.fileName = 'filename.png' + ctx.req.file = { + path: ctx.path, } - this.req.body = { - name: this.name, + ctx.req.body = { + name: ctx.fileName, } - this.req.session = { + ctx.req.session = { user: { - _id: this.user_id, + _id: ctx.user_id, }, } - this.req.params = { Project_id: this.project_id } - this.req.query = { folder_id: this.folder_id } - return (this.fs.unlink = sinon.stub()) + ctx.req.params = { Project_id: ctx.project_id } + ctx.req.query = { folder_id: ctx.folder_id } + ctx.fs.unlink = sinon.stub() }) describe('successfully', function () { - beforeEach(function () { - this.entity = { + beforeEach(function (ctx) { + ctx.entity = { _id: '1234', type: 'file', } - this.FileSystemImportManager.addEntity = sinon + ctx.FileSystemImportManager.addEntity = sinon .stub() - .callsArgWith(6, null, this.entity) - return this.ProjectUploadController.uploadFile(this.req, this.res) + .callsArgWith(6, null, ctx.entity) + ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res) }) - it('should insert the file', function () { - return this.FileSystemImportManager.addEntity + it('should insert the file', function (ctx) { + return ctx.FileSystemImportManager.addEntity .calledWith( - this.user_id, - this.project_id, - this.folder_id, - this.name, - this.path + ctx.user_id, + ctx.project_id, + ctx.folder_id, + ctx.fileName, + ctx.path ) .should.equal(true) }) - it('should return a successful response to the FileUploader client', function () { - return expect(this.res.body).to.deep.equal( + it('should return a successful response to the FileUploader client', function (ctx) { + expect(ctx.res.body).to.deep.equal( JSON.stringify({ success: true, - entity_id: this.entity._id, + entity_id: ctx.entity._id, entity_type: 'file', }) ) }) - it('should time the request', function () { - return this.metrics.Timer.prototype.done.called.should.equal(true) + it('should time the request', function (ctx) { + ctx.metrics.Timer.prototype.done.called.should.equal(true) }) - it('should remove the uploaded file', function () { - return this.fs.unlink.calledWith(this.path).should.equal(true) + it('should remove the uploaded file', function (ctx) { + ctx.fs.unlink.calledWith(ctx.path).should.equal(true) }) }) describe('with folder structure', function () { - beforeEach(function (done) { - this.entity = { - _id: '1234', - type: 'file', - } - this.FileSystemImportManager.addEntity = sinon - .stub() - .callsArgWith(6, null, this.entity) - this.ProjectLocator.promises.findElement = sinon.stub().resolves({ - path: { fileSystem: '/test' }, + beforeEach(function (ctx) { + return new Promise(resolve => { + ctx.entity = { + _id: '1234', + type: 'file', + } + ctx.FileSystemImportManager.addEntity = sinon + .stub() + .callsArgWith(6, null, ctx.entity) + ctx.ProjectLocator.promises.findElement = sinon.stub().resolves({ + path: { fileSystem: '/test' }, + }) + ctx.EditorController.promises.mkdirp = sinon.stub().resolves({ + lastFolder: { _id: 'folder-id' }, + }) + ctx.req.body.relativePath = 'foo/bar/' + ctx.fileName + ctx.res.json = data => { + expect(data.success).to.be.true + resolve() + } + ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res) }) - this.EditorController.promises.mkdirp = sinon.stub().resolves({ - lastFolder: { _id: 'folder-id' }, - }) - this.req.body.relativePath = 'foo/bar/' + this.name - this.res.json = data => { - expect(data.success).to.be.true - done() - } - this.ProjectUploadController.uploadFile(this.req, this.res) }) - it('should insert the file', function () { - this.ProjectLocator.promises.findElement.should.be.calledOnceWithExactly( + it('should insert the file', function (ctx) { + ctx.ProjectLocator.promises.findElement.should.be.calledOnceWithExactly( { - project_id: this.project_id, - element_id: this.folder_id, + project_id: ctx.project_id, + element_id: ctx.folder_id, type: 'folder', } ) - this.EditorController.promises.mkdirp.should.be.calledWith( - this.project_id, + ctx.EditorController.promises.mkdirp.should.be.calledWith( + ctx.project_id, '/test/foo/bar', - this.user_id + ctx.user_id ) - this.FileSystemImportManager.addEntity.should.be.calledOnceWith( - this.user_id, - this.project_id, + ctx.FileSystemImportManager.addEntity.should.be.calledOnceWith( + ctx.user_id, + ctx.project_id, 'folder-id', - this.name, - this.path + ctx.fileName, + ctx.path ) }) }) describe('when FileSystemImportManager.addEntity returns a generic error', function () { - beforeEach(function () { - this.FileSystemImportManager.addEntity = sinon + beforeEach(function (ctx) { + ctx.FileSystemImportManager.addEntity = sinon .stub() .callsArgWith(6, new Error('Sorry something went wrong')) - return this.ProjectUploadController.uploadFile(this.req, this.res) + ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res) }) - it('should return an unsuccessful response to the FileUploader client', function () { - return expect(this.res.body).to.deep.equal( + it('should return an unsuccessful response to the FileUploader client', function (ctx) { + expect(ctx.res.body).to.deep.equal( JSON.stringify({ success: false, }) @@ -299,15 +332,15 @@ describe('ProjectUploadController', function () { }) describe('when FileSystemImportManager.addEntity returns a too many files error', function () { - beforeEach(function () { - this.FileSystemImportManager.addEntity = sinon + beforeEach(function (ctx) { + ctx.FileSystemImportManager.addEntity = sinon .stub() .callsArgWith(6, new Error('project_has_too_many_files')) - return this.ProjectUploadController.uploadFile(this.req, this.res) + ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res) }) - it('should return an unsuccessful response to the FileUploader client', function () { - return expect(this.res.body).to.deep.equal( + it('should return an unsuccessful response to the FileUploader client', function (ctx) { + expect(ctx.res.body).to.deep.equal( JSON.stringify({ success: false, error: 'project_has_too_many_files', @@ -317,13 +350,13 @@ describe('ProjectUploadController', function () { }) describe('with an invalid filename', function () { - beforeEach(function () { - this.req.body.name = '' - return this.ProjectUploadController.uploadFile(this.req, this.res) + beforeEach(function (ctx) { + ctx.req.body.name = '' + ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res) }) - it('should return a a non success response', function () { - return expect(this.res.body).to.deep.equal( + it('should return a a non success response', function (ctx) { + expect(ctx.res.body).to.deep.equal( JSON.stringify({ success: false, error: 'invalid_filename', diff --git a/services/web/test/unit/src/User/UserPagesController.test.mjs b/services/web/test/unit/src/User/UserPagesController.test.mjs index 6b19ef03f5..181c9513ae 100644 --- a/services/web/test/unit/src/User/UserPagesController.test.mjs +++ b/services/web/test/unit/src/User/UserPagesController.test.mjs @@ -1,18 +1,15 @@ -import esmock from 'esmock' +import { vi } from 'vitest' import assert from 'assert' import sinon from 'sinon' import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' import MockRequest from '../helpers/MockRequest.js' -const modulePath = new URL( - '../../../../app/src/Features/User/UserPagesController', - import.meta.url -).pathname +const modulePath = '../../../../app/src/Features/User/UserPagesController' describe('UserPagesController', function () { - beforeEach(async function () { - this.settings = { + beforeEach(async function (ctx) { + ctx.settings = { apis: { v1: { url: 'some.host', @@ -21,8 +18,8 @@ describe('UserPagesController', function () { }, }, } - this.user = { - _id: (this.user_id = 'kwjewkl'), + ctx.user = { + _id: (ctx.user_id = 'kwjewkl'), features: {}, email: 'joe@example.com', ip_address: '1.1.1.1', @@ -39,414 +36,507 @@ describe('UserPagesController', function () { papers: { encrypted: 'cccc' }, }, } - this.adminEmail = 'group-admin-email@overleaf.com' - this.subscriptionViewModel = { + ctx.adminEmail = 'group-admin-email@overleaf.com' + ctx.subscriptionViewModel = { memberGroupSubscriptions: [], } - this.UserGetter = { + ctx.UserGetter = { getUser: sinon.stub(), promises: { getUser: sinon.stub() }, } - this.UserSessionsManager = { getAllUserSessions: sinon.stub() } - this.dropboxStatus = {} - this.ErrorController = { notFound: sinon.stub() } - this.SessionManager = { - getLoggedInUserId: sinon.stub().returns(this.user._id), - getSessionUser: sinon.stub().returns(this.user), + ctx.UserSessionsManager = { getAllUserSessions: sinon.stub() } + ctx.dropboxStatus = {} + ctx.ErrorController = { notFound: sinon.stub() } + ctx.SessionManager = { + getLoggedInUserId: sinon.stub().returns(ctx.user._id), + getSessionUser: sinon.stub().returns(ctx.user), } - this.NewsletterManager = { + ctx.NewsletterManager = { subscribed: sinon.stub().yields(), } - this.AuthenticationController = { + ctx.AuthenticationController = { getRedirectFromSession: sinon.stub(), setRedirectInSession: sinon.stub(), } - this.Features = { + ctx.Features = { hasFeature: sinon.stub().returns(false), } - this.PersonalAccessTokenManager = { + ctx.PersonalAccessTokenManager = { listTokens: sinon.stub().returns([]), } - this.SubscriptionLocator = { + ctx.SubscriptionLocator = { promises: { - getAdminEmail: sinon.stub().returns(this.adminEmail), + getAdminEmail: sinon.stub().returns(ctx.adminEmail), getMemberSubscriptions: sinon.stub().resolves(), }, } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().returns('default'), }, } - this.Modules = { + ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves(), }, }, } - this.UserPagesController = await esmock.strict(modulePath, { - '@overleaf/settings': this.settings, - '../../../../app/src/Features/User/UserGetter': this.UserGetter, - '../../../../app/src/Features/User/UserSessionsManager': - this.UserSessionsManager, - '../../../../app/src/Features/Newsletter/NewsletterManager': - this.NewsletterManager, - '../../../../app/src/Features/Errors/ErrorController': - this.ErrorController, - '../../../../app/src/Features/Authentication/AuthenticationController': - this.AuthenticationController, - '../../../../app/src/Features/Subscription/SubscriptionLocator': - this.SubscriptionLocator, - '../../../../app/src/infrastructure/Features': this.Features, - '../../../../modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager': - this.PersonalAccessTokenManager, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/Features/SplitTests/SplitTestHandler': - this.SplitTestHandler, - '../../../../app/src/infrastructure/Modules': this.Modules, - request: (this.request = sinon.stub()), - }) - this.req = new MockRequest() - this.req.session.user = this.user - this.res = new MockResponse() + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({ + default: ctx.UserSessionsManager, + })) + + vi.doMock( + '../../../../app/src/Features/Newsletter/NewsletterManager', + () => ({ + default: ctx.NewsletterManager, + }) + ) + + vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({ + default: ctx.ErrorController, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController', + () => ({ + default: ctx.AuthenticationController, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: ctx.SubscriptionLocator, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: ctx.Features, + })) + + vi.doMock( + '../../../../modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager', + () => ({ + default: ctx.PersonalAccessTokenManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: ctx.Modules, + })) + ctx.request = sinon.stub() + vi.doMock('request', () => ({ + default: ctx.request, + })) + + ctx.UserPagesController = (await import(modulePath)).default + ctx.req = new MockRequest() + ctx.req.session.user = ctx.user + ctx.res = new MockResponse() }) describe('registerPage', function () { - it('should render the register page', function (done) { - this.res.callback = () => { - this.res.renderedTemplate.should.equal('user/register') - done() - } - this.UserPagesController.registerPage(this.req, this.res, done) + it('should render the register page', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.renderedTemplate.should.equal('user/register') + resolve() + } + ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve) + }) }) - it('should set sharedProjectData', function (done) { - this.req.session.sharedProjectData = { - project_name: 'myProject', - user_first_name: 'user_first_name_here', - } + it('should set sharedProjectData', function (ctx) { + return new Promise(resolve => { + ctx.req.session.sharedProjectData = { + project_name: 'myProject', + user_first_name: 'user_first_name_here', + } - this.res.callback = () => { - this.res.renderedVariables.sharedProjectData.project_name.should.equal( - 'myProject' - ) - this.res.renderedVariables.sharedProjectData.user_first_name.should.equal( - 'user_first_name_here' - ) - done() - } - this.UserPagesController.registerPage(this.req, this.res, done) + ctx.res.callback = () => { + ctx.res.renderedVariables.sharedProjectData.project_name.should.equal( + 'myProject' + ) + ctx.res.renderedVariables.sharedProjectData.user_first_name.should.equal( + 'user_first_name_here' + ) + resolve() + } + ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve) + }) }) - it('should set newTemplateData', function (done) { - this.req.session.templateData = { templateName: 'templateName' } + it('should set newTemplateData', function (ctx) { + return new Promise(resolve => { + ctx.req.session.templateData = { templateName: 'templateName' } - this.res.callback = () => { - this.res.renderedVariables.newTemplateData.templateName.should.equal( - 'templateName' - ) - done() - } - this.UserPagesController.registerPage(this.req, this.res, done) + ctx.res.callback = () => { + ctx.res.renderedVariables.newTemplateData.templateName.should.equal( + 'templateName' + ) + resolve() + } + ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve) + }) }) - it('should not set the newTemplateData if there is nothing in the session', function (done) { - this.res.callback = () => { - assert.equal( - this.res.renderedVariables.newTemplateData.templateName, - undefined - ) - done() - } - this.UserPagesController.registerPage(this.req, this.res, done) + it('should not set the newTemplateData if there is nothing in the session', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + assert.equal( + ctx.res.renderedVariables.newTemplateData.templateName, + undefined + ) + resolve() + } + ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve) + }) }) }) describe('loginForm', function () { - it('should render the login page', function (done) { - this.res.callback = () => { - this.res.renderedTemplate.should.equal('user/login') - done() - } - this.UserPagesController.loginPage(this.req, this.res, done) + it('should render the login page', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.renderedTemplate.should.equal('user/login') + resolve() + } + ctx.UserPagesController.loginPage(ctx.req, ctx.res, resolve) + }) }) describe('when an explicit redirect is set via query string', function () { - beforeEach(function () { - this.AuthenticationController.getRedirectFromSession = sinon + beforeEach(function (ctx) { + ctx.AuthenticationController.getRedirectFromSession = sinon .stub() .returns(null) - this.AuthenticationController.setRedirectInSession = sinon.stub() - this.req.query.redir = '/somewhere/in/particular' + ctx.AuthenticationController.setRedirectInSession = sinon.stub() + ctx.req.query.redir = '/somewhere/in/particular' }) - it('should set a redirect', function (done) { - this.res.callback = page => { - this.AuthenticationController.setRedirectInSession.callCount.should.equal( - 1 - ) - expect( - this.AuthenticationController.setRedirectInSession.lastCall.args[1] - ).to.equal(this.req.query.redir) - done() - } - this.UserPagesController.loginPage(this.req, this.res, done) + it('should set a redirect', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = page => { + ctx.AuthenticationController.setRedirectInSession.callCount.should.equal( + 1 + ) + expect( + ctx.AuthenticationController.setRedirectInSession.lastCall.args[1] + ).to.equal(ctx.req.query.redir) + resolve() + } + ctx.UserPagesController.loginPage(ctx.req, ctx.res, resolve) + }) }) }) }) describe('sessionsPage', function () { - beforeEach(function () { - this.UserSessionsManager.getAllUserSessions.callsArgWith(2, null, []) + beforeEach(function (ctx) { + ctx.UserSessionsManager.getAllUserSessions.callsArgWith(2, null, []) }) - it('should render user/sessions', function (done) { - this.res.callback = () => { - this.res.renderedTemplate.should.equal('user/sessions') - done() - } - this.UserPagesController.sessionsPage(this.req, this.res, done) + it('should render user/sessions', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.renderedTemplate.should.equal('user/sessions') + resolve() + } + ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve) + }) }) - it('should include current session data in the view', function (done) { - this.res.callback = () => { - expect(this.res.renderedVariables.currentSession).to.deep.equal({ - ip_address: '1.1.1.1', - session_created: 'timestamp', - }) - done() - } - this.UserPagesController.sessionsPage(this.req, this.res, done) + it('should include current session data in the view', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + expect(ctx.res.renderedVariables.currentSession).to.deep.equal({ + ip_address: '1.1.1.1', + session_created: 'timestamp', + }) + resolve() + } + ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve) + }) }) - it('should have called getAllUserSessions', function (done) { - this.res.callback = page => { - this.UserSessionsManager.getAllUserSessions.callCount.should.equal(1) - done() - } - this.UserPagesController.sessionsPage(this.req, this.res, done) + it('should have called getAllUserSessions', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = page => { + ctx.UserSessionsManager.getAllUserSessions.callCount.should.equal(1) + resolve() + } + ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve) + }) }) describe('when getAllUserSessions produces an error', function () { - beforeEach(function () { - this.UserSessionsManager.getAllUserSessions.callsArgWith( + beforeEach(function (ctx) { + ctx.UserSessionsManager.getAllUserSessions.callsArgWith( 2, new Error('woops') ) }) - it('should call next with an error', function (done) { - this.next = err => { - assert(err !== null) - assert(err instanceof Error) - done() - } - this.UserPagesController.sessionsPage(this.req, this.res, this.next) + it('should call next with an error', function (ctx) { + return new Promise(resolve => { + ctx.next = err => { + assert(err !== null) + assert(err instanceof Error) + resolve() + } + ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, ctx.next) + }) }) }) }) describe('emailPreferencesPage', function () { - beforeEach(function () { - this.UserGetter.getUser = sinon.stub().yields(null, this.user) + beforeEach(function (ctx) { + ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user) }) - it('render page with subscribed status', function (done) { - this.NewsletterManager.subscribed.yields(null, true) - this.res.callback = () => { - this.res.renderedTemplate.should.equal('user/email-preferences') - this.res.renderedVariables.title.should.equal('newsletter_info_title') - this.res.renderedVariables.subscribed.should.equal(true) - done() - } - this.UserPagesController.emailPreferencesPage(this.req, this.res, done) + it('render page with subscribed status', function (ctx) { + return new Promise(resolve => { + ctx.NewsletterManager.subscribed.yields(null, true) + ctx.res.callback = () => { + ctx.res.renderedTemplate.should.equal('user/email-preferences') + ctx.res.renderedVariables.title.should.equal('newsletter_info_title') + ctx.res.renderedVariables.subscribed.should.equal(true) + resolve() + } + ctx.UserPagesController.emailPreferencesPage(ctx.req, ctx.res, resolve) + }) }) - it('render page with unsubscribed status', function (done) { - this.NewsletterManager.subscribed.yields(null, false) - this.res.callback = () => { - this.res.renderedTemplate.should.equal('user/email-preferences') - this.res.renderedVariables.title.should.equal('newsletter_info_title') - this.res.renderedVariables.subscribed.should.equal(false) - done() - } - this.UserPagesController.emailPreferencesPage(this.req, this.res, done) + it('render page with unsubscribed status', function (ctx) { + return new Promise(resolve => { + ctx.NewsletterManager.subscribed.yields(null, false) + ctx.res.callback = () => { + ctx.res.renderedTemplate.should.equal('user/email-preferences') + ctx.res.renderedVariables.title.should.equal('newsletter_info_title') + ctx.res.renderedVariables.subscribed.should.equal(false) + resolve() + } + ctx.UserPagesController.emailPreferencesPage(ctx.req, ctx.res, resolve) + }) }) }) describe('settingsPage', function () { - beforeEach(function () { - this.request.get = sinon + beforeEach(function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith(1, null, { statusCode: 200 }, { has_password: true }) - this.UserGetter.promises.getUser = sinon.stub().resolves(this.user) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.user) }) - it('should render user/settings', function (done) { - this.res.callback = () => { - this.res.renderedTemplate.should.equal('user/settings') - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it('should render user/settings', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.renderedTemplate.should.equal('user/settings') + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) - it('should send user', function (done) { - this.res.callback = () => { - this.res.renderedVariables.user.id.should.equal(this.user._id) - this.res.renderedVariables.user.email.should.equal(this.user.email) - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it('should send user', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.renderedVariables.user.id.should.equal(ctx.user._id) + ctx.res.renderedVariables.user.email.should.equal(ctx.user.email) + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) - it("should set 'shouldAllowEditingDetails' to true", function (done) { - this.res.callback = () => { - this.res.renderedVariables.shouldAllowEditingDetails.should.equal(true) - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it("should set 'shouldAllowEditingDetails' to true", function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal(true) + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) - it('should restructure thirdPartyIdentifiers data for template use', function (done) { - const expectedResult = { - google: 'testId', - } - this.res.callback = () => { - expect(this.res.renderedVariables.thirdPartyIds).to.include( - expectedResult - ) - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it('should restructure thirdPartyIdentifiers data for template use', function (ctx) { + return new Promise(resolve => { + const expectedResult = { + google: 'testId', + } + ctx.res.callback = () => { + expect(ctx.res.renderedVariables.thirdPartyIds).to.include( + expectedResult + ) + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) - it("should set and clear 'projectSyncSuccessMessage'", function (done) { - this.req.session.projectSyncSuccessMessage = 'Some Sync Success' - this.res.callback = () => { - this.res.renderedVariables.projectSyncSuccessMessage.should.equal( - 'Some Sync Success' - ) - expect(this.req.session.projectSyncSuccessMessage).to.not.exist - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it("should set and clear 'projectSyncSuccessMessage'", function (ctx) { + return new Promise(resolve => { + ctx.req.session.projectSyncSuccessMessage = 'Some Sync Success' + ctx.res.callback = () => { + ctx.res.renderedVariables.projectSyncSuccessMessage.should.equal( + 'Some Sync Success' + ) + expect(ctx.req.session.projectSyncSuccessMessage).to.not.exist + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) - it('should cast refProviders to booleans', function (done) { - this.res.callback = () => { - expect(this.res.renderedVariables.user.refProviders).to.deep.equal({ - mendeley: true, - papers: true, - zotero: true, - }) - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it('should cast refProviders to booleans', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + expect(ctx.res.renderedVariables.user.refProviders).to.deep.equal({ + mendeley: true, + papers: true, + zotero: true, + }) + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) - it('should send the correct managed user admin email', function (done) { - this.res.callback = () => { - expect( - this.res.renderedVariables.currentManagedUserAdminEmail - ).to.equal(this.adminEmail) - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it('should send the correct managed user admin email', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + expect( + ctx.res.renderedVariables.currentManagedUserAdminEmail + ).to.equal(ctx.adminEmail) + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) - it('should send info for groups with SSO enabled', function (done) { - this.user.enrollment = { - sso: [ - { - groupId: 'abc123abc123', - primary: true, - linkedAt: new Date(), + it('should send info for groups with SSO enabled', function (ctx) { + return new Promise(resolve => { + ctx.user.enrollment = { + sso: [ + { + groupId: 'abc123abc123', + primary: true, + linkedAt: new Date(), + }, + ], + } + const group1 = { + _id: 'abc123abc123', + teamName: 'Group SSO Rulz', + admin_id: { + email: 'admin.email@ssolove.com', }, - ], - } - const group1 = { - _id: 'abc123abc123', - teamName: 'Group SSO Rulz', - admin_id: { - email: 'admin.email@ssolove.com', - }, - linked: true, - } - const group2 = { - _id: 'def456def456', - admin_id: { - email: 'someone.else@noname.co.uk', - }, - linked: false, - } - - this.Modules.promises.hooks.fire - .withArgs('getUserGroupsSSOEnrollmentStatus') - .resolves([[group1, group2]]) - - this.res.callback = () => { - expect( - this.res.renderedVariables.memberOfSSOEnabledGroups - ).to.deep.equal([ - { - groupId: 'abc123abc123', - groupName: 'Group SSO Rulz', - adminEmail: 'admin.email@ssolove.com', - linked: true, + linked: true, + } + const group2 = { + _id: 'def456def456', + admin_id: { + email: 'someone.else@noname.co.uk', }, - { - groupId: 'def456def456', - groupName: undefined, - adminEmail: 'someone.else@noname.co.uk', - linked: false, - }, - ]) - done() - } + linked: false, + } - this.UserPagesController.settingsPage(this.req, this.res, done) + ctx.Modules.promises.hooks.fire + .withArgs('getUserGroupsSSOEnrollmentStatus') + .resolves([[group1, group2]]) + + ctx.res.callback = () => { + expect( + ctx.res.renderedVariables.memberOfSSOEnabledGroups + ).to.deep.equal([ + { + groupId: 'abc123abc123', + groupName: 'Group SSO Rulz', + adminEmail: 'admin.email@ssolove.com', + linked: true, + }, + { + groupId: 'def456def456', + groupName: undefined, + adminEmail: 'someone.else@noname.co.uk', + linked: false, + }, + ]) + resolve() + } + + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) describe('when ldap.updateUserDetailsOnLogin is true', function () { - beforeEach(function () { - this.settings.ldap = { updateUserDetailsOnLogin: true } + beforeEach(function (ctx) { + ctx.settings.ldap = { updateUserDetailsOnLogin: true } }) - afterEach(function () { - delete this.settings.ldap + afterEach(function (ctx) { + delete ctx.settings.ldap }) - it('should set "shouldAllowEditingDetails" to false', function (done) { - this.res.callback = () => { - this.res.renderedVariables.shouldAllowEditingDetails.should.equal( - false - ) - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it('should set "shouldAllowEditingDetails" to false', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal( + false + ) + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) }) describe('when saml.updateUserDetailsOnLogin is true', function () { - beforeEach(function () { - this.settings.saml = { updateUserDetailsOnLogin: true } + beforeEach(function (ctx) { + ctx.settings.saml = { updateUserDetailsOnLogin: true } }) - afterEach(function () { - delete this.settings.saml + afterEach(function (ctx) { + delete ctx.settings.saml }) - it('should set "shouldAllowEditingDetails" to false', function (done) { - this.res.callback = () => { - this.res.renderedVariables.shouldAllowEditingDetails.should.equal( - false - ) - done() - } - this.UserPagesController.settingsPage(this.req, this.res, done) + it('should set "shouldAllowEditingDetails" to false', function (ctx) { + return new Promise(resolve => { + ctx.res.callback = () => { + ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal( + false + ) + resolve() + } + ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + }) }) }) }) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index f6dedf2097..55bc62cd2d 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs @@ -1,6 +1,6 @@ +import { vi } from 'vitest' import sinon from 'sinon' import { expect } from 'chai' -import esmock from 'esmock' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js' @@ -15,27 +15,39 @@ const assertCalledWith = sinon.assert.calledWith const modulePath = '../../../../app/src/Features/UserMembership/UserMembershipController.mjs' +vi.mock( + '../../../../app/src/Features/UserMembership/UserMembershipErrors.js', + () => + vi.importActual( + '../../../../app/src/Features/UserMembership/UserMembershipErrors.js' + ) +) + +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + describe('UserMembershipController', function () { - beforeEach(async function () { - this.req = new MockRequest() - this.req.params.id = 'mock-entity-id' - this.user = { _id: 'mock-user-id' } - this.newUser = { _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' } - this.subscription = { + beforeEach(async function (ctx) { + ctx.req = new MockRequest() + 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' } + ctx.subscription = { _id: 'mock-subscription-id', admin_id: 'mock-admin-id', - fetchV1Data: callback => callback(null, this.subscription), + fetchV1Data: callback => callback(null, ctx.subscription), } - this.institution = { + ctx.institution = { _id: 'mock-institution-id', v1Id: 123, fetchV1Data: callback => { - const institution = Object.assign({}, this.institution) + const institution = Object.assign({}, ctx.institution) institution.name = 'Test Institution Name' callback(null, institution) }, } - this.users = [ + ctx.users = [ { _id: 'mock-member-id-1', email: 'mock-email-1@foo.com', @@ -50,106 +62,136 @@ describe('UserMembershipController', function () { }, ] - this.Settings = { + ctx.Settings = { managedUsers: { enabled: false, }, } - this.SessionManager = { - getSessionUser: sinon.stub().returns(this.user), - getLoggedInUserId: sinon.stub().returns(this.user._id), + ctx.SessionManager = { + getSessionUser: sinon.stub().returns(ctx.user), + getLoggedInUserId: sinon.stub().returns(ctx.user._id), } - this.SSOConfig = { + ctx.SSOConfig = { findById: sinon .stub() .returns({ exec: sinon.stub().resolves({ enabled: true }) }), } - this.UserMembershipHandler = { - getEntity: sinon.stub().yields(null, this.subscription), - createEntity: sinon.stub().yields(null, this.institution), - getUsers: sinon.stub().yields(null, this.users), - addUser: sinon.stub().yields(null, this.newUser), + 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), promises: { - getUsers: sinon.stub().resolves(this.users), + getUsers: sinon.stub().resolves(ctx.users), }, } - this.SplitTestHandler = { + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({ variant: 'default' }), }, getAssignment: sinon.stub().yields(null, { variant: 'default' }), } - this.RecurlyClient = { + ctx.RecurlyClient = { promises: { getSubscription: sinon.stub().resolves({}), }, } - this.UserMembershipController = await esmock.strict(modulePath, { - '../../../../app/src/Features/UserMembership/UserMembershipErrors': { + + vi.doMock( + '../../../../app/src/Features/UserMembership/UserMembershipErrors', + () => ({ UserIsManagerError, UserNotFoundError, UserAlreadyAddedError, - }, - '../../../../app/src/Features/Authentication/SessionManager': - this.SessionManager, - '../../../../app/src/Features/SplitTests/SplitTestHandler': - this.SplitTestHandler, - '../../../../app/src/Features/UserMembership/UserMembershipHandler': - this.UserMembershipHandler, - '../../../../app/src/Features/Subscription/RecurlyClient': - this.RecurlyClient, - '@overleaf/settings': this.Settings, - '../../../../app/src/models/SSOConfig': { SSOConfig: this.SSOConfig }, - }) + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/UserMembership/UserMembershipHandler', + () => ({ + default: ctx.UserMembershipHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/RecurlyClient', + () => ({ + default: ctx.RecurlyClient, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock('../../../../app/src/models/SSOConfig', () => ({ + SSOConfig: ctx.SSOConfig, + })) + + ctx.UserMembershipController = (await import(modulePath)).default }) describe('index', function () { - beforeEach(function () { - this.req.entity = this.subscription - this.req.entityConfig = EntityConfigs.group + beforeEach(function (ctx) { + ctx.req.entity = ctx.subscription + ctx.req.entityConfig = EntityConfigs.group }) - it('get users', async function () { - await this.UserMembershipController.manageGroupMembers(this.req, { + it('get users', async function (ctx) { + await ctx.UserMembershipController.manageGroupMembers(ctx.req, { render: () => { sinon.assert.calledWithMatch( - this.UserMembershipHandler.promises.getUsers, - this.subscription, + ctx.UserMembershipHandler.promises.getUsers, + ctx.subscription, { modelName: 'Subscription' } ) }, }) }) - it('render group view', async function () { - this.subscription.managedUsersEnabled = false - await this.UserMembershipController.manageGroupMembers(this.req, { + it('render group view', async function (ctx) { + ctx.subscription.managedUsersEnabled = false + await ctx.UserMembershipController.manageGroupMembers(ctx.req, { render: (viewPath, viewParams) => { expect(viewPath).to.equal('user_membership/group-members-react') - expect(viewParams.users).to.deep.equal(this.users) - expect(viewParams.groupSize).to.equal(this.subscription.membersLimit) + expect(viewParams.users).to.deep.equal(ctx.users) + expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit) expect(viewParams.managedUsersActive).to.equal(false) }, }) }) - it('render group view with managed users', async function () { - this.subscription.managedUsersEnabled = true - await this.UserMembershipController.manageGroupMembers(this.req, { + it('render group view with managed users', async function (ctx) { + ctx.subscription.managedUsersEnabled = true + await ctx.UserMembershipController.manageGroupMembers(ctx.req, { render: (viewPath, viewParams) => { expect(viewPath).to.equal('user_membership/group-members-react') - expect(viewParams.users).to.deep.equal(this.users) - expect(viewParams.groupSize).to.equal(this.subscription.membersLimit) + expect(viewParams.users).to.deep.equal(ctx.users) + expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit) expect(viewParams.managedUsersActive).to.equal(true) }, }) }) - it('render group managers view', async function () { - this.req.entityConfig = EntityConfigs.groupManagers - await this.UserMembershipController.manageGroupManagers(this.req, { + it('render group managers view', async function (ctx) { + ctx.req.entityConfig = EntityConfigs.groupManagers + await ctx.UserMembershipController.manageGroupManagers(ctx.req, { render: (viewPath, viewParams) => { expect(viewPath).to.equal('user_membership/group-managers-react') expect(viewParams.groupSize).to.equal(undefined) @@ -157,10 +199,10 @@ describe('UserMembershipController', function () { }) }) - it('render institution view', async function () { - this.req.entity = this.institution - this.req.entityConfig = EntityConfigs.institution - await this.UserMembershipController.manageInstitutionManagers(this.req, { + it('render institution view', async function (ctx) { + ctx.req.entity = ctx.institution + ctx.req.entityConfig = EntityConfigs.institution + await ctx.UserMembershipController.manageInstitutionManagers(ctx.req, { render: (viewPath, viewParams) => { expect(viewPath).to.equal( 'user_membership/institution-managers-react' @@ -173,207 +215,233 @@ describe('UserMembershipController', function () { }) describe('add', function () { - beforeEach(function () { - this.req.body.email = this.newUser.email - this.req.entity = this.subscription - this.req.entityConfig = EntityConfigs.groupManagers + beforeEach(function (ctx) { + ctx.req.body.email = ctx.newUser.email + ctx.req.entity = ctx.subscription + ctx.req.entityConfig = EntityConfigs.groupManagers }) - it('add user', function (done) { - this.UserMembershipController.add(this.req, { - json: () => { - sinon.assert.calledWithMatch( - this.UserMembershipHandler.addUser, - this.subscription, - { modelName: 'Subscription' }, - this.newUser.email - ) - done() - }, - }) - }) - - it('return user object', function (done) { - this.UserMembershipController.add(this.req, { - json: payload => { - payload.user.should.equal(this.newUser) - done() - }, - }) - }) - - it('handle readOnly entity', function (done) { - this.req.entityConfig = EntityConfigs.group - this.UserMembershipController.add(this.req, null, error => { - expect(error).to.exist - expect(error).to.be.an.instanceof(Errors.NotFoundError) - done() - }) - }) - - it('handle user already added', function (done) { - this.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError()) - this.UserMembershipController.add(this.req, { - status: () => ({ - json: payload => { - expect(payload.error.code).to.equal('user_already_added') - done() + it('add user', function (ctx) { + return new Promise(resolve => { + ctx.UserMembershipController.add(ctx.req, { + json: () => { + sinon.assert.calledWithMatch( + ctx.UserMembershipHandler.addUser, + ctx.subscription, + { modelName: 'Subscription' }, + ctx.newUser.email + ) + resolve() }, - }), + }) }) }) - it('handle user not found', function (done) { - this.UserMembershipHandler.addUser.yields(new UserNotFoundError()) - this.UserMembershipController.add(this.req, { - status: () => ({ + it('return user object', function (ctx) { + return new Promise(resolve => { + ctx.UserMembershipController.add(ctx.req, { json: payload => { - expect(payload.error.code).to.equal('user_not_found') - done() + payload.user.should.equal(ctx.newUser) + resolve() }, - }), + }) }) }) - it('handle invalid email', function (done) { - this.req.body.email = 'not_valid_email' - this.UserMembershipController.add(this.req, { - status: () => ({ - json: payload => { - expect(payload.error.code).to.equal('invalid_email') - done() - }, - }), + it('handle readOnly entity', function (ctx) { + return 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 user already added', function (ctx) { + return new Promise(resolve => { + ctx.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError()) + ctx.UserMembershipController.add(ctx.req, { + status: () => ({ + json: payload => { + expect(payload.error.code).to.equal('user_already_added') + resolve() + }, + }), + }) + }) + }) + + it('handle user not found', function (ctx) { + return 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', function (ctx) { + return 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() + }, + }), + }) }) }) }) describe('remove', function () { - beforeEach(function () { - this.req.params.userId = this.newUser._id - this.req.entity = this.subscription - this.req.entityConfig = EntityConfigs.groupManagers + beforeEach(function (ctx) { + ctx.req.params.userId = ctx.newUser._id + ctx.req.entity = ctx.subscription + ctx.req.entityConfig = EntityConfigs.groupManagers }) - it('remove user', function (done) { - this.UserMembershipController.remove(this.req, { - sendStatus: () => { - sinon.assert.calledWithMatch( - this.UserMembershipHandler.removeUser, - this.subscription, - { modelName: 'Subscription' }, - this.newUser._id - ) - done() - }, - }) - }) - - it('handle readOnly entity', function (done) { - this.req.entityConfig = EntityConfigs.group - this.UserMembershipController.remove(this.req, null, error => { - expect(error).to.exist - expect(error).to.be.an.instanceof(Errors.NotFoundError) - done() - }) - }) - - it('prevent self removal', function (done) { - this.req.params.userId = this.user._id - this.UserMembershipController.remove(this.req, { - status: () => ({ - json: payload => { - expect(payload.error.code).to.equal('managers_cannot_remove_self') - done() + it('remove user', function (ctx) { + return new Promise(resolve => { + ctx.UserMembershipController.remove(ctx.req, { + sendStatus: () => { + sinon.assert.calledWithMatch( + ctx.UserMembershipHandler.removeUser, + ctx.subscription, + { modelName: 'Subscription' }, + ctx.newUser._id + ) + resolve() }, - }), + }) }) }) - it('prevent admin removal', function (done) { - this.UserMembershipHandler.removeUser.yields(new UserIsManagerError()) - this.UserMembershipController.remove(this.req, { - status: () => ({ - json: payload => { - expect(payload.error.code).to.equal('managers_cannot_remove_admin') - done() - }, - }), + it('handle readOnly entity', function (ctx) { + return 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', function (ctx) { + return 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', function (ctx) { + return 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() + }, + }), + }) }) }) }) describe('exportCsv', function () { - beforeEach(function () { - this.req.entity = this.subscription - this.req.entityConfig = EntityConfigs.groupManagers - this.res = new MockResponse() - this.UserMembershipController.exportCsv(this.req, this.res) + beforeEach(function (ctx) { + ctx.req.entity = ctx.subscription + ctx.req.entityConfig = EntityConfigs.groupManagers + ctx.res = new MockResponse() + ctx.UserMembershipController.exportCsv(ctx.req, ctx.res) }) - it('get users', function () { + it('get users', function (ctx) { sinon.assert.calledWithMatch( - this.UserMembershipHandler.getUsers, - this.subscription, + ctx.UserMembershipHandler.getUsers, + ctx.subscription, { modelName: 'Subscription' } ) }) - it('should set the correct content type on the request', function () { - assertCalledWith(this.res.contentType, 'text/csv; charset=utf-8') + it('should set the correct content type on the request', function (ctx) { + assertCalledWith(ctx.res.contentType, 'text/csv; charset=utf-8') }) - it('should name the exported csv file', function () { + it('should name the exported csv file', function (ctx) { assertCalledWith( - this.res.header, + ctx.res.header, 'Content-Disposition', 'attachment; filename="Group.csv"' ) }) - it('should export the correct csv', function () { + it('should export the correct csv', function (ctx) { assertCalledWith( - this.res.send, + ctx.res.send, '"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"' ) }) }) describe('new', function () { - beforeEach(function () { - this.req.params.name = 'publisher' - this.req.params.id = 'abc' + beforeEach(function (ctx) { + ctx.req.params.name = 'publisher' + ctx.req.params.id = 'abc' }) - it('renders view', function (done) { - this.UserMembershipController.new(this.req, { - render: (viewPath, data) => { - expect(data.entityName).to.eq('publisher') - expect(data.entityId).to.eq('abc') - done() - }, + it('renders view', function (ctx) { + return new Promise(resolve => { + ctx.UserMembershipController.new(ctx.req, { + render: (viewPath, data) => { + expect(data.entityName).to.eq('publisher') + expect(data.entityId).to.eq('abc') + resolve() + }, + }) }) }) }) describe('create', function () { - beforeEach(function () { - this.req.params.name = 'institution' - this.req.entityConfig = EntityConfigs.institution - this.req.params.id = 123 + beforeEach(function (ctx) { + ctx.req.params.name = 'institution' + ctx.req.entityConfig = EntityConfigs.institution + ctx.req.params.id = 123 }) - it('creates institution', function (done) { - this.UserMembershipController.create(this.req, { - redirect: path => { - expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index) - sinon.assert.calledWithMatch( - this.UserMembershipHandler.createEntity, - 123, - { modelName: 'Institution' } - ) - done() - }, + it('creates institution', function (ctx) { + return 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() + }, + }) }) }) }) diff --git a/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs b/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs index 01fe5d7a0d..4d8479a9cb 100644 --- a/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs +++ b/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs @@ -1,24 +1,22 @@ -import { strict as esmock } from 'esmock' +import { vi } from 'vitest' import { expect } from 'chai' import Path from 'node:path' -import { fileURLToPath } from 'node:url' import sinon from 'sinon' import MockResponse from '../helpers/MockResponse.js' import MockRequest from '../helpers/MockRequest.js' -const __dirname = fileURLToPath(new URL('.', import.meta.url)) const modulePath = Path.join( - __dirname, + import.meta.dirname, '../../../../app/src/infrastructure/ServeStaticWrapper' ) describe('ServeStaticWrapperTests', function () { let error = null - beforeEach(async function () { - this.req = new MockRequest() - this.res = new MockResponse() - this.express = { + beforeEach(async function (ctx) { + ctx.req = new MockRequest() + ctx.res = new MockResponse() + ctx.express = { static: () => (req, res, next) => { if (error) { next(error) @@ -27,36 +25,39 @@ describe('ServeStaticWrapperTests', function () { } }, } - this.serveStaticWrapper = await esmock(modulePath, { - express: this.express, - }) + + vi.doMock('express', () => ({ + default: ctx.express, + })) + + ctx.serveStaticWrapper = (await import(modulePath)).default }) - this.afterEach(() => { + afterEach(() => { error = null }) - it('Premature close error thrown', async function () { + it('Premature close error thrown', async function (ctx) { error = new Error() error.code = 'ERR_STREAM_PREMATURE_CLOSE' - const middleware = this.serveStaticWrapper('test_folder', {}) + const middleware = ctx.serveStaticWrapper('test_folder', {}) const next = sinon.stub() - middleware(this.req, this.res, next) + middleware(ctx.req, ctx.res, next) expect(next.called).to.be.false }) - it('No error thrown', async function () { - const middleware = this.serveStaticWrapper('test_folder', {}) + it('No error thrown', async function (ctx) { + const middleware = ctx.serveStaticWrapper('test_folder', {}) const next = sinon.stub() - middleware(this.req, this.res, next) + middleware(ctx.req, ctx.res, next) expect(next).to.be.calledWith() }) - it('Other error thrown', async function () { + it('Other error thrown', async function (ctx) { error = new Error() - const middleware = this.serveStaticWrapper('test_folder', {}) + const middleware = ctx.serveStaticWrapper('test_folder', {}) const next = sinon.stub() - middleware(this.req, this.res, next) + middleware(ctx.req, ctx.res, next) expect(next).to.be.calledWith(error) }) }) From 5b764953c0beecfc0cbfb6910c0d218f80ca1359 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Tue, 6 May 2025 14:11:51 +0100 Subject: [PATCH 033/259] Add eslint rules for skipped/focused tests (and fix issues) GitOrigin-RevId: 01735e0805a28609a68df667cd2a4c3d89c5b968 --- services/web/.eslintrc.js | 4 ++++ services/web/test/unit/src/Referal/ReferalController.test.mjs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/services/web/.eslintrc.js b/services/web/.eslintrc.js index b505080b98..2fa9e8f547 100644 --- a/services/web/.eslintrc.js +++ b/services/web/.eslintrc.js @@ -109,6 +109,10 @@ module.exports = { }, plugins: ['@vitest', 'chai-expect', 'chai-friendly'], // still using chai for now rules: { + // vitest-specific rules + '@vitest/no-focused-tests': 'error', + '@vitest/no-disabled-tests': 'error', + // Swap the no-unused-expressions rule with a more chai-friendly one 'no-unused-expressions': 'off', 'chai-friendly/no-unused-expressions': 'error', diff --git a/services/web/test/unit/src/Referal/ReferalController.test.mjs b/services/web/test/unit/src/Referal/ReferalController.test.mjs index 0a7b8aa87d..383902946f 100644 --- a/services/web/test/unit/src/Referal/ReferalController.test.mjs +++ b/services/web/test/unit/src/Referal/ReferalController.test.mjs @@ -1,6 +1,6 @@ const modulePath = '../../../../app/src/Features/Referal/ReferalController.js' -describe.skip('Referal controller', function () { +describe.todo('Referal controller', function () { beforeEach(async function (ctx) { ctx.controller = (await import(modulePath)).default }) From ee8044d1624fd286535d301c61265359d8eaf312 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 15 May 2025 17:34:18 +0100 Subject: [PATCH 034/259] Update script to handle multiple directories and no vitest tests scenarios GitOrigin-RevId: 92a394387c2326d350b64c6a25e3b34c92e342aa --- services/web/bin/test_unit_run_dir | 57 +++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/services/web/bin/test_unit_run_dir b/services/web/bin/test_unit_run_dir index 20f580cf06..50ab911b33 100755 --- a/services/web/bin/test_unit_run_dir +++ b/services/web/bin/test_unit_run_dir @@ -1,9 +1,28 @@ #!/bin/bash -TARGET_DIR=$1 +declare -a vitest_args=("$@") +has_mocha_test=0 +has_mocha_mjs_test=0 +has_vitest_test=0 -declare -a vitest_args=("$TARGET_DIR") +mocha_mjs_dirs=() + +for dir_path in "$@"; do + if [ -n "$(find "$dir_path" -name "*.js" -type f -print -quit 2>/dev/null)" ]; then + has_mocha_test=1 + fi + + if [ -n "$(find "$dir_path" -name "*Tests.mjs" -type f -print -quit 2>/dev/null)" ]; then + has_mocha_mjs_test=1 + mocha_mjs_dirs+=("$dir_path/**/*Tests.mjs") + fi + + if [ -n "$(find "$dir_path" -name "*.test.mjs" -type f -print -quit 2>/dev/null)" ]; then + has_vitest_test=1 + fi + +done if [[ -n "$MOCHA_GREP" ]]; then vitest_args+=("--testNamePattern" "$MOCHA_GREP") @@ -14,31 +33,43 @@ if [[ -n "$VITEST_NO_CACHE" ]]; then vitest_args+=("--no-cache") fi -echo "Running unit tests in directory: $TARGET_DIR" +echo "Running unit tests in directory: $*" -npm run test:unit:esm -- "${vitest_args[@]}" - -vitest_status=$? - -if find "$TARGET_DIR" -type f -name "*.js" -print -quit | grep -q '.'; then - mocha --recursive --timeout 25000 --exit --grep="$MOCHA_GREP" --require test/unit/bootstrap.js --extension=js "$TARGET_DIR" - mocha_status=$? +# Remove this if/else when we have converted all module tests to vitest. +if (( has_vitest_test == 1 )); then + npm run test:unit:esm -- "${vitest_args[@]}" + vitest_status=$? else + echo "No vitest tests found in $*, skipping vitest step." + vitest_status=0 +fi + +if (( has_mocha_test == 1 )); then + mocha --recursive --timeout 25000 --exit --grep="$MOCHA_GREP" --require test/unit/bootstrap.js --extension=js "$@" + mocha_status=$? +fi +# Remove this if/else when we have converted all module tests to vitest. +if (( has_mocha_mjs_test == 1)); then + mocha --recursive --timeout 25000 --exit --grep="$MOCHA_GREP" --require test/unit/bootstrap.js --extension=mjs "${mocha_mjs_dirs[@]}" + mocha_status=$((mocha_status != 0 ? mocha_status : $?)) +fi + +if (( has_mocha_mjs_test == 0 && has_mocha_test == 0 )); then echo "No mocha tests found in $TARGET_DIR, skipping mocha step." mocha_status=0 fi -if [ $mocha_status -eq 0 ] && [ $vitest_status -eq 0 ]; then +if [ "$mocha_status" -eq 0 ] && [ "$vitest_status" -eq 0 ]; then exit 0 fi # Report status briefly at the end for failures -if [ $mocha_status -ne 0 ]; then +if [ "$mocha_status" -ne 0 ]; then echo "Mocha tests failed with status: $mocha_status" fi -if [ $vitest_status -ne 0 ]; then +if [ "$vitest_status" -ne 0 ]; then echo "Vitest tests failed with status: $vitest_status" fi From b35b54cb807b34fab53c16c7f1a069690961c748 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 15 May 2025 17:35:12 +0100 Subject: [PATCH 035/259] Use vi for logger mocks GitOrigin-RevId: aeff4a82f96300ec3f81c8418e8373e923b8c4d4 --- services/web/test/unit/vitest_bootstrap.mjs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/services/web/test/unit/vitest_bootstrap.mjs b/services/web/test/unit/vitest_bootstrap.mjs index fc4d883b1a..2244faefd3 100644 --- a/services/web/test/unit/vitest_bootstrap.mjs +++ b/services/web/test/unit/vitest_bootstrap.mjs @@ -4,16 +4,15 @@ import sinon from 'sinon' import logger from '@overleaf/logger' vi.mock('@overleaf/logger', async () => { - const sinon = (await import('sinon')).default return { default: { - debug: sinon.stub(), - info: sinon.stub(), - log: sinon.stub(), - warn: sinon.stub(), - err: sinon.stub(), - error: sinon.stub(), - fatal: sinon.stub(), + debug: vi.fn(), + info: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + err: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), }, } }) From 18c0634011ad80fb55acdec330670a3fa9147349 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 15 May 2025 17:36:03 +0100 Subject: [PATCH 036/259] Disable test isolation Isolation isn't required and it takes the setup contribution to our tests down from over 60 seconds to single figures, greatly speeding up the tests. GitOrigin-RevId: 72516e420583fa2dfcef13f2cc50b0769a100baf --- services/web/vitest.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/vitest.config.js b/services/web/vitest.config.js index 3b84690447..51f4ed811f 100644 --- a/services/web/vitest.config.js +++ b/services/web/vitest.config.js @@ -8,5 +8,6 @@ module.exports = defineConfig({ ], setupFiles: ['./test/unit/vitest_bootstrap.mjs'], globals: true, + isolate: false, }, }) From c8d4b644bf7814501c7699868e1440384be517f6 Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Wed, 28 May 2025 12:48:43 +0200 Subject: [PATCH 037/259] Update the Labs button's content and border colour (#25942) GitOrigin-RevId: 36de10a13ff5d8721ffcac25c5c002fe25f7a125 --- .../frontend/stylesheets/bootstrap-5/abstracts/mixins.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss index d1a823a120..1a79a1221c 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss @@ -62,9 +62,9 @@ @mixin labs-button { @include ol-button-variant( - $color: var(--content-positive), + $color: var(--green-60), $background: var(--bg-accent-03), - $border: var(--green-40), + $border: var(--green-50), $hover-background: var(--bg-accent-03), $hover-border: var(--green-40), $borderless: false From 9f821b4cfa5dbf78cfaf2443aa88de652ae68429 Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Wed, 28 May 2025 12:48:51 +0200 Subject: [PATCH 038/259] Add landmark for the cookie banner and update its links color (#25823) * Update cookie banner link color * Add landmark for the cookie banner GitOrigin-RevId: 9500cdfd7ddacbc2442680ed477ca1ac793720f7 --- services/web/app/views/_cookie_banner.pug | 4 ++-- .../frontend/stylesheets/bootstrap-5/components/footer.scss | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/services/web/app/views/_cookie_banner.pug b/services/web/app/views/_cookie_banner.pug index a164e48e83..2d5631f9c8 100644 --- a/services/web/app/views/_cookie_banner.pug +++ b/services/web/app/views/_cookie_banner.pug @@ -1,5 +1,5 @@ -.cookie-banner.hidden-print.hidden +section.cookie-banner.hidden-print.hidden(aria-label="Cookie banner") .cookie-banner-content We only use cookies for essential purposes and to improve your experience on our site. You can find out more in our cookie policy. .cookie-banner-actions button(type="button" class="btn btn-link btn-sm" data-ol-cookie-banner-set-consent="essential") Essential cookies only - button(type="button" class="btn btn-primary btn-sm" data-ol-cookie-banner-set-consent="all") Accept all cookies \ No newline at end of file + button(type="button" class="btn btn-primary btn-sm" data-ol-cookie-banner-set-consent="all") Accept all cookies diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/footer.scss b/services/web/frontend/stylesheets/bootstrap-5/components/footer.scss index 139994ab08..cd92669668 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/footer.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/footer.scss @@ -395,6 +395,11 @@ footer.site-footer { white-space: nowrap; padding-top: var(--spacing-00); } + + .cookie-banner-content a, + .cookie-banner-actions .btn-link { + color: var(--link-ui); + } } } From b525a80d281e4ec62271a457218fb2f65d9d73f2 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Wed, 28 May 2025 15:22:23 +0100 Subject: [PATCH 039/259] Merge pull request #25470 from overleaf/bg-history-redis-downgrade-job-related-errors downgrade expected job errors in scanAndProcessDueItems GitOrigin-RevId: 0a2689699bfc6512c5017c7f5e51ac4f80c409fe --- services/history-v1/storage/lib/scan.js | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/services/history-v1/storage/lib/scan.js b/services/history-v1/storage/lib/scan.js index fe4b8d514e..2d9a0fd445 100644 --- a/services/history-v1/storage/lib/scan.js +++ b/services/history-v1/storage/lib/scan.js @@ -1,5 +1,5 @@ const logger = require('@overleaf/logger') - +const { JobNotFoundError, JobNotReadyError } = require('./errors') const BATCH_SIZE = 1000 // Default batch size for SCAN /** @@ -147,10 +147,24 @@ async function scanAndProcessDueItems( `Successfully performed ${taskName} for project` ) } catch (err) { - logger.error( - { ...logContext, projectId, err }, - `Error performing ${taskName} for project` - ) + if (err instanceof JobNotReadyError) { + // the project has been touched since the job was created + logger.info( + { ...logContext, projectId }, + `Job not ready for ${taskName} for project` + ) + } else if (err instanceof JobNotFoundError) { + // the project has been expired already by another worker + logger.info( + { ...logContext, projectId }, + `Job not found for ${taskName} for project` + ) + } else { + logger.error( + { ...logContext, projectId, err }, + `Error performing ${taskName} for project` + ) + } continue } } From 3296fc15da3957243a6a26b2c56a3d98ce4caa53 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Wed, 28 May 2025 15:22:32 +0100 Subject: [PATCH 040/259] Merge pull request #25905 from overleaf/bg-history-redis-fix-import-path fix import path for Job errors in history-v1 GitOrigin-RevId: f5f88bd34e713cd2ed78185ed4ce917e10d09caf --- services/history-v1/storage/lib/scan.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/history-v1/storage/lib/scan.js b/services/history-v1/storage/lib/scan.js index 2d9a0fd445..1f2a335254 100644 --- a/services/history-v1/storage/lib/scan.js +++ b/services/history-v1/storage/lib/scan.js @@ -1,5 +1,5 @@ const logger = require('@overleaf/logger') -const { JobNotFoundError, JobNotReadyError } = require('./errors') +const { JobNotFoundError, JobNotReadyError } = require('./chunk_store/errors') const BATCH_SIZE = 1000 // Default batch size for SCAN /** From d49a8f83df9d44fa1003da22d02cab696239bbd4 Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Wed, 28 May 2025 10:38:36 -0400 Subject: [PATCH 041/259] Revert Recurly based subscription upgrades on failed payments (#25824) * feat: add ability to set restore point for subscriptions * feat: update recurly client with ability to get past due invoices and fail invoices * utility to retrieve last valid subscription * create revert requests and fail invoices, revert subscriptions to previous valid states on failed upgrade payments * add restore point and call to revert plans on failed payments * code style for PaymentProviderEntities * moving subs restore point check to SubscriptionController, and removing unecessary error * adding ability to stop sub restores without a deploy * ensure that subs restore point is set before changing plan * changing reverted flag on subscription to count, and only reverting automatic invoices * updating tests with restorepoint functions * rethrow error after voiding restore point, and ensure that recurly failed_payment always gets a 200 response * only void restore point if the changeRequest fails GitOrigin-RevId: cf3074c13db22d1cf680b59c4d57817c390db23e --- .../web/app/src/Features/Errors/Errors.js | 3 + .../Subscription/PaymentProviderEntities.js | 38 +++++++++ .../Features/Subscription/RecurlyClient.js | 36 ++++++++ .../Subscription/SubscriptionController.js | 32 +++++++- .../Subscription/SubscriptionHandler.js | 82 ++++++++++++++++++- .../Subscription/SubscriptionLocator.js | 28 ++++++- .../Subscription/SubscriptionUpdater.js | 54 ++++++++++++ services/web/app/src/models/Subscription.js | 7 ++ 8 files changed, 277 insertions(+), 3 deletions(-) diff --git a/services/web/app/src/Features/Errors/Errors.js b/services/web/app/src/Features/Errors/Errors.js index 4b1b7dd064..487b8cbd03 100644 --- a/services/web/app/src/Features/Errors/Errors.js +++ b/services/web/app/src/Features/Errors/Errors.js @@ -47,6 +47,8 @@ class DuplicateNameError extends OError {} class InvalidNameError extends BackwardCompatibleError {} +class IndeterminateInvoiceError extends OError {} + class UnsupportedFileTypeError extends BackwardCompatibleError {} class FileTooLargeError extends BackwardCompatibleError {} @@ -333,6 +335,7 @@ module.exports = { UnconfirmedEmailError, EmailExistsError, InvalidError, + IndeterminateInvoiceError, NotInV2Error, OutputFileFetchFailedError, SAMLAssertionAudienceMismatch, diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js index f6a8af4aa5..6fe8638389 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js @@ -2,6 +2,7 @@ /** * @import { PaymentProvider } from '../../../../types/subscription/dashboard/subscription' + * @import { AddOn } from '../../../../types/subscription/plan' */ const OError = require('@overleaf/o-error') @@ -254,6 +255,43 @@ class PaymentProviderSubscription { }) } + /** + * Form a request to revert the plan to it's last saved backup state + * + * @param {string} previousPlanCode + * @param {Array | null} previousAddOns + * @return {PaymentProviderSubscriptionChangeRequest} + * + * @throws {OError} if the restore point plan doesnt exist + */ + getRequestForPlanRevert(previousPlanCode, previousAddOns) { + const lastSuccessfulPlan = + PlansLocator.findLocalPlanInSettings(previousPlanCode) + if (lastSuccessfulPlan == null) { + throw new OError('Unable to find plan in settings', { previousPlanCode }) + } + const changeRequest = new PaymentProviderSubscriptionChangeRequest({ + subscription: this, + timeframe: 'now', + planCode: previousPlanCode, + }) + + // defaulting to empty array is important, as that will wipe away any add-ons that were added in the failed payment + // but were not part of the last successful subscription + const addOns = [] + for (const previousAddon of previousAddOns || []) { + const addOnUpdate = new PaymentProviderSubscriptionAddOnUpdate({ + code: previousAddon.addOnCode, + quantity: previousAddon.quantity, + unitPrice: previousAddon.unitAmountInCents / 100, + }) + addOns.push(addOnUpdate) + } + changeRequest.addOnUpdates = addOns + + return changeRequest + } + /** * Upgrade group plan with the plan code provided * diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index fdb3b023e6..753d49ba0f 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -685,6 +685,38 @@ function subscriptionUpdateRequestToApi(updateRequest) { return requestBody } +/** + * Retrieves a list of failed invoices for a given Recurly subscription ID. + * + * @async + * @function + * @param {string} subscriptionId - The ID of the Recurly subscription to fetch failed invoices for. + * @returns {Promise>} A promise that resolves to an array of failed invoice objects. + */ +async function getPastDueInvoices(subscriptionId) { + const failed = [] + const invoices = client.listSubscriptionInvoices(`uuid-${subscriptionId}`, { + params: { state: 'past_due' }, + }) + + for await (const invoice of invoices.each()) { + failed.push(invoice) + } + return failed +} + +/** + * Marks an invoice as failed using the Recurly client. + * + * @async + * @function failInvoice + * @param {string} invoiceId - The ID of the invoice to be marked as failed. + * @returns {Promise} Resolves when the invoice has been successfully marked as failed. + */ +async function failInvoice(invoiceId) { + await client.markInvoiceFailed(invoiceId) +} + module.exports = { errors: recurly.errors, @@ -706,6 +738,8 @@ module.exports = { subscriptionIsCanceledOrExpired, pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid), resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid), + getPastDueInvoices: callbackify(getPastDueInvoices), + failInvoice: callbackify(failInvoice), promises: { getSubscription, @@ -726,5 +760,7 @@ module.exports = { getPaymentMethod, getAddOn, getPlan, + getPastDueInvoices, + failInvoice, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index db278b23c0..1cc2ad0094 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -410,6 +410,8 @@ async function purchaseAddon(req, res, next) { logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons') try { + // set a restore point in the case of a failed payment for the upgrade (Recurly only) + await SubscriptionHandler.promises.setSubscriptionRestorePoint(user._id) await SubscriptionHandler.promises.purchaseAddon( user._id, addOnCode, @@ -574,7 +576,35 @@ function recurlyCallback(req, res, next) { ) ) - if ( + // this is a recurly only case which is required since Recurly does not have a reliable way to check credit info pre-upgrade purchase + if (event === 'failed_payment_notification') { + if (!Settings.planReverts?.enabled) { + return res.sendStatus(200) + } + + SubscriptionHandler.getSubscriptionRestorePoint( + eventData.transaction.subscription_id, + function (err, lastSubscription) { + if (err) { + return next(err) + } + // if theres no restore point it could be a failed renewal, or no restore set. Either way it will be handled through dunning automatically + if (!lastSubscription || !lastSubscription?.planCode) { + res.sendStatus(200) + } + SubscriptionHandler.revertPlanChange( + eventData.transaction.subscription_id, + lastSubscription, + function (err) { + if (err) { + return next(err) + } + res.sendStatus(200) + } + ) + } + ) + } else if ( [ 'new_subscription_notification', 'updated_subscription_notification', diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 39a44f305f..1296a2a7de 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -11,7 +11,7 @@ const LimitationsManager = require('./LimitationsManager') const EmailHandler = require('../Email/EmailHandler') const { callbackify } = require('@overleaf/promise-utils') const UserUpdater = require('../User/UserUpdater') -const { NotFoundError } = require('../Errors/Errors') +const { NotFoundError, IndeterminateInvoiceError } = require('../Errors/Errors') const Modules = require('../../infrastructure/Modules') /** @@ -387,6 +387,80 @@ async function resumeSubscription(user) { ) } +/** + * @param recurlySubscriptionId + */ +async function getSubscriptionRestorePoint(recurlySubscriptionId) { + const lastSubscription = + await SubscriptionLocator.promises.getLastSuccessfulSubscription( + recurlySubscriptionId + ) + return lastSubscription +} + +/** + * @param recurlySubscriptionId + * @param subscriptionRestorePoint + */ +async function revertPlanChange( + recurlySubscriptionId, + subscriptionRestorePoint +) { + const subscription = await RecurlyClient.promises.getSubscription( + recurlySubscriptionId + ) + + const changeRequest = subscription.getRequestForPlanRevert( + subscriptionRestorePoint.planCode, + subscriptionRestorePoint.addOns + ) + + const pastDue = await RecurlyClient.promises.getPastDueInvoices( + recurlySubscriptionId + ) + + // only process revert requests within the past 24 hours, as we dont want to restore plans at the end of their dunning cycle + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + if ( + pastDue.length !== 1 || + !pastDue[0].id || + !pastDue[0].dueAt || + pastDue[0].dueAt < yesterday || + pastDue[0].collectionMethod !== 'automatic' + ) { + throw new IndeterminateInvoiceError( + 'cant determine invoice to fail for plan revert', + { + info: { recurlySubscriptionId }, + } + ) + } + + await RecurlyClient.promises.failInvoice(pastDue[0].id) + await SubscriptionUpdater.promises.setSubscriptionWasReverted( + subscriptionRestorePoint._id + ) + await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) + await syncSubscription({ uuid: recurlySubscriptionId }, {}) +} + +async function setSubscriptionRestorePoint(userId) { + const subscription = + await SubscriptionLocator.promises.getUsersSubscription(userId) + // if the subscription is not a recurly one, we can return early as we dont allow for failed payments on other payment providers + // we need to deal with it for recurly, because we cant verify payment in advance + if (!subscription?.recurlySubscription_id || !subscription.planCode) { + return + } + await SubscriptionUpdater.promises.setRestorePoint( + subscription.id, + subscription.planCode, + subscription.addOns, + false + ) +} + module.exports = { validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly), createSubscription: callbackify(createSubscription), @@ -403,6 +477,9 @@ module.exports = { removeAddon: callbackify(removeAddon), pauseSubscription: callbackify(pauseSubscription), resumeSubscription: callbackify(resumeSubscription), + revertPlanChange: callbackify(revertPlanChange), + setSubscriptionRestorePoint: callbackify(setSubscriptionRestorePoint), + getSubscriptionRestorePoint: callbackify(getSubscriptionRestorePoint), promises: { validateNoSubscriptionInRecurly, createSubscription, @@ -419,5 +496,8 @@ module.exports = { removeAddon, pauseSubscription, resumeSubscription, + revertPlanChange, + setSubscriptionRestorePoint, + getSubscriptionRestorePoint, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js index 8526ad0fb2..978f4d41b7 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js +++ b/services/web/app/src/Features/Subscription/SubscriptionLocator.js @@ -1,3 +1,7 @@ +/** + * @import { AddOn } from '../../../../types/subscription/plan' + */ + const { callbackifyAll } = require('@overleaf/promise-utils') const { Subscription } = require('../../models/Subscription') const { DeletedSubscription } = require('../../models/DeletedSubscription') @@ -124,7 +128,8 @@ const SubscriptionLocator = { // todo: as opposed to recurlyEntities which use addon.code, subscription model uses addon.addOnCode // which we hope to align via https://github.com/overleaf/internal/issues/25494 return Boolean( - isStandaloneAiAddOnPlanCode(subscription?.planCode) || + (subscription?.planCode && + isStandaloneAiAddOnPlanCode(subscription?.planCode)) || subscription?.addOns?.some(addOn => addOn.addOnCode === AI_ADD_ON_CODE) ) }, @@ -136,6 +141,27 @@ const SubscriptionLocator = { return userOrId } }, + + /** + * Retrieves the last successful subscription for a given user. + * + * @async + * @function + * @param {string} recurlyId - The ID of the recurly subscription tied to the mongo subscription to check for a previous successful state. + * @returns {Promise<{_id: ObjectId, planCode: string, addOns: [AddOn]}|null>} A promise that resolves to the last successful planCode and addon state, + * or null if we havent stored a previous + */ + async getLastSuccessfulSubscription(recurlyId) { + const subscription = await Subscription.findOne({ + recurlySubscription_id: recurlyId, + }).exec() + return subscription && subscription.lastSuccesfulSubscription + ? { + ...subscription.lastSuccesfulSubscription, + _id: subscription._id, + } + : null + }, } module.exports = { diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index 15f61b6160..b0e24ce5ad 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js @@ -19,6 +19,7 @@ const Modules = require('../../infrastructure/Modules') * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription * @typedef {import('../../../../types/subscription/dashboard/subscription').PaymentProvider} PaymentProvider * @typedef {import('../../../../types/group-management/group-audit-log').GroupAuditLog} GroupAuditLog + * @import { AddOn } from '../../../../types/subscription/plan' */ /** @@ -486,6 +487,53 @@ async function _sendSubscriptionEventForAllMembers(subscriptionId, event) { } } +/** + * Sets the plan code and addon state to revert the plan to in case of failed upgrades, or clears the last restore point if it was used/ voided + * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for + * @param {string} planCode the plan code to revert to + * @param {Array} addOns the addOns to revert to + * @param {Boolean} consumed whether the restore point was used to revert a subscription + */ +async function setRestorePoint(subscriptionId, planCode, addOns, consumed) { + const update = { + $set: { + 'lastSuccesfulSubscription.planCode': planCode, + 'lastSuccesfulSubscription.addOns': addOns, + }, + } + + if (consumed) { + update.$inc = { revertedDueToFailedPayment: 1 } + } + + await Subscription.updateOne({ _id: subscriptionId }, update).exec() +} + +/** + * Clears the restore point for a given subscription, and signals that the subscription was sucessfully reverted. + * + * @async + * @function setSubscriptionWasReverted + * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for + * @returns {Promise} Resolves when the restore point has been cleared. + */ +async function setSubscriptionWasReverted(subscriptionId) { + // consume the backup and flag that the subscription was reverted due to failed payment + await setRestorePoint(subscriptionId, null, null, true) +} + +/** + * Clears the restore point for a given subscription, and signals that the subscription was not reverted. + * + * @async + * @function voidRestorePoint + * @param {string} subscriptionId - The unique identifier of the subscription. + * @returns {Promise} Resolves when the restore point has been cleared. + */ +async function voidRestorePoint(subscriptionId) { + await setRestorePoint(subscriptionId, null, null, false) +} + module.exports = { updateAdmin: callbackify(updateAdmin), syncSubscription: callbackify(syncSubscription), @@ -500,6 +548,9 @@ module.exports = { restoreSubscription: callbackify(restoreSubscription), updateSubscriptionFromRecurly: callbackify(updateSubscriptionFromRecurly), scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures), + setSubscriptionRestorePoint: callbackify(setRestorePoint), + setSubscriptionWasReverted: callbackify(setSubscriptionWasReverted), + voidRestorePoint: callbackify(voidRestorePoint), promises: { updateAdmin, syncSubscription, @@ -514,5 +565,8 @@ module.exports = { restoreSubscription, updateSubscriptionFromRecurly, scheduleRefreshFeatures, + setRestorePoint, + setSubscriptionWasReverted, + voidRestorePoint, }, } diff --git a/services/web/app/src/models/Subscription.js b/services/web/app/src/models/Subscription.js index 92a7739515..4a5fed6f1f 100644 --- a/services/web/app/src/models/Subscription.js +++ b/services/web/app/src/models/Subscription.js @@ -25,6 +25,13 @@ const SubscriptionSchema = new Schema( invited_emails: [String], teamInvites: [TeamInviteSchema], recurlySubscription_id: String, + lastSuccesfulSubscription: { + planCode: { + type: String, + }, + addOns: Schema.Types.Mixed, + }, + timesRevertedDueToFailedPayment: { type: Number, default: 0 }, teamName: { type: String }, teamNotice: { type: String }, planCode: { type: String }, From de4a80ef935bcd3f372ff29211188100114ba623 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 28 May 2025 14:24:59 +0100 Subject: [PATCH 042/259] Update unit test script to remove mocha module tests GitOrigin-RevId: 3bcc265e32486a179dd473233bed27ed798fba47 --- services/web/bin/test_unit_run_dir | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/services/web/bin/test_unit_run_dir b/services/web/bin/test_unit_run_dir index 50ab911b33..4d5d5ecb9a 100755 --- a/services/web/bin/test_unit_run_dir +++ b/services/web/bin/test_unit_run_dir @@ -3,25 +3,16 @@ declare -a vitest_args=("$@") has_mocha_test=0 -has_mocha_mjs_test=0 has_vitest_test=0 -mocha_mjs_dirs=() - for dir_path in "$@"; do if [ -n "$(find "$dir_path" -name "*.js" -type f -print -quit 2>/dev/null)" ]; then has_mocha_test=1 fi - if [ -n "$(find "$dir_path" -name "*Tests.mjs" -type f -print -quit 2>/dev/null)" ]; then - has_mocha_mjs_test=1 - mocha_mjs_dirs+=("$dir_path/**/*Tests.mjs") - fi - if [ -n "$(find "$dir_path" -name "*.test.mjs" -type f -print -quit 2>/dev/null)" ]; then has_vitest_test=1 fi - done if [[ -n "$MOCHA_GREP" ]]; then @@ -47,14 +38,7 @@ fi if (( has_mocha_test == 1 )); then mocha --recursive --timeout 25000 --exit --grep="$MOCHA_GREP" --require test/unit/bootstrap.js --extension=js "$@" mocha_status=$? -fi -# Remove this if/else when we have converted all module tests to vitest. -if (( has_mocha_mjs_test == 1)); then - mocha --recursive --timeout 25000 --exit --grep="$MOCHA_GREP" --require test/unit/bootstrap.js --extension=mjs "${mocha_mjs_dirs[@]}" - mocha_status=$((mocha_status != 0 ? mocha_status : $?)) -fi - -if (( has_mocha_mjs_test == 0 && has_mocha_test == 0 )); then +else echo "No mocha tests found in $TARGET_DIR, skipping mocha step." mocha_status=0 fi From a06ae82b56c103a726b3f6175d26ae36eac55edd Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 28 May 2025 16:25:21 +0100 Subject: [PATCH 043/259] Remove esmock from web GitOrigin-RevId: 32aa3f23da8bb135d41f2e305662f157094d4936 --- package-lock.json | 10 ---------- services/web/package.json | 1 - 2 files changed, 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 146ba3255d..73b722b1f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44892,7 +44892,6 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-testing-library": "^7.1.1", "eslint-plugin-unicorn": "^56.0.0", - "esmock": "^2.6.7", "events": "^3.3.0", "fake-indexeddb": "^6.0.0", "fetch-mock": "^12.5.2", @@ -45801,15 +45800,6 @@ "url": "https://opencollective.com/eslint" } }, - "services/web/node_modules/esmock": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/esmock/-/esmock-2.6.7.tgz", - "integrity": "sha512-4DmjZ0qQIG+NQV1njHvWrua/cZEuJq56A3pSELT2BjNuol1aads7BluofCbLErdO41Ic1XCd2UMepVLpjL64YQ==", - "dev": true, - "engines": { - "node": ">=14.16.0" - } - }, "services/web/node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", diff --git a/services/web/package.json b/services/web/package.json index ee5f81d4f8..609d24c0a3 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -294,7 +294,6 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-testing-library": "^7.1.1", "eslint-plugin-unicorn": "^56.0.0", - "esmock": "^2.6.7", "events": "^3.3.0", "fake-indexeddb": "^6.0.0", "fetch-mock": "^12.5.2", From aee3909a5f363bc6ab1f54a3480758c663dc5857 Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Wed, 28 May 2025 13:34:58 -0400 Subject: [PATCH 044/259] prevent attempting to set headers after we already sent respone (#25994) GitOrigin-RevId: be9f63f4c6d86ccd7f55850d71f5f2564eab2f12 --- .../app/src/Features/Subscription/SubscriptionController.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 1cc2ad0094..7aa345e7a8 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -590,7 +590,7 @@ function recurlyCallback(req, res, next) { } // if theres no restore point it could be a failed renewal, or no restore set. Either way it will be handled through dunning automatically if (!lastSubscription || !lastSubscription?.planCode) { - res.sendStatus(200) + return res.sendStatus(200) } SubscriptionHandler.revertPlanChange( eventData.transaction.subscription_id, @@ -599,7 +599,7 @@ function recurlyCallback(req, res, next) { if (err) { return next(err) } - res.sendStatus(200) + return res.sendStatus(200) } ) } From 1ea7a6f33f29f7498d2f1af9aae3201a2822d05a Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Thu, 29 May 2025 10:11:28 +0200 Subject: [PATCH 045/259] Merge pull request #25968 from overleaf/msm-git-bridge-bump-async-handler [git-bridge] Bump `async-http-client` to 3.0.2 GitOrigin-RevId: 659e997b0403e9eb5af03ce398a84730661ff66a --- services/git-bridge/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/git-bridge/pom.xml b/services/git-bridge/pom.xml index 840eb57721..3feb4dd860 100644 --- a/services/git-bridge/pom.xml +++ b/services/git-bridge/pom.xml @@ -18,7 +18,7 @@ 2.8.4 9.4.57.v20241219 2.9.0 - 3.0.1 + 3.0.2 6.10.1.202505221210-r 3.41.2.2 2.9.9 From b8816848a00812fa2a1700eb4ae0add8de711562 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Thu, 29 May 2025 10:11:47 +0200 Subject: [PATCH 046/259] Merge pull request #25972 from overleaf/msm-unvendor-envsubst [git-bridge] Un-vendor `envsubst` GitOrigin-RevId: 02abdd20aede8b6fd90013f4841ad3375997335c --- services/git-bridge/Dockerfile | 12 +++++++++--- services/git-bridge/vendor/envsubst | Bin 2413941 -> 0 bytes 2 files changed, 9 insertions(+), 3 deletions(-) delete mode 100755 services/git-bridge/vendor/envsubst diff --git a/services/git-bridge/Dockerfile b/services/git-bridge/Dockerfile index 58572ae8b9..48579b9494 100644 --- a/services/git-bridge/Dockerfile +++ b/services/git-bridge/Dockerfile @@ -1,11 +1,17 @@ -# Dockerfile for git-bridge +# Build the a8m/envsubst binary, as it supports default values, +# which the gnu envsubst (from gettext-base) does not. +FROM golang:1.24.3-alpine AS envsubst_builder + +WORKDIR /build + +RUN go install github.com/a8m/envsubst/cmd/envsubst@latest FROM maven:3-amazoncorretto-21-debian AS base RUN apt-get update && apt-get install -y make git sqlite3 \ && rm -rf /var/lib/apt/lists -COPY vendor/envsubst /opt/envsubst +COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst RUN chmod +x /opt/envsubst RUN useradd --create-home node @@ -33,7 +39,7 @@ RUN adduser -D node COPY --from=builder /git-bridge.jar / -COPY vendor/envsubst /opt/envsubst +COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst RUN chmod +x /opt/envsubst COPY conf/envsubst_template.json envsubst_template.json diff --git a/services/git-bridge/vendor/envsubst b/services/git-bridge/vendor/envsubst deleted file mode 100755 index f7ad8081d0135953c0bdece3905b36b4e1847ce0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2413941 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ν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≻{vz!`Un21?RY=T6=82Kx=8VBE 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

=*Rb<3bPuFtSVX%=6Hz53WE@Zc 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

* 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ïp?)-cm409lC=>q5)oMfT`k3*_bkXX} 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¬l>R<-GyX7nkwf~K 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

04A0!oR^*0CMrMAAj#+zln;9y>58$lK(O)!6J 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

-WR1P*}++##T}f> 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%0D#qSOg)r`Ui3TGsdV26mT$SXC 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 From 28c5d777a4fcb351374dab02510d1230f0e93379 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Thu, 29 May 2025 09:37:37 +0100 Subject: [PATCH 047/259] Merge pull request #25846 from overleaf/dp-themed-style-variables Create themed colour variables and use them in new editor rail GitOrigin-RevId: 48719f1b29170bcb95d34ecd538554bdf4fad2bb --- .../abstracts/themes-common-variables.scss | 48 +++++++++++++++++++ .../bootstrap-5/pages/editor/rail.scss | 26 ++++------ 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss index e113f63829..562dfb3efd 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss @@ -1,7 +1,55 @@ +/* ====== Semantic CSS color variables that adjust depending on the current theme ====== */ :root { --editor-border-color: var(--neutral-80); + --bg-primary-themed: var(--bg-dark-primary); + --bg-secondary-themed: var(--bg-dark-secondary); + --bg-tertiary-themed: var(--bg-dark-tertiary); + --bg-disabled-themed: var(--bg-dark-disabled); + --content-primary-themed: var(--content-primary-dark); + --content-secondary-themed: var(--content-secondary-dark); + --content-disabled-themed: var(--content-disabled-dark); + --content-placeholder-themed: var(--content-placeholder-dark); + --content-danger-themed: var(--content-danger-dark); + --content-warning-themed: var(--content-warning-dark); + --content-positive-themed: var(--content-positive-dark); + --border-primary-themed: var(--border-primary-dark); + --border-hover-themed: var(--border-hover-dark); + --border-disabled-themed: var(--border-disabled-dark); + --border-active-themed: var(--border-active-dark); + --border-danger-themed: var(--border-danger-dark); + --border-divider-themed: var(--border-divider-dark); + --link-web-themed: var(--link-web-dark); + --link-web-hover-themed: var(--link-web-hover-dark); + --link-web-visited-themed: var(--link-web-visited-dark); + --link-ui-themed: var(--link-ui-dark); + --link-ui-hover-themed: var(--link-ui-hover-dark); + --link-ui-visited-themed: var(--link-ui-visited-dark); } @include theme('light') { --editor-border-color: var(--neutral-20); + --bg-primary-themed: var(--bg-light-primary); + --bg-secondary-themed: var(--bg-light-secondary); + --bg-tertiary-themed: var(--bg-light-tertiary); + --bg-disabled-themed: var(--bg-light-disabled); + --content-primary-themed: var(--content-primary); + --content-secondary-themed: var(--content-secondary); + --content-disabled-themed: var(--content-disabled); + --content-placeholder-themed: var(--content-placeholder); + --content-danger-themed: var(--content-danger); + --content-warning-themed: var(--content-warning); + --content-positive-themed: var(--content-positive); + --border-primary-themed: var(--border-primary); + --border-hover-themed: var(--border-hover); + --border-disabled-themed: var(--border-disabled); + --border-active-themed: var(--border-active); + --border-danger-themed: var(--border-danger); + --border-divider-themed: var(--border-divider); + --border-dark-divider-themed: var(--border-dark-divider); + --link-web-themed: var(--link-web); + --link-web-hover-themed: var(--link-web-hover); + --link-web-visited-themed: var(--link-web-visited); + --link-ui-themed: var(--link-ui); + --link-ui-hover-themed: var(--link-ui-hover); + --link-ui-visited-themed: var(--link-ui-visited); } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss index a3aa9ddbb4..f6e65416cc 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss @@ -1,27 +1,19 @@ -:root { - --ide-rail-background: var(--bg-dark-primary); - --ide-rail-color: var(--content-primary-dark); - --ide-rail-link-background: var(--bg-dark-primary); +body { + --ide-rail-background: var(--bg-primary-themed); + --ide-rail-color: var(--content-primary-themed); + --ide-rail-link-hover-color: var(--content-primary-themed); + --ide-rail-link-background: var(--bg-primary-themed); + --ide-rail-link-hover-background: var(--bg-secondary-themed); + --ide-rail-border-colour: var(--border-divider-themed); + --ide-rail-header-subdued-button-color: var(--content-primary-themed); + --ide-rail-header-subdued-button-hover-background: var(--bg-tertiary-themed); --ide-rail-link-active-color: var(--green-10); --ide-rail-link-active-background: var(--green-70); - --ide-rail-link-hover-color: var(--content-primary-dark); - --ide-rail-link-hover-background: var(--bg-dark-secondary); - --ide-rail-border-colour: var(--border-divider-dark); - --ide-rail-header-subdued-button-color: var(--content-primary-dark); - --ide-rail-header-subdued-button-hover-background: var(--bg-dark-tertiary); } @include theme('light') { - --ide-rail-background: #fff; - --ide-rail-color: var(--content-primary); - --ide-rail-link-background: #fff; --ide-rail-link-active-color: var(--green-70); --ide-rail-link-active-background: var(--bg-accent-03); - --ide-rail-link-hover-color: var(--content-primary); - --ide-rail-link-hover-background: var(--bg-light-secondary); - --ide-rail-border-colour: var(--border-divider); - --ide-rail-header-subdued-button-color: var(--content-primary); - --ide-rail-header-subdued-button-hover-background: var(--bg-light-tertiary); } .rail-panel-header { From ba53ea3306549a68f3b579e71dcd38dc07352512 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Thu, 29 May 2025 09:37:46 +0100 Subject: [PATCH 048/259] Merge pull request #25999 from overleaf/dp-eq-preview-fix Move rendering of equation preview math into codemirror extension to fix zoomed in issue GitOrigin-RevId: 66bf9120191da236d88213d16b457c0a676f38ac --- .../components/math-preview-tooltip.tsx | 95 ++++++++----------- .../source-editor/extensions/math-preview.ts | 13 ++- 2 files changed, 50 insertions(+), 58 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/components/math-preview-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/math-preview-tooltip.tsx index 60d9c430e2..cfa88d292d 100644 --- a/services/web/frontend/js/features/source-editor/components/math-preview-tooltip.tsx +++ b/services/web/frontend/js/features/source-editor/components/math-preview-tooltip.tsx @@ -13,7 +13,7 @@ import OLModal, { } from '@/features/ui/components/ol/ol-modal' import MaterialIcon from '@/shared/components/material-icon' import useEventListener from '@/shared/hooks/use-event-listener' -import { FC, useCallback, useLayoutEffect, useRef, useState } from 'react' +import { FC, useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useCodeMirrorStateContext, @@ -35,9 +35,9 @@ const MathPreviewTooltipContainer: FC = () => { return null } - const { tooltip, mathContent } = mathPreviewState + const { tooltip } = mathPreviewState - if (!tooltip || !mathContent) { + if (!tooltip) { return null } @@ -47,15 +47,16 @@ const MathPreviewTooltipContainer: FC = () => { return null } - return ReactDOM.createPortal( - , - tooltipView.dom - ) + const inner = tooltipView.dom.querySelector('#ol-cm-math-tooltip') + + if (!inner) { + return null + } + + return ReactDOM.createPortal(, inner) } -const MathPreviewTooltip: FC<{ mathContent: HTMLDivElement }> = ({ - mathContent, -}) => { +const MathPreviewTooltipMenu: FC = () => { const { t } = useTranslation() const newEditor = useIsNewEditorEnabled() @@ -69,8 +70,6 @@ const MathPreviewTooltip: FC<{ mathContent: HTMLDivElement }> = ({ window.dispatchEvent(new Event('editor:hideMathTooltip')) }, []) - const mathRef = useRef(null) - const keyDownListener = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -82,50 +81,40 @@ const MathPreviewTooltip: FC<{ mathContent: HTMLDivElement }> = ({ useEventListener('keydown', keyDownListener) - useLayoutEffect(() => { - if (mathRef.current) { - mathRef.current.replaceChildren(mathContent) - } - }, [mathContent]) - return ( <> -

+ {t('hide')} + + + {t('disable')} + + + {showDisableModal && ( diff --git a/services/web/frontend/js/features/source-editor/extensions/math-preview.ts b/services/web/frontend/js/features/source-editor/extensions/math-preview.ts index d946769fb3..3df55b2f28 100644 --- a/services/web/frontend/js/features/source-editor/extensions/math-preview.ts +++ b/services/web/frontend/js/features/source-editor/extensions/math-preview.ts @@ -44,7 +44,6 @@ export const setMathPreview = (enabled: boolean): TransactionSpec => ({ export const mathPreviewStateField = StateField.define<{ tooltip: Tooltip | null - mathContent: HTMLDivElement | null hide: boolean }>({ create: buildInitialState, @@ -52,7 +51,7 @@ export const mathPreviewStateField = StateField.define<{ update(state, tr) { for (const effect of tr.effects) { if (effect.is(hideTooltipEffect)) { - return { tooltip: null, hide: true, mathContent: null } + return { tooltip: null, hide: true } } } @@ -61,19 +60,18 @@ export const mathPreviewStateField = StateField.define<{ if (mathContainer) { if (state.hide) { - return { tooltip: null, hide: true, mathContent: null } + return { tooltip: null, hide: true } } else { const mathContent = buildTooltipContent(tr.state, mathContainer) return { tooltip: buildTooltip(mathContainer, mathContent), - mathContent, hide: false, } } } - return { tooltip: null, hide: false, mathContent: null } + return { tooltip: null, hide: false } } return state @@ -159,6 +157,11 @@ function buildTooltip( create() { const dom = document.createElement('div') dom.classList.add('ol-cm-math-tooltip-container') + const innerElt = document.createElement('div') + innerElt.classList.add('ol-cm-math-tooltip') + innerElt.id = 'ol-cm-math-tooltip' + innerElt.appendChild(mathContent) + dom.appendChild(innerElt) return { dom, overlap: true, offset: { x: 0, y: 8 } } }, From f40eb5026447c1628b7311af7acb9ef35786430e Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 29 May 2025 09:38:36 +0100 Subject: [PATCH 049/259] Merge pull request #25987 from overleaf/mj-ide-review-panel-overview [web] Editor redesign: Align review panel overview to top GitOrigin-RevId: d713d07b1e4eba76164fd29bce696288cca1d63c --- .../bootstrap-5/pages/editor/review-panel-new.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss index 7f1c32b139..cf90ef5040 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss @@ -68,6 +68,10 @@ $rp-type-blue: #6b7797; } } } + + .review-panel-overview { + top: 0; + } } .review-panel-container { From 102b59a641903337cadcd6344c37654421828a59 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 29 May 2025 09:38:56 +0100 Subject: [PATCH 050/259] Merge pull request #25984 from overleaf/mj-breadcrumbs-filename-refresh [web] Update breadcrumbs file name on renames GitOrigin-RevId: 8d2f176b14880bec512a9b37c15148e10f29a758 --- .../features/ide-redesign/components/breadcrumbs.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx index 455df85d7f..f148e0142e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx @@ -50,6 +50,15 @@ export default function Breadcrumbs() { }) }, [openEntity, fileTreeData]) + const fileName = useMemo(() => { + // NOTE: openEntity.entity.name may not always be accurate, so we read it + // from the file tree data instead. + if (!openEntity || !fileTreeData) { + return undefined + } + return findInTreeOrThrow(fileTreeData, openEntity.entity._id)?.entity.name + }, [fileTreeData, openEntity]) + const outlineHierarchy = useMemo(() => { if (!canShowOutline || !outline) { return [] @@ -73,7 +82,7 @@ export default function Breadcrumbs() { ))} -
{openEntity.entity.name}
+
{fileName}
{numOutlineItems > 0 && } {outlineHierarchy.map((section, idx) => ( From 393e738ce6fb9e0ebe2ccfd1beec9480d2e32c7e Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 29 May 2025 09:39:02 +0100 Subject: [PATCH 051/259] Merge pull request #25978 from overleaf/mj-rail-active-indicator-overflow [web] Avoid showing active rail indicator overflow GitOrigin-RevId: a81d97bde6dfa22102374f13b8d372d61e08180e --- .../stylesheets/bootstrap-5/pages/editor/rail.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss index f6e65416cc..2e6f3035f8 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss @@ -85,15 +85,15 @@ body { &::after { $indicator-height: 3px; - border-radius: 12px; + border-top-left-radius: 12px; + border-top-right-radius: 12px; content: ''; position: absolute; - bottom: -$indicator-height; + bottom: 0; left: 4px; box-sizing: border-box; width: 24px; - height: $indicator-height * 2; - border: $indicator-height solid var(--ide-rail-link-active-color); + height: $indicator-height; background-color: var(--ide-rail-link-active-color); } } From 97f8149a2b265d41d8c0a4dedf7188b28ef48029 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 29 May 2025 11:48:22 +0100 Subject: [PATCH 052/259] Merge pull request #25955 from overleaf/mj-ide-editing-session [analytics+web] Add editor redesign status to editing session segmentation GitOrigin-RevId: 8f2a05a6851d41712a592952c18b845b77115f47 --- .../hooks/use-editing-session-heartbeat.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/hooks/use-editing-session-heartbeat.ts b/services/web/frontend/js/features/ide-react/hooks/use-editing-session-heartbeat.ts index d264766d76..cdb0a151ae 100644 --- a/services/web/frontend/js/features/ide-react/hooks/use-editing-session-heartbeat.ts +++ b/services/web/frontend/js/features/ide-react/hooks/use-editing-session-heartbeat.ts @@ -6,10 +6,15 @@ import { debugConsole } from '@/utils/debugging' import { useCallback, useEffect, useRef } from 'react' import useEventListener from '@/shared/hooks/use-event-listener' import useDomEventListener from '@/shared/hooks/use-dom-event-listener' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' -function createEditingSessionHeartbeatData(editorType: EditorType) { +function createEditingSessionHeartbeatData( + editorType: EditorType, + newEditor: boolean +) { return { editorType, + editorRedesign: newEditor, } } @@ -25,6 +30,7 @@ function sendEditingSessionHeartbeat( export function useEditingSessionHeartbeat() { const { projectId } = useIdeReactContext() const { getEditorType } = useEditorManagerContext() + const newEditor = useIsNewEditorEnabled() // Keep track of how many heartbeats we've sent so that we can calculate how // long to wait until the next one @@ -51,7 +57,10 @@ export function useEditingSessionHeartbeat() { heartBeatSentRecentlyRef.current = true - const segmentation = createEditingSessionHeartbeatData(editorType) + const segmentation = createEditingSessionHeartbeatData( + editorType, + newEditor + ) debugConsole.log('[Event] send heartbeat request', segmentation) sendEditingSessionHeartbeat(projectId, segmentation) @@ -71,7 +80,7 @@ export function useEditingSessionHeartbeat() { heartBeatResetTimerRef.current = window.setTimeout(() => { heartBeatSentRecentlyRef.current = false }, backoffSecs * 1000) - }, [getEditorType, projectId]) + }, [getEditorType, projectId, newEditor]) // Hook the heartbeat up to editor events useEventListener('cursor:editor:update', editingSessionHeartbeat) From efa20c26c9690ff1eefffd303b71a1c695bfcd56 Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Fri, 30 May 2025 01:04:16 +0000 Subject: [PATCH 053/259] auto update translation GitOrigin-RevId: 410e63cee274ad03fc9f64b277ff0cd8aa8c1995 --- services/web/locales/da.json | 2 -- services/web/locales/de.json | 1 - services/web/locales/fr.json | 2 -- services/web/locales/pt.json | 1 - services/web/locales/sv.json | 1 - services/web/locales/zh-CN.json | 5 ----- 6 files changed, 12 deletions(-) diff --git a/services/web/locales/da.json b/services/web/locales/da.json index 3fc8910d50..e0c9b82938 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -541,7 +541,6 @@ "enabled": "Aktiveret", "enabling": "Aktiverer", "end_of_document": "Slutningen af dokumentet", - "enter_6_digit_code": "Indtast 6-cifret kode", "enter_any_size_including_units_or_valid_latex_command": "Indtast en størrelse (inklusiv enhed) eller en gyldig LaTeX kommando", "enter_image_url": "Indtast billedets URL", "enter_the_confirmation_code": "Indtast den 6-cifrede bekræftelseskode sendt til __email__.", @@ -1529,7 +1528,6 @@ "resend_link_sso": "Gensend SSO invitation", "resend_managed_user_invite": "Gensend invitation til styrede brugere", "resending_confirmation_code": "Gensender bekræftelseskode", - "resending_confirmation_email": "Gensender bekræftelsesmail", "reset_password": "Nulstil dit kodeord", "reset_password_link": "Klik på dette link for at nulstille dit kodeord", "reset_your_password": "Nulstil dit kodeord", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index c336215ee8..11129073df 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -1060,7 +1060,6 @@ "resend": "Sende erneut", "resend_confirmation_email": "Bestätigungs-E-Mail erneut senden", "resend_managed_user_invite": "Einladung zu Verwaltete Benutzer erneut senden", - "resending_confirmation_email": "Bestätigungs-E-Mail wird erneut gesendet", "reset_password": "Passwort zurücksetzen", "reset_your_password": "Dein Passwort zurücksetzen", "resolve": "Lösen", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index a47a785740..c081b84651 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -423,7 +423,6 @@ "empty_zip_file": "L’archive ne contient aucun fichier", "en": "Anglais", "end_of_document": "Fin du document", - "enter_6_digit_code": "Saisissez le code à 6 chiffres", "enter_image_url": "Entrez l’URL de l’image", "enter_your_email_address": "Entrez votre adresse email", "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Entrez votre adresse email ci-dessous, et nous vous enverrons un lien pour réinitialiser votre mot de passe", @@ -957,7 +956,6 @@ "required": "requis", "resend": "Envoyer de nouveau", "resend_confirmation_email": "Réexpédier le courriel de confirmation", - "resending_confirmation_email": "Réexpédition du courriel de confirmation", "reset_password": "Réinitialiser le mot de passe", "reset_your_password": "Réinitialiser votre mot de passe", "resolve": "Résoudre", diff --git a/services/web/locales/pt.json b/services/web/locales/pt.json index 27add8a65b..0b31a1668f 100644 --- a/services/web/locales/pt.json +++ b/services/web/locales/pt.json @@ -497,7 +497,6 @@ "required": "Obrigatório", "resend": "Reenviar", "resend_confirmation_email": "Reenviar e-mail de confirmação", - "resending_confirmation_email": "Reenviando email de confirmação", "reset_password": "Trocar Senha", "reset_your_password": "Redefinir sua senha", "resolve": "Resolver", diff --git a/services/web/locales/sv.json b/services/web/locales/sv.json index 9304c00da6..9ed626fe36 100644 --- a/services/web/locales/sv.json +++ b/services/web/locales/sv.json @@ -703,7 +703,6 @@ "required": "Obligatorisk", "resend": "Skicka igen", "resend_confirmation_email": "Skicka om e-postbekräftelse", - "resending_confirmation_email": "Skickar e-postmeddelande med bekräftelse igen", "reset_password": "Återställ lösenord", "reset_your_password": "Återställ ditt lösenord", "resolve": "Lös", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index e69edbbd6d..bd05d2ab1b 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -649,7 +649,6 @@ "enabling": "开启", "end_of_document": "文档末尾", "ensure_recover_account": "这将确保在您无法访问主电子邮件地址时可以使用它来恢复您的__appName__帐户。", - "enter_6_digit_code": "输入6位数验证码", "enter_any_size_including_units_or_valid_latex_command": "输入任意大小(包括单位)或有效的 LaTeX 命令", "enter_image_url": "输入图片 URL", "enter_the_code": "输入发送至__email__的6位数代码。", @@ -1578,8 +1577,6 @@ "please_compile_pdf_before_download": "请在下载PDF之前编译您的项目", "please_compile_pdf_before_word_count": "请您在统计字数之前先编译您的的项目", "please_confirm_email": "请点击电子邮件中的链接确认您的电子邮件地址 __emailAddress__ ", - "please_confirm_primary_email": "请点击确认电子邮件中的链接来确认您的主电子邮件地址__emailAddress__。", - "please_confirm_secondary_email": "请点击确认电子邮件中的链接来确认您的辅助电子邮件地址__emailAddress__。", "please_confirm_your_email_before_making_it_default": "请先确认您的电子邮件,然后再将其作为主要邮件。", "please_contact_support_to_makes_change_to_your_plan": "请<0>联系支持以更改您的计划", "please_contact_us_if_you_think_this_is_in_error": "如果您认为此信息有误,请<0>联系我们。", @@ -1758,7 +1755,6 @@ "remote_service_error": "远程服务产生错误", "remove": "删除", "remove_access": "移除权限", - "remove_email_address": "移除邮件地址", "remove_from_group": "从群组中移除", "remove_link": "移除链接", "remove_manager": "删除管理者", @@ -1801,7 +1797,6 @@ "resend_link_sso": "重新发送 SSO 邀请", "resend_managed_user_invite": "重新发送托管用户邀请", "resending_confirmation_code": "重新发送确认码", - "resending_confirmation_email": "重新发送确认电子邮件", "reset_password": "重置密码", "reset_password_link": "单击此链接重置您的密码", "reset_password_sentence_case": "重设密码", From 9ba772b18f392bb3fbbe0df47e307948694ae083 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 30 May 2025 10:29:28 +0200 Subject: [PATCH 054/259] [web] handle 3DS challenges for Stripe (#25918) * handle 3DS challenges on the subscription dashboard * add `/user/subscription/sync` endpoint * upgrade `stripe-js` & rm `react-stripe-js` * group related unit tests together * add modules `SubscriptionController` unit tests and convert to async/await * add `StripeClient` unit tests for 3DS failure GitOrigin-RevId: 9da4758703f6ef4ec08248b328abddbbdd8e44ad --- package-lock.json | 35 ++++++------------- .../app/src/Features/Subscription/Errors.js | 7 ++++ .../Subscription/SubscriptionController.js | 11 +++++- .../views/subscriptions/dashboard-react.pug | 1 + .../modals/confirm-change-plan-modal.tsx | 15 ++++++-- .../preview-subscription-change/root.tsx | 32 +++++++++++------ .../util/handle-stripe-payment-action.ts | 28 +++++++++++++++ services/web/frontend/js/utils/meta.ts | 1 + services/web/package.json | 3 +- 9 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts diff --git a/package-lock.json b/package-lock.json index 73b722b1f5..ce941a1670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11575,29 +11575,6 @@ "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, - "node_modules/@stripe/react-stripe-js": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.5.0.tgz", - "integrity": "sha512-oo5J2SNbuAUjE9XmQv/SOD7vgZCa1Y9OcZyRAfvQPkyrDrru35sg5c64ANdHEmOWUibism3+25rKdARSw3HOfA==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "@stripe/stripe-js": ">=1.44.1 <7.0.0", - "react": ">=16.8.0 <20.0.0", - "react-dom": ">=16.8.0 <20.0.0" - } - }, - "node_modules/@stripe/stripe-js": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.10.0.tgz", - "integrity": "sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==", - "license": "MIT", - "engines": { - "node": ">=12.16" - } - }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -44687,8 +44664,7 @@ "@overleaf/settings": "*", "@phosphor-icons/react": "^2.1.7", "@slack/webhook": "^7.0.2", - "@stripe/react-stripe-js": "^3.1.1", - "@stripe/stripe-js": "^5.6.0", + "@stripe/stripe-js": "^7.3.0", "@xmldom/xmldom": "^0.7.13", "accepts": "^1.3.7", "ajv": "^8.12.0", @@ -45175,6 +45151,15 @@ "lodash": "^4.17.15" } }, + "services/web/node_modules/@stripe/stripe-js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.3.0.tgz", + "integrity": "sha512-xnCyFIEI5SQnQrKkCxVj7nS5fWTZap+zuIGzmmxLMdlmgahFJaihK4zogqE8YyKKTLtrp/EldkEijSgtXsRVDg==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "services/web/node_modules/@transloadit/prettier-bytes": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js index cbcd0014f7..9ebb08c6db 100644 --- a/services/web/app/src/Features/Subscription/Errors.js +++ b/services/web/app/src/Features/Subscription/Errors.js @@ -26,10 +26,17 @@ class SubtotalLimitExceededError extends OError {} class HasPastDueInvoiceError extends OError {} +class PaymentActionRequiredError extends OError { + constructor(info) { + super('Payment action required', info) + } +} + module.exports = { RecurlyTransactionError, DuplicateAddOnError, AddOnNotPresentError, + PaymentActionRequiredError, MissingBillingInfoError, ManuallyCollectedError, PendingChangeError, diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 7aa345e7a8..a38b41f628 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -15,7 +15,11 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager') const RecurlyEventHandler = require('./RecurlyEventHandler') const { expressify } = require('@overleaf/promise-utils') const OError = require('@overleaf/o-error') -const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors') +const { + DuplicateAddOnError, + AddOnNotPresentError, + PaymentActionRequiredError, +} = require('./Errors') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const AuthorizationManager = require('../Authorization/AuthorizationManager') const Modules = require('../../infrastructure/Modules') @@ -425,6 +429,11 @@ async function purchaseAddon(req, res, next) { 'Your subscription already includes this add-on', { addon: addOnCode } ) + } else if (err instanceof PaymentActionRequiredError) { + return res.status(402).json({ + message: 'Payment action required', + clientSecret: err.info.clientSecret, + }) } else { if (err instanceof Error) { OError.tag(err, 'something went wrong purchasing add-ons', { diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index d6a1bff49c..8cc5ec1976 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -27,6 +27,7 @@ block append meta meta(name="ol-user" data-type="json" content=user) if (personalSubscription && personalSubscription.payment) meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) + meta(name="ol-stripeApiKey" content=settings.apis.stripe.publishableKey) meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency) meta(name="ol-groupPlans" data-type="json" content=groupPlans) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx index 08cbf1743f..a964009dcc 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx @@ -1,7 +1,10 @@ import { useState } from 'react' import { useTranslation, Trans } from 'react-i18next' import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids' -import { postJSON } from '../../../../../../../../infrastructure/fetch-json' +import { + postJSON, + FetchError, +} from '../../../../../../../../infrastructure/fetch-json' import getMeta from '../../../../../../../../utils/meta' import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url' @@ -14,6 +17,7 @@ import OLModal, { } from '@/features/ui/components/ol/ol-modal' import OLButton from '@/features/ui/components/ol/ol-button' import OLNotification from '@/features/ui/components/ol/ol-notification' +import handleStripePaymentAction from '@/features/subscription/util/handle-stripe-payment-action' export function ConfirmChangePlanModal() { const modalId: SubscriptionDashModalIds = 'change-to-plan' @@ -37,8 +41,13 @@ export function ConfirmChangePlanModal() { }) location.reload() } catch (e) { - setError(true) - setInflight(false) + const { handled } = await handleStripePaymentAction(e as FetchError) + if (handled) { + location.reload() + } else { + setError(true) + setInflight(false) + } } } diff --git a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx index 367a5e35a9..112d15d7e3 100644 --- a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx +++ b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx @@ -11,7 +11,7 @@ import { formatCurrency } from '@/shared/utils/currency' import useAsync from '@/shared/hooks/use-async' import { useLocation } from '@/shared/hooks/use-location' import { debugConsole } from '@/utils/debugging' -import { postJSON } from '@/infrastructure/fetch-json' +import { FetchError, postJSON } from '@/infrastructure/fetch-json' import Notification from '@/shared/components/notification' import OLCard from '@/features/ui/components/ol/ol-card' import OLRow from '@/features/ui/components/ol/ol-row' @@ -21,6 +21,7 @@ import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription import * as eventTracking from '@/infrastructure/event-tracking' import sparkleText from '@/shared/svgs/ai-sparkle-text.svg' import { useFeatureFlag } from '@/shared/context/split-test-context' +import handleStripePaymentAction from '../../util/handle-stripe-payment-action' function PreviewSubscriptionChange() { const preview = getMeta( @@ -279,16 +280,25 @@ function PreviewSubscriptionChange() { } async function payNow(preview: SubscriptionChangePreview) { - if (preview.change.type === 'add-on-purchase') { - await postJSON(`/user/subscription/addon/${preview.change.addOn.code}/add`) - } else if (preview.change.type === 'premium-subscription') { - await postJSON(subscriptionUpdateUrl, { - body: { plan_code: preview.change.plan.code }, - }) - } else { - throw new Error( - `Unknown subscription change preview type: ${preview.change}` - ) + try { + if (preview.change.type === 'add-on-purchase') { + await postJSON( + `/user/subscription/addon/${preview.change.addOn.code}/add` + ) + } else if (preview.change.type === 'premium-subscription') { + await postJSON(subscriptionUpdateUrl, { + body: { plan_code: preview.change.plan.code }, + }) + } else { + throw new Error( + `Unknown subscription change preview type: ${preview.change}` + ) + } + } catch (e) { + const { handled } = await handleStripePaymentAction(e as FetchError) + if (!handled) { + throw e + } } } diff --git a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts new file mode 100644 index 0000000000..fd29674893 --- /dev/null +++ b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts @@ -0,0 +1,28 @@ +import { FetchError, postJSON } from '@/infrastructure/fetch-json' +import getMeta from '../../../utils/meta' +import { loadStripe } from '@stripe/stripe-js/pure' + +export default async function handleStripePaymentAction( + error: FetchError +): Promise<{ handled: boolean }> { + const clientSecret = error?.data?.clientSecret + + if (clientSecret) { + const stripePublicKey = getMeta('ol-stripeApiKey') + const stripe = await loadStripe(stripePublicKey) + if (stripe) { + const manualConfirmationFlow = + await stripe.confirmCardPayment(clientSecret) + if (!manualConfirmationFlow.error) { + try { + await postJSON(`/user/subscription/sync`) + } catch (error) { + // if the sync fails, there may be stale data until the webhook is + // processed but we can't do any special handling for that in here + } + return { handled: true } + } + } + } + return { handled: false } +} diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 9461635625..2a396c805b 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -224,6 +224,7 @@ export interface Meta { 'ol-splitTestVariants': { [name: string]: string } 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string + 'ol-stripeApiKey': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionId': string diff --git a/services/web/package.json b/services/web/package.json index 609d24c0a3..cc286b9225 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -89,8 +89,7 @@ "@overleaf/settings": "*", "@phosphor-icons/react": "^2.1.7", "@slack/webhook": "^7.0.2", - "@stripe/stripe-js": "^5.6.0", - "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^7.3.0", "@xmldom/xmldom": "^0.7.13", "accepts": "^1.3.7", "ajv": "^8.12.0", From fe64856be7d984d11dad0bfeac9cedad15e074b9 Mon Sep 17 00:00:00 2001 From: Christopher Hoskin Date: Fri, 30 May 2025 09:54:17 +0100 Subject: [PATCH 055/259] Merge pull request #26021 from overleaf/csh-issue-25976-dev-env-ci Upgrade to Redis 7.4 in dev and CI GitOrigin-RevId: 068e54899bf50a247fedd0243d66f1545bc7cf01 --- services/document-updater/docker-compose.ci.yml | 2 +- services/document-updater/docker-compose.yml | 2 +- services/history-v1/docker-compose.ci.yml | 2 +- services/history-v1/docker-compose.yml | 2 +- services/project-history/docker-compose.ci.yml | 2 +- services/project-history/docker-compose.yml | 2 +- services/real-time/docker-compose.ci.yml | 2 +- services/real-time/docker-compose.yml | 2 +- services/web/docker-compose.ci.yml | 2 +- services/web/docker-compose.yml | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml index 2fe97bd9b3..8236c51af9 100644 --- a/services/document-updater/docker-compose.ci.yml +++ b/services/document-updater/docker-compose.ci.yml @@ -45,7 +45,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml index 8a94d1a24c..a7842bc0fd 100644 --- a/services/document-updater/docker-compose.yml +++ b/services/document-updater/docker-compose.yml @@ -48,7 +48,7 @@ services: command: npm run --silent test:acceptance redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml index 0dfe8b99d3..69b218221d 100644 --- a/services/history-v1/docker-compose.ci.yml +++ b/services/history-v1/docker-compose.ci.yml @@ -66,7 +66,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml index b87d859e1e..760f4f01e3 100644 --- a/services/history-v1/docker-compose.yml +++ b/services/history-v1/docker-compose.yml @@ -74,7 +74,7 @@ services: command: npm run --silent test:acceptance redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml index 2fe97bd9b3..8236c51af9 100644 --- a/services/project-history/docker-compose.ci.yml +++ b/services/project-history/docker-compose.ci.yml @@ -45,7 +45,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml index 68360baf44..2659916373 100644 --- a/services/project-history/docker-compose.yml +++ b/services/project-history/docker-compose.yml @@ -48,7 +48,7 @@ services: command: npm run --silent test:acceptance redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/real-time/docker-compose.ci.yml b/services/real-time/docker-compose.ci.yml index 9011627c06..a5a2292e72 100644 --- a/services/real-time/docker-compose.ci.yml +++ b/services/real-time/docker-compose.ci.yml @@ -43,7 +43,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml index 9333271dcf..f1041164bc 100644 --- a/services/real-time/docker-compose.yml +++ b/services/real-time/docker-compose.yml @@ -46,7 +46,7 @@ services: command: npm run --silent test:acceptance redis: - image: redis + image: redis:7.4.3 healthcheck: test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] interval: 1s diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml index 5cffe19810..6bb6cc768c 100644 --- a/services/web/docker-compose.ci.yml +++ b/services/web/docker-compose.ci.yml @@ -86,7 +86,7 @@ services: user: root redis: - image: redis + image: redis:7.4.3 mongo: image: mongo:7.0.20 diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index 5314e94ed3..27c8cbbe8b 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -84,7 +84,7 @@ services: - "cypress:run-ct" redis: - image: redis + image: redis:7.4.3 mongo: image: mongo:7.0.20 From c6f42291479a108676eb269af7d23fd4614877cf Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Fri, 30 May 2025 07:34:24 -0400 Subject: [PATCH 056/259] Merge pull request #25952 from overleaf/em-split-editor-facade Split EditorFacade functionality for history OT (2nd attempt) GitOrigin-RevId: 2bc6d6c54a9f336fd4a69f0eb548dd06b9f06f5f --- .../features/ide-react/editor/share-js-doc.ts | 2 +- .../source-editor/extensions/realtime.ts | 73 ++++++++++++++----- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index 96e866afec..a773684dcb 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -365,7 +365,7 @@ export class ShareJsDoc extends EventEmitter { attachToCM6(cm6: EditorFacade) { this.attachToEditor(cm6, () => { - cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength')) + cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'), this.type) }) } diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 36d9956a76..9118e4f151 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -5,6 +5,7 @@ import RangesTracker from '@overleaf/ranges-tracker' import { ShareDoc } from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' +import { OTType } from '@/features/ide-react/editor/share-js-doc' /* * Integrate CodeMirror 6 with the real-time system, via ShareJS. @@ -76,15 +77,22 @@ export const realtime = ( return Prec.highest([realtimePlugin, ensureRealtimePlugin]) } +type OTAdapter = { + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ): void + attachShareJs(): void +} + export class EditorFacade extends EventEmitter { - public shareDoc: ShareDoc | null + private otAdapter: OTAdapter | null public events: EventEmitter - private maxDocLength?: number constructor(public view: EditorView) { super() this.view = view - this.shareDoc = null + this.otAdapter = null this.events = new EventEmitter() } @@ -118,23 +126,56 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } + attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) { + this.otAdapter = + type === 'history-ot' + ? new HistoryOTAdapter(this, shareDoc, maxDocLength) + : new ShareLatexOTAdapter(this, shareDoc, maxDocLength) + this.otAdapter.attachShareJs() + } + + detachShareJs() { + this.otAdapter = null + } + + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ) { + if (this.otAdapter == null) { + throw new Error('Trying to process updates with no otAdapter') + } + + this.otAdapter.handleUpdateFromCM(transactions, ranges) + } +} + +class ShareLatexOTAdapter { + constructor( + public editor: EditorFacade, + private shareDoc: ShareDoc, + private maxDocLength?: number + ) { + this.editor = editor + this.shareDoc = shareDoc + this.maxDocLength = maxDocLength + } + // Connect to ShareJS, passing changes to the CodeMirror view // as new transactions. // This is a broad immitation of helper functions supplied in // the sharejs library. (See vendor/libs/sharejs, in particular // the 'attach_ace' helper) - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { - this.shareDoc = shareDoc - this.maxDocLength = maxDocLength - + attachShareJs() { + const shareDoc = this.shareDoc const check = () => { // run in a timeout so it checks the editor content once this update has been applied window.setTimeout(() => { - const editorText = this.getValue() + const editorText = this.editor.getValue() const otText = shareDoc.getText() if (editorText !== otText) { - shareDoc.emit('error', 'Text does not match in CodeMirror 6') + this.shareDoc.emit('error', 'Text does not match in CodeMirror 6') debugConsole.error('Text does not match!') debugConsole.error('editor: ' + editorText) debugConsole.error('ot: ' + otText) @@ -143,12 +184,12 @@ export class EditorFacade extends EventEmitter { } const onInsert = (pos: number, text: string) => { - this.cmInsert(pos, text, 'remote') + this.editor.cmInsert(pos, text, 'remote') check() } const onDelete = (pos: number, text: string) => { - this.cmDelete(pos, text, 'remote') + this.editor.cmDelete(pos, text, 'remote') check() } @@ -161,7 +202,7 @@ export class EditorFacade extends EventEmitter { shareDoc.removeListener('insert', onInsert) shareDoc.removeListener('delete', onDelete) delete shareDoc.detach_cm6 - this.shareDoc = null + this.editor.detachShareJs() } } @@ -175,10 +216,6 @@ export class EditorFacade extends EventEmitter { const trackedDeletesLength = ranges != null ? ranges.getTrackedDeletesLength() : 0 - if (!shareDoc) { - throw new Error('Trying to process updates with no shareDoc') - } - for (const transaction of transactions) { if (transaction.docChanged) { const origin = chooseOrigin(transaction) @@ -234,7 +271,7 @@ export class EditorFacade extends EventEmitter { removed, } - this.emit('change', this, changeDescription) + this.editor.emit('change', this.editor, changeDescription) } ) } @@ -242,6 +279,8 @@ export class EditorFacade extends EventEmitter { } } +class HistoryOTAdapter extends ShareLatexOTAdapter {} + export const trackChangesAnnotation = Annotation.define() const chooseOrigin = (transaction: Transaction) => { From 26a77e739de0c306f0c012dccf964850e5490434 Mon Sep 17 00:00:00 2001 From: Liangjun Song <146005915+adai26@users.noreply.github.com> Date: Fri, 30 May 2025 12:40:07 +0100 Subject: [PATCH 057/259] Merge pull request #25852 from overleaf/ls-sync-stripe-subscription-logic Replicate syncing logic for Stripe subscription GitOrigin-RevId: 9422a3e193160409eddd4c5f2c80e8578bd88559 --- .../Subscription/SubscriptionUpdater.js | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index b0e24ce5ad..98bd98b30e 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js @@ -318,38 +318,7 @@ async function updateSubscriptionFromRecurly( requesterData ) { if (recurlySubscription.state === 'expired') { - const hasManagedUsersFeature = - Features.hasFeature('saas') && subscription?.managedUsersEnabled - - // If a payment lapses and if the group is managed or has group SSO, as a temporary measure we need to - // make sure that the group continues as-is and no destructive actions are taken. - if (hasManagedUsersFeature) { - logger.warn( - { subscriptionId: subscription._id }, - 'expired subscription has managedUsers feature enabled, skipping deletion' - ) - } else { - let hasGroupSSOEnabled = false - if (subscription?.ssoConfig) { - const ssoConfig = await SSOConfig.findOne({ - _id: subscription.ssoConfig._id || subscription.ssoConfig, - }) - .lean() - .exec() - if (ssoConfig.enabled) { - hasGroupSSOEnabled = true - } - } - - if (hasGroupSSOEnabled) { - logger.warn( - { subscriptionId: subscription._id }, - 'expired subscription has groupSSO feature enabled, skipping deletion' - ) - } else { - await deleteSubscription(subscription, requesterData) - } - } + await handleExpiredSubscription(subscription, requesterData) return } const updatedPlanCode = recurlySubscription.plan.plan_code @@ -450,6 +419,41 @@ async function _sendUserGroupPlanCodeUserProperty(userId) { } } +async function handleExpiredSubscription(subscription, requesterData) { + const hasManagedUsersFeature = + Features.hasFeature('saas') && subscription?.managedUsersEnabled + + // If a payment lapses and if the group is managed or has group SSO, as a temporary measure we need to + // make sure that the group continues as-is and no destructive actions are taken. + if (hasManagedUsersFeature) { + logger.warn( + { subscriptionId: subscription._id }, + 'expired subscription has managedUsers feature enabled, skipping deletion' + ) + } else { + let hasGroupSSOEnabled = false + if (subscription?.ssoConfig) { + const ssoConfig = await SSOConfig.findOne({ + _id: subscription.ssoConfig._id || subscription.ssoConfig, + }) + .lean() + .exec() + if (ssoConfig.enabled) { + hasGroupSSOEnabled = true + } + } + + if (hasGroupSSOEnabled) { + logger.warn( + { subscriptionId: subscription._id }, + 'expired subscription has groupSSO feature enabled, skipping deletion' + ) + } else { + await deleteSubscription(subscription, requesterData) + } + } +} + async function _sendSubscriptionEvent(userId, subscriptionId, event) { const subscription = await Subscription.findOne( { _id: subscriptionId }, @@ -568,5 +572,6 @@ module.exports = { setRestorePoint, setSubscriptionWasReverted, voidRestorePoint, + handleExpiredSubscription, }, } From 86e13b088a34ddee1a68acd989f3b067a9239cc1 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 2 Jun 2025 11:30:15 +0100 Subject: [PATCH 058/259] Merge pull request #25938 from overleaf/mj-core-pug-teardown [web] Tear down core-pug-bs5 feature flag GitOrigin-RevId: 875417ca02d8212940b4782bc3016778344116ba --- .../Features/Collaborators/CollaboratorsInviteController.mjs | 4 ---- .../web/app/src/Features/Templates/TemplatesController.js | 4 ---- services/web/app/views/project/editor/new_from_template.pug | 2 -- services/web/app/views/project/invite/not-valid.pug | 4 ---- services/web/app/views/project/invite/show.pug | 4 ---- 5 files changed, 18 deletions(-) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs index 4c2d911709..db853afac3 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs @@ -16,7 +16,6 @@ import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js' import Errors from '../Errors/Errors.js' import AuthenticationController from '../Authentication/AuthenticationController.js' import PrivilegeLevels from '../Authorization/PrivilegeLevels.js' -import SplitTestHandler from '../SplitTests/SplitTestHandler.js' // This rate limiter allows a different number of requests depending on the // number of callaborators a user is allowed. This is implemented by providing @@ -246,9 +245,6 @@ async function viewInvite(req, res) { const projectId = req.params.Project_id const { token } = req.params - // Read split test assignment so that it's available for Pug to read - await SplitTestHandler.promises.getAssignment(req, res, 'core-pug-bs5') - const _renderInvalidPage = function () { res.status(404) logger.debug({ projectId }, 'invite not valid, rendering not-valid page') diff --git a/services/web/app/src/Features/Templates/TemplatesController.js b/services/web/app/src/Features/Templates/TemplatesController.js index a8730a61be..39c4d50ae0 100644 --- a/services/web/app/src/Features/Templates/TemplatesController.js +++ b/services/web/app/src/Features/Templates/TemplatesController.js @@ -4,13 +4,9 @@ const TemplatesManager = require('./TemplatesManager') const ProjectHelper = require('../Project/ProjectHelper') const logger = require('@overleaf/logger') const { expressify } = require('@overleaf/promise-utils') -const SplitTestHandler = require('../SplitTests/SplitTestHandler') const TemplatesController = { async getV1Template(req, res) { - // Read split test assignment so that it's available for Pug to read - await SplitTestHandler.promises.getAssignment(req, res, 'core-pug-bs5') - const templateVersionId = req.params.Template_version_id const templateId = req.query.id if (!/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)) { diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index f2945b20f1..b1b5ae1e25 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -4,8 +4,6 @@ block vars - var suppressFooter = true - var suppressCookieBanner = true - var suppressSkipToContent = true - - bootstrap5PageStatus = 'enabled' - - bootstrap5PageSplitTest = 'core-pug-bs5' block content .editor.full-size diff --git a/services/web/app/views/project/invite/not-valid.pug b/services/web/app/views/project/invite/not-valid.pug index 693c162205..b4cbc1be1b 100644 --- a/services/web/app/views/project/invite/not-valid.pug +++ b/services/web/app/views/project/invite/not-valid.pug @@ -1,9 +1,5 @@ extends ../../layout-marketing -block vars - - bootstrap5PageStatus = 'enabled' - - bootstrap5PageSplitTest = 'core-pug-bs5' - block content main.content.content-alt#main-content .container diff --git a/services/web/app/views/project/invite/show.pug b/services/web/app/views/project/invite/show.pug index 35926977e2..a18518c716 100644 --- a/services/web/app/views/project/invite/show.pug +++ b/services/web/app/views/project/invite/show.pug @@ -1,9 +1,5 @@ extends ../../layout-marketing -block vars - - bootstrap5PageStatus = 'enabled' - - bootstrap5PageSplitTest = 'core-pug-bs5' - block content main.content.content-alt#main-content .container From 1b15dc38542ec7b1a6e4fea7515200c016367f22 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 2 Jun 2025 11:30:25 +0100 Subject: [PATCH 059/259] Merge pull request #26003 from overleaf/mj-ide-duplicate-project [web] Editor redesign: Add project duplication button GitOrigin-RevId: 93e5aa66a7ccc13650e07fda041394811874dafa --- .../components/toolbar/duplicate-project.tsx | 48 +++++++++++++++++++ .../components/toolbar/project-title.tsx | 2 + 2 files changed, 50 insertions(+) create mode 100644 services/web/frontend/js/features/ide-redesign/components/toolbar/duplicate-project.tsx diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/duplicate-project.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/duplicate-project.tsx new file mode 100644 index 0000000000..74f868cc91 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/duplicate-project.tsx @@ -0,0 +1,48 @@ +import EditorCloneProjectModalWrapper from '@/features/clone-project-modal/components/editor-clone-project-modal-wrapper' +import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { useLocation } from '@/shared/hooks/use-location' +import getMeta from '@/utils/meta' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type ProjectCopyResponse = { + project_id: string +} + +export const DuplicateProject = () => { + const { sendEvent } = useEditorAnalytics() + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const location = useLocation() + const anonymous = getMeta('ol-anonymous') + + const openProject = useCallback( + ({ project_id: projectId }: ProjectCopyResponse) => { + location.assign(`/project/${projectId}`) + }, + [location] + ) + + const handleShowModal = useCallback(() => { + sendEvent('copy-project', { location: 'project-title-dropdown' }) + setShowModal(true) + }, [sendEvent]) + + if (anonymous) { + return null + } + + return ( + <> + + {t('copy')} + + setShowModal(false)} + openProject={openProject} + /> + + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx index 68860da4ea..61e29023a0 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx @@ -13,6 +13,7 @@ import { DownloadProjectPDF, DownloadProjectZip } from './download-project' import { useCallback, useState } from 'react' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item' import EditableLabel from './editable-label' +import { DuplicateProject } from './duplicate-project' const [publishModalModules] = importOverleafModules('publishModal') const SubmitProjectButton = publishModalModules?.import.NewPublishToolbarButton @@ -73,6 +74,7 @@ export const ToolbarProjectTitle = () => { + { setIsRenaming(true) From da449f9f5fc684d61e7964b2947375e62d43e9b9 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 2 Jun 2025 11:30:40 +0100 Subject: [PATCH 060/259] Merge pull request #26015 from overleaf/mj-ide-breadcrumbs-setting [web] Add setting to control editor breadcrumbs GitOrigin-RevId: 6e0a4bb97eba63a1df43d85840f8962bf0238b7c --- .../src/Features/Project/ProjectController.js | 1 + .../app/src/Features/User/UserController.js | 3 ++ services/web/app/src/models/User.js | 1 + .../web/frontend/extracted-translations.json | 3 ++ .../context/project-settings-context.tsx | 7 +++++ .../hooks/use-user-wide-settings.tsx | 10 +++++++ .../editor-settings/breadcrumbs-setting.tsx | 18 ++++++++++++ .../editor-settings/editor-settings.tsx | 2 ++ .../components/toolbar/menu-bar.tsx | 29 +++++++++++-------- .../components/codemirror-toolbar.tsx | 6 +++- .../shared/context/user-settings-context.tsx | 1 + services/web/locales/en.json | 3 ++ services/web/types/user-settings.ts | 1 + 13 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/breadcrumbs-setting.tsx diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index ec128ffd54..842215d80e 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -824,6 +824,7 @@ const _ProjectController = { lineHeight: user.ace.lineHeight || 'normal', overallTheme: user.ace.overallTheme, mathPreview: user.ace.mathPreview, + breadcrumbs: user.ace.breadcrumbs, referencesSearchMode: user.ace.referencesSearchMode, enableNewEditor: user.ace.enableNewEditor ?? true, }, diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index e4186d39a8..b767dcd4a1 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -387,6 +387,9 @@ async function updateUserSettings(req, res, next) { if (req.body.mathPreview != null) { user.ace.mathPreview = req.body.mathPreview } + if (req.body.breadcrumbs != null) { + user.ace.breadcrumbs = Boolean(req.body.breadcrumbs) + } if (req.body.referencesSearchMode != null) { const mode = req.body.referencesSearchMode === 'simple' ? 'simple' : 'advanced' diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index d228c46b82..c1701023c4 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -97,6 +97,7 @@ const UserSchema = new Schema( fontFamily: { type: String }, lineHeight: { type: String }, mathPreview: { type: Boolean, default: true }, + breadcrumbs: { type: Boolean, default: true }, referencesSearchMode: { type: String, default: 'advanced' }, // 'advanced' or 'simple' enableNewEditor: { type: Boolean }, }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 9862e47817..09c2ba90dc 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -186,6 +186,7 @@ "blog": "", "bold": "", "booktabs": "", + "breadcrumbs": "", "browser": "", "bullet_list": "", "buy_licenses": "", @@ -1543,6 +1544,8 @@ "sharelatex_beta_program": "", "shortcut_to_open_advanced_reference_search": "", "show_all_projects": "", + "show_breadcrumbs": "", + "show_breadcrumbs_in_toolbar": "", "show_document_preamble": "", "show_equation_preview": "", "show_file_tree": "", diff --git a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx index e40c4c6872..e5cd576ba1 100644 --- a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx +++ b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx @@ -27,6 +27,7 @@ type ProjectSettingsSetterContextValue = { setLineHeight: (lineHeight: UserSettings['lineHeight']) => void setPdfViewer: (pdfViewer: UserSettings['pdfViewer']) => void setMathPreview: (mathPreview: UserSettings['mathPreview']) => void + setBreadcrumbs: (breadcrumbs: UserSettings['breadcrumbs']) => void } type ProjectSettingsContextValue = Partial & @@ -74,6 +75,8 @@ export const ProjectSettingsProvider: FC = ({ setPdfViewer, mathPreview, setMathPreview, + breadcrumbs, + setBreadcrumbs, } = useUserWideSettings() useProjectWideSettingsSocketListener() @@ -110,6 +113,8 @@ export const ProjectSettingsProvider: FC = ({ setPdfViewer, mathPreview, setMathPreview, + breadcrumbs, + setBreadcrumbs, }), [ compiler, @@ -142,6 +147,8 @@ export const ProjectSettingsProvider: FC = ({ setPdfViewer, mathPreview, setMathPreview, + breadcrumbs, + setBreadcrumbs, ] ) diff --git a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx index 70202c9446..978148721a 100644 --- a/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx +++ b/services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx @@ -20,6 +20,7 @@ export default function useUserWideSettings() { lineHeight, pdfViewer, mathPreview, + breadcrumbs, } = userSettings const setOverallTheme = useSetOverallTheme() @@ -93,6 +94,13 @@ export default function useUserWideSettings() { [saveUserSettings] ) + const setBreadcrumbs = useCallback( + (breadcrumbs: UserSettings['breadcrumbs']) => { + saveUserSettings('breadcrumbs', breadcrumbs) + }, + [saveUserSettings] + ) + return { autoComplete, setAutoComplete, @@ -116,5 +124,7 @@ export default function useUserWideSettings() { setPdfViewer, mathPreview, setMathPreview, + breadcrumbs, + setBreadcrumbs, } } diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/breadcrumbs-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/breadcrumbs-setting.tsx new file mode 100644 index 0000000000..c4dff10485 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/breadcrumbs-setting.tsx @@ -0,0 +1,18 @@ +import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context' +import ToggleSetting from '../toggle-setting' +import { useTranslation } from 'react-i18next' + +export default function BreadcrumbsSetting() { + const { breadcrumbs, setBreadcrumbs } = useProjectSettingsContext() + const { t } = useTranslation() + + return ( + + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx index 28dcef8a9b..a58b0c101e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx @@ -9,6 +9,7 @@ import PDFViewerSetting from './pdf-viewer-setting' import SpellCheckSetting from './spell-check-setting' import DictionarySetting from './dictionary-setting' import importOverleafModules from '../../../../../../macros/import-overleaf-module.macro' +import BreadcrumbsSetting from './breadcrumbs-setting' const [referenceSearchSettingModule] = importOverleafModules( 'referenceSearchSetting' @@ -33,6 +34,7 @@ export default function EditorSettings() { + diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx index ed0ebd77f8..68f4772644 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx @@ -20,12 +20,12 @@ import CommandDropdown, { MenuSectionStructure, MenuStructure, } from './command-dropdown' -import { useUserSettingsContext } from '@/shared/context/user-settings-context' import { useRailContext } from '../../contexts/rail-context' import WordCountModal from '@/features/word-count-modal/components/word-count-modal' import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context' export const ToolbarMenuBar = () => { const { t } = useTranslation() @@ -170,19 +170,16 @@ export const ToolbarMenuBar = () => { [t] ) - const { - userSettings: { mathPreview }, - setUserSettings, - } = useUserSettingsContext() + const { mathPreview, setMathPreview, breadcrumbs, setBreadcrumbs } = + useProjectSettingsContext() const toggleMathPreview = useCallback(() => { - setUserSettings(prev => { - return { - ...prev, - mathPreview: !prev.mathPreview, - } - }) - }, [setUserSettings]) + setMathPreview(!mathPreview) + }, [setMathPreview, mathPreview]) + + const toggleBreadcrumbs = useCallback(() => { + setBreadcrumbs(!breadcrumbs) + }, [setBreadcrumbs, breadcrumbs]) const { setActiveModal } = useRailContext() const openKeyboardShortcutsModal = useCallback(() => { @@ -212,6 +209,14 @@ export const ToolbarMenuBar = () => { Editor settings + + } + onClick={toggleBreadcrumbs} + /> { const view = useCodeMirrorViewContext() @@ -41,6 +42,9 @@ const Toolbar = memo(function Toolbar() { const { t } = useTranslation() const state = useCodeMirrorStateContext() const view = useCodeMirrorViewContext() + const { + userSettings: { breadcrumbs }, + } = useUserSettingsContext() const [overflowed, setOverflowed] = useState(false) @@ -192,7 +196,7 @@ const Toolbar = memo(function Toolbar() {

}m6SCId)FFKi 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�Sf$G@+)^1-T|MSWpFs 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

- {newEditor && } + {newEditor && breadcrumbs && }
) diff --git a/services/web/frontend/js/shared/context/user-settings-context.tsx b/services/web/frontend/js/shared/context/user-settings-context.tsx index b0bce5bf5c..b368371013 100644 --- a/services/web/frontend/js/shared/context/user-settings-context.tsx +++ b/services/web/frontend/js/shared/context/user-settings-context.tsx @@ -29,6 +29,7 @@ const defaultSettings: UserSettings = { mathPreview: true, referencesSearchMode: 'advanced', enableNewEditor: true, + breadcrumbs: true, } type UserSettingsContextValue = { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 910621f51a..9e0f86e8b9 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -240,6 +240,7 @@ "blog": "Blog", "bold": "Bold", "booktabs": "Booktabs", + "breadcrumbs": "Breadcrumbs", "brl_discount_offer_plans_page_banner": "__flag__ Great news! We’ve applied a 50% discount to premium plans on this page for our users in Brazil. Check out the new lower prices.", "browser": "Browser", "built_in": "Built-In", @@ -2015,6 +2016,8 @@ "sharelatex_beta_program": "__appName__ Beta Program", "shortcut_to_open_advanced_reference_search": "(__ctrlSpace__ or __altSpace__)", "show_all_projects": "Show all projects", + "show_breadcrumbs": "Show breadcrumbs", + "show_breadcrumbs_in_toolbar": "Show breadcrumbs in toolbar", "show_document_preamble": "Show document preamble", "show_equation_preview": "Show equation preview", "show_file_tree": "Show file tree", diff --git a/services/web/types/user-settings.ts b/services/web/types/user-settings.ts index 3e748d937e..add460edfa 100644 --- a/services/web/types/user-settings.ts +++ b/services/web/types/user-settings.ts @@ -17,4 +17,5 @@ export type UserSettings = { mathPreview: boolean referencesSearchMode: 'advanced' | 'simple' enableNewEditor: boolean + breadcrumbs: boolean } From 2e50e0ffa189501750afc3dbd5125643e8241296 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 2 Jun 2025 13:33:07 +0200 Subject: [PATCH 061/259] [web] add ProjectAccess helper class (#25663) * [web] add ProjectAccess helper class * [web] remove ts-ignore for calling OError.tag with try/catch error GitOrigin-RevId: e097a95b4d929a3927a3eeb70635590680c93007 --- .../Collaborators/CollaboratorsGetter.js | 260 +++++++++++++----- 1 file changed, 191 insertions(+), 69 deletions(-) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index caa6ef159d..2906edad4e 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -1,3 +1,4 @@ +// @ts-check const { callbackify } = require('util') const pLimit = require('p-limit') const { ObjectId } = require('mongodb-legacy') @@ -50,7 +51,155 @@ module.exports = { }, } -async function getMemberIdsWithPrivilegeLevels(projectId) { +/** + * @typedef ProjectMember + * @property {string} id + * @property {typeof PrivilegeLevels[keyof PrivilegeLevels]} privilegeLevel + * @property {typeof Sources[keyof Sources]} source + * @property {boolean} [pendingEditor] + * @property {boolean} [pendingReviewer] + */ + +/** + * @typedef LoadedProjectMember + * @property {typeof PrivilegeLevels[keyof PrivilegeLevels]} privilegeLevel + * @property {{_id: ObjectId, email: string, features: any, first_name: string, last_name: string, signUpDate: Date}} user + * @property {boolean} [pendingEditor] + * @property {boolean} [pendingReviewer] + */ + +// Wrapper for determining multiple dimensions of project access. +class ProjectAccess { + /** @type {ProjectMember[]} */ + #members + + /** @type {typeof PublicAccessLevels[keyof PublicAccessLevels]} */ + #publicAccessLevel + + /** + * @param {{ owner_ref: ObjectId; collaberator_refs: ObjectId[]; readOnly_refs: ObjectId[]; tokenAccessReadAndWrite_refs: ObjectId[]; tokenAccessReadOnly_refs: ObjectId[]; publicAccesLevel: typeof PublicAccessLevels[keyof PublicAccessLevels]; pendingEditor_refs: ObjectId[]; reviewer_refs: ObjectId[]; pendingReviewer_refs: ObjectId[]; }} project + */ + constructor(project) { + this.#members = _getMemberIdsWithPrivilegeLevelsFromFields( + project.owner_ref, + project.collaberator_refs, + project.readOnly_refs, + project.tokenAccessReadAndWrite_refs, + project.tokenAccessReadOnly_refs, + project.publicAccesLevel, + project.pendingEditor_refs, + project.reviewer_refs, + project.pendingReviewer_refs + ) + this.#publicAccessLevel = project.publicAccesLevel + } + + /** + * @return {Promise} + */ + async loadInvitedMembers() { + return _loadMembers(this.#members.filter(m => m.source !== Sources.TOKEN)) + } + + /** + * @return {ProjectMember[]} + */ + allMembers() { + return this.#members + } + + /** + * @return {typeof PublicAccessLevels[keyof PublicAccessLevels]} + */ + publicAccessLevel() { + return this.#publicAccessLevel + } + + /** + * @return {string[]} + */ + memberIds() { + return this.#members.map(m => m.id) + } + + /** + * @return {string[]} + */ + invitedMemberIds() { + return this.#members.filter(m => m.source !== Sources.TOKEN).map(m => m.id) + } + + /** + * @param {string | ObjectId} userId + * @return {typeof PrivilegeLevels[keyof PrivilegeLevels]} + */ + privilegeLevelForUser(userId) { + for (const member of this.#members) { + if (member.id === userId.toString()) { + return member.privilegeLevel + } + } + return PrivilegeLevels.NONE + } + + /** + * @param {string | ObjectId} userId + * @return {boolean} + */ + isUserInvitedMember(userId) { + for (const member of this.#members) { + if (member.id === userId.toString() && member.source !== Sources.TOKEN) { + return true + } + } + return false + } + + /** + * @param {string | ObjectId} userId + * @return {boolean} + */ + isUserInvitedReadWriteMember(userId) { + for (const member of this.#members) { + if ( + member.id.toString() === userId.toString() && + member.source !== Sources.TOKEN && + member.privilegeLevel === PrivilegeLevels.READ_AND_WRITE + ) { + return true + } + } + return false + } + + /** + * Counts invited members with editor or reviewer roles + * @return {number} + */ + countInvitedEditCollaborators() { + return this.#members.filter( + m => + m.source === Sources.INVITE && + (m.privilegeLevel === PrivilegeLevels.READ_AND_WRITE || + m.privilegeLevel === PrivilegeLevels.REVIEW) + ).length + } + + /** + * Counts invited members that are readonly pending editors or pending reviewers + * @return {number} + */ + countInvitedPendingEditors() { + return this.#members.filter( + m => + m.source === Sources.INVITE && + m.privilegeLevel === PrivilegeLevels.READ_ONLY && + (m.pendingEditor || m.pendingReviewer) + ).length + } +} + +async function getProjectAccess(projectId) { const project = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1, collaberator_refs: 1, @@ -65,34 +214,23 @@ async function getMemberIdsWithPrivilegeLevels(projectId) { if (!project) { throw new Errors.NotFoundError(`no project found with id ${projectId}`) } - const memberIds = _getMemberIdsWithPrivilegeLevelsFromFields( - project.owner_ref, - project.collaberator_refs, - project.readOnly_refs, - project.tokenAccessReadAndWrite_refs, - project.tokenAccessReadOnly_refs, - project.publicAccesLevel, - project.pendingEditor_refs, - project.reviewer_refs, - project.pendingReviewer_refs - ) - return memberIds + return new ProjectAccess(project) +} + +async function getMemberIdsWithPrivilegeLevels(projectId) { + return (await getProjectAccess(projectId)).allMembers() } async function getMemberIds(projectId) { - const members = await getMemberIdsWithPrivilegeLevels(projectId) - return members.map(m => m.id) + return (await getProjectAccess(projectId)).memberIds() } async function getInvitedMemberIds(projectId) { - const members = await getMemberIdsWithPrivilegeLevels(projectId) - return members.filter(m => m.source !== Sources.TOKEN).map(m => m.id) + return (await getProjectAccess(projectId)).invitedMemberIds() } async function getInvitedMembersWithPrivilegeLevels(projectId) { - let members = await getMemberIdsWithPrivilegeLevels(projectId) - members = members.filter(m => m.source !== Sources.TOKEN) - return _loadMembers(members) + return await (await getProjectAccess(projectId)).loadInvitedMembers() } async function getInvitedMembersWithPrivilegeLevelsFromFields( @@ -107,7 +245,7 @@ async function getInvitedMembersWithPrivilegeLevelsFromFields( readOnlyIds, [], [], - null, + 'private', [], reviewerIds, [] @@ -121,69 +259,31 @@ async function getMemberIdPrivilegeLevel(userId, projectId) { if (userId == null) { return PrivilegeLevels.NONE } - const members = await getMemberIdsWithPrivilegeLevels(projectId) - for (const member of members) { - if (member.id === userId.toString()) { - return member.privilegeLevel - } - } - return PrivilegeLevels.NONE + return (await getProjectAccess(projectId)).privilegeLevelForUser(userId) } async function getInvitedEditCollaboratorCount(projectId) { - // Counts invited members with editor or reviewer roles - const members = await getMemberIdsWithPrivilegeLevels(projectId) - return members.filter( - m => - m.source === Sources.INVITE && - (m.privilegeLevel === PrivilegeLevels.READ_AND_WRITE || - m.privilegeLevel === PrivilegeLevels.REVIEW) - ).length + return (await getProjectAccess(projectId)).countInvitedEditCollaborators() } async function getInvitedPendingEditorCount(projectId) { - // Only counts invited members that are readonly pending editors or pending - // reviewers - const members = await getMemberIdsWithPrivilegeLevels(projectId) - return members.filter( - m => - m.source === Sources.INVITE && - m.privilegeLevel === PrivilegeLevels.READ_ONLY && - (m.pendingEditor || m.pendingReviewer) - ).length + return (await getProjectAccess(projectId)).countInvitedPendingEditors() } async function isUserInvitedMemberOfProject(userId, projectId) { if (!userId) { return false } - const members = await getMemberIdsWithPrivilegeLevels(projectId) - for (const member of members) { - if ( - member.id.toString() === userId.toString() && - member.source !== Sources.TOKEN - ) { - return true - } - } - return false + return (await getProjectAccess(projectId)).isUserInvitedMember(userId) } async function isUserInvitedReadWriteMemberOfProject(userId, projectId) { if (!userId) { return false } - const members = await getMemberIdsWithPrivilegeLevels(projectId) - for (const member of members) { - if ( - member.id.toString() === userId.toString() && - member.source !== Sources.TOKEN && - member.privilegeLevel === PrivilegeLevels.READ_AND_WRITE - ) { - return true - } - } - return false + return (await getProjectAccess(projectId)).isUserInvitedReadWriteMember( + userId + ) } async function getPublicShareTokens(userId, projectId) { @@ -209,10 +309,13 @@ async function getPublicShareTokens(userId, projectId) { return null } + // @ts-ignore if (memberInfo.isOwner) { return memberInfo.tokens + // @ts-ignore } else if (memberInfo.hasTokenReadOnlyAccess) { return { + // @ts-ignore readOnly: memberInfo.tokens.readOnly, } } else { @@ -224,6 +327,7 @@ async function getPublicShareTokens(userId, projectId) { // excluding projects where the user is listed in the token access fields when // token access has been disabled. async function getProjectsUserIsMemberOf(userId, fields) { + // @ts-ignore const limit = pLimit(2) const [readAndWrite, review, readOnly, tokenReadAndWrite, tokenReadOnly] = await Promise.all([ @@ -274,9 +378,9 @@ async function dangerouslyGetAllProjectsUserIsMemberOf(userId, fields) { async function getAllInvitedMembers(projectId) { try { - const rawMembers = await getInvitedMembersWithPrivilegeLevels(projectId) - const { members } = - ProjectEditorHandler.buildOwnerAndMembersViews(rawMembers) + const { members } = ProjectEditorHandler.buildOwnerAndMembersViews( + await (await getProjectAccess(projectId)).loadInvitedMembers() + ) return members } catch (err) { throw OError.tag(err, 'error getting members for project', { projectId }) @@ -316,6 +420,19 @@ async function userIsReadWriteTokenMember(userId, projectId) { return project != null } +/** + * @param {ObjectId} ownerId + * @param {ObjectId[]} collaboratorIds + * @param {ObjectId[]} readOnlyIds + * @param {ObjectId[]} tokenAccessIds + * @param {ObjectId[]} tokenAccessReadOnlyIds + * @param {typeof PublicAccessLevels[keyof PublicAccessLevels]} publicAccessLevel + * @param {ObjectId[]} pendingEditorIds + * @param {ObjectId[]} reviewerIds + * @param {ObjectId[]} pendingReviewerIds + * @return {ProjectMember[]} + * @private + */ function _getMemberIdsWithPrivilegeLevelsFromFields( ownerId, collaboratorIds, @@ -384,6 +501,11 @@ function _getMemberIdsWithPrivilegeLevelsFromFields( return members } +/** + * @param {ProjectMember[]} members + * @return {Promise} + * @private + */ async function _loadMembers(members) { const userIds = Array.from(new Set(members.map(m => m.id))) const users = new Map() From 6cbacc8cb73afe55f7ecba67d5c5f7474a3a7373 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 2 Jun 2025 13:34:39 +0200 Subject: [PATCH 062/259] [web] fetch project once for joinProject (#25667) * [web] fetch project once for joinProject * [web] await all the nested helpers for getting privilege levels Co-authored-by: Mathias Jakobsen --------- Co-authored-by: Mathias Jakobsen GitOrigin-RevId: f0280c36ef995b417ccdab15014f05954e18c5f0 --- .../Authorization/AuthorizationManager.js | 81 ++++++++-- .../Collaborators/CollaboratorsGetter.js | 19 +++ .../Features/Editor/EditorHttpController.js | 25 +-- .../AuthorizationManagerTests.js | 149 ++++++++++++++---- .../src/Editor/EditorHttpControllerTests.js | 50 ++++-- 5 files changed, 250 insertions(+), 74 deletions(-) diff --git a/services/web/app/src/Features/Authorization/AuthorizationManager.js b/services/web/app/src/Features/Authorization/AuthorizationManager.js index 2f339de83d..22d92ea9d9 100644 --- a/services/web/app/src/Features/Authorization/AuthorizationManager.js +++ b/services/web/app/src/Features/Authorization/AuthorizationManager.js @@ -88,9 +88,54 @@ async function getPrivilegeLevelForProject( opts = {} ) { if (userId) { - return getPrivilegeLevelForProjectWithUser(userId, projectId, opts) + return await getPrivilegeLevelForProjectWithUser( + userId, + projectId, + null, + opts + ) } else { - return getPrivilegeLevelForProjectWithoutUser(projectId, token, opts) + return await getPrivilegeLevelForProjectWithoutUser(projectId, token, opts) + } +} + +/** + * Get the privilege level that the user has for the project. + * + * @param userId - The id of the user that wants to access the project. + * @param projectId - The id of the project to be accessed. + * @param {string} token + * @param {ProjectAccess} projectAccess + * @param {Object} opts + * @param {boolean} opts.ignoreSiteAdmin - Do not consider whether the user is + * a site admin. + * @param {boolean} opts.ignorePublicAccess - Do not consider the project is + * publicly accessible. + * + * @returns {string|boolean} The privilege level. One of "owner", + * "readAndWrite", "readOnly" or false. + */ +async function getPrivilegeLevelForProjectWithProjectAccess( + userId, + projectId, + token, + projectAccess, + opts = {} +) { + if (userId) { + return await getPrivilegeLevelForProjectWithUser( + userId, + projectId, + projectAccess, + opts + ) + } else { + return await _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel( + projectId, + token, + projectAccess.publicAccessLevel(), + opts + ) } } @@ -98,6 +143,7 @@ async function getPrivilegeLevelForProject( async function getPrivilegeLevelForProjectWithUser( userId, projectId, + projectAccess, opts = {} ) { if (!opts.ignoreSiteAdmin) { @@ -106,11 +152,11 @@ async function getPrivilegeLevelForProjectWithUser( } } - const privilegeLevel = - await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel( - userId, - projectId - ) + projectAccess = + projectAccess || + (await CollaboratorsGetter.promises.getProjectAccess(projectId)) + + const privilegeLevel = projectAccess.privilegeLevelForUser(userId) if (privilegeLevel && privilegeLevel !== PrivilegeLevels.NONE) { // The user has direct access return privilegeLevel @@ -119,7 +165,7 @@ async function getPrivilegeLevelForProjectWithUser( if (!opts.ignorePublicAccess) { // Legacy public-access system // User is present (not anonymous), but does not have direct access - const publicAccessLevel = await getPublicAccessLevel(projectId) + const publicAccessLevel = projectAccess.publicAccessLevel() if (publicAccessLevel === PublicAccessLevels.READ_ONLY) { return PrivilegeLevels.READ_ONLY } @@ -137,7 +183,21 @@ async function getPrivilegeLevelForProjectWithoutUser( token, opts = {} ) { - const publicAccessLevel = await getPublicAccessLevel(projectId) + return await _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel( + projectId, + token, + await getPublicAccessLevel(projectId), + opts + ) +} + +// User is Anonymous, Try Token-based access +async function _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel( + projectId, + token, + publicAccessLevel, + opts = {} +) { if (!opts.ignorePublicAccess) { if (publicAccessLevel === PublicAccessLevels.READ_ONLY) { // Legacy public read-only access for anonymous user @@ -149,7 +209,7 @@ async function getPrivilegeLevelForProjectWithoutUser( } } if (publicAccessLevel === PublicAccessLevels.TOKEN_BASED) { - return getPrivilegeLevelForProjectWithToken(projectId, token) + return await getPrivilegeLevelForProjectWithToken(projectId, token) } // Deny anonymous user access @@ -309,6 +369,7 @@ module.exports = { canUserRenameProject, canUserAdminProject, getPrivilegeLevelForProject, + getPrivilegeLevelForProjectWithProjectAccess, isRestrictedUserForProject, isUserSiteAdmin, }, diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index 2906edad4e..10c8e53757 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -32,6 +32,7 @@ module.exports = { userIsTokenMember: callbackify(userIsTokenMember), getAllInvitedMembers: callbackify(getAllInvitedMembers), promises: { + getProjectAccess, getMemberIdsWithPrivilegeLevels, getMemberIds, getInvitedMemberIds, @@ -134,6 +135,7 @@ class ProjectAccess { * @return {typeof PrivilegeLevels[keyof PrivilegeLevels]} */ privilegeLevelForUser(userId) { + if (!userId) return PrivilegeLevels.NONE for (const member of this.#members) { if (member.id === userId.toString()) { return member.privilegeLevel @@ -142,11 +144,26 @@ class ProjectAccess { return PrivilegeLevels.NONE } + /** + * @param {string | ObjectId} userId + * @return {boolean} + */ + isUserTokenMember(userId) { + if (!userId) return false + for (const member of this.#members) { + if (member.id === userId.toString() && member.source === Sources.TOKEN) { + return true + } + } + return false + } + /** * @param {string | ObjectId} userId * @return {boolean} */ isUserInvitedMember(userId) { + if (!userId) return false for (const member of this.#members) { if (member.id === userId.toString() && member.source !== Sources.TOKEN) { return true @@ -199,6 +216,8 @@ class ProjectAccess { } } +module.exports.ProjectAccess = ProjectAccess + async function getProjectAccess(projectId) { const project = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1, diff --git a/services/web/app/src/Features/Editor/EditorHttpController.js b/services/web/app/src/Features/Editor/EditorHttpController.js index 8128a95b26..def7face04 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.js +++ b/services/web/app/src/Features/Editor/EditorHttpController.js @@ -4,14 +4,13 @@ const ProjectGetter = require('../Project/ProjectGetter') const AuthorizationManager = require('../Authorization/AuthorizationManager') const ProjectEditorHandler = require('../Project/ProjectEditorHandler') const Metrics = require('@overleaf/metrics') -const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter') const CollaboratorsInviteGetter = require('../Collaborators/CollaboratorsInviteGetter') -const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler') const PrivilegeLevels = require('../Authorization/PrivilegeLevels') const SessionManager = require('../Authentication/SessionManager') const Errors = require('../Errors/Errors') const { expressify } = require('@overleaf/promise-utils') const Settings = require('@overleaf/settings') +const { ProjectAccess } = require('../Collaborators/CollaboratorsGetter') module.exports = { joinProject: expressify(joinProject), @@ -75,31 +74,23 @@ async function _buildJoinProjectView(req, projectId, userId) { if (project == null) { throw new Errors.NotFoundError('project not found') } - const members = - await CollaboratorsGetter.promises.getInvitedMembersWithPrivilegeLevels( - projectId - ) + const projectAccess = new ProjectAccess(project) + const members = await projectAccess.loadInvitedMembers() const token = req.body.anonymousAccessToken const privilegeLevel = - await AuthorizationManager.promises.getPrivilegeLevelForProject( + await AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess( userId, projectId, - token + token, + projectAccess ) if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) { return { project: null, privilegeLevel: null, isRestrictedUser: false } } const invites = await CollaboratorsInviteGetter.promises.getAllInvites(projectId) - const isTokenMember = await CollaboratorsHandler.promises.userIsTokenMember( - userId, - projectId - ) - const isInvitedMember = - await CollaboratorsGetter.promises.isUserInvitedMemberOfProject( - userId, - projectId - ) + const isTokenMember = projectAccess.isUserTokenMember(userId) + const isInvitedMember = projectAccess.isUserInvitedMember(userId) const isRestrictedUser = AuthorizationManager.isRestrictedUser( userId, privilegeLevel, diff --git a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js index 7463bbdeb7..e4c67d2f77 100644 --- a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js +++ b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js @@ -27,7 +27,10 @@ describe('AuthorizationManager', function () { this.CollaboratorsGetter = { promises: { - getMemberIdPrivilegeLevel: sinon.stub().resolves(PrivilegeLevels.NONE), + getProjectAccess: sinon.stub().resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon.stub().returns(PrivilegeLevels.NONE), + }), }, } @@ -113,9 +116,17 @@ describe('AuthorizationManager', function () { describe('with a user id with a privilege level', function () { beforeEach(async function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon + .stub() + .returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) this.result = await this.AuthorizationManager.promises.getPrivilegeLevelForProject( this.user._id, @@ -171,8 +182,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -204,8 +215,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -237,8 +248,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -264,9 +275,17 @@ describe('AuthorizationManager', function () { describe('with a user id with a privilege level', function () { beforeEach(async function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon + .stub() + .returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) this.result = await this.AuthorizationManager.promises.getPrivilegeLevelForProject( this.user._id, @@ -321,8 +340,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -336,13 +355,32 @@ describe('AuthorizationManager', function () { describe('with a public project', function () { beforeEach(function () { this.project.publicAccesLevel = 'readAndWrite' + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon + .stub() + .returns(this.project.publicAccesLevel), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.NONE), + }) }) describe('with a user id with a privilege level', function () { beforeEach(async function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon + .stub() + .returns(this.project.publicAccesLevel), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) this.result = await this.AuthorizationManager.promises.getPrivilegeLevelForProject( this.user._id, @@ -397,8 +435,8 @@ describe('AuthorizationManager', function () { ) }) - it('should not call CollaboratorsGetter.getMemberIdPrivilegeLevel', function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.called.should.equal( + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( false ) }) @@ -410,6 +448,11 @@ describe('AuthorizationManager', function () { }) describe("when the project doesn't exist", function () { + beforeEach(function () { + this.CollaboratorsGetter.promises.getProjectAccess.rejects( + new Errors.NotFoundError() + ) + }) it('should return a NotFoundError', async function () { const someOtherId = new ObjectId() await expect( @@ -424,9 +467,15 @@ describe('AuthorizationManager', function () { describe('when the project id is not valid', function () { beforeEach(function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) }) it('should return a error', async function () { @@ -529,9 +578,15 @@ describe('AuthorizationManager', function () { describe('canUserDeleteOrResolveThread', function () { it('should return true when user has write permissions', async function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_AND_WRITE) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_AND_WRITE), + }) const canResolve = await this.AuthorizationManager.promises.canUserDeleteOrResolveThread( @@ -546,9 +601,15 @@ describe('AuthorizationManager', function () { }) it('should return false when user has read permission', async function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.READ_ONLY) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.READ_ONLY), + }) const canResolve = await this.AuthorizationManager.promises.canUserDeleteOrResolveThread( @@ -564,9 +625,15 @@ describe('AuthorizationManager', function () { describe('when user has review permission', function () { beforeEach(function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(PrivilegeLevels.REVIEW) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(PrivilegeLevels.REVIEW), + }) }) it('should return false when user is not the comment author', async function () { @@ -691,15 +758,27 @@ function testPermission(permission, privilegeLevels) { function setupUserPrivilegeLevel(privilegeLevel) { beforeEach(`set user privilege level to ${privilegeLevel}`, function () { - this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel - .withArgs(this.user._id, this.project._id) - .resolves(privilegeLevel) + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(PublicAccessLevels.PRIVATE), + privilegeLevelForUser: sinon + .stub() + .withArgs(this.user._id) + .returns(privilegeLevel), + }) }) } function setupPublicAccessLevel(level) { beforeEach(`set public access level to ${level}`, function () { this.project.publicAccesLevel = level + this.CollaboratorsGetter.promises.getProjectAccess + .withArgs(this.project._id) + .resolves({ + publicAccessLevel: sinon.stub().returns(this.project.publicAccesLevel), + privilegeLevelForUser: sinon.stub().returns(PrivilegeLevels.NONE), + }) }) } diff --git a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js index dffa2d21ff..f9fcf4362e 100644 --- a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js +++ b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js @@ -51,10 +51,25 @@ describe('EditorHttpController', function () { this.AuthorizationManager = { isRestrictedUser: sinon.stub().returns(false), promises: { - getPrivilegeLevelForProject: sinon.stub().resolves('owner'), + getPrivilegeLevelForProjectWithProjectAccess: sinon + .stub() + .resolves('owner'), }, } this.CollaboratorsGetter = { + ProjectAccess: class { + loadInvitedMembers() { + return [] + } + + isUserTokenMember() { + return false + } + + isUserInvitedMember() { + return false + } + }, promises: { getInvitedMembersWithPrivilegeLevels: sinon .stub() @@ -170,9 +185,12 @@ describe('EditorHttpController', function () { describe('successfully', function () { beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - true - ) + sinon + .stub( + this.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserInvitedMember' + ) + .returns(true) this.res.callback = done this.EditorHttpController.joinProject(this.req, this.res) }) @@ -214,7 +232,7 @@ describe('EditorHttpController', function () { describe('with a restricted user', function () { beforeEach(function (done) { this.AuthorizationManager.isRestrictedUser.returns(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( 'readOnly' ) this.res.callback = done @@ -234,7 +252,7 @@ describe('EditorHttpController', function () { describe('when not authorized', function () { beforeEach(function (done) { - this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( null ) this.res.callback = done @@ -258,7 +276,7 @@ describe('EditorHttpController', function () { this.AuthorizationManager.isRestrictedUser .withArgs(null, 'readOnly', false, false) .returns(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProject + this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess .withArgs(null, this.project._id, this.token) .resolves('readOnly') this.EditorHttpController.joinProject(this.req, this.res) @@ -277,11 +295,19 @@ describe('EditorHttpController', function () { describe('with a token access user', function () { beforeEach(function (done) { - this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - false - ) - this.CollaboratorsHandler.promises.userIsTokenMember.resolves(true) - this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + sinon + .stub( + this.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserInvitedMember' + ) + .returns(false) + sinon + .stub( + this.CollaboratorsGetter.ProjectAccess.prototype, + 'isUserTokenMember' + ) + .returns(true) + this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( 'readAndWrite' ) this.res.callback = done From 0aae5c48b4632e5efaf25aa7111b90d834bf8ff6 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 2 Jun 2025 13:38:40 +0200 Subject: [PATCH 063/259] [web] skip fetching members and invites for restricted users (#25673) * [web] hide sensitive data from joinProject when building project view * [web] skip fetching members and invites for restricted users * [web] fix owner features in joinProject view * [web] separate invited members from owner * [web] skip fetching users with empty members list * [web] split await chain Co-authored-by: Antoine Clausse * [web] remove spurious parentheses * [web] remove dead code Co-authored-by: Antoine Clausse --------- Co-authored-by: Antoine Clausse GitOrigin-RevId: 5b4d874f974971e9c14d7412620805f8ebf63541 --- .../Collaborators/CollaboratorsGetter.js | 47 +++-- .../Features/Editor/EditorHttpController.js | 23 +- .../Features/Project/ProjectEditorHandler.js | 45 ++-- .../Collaborators/CollaboratorsGetterTests.js | 60 ++---- .../src/Editor/EditorHttpControllerTests.js | 83 ++++++-- .../src/Project/ProjectEditorHandlerTests.js | 199 +++++++++++------- 6 files changed, 268 insertions(+), 189 deletions(-) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index 10c8e53757..a3543ae614 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -16,9 +16,6 @@ module.exports = { getMemberIdsWithPrivilegeLevels: callbackify(getMemberIdsWithPrivilegeLevels), getMemberIds: callbackify(getMemberIds), getInvitedMemberIds: callbackify(getInvitedMemberIds), - getInvitedMembersWithPrivilegeLevels: callbackify( - getInvitedMembersWithPrivilegeLevels - ), getInvitedMembersWithPrivilegeLevelsFromFields: callbackify( getInvitedMembersWithPrivilegeLevelsFromFields ), @@ -36,7 +33,6 @@ module.exports = { getMemberIdsWithPrivilegeLevels, getMemberIds, getInvitedMemberIds, - getInvitedMembersWithPrivilegeLevels, getInvitedMembersWithPrivilegeLevelsFromFields, getMemberIdPrivilegeLevel, getInvitedEditCollaboratorCount, @@ -95,11 +91,40 @@ class ProjectAccess { this.#publicAccessLevel = project.publicAccesLevel } + /** + * @return {Promise<{ownerMember: LoadedProjectMember|undefined, members: LoadedProjectMember[]}>} + */ + async loadOwnerAndInvitedMembers() { + const all = await _loadMembers( + this.#members.filter(m => m.source !== Sources.TOKEN) + ) + return { + ownerMember: all.find(m => m.privilegeLevel === PrivilegeLevels.OWNER), + members: all.filter(m => m.privilegeLevel !== PrivilegeLevels.OWNER), + } + } + /** * @return {Promise} */ async loadInvitedMembers() { - return _loadMembers(this.#members.filter(m => m.source !== Sources.TOKEN)) + return _loadMembers( + this.#members.filter( + m => + m.source !== Sources.TOKEN && + m.privilegeLevel !== PrivilegeLevels.OWNER + ) + ) + } + + /** + * @return {Promise} + */ + async loadOwner() { + const [owner] = await _loadMembers( + this.#members.filter(m => m.privilegeLevel === PrivilegeLevels.OWNER) + ) + return owner } /** @@ -248,10 +273,6 @@ async function getInvitedMemberIds(projectId) { return (await getProjectAccess(projectId)).invitedMemberIds() } -async function getInvitedMembersWithPrivilegeLevels(projectId) { - return await (await getProjectAccess(projectId)).loadInvitedMembers() -} - async function getInvitedMembersWithPrivilegeLevelsFromFields( ownerId, collaboratorIds, @@ -397,10 +418,9 @@ async function dangerouslyGetAllProjectsUserIsMemberOf(userId, fields) { async function getAllInvitedMembers(projectId) { try { - const { members } = ProjectEditorHandler.buildOwnerAndMembersViews( - await (await getProjectAccess(projectId)).loadInvitedMembers() - ) - return members + const projectAccess = await getProjectAccess(projectId) + const invitedMembers = await projectAccess.loadInvitedMembers() + return invitedMembers.map(ProjectEditorHandler.buildUserModelView) } catch (err) { throw OError.tag(err, 'error getting members for project', { projectId }) } @@ -526,6 +546,7 @@ function _getMemberIdsWithPrivilegeLevelsFromFields( * @private */ async function _loadMembers(members) { + if (members.length === 0) return [] const userIds = Array.from(new Set(members.map(m => m.id))) const users = new Map() for (const user of await UserGetter.promises.getUsers(userIds, { diff --git a/services/web/app/src/Features/Editor/EditorHttpController.js b/services/web/app/src/Features/Editor/EditorHttpController.js index def7face04..f44b57f069 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.js +++ b/services/web/app/src/Features/Editor/EditorHttpController.js @@ -42,12 +42,6 @@ async function joinProject(req, res, next) { if (!project) { return res.sendStatus(403) } - // Hide sensitive data if the user is restricted - if (isRestrictedUser) { - project.owner = { _id: project.owner._id } - project.members = [] - project.invites = [] - } // Only show the 'renamed or deleted' message once if (project.deletedByExternalDataSource) { await ProjectDeleter.promises.unmarkAsDeletedByExternalSource(projectId) @@ -75,7 +69,6 @@ async function _buildJoinProjectView(req, projectId, userId) { throw new Errors.NotFoundError('project not found') } const projectAccess = new ProjectAccess(project) - const members = await projectAccess.loadInvitedMembers() const token = req.body.anonymousAccessToken const privilegeLevel = await AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess( @@ -87,8 +80,6 @@ async function _buildJoinProjectView(req, projectId, userId) { if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) { return { project: null, privilegeLevel: null, isRestrictedUser: false } } - const invites = - await CollaboratorsInviteGetter.promises.getAllInvites(projectId) const isTokenMember = projectAccess.isUserTokenMember(userId) const isInvitedMember = projectAccess.isUserInvitedMember(userId) const isRestrictedUser = AuthorizationManager.isRestrictedUser( @@ -97,11 +88,23 @@ async function _buildJoinProjectView(req, projectId, userId) { isTokenMember, isInvitedMember ) + let ownerMember + let members = [] + let invites = [] + if (isRestrictedUser) { + ownerMember = await projectAccess.loadOwner() + } else { + ;({ ownerMember, members } = + await projectAccess.loadOwnerAndInvitedMembers()) + invites = await CollaboratorsInviteGetter.promises.getAllInvites(projectId) + } return { project: ProjectEditorHandler.buildProjectModelView( project, + ownerMember, members, - invites + invites, + isRestrictedUser ), privilegeLevel, isTokenMember, diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js index 05e5beba09..3d3d300e66 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.js +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js @@ -6,8 +6,13 @@ const Features = require('../../infrastructure/Features') module.exports = ProjectEditorHandler = { trackChangesAvailable: false, - buildProjectModelView(project, members, invites) { - let owner, ownerFeatures + buildProjectModelView( + project, + ownerMember, + members, + invites, + isRestrictedUser + ) { const result = { _id: project._id, name: project.name, @@ -20,20 +25,23 @@ module.exports = ProjectEditorHandler = { description: project.description, spellCheckLanguage: project.spellCheckLanguage, deletedByExternalDataSource: project.deletedByExternalDataSource || false, - members: [], - invites: this.buildInvitesView(invites), imageName: project.imageName != null ? Path.basename(project.imageName) : undefined, } - ;({ owner, ownerFeatures, members } = - this.buildOwnerAndMembersViews(members)) - result.owner = owner - result.members = members + if (isRestrictedUser) { + result.owner = { _id: project.owner_ref } + result.members = [] + result.invites = [] + } else { + result.owner = this.buildUserModelView(ownerMember) + result.members = members.map(this.buildUserModelView) + result.invites = this.buildInvitesView(invites) + } - result.features = _.defaults(ownerFeatures || {}, { + result.features = _.defaults(ownerMember?.user?.features || {}, { collaborators: -1, // Infinite versioning: false, dropbox: false, @@ -62,25 +70,6 @@ module.exports = ProjectEditorHandler = { return result }, - buildOwnerAndMembersViews(members) { - let owner = null - let ownerFeatures = null - const filteredMembers = [] - for (const member of members || []) { - if (member.privilegeLevel === 'owner') { - ownerFeatures = member.user.features - owner = this.buildUserModelView(member) - } else { - filteredMembers.push(this.buildUserModelView(member)) - } - } - return { - owner, - ownerFeatures, - members: filteredMembers, - } - }, - buildUserModelView(member) { const user = member.user return { diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js index dda99e04f3..10542c4564 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js @@ -62,7 +62,7 @@ describe('CollaboratorsGetter', function () { }, } this.ProjectEditorHandler = { - buildOwnerAndMembersViews: sinon.stub(), + buildUserModelView: sinon.stub(), } this.CollaboratorsGetter = SandboxedModule.require(MODULE_PATH, { requires: { @@ -204,30 +204,6 @@ describe('CollaboratorsGetter', function () { }) }) - describe('getInvitedMembersWithPrivilegeLevels', function () { - beforeEach(function () { - this.UserGetter.promises.getUsers.resolves([ - { _id: this.readOnlyRef1 }, - { _id: this.readOnlyTokenRef }, - { _id: this.readWriteRef2 }, - { _id: this.readWriteTokenRef }, - { _id: this.reviewer1Ref }, - ]) - }) - - it('should return an array of invited members with their privilege levels', async function () { - const result = - await this.CollaboratorsGetter.promises.getInvitedMembersWithPrivilegeLevels( - this.project._id - ) - expect(result).to.have.deep.members([ - { user: { _id: this.readOnlyRef1 }, privilegeLevel: 'readOnly' }, - { user: { _id: this.readWriteRef2 }, privilegeLevel: 'readAndWrite' }, - { user: { _id: this.reviewer1Ref }, privilegeLevel: 'review' }, - ]) - }) - }) - describe('getMemberIdPrivilegeLevel', function () { it('should return the privilege level if it exists', async function () { const level = @@ -401,20 +377,21 @@ describe('CollaboratorsGetter', function () { { user: this.readWriteUser, privilegeLevel: 'readAndWrite' }, { user: this.reviewUser, privilegeLevel: 'review' }, ] - this.views = { - owner: this.owningUser, - ownerFeatures: this.owningUser.features, - members: [ - { _id: this.readWriteUser._id, email: this.readWriteUser.email }, - { _id: this.reviewUser._id, email: this.reviewUser.email }, - ], - } + this.memberViews = [ + { _id: this.readWriteUser._id, email: this.readWriteUser.email }, + { _id: this.reviewUser._id, email: this.reviewUser.email }, + ] this.UserGetter.promises.getUsers.resolves([ this.owningUser, this.readWriteUser, this.reviewUser, ]) - this.ProjectEditorHandler.buildOwnerAndMembersViews.returns(this.views) + this.ProjectEditorHandler.buildUserModelView + .withArgs(this.members[1]) + .returns(this.memberViews[0]) + this.ProjectEditorHandler.buildUserModelView + .withArgs(this.members[2]) + .returns(this.memberViews[1]) this.result = await this.CollaboratorsGetter.promises.getAllInvitedMembers( this.project._id @@ -422,15 +399,18 @@ describe('CollaboratorsGetter', function () { }) it('should produce a list of members', function () { - expect(this.result).to.deep.equal(this.views.members) + expect(this.result).to.deep.equal(this.memberViews) }) - it('should call ProjectEditorHandler.buildOwnerAndMembersViews', function () { - expect(this.ProjectEditorHandler.buildOwnerAndMembersViews).to.have.been - .calledOnce + it('should call ProjectEditorHandler.buildUserModelView', function () { + expect(this.ProjectEditorHandler.buildUserModelView).to.have.been + .calledTwice expect( - this.ProjectEditorHandler.buildOwnerAndMembersViews - ).to.have.been.calledWith(this.members) + this.ProjectEditorHandler.buildUserModelView + ).to.have.been.calledWith(this.members[1]) + expect( + this.ProjectEditorHandler.buildUserModelView + ).to.have.been.calledWith(this.members[2]) }) }) diff --git a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js index f9fcf4362e..7fc08c45d3 100644 --- a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js +++ b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js @@ -20,6 +20,12 @@ describe('EditorHttpController', function () { _id: new ObjectId(), projects: {}, } + this.members = [ + { user: { _id: 'owner', features: {} }, privilegeLevel: 'owner' }, + { user: { _id: 'one' }, privilegeLevel: 'readOnly' }, + ] + this.ownerMember = this.members[0] + this.invites = [{ _id: 'three' }, { _id: 'four' }] this.projectView = { _id: this.project._id, owner: { @@ -27,7 +33,10 @@ describe('EditorHttpController', function () { email: 'owner@example.com', other_property: true, }, - members: [{ one: 1 }, { two: 2 }], + members: [ + { _id: 'owner', privileges: 'owner' }, + { _id: 'one', privileges: 'readOnly' }, + ], invites: [{ three: 3 }, { four: 4 }], } this.reducedProjectView = { @@ -56,10 +65,16 @@ describe('EditorHttpController', function () { .resolves('owner'), }, } + const members = this.members + const ownerMember = this.ownerMember this.CollaboratorsGetter = { ProjectAccess: class { - loadInvitedMembers() { - return [] + loadOwnerAndInvitedMembers() { + return { members, ownerMember } + } + + loadOwner() { + return ownerMember } isUserTokenMember() { @@ -71,9 +86,6 @@ describe('EditorHttpController', function () { } }, promises: { - getInvitedMembersWithPrivilegeLevels: sinon - .stub() - .resolves(['members', 'mock']), isUserInvitedMemberOfProject: sinon.stub().resolves(false), }, } @@ -82,22 +94,23 @@ describe('EditorHttpController', function () { userIsTokenMember: sinon.stub().resolves(false), }, } + this.invites = [ + { + _id: 'invite_one', + email: 'user-one@example.com', + privileges: 'readOnly', + projectId: this.project._id, + }, + { + _id: 'invite_two', + email: 'user-two@example.com', + privileges: 'readOnly', + projectId: this.project._id, + }, + ] this.CollaboratorsInviteGetter = { promises: { - getAllInvites: sinon.stub().resolves([ - { - _id: 'invite_one', - email: 'user-one@example.com', - privileges: 'readOnly', - projectId: this.project._id, - }, - { - _id: 'invite_two', - email: 'user-two@example.com', - privileges: 'readOnly', - projectId: this.project._id, - }, - ]), + getAllInvites: sinon.stub().resolves(this.invites), }, } this.EditorController = { @@ -195,6 +208,18 @@ describe('EditorHttpController', function () { this.EditorHttpController.joinProject(this.req, this.res) }) + it('should request a full view', function () { + expect( + this.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith( + this.project, + this.ownerMember, + this.members, + this.invites, + false + ) + }) + it('should return the project and privilege level', function () { expect(this.res.json).to.have.been.calledWith({ project: this.projectView, @@ -231,6 +256,9 @@ describe('EditorHttpController', function () { describe('with a restricted user', function () { beforeEach(function (done) { + this.ProjectEditorHandler.buildProjectModelView.returns( + this.reducedProjectView + ) this.AuthorizationManager.isRestrictedUser.returns(true) this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( 'readOnly' @@ -239,6 +267,12 @@ describe('EditorHttpController', function () { this.EditorHttpController.joinProject(this.req, this.res) }) + it('should request a restricted view', function () { + expect( + this.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith(this.project, this.ownerMember, [], [], true) + }) + it('should mark the user as restricted, and hide details of owner', function () { expect(this.res.json).to.have.been.calledWith({ project: this.reducedProjectView, @@ -268,6 +302,9 @@ describe('EditorHttpController', function () { beforeEach(function (done) { this.token = 'token' this.TokenAccessHandler.getRequestToken.returns(this.token) + this.ProjectEditorHandler.buildProjectModelView.returns( + this.reducedProjectView + ) this.req.body = { userId: 'anonymous-user', anonymousAccessToken: this.token, @@ -282,6 +319,12 @@ describe('EditorHttpController', function () { this.EditorHttpController.joinProject(this.req, this.res) }) + it('should request a restricted view', function () { + expect( + this.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith(this.project, this.ownerMember, [], [], true) + }) + it('should mark the user as restricted', function () { expect(this.res.json).to.have.been.calledWith({ project: this.reducedProjectView, diff --git a/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js b/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js index 0fb5b5fce4..8456fe2227 100644 --- a/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js +++ b/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js @@ -8,6 +8,7 @@ describe('ProjectEditorHandler', function () { beforeEach(function () { this.project = { _id: 'project-id', + owner_ref: 'owner-id', name: 'Project Name', rootDoc_id: 'file-id', publicAccesLevel: 'private', @@ -43,16 +44,19 @@ describe('ProjectEditorHandler', function () { }, ], } + this.ownerMember = { + user: (this.owner = { + _id: 'owner-id', + first_name: 'Owner', + last_name: 'Overleaf', + email: 'owner@overleaf.com', + features: { + compileTimeout: 240, + }, + }), + privilegeLevel: 'owner', + } this.members = [ - { - user: (this.owner = { - _id: 'owner-id', - first_name: 'Owner', - last_name: 'Overleaf', - email: 'owner@overleaf.com', - }), - privilegeLevel: 'owner', - }, { user: { _id: 'read-only-id', @@ -96,8 +100,10 @@ describe('ProjectEditorHandler', function () { beforeEach(function () { this.result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - this.invites + this.invites, + false ) }) @@ -206,6 +212,93 @@ describe('ProjectEditorHandler', function () { expect(invite.token).not.to.exist } }) + + it('should have the correct features', function () { + expect(this.result.features.compileTimeout).to.equal(240) + }) + }) + + describe('with a restricted user', function () { + beforeEach(function () { + this.result = this.handler.buildProjectModelView( + this.project, + this.ownerMember, + [], + [], + true + ) + }) + + it('should include the id', function () { + expect(this.result._id).to.exist + this.result._id.should.equal('project-id') + }) + + it('should include the name', function () { + expect(this.result.name).to.exist + this.result.name.should.equal('Project Name') + }) + + it('should include the root doc id', function () { + expect(this.result.rootDoc_id).to.exist + this.result.rootDoc_id.should.equal('file-id') + }) + + it('should include the public access level', function () { + expect(this.result.publicAccesLevel).to.exist + this.result.publicAccesLevel.should.equal('private') + }) + + it('should hide the owner', function () { + expect(this.result.owner).to.deep.equal({ _id: 'owner-id' }) + }) + + it('should hide members', function () { + this.result.members.length.should.equal(0) + }) + + it('should include folders in the project', function () { + this.result.rootFolder[0]._id.should.equal('root-folder-id') + this.result.rootFolder[0].name.should.equal('') + + this.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id') + this.result.rootFolder[0].folders[0].name.should.equal('folder') + }) + + it('should not duplicate folder contents', function () { + this.result.rootFolder[0].docs.length.should.equal(0) + this.result.rootFolder[0].fileRefs.length.should.equal(0) + }) + + it('should include files in the project', function () { + this.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal( + 'file-id' + ) + this.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal( + 'image.png' + ) + this.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal( + this.created + ) + expect(this.result.rootFolder[0].folders[0].fileRefs[0].size).not.to + .exist + }) + + it('should include docs in the project but not the lines', function () { + this.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id') + this.result.rootFolder[0].folders[0].docs[0].name.should.equal( + 'main.tex' + ) + expect(this.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist + }) + + it('should hide invites', function () { + expect(this.result.invites).to.have.length(0) + }) + + it('should have the correct features', function () { + expect(this.result.features.compileTimeout).to.equal(240) + }) }) describe('deletedByExternalDataSource', function () { @@ -213,8 +306,10 @@ describe('ProjectEditorHandler', function () { delete this.project.deletedByExternalDataSource const result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) result.deletedByExternalDataSource.should.equal(false) }) @@ -222,8 +317,10 @@ describe('ProjectEditorHandler', function () { it('should set the deletedByExternalDataSource flag to false when it is false', function () { const result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) result.deletedByExternalDataSource.should.equal(false) }) @@ -232,8 +329,10 @@ describe('ProjectEditorHandler', function () { this.project.deletedByExternalDataSource = true const result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) result.deletedByExternalDataSource.should.equal(true) }) @@ -249,8 +348,10 @@ describe('ProjectEditorHandler', function () { } this.result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) }) @@ -278,8 +379,10 @@ describe('ProjectEditorHandler', function () { } this.result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) }) it('should not emit trackChangesState', function () { @@ -302,8 +405,10 @@ describe('ProjectEditorHandler', function () { this.project.track_changes = dbEntry this.result = this.handler.buildProjectModelView( this.project, + this.ownerMember, this.members, - [] + [], + false ) }) it(`should set trackChangesState=${expected}`, function () { @@ -322,66 +427,4 @@ describe('ProjectEditorHandler', function () { }) }) }) - - describe('buildOwnerAndMembersViews', function () { - beforeEach(function () { - this.owner.features = { - versioning: true, - collaborators: 3, - compileGroup: 'priority', - compileTimeout: 22, - } - this.result = this.handler.buildOwnerAndMembersViews(this.members) - }) - - it('should produce an object with the right keys', function () { - expect(this.result).to.have.all.keys([ - 'owner', - 'ownerFeatures', - 'members', - ]) - }) - - it('should separate the owner from the members', function () { - this.result.members.length.should.equal(this.members.length - 1) - expect(this.result.owner._id).to.equal(this.owner._id) - expect(this.result.owner.email).to.equal(this.owner.email) - expect( - this.result.members.filter(m => m._id === this.owner._id).length - ).to.equal(0) - }) - - it('should extract the ownerFeatures from the owner object', function () { - expect(this.result.ownerFeatures).to.deep.equal(this.owner.features) - }) - - describe('when there is no owner', function () { - beforeEach(function () { - // remove the owner from members list - this.membersWithoutOwner = this.members.filter( - m => m.user._id !== this.owner._id - ) - this.result = this.handler.buildOwnerAndMembersViews( - this.membersWithoutOwner - ) - }) - - it('should produce an object with the right keys', function () { - expect(this.result).to.have.all.keys([ - 'owner', - 'ownerFeatures', - 'members', - ]) - }) - - it('should not separate out an owner', function () { - this.result.members.length.should.equal(this.membersWithoutOwner.length) - expect(this.result.owner).to.equal(null) - }) - - it('should not extract the ownerFeatures from the owner object', function () { - expect(this.result.ownerFeatures).to.equal(null) - }) - }) - }) }) From 3fbbb50ef751a90ddd90b326f5065437790dbc04 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 2 Jun 2025 13:38:47 +0200 Subject: [PATCH 064/259] [web] use correct term in setPublicAccessLevel API wrapper (#25848) GitOrigin-RevId: 022c59d6d5c6f239438ed8e91f3ca47954198a0c --- .../features/share-project-modal/components/link-sharing.tsx | 4 ++-- .../web/frontend/js/features/share-project-modal/utils/api.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx index d235bd248b..a2d17734b0 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx @@ -1,7 +1,7 @@ import { useCallback, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useShareProjectContext } from './share-project-modal' -import { setProjectAccessLevel } from '../utils/api' +import { setPublicAccessLevel } from '../utils/api' import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' import { useProjectContext } from '@/shared/context/project-context' import * as eventTracking from '../../../infrastructure/event-tracking' @@ -43,7 +43,7 @@ export default function LinkSharing() { project_id: projectId, }) monitorRequest(() => - setProjectAccessLevel(projectId, newPublicAccessLevel) + setPublicAccessLevel(projectId, newPublicAccessLevel) ) .then(() => { // NOTE: not calling `updateProject` here as it receives data via diff --git a/services/web/frontend/js/features/share-project-modal/utils/api.js b/services/web/frontend/js/features/share-project-modal/utils/api.js index d52b6a4857..38b2040f2b 100644 --- a/services/web/frontend/js/features/share-project-modal/utils/api.js +++ b/services/web/frontend/js/features/share-project-modal/utils/api.js @@ -47,7 +47,7 @@ export function transferProjectOwnership(projectId, member) { }) } -export function setProjectAccessLevel(projectId, publicAccessLevel) { +export function setPublicAccessLevel(projectId, publicAccessLevel) { return postJSON(`/project/${projectId}/settings/admin`, { body: { publicAccessLevel }, }) From 35500cc72bdd29f700e8a9ef6b8a7df9801a8754 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Mon, 2 Jun 2025 06:18:06 -0700 Subject: [PATCH 065/259] Merge pull request #25607 from overleaf/mf-free-trial-limit-stripe-handler [web] Limit user free trial on stripe subscription GitOrigin-RevId: b3d978ed598d20451a99cf811fcae9ba2e3b23f0 --- services/web/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 9e0f86e8b9..445fb62c8b 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -2739,6 +2739,7 @@ "youre_signed_up": "You’re signed up", "youve_added_more_licenses": "You’ve added more license(s)!", "youve_added_x_more_licenses_to_your_subscription_invite_people": "You’ve added __users__ more license(s) to your subscription. <0>Invite people.", + "youve_already_used_your_free_tial": "You’ve already used your free trial. Upgrade to continue using premium features.", "youve_lost_collaboration_access": "You’ve lost collaboration access", "youve_paused_your_subscription": "Your <0>__planName__ subscription is paused until <0>__reactivationDate__, then it’ll automatically unpause. You can unpause early at any time.", "youve_unlinked_all_users": "You’ve unlinked all users", From 4b9963757f9df83f859a80d82e6a5b69785163d0 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Mon, 2 Jun 2025 14:47:24 +0100 Subject: [PATCH 066/259] Merge pull request #26047 from overleaf/bg-web-api-is-leaking-disk-space clean up temporary files in GitBridgeHandler operations GitOrigin-RevId: b4a202f4f4c563a020fed8a47da1a84417ccbd2d --- .../acceptance/src/mocks/MockGitBridgeApi.mjs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs b/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs index 4927814b9a..805bbdd8fe 100644 --- a/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs @@ -3,12 +3,16 @@ import AbstractMockApi from './AbstractMockApi.mjs' class MockGitBridgeApi extends AbstractMockApi { reset() { this.projects = {} + this.postbacks = {} } applyRoutes() { this.app.delete('/api/projects/:projectId', (req, res) => { this.deleteProject(req, res) }) + this.app.post('/postback/:id', (req, res) => { + this.postback(req, res) + }) } deleteProject(req, res) { @@ -16,6 +20,24 @@ class MockGitBridgeApi extends AbstractMockApi { delete this.projects[projectId] res.sendStatus(204) } + + // Git bridge accepts a postback to indicate when a operation is complete. + // Each postback is identified by a unique ID. + // Allow registering a handler which resolves when a postback is received. + registerPostback(id) { + return new Promise((resolve, reject) => { + this.postbacks[id] = { resolve, reject } + }) + } + + postback(req, res) { + const { id } = req.params + const postbackData = req.body + if (this.postbacks[id]) { + this.postbacks[id].resolve(postbackData) + } + res.sendStatus(204) + } } export default MockGitBridgeApi From 3a96df4623b82d94a956693c26394c321be84c09 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Mon, 2 Jun 2025 14:50:28 +0100 Subject: [PATCH 067/259] Merge pull request #26050 from overleaf/em-saml-user-query Improve index usage for SAML user query GitOrigin-RevId: 189aba60a12c8369a0062e7df4c57bef8a16c98c --- .../web/app/src/Features/User/SAMLIdentityManager.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/services/web/app/src/Features/User/SAMLIdentityManager.js b/services/web/app/src/Features/User/SAMLIdentityManager.js index dc790c59ca..0d3c382775 100644 --- a/services/web/app/src/Features/User/SAMLIdentityManager.js +++ b/services/web/app/src/Features/User/SAMLIdentityManager.js @@ -210,9 +210,13 @@ async function getUser(providerId, externalUserId, userIdAttribute) { ) } const user = await User.findOne({ - 'samlIdentifiers.externalUserId': externalUserId.toString(), - 'samlIdentifiers.providerId': providerId.toString(), - 'samlIdentifiers.userIdAttribute': userIdAttribute.toString(), + samlIdentifiers: { + $elemMatch: { + externalUserId: externalUserId.toString(), + providerId: providerId.toString(), + userIdAttribute: userIdAttribute.toString(), + }, + }, }).exec() return user From 48337b2e2ca84da0cdf675ada0e8e945fbe64a10 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 2 Jun 2025 15:27:18 +0100 Subject: [PATCH 068/259] Merge pull request #25808 from overleaf/mj-ide-full-project-search [web] Editor redesign: Add full project search GitOrigin-RevId: b4327c4ba0ddd7387ec8d6640e31200ca0fe4a6e --- services/web/config/settings.defaults.js | 1 + .../web/frontend/extracted-translations.json | 1 + ...alSymbolsRoundedUnfilledPartialSlice.woff2 | Bin 4384 -> 4444 bytes .../material-symbols/unfilled-symbols.mjs | 3 ++- .../features/event-tracking/search-events.ts | 2 +- .../components/full-project-search-panel.tsx | 19 +++++++++++++++ .../features/ide-redesign/components/rail.tsx | 23 ++++++++++++++++-- .../ide-redesign/contexts/rail-context.tsx | 1 + .../components/codemirror-search-form.tsx | 5 +--- .../components/full-project-search-button.tsx | 12 +++++++-- services/web/locales/en.json | 1 + 11 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/full-project-search-panel.tsx diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index a7ff970ef0..d8892e70ff 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -996,6 +996,7 @@ module.exports = { toastGenerators: [], editorSidebarComponents: [], fileTreeToolbarComponents: [], + fullProjectSearchPanel: [], integrationPanelComponents: [], referenceSearchSetting: [], }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 09c2ba90dc..20459e0ed6 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1292,6 +1292,7 @@ "project_ownership_transfer_confirmation_2": "", "project_renamed_or_deleted": "", "project_renamed_or_deleted_detail": "", + "project_search": "", "project_search_file_count": "", "project_search_file_count_plural": "", "project_search_result_count": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index df942df17688781db3c188df5a20253919ec02fc..8e72799b077bd442cf7211bd1488813826bc9018 100644 GIT binary patch literal 4444 zcmV-i5u@&RPew8T0RR9101;dO4gdfE03%=k01*TL0RR9100000000000000000000 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 literal 4384 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 awAp~2GYtG2#iPx+myzd%{|nE^D**u2R#deB diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index baefac05aa..1c41421910 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -10,6 +10,7 @@ export default /** @type {const} */ ([ 'create_new_folder', 'delete', 'description', + 'error', 'experiment', 'forum', 'help', @@ -20,10 +21,10 @@ export default /** @type {const} */ ([ 'picture_as_pdf', 'rate_review', 'report', + 'search', 'settings', 'space_dashboard', 'table_chart', 'upload_file', 'web_asset', - 'error', ]) diff --git a/services/web/frontend/js/features/event-tracking/search-events.ts b/services/web/frontend/js/features/event-tracking/search-events.ts index cd9ff4b8ba..630d07aeaa 100644 --- a/services/web/frontend/js/features/event-tracking/search-events.ts +++ b/services/web/frontend/js/features/event-tracking/search-events.ts @@ -6,7 +6,7 @@ type SearchEventSegmentation = { searchType: 'full-project' } & ( | { method: 'keyboard' } - | { method: 'button'; location: 'toolbar' | 'search-form' } + | { method: 'button'; location: 'toolbar' | 'search-form' | 'rail' } )) | ({ searchType: 'document' diff --git a/services/web/frontend/js/features/ide-redesign/components/full-project-search-panel.tsx b/services/web/frontend/js/features/ide-redesign/components/full-project-search-panel.tsx new file mode 100644 index 0000000000..926341ce89 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/full-project-search-panel.tsx @@ -0,0 +1,19 @@ +import { ElementType } from 'react' +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' + +const componentModule = importOverleafModules('fullProjectSearchPanel')[0] as + | { + import: { default: ElementType } + path: string + } + | undefined + +export const FullProjectSearchPanel = () => { + if (!componentModule) { + return null + } + const FullProjectSearch = componentModule.import.default + return +} + +export const hasFullProjectSearch = Boolean(componentModule) diff --git a/services/web/frontend/js/features/ide-redesign/components/rail.tsx b/services/web/frontend/js/features/ide-redesign/components/rail.tsx index d6e1112536..9bd70ac4bb 100644 --- a/services/web/frontend/js/features/ide-redesign/components/rail.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/rail.tsx @@ -34,6 +34,11 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import OLIconButton from '@/features/ui/components/ol/ol-icon-button' import { useChatContext } from '@/features/chat/context/chat-context' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { + FullProjectSearchPanel, + hasFullProjectSearch, +} from './full-project-search-panel' +import { sendSearchEvent } from '@/features/event-tracking/search-events' type RailElement = { icon: AvailableUnfilledIcon @@ -106,6 +111,13 @@ export const RailLayout = () => { title: t('file_tree'), component: , }, + { + key: 'full-project-search', + icon: 'search', + title: t('project_search'), + component: , + hide: !hasFullProjectSearch, + }, { key: 'integrations', icon: 'integration_instructions', @@ -170,10 +182,17 @@ export const RailLayout = () => { // Attempting to open a non-existent tab return } - const keyOrDefault = key ?? 'file-tree' + const keyOrDefault = (key ?? 'file-tree') as RailTabKey // Change the selected tab and make sure it's open - openTab(keyOrDefault as RailTabKey) + openTab(keyOrDefault) sendEvent('rail-click', { tab: keyOrDefault }) + if (keyOrDefault === 'full-project-search') { + sendSearchEvent('search-open', { + searchType: 'full-project', + method: 'button', + location: 'rail', + }) + } if (key === 'chat') { markMessagesAsRead() diff --git a/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx b/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx index c02d17fb9b..85ec482fc0 100644 --- a/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx +++ b/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx @@ -19,6 +19,7 @@ export type RailTabKey = | 'review-panel' | 'chat' | 'errors' + | 'full-project-search' export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary' diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx index 90a968add6..a65232f94d 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx @@ -36,7 +36,6 @@ import { getStoredSelection, setStoredSelection } from '../extensions/search' import { debounce } from 'lodash' import { EditorSelection, EditorState } from '@codemirror/state' import { sendSearchEvent } from '@/features/event-tracking/search-events' -import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' import { FullProjectSearchButton } from './full-project-search-button' const MATCH_COUNT_DEBOUNCE_WAIT = 100 // the amount of ms to wait before counting matches @@ -82,8 +81,6 @@ const CodeMirrorSearchForm: FC = () => { const inputRef = useRef(null) const replaceRef = useRef(null) - const newEditor = useIsNewEditorEnabled() - const handleInputRef = useCallback((node: HTMLInputElement) => { inputRef.current = node @@ -443,7 +440,7 @@ const CodeMirrorSearchForm: FC = () => { - {!newEditor && } + {position !== null && (
diff --git a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx index 698204d89c..be02fdbe3c 100644 --- a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx +++ b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx @@ -12,6 +12,8 @@ import Close from '@/shared/components/close' import useTutorial from '@/shared/hooks/promotions/use-tutorial' import { useEditorContext } from '@/shared/context/editor-context' import getMeta from '@/utils/meta' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' +import { useRailContext } from '@/features/ide-redesign/contexts/rail-context' const PROMOTION_SIGNUP_CUT_OFF_DATE = new Date('2025-04-22T00:00:00Z') @@ -19,6 +21,8 @@ export const FullProjectSearchButton = ({ query }: { query: SearchQuery }) => { const view = useCodeMirrorViewContext() const { t } = useTranslation() const { setProjectSearchIsOpen } = useLayoutContext() + const newEditor = useIsNewEditorEnabled() + const { openTab } = useRailContext() const ref = useRef(null) const { inactiveTutorials } = useEditorContext() @@ -44,14 +48,18 @@ export const FullProjectSearchButton = ({ query }: { query: SearchQuery }) => { } const openFullProjectSearch = useCallback(() => { - setProjectSearchIsOpen(true) + if (newEditor) { + openTab('full-project-search') + } else { + setProjectSearchIsOpen(true) + } closeSearchPanel(view) window.setTimeout(() => { window.dispatchEvent( new CustomEvent('editor:full-project-search', { detail: query }) ) }, 200) - }, [setProjectSearchIsOpen, query, view]) + }, [setProjectSearchIsOpen, query, view, newEditor, openTab]) const onClick = useCallback(() => { sendSearchEvent('search-open', { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 445fb62c8b..2efd23fd9f 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1708,6 +1708,7 @@ "project_ownership_transfer_confirmation_2": "This action cannot be undone. The new owner will be notified and will be able to change project access settings (including removing your own access).", "project_renamed_or_deleted": "Project Renamed or Deleted", "project_renamed_or_deleted_detail": "This project has either been renamed or deleted by an external data source such as Dropbox. We don’t want to delete your data on Overleaf, so this project still contains your history and collaborators. If the project has been renamed please look in your project list for a new project under the new name.", + "project_search": "Project search", "project_search_file_count": "in __count__ file", "project_search_file_count_plural": "in __count__ files", "project_search_result_count": "__count__ result", From a63e25953ff7ee703f5f47cf5f58fc701b70ac0d Mon Sep 17 00:00:00 2001 From: roo hutton Date: Tue, 3 Jun 2025 10:02:22 +0100 Subject: [PATCH 069/259] Merge pull request #25896 from overleaf/rh-load-odc-data Load ODC data when revisiting onboarding form GitOrigin-RevId: 506df5d58a8b0305d83b9f43986a55fd309a2720 --- services/web/frontend/js/utils/meta.ts | 3 +++ services/web/types/onboarding.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 services/web/types/onboarding.ts diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 2a396c805b..6e15309187 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -34,6 +34,7 @@ import { import { SplitTestInfo } from '../../../types/split-test' import { ValidationStatus } from '../../../types/group-management/validation' import { ManagedInstitution } from '../../../types/subscription/dashboard/managed-institution' +import { OnboardingFormData } from '../../../types/onboarding' import { GroupSSOTestResult } from '../../../modules/group-settings/frontend/js/utils/types' import { AccessToken, @@ -53,6 +54,7 @@ import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-na import { FooterMetadata } from '@/features/ui/components/types/footer-metadata' import type { ScriptLogType } from '../../../modules/admin-panel/frontend/js/features/script-logs/script-log' import { ActiveExperiment } from './labs-utils' + export interface Meta { 'ol-ExposedSettings': ExposedSettings 'ol-addonPrices': Record< @@ -170,6 +172,7 @@ export interface Meta { 'ol-notifications': NotificationType[] 'ol-notificationsInstitution': InstitutionType[] 'ol-oauthProviders': OAuthProviders + 'ol-odcData': OnboardingFormData 'ol-odcRole': string 'ol-overallThemes': OverallThemeMeta[] 'ol-pages': number diff --git a/services/web/types/onboarding.ts b/services/web/types/onboarding.ts new file mode 100644 index 0000000000..11ae3e51d0 --- /dev/null +++ b/services/web/types/onboarding.ts @@ -0,0 +1,25 @@ +export type UsedLatex = 'never' | 'occasionally' | 'often' +export type Occupation = + | 'university' + | 'company' + | 'nonprofitngo' + | 'government' + | 'other' + +export type OnboardingFormData = { + firstName: string + lastName: string + primaryOccupation: Occupation | null + usedLatex: UsedLatex | null + companyDivisionDepartment: string + companyJobTitle: string + governmentJobTitle: string + institutionName: string + otherJobTitle: string + nonprofitDivisionDepartment: string + nonprofitJobTitle: string + role: string + subjectArea: string + updatedAt?: Date + shouldReceiveUpdates?: boolean +} From 4aaf411cd2d4d5d8aa299c1a6583207087b473b0 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 3 Jun 2025 11:20:18 +0200 Subject: [PATCH 070/259] [misc] improve logging in history system (#26086) * [project-history] tag all the errors * [history-v1] log warnings for unexpected cases GitOrigin-RevId: 3189fa487eee88985688ff990ec101daad0d13b1 --- .../api/controllers/project_import.js | 2 + .../history-v1/api/controllers/projects.js | 11 ++++- services/history-v1/app.js | 2 + .../app/js/HistoryStoreManager.js | 41 +++++++++++++------ 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/services/history-v1/api/controllers/project_import.js b/services/history-v1/api/controllers/project_import.js index edffb19a25..72df912a88 100644 --- a/services/history-v1/api/controllers/project_import.js +++ b/services/history-v1/api/controllers/project_import.js @@ -35,6 +35,7 @@ async function importSnapshot(req, res) { try { snapshot = Snapshot.fromRaw(rawSnapshot) } catch (err) { + logger.warn({ err, projectId }, 'failed to import snapshot') return render.unprocessableEntity(res) } @@ -43,6 +44,7 @@ async function importSnapshot(req, res) { historyId = await chunkStore.initializeProject(projectId, snapshot) } catch (err) { if (err instanceof chunkStore.AlreadyInitialized) { + logger.warn({ err, projectId }, 'already initialized') return render.conflict(res) } else { throw err diff --git a/services/history-v1/api/controllers/projects.js b/services/history-v1/api/controllers/projects.js index 47a1d959ad..031833688c 100644 --- a/services/history-v1/api/controllers/projects.js +++ b/services/history-v1/api/controllers/projects.js @@ -34,6 +34,7 @@ async function initializeProject(req, res, next) { res.status(HTTPStatus.OK).json({ projectId }) } catch (err) { if (err instanceof chunkStore.AlreadyInitialized) { + logger.warn({ err, projectId }, 'failed to initialize') render.conflict(res) } else { throw err @@ -242,11 +243,15 @@ async function createProjectBlob(req, res, next) { const sizeLimit = new StreamSizeLimit(maxUploadSize) await pipeline(req, sizeLimit, fs.createWriteStream(tmpPath)) if (sizeLimit.sizeLimitExceeded) { + logger.warn( + { projectId, expectedHash, maxUploadSize }, + 'blob exceeds size threshold' + ) return render.requestEntityTooLarge(res) } const hash = await blobHash.fromFile(tmpPath) if (hash !== expectedHash) { - logger.debug({ hash, expectedHash }, 'Hash mismatch') + logger.warn({ projectId, hash, expectedHash }, 'Hash mismatch') return render.conflict(res, 'File hash mismatch') } @@ -343,6 +348,10 @@ async function copyProjectBlob(req, res, next) { targetBlobStore.getBlob(blobHash), ]) if (!sourceBlob) { + logger.warn( + { sourceProjectId, targetProjectId, blobHash }, + 'missing source blob when copying across projects' + ) return render.notFound(res) } // Exit early if the blob exists in the target project. diff --git a/services/history-v1/app.js b/services/history-v1/app.js index 261f1001b6..dd991c1a6d 100644 --- a/services/history-v1/app.js +++ b/services/history-v1/app.js @@ -100,11 +100,13 @@ function setupErrorHandling() { }) } if (err.code === 'ENUM_MISMATCH') { + logger.warn({ err, projectId }, err.message) return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({ message: 'invalid enum value: ' + err.paramName, }) } if (err.code === 'REQUIRED') { + logger.warn({ err, projectId }, err.message) return res.status(HTTPStatus.UNPROCESSABLE_ENTITY).json({ message: err.message, }) diff --git a/services/project-history/app/js/HistoryStoreManager.js b/services/project-history/app/js/HistoryStoreManager.js index bb41dfb3c0..38658bdf5b 100644 --- a/services/project-history/app/js/HistoryStoreManager.js +++ b/services/project-history/app/js/HistoryStoreManager.js @@ -35,7 +35,10 @@ class StringStream extends stream.Readable { _mocks.getMostRecentChunk = (projectId, historyId, callback) => { const path = `projects/${historyId}/latest/history` logger.debug({ projectId, historyId }, 'getting chunk from history service') - _requestChunk({ path, json: true }, callback) + _requestChunk({ path, json: true }, (err, chunk) => { + if (err) return callback(OError.tag(err)) + callback(null, chunk) + }) } /** @@ -54,7 +57,10 @@ export function getChunkAtVersion(projectId, historyId, version, callback) { { projectId, historyId, version }, 'getting chunk from history service for version' ) - _requestChunk({ path, json: true }, callback) + _requestChunk({ path, json: true }, (err, chunk) => { + if (err) return callback(OError.tag(err)) + callback(null, chunk) + }) } export function getMostRecentVersion(projectId, historyId, callback) { @@ -68,8 +74,10 @@ export function getMostRecentVersion(projectId, historyId, callback) { _.sortBy(chunk.chunk.history.changes || [], x => x.timestamp) ) // find the latest project and doc versions in the chunk - _getLatestProjectVersion(projectId, chunk, (err1, projectVersion) => + _getLatestProjectVersion(projectId, chunk, (err1, projectVersion) => { + if (err1) err1 = OError.tag(err1) _getLatestV2DocVersions(projectId, chunk, (err2, v2DocVersions) => { + if (err2) err2 = OError.tag(err2) // return the project and doc versions const projectStructureAndDocVersions = { project: projectVersion, @@ -83,7 +91,7 @@ export function getMostRecentVersion(projectId, historyId, callback) { chunk ) }) - ) + }) }) } @@ -211,7 +219,10 @@ export function getProjectBlob(historyId, blobHash, callback) { logger.debug({ historyId, blobHash }, 'getting blob from history service') _requestHistoryService( { path: `projects/${historyId}/blobs/${blobHash}` }, - callback + (err, blob) => { + if (err) return callback(OError.tag(err)) + callback(null, blob) + } ) } @@ -277,7 +288,10 @@ function createBlobFromString(historyId, data, fileId, callback) { (fsPath, cb) => { _createBlob(historyId, fsPath, cb) }, - callback + (err, hash) => { + if (err) return callback(OError.tag(err)) + callback(null, hash) + } ) } @@ -330,7 +344,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { try { ranges = HistoryBlobTranslator.createRangeBlobDataFromUpdate(update) } catch (error) { - return callback(error) + return callback(OError.tag(error)) } createBlobFromString( historyId, @@ -338,7 +352,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { `project-${projectId}-doc-${update.doc}`, (err, fileHash) => { if (err) { - return callback(err) + return callback(OError.tag(err)) } if (ranges) { createBlobFromString( @@ -347,7 +361,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { `project-${projectId}-doc-${update.doc}-ranges`, (err, rangesHash) => { if (err) { - return callback(err) + return callback(OError.tag(err)) } logger.debug( { fileHash, rangesHash }, @@ -415,7 +429,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { }, (err, fileHash) => { if (err) { - return callback(err) + return callback(OError.tag(err)) } if (update.hash && update.hash !== fileHash) { logger.warn( @@ -447,7 +461,7 @@ export function createBlobForUpdate(projectId, historyId, update, callback) { }, (err, fileHash) => { if (err) { - return callback(err) + return callback(OError.tag(err)) } logger.debug({ fileHash }, 'created empty blob for file') callback(null, { file: fileHash }) @@ -520,7 +534,10 @@ export function initializeProject(historyId, callback) { export function deleteProject(projectId, callback) { _requestHistoryService( { method: 'DELETE', path: `projects/${projectId}` }, - callback + err => { + if (err) return callback(OError.tag(err)) + callback(null) + } ) } From ee23e8f49f45cf60ab2a5e6bffa28e957a5ab5c9 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Tue, 3 Jun 2025 12:27:23 +0200 Subject: [PATCH 071/259] Merge pull request #26093 from overleaf/msm-e2e-fix [CE/SP] Force build of docker compose containers GitOrigin-RevId: 0605fcdcaf670e3d8435f1e180d2bfc34a29ed81 --- server-ce/test/Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/server-ce/test/Makefile b/server-ce/test/Makefile index 18f4446902..48c3dfc475 100644 --- a/server-ce/test/Makefile +++ b/server-ce/test/Makefile @@ -20,6 +20,7 @@ test-e2e-native: npm run cypress:open test-e2e: + docker compose build host-admin docker compose up --no-log-prefix --exit-code-from=e2e e2e test-e2e-open: From b2b676249d58381a167273860dfe53c47790af78 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:49:38 +0100 Subject: [PATCH 072/259] Merge pull request #25928 from overleaf/bg-history-redis-move-test-script-helpers move test script helpers in history-v1 GitOrigin-RevId: cc2e5d8b1baea7396f948883a12a91846f77836c --- .../js/storage/expire_redis_chunks.test.js | 86 +------------------ .../acceptance/js/storage/support/redis.js | 75 ++++++++++++++++ .../js/storage/support/runscript.js | 35 ++++++++ 3 files changed, 114 insertions(+), 82 deletions(-) create mode 100644 services/history-v1/test/acceptance/js/storage/support/redis.js create mode 100644 services/history-v1/test/acceptance/js/storage/support/runscript.js diff --git a/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js b/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js index b657991dda..f8a5943c43 100644 --- a/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js +++ b/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js @@ -1,91 +1,13 @@ 'use strict' const { expect } = require('chai') -const { promisify } = require('node:util') -const { execFile } = require('node:child_process') -const { Snapshot, Author, Change } = require('overleaf-editor-core') +const { Author, Change } = require('overleaf-editor-core') const cleanup = require('./support/cleanup') -const redisBackend = require('../../../../storage/lib/chunk_store/redis') -const redis = require('../../../../storage/lib/redis') -const rclient = redis.rclientHistory -const keySchema = redisBackend.keySchema +const { setupProjectState, rclient, keySchema } = require('./support/redis') +const { runScript } = require('./support/runscript') const SCRIPT_PATH = 'storage/scripts/expire_redis_chunks.js' -async function runExpireScript() { - const TIMEOUT = 10 * 1000 // 10 seconds - let result - try { - result = await promisify(execFile)('node', [SCRIPT_PATH], { - encoding: 'utf-8', - timeout: TIMEOUT, - env: { - ...process.env, - LOG_LEVEL: 'debug', // Override LOG_LEVEL for script output - }, - }) - result.status = 0 - } catch (err) { - const { stdout, stderr, code } = err - if (typeof code !== 'number') { - console.error('Error running expire script:', err) - throw err - } - result = { stdout, stderr, status: code } - } - // The script might exit with status 1 if it finds no keys to process, which is ok - if (result.status !== 0 && result.status !== 1) { - console.error('Expire script failed:', result.stderr) - throw new Error(`expire script failed with status ${result.status}`) - } - return result -} - -// Helper to set up a basic project state in Redis -async function setupProjectState( - projectId, - { - headVersion = 0, - persistedVersion = null, - expireTime = null, - persistTime = null, - changes = [], - } -) { - const headSnapshot = new Snapshot() - await rclient.set( - keySchema.head({ projectId }), - JSON.stringify(headSnapshot.toRaw()) - ) - await rclient.set( - keySchema.headVersion({ projectId }), - headVersion.toString() - ) - - if (persistedVersion !== null) { - await rclient.set( - keySchema.persistedVersion({ projectId }), - persistedVersion.toString() - ) - } - if (expireTime !== null) { - await rclient.set( - keySchema.expireTime({ projectId }), - expireTime.toString() - ) - } - if (persistTime !== null) { - await rclient.set( - keySchema.persistTime({ projectId }), - persistTime.toString() - ) - } - if (changes.length > 0) { - const rawChanges = changes.map(c => JSON.stringify(c.toRaw())) - await rclient.rpush(keySchema.changes({ projectId }), ...rawChanges) - } -} - function makeChange() { const timestamp = new Date() const author = new Author(123, 'test@example.com', 'Test User') @@ -150,7 +72,7 @@ describe('expire_redis_chunks script', function () { }) // Run the expire script once after all projects are set up - await runExpireScript() + await runScript(SCRIPT_PATH) }) async function checkProjectStatus(projectId) { diff --git a/services/history-v1/test/acceptance/js/storage/support/redis.js b/services/history-v1/test/acceptance/js/storage/support/redis.js new file mode 100644 index 0000000000..3f5b9cda27 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/redis.js @@ -0,0 +1,75 @@ +'use strict' + +const { Snapshot } = require('overleaf-editor-core') +const redis = require('../../../../../storage/lib/redis') +const redisBackend = require('../../../../../storage/lib/chunk_store/redis') +const rclient = redis.rclientHistory +const keySchema = redisBackend.keySchema + +// Helper to set up a basic project state in Redis +async function setupProjectState( + projectId, + { + headVersion = 0, + persistedVersion = null, + expireTime = null, + persistTime = null, + changes = [], + expireTimeFuture = false, // Default to not setting future expire time unless specified + } +) { + const headSnapshot = new Snapshot() + await rclient.set( + keySchema.head({ projectId }), + JSON.stringify(headSnapshot.toRaw()) + ) + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) + + if (persistedVersion !== null) { + await rclient.set( + keySchema.persistedVersion({ projectId }), + persistedVersion.toString() + ) + } else { + await rclient.del(keySchema.persistedVersion({ projectId })) + } + + if (expireTime !== null) { + await rclient.set( + keySchema.expireTime({ projectId }), + expireTime.toString() + ) + } else { + // If expireTimeFuture is true, set it to a future time, otherwise delete it if null + if (expireTimeFuture) { + const futureExpireTime = Date.now() + 5 * 60 * 1000 // 5 minutes in the future + await rclient.set( + keySchema.expireTime({ projectId }), + futureExpireTime.toString() + ) + } else { + await rclient.del(keySchema.expireTime({ projectId })) + } + } + + if (persistTime !== null) { + await rclient.set( + keySchema.persistTime({ projectId }), + persistTime.toString() + ) + } else { + await rclient.del(keySchema.persistTime({ projectId })) + } + + if (changes.length > 0) { + const rawChanges = changes.map(c => JSON.stringify(c.toRaw())) + await rclient.rpush(keySchema.changes({ projectId }), ...rawChanges) + } else { + await rclient.del(keySchema.changes({ projectId })) + } +} + +module.exports = { setupProjectState, rclient, keySchema } diff --git a/services/history-v1/test/acceptance/js/storage/support/runscript.js b/services/history-v1/test/acceptance/js/storage/support/runscript.js new file mode 100644 index 0000000000..7ff8355566 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/runscript.js @@ -0,0 +1,35 @@ +'use strict' + +const { promisify } = require('node:util') +const { execFile } = require('node:child_process') + +async function runScript(scriptPath, options = {}) { + const TIMEOUT = options.timeout || 10 * 1000 // 10 seconds default + let result + try { + result = await promisify(execFile)('node', [scriptPath], { + encoding: 'utf-8', + timeout: TIMEOUT, + env: { + ...process.env, + LOG_LEVEL: 'debug', // Override LOG_LEVEL for script output + }, + }) + result.status = 0 + } catch (err) { + const { stdout, stderr, code } = err + if (typeof code !== 'number') { + console.error(`Error running script ${scriptPath}:`, err) + throw err + } + result = { stdout, stderr, status: code } + } + // The script might exit with status 1 if it finds no keys to process, which is ok + if (result.status !== 0 && result.status !== 1) { + console.error(`Script ${scriptPath} failed:`, result.stderr) + throw new Error(`Script ${scriptPath} failed with status ${result.status}`) + } + return result +} + +module.exports = { runScript } From cb350ecc657201aa05c6c0f3a0d0089208f3f611 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:49:52 +0100 Subject: [PATCH 073/259] Merge pull request #25907 from overleaf/bg-history-redis-persist-buffer add a `persistBuffer` method to history-v1 GitOrigin-RevId: 71a34e48e9ebe378e2f765f3216023e505a58a5d --- .../api/controllers/project_import.js | 4 +- services/history-v1/storage/index.js | 1 + .../history-v1/storage/lib/persist_buffer.js | 173 +++++++++ .../history-v1/storage/lib/persist_changes.js | 23 +- .../js/storage/persist_buffer.test.mjs | 338 ++++++++++++++++++ 5 files changed, 532 insertions(+), 7 deletions(-) create mode 100644 services/history-v1/storage/lib/persist_buffer.js create mode 100644 services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs diff --git a/services/history-v1/api/controllers/project_import.js b/services/history-v1/api/controllers/project_import.js index 72df912a88..dee45efce8 100644 --- a/services/history-v1/api/controllers/project_import.js +++ b/services/history-v1/api/controllers/project_import.js @@ -110,7 +110,9 @@ async function importChanges(req, res, next) { let result try { - result = await persistChanges(projectId, changes, limits, endVersion) + result = await persistChanges(projectId, changes, limits, endVersion, { + queueChangesInRedis: true, + }) } catch (err) { if ( err instanceof Chunk.ConflictingEndVersion || diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index 2aa492f46e..a9d8e2fc03 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -8,6 +8,7 @@ exports.mongodb = require('./lib/mongodb') exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') +exports.persistBuffer = require('./lib/persist_buffer').persistBuffer exports.ProjectArchive = require('./lib/project_archive') exports.streams = require('./lib/streams') exports.temp = require('./lib/temp') diff --git a/services/history-v1/storage/lib/persist_buffer.js b/services/history-v1/storage/lib/persist_buffer.js new file mode 100644 index 0000000000..0dfeb9a38c --- /dev/null +++ b/services/history-v1/storage/lib/persist_buffer.js @@ -0,0 +1,173 @@ +// @ts-check +'use strict' + +const logger = require('@overleaf/logger') +const OError = require('@overleaf/o-error') +const assert = require('./assert') +const chunkStore = require('./chunk_store') +const { BlobStore } = require('./blob_store') +const BatchBlobStore = require('./batch_blob_store') +const persistChanges = require('./persist_changes') +const redisBackend = require('./chunk_store/redis') + +/** + * Persist the changes from Redis buffer to the main storage + * + * Algorithm Outline: + * 1. Get the latest chunk's endVersion from the database + * 2. Get non-persisted changes from Redis that are after this endVersion. + * 3. If no such changes, exit. + * 4. Load file blobs for these Redis changes. + * 5. Run the persistChanges() algorithm to store these changes into a new chunk(s) in GCS. + * - This must not decrease the endVersion. If changes were processed, it must advance. + * 6. Set the new persisted version (endVersion of the latest persisted chunk) in Redis. + * + * @param {string} projectId + * @throws {Error | OError} If a critical error occurs during persistence. + */ +async function persistBuffer(projectId) { + assert.projectId(projectId) + logger.debug({ projectId }, 'starting persistBuffer operation') + + // Set limits to force us to persist all of the changes. + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + const limits = { + maxChanges: 0, + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + + // 1. Get the latest chunk's endVersion from GCS/main store + let endVersion + const latestChunkMetadata = await chunkStore.getLatestChunkMetadata(projectId) + + if (latestChunkMetadata) { + endVersion = latestChunkMetadata.endVersion + } else { + endVersion = 0 // No chunks found, start from version 0 + logger.debug({ projectId }, 'no existing chunks found in main storage') + } + + logger.debug({ projectId, endVersion }, 'got latest persisted chunk') + + // 2. Get non-persisted changes from Redis + const changesToPersist = await redisBackend.getNonPersistedChanges( + projectId, + endVersion + ) + + if (changesToPersist.length === 0) { + logger.debug( + { projectId, endVersion }, + 'no new changes in Redis buffer to persist' + ) + // No changes to persist, update the persisted version in Redis + // to match the current endVersion. This shouldn't be needed + // unless a worker failed to update the persisted version. + await redisBackend.setPersistedVersion(projectId, endVersion) + return + } + + logger.debug( + { + projectId, + endVersion, + count: changesToPersist.length, + }, + 'found changes in Redis to persist' + ) + + // 4. Load file blobs for these Redis changes. Errors will propagate. + const blobStore = new BlobStore(projectId) + const batchBlobStore = new BatchBlobStore(blobStore) + + const blobHashes = new Set() + for (const change of changesToPersist) { + change.findBlobHashes(blobHashes) + } + if (blobHashes.size > 0) { + await batchBlobStore.preload(Array.from(blobHashes)) + } + for (const change of changesToPersist) { + await change.loadFiles('lazy', blobStore) + } + + // 5. Run the persistChanges() algorithm. Errors will propagate. + logger.debug( + { + projectId, + endVersion, + changeCount: changesToPersist.length, + }, + 'calling persistChanges' + ) + + const persistResult = await persistChanges( + projectId, + changesToPersist, + limits, + endVersion + ) + + if (!persistResult || !persistResult.currentChunk) { + throw new OError( + 'persistChanges did not produce a new chunk for non-empty changes', + { + projectId, + endVersion, + changeCount: changesToPersist.length, + } + ) + } + + const newPersistedChunk = persistResult.currentChunk + const newEndVersion = newPersistedChunk.getEndVersion() + + if (newEndVersion <= endVersion) { + throw new OError( + 'persisted chunk endVersion must be greater than current persisted chunk end version for non-empty changes', + { + projectId, + newEndVersion, + endVersion, + changeCount: changesToPersist.length, + } + ) + } + + logger.debug( + { + projectId, + oldVersion: endVersion, + newVersion: newEndVersion, + }, + 'successfully persisted changes from Redis to main storage' + ) + + // 6. Set the persisted version in Redis. Errors will propagate. + const status = await redisBackend.setPersistedVersion( + projectId, + newEndVersion + ) + + if (status !== 'ok') { + throw new OError('failed to update persisted version in Redis', { + projectId, + newEndVersion, + status, + }) + } + + logger.debug( + { projectId, newEndVersion }, + 'updated persisted version in Redis' + ) + + logger.debug( + { projectId, finalPersistedVersion: newEndVersion }, + 'persistBuffer operation completed successfully' + ) +} + +module.exports = { persistBuffer } diff --git a/services/history-v1/storage/lib/persist_changes.js b/services/history-v1/storage/lib/persist_changes.js index 5b80285eb0..95ffdc67d2 100644 --- a/services/history-v1/storage/lib/persist_changes.js +++ b/services/history-v1/storage/lib/persist_changes.js @@ -57,9 +57,18 @@ Timer.prototype.elapsed = function () { * @param {core.Change[]} allChanges * @param {Object} limits * @param {number} clientEndVersion + * @param {Object} options + * @param {Boolean} [options.queueChangesInRedis] + * If true, queue the changes in Redis for testing purposes. * @return {Promise.} */ -async function persistChanges(projectId, allChanges, limits, clientEndVersion) { +async function persistChanges( + projectId, + allChanges, + limits, + clientEndVersion, + options = {} +) { assert.projectId(projectId) assert.array(allChanges) assert.maybe.object(limits) @@ -289,11 +298,13 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { const numberOfChangesToPersist = oldChanges.length await loadLatestChunk() - try { - await queueChangesInRedis() - await fakePersistRedisChanges() - } catch (err) { - logger.error({ err }, 'Chunk buffer verification failed') + if (options.queueChangesInRedis) { + try { + await queueChangesInRedis() + await fakePersistRedisChanges() + } catch (err) { + logger.error({ err }, 'Chunk buffer verification failed') + } } await extendLastChunkIfPossible() await createNewChunksAsNeeded() diff --git a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs new file mode 100644 index 0000000000..64772c4b70 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs @@ -0,0 +1,338 @@ +'use strict' + +import fs from 'node:fs' +import { expect } from 'chai' +import { + Change, + Snapshot, + File, + TextOperation, + AddFileOperation, + EditFileOperation, // Added EditFileOperation +} from 'overleaf-editor-core' +import { persistBuffer } from '../../../../storage/lib/persist_buffer.js' +import chunkStore from '../../../../storage/lib/chunk_store/index.js' +import redisBackend from '../../../../storage/lib/chunk_store/redis.js' +import persistChanges from '../../../../storage/lib/persist_changes.js' +import cleanup from './support/cleanup.js' +import fixtures from './support/fixtures.js' +import testFiles from './support/test_files.js' + +describe('persistBuffer', function () { + let projectId + const initialVersion = 0 + let limitsToPersistImmediately + + before(function () { + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + limitsToPersistImmediately = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + maxChunkChanges: 10, + } + }) + + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + + beforeEach(async function () { + projectId = fixtures.docs.uninitializedProject.id + await chunkStore.initializeProject(projectId) + }) + + describe('with an empty initial chunk (new project)', function () { + it('should persist changes from Redis to a new chunk', async function () { + // create an initial snapshot and add the empty file `main.tex` + const HELLO_TXT = fs.readFileSync(testFiles.path('hello.txt')).toString() + + const createFile = new Change( + [new AddFileOperation('main.tex', File.fromString(HELLO_TXT))], + new Date(), + [] + ) + + await persistChanges( + projectId, + [createFile], + limitsToPersistImmediately, + 0 + ) + // Now queue some changes in Redis + const op1 = new TextOperation().insert('Hello').retain(HELLO_TXT.length) + const change1 = new Change( + [new EditFileOperation('main.tex', op1)], + new Date() + ) + + const op2 = new TextOperation() + .retain('Hello'.length) + .insert(' World') + .retain(HELLO_TXT.length) + const change2 = new Change( + [new EditFileOperation('main.tex', op2)], + new Date() + ) + + const changesToQueue = [change1, change2] + + const finalHeadVersion = initialVersion + 1 + changesToQueue.length + + const now = Date.now() + await redisBackend.queueChanges( + projectId, + new Snapshot(), // dummy snapshot + 1, + changesToQueue, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + await redisBackend.setPersistedVersion(projectId, initialVersion) + + // Persist the changes from Redis to the chunk store + await persistBuffer(projectId) + + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk).to.exist + expect(latestChunk.getStartVersion()).to.equal(initialVersion) + expect(latestChunk.getEndVersion()).to.equal(finalHeadVersion) + expect(latestChunk.getChanges().length).to.equal( + changesToQueue.length + 1 + ) + + const chunkSnapshot = latestChunk.getSnapshot() + expect(Object.keys(chunkSnapshot.getFileMap()).length).to.equal(1) + + const persistedVersionInRedis = (await redisBackend.getState(projectId)) + .persistedVersion + expect(persistedVersionInRedis).to.equal(finalHeadVersion) + + const nonPersisted = await redisBackend.getNonPersistedChanges( + projectId, + finalHeadVersion + ) + expect(nonPersisted).to.be.an('array').that.is.empty + }) + }) + + describe('with an existing chunk and new changes in Redis', function () { + it('should persist new changes from Redis, appending to existing history', async function () { + const initialContent = 'Initial document content.\n' + + const addInitialFileChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(), + [] + ) + + await persistChanges( + projectId, + [addInitialFileChange], + limitsToPersistImmediately, + initialVersion + ) + const versionAfterInitialSetup = initialVersion + 1 // Now version is 1 + + const opForChunk1 = new TextOperation() + .retain(initialContent.length) + .insert(' First addition.') + const changesForChunk1 = [ + new Change( + [new EditFileOperation('main.tex', opForChunk1)], + new Date(), + [] + ), + ] + + await persistChanges( + projectId, + changesForChunk1, + limitsToPersistImmediately, // Original limits for this step + versionAfterInitialSetup // Correct clientEndVersion + ) + // Update persistedChunkEndVersion: 1 (from setup) + 1 (from changesForChunk1) = 2 + const persistedChunkEndVersion = + versionAfterInitialSetup + changesForChunk1.length + const contentAfterChunk1 = initialContent + ' First addition.' + + const opVersion2 = new TextOperation() + .retain(contentAfterChunk1.length) + .insert(' Second addition.') + const changeVersion2 = new Change( + [new EditFileOperation('main.tex', opVersion2)], + new Date(), + [] + ) + + const contentAfterChange2 = contentAfterChunk1 + ' Second addition.' + const opVersion3 = new TextOperation() + .retain(contentAfterChange2.length) + .insert(' Third addition.') + const changeVersion3 = new Change( + [new EditFileOperation('main.tex', opVersion3)], + new Date(), + [] + ) + + const redisChangesToPush = [changeVersion2, changeVersion3] + const finalHeadVersionAfterRedisPush = + persistedChunkEndVersion + redisChangesToPush.length + const now = Date.now() + + await redisBackend.queueChanges( + projectId, + new Snapshot(), // Use new Snapshot() like in the first test + persistedChunkEndVersion, + redisChangesToPush, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + await redisBackend.setPersistedVersion( + projectId, + persistedChunkEndVersion + ) + + await persistBuffer(projectId) + + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk).to.exist + expect(latestChunk.getStartVersion()).to.equal(0) + expect(latestChunk.getEndVersion()).to.equal( + finalHeadVersionAfterRedisPush + ) + expect(latestChunk.getChanges().length).to.equal( + persistedChunkEndVersion + redisChangesToPush.length + ) + + const persistedVersionInRedisAfter = ( + await redisBackend.getState(projectId) + ).persistedVersion + expect(persistedVersionInRedisAfter).to.equal( + finalHeadVersionAfterRedisPush + ) + + const nonPersisted = await redisBackend.getNonPersistedChanges( + projectId, + finalHeadVersionAfterRedisPush + ) + expect(nonPersisted).to.be.an('array').that.is.empty + }) + }) + + describe('when Redis has no new changes', function () { + let persistedChunkEndVersion + let changesForChunk1 + + beforeEach(async function () { + const initialContent = 'Content.' + + const addInitialFileChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(), + [] + ) + + // Replace chunkStore.create with persistChanges + // clientEndVersion is initialVersion (0). This advances version to 1. + await persistChanges( + projectId, + [addInitialFileChange], + limitsToPersistImmediately, + initialVersion + ) + const versionAfterInitialSetup = initialVersion + 1 // Now version is 1 + + const opForChunk1 = new TextOperation() + .retain(initialContent.length) + .insert(' More.') + changesForChunk1 = [ + new Change( + [new EditFileOperation('main.tex', opForChunk1)], + new Date(), + [] + ), + ] + // Corrected persistChanges call: clientEndVersion is versionAfterInitialSetup (1) + await persistChanges( + projectId, + changesForChunk1, + limitsToPersistImmediately, // Original limits for this step + versionAfterInitialSetup // Correct clientEndVersion + ) + // Update persistedChunkEndVersion: 1 (from setup) + 1 (from changesForChunk1) = 2 + persistedChunkEndVersion = + versionAfterInitialSetup + changesForChunk1.length + }) + + it('should leave the persisted version and stored chunks unchanged', async function () { + const now = Date.now() + await redisBackend.queueChanges( + projectId, + new Snapshot(), + persistedChunkEndVersion - 1, + changesForChunk1, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + await redisBackend.setPersistedVersion( + projectId, + persistedChunkEndVersion + ) + + const chunksBefore = await chunkStore.getProjectChunks(projectId) + + await persistBuffer(projectId) + + const chunksAfter = await chunkStore.getProjectChunks(projectId) + expect(chunksAfter.length).to.equal(chunksBefore.length) + expect(chunksAfter).to.deep.equal(chunksBefore) + + const finalPersistedVersionInRedis = ( + await redisBackend.getState(projectId) + ).persistedVersion + expect(finalPersistedVersionInRedis).to.equal(persistedChunkEndVersion) + }) + + it('should update the persisted version if it is behind the chunk store end version', async function () { + const now = Date.now() + + await redisBackend.queueChanges( + projectId, + new Snapshot(), + persistedChunkEndVersion - 1, + changesForChunk1, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + // Force the persisted version in Redis to lag behind the chunk store, + // simulating the situation where a worker has persisted changes to the + // chunk store but failed to update the version in redis. + await redisBackend.setPersistedVersion( + projectId, + persistedChunkEndVersion - 1 + ) + + const chunksBefore = await chunkStore.getProjectChunks(projectId) + + // Persist buffer (which should do nothing as there are no new changes) + await persistBuffer(projectId, limitsToPersistImmediately) + + const chunksAfter = await chunkStore.getProjectChunks(projectId) + expect(chunksAfter.length).to.equal(chunksBefore.length) + expect(chunksAfter).to.deep.equal(chunksBefore) + + const finalPersistedVersionInRedis = ( + await redisBackend.getState(projectId) + ).persistedVersion + expect(finalPersistedVersionInRedis).to.equal(persistedChunkEndVersion) + }) + }) +}) From a80203f7489ed441c109fe433668dd7c3531b691 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:50:01 +0100 Subject: [PATCH 074/259] Merge pull request #25909 from overleaf/bg-history-persist-worker add history persist worker GitOrigin-RevId: b9e31e7bdd84570efc0b87b9f5e90b4078551a8c --- .../storage/scripts/persist_redis_chunks.js | 56 ++++ .../js/storage/persist_redis_chunks.test.js | 262 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 services/history-v1/storage/scripts/persist_redis_chunks.js create mode 100644 services/history-v1/test/acceptance/js/storage/persist_redis_chunks.test.js diff --git a/services/history-v1/storage/scripts/persist_redis_chunks.js b/services/history-v1/storage/scripts/persist_redis_chunks.js new file mode 100644 index 0000000000..88964bac69 --- /dev/null +++ b/services/history-v1/storage/scripts/persist_redis_chunks.js @@ -0,0 +1,56 @@ +const logger = require('@overleaf/logger') +const commandLineArgs = require('command-line-args') +const redis = require('../lib/redis') +const knex = require('../lib/knex.js') +const knexReadOnly = require('../lib/knex_read_only.js') +const { client } = require('../lib/mongodb.js') +const { scanAndProcessDueItems } = require('../lib/scan') +const { persistBuffer } = require('../lib/persist_buffer') +const { claimPersistJob } = require('../lib/chunk_store/redis') + +const rclient = redis.rclientHistory + +const optionDefinitions = [{ name: 'dry-run', alias: 'd', type: Boolean }] +const options = commandLineArgs(optionDefinitions) +const DRY_RUN = options['dry-run'] || false + +logger.initialize('persist-redis-chunks') + +async function persistProjectAction(projectId) { + const job = await claimPersistJob(projectId) + await persistBuffer(projectId) + if (job && job.close) { + await job.close() + } +} + +async function runPersistChunks() { + await scanAndProcessDueItems( + rclient, + 'persistChunks', + 'persist-time', + persistProjectAction, + DRY_RUN + ) +} + +if (require.main === module) { + runPersistChunks() + .catch(err => { + logger.fatal( + { err, taskName: 'persistChunks' }, + 'Unhandled error in runPersistChunks' + ) + process.exit(1) + }) + .finally(async () => { + await redis.disconnect() + await client.close() + await knex.destroy() + await knexReadOnly.destroy() + }) +} else { + module.exports = { + runPersistChunks, + } +} diff --git a/services/history-v1/test/acceptance/js/storage/persist_redis_chunks.test.js b/services/history-v1/test/acceptance/js/storage/persist_redis_chunks.test.js new file mode 100644 index 0000000000..3f2a4a390f --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/persist_redis_chunks.test.js @@ -0,0 +1,262 @@ +'use strict' + +const { expect } = require('chai') +const { + Change, + AddFileOperation, + EditFileOperation, + TextOperation, + File, +} = require('overleaf-editor-core') +const cleanup = require('./support/cleanup') +const fixtures = require('./support/fixtures') +const chunkStore = require('../../../../storage/lib/chunk_store') +const { getState } = require('../../../../storage/lib/chunk_store/redis') +const { setupProjectState } = require('./support/redis') +const { runScript } = require('./support/runscript') +const persistChanges = require('../../../../storage/lib/persist_changes') + +const SCRIPT_PATH = 'storage/scripts/persist_redis_chunks.js' + +describe('persist_redis_chunks script', function () { + before(cleanup.everything) + + let now, past, future + let projectIdsStore // To store the generated project IDs, keyed by scenario name + let limitsToPersistImmediately + + before(async function () { + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + limitsToPersistImmediately = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + maxChunkChanges: 100, // Allow enough changes for setup + } + + await fixtures.create() + + now = Date.now() + past = now - 10000 // 10 seconds ago + future = now + 60000 // 1 minute in the future + + projectIdsStore = {} + + // Scenario 1: project_due_for_persistence + // Goal: Has initial persisted content (v1), Redis has new changes (v1->v2) due for persistence. + // Expected: Script persists Redis changes, persistedVersion becomes 2. + { + const dueProjectId = await chunkStore.initializeProject() + projectIdsStore.project_due_for_persistence = dueProjectId + const initialContent = 'Initial content for due project.' + const initialChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + dueProjectId, + [initialChange], + limitsToPersistImmediately, + 0 + ) + const secondChangeDue = new Change( + [ + new EditFileOperation( + 'main.tex', + new TextOperation() + .retain(initialContent.length) + .insert(' More content.') + ), + ], + new Date(now - 20000), // 20 seconds ago + [] + ) + await setupProjectState(dueProjectId, { + persistTime: past, + headVersion: 2, // After secondChangeDue + persistedVersion: 1, // Initial content is at v1 + changes: [secondChangeDue], // New changes in Redis (v1->v2) + expireTimeFuture: true, + }) + } + + // Scenario 2: project_not_due_for_persistence + // Goal: Has initial persisted content (v1), Redis has no new changes, not due. + // Expected: Script does nothing, persistedVersion remains 1. + { + const notDueProjectId = await chunkStore.initializeProject() + projectIdsStore.project_not_due_for_persistence = notDueProjectId + const initialContent = 'Initial content for not_due project.' + const initialChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + notDueProjectId, + [initialChange], + limitsToPersistImmediately, + 0 + ) // Persisted: v0 -> v1 + await setupProjectState(notDueProjectId, { + persistTime: future, + headVersion: 1, // Matches persisted version + persistedVersion: 1, + changes: [], // No new changes in Redis + expireTimeFuture: true, + }) + } + + // Scenario 3: project_no_persist_time + // Goal: Has initial persisted content (v1), Redis has no new changes, no persistTime. + // Expected: Script does nothing, persistedVersion remains 1. + { + const noPersistTimeProjectId = await chunkStore.initializeProject() + projectIdsStore.project_no_persist_time = noPersistTimeProjectId + const initialContent = 'Initial content for no_persist_time project.' + const initialChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + noPersistTimeProjectId, + [initialChange], + limitsToPersistImmediately, + 0 + ) // Persisted: v0 -> v1 + await setupProjectState(noPersistTimeProjectId, { + persistTime: null, + headVersion: 1, // Matches persisted version + persistedVersion: 1, + changes: [], // No new changes in Redis + expireTimeFuture: true, + }) + } + + // Scenario 4: project_due_fully_persisted + // Goal: Has content persisted up to v2, Redis reflects this (head=2, persisted=2), due for check. + // Expected: Script clears persistTime, persistedVersion remains 2. + { + const dueFullyPersistedId = await chunkStore.initializeProject() + projectIdsStore.project_due_fully_persisted = dueFullyPersistedId + const initialContent = 'Content part 1 for fully persisted.' + const change1 = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 40000), // 40 seconds ago + [] + ) + const change2 = new Change( + [ + new EditFileOperation( + 'main.tex', + new TextOperation() + .retain(initialContent.length) + .insert(' Content part 2.') + ), + ], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + dueFullyPersistedId, + [change1, change2], + limitsToPersistImmediately, + 0 + ) + await setupProjectState(dueFullyPersistedId, { + persistTime: past, + headVersion: 2, + persistedVersion: 2, + changes: [], // No new unpersisted changes in Redis + expireTimeFuture: true, + }) + } + + // Scenario 5: project_fails_to_persist + // Goal: Has initial persisted content (v1), Redis has new changes (v1->v2) due for persistence, but these changes will cause an error. + // Expected: Script attempts to persist, fails, and persistTime is NOT cleared. + { + const failsToPersistProjectId = await chunkStore.initializeProject() + projectIdsStore.project_fails_to_persist = failsToPersistProjectId + const initialContent = 'Initial content for failure case.' + const initialChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(now - 30000), // 30 seconds ago + [] + ) + await persistChanges( + failsToPersistProjectId, + [initialChange], + limitsToPersistImmediately, + 0 + ) + // This change will fail because it tries to insert at a non-existent offset + // assuming the initial content is shorter than 1000 characters. + const conflictingChange = new Change( + [ + new EditFileOperation( + 'main.tex', + new TextOperation().retain(1000).insert('This will fail.') + ), + ], + new Date(now - 20000), // 20 seconds ago + [] + ) + await setupProjectState(failsToPersistProjectId, { + persistTime: past, // Due for persistence + headVersion: 2, // After conflictingChange + persistedVersion: 1, // Initial content is at v1 + changes: [conflictingChange], // New changes in Redis (v1->v2) + expireTimeFuture: true, + }) + } + + await runScript(SCRIPT_PATH) + }) + + describe('when the buffer has new changes', function () { + it('should update persisted-version when the persist-time is in the past', async function () { + const projectId = projectIdsStore.project_due_for_persistence + const state = await getState(projectId) + // console.log('State after running script (project_due_for_persistence):', state) + expect(state.persistTime).to.be.null + expect(state.persistedVersion).to.equal(2) + }) + + it('should not perform any operations when the persist-time is in the future', async function () { + const projectId = projectIdsStore.project_not_due_for_persistence + const state = await getState(projectId) + expect(state.persistTime).to.equal(future) + expect(state.persistedVersion).to.equal(1) + }) + }) + + describe('when the changes in the buffer are already persisted', function () { + it('should delete persist-time for a project when the persist-time is in the past', async function () { + const projectId = projectIdsStore.project_due_fully_persisted + const state = await getState(projectId) + expect(state.persistTime).to.be.null + expect(state.persistedVersion).to.equal(2) + }) + }) + + describe('when there is no persist-time set', function () { + it('should not change redis when there is no persist-time set initially', async function () { + const projectId = projectIdsStore.project_no_persist_time + const state = await getState(projectId) + expect(state.persistTime).to.be.null + expect(state.persistedVersion).to.equal(1) + }) + }) + + describe('when persistence fails due to conflicting changes', function () { + it('should not clear persist-time and not update persisted-version', async function () { + const projectId = projectIdsStore.project_fails_to_persist + const state = await getState(projectId) + expect(state.persistTime).to.be.greaterThan(now) // persistTime should be pushed to the future by RETRY_DELAY_MS + expect(state.persistedVersion).to.equal(1) // persistedVersion should not change + }) + }) +}) From 50df3862e905b903c272b9de6769171bd2ff1b7c Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:50:54 +0100 Subject: [PATCH 075/259] Merge pull request #25954 from overleaf/bg-history-expire-worker-fix fix expire_redis_chunks to only clear job on error GitOrigin-RevId: f7ec435edda95958b453fba501686dcfd84426f7 --- .../history-v1/storage/scripts/expire_redis_chunks.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/services/history-v1/storage/scripts/expire_redis_chunks.js b/services/history-v1/storage/scripts/expire_redis_chunks.js index af2be097b6..60ce4c66f6 100644 --- a/services/history-v1/storage/scripts/expire_redis_chunks.js +++ b/services/history-v1/storage/scripts/expire_redis_chunks.js @@ -14,12 +14,9 @@ logger.initialize('expire-redis-chunks') async function expireProjectAction(projectId) { const job = await claimExpireJob(projectId) - try { - await expireProject(projectId) - } finally { - if (job && job.close) { - await job.close() - } + await expireProject(projectId) + if (job && job.close) { + await job.close() } } From 393cee7af543a82e6b32b2a473509a5539173813 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 11:51:07 +0100 Subject: [PATCH 076/259] Merge pull request #25993 from overleaf/bg-history-refactor-persist-buffer-limits refactor persist buffer to add limits GitOrigin-RevId: 4a40a7a8812acf5bb7f98bfd7b94d81ebe19fc57 --- .../history-v1/storage/lib/persist_buffer.js | 12 +- .../storage/scripts/persist_redis_chunks.js | 10 +- .../js/storage/persist_buffer.test.mjs | 107 +++++++++++++++++- 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/services/history-v1/storage/lib/persist_buffer.js b/services/history-v1/storage/lib/persist_buffer.js index 0dfeb9a38c..4cfd7ecab3 100644 --- a/services/history-v1/storage/lib/persist_buffer.js +++ b/services/history-v1/storage/lib/persist_buffer.js @@ -23,21 +23,13 @@ const redisBackend = require('./chunk_store/redis') * 6. Set the new persisted version (endVersion of the latest persisted chunk) in Redis. * * @param {string} projectId + * @param {Object} limits * @throws {Error | OError} If a critical error occurs during persistence. */ -async function persistBuffer(projectId) { +async function persistBuffer(projectId, limits) { assert.projectId(projectId) logger.debug({ projectId }, 'starting persistBuffer operation') - // Set limits to force us to persist all of the changes. - const farFuture = new Date() - farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) - const limits = { - maxChanges: 0, - minChangeTimestamp: farFuture, - maxChangeTimestamp: farFuture, - } - // 1. Get the latest chunk's endVersion from GCS/main store let endVersion const latestChunkMetadata = await chunkStore.getLatestChunkMetadata(projectId) diff --git a/services/history-v1/storage/scripts/persist_redis_chunks.js b/services/history-v1/storage/scripts/persist_redis_chunks.js index 88964bac69..9d64964f81 100644 --- a/services/history-v1/storage/scripts/persist_redis_chunks.js +++ b/services/history-v1/storage/scripts/persist_redis_chunks.js @@ -18,7 +18,15 @@ logger.initialize('persist-redis-chunks') async function persistProjectAction(projectId) { const job = await claimPersistJob(projectId) - await persistBuffer(projectId) + // Set limits to force us to persist all of the changes. + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + const limits = { + maxChanges: 0, + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + } + await persistBuffer(projectId, limits) if (job && job.close) { await job.close() } diff --git a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs index 64772c4b70..496d16cd1e 100644 --- a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs @@ -92,7 +92,7 @@ describe('persistBuffer', function () { await redisBackend.setPersistedVersion(projectId, initialVersion) // Persist the changes from Redis to the chunk store - await persistBuffer(projectId) + await persistBuffer(projectId, limitsToPersistImmediately) const latestChunk = await chunkStore.loadLatest(projectId) expect(latestChunk).to.exist @@ -196,7 +196,7 @@ describe('persistBuffer', function () { persistedChunkEndVersion ) - await persistBuffer(projectId) + await persistBuffer(projectId, limitsToPersistImmediately) const latestChunk = await chunkStore.loadLatest(projectId) expect(latestChunk).to.exist @@ -287,7 +287,8 @@ describe('persistBuffer', function () { const chunksBefore = await chunkStore.getProjectChunks(projectId) - await persistBuffer(projectId) + // Persist buffer (which should do nothing as there are no new changes) + await persistBuffer(projectId, limitsToPersistImmediately) const chunksAfter = await chunkStore.getProjectChunks(projectId) expect(chunksAfter.length).to.equal(chunksBefore.length) @@ -335,4 +336,104 @@ describe('persistBuffer', function () { expect(finalPersistedVersionInRedis).to.equal(persistedChunkEndVersion) }) }) + + describe('when limits restrict the number of changes to persist', function () { + it('should persist only a subset of changes and update persistedVersion accordingly', async function () { + const now = Date.now() + const oneDayAgo = now - 1000 * 60 * 60 * 24 + const oneHourAgo = now - 1000 * 60 * 60 + const twoHoursAgo = now - 1000 * 60 * 60 * 2 + const threeHoursAgo = now - 1000 * 60 * 60 * 3 + + // Create an initial file with some content + const initialContent = 'Initial content.' + const addInitialFileChange = new Change( + [new AddFileOperation('main.tex', File.fromString(initialContent))], + new Date(oneDayAgo), + [] + ) + + await persistChanges( + projectId, + [addInitialFileChange], + limitsToPersistImmediately, + initialVersion + ) + const versionAfterInitialSetup = initialVersion + 1 // Version is 1 + + // Queue three additional changes in Redis + const op1 = new TextOperation() + .retain(initialContent.length) + .insert(' Change 1.') + const change1 = new Change( + [new EditFileOperation('main.tex', op1)], + new Date(threeHoursAgo) + ) + const contentAfterC1 = initialContent + ' Change 1.' + + const op2 = new TextOperation() + .retain(contentAfterC1.length) + .insert(' Change 2.') + const change2 = new Change( + [new EditFileOperation('main.tex', op2)], + new Date(twoHoursAgo) + ) + const contentAfterC2 = contentAfterC1 + ' Change 2.' + + const op3 = new TextOperation() + .retain(contentAfterC2.length) + .insert(' Change 3.') + const change3 = new Change( + [new EditFileOperation('main.tex', op3)], + new Date(oneHourAgo) + ) + + const changesToQueue = [change1, change2, change3] + await redisBackend.queueChanges( + projectId, + new Snapshot(), // dummy snapshot + versionAfterInitialSetup, // startVersion for queued changes + changesToQueue, + { + persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS, + expireTime: now + redisBackend.PROJECT_TTL_MS, + } + ) + await redisBackend.setPersistedVersion( + projectId, + versionAfterInitialSetup + ) + + // Define limits to only persist 2 additional changes (on top of the initial file creation), + // which should leave the final change (change3) in the redis buffer. + const restrictiveLimits = { + minChangeTimestamp: new Date(oneHourAgo), // only changes more than 1 hour old are considered + maxChangeTimestamp: new Date(twoHoursAgo), // they will be persisted if any change is older than 2 hours + } + + await persistBuffer(projectId, restrictiveLimits) + + // Check the latest persisted chunk, it should only have the initial file and the first two changes + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) + expect(latestChunk).to.exist + expect(latestChunk.getChanges().length).to.equal(3) // addInitialFileChange + change1 + change2 + expect(latestChunk.getStartVersion()).to.equal(initialVersion) + const expectedEndVersion = versionAfterInitialSetup + 2 // Persisted two changes from the queue + expect(latestChunk.getEndVersion()).to.equal(expectedEndVersion) + + // Check persisted version in Redis + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(expectedEndVersion) + + // Check non-persisted changes in Redis + const nonPersisted = await redisBackend.getNonPersistedChanges( + projectId, + expectedEndVersion + ) + expect(nonPersisted).to.be.an('array').with.lengthOf(1) // change3 should remain + expect(nonPersisted).to.deep.equal([change3]) + }) + }) }) From 4dbc70b745edafe5b48f3cc0b980f38e9fbcf938 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:31:58 +0200 Subject: [PATCH 077/259] [web] Replace action button to "Go to Account Settings" link in group-settings alert for email confirmation (#25672) * Replace action button to "Go to Account Settings" link in group-settings alert for email confirmation * `bin/run web npm run extract-translations` & `make cleanup_unused_locales` * Fix test capitalization * Update "Go to account settings" to lowercase and link-styling * `bin/run web npm run extract-translations` * Fix test GitOrigin-RevId: d66ce34556bdfc2a37f12900055640cc995ac140 --- services/web/frontend/extracted-translations.json | 3 +-- services/web/locales/en.json | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 20459e0ed6..506a5bb5f8 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -679,6 +679,7 @@ "go_next_page": "", "go_page": "", "go_prev_page": "", + "go_to_account_settings": "", "go_to_code_location_in_pdf": "", "go_to_overleaf": "", "go_to_pdf_location_in_code": "", @@ -1403,7 +1404,6 @@ "resend": "", "resend_confirmation_code": "", "resend_confirmation_email": "", - "resend_email": "", "resend_group_invite": "", "resend_link_sso": "", "resend_managed_user_invite": "", @@ -1524,7 +1524,6 @@ "send_message": "", "send_request": "", "sending": "", - "sent": "", "server_error": "", "server_pro_license_entitlement_line_1": "", "server_pro_license_entitlement_line_2": "", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 2efd23fd9f..bdebf3d289 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -888,7 +888,7 @@ "go_next_page": "Go to Next Page", "go_page": "Go to page __page__", "go_prev_page": "Go to Previous Page", - "go_to_account_settings": "Go to Account settings", + "go_to_account_settings": "Go to account settings", "go_to_code_location_in_pdf": "Go to code location in PDF", "go_to_first_page": "Go to first page", "go_to_last_page": "Go to last page", @@ -1848,7 +1848,6 @@ "resend": "Resend", "resend_confirmation_code": "Resend confirmation code", "resend_confirmation_email": "Resend confirmation email", - "resend_email": "Resend email", "resend_group_invite": "Resend group invite", "resend_link_sso": "Resend SSO invite", "resend_managed_user_invite": "Resend managed user invite", From 397016744e2828138f94491b13db64c243343805 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:32:19 +0200 Subject: [PATCH 078/259] [web] Migrate metrics module Pug files to Bootstrap 5 (#25745) * Remove `bootstrap5PageStatus = 'disabled'` * Update from 'col-xs-' to 'col-' * Rename LESS files to SCSS * Rename local vars * Refactor color variables to use SCSS variables in stylesheets * Remove unused `.superscript` It was added in https://github.com/overleaf/internal/commit/6696ffdd50e1a74f7b8388187386fb076e9ded8f * Remove -moz and -webkit properties * Remove unused(?) `.hub-circle img` * Fix selector specificity for calendar display in daterange-picker * Fix space/tab indents * Fixup btn-link classes: fixes some borders * Add support for svg.nvd3-iddle alongside svg.nvd3-svg in styles * Add dropdown-item classes (improves styles) * Replace `data-toggle` by `data-bs-toggle` * Fixup table: remove .card class, add scope="col", add tbody * Update dropdown caret icon * Update icons to material symbols * Remove green color override for links * Remove/rearrange CSS unrelated to metrics module * Add space after "by" in lags-container (by Day/Week/Month) * Fix SCSS linting * Re-add CSS that belongs in portals module * Use `layout-react` * Put table in Card. It still overflows but looks slightly better * Fix columns breakbpoints * Revert "Use `layout-react`" This reverts commit a9e0d8f5c19d1dfd7417bf67b90799ad199a5913. * Use css variables, use breakpoint mixins * Add `.py-0` on subscriptions table card, so overflows appear less bad GitOrigin-RevId: 55295ad76c112609baf43de4aa606d0c3da7a91f --- services/web/.prettierignore | 2 +- .../frontend/stylesheets/app/admin-hub.less | 156 -- .../web/frontend/stylesheets/app/portals.less | 34 + .../stylesheets/bootstrap-5/modules/all.scss | 7 + .../modules/metrics/admin-hub.scss | 93 ++ .../modules/metrics/daterange-picker.scss | 617 ++++++++ .../modules/metrics/institution-hub.scss} | 28 +- .../modules/metrics/metrics.scss} | 58 +- .../modules/metrics/nvd3.scss} | 1368 ++++++++--------- .../modules/metrics/nvd3_override.scss} | 5 +- .../modules/metrics/publisher-hub.scss} | 36 +- .../bootstrap-5/pages/admin/admin.scss | 5 + .../bootstrap-5/pages/project-list.scss | 2 +- .../components/daterange-picker.less | 656 -------- .../web/frontend/stylesheets/main-style.less | 7 - 15 files changed, 1517 insertions(+), 1557 deletions(-) delete mode 100644 services/web/frontend/stylesheets/app/admin-hub.less create mode 100644 services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss create mode 100644 services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss rename services/web/frontend/stylesheets/{app/institution-hub.less => bootstrap-5/modules/metrics/institution-hub.scss} (52%) rename services/web/frontend/stylesheets/{app/metrics.less => bootstrap-5/modules/metrics/metrics.scss} (78%) rename services/web/frontend/stylesheets/{components/nvd3.less => bootstrap-5/modules/metrics/nvd3.scss} (78%) rename services/web/frontend/stylesheets/{components/nvd3_override.less => bootstrap-5/modules/metrics/nvd3_override.scss} (74%) rename services/web/frontend/stylesheets/{app/publisher-hub.less => bootstrap-5/modules/metrics/publisher-hub.scss} (52%) delete mode 100644 services/web/frontend/stylesheets/components/daterange-picker.less diff --git a/services/web/.prettierignore b/services/web/.prettierignore index f4be187b87..39282c64c2 100644 --- a/services/web/.prettierignore +++ b/services/web/.prettierignore @@ -6,7 +6,7 @@ frontend/js/vendor modules/**/frontend/js/vendor public/js public/minjs -frontend/stylesheets/components/nvd3.less +frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss frontend/js/features/source-editor/lezer-latex/latex.mjs frontend/js/features/source-editor/lezer-latex/latex.terms.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs diff --git a/services/web/frontend/stylesheets/app/admin-hub.less b/services/web/frontend/stylesheets/app/admin-hub.less deleted file mode 100644 index bae3312447..0000000000 --- a/services/web/frontend/stylesheets/app/admin-hub.less +++ /dev/null @@ -1,156 +0,0 @@ -.hub-header { - h2 { - display: inline-block; - } - a { - color: @ol-dark-green; - } - i { - font-size: 30px; - } - .dropdown { - margin-right: 10px; - } -} -.admin-item { - position: relative; - margin-bottom: 60px; - .section-title { - text-transform: capitalize; - } - .alert-danger { - color: @ol-red; - } -} -.hidden-chart-section { - display: none; -} -.hub-circle { - display: inline-block; - background-color: @accent-color-secondary; - border-radius: 50%; - width: 160px; - height: 160px; - text-align: center; - //padding-top: 160px / 6.4; - img { - height: 160px - 160px / 3.2; - } - padding-top: 50px; - color: white; -} -.hub-circle-number { - display: block; - font-size: 36px; - font-weight: 900; - line-height: 1; -} -.hub-big-number { - float: left; - font-size: 32px; - font-weight: 900; - line-height: 40px; - color: @accent-color-secondary; -} -.hub-big-number, -.hub-number-label { - display: block; -} -.hub-metric-link { - position: absolute; - top: 9px; - right: 0; - a { - color: @accent-color-secondary; - } - i { - margin-right: 5px; - } -} -.custom-donut-container { - svg { - max-width: 700px; - margin: auto; - } - .chart-center-text { - font-family: @font-family-sans-serif; - font-size: 40px; - font-weight: bold; - fill: @accent-color-secondary; - text-anchor: middle; - } - - .nv-legend-text { - font-family: @font-family-sans-serif; - font-size: 14px; - } -} -.chart-no-center-text { - .chart-center-text { - display: none; - } -} - -.superscript { - font-size: @font-size-large; -} - -.admin-page { - summary { - // firefox does not show markers for block items - display: list-item; - } -} - -.material-switch { - input[type='checkbox'] { - display: none; - - &:checked + label::before { - background: inherit; - opacity: 0.5; - } - &:checked + label::after { - background: inherit; - left: 20px; - } - &:disabled + label { - opacity: 0.5; - cursor: not-allowed; - } - } - - label { - cursor: pointer; - height: 0; - position: relative; - width: 40px; - - &:before { - background: rgb(0, 0, 0); - box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); - border-radius: 8px; - content: ''; - height: 16px; - margin-top: -2px; - position: absolute; - opacity: 0.3; - transition: all 0.2s ease-in-out; - width: 40px; - } - - &:after { - background: rgb(255, 255, 255); - border-radius: 16px; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); - content: ''; - height: 24px; - left: -4px; - margin-top: -2px; - position: absolute; - top: -4px; - transition: all 0.2s ease-in-out; - width: 24px; - } - } -} diff --git a/services/web/frontend/stylesheets/app/portals.less b/services/web/frontend/stylesheets/app/portals.less index 9dfd4a57b7..b69176b05f 100644 --- a/services/web/frontend/stylesheets/app/portals.less +++ b/services/web/frontend/stylesheets/app/portals.less @@ -141,4 +141,38 @@ } } } + .hub-circle { + display: inline-block; + background-color: @green-70; + border-radius: 50%; + width: 160px; + height: 160px; + text-align: center; + padding-top: 50px; + color: white; + } + .hub-circle-number { + display: block; + font-size: 36px; + font-weight: 900; + line-height: 1; + } + .custom-donut-container { + svg { + max-width: 700px; + margin: auto; + } + .chart-center-text { + font-family: @font-family-sans-serif; + font-size: 40px; + font-weight: bold; + fill: @green-70; + text-anchor: middle; + } + + .nv-legend-text { + font-family: @font-family-sans-serif; + font-size: 14px; + } + } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss index b92eb80551..01d58c8c20 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss @@ -1,3 +1,10 @@ +@import 'metrics/admin-hub'; +@import 'metrics/daterange-picker'; +@import 'metrics/institution-hub'; +@import 'metrics/metrics'; +@import 'metrics/nvd3'; +@import 'metrics/nvd3_override'; +@import 'metrics/publisher-hub'; @import 'third-party-references'; @import 'symbol-palette'; @import 'writefull'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss new file mode 100644 index 0000000000..3e6576cf92 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss @@ -0,0 +1,93 @@ +.hub-header { + h2 { + display: inline-block; + } + + .dropdown { + margin-right: var(--spacing-04); + } +} + +.admin-item { + position: relative; + margin-bottom: var(--spacing-12); + + .section-title { + text-transform: capitalize; + } + + .alert-danger { + color: var(--content-danger); + } +} + +.hidden-chart-section { + display: none; +} + +.hub-circle { + display: inline-block; + background-color: var(--green-70); + border-radius: 50%; + width: 160px; + height: 160px; + text-align: center; + padding-top: 50px; + color: white; +} + +.hub-circle-number { + display: block; + font-size: 36px; + font-weight: 900; + line-height: 1; +} + +.hub-big-number { + float: left; + font-size: 32px; + font-weight: 900; + line-height: 40px; + color: var(--green-70); +} + +.hub-big-number, +.hub-number-label { + display: block; +} + +.hub-metric-link { + position: absolute; + top: 9px; + right: 0; + + i { + margin-right: 5px; + } +} + +.custom-donut-container { + svg { + max-width: 700px; + margin: auto; + } + + .chart-center-text { + font-family: $font-family-sans-serif; + font-size: 40px; + font-weight: bold; + fill: var(--green-70); + text-anchor: middle; + } + + .nv-legend-text { + font-family: $font-family-sans-serif; + font-size: 14px; + } +} + +.chart-no-center-text { + .chart-center-text { + display: none; + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss new file mode 100644 index 0000000000..33e466bd91 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss @@ -0,0 +1,617 @@ +// A stylesheet for use with Bootstrap 3.x +// @author: Dan Grossman http://www.dangrossman.info/ +// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved. +// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php +// @website: https://www.improvely.com/ + +/* stylelint-disable selector-class-pattern */ + +// VARIABLES + +// Settings + +// The class name to contain everything within. +$arrow-size: 7px; + +// Colors +$daterangepicker-color: $green-50; +$daterangepicker-bg-color: #fff; +$daterangepicker-cell-color: $daterangepicker-color; +$daterangepicker-cell-border-color: transparent; +$daterangepicker-cell-bg-color: $daterangepicker-bg-color; +$daterangepicker-cell-hover-color: $daterangepicker-color; +$daterangepicker-cell-hover-border-color: $daterangepicker-cell-border-color; +$daterangepicker-cell-hover-bg-color: #eee; +$daterangepicker-in-range-color: #000; +$daterangepicker-in-range-border-color: transparent; +$daterangepicker-in-range-bg-color: #ebf4f8; +$daterangepicker-active-color: #fff; +$daterangepicker-active-bg-color: #138a07; +$daterangepicker-active-border-color: transparent; +$daterangepicker-unselected-color: #999; +$daterangepicker-unselected-border-color: transparent; +$daterangepicker-unselected-bg-color: #fff; + +// daterangepicker +$daterangepicker-width: 278px; +$daterangepicker-padding: 4px; +$daterangepicker-z-index: 3000; +$daterangepicker-border-size: 1px; +$daterangepicker-border-color: #ccc; +$daterangepicker-border-radius: 4px; + +// Calendar +$daterangepicker-calendar-margin: $daterangepicker-padding; +$daterangepicker-calendar-bg-color: $daterangepicker-bg-color; +$daterangepicker-calendar-border-size: 1px; +$daterangepicker-calendar-border-color: $daterangepicker-bg-color; +$daterangepicker-calendar-border-radius: $daterangepicker-border-radius; + +// Calendar Cells +$daterangepicker-cell-size: 20px; +$daterangepicker-cell-width: $daterangepicker-cell-size; +$daterangepicker-cell-height: $daterangepicker-cell-size; +$daterangepicker-cell-border-radius: $daterangepicker-calendar-border-radius; +$daterangepicker-cell-border-size: 1px; + +// Dropdowns +$daterangepicker-dropdown-z-index: $daterangepicker-z-index + 1; + +// Controls +$daterangepicker-control-height: 30px; +$daterangepicker-control-line-height: $daterangepicker-control-height; +$daterangepicker-control-color: #555; +$daterangepicker-control-border-size: 1px; +$daterangepicker-control-border-color: #ccc; +$daterangepicker-control-border-radius: 4px; +$daterangepicker-control-active-border-size: 1px; +$daterangepicker-control-active-border-color: $green-50; +$daterangepicker-control-active-border-radius: $daterangepicker-control-border-radius; +$daterangepicker-control-disabled-color: #ccc; + +// Ranges +$daterangepicker-ranges-color: $green-50; +$daterangepicker-ranges-bg-color: daterangepicker-ranges-color; +$daterangepicker-ranges-border-size: 1px; +$daterangepicker-ranges-border-color: $daterangepicker-ranges-bg-color; +$daterangepicker-ranges-border-radius: $daterangepicker-border-radius; +$daterangepicker-ranges-hover-color: #fff; +$daterangepicker-ranges-hover-bg-color: $daterangepicker-ranges-color; +$daterangepicker-ranges-hover-border-size: $daterangepicker-ranges-border-size; +$daterangepicker-ranges-hover-border-color: $daterangepicker-ranges-hover-bg-color; +$daterangepicker-ranges-hover-border-radius: $daterangepicker-border-radius; +$daterangepicker-ranges-active-border-size: $daterangepicker-ranges-border-size; +$daterangepicker-ranges-active-border-color: $daterangepicker-ranges-bg-color; +$daterangepicker-ranges-active-border-radius: $daterangepicker-border-radius; + +// STYLESHEETS +.daterangepicker { + position: absolute; + color: $daterangepicker-color; + background-color: $daterangepicker-bg-color; + border-radius: $daterangepicker-border-radius; + width: $daterangepicker-width; + padding: $daterangepicker-padding; + margin-top: $daterangepicker-border-size; + + // TODO: Should these be parameterized?? + // top: 100px; + // left: 20px; + + $arrow-prefix-size: $arrow-size; + $arrow-suffix-size: ($arrow-size - $daterangepicker-border-size); + + &::before, + &::after { + position: absolute; + display: inline-block; + border-bottom-color: rgb(0 0 0 / 20%); + content: ''; + } + + &::before { + top: -$arrow-prefix-size; + border-right: $arrow-prefix-size solid transparent; + border-left: $arrow-prefix-size solid transparent; + border-bottom: $arrow-prefix-size solid $daterangepicker-border-color; + } + + &::after { + top: -$arrow-suffix-size; + border-right: $arrow-suffix-size solid transparent; + border-bottom: $arrow-suffix-size solid $daterangepicker-bg-color; + border-left: $arrow-suffix-size solid transparent; + } + + &.opensleft { + &::before { + // TODO: Make this relative to prefix size. + right: $arrow-prefix-size + 2px; + } + + &::after { + // TODO: Make this relative to suffix size. + right: $arrow-suffix-size + 4px; + } + } + + &.openscenter { + &::before { + left: 0; + right: 0; + width: 0; + margin-left: auto; + margin-right: auto; + } + + &::after { + left: 0; + right: 0; + width: 0; + margin-left: auto; + margin-right: auto; + } + } + + &.opensright { + &::before { + // TODO: Make this relative to prefix size. + left: $arrow-prefix-size + 2px; + } + + &::after { + // TODO: Make this relative to suffix size. + left: $arrow-suffix-size + 4px; + } + } + + &.dropup { + margin-top: -5px; + + // NOTE: Note sure why these are special-cased. + &::before { + top: initial; + bottom: -$arrow-prefix-size; + border-bottom: initial; + border-top: $arrow-prefix-size solid $daterangepicker-border-color; + } + + &::after { + top: initial; + bottom: -$arrow-suffix-size; + border-bottom: initial; + border-top: $arrow-suffix-size solid $daterangepicker-bg-color; + } + } + + &.dropdown-menu { + max-width: none; + z-index: $daterangepicker-dropdown-z-index; + } + + &.single { + .ranges, + .calendar { + float: none; + } + } + + /* Calendars */ + &.show-calendar { + .calendar { + display: block; + } + } + + .calendar { + display: none; + max-width: $daterangepicker-width - ($daterangepicker-calendar-margin * 2); + margin: $daterangepicker-calendar-margin; + + &.single { + .calendar-table { + border: none; + } + } + + th, + td { + white-space: nowrap; + text-align: center; + + // TODO: Should this actually be hard-coded? + min-width: 32px; + } + } + + .calendar-table { + border: $daterangepicker-calendar-border-size solid + $daterangepicker-calendar-border-color; + padding: $daterangepicker-calendar-margin; + border-radius: $daterangepicker-calendar-border-radius; + background-color: $daterangepicker-calendar-bg-color; + } + + table { + width: 100%; + margin: 0; + } + + td, + th { + text-align: center; + width: $daterangepicker-cell-width; + height: $daterangepicker-cell-height; + border-radius: $daterangepicker-cell-border-radius; + border: $daterangepicker-cell-border-size solid + $daterangepicker-cell-border-color; + white-space: nowrap; + cursor: pointer; + + &.available { + &:hover { + background-color: $daterangepicker-cell-hover-bg-color; + border-color: $daterangepicker-cell-hover-border-color; + color: $daterangepicker-cell-hover-color; + } + } + + &.week { + font-size: 80%; + color: #ccc; + } + } + + td { + &.off { + &, + &.in-range, + &.start-date, + &.end-date { + background-color: $daterangepicker-unselected-bg-color; + border-color: $daterangepicker-unselected-border-color; + color: $daterangepicker-unselected-color; + } + } + + // Date Range + &.in-range { + background-color: $daterangepicker-in-range-bg-color; + border-color: $daterangepicker-in-range-border-color; + color: $daterangepicker-in-range-color; + + // TODO: Should this be static or should it be parameterized? + border-radius: 0; + } + + &.start-date { + border-radius: $daterangepicker-cell-border-radius 0 0 + $daterangepicker-cell-border-radius; + } + + &.end-date { + border-radius: 0 $daterangepicker-cell-border-radius + $daterangepicker-cell-border-radius 0; + } + + &.start-date.end-date { + border-radius: $daterangepicker-cell-border-radius; + } + + &.active { + &, + &:hover { + background-color: $daterangepicker-active-bg-color; + border-color: $daterangepicker-active-border-color; + color: $daterangepicker-active-color; + } + } + } + + th { + &.month { + width: auto; + } + } + + // Disabled Controls + td, + option { + &.disabled { + color: #999; + cursor: not-allowed; + text-decoration: line-through; + } + } + + select { + &.monthselect, + &.yearselect { + font-size: 12px; + padding: 1px; + height: auto; + margin: 0; + cursor: default; + } + + &.monthselect { + margin-right: 2%; + width: 56%; + } + + &.yearselect { + width: 40%; + } + + &.hourselect, + &.minuteselect, + &.secondselect, + &.ampmselect { + width: 50px; + margin-bottom: 0; + } + } + + // Text Input Controls (above calendar) + .input-mini { + border: $daterangepicker-control-border-size solid + $daterangepicker-control-border-color; + border-radius: $daterangepicker-control-border-radius; + color: $daterangepicker-control-color; + height: $daterangepicker-control-line-height; + line-height: $daterangepicker-control-height; + display: block; + vertical-align: middle; + + // TODO: Should these all be static, too?? + margin: 0 0 5px; + padding: 0 6px 0 28px; + width: 100%; + + &.active { + border: $daterangepicker-control-active-border-size solid + $daterangepicker-control-active-border-color; + border-radius: $daterangepicker-control-active-border-radius; + } + } + + .daterangepicker_input { + position: relative; + padding-left: 0; + + i { + position: absolute; + + // NOTE: These appear to be eyeballed to me... + left: 8px; + top: var(--spacing-04); + } + } + + &.rtl { + .input-mini { + padding-right: 28px; + padding-left: 6px; + } + + .daterangepicker_input i { + left: auto; + right: 8px; + } + } + + // Time Picker + .calendar-time { + text-align: center; + margin: 5px auto; + line-height: $daterangepicker-control-line-height; + position: relative; + padding-left: 28px; + + select { + &.disabled { + color: $daterangepicker-control-disabled-color; + cursor: not-allowed; + } + } + } +} + +// Predefined Ranges +.ranges { + font-size: 11px; + float: none; + margin: 4px; + text-align: left; + + ul { + list-style: none; + margin: 0 auto; + padding: 0; + width: 100%; + } + + li { + font-size: 13px; + background-color: $daterangepicker-ranges-bg-color; + border: $daterangepicker-ranges-border-size solid + $daterangepicker-ranges-border-color; + border-radius: $daterangepicker-ranges-border-radius; + color: $daterangepicker-ranges-color; + padding: 3px 12px; + margin-bottom: 8px; + cursor: pointer; + + &:hover { + background-color: $daterangepicker-ranges-hover-bg-color; + color: $daterangepicker-ranges-hover-color; + } + + &.active { + background-color: $daterangepicker-ranges-hover-bg-color; + border: $daterangepicker-ranges-hover-border-size solid + $daterangepicker-ranges-hover-border-color; + color: $daterangepicker-ranges-hover-color; + } + } +} + +/* Larger Screen Styling */ +@include media-breakpoint-up(sm) { + .daterangepicker { + .glyphicon { + /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */ + font-family: FontAwesome; + } + + .glyphicon-chevron-left::before { + content: '\f053'; + } + + .glyphicon-chevron-right::before { + content: '\f054'; + } + + .glyphicon-calendar::before { + content: '\f073'; + } + + width: auto; + + .ranges { + ul { + width: 160px; + } + } + + &.single { + .ranges { + ul { + width: 100%; + } + } + + .calendar.left { + clear: none; + } + + &.ltr { + .ranges, + .calendar { + float: left; + } + } + + &.rtl { + .ranges, + .calendar { + float: right; + } + } + } + + &.ltr { + direction: ltr; + text-align: left; + + .calendar { + &.left { + clear: left; + margin-right: 0; + + .calendar-table { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + padding-right: 12px; + } + } + + &.right { + margin-left: 0; + + .calendar-table { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + } + + .left .daterangepicker_input { + padding-right: 12px; + } + + .ranges, + .calendar { + float: left; + } + } + + &.rtl { + direction: rtl; + text-align: right; + + .calendar { + &.left { + clear: right; + margin-left: 0; + + .calendar-table { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding-left: 12px; + } + } + + &.right { + margin-right: 0; + + .calendar-table { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + } + + .ranges, + .calendar { + text-align: right; + float: right; + } + } + } +} + +@include media-breakpoint-up(md) { + /* force the calendar to display on one row */ + .show-calendar { + min-width: 658px; /* width of all contained elements, IE/Edge fallback */ + width: max-content; + } + + .daterangepicker { + .ranges { + width: auto; + } + + &.ltr { + .ranges { + float: left; + clear: none !important; + } + } + + &.rtl { + .ranges { + float: right; + } + } + + .calendar { + clear: none !important; + } + } +} diff --git a/services/web/frontend/stylesheets/app/institution-hub.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss similarity index 52% rename from services/web/frontend/stylesheets/app/institution-hub.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss index cb705e4b99..67cbe580e4 100644 --- a/services/web/frontend/stylesheets/app/institution-hub.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss @@ -1,38 +1,54 @@ #institution-hub { - .section_header { + .section-header { .dropdown { - margin-right: 10px; + margin-right: var(--spacing-04); } } #usage { .recent-activity { .overbox { - font-size: 16px; + @include body-base; } + .hub-big-number, .hub-number-label, .worked-on { display: block; width: 50%; } + .hub-big-number { - padding-right: 10px; + padding-right: var(--spacing-04); text-align: right; } + .hub-number-label, .worked-on { float: right; } + .hub-number-label { &:nth-child(odd) { - margin-top: 16px; + margin-top: var(--spacing-06); } } + .worked-on { - color: @text-small-color; + color: var(--content-secondary); font-style: italic; } } } + + .overbox { + margin: 0; + padding: var(--spacing-10) var(--spacing-07); + background: var(--white); + border: 1px solid var(--content-disabled); + + &.overbox-small { + padding: var(--spacing-04); + } + } } diff --git a/services/web/frontend/stylesheets/app/metrics.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss similarity index 78% rename from services/web/frontend/stylesheets/app/metrics.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss index 5256b8a8bd..32cde9c522 100644 --- a/services/web/frontend/stylesheets/app/metrics.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss @@ -1,6 +1,6 @@ #metrics { max-width: none; - padding: 0 30px; + padding: 0 var(--spacing-09); width: auto; svg.nvd3-svg { @@ -9,17 +9,18 @@ .overbox { margin: 0; - padding: 40px 20px; + padding: var(--spacing-10) var(--spacing-07); background: #fff; border: 1px solid #dfdfdf; + .box { - padding-bottom: 30px; + padding-bottom: var(--spacing-09); overflow: hidden; - margin-bottom: 40px; - border-bottom: 1px solid rgb(216, 216, 216); + margin-bottom: var(--spacing-10); + border-bottom: 1px solid rgb(216 216 216); .header { - margin-bottom: 20px; + margin-bottom: var(--spacing-07); h4 { font-size: 19px; @@ -27,10 +28,14 @@ } } } + + &.overbox-small { + padding: var(--spacing-04); + } } .print-button { - margin-right: 10px; + margin-right: var(--spacing-04); font-size: 20px; } @@ -40,21 +45,17 @@ } .metric-col { - padding: 15px; - } - - .metric-header-container { - h4 { - margin-bottom: 0; - } + padding: var(--spacing-06); } svg { display: block; height: 250px; + text { font-family: 'Open Sans', sans-serif; } + &:not(:root) { overflow: visible; } @@ -79,6 +80,10 @@ // BEGIN: Metrics header .metric-header-container { + h4 { + margin-bottom: 0; + } + > h4 { margin-top: 0; margin-bottom: 0; @@ -89,12 +94,14 @@ font-size: 0.5em; } } + // END: Metrics header // BEGIN: Metrics footer .metric-footer-container { text-align: center; } + // END: Metrics footer // BEGIN: Metrics overlays @@ -107,7 +114,7 @@ height: 100%; width: 100%; padding: 16px; /* 15px of .metric-col padding + 1px border */ - padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding*/ + padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding */ } .metric-overlay-loading { @@ -129,19 +136,20 @@ width: 100%; height: 100%; } + // END: Metrics overlays } #metrics-header { - @media (min-width: 1200px) { - margin-bottom: 30px; + @include media-breakpoint-up(lg) { + margin-bottom: var(--spacing-09); } h3 { display: inline-block; } - .section_header { + .section-header { margin-bottom: 0; } @@ -162,9 +170,11 @@ #dates-container { display: inline-block; + .daterangepicker { - margin-right: 15px; + margin-right: var(--spacing-06); } + #metrics-dates { padding: 0; } @@ -172,14 +182,10 @@ } #metrics-footer { - margin-top: 30px; + margin-top: var(--spacing-09); text-align: center; } -body.print-loading { - #metrics { - .metric-col { - opacity: 0.5; - } - } +body.print-loading #metrics .metric-col { + opacity: 0.5; } diff --git a/services/web/frontend/stylesheets/components/nvd3.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss similarity index 78% rename from services/web/frontend/stylesheets/components/nvd3.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss index f1fea65901..4983129a80 100755 --- a/services/web/frontend/stylesheets/components/nvd3.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss @@ -1,691 +1,677 @@ -/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ -.nvd3 .nv-axis { - pointer-events: none; - opacity: 1; -} - -.nvd3 .nv-axis path { - fill: none; - stroke: #000; - stroke-opacity: 0.75; - shape-rendering: crispEdges; -} - -.nvd3 .nv-axis path.domain { - stroke-opacity: 0.75; -} - -.nvd3 .nv-axis.nv-x path.domain { - stroke-opacity: 0; -} - -.nvd3 .nv-axis line { - fill: none; - stroke: #e5e5e5; - shape-rendering: crispEdges; -} - -.nvd3 .nv-axis .zero line, - /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { - stroke-opacity: 0.75; -} - -.nvd3 .nv-axis .nv-axisMaxMin text { - font-weight: bold; -} - -.nvd3 .x .nv-axis .nv-axisMaxMin text, -.nvd3 .x2 .nv-axis .nv-axisMaxMin text, -.nvd3 .x3 .nv-axis .nv-axisMaxMin text { - text-anchor: middle; -} - -.nvd3 .nv-axis.nv-disabled { - opacity: 0; -} - -.nvd3 .nv-bars rect { - fill-opacity: 0.75; - - transition: fill-opacity 250ms linear; - -moz-transition: fill-opacity 250ms linear; - -webkit-transition: fill-opacity 250ms linear; -} - -.nvd3 .nv-bars rect.hover { - fill-opacity: 1; -} - -.nvd3 .nv-bars .hover rect { - fill: lightblue; -} - -.nvd3 .nv-bars text { - fill: rgba(0, 0, 0, 0); -} - -.nvd3 .nv-bars .hover text { - fill: rgba(0, 0, 0, 1); -} - -.nvd3 .nv-multibar .nv-groups rect, -.nvd3 .nv-multibarHorizontal .nv-groups rect, -.nvd3 .nv-discretebar .nv-groups rect { - stroke-opacity: 0; - - transition: fill-opacity 250ms linear; - -moz-transition: fill-opacity 250ms linear; - -webkit-transition: fill-opacity 250ms linear; -} - -.nvd3 .nv-multibar .nv-groups rect:hover, -.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, -.nvd3 .nv-candlestickBar .nv-ticks rect:hover, -.nvd3 .nv-discretebar .nv-groups rect:hover { - fill-opacity: 1; -} - -.nvd3 .nv-discretebar .nv-groups text, -.nvd3 .nv-multibarHorizontal .nv-groups text { - font-weight: bold; - fill: rgba(0, 0, 0, 1); - stroke: rgba(0, 0, 0, 0); -} - -/* boxplot CSS */ -.nvd3 .nv-boxplot circle { - fill-opacity: 0.5; -} - -.nvd3 .nv-boxplot circle:hover { - fill-opacity: 1; -} - -.nvd3 .nv-boxplot rect:hover { - fill-opacity: 1; -} - -.nvd3 line.nv-boxplot-median { - stroke: black; -} - -.nv-boxplot-tick:hover { - stroke-width: 2.5px; -} -/* bullet */ -.nvd3.nv-bullet { - font: 10px sans-serif; -} -.nvd3.nv-bullet .nv-measure { - fill-opacity: 0.8; -} -.nvd3.nv-bullet .nv-measure:hover { - fill-opacity: 1; -} -.nvd3.nv-bullet .nv-marker { - stroke: #000; - stroke-width: 2px; -} -.nvd3.nv-bullet .nv-markerTriangle { - stroke: #000; - fill: #fff; - stroke-width: 1.5px; -} -.nvd3.nv-bullet .nv-markerLine { - stroke: #000; - stroke-width: 1.5px; -} -.nvd3.nv-bullet .nv-tick line { - stroke: #666; - stroke-width: 0.5px; -} -.nvd3.nv-bullet .nv-range.nv-s0 { - fill: #eee; -} -.nvd3.nv-bullet .nv-range.nv-s1 { - fill: #ddd; -} -.nvd3.nv-bullet .nv-range.nv-s2 { - fill: #ccc; -} -.nvd3.nv-bullet .nv-title { - font-size: 14px; - font-weight: bold; -} -.nvd3.nv-bullet .nv-subtitle { - fill: #999; -} - -.nvd3.nv-bullet .nv-range { - fill: #bababa; - fill-opacity: 0.4; -} -.nvd3.nv-bullet .nv-range:hover { - fill-opacity: 0.7; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick { - stroke-width: 1px; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { - stroke-width: 2px; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { - stroke: #2ca02c; - fill: #2ca02c; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { - stroke: #d62728; - fill: #d62728; -} - -.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { - transition: stroke-width 250ms linear, stroke-opacity 250ms linear; - -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; - -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-candlestickBar .nv-ticks line { - stroke: #333; -} - -.nv-force-node { - stroke: #fff; - stroke-width: 1.5px; -} -.nv-force-link { - stroke: #999; - stroke-opacity: 0.6; -} -.nv-force-node text { - stroke-width: 0px; -} - -.nvd3 .nv-legend .nv-disabled rect { - /*fill-opacity: 0;*/ -} - -.nvd3 .nv-check-box .nv-box { - fill-opacity: 0; - stroke-width: 2; -} - -.nvd3 .nv-check-box .nv-check { - fill-opacity: 0; - stroke-width: 4; -} - -.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { - opacity: 0; -} - -/* line plus bar */ -.nvd3.nv-linePlusBar .nv-bar rect { - fill-opacity: 0.75; -} - -.nvd3.nv-linePlusBar .nv-bar rect:hover { - fill-opacity: 1; -} -.nvd3 .nv-groups path.nv-line { - fill: none; -} - -.nvd3 .nv-groups path.nv-area { - stroke: none; -} - -.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { - fill-opacity: 0.5 !important; - stroke-opacity: 0.5 !important; -} - -.with-transitions .nvd3 .nv-groups .nv-point { - transition: stroke-width 250ms linear, stroke-opacity 250ms linear; - -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; - -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-scatter .nv-groups .nv-point.hover, -.nvd3 .nv-groups .nv-point.hover { - stroke-width: 7px; - fill-opacity: 0.95 !important; - stroke-opacity: 0.95 !important; -} - -.nvd3 .nv-point-paths path { - stroke: #aaa; - stroke-opacity: 0; - fill: #eee; - fill-opacity: 0; -} - -.nvd3 .nv-indexLine { - cursor: ew-resize; -} - -/******************** - * SVG CSS - */ - -/******************** - Default CSS for an svg element nvd3 used -*/ -svg.nvd3-svg { - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -ms-user-select: none; - -moz-user-select: none; - user-select: none; - display: block; - width: 100%; - height: 100%; -} - -/******************** - Box shadow and border radius styling -*/ -.nvtooltip.with-3d-shadow, -.with-3d-shadow .nvtooltip { - -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; -} - -.nvd3 text { - font: normal 12px Arial; -} - -.nvd3 .title { - font: bold 14px Arial; -} - -.nvd3 .nv-background { - fill: white; - fill-opacity: 0; -} - -.nvd3.nv-noData { - font-size: 18px; - font-weight: bold; -} - -/********** -* Brush -*/ - -.nv-brush .extent { - fill-opacity: 0.125; - shape-rendering: crispEdges; -} - -.nv-brush .resize path { - fill: #eee; - stroke: #666; -} - -/********** -* Legend -*/ - -.nvd3 .nv-legend .nv-series { - cursor: pointer; -} - -.nvd3 .nv-legend .nv-disabled circle { - fill-opacity: 0; -} - -/* focus */ -.nvd3 .nv-brush .extent { - fill-opacity: 0 !important; -} - -.nvd3 .nv-brushBackground rect { - stroke: #000; - stroke-width: 0.4; - fill: #fff; - fill-opacity: 0.7; -} - -/********** -* Print -*/ - -@media print { - .nvd3 text { - stroke-width: 0; - fill-opacity: 1; - } -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick { - stroke-width: 1px; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { - stroke-width: 2px; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { - stroke: #2ca02c; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { - stroke: #d62728; -} - -.nvd3 .background path { - fill: none; - stroke: #eee; - stroke-opacity: 0.4; - shape-rendering: crispEdges; -} - -.nvd3 .foreground path { - fill: none; - stroke-opacity: 0.7; -} - -.nvd3 .nv-parallelCoordinates-brush .extent { - fill: #fff; - fill-opacity: 0.6; - stroke: gray; - shape-rendering: crispEdges; -} - -.nvd3 .nv-parallelCoordinates .hover { - fill-opacity: 1; - stroke-width: 3px; -} - -.nvd3 .missingValuesline line { - fill: none; - stroke: black; - stroke-width: 1; - stroke-opacity: 1; - stroke-dasharray: 5, 5; -} -.nvd3.nv-pie path { - stroke-opacity: 0; - transition: fill-opacity 250ms linear, stroke-width 250ms linear, - stroke-opacity 250ms linear; - -moz-transition: fill-opacity 250ms linear, stroke-width 250ms linear, - stroke-opacity 250ms linear; - -webkit-transition: fill-opacity 250ms linear, stroke-width 250ms linear, - stroke-opacity 250ms linear; -} - -.nvd3.nv-pie .nv-pie-title { - font-size: 24px; - fill: rgba(19, 196, 249, 0.59); -} - -.nvd3.nv-pie .nv-slice text { - stroke: #000; - stroke-width: 0; -} - -.nvd3.nv-pie path { - stroke: #fff; - stroke-width: 1px; - stroke-opacity: 1; -} - -.nvd3.nv-pie path { - fill-opacity: 0.7; -} -.nvd3.nv-pie .hover path { - fill-opacity: 1; -} -.nvd3.nv-pie .nv-label { - pointer-events: none; -} -.nvd3.nv-pie .nv-label rect { - fill-opacity: 0; - stroke-opacity: 0; -} - -/* scatter */ -.nvd3 .nv-groups .nv-point.hover { - stroke-width: 20px; - stroke-opacity: 0.5; -} - -.nvd3 .nv-scatter .nv-point.hover { - fill-opacity: 1; -} -.nv-noninteractive { - pointer-events: none; -} - -.nv-distx, -.nv-disty { - pointer-events: none; -} - -/* sparkline */ -.nvd3.nv-sparkline path { - fill: none; -} - -.nvd3.nv-sparklineplus g.nv-hoverValue { - pointer-events: none; -} - -.nvd3.nv-sparklineplus .nv-hoverValue line { - stroke: #333; - stroke-width: 1.5px; -} - -.nvd3.nv-sparklineplus, -.nvd3.nv-sparklineplus g { - pointer-events: all; -} - -.nvd3 .nv-hoverArea { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3.nv-sparklineplus .nv-xValue, -.nvd3.nv-sparklineplus .nv-yValue { - stroke-width: 0; - font-size: 0.9em; - font-weight: normal; -} - -.nvd3.nv-sparklineplus .nv-yValue { - stroke: #f66; -} - -.nvd3.nv-sparklineplus .nv-maxValue { - stroke: #2ca02c; - fill: #2ca02c; -} - -.nvd3.nv-sparklineplus .nv-minValue { - stroke: #d62728; - fill: #d62728; -} - -.nvd3.nv-sparklineplus .nv-currentValue { - font-weight: bold; - font-size: 1.1em; -} -/* stacked area */ -.nvd3.nv-stackedarea path.nv-area { - fill-opacity: 0.7; - stroke-opacity: 0; - transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; - -moz-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; - -webkit-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-stackedarea path.nv-area.hover { - fill-opacity: 0.9; -} - -.nvd3.nv-stackedarea .nv-groups .nv-point { - stroke-opacity: 0; - fill-opacity: 0; -} - -.nvtooltip { - position: absolute; - background-color: rgba(255, 255, 255, 1); - color: rgba(0, 0, 0, 1); - padding: 1px; - border: 1px solid rgba(0, 0, 0, 0.2); - z-index: 10000; - display: block; - - font-family: Arial; - font-size: 13px; - text-align: left; - pointer-events: none; - - white-space: nowrap; - - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.nvtooltip { - background: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(0, 0, 0, 0.5); - border-radius: 4px; -} - -/*Give tooltips that old fade in transition by - putting a "with-transitions" class on the container div. -*/ -.nvtooltip.with-transitions, -.with-transitions .nvtooltip { - transition: opacity 50ms linear; - -moz-transition: opacity 50ms linear; - -webkit-transition: opacity 50ms linear; - - transition-delay: 200ms; - -moz-transition-delay: 200ms; - -webkit-transition-delay: 200ms; -} - -.nvtooltip.x-nvtooltip, -.nvtooltip.y-nvtooltip { - padding: 8px; -} - -.nvtooltip h3 { - margin: 0; - padding: 4px 14px; - line-height: 18px; - font-weight: normal; - background-color: rgba(247, 247, 247, 0.75); - color: rgba(0, 0, 0, 1); - text-align: center; - - border-bottom: 1px solid #ebebeb; - - -webkit-border-radius: 5px 5px 0 0; - -moz-border-radius: 5px 5px 0 0; - border-radius: 5px 5px 0 0; -} - -.nvtooltip p { - margin: 0; - padding: 5px 14px; - text-align: center; -} - -.nvtooltip span { - display: inline-block; - margin: 2px 0; -} - -.nvtooltip table { - margin: 6px; - border-spacing: 0; -} - -.nvtooltip table td { - padding: 2px 9px 2px 0; - vertical-align: middle; -} - -.nvtooltip table td.key { - font-weight: normal; -} -.nvtooltip table td.key.total { - font-weight: bold; -} -.nvtooltip table td.value { - text-align: right; - font-weight: bold; -} - -.nvtooltip table td.percent { - color: darkgray; -} - -.nvtooltip table tr.highlight td { - padding: 1px 9px 1px 0; - border-bottom-style: solid; - border-bottom-width: 1px; - border-top-style: solid; - border-top-width: 1px; -} - -.nvtooltip table td.legend-color-guide div { - width: 8px; - height: 8px; - vertical-align: middle; -} - -.nvtooltip table td.legend-color-guide div { - width: 12px; - height: 12px; - border: 1px solid #999; -} - -.nvtooltip .footer { - padding: 3px; - text-align: center; -} - -.nvtooltip-pending-removal { - pointer-events: none; - display: none; -} - -/**** -Interactive Layer -*/ -.nvd3 .nv-interactiveGuideLine { - pointer-events: none; -} -.nvd3 line.nv-guideline { - stroke: #ccc; -} +/* stylelint-disable */ + +/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ +.nvd3 .nv-axis { + pointer-events: none; + opacity: 1; +} + +.nvd3 .nv-axis path { + fill: none; + stroke: #000; + stroke-opacity: 0.75; + shape-rendering: crispedges; +} + +.nvd3 .nv-axis path.domain { + stroke-opacity: 0.75; +} + +.nvd3 .nv-axis.nv-x path.domain { + stroke-opacity: 0; +} + +.nvd3 .nv-axis line { + fill: none; + stroke: #e5e5e5; + shape-rendering: crispedges; +} + +.nvd3 .nv-axis .zero line, + /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { + stroke-opacity: 0.75; +} + +.nvd3 .nv-axis .nv-axisMaxMin text { + font-weight: bold; +} + +.nvd3 .x .nv-axis .nv-axisMaxMin text, +.nvd3 .x2 .nv-axis .nv-axisMaxMin text, +.nvd3 .x3 .nv-axis .nv-axisMaxMin text { + text-anchor: middle; +} + +.nvd3 .nv-axis.nv-disabled { + opacity: 0; +} + +.nvd3 .nv-bars rect { + fill-opacity: 0.75; + transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-bars rect.hover { + fill-opacity: 1; +} + +.nvd3 .nv-bars .hover rect { + fill: lightblue; +} + +.nvd3 .nv-bars text { + fill: rgb(0 0 0 / 0%); +} + +.nvd3 .nv-bars .hover text { + fill: rgb(0 0 0 / 100%); +} + +.nvd3 .nv-multibar .nv-groups rect, +.nvd3 .nv-multibarHorizontal .nv-groups rect, +.nvd3 .nv-discretebar .nv-groups rect { + stroke-opacity: 0; + transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-multibar .nv-groups rect:hover, +.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, +.nvd3 .nv-candlestickBar .nv-ticks rect:hover, +.nvd3 .nv-discretebar .nv-groups rect:hover { + fill-opacity: 1; +} + +.nvd3 .nv-discretebar .nv-groups text, +.nvd3 .nv-multibarHorizontal .nv-groups text { + font-weight: bold; + fill: rgb(0 0 0 / 100%); + stroke: rgb(0 0 0 / 0%); +} + +/* boxplot CSS */ +.nvd3 .nv-boxplot circle { + fill-opacity: 0.5; +} + +.nvd3 .nv-boxplot circle:hover { + fill-opacity: 1; +} + +.nvd3 .nv-boxplot rect:hover { + fill-opacity: 1; +} + +.nvd3 line.nv-boxplot-median { + stroke: black; +} + +.nv-boxplot-tick:hover { + stroke-width: 2.5px; +} + +/* bullet */ +.nvd3.nv-bullet { + font: 10px sans-serif; +} + +.nvd3.nv-bullet .nv-measure { + fill-opacity: 0.8; +} + +.nvd3.nv-bullet .nv-measure:hover { + fill-opacity: 1; +} + +.nvd3.nv-bullet .nv-marker { + stroke: #000; + stroke-width: 2px; +} + +.nvd3.nv-bullet .nv-markerTriangle { + stroke: #000; + fill: #fff; + stroke-width: 1.5px; +} + +.nvd3.nv-bullet .nv-markerLine { + stroke: #000; + stroke-width: 1.5px; +} + +.nvd3.nv-bullet .nv-tick line { + stroke: #666; + stroke-width: 0.5px; +} + +.nvd3.nv-bullet .nv-range.nv-s0 { + fill: #eee; +} + +.nvd3.nv-bullet .nv-range.nv-s1 { + fill: #ddd; +} + +.nvd3.nv-bullet .nv-range.nv-s2 { + fill: #ccc; +} + +.nvd3.nv-bullet .nv-title { + font-size: 14px; + font-weight: bold; +} + +.nvd3.nv-bullet .nv-subtitle { + fill: #999; +} + +.nvd3.nv-bullet .nv-range { + fill: #bababa; + fill-opacity: 0.4; +} + +.nvd3.nv-bullet .nv-range:hover { + fill-opacity: 0.7; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { + stroke: #d62728; + fill: #d62728; +} + +.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-candlestickBar .nv-ticks line { + stroke: #333; +} + +.nv-force-node { + stroke: #fff; + stroke-width: 1.5px; +} + +.nv-force-link { + stroke: #999; + stroke-opacity: 0.6; +} + +.nv-force-node text { + stroke-width: 0; +} + +.nvd3 .nv-legend .nv-disabled rect { + /* fill-opacity: 0; */ +} + +.nvd3 .nv-check-box .nv-box { + fill-opacity: 0; + stroke-width: 2; +} + +.nvd3 .nv-check-box .nv-check { + fill-opacity: 0; + stroke-width: 4; +} + +.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { + opacity: 0; +} + +/* line plus bar */ +.nvd3.nv-linePlusBar .nv-bar rect { + fill-opacity: 0.75; +} + +.nvd3.nv-linePlusBar .nv-bar rect:hover { + fill-opacity: 1; +} + +.nvd3 .nv-groups path.nv-line { + fill: none; +} + +.nvd3 .nv-groups path.nv-area { + stroke: none; +} + +.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { + fill-opacity: 0.5 !important; + stroke-opacity: 0.5 !important; +} + +.with-transitions .nvd3 .nv-groups .nv-point { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-scatter .nv-groups .nv-point.hover, +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 7px; + fill-opacity: 0.95 !important; + stroke-opacity: 0.95 !important; +} + +.nvd3 .nv-point-paths path { + stroke: #aaa; + stroke-opacity: 0; + fill: #eee; + fill-opacity: 0; +} + +.nvd3 .nv-indexLine { + cursor: ew-resize; +} + +/******************** + * SVG CSS + */ + +/******************** + Default CSS for an svg element nvd3 used +*/ +svg.nvd3-svg, svg.nvd3-iddle { + -webkit-touch-callout: none; + user-select: none; + display: block; + width: 100%; + height: 100%; +} + +/******************** + Box shadow and border radius styling +*/ +.nvtooltip.with-3d-shadow, +.with-3d-shadow .nvtooltip { + box-shadow: 0 5px 10px rgb(0 0 0 / 20%); + border-radius: 5px; +} + +.nvd3 text { + font: normal 12px Arial; +} + +.nvd3 .title { + font: bold 14px Arial; +} + +.nvd3 .nv-background { + fill: white; + fill-opacity: 0; +} + +.nvd3.nv-noData { + font-size: 18px; + font-weight: bold; +} + +/********** +* Brush +*/ + +.nv-brush .extent { + fill-opacity: 0.125; + shape-rendering: crispedges; +} + +.nv-brush .resize path { + fill: #eee; + stroke: #666; +} + +/********** +* Legend +*/ + +.nvd3 .nv-legend .nv-series { + cursor: pointer; +} + +.nvd3 .nv-legend .nv-disabled circle { + fill-opacity: 0; +} + +/* focus */ +.nvd3 .nv-brush .extent { + fill-opacity: 0 !important; +} + +.nvd3 .nv-brushBackground rect { + stroke: #000; + stroke-width: 0.4; + fill: #fff; + fill-opacity: 0.7; +} + +/********** +* Print +*/ + +@media print { + .nvd3 text { + stroke-width: 0; + fill-opacity: 1; + } +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { + stroke: #2ca02c; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { + stroke: #d62728; +} + +.nvd3 .background path { + fill: none; + stroke: #eee; + stroke-opacity: 0.4; + shape-rendering: crispedges; +} + +.nvd3 .foreground path { + fill: none; + stroke-opacity: 0.7; +} + +.nvd3 .nv-parallelCoordinates-brush .extent { + fill: #fff; + fill-opacity: 0.6; + stroke: gray; + shape-rendering: crispedges; +} + +.nvd3 .nv-parallelCoordinates .hover { + fill-opacity: 1; + stroke-width: 3px; +} + +.nvd3 .missingValuesline line { + fill: none; + stroke: black; + stroke-width: 1; + stroke-opacity: 1; + stroke-dasharray: 5, 5; +} + +.nvd3.nv-pie path { + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-width 250ms linear, + stroke-opacity 250ms linear; +} + +.nvd3.nv-pie .nv-pie-title { + font-size: 24px; + fill: rgb(19 196 249 / 59%); +} + +.nvd3.nv-pie .nv-slice text { + stroke: #000; + stroke-width: 0; +} + +.nvd3.nv-pie path { + stroke: #fff; + stroke-width: 1px; + stroke-opacity: 1; +} + +.nvd3.nv-pie path { + fill-opacity: 0.7; +} + +.nvd3.nv-pie .hover path { + fill-opacity: 1; +} + +.nvd3.nv-pie .nv-label { + pointer-events: none; +} + +.nvd3.nv-pie .nv-label rect { + fill-opacity: 0; + stroke-opacity: 0; +} + +/* scatter */ +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 20px; + stroke-opacity: 0.5; +} + +.nvd3 .nv-scatter .nv-point.hover { + fill-opacity: 1; +} + +.nv-noninteractive { + pointer-events: none; +} + +.nv-distx, +.nv-disty { + pointer-events: none; +} + +/* sparkline */ +.nvd3.nv-sparkline path { + fill: none; +} + +.nvd3.nv-sparklineplus g.nv-hoverValue { + pointer-events: none; +} + +.nvd3.nv-sparklineplus .nv-hoverValue line { + stroke: #333; + stroke-width: 1.5px; +} + +.nvd3.nv-sparklineplus, +.nvd3.nv-sparklineplus g { + pointer-events: all; +} + +.nvd3 .nv-hoverArea { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-sparklineplus .nv-xValue, +.nvd3.nv-sparklineplus .nv-yValue { + stroke-width: 0; + font-size: 0.9em; + font-weight: normal; +} + +.nvd3.nv-sparklineplus .nv-yValue { + stroke: #f66; +} + +.nvd3.nv-sparklineplus .nv-maxValue { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-sparklineplus .nv-minValue { + stroke: #d62728; + fill: #d62728; +} + +.nvd3.nv-sparklineplus .nv-currentValue { + font-weight: bold; + font-size: 1.1em; +} + +/* stacked area */ +.nvd3.nv-stackedarea path.nv-area { + fill-opacity: 0.7; + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-stackedarea path.nv-area.hover { + fill-opacity: 0.9; +} + +.nvd3.nv-stackedarea .nv-groups .nv-point { + stroke-opacity: 0; + fill-opacity: 0; +} + +.nvtooltip { + position: absolute; + background-color: rgb(255 255 255 / 100%); + color: rgb(0 0 0 / 100%); + padding: 1px; + border: 1px solid rgb(0 0 0 / 20%); + z-index: 10000; + display: block; + font-family: Arial; + font-size: 13px; + text-align: left; + pointer-events: none; + white-space: nowrap; + -webkit-touch-callout: none; + user-select: none; +} + +.nvtooltip { + background: rgb(255 255 255 / 80%); + border: 1px solid rgb(0 0 0 / 50%); + border-radius: 4px; +} + +/* Give tooltips that old fade in transition by + putting a "with-transitions" class on the container div. +*/ +.nvtooltip.with-transitions, +.with-transitions .nvtooltip { + transition: opacity 50ms linear; + transition-delay: 200ms; +} + +.nvtooltip.x-nvtooltip, +.nvtooltip.y-nvtooltip { + padding: 8px; +} + +.nvtooltip h3 { + margin: 0; + padding: 4px 14px; + line-height: 18px; + font-weight: normal; + background-color: rgb(247 247 247 / 75%); + color: rgb(0 0 0 / 100%); + text-align: center; + border-bottom: 1px solid #ebebeb; + border-radius: 5px 5px 0 0; +} + +.nvtooltip p { + margin: 0; + padding: 5px 14px; + text-align: center; +} + +.nvtooltip span { + display: inline-block; + margin: 2px 0; +} + +.nvtooltip table { + margin: 6px; + border-spacing: 0; +} + +.nvtooltip table td { + padding: 2px 9px 2px 0; + vertical-align: middle; +} + +.nvtooltip table td.key { + font-weight: normal; +} + +.nvtooltip table td.key.total { + font-weight: bold; +} + +.nvtooltip table td.value { + text-align: right; + font-weight: bold; +} + +.nvtooltip table td.percent { + color: darkgray; +} + +.nvtooltip table tr.highlight td { + padding: 1px 9px 1px 0; + border-bottom-style: solid; + border-bottom-width: 1px; + border-top-style: solid; + border-top-width: 1px; +} + +.nvtooltip table td.legend-color-guide div { + width: 8px; + height: 8px; + vertical-align: middle; +} + +.nvtooltip table td.legend-color-guide div { + width: 12px; + height: 12px; + border: 1px solid #999; +} + +.nvtooltip .footer { + padding: 3px; + text-align: center; +} + +.nvtooltip-pending-removal { + pointer-events: none; + display: none; +} + +/**** +Interactive Layer +*/ +.nvd3 .nv-interactiveGuideLine { + pointer-events: none; +} + +.nvd3 line.nv-guideline { + stroke: #ccc; +} diff --git a/services/web/frontend/stylesheets/components/nvd3_override.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss similarity index 74% rename from services/web/frontend/stylesheets/components/nvd3_override.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss index 929a99e9db..72c3e2f99a 100644 --- a/services/web/frontend/stylesheets/components/nvd3_override.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss @@ -5,12 +5,9 @@ opacity: 0; } } + path.domain { opacity: 0; } } } - -svg.nvd3-iddle { - &:extend(svg.nvd3-svg); -} diff --git a/services/web/frontend/stylesheets/app/publisher-hub.less b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss similarity index 52% rename from services/web/frontend/stylesheets/app/publisher-hub.less rename to services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss index 8d7e5ea7eb..f59b33e6ef 100644 --- a/services/web/frontend/stylesheets/app/publisher-hub.less +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss @@ -2,48 +2,66 @@ .recent-activity { .hub-big-number { text-align: right; - padding-right: 15px; + padding-right: var(--spacing-06); } } #templates-container { width: 100%; + tr { - border: 1px solid @ol-blue-gray-0; + border: 1px solid var(--bg-light-secondary); } + td { - padding: 15px; + padding: var(--spacing-06); } + td:last-child { text-align: right; } + .title-cell { max-width: 300px; } + .title-text { font-weight: bold; } + .hub-big-number { width: 60%; - padding-right: 10px; - padding-top: 10px; + padding-right: var(--spacing-04); + padding-top: var(--spacing-04); text-align: right; } + .hub-number-label, .since { width: 35%; float: right; - @media screen and (max-width: 940px) { + + @include media-breakpoint-down(md) { float: none; } } + .hub-long-big-number { - padding-right: 40px; + padding-right: var(--spacing-10); } + .created-on { - color: @gray-light; + @include body-sm; + + color: var(--content-disabled); font-style: italic; - font-size: 14px; } } + + .overbox { + margin: 0; + padding: var(--spacing-10) var(--spacing-07); + background: var(--white); + border: 1px solid var(--content-disabled); + } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss index e2c807e928..a4bfa532e3 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss @@ -91,3 +91,8 @@ color: var(--yellow-50); } } + +.admin-page summary { + // firefox does not show markers for block items + display: list-item; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss index 83b6fbd28a..1bf487eeca 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss @@ -85,7 +85,7 @@ } &:hover { - background-color: $bg-light-secondary; + background-color: var(--bg-light-secondary); } .welcome-message-card-img { diff --git a/services/web/frontend/stylesheets/components/daterange-picker.less b/services/web/frontend/stylesheets/components/daterange-picker.less deleted file mode 100644 index 43e0e3ba55..0000000000 --- a/services/web/frontend/stylesheets/components/daterange-picker.less +++ /dev/null @@ -1,656 +0,0 @@ -// -// A stylesheet for use with Bootstrap 3.x -// @author: Dan Grossman http://www.dangrossman.info/ -// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved. -// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php -// @website: https://www.improvely.com/ -// - -// -// VARIABLES -// - -// -// Settings - -// The class name to contain everything within. -@arrow-size: 7px; - -// -// Colors -@daterangepicker-color: @brand-primary; -@daterangepicker-bg-color: #fff; - -@daterangepicker-cell-color: @daterangepicker-color; -@daterangepicker-cell-border-color: transparent; -@daterangepicker-cell-bg-color: @daterangepicker-bg-color; - -@daterangepicker-cell-hover-color: @daterangepicker-color; -@daterangepicker-cell-hover-border-color: @daterangepicker-cell-border-color; -@daterangepicker-cell-hover-bg-color: #eee; - -@daterangepicker-in-range-color: #000; -@daterangepicker-in-range-border-color: transparent; -@daterangepicker-in-range-bg-color: #ebf4f8; - -@daterangepicker-active-color: #fff; -@daterangepicker-active-bg-color: #138a07; -@daterangepicker-active-border-color: transparent; - -@daterangepicker-unselected-color: #999; -@daterangepicker-unselected-border-color: transparent; -@daterangepicker-unselected-bg-color: #fff; - -// -// daterangepicker -@daterangepicker-width: 278px; -@daterangepicker-padding: 4px; -@daterangepicker-z-index: 3000; - -@daterangepicker-border-size: 1px; -@daterangepicker-border-color: #ccc; -@daterangepicker-border-radius: 4px; - -// -// Calendar -@daterangepicker-calendar-margin: @daterangepicker-padding; -@daterangepicker-calendar-bg-color: @daterangepicker-bg-color; - -@daterangepicker-calendar-border-size: 1px; -@daterangepicker-calendar-border-color: @daterangepicker-bg-color; -@daterangepicker-calendar-border-radius: @daterangepicker-border-radius; - -// -// Calendar Cells -@daterangepicker-cell-size: 20px; -@daterangepicker-cell-width: @daterangepicker-cell-size; -@daterangepicker-cell-height: @daterangepicker-cell-size; - -@daterangepicker-cell-border-radius: @daterangepicker-calendar-border-radius; -@daterangepicker-cell-border-size: 1px; - -// -// Dropdowns -@daterangepicker-dropdown-z-index: @daterangepicker-z-index + 1; - -// -// Controls -@daterangepicker-control-height: 30px; -@daterangepicker-control-line-height: @daterangepicker-control-height; -@daterangepicker-control-color: #555; - -@daterangepicker-control-border-size: 1px; -@daterangepicker-control-border-color: #ccc; -@daterangepicker-control-border-radius: 4px; - -@daterangepicker-control-active-border-size: 1px; -@daterangepicker-control-active-border-color: @brand-primary; -@daterangepicker-control-active-border-radius: @daterangepicker-control-border-radius; - -@daterangepicker-control-disabled-color: #ccc; - -// -// Ranges -@daterangepicker-ranges-color: @brand-primary; -@daterangepicker-ranges-bg-color: daterangepicker-ranges-color; - -@daterangepicker-ranges-border-size: 1px; -@daterangepicker-ranges-border-color: @daterangepicker-ranges-bg-color; -@daterangepicker-ranges-border-radius: @daterangepicker-border-radius; - -@daterangepicker-ranges-hover-color: #fff; -@daterangepicker-ranges-hover-bg-color: @daterangepicker-ranges-color; -@daterangepicker-ranges-hover-border-size: @daterangepicker-ranges-border-size; -@daterangepicker-ranges-hover-border-color: @daterangepicker-ranges-hover-bg-color; -@daterangepicker-ranges-hover-border-radius: @daterangepicker-border-radius; - -@daterangepicker-ranges-active-border-size: @daterangepicker-ranges-border-size; -@daterangepicker-ranges-active-border-color: @daterangepicker-ranges-bg-color; -@daterangepicker-ranges-active-border-radius: @daterangepicker-border-radius; - -// -// STYLESHEETS -// -.daterangepicker { - position: absolute; - color: @daterangepicker-color; - background-color: @daterangepicker-bg-color; - border-radius: @daterangepicker-border-radius; - width: @daterangepicker-width; - padding: @daterangepicker-padding; - margin-top: @daterangepicker-border-size; - - // TODO: Should these be parameterized?? - // top: 100px; - // left: 20px; - - @arrow-prefix-size: @arrow-size; - @arrow-suffix-size: (@arrow-size - @daterangepicker-border-size); - - &:before, - &:after { - position: absolute; - display: inline-block; - - border-bottom-color: rgba(0, 0, 0, 0.2); - content: ''; - } - - &:before { - top: -@arrow-prefix-size; - - border-right: @arrow-prefix-size solid transparent; - border-left: @arrow-prefix-size solid transparent; - border-bottom: @arrow-prefix-size solid @daterangepicker-border-color; - } - - &:after { - top: -@arrow-suffix-size; - - border-right: @arrow-suffix-size solid transparent; - border-bottom: @arrow-suffix-size solid @daterangepicker-bg-color; - border-left: @arrow-suffix-size solid transparent; - } - - &.opensleft { - &:before { - // TODO: Make this relative to prefix size. - right: @arrow-prefix-size + 2px; - } - - &:after { - // TODO: Make this relative to suffix size. - right: @arrow-suffix-size + 4px; - } - } - - &.openscenter { - &:before { - left: 0; - right: 0; - width: 0; - margin-left: auto; - margin-right: auto; - } - - &:after { - left: 0; - right: 0; - width: 0; - margin-left: auto; - margin-right: auto; - } - } - - &.opensright { - &:before { - // TODO: Make this relative to prefix size. - left: @arrow-prefix-size + 2px; - } - - &:after { - // TODO: Make this relative to suffix size. - left: @arrow-suffix-size + 4px; - } - } - - &.dropup { - margin-top: -5px; - - // NOTE: Note sure why these are special-cased. - &:before { - top: initial; - bottom: -@arrow-prefix-size; - border-bottom: initial; - border-top: @arrow-prefix-size solid @daterangepicker-border-color; - } - - &:after { - top: initial; - bottom: -@arrow-suffix-size; - border-bottom: initial; - border-top: @arrow-suffix-size solid @daterangepicker-bg-color; - } - } - - &.dropdown-menu { - max-width: none; - z-index: @daterangepicker-dropdown-z-index; - } - - &.single { - .ranges, - .calendar { - float: none; - } - } - - /* Calendars */ - &.show-calendar { - .calendar { - display: block; - } - } - - .calendar { - display: none; - max-width: @daterangepicker-width - (@daterangepicker-calendar-margin * 2); - margin: @daterangepicker-calendar-margin; - - &.single { - .calendar-table { - border: none; - } - } - - th, - td { - white-space: nowrap; - text-align: center; - - // TODO: Should this actually be hard-coded? - min-width: 32px; - } - } - - .calendar-table { - border: @daterangepicker-calendar-border-size solid - @daterangepicker-calendar-border-color; - padding: @daterangepicker-calendar-margin; - border-radius: @daterangepicker-calendar-border-radius; - background-color: @daterangepicker-calendar-bg-color; - } - - table { - width: 100%; - margin: 0; - } - - td, - th { - text-align: center; - width: @daterangepicker-cell-width; - height: @daterangepicker-cell-height; - border-radius: @daterangepicker-cell-border-radius; - border: @daterangepicker-cell-border-size solid - @daterangepicker-cell-border-color; - white-space: nowrap; - cursor: pointer; - - &.available { - &:hover { - background-color: @daterangepicker-cell-hover-bg-color; - border-color: @daterangepicker-cell-hover-border-color; - color: @daterangepicker-cell-hover-color; - } - } - - &.week { - font-size: 80%; - color: #ccc; - } - } - - td { - &.off { - &, - &.in-range, - &.start-date, - &.end-date { - background-color: @daterangepicker-unselected-bg-color; - border-color: @daterangepicker-unselected-border-color; - color: @daterangepicker-unselected-color; - } - } - - // - // Date Range - &.in-range { - background-color: @daterangepicker-in-range-bg-color; - border-color: @daterangepicker-in-range-border-color; - color: @daterangepicker-in-range-color; - - // TODO: Should this be static or should it be parameterized? - border-radius: 0; - } - - &.start-date { - border-radius: @daterangepicker-cell-border-radius 0 0 - @daterangepicker-cell-border-radius; - } - - &.end-date { - border-radius: 0 @daterangepicker-cell-border-radius - @daterangepicker-cell-border-radius 0; - } - - &.start-date.end-date { - border-radius: @daterangepicker-cell-border-radius; - } - - &.active { - &, - &:hover { - background-color: @daterangepicker-active-bg-color; - border-color: @daterangepicker-active-border-color; - color: @daterangepicker-active-color; - } - } - } - - th { - &.month { - width: auto; - } - } - - // - // Disabled Controls - // - td, - option { - &.disabled { - color: #999; - cursor: not-allowed; - text-decoration: line-through; - } - } - - select { - &.monthselect, - &.yearselect { - font-size: 12px; - padding: 1px; - height: auto; - margin: 0; - cursor: default; - } - - &.monthselect { - margin-right: 2%; - width: 56%; - } - - &.yearselect { - width: 40%; - } - - &.hourselect, - &.minuteselect, - &.secondselect, - &.ampmselect { - width: 50px; - margin-bottom: 0; - } - } - - // - // Text Input Controls (above calendar) - // - .input-mini { - border: @daterangepicker-control-border-size solid - @daterangepicker-control-border-color; - border-radius: @daterangepicker-control-border-radius; - color: @daterangepicker-control-color; - height: @daterangepicker-control-line-height; - line-height: @daterangepicker-control-height; - display: block; - vertical-align: middle; - - // TODO: Should these all be static, too?? - margin: 0 0 5px 0; - padding: 0 6px 0 28px; - width: 100%; - - &.active { - border: @daterangepicker-control-active-border-size solid - @daterangepicker-control-active-border-color; - border-radius: @daterangepicker-control-active-border-radius; - } - } - - .daterangepicker_input { - position: relative; - padding-left: 0; - - i { - position: absolute; - - // NOTE: These appear to be eyeballed to me... - left: 8px; - top: 10px; - } - } - &.rtl { - .input-mini { - padding-right: 28px; - padding-left: 6px; - } - .daterangepicker_input i { - left: auto; - right: 8px; - } - } - - // - // Time Picker - // - .calendar-time { - text-align: center; - margin: 5px auto; - line-height: @daterangepicker-control-line-height; - position: relative; - padding-left: 28px; - - select { - &.disabled { - color: @daterangepicker-control-disabled-color; - cursor: not-allowed; - } - } - } -} - -// -// Predefined Ranges -// - -.ranges { - font-size: 11px; - float: none; - margin: 4px; - text-align: left; - - ul { - list-style: none; - margin: 0 auto; - padding: 0; - width: 100%; - } - - li { - font-size: 13px; - background-color: @daterangepicker-ranges-bg-color; - border: @daterangepicker-ranges-border-size solid - @daterangepicker-ranges-border-color; - border-radius: @daterangepicker-ranges-border-radius; - color: @daterangepicker-ranges-color; - padding: 3px 12px; - margin-bottom: 8px; - cursor: pointer; - - &:hover { - background-color: @daterangepicker-ranges-hover-bg-color; - color: @daterangepicker-ranges-hover-color; - } - - &.active { - background-color: @daterangepicker-ranges-hover-bg-color; - border: @daterangepicker-ranges-hover-border-size solid - @daterangepicker-ranges-hover-border-color; - color: @daterangepicker-ranges-hover-color; - } - } -} - -/* Larger Screen Styling */ -@media (min-width: 564px) { - .daterangepicker { - .glyphicon { - font-family: FontAwesome; - } - .glyphicon-chevron-left:before { - content: '\f053'; - } - .glyphicon-chevron-right:before { - content: '\f054'; - } - .glyphicon-calendar:before { - content: '\f073'; - } - - width: auto; - - .ranges { - ul { - width: 160px; - } - } - - &.single { - .ranges { - ul { - width: 100%; - } - } - - .calendar.left { - clear: none; - } - - &.ltr { - .ranges, - .calendar { - float: left; - } - } - &.rtl { - .ranges, - .calendar { - float: right; - } - } - } - - &.ltr { - direction: ltr; - text-align: left; - .calendar { - &.left { - clear: left; - margin-right: 0; - - .calendar-table { - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - } - - &.right { - margin-left: 0; - - .calendar-table { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - } - } - - .left .daterangepicker_input { - padding-right: 12px; - } - - .calendar.left .calendar-table { - padding-right: 12px; - } - - .ranges, - .calendar { - float: left; - } - } - &.rtl { - direction: rtl; - text-align: right; - .calendar { - &.left { - clear: right; - margin-left: 0; - - .calendar-table { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - } - - &.right { - margin-right: 0; - - .calendar-table { - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - } - } - - .left .daterangepicker_input { - padding-left: 12px; - } - - .calendar.left .calendar-table { - padding-left: 12px; - } - - .ranges, - .calendar { - text-align: right; - float: right; - } - } - } -} - -@media (min-width: 730px) { - /* force the calendar to display on one row */ - &.show-calendar { - min-width: 658px; /* width of all contained elements, IE/Edge fallback */ - width: -moz-max-content; - width: -webkit-max-content; - width: max-content; - } - - .daterangepicker { - .ranges { - width: auto; - } - &.ltr { - .ranges { - float: left; - clear: none !important; - } - } - &.rtl { - .ranges { - float: right; - } - } - - .calendar { - clear: none !important; - } - } -} diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index d42a2ab502..fd8c308117 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -61,8 +61,6 @@ @import 'components/hover.less'; @import 'components/ui-select.less'; @import 'components/input-suggestions.less'; -@import 'components/nvd3.less'; -@import 'components/nvd3_override.less'; @import 'components/infinite-scroll.less'; @import 'components/expand-collapse.less'; @import 'components/beta-badges.less'; @@ -82,7 +80,6 @@ @import 'components/modals.less'; @import 'components/tooltip.less'; @import 'components/popovers.less'; -@import 'components/daterange-picker'; @import 'components/lists.less'; @import 'components/overbox.less'; @import 'components/embed-responsive.less'; @@ -118,7 +115,6 @@ @import 'app/invite.less'; @import 'app/error-pages.less'; @import 'app/editor/history-v2.less'; -@import 'app/metrics.less'; @import 'app/open-in-overleaf.less'; @import 'app/primary-email-check'; @import 'app/grammarly'; @@ -126,9 +122,6 @@ @import 'app/ol-chat.less'; @import 'app/templates-v2.less'; @import 'app/login-register.less'; -@import 'app/institution-hub.less'; -@import 'app/publisher-hub.less'; -@import 'app/admin-hub.less'; @import 'app/import.less'; @import 'app/website-redesign.less'; @import 'app/add-secondary-email-prompt.less'; From 25d397281024578f3c230a3e7593040193859f21 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:32:40 +0200 Subject: [PATCH 079/259] [web] Migrate post-gateway.pug to BS5 (#25860) * Remove `data-ol-auto-submit`, to test the page * Migrate post-gateway.pug to BS5 * Revert "Remove `data-ol-auto-submit`, to test the page" This reverts commit ee728b0bdda80d739bd09b2e4e9419303f7053db. * Fix breakbpoints * Use `layout-marketing` GitOrigin-RevId: 73aa4da1e4ddae03d9c8e6671c6a8ccb89ecf0b0 --- services/web/app/views/general/post-gateway.pug | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/app/views/general/post-gateway.pug b/services/web/app/views/general/post-gateway.pug index dcc844171a..b17e61cb41 100644 --- a/services/web/app/views/general/post-gateway.pug +++ b/services/web/app/views/general/post-gateway.pug @@ -5,15 +5,15 @@ block vars - var suppressFooter = true - var suppressSkipToContent = true - var suppressCookieBanner = true - - bootstrap5PageStatus = 'disabled' block content .content.content-alt .container .row - .col-md-6.col-md-offset-3 + .col-lg-6.offset-lg-3 .card - p.text-center #{translate('processing_your_request')} + .card-body + p.text-center #{translate('processing_your_request')} form( data-ol-regular-form From a210a7b14d720c6a73a8b741b765952d00dd2bdd Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:32:53 +0200 Subject: [PATCH 080/259] [web] Migrate general Pug pages to BS5 (#25937) * Revert me! Temporarily update code to test updates * Update layout-no-js.pug to use BS5 * Migrate pages to BS5 * Revert "Revert me! Temporarily update code to test updates" This reverts commit 03d980939dcbdc3f73ddf1e673acbc3fbfdfe2ec. * Use `.error-container` class instead of BS5 utility * Fix breakbpoints * Use `.error-container` instead of utility class GitOrigin-RevId: fd39c4f7278f175bbdeee24826f7a2226b1d7c70 --- services/web/app/views/general/400.pug | 43 +++++------ services/web/app/views/general/404.pug | 15 ++-- services/web/app/views/general/500.pug | 34 ++++----- services/web/app/views/general/closed.pug | 8 +- .../app/views/general/unsupported-browser.pug | 74 +++++++++---------- .../web/app/views/layout/layout-no-js.pug | 2 +- .../stylesheets/bootstrap-5/pages/all.scss | 1 + .../bootstrap-5/pages/error-pages.scss | 7 ++ 8 files changed, 92 insertions(+), 92 deletions(-) create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss diff --git a/services/web/app/views/general/400.pug b/services/web/app/views/general/400.pug index 9fc97823c5..26aeeb778a 100644 --- a/services/web/app/views/general/400.pug +++ b/services/web/app/views/general/400.pug @@ -2,30 +2,27 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } - - bootstrap5PageStatus = 'disabled' block body - body.full-height - main.content.content-alt.full-height#main-content - .container.full-height - .error-container.full-height - .error-details - p.error-status Something went wrong, sorry. - p.error-description - | There was a problem with your request. - if(message) - | - | The error is: + body + main.content.content-alt#main-content + .container + .error-container + h1.mb-4 Something went wrong, sorry. + p.fs-5 + | There was a problem with your request. if(message) - p.error-box - | #{message} - p.error-description - | Please go back and try again. - | If the problem persists, please contact us at | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} - | . - p.error-actions - a.error-btn(href="javascript:history.back()") Back - |   - a.btn.btn-secondary(href="/") Home + | The error is: + if(message) + p.bg-light.p-3.border-2.font-monospace + | #{message} + p.fs-5 + | Please go back and try again. + | If the problem persists, please contact us at + | + a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + | . + p.mt-5.d-flex.gap-3 + a.btn.btn-primary(href="javascript:history.back()") Back + a.btn.btn-secondary(href="/") Home diff --git a/services/web/app/views/general/404.pug b/services/web/app/views/general/404.pug index f4b5800cf2..42b0a44932 100644 --- a/services/web/app/views/general/404.pug +++ b/services/web/app/views/general/404.pug @@ -1,14 +1,13 @@ -extends ../layout-marketing +extends ../layout-react -block vars - - bootstrap5PageStatus = 'disabled' +block append meta + meta(name="ol-user" data-type="json" content=user) block content main.content.content-alt#main-content .container .error-container - .error-details - p.error-status Not found - p.error-description #{translate("cant_find_page")} - p.error-actions - a.error-btn(href="/") Home + h1.mb-4 Not found + p.fs-5 #{translate("cant_find_page")} + p.mt-5 + a.btn.btn-primary.d-block.d-md-inline-block(href="/") Home diff --git a/services/web/app/views/general/500.pug b/services/web/app/views/general/500.pug index 90cb1e3606..32dfb27a58 100644 --- a/services/web/app/views/general/500.pug +++ b/services/web/app/views/general/500.pug @@ -2,23 +2,21 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } - - bootstrap5PageStatus = 'disabled' block body - body.full-height - main.content.content-alt.full-height#main-content - .container.full-height - .error-container.full-height - .error-details - p.error-status Something went wrong, sorry. - p.error-description Our staff are probably looking into this, but if it continues, please check our status page at - | - | - a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} - | - | or contact us at - | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} - | . - p.error-actions - a.error-btn(href="/") Home + body + main.content.content-alt#main-content + .container + .error-container + h1.mb-4 Something went wrong, sorry. + p.fs-5 Our staff are probably looking into this, but if it continues, please check our status page at + | + | + a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} + | + | or contact us at + | + a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + | . + p.mt-5 + a.btn.btn-primary.d-block.d-md-inline-block(href="/") Home diff --git a/services/web/app/views/general/closed.pug b/services/web/app/views/general/closed.pug index f4012997bd..0d10a84476 100644 --- a/services/web/app/views/general/closed.pug +++ b/services/web/app/views/general/closed.pug @@ -1,13 +1,13 @@ -extends ../layout-marketing +extends ../layout-react -block vars - - bootstrap5PageStatus = 'disabled' +block append meta + meta(name="ol-user" data-type="json" content=user) block content main.content#main-content .container .row - .col-md-8.col-md-offset-2.text-center + .col-lg-8.offset-lg-2.text-center .page-header h1 Maintenance p diff --git a/services/web/app/views/general/unsupported-browser.pug b/services/web/app/views/general/unsupported-browser.pug index f8806cf8d2..84e1f8f55e 100644 --- a/services/web/app/views/general/unsupported-browser.pug +++ b/services/web/app/views/general/unsupported-browser.pug @@ -2,45 +2,43 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Unsupported browser' } - - bootstrap5PageStatus = 'disabled' block body - body.full-height - main.content.content-alt.full-height#main-content - .container.full-height - .error-container.full-height - .error-details - h1.error-status Unsupported Browser - p.error-description - | Sorry, we don't support your browser anymore. Please see below what browsers we support. - br - | If you think you're seeing this message in error, - | - a(href="mailto:" + settings.adminEmail) please let us know - | . - - if fromURL - p - | URL: - | - a(href=fromURL) #{fromURL} - - hr - + body + main.content.content-alt#main-content + .container + .error-container + h1.mb-4 Unsupported Browser + p.fs-5 + | Sorry, we don't support your browser anymore. Please see below what browsers we support. + br + | If you think you're seeing this message in error, + | + a(href="mailto:" + settings.adminEmail) please let us know + | . + + if fromURL p - | Overleaf officially supports versions of Chrome, Firefox, Safari and Microsoft Edge released in the last 12 months. - br - | Firefox ESR is also supported for 12 months. - p - | Support for beta or developer-preview browser versions cannot be guaranteed. Please + | URL: | - a(href="mailto:" + settings.adminEmail) get in touch - | - | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. - p - strong Overleaf has stopped supporting Internet Explorer as of April 26, 2021, and access is now blocked. - p - | If you cannot upgrade to one of the supported browsers, - | - a(href="mailto:" + settings.adminEmail) please let us know - | . + a(href=fromURL) #{fromURL} + + hr + + p + | Overleaf officially supports versions of Chrome, Firefox, Safari and Microsoft Edge released in the last 12 months. + br + | Firefox ESR is also supported for 12 months. + p + | Support for beta or developer-preview browser versions cannot be guaranteed. Please + | + a(href="mailto:" + settings.adminEmail) get in touch + | + | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. + p + strong Overleaf has stopped supporting Internet Explorer as of April 26, 2021, and access is now blocked. + p + | If you cannot upgrade to one of the supported browsers, + | + a(href="mailto:" + settings.adminEmail) please let us know + | . diff --git a/services/web/app/views/layout/layout-no-js.pug b/services/web/app/views/layout/layout-no-js.pug index c86721a810..b5bf3cc434 100644 --- a/services/web/app/views/layout/layout-no-js.pug +++ b/services/web/app/views/layout/layout-no-js.pug @@ -13,6 +13,6 @@ html(lang="en") link(rel="icon", href="/favicon.ico") if buildCssPath - link(rel="stylesheet", href=buildCssPath()) + link(rel="stylesheet", href=buildCssPath('', 5)) block body diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index a3adc98819..f10f00842d 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -37,6 +37,7 @@ @import 'editor/math-preview'; @import 'editor/references-search'; @import 'editor/editor-survey'; +@import 'error-pages'; @import 'website-redesign'; @import 'group-settings'; @import 'templates-v2'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss new file mode 100644 index 0000000000..e68f675aa0 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss @@ -0,0 +1,7 @@ +.error-container { + padding: var(--spacing-08); + + @include media-breakpoint-up(lg) { + padding: var(--spacing-08) var(--spacing-11); + } +} From 2226594ade459828e727c143606246a0e0107c48 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:33:09 +0200 Subject: [PATCH 081/259] [web] Migrate 4 simple user pages to BS5 (#25947) * Migrate email-preferences.pug to BS5 https://www.dev-overleaf.com/user/email-preferences * Migrate sessions.pug to BS5 https://www.dev-overleaf.com/user/sessions * Migrate one_time_login.pug to BS5 https://www.dev-overleaf.com/read-only/one-time-login * Fix positions in back-to-btns mixin * Migrate accountSuspended.pug to BS5 https://www.dev-overleaf.com/account-suspended * Set max-width of 400px in account-suspended page * Fix column widths in sessions.pug GitOrigin-RevId: 8ec6100fb230cf532049fcc9aba7c00def20ea0e --- .../web/app/views/_mixins/back_to_btns.pug | 6 +- .../web/app/views/user/accountSuspended.pug | 8 +- .../web/app/views/user/email-preferences.pug | 80 ++++++------ .../web/app/views/user/one_time_login.pug | 24 ++-- services/web/app/views/user/sessions.pug | 116 +++++++++--------- .../stylesheets/bootstrap-5/base/layout.scss | 4 + 6 files changed, 118 insertions(+), 120 deletions(-) diff --git a/services/web/app/views/_mixins/back_to_btns.pug b/services/web/app/views/_mixins/back_to_btns.pug index 570237b5bc..287a76acd7 100644 --- a/services/web/app/views/_mixins/back_to_btns.pug +++ b/services/web/app/views/_mixins/back_to_btns.pug @@ -1,4 +1,4 @@ mixin back-to-btns(settingsAnchor) - a.btn.btn-secondary(href=`/user/settings${settingsAnchor ? '#' + settingsAnchor : '' }`) #{translate('back_to_account_settings')} - | - a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} + .d-flex.gap-3 + a.btn.btn-secondary(href=`/user/settings${settingsAnchor ? '#' + settingsAnchor : '' }`) #{translate('back_to_account_settings')} + a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} diff --git a/services/web/app/views/user/accountSuspended.pug b/services/web/app/views/user/accountSuspended.pug index da57f4d9ff..7231713416 100644 --- a/services/web/app/views/user/accountSuspended.pug +++ b/services/web/app/views/user/accountSuspended.pug @@ -4,12 +4,12 @@ block vars - var suppressNavbar = true - var suppressFooter = true - metadata.robotsNoindexNofollow = true - - bootstrap5PageStatus = 'disabled' block content main.content.content-alt#main-content .container-custom-sm.mx-auto .card - h3 #{translate('your_account_is_suspended')} - p #{translate('sorry_this_account_has_been_suspended')} - p !{translate('please_contact_us_if_you_think_this_is_in_error', {}, [{name: 'a', attrs: {href: `mailto:${settings.adminEmail}`}}])} + .card-body + h3 #{translate('your_account_is_suspended')} + p #{translate('sorry_this_account_has_been_suspended')} + p !{translate('please_contact_us_if_you_think_this_is_in_error', {}, [{name: 'a', attrs: {href: `mailto:${settings.adminEmail}`}}])} diff --git a/services/web/app/views/user/email-preferences.pug b/services/web/app/views/user/email-preferences.pug index 465ffede37..86ebc5f841 100644 --- a/services/web/app/views/user/email-preferences.pug +++ b/services/web/app/views/user/email-preferences.pug @@ -1,49 +1,47 @@ extends ../layout-marketing include ../_mixins/back_to_btns -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container .row - .col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2 + .col-lg-10.offset-lg-1.col-xl-8.offset-xl-2 .card - .page-header - h1 #{translate("newsletter_info_title")} - - p #{translate("newsletter_info_summary")} - - - var submitAction - if subscribed - - submitAction = '/user/newsletter/unsubscribe' - p !{translate("newsletter_info_subscribed", {}, ['strong'])} - else - - submitAction = '/user/newsletter/subscribe' - p !{translate("newsletter_info_unsubscribed", {}, ['strong'])} - - form( - data-ol-async-form - data-ol-reload-on-success - name="newsletterForm" - action=submitAction - method="POST" - ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() - p.actions.text-center - if subscribed - button.btn-danger.btn(type='submit', data-ol-disabled-inflight) - span(data-ol-inflight="idle") #{translate("unsubscribe")} - span(hidden data-ol-inflight="pending") #{translate("saving")}… - else - button.btn-primary.btn(type='submit', data-ol-disabled-inflight) - span(data-ol-inflight="idle") #{translate("subscribe")} - span(hidden data-ol-inflight="pending") #{translate("saving")}… - - if subscribed - p #{translate("newsletter_info_note")} - - .page-separator - +back-to-btns() + .card-body + .page-header + h1 #{translate("newsletter_info_title")} + + p #{translate("newsletter_info_summary")} + + - var submitAction + if subscribed + - submitAction = '/user/newsletter/unsubscribe' + p !{translate("newsletter_info_subscribed", {}, ['strong'])} + else + - submitAction = '/user/newsletter/subscribe' + p !{translate("newsletter_info_unsubscribed", {}, ['strong'])} + + form( + data-ol-async-form + data-ol-reload-on-success + name="newsletterForm" + action=submitAction + method="POST" + ) + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + p.actions.text-center + if subscribed + button.btn-danger.btn(type='submit', data-ol-disabled-inflight) + span(data-ol-inflight="idle") #{translate("unsubscribe")} + span(hidden data-ol-inflight="pending") #{translate("saving")}… + else + button.btn-primary.btn(type='submit', data-ol-disabled-inflight) + span(data-ol-inflight="idle") #{translate("subscribe")} + span(hidden data-ol-inflight="pending") #{translate("saving")}… + + if subscribed + p #{translate("newsletter_info_note")} + + .page-separator + +back-to-btns() diff --git a/services/web/app/views/user/one_time_login.pug b/services/web/app/views/user/one_time_login.pug index 89e1491913..648f6d93c1 100644 --- a/services/web/app/views/user/one_time_login.pug +++ b/services/web/app/views/user/one_time_login.pug @@ -1,20 +1,18 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container .row - .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4 + .col-lg-6.offset-lg-3.col-xl-4.offset-xl-4 .card - .page-header - h1 We're back! - p Overleaf is now running normally. - p - | Please - | - a(href="/login") log in - | - | to continue working on your projects. + .card-body + .page-header + h1 We're back! + p Overleaf is now running normally. + p + | Please + | + a(href="/login") log in + | + | to continue working on your projects. diff --git a/services/web/app/views/user/sessions.pug b/services/web/app/views/user/sessions.pug index 187c1dae75..ffd65a3548 100644 --- a/services/web/app/views/user/sessions.pug +++ b/services/web/app/views/user/sessions.pug @@ -1,72 +1,70 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container .row - .col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2 + .col-lg-10.offset-lg-1.col-xl-8.offset-xl-2 .card.clear-user-sessions - .page-header - h1 #{translate("your_sessions")} - - if currentSession.ip_address && currentSession.session_created - h3 #{translate("current_session")} - div - table.table.table-striped - thead - tr - th #{translate("ip_address")} - th #{translate("session_created_at")} - tr - td #{currentSession.ip_address} - td #{moment(currentSession.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC - - h3 #{translate("other_sessions")} - div - p.small - | !{translate("clear_sessions_description")} - - form( - data-ol-async-form - action='/user/sessions/clear' - method='POST' - ) - input(name='_csrf' type='hidden' value=csrfToken) - div(data-ol-not-sent) - if sessions.length == 0 - p.text-center - | #{translate("no_other_sessions")} - - if sessions.length > 0 + .card-body + .page-header + h1 #{translate("your_sessions")} + + if currentSession.ip_address && currentSession.session_created + h3 #{translate("current_session")} + div table.table.table-striped thead tr th #{translate("ip_address")} th #{translate("session_created_at")} - for session in sessions tr - td #{session.ip_address} - td #{moment(session.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC - - p.actions - .text-center - button.btn.btn-lg.btn-primary( - type="submit" - data-ol-disable-inflight - ) - span(data-ol-inflight="idle") #{translate('clear_sessions')} - span(hidden data-ol-inflight="pending") #{translate("processing")}… - - div(hidden data-ol-sent) - p.text-center - | #{translate("no_other_sessions")} - - p.text-success.text-center - | #{translate('clear_sessions_success')} - .page-separator - a.btn.btn-secondary(href='/user/settings') #{translate('back_to_account_settings')} - | - a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} + td #{currentSession.ip_address} + td #{moment(currentSession.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC + + h3 #{translate("other_sessions")} + div + p.small + | !{translate("clear_sessions_description")} + + form( + data-ol-async-form + action='/user/sessions/clear' + method='POST' + ) + input(name='_csrf' type='hidden' value=csrfToken) + div(data-ol-not-sent) + if sessions.length == 0 + p.text-center + | #{translate("no_other_sessions")} + + if sessions.length > 0 + table.table.table-striped + thead + tr + th #{translate("ip_address")} + th #{translate("session_created_at")} + for session in sessions + tr + td #{session.ip_address} + td #{moment(session.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC + + p.actions + .text-center + button.btn.btn-lg.btn-primary( + type="submit" + data-ol-disable-inflight + ) + span(data-ol-inflight="idle") #{translate('clear_sessions')} + span(hidden data-ol-inflight="pending") #{translate("processing")}… + + div(hidden data-ol-sent) + p.text-center + | #{translate("no_other_sessions")} + + p.text-success.text-center + | #{translate('clear_sessions_success')} + .page-separator + .d-flex.gap-3 + a.btn.btn-secondary(href='/user/settings') #{translate('back_to_account_settings')} + a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss index 650bdc727f..0733a04304 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss @@ -87,3 +87,7 @@ hr { text-decoration: none; } } + +.container-custom-sm { + max-width: 400px; +} From 385f5706d869d86b5d765d28a139e344384f7d85 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Tue, 3 Jun 2025 12:38:04 +0100 Subject: [PATCH 082/259] Add doc and file counts to the admin info page for a project (#26076) GitOrigin-RevId: afa7fa4e562962a4c7c88f6d3d5f13c0f1feb2e3 --- services/web/frontend/js/utils/meta.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 6e15309187..2e8df94273 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -190,6 +190,7 @@ export interface Meta { 'ol-preventCompileOnLoad'?: boolean 'ol-primaryEmail': { email: string; confirmed: boolean } 'ol-project': any // TODO + 'ol-projectEntityCounts'?: { files: number; docs: number } 'ol-projectHistoryBlobsEnabled': boolean 'ol-projectName': string 'ol-projectOwnerHasPremiumOnPageLoad': boolean From d5ba2e3f1c96452085fe4467bf4c18d48801b45f Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 3 Jun 2025 12:39:43 +0100 Subject: [PATCH 083/259] Merge pull request #26094 from overleaf/mj-ide-fps-update [web] Add full project search to redesign switcher modal GitOrigin-RevId: 3f494ddc3bf94d9f7c2d6de62183b1805b110601 --- services/web/frontend/extracted-translations.json | 1 + .../js/features/ide-redesign/components/switcher-modal/modal.tsx | 1 + services/web/locales/en.json | 1 + 3 files changed, 3 insertions(+) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 506a5bb5f8..e5bb2fced3 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1483,6 +1483,7 @@ "search_whole_word": "", "search_within_selection": "", "searched_path_for_lines_containing": "", + "searching_all_project_files_is_now_available": "", "security": "", "see_suggestions_from_collaborators": "", "select_a_column_or_a_merged_cell_to_align": "", diff --git a/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx b/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx index 6942674de5..2bb724c8a4 100644 --- a/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx @@ -160,6 +160,7 @@ const SwitcherWhatsNew = () => {

{t('latest_updates')}

    +
  • {t('searching_all_project_files_is_now_available')}
  • {t('double_clicking_on_the_pdf_shows')}

diff --git a/services/web/locales/en.json b/services/web/locales/en.json index bdebf3d289..a903e6a26b 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1946,6 +1946,7 @@ "search_whole_word": "Whole word", "search_within_selection": "Within selection", "searched_path_for_lines_containing": "Searched __path__ for lines containing \"__query__\"", + "searching_all_project_files_is_now_available": "Searching all project files is now available (2 June 2025)", "secondary_email_password_reset": "That email is registered as a secondary email. Please enter the primary email for your account.", "security": "Security", "see_suggestions_from_collaborators": "See suggestions from collaborators", From b84d23564bef3709f528173091530f2264148a00 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 3 Jun 2025 13:41:12 +0200 Subject: [PATCH 084/259] [web] remove spurious cleanup of project audit log entries (#26102) GitOrigin-RevId: 32693f89b417b357588d059500ab51c3a9dd46dd --- services/web/app/src/Features/Project/ProjectDeleter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js index e5764bab86..2bb8dd0b1f 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.js +++ b/services/web/app/src/Features/Project/ProjectDeleter.js @@ -343,7 +343,6 @@ async function expireDeletedProject(projectId) { await DeletedProject.deleteOne({ 'deleterData.deletedProjectId': projectId, }) - await ProjectAuditLogEntry.deleteMany({ projectId }) return } const deletedProject = await DeletedProject.findOne({ From edacb9ec0b2a79e04676488d6a5d7ff5e72b44cd Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 3 Jun 2025 13:57:29 +0200 Subject: [PATCH 085/259] Merge pull request #26111 from overleaf/revert-25937-ac-bs5-general-pug-pages Revert "[web] Migrate general Pug pages to BS5" GitOrigin-RevId: fcc42ee28004aa55c09ecbd5f5e96c6067e717e9 --- services/web/app/views/general/400.pug | 43 ++++++----- services/web/app/views/general/404.pug | 15 ++-- services/web/app/views/general/500.pug | 34 +++++---- services/web/app/views/general/closed.pug | 8 +- .../app/views/general/unsupported-browser.pug | 76 ++++++++++--------- .../web/app/views/layout/layout-no-js.pug | 2 +- .../stylesheets/bootstrap-5/pages/all.scss | 1 - .../bootstrap-5/pages/error-pages.scss | 7 -- 8 files changed, 93 insertions(+), 93 deletions(-) delete mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss diff --git a/services/web/app/views/general/400.pug b/services/web/app/views/general/400.pug index 26aeeb778a..9fc97823c5 100644 --- a/services/web/app/views/general/400.pug +++ b/services/web/app/views/general/400.pug @@ -2,27 +2,30 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } + - bootstrap5PageStatus = 'disabled' block body - body - main.content.content-alt#main-content - .container - .error-container - h1.mb-4 Something went wrong, sorry. - p.fs-5 - | There was a problem with your request. + body.full-height + main.content.content-alt.full-height#main-content + .container.full-height + .error-container.full-height + .error-details + p.error-status Something went wrong, sorry. + p.error-description + | There was a problem with your request. + if(message) + | + | The error is: if(message) + p.error-box + | #{message} + p.error-description + | Please go back and try again. + | If the problem persists, please contact us at | - | The error is: - if(message) - p.bg-light.p-3.border-2.font-monospace - | #{message} - p.fs-5 - | Please go back and try again. - | If the problem persists, please contact us at - | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} - | . - p.mt-5.d-flex.gap-3 - a.btn.btn-primary(href="javascript:history.back()") Back - a.btn.btn-secondary(href="/") Home + a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + | . + p.error-actions + a.error-btn(href="javascript:history.back()") Back + |   + a.btn.btn-secondary(href="/") Home diff --git a/services/web/app/views/general/404.pug b/services/web/app/views/general/404.pug index 42b0a44932..f4b5800cf2 100644 --- a/services/web/app/views/general/404.pug +++ b/services/web/app/views/general/404.pug @@ -1,13 +1,14 @@ -extends ../layout-react +extends ../layout-marketing -block append meta - meta(name="ol-user" data-type="json" content=user) +block vars + - bootstrap5PageStatus = 'disabled' block content main.content.content-alt#main-content .container .error-container - h1.mb-4 Not found - p.fs-5 #{translate("cant_find_page")} - p.mt-5 - a.btn.btn-primary.d-block.d-md-inline-block(href="/") Home + .error-details + p.error-status Not found + p.error-description #{translate("cant_find_page")} + p.error-actions + a.error-btn(href="/") Home diff --git a/services/web/app/views/general/500.pug b/services/web/app/views/general/500.pug index 32dfb27a58..90cb1e3606 100644 --- a/services/web/app/views/general/500.pug +++ b/services/web/app/views/general/500.pug @@ -2,21 +2,23 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } + - bootstrap5PageStatus = 'disabled' block body - body - main.content.content-alt#main-content - .container - .error-container - h1.mb-4 Something went wrong, sorry. - p.fs-5 Our staff are probably looking into this, but if it continues, please check our status page at - | - | - a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} - | - | or contact us at - | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} - | . - p.mt-5 - a.btn.btn-primary.d-block.d-md-inline-block(href="/") Home + body.full-height + main.content.content-alt.full-height#main-content + .container.full-height + .error-container.full-height + .error-details + p.error-status Something went wrong, sorry. + p.error-description Our staff are probably looking into this, but if it continues, please check our status page at + | + | + a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} + | + | or contact us at + | + a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + | . + p.error-actions + a.error-btn(href="/") Home diff --git a/services/web/app/views/general/closed.pug b/services/web/app/views/general/closed.pug index 0d10a84476..f4012997bd 100644 --- a/services/web/app/views/general/closed.pug +++ b/services/web/app/views/general/closed.pug @@ -1,13 +1,13 @@ -extends ../layout-react +extends ../layout-marketing -block append meta - meta(name="ol-user" data-type="json" content=user) +block vars + - bootstrap5PageStatus = 'disabled' block content main.content#main-content .container .row - .col-lg-8.offset-lg-2.text-center + .col-md-8.col-md-offset-2.text-center .page-header h1 Maintenance p diff --git a/services/web/app/views/general/unsupported-browser.pug b/services/web/app/views/general/unsupported-browser.pug index 84e1f8f55e..f8806cf8d2 100644 --- a/services/web/app/views/general/unsupported-browser.pug +++ b/services/web/app/views/general/unsupported-browser.pug @@ -2,43 +2,45 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Unsupported browser' } + - bootstrap5PageStatus = 'disabled' block body - body - main.content.content-alt#main-content - .container - .error-container - h1.mb-4 Unsupported Browser - p.fs-5 - | Sorry, we don't support your browser anymore. Please see below what browsers we support. - br - | If you think you're seeing this message in error, - | - a(href="mailto:" + settings.adminEmail) please let us know - | . - - if fromURL - p - | URL: + body.full-height + main.content.content-alt.full-height#main-content + .container.full-height + .error-container.full-height + .error-details + h1.error-status Unsupported Browser + p.error-description + | Sorry, we don't support your browser anymore. Please see below what browsers we support. + br + | If you think you're seeing this message in error, | - a(href=fromURL) #{fromURL} - - hr - - p - | Overleaf officially supports versions of Chrome, Firefox, Safari and Microsoft Edge released in the last 12 months. - br - | Firefox ESR is also supported for 12 months. - p - | Support for beta or developer-preview browser versions cannot be guaranteed. Please - | - a(href="mailto:" + settings.adminEmail) get in touch - | - | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. - p - strong Overleaf has stopped supporting Internet Explorer as of April 26, 2021, and access is now blocked. - p - | If you cannot upgrade to one of the supported browsers, - | - a(href="mailto:" + settings.adminEmail) please let us know - | . + a(href="mailto:" + settings.adminEmail) please let us know + | . + + if fromURL + p + | URL: + | + a(href=fromURL) #{fromURL} + + hr + + p + | Overleaf officially supports versions of Chrome, Firefox, Safari and Microsoft Edge released in the last 12 months. + br + | Firefox ESR is also supported for 12 months. + p + | Support for beta or developer-preview browser versions cannot be guaranteed. Please + | + a(href="mailto:" + settings.adminEmail) get in touch + | + | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. + p + strong Overleaf has stopped supporting Internet Explorer as of April 26, 2021, and access is now blocked. + p + | If you cannot upgrade to one of the supported browsers, + | + a(href="mailto:" + settings.adminEmail) please let us know + | . diff --git a/services/web/app/views/layout/layout-no-js.pug b/services/web/app/views/layout/layout-no-js.pug index b5bf3cc434..c86721a810 100644 --- a/services/web/app/views/layout/layout-no-js.pug +++ b/services/web/app/views/layout/layout-no-js.pug @@ -13,6 +13,6 @@ html(lang="en") link(rel="icon", href="/favicon.ico") if buildCssPath - link(rel="stylesheet", href=buildCssPath('', 5)) + link(rel="stylesheet", href=buildCssPath()) block body diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index f10f00842d..a3adc98819 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -37,7 +37,6 @@ @import 'editor/math-preview'; @import 'editor/references-search'; @import 'editor/editor-survey'; -@import 'error-pages'; @import 'website-redesign'; @import 'group-settings'; @import 'templates-v2'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss deleted file mode 100644 index e68f675aa0..0000000000 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss +++ /dev/null @@ -1,7 +0,0 @@ -.error-container { - padding: var(--spacing-08); - - @include media-breakpoint-up(lg) { - padding: var(--spacing-08) var(--spacing-11); - } -} From 54c0eb7fdc09f4ab5465c89868b6fe58eafa2bf8 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 3 Jun 2025 14:34:10 +0100 Subject: [PATCH 086/259] Merge pull request #25958 from overleaf/bg-history-redis-check-persisted-version-on-update prevent setPersistedVersion from setting an out of bounds version GitOrigin-RevId: 9561b7b96399bed901db5c2ac20a0cdbf4c67395 --- .../storage/lib/chunk_store/redis.js | 12 +++++++++ .../storage/chunk_store_redis_backend.test.js | 27 ++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/services/history-v1/storage/lib/chunk_store/redis.js b/services/history-v1/storage/lib/chunk_store/redis.js index 0ae7cee2e5..9163536342 100644 --- a/services/history-v1/storage/lib/chunk_store/redis.js +++ b/services/history-v1/storage/lib/chunk_store/redis.js @@ -501,6 +501,11 @@ rclient.defineCommand('set_persisted_version', { return 'too_low' end + -- Refuse to set a persisted version that is higher than the head version + if newPersistedVersion > headVersion then + return 'too_high' + end + -- Set the persisted version redis.call('SET', persistedVersionKey, newPersistedVersion) @@ -541,6 +546,13 @@ async function setPersistedVersion(projectId, persistedVersion) { status, }) + if (status === 'too_high') { + throw new VersionOutOfBoundsError( + 'Persisted version cannot be higher than head version', + { projectId, persistedVersion } + ) + } + return status } catch (err) { metrics.inc('chunk_store.redis.set_persisted_version', 1, { diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js index 2b13343fc4..04d801c73d 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js @@ -714,10 +714,20 @@ describe('chunk buffer Redis backend', function () { }) it('should set the persisted version', async function () { - await redisBackend.setPersistedVersion(projectId, 3) + const status = await redisBackend.setPersistedVersion(projectId, 3) + expect(status).to.equal('ok') const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.equal(3) }) + + it('should refuse to set a persisted version greater than the head version', async function () { + await expect( + redisBackend.setPersistedVersion(projectId, 10) + ).to.be.rejectedWith(VersionOutOfBoundsError) + // Ensure persisted version remains unchanged + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.be.null + }) }) describe('when the persisted version is set', function () { @@ -730,13 +740,24 @@ describe('chunk buffer Redis backend', function () { }) it('should set the persisted version', async function () { - await redisBackend.setPersistedVersion(projectId, 5) + const status = await redisBackend.setPersistedVersion(projectId, 5) + expect(status).to.equal('ok') const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.equal(5) }) it('should not decrease the persisted version', async function () { - await redisBackend.setPersistedVersion(projectId, 2) + const status = await redisBackend.setPersistedVersion(projectId, 2) + expect(status).to.equal('too_low') + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(3) + }) + + it('should refuse to set a persisted version greater than the head version', async function () { + await expect( + redisBackend.setPersistedVersion(projectId, 10) + ).to.be.rejectedWith(VersionOutOfBoundsError) + // Ensure persisted version remains unchanged const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.equal(3) }) From ef810a9f3675cd2f73b902b91a49efe16e8560d5 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 3 Jun 2025 06:40:36 -0700 Subject: [PATCH 087/259] Merge pull request #25967 from overleaf/mf-sync-email-update-to-stripe-account [web] Sync Stripe customer email when user update their primary email in account setting GitOrigin-RevId: a5f4b4e960d2c9d4ba96a2b3036329f4868e1bb8 --- .../Features/Subscription/RecurlyWrapper.js | 17 ++++++---- .../Subscription/SubscriptionController.js | 23 +++++++------ .../web/app/src/Features/User/UserUpdater.js | 7 ++-- .../SubscriptionControllerTests.js | 32 ++++++++++++------- .../test/unit/src/User/UserUpdaterTests.js | 21 ++++++------ 5 files changed, 58 insertions(+), 42 deletions(-) diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 2227597737..234f094ae0 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -11,22 +11,27 @@ const SubscriptionErrors = require('./Errors') const { callbackify } = require('@overleaf/promise-utils') /** - * @param accountId - * @param newEmail + * Updates the email address of a Recurly account + * + * @param userId + * @param newAccountEmail - the new email address to set for the Recurly account */ -async function updateAccountEmailAddress(accountId, newEmail) { +async function updateAccountEmailAddress(userId, newAccountEmail) { const data = { - email: newEmail, + email: newAccountEmail, } let requestBody try { requestBody = RecurlyWrapper._buildXml('account', data) } catch (error) { - throw OError.tag(error, 'error building xml', { accountId, newEmail }) + throw OError.tag(error, 'error building xml', { + accountId: userId, + newEmail: newAccountEmail, + }) } const { body } = await RecurlyWrapper.promises.apiRequest({ - url: `accounts/${accountId}`, + url: `accounts/${userId}`, method: 'PUT', body: requestBody, }) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index a38b41f628..bd60fdc099 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -535,18 +535,17 @@ function cancelPendingSubscriptionChange(req, res, next) { }) } -function updateAccountEmailAddress(req, res, next) { +async function updateAccountEmailAddress(req, res, next) { const user = SessionManager.getSessionUser(req.session) - RecurlyWrapper.updateAccountEmailAddress( - user._id, - user.email, - function (error) { - if (error) { - return next(error) - } - res.sendStatus(200) - } - ) + try { + await RecurlyWrapper.promises.updateAccountEmailAddress( + user._id, + user.email + ) + return res.sendStatus(200) + } catch (error) { + return next(error) + } } function reactivateSubscription(req, res, next) { @@ -859,7 +858,7 @@ module.exports = { cancelV1Subscription, previewSubscription: expressify(previewSubscription), cancelPendingSubscriptionChange, - updateAccountEmailAddress, + updateAccountEmailAddress: expressify(updateAccountEmailAddress), reactivateSubscription, recurlyCallback, extendTrial: expressify(extendTrial), diff --git a/services/web/app/src/Features/User/UserUpdater.js b/services/web/app/src/Features/User/UserUpdater.js index 627e73875d..f21ee9a1ed 100644 --- a/services/web/app/src/Features/User/UserUpdater.js +++ b/services/web/app/src/Features/User/UserUpdater.js @@ -11,7 +11,6 @@ const EmailHandler = require('../Email/EmailHandler') const EmailHelper = require('../Helpers/EmailHelper') const Errors = require('../Errors/Errors') const NewsletterManager = require('../Newsletter/NewsletterManager') -const RecurlyWrapper = require('../Subscription/RecurlyWrapper') const UserAuditLogHandler = require('./UserAuditLogHandler') const AnalyticsManager = require('../Analytics/AnalyticsManager') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') @@ -252,7 +251,11 @@ async function setDefaultEmailAddress( } try { - await RecurlyWrapper.promises.updateAccountEmailAddress(user._id, email) + await Modules.promises.hooks.fire( + 'updateAccountEmailAddress', + user._id, + email + ) } catch (error) { // errors are ignored } diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index b3ae6610e1..546f10f17b 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -153,7 +153,9 @@ describe('SubscriptionController', function () { '@overleaf/settings': this.settings, '../User/UserGetter': this.UserGetter, './RecurlyWrapper': (this.RecurlyWrapper = { - updateAccountEmailAddress: sinon.stub().yields(), + promises: { + updateAccountEmailAddress: sinon.stub().resolves(), + }, }), './RecurlyEventHandler': { sendRecurlyAnalyticsEvent: sinon.stub().resolves(), @@ -309,31 +311,39 @@ describe('SubscriptionController', function () { }) describe('updateAccountEmailAddress via put', function () { - it('should send the user and subscriptionId to RecurlyWrapper', function () { + it('should send the user and subscriptionId to RecurlyWrapper', async function () { this.res.sendStatus = sinon.spy() - this.SubscriptionController.updateAccountEmailAddress(this.req, this.res) - this.RecurlyWrapper.updateAccountEmailAddress + await this.SubscriptionController.updateAccountEmailAddress( + this.req, + this.res + ) + this.RecurlyWrapper.promises.updateAccountEmailAddress .calledWith(this.user._id, this.user.email) .should.equal(true) }) - it('should respond with 200', function () { + it('should respond with 200', async function () { this.res.sendStatus = sinon.spy() - this.SubscriptionController.updateAccountEmailAddress(this.req, this.res) + await this.SubscriptionController.updateAccountEmailAddress( + this.req, + this.res + ) this.res.sendStatus.calledWith(200).should.equal(true) }) - it('should send the error to the next handler when updating recurly account email fails', function (done) { - this.RecurlyWrapper.updateAccountEmailAddress.yields(new Error()) + it('should send the error to the next handler when updating recurly account email fails', async function () { + this.RecurlyWrapper.promises.updateAccountEmailAddress.rejects( + new Error() + ) this.next = sinon.spy(error => { - expect(error).instanceOf(Error) - done() + expect(error).to.be.instanceOf(Error) }) - this.SubscriptionController.updateAccountEmailAddress( + await this.SubscriptionController.updateAccountEmailAddress( this.req, this.res, this.next ) + expect(this.next.calledOnce).to.be.true }) }) diff --git a/services/web/test/unit/src/User/UserUpdaterTests.js b/services/web/test/unit/src/User/UserUpdaterTests.js index 5832bc4656..2803e6d6f2 100644 --- a/services/web/test/unit/src/User/UserUpdaterTests.js +++ b/services/web/test/unit/src/User/UserUpdaterTests.js @@ -59,11 +59,6 @@ describe('UserUpdater', function () { changeEmail: sinon.stub().resolves(), }, } - this.RecurlyWrapper = { - promises: { - updateAccountEmailAddress: sinon.stub().resolves(), - }, - } this.AnalyticsManager = { recordEventForUserInBackground: sinon.stub(), } @@ -264,9 +259,11 @@ describe('UserUpdater', function () { expect( this.NewsletterManager.promises.changeEmail ).to.have.been.calledWith(this.user, this.newEmail) - expect( - this.RecurlyWrapper.promises.updateAccountEmailAddress - ).to.have.been.calledWith(this.user._id, this.newEmail) + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updateAccountEmailAddress', + this.user._id, + this.newEmail + ) }) it('validates email', async function () { @@ -615,9 +612,11 @@ describe('UserUpdater', function () { expect( this.NewsletterManager.promises.changeEmail ).to.have.been.calledWith(this.user, this.newEmail) - expect( - this.RecurlyWrapper.promises.updateAccountEmailAddress - ).to.have.been.calledWith(this.user._id, this.newEmail) + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updateAccountEmailAddress', + this.user._id, + this.newEmail + ) }) it('handles Mongo errors', async function () { From 832f9923b96565c2b4c671a137e1a5cc423f3715 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 3 Jun 2025 06:40:51 -0700 Subject: [PATCH 088/259] Merge pull request #25998 from overleaf/mf-update-stripe-email-from-subscription-dashboard [web] Make user able to sync their email address in subscription dashboard for Stripe subscription GitOrigin-RevId: 9abdc0e18ebea29b18c2041130946b9e50fa43db --- .../Subscription/SubscriptionController.js | 3 ++- ...x => personal-subscription-sync-email.tsx} | 18 ++++++------- .../dashboard/personal-subscription.tsx | 4 +-- .../dashboard/personal-subscription.test.tsx | 4 ++- .../SubscriptionControllerTests.js | 25 +++++++++++++------ 5 files changed, 34 insertions(+), 20 deletions(-) rename services/web/frontend/js/features/subscription/components/dashboard/{personal-subscription-recurly-sync-email.tsx => personal-subscription-sync-email.tsx} (87%) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index bd60fdc099..4a69acf56d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -538,7 +538,8 @@ function cancelPendingSubscriptionChange(req, res, next) { async function updateAccountEmailAddress(req, res, next) { const user = SessionManager.getSessionUser(req.session) try { - await RecurlyWrapper.promises.updateAccountEmailAddress( + await Modules.promises.hooks.fire( + 'updateAccountEmailAddress', user._id, user.email ) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-sync-email.tsx similarity index 87% rename from services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx rename to services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-sync-email.tsx index c518b3ca8c..5f38a6be9e 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-sync-email.tsx @@ -7,23 +7,23 @@ import OLNotification from '@/features/ui/components/ol/ol-notification' import OLButton from '@/features/ui/components/ol/ol-button' import OLFormGroup from '@/features/ui/components/ol/ol-form-group' -function PersonalSubscriptionRecurlySyncEmail() { +function PersonalSubscriptionSyncEmail() { const { t } = useTranslation() const { personalSubscription } = useSubscriptionDashboardContext() const userEmail = getMeta('ol-usersEmail') const { isLoading, isSuccess, runAsync } = useAsync() + if (!personalSubscription || !('payment' in personalSubscription)) return null + + const accountEmail = personalSubscription.payment.accountEmail + + if (!userEmail || accountEmail === userEmail) return null + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() runAsync(postJSON('/user/subscription/account/email')) } - if (!personalSubscription || !('payment' in personalSubscription)) return null - - const recurlyEmail = personalSubscription.payment.accountEmail - - if (!userEmail || recurlyEmail === userEmail) return null - return ( <>
@@ -39,7 +39,7 @@ function PersonalSubscriptionRecurlySyncEmail() { , ]} // eslint-disable-line react/jsx-key - values={{ recurlyEmail, userEmail }} + values={{ recurlyEmail: accountEmail, userEmail }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }} /> @@ -64,4 +64,4 @@ function PersonalSubscriptionRecurlySyncEmail() { ) } -export default PersonalSubscriptionRecurlySyncEmail +export default PersonalSubscriptionSyncEmail diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx index 2173ea45d3..ce9bbf97ed 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx @@ -5,7 +5,7 @@ import { ActiveSubscriptionNew } from '@/features/subscription/components/dashbo import { CanceledSubscription } from './states/canceled' import { ExpiredSubscription } from './states/expired' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' -import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email' +import PersonalSubscriptionSyncEmail from './personal-subscription-sync-email' import OLNotification from '@/features/ui/components/ol/ol-notification' import RedirectAlerts from './redirect-alerts' @@ -90,7 +90,7 @@ function PersonalSubscription() { /> )}
- + ) } diff --git a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx index 8edc881caa..a61c9fca7f 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx @@ -190,7 +190,9 @@ describe('', function () { }) it('shows different payment email address section', async function () { - fetchMock.post('/user/subscription/account/email', 200) + fetchMock.post('/user/subscription/account/email', { + status: 200, + }) const usersEmail = 'foo@example.com' renderWithSubscriptionDashContext(, { metaTags: [ diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 546f10f17b..879a31b917 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -311,15 +311,25 @@ describe('SubscriptionController', function () { }) describe('updateAccountEmailAddress via put', function () { - it('should send the user and subscriptionId to RecurlyWrapper', async function () { + beforeEach(function () { + this.req.body = { + account_email: 'current_account_email@overleaf.com', + } + }) + + it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function () { this.res.sendStatus = sinon.spy() + await this.SubscriptionController.updateAccountEmailAddress( this.req, this.res ) - this.RecurlyWrapper.promises.updateAccountEmailAddress - .calledWith(this.user._id, this.user.email) - .should.equal(true) + + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updateAccountEmailAddress', + this.user._id, + this.user.email + ) }) it('should respond with 200', async function () { @@ -332,9 +342,10 @@ describe('SubscriptionController', function () { }) it('should send the error to the next handler when updating recurly account email fails', async function () { - this.RecurlyWrapper.promises.updateAccountEmailAddress.rejects( - new Error() - ) + this.Modules.promises.hooks.fire + .withArgs('updateAccountEmailAddress', this.user._id, this.user.email) + .rejects(new Error()) + this.next = sinon.spy(error => { expect(error).to.be.instanceOf(Error) }) From d173bdf8e20878515422ce4e4ae84586ef59c4b2 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 3 Jun 2025 06:41:33 -0700 Subject: [PATCH 089/259] Merge pull request #25355 from overleaf/mf-whitelist-staging-url-stripe-test [web] Bypass country requirement for Stripe if user is on staging or dev environment to ease the testing process GitOrigin-RevId: 0924a57d3a1b7b530a3822fb8f9056a1dd7119e9 --- .../src/Features/Subscription/SubscriptionController.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 4a69acf56d..4be61d255c 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -704,7 +704,7 @@ async function getRecommendedCurrency(req, res) { ip = req.query.ip } const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(ip) - let countryCode = currencyLookup.countryCode + const countryCode = currencyLookup.countryCode const recommendedCurrency = currencyLookup.currencyCode let currency = null @@ -715,13 +715,6 @@ async function getRecommendedCurrency(req, res) { currency = recommendedCurrency } - const queryCountryCode = req.query.countryCode?.toUpperCase() - - // only enable countryCode testing flag on staging or dev environments - if (queryCountryCode && process.env.NODE_ENV !== 'production') { - countryCode = queryCountryCode - } - return { currency, recommendedCurrency, From f11ea06c1a399f65e24c0a30e1ebdbbe12a50e0a Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:08:27 -0400 Subject: [PATCH 090/259] Merge pull request #25910 from overleaf/em-track-changes-sharejs Track changes in the history OT sharejs doc GitOrigin-RevId: 17365219f24a25790eac611dbde9681eb73d0961 --- .../context/editor-manager-context.tsx | 6 +- .../ide-react/editor/document-container.ts | 8 +- .../features/ide-react/editor/share-js-doc.ts | 32 +- .../editor/share-js-history-ot-type.ts | 130 ++++---- .../source-editor/extensions/history-ot.ts | 290 ++++++++++++++++++ .../source-editor/extensions/index.ts | 5 +- .../source-editor/extensions/realtime.ts | 169 +++++++++- .../hooks/use-codemirror-scope.ts | 4 +- .../web/frontend/js/vendor/libs/sharejs.js | 5 +- .../source-editor/source-editor.stories.tsx | 2 +- .../source-editor/helpers/mock-doc.ts | 14 +- services/web/types/share-doc.ts | 16 +- 12 files changed, 578 insertions(+), 103 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/extensions/history-ot.ts diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index e1bb49c39c..e830d7ec1a 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -18,6 +18,7 @@ import { useConnectionContext } from '@/features/ide-react/context/connection-co import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' import { useLayoutContext } from '@/shared/context/layout-context' +import { useUserContext } from '@/shared/context/user-context' import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { Doc } from '../../../../../types/doc' import { useFileTreeData } from '@/shared/context/file-tree-data-context' @@ -99,6 +100,7 @@ export const EditorManagerProvider: FC = ({ const { view, setView } = useLayoutContext() const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } = useModalsContext() + const { id: userId } = useUserContext() const [showSymbolPalette, setShowSymbolPalette] = useScopeValue( 'editor.showSymbolPalette' @@ -309,7 +311,7 @@ export const EditorManagerProvider: FC = ({ const tryToggle = () => { const saved = doc.getInflightOp() == null && doc.getPendingOp() == null if (saved) { - doc.setTrackingChanges(want) + doc.setTrackChangesUserId(want ? userId : null) setTrackChanges(want) } else { syncTimeoutRef.current = window.setTimeout(tryToggle, 100) @@ -318,7 +320,7 @@ export const EditorManagerProvider: FC = ({ tryToggle() }, - [setTrackChanges] + [setTrackChanges, userId] ) const doOpenNewDocument = useCallback( diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts index fee359f146..2ded041fb1 100644 --- a/services/web/frontend/js/features/ide-react/editor/document-container.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -196,9 +196,13 @@ export class DocumentContainer extends EventEmitter { return this.doc?.hasBufferedOps() } - setTrackingChanges(track_changes: boolean) { + setTrackChangesUserId(userId: string | null) { + this.track_changes_as = userId if (this.doc) { - this.doc.track_changes = track_changes + this.doc.setTrackChangesUserId(userId) + } + if (this.cm6) { + this.cm6.setTrackChangesUserId(userId) } } diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index a773684dcb..e94de4e88b 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -12,18 +12,14 @@ import { Message, ShareJsConnectionState, ShareJsOperation, - ShareJsTextType, TrackChangesIdSeeds, } from '@/features/ide-react/editor/types/document' import { EditorFacade } from '@/features/source-editor/extensions/realtime' import { recordDocumentFirstChangeEvent } from '@/features/event-tracking/document-first-change-event' import getMeta from '@/utils/meta' -import { HistoryOTType } from './share-js-history-ot-type' -import { StringFileData } from 'overleaf-editor-core/index' -import { - RawEditOperation, - StringFileRawData, -} from 'overleaf-editor-core/lib/types' +import { historyOTType } from './share-js-history-ot-type' +import { StringFileData, TrackedChangeList } from 'overleaf-editor-core/index' +import { StringFileRawData } from 'overleaf-editor-core/lib/types' // All times below are in milliseconds const SINGLE_USER_FLUSH_DELAY = 2000 @@ -68,19 +64,17 @@ export class ShareJsDoc extends EventEmitter { readonly type: OTType = 'sharejs-text-ot' ) { super() - let sharejsType: ShareJsTextType = sharejs.types.text + let sharejsType // Decode any binary bits of data let snapshot: string | StringFileData if (this.type === 'history-ot') { snapshot = StringFileData.fromRaw( docLines as unknown as StringFileRawData ) - sharejsType = new HistoryOTType(snapshot) as ShareJsTextType< - StringFileData, - RawEditOperation[] - > + sharejsType = historyOTType } else { snapshot = docLines.map(line => decodeUtf8(line)).join('\n') + sharejsType = sharejs.types.text } this.connection = { @@ -159,6 +153,18 @@ export class ShareJsDoc extends EventEmitter { this.removeCarriageReturnCharFromShareJsDoc() } + setTrackChangesUserId(userId: string | null) { + this.track_changes = userId != null + } + + getTrackedChanges() { + if (this._doc.otType === 'history-ot') { + return this._doc.snapshot.getTrackedChanges() as TrackedChangeList + } else { + return null + } + } + private removeCarriageReturnCharFromShareJsDoc() { const doc = this._doc let nextPos @@ -365,7 +371,7 @@ export class ShareJsDoc extends EventEmitter { attachToCM6(cm6: EditorFacade) { this.attachToEditor(cm6, () => { - cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'), this.type) + cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength')) }) } diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index fde66d89a1..2832ca390e 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -1,4 +1,3 @@ -import EventEmitter from '@/utils/EventEmitter' import { EditOperationBuilder, EditOperationTransformer, @@ -9,75 +8,28 @@ import { TextOperation, } from 'overleaf-editor-core' import { RawEditOperation } from 'overleaf-editor-core/lib/types' +import { ShareDoc } from '../../../../../types/share-doc' -export class HistoryOTType extends EventEmitter { - // stub interface, these are actually on the Doc - api: HistoryOTType - snapshot: StringFileData +type Api = { + otType: 'history-ot' + trackChangesUserId: string | null - constructor(snapshot: StringFileData) { - super() - this.api = this - this.snapshot = snapshot - } + getText(): string + getLength(): number + _register(): void +} - transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { - const [a, b] = EditOperationTransformer.transform( - EditOperationBuilder.fromJSON(raw1[0]), - EditOperationBuilder.fromJSON(raw2[0]) - ) - return [[a.toJSON()], [b.toJSON()]] - } - - apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) - const afterFile = StringFileData.fromRaw(snapshot.toRaw()) - afterFile.edit(operation) - this.snapshot = afterFile - return afterFile - } - - compose(op1: RawEditOperation[], op2: RawEditOperation[]) { - return [ - EditOperationBuilder.fromJSON(op1[0]) - .compose(EditOperationBuilder.fromJSON(op2[0])) - .toJSON(), - ] - } - - // Do not provide normalize, used by submitOp to fixup bad input. - // normalize(op: TextOperation) {} - - // Do not provide invert, only needed for reverting a rejected update. - // We are displaying an out-of-sync modal when an op is rejected. - // invert(op: TextOperation) {} - - // API - insert(pos: number, text: string, fromUndo: boolean) { - const old = this.getText() - const op = new TextOperation() - op.retain(pos) - op.insert(text) - op.retain(old.length - pos) - this.submitOp([op.toJSON()]) - } - - del(pos: number, length: number, fromUndo: boolean) { - const old = this.getText() - const op = new TextOperation() - op.retain(pos) - op.remove(length) - op.retain(old.length - pos - length) - this.submitOp([op.toJSON()]) - } +const api: Api & ThisType = { + otType: 'history-ot', + trackChangesUserId: null, getText() { - return this.snapshot.getContent({ filterTrackedDeletes: true }) - } + return this.snapshot.getContent() + }, getLength() { - return this.getText().length - } + return this.snapshot.getStringLength() + }, _register() { this.on( @@ -95,10 +47,14 @@ export class HistoryOTType extends EventEmitter { let outputCursor = 0 let inputCursor = 0 + let trackedChangesInvalidated = false for (const op of operation.ops) { if (op instanceof RetainOp) { inputCursor += op.length outputCursor += op.length + if (op.tracking != null) { + trackedChangesInvalidated = true + } } else if (op instanceof InsertOp) { this.emit( 'insert', @@ -107,6 +63,7 @@ export class HistoryOTType extends EventEmitter { op.insertion.length ) outputCursor += op.insertion.length + trackedChangesInvalidated = true } else if (op instanceof RemoveOp) { this.emit( 'delete', @@ -114,20 +71,57 @@ export class HistoryOTType extends EventEmitter { str.slice(inputCursor, inputCursor + op.length) ) inputCursor += op.length + trackedChangesInvalidated = true } } - if (inputCursor !== str.length) + if (inputCursor !== str.length) { throw new TextOperation.ApplyError( "The operation didn't operate on the whole string.", operation, str ) + } + + if (trackedChangesInvalidated) { + this.emit('tracked-changes-invalidated') + } } } ) - } - - // stub-interface, provided by sharejs.Doc - submitOp(op: RawEditOperation[]) {} + }, +} + +export const historyOTType = { + api, + + transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { + const [a, b] = EditOperationTransformer.transform( + EditOperationBuilder.fromJSON(raw1[0]), + EditOperationBuilder.fromJSON(raw2[0]) + ) + return [[a.toJSON()], [b.toJSON()]] + }, + + apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { + const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) + const afterFile = StringFileData.fromRaw(snapshot.toRaw()) + afterFile.edit(operation) + return afterFile + }, + + compose(op1: RawEditOperation[], op2: RawEditOperation[]) { + return [ + EditOperationBuilder.fromJSON(op1[0]) + .compose(EditOperationBuilder.fromJSON(op2[0])) + .toJSON(), + ] + }, + + // Do not provide normalize, used by submitOp to fixup bad input. + // normalize(op: TextOperation) {} + + // Do not provide invert, only needed for reverting a rejected update. + // We are displaying an out-of-sync modal when an op is rejected. + // invert(op: TextOperation) {} } diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts new file mode 100644 index 0000000000..58c2a42540 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -0,0 +1,290 @@ +import { Decoration, EditorView } from '@codemirror/view' +import { + ChangeSpec, + EditorState, + StateEffect, + StateField, + Transaction, +} from '@codemirror/state' +import { + CommentList, + EditOperation, + TextOperation, + TrackingProps, + TrackedChangeList, +} from 'overleaf-editor-core' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' + +export const historyOT = (currentDoc: DocumentContainer) => { + const trackedChanges = currentDoc.doc?.getTrackedChanges() + return [ + trackChangesUserIdState, + commentsState, + trackedChanges != null + ? trackedChangesState.init(() => + buildTrackedChangesDecorations(trackedChanges) + ) + : trackedChangesState, + trackedChangesFilter, + rangesTheme, + ] +} + +const rangesTheme = EditorView.theme({ + '.tracked-change-insertion': { + backgroundColor: 'rgba(0, 255, 0, 0.2)', + }, + '.tracked-change-deletion': { + backgroundColor: 'rgba(255, 0, 0, 0.2)', + }, + '.comment': { + backgroundColor: 'rgba(255, 255, 0, 0.2)', + }, +}) + +const updateTrackedChangesEffect = StateEffect.define() + +export const updateTrackedChanges = (trackedChanges: TrackedChangeList) => { + return { + effects: updateTrackedChangesEffect.of(trackedChanges), + } +} + +const buildTrackedChangesDecorations = (trackedChanges: TrackedChangeList) => + Decoration.set( + trackedChanges.asSorted().map(change => + Decoration.mark({ + class: + change.tracking.type === 'insert' + ? 'tracked-change-insertion' + : 'tracked-change-deletion', + tracking: change.tracking, + }).range(change.range.pos, change.range.end) + ), + true + ) + +const trackedChangesState = StateField.define({ + create() { + return Decoration.none + }, + + update(value, transaction) { + if (transaction.docChanged) { + value = value.map(transaction.changes) + } + + for (const effect of transaction.effects) { + if (effect.is(updateTrackedChangesEffect)) { + value = buildTrackedChangesDecorations(effect.value) + } + } + + return value + }, + + provide(field) { + return EditorView.decorations.from(field) + }, +}) + +const setTrackChangesUserIdEffect = StateEffect.define() + +export const setTrackChangesUserId = (userId: string | null) => { + return { + effects: setTrackChangesUserIdEffect.of(userId), + } +} + +const trackChangesUserIdState = StateField.define({ + create() { + return null + }, + + update(value, transaction) { + for (const effect of transaction.effects) { + if (effect.is(setTrackChangesUserIdEffect)) { + value = effect.value + } + } + return value + }, +}) + +const updateCommentsEffect = StateEffect.define() + +export const updateComments = (comments: CommentList) => { + return { + effects: updateCommentsEffect.of(comments), + } +} + +const buildCommentsDecorations = (comments: CommentList) => + Decoration.set( + comments.toArray().flatMap(comment => + comment.ranges.map(range => + Decoration.mark({ + class: 'tracked-change-comment', + id: comment.id, + resolved: comment.resolved, + }).range(range.pos, range.end) + ) + ), + true + ) + +const commentsState = StateField.define({ + create() { + return Decoration.none // TODO: init from snapshot + }, + + update(value, transaction) { + if (transaction.docChanged) { + value = value.map(transaction.changes) + } + + for (const effect of transaction.effects) { + if (effect.is(updateCommentsEffect)) { + value = buildCommentsDecorations(effect.value) + } + } + + return value + }, + + provide(field) { + return EditorView.decorations.from(field) + }, +}) + +export const historyOTOperationEffect = StateEffect.define() + +const trackedChangesFilter = EditorState.transactionFilter.of(tr => { + if (!tr.docChanged || tr.annotation(Transaction.remote)) { + return tr + } + + const trackingUserId = tr.startState.field(trackChangesUserIdState) + const startDoc = tr.startState.doc + const changes: ChangeSpec[] = [] + const opBuilder = new OperationBuilder(startDoc.length) + + if (trackingUserId == null) { + // Not tracking changes + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + // insert + if (inserted.length > 0) { + opBuilder.insert(fromA, inserted.toString()) + } + + // deletion + if (toA > fromA) { + opBuilder.delete(fromA, toA - fromA) + } + }) + } else { + // Tracking changes + const timestamp = new Date() + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + // insertion + if (inserted.length > 0) { + opBuilder.trackedInsert( + fromA, + inserted.toString(), + trackingUserId, + timestamp + ) + } + + // deletion + if (toA > fromA) { + const deleted = startDoc.sliceString(fromA, toA) + // re-insert the deleted text after the inserted text + changes.push({ + from: fromB + inserted.length, + insert: deleted, + }) + + opBuilder.trackedDelete(fromA, toA - fromA, trackingUserId, timestamp) + } + }) + } + + const op = opBuilder.finish() + return [ + tr, + { changes, effects: historyOTOperationEffect.of([op]), sequential: true }, + ] +}) + +/** + * Incrementally builds a TextOperation from a series of inserts and deletes. + * + * This relies on inserts and deletes being ordered by document position. This + * is not clear in the documentation, but has been confirmed by Marijn in + * https://discuss.codemirror.net/t/iterators-can-be-hard-to-work-with-for-beginners/3533/10 + */ +class OperationBuilder { + /** + * Source document length + */ + private docLength: number + + /** + * Position in the source document + */ + private pos: number + + /** + * Operation built + */ + private op: TextOperation + + constructor(docLength: number) { + this.docLength = docLength + this.op = new TextOperation() + this.pos = 0 + } + + insert(pos: number, text: string) { + this.retainUntil(pos) + this.op.insert(text) + } + + delete(pos: number, length: number) { + this.retainUntil(pos) + this.op.remove(length) + this.pos += length + } + + trackedInsert(pos: number, text: string, userId: string, timestamp: Date) { + this.retainUntil(pos) + this.op.insert(text, { + tracking: new TrackingProps('insert', userId, timestamp), + }) + } + + trackedDelete(pos: number, length: number, userId: string, timestamp: Date) { + this.retainUntil(pos) + this.op.retain(length, { + tracking: new TrackingProps('delete', userId, timestamp), + }) + this.pos += length + } + + retainUntil(pos: number) { + if (pos > this.pos) { + this.op.retain(pos - this.pos) + this.pos = pos + } else if (pos < this.pos) { + throw Error( + `Out of order: position ${pos} comes before current position: ${this.pos}` + ) + } + } + + finish() { + this.retainUntil(this.docLength) + return this.op + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index 0a65739c55..0e19d42fc1 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -50,6 +50,7 @@ import { docName } from './doc-name' import { fileTreeItemDrop } from './file-tree-item-drop' import { mathPreview } from './math-preview' import { ranges } from './ranges' +import { historyOT } from './history-ot' import { trackDetachedComments } from './track-detached-comments' import { reviewTooltip } from './review-tooltip' @@ -142,7 +143,9 @@ export const createExtensions = (options: Record): Extension[] => [ // NOTE: `emptyLineFiller` needs to be before `trackChanges`, // so the decorations are added in the correct order. emptyLineFiller(), - ranges(), + options.currentDoc.currentDocument.getType() === 'history-ot' + ? historyOT(options.currentDoc.currentDocument) + : ranges(), trackDetachedComments(options.currentDoc), visual(options.visual), mathPreview(options.settings.mathPreview), diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 9118e4f151..72ad016f41 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -1,11 +1,26 @@ -import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state' +import { + Prec, + Transaction, + Annotation, + ChangeSpec, + Text, +} from '@codemirror/state' import { EditorView, ViewPlugin } from '@codemirror/view' import { EventEmitter } from 'events' import RangesTracker from '@overleaf/ranges-tracker' -import { ShareDoc } from '../../../../../types/share-doc' +import { + ShareDoc, + ShareLatexOTShareDoc, + HistoryOTShareDoc, +} from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' -import { OTType } from '@/features/ide-react/editor/share-js-doc' +import { TrackedChangeList } from 'overleaf-editor-core' +import { + updateTrackedChanges, + setTrackChangesUserId, + historyOTOperationEffect, +} from './history-ot' /* * Integrate CodeMirror 6 with the real-time system, via ShareJS. @@ -26,8 +41,10 @@ import { OTType } from '@/features/ide-react/editor/share-js-doc' * - frontend/js/features/ide-react/connection/editor-watchdog-manager.js */ +type Origin = 'remote' | 'undo' | 'reject' | undefined + export type ChangeDescription = { - origin: 'remote' | 'undo' | 'reject' | undefined + origin: Origin inserted: boolean removed: boolean } @@ -126,9 +143,13 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) { + cmUpdateTrackedChanges(trackedChanges: TrackedChangeList) { + this.view.dispatch(updateTrackedChanges(trackedChanges)) + } + + attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { this.otAdapter = - type === 'history-ot' + shareDoc.otType === 'history-ot' ? new HistoryOTAdapter(this, shareDoc, maxDocLength) : new ShareLatexOTAdapter(this, shareDoc, maxDocLength) this.otAdapter.attachShareJs() @@ -148,12 +169,18 @@ export class EditorFacade extends EventEmitter { this.otAdapter.handleUpdateFromCM(transactions, ranges) } + + setTrackChangesUserId(userId: string | null) { + if (this.otAdapter instanceof HistoryOTAdapter) { + this.view.dispatch(setTrackChangesUserId(userId)) + } + } } class ShareLatexOTAdapter { constructor( public editor: EditorFacade, - private shareDoc: ShareDoc, + private shareDoc: ShareLatexOTShareDoc, private maxDocLength?: number ) { this.editor = editor @@ -279,7 +306,133 @@ class ShareLatexOTAdapter { } } -class HistoryOTAdapter extends ShareLatexOTAdapter {} +class HistoryOTAdapter { + constructor( + public editor: EditorFacade, + private shareDoc: HistoryOTShareDoc, + private maxDocLength?: number + ) { + this.editor = editor + this.shareDoc = shareDoc + this.maxDocLength = maxDocLength + } + + attachShareJs() { + this.checkContent() + + const onInsert = this.onShareJsInsert.bind(this) + const onDelete = this.onShareJsDelete.bind(this) + const onTrackedChangesInvalidated = + this.onShareJsTrackedChangesInvalidated.bind(this) + + this.shareDoc.on('insert', onInsert) + this.shareDoc.on('delete', onDelete) + this.shareDoc.on('tracked-changes-invalidated', onTrackedChangesInvalidated) + + this.shareDoc.detach_cm6 = () => { + this.shareDoc.removeListener('insert', onInsert) + this.shareDoc.removeListener('delete', onDelete) + this.shareDoc.removeListener( + 'tracked-changes-invalidated', + onTrackedChangesInvalidated + ) + delete this.shareDoc.detach_cm6 + this.editor.detachShareJs() + } + } + + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ) { + for (const transaction of transactions) { + if ( + this.maxDocLength && + transaction.changes.newLength >= this.maxDocLength + ) { + this.shareDoc.emit( + 'error', + new Error('document length is greater than maxDocLength') + ) + return + } + + let snapshotUpdated = false + for (const effect of transaction.effects) { + if (effect.is(historyOTOperationEffect)) { + this.shareDoc.submitOp(effect.value.map(op => op.toJSON())) + snapshotUpdated = true + } + } + + if (snapshotUpdated || transaction.annotation(Transaction.remote)) { + window.setTimeout(() => { + this.editor.cmUpdateTrackedChanges( + this.shareDoc.snapshot.getTrackedChanges() + ) + }, 0) + } + + const origin = chooseOrigin(transaction) + transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin) + }) + } + } + + onShareJsInsert(pos: number, text: string) { + this.editor.cmInsert(pos, text, 'remote') + this.checkContent() + } + + onShareJsDelete(pos: number, text: string) { + this.editor.cmDelete(pos, text, 'remote') + this.checkContent() + } + + onShareJsTrackedChangesInvalidated() { + this.editor.cmUpdateTrackedChanges( + this.shareDoc.snapshot.getTrackedChanges() + ) + } + + onCodeMirrorChange( + fromA: number, + toA: number, + fromB: number, + toB: number, + insertedText: Text, + origin: Origin + ) { + const insertedLength = insertedText.length + const removedLength = toA - fromA + const inserted = insertedLength > 0 + const removed = removedLength > 0 + + const changeDescription: ChangeDescription = { + origin, + inserted, + removed, + } + + this.editor.emit('change', this.editor, changeDescription) + } + + checkContent() { + // run in a timeout so it checks the editor content once this update has been applied + window.setTimeout(() => { + const editorText = this.editor.getValue() + const otText = this.shareDoc.getText() + + if (editorText !== otText) { + this.shareDoc.emit('error', 'Text does not match in CodeMirror 6') + debugConsole.error('Text does not match!') + debugConsole.error('editor: ' + editorText) + debugConsole.error('ot: ' + otText) + } + }, 0) + } +} export const trackChangesAnnotation = Annotation.define() diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index a4e2862e1f..2504afdd0c 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -185,9 +185,9 @@ function useCodeMirrorScope(view: EditorView) { if (currentDocument) { if (trackChanges) { - currentDocument.track_changes_as = userId || 'anonymous' + currentDocument.setTrackChangesUserId(userId ?? 'anonymous') } else { - currentDocument.track_changes_as = null + currentDocument.setTrackChangesUserId(null) } } }, [userId, currentDocument, trackChanges]) diff --git a/services/web/frontend/js/vendor/libs/sharejs.js b/services/web/frontend/js/vendor/libs/sharejs.js index accc2b5b04..52e201ce37 100644 --- a/services/web/frontend/js/vendor/libs/sharejs.js +++ b/services/web/frontend/js/vendor/libs/sharejs.js @@ -680,6 +680,7 @@ export const { Doc } = (() => { // Text document API for text text.api = { + otType: "sharejs-text-ot", provides: { text: true }, // The number of characters in the string @@ -1008,8 +1009,8 @@ export const { Doc } = (() => { this.type = type; if (type.api) { - for (const k of ['insert', 'del', 'getText', 'getLength', '_register']) { - this[k] = type.api[k] + for (var k in type.api) { + var v = type.api[k];this[k] = v; } return typeof this._register === 'function' ? this._register() : undefined; } else { diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx index d87179b65f..3cc6b1c95f 100644 --- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx +++ b/services/web/frontend/stories/source-editor/source-editor.stories.tsx @@ -198,7 +198,7 @@ const mockDoc = (content: string, changes: Array> = []) => { setTrackChangesIdSeeds: () => { // Do nothing }, - setTrackingChanges: () => { + setTrackChangesUserId: () => { // Do nothing }, getTrackingChanges: () => { diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts index 4c239c1f60..f13d9ad6bb 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts @@ -1,4 +1,4 @@ -import { ShareDoc } from '../../../../../types/share-doc' +import { ShareLatexOTShareDoc } from '../../../../../types/share-doc' import { EventEmitter } from 'events' export const docId = 'test-doc' @@ -36,6 +36,9 @@ const defaultContent = mockDocContent(contentLines.join('\n')) const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength class MockShareDoc extends EventEmitter { + otType = 'sharejs-text-ot' as const + snapshot = '' + constructor(public text: string) { super() } @@ -51,16 +54,21 @@ class MockShareDoc extends EventEmitter { del() { // do nothing } + + submitOp() { + // do nothing + } } export const mockDoc = ( content = defaultContent, { rangesOptions = {} } = {} ) => { - const mockShareJSDoc: ShareDoc = new MockShareDoc(content) + const mockShareJSDoc: ShareLatexOTShareDoc = new MockShareDoc(content) return { doc_id: docId, + getType: () => 'sharejs-text-ot', getSnapshot: () => { return content }, @@ -101,7 +109,7 @@ export const mockDoc = ( submitOp: (op: any) => {}, setTrackChangesIdSeeds: () => {}, getTrackingChanges: () => true, - setTrackingChanges: () => {}, + setTrackChangesUserId: () => {}, getInflightOp: () => null, getPendingOp: () => null, hasBufferedOps: () => false, diff --git a/services/web/types/share-doc.ts b/services/web/types/share-doc.ts index d071c97f28..7c75e6d0de 100644 --- a/services/web/types/share-doc.ts +++ b/services/web/types/share-doc.ts @@ -1,9 +1,23 @@ import EventEmitter from 'events' +import { StringFileData } from 'overleaf-editor-core' // type for the Doc class in vendor/libs/sharejs.js -export interface ShareDoc extends EventEmitter { +export interface ShareLatexOTShareDoc extends EventEmitter { + otType: 'sharejs-text-ot' + snapshot: string detach_cm6?: () => void getText: () => string insert: (pos: number, insert: string, fromUndo: boolean) => void del: (pos: number, length: number, fromUndo: boolean) => void + submitOp(op: any[]): void } + +export interface HistoryOTShareDoc extends EventEmitter { + otType: 'history-ot' + snapshot: StringFileData + detach_cm6?: () => void + getText: () => string + submitOp(op: any[]): void +} + +export type ShareDoc = ShareLatexOTShareDoc | HistoryOTShareDoc From 7a556cf1fdb84ba69f6ee690b62397c47a8a4ac4 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:08:42 -0400 Subject: [PATCH 091/259] Merge pull request #26041 from overleaf/em-history-ot-type-serialize History OT type: operate on parsed EditOperations GitOrigin-RevId: dbb35789736958d4ef398e566400d6e9a0e49e8b --- .../features/ide-react/editor/share-js-doc.ts | 21 +++- .../editor/share-js-history-ot-type.ts | 117 ++++++++---------- .../ide-react/editor/types/document.ts | 2 + .../source-editor/extensions/realtime.ts | 2 +- 4 files changed, 71 insertions(+), 71 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index e94de4e88b..5b362299d2 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -18,8 +18,15 @@ import { EditorFacade } from '@/features/source-editor/extensions/realtime' import { recordDocumentFirstChangeEvent } from '@/features/event-tracking/document-first-change-event' import getMeta from '@/utils/meta' import { historyOTType } from './share-js-history-ot-type' -import { StringFileData, TrackedChangeList } from 'overleaf-editor-core/index' -import { StringFileRawData } from 'overleaf-editor-core/lib/types' +import { + StringFileData, + TrackedChangeList, + EditOperationBuilder, +} from 'overleaf-editor-core' +import { + StringFileRawData, + RawEditOperation, +} from 'overleaf-editor-core/lib/types' // All times below are in milliseconds const SINGLE_USER_FLUSH_DELAY = 2000 @@ -259,7 +266,15 @@ export class ShareJsDoc extends EventEmitter { // issues are resolved. processUpdateFromServer(message: Message) { try { - this._doc._onMessage(message) + if (this.type === 'history-ot' && message.op != null) { + const ops = message.op as RawEditOperation[] + this._doc._onMessage({ + ...message, + op: ops.map(EditOperationBuilder.fromJSON), + }) + } else { + this._doc._onMessage(message) + } } catch (error) { // Version mismatches are thrown as errors debugConsole.log(error) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index 2832ca390e..4621fd07fb 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -1,5 +1,5 @@ import { - EditOperationBuilder, + EditOperation, EditOperationTransformer, InsertOp, RemoveOp, @@ -7,7 +7,6 @@ import { StringFileData, TextOperation, } from 'overleaf-editor-core' -import { RawEditOperation } from 'overleaf-editor-core/lib/types' import { ShareDoc } from '../../../../../types/share-doc' type Api = { @@ -32,90 +31,74 @@ const api: Api & ThisType = { }, _register() { - this.on( - 'remoteop', - (rawEditOperation: RawEditOperation[], oldSnapshot: StringFileData) => { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) - if (operation instanceof TextOperation) { - const str = oldSnapshot.getContent() - if (str.length !== operation.baseLength) - throw new TextOperation.ApplyError( - "The operation's base length must be equal to the string's length.", - operation, - str - ) + this.on('remoteop', (ops: EditOperation[], oldSnapshot: StringFileData) => { + const operation = ops[0] + if (operation instanceof TextOperation) { + const str = oldSnapshot.getContent() + if (str.length !== operation.baseLength) + throw new TextOperation.ApplyError( + "The operation's base length must be equal to the string's length.", + operation, + str + ) - let outputCursor = 0 - let inputCursor = 0 - let trackedChangesInvalidated = false - for (const op of operation.ops) { - if (op instanceof RetainOp) { - inputCursor += op.length - outputCursor += op.length - if (op.tracking != null) { - trackedChangesInvalidated = true - } - } else if (op instanceof InsertOp) { - this.emit( - 'insert', - outputCursor, - op.insertion, - op.insertion.length - ) - outputCursor += op.insertion.length - trackedChangesInvalidated = true - } else if (op instanceof RemoveOp) { - this.emit( - 'delete', - outputCursor, - str.slice(inputCursor, inputCursor + op.length) - ) - inputCursor += op.length + let outputCursor = 0 + let inputCursor = 0 + let trackedChangesInvalidated = false + for (const op of operation.ops) { + if (op instanceof RetainOp) { + inputCursor += op.length + outputCursor += op.length + if (op.tracking != null) { trackedChangesInvalidated = true } - } - - if (inputCursor !== str.length) { - throw new TextOperation.ApplyError( - "The operation didn't operate on the whole string.", - operation, - str + } else if (op instanceof InsertOp) { + this.emit('insert', outputCursor, op.insertion, op.insertion.length) + outputCursor += op.insertion.length + trackedChangesInvalidated = true + } else if (op instanceof RemoveOp) { + this.emit( + 'delete', + outputCursor, + str.slice(inputCursor, inputCursor + op.length) ) - } - - if (trackedChangesInvalidated) { - this.emit('tracked-changes-invalidated') + inputCursor += op.length + trackedChangesInvalidated = true } } + + if (inputCursor !== str.length) { + throw new TextOperation.ApplyError( + "The operation didn't operate on the whole string.", + operation, + str + ) + } + + if (trackedChangesInvalidated) { + this.emit('tracked-changes-invalidated') + } } - ) + }) }, } export const historyOTType = { api, - transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { - const [a, b] = EditOperationTransformer.transform( - EditOperationBuilder.fromJSON(raw1[0]), - EditOperationBuilder.fromJSON(raw2[0]) - ) - return [[a.toJSON()], [b.toJSON()]] + transformX(ops1: EditOperation[], ops2: EditOperation[]) { + const [a, b] = EditOperationTransformer.transform(ops1[0], ops2[0]) + return [[a], [b]] }, - apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) + apply(snapshot: StringFileData, ops: EditOperation[]) { const afterFile = StringFileData.fromRaw(snapshot.toRaw()) - afterFile.edit(operation) + afterFile.edit(ops[0]) return afterFile }, - compose(op1: RawEditOperation[], op2: RawEditOperation[]) { - return [ - EditOperationBuilder.fromJSON(op1[0]) - .compose(EditOperationBuilder.fromJSON(op2[0])) - .toJSON(), - ] + compose(ops1: EditOperation[], ops2: EditOperation[]) { + return [ops1[0].compose(ops2[0])] }, // Do not provide normalize, used by submitOp to fixup bad input. diff --git a/services/web/frontend/js/features/ide-react/editor/types/document.ts b/services/web/frontend/js/features/ide-react/editor/types/document.ts index fbed3ab8f1..f6e5f6aebb 100644 --- a/services/web/frontend/js/features/ide-react/editor/types/document.ts +++ b/services/web/frontend/js/features/ide-react/editor/types/document.ts @@ -1,5 +1,6 @@ import { StringFileData } from 'overleaf-editor-core' import { AnyOperation } from '../../../../../../types/change' +import { RawEditOperation } from 'overleaf-editor-core/lib/types' export type Version = number @@ -36,4 +37,5 @@ export type Message = { doc?: string snapshot?: string | StringFileData type?: ShareJsTextType + op?: AnyOperation[] | RawEditOperation[] } diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 72ad016f41..1797cbc17e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -360,7 +360,7 @@ class HistoryOTAdapter { let snapshotUpdated = false for (const effect of transaction.effects) { if (effect.is(historyOTOperationEffect)) { - this.shareDoc.submitOp(effect.value.map(op => op.toJSON())) + this.shareDoc.submitOp(effect.value) snapshotUpdated = true } } From a134a2b799740d553e5c325ef9550884b0ed831d Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:02:14 +0200 Subject: [PATCH 092/259] [web] support purchasing/removing add-ons for Stripe subscriptions (#26081) GitOrigin-RevId: 01c2eaccc7c34bc37be43120de83270490e5e6da --- .../src/Features/Subscription/PlansLocator.js | 31 +++++++++- .../Subscription/SubscriptionController.js | 2 - .../Subscription/SubscriptionHandler.js | 62 ++----------------- .../src/Subscription/PlansLocatorTests.js | 41 ++++++++++++ services/web/types/subscription/plan.ts | 6 ++ 5 files changed, 82 insertions(+), 60 deletions(-) diff --git a/services/web/app/src/Features/Subscription/PlansLocator.js b/services/web/app/src/Features/Subscription/PlansLocator.js index 24343e1109..c04f0c860d 100644 --- a/services/web/app/src/Features/Subscription/PlansLocator.js +++ b/services/web/app/src/Features/Subscription/PlansLocator.js @@ -4,7 +4,9 @@ const logger = require('@overleaf/logger') /** * @typedef {import('../../../../types/subscription/plan').RecurlyPlanCode} RecurlyPlanCode + * @typedef {import('../../../../types/subscription/plan').RecurlyAddOnCode} RecurlyAddOnCode * @typedef {import('../../../../types/subscription/plan').StripeLookupKey} StripeLookupKey + * @typedef {import('stripe').Stripe.Price.Recurring.Interval} BillingCycleInterval */ function ensurePlansAreSetupCorrectly() { @@ -38,7 +40,7 @@ const recurlyPlanCodeToStripeLookupKey = { group_professional_educational: 'group_professional_educational', group_collaborator: 'group_standard_enterprise', group_collaborator_educational: 'group_standard_educational', - assistant_annual: 'error_assist_annual', + 'assistant-annual': 'error_assist_annual', assistant: 'error_assist_monthly', } @@ -51,6 +53,28 @@ function mapRecurlyPlanCodeToStripeLookupKey(recurlyPlanCode) { return recurlyPlanCodeToStripeLookupKey[recurlyPlanCode] } +/** + * + * @param {RecurlyAddOnCode} recurlyAddOnCode + * @param {BillingCycleInterval} billingCycleInterval + * @returns {StripeLookupKey|null} + */ +function mapRecurlyAddOnCodeToStripeLookupKey( + recurlyAddOnCode, + billingCycleInterval +) { + // Recurly always uses 'assistant' as the code regardless of the subscription duration + if (recurlyAddOnCode === 'assistant') { + if (billingCycleInterval === 'month') { + return 'error_assist_monthly' + } + if (billingCycleInterval === 'year') { + return 'error_assist_annual' + } + } + return null +} + const recurlyPlanCodeToPlanTypeAndPeriod = { collaborator: { planType: 'individual', period: 'monthly' }, collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' }, @@ -68,12 +92,14 @@ const recurlyPlanCodeToPlanTypeAndPeriod = { group_professional_educational: { planType: 'group', period: 'annual' }, group_collaborator: { planType: 'group', period: 'annual' }, group_collaborator_educational: { planType: 'group', period: 'annual' }, + assistant: { planType: null, period: 'monthly' }, + 'assistant-annual': { planType: null, period: 'annual' }, } /** * * @param {RecurlyPlanCode} recurlyPlanCode - * @returns {{ planType: 'individual' | 'group' | 'student', period: 'annual' | 'monthly'}} + * @returns {{ planType: 'individual' | 'group' | 'student' | null, period: 'annual' | 'monthly'}} */ function getPlanTypeAndPeriodFromRecurlyPlanCode(recurlyPlanCode) { return recurlyPlanCodeToPlanTypeAndPeriod[recurlyPlanCode] @@ -92,5 +118,6 @@ module.exports = { ensurePlansAreSetupCorrectly, findLocalPlanInSettings, mapRecurlyPlanCodeToStripeLookupKey, + mapRecurlyAddOnCodeToStripeLookupKey, getPlanTypeAndPeriodFromRecurlyPlanCode, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 4be61d255c..aa0b97d497 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -414,8 +414,6 @@ async function purchaseAddon(req, res, next) { logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons') try { - // set a restore point in the case of a failed payment for the upgrade (Recurly only) - await SubscriptionHandler.promises.setSubscriptionRestorePoint(user._id) await SubscriptionHandler.promises.purchaseAddon( user._id, addOnCode, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 1296a2a7de..8aa0ee84eb 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -1,6 +1,5 @@ // @ts-check -const recurly = require('recurly') const RecurlyWrapper = require('./RecurlyWrapper') const RecurlyClient = require('./RecurlyClient') const { User } = require('../../models/User') @@ -11,11 +10,11 @@ const LimitationsManager = require('./LimitationsManager') const EmailHandler = require('../Email/EmailHandler') const { callbackify } = require('@overleaf/promise-utils') const UserUpdater = require('../User/UserUpdater') -const { NotFoundError, IndeterminateInvoiceError } = require('../Errors/Errors') +const { IndeterminateInvoiceError } = require('../Errors/Errors') const Modules = require('../../infrastructure/Modules') /** - * @import { PaymentProviderSubscription, PaymentProviderSubscriptionChange } from './PaymentProviderEntities' + * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities' */ async function validateNoSubscriptionInRecurly(userId) { @@ -278,24 +277,12 @@ async function previewAddonPurchase(userId, addOnCode) { * @param {number} quantity */ async function purchaseAddon(userId, addOnCode, quantity) { - const subscription = await getSubscriptionForUser(userId) - try { - await RecurlyClient.promises.getAddOn(subscription.planCode, addOnCode) - } catch (err) { - if (err instanceof recurly.errors.NotFoundError) { - throw new NotFoundError({ - message: 'Add-on not found', - info: { addOnCode }, - }) - } - throw err - } - const changeRequest = subscription.getRequestForAddOnPurchase( + await Modules.promises.hooks.fire( + 'purchaseAddOn', + userId, addOnCode, quantity ) - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await syncSubscription({ uuid: subscription.id }, userId) } /** @@ -305,44 +292,7 @@ async function purchaseAddon(userId, addOnCode, quantity) { * @param {string} addOnCode */ async function removeAddon(userId, addOnCode) { - const subscription = await getSubscriptionForUser(userId) - const changeRequest = subscription.getRequestForAddOnRemoval(addOnCode) - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await syncSubscription({ uuid: subscription.id }, userId) -} - -/** - * Returns the Recurly UUID for the given user - * - * Throws a NotFoundError if the subscription can't be found - * - * @param {string} userId - * @return {Promise} - */ -async function getSubscriptionForUser(userId) { - const subscription = - await SubscriptionLocator.promises.getUsersSubscription(userId) - const recurlyId = subscription?.recurlySubscription_id - if (recurlyId == null) { - throw new NotFoundError({ - message: 'Recurly subscription not found', - info: { userId }, - }) - } - - try { - const subscription = await RecurlyClient.promises.getSubscription(recurlyId) - return subscription - } catch (err) { - if (err instanceof recurly.errors.NotFoundError) { - throw new NotFoundError({ - message: 'Subscription not found', - info: { userId, recurlyId }, - }) - } else { - throw err - } - } + await Modules.promises.hooks.fire('removeAddOn', userId, addOnCode) } async function pauseSubscription(user, pauseCycles) { diff --git a/services/web/test/unit/src/Subscription/PlansLocatorTests.js b/services/web/test/unit/src/Subscription/PlansLocatorTests.js index f705baa01c..0c7a6dca03 100644 --- a/services/web/test/unit/src/Subscription/PlansLocatorTests.js +++ b/services/web/test/unit/src/Subscription/PlansLocatorTests.js @@ -29,6 +29,7 @@ const plans = [ describe('PlansLocator', function () { beforeEach(function () { this.settings = { plans } + this.AI_ADD_ON_CODE = 'assistant' this.PlansLocator = SandboxedModule.require(modulePath, { requires: { @@ -114,6 +115,46 @@ describe('PlansLocator', function () { }) }) + describe('mapRecurlyAddOnCodeToStripeLookupKey', function () { + it('should return null for unknown add-on codes', function () { + const billingCycleInterval = 'month' + const addOnCode = 'unknown_addon' + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal(null) + }) + + it('should handle missing input', function () { + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + undefined, + undefined + ) + expect(lookupKey).to.equal(null) + }) + + it('returns the key for a monthly AI assist add-on', function () { + const billingCycleInterval = 'month' + const addOnCode = this.AI_ADD_ON_CODE + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal('error_assist_monthly') + }) + + it('returns the key for an annual AI assist add-on', function () { + const billingCycleInterval = 'year' + const addOnCode = this.AI_ADD_ON_CODE + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal('error_assist_annual') + }) + }) + describe('getPlanTypeAndPeriodFromRecurlyPlanCode', function () { it('should return the plan type and period for "collaborator"', function () { const { planType, period } = diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index c5e8f7e820..4759bb1255 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -85,6 +85,10 @@ export type RecurlyPlanCode = | 'group_professional_educational' | 'group_collaborator' | 'group_collaborator_educational' + | 'assistant' + | 'assistant-annual' + +export type RecurlyAddOnCode = 'assistant' export type StripeLookupKey = | 'standard_monthly' @@ -97,3 +101,5 @@ export type StripeLookupKey = | 'group_professional_enterprise' | 'group_standard_educational' | 'group_professional_educational' + | 'error_assist_annual' + | 'error_assist_monthly' From db98f5132b262a10f9a659859686524780b28233 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Wed, 4 Jun 2025 10:46:58 +0100 Subject: [PATCH 093/259] Merge pull request #25939 from overleaf/dp-error-logs Update error logs designs for new editor GitOrigin-RevId: 0de3a54446a0ff114a1debb7b5f274d3a8f19c42 --- .../src/Features/Project/ProjectController.js | 1 + services/web/config/settings.defaults.js | 1 + .../web/frontend/extracted-translations.json | 4 +- ...alSymbolsRoundedUnfilledPartialSlice.woff2 | Bin 4444 -> 4612 bytes .../material-symbols/unfilled-symbols.mjs | 1 + .../ide-redesign/components/chat/chat.tsx | 2 +- .../error-indicator.tsx} | 14 +- .../error-logs/error-logs-header.tsx | 98 ++++++ .../error-logs/error-logs-panel.tsx | 14 + .../components/error-logs/error-logs.tsx | 133 ++++++++ .../error-logs/log-entry-header.tsx | 153 +++++++++ .../components/error-logs/log-entry.tsx | 109 ++++++ .../components/error-logs/old-error-pane.tsx | 10 + .../integrations-panel/integrations-panel.tsx | 2 +- .../components/rail-panel-header.tsx | 31 ++ .../features/ide-redesign/components/rail.tsx | 41 ++- .../components/pdf-log-entry-content.tsx | 8 +- .../components/pdf-log-entry-raw-content.tsx | 12 +- .../pdf-preview/components/pdf-log-entry.tsx | 39 ++- .../components/pdf-preview-error.tsx | 312 ++++++++++-------- .../timeout-upgrade-paywall-prompt.tsx | 10 +- .../components/preview-log-entry-header.tsx | 14 - .../shared/context/local-compile-context.tsx | 9 +- .../stories/pdf-log-entry.stories.tsx | 1 - .../bootstrap-5/pages/editor/logs.scss | 244 +++++++++++--- .../bootstrap-5/pages/editor/rail.scss | 16 +- services/web/locales/da.json | 1 - services/web/locales/de.json | 1 - services/web/locales/en.json | 4 +- services/web/locales/fr.json | 1 - services/web/locales/sv.json | 1 - services/web/locales/zh-CN.json | 1 - 32 files changed, 1021 insertions(+), 267 deletions(-) rename services/web/frontend/js/features/ide-redesign/components/{errors.tsx => error-logs/error-indicator.tsx} (56%) create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-header.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-panel.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry-header.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/error-logs/old-error-pane.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/rail-panel-header.tsx diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 842215d80e..e88cb53449 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -352,6 +352,7 @@ const _ProjectController = { 'overleaf-assist-bundle', 'word-count-client', 'editor-popup-ux-survey', + 'new-editor-error-logs-redesign', ].filter(Boolean) const getUserValues = async userId => diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index d8892e70ff..0d3ea86314 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -966,6 +966,7 @@ module.exports = { editorToolbarButtons: [], sourceEditorExtensions: [], sourceEditorComponents: [], + pdfLogEntryHeaderActionComponents: [], pdfLogEntryComponents: [], pdfLogEntriesComponents: [], pdfPreviewPromotions: [], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index e5bb2fced3..cad43ed4e1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -411,7 +411,6 @@ "discount": "", "discount_of": "", "discover_the_fastest_way_to_search_and_cite": "", - "dismiss_error_popup": "", "display": "", "display_deleted_user": "", "display_math": "", @@ -680,6 +679,7 @@ "go_page": "", "go_prev_page": "", "go_to_account_settings": "", + "go_to_code_location": "", "go_to_code_location_in_pdf": "", "go_to_overleaf": "", "go_to_pdf_location_in_code": "", @@ -969,6 +969,7 @@ "login_count": "", "login_to_accept_invitation": "", "login_with_service": "", + "logs": "", "logs_and_output_files": "", "looking_multiple_licenses": "", "looks_like_youre_at": "", @@ -1039,6 +1040,7 @@ "more_compile_time": "", "more_editor_toolbar_item": "", "more_info": "", + "more_logs_and_files": "", "more_options": "", "my_library": "", "n_items": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index 8e72799b077bd442cf7211bd1488813826bc9018..bc2ec87d3ab482401e7f59c91abac055c319e539 100644 GIT binary patch literal 4612 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
~K%gi_OiI+v~ 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 diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index 1c41421910..68f0918301 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -4,6 +4,7 @@ // You may need to hard reload your browser window to see the changes. export default /** @type {const} */ ([ + 'auto_delete', 'book_5', 'brush', 'code', diff --git a/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx b/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx index 9ebe33e065..54d098c6c8 100644 --- a/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx @@ -9,8 +9,8 @@ import { useUserContext } from '@/shared/context/user-context' import { lazy, Suspense, useEffect } from 'react' import { useTranslation } from 'react-i18next' import classNames from 'classnames' -import { RailPanelHeader } from '../rail' import { RailIndicator } from '../rail-indicator' +import RailPanelHeader from '../rail-panel-header' const MessageList = lazy(() => import('../../../chat/components/message-list')) diff --git a/services/web/frontend/js/features/ide-redesign/components/errors.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-indicator.tsx similarity index 56% rename from services/web/frontend/js/features/ide-redesign/components/errors.tsx rename to services/web/frontend/js/features/ide-redesign/components/error-logs/error-indicator.tsx index 2313022d3c..7b721a1d51 100644 --- a/services/web/frontend/js/features/ide-redesign/components/errors.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-indicator.tsx @@ -1,9 +1,7 @@ -import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer' -import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' -import { RailIndicator } from './rail-indicator' +import { RailIndicator } from '../rail-indicator' -export const ErrorIndicator = () => { +export default function ErrorIndicator() { const { logEntries } = useCompileContext() if (!logEntries) { @@ -25,11 +23,3 @@ export const ErrorIndicator = () => { /> ) } - -export const ErrorPane = () => { - return ( - - - - ) -} diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-header.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-header.tsx new file mode 100644 index 0000000000..2f3a54b095 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-header.tsx @@ -0,0 +1,98 @@ +import { useTranslation } from 'react-i18next' +import RailPanelHeader from '../rail-panel-header' +import OLIconButton from '@/features/ui/components/ol/ol-icon-button' +import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' +import { + Dropdown, + DropdownMenu, + DropdownToggle, +} from '@/features/ui/components/bootstrap-5/dropdown-menu' +import PdfFileList from '@/features/pdf-preview/components/pdf-file-list' +import { forwardRef } from 'react' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' + +export default function ErrorLogsHeader() { + const { t } = useTranslation() + + return ( + , + , + ]} + /> + ) +} + +const ClearCacheButton = () => { + const { compiling, clearCache, clearingCache } = useCompileContext() + const { t } = useTranslation() + + return ( + + clearCache()} + className="rail-panel-header-button-subdued" + icon="auto_delete" + isLoading={clearingCache} + disabled={clearingCache || compiling} + accessibilityLabel={t('clear_cached_files')} + size="sm" + /> + + ) +} + +const DownloadFileDropdown = () => { + const { fileList } = useCompileContext() + + const { t } = useTranslation() + + return ( + + + {t('other_logs_and_files')} + + {fileList && ( + + + + )} + + ) +} + +const DownloadFileDropdownToggleButton = forwardRef< + HTMLButtonElement, + { onClick: React.MouseEventHandler } +>(function DownloadFileDropdownToggleButton({ onClick }, ref) { + const { compiling, fileList } = useCompileContext() + const { t } = useTranslation() + + return ( + + + + ) +}) diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-panel.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-panel.tsx new file mode 100644 index 0000000000..2cff048256 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs-panel.tsx @@ -0,0 +1,14 @@ +import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' +import ErrorLogs from './error-logs' +import ErrorLogsHeader from './error-logs-header' + +export default function ErrorLogsPanel() { + return ( + +
+ + +
+
+ ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx new file mode 100644 index 0000000000..7b54785295 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from 'react-i18next' +import { memo, useMemo, useState } from 'react' +import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider' +import StopOnFirstErrorPrompt from '@/features/pdf-preview/components/stop-on-first-error-prompt' +import PdfPreviewError from '@/features/pdf-preview/components/pdf-preview-error' +import PdfValidationIssue from '@/features/pdf-preview/components/pdf-validation-issue' +import PdfLogsEntries from '@/features/pdf-preview/components/pdf-logs-entries' +import PdfPreviewErrorBoundaryFallback from '@/features/pdf-preview/components/pdf-preview-error-boundary-fallback' +import withErrorBoundary from '@/infrastructure/error-boundary' +import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' +import { Nav, NavLink, TabContainer, TabContent } from 'react-bootstrap' +import { LogEntry as LogEntryData } from '@/features/pdf-preview/util/types' +import LogEntry from './log-entry' + +type ErrorLogTab = { + key: string + label: string + entries: LogEntryData[] | undefined +} + +function ErrorLogs() { + const { error, logEntries, rawLog, validationIssues, stoppedOnFirstError } = + useCompileContext() + + const tabs = useMemo(() => { + return [ + { key: 'all', label: 'All', entries: logEntries?.all }, + { key: 'errors', label: 'Errors', entries: logEntries?.errors }, + { key: 'warnings', label: 'Warnings', entries: logEntries?.warnings }, + { key: 'info', label: 'Info', entries: logEntries?.typesetting }, + ] + }, [logEntries]) + + const { loadingError } = usePdfPreviewContext() + + const { t } = useTranslation() + + const [activeTab, setActiveTab] = useState('all') + + const entries = useMemo(() => { + return tabs.find(tab => tab.key === activeTab)?.entries || [] + }, [activeTab, tabs]) + + const includeErrors = activeTab === 'all' || activeTab === 'errors' + const includeWarnings = activeTab === 'all' || activeTab === 'warnings' + const includeInfo = activeTab === 'all' || activeTab === 'info' + + return ( + + + +
+ {stoppedOnFirstError && includeErrors && } + + {loadingError && ( + + )} + + {error && ( + + )} + + {includeErrors && + validationIssues && + Object.entries(validationIssues).map(([name, issue]) => ( + + ))} + + {entries && ( + 0} + /> + )} + + {rawLog && includeInfo && ( + + )} +
+
+
+ ) +} + +function formatErrorNumber(num: number | undefined) { + if (num === undefined) { + return undefined + } + + if (num > 99) { + return '99+' + } + + return Math.floor(num).toString() +} + +const TabHeader = ({ tab, active }: { tab: ErrorLogTab; active: boolean }) => { + return ( + + {tab.label} +
+ {/* TODO: it would be nice if this number included custom errors */} + {formatErrorNumber(tab.entries?.length)} +
+
+ ) +} + +export default withErrorBoundary(memo(ErrorLogs), () => ( + +)) diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry-header.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry-header.tsx new file mode 100644 index 0000000000..ff60fc63b9 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry-header.tsx @@ -0,0 +1,153 @@ +import classNames from 'classnames' +import { useState, useRef, MouseEventHandler, ElementType } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import { + ErrorLevel, + SourceLocation, + LogEntry as LogEntryData, +} from '@/features/pdf-preview/util/types' +import useResizeObserver from '@/features/preview/hooks/use-resize-observer' +import OLIconButton from '@/features/ui/components/ol/ol-icon-button' +import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' + +const actionComponents = importOverleafModules( + 'pdfLogEntryHeaderActionComponents' +) as { + import: { default: ElementType } + path: string +}[] + +function LogEntryHeader({ + sourceLocation, + level, + headerTitle, + logType, + showSourceLocationLink = true, + onSourceLocationClick, + collapsed, + onToggleCollapsed, + id, + logEntry, +}: { + headerTitle: string | React.ReactNode + level: ErrorLevel + logType?: string + sourceLocation?: SourceLocation + showSourceLocationLink?: boolean + onSourceLocationClick?: MouseEventHandler + collapsed: boolean + onToggleCollapsed: () => void + id?: string + logEntry?: LogEntryData +}) { + const { t } = useTranslation() + const logLocationSpanRef = useRef(null) + const [locationSpanOverflown, setLocationSpanOverflown] = useState(false) + + useResizeObserver( + logLocationSpanRef, + locationSpanOverflown, + checkLocationSpanOverflow + ) + + const file = sourceLocation ? sourceLocation.file : null + const line = sourceLocation ? sourceLocation.line : null + const logEntryHeaderTextClasses = classNames('log-entry-header-text', { + 'log-entry-header-text-error': level === 'error', + 'log-entry-header-text-warning': level === 'warning', + 'log-entry-header-text-info': + level === 'info' || level === 'typesetting' || level === 'raw', + 'log-entry-header-text-success': level === 'success', + }) + + function checkLocationSpanOverflow(observedElement: ResizeObserverEntry) { + const spanEl = observedElement.target + const isOverflowing = spanEl.scrollWidth > spanEl.clientWidth + setLocationSpanOverflown(isOverflowing) + } + + const locationText = + showSourceLocationLink && file ? `${file}${line ? `, ${line}` : ''}` : null + + // Because we want an ellipsis on the left-hand side (e.g. "...longfilename.tex"), the + // `log-entry-location` class has text laid out from right-to-left using the CSS + // rule `direction: rtl;`. + // This works most of the times, except when the first character of the filename is considered + // a punctuation mark, like `/` (e.g. `/foo/bar/baz.sty`). In this case, because of + // right-to-left writing rules, the punctuation mark is moved to the right-side of the string, + // resulting in `...bar/baz.sty/` instead of `...bar/baz.sty`. + // To avoid this edge-case, we wrap the `logLocationLinkText` in two directional formatting + // characters: + // * \u202A LEFT-TO-RIGHT EMBEDDING Treat the following text as embedded left-to-right. + // * \u202C POP DIRECTIONAL FORMATTING End the scope of the last LRE, RLE, RLO, or LRO. + // This essentially tells the browser that, althought the text is laid out from right-to-left, + // the wrapped portion of text should follow left-to-right writing rules. + const formattedLocationText = locationText ? ( + + {`\u202A${locationText}\u202C`} + + ) : null + + const headerTitleText = logType ? `${logType} ${headerTitle}` : headerTitle + + return ( +
+ + + +
+

{headerTitleText}

+ {locationSpanOverflown && formattedLocationText && locationText ? ( + + {formattedLocationText} + + ) : ( + formattedLocationText + )} +
+
+ {showSourceLocationLink && ( + + + + )} + {actionComponents.map(({ import: { default: Component }, path }) => ( + + ))} +
+
+ ) +} + +export default LogEntryHeader diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry.tsx new file mode 100644 index 0000000000..0986343131 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/log-entry.tsx @@ -0,0 +1,109 @@ +import { memo, MouseEventHandler, useCallback, useState } from 'react' +import HumanReadableLogsHints from '../../../../ide/human-readable-logs/HumanReadableLogsHints' +import { sendMB } from '@/infrastructure/event-tracking' +import { + ErrorLevel, + LogEntry as LogEntryData, + SourceLocation, +} from '@/features/pdf-preview/util/types' +import LogEntryHeader from './log-entry-header' +import PdfLogEntryContent from '@/features/pdf-preview/components/pdf-log-entry-content' + +function LogEntry({ + ruleId, + headerTitle, + rawContent, + logType, + formattedContent, + extraInfoURL, + level, + sourceLocation, + showSourceLocationLink = true, + entryAriaLabel = undefined, + contentDetails, + onSourceLocationClick, + index, + logEntry, + id, + alwaysExpandRawContent = false, +}: { + headerTitle: string | React.ReactNode + level: ErrorLevel + ruleId?: string + rawContent?: string + logType?: string + formattedContent?: React.ReactNode + extraInfoURL?: string | null + sourceLocation?: SourceLocation + showSourceLocationLink?: boolean + entryAriaLabel?: string + contentDetails?: string[] + onSourceLocationClick?: (sourceLocation: SourceLocation) => void + index?: number + logEntry?: LogEntryData + id?: string + alwaysExpandRawContent?: boolean +}) { + const [collapsed, setCollapsed] = useState(true) + + if (ruleId && HumanReadableLogsHints[ruleId]) { + const hint = HumanReadableLogsHints[ruleId] + formattedContent = hint.formattedContent(contentDetails) + extraInfoURL = hint.extraInfoURL + } + + const handleLogEntryLinkClick: MouseEventHandler = + useCallback( + event => { + event.preventDefault() + + if (onSourceLocationClick && sourceLocation) { + onSourceLocationClick(sourceLocation) + + const parts = sourceLocation?.file?.split('.') + const extension = + parts?.length && parts?.length > 1 ? parts.pop() : '' + sendMB('log-entry-link-click', { level, ruleId, extension }) + } + }, + [level, onSourceLocationClick, ruleId, sourceLocation] + ) + + return ( +
Date: Wed, 4 Jun 2025 10:47:18 +0100 Subject: [PATCH 095/259] Merge pull request #26100 from overleaf/dp-compile-timeout-paywall Add compile timeout paywall to new editor GitOrigin-RevId: 9742ae67b4103c72cc9d87852801ae8751f85d6d --- .../web/frontend/extracted-translations.json | 2 + .../pdf-preview/pdf-error-state.tsx | 112 ++++++++++++++---- .../pages/editor/pdf-error-state.scss | 13 +- services/web/locales/en.json | 2 + 4 files changed, 100 insertions(+), 29 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index cad43ed4e1..fda4b6368b 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -287,6 +287,8 @@ "compile_error_entry_description": "", "compile_error_handling": "", "compile_larger_projects": "", + "compile_limit_reached": "", + "compile_limit_upgrade_prompt": "", "compile_mode": "", "compile_terminated_by_user": "", "compiler": "", diff --git a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx index ef77c0fa5d..a4f53ae614 100644 --- a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state.tsx @@ -5,31 +5,37 @@ import { useRailContext } from '../../contexts/rail-context' import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider' import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' import { useIsNewEditorEnabled } from '../../utils/new-editor-utils' +import { upgradePlan } from '@/main/account-upgrade' +import classNames from 'classnames' function PdfErrorState() { const { loadingError } = usePdfPreviewContext() // TODO ide-redesign-cleanup: rename showLogs to something else and check usages - const { showLogs } = useCompileContext() - const { t } = useTranslation() - const { openTab: openRailTab } = useRailContext() + const { hasShortCompileTimeout, error, showLogs } = useCompileContext() const newEditor = useIsNewEditorEnabled() if (!newEditor || (!loadingError && !showLogs)) { return null } + if (hasShortCompileTimeout && error === 'timedout') { + return + } + + return +} + +const GeneralErrorState = () => { + const { t } = useTranslation() + const { openTab: openRailTab } = useRailContext() + return ( -
-
-
- -
-
-

{t('pdf_couldnt_compile')}

-

- {t('we_are_unable_to_generate_the_pdf_at_this_time')} -

-
+ {t('check_logs')} -
-
-
- - {t('why_might_this_happen')} + } + extraContent={ +
+
+ + {t('why_might_this_happen')} +
+
    +
  • {t('there_is_an_unrecoverable_latex_error')}
  • +
  • {t('the_document_environment_contains_no_content')}
  • +
  • {t('this_project_contains_a_file_called_output')}
  • +
-
    -
  • {t('there_is_an_unrecoverable_latex_error')}
  • -
  • {t('the_document_environment_contains_no_content')}
  • -
  • {t('this_project_contains_a_file_called_output')}
  • -
-
-
+ } + /> ) } +const CompileTimeoutErrorState = () => { + const { t } = useTranslation() + + return ( + upgradePlan('compile-timeout')} + > + {t('upgrade')} + + } + /> + ) +} + +const ErrorState = ({ + title, + description, + iconType, + actions, + iconClassName, + extraContent, +}: { + title: string + description: string + iconType: string + actions: React.ReactNode + iconClassName?: string + extraContent?: React.ReactNode +}) => { + return ( +
+
+
+ +
+
+

{title}

+

{description}

+
+ {actions} +
+ {extraContent} +
+ ) +} export default PdfErrorState diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf-error-state.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf-error-state.scss index c1da6ab431..aee036f775 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf-error-state.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf-error-state.scss @@ -33,9 +33,7 @@ padding: 0 var(--spacing-09) var(--spacing-09) var(--spacing-09); } -.pdf-error-state-warning-icon { - background-color: var(--bg-danger-03); - color: var(--content-danger); +.pdf-error-state-icon { width: 80px; height: 80px; display: flex; @@ -43,6 +41,15 @@ justify-content: center; border-radius: 100%; + .material-symbols { + font-size: 80px; + } +} + +.pdf-error-state-warning-icon { + background-color: var(--bg-danger-03); + color: var(--content-danger); + .material-symbols { font-size: 32px; } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index daa2317683..4404e57553 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -370,6 +370,8 @@ "compile_error_entry_description": "An error which prevented this project from compiling", "compile_error_handling": "Compile error handling", "compile_larger_projects": "Compile larger projects", + "compile_limit_reached": "Compile limit reached", + "compile_limit_upgrade_prompt": "Your document took longer than the free plan’s compile window. Upgrade to Overleaf Premium for extended compile durations, priority build servers, and uninterrupted LaTeX processing—so you can focus on writing, not waiting.", "compile_mode": "Compile mode", "compile_servers": "Compile servers", "compile_servers_info_new": "The servers used to compile your project. Compiles for users on paid plans always run on the fastest available servers.", From 62714d995dcaa6044ddc2c7791b9a5d9646fcaf7 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Wed, 4 Jun 2025 15:52:21 +0200 Subject: [PATCH 096/259] Revert "Reinitialise Writefull toolbar after buying AI assist (#25741)" (#26144) This reverts commit 7247ae45ca7de7f1f3778b1b22f49e2ff840a7ef. GitOrigin-RevId: c6dc1a073ce3d0f9703e426df1c12fa1c7ffac5c --- .../web/frontend/js/shared/context/types/writefull-instance.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/frontend/js/shared/context/types/writefull-instance.ts b/services/web/frontend/js/shared/context/types/writefull-instance.ts index 18b6d08616..120590e668 100644 --- a/services/web/frontend/js/shared/context/types/writefull-instance.ts +++ b/services/web/frontend/js/shared/context/types/writefull-instance.ts @@ -42,5 +42,4 @@ export interface WritefullAPI { ): void openTableGenerator(): void openEquationGenerator(): void - refreshSession(): void } From e3310e2358e5f59b14ee6c1d639c6e8570345ec3 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 4 Jun 2025 07:55:45 -0700 Subject: [PATCH 097/259] Merge pull request #26117 from overleaf/sg-money-back-wording Update en.json GitOrigin-RevId: 5b02970e6344b65e37c49c196c9e3c89b1555c75 --- services/web/locales/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 4404e57553..78ea2d6463 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -260,7 +260,7 @@ "can_view_content": "Can view content", "cancel": "Cancel", "cancel_add_on": "Cancel add-on", - "cancel_anytime": "We’re confident that you’ll love __appName__, but if not you can cancel anytime. We’ll give you your money back, no questions asked, if you let us know within 30 days.", + "cancel_anytime": "We’re confident that you’ll love __appName__, but if not, you can cancel anytime and request your money back, hassle free, within 30 days.", "cancel_my_account": "Cancel my subscription", "cancel_my_subscription": "Cancel my subscription", "cancel_personal_subscription_first": "You already have an individual subscription, would you like us to cancel this first before joining the group licence?", @@ -1349,7 +1349,7 @@ "missing_field_for_entry": "Missing field for", "missing_fields_for_entry": "Missing fields for", "missing_payment_details": "Missing payment details", - "money_back_guarantee": "30-day money back guarantee, no questions asked", + "money_back_guarantee": "30-day money back guarantee, hassle free", "month": "month", "month_plural": "months", "monthly": "Monthly", From ca109044849eda9c4c86fed187783e7300e5a017 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 4 Jun 2025 07:56:01 -0700 Subject: [PATCH 098/259] Merge pull request #26027 from overleaf/mf-admin-panel-stripe [web] Update admin panel with Stripe subscription data GitOrigin-RevId: fc4f773c5d6d2eae206a791c1ad40d8ccbf766e7 --- services/web/frontend/js/utils/meta.ts | 13 +++++++++++++ services/web/types/admin/subscription.ts | 14 +++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 2e8df94273..1dd4af88e0 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -54,6 +54,7 @@ import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-na import { FooterMetadata } from '@/features/ui/components/types/footer-metadata' import type { ScriptLogType } from '../../../modules/admin-panel/frontend/js/features/script-logs/script-log' import { ActiveExperiment } from './labs-utils' +import { Subscription as AdminSubscription } from '../../../types/admin/subscription' export interface Meta { 'ol-ExposedSettings': ExposedSettings @@ -61,6 +62,7 @@ export interface Meta { string, { annual: string; monthly: string; annualDividedByTwelve: string } > + 'ol-adminSubscription': AdminSubscription 'ol-aiAssistViaWritefullSource': string 'ol-allInReconfirmNotificationPeriods': UserEmailData[] 'ol-allowedExperiments': string[] @@ -201,6 +203,16 @@ export interface Meta { 'ol-recommendedCurrency': CurrencyCode 'ol-reconfirmationRemoveEmail': string 'ol-reconfirmedViaSAML': string + 'ol-recurlyAccount': + | { + code: string + error?: undefined + } + | { + error: boolean + code?: undefined + } + | undefined 'ol-recurlyApiKey': string 'ol-recurlySubdomain': string 'ol-ro-mirror-on-client-no-local-storage': boolean @@ -229,6 +241,7 @@ export interface Meta { 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string 'ol-stripeApiKey': string + 'ol-stripeCustomerId': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionId': string diff --git a/services/web/types/admin/subscription.ts b/services/web/types/admin/subscription.ts index bbcdd3b953..ad05fbac40 100644 --- a/services/web/types/admin/subscription.ts +++ b/services/web/types/admin/subscription.ts @@ -1,7 +1,15 @@ -import { GroupPolicy } from '../subscription/dashboard/subscription' +import { + GroupPolicy, + PaymentProvider, +} from '../subscription/dashboard/subscription' import { SSOConfig } from '../subscription/sso' import { TeamInvite } from '../team-invite' +type RecurlyAdminClientPaymentProvider = Record +type StripeAdminClientPaymentProvider = PaymentProvider & { + service: 'stripe' +} + export type Subscription = { _id: string teamInvites: TeamInvite[] @@ -13,4 +21,8 @@ export type Subscription = { managedUsersEnabled: boolean v1_id: number salesforce_id: string + recurlySubscription_id?: string + paymentProvider: + | RecurlyAdminClientPaymentProvider + | StripeAdminClientPaymentProvider } From cd10a31a16fd0d982583d37195c20c756d309e28 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Wed, 4 Jun 2025 17:33:05 +0200 Subject: [PATCH 099/259] [server-ce] fix direct invocation of create-user.mjs script in web (#26152) GitOrigin-RevId: 9c7917e489dc8f3651f4ccf88a740ad60b6b4437 --- .../modules/server-ce-scripts/scripts/create-user.mjs | 10 ++++++++++ .../test/acceptance/src/ServerCEScriptsTests.mjs | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/services/web/modules/server-ce-scripts/scripts/create-user.mjs b/services/web/modules/server-ce-scripts/scripts/create-user.mjs index 219578b4b0..7c29ca7f5f 100644 --- a/services/web/modules/server-ce-scripts/scripts/create-user.mjs +++ b/services/web/modules/server-ce-scripts/scripts/create-user.mjs @@ -48,3 +48,13 @@ Please visit the following URL to set a password for ${email} and log in: ) }) } + +if (filename === process.argv[1]) { + try { + await main() + process.exit(0) + } catch (error) { + console.error({ error }) + process.exit(1) + } +} diff --git a/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs b/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs index a02e9a0e68..f8d458ff6b 100644 --- a/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs +++ b/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs @@ -99,6 +99,14 @@ describe('ServerCEScripts', function () { expect(await getUser('foo@bar.com')).to.deep.equal({ isAdmin: false }) }) + it('should also work with mjs version', async function () { + const out = run( + 'node modules/server-ce-scripts/scripts/create-user.mjs --email=foo@bar.com' + ) + expect(out).to.include('/user/activate?token=') + expect(await getUser('foo@bar.com')).to.deep.equal({ isAdmin: false }) + }) + it('should create an admin user with --admin flag', async function () { run( 'node modules/server-ce-scripts/scripts/create-user.js --admin --email=foo@bar.com' From 0037b0b3fc0290954e50e751dc995449e0a54b65 Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Thu, 5 Jun 2025 01:04:13 +0000 Subject: [PATCH 100/259] auto update translation GitOrigin-RevId: f0b783bc74dc2212d330305600c8f3d16d27eef3 --- services/web/locales/da.json | 2 +- services/web/locales/de.json | 1 + services/web/locales/fr.json | 1 + services/web/locales/sv.json | 1 + services/web/locales/zh-CN.json | 2 +- 5 files changed, 5 insertions(+), 2 deletions(-) diff --git a/services/web/locales/da.json b/services/web/locales/da.json index a984ea8605..3d8b52e547 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -432,6 +432,7 @@ "disconnected": "Forbindelsen blev afbrudt", "discount_of": "Rabat på __amount__", "discover_latex_templates_and_examples": "Opdag LaTeX skabeloner og eksempler til at hjælpe med alt fra at skrive en artikel til at bruge en specifik LaTeX pakke.", + "dismiss_error_popup": "Afvis første fejlmeddelelse", "display_deleted_user": "Vis slettede brugere", "do_not_have_acct_or_do_not_want_to_link": "Hvis du ikke har en __appName__-konto, eller hvis du ikke vil kæde den sammen med din __institutionName__-konto, klik venligst __clickText__.", "do_not_link_accounts": "Kæd ikke kontoer sammen", @@ -1522,7 +1523,6 @@ "resend": "Gensend", "resend_confirmation_code": "Gensend bekræftelseskode", "resend_confirmation_email": "Gensend bekræftelsesmail", - "resend_email": "Gensend e-mail", "resend_group_invite": "Gensend gruppeinvitation", "resend_link_sso": "Gensend SSO invitation", "resend_managed_user_invite": "Gensend invitation til styrede brugere", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index a6d68345ba..11129073df 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -312,6 +312,7 @@ "disable_stop_on_first_error": "„Anhalten beim ersten Fehler“ deaktivieren", "disconnected": "Nicht verbunden", "discount_of": "__amount__ Rabatt", + "dismiss_error_popup": "Erste Fehlermeldung schließen", "do_not_have_acct_or_do_not_want_to_link": "Wenn du kein __appName__-Konto hast oder nicht mit deinem __institutionName__-Konto verknüpfen möchtest, klicke auf „__clickText__“.", "do_not_link_accounts": "Konten nicht verknüpfen", "do_you_want_to_change_your_primary_email_address_to": "Willst Du deine primäre E-Mail-Adresse in __email__ ändern?", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 2e80ea2132..c081b84651 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -344,6 +344,7 @@ "disable_stop_on_first_error": "Désactiver “Arrêter à la première erreur”", "disconnected": "Déconnecté", "discount_of": "Remise de __amount__", + "dismiss_error_popup": "Ignorer l’alerte de première erreur", "do_not_have_acct_or_do_not_want_to_link": "Si vous n’avez pas de compte __appName__ ou si vous ne souhaitez pas le lier à votre compte __institutionName__, veuillez cliquer __clickText__.", "do_not_link_accounts": "Ne pas lier les comptes", "do_you_want_to_change_your_primary_email_address_to": "Voulez-vous définir __email__ comme votre adresse email principale ?", diff --git a/services/web/locales/sv.json b/services/web/locales/sv.json index ab9f615050..9ed626fe36 100644 --- a/services/web/locales/sv.json +++ b/services/web/locales/sv.json @@ -208,6 +208,7 @@ "dictionary": "Ordbok", "disable_stop_on_first_error": "Inaktivera \"Stopp vid första fel\"", "disconnected": "Frånkopplad", + "dismiss_error_popup": "Avfärda varning om första fel", "do_not_have_acct_or_do_not_want_to_link": "Om du inte har ett __appName__-konto, eller om du inte vill länka till ditt __institutionName__-konto, vänligen klicka på __clickText__.", "do_not_link_accounts": "Länka ej konton", "documentation": "Dokumentation", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index e4704f2e9d..44e303d64d 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -518,6 +518,7 @@ "discover_latex_templates_and_examples": "探索 LaTeX 模板和示例,以帮助完成从撰写期刊文章到使用特定 LaTeX 包的所有工作。", "discover_the_fastest_way_to_search_and_cite": "探索搜索和引用的最快方法", "discover_why_over_people_worldwide_trust_overleaf": "了解为什么全世界有超过__count__万人信任 Overleaf 并把工作交给它。", + "dismiss_error_popup": "忽略第一个错误提示", "display": "显示", "display_deleted_user": "显示已删除的用户", "display_math": "显示数学公式", @@ -1791,7 +1792,6 @@ "resend": "重发", "resend_confirmation_code": "重新发送确认码", "resend_confirmation_email": "重新发送确认电子邮件", - "resend_email": "重新发送电子邮件", "resend_group_invite": "重新发送群组邀请", "resend_link_sso": "重新发送 SSO 邀请", "resend_managed_user_invite": "重新发送托管用户邀请", From 11e410c9c03bfc91737e176d35f2760cb4b7182b Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Thu, 5 Jun 2025 09:28:57 +0200 Subject: [PATCH 101/259] Merge pull request #26163 from overleaf/revert-25745-ac-bs5-metrics-module Revert "[web] Migrate metrics module Pug files to Bootstrap 5 (#25745)" GitOrigin-RevId: b97eecc2232f56833391fb789902f9a85936c365 --- services/web/.prettierignore | 2 +- .../frontend/stylesheets/app/admin-hub.less | 156 ++ .../institution-hub.less} | 28 +- .../metrics/metrics.scss => app/metrics.less} | 58 +- .../web/frontend/stylesheets/app/portals.less | 34 - .../publisher-hub.less} | 36 +- .../stylesheets/bootstrap-5/modules/all.scss | 7 - .../modules/metrics/admin-hub.scss | 93 -- .../modules/metrics/daterange-picker.scss | 617 -------- .../bootstrap-5/pages/admin/admin.scss | 5 - .../bootstrap-5/pages/project-list.scss | 2 +- .../components/daterange-picker.less | 656 ++++++++ .../nvd3.scss => components/nvd3.less} | 1368 +++++++++-------- .../nvd3_override.less} | 5 +- .../web/frontend/stylesheets/main-style.less | 7 + 15 files changed, 1557 insertions(+), 1517 deletions(-) create mode 100644 services/web/frontend/stylesheets/app/admin-hub.less rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/institution-hub.scss => app/institution-hub.less} (52%) rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/metrics.scss => app/metrics.less} (78%) rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/publisher-hub.scss => app/publisher-hub.less} (52%) delete mode 100644 services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss delete mode 100644 services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss create mode 100644 services/web/frontend/stylesheets/components/daterange-picker.less rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/nvd3.scss => components/nvd3.less} (78%) rename services/web/frontend/stylesheets/{bootstrap-5/modules/metrics/nvd3_override.scss => components/nvd3_override.less} (74%) diff --git a/services/web/.prettierignore b/services/web/.prettierignore index 39282c64c2..f4be187b87 100644 --- a/services/web/.prettierignore +++ b/services/web/.prettierignore @@ -6,7 +6,7 @@ frontend/js/vendor modules/**/frontend/js/vendor public/js public/minjs -frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss +frontend/stylesheets/components/nvd3.less frontend/js/features/source-editor/lezer-latex/latex.mjs frontend/js/features/source-editor/lezer-latex/latex.terms.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs diff --git a/services/web/frontend/stylesheets/app/admin-hub.less b/services/web/frontend/stylesheets/app/admin-hub.less new file mode 100644 index 0000000000..bae3312447 --- /dev/null +++ b/services/web/frontend/stylesheets/app/admin-hub.less @@ -0,0 +1,156 @@ +.hub-header { + h2 { + display: inline-block; + } + a { + color: @ol-dark-green; + } + i { + font-size: 30px; + } + .dropdown { + margin-right: 10px; + } +} +.admin-item { + position: relative; + margin-bottom: 60px; + .section-title { + text-transform: capitalize; + } + .alert-danger { + color: @ol-red; + } +} +.hidden-chart-section { + display: none; +} +.hub-circle { + display: inline-block; + background-color: @accent-color-secondary; + border-radius: 50%; + width: 160px; + height: 160px; + text-align: center; + //padding-top: 160px / 6.4; + img { + height: 160px - 160px / 3.2; + } + padding-top: 50px; + color: white; +} +.hub-circle-number { + display: block; + font-size: 36px; + font-weight: 900; + line-height: 1; +} +.hub-big-number { + float: left; + font-size: 32px; + font-weight: 900; + line-height: 40px; + color: @accent-color-secondary; +} +.hub-big-number, +.hub-number-label { + display: block; +} +.hub-metric-link { + position: absolute; + top: 9px; + right: 0; + a { + color: @accent-color-secondary; + } + i { + margin-right: 5px; + } +} +.custom-donut-container { + svg { + max-width: 700px; + margin: auto; + } + .chart-center-text { + font-family: @font-family-sans-serif; + font-size: 40px; + font-weight: bold; + fill: @accent-color-secondary; + text-anchor: middle; + } + + .nv-legend-text { + font-family: @font-family-sans-serif; + font-size: 14px; + } +} +.chart-no-center-text { + .chart-center-text { + display: none; + } +} + +.superscript { + font-size: @font-size-large; +} + +.admin-page { + summary { + // firefox does not show markers for block items + display: list-item; + } +} + +.material-switch { + input[type='checkbox'] { + display: none; + + &:checked + label::before { + background: inherit; + opacity: 0.5; + } + &:checked + label::after { + background: inherit; + left: 20px; + } + &:disabled + label { + opacity: 0.5; + cursor: not-allowed; + } + } + + label { + cursor: pointer; + height: 0; + position: relative; + width: 40px; + + &:before { + background: rgb(0, 0, 0); + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); + border-radius: 8px; + content: ''; + height: 16px; + margin-top: -2px; + position: absolute; + opacity: 0.3; + transition: all 0.2s ease-in-out; + width: 40px; + } + + &:after { + background: rgb(255, 255, 255); + border-radius: 16px; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + content: ''; + height: 24px; + left: -4px; + margin-top: -2px; + position: absolute; + top: -4px; + transition: all 0.2s ease-in-out; + width: 24px; + } + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss b/services/web/frontend/stylesheets/app/institution-hub.less similarity index 52% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss rename to services/web/frontend/stylesheets/app/institution-hub.less index 67cbe580e4..cb705e4b99 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/institution-hub.scss +++ b/services/web/frontend/stylesheets/app/institution-hub.less @@ -1,54 +1,38 @@ #institution-hub { - .section-header { + .section_header { .dropdown { - margin-right: var(--spacing-04); + margin-right: 10px; } } #usage { .recent-activity { .overbox { - @include body-base; + font-size: 16px; } - .hub-big-number, .hub-number-label, .worked-on { display: block; width: 50%; } - .hub-big-number { - padding-right: var(--spacing-04); + padding-right: 10px; text-align: right; } - .hub-number-label, .worked-on { float: right; } - .hub-number-label { &:nth-child(odd) { - margin-top: var(--spacing-06); + margin-top: 16px; } } - .worked-on { - color: var(--content-secondary); + color: @text-small-color; font-style: italic; } } } - - .overbox { - margin: 0; - padding: var(--spacing-10) var(--spacing-07); - background: var(--white); - border: 1px solid var(--content-disabled); - - &.overbox-small { - padding: var(--spacing-04); - } - } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss b/services/web/frontend/stylesheets/app/metrics.less similarity index 78% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss rename to services/web/frontend/stylesheets/app/metrics.less index 32cde9c522..5256b8a8bd 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/metrics.scss +++ b/services/web/frontend/stylesheets/app/metrics.less @@ -1,6 +1,6 @@ #metrics { max-width: none; - padding: 0 var(--spacing-09); + padding: 0 30px; width: auto; svg.nvd3-svg { @@ -9,18 +9,17 @@ .overbox { margin: 0; - padding: var(--spacing-10) var(--spacing-07); + padding: 40px 20px; background: #fff; border: 1px solid #dfdfdf; - .box { - padding-bottom: var(--spacing-09); + padding-bottom: 30px; overflow: hidden; - margin-bottom: var(--spacing-10); - border-bottom: 1px solid rgb(216 216 216); + margin-bottom: 40px; + border-bottom: 1px solid rgb(216, 216, 216); .header { - margin-bottom: var(--spacing-07); + margin-bottom: 20px; h4 { font-size: 19px; @@ -28,14 +27,10 @@ } } } - - &.overbox-small { - padding: var(--spacing-04); - } } .print-button { - margin-right: var(--spacing-04); + margin-right: 10px; font-size: 20px; } @@ -45,17 +40,21 @@ } .metric-col { - padding: var(--spacing-06); + padding: 15px; + } + + .metric-header-container { + h4 { + margin-bottom: 0; + } } svg { display: block; height: 250px; - text { font-family: 'Open Sans', sans-serif; } - &:not(:root) { overflow: visible; } @@ -80,10 +79,6 @@ // BEGIN: Metrics header .metric-header-container { - h4 { - margin-bottom: 0; - } - > h4 { margin-top: 0; margin-bottom: 0; @@ -94,14 +89,12 @@ font-size: 0.5em; } } - // END: Metrics header // BEGIN: Metrics footer .metric-footer-container { text-align: center; } - // END: Metrics footer // BEGIN: Metrics overlays @@ -114,7 +107,7 @@ height: 100%; width: 100%; padding: 16px; /* 15px of .metric-col padding + 1px border */ - padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding */ + padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding*/ } .metric-overlay-loading { @@ -136,20 +129,19 @@ width: 100%; height: 100%; } - // END: Metrics overlays } #metrics-header { - @include media-breakpoint-up(lg) { - margin-bottom: var(--spacing-09); + @media (min-width: 1200px) { + margin-bottom: 30px; } h3 { display: inline-block; } - .section-header { + .section_header { margin-bottom: 0; } @@ -170,11 +162,9 @@ #dates-container { display: inline-block; - .daterangepicker { - margin-right: var(--spacing-06); + margin-right: 15px; } - #metrics-dates { padding: 0; } @@ -182,10 +172,14 @@ } #metrics-footer { - margin-top: var(--spacing-09); + margin-top: 30px; text-align: center; } -body.print-loading #metrics .metric-col { - opacity: 0.5; +body.print-loading { + #metrics { + .metric-col { + opacity: 0.5; + } + } } diff --git a/services/web/frontend/stylesheets/app/portals.less b/services/web/frontend/stylesheets/app/portals.less index b69176b05f..9dfd4a57b7 100644 --- a/services/web/frontend/stylesheets/app/portals.less +++ b/services/web/frontend/stylesheets/app/portals.less @@ -141,38 +141,4 @@ } } } - .hub-circle { - display: inline-block; - background-color: @green-70; - border-radius: 50%; - width: 160px; - height: 160px; - text-align: center; - padding-top: 50px; - color: white; - } - .hub-circle-number { - display: block; - font-size: 36px; - font-weight: 900; - line-height: 1; - } - .custom-donut-container { - svg { - max-width: 700px; - margin: auto; - } - .chart-center-text { - font-family: @font-family-sans-serif; - font-size: 40px; - font-weight: bold; - fill: @green-70; - text-anchor: middle; - } - - .nv-legend-text { - font-family: @font-family-sans-serif; - font-size: 14px; - } - } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss b/services/web/frontend/stylesheets/app/publisher-hub.less similarity index 52% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss rename to services/web/frontend/stylesheets/app/publisher-hub.less index f59b33e6ef..8d7e5ea7eb 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/publisher-hub.scss +++ b/services/web/frontend/stylesheets/app/publisher-hub.less @@ -2,66 +2,48 @@ .recent-activity { .hub-big-number { text-align: right; - padding-right: var(--spacing-06); + padding-right: 15px; } } #templates-container { width: 100%; - tr { - border: 1px solid var(--bg-light-secondary); + border: 1px solid @ol-blue-gray-0; } - td { - padding: var(--spacing-06); + padding: 15px; } - td:last-child { text-align: right; } - .title-cell { max-width: 300px; } - .title-text { font-weight: bold; } - .hub-big-number { width: 60%; - padding-right: var(--spacing-04); - padding-top: var(--spacing-04); + padding-right: 10px; + padding-top: 10px; text-align: right; } - .hub-number-label, .since { width: 35%; float: right; - - @include media-breakpoint-down(md) { + @media screen and (max-width: 940px) { float: none; } } - .hub-long-big-number { - padding-right: var(--spacing-10); + padding-right: 40px; } - .created-on { - @include body-sm; - - color: var(--content-disabled); + color: @gray-light; font-style: italic; + font-size: 14px; } } - - .overbox { - margin: 0; - padding: var(--spacing-10) var(--spacing-07); - background: var(--white); - border: 1px solid var(--content-disabled); - } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss index 01d58c8c20..b92eb80551 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/all.scss @@ -1,10 +1,3 @@ -@import 'metrics/admin-hub'; -@import 'metrics/daterange-picker'; -@import 'metrics/institution-hub'; -@import 'metrics/metrics'; -@import 'metrics/nvd3'; -@import 'metrics/nvd3_override'; -@import 'metrics/publisher-hub'; @import 'third-party-references'; @import 'symbol-palette'; @import 'writefull'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss deleted file mode 100644 index 3e6576cf92..0000000000 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/admin-hub.scss +++ /dev/null @@ -1,93 +0,0 @@ -.hub-header { - h2 { - display: inline-block; - } - - .dropdown { - margin-right: var(--spacing-04); - } -} - -.admin-item { - position: relative; - margin-bottom: var(--spacing-12); - - .section-title { - text-transform: capitalize; - } - - .alert-danger { - color: var(--content-danger); - } -} - -.hidden-chart-section { - display: none; -} - -.hub-circle { - display: inline-block; - background-color: var(--green-70); - border-radius: 50%; - width: 160px; - height: 160px; - text-align: center; - padding-top: 50px; - color: white; -} - -.hub-circle-number { - display: block; - font-size: 36px; - font-weight: 900; - line-height: 1; -} - -.hub-big-number { - float: left; - font-size: 32px; - font-weight: 900; - line-height: 40px; - color: var(--green-70); -} - -.hub-big-number, -.hub-number-label { - display: block; -} - -.hub-metric-link { - position: absolute; - top: 9px; - right: 0; - - i { - margin-right: 5px; - } -} - -.custom-donut-container { - svg { - max-width: 700px; - margin: auto; - } - - .chart-center-text { - font-family: $font-family-sans-serif; - font-size: 40px; - font-weight: bold; - fill: var(--green-70); - text-anchor: middle; - } - - .nv-legend-text { - font-family: $font-family-sans-serif; - font-size: 14px; - } -} - -.chart-no-center-text { - .chart-center-text { - display: none; - } -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss deleted file mode 100644 index 33e466bd91..0000000000 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/daterange-picker.scss +++ /dev/null @@ -1,617 +0,0 @@ -// A stylesheet for use with Bootstrap 3.x -// @author: Dan Grossman http://www.dangrossman.info/ -// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved. -// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php -// @website: https://www.improvely.com/ - -/* stylelint-disable selector-class-pattern */ - -// VARIABLES - -// Settings - -// The class name to contain everything within. -$arrow-size: 7px; - -// Colors -$daterangepicker-color: $green-50; -$daterangepicker-bg-color: #fff; -$daterangepicker-cell-color: $daterangepicker-color; -$daterangepicker-cell-border-color: transparent; -$daterangepicker-cell-bg-color: $daterangepicker-bg-color; -$daterangepicker-cell-hover-color: $daterangepicker-color; -$daterangepicker-cell-hover-border-color: $daterangepicker-cell-border-color; -$daterangepicker-cell-hover-bg-color: #eee; -$daterangepicker-in-range-color: #000; -$daterangepicker-in-range-border-color: transparent; -$daterangepicker-in-range-bg-color: #ebf4f8; -$daterangepicker-active-color: #fff; -$daterangepicker-active-bg-color: #138a07; -$daterangepicker-active-border-color: transparent; -$daterangepicker-unselected-color: #999; -$daterangepicker-unselected-border-color: transparent; -$daterangepicker-unselected-bg-color: #fff; - -// daterangepicker -$daterangepicker-width: 278px; -$daterangepicker-padding: 4px; -$daterangepicker-z-index: 3000; -$daterangepicker-border-size: 1px; -$daterangepicker-border-color: #ccc; -$daterangepicker-border-radius: 4px; - -// Calendar -$daterangepicker-calendar-margin: $daterangepicker-padding; -$daterangepicker-calendar-bg-color: $daterangepicker-bg-color; -$daterangepicker-calendar-border-size: 1px; -$daterangepicker-calendar-border-color: $daterangepicker-bg-color; -$daterangepicker-calendar-border-radius: $daterangepicker-border-radius; - -// Calendar Cells -$daterangepicker-cell-size: 20px; -$daterangepicker-cell-width: $daterangepicker-cell-size; -$daterangepicker-cell-height: $daterangepicker-cell-size; -$daterangepicker-cell-border-radius: $daterangepicker-calendar-border-radius; -$daterangepicker-cell-border-size: 1px; - -// Dropdowns -$daterangepicker-dropdown-z-index: $daterangepicker-z-index + 1; - -// Controls -$daterangepicker-control-height: 30px; -$daterangepicker-control-line-height: $daterangepicker-control-height; -$daterangepicker-control-color: #555; -$daterangepicker-control-border-size: 1px; -$daterangepicker-control-border-color: #ccc; -$daterangepicker-control-border-radius: 4px; -$daterangepicker-control-active-border-size: 1px; -$daterangepicker-control-active-border-color: $green-50; -$daterangepicker-control-active-border-radius: $daterangepicker-control-border-radius; -$daterangepicker-control-disabled-color: #ccc; - -// Ranges -$daterangepicker-ranges-color: $green-50; -$daterangepicker-ranges-bg-color: daterangepicker-ranges-color; -$daterangepicker-ranges-border-size: 1px; -$daterangepicker-ranges-border-color: $daterangepicker-ranges-bg-color; -$daterangepicker-ranges-border-radius: $daterangepicker-border-radius; -$daterangepicker-ranges-hover-color: #fff; -$daterangepicker-ranges-hover-bg-color: $daterangepicker-ranges-color; -$daterangepicker-ranges-hover-border-size: $daterangepicker-ranges-border-size; -$daterangepicker-ranges-hover-border-color: $daterangepicker-ranges-hover-bg-color; -$daterangepicker-ranges-hover-border-radius: $daterangepicker-border-radius; -$daterangepicker-ranges-active-border-size: $daterangepicker-ranges-border-size; -$daterangepicker-ranges-active-border-color: $daterangepicker-ranges-bg-color; -$daterangepicker-ranges-active-border-radius: $daterangepicker-border-radius; - -// STYLESHEETS -.daterangepicker { - position: absolute; - color: $daterangepicker-color; - background-color: $daterangepicker-bg-color; - border-radius: $daterangepicker-border-radius; - width: $daterangepicker-width; - padding: $daterangepicker-padding; - margin-top: $daterangepicker-border-size; - - // TODO: Should these be parameterized?? - // top: 100px; - // left: 20px; - - $arrow-prefix-size: $arrow-size; - $arrow-suffix-size: ($arrow-size - $daterangepicker-border-size); - - &::before, - &::after { - position: absolute; - display: inline-block; - border-bottom-color: rgb(0 0 0 / 20%); - content: ''; - } - - &::before { - top: -$arrow-prefix-size; - border-right: $arrow-prefix-size solid transparent; - border-left: $arrow-prefix-size solid transparent; - border-bottom: $arrow-prefix-size solid $daterangepicker-border-color; - } - - &::after { - top: -$arrow-suffix-size; - border-right: $arrow-suffix-size solid transparent; - border-bottom: $arrow-suffix-size solid $daterangepicker-bg-color; - border-left: $arrow-suffix-size solid transparent; - } - - &.opensleft { - &::before { - // TODO: Make this relative to prefix size. - right: $arrow-prefix-size + 2px; - } - - &::after { - // TODO: Make this relative to suffix size. - right: $arrow-suffix-size + 4px; - } - } - - &.openscenter { - &::before { - left: 0; - right: 0; - width: 0; - margin-left: auto; - margin-right: auto; - } - - &::after { - left: 0; - right: 0; - width: 0; - margin-left: auto; - margin-right: auto; - } - } - - &.opensright { - &::before { - // TODO: Make this relative to prefix size. - left: $arrow-prefix-size + 2px; - } - - &::after { - // TODO: Make this relative to suffix size. - left: $arrow-suffix-size + 4px; - } - } - - &.dropup { - margin-top: -5px; - - // NOTE: Note sure why these are special-cased. - &::before { - top: initial; - bottom: -$arrow-prefix-size; - border-bottom: initial; - border-top: $arrow-prefix-size solid $daterangepicker-border-color; - } - - &::after { - top: initial; - bottom: -$arrow-suffix-size; - border-bottom: initial; - border-top: $arrow-suffix-size solid $daterangepicker-bg-color; - } - } - - &.dropdown-menu { - max-width: none; - z-index: $daterangepicker-dropdown-z-index; - } - - &.single { - .ranges, - .calendar { - float: none; - } - } - - /* Calendars */ - &.show-calendar { - .calendar { - display: block; - } - } - - .calendar { - display: none; - max-width: $daterangepicker-width - ($daterangepicker-calendar-margin * 2); - margin: $daterangepicker-calendar-margin; - - &.single { - .calendar-table { - border: none; - } - } - - th, - td { - white-space: nowrap; - text-align: center; - - // TODO: Should this actually be hard-coded? - min-width: 32px; - } - } - - .calendar-table { - border: $daterangepicker-calendar-border-size solid - $daterangepicker-calendar-border-color; - padding: $daterangepicker-calendar-margin; - border-radius: $daterangepicker-calendar-border-radius; - background-color: $daterangepicker-calendar-bg-color; - } - - table { - width: 100%; - margin: 0; - } - - td, - th { - text-align: center; - width: $daterangepicker-cell-width; - height: $daterangepicker-cell-height; - border-radius: $daterangepicker-cell-border-radius; - border: $daterangepicker-cell-border-size solid - $daterangepicker-cell-border-color; - white-space: nowrap; - cursor: pointer; - - &.available { - &:hover { - background-color: $daterangepicker-cell-hover-bg-color; - border-color: $daterangepicker-cell-hover-border-color; - color: $daterangepicker-cell-hover-color; - } - } - - &.week { - font-size: 80%; - color: #ccc; - } - } - - td { - &.off { - &, - &.in-range, - &.start-date, - &.end-date { - background-color: $daterangepicker-unselected-bg-color; - border-color: $daterangepicker-unselected-border-color; - color: $daterangepicker-unselected-color; - } - } - - // Date Range - &.in-range { - background-color: $daterangepicker-in-range-bg-color; - border-color: $daterangepicker-in-range-border-color; - color: $daterangepicker-in-range-color; - - // TODO: Should this be static or should it be parameterized? - border-radius: 0; - } - - &.start-date { - border-radius: $daterangepicker-cell-border-radius 0 0 - $daterangepicker-cell-border-radius; - } - - &.end-date { - border-radius: 0 $daterangepicker-cell-border-radius - $daterangepicker-cell-border-radius 0; - } - - &.start-date.end-date { - border-radius: $daterangepicker-cell-border-radius; - } - - &.active { - &, - &:hover { - background-color: $daterangepicker-active-bg-color; - border-color: $daterangepicker-active-border-color; - color: $daterangepicker-active-color; - } - } - } - - th { - &.month { - width: auto; - } - } - - // Disabled Controls - td, - option { - &.disabled { - color: #999; - cursor: not-allowed; - text-decoration: line-through; - } - } - - select { - &.monthselect, - &.yearselect { - font-size: 12px; - padding: 1px; - height: auto; - margin: 0; - cursor: default; - } - - &.monthselect { - margin-right: 2%; - width: 56%; - } - - &.yearselect { - width: 40%; - } - - &.hourselect, - &.minuteselect, - &.secondselect, - &.ampmselect { - width: 50px; - margin-bottom: 0; - } - } - - // Text Input Controls (above calendar) - .input-mini { - border: $daterangepicker-control-border-size solid - $daterangepicker-control-border-color; - border-radius: $daterangepicker-control-border-radius; - color: $daterangepicker-control-color; - height: $daterangepicker-control-line-height; - line-height: $daterangepicker-control-height; - display: block; - vertical-align: middle; - - // TODO: Should these all be static, too?? - margin: 0 0 5px; - padding: 0 6px 0 28px; - width: 100%; - - &.active { - border: $daterangepicker-control-active-border-size solid - $daterangepicker-control-active-border-color; - border-radius: $daterangepicker-control-active-border-radius; - } - } - - .daterangepicker_input { - position: relative; - padding-left: 0; - - i { - position: absolute; - - // NOTE: These appear to be eyeballed to me... - left: 8px; - top: var(--spacing-04); - } - } - - &.rtl { - .input-mini { - padding-right: 28px; - padding-left: 6px; - } - - .daterangepicker_input i { - left: auto; - right: 8px; - } - } - - // Time Picker - .calendar-time { - text-align: center; - margin: 5px auto; - line-height: $daterangepicker-control-line-height; - position: relative; - padding-left: 28px; - - select { - &.disabled { - color: $daterangepicker-control-disabled-color; - cursor: not-allowed; - } - } - } -} - -// Predefined Ranges -.ranges { - font-size: 11px; - float: none; - margin: 4px; - text-align: left; - - ul { - list-style: none; - margin: 0 auto; - padding: 0; - width: 100%; - } - - li { - font-size: 13px; - background-color: $daterangepicker-ranges-bg-color; - border: $daterangepicker-ranges-border-size solid - $daterangepicker-ranges-border-color; - border-radius: $daterangepicker-ranges-border-radius; - color: $daterangepicker-ranges-color; - padding: 3px 12px; - margin-bottom: 8px; - cursor: pointer; - - &:hover { - background-color: $daterangepicker-ranges-hover-bg-color; - color: $daterangepicker-ranges-hover-color; - } - - &.active { - background-color: $daterangepicker-ranges-hover-bg-color; - border: $daterangepicker-ranges-hover-border-size solid - $daterangepicker-ranges-hover-border-color; - color: $daterangepicker-ranges-hover-color; - } - } -} - -/* Larger Screen Styling */ -@include media-breakpoint-up(sm) { - .daterangepicker { - .glyphicon { - /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */ - font-family: FontAwesome; - } - - .glyphicon-chevron-left::before { - content: '\f053'; - } - - .glyphicon-chevron-right::before { - content: '\f054'; - } - - .glyphicon-calendar::before { - content: '\f073'; - } - - width: auto; - - .ranges { - ul { - width: 160px; - } - } - - &.single { - .ranges { - ul { - width: 100%; - } - } - - .calendar.left { - clear: none; - } - - &.ltr { - .ranges, - .calendar { - float: left; - } - } - - &.rtl { - .ranges, - .calendar { - float: right; - } - } - } - - &.ltr { - direction: ltr; - text-align: left; - - .calendar { - &.left { - clear: left; - margin-right: 0; - - .calendar-table { - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - padding-right: 12px; - } - } - - &.right { - margin-left: 0; - - .calendar-table { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - } - } - - .left .daterangepicker_input { - padding-right: 12px; - } - - .ranges, - .calendar { - float: left; - } - } - - &.rtl { - direction: rtl; - text-align: right; - - .calendar { - &.left { - clear: right; - margin-left: 0; - - .calendar-table { - border-left: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - padding-left: 12px; - } - } - - &.right { - margin-right: 0; - - .calendar-table { - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - } - } - - .ranges, - .calendar { - text-align: right; - float: right; - } - } - } -} - -@include media-breakpoint-up(md) { - /* force the calendar to display on one row */ - .show-calendar { - min-width: 658px; /* width of all contained elements, IE/Edge fallback */ - width: max-content; - } - - .daterangepicker { - .ranges { - width: auto; - } - - &.ltr { - .ranges { - float: left; - clear: none !important; - } - } - - &.rtl { - .ranges { - float: right; - } - } - - .calendar { - clear: none !important; - } - } -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss index a4bfa532e3..e2c807e928 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss @@ -91,8 +91,3 @@ color: var(--yellow-50); } } - -.admin-page summary { - // firefox does not show markers for block items - display: list-item; -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss index 1bf487eeca..83b6fbd28a 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss @@ -85,7 +85,7 @@ } &:hover { - background-color: var(--bg-light-secondary); + background-color: $bg-light-secondary; } .welcome-message-card-img { diff --git a/services/web/frontend/stylesheets/components/daterange-picker.less b/services/web/frontend/stylesheets/components/daterange-picker.less new file mode 100644 index 0000000000..43e0e3ba55 --- /dev/null +++ b/services/web/frontend/stylesheets/components/daterange-picker.less @@ -0,0 +1,656 @@ +// +// A stylesheet for use with Bootstrap 3.x +// @author: Dan Grossman http://www.dangrossman.info/ +// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved. +// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php +// @website: https://www.improvely.com/ +// + +// +// VARIABLES +// + +// +// Settings + +// The class name to contain everything within. +@arrow-size: 7px; + +// +// Colors +@daterangepicker-color: @brand-primary; +@daterangepicker-bg-color: #fff; + +@daterangepicker-cell-color: @daterangepicker-color; +@daterangepicker-cell-border-color: transparent; +@daterangepicker-cell-bg-color: @daterangepicker-bg-color; + +@daterangepicker-cell-hover-color: @daterangepicker-color; +@daterangepicker-cell-hover-border-color: @daterangepicker-cell-border-color; +@daterangepicker-cell-hover-bg-color: #eee; + +@daterangepicker-in-range-color: #000; +@daterangepicker-in-range-border-color: transparent; +@daterangepicker-in-range-bg-color: #ebf4f8; + +@daterangepicker-active-color: #fff; +@daterangepicker-active-bg-color: #138a07; +@daterangepicker-active-border-color: transparent; + +@daterangepicker-unselected-color: #999; +@daterangepicker-unselected-border-color: transparent; +@daterangepicker-unselected-bg-color: #fff; + +// +// daterangepicker +@daterangepicker-width: 278px; +@daterangepicker-padding: 4px; +@daterangepicker-z-index: 3000; + +@daterangepicker-border-size: 1px; +@daterangepicker-border-color: #ccc; +@daterangepicker-border-radius: 4px; + +// +// Calendar +@daterangepicker-calendar-margin: @daterangepicker-padding; +@daterangepicker-calendar-bg-color: @daterangepicker-bg-color; + +@daterangepicker-calendar-border-size: 1px; +@daterangepicker-calendar-border-color: @daterangepicker-bg-color; +@daterangepicker-calendar-border-radius: @daterangepicker-border-radius; + +// +// Calendar Cells +@daterangepicker-cell-size: 20px; +@daterangepicker-cell-width: @daterangepicker-cell-size; +@daterangepicker-cell-height: @daterangepicker-cell-size; + +@daterangepicker-cell-border-radius: @daterangepicker-calendar-border-radius; +@daterangepicker-cell-border-size: 1px; + +// +// Dropdowns +@daterangepicker-dropdown-z-index: @daterangepicker-z-index + 1; + +// +// Controls +@daterangepicker-control-height: 30px; +@daterangepicker-control-line-height: @daterangepicker-control-height; +@daterangepicker-control-color: #555; + +@daterangepicker-control-border-size: 1px; +@daterangepicker-control-border-color: #ccc; +@daterangepicker-control-border-radius: 4px; + +@daterangepicker-control-active-border-size: 1px; +@daterangepicker-control-active-border-color: @brand-primary; +@daterangepicker-control-active-border-radius: @daterangepicker-control-border-radius; + +@daterangepicker-control-disabled-color: #ccc; + +// +// Ranges +@daterangepicker-ranges-color: @brand-primary; +@daterangepicker-ranges-bg-color: daterangepicker-ranges-color; + +@daterangepicker-ranges-border-size: 1px; +@daterangepicker-ranges-border-color: @daterangepicker-ranges-bg-color; +@daterangepicker-ranges-border-radius: @daterangepicker-border-radius; + +@daterangepicker-ranges-hover-color: #fff; +@daterangepicker-ranges-hover-bg-color: @daterangepicker-ranges-color; +@daterangepicker-ranges-hover-border-size: @daterangepicker-ranges-border-size; +@daterangepicker-ranges-hover-border-color: @daterangepicker-ranges-hover-bg-color; +@daterangepicker-ranges-hover-border-radius: @daterangepicker-border-radius; + +@daterangepicker-ranges-active-border-size: @daterangepicker-ranges-border-size; +@daterangepicker-ranges-active-border-color: @daterangepicker-ranges-bg-color; +@daterangepicker-ranges-active-border-radius: @daterangepicker-border-radius; + +// +// STYLESHEETS +// +.daterangepicker { + position: absolute; + color: @daterangepicker-color; + background-color: @daterangepicker-bg-color; + border-radius: @daterangepicker-border-radius; + width: @daterangepicker-width; + padding: @daterangepicker-padding; + margin-top: @daterangepicker-border-size; + + // TODO: Should these be parameterized?? + // top: 100px; + // left: 20px; + + @arrow-prefix-size: @arrow-size; + @arrow-suffix-size: (@arrow-size - @daterangepicker-border-size); + + &:before, + &:after { + position: absolute; + display: inline-block; + + border-bottom-color: rgba(0, 0, 0, 0.2); + content: ''; + } + + &:before { + top: -@arrow-prefix-size; + + border-right: @arrow-prefix-size solid transparent; + border-left: @arrow-prefix-size solid transparent; + border-bottom: @arrow-prefix-size solid @daterangepicker-border-color; + } + + &:after { + top: -@arrow-suffix-size; + + border-right: @arrow-suffix-size solid transparent; + border-bottom: @arrow-suffix-size solid @daterangepicker-bg-color; + border-left: @arrow-suffix-size solid transparent; + } + + &.opensleft { + &:before { + // TODO: Make this relative to prefix size. + right: @arrow-prefix-size + 2px; + } + + &:after { + // TODO: Make this relative to suffix size. + right: @arrow-suffix-size + 4px; + } + } + + &.openscenter { + &:before { + left: 0; + right: 0; + width: 0; + margin-left: auto; + margin-right: auto; + } + + &:after { + left: 0; + right: 0; + width: 0; + margin-left: auto; + margin-right: auto; + } + } + + &.opensright { + &:before { + // TODO: Make this relative to prefix size. + left: @arrow-prefix-size + 2px; + } + + &:after { + // TODO: Make this relative to suffix size. + left: @arrow-suffix-size + 4px; + } + } + + &.dropup { + margin-top: -5px; + + // NOTE: Note sure why these are special-cased. + &:before { + top: initial; + bottom: -@arrow-prefix-size; + border-bottom: initial; + border-top: @arrow-prefix-size solid @daterangepicker-border-color; + } + + &:after { + top: initial; + bottom: -@arrow-suffix-size; + border-bottom: initial; + border-top: @arrow-suffix-size solid @daterangepicker-bg-color; + } + } + + &.dropdown-menu { + max-width: none; + z-index: @daterangepicker-dropdown-z-index; + } + + &.single { + .ranges, + .calendar { + float: none; + } + } + + /* Calendars */ + &.show-calendar { + .calendar { + display: block; + } + } + + .calendar { + display: none; + max-width: @daterangepicker-width - (@daterangepicker-calendar-margin * 2); + margin: @daterangepicker-calendar-margin; + + &.single { + .calendar-table { + border: none; + } + } + + th, + td { + white-space: nowrap; + text-align: center; + + // TODO: Should this actually be hard-coded? + min-width: 32px; + } + } + + .calendar-table { + border: @daterangepicker-calendar-border-size solid + @daterangepicker-calendar-border-color; + padding: @daterangepicker-calendar-margin; + border-radius: @daterangepicker-calendar-border-radius; + background-color: @daterangepicker-calendar-bg-color; + } + + table { + width: 100%; + margin: 0; + } + + td, + th { + text-align: center; + width: @daterangepicker-cell-width; + height: @daterangepicker-cell-height; + border-radius: @daterangepicker-cell-border-radius; + border: @daterangepicker-cell-border-size solid + @daterangepicker-cell-border-color; + white-space: nowrap; + cursor: pointer; + + &.available { + &:hover { + background-color: @daterangepicker-cell-hover-bg-color; + border-color: @daterangepicker-cell-hover-border-color; + color: @daterangepicker-cell-hover-color; + } + } + + &.week { + font-size: 80%; + color: #ccc; + } + } + + td { + &.off { + &, + &.in-range, + &.start-date, + &.end-date { + background-color: @daterangepicker-unselected-bg-color; + border-color: @daterangepicker-unselected-border-color; + color: @daterangepicker-unselected-color; + } + } + + // + // Date Range + &.in-range { + background-color: @daterangepicker-in-range-bg-color; + border-color: @daterangepicker-in-range-border-color; + color: @daterangepicker-in-range-color; + + // TODO: Should this be static or should it be parameterized? + border-radius: 0; + } + + &.start-date { + border-radius: @daterangepicker-cell-border-radius 0 0 + @daterangepicker-cell-border-radius; + } + + &.end-date { + border-radius: 0 @daterangepicker-cell-border-radius + @daterangepicker-cell-border-radius 0; + } + + &.start-date.end-date { + border-radius: @daterangepicker-cell-border-radius; + } + + &.active { + &, + &:hover { + background-color: @daterangepicker-active-bg-color; + border-color: @daterangepicker-active-border-color; + color: @daterangepicker-active-color; + } + } + } + + th { + &.month { + width: auto; + } + } + + // + // Disabled Controls + // + td, + option { + &.disabled { + color: #999; + cursor: not-allowed; + text-decoration: line-through; + } + } + + select { + &.monthselect, + &.yearselect { + font-size: 12px; + padding: 1px; + height: auto; + margin: 0; + cursor: default; + } + + &.monthselect { + margin-right: 2%; + width: 56%; + } + + &.yearselect { + width: 40%; + } + + &.hourselect, + &.minuteselect, + &.secondselect, + &.ampmselect { + width: 50px; + margin-bottom: 0; + } + } + + // + // Text Input Controls (above calendar) + // + .input-mini { + border: @daterangepicker-control-border-size solid + @daterangepicker-control-border-color; + border-radius: @daterangepicker-control-border-radius; + color: @daterangepicker-control-color; + height: @daterangepicker-control-line-height; + line-height: @daterangepicker-control-height; + display: block; + vertical-align: middle; + + // TODO: Should these all be static, too?? + margin: 0 0 5px 0; + padding: 0 6px 0 28px; + width: 100%; + + &.active { + border: @daterangepicker-control-active-border-size solid + @daterangepicker-control-active-border-color; + border-radius: @daterangepicker-control-active-border-radius; + } + } + + .daterangepicker_input { + position: relative; + padding-left: 0; + + i { + position: absolute; + + // NOTE: These appear to be eyeballed to me... + left: 8px; + top: 10px; + } + } + &.rtl { + .input-mini { + padding-right: 28px; + padding-left: 6px; + } + .daterangepicker_input i { + left: auto; + right: 8px; + } + } + + // + // Time Picker + // + .calendar-time { + text-align: center; + margin: 5px auto; + line-height: @daterangepicker-control-line-height; + position: relative; + padding-left: 28px; + + select { + &.disabled { + color: @daterangepicker-control-disabled-color; + cursor: not-allowed; + } + } + } +} + +// +// Predefined Ranges +// + +.ranges { + font-size: 11px; + float: none; + margin: 4px; + text-align: left; + + ul { + list-style: none; + margin: 0 auto; + padding: 0; + width: 100%; + } + + li { + font-size: 13px; + background-color: @daterangepicker-ranges-bg-color; + border: @daterangepicker-ranges-border-size solid + @daterangepicker-ranges-border-color; + border-radius: @daterangepicker-ranges-border-radius; + color: @daterangepicker-ranges-color; + padding: 3px 12px; + margin-bottom: 8px; + cursor: pointer; + + &:hover { + background-color: @daterangepicker-ranges-hover-bg-color; + color: @daterangepicker-ranges-hover-color; + } + + &.active { + background-color: @daterangepicker-ranges-hover-bg-color; + border: @daterangepicker-ranges-hover-border-size solid + @daterangepicker-ranges-hover-border-color; + color: @daterangepicker-ranges-hover-color; + } + } +} + +/* Larger Screen Styling */ +@media (min-width: 564px) { + .daterangepicker { + .glyphicon { + font-family: FontAwesome; + } + .glyphicon-chevron-left:before { + content: '\f053'; + } + .glyphicon-chevron-right:before { + content: '\f054'; + } + .glyphicon-calendar:before { + content: '\f073'; + } + + width: auto; + + .ranges { + ul { + width: 160px; + } + } + + &.single { + .ranges { + ul { + width: 100%; + } + } + + .calendar.left { + clear: none; + } + + &.ltr { + .ranges, + .calendar { + float: left; + } + } + &.rtl { + .ranges, + .calendar { + float: right; + } + } + } + + &.ltr { + direction: ltr; + text-align: left; + .calendar { + &.left { + clear: left; + margin-right: 0; + + .calendar-table { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + + &.right { + margin-left: 0; + + .calendar-table { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + } + + .left .daterangepicker_input { + padding-right: 12px; + } + + .calendar.left .calendar-table { + padding-right: 12px; + } + + .ranges, + .calendar { + float: left; + } + } + &.rtl { + direction: rtl; + text-align: right; + .calendar { + &.left { + clear: right; + margin-left: 0; + + .calendar-table { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + + &.right { + margin-right: 0; + + .calendar-table { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + } + + .left .daterangepicker_input { + padding-left: 12px; + } + + .calendar.left .calendar-table { + padding-left: 12px; + } + + .ranges, + .calendar { + text-align: right; + float: right; + } + } + } +} + +@media (min-width: 730px) { + /* force the calendar to display on one row */ + &.show-calendar { + min-width: 658px; /* width of all contained elements, IE/Edge fallback */ + width: -moz-max-content; + width: -webkit-max-content; + width: max-content; + } + + .daterangepicker { + .ranges { + width: auto; + } + &.ltr { + .ranges { + float: left; + clear: none !important; + } + } + &.rtl { + .ranges { + float: right; + } + } + + .calendar { + clear: none !important; + } + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss b/services/web/frontend/stylesheets/components/nvd3.less similarity index 78% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss rename to services/web/frontend/stylesheets/components/nvd3.less index 4983129a80..f1fea65901 100755 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3.scss +++ b/services/web/frontend/stylesheets/components/nvd3.less @@ -1,677 +1,691 @@ -/* stylelint-disable */ - -/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ -.nvd3 .nv-axis { - pointer-events: none; - opacity: 1; -} - -.nvd3 .nv-axis path { - fill: none; - stroke: #000; - stroke-opacity: 0.75; - shape-rendering: crispedges; -} - -.nvd3 .nv-axis path.domain { - stroke-opacity: 0.75; -} - -.nvd3 .nv-axis.nv-x path.domain { - stroke-opacity: 0; -} - -.nvd3 .nv-axis line { - fill: none; - stroke: #e5e5e5; - shape-rendering: crispedges; -} - -.nvd3 .nv-axis .zero line, - /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { - stroke-opacity: 0.75; -} - -.nvd3 .nv-axis .nv-axisMaxMin text { - font-weight: bold; -} - -.nvd3 .x .nv-axis .nv-axisMaxMin text, -.nvd3 .x2 .nv-axis .nv-axisMaxMin text, -.nvd3 .x3 .nv-axis .nv-axisMaxMin text { - text-anchor: middle; -} - -.nvd3 .nv-axis.nv-disabled { - opacity: 0; -} - -.nvd3 .nv-bars rect { - fill-opacity: 0.75; - transition: fill-opacity 250ms linear; -} - -.nvd3 .nv-bars rect.hover { - fill-opacity: 1; -} - -.nvd3 .nv-bars .hover rect { - fill: lightblue; -} - -.nvd3 .nv-bars text { - fill: rgb(0 0 0 / 0%); -} - -.nvd3 .nv-bars .hover text { - fill: rgb(0 0 0 / 100%); -} - -.nvd3 .nv-multibar .nv-groups rect, -.nvd3 .nv-multibarHorizontal .nv-groups rect, -.nvd3 .nv-discretebar .nv-groups rect { - stroke-opacity: 0; - transition: fill-opacity 250ms linear; -} - -.nvd3 .nv-multibar .nv-groups rect:hover, -.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, -.nvd3 .nv-candlestickBar .nv-ticks rect:hover, -.nvd3 .nv-discretebar .nv-groups rect:hover { - fill-opacity: 1; -} - -.nvd3 .nv-discretebar .nv-groups text, -.nvd3 .nv-multibarHorizontal .nv-groups text { - font-weight: bold; - fill: rgb(0 0 0 / 100%); - stroke: rgb(0 0 0 / 0%); -} - -/* boxplot CSS */ -.nvd3 .nv-boxplot circle { - fill-opacity: 0.5; -} - -.nvd3 .nv-boxplot circle:hover { - fill-opacity: 1; -} - -.nvd3 .nv-boxplot rect:hover { - fill-opacity: 1; -} - -.nvd3 line.nv-boxplot-median { - stroke: black; -} - -.nv-boxplot-tick:hover { - stroke-width: 2.5px; -} - -/* bullet */ -.nvd3.nv-bullet { - font: 10px sans-serif; -} - -.nvd3.nv-bullet .nv-measure { - fill-opacity: 0.8; -} - -.nvd3.nv-bullet .nv-measure:hover { - fill-opacity: 1; -} - -.nvd3.nv-bullet .nv-marker { - stroke: #000; - stroke-width: 2px; -} - -.nvd3.nv-bullet .nv-markerTriangle { - stroke: #000; - fill: #fff; - stroke-width: 1.5px; -} - -.nvd3.nv-bullet .nv-markerLine { - stroke: #000; - stroke-width: 1.5px; -} - -.nvd3.nv-bullet .nv-tick line { - stroke: #666; - stroke-width: 0.5px; -} - -.nvd3.nv-bullet .nv-range.nv-s0 { - fill: #eee; -} - -.nvd3.nv-bullet .nv-range.nv-s1 { - fill: #ddd; -} - -.nvd3.nv-bullet .nv-range.nv-s2 { - fill: #ccc; -} - -.nvd3.nv-bullet .nv-title { - font-size: 14px; - font-weight: bold; -} - -.nvd3.nv-bullet .nv-subtitle { - fill: #999; -} - -.nvd3.nv-bullet .nv-range { - fill: #bababa; - fill-opacity: 0.4; -} - -.nvd3.nv-bullet .nv-range:hover { - fill-opacity: 0.7; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick { - stroke-width: 1px; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { - stroke-width: 2px; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { - stroke: #2ca02c; - fill: #2ca02c; -} - -.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { - stroke: #d62728; - fill: #d62728; -} - -.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { - transition: stroke-width 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-candlestickBar .nv-ticks line { - stroke: #333; -} - -.nv-force-node { - stroke: #fff; - stroke-width: 1.5px; -} - -.nv-force-link { - stroke: #999; - stroke-opacity: 0.6; -} - -.nv-force-node text { - stroke-width: 0; -} - -.nvd3 .nv-legend .nv-disabled rect { - /* fill-opacity: 0; */ -} - -.nvd3 .nv-check-box .nv-box { - fill-opacity: 0; - stroke-width: 2; -} - -.nvd3 .nv-check-box .nv-check { - fill-opacity: 0; - stroke-width: 4; -} - -.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { - opacity: 0; -} - -/* line plus bar */ -.nvd3.nv-linePlusBar .nv-bar rect { - fill-opacity: 0.75; -} - -.nvd3.nv-linePlusBar .nv-bar rect:hover { - fill-opacity: 1; -} - -.nvd3 .nv-groups path.nv-line { - fill: none; -} - -.nvd3 .nv-groups path.nv-area { - stroke: none; -} - -.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { - fill-opacity: 0.5 !important; - stroke-opacity: 0.5 !important; -} - -.with-transitions .nvd3 .nv-groups .nv-point { - transition: stroke-width 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-scatter .nv-groups .nv-point.hover, -.nvd3 .nv-groups .nv-point.hover { - stroke-width: 7px; - fill-opacity: 0.95 !important; - stroke-opacity: 0.95 !important; -} - -.nvd3 .nv-point-paths path { - stroke: #aaa; - stroke-opacity: 0; - fill: #eee; - fill-opacity: 0; -} - -.nvd3 .nv-indexLine { - cursor: ew-resize; -} - -/******************** - * SVG CSS - */ - -/******************** - Default CSS for an svg element nvd3 used -*/ -svg.nvd3-svg, svg.nvd3-iddle { - -webkit-touch-callout: none; - user-select: none; - display: block; - width: 100%; - height: 100%; -} - -/******************** - Box shadow and border radius styling -*/ -.nvtooltip.with-3d-shadow, -.with-3d-shadow .nvtooltip { - box-shadow: 0 5px 10px rgb(0 0 0 / 20%); - border-radius: 5px; -} - -.nvd3 text { - font: normal 12px Arial; -} - -.nvd3 .title { - font: bold 14px Arial; -} - -.nvd3 .nv-background { - fill: white; - fill-opacity: 0; -} - -.nvd3.nv-noData { - font-size: 18px; - font-weight: bold; -} - -/********** -* Brush -*/ - -.nv-brush .extent { - fill-opacity: 0.125; - shape-rendering: crispedges; -} - -.nv-brush .resize path { - fill: #eee; - stroke: #666; -} - -/********** -* Legend -*/ - -.nvd3 .nv-legend .nv-series { - cursor: pointer; -} - -.nvd3 .nv-legend .nv-disabled circle { - fill-opacity: 0; -} - -/* focus */ -.nvd3 .nv-brush .extent { - fill-opacity: 0 !important; -} - -.nvd3 .nv-brushBackground rect { - stroke: #000; - stroke-width: 0.4; - fill: #fff; - fill-opacity: 0.7; -} - -/********** -* Print -*/ - -@media print { - .nvd3 text { - stroke-width: 0; - fill-opacity: 1; - } -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick { - stroke-width: 1px; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { - stroke-width: 2px; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { - stroke: #2ca02c; -} - -.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { - stroke: #d62728; -} - -.nvd3 .background path { - fill: none; - stroke: #eee; - stroke-opacity: 0.4; - shape-rendering: crispedges; -} - -.nvd3 .foreground path { - fill: none; - stroke-opacity: 0.7; -} - -.nvd3 .nv-parallelCoordinates-brush .extent { - fill: #fff; - fill-opacity: 0.6; - stroke: gray; - shape-rendering: crispedges; -} - -.nvd3 .nv-parallelCoordinates .hover { - fill-opacity: 1; - stroke-width: 3px; -} - -.nvd3 .missingValuesline line { - fill: none; - stroke: black; - stroke-width: 1; - stroke-opacity: 1; - stroke-dasharray: 5, 5; -} - -.nvd3.nv-pie path { - stroke-opacity: 0; - transition: fill-opacity 250ms linear, stroke-width 250ms linear, - stroke-opacity 250ms linear; -} - -.nvd3.nv-pie .nv-pie-title { - font-size: 24px; - fill: rgb(19 196 249 / 59%); -} - -.nvd3.nv-pie .nv-slice text { - stroke: #000; - stroke-width: 0; -} - -.nvd3.nv-pie path { - stroke: #fff; - stroke-width: 1px; - stroke-opacity: 1; -} - -.nvd3.nv-pie path { - fill-opacity: 0.7; -} - -.nvd3.nv-pie .hover path { - fill-opacity: 1; -} - -.nvd3.nv-pie .nv-label { - pointer-events: none; -} - -.nvd3.nv-pie .nv-label rect { - fill-opacity: 0; - stroke-opacity: 0; -} - -/* scatter */ -.nvd3 .nv-groups .nv-point.hover { - stroke-width: 20px; - stroke-opacity: 0.5; -} - -.nvd3 .nv-scatter .nv-point.hover { - fill-opacity: 1; -} - -.nv-noninteractive { - pointer-events: none; -} - -.nv-distx, -.nv-disty { - pointer-events: none; -} - -/* sparkline */ -.nvd3.nv-sparkline path { - fill: none; -} - -.nvd3.nv-sparklineplus g.nv-hoverValue { - pointer-events: none; -} - -.nvd3.nv-sparklineplus .nv-hoverValue line { - stroke: #333; - stroke-width: 1.5px; -} - -.nvd3.nv-sparklineplus, -.nvd3.nv-sparklineplus g { - pointer-events: all; -} - -.nvd3 .nv-hoverArea { - fill-opacity: 0; - stroke-opacity: 0; -} - -.nvd3.nv-sparklineplus .nv-xValue, -.nvd3.nv-sparklineplus .nv-yValue { - stroke-width: 0; - font-size: 0.9em; - font-weight: normal; -} - -.nvd3.nv-sparklineplus .nv-yValue { - stroke: #f66; -} - -.nvd3.nv-sparklineplus .nv-maxValue { - stroke: #2ca02c; - fill: #2ca02c; -} - -.nvd3.nv-sparklineplus .nv-minValue { - stroke: #d62728; - fill: #d62728; -} - -.nvd3.nv-sparklineplus .nv-currentValue { - font-weight: bold; - font-size: 1.1em; -} - -/* stacked area */ -.nvd3.nv-stackedarea path.nv-area { - fill-opacity: 0.7; - stroke-opacity: 0; - transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; -} - -.nvd3.nv-stackedarea path.nv-area.hover { - fill-opacity: 0.9; -} - -.nvd3.nv-stackedarea .nv-groups .nv-point { - stroke-opacity: 0; - fill-opacity: 0; -} - -.nvtooltip { - position: absolute; - background-color: rgb(255 255 255 / 100%); - color: rgb(0 0 0 / 100%); - padding: 1px; - border: 1px solid rgb(0 0 0 / 20%); - z-index: 10000; - display: block; - font-family: Arial; - font-size: 13px; - text-align: left; - pointer-events: none; - white-space: nowrap; - -webkit-touch-callout: none; - user-select: none; -} - -.nvtooltip { - background: rgb(255 255 255 / 80%); - border: 1px solid rgb(0 0 0 / 50%); - border-radius: 4px; -} - -/* Give tooltips that old fade in transition by - putting a "with-transitions" class on the container div. -*/ -.nvtooltip.with-transitions, -.with-transitions .nvtooltip { - transition: opacity 50ms linear; - transition-delay: 200ms; -} - -.nvtooltip.x-nvtooltip, -.nvtooltip.y-nvtooltip { - padding: 8px; -} - -.nvtooltip h3 { - margin: 0; - padding: 4px 14px; - line-height: 18px; - font-weight: normal; - background-color: rgb(247 247 247 / 75%); - color: rgb(0 0 0 / 100%); - text-align: center; - border-bottom: 1px solid #ebebeb; - border-radius: 5px 5px 0 0; -} - -.nvtooltip p { - margin: 0; - padding: 5px 14px; - text-align: center; -} - -.nvtooltip span { - display: inline-block; - margin: 2px 0; -} - -.nvtooltip table { - margin: 6px; - border-spacing: 0; -} - -.nvtooltip table td { - padding: 2px 9px 2px 0; - vertical-align: middle; -} - -.nvtooltip table td.key { - font-weight: normal; -} - -.nvtooltip table td.key.total { - font-weight: bold; -} - -.nvtooltip table td.value { - text-align: right; - font-weight: bold; -} - -.nvtooltip table td.percent { - color: darkgray; -} - -.nvtooltip table tr.highlight td { - padding: 1px 9px 1px 0; - border-bottom-style: solid; - border-bottom-width: 1px; - border-top-style: solid; - border-top-width: 1px; -} - -.nvtooltip table td.legend-color-guide div { - width: 8px; - height: 8px; - vertical-align: middle; -} - -.nvtooltip table td.legend-color-guide div { - width: 12px; - height: 12px; - border: 1px solid #999; -} - -.nvtooltip .footer { - padding: 3px; - text-align: center; -} - -.nvtooltip-pending-removal { - pointer-events: none; - display: none; -} - -/**** -Interactive Layer -*/ -.nvd3 .nv-interactiveGuideLine { - pointer-events: none; -} - -.nvd3 line.nv-guideline { - stroke: #ccc; -} +/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ +.nvd3 .nv-axis { + pointer-events: none; + opacity: 1; +} + +.nvd3 .nv-axis path { + fill: none; + stroke: #000; + stroke-opacity: 0.75; + shape-rendering: crispEdges; +} + +.nvd3 .nv-axis path.domain { + stroke-opacity: 0.75; +} + +.nvd3 .nv-axis.nv-x path.domain { + stroke-opacity: 0; +} + +.nvd3 .nv-axis line { + fill: none; + stroke: #e5e5e5; + shape-rendering: crispEdges; +} + +.nvd3 .nv-axis .zero line, + /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { + stroke-opacity: 0.75; +} + +.nvd3 .nv-axis .nv-axisMaxMin text { + font-weight: bold; +} + +.nvd3 .x .nv-axis .nv-axisMaxMin text, +.nvd3 .x2 .nv-axis .nv-axisMaxMin text, +.nvd3 .x3 .nv-axis .nv-axisMaxMin text { + text-anchor: middle; +} + +.nvd3 .nv-axis.nv-disabled { + opacity: 0; +} + +.nvd3 .nv-bars rect { + fill-opacity: 0.75; + + transition: fill-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-bars rect.hover { + fill-opacity: 1; +} + +.nvd3 .nv-bars .hover rect { + fill: lightblue; +} + +.nvd3 .nv-bars text { + fill: rgba(0, 0, 0, 0); +} + +.nvd3 .nv-bars .hover text { + fill: rgba(0, 0, 0, 1); +} + +.nvd3 .nv-multibar .nv-groups rect, +.nvd3 .nv-multibarHorizontal .nv-groups rect, +.nvd3 .nv-discretebar .nv-groups rect { + stroke-opacity: 0; + + transition: fill-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-multibar .nv-groups rect:hover, +.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, +.nvd3 .nv-candlestickBar .nv-ticks rect:hover, +.nvd3 .nv-discretebar .nv-groups rect:hover { + fill-opacity: 1; +} + +.nvd3 .nv-discretebar .nv-groups text, +.nvd3 .nv-multibarHorizontal .nv-groups text { + font-weight: bold; + fill: rgba(0, 0, 0, 1); + stroke: rgba(0, 0, 0, 0); +} + +/* boxplot CSS */ +.nvd3 .nv-boxplot circle { + fill-opacity: 0.5; +} + +.nvd3 .nv-boxplot circle:hover { + fill-opacity: 1; +} + +.nvd3 .nv-boxplot rect:hover { + fill-opacity: 1; +} + +.nvd3 line.nv-boxplot-median { + stroke: black; +} + +.nv-boxplot-tick:hover { + stroke-width: 2.5px; +} +/* bullet */ +.nvd3.nv-bullet { + font: 10px sans-serif; +} +.nvd3.nv-bullet .nv-measure { + fill-opacity: 0.8; +} +.nvd3.nv-bullet .nv-measure:hover { + fill-opacity: 1; +} +.nvd3.nv-bullet .nv-marker { + stroke: #000; + stroke-width: 2px; +} +.nvd3.nv-bullet .nv-markerTriangle { + stroke: #000; + fill: #fff; + stroke-width: 1.5px; +} +.nvd3.nv-bullet .nv-markerLine { + stroke: #000; + stroke-width: 1.5px; +} +.nvd3.nv-bullet .nv-tick line { + stroke: #666; + stroke-width: 0.5px; +} +.nvd3.nv-bullet .nv-range.nv-s0 { + fill: #eee; +} +.nvd3.nv-bullet .nv-range.nv-s1 { + fill: #ddd; +} +.nvd3.nv-bullet .nv-range.nv-s2 { + fill: #ccc; +} +.nvd3.nv-bullet .nv-title { + font-size: 14px; + font-weight: bold; +} +.nvd3.nv-bullet .nv-subtitle { + fill: #999; +} + +.nvd3.nv-bullet .nv-range { + fill: #bababa; + fill-opacity: 0.4; +} +.nvd3.nv-bullet .nv-range:hover { + fill-opacity: 0.7; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { + stroke: #d62728; + fill: #d62728; +} + +.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-candlestickBar .nv-ticks line { + stroke: #333; +} + +.nv-force-node { + stroke: #fff; + stroke-width: 1.5px; +} +.nv-force-link { + stroke: #999; + stroke-opacity: 0.6; +} +.nv-force-node text { + stroke-width: 0px; +} + +.nvd3 .nv-legend .nv-disabled rect { + /*fill-opacity: 0;*/ +} + +.nvd3 .nv-check-box .nv-box { + fill-opacity: 0; + stroke-width: 2; +} + +.nvd3 .nv-check-box .nv-check { + fill-opacity: 0; + stroke-width: 4; +} + +.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { + opacity: 0; +} + +/* line plus bar */ +.nvd3.nv-linePlusBar .nv-bar rect { + fill-opacity: 0.75; +} + +.nvd3.nv-linePlusBar .nv-bar rect:hover { + fill-opacity: 1; +} +.nvd3 .nv-groups path.nv-line { + fill: none; +} + +.nvd3 .nv-groups path.nv-area { + stroke: none; +} + +.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { + fill-opacity: 0.5 !important; + stroke-opacity: 0.5 !important; +} + +.with-transitions .nvd3 .nv-groups .nv-point { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-scatter .nv-groups .nv-point.hover, +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 7px; + fill-opacity: 0.95 !important; + stroke-opacity: 0.95 !important; +} + +.nvd3 .nv-point-paths path { + stroke: #aaa; + stroke-opacity: 0; + fill: #eee; + fill-opacity: 0; +} + +.nvd3 .nv-indexLine { + cursor: ew-resize; +} + +/******************** + * SVG CSS + */ + +/******************** + Default CSS for an svg element nvd3 used +*/ +svg.nvd3-svg { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + user-select: none; + display: block; + width: 100%; + height: 100%; +} + +/******************** + Box shadow and border radius styling +*/ +.nvtooltip.with-3d-shadow, +.with-3d-shadow .nvtooltip { + -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} + +.nvd3 text { + font: normal 12px Arial; +} + +.nvd3 .title { + font: bold 14px Arial; +} + +.nvd3 .nv-background { + fill: white; + fill-opacity: 0; +} + +.nvd3.nv-noData { + font-size: 18px; + font-weight: bold; +} + +/********** +* Brush +*/ + +.nv-brush .extent { + fill-opacity: 0.125; + shape-rendering: crispEdges; +} + +.nv-brush .resize path { + fill: #eee; + stroke: #666; +} + +/********** +* Legend +*/ + +.nvd3 .nv-legend .nv-series { + cursor: pointer; +} + +.nvd3 .nv-legend .nv-disabled circle { + fill-opacity: 0; +} + +/* focus */ +.nvd3 .nv-brush .extent { + fill-opacity: 0 !important; +} + +.nvd3 .nv-brushBackground rect { + stroke: #000; + stroke-width: 0.4; + fill: #fff; + fill-opacity: 0.7; +} + +/********** +* Print +*/ + +@media print { + .nvd3 text { + stroke-width: 0; + fill-opacity: 1; + } +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { + stroke: #2ca02c; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { + stroke: #d62728; +} + +.nvd3 .background path { + fill: none; + stroke: #eee; + stroke-opacity: 0.4; + shape-rendering: crispEdges; +} + +.nvd3 .foreground path { + fill: none; + stroke-opacity: 0.7; +} + +.nvd3 .nv-parallelCoordinates-brush .extent { + fill: #fff; + fill-opacity: 0.6; + stroke: gray; + shape-rendering: crispEdges; +} + +.nvd3 .nv-parallelCoordinates .hover { + fill-opacity: 1; + stroke-width: 3px; +} + +.nvd3 .missingValuesline line { + fill: none; + stroke: black; + stroke-width: 1; + stroke-opacity: 1; + stroke-dasharray: 5, 5; +} +.nvd3.nv-pie path { + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-width 250ms linear, + stroke-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear, stroke-width 250ms linear, + stroke-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear, stroke-width 250ms linear, + stroke-opacity 250ms linear; +} + +.nvd3.nv-pie .nv-pie-title { + font-size: 24px; + fill: rgba(19, 196, 249, 0.59); +} + +.nvd3.nv-pie .nv-slice text { + stroke: #000; + stroke-width: 0; +} + +.nvd3.nv-pie path { + stroke: #fff; + stroke-width: 1px; + stroke-opacity: 1; +} + +.nvd3.nv-pie path { + fill-opacity: 0.7; +} +.nvd3.nv-pie .hover path { + fill-opacity: 1; +} +.nvd3.nv-pie .nv-label { + pointer-events: none; +} +.nvd3.nv-pie .nv-label rect { + fill-opacity: 0; + stroke-opacity: 0; +} + +/* scatter */ +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 20px; + stroke-opacity: 0.5; +} + +.nvd3 .nv-scatter .nv-point.hover { + fill-opacity: 1; +} +.nv-noninteractive { + pointer-events: none; +} + +.nv-distx, +.nv-disty { + pointer-events: none; +} + +/* sparkline */ +.nvd3.nv-sparkline path { + fill: none; +} + +.nvd3.nv-sparklineplus g.nv-hoverValue { + pointer-events: none; +} + +.nvd3.nv-sparklineplus .nv-hoverValue line { + stroke: #333; + stroke-width: 1.5px; +} + +.nvd3.nv-sparklineplus, +.nvd3.nv-sparklineplus g { + pointer-events: all; +} + +.nvd3 .nv-hoverArea { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-sparklineplus .nv-xValue, +.nvd3.nv-sparklineplus .nv-yValue { + stroke-width: 0; + font-size: 0.9em; + font-weight: normal; +} + +.nvd3.nv-sparklineplus .nv-yValue { + stroke: #f66; +} + +.nvd3.nv-sparklineplus .nv-maxValue { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-sparklineplus .nv-minValue { + stroke: #d62728; + fill: #d62728; +} + +.nvd3.nv-sparklineplus .nv-currentValue { + font-weight: bold; + font-size: 1.1em; +} +/* stacked area */ +.nvd3.nv-stackedarea path.nv-area { + fill-opacity: 0.7; + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-stackedarea path.nv-area.hover { + fill-opacity: 0.9; +} + +.nvd3.nv-stackedarea .nv-groups .nv-point { + stroke-opacity: 0; + fill-opacity: 0; +} + +.nvtooltip { + position: absolute; + background-color: rgba(255, 255, 255, 1); + color: rgba(0, 0, 0, 1); + padding: 1px; + border: 1px solid rgba(0, 0, 0, 0.2); + z-index: 10000; + display: block; + + font-family: Arial; + font-size: 13px; + text-align: left; + pointer-events: none; + + white-space: nowrap; + + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.nvtooltip { + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(0, 0, 0, 0.5); + border-radius: 4px; +} + +/*Give tooltips that old fade in transition by + putting a "with-transitions" class on the container div. +*/ +.nvtooltip.with-transitions, +.with-transitions .nvtooltip { + transition: opacity 50ms linear; + -moz-transition: opacity 50ms linear; + -webkit-transition: opacity 50ms linear; + + transition-delay: 200ms; + -moz-transition-delay: 200ms; + -webkit-transition-delay: 200ms; +} + +.nvtooltip.x-nvtooltip, +.nvtooltip.y-nvtooltip { + padding: 8px; +} + +.nvtooltip h3 { + margin: 0; + padding: 4px 14px; + line-height: 18px; + font-weight: normal; + background-color: rgba(247, 247, 247, 0.75); + color: rgba(0, 0, 0, 1); + text-align: center; + + border-bottom: 1px solid #ebebeb; + + -webkit-border-radius: 5px 5px 0 0; + -moz-border-radius: 5px 5px 0 0; + border-radius: 5px 5px 0 0; +} + +.nvtooltip p { + margin: 0; + padding: 5px 14px; + text-align: center; +} + +.nvtooltip span { + display: inline-block; + margin: 2px 0; +} + +.nvtooltip table { + margin: 6px; + border-spacing: 0; +} + +.nvtooltip table td { + padding: 2px 9px 2px 0; + vertical-align: middle; +} + +.nvtooltip table td.key { + font-weight: normal; +} +.nvtooltip table td.key.total { + font-weight: bold; +} +.nvtooltip table td.value { + text-align: right; + font-weight: bold; +} + +.nvtooltip table td.percent { + color: darkgray; +} + +.nvtooltip table tr.highlight td { + padding: 1px 9px 1px 0; + border-bottom-style: solid; + border-bottom-width: 1px; + border-top-style: solid; + border-top-width: 1px; +} + +.nvtooltip table td.legend-color-guide div { + width: 8px; + height: 8px; + vertical-align: middle; +} + +.nvtooltip table td.legend-color-guide div { + width: 12px; + height: 12px; + border: 1px solid #999; +} + +.nvtooltip .footer { + padding: 3px; + text-align: center; +} + +.nvtooltip-pending-removal { + pointer-events: none; + display: none; +} + +/**** +Interactive Layer +*/ +.nvd3 .nv-interactiveGuideLine { + pointer-events: none; +} +.nvd3 line.nv-guideline { + stroke: #ccc; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss b/services/web/frontend/stylesheets/components/nvd3_override.less similarity index 74% rename from services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss rename to services/web/frontend/stylesheets/components/nvd3_override.less index 72c3e2f99a..929a99e9db 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/metrics/nvd3_override.scss +++ b/services/web/frontend/stylesheets/components/nvd3_override.less @@ -5,9 +5,12 @@ opacity: 0; } } - path.domain { opacity: 0; } } } + +svg.nvd3-iddle { + &:extend(svg.nvd3-svg); +} diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index fd8c308117..d42a2ab502 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -61,6 +61,8 @@ @import 'components/hover.less'; @import 'components/ui-select.less'; @import 'components/input-suggestions.less'; +@import 'components/nvd3.less'; +@import 'components/nvd3_override.less'; @import 'components/infinite-scroll.less'; @import 'components/expand-collapse.less'; @import 'components/beta-badges.less'; @@ -80,6 +82,7 @@ @import 'components/modals.less'; @import 'components/tooltip.less'; @import 'components/popovers.less'; +@import 'components/daterange-picker'; @import 'components/lists.less'; @import 'components/overbox.less'; @import 'components/embed-responsive.less'; @@ -115,6 +118,7 @@ @import 'app/invite.less'; @import 'app/error-pages.less'; @import 'app/editor/history-v2.less'; +@import 'app/metrics.less'; @import 'app/open-in-overleaf.less'; @import 'app/primary-email-check'; @import 'app/grammarly'; @@ -122,6 +126,9 @@ @import 'app/ol-chat.less'; @import 'app/templates-v2.less'; @import 'app/login-register.less'; +@import 'app/institution-hub.less'; +@import 'app/publisher-hub.less'; +@import 'app/admin-hub.less'; @import 'app/import.less'; @import 'app/website-redesign.less'; @import 'app/add-secondary-email-prompt.less'; From 1e6112d5b0d81102bf70a94feb7f21c6224d2355 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Thu, 5 Jun 2025 08:58:35 +0100 Subject: [PATCH 102/259] Merge pull request #25467 from overleaf/bg-fix-error-handling-when-accounts-are-deleted improve logging deleted when user data is expired GitOrigin-RevId: ac85b66c503184a815348a11a730fb68a504d80a --- .../web/app/src/Features/User/UserDeleter.js | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/services/web/app/src/Features/User/UserDeleter.js b/services/web/app/src/Features/User/UserDeleter.js index 662c51ca65..c8d9891bf9 100644 --- a/services/web/app/src/Features/User/UserDeleter.js +++ b/services/web/app/src/Features/User/UserDeleter.js @@ -87,17 +87,29 @@ async function deleteMongoUser(userId) { } async function expireDeletedUser(userId) { - await Modules.promises.hooks.fire('expireDeletedUser', userId) - const deletedUser = await DeletedUser.findOne({ - 'deleterData.deletedUserId': userId, - }).exec() - - await Feedback.deleteMany({ userId }).exec() - await OnboardingDataCollectionManager.deleteOnboardingDataCollection(userId) - - deletedUser.user = undefined - deletedUser.deleterData.deleterIpAddress = undefined - await deletedUser.save() + logger.info({ userId }, 'expiring deleted user') + try { + logger.info({ userId }, 'firing expireDeletedUser hook') + await Modules.promises.hooks.fire('expireDeletedUser', userId) + logger.info({ userId }, 'removing deleted user feedback records') + await Feedback.deleteMany({ userId }).exec() + logger.info({ userId }, 'removing deleted user onboarding data') + await OnboardingDataCollectionManager.deleteOnboardingDataCollection(userId) + logger.info({ userId }, 'redacting PII from the deleted user record') + const deletedUser = await DeletedUser.findOne({ + 'deleterData.deletedUserId': userId, + }).exec() + deletedUser.user = undefined + deletedUser.deleterData.deleterIpAddress = undefined + await deletedUser.save() + logger.info({ userId }, 'deleted user expiry complete') + } catch (error) { + logger.warn( + { error, userId }, + 'something went wrong expiring the deleted user' + ) + throw error + } } async function expireDeletedUsersAfterDuration() { @@ -112,11 +124,27 @@ async function expireDeletedUsersAfterDuration() { if (deletedUsers.length === 0) { return } - - for (let i = 0; i < deletedUsers.length; i++) { - const deletedUserId = deletedUsers[i].deleterData.deletedUserId - await expireDeletedUser(deletedUserId) - await UserAuditLogEntry.deleteMany({ userId: deletedUserId }).exec() + logger.info( + { deletedUsers: deletedUsers.length, retentionPeriodInDays: DURATION }, + 'expiring batch of deleted users older than retention period' + ) + try { + for (let i = 0; i < deletedUsers.length; i++) { + const deletedUserId = deletedUsers[i].deleterData.deletedUserId + await expireDeletedUser(deletedUserId) + logger.info({ deletedUserId }, 'removing deleted user audit log entries') + await UserAuditLogEntry.deleteMany({ userId: deletedUserId }).exec() + } + logger.info( + { deletedUsers: deletedUsers.length }, + 'batch of deleted users expired successfully' + ) + } catch (error) { + logger.warn( + { error }, + 'something went wrong expiring batch of deleted users' + ) + throw error } } From 842f6c289fdb18a1193545928b6ed02ef01c45b8 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 5 Jun 2025 10:07:00 +0200 Subject: [PATCH 103/259] [document-updater] make setDoc aware of tracked deletes in history-ot (#26126) GitOrigin-RevId: efa1a94f2f435058b553f639e43832454c58591d --- services/document-updater/app/js/DiffCodec.js | 54 +++- .../app/js/DocumentManager.js | 5 +- .../acceptance/js/SettingADocumentTests.js | 281 ++++++++++++++++++ 3 files changed, 330 insertions(+), 10 deletions(-) diff --git a/services/document-updater/app/js/DiffCodec.js b/services/document-updater/app/js/DiffCodec.js index 8c574cff70..17da409386 100644 --- a/services/document-updater/app/js/DiffCodec.js +++ b/services/document-updater/app/js/DiffCodec.js @@ -1,3 +1,4 @@ +const OError = require('@overleaf/o-error') const DMP = require('diff-match-patch') const { TextOperation } = require('overleaf-editor-core') const dmp = new DMP() @@ -38,23 +39,62 @@ module.exports = { return ops }, - diffAsHistoryV1EditOperation(before, after) { - const diffs = dmp.diff_main(before, after) + /** + * @param {import("overleaf-editor-core").StringFileData} file + * @param {string} after + * @return {TextOperation} + */ + diffAsHistoryOTEditOperation(file, after) { + const beforeWithoutTrackedDeletes = file.getContent({ + filterTrackedDeletes: true, + }) + const diffs = dmp.diff_main(beforeWithoutTrackedDeletes, after) dmp.diff_cleanupSemantic(diffs) + const trackedChanges = file.trackedChanges.asSorted() + let nextTc = trackedChanges.shift() + const op = new TextOperation() for (const diff of diffs) { - const [type, content] = diff + let [type, content] = diff if (type === this.ADDED) { op.insert(content) - } else if (type === this.REMOVED) { - op.remove(content.length) - } else if (type === this.UNCHANGED) { - op.retain(content.length) + } else if (type === this.REMOVED || type === this.UNCHANGED) { + while (op.baseLength + content.length > nextTc?.range.start) { + if (nextTc.tracking.type === 'delete') { + const untilRange = nextTc.range.start - op.baseLength + if (type === this.REMOVED) { + op.remove(untilRange) + } else if (type === this.UNCHANGED) { + op.retain(untilRange) + } + op.retain(nextTc.range.end - nextTc.range.start) + content = content.slice(untilRange) + } + nextTc = trackedChanges.shift() + } + if (type === this.REMOVED) { + op.remove(content.length) + } else if (type === this.UNCHANGED) { + op.retain(content.length) + } } else { throw new Error('Unknown type') } } + while (nextTc) { + if ( + nextTc.tracking.type !== 'delete' || + nextTc.range.start !== op.baseLength + ) { + throw new OError( + 'StringFileData.trackedChanges out of sync: unexpected range after end of diff', + { nextTc, baseLength: op.baseLength } + ) + } + op.retain(nextTc.range.end - nextTc.range.start) + nextTc = trackedChanges.shift() + } return op }, } diff --git a/services/document-updater/app/js/DocumentManager.js b/services/document-updater/app/js/DocumentManager.js index 4803056423..6080c1c97d 100644 --- a/services/document-updater/app/js/DocumentManager.js +++ b/services/document-updater/app/js/DocumentManager.js @@ -194,9 +194,8 @@ const DocumentManager = { let op if (type === 'history-ot') { const file = StringFileData.fromRaw(oldLines) - const operation = DiffCodec.diffAsHistoryV1EditOperation( - // TODO(24596): tc support for history-ot - file.getContent({ filterTrackedDeletes: true }), + const operation = DiffCodec.diffAsHistoryOTEditOperation( + file, newLines.join('\n') ) if (operation.isNoop()) { diff --git a/services/document-updater/test/acceptance/js/SettingADocumentTests.js b/services/document-updater/test/acceptance/js/SettingADocumentTests.js index fd1851a221..e1bc54dc90 100644 --- a/services/document-updater/test/acceptance/js/SettingADocumentTests.js +++ b/services/document-updater/test/acceptance/js/SettingADocumentTests.js @@ -686,4 +686,285 @@ describe('Setting a document', function () { }) }) }) + + describe('with track changes (history-ot)', function () { + const lines = ['one', 'one and a half', 'two', 'three'] + const userId = DocUpdaterClient.randomId() + const ts = new Date().toISOString() + beforeEach(function (done) { + numberOfReceivedUpdates = 0 + this.newLines = ['one', 'two', 'three'] + this.project_id = DocUpdaterClient.randomId() + this.doc_id = DocUpdaterClient.randomId() + this.historyOTUpdate = { + doc: this.doc_id, + op: [ + { + textOperation: [ + 4, + { + r: 'one and a half\n'.length, + tracking: { + type: 'delete', + userId, + ts, + }, + }, + 9, + ], + }, + ], + v: this.version, + meta: { source: 'random-publicId' }, + } + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines, + version: this.version, + otMigrationStage: 1, + }) + DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => { + if (error) { + throw error + } + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.historyOTUpdate, + error => { + if (error) { + throw error + } + DocUpdaterClient.waitForPendingUpdates( + this.project_id, + this.doc_id, + done + ) + } + ) + }) + }) + + afterEach(function () { + MockProjectHistoryApi.flushProject.resetHistory() + MockWebApi.setDocument.resetHistory() + }) + it('should record tracked changes', function (done) { + docUpdaterRedis.get( + Keys.docLines({ doc_id: this.doc_id }), + (error, data) => { + if (error) { + throw error + } + expect(JSON.parse(data)).to.deep.equal({ + content: lines.join('\n'), + trackedChanges: [ + { + range: { + pos: 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }) + done() + } + ) + }) + + it('should apply the change', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, data) => { + if (error) { + throw error + } + expect(data.lines).to.deep.equal(this.newLines) + done() + } + ) + }) + const cases = [ + { + name: 'when resetting the content', + lines, + want: { + content: 'one\none and a half\none and a half\ntwo\nthree', + trackedChanges: [ + { + range: { + pos: 'one and a half\n'.length + 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when adding content before a tracked delete', + lines: ['one', 'INSERT', 'two', 'three'], + want: { + content: 'one\nINSERT\none and a half\ntwo\nthree', + trackedChanges: [ + { + range: { + pos: 'INSERT\n'.length + 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when adding content after a tracked delete', + lines: ['one', 'two', 'INSERT', 'three'], + want: { + content: 'one\none and a half\ntwo\nINSERT\nthree', + trackedChanges: [ + { + range: { + pos: 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when deleting content before a tracked delete', + lines: ['two', 'three'], + want: { + content: 'one and a half\ntwo\nthree', + trackedChanges: [ + { + range: { + pos: 0, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when deleting content after a tracked delete', + lines: ['one', 'two'], + want: { + content: 'one\none and a half\ntwo', + trackedChanges: [ + { + range: { + pos: 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when deleting content immediately after a tracked delete', + lines: ['one', 'three'], + want: { + content: 'one\none and a half\nthree', + trackedChanges: [ + { + range: { + pos: 4, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + { + name: 'when deleting content across a tracked delete', + lines: ['onethree'], + want: { + content: 'oneone and a half\nthree', + trackedChanges: [ + { + range: { + pos: 3, + length: 15, + }, + tracking: { + ts, + type: 'delete', + userId, + }, + }, + ], + }, + }, + ] + + for (const { name, lines, want } of cases) { + describe(name, function () { + beforeEach(function (done) { + DocUpdaterClient.setDocLines( + this.project_id, + this.doc_id, + lines, + this.source, + userId, + false, + (error, res, body) => { + if (error) { + return done(error) + } + this.statusCode = res.statusCode + this.body = body + done() + } + ) + }) + it('should update accordingly', function (done) { + docUpdaterRedis.get( + Keys.docLines({ doc_id: this.doc_id }), + (error, data) => { + if (error) { + throw error + } + expect(JSON.parse(data)).to.deep.equal(want) + done() + } + ) + }) + }) + } + }) }) From af7bcfc96ae03ab6f299fa15993051127185c58f Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Thu, 5 Jun 2025 09:39:37 +0100 Subject: [PATCH 104/259] Merge pull request #25486 from overleaf/bg-add-logging-when-projects-are-expired add logging when projects are expired GitOrigin-RevId: 5107f9f3d2f35aac1ee3f02a9a92c5f625d47f7a --- .../src/Features/Project/ProjectDeleter.js | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js index 2bb8dd0b1f..c154637ec5 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.js +++ b/services/web/app/src/Features/Project/ProjectDeleter.js @@ -106,8 +106,24 @@ async function expireDeletedProjectsAfterDuration() { deletedProject => deletedProject.deleterData.deletedProjectId ) ) - for (const projectId of projectIds) { - await expireDeletedProject(projectId) + logger.info( + { projectCount: projectIds.length }, + 'expiring batch of deleted projects' + ) + try { + for (const projectId of projectIds) { + await expireDeletedProject(projectId) + } + logger.info( + { projectCount: projectIds.length }, + 'batch of deleted projects expired successfully' + ) + } catch (error) { + logger.warn( + { error }, + 'something went wrong expiring batch of deleted projects' + ) + throw error } } @@ -276,12 +292,15 @@ async function deleteProject(projectId, options = {}) { ) await Project.deleteOne({ _id: projectId }).exec() + + logger.info( + { projectId, userId: project.owner_ref }, + 'successfully deleted project' + ) } catch (err) { logger.warn({ err }, 'problem deleting project') throw err } - - logger.debug({ projectId }, 'successfully deleted project') } async function undeleteProject(projectId, options = {}) { @@ -335,16 +354,22 @@ async function undeleteProject(projectId, options = {}) { async function expireDeletedProject(projectId) { try { + logger.info({ projectId }, 'expiring deleted project') const activeProject = await Project.findById(projectId).exec() if (activeProject) { // That project is active. The deleted project record might be there // because of an incomplete delete or undelete operation. Clean it up and // return. + logger.info( + { projectId }, + 'deleted project record found but project is active' + ) await DeletedProject.deleteOne({ 'deleterData.deletedProjectId': projectId, }) return } + const deletedProject = await DeletedProject.findOne({ 'deleterData.deletedProjectId': projectId, }).exec() @@ -360,12 +385,14 @@ async function expireDeletedProject(projectId) { ) return } - + const userId = deleteProject.deletedProjectOwnerId const historyId = deletedProject.project.overleaf && deletedProject.project.overleaf.history && deletedProject.project.overleaf.history.id + logger.info({ projectId, userId }, 'destroying expired project data') + await Promise.all([ DocstoreManager.promises.destroyProject(deletedProject.project._id), HistoryManager.promises.deleteProject( @@ -378,6 +405,10 @@ async function expireDeletedProject(projectId) { Modules.promises.hooks.fire('projectExpired', deletedProject.project._id), ]) + logger.info( + { projectId, userId }, + 'redacting PII from the deleted project record' + ) await DeletedProject.updateOne( { _id: deletedProject._id, @@ -389,6 +420,7 @@ async function expireDeletedProject(projectId) { }, } ).exec() + logger.info({ projectId, userId }, 'expired deleted project successfully') } catch (error) { logger.warn({ projectId, error }, 'error expiring deleted project') throw error From d7833afd35167cee0c901deb804b5c498dea4d65 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Thu, 5 Jun 2025 10:09:09 +0100 Subject: [PATCH 105/259] Merge pull request #26173 from overleaf/bg-fix-typo-in-project-deletion fix deleted project owner ID in expireDeletedProject function GitOrigin-RevId: 7e427bf9877865752f259a75b99354597d2e0a7f --- services/web/app/src/Features/Project/ProjectDeleter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js index c154637ec5..b81281e319 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.js +++ b/services/web/app/src/Features/Project/ProjectDeleter.js @@ -385,7 +385,7 @@ async function expireDeletedProject(projectId) { ) return } - const userId = deleteProject.deletedProjectOwnerId + const userId = deletedProject.deletedProjectOwnerId const historyId = deletedProject.project.overleaf && deletedProject.project.overleaf.history && From 3b684e08caa8a686c3d5096b8a08f91079eb7747 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 5 Jun 2025 11:10:19 +0200 Subject: [PATCH 106/259] [web] fetch token users in a single db query per access mode (#26078) * [web] skip db query when getting empty list of users * [web] fetch token users in a single db query per access mode GitOrigin-RevId: fa5d9edcb761bd5d5e5ea07d137a5a86efdbdd5c --- services/web/app/src/Features/User/UserGetter.js | 1 + services/web/test/unit/src/User/UserGetterTests.js | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/services/web/app/src/Features/User/UserGetter.js b/services/web/app/src/Features/User/UserGetter.js index bce4568880..a5fbe42651 100644 --- a/services/web/app/src/Features/User/UserGetter.js +++ b/services/web/app/src/Features/User/UserGetter.js @@ -269,6 +269,7 @@ const UserGetter = { getUsers(query, projection, callback) { try { query = normalizeMultiQuery(query) + if (query?._id?.$in?.length === 0) return callback(null, []) // shortcut for getUsers([]) db.users.find(query, { projection }).toArray(callback) } catch (err) { callback(err) diff --git a/services/web/test/unit/src/User/UserGetterTests.js b/services/web/test/unit/src/User/UserGetterTests.js index 0e0c170fd6..315a8073d6 100644 --- a/services/web/test/unit/src/User/UserGetterTests.js +++ b/services/web/test/unit/src/User/UserGetterTests.js @@ -119,6 +119,17 @@ describe('UserGetter', function () { }) }) + it('should not call mongo with empty list', function (done) { + const query = [] + const projection = { email: 1 } + this.UserGetter.getUsers(query, projection, (error, users) => { + expect(error).to.not.exist + expect(users).to.deep.equal([]) + expect(this.find).to.not.have.been.called + done() + }) + }) + it('should not allow null query', function (done) { this.UserGetter.getUser(null, {}, error => { error.should.exist From f7fcf4c23fcebb9a11f95d120007ff270ed57226 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 28 May 2025 15:21:09 +0100 Subject: [PATCH 107/259] Remove projectHistoryMetaData from mongo db interface GitOrigin-RevId: dbbc2218c7b1ff8b7907248f86b03189e9e4006d --- services/web/app/src/infrastructure/mongodb.js | 1 - services/web/scripts/history/clean_sl_history_data.mjs | 10 ++++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/services/web/app/src/infrastructure/mongodb.js b/services/web/app/src/infrastructure/mongodb.js index a3342c6575..24103b2d82 100644 --- a/services/web/app/src/infrastructure/mongodb.js +++ b/services/web/app/src/infrastructure/mongodb.js @@ -61,7 +61,6 @@ const db = { projectHistoryFailures: internalDb.collection('projectHistoryFailures'), projectHistoryGlobalBlobs: internalDb.collection('projectHistoryGlobalBlobs'), projectHistoryLabels: internalDb.collection('projectHistoryLabels'), - projectHistoryMetaData: internalDb.collection('projectHistoryMetaData'), projectHistorySyncState: internalDb.collection('projectHistorySyncState'), projectInvites: internalDb.collection('projectInvites'), projects: internalDb.collection('projects'), diff --git a/services/web/scripts/history/clean_sl_history_data.mjs b/services/web/scripts/history/clean_sl_history_data.mjs index 8eb541e078..8f2bc1eab0 100644 --- a/services/web/scripts/history/clean_sl_history_data.mjs +++ b/services/web/scripts/history/clean_sl_history_data.mjs @@ -1,4 +1,7 @@ -import { db } from '../../app/src/infrastructure/mongodb.js' +import { + db, + getCollectionInternal, +} from '../../app/src/infrastructure/mongodb.js' import { ensureMongoTimeout } from '../helpers/env_variable_helper.mjs' import { scriptRunner } from '../lib/ScriptRunner.mjs' // Ensure default mongo query timeout has been increased 1h @@ -47,7 +50,10 @@ async function setAllowDowngradeToFalse() { async function deleteHistoryCollections() { await gracefullyDropCollection(db.docHistory) await gracefullyDropCollection(db.docHistoryIndex) - await gracefullyDropCollection(db.projectHistoryMetaData) + const projectHistoryMetaData = await getCollectionInternal( + 'projectHistoryMetaData' + ) + await gracefullyDropCollection(projectHistoryMetaData) } async function gracefullyDropCollection(collection) { From 1386ca166969719781802c4be002af81609a0877 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 28 May 2025 15:21:27 +0100 Subject: [PATCH 108/259] Add migration for drop projectHistoryMetaData collection GitOrigin-RevId: 1ebfc60ee9591837f37e507fb1dcb059c09a7f3b --- ..._create_projectHistoryMetaData_indexes.mjs | 19 +++++++++++-------- ...drop_projectHistoryMetaData_collection.mjs | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 services/web/migrations/20250528141310_drop_projectHistoryMetaData_collection.mjs diff --git a/services/web/migrations/20190912145020_create_projectHistoryMetaData_indexes.mjs b/services/web/migrations/20190912145020_create_projectHistoryMetaData_indexes.mjs index ef9115a8bf..9272afc2e7 100644 --- a/services/web/migrations/20190912145020_create_projectHistoryMetaData_indexes.mjs +++ b/services/web/migrations/20190912145020_create_projectHistoryMetaData_indexes.mjs @@ -1,6 +1,7 @@ /* eslint-disable no-unused-vars */ import Helpers from './lib/helpers.mjs' +import mongodb from '../app/src/infrastructure/mongodb.js' const tags = ['saas'] @@ -13,17 +14,19 @@ const indexes = [ }, ] -const migrate = async client => { - const { db } = client - - await Helpers.addIndexesToCollection(db.projectHistoryMetaData, indexes) +const migrate = async () => { + await Helpers.addIndexesToCollection( + await mongodb.getCollectionInternal('projectHistoryMetaData'), + indexes + ) } -const rollback = async client => { - const { db } = client - +const rollback = async () => { try { - await Helpers.dropIndexesFromCollection(db.projectHistoryMetaData, indexes) + await Helpers.dropIndexesFromCollection( + await mongodb.getCollectionInternal('projectHistoryMetaData'), + indexes + ) } catch (err) { console.error('Something went wrong rolling back the migrations', err) } diff --git a/services/web/migrations/20250528141310_drop_projectHistoryMetaData_collection.mjs b/services/web/migrations/20250528141310_drop_projectHistoryMetaData_collection.mjs new file mode 100644 index 0000000000..45ec81c02d --- /dev/null +++ b/services/web/migrations/20250528141310_drop_projectHistoryMetaData_collection.mjs @@ -0,0 +1,17 @@ +import Helpers from './lib/helpers.mjs' + +const tags = ['saas'] + +const migrate = async () => { + await Helpers.dropCollection('projectHistoryMetaData') +} + +const rollback = async () => { + // Can't really do anything here +} + +export default { + tags, + migrate, + rollback, +} From 24e12bfbd4acdf8eb102463868cbb0c70fac882e Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:05:55 +0200 Subject: [PATCH 109/259] Migrate institutional account linking pages to Bootstrap 5 (#25900) GitOrigin-RevId: 75734bdbde52e90305ae759789acaf4203ec49b4 --- .../stylesheets/bootstrap-5/pages/auth.scss | 40 +++++++++++++++++++ .../bootstrap-5/pages/login-register.scss | 10 +++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/auth.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/auth.scss index cd23cadcee..9432892c1b 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/auth.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/auth.scss @@ -35,6 +35,33 @@ padding-bottom: var(--spacing-09); } +.login-register-container { + max-width: 400px; + margin: 0 auto; + padding-bottom: 125px; +} + +.login-register-error-container { + padding-bottom: var(--spacing-05); + text-align: left; +} + +.login-register-card { + padding-top: 0; + padding-bottom: 0; + text-align: center; +} + +.login-register-header { + padding-bottom: var(--spacing-07); + border-bottom: solid 1px var(--border-divider); +} + +.login-register-header-heading { + margin: 0; + color: var(--content-secondary); +} + .login-register-hr-text-container { line-height: 1; position: relative; @@ -58,6 +85,19 @@ padding: 0 var(--spacing-05); } +.login-register-text, +.login-register-hr-text-container { + margin: 0; +} + +.login-register-text { + padding-bottom: var(--spacing-08); + + &:last-child { + padding-bottom: 0; + } +} + .sso-auth-login-container { max-width: 400px; margin: 0 auto; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/login-register.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/login-register.scss index 25d3a42c4f..77771806a3 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/login-register.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/login-register.scss @@ -22,9 +22,13 @@ } } -.login-register-error-container { - padding-bottom: var(--spacing-05); - text-align: left; +.login-register-form { + padding: var(--spacing-08) var(--spacing-08) 0 var(--spacing-08); + border-bottom: solid 1px var(--border-divider); + + &:last-child { + border-bottom-width: 0; + } } .form-group-password { From ae51e57c75c06cdf225ec5ba57079c6b7811ace2 Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:06:02 +0200 Subject: [PATCH 110/259] Migrate user email confirmation page to Bootstrap 5 (#26026) GitOrigin-RevId: 8e12b19fb941c0adfeaa16089bfe229e8816ad8d --- services/web/app/views/user/confirm_email.pug | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/services/web/app/views/user/confirm_email.pug b/services/web/app/views/user/confirm_email.pug index 37c04880b1..13e911f386 100644 --- a/services/web/app/views/user/confirm_email.pug +++ b/services/web/app/views/user/confirm_email.pug @@ -1,60 +1,57 @@ extends ../layout-marketing - -block vars - - bootstrap5PageStatus = 'disabled' +include ../_mixins/notification block content main.content.content-alt#main-content .container .row - .col-md-8.col-md-offset-2.col-lg-6.col-lg-offset-3 + .col-lg-8.offset-lg-2.col-xl-6.offset-xl-3 .card - .page-header(data-ol-hide-on-error-message="confirm-email-wrong-user") - h1 #{translate("confirm_email")} - form( - method="POST" - action="/logout" - id="logoutForm" - ) - input(type="hidden", name="_csrf", value=csrfToken) - input(type="hidden", name="redirect", value=currentUrlWithQueryParams) - form( - data-ol-async-form, - data-ol-auto-submit, - name="confirmEmailForm" - action="/user/emails/confirm", - method="POST", - id="confirmEmailForm", - ) - input(type="hidden", name="_csrf", value=csrfToken) - input(type="hidden", name="token", value=token) + .card-body + .page-header(data-ol-hide-on-error-message="confirm-email-wrong-user") + h1 #{translate("confirm_email")} + form( + method="POST" + action="/logout" + id="logoutForm" + ) + input(type="hidden", name="_csrf", value=csrfToken) + input(type="hidden", name="redirect", value=currentUrlWithQueryParams) + form( + data-ol-async-form, + data-ol-auto-submit, + name="confirmEmailForm" + action="/user/emails/confirm", + method="POST", + id="confirmEmailForm", + ) + input(type="hidden", name="_csrf", value=csrfToken) + input(type="hidden", name="token", value=token) + + div(data-ol-not-sent) + +formMessages() + div(data-ol-custom-form-message="confirm-email-wrong-user" hidden) + h1.h3 #{translate("we_cant_confirm_this_email")} + p !{translate("to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account")} + p !{translate("you_are_currently_logged_in_as", {email: getUserEmail()})} + .actions + button.btn-primary.btn.w-100( + form="logoutForm" + ) #{translate('log_in_with_a_different_account')} - div(data-ol-not-sent) - +formMessages() - div(data-ol-custom-form-message="confirm-email-wrong-user" hidden) - h1.h3 #{translate("we_cant_confirm_this_email")} - p !{translate("to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account")} - p !{translate("you_are_currently_logged_in_as", {email: getUserEmail()})} .actions - button.btn-primary.btn.btn-block( - form="logoutForm" - ) #{translate('log_in_with_a_different_account')} + button.btn-primary.btn.w-100( + type='submit', + data-ol-disabled-inflight + data-ol-hide-on-error-message="confirm-email-wrong-user" + ) + span(data-ol-inflight="idle") + | #{translate('confirm')} + span(hidden data-ol-inflight="pending") + span(role='status').spinner-border.spinner-border-sm.mx-2 - .actions - button.btn-primary.btn.btn-block( - type='submit', - data-ol-disabled-inflight - data-ol-hide-on-error-message="confirm-email-wrong-user" - ) - span(data-ol-inflight="idle") - | #{translate('confirm')} - span(hidden data-ol-inflight="pending") - i.fa.fa-fw.fa-spin.fa-spinner(aria-hidden="true") - |  #{translate('confirming')}… - - div(hidden data-ol-sent) - .alert.alert-success - | #{translate('thank_you_email_confirmed')} - div.text-center - a.btn.btn-primary(href="/user/settings") - | #{translate('go_to_account_settings')} + div(hidden data-ol-sent) + +notification({ariaLive: 'polite', type: 'success', className: 'mb-3', content: translate("thank_you_email_confirmed")}) + div.text-center + a.btn.btn-primary(href="/user/settings") + | #{translate('go_to_account_settings')} From 784559f1b89c765899d8b14071eed4e249da8a3e Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:13:32 +0200 Subject: [PATCH 111/259] Add video caption track if captionFile is available (#25997) GitOrigin-RevId: fefcce66fe573385dfec34cc0f8697220fe418a3 --- services/web/config/settings.defaults.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 0d3ea86314..4d55f21db8 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -893,6 +893,7 @@ module.exports = { 'figcaption', 'span', 'source', + 'track', 'video', 'del', ], @@ -943,6 +944,7 @@ module.exports = { 'style', ], tr: ['class'], + track: ['src', 'kind', 'srcLang', 'label'], video: ['alt', 'class', 'controls', 'height', 'width'], }, }, From df233f3e5ef649a2efda3d4850def62c7532ea35 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 5 Jun 2025 11:58:39 +0100 Subject: [PATCH 112/259] Add commands for running just mocha tests GitOrigin-RevId: 6cd5c6aedd4fb2f222a758d6aca130f178a4acf3 --- services/web/Makefile | 5 +++++ services/web/package.json | 2 ++ 2 files changed, 7 insertions(+) diff --git a/services/web/Makefile b/services/web/Makefile index 58323058b8..6ebbc357c6 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -83,6 +83,11 @@ test_unit_app: $(DOCKER_COMPOSE) run --name unit_test_$(BUILD_DIR_NAME) --rm test_unit $(DOCKER_COMPOSE) down -v -t 0 +test_unit_mocha: export COMPOSE_PROJECT_NAME=unit_test_mocha_$(BUILD_DIR_NAME) +test_unit_mocha: + $(DOCKER_COMPOSE) run --rm test_unit npm run test:unit:mocha + $(DOCKER_COMPOSE) down -v -t 0 + test_unit_esm: export COMPOSE_PROJECT_NAME=unit_test_esm_$(BUILD_DIR_NAME) test_unit_esm: $(DOCKER_COMPOSE) run --rm test_unit npm run test:unit:esm diff --git a/services/web/package.json b/services/web/package.json index cc286b9225..826e051a9d 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -13,6 +13,8 @@ "test:unit:all": "npm run test:unit:run_dir -- test/unit/src modules/*/test/unit/src", "test:unit:all:silent": "npm run test:unit:all -- --reporter dot", "test:unit:app": "npm run test:unit:run_dir -- test/unit/src", + "test:unit:mocha": "npm run test:unit:mocha:run_dir -- test/unit/src modules/*/test/unit/src", + "test:unit:mocha:run_dir": "mocha --recursive --timeout 25000 --exit --grep=$MOCHA_GREP --require test/unit/bootstrap.js --extension=js", "test:unit:esm": "vitest run", "test:unit:esm:watch": "vitest", "test:frontend": "NODE_ENV=test TZ=GMT mocha --recursive --timeout 5000 --exit --extension js,jsx,mjs,ts,tsx --grep=$MOCHA_GREP --require test/frontend/bootstrap.js --ignore '**/*.spec.{js,jsx,ts,tsx}' --ignore '**/helpers/**/*.{js,jsx,ts,tsx}' test/frontend modules/*/test/frontend", From e5d828673e4f0cdda26c492ac6e7e978d583be6e Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:32:29 -0400 Subject: [PATCH 113/259] Merge pull request #26128 from overleaf/em-no-tracked-deletes-in-cm History OT: Remove tracked deletes from CodeMirror GitOrigin-RevId: 4e7f30cf2ed90b0c261eaa4ba51a2f54fe6e3cef --- .../editor/share-js-history-ot-type.ts | 59 +--- .../source-editor/extensions/history-ot.ts | 291 ++++++++++++++---- .../source-editor/extensions/realtime.ts | 125 +++++--- 3 files changed, 303 insertions(+), 172 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index 4621fd07fb..0e70e93676 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -1,11 +1,7 @@ import { EditOperation, EditOperationTransformer, - InsertOp, - RemoveOp, - RetainOp, StringFileData, - TextOperation, } from 'overleaf-editor-core' import { ShareDoc } from '../../../../../types/share-doc' @@ -15,7 +11,6 @@ type Api = { getText(): string getLength(): number - _register(): void } const api: Api & ThisType = { @@ -23,64 +18,12 @@ const api: Api & ThisType = { trackChangesUserId: null, getText() { - return this.snapshot.getContent() + return this.snapshot.getContent({ filterTrackedDeletes: true }) }, getLength() { return this.snapshot.getStringLength() }, - - _register() { - this.on('remoteop', (ops: EditOperation[], oldSnapshot: StringFileData) => { - const operation = ops[0] - if (operation instanceof TextOperation) { - const str = oldSnapshot.getContent() - if (str.length !== operation.baseLength) - throw new TextOperation.ApplyError( - "The operation's base length must be equal to the string's length.", - operation, - str - ) - - let outputCursor = 0 - let inputCursor = 0 - let trackedChangesInvalidated = false - for (const op of operation.ops) { - if (op instanceof RetainOp) { - inputCursor += op.length - outputCursor += op.length - if (op.tracking != null) { - trackedChangesInvalidated = true - } - } else if (op instanceof InsertOp) { - this.emit('insert', outputCursor, op.insertion, op.insertion.length) - outputCursor += op.insertion.length - trackedChangesInvalidated = true - } else if (op instanceof RemoveOp) { - this.emit( - 'delete', - outputCursor, - str.slice(inputCursor, inputCursor + op.length) - ) - inputCursor += op.length - trackedChangesInvalidated = true - } - } - - if (inputCursor !== str.length) { - throw new TextOperation.ApplyError( - "The operation didn't operate on the whole string.", - operation, - str - ) - } - - if (trackedChangesInvalidated) { - this.emit('tracked-changes-invalidated') - } - } - }) - }, } export const historyOTType = { diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts index 58c2a42540..b10a629189 100644 --- a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -1,4 +1,4 @@ -import { Decoration, EditorView } from '@codemirror/view' +import { Decoration, EditorView, WidgetType } from '@codemirror/view' import { ChangeSpec, EditorState, @@ -14,69 +14,151 @@ import { TrackedChangeList, } from 'overleaf-editor-core' import { DocumentContainer } from '@/features/ide-react/editor/document-container' +import { HistoryOTShareDoc } from '../../../../../types/share-doc' export const historyOT = (currentDoc: DocumentContainer) => { - const trackedChanges = currentDoc.doc?.getTrackedChanges() + const trackedChanges = + currentDoc.doc?.getTrackedChanges() ?? new TrackedChangeList([]) + const positionMapper = new PositionMapper(trackedChanges) return [ trackChangesUserIdState, + shareDocState.init(() => currentDoc?.doc?._doc ?? null), commentsState, - trackedChanges != null - ? trackedChangesState.init(() => - buildTrackedChangesDecorations(trackedChanges) - ) - : trackedChangesState, + trackedChangesState.init(() => ({ + decorations: buildTrackedChangesDecorations( + trackedChanges, + positionMapper + ), + positionMapper, + })), trackedChangesFilter, - rangesTheme, + trackedChangesTheme, ] } -const rangesTheme = EditorView.theme({ - '.tracked-change-insertion': { - backgroundColor: 'rgba(0, 255, 0, 0.2)', - }, - '.tracked-change-deletion': { - backgroundColor: 'rgba(255, 0, 0, 0.2)', - }, - '.comment': { - backgroundColor: 'rgba(255, 255, 0, 0.2)', - }, -}) - -const updateTrackedChangesEffect = StateEffect.define() - -export const updateTrackedChanges = (trackedChanges: TrackedChangeList) => { - return { - effects: updateTrackedChangesEffect.of(trackedChanges), - } -} - -const buildTrackedChangesDecorations = (trackedChanges: TrackedChangeList) => - Decoration.set( - trackedChanges.asSorted().map(change => - Decoration.mark({ - class: - change.tracking.type === 'insert' - ? 'tracked-change-insertion' - : 'tracked-change-deletion', - tracking: change.tracking, - }).range(change.range.pos, change.range.end) - ), - true - ) - -const trackedChangesState = StateField.define({ +export const shareDocState = StateField.define({ create() { - return Decoration.none + return null }, update(value, transaction) { - if (transaction.docChanged) { - value = value.map(transaction.changes) - } + // this state is constant + return value + }, +}) +const trackedChangesTheme = EditorView.baseTheme({ + '.ol-cm-change-i, .ol-cm-change-highlight-i, .ol-cm-change-focus-i': { + backgroundColor: 'rgba(44, 142, 48, 0.30)', + }, + '&light .ol-cm-change-c, &light .ol-cm-change-highlight-c, &light .ol-cm-change-focus-c': + { + backgroundColor: 'rgba(243, 177, 17, 0.30)', + }, + '&dark .ol-cm-change-c, &dark .ol-cm-change-highlight-c, &dark .ol-cm-change-focus-c': + { + backgroundColor: 'rgba(194, 93, 11, 0.15)', + }, + '.ol-cm-change': { + padding: 'var(--half-leading, 0) 0', + }, + '.ol-cm-change-highlight': { + padding: 'var(--half-leading, 0) 0', + }, + '.ol-cm-change-focus': { + padding: 'var(--half-leading, 0) 0', + }, + '&light .ol-cm-change-d': { + borderLeft: '2px dotted #c5060b', + marginLeft: '-1px', + }, + '&dark .ol-cm-change-d': { + borderLeft: '2px dotted #c5060b', + marginLeft: '-1px', + }, + '&light .ol-cm-change-d-highlight': { + borderLeft: '3px solid #c5060b', + marginLeft: '-2px', + }, + '&dark .ol-cm-change-d-highlight': { + borderLeft: '3px solid #c5060b', + marginLeft: '-2px', + }, + '&light .ol-cm-change-d-focus': { + borderLeft: '3px solid #B83A33', + marginLeft: '-2px', + }, + '&dark .ol-cm-change-d-focus': { + borderLeft: '3px solid #B83A33', + marginLeft: '-2px', + }, +}) + +export const updateTrackedChangesEffect = + StateEffect.define() + +const buildTrackedChangesDecorations = ( + trackedChanges: TrackedChangeList, + positionMapper: PositionMapper +) => { + const decorations = [] + for (const change of trackedChanges.asSorted()) { + if (change.tracking.type === 'insert') { + decorations.push( + Decoration.mark({ + class: 'ol-cm-change ol-cm-change-i', + tracking: change.tracking, + }).range( + positionMapper.toCM6(change.range.pos), + positionMapper.toCM6(change.range.end) + ) + ) + } else { + decorations.push( + Decoration.widget({ + widget: new ChangeDeletedWidget(), + side: 1, + }).range(positionMapper.toCM6(change.range.pos)) + ) + } + } + + return Decoration.set(decorations, true) +} + +class ChangeDeletedWidget extends WidgetType { + toDOM() { + const widget = document.createElement('span') + widget.classList.add('ol-cm-change') + widget.classList.add('ol-cm-change-d') + return widget + } + + eq(old: ChangeDeletedWidget) { + return true + } +} + +export const trackedChangesState = StateField.define({ + create() { + return { + decorations: Decoration.none, + positionMapper: new PositionMapper(new TrackedChangeList([])), + } + }, + + update(value, transaction) { for (const effect of transaction.effects) { if (effect.is(updateTrackedChangesEffect)) { - value = buildTrackedChangesDecorations(effect.value) + const trackedChanges = effect.value + const positionMapper = new PositionMapper(trackedChanges) + value = { + decorations: buildTrackedChangesDecorations( + effect.value, + positionMapper + ), + positionMapper, + } } } @@ -84,7 +166,7 @@ const trackedChangesState = StateField.define({ }, provide(field) { - return EditorView.decorations.from(field) + return EditorView.decorations.from(field, value => value.decorations) }, }) @@ -165,21 +247,28 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => { } const trackingUserId = tr.startState.field(trackChangesUserIdState) + const positionMapper = tr.startState.field(trackedChangesState).positionMapper const startDoc = tr.startState.doc const changes: ChangeSpec[] = [] - const opBuilder = new OperationBuilder(startDoc.length) + const effects = [] + const opBuilder = new OperationBuilder( + positionMapper.toSnapshot(startDoc.length) + ) if (trackingUserId == null) { // Not tracking changes tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { // insert if (inserted.length > 0) { - opBuilder.insert(fromA, inserted.toString()) + const pos = positionMapper.toSnapshot(fromA) + opBuilder.insert(pos, inserted.toString()) } // deletion if (toA > fromA) { - opBuilder.delete(fromA, toA - fromA) + const start = positionMapper.toSnapshot(fromA) + const end = positionMapper.toSnapshot(toA) + opBuilder.delete(start, end - start) } }) } else { @@ -188,8 +277,9 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => { tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { // insertion if (inserted.length > 0) { + const pos = positionMapper.toSnapshot(fromA) opBuilder.trackedInsert( - fromA, + pos, inserted.toString(), trackingUserId, timestamp @@ -198,23 +288,23 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => { // deletion if (toA > fromA) { - const deleted = startDoc.sliceString(fromA, toA) - // re-insert the deleted text after the inserted text - changes.push({ - from: fromB + inserted.length, - insert: deleted, - }) - - opBuilder.trackedDelete(fromA, toA - fromA, trackingUserId, timestamp) + const start = positionMapper.toSnapshot(fromA) + const end = positionMapper.toSnapshot(toA) + opBuilder.trackedDelete(start, end - start, trackingUserId, timestamp) } }) } const op = opBuilder.finish() - return [ - tr, - { changes, effects: historyOTOperationEffect.of([op]), sequential: true }, - ] + const shareDoc = tr.startState.field(shareDocState) + if (shareDoc != null) { + shareDoc.submitOp([op]) + effects.push( + updateTrackedChangesEffect.of(shareDoc.snapshot.getTrackedChanges()) + ) + } + + return [tr, { changes, effects, sequential: true }] }) /** @@ -288,3 +378,74 @@ class OperationBuilder { return this.op } } + +type OffsetTable = { pos: number; map: (pos: number) => number }[] + +class PositionMapper { + private offsets: { + toCM6: OffsetTable + toSnapshot: OffsetTable + } + + constructor(trackedChanges: TrackedChangeList) { + this.offsets = { + toCM6: [{ pos: 0, map: pos => pos }], + toSnapshot: [{ pos: 0, map: pos => pos }], + } + + // Offset of the snapshot pos relative to the CM6 pos + let offset = 0 + for (const change of trackedChanges.asSorted()) { + if (change.tracking.type === 'delete') { + const deleteLength = change.range.length + const deletePos = change.range.pos + const oldOffset = offset + const newOffset = offset + deleteLength + this.offsets.toSnapshot.push({ + pos: change.range.pos - offset + 1, + map: pos => pos + newOffset, + }) + this.offsets.toCM6.push({ + pos: change.range.pos, + map: pos => deletePos - oldOffset, + }) + this.offsets.toCM6.push({ + pos: change.range.pos + deleteLength, + map: pos => pos - newOffset, + }) + offset = newOffset + } + } + } + + toCM6(snapshotPos: number) { + return this.mapPos(snapshotPos, this.offsets.toCM6) + } + + toSnapshot(cm6Pos: number) { + return this.mapPos(cm6Pos, this.offsets.toSnapshot) + } + + mapPos(pos: number, offsets: OffsetTable) { + // Binary search for the offset at the last position before pos + let low = 0 + let high = offsets.length - 1 + while (low < high) { + const middle = Math.ceil((low + high) / 2) + const entry = offsets[middle] + if (entry.pos < pos) { + // This entry could be the right offset, but lower entries are too low + // Because we used Math.ceil(), middle is higher than low and the + // algorithm progresses. + low = middle + } else if (entry.pos > pos) { + // This entry is too high + high = middle - 1 + } else { + // This is the right entry + return entry.map(pos) + } + } + return offsets[low].map(pos) + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 1797cbc17e..e9f5710338 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -4,6 +4,7 @@ import { Annotation, ChangeSpec, Text, + StateEffect, } from '@codemirror/state' import { EditorView, ViewPlugin } from '@codemirror/view' import { EventEmitter } from 'events' @@ -15,11 +16,18 @@ import { } from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' -import { TrackedChangeList } from 'overleaf-editor-core' import { - updateTrackedChanges, + EditOperation, + TextOperation, + InsertOp, + RemoveOp, + RetainOp, +} from 'overleaf-editor-core' +import { + updateTrackedChangesEffect, setTrackChangesUserId, - historyOTOperationEffect, + trackedChangesState, + shareDocState, } from './history-ot' /* @@ -143,10 +151,6 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } - cmUpdateTrackedChanges(trackedChanges: TrackedChangeList) { - this.view.dispatch(updateTrackedChanges(trackedChanges)) - } - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { this.otAdapter = shareDoc.otType === 'history-ot' @@ -320,22 +324,11 @@ class HistoryOTAdapter { attachShareJs() { this.checkContent() - const onInsert = this.onShareJsInsert.bind(this) - const onDelete = this.onShareJsDelete.bind(this) - const onTrackedChangesInvalidated = - this.onShareJsTrackedChangesInvalidated.bind(this) - - this.shareDoc.on('insert', onInsert) - this.shareDoc.on('delete', onDelete) - this.shareDoc.on('tracked-changes-invalidated', onTrackedChangesInvalidated) + const onRemoteOp = this.onRemoteOp.bind(this) + this.shareDoc.on('remoteop', onRemoteOp) this.shareDoc.detach_cm6 = () => { - this.shareDoc.removeListener('insert', onInsert) - this.shareDoc.removeListener('delete', onDelete) - this.shareDoc.removeListener( - 'tracked-changes-invalidated', - onTrackedChangesInvalidated - ) + this.shareDoc.removeListener('remoteop', onRemoteOp) delete this.shareDoc.detach_cm6 this.editor.detachShareJs() } @@ -357,22 +350,6 @@ class HistoryOTAdapter { return } - let snapshotUpdated = false - for (const effect of transaction.effects) { - if (effect.is(historyOTOperationEffect)) { - this.shareDoc.submitOp(effect.value) - snapshotUpdated = true - } - } - - if (snapshotUpdated || transaction.annotation(Transaction.remote)) { - window.setTimeout(() => { - this.editor.cmUpdateTrackedChanges( - this.shareDoc.snapshot.getTrackedChanges() - ) - }, 0) - } - const origin = chooseOrigin(transaction) transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin) @@ -380,20 +357,70 @@ class HistoryOTAdapter { } } - onShareJsInsert(pos: number, text: string) { - this.editor.cmInsert(pos, text, 'remote') - this.checkContent() - } + onRemoteOp(operations: EditOperation[]) { + const positionMapper = + this.editor.view.state.field(trackedChangesState).positionMapper + const changes: ChangeSpec[] = [] + let trackedChangesUpdated = false + for (const operation of operations) { + if (operation instanceof TextOperation) { + let cursor = 0 + for (const op of operation.ops) { + if (op instanceof InsertOp) { + if (op.tracking?.type !== 'delete') { + changes.push({ + from: positionMapper.toCM6(cursor), + insert: op.insertion, + }) + } + trackedChangesUpdated = true + } else if (op instanceof RemoveOp) { + changes.push({ + from: positionMapper.toCM6(cursor), + to: positionMapper.toCM6(cursor + op.length), + }) + cursor += op.length + trackedChangesUpdated = true + } else if (op instanceof RetainOp) { + if (op.tracking != null) { + if (op.tracking.type === 'delete') { + changes.push({ + from: positionMapper.toCM6(cursor), + to: positionMapper.toCM6(cursor + op.length), + }) + } + trackedChangesUpdated = true + } + cursor += op.length + } + } + } - onShareJsDelete(pos: number, text: string) { - this.editor.cmDelete(pos, text, 'remote') - this.checkContent() - } + const view = this.editor.view + const effects: StateEffect[] = [] + const scrollEffect = view + .scrollSnapshot() + .map(view.state.changes(changes)) + if (scrollEffect != null) { + effects.push(scrollEffect) + } + if (trackedChangesUpdated) { + const shareDoc = this.editor.view.state.field(shareDocState) + if (shareDoc != null) { + const trackedChanges = shareDoc.snapshot.getTrackedChanges() + effects.push(updateTrackedChangesEffect.of(trackedChanges)) + } + } - onShareJsTrackedChangesInvalidated() { - this.editor.cmUpdateTrackedChanges( - this.shareDoc.snapshot.getTrackedChanges() - ) + view.dispatch({ + changes, + effects, + annotations: [ + Transaction.remote.of(true), + Transaction.addToHistory.of(false), + ], + }) + } } onCodeMirrorChange( From 9e9ad3c00531ee780b9ba48c3b5825970c6b085a Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Fri, 6 Jun 2025 01:09:10 +0000 Subject: [PATCH 114/259] auto update translation GitOrigin-RevId: 52a28c6823536ef916c656128dbcdff1da80635b --- services/web/locales/da.json | 1 - services/web/locales/de.json | 1 - services/web/locales/fr.json | 1 - services/web/locales/sv.json | 1 - services/web/locales/zh-CN.json | 1 - 5 files changed, 5 deletions(-) diff --git a/services/web/locales/da.json b/services/web/locales/da.json index 3d8b52e547..7b9df181ac 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -432,7 +432,6 @@ "disconnected": "Forbindelsen blev afbrudt", "discount_of": "Rabat på __amount__", "discover_latex_templates_and_examples": "Opdag LaTeX skabeloner og eksempler til at hjælpe med alt fra at skrive en artikel til at bruge en specifik LaTeX pakke.", - "dismiss_error_popup": "Afvis første fejlmeddelelse", "display_deleted_user": "Vis slettede brugere", "do_not_have_acct_or_do_not_want_to_link": "Hvis du ikke har en __appName__-konto, eller hvis du ikke vil kæde den sammen med din __institutionName__-konto, klik venligst __clickText__.", "do_not_link_accounts": "Kæd ikke kontoer sammen", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 11129073df..a6d68345ba 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -312,7 +312,6 @@ "disable_stop_on_first_error": "„Anhalten beim ersten Fehler“ deaktivieren", "disconnected": "Nicht verbunden", "discount_of": "__amount__ Rabatt", - "dismiss_error_popup": "Erste Fehlermeldung schließen", "do_not_have_acct_or_do_not_want_to_link": "Wenn du kein __appName__-Konto hast oder nicht mit deinem __institutionName__-Konto verknüpfen möchtest, klicke auf „__clickText__“.", "do_not_link_accounts": "Konten nicht verknüpfen", "do_you_want_to_change_your_primary_email_address_to": "Willst Du deine primäre E-Mail-Adresse in __email__ ändern?", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index c081b84651..2e80ea2132 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -344,7 +344,6 @@ "disable_stop_on_first_error": "Désactiver “Arrêter à la première erreur”", "disconnected": "Déconnecté", "discount_of": "Remise de __amount__", - "dismiss_error_popup": "Ignorer l’alerte de première erreur", "do_not_have_acct_or_do_not_want_to_link": "Si vous n’avez pas de compte __appName__ ou si vous ne souhaitez pas le lier à votre compte __institutionName__, veuillez cliquer __clickText__.", "do_not_link_accounts": "Ne pas lier les comptes", "do_you_want_to_change_your_primary_email_address_to": "Voulez-vous définir __email__ comme votre adresse email principale ?", diff --git a/services/web/locales/sv.json b/services/web/locales/sv.json index 9ed626fe36..ab9f615050 100644 --- a/services/web/locales/sv.json +++ b/services/web/locales/sv.json @@ -208,7 +208,6 @@ "dictionary": "Ordbok", "disable_stop_on_first_error": "Inaktivera \"Stopp vid första fel\"", "disconnected": "Frånkopplad", - "dismiss_error_popup": "Avfärda varning om första fel", "do_not_have_acct_or_do_not_want_to_link": "Om du inte har ett __appName__-konto, eller om du inte vill länka till ditt __institutionName__-konto, vänligen klicka på __clickText__.", "do_not_link_accounts": "Länka ej konton", "documentation": "Dokumentation", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index 44e303d64d..bd3519a73c 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -518,7 +518,6 @@ "discover_latex_templates_and_examples": "探索 LaTeX 模板和示例,以帮助完成从撰写期刊文章到使用特定 LaTeX 包的所有工作。", "discover_the_fastest_way_to_search_and_cite": "探索搜索和引用的最快方法", "discover_why_over_people_worldwide_trust_overleaf": "了解为什么全世界有超过__count__万人信任 Overleaf 并把工作交给它。", - "dismiss_error_popup": "忽略第一个错误提示", "display": "显示", "display_deleted_user": "显示已删除的用户", "display_math": "显示数学公式", From a8df91e91bc6a4d655bc99323de1fbceeef9ce13 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:19:42 +0200 Subject: [PATCH 115/259] Merge pull request #26087 from overleaf/mf-change-to-stripe-uk [web] Configure to use Stripe UK account GitOrigin-RevId: 0856f6da2caae8caf9887ec2acea8e7f0972e598 --- .../src/Features/Subscription/PlansLocator.js | 49 +++++++++++-------- .../views/subscriptions/dashboard-react.pug | 2 +- .../util/handle-stripe-payment-action.ts | 5 +- services/web/frontend/js/utils/meta.ts | 2 +- .../src/Subscription/PlansLocatorTests.js | 22 ++++----- .../SubscriptionViewModelBuilderTests.js | 8 +-- services/web/types/admin/subscription.ts | 2 +- .../subscription/dashboard/subscription.ts | 2 +- services/web/types/subscription/plan.ts | 17 ++++--- 9 files changed, 60 insertions(+), 49 deletions(-) diff --git a/services/web/app/src/Features/Subscription/PlansLocator.js b/services/web/app/src/Features/Subscription/PlansLocator.js index c04f0c860d..1d4fe210d5 100644 --- a/services/web/app/src/Features/Subscription/PlansLocator.js +++ b/services/web/app/src/Features/Subscription/PlansLocator.js @@ -27,21 +27,26 @@ function ensurePlansAreSetupCorrectly() { } const recurlyPlanCodeToStripeLookupKey = { - 'professional-annual': 'professional_annual', - professional: 'professional_monthly', - professional_free_trial_7_days: 'professional_monthly', - 'collaborator-annual': 'standard_annual', - collaborator: 'standard_monthly', - collaborator_free_trial_7_days: 'standard_monthly', - 'student-annual': 'student_annual', - student: 'student_monthly', - student_free_trial_7_days: 'student_monthly', - group_professional: 'group_professional_enterprise', - group_professional_educational: 'group_professional_educational', + collaborator: 'collaborator_may2025', + 'collaborator-annual': 'collaborator_annual_may2025', + collaborator_free_trial_7_days: 'collaborator_may2025', + + professional: 'professional_may2025', + 'professional-annual': 'professional_annual_may2025', + professional_free_trial_7_days: 'professional_may2025', + + student: 'student_may2025', + 'student-annual': 'student_annual_may2025', + student_free_trial_7_days: 'student_may2025', + + // TODO: change all group plans' lookup_keys to match the UK account after they have been added group_collaborator: 'group_standard_enterprise', group_collaborator_educational: 'group_standard_educational', - 'assistant-annual': 'error_assist_annual', - assistant: 'error_assist_monthly', + group_professional: 'group_professional_enterprise', + group_professional_educational: 'group_professional_educational', + + assistant: 'assistant_may2025', + 'assistant-annual': 'assistant_annual_may2025', } /** @@ -66,10 +71,10 @@ function mapRecurlyAddOnCodeToStripeLookupKey( // Recurly always uses 'assistant' as the code regardless of the subscription duration if (recurlyAddOnCode === 'assistant') { if (billingCycleInterval === 'month') { - return 'error_assist_monthly' + return 'assistant_may2025' } if (billingCycleInterval === 'year') { - return 'error_assist_annual' + return 'assistant_annual_may2025' } } return null @@ -77,21 +82,25 @@ function mapRecurlyAddOnCodeToStripeLookupKey( const recurlyPlanCodeToPlanTypeAndPeriod = { collaborator: { planType: 'individual', period: 'monthly' }, - collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' }, 'collaborator-annual': { planType: 'individual', period: 'annual' }, + collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' }, + professional: { planType: 'individual', period: 'monthly' }, + 'professional-annual': { planType: 'individual', period: 'annual' }, professional_free_trial_7_days: { planType: 'individual', period: 'monthly', }, - 'professional-annual': { planType: 'individual', period: 'annual' }, + student: { planType: 'student', period: 'monthly' }, - student_free_trial_7_days: { planType: 'student', period: 'monthly' }, 'student-annual': { planType: 'student', period: 'annual' }, - group_professional: { planType: 'group', period: 'annual' }, - group_professional_educational: { planType: 'group', period: 'annual' }, + student_free_trial_7_days: { planType: 'student', period: 'monthly' }, + group_collaborator: { planType: 'group', period: 'annual' }, group_collaborator_educational: { planType: 'group', period: 'annual' }, + group_professional: { planType: 'group', period: 'annual' }, + group_professional_educational: { planType: 'group', period: 'annual' }, + assistant: { planType: null, period: 'monthly' }, 'assistant-annual': { planType: null, period: 'annual' }, } diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index 8cc5ec1976..2b6251f2a3 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -27,7 +27,7 @@ block append meta meta(name="ol-user" data-type="json" content=user) if (personalSubscription && personalSubscription.payment) meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) - meta(name="ol-stripeApiKey" content=settings.apis.stripe.publishableKey) + meta(name="ol-stripeUKApiKey" content=settings.apis.stripeUK.publishableKey) meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency) meta(name="ol-groupPlans" data-type="json" content=groupPlans) diff --git a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts index fd29674893..f533cba730 100644 --- a/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts +++ b/services/web/frontend/js/features/subscription/util/handle-stripe-payment-action.ts @@ -8,8 +8,9 @@ export default async function handleStripePaymentAction( const clientSecret = error?.data?.clientSecret if (clientSecret) { - const stripePublicKey = getMeta('ol-stripeApiKey') - const stripe = await loadStripe(stripePublicKey) + // TODO: support both US and UK Stripe accounts + const stripeUKPublicKey = getMeta('ol-stripeUKApiKey') + const stripe = await loadStripe(stripeUKPublicKey) if (stripe) { const manualConfirmationFlow = await stripe.confirmCardPayment(clientSecret) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 1dd4af88e0..f2692a0b7b 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -240,8 +240,8 @@ export interface Meta { 'ol-splitTestVariants': { [name: string]: string } 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string - 'ol-stripeApiKey': string 'ol-stripeCustomerId': string + 'ol-stripeUKApiKey': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionId': string diff --git a/services/web/test/unit/src/Subscription/PlansLocatorTests.js b/services/web/test/unit/src/Subscription/PlansLocatorTests.js index 0c7a6dca03..e0db2e825d 100644 --- a/services/web/test/unit/src/Subscription/PlansLocatorTests.js +++ b/services/web/test/unit/src/Subscription/PlansLocatorTests.js @@ -55,63 +55,63 @@ describe('PlansLocator', function () { const planCode = 'collaborator' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('standard_monthly') + expect(lookupKey).to.equal('collaborator_may2025') }) it('should map "collaborator_free_trial_7_days" plan code to stripe lookup keys', function () { const planCode = 'collaborator_free_trial_7_days' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('standard_monthly') + expect(lookupKey).to.equal('collaborator_may2025') }) it('should map "collaborator-annual" plan code to stripe lookup keys', function () { const planCode = 'collaborator-annual' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('standard_annual') + expect(lookupKey).to.equal('collaborator_annual_may2025') }) it('should map "professional" plan code to stripe lookup keys', function () { const planCode = 'professional' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('professional_monthly') + expect(lookupKey).to.equal('professional_may2025') }) it('should map "professional_free_trial_7_days" plan code to stripe lookup keys', function () { const planCode = 'professional_free_trial_7_days' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('professional_monthly') + expect(lookupKey).to.equal('professional_may2025') }) it('should map "professional-annual" plan code to stripe lookup keys', function () { const planCode = 'professional-annual' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('professional_annual') + expect(lookupKey).to.equal('professional_annual_may2025') }) it('should map "student" plan code to stripe lookup keys', function () { const planCode = 'student' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('student_monthly') + expect(lookupKey).to.equal('student_may2025') }) it('shoult map "student_free_trial_7_days" plan code to stripe lookup keys', function () { const planCode = 'student_free_trial_7_days' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('student_monthly') + expect(lookupKey).to.equal('student_may2025') }) it('should map "student-annual" plan code to stripe lookup keys', function () { const planCode = 'student-annual' const lookupKey = this.PlansLocator.mapRecurlyPlanCodeToStripeLookupKey(planCode) - expect(lookupKey).to.equal('student_annual') + expect(lookupKey).to.equal('student_annual_may2025') }) }) @@ -141,7 +141,7 @@ describe('PlansLocator', function () { addOnCode, billingCycleInterval ) - expect(lookupKey).to.equal('error_assist_monthly') + expect(lookupKey).to.equal('assistant_may2025') }) it('returns the key for an annual AI assist add-on', function () { @@ -151,7 +151,7 @@ describe('PlansLocator', function () { addOnCode, billingCycleInterval ) - expect(lookupKey).to.equal('error_assist_annual') + expect(lookupKey).to.equal('assistant_annual_may2025') }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index 0f666b888a..a7c02f1e65 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -589,7 +589,7 @@ describe('SubscriptionViewModelBuilder', function () { describe('isEligibleForGroupPlan', function () { it('is false for Stripe subscriptions', async function () { - this.paymentRecord.service = 'stripe' + this.paymentRecord.service = 'stripe-us' const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user @@ -627,7 +627,7 @@ describe('SubscriptionViewModelBuilder', function () { describe('isEligibleForPause', function () { it('is false for Stripe subscriptions', async function () { - this.paymentRecord.service = 'stripe' + this.paymentRecord.service = 'stripe-us' const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user @@ -777,7 +777,7 @@ describe('SubscriptionViewModelBuilder', function () { this.paymentRecord.pausePeriodStart = null this.paymentRecord.remainingPauseCycles = null this.paymentRecord.trialPeriodEnd = null - this.paymentRecord.service = 'stripe' + this.paymentRecord.service = 'stripe-us' const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user @@ -847,7 +847,7 @@ describe('SubscriptionViewModelBuilder', function () { }) it('does not add a billing details link for a Stripe subscription', async function () { - this.paymentRecord.service = 'stripe' + this.paymentRecord.service = 'stripe-us' this.Modules.hooks.fire .withArgs('getPaymentFromRecord', this.individualSubscription) .yields(null, [ diff --git a/services/web/types/admin/subscription.ts b/services/web/types/admin/subscription.ts index ad05fbac40..811ebf54bf 100644 --- a/services/web/types/admin/subscription.ts +++ b/services/web/types/admin/subscription.ts @@ -7,7 +7,7 @@ import { TeamInvite } from '../team-invite' type RecurlyAdminClientPaymentProvider = Record type StripeAdminClientPaymentProvider = PaymentProvider & { - service: 'stripe' + service: 'stripe-us' | 'stripe-uk' } export type Subscription = { diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index a1ee934423..92a61e8ddb 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -103,7 +103,7 @@ export type MemberGroupSubscription = Omit & { admin_id: User } -type PaymentProviderService = 'stripe' | 'recurly' +type PaymentProviderService = 'stripe-us' | 'stripe-uk' | 'recurly' export type PaymentProvider = { service: PaymentProviderService diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index 4759bb1255..5a0d40a695 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -91,15 +91,16 @@ export type RecurlyPlanCode = export type RecurlyAddOnCode = 'assistant' export type StripeLookupKey = - | 'standard_monthly' - | 'standard_annual' - | 'professional_monthly' - | 'professional_annual' - | 'student_monthly' - | 'student_annual' + | 'collaborator_may2025' + | 'collaborator_annual_may2025' + | 'professional_may2025' + | 'professional_annual_may2025' + | 'student_may2025' + | 'student_annual_may2025' + // TODO: change all group plans' lookup_keys to match the UK account after they have been added | 'group_standard_enterprise' | 'group_professional_enterprise' | 'group_standard_educational' | 'group_professional_educational' - | 'error_assist_annual' - | 'error_assist_monthly' + | 'assistant_annual_may2025' + | 'assistant_may2025' From 7a449f468620908f5fd0f1f24ac76d60c142fafe Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:20:22 +0200 Subject: [PATCH 116/259] Merge pull request #26014 from overleaf/kh-remaining-references-to-recurly-fields [web] update remaining references to `recurlyStatus` and `recurlySubscription_id` GitOrigin-RevId: f5e905eba598cfcd146803c6ccc36a2304021544 --- .../src/Features/Project/ProjectController.js | 8 +- .../Project/ProjectListController.mjs | 13 +- .../Features/Subscription/FeaturesUpdater.js | 6 +- .../Subscription/SubscriptionController.js | 4 +- .../Subscription/SubscriptionGroupHandler.js | 3 +- .../Subscription/SubscriptionHandler.js | 21 +- .../Subscription/SubscriptionHelper.js | 59 +++++ .../SubscriptionViewModelBuilder.js | 34 ++- .../Subscription/TeamInvitesController.mjs | 13 +- services/web/app/views/project/list-react.pug | 2 +- .../app/views/subscriptions/team/invite.pug | 2 +- .../current-plan-widget.tsx | 3 +- .../use-group-invitation-notification.tsx | 8 +- .../js/features/project-list/util/user.ts | 14 ++ .../components/group-invite/group-invite.tsx | 8 +- services/web/frontend/js/utils/meta.ts | 2 +- .../project-list/notifications.stories.tsx | 2 +- .../notifications/group-invitation.spec.tsx | 5 +- .../components/notifications.test.tsx | 2 +- .../group-invite/group-invite.test.tsx | 10 +- .../subscription/fixtures/subscriptions.ts | 12 -- .../src/Project/ProjectControllerTests.js | 3 - .../SubscriptionControllerTests.js | 4 +- .../Subscription/SubscriptionHandlerTests.js | 2 + .../Subscription/SubscriptionHelperTests.js | 202 ++++++++++++++++++ .../SubscriptionViewModelBuilderTests.js | 165 +++++++++----- .../TeamInvitesController.test.mjs | 8 +- .../types/project/dashboard/subscription.ts | 6 +- .../subscription/dashboard/subscription.ts | 1 - services/web/types/user.ts | 2 +- 30 files changed, 477 insertions(+), 147 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index e88cb53449..7b8f989458 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -14,6 +14,7 @@ const ProjectHelper = require('./ProjectHelper') const metrics = require('@overleaf/metrics') const { User } = require('../../models/User') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') +const { isPaidSubscription } = require('../Subscription/SubscriptionHelper') const LimitationsManager = require('../Subscription/LimitationsManager') const Settings = require('@overleaf/settings') const AuthorizationManager = require('../Authorization/AuthorizationManager') @@ -655,12 +656,11 @@ const _ProjectController = { } } - const hasNonRecurlySubscription = - subscription && !subscription.recurlySubscription_id + const hasPaidSubscription = isPaidSubscription(subscription) const hasManuallyCollectedSubscription = subscription?.collectionMethod === 'manual' const canPurchaseAddons = !( - hasNonRecurlySubscription || hasManuallyCollectedSubscription + hasPaidSubscription || hasManuallyCollectedSubscription ) const assistantDisabled = user.aiErrorAssistant?.enabled === false // the assistant has been manually disabled by the user const canUseErrorAssistant = @@ -792,7 +792,7 @@ const _ProjectController = { referal_id: user.referal_id, signUpDate: user.signUpDate, allowedFreeTrial, - hasRecurlySubscription: subscription?.recurlySubscription_id != null, + hasPaidSubscription, featureSwitches: user.featureSwitches, features: fullFeatureSet, featureUsage, diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index c62396e153..1faa2df017 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -26,6 +26,7 @@ import GeoIpLookup from '../../infrastructure/GeoIpLookup.js' import SplitTestHandler from '../SplitTests/SplitTestHandler.js' import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js' import TutorialHandler from '../Tutorial/TutorialHandler.js' +import SubscriptionHelper from '../Subscription/SubscriptionHelper.js' /** * @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types" @@ -388,13 +389,13 @@ async function projectListPage(req, res, next) { } } - let hasIndividualRecurlySubscription = false + let hasIndividualPaidSubscription = false try { - hasIndividualRecurlySubscription = - usersIndividualSubscription?.groupPlan === false && - usersIndividualSubscription?.recurlyStatus?.state !== 'canceled' && - usersIndividualSubscription?.recurlySubscription_id !== '' + hasIndividualPaidSubscription = + SubscriptionHelper.isIndividualActivePaidSubscription( + usersIndividualSubscription + ) } catch (error) { logger.error({ err: error }, 'Failed to get individual subscription') } @@ -437,7 +438,7 @@ async function projectListPage(req, res, next) { groupId: subscription._id, groupName: subscription.teamName, })), - hasIndividualRecurlySubscription, + hasIndividualPaidSubscription, userRestrictions: Array.from(req.userRestrictions || []), }) } diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.js b/services/web/app/src/Features/Subscription/FeaturesUpdater.js index a8c27f705f..16413c501c 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.js +++ b/services/web/app/src/Features/Subscription/FeaturesUpdater.js @@ -3,6 +3,7 @@ const { callbackify } = require('util') const { callbackifyMultiResult } = require('@overleaf/promise-utils') const PlansLocator = require('./PlansLocator') const SubscriptionLocator = require('./SubscriptionLocator') +const SubscriptionHelper = require('./SubscriptionHelper') const UserFeaturesUpdater = require('./UserFeaturesUpdater') const FeaturesHelper = require('./FeaturesHelper') const Settings = require('@overleaf/settings') @@ -117,7 +118,10 @@ async function computeFeatures(userId) { async function _getIndividualFeatures(userId) { const subscription = await SubscriptionLocator.promises.getUsersSubscription(userId) - if (subscription == null || subscription?.recurlyStatus?.state === 'paused') { + if ( + subscription == null || + SubscriptionHelper.getPaidSubscriptionState(subscription) === 'paused' + ) { return {} } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index aa0b97d497..d7de79f5a4 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -2,6 +2,7 @@ const SessionManager = require('../Authentication/SessionManager') const SubscriptionHandler = require('./SubscriptionHandler') +const SubscriptionHelper = require('./SubscriptionHelper') const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder') const LimitationsManager = require('./LimitationsManager') const RecurlyWrapper = require('./RecurlyWrapper') @@ -262,7 +263,8 @@ async function pauseSubscription(req, res, next) { { pause_length: pauseCycles, plan_code: subscription?.planCode, - subscriptionId: subscription?.recurlySubscription_id, + subscriptionId: + SubscriptionHelper.getPaymentProviderSubscriptionId(subscription), } ) diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index c717b2eec6..ba862baa67 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -4,6 +4,7 @@ const OError = require('@overleaf/o-error') const SubscriptionUpdater = require('./SubscriptionUpdater') const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionController = require('./SubscriptionController') +const SubscriptionHelper = require('./SubscriptionHelper') const { Subscription } = require('../../models/Subscription') const { User } = require('../../models/User') const RecurlyClient = require('./RecurlyClient') @@ -77,7 +78,7 @@ async function ensureFlexibleLicensingEnabled(plan) { } async function ensureSubscriptionIsActive(subscription) { - if (subscription?.recurlyStatus?.state !== 'active') { + if (SubscriptionHelper.getPaidSubscriptionState(subscription) !== 'active') { throw new InactiveError('The subscription is not active', { subscriptionId: subscription._id.toString(), }) diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 8aa0ee84eb..9471974b08 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -4,6 +4,7 @@ const RecurlyWrapper = require('./RecurlyWrapper') const RecurlyClient = require('./RecurlyClient') const { User } = require('../../models/User') const logger = require('@overleaf/logger') +const SubscriptionHelper = require('./SubscriptionHelper') const SubscriptionUpdater = require('./SubscriptionUpdater') const SubscriptionLocator = require('./SubscriptionLocator') const LimitationsManager = require('./LimitationsManager') @@ -101,8 +102,7 @@ async function updateSubscription(user, planCode) { if ( !hasSubscription || subscription == null || - (subscription.recurlySubscription_id == null && - subscription.paymentProvider?.subscriptionId == null) + SubscriptionHelper.getPaymentProviderSubscriptionId(subscription) == null ) { return } @@ -299,7 +299,10 @@ async function pauseSubscription(user, pauseCycles) { // only allow pausing on monthly plans not in a trial const { subscription } = await LimitationsManager.promises.userHasSubscription(user) - if (!subscription || !subscription.recurlyStatus) { + if ( + !subscription || + !SubscriptionHelper.getPaidSubscriptionState(subscription) + ) { throw new Error('No active subscription to pause') } @@ -310,10 +313,9 @@ async function pauseSubscription(user, pauseCycles) { ) { throw new Error('Can only pause monthly individual plans') } - if ( - subscription.recurlyStatus.trialEndsAt && - subscription.recurlyStatus.trialEndsAt > new Date() - ) { + const trialEndsAt = + SubscriptionHelper.getSubscriptionTrialEndsAt(subscription) + if (trialEndsAt && trialEndsAt > new Date()) { throw new Error('Cannot pause a subscription in a trial') } if (subscription.addOns?.length) { @@ -329,7 +331,10 @@ async function pauseSubscription(user, pauseCycles) { async function resumeSubscription(user) { const { subscription } = await LimitationsManager.promises.userHasSubscription(user) - if (!subscription || !subscription.recurlyStatus) { + if ( + !subscription || + !SubscriptionHelper.getPaidSubscriptionState(subscription) + ) { throw new Error('No active subscription to resume') } await RecurlyClient.promises.resumeSubscriptionByUuid( diff --git a/services/web/app/src/Features/Subscription/SubscriptionHelper.js b/services/web/app/src/Features/Subscription/SubscriptionHelper.js index efb8895280..b4acef4d3e 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHelper.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHelper.js @@ -86,7 +86,66 @@ function generateInitialLocalizedGroupPrice(recommendedCurrency, locale) { } } +function isPaidSubscription(subscription) { + const hasRecurlySubscription = + subscription?.recurlySubscription_id && + subscription?.recurlySubscription_id !== '' + const hasStripeSubscription = + subscription?.paymentProvider?.subscriptionId && + subscription?.paymentProvider?.subscriptionId !== '' + return !!(subscription && (hasRecurlySubscription || hasStripeSubscription)) +} + +function isIndividualActivePaidSubscription(subscription) { + return ( + isPaidSubscription(subscription) && + subscription?.groupPlan === false && + subscription?.recurlyStatus?.state !== 'canceled' && + subscription?.paymentProvider?.state !== 'canceled' + ) +} + +function getPaymentProviderSubscriptionId(subscription) { + if (subscription?.recurlySubscription_id) { + return subscription.recurlySubscription_id + } + if (subscription?.paymentProvider?.subscriptionId) { + return subscription.paymentProvider.subscriptionId + } + return null +} + +function getPaidSubscriptionState(subscription) { + if (subscription?.recurlyStatus?.state) { + return subscription.recurlyStatus.state + } + if (subscription?.paymentProvider?.state) { + return subscription.paymentProvider.state + } + return null +} + +function getSubscriptionTrialStartedAt(subscription) { + if (subscription?.recurlyStatus) { + return subscription.recurlyStatus?.trialStartedAt + } + return subscription?.paymentProvider?.trialStartedAt +} + +function getSubscriptionTrialEndsAt(subscription) { + if (subscription?.recurlyStatus) { + return subscription.recurlyStatus?.trialEndsAt + } + return subscription?.paymentProvider?.trialEndsAt +} + module.exports = { shouldPlanChangeAtTermEnd, generateInitialLocalizedGroupPrice, + isPaidSubscription, + isIndividualActivePaidSubscription, + getPaymentProviderSubscriptionId, + getPaidSubscriptionState, + getSubscriptionTrialStartedAt, + getSubscriptionTrialEndsAt, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index 441d9c2c9b..25b00c28a5 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -1,6 +1,5 @@ // ts-check const Settings = require('@overleaf/settings') -const RecurlyWrapper = require('./RecurlyWrapper') const PlansLocator = require('./PlansLocator') const { isStandaloneAiAddOnPlanCode, @@ -8,7 +7,6 @@ const { } = require('./PaymentProviderEntities') const SubscriptionFormatters = require('./SubscriptionFormatters') const SubscriptionLocator = require('./SubscriptionLocator') -const SubscriptionUpdater = require('./SubscriptionUpdater') const InstitutionsGetter = require('../Institutions/InstitutionsGetter') const InstitutionsManager = require('../Institutions/InstitutionsManager') const PublishersGetter = require('../Publishers/PublishersGetter') @@ -227,6 +225,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { // don't return subscription payment information delete personalSubscription.paymentProvider delete personalSubscription.recurly + delete personalSubscription.recurlySubscription_id const tax = paymentRecord.subscription.taxAmount || 0 // Some plans allow adding more seats than the base plan provides. @@ -374,15 +373,6 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { } } -/** - * @param {{_id: string}} user - * @returns {Promise} - */ -async function getBestSubscription(user) { - const { bestSubscription } = await getUsersSubscriptionDetails(user) - return bestSubscription -} - /** * @param {{_id: string}} user * @returns {Promise<{bestSubscription:Subscription,individualSubscription:DBSubscription|null,memberGroupSubscriptions:DBSubscription[]}>} @@ -400,15 +390,18 @@ async function getUsersSubscriptionDetails(user) { if ( individualSubscription && !individualSubscription.customAccount && - individualSubscription.recurlySubscription_id && - !individualSubscription.recurlyStatus?.state + SubscriptionHelper.getPaymentProviderSubscriptionId( + individualSubscription + ) && + !SubscriptionHelper.getPaidSubscriptionState(individualSubscription) ) { - const recurlySubscription = await RecurlyWrapper.promises.getSubscription( - individualSubscription.recurlySubscription_id, - { includeAccount: true } + const paymentResults = await Modules.promises.hooks.fire( + 'getPaymentFromRecordPromise', + individualSubscription ) - await SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - recurlySubscription, + await Modules.promises.hooks.fire( + 'syncSubscription', + paymentResults[0]?.subscription, individualSubscription ) individualSubscription = @@ -540,7 +533,8 @@ function _isPlanEqualOrBetter(planA, planB) { function _getRemainingTrialDays(subscription) { const now = new Date() - const trialEndDate = subscription.recurlyStatus?.trialEndsAt + const trialEndDate = + SubscriptionHelper.getSubscriptionTrialEndsAt(subscription) return trialEndDate && trialEndDate > now ? Math.ceil( (trialEndDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) @@ -605,10 +599,8 @@ module.exports = { buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel), buildPlansList, buildPlansListForSubscriptionDash, - getBestSubscription: callbackify(getBestSubscription), promises: { buildUsersSubscriptionViewModel, - getBestSubscription, getUsersSubscriptionDetails, }, } diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index b2c9840de4..cbe46d2c29 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -4,6 +4,7 @@ import OError from '@overleaf/o-error' import TeamInvitesHandler from './TeamInvitesHandler.js' import SessionManager from '../Authentication/SessionManager.js' import SubscriptionLocator from './SubscriptionLocator.js' +import SubscriptionHelper from './SubscriptionHelper.js' import ErrorController from '../Errors/ErrorController.js' import EmailHelper from '../Helpers/EmailHelper.js' import UserGetter from '../User/UserGetter.js' @@ -87,12 +88,10 @@ async function viewInvite(req, res, next) { const personalSubscription = await SubscriptionLocator.promises.getUsersSubscription(userId) - const hasIndividualRecurlySubscription = - personalSubscription && - personalSubscription.groupPlan === false && - personalSubscription.recurlyStatus?.state !== 'canceled' && - personalSubscription.recurlySubscription_id && - personalSubscription.recurlySubscription_id !== '' + const hasIndividualPaidSubscription = + SubscriptionHelper.isIndividualActivePaidSubscription( + personalSubscription + ) if (subscription?.managedUsersEnabled) { if (!subscription.populated('groupPolicy')) { @@ -155,7 +154,7 @@ async function viewInvite(req, res, next) { return res.render('subscriptions/team/invite', { inviterName: invite.inviterName, inviteToken: invite.token, - hasIndividualRecurlySubscription, + hasIndividualPaidSubscription, expired: req.query.expired, userRestrictions: Array.from(req.userRestrictions || []), currentManagedUserAdminEmail, diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index be9233ecbb..60e7d0c0fc 100644 --- a/services/web/app/views/project/list-react.pug +++ b/services/web/app/views/project/list-react.pug @@ -34,7 +34,7 @@ block append meta meta(name="ol-recommendedCurrency" data-type="string" content=recommendedCurrency) meta(name="ol-showLATAMBanner" data-type="boolean" content=showLATAMBanner) meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment) - meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription) + meta(name="ol-hasIndividualPaidSubscription" data-type="boolean" content=hasIndividualPaidSubscription) meta(name="ol-groupSsoSetupSuccess" data-type="boolean" content=groupSsoSetupSuccess) meta(name="ol-showUSGovBanner" data-type="boolean" content=showUSGovBanner) meta(name="ol-usGovBannerVariant" data-type="string" content=usGovBannerVariant) diff --git a/services/web/app/views/subscriptions/team/invite.pug b/services/web/app/views/subscriptions/team/invite.pug index dc1b509cbf..1b2ecb4646 100644 --- a/services/web/app/views/subscriptions/team/invite.pug +++ b/services/web/app/views/subscriptions/team/invite.pug @@ -4,7 +4,7 @@ block entrypointVar - entrypoint = 'pages/user/subscription/invite' block append meta - meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription) + meta(name="ol-hasIndividualPaidSubscription" data-type="boolean" content=hasIndividualPaidSubscription) meta(name="ol-inviterName" data-type="string" content=inviterName) meta(name="ol-inviteToken" data-type="string" content=inviteToken) meta(name="ol-currentManagedUserAdminEmail" data-type="string" content=currentManagedUserAdminEmail) diff --git a/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx b/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx index 20bfe55479..1d17fe75a3 100644 --- a/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx +++ b/services/web/frontend/js/features/project-list/components/current-plan-widget/current-plan-widget.tsx @@ -4,6 +4,7 @@ import GroupPlan from './group-plan' import CommonsPlan from './commons-plan' import PausedPlan from './paused-plan' import getMeta from '../../../../utils/meta' +import { getUserSubscriptionState } from '../../util/user' function CurrentPlanWidget() { const usersBestSubscription = getMeta('ol-usersBestSubscription') @@ -19,7 +20,7 @@ function CurrentPlanWidget() { const isCommonsPlan = type === 'commons' const isPaused = isIndividualPlan && - usersBestSubscription.subscription?.recurlyStatus?.state === 'paused' + getUserSubscriptionState(usersBestSubscription) === 'paused' const featuresPageURL = '/learn/how-to/Overleaf_premium_features' const subscriptionPageUrl = '/user/subscription' diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx index 6c25513124..15248f8c42 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx @@ -57,19 +57,19 @@ export function useGroupInvitationNotification( const location = useLocation() const { handleDismiss } = useAsyncDismiss() - const hasIndividualRecurlySubscription = getMeta( - 'ol-hasIndividualRecurlySubscription' + const hasIndividualPaidSubscription = getMeta( + 'ol-hasIndividualPaidSubscription' ) useEffect(() => { - if (hasIndividualRecurlySubscription) { + if (hasIndividualPaidSubscription) { setGroupInvitationStatus( GroupInvitationStatus.CancelIndividualSubscription ) } else { setGroupInvitationStatus(GroupInvitationStatus.AskToJoin) } - }, [hasIndividualRecurlySubscription]) + }, [hasIndividualPaidSubscription]) const acceptGroupInvite = useCallback(() => { if (managedUsersEnabled) { diff --git a/services/web/frontend/js/features/project-list/util/user.ts b/services/web/frontend/js/features/project-list/util/user.ts index cb63ba3aee..115ad03cbc 100644 --- a/services/web/frontend/js/features/project-list/util/user.ts +++ b/services/web/frontend/js/features/project-list/util/user.ts @@ -1,4 +1,5 @@ import { UserRef } from '../../../../../types/project/dashboard/api' +import { Subscription } from '../../../../../types/project/dashboard/subscription' import getMeta from '@/utils/meta' export function getUserName(user: UserRef) { @@ -20,3 +21,16 @@ export function getUserName(user: UserRef) { return 'None' } + +export function getUserSubscriptionState(subscription: Subscription) { + if ('subscription' in subscription) { + if (subscription.subscription.recurlyStatus) { + return subscription.subscription.recurlyStatus.state + } + if (subscription.subscription.paymentProvider) { + return subscription.subscription.paymentProvider.state + } + } + + return null +} diff --git a/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx b/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx index a4e8fb2da8..66b6288388 100644 --- a/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx +++ b/services/web/frontend/js/features/subscription/components/group-invite/group-invite.tsx @@ -19,20 +19,20 @@ export type InviteViewTypes = | undefined function GroupInviteViews() { - const hasIndividualRecurlySubscription = getMeta( - 'ol-hasIndividualRecurlySubscription' + const hasIndividualPaidSubscription = getMeta( + 'ol-hasIndividualPaidSubscription' ) const cannotJoinSubscription = getMeta('ol-cannot-join-subscription') useEffect(() => { if (cannotJoinSubscription) { setView('managed-user-cannot-join') - } else if (hasIndividualRecurlySubscription) { + } else if (hasIndividualPaidSubscription) { setView('cancel-personal-subscription') } else { setView('invite') } - }, [cannotJoinSubscription, hasIndividualRecurlySubscription]) + }, [cannotJoinSubscription, hasIndividualPaidSubscription]) const [view, setView] = useState(undefined) if (!view) { diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index f2692a0b7b..f574b1154e 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -127,7 +127,7 @@ export interface Meta { 'ol-groupsAndEnterpriseBannerVariant': GroupsAndEnterpriseBannerVariant 'ol-hasAiAssistViaWritefull': boolean 'ol-hasGroupSSOFeature': boolean - 'ol-hasIndividualRecurlySubscription': boolean + 'ol-hasIndividualPaidSubscription': boolean 'ol-hasManagedUsersFeature': boolean 'ol-hasPassword': boolean 'ol-hasSubscription': boolean diff --git a/services/web/frontend/stories/project-list/notifications.stories.tsx b/services/web/frontend/stories/project-list/notifications.stories.tsx index 90fa82bfa5..ea00f84681 100644 --- a/services/web/frontend/stories/project-list/notifications.stories.tsx +++ b/services/web/frontend/stories/project-list/notifications.stories.tsx @@ -186,7 +186,7 @@ export const NotificationGroupInvitationCancelSubscription = (args: any) => { }, }) - window.metaAttributesCache.set('ol-hasIndividualRecurlySubscription', true) + window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', true) return ( diff --git a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx index 5767302fed..31114a2405 100644 --- a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx +++ b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx @@ -62,10 +62,7 @@ describe('', function () { describe('user with existing personal subscription', function () { beforeEach(function () { - window.metaAttributesCache.set( - 'ol-hasIndividualRecurlySubscription', - true - ) + window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', true) }) it('is able to join group successfully without cancelling personal subscription', function () { diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx index 78c732ebe3..9a845283d7 100644 --- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx +++ b/services/web/test/frontend/features/project-list/components/notifications.test.tsx @@ -441,7 +441,7 @@ describe('', function () { ), ]) window.metaAttributesCache.set( - 'ol-hasIndividualRecurlySubscription', + 'ol-hasIndividualPaidSubscription', true ) diff --git a/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx index cc70eff90d..d7b769fd20 100644 --- a/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx +++ b/services/web/test/frontend/features/subscription/components/group-invite/group-invite.test.tsx @@ -18,10 +18,7 @@ describe('group invite', function () { describe('when user has personal subscription', function () { beforeEach(function () { - window.metaAttributesCache.set( - 'ol-hasIndividualRecurlySubscription', - true - ) + window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', true) }) it('renders cancel personal subscription view', async function () { @@ -55,10 +52,7 @@ describe('group invite', function () { describe('when user does not have a personal subscription', function () { beforeEach(function () { - window.metaAttributesCache.set( - 'ol-hasIndividualRecurlySubscription', - false - ) + window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', false) window.metaAttributesCache.set('ol-inviteToken', 'token123') }) diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts index 08690742d3..8011c5206d 100644 --- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts +++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts @@ -25,7 +25,6 @@ export const annualActiveSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -68,7 +67,6 @@ export const annualActiveSubscriptionEuro: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -111,7 +109,6 @@ export const annualActiveSubscriptionPro: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'professional', - recurlySubscription_id: 'ghi789', plan: { planCode: 'professional', name: 'Professional', @@ -153,7 +150,6 @@ export const pastDueExpiredSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -196,7 +192,6 @@ export const canceledSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -239,7 +234,6 @@ export const pendingSubscriptionChange: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -290,7 +284,6 @@ export const groupActiveSubscription: GroupSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'group_collaborator_10_enterprise', - recurlySubscription_id: 'ghi789', plan: { planCode: 'group_collaborator_10_enterprise', name: 'Overleaf Standard (Collaborator) - Group Account (10 licenses) - Enterprise', @@ -338,7 +331,6 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription admin_id: 'abc123', teamInvites: [], planCode: 'group_collaborator_10_enterprise', - recurlySubscription_id: 'ghi789', plan: { planCode: 'group_collaborator_10_enterprise', name: 'Overleaf Standard (Collaborator) - Group Account (10 licenses) - Enterprise', @@ -396,7 +388,6 @@ export const trialSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'paid-personal_free_trial_7_days', - recurlySubscription_id: 'ghi789', plan: { planCode: 'paid-personal_free_trial_7_days', name: 'Personal', @@ -439,7 +430,6 @@ export const customSubscription: CustomSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator-annual', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator-annual', name: 'Standard (Collaborator) Annual', @@ -460,7 +450,6 @@ export const trialCollaboratorSubscription: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator_free_trial_7_days', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator_free_trial_7_days', name: 'Standard (Collaborator)', @@ -503,7 +492,6 @@ export const monthlyActiveCollaborator: PaidSubscription = { admin_id: 'abc123', teamInvites: [], planCode: 'collaborator', - recurlySubscription_id: 'ghi789', plan: { planCode: 'collaborator', name: 'Standard (Collaborator)', diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 46427171da..7745ece8fa 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -201,9 +201,6 @@ describe('ProjectController', function () { getCurrentAffiliations: sinon.stub().resolves([]), }, } - this.SubscriptionViewModelBuilder = { - getBestSubscription: sinon.stub().yields(null, { type: 'free' }), - } this.SurveyHandler = { getSurvey: sinon.stub().yields(null, {}), } diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 879a31b917..087df52815 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -6,6 +6,7 @@ const MockResponse = require('../helpers/MockResponse') const modulePath = '../../../../app/src/Features/Subscription/SubscriptionController' const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors') +const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') const mockSubscriptions = { 'subscription-123-active': { @@ -77,7 +78,6 @@ describe('SubscriptionController', function () { buildPlansList: sinon.stub(), promises: { buildUsersSubscriptionViewModel: sinon.stub().resolves({}), - getBestSubscription: sinon.stub().resolves({}), }, buildPlansListForSubscriptionDash: sinon .stub() @@ -146,7 +146,7 @@ describe('SubscriptionController', function () { '../SplitTests/SplitTestHandler': this.SplitTestV2Hander, '../Authentication/SessionManager': this.SessionManager, './SubscriptionHandler': this.SubscriptionHandler, - './SubscriptionHelper': this.SubscriptionHelper, + './SubscriptionHelper': SubscriptionHelper, './SubscriptionViewModelBuilder': this.SubscriptionViewModelBuilder, './LimitationsManager': this.LimitationsManager, '../../infrastructure/GeoIpLookup': this.GeoIpLookup, diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js index ed5ed2f6d1..7bf23defd2 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js @@ -5,6 +5,7 @@ const { expect } = chai const { PaymentProviderSubscription, } = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') +const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') const MODULE_PATH = '../../../../app/src/Features/Subscription/SubscriptionHandler' @@ -149,6 +150,7 @@ describe('SubscriptionHandler', function () { '../../models/User': { User: this.User, }, + './SubscriptionHelper': SubscriptionHelper, './SubscriptionUpdater': this.SubscriptionUpdater, './SubscriptionLocator': this.SubscriptionLocator, './LimitationsManager': this.LimitationsManager, diff --git a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js index a6e1ffa089..c700e67316 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js @@ -267,4 +267,206 @@ describe('SubscriptionHelper', function () { }) }) }) + + describe('isPaidSubscription', function () { + it('should return true for a subscription with a recurly subscription id', function () { + const result = this.SubscriptionHelper.isPaidSubscription({ + recurlySubscription_id: 'some-id', + }) + expect(result).to.be.true + }) + + it('should return true for a subscription with a stripe subscription id', function () { + const result = this.SubscriptionHelper.isPaidSubscription({ + paymentProvider: { subscriptionId: 'some-id' }, + }) + expect(result).to.be.true + }) + + it('should return false for a free subscription', function () { + const result = this.SubscriptionHelper.isPaidSubscription({}) + expect(result).to.be.false + }) + + it('should return false for a missing subscription', function () { + const result = this.SubscriptionHelper.isPaidSubscription() + expect(result).to.be.false + }) + }) + + describe('isIndividualActivePaidSubscription', function () { + it('should return true for an active recurly subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + recurlyStatus: { state: 'active' }, + recurlySubscription_id: 'some-id', + } + ) + expect(result).to.be.true + }) + + it('should return true for an active stripe subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + paymentProvider: { subscriptionId: 'sub_123', state: 'active' }, + } + ) + expect(result).to.be.true + }) + + it('should return false for a canceled recurly subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + recurlyStatus: { state: 'canceled' }, + recurlySubscription_id: 'some-id', + } + ) + expect(result).to.be.false + }) + + it('should return false for a canceled stripe subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + paymentProvider: { state: 'canceled', subscriptionId: 'sub_123' }, + } + ) + expect(result).to.be.false + }) + + it('should return false for a group plan subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: true, + recurlyStatus: { state: 'active' }, + recurlySubscription_id: 'some-id', + } + ) + expect(result).to.be.false + }) + + it('should return false for a free subscription', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + {} + ) + expect(result).to.be.false + }) + + it('should return false for a subscription with an empty string for recurlySubscription_id', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + recurlySubscription_id: '', + recurlyStatus: { state: 'active' }, + } + ) + expect(result).to.be.false + }) + + it('should return false for a subscription with an empty string for paymentProvider.subscriptionId', function () { + const result = this.SubscriptionHelper.isIndividualActivePaidSubscription( + { + groupPlan: false, + paymentProvider: { state: 'active', subscriptionId: '' }, + } + ) + expect(result).to.be.false + }) + + it('should return false for a missing subscription', function () { + const result = this.SubscriptionHelper.isPaidSubscription() + expect(result).to.be.false + }) + }) + + describe('getPaymentProviderSubscriptionId', function () { + it('should return the recurly subscription id if it exists', function () { + const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId({ + recurlySubscription_id: 'some-id', + }) + expect(result).to.equal('some-id') + }) + + it('should return the payment provider subscription id if it exists', function () { + const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId({ + paymentProvider: { subscriptionId: 'sub_123' }, + }) + expect(result).to.equal('sub_123') + }) + + it('should return null if no subscription id exists', function () { + const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId( + {} + ) + expect(result).to.be.null + }) + }) + + describe('getPaidSubscriptionState', function () { + it('should return the recurly state if it exists', function () { + const result = this.SubscriptionHelper.getPaidSubscriptionState({ + recurlyStatus: { state: 'active' }, + }) + expect(result).to.equal('active') + }) + + it('should return the payment provider state if it exists', function () { + const result = this.SubscriptionHelper.getPaidSubscriptionState({ + paymentProvider: { state: 'active' }, + }) + expect(result).to.equal('active') + }) + + it('should return null if no state exists', function () { + const result = this.SubscriptionHelper.getPaidSubscriptionState({}) + expect(result).to.be.null + }) + }) + + describe('getSubscriptionTrialStartedAt', function () { + it('should return the recurly trial start date if it exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({ + recurlySubscription_id: 'some-id', + recurlyStatus: { trialStartedAt: new Date('2023-01-01') }, + }) + expect(result).to.deep.equal(new Date('2023-01-01')) + }) + + it('should return the payment provider trial start date if it exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({ + paymentProvider: { trialStartedAt: new Date('2023-01-01') }, + }) + expect(result).to.deep.equal(new Date('2023-01-01')) + }) + + it('should return undefined if no trial start date exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({}) + expect(result).to.be.undefined + }) + }) + + describe('getSubscriptionTrialEndsAt', function () { + it('should return the recurly trial end date if it exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({ + recurlySubscription_id: 'some-id', + recurlyStatus: { trialEndsAt: new Date('2023-01-01') }, + }) + expect(result).to.deep.equal(new Date('2023-01-01')) + }) + + it('should return the payment provider trial end date if it exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({ + paymentProvider: { trialEndsAt: new Date('2023-01-01') }, + }) + expect(result).to.deep.equal(new Date('2023-01-01')) + }) + + it('should return undefined if no trial end date exists', function () { + const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({}) + expect(result).to.be.undefined + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index a7c02f1e65..86eb51070e 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -7,6 +7,7 @@ const { PaymentProviderSubscriptionAddOn, PaymentProviderSubscriptionChange, } = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') +const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') const modulePath = '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder' @@ -159,13 +160,14 @@ describe('SubscriptionViewModelBuilder', function () { './SubscriptionUpdater': this.SubscriptionUpdater, './PlansLocator': this.PlansLocator, '../../infrastructure/Modules': (this.Modules = { + promises: { hooks: { fire: sinon.stub().resolves([]) } }, hooks: { fire: sinon.stub().yields(null, []), }, }), './V1SubscriptionManager': {}, '../Publishers/PublishersGetter': this.PublishersGetter, - './SubscriptionHelper': {}, + './SubscriptionHelper': SubscriptionHelper, }, }) @@ -180,10 +182,10 @@ describe('SubscriptionViewModelBuilder', function () { .returns(this.commonsPlan) }) - describe('getBestSubscription', function () { + describe('getUsersSubscriptionDetails', function () { it('should return a free plan when user has no subscription or affiliation', async function () { - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) assert.deepEqual(usersBestSubscription, { type: 'free' }) @@ -195,8 +197,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user) .resolves(this.individualCustomSubscription) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -213,8 +215,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user) .resolves(this.individualSubscription) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -234,8 +236,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user) .resolves(this.individualSubscription) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -255,8 +257,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user) .resolves(this.individualSubscription) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -268,8 +270,8 @@ describe('SubscriptionViewModelBuilder', function () { }) }) - it('should update subscription if recurly data is missing', async function () { - this.individualSubscriptionWithoutRecurly = { + it('should update subscription if recurly payment state is missing', async function () { + this.individualSubscriptionWithoutPaymentState = { planCode: this.planCode, plan: this.plan, recurlySubscription_id: this.recurlySubscription_id, @@ -280,37 +282,104 @@ describe('SubscriptionViewModelBuilder', function () { this.SubscriptionLocator.promises.getUsersSubscription .withArgs(this.user) .onCall(0) - .resolves(this.individualSubscriptionWithoutRecurly) + .resolves(this.individualSubscriptionWithoutPaymentState) .withArgs(this.user) .onCall(1) .resolves(this.individualSubscription) - this.RecurlyWrapper.promises.getSubscription - .withArgs(this.individualSubscription.recurlySubscription_id, { - includeAccount: true, - }) - .resolves(this.paymentRecord) + const payment = { + subscription: this.paymentRecord, + account: new PaymentProviderAccount({}), + coupons: [], + } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + this.Modules.promises.hooks.fire + .withArgs( + 'getPaymentFromRecordPromise', + this.individualSubscriptionWithoutPaymentState + ) + .resolves([payment]) + this.Modules.promises.hooks.fire + .withArgs( + 'syncSubscription', + payment, + this.individualSubscriptionWithoutPaymentState + ) + .resolves([]) + + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) - sinon.assert.calledWith( - this.RecurlyWrapper.promises.getSubscription, - this.individualSubscriptionWithoutRecurly.recurlySubscription_id, - { includeAccount: true } - ) - sinon.assert.calledWith( - this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly, - this.paymentRecord, - this.individualSubscriptionWithoutRecurly - ) assert.deepEqual(usersBestSubscription, { type: 'individual', subscription: this.individualSubscription, plan: this.plan, remainingTrialDays: -1, }) + assert.isTrue( + this.Modules.promises.hooks.fire.withArgs( + 'getPaymentFromRecordPromise', + this.individualSubscriptionWithoutPaymentState + ).calledOnce + ) + }) + + it('should update subscription if stripe payment state is missing', async function () { + this.individualSubscriptionWithoutPaymentState = { + planCode: this.planCode, + plan: this.plan, + paymentProvider: { + subscriptionId: this.recurlySubscription_id, + }, + } + this.paymentRecord = { + state: 'active', + } + this.SubscriptionLocator.promises.getUsersSubscription + .withArgs(this.user) + .onCall(0) + .resolves(this.individualSubscriptionWithoutPaymentState) + .withArgs(this.user) + .onCall(1) + .resolves(this.individualSubscription) + const payment = { + subscription: this.paymentRecord, + account: new PaymentProviderAccount({}), + coupons: [], + } + + this.Modules.promises.hooks.fire + .withArgs( + 'getPaymentFromRecordPromise', + this.individualSubscriptionWithoutPaymentState + ) + .resolves([payment]) + this.Modules.promises.hooks.fire + .withArgs( + 'syncSubscription', + payment, + this.individualSubscriptionWithoutPaymentState + ) + .resolves([]) + + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + this.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: this.individualSubscription, + plan: this.plan, + remainingTrialDays: -1, + }) + assert.isTrue( + this.Modules.promises.hooks.fire.withArgs( + 'getPaymentFromRecordPromise', + this.individualSubscriptionWithoutPaymentState + ).calledOnce + ) }) }) @@ -318,8 +387,8 @@ describe('SubscriptionViewModelBuilder', function () { this.SubscriptionLocator.promises.getMemberSubscriptions .withArgs(this.user) .resolves([this.groupSubscription]) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) assert.deepEqual(usersBestSubscription, { @@ -336,8 +405,8 @@ describe('SubscriptionViewModelBuilder', function () { .resolves([ Object.assign({}, this.groupSubscription, { teamName: 'test team' }), ]) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) assert.deepEqual(usersBestSubscription, { @@ -353,8 +422,8 @@ describe('SubscriptionViewModelBuilder', function () { .withArgs(this.user._id) .resolves([this.commonsSubscription]) - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -385,8 +454,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 60, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -410,8 +479,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 60, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -440,8 +509,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 240, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -469,8 +538,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 240, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) @@ -499,8 +568,8 @@ describe('SubscriptionViewModelBuilder', function () { compileTimeout: 240, } - const usersBestSubscription = - await this.SubscriptionViewModelBuilder.promises.getBestSubscription( + const { bestSubscription: usersBestSubscription } = + await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( this.user ) diff --git a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs index b72a406ac0..87fc435a26 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs +++ b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs @@ -175,7 +175,7 @@ describe('TeamInvitesController', function () { }, } - describe('hasIndividualRecurlySubscription', function () { + describe('hasIndividualPaidSubscription', function () { it('is true for personal subscription', function (ctx) { return new Promise(resolve => { ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({ @@ -184,7 +184,7 @@ describe('TeamInvitesController', function () { }) const res = { render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.true + expect(data.hasIndividualPaidSubscription).to.be.true resolve() }, } @@ -200,7 +200,7 @@ describe('TeamInvitesController', function () { }) const res = { render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.false + expect(data.hasIndividualPaidSubscription).to.be.false resolve() }, } @@ -219,7 +219,7 @@ describe('TeamInvitesController', function () { }) const res = { render: (template, data) => { - expect(data.hasIndividualRecurlySubscription).to.be.false + expect(data.hasIndividualPaidSubscription).to.be.false resolve() }, } diff --git a/services/web/types/project/dashboard/subscription.ts b/services/web/types/project/dashboard/subscription.ts index e8b595c49f..c8f8835b34 100644 --- a/services/web/types/project/dashboard/subscription.ts +++ b/services/web/types/project/dashboard/subscription.ts @@ -1,4 +1,7 @@ -import { SubscriptionState } from '../../subscription/dashboard/subscription' +import { + SubscriptionState, + PaymentProvider, +} from '../../subscription/dashboard/subscription' type SubscriptionBase = { featuresPageURL: string @@ -22,6 +25,7 @@ type PaidSubscriptionBase = { teamName?: string name: string recurlyStatus?: RecurlyStatus + paymentProvider?: PaymentProvider } } & SubscriptionBase diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index 92a61e8ddb..db17b25684 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -64,7 +64,6 @@ export type Subscription = { membersLimit: number teamInvites: object[] planCode: string - recurlySubscription_id: string plan: Plan pendingPlan?: PendingPaymentProviderPlan addOns?: AddOn[] diff --git a/services/web/types/user.ts b/services/web/types/user.ts index 8d00ea803f..2fce1ce46b 100644 --- a/services/web/types/user.ts +++ b/services/web/types/user.ts @@ -39,7 +39,7 @@ export type User = { isAdmin?: boolean email: string allowedFreeTrial?: boolean - hasRecurlySubscription?: boolean + hasPaidSubscription?: boolean first_name?: string last_name?: string alphaProgram?: boolean From a9923fed4ed468dd8de1753d81631e58d409cda0 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:27:57 +0200 Subject: [PATCH 117/259] Merge pull request #26198 from overleaf/jpa-recurly-metrics [web] add metrics for recurly API usage GitOrigin-RevId: 89840829f86ce1ff750d57f3445f279f4b151d6f --- .../Features/Subscription/RecurlyClient.js | 21 +++++++++- .../Features/Subscription/RecurlyMetrics.js | 38 +++++++++++++++++++ .../Features/Subscription/RecurlyWrapper.js | 9 ++++- .../acceptance/src/mocks/MockRecurlyApi.mjs | 17 +++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 services/web/app/src/Features/Subscription/RecurlyMetrics.js diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 753d49ba0f..b5af796bb2 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -22,6 +22,7 @@ const { MissingBillingInfoError, SubtotalLimitExceededError, } = require('./Errors') +const RecurlyMetrics = require('./RecurlyMetrics') /** * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities' @@ -29,10 +30,28 @@ const { * @import { PaymentMethod } from './types' */ +class RecurlyClientWithErrorHandling extends recurly.Client { + /** + * @param {import('recurly/lib/recurly/Http').Response} response + * @return {Error | null} + * @private + */ + _errorFromResponse(response) { + RecurlyMetrics.recordMetrics( + response.status, + response.rateLimit, + response.rateLimitRemaining, + response.rateLimitReset.getTime() + ) + // @ts-ignore + return super._errorFromResponse(response) + } +} + const recurlySettings = Settings.apis.recurly const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined -const client = new recurly.Client(recurlyApiKey) +const client = new RecurlyClientWithErrorHandling(recurlyApiKey) /** * Get account for a given user diff --git a/services/web/app/src/Features/Subscription/RecurlyMetrics.js b/services/web/app/src/Features/Subscription/RecurlyMetrics.js new file mode 100644 index 0000000000..1b709d7dc4 --- /dev/null +++ b/services/web/app/src/Features/Subscription/RecurlyMetrics.js @@ -0,0 +1,38 @@ +const Metrics = require('@overleaf/metrics') + +/** + * @param {number} status + * @param {number} rateLimit + * @param {number} rateLimitRemaining + * @param {number} rateLimitReset + */ +function recordMetrics(status, rateLimit, rateLimitRemaining, rateLimitReset) { + Metrics.inc('recurly_request', 1, { status }) + const metrics = { rateLimit, rateLimitRemaining, rateLimitReset } + for (const [method, v] of Object.entries(metrics)) { + if (Number.isNaN(v)) continue + Metrics.gauge('recurly_request_rate_limiting', v, 1, { method }) + } +} + +/** + * @param {Response} response + */ +function recordMetricsFromResponse(response) { + const rateLimit = parseInt( + response.headers.get('X-RateLimit-Limit') || '', + 10 + ) + const rateLimitRemaining = parseInt( + response.headers.get('X-RateLimit-Remaining') || '', + 10 + ) + const rateLimitReset = + parseInt(response.headers.get('X-RateLimit-Reset') || '', 10) * 1000 + recordMetrics(response.status, rateLimit, rateLimitRemaining, rateLimitReset) +} + +module.exports = { + recordMetrics, + recordMetricsFromResponse, +} diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 234f094ae0..d5c2369009 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -9,6 +9,7 @@ const logger = require('@overleaf/logger') const Errors = require('../Errors/Errors') const SubscriptionErrors = require('./Errors') const { callbackify } = require('@overleaf/promise-utils') +const RecurlyMetrics = require('./RecurlyMetrics') /** * Updates the email address of a Recurly account @@ -417,9 +418,15 @@ const promises = { } try { - return await fetchStringWithResponse(fetchUrl, fetchOptions) + const { body, response } = await fetchStringWithResponse( + fetchUrl, + fetchOptions + ) + RecurlyMetrics.recordMetricsFromResponse(response) + return { body, response } } catch (error) { if (error instanceof RequestFailedError) { + RecurlyMetrics.recordMetricsFromResponse(error.response) if (error.response.status === 404 && expect404) { return { response: error.response, body: null } } else if (error.response.status === 422 && expect422) { diff --git a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs index c1e7c2aa8b..091ff62ad5 100644 --- a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs @@ -7,6 +7,9 @@ class MockRecurlyApi extends AbstractMockApi { this.mockSubscriptions = [] this.redemptions = {} this.coupons = {} + this.rateLimitResetSeconds = Math.ceil( + (Date.now() + 24 * 60 * 60 * 1000) / 1000 + ) } addMockSubscription(recurlySubscription) { @@ -25,7 +28,21 @@ class MockRecurlyApi extends AbstractMockApi { ) } + getRateLimitHeaders() { + return { + 'X-RateLimit-Limit': 1000, + 'X-RateLimit-Remaining': 999, + 'X-RateLimit-Reset': this.rateLimitResetSeconds, + } + } + applyRoutes() { + this.app.use((req, res, next) => { + for (const [name, v] of Object.entries(this.getRateLimitHeaders())) { + res.setHeader(name, v) + } + next() + }) this.app.get('/subscriptions/:id', (req, res) => { const subscription = this.getMockSubscriptionById(req.params.id) if (!subscription) { From d280f40885fdee1a642a334ab00a2f1933391c06 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Fri, 6 Jun 2025 11:51:02 +0100 Subject: [PATCH 118/259] Merge pull request #26116 from overleaf/bg-history-redis-show-buffer add script to display redis buffer for a given history ID GitOrigin-RevId: 71c2e79480c0873d30801ed3c13aa9a7fc7873f6 --- .../history-v1/storage/scripts/show_buffer.js | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 services/history-v1/storage/scripts/show_buffer.js diff --git a/services/history-v1/storage/scripts/show_buffer.js b/services/history-v1/storage/scripts/show_buffer.js new file mode 100644 index 0000000000..1d80ee227d --- /dev/null +++ b/services/history-v1/storage/scripts/show_buffer.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// @ts-check + +const { rclientHistory: rclient } = require('../lib/redis') +const { keySchema } = require('../lib/chunk_store/redis') +const commandLineArgs = require('command-line-args') + +const optionDefinitions = [ + { name: 'historyId', type: String, defaultOption: true }, +] + +// Column width for key display alignment; can be overridden with COL_WIDTH env variable +const COLUMN_WIDTH = process.env.COL_WIDTH + ? parseInt(process.env.COL_WIDTH, 10) + : 45 + +let options +try { + options = commandLineArgs(optionDefinitions) +} catch (e) { + console.error( + 'Error parsing command line arguments:', + e instanceof Error ? e.message : String(e) + ) + console.error('Usage: ./show_buffer.js ') + process.exit(1) +} + +const { historyId } = options + +if (!historyId) { + console.error('Usage: ./show_buffer.js ') + process.exit(1) +} + +function format(str, indent = COLUMN_WIDTH + 2) { + const lines = str.split('\n') + for (let i = 1; i < lines.length; i++) { + lines[i] = ' '.repeat(indent) + lines[i] + } + return lines.join('\n') +} + +async function displayKeyValue( + rclient, + key, + { parseJson = false, formatDate = false } = {} +) { + const value = await rclient.get(key) + let displayValue = '(nil)' + if (value) { + if (parseJson) { + try { + displayValue = format(JSON.stringify(JSON.parse(value), null, 2)) + } catch (e) { + displayValue = ` Raw value: ${value}` + } + } else if (formatDate) { + const ts = parseInt(value, 10) + displayValue = `${new Date(ts).toISOString()} (${value})` + } else { + displayValue = value + } + } + console.log(`${key.padStart(COLUMN_WIDTH)}: ${displayValue}`) +} + +async function displayBuffer(projectId) { + console.log(`Buffer for history ID: ${projectId}`) + console.log('--------------------------------------------------') + + try { + const headKey = keySchema.head({ projectId }) + const headVersionKey = keySchema.headVersion({ projectId }) + const persistedVersionKey = keySchema.persistedVersion({ projectId }) + const expireTimeKey = keySchema.expireTime({ projectId }) + const persistTimeKey = keySchema.persistTime({ projectId }) + const changesKey = keySchema.changes({ projectId }) + + await displayKeyValue(rclient, headKey, { parseJson: true }) + await displayKeyValue(rclient, headVersionKey) + await displayKeyValue(rclient, persistedVersionKey) + await displayKeyValue(rclient, expireTimeKey, { formatDate: true }) + await displayKeyValue(rclient, persistTimeKey, { formatDate: true }) + + const changesList = await rclient.lrange(changesKey, 0, -1) + + // 6. changes + let changesListDisplay = '(nil)' + if (changesList) { + changesListDisplay = changesList.length + ? format( + changesList + .map((change, index) => `[${index}]: ${change}`) + .join('\n') + ) + : '(empty list)' + } + console.log(`${changesKey.padStart(COLUMN_WIDTH)}: ${changesListDisplay}`) + } catch (error) { + console.error('Error fetching data from Redis:', error) + throw error + } +} + +;(async () => { + let errorOccurred = false + try { + await displayBuffer(historyId) + } catch (error) { + errorOccurred = true + } finally { + rclient.quit(() => { + process.exit(errorOccurred ? 1 : 0) + }) + } +})() From 2eb695f4c38bd08562572191baaae8ffae157bb2 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Fri, 6 Jun 2025 11:51:16 +0100 Subject: [PATCH 119/259] Merge pull request #26122 from overleaf/bg-history-redis-make-persist-buffer-consistent make persistBuffer export consistent with other methods GitOrigin-RevId: 24536e521e1d20ef63cc74bd9ba40e095025d512 --- services/history-v1/storage/index.js | 2 +- services/history-v1/storage/lib/persist_buffer.js | 2 +- services/history-v1/storage/scripts/persist_redis_chunks.js | 2 +- .../test/acceptance/js/storage/persist_buffer.test.mjs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index a9d8e2fc03..46fa63b689 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -8,7 +8,7 @@ exports.mongodb = require('./lib/mongodb') exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') -exports.persistBuffer = require('./lib/persist_buffer').persistBuffer +exports.persistBuffer = require('./lib/persist_buffer') exports.ProjectArchive = require('./lib/project_archive') exports.streams = require('./lib/streams') exports.temp = require('./lib/temp') diff --git a/services/history-v1/storage/lib/persist_buffer.js b/services/history-v1/storage/lib/persist_buffer.js index 4cfd7ecab3..1f508c43f3 100644 --- a/services/history-v1/storage/lib/persist_buffer.js +++ b/services/history-v1/storage/lib/persist_buffer.js @@ -162,4 +162,4 @@ async function persistBuffer(projectId, limits) { ) } -module.exports = { persistBuffer } +module.exports = persistBuffer diff --git a/services/history-v1/storage/scripts/persist_redis_chunks.js b/services/history-v1/storage/scripts/persist_redis_chunks.js index 9d64964f81..414fbf3458 100644 --- a/services/history-v1/storage/scripts/persist_redis_chunks.js +++ b/services/history-v1/storage/scripts/persist_redis_chunks.js @@ -5,7 +5,7 @@ const knex = require('../lib/knex.js') const knexReadOnly = require('../lib/knex_read_only.js') const { client } = require('../lib/mongodb.js') const { scanAndProcessDueItems } = require('../lib/scan') -const { persistBuffer } = require('../lib/persist_buffer') +const persistBuffer = require('../lib/persist_buffer') const { claimPersistJob } = require('../lib/chunk_store/redis') const rclient = redis.rclientHistory diff --git a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs index 496d16cd1e..216399f676 100644 --- a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs @@ -10,7 +10,7 @@ import { AddFileOperation, EditFileOperation, // Added EditFileOperation } from 'overleaf-editor-core' -import { persistBuffer } from '../../../../storage/lib/persist_buffer.js' +import persistBuffer from '../../../../storage/lib/persist_buffer.js' import chunkStore from '../../../../storage/lib/chunk_store/index.js' import redisBackend from '../../../../storage/lib/chunk_store/redis.js' import persistChanges from '../../../../storage/lib/persist_changes.js' From c0b7efea102617030ff02004bfc898363d75ce0a Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 5 Jun 2025 16:21:31 +0100 Subject: [PATCH 120/259] Change imports that use chai to use vitest GitOrigin-RevId: 59d780f754adbb5160a2de8e5eca1def6968584b --- .../launchpad/test/unit/src/LaunchpadController.test.mjs | 3 +-- .../src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs | 3 +-- .../test/unit/src/BetaProgram/BetaProgramController.test.mjs | 3 +-- .../web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs | 3 +-- .../unit/src/Collaborators/CollaboratorsController.test.mjs | 3 +-- .../src/Collaborators/CollaboratorsInviteController.test.mjs | 3 +-- .../src/Collaborators/CollaboratorsInviteHandler.test.mjs | 3 +-- services/web/test/unit/src/Contact/ContactController.test.mjs | 3 +-- .../web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs | 3 +-- .../src/DocumentUpdater/DocumentUpdaterController.test.mjs | 3 +-- services/web/test/unit/src/Exports/ExportsController.test.mjs | 3 +-- services/web/test/unit/src/Exports/ExportsHandler.test.mjs | 3 +-- .../web/test/unit/src/FileStore/FileStoreController.test.mjs | 3 +-- .../test/unit/src/LinkedFiles/LinkedFilesController.test.mjs | 3 +-- services/web/test/unit/src/Metadata/MetaController.test.mjs | 3 +-- services/web/test/unit/src/Metadata/MetaHandler.test.mjs | 3 +-- .../unit/src/PasswordReset/PasswordResetController.test.mjs | 3 +-- .../test/unit/src/PasswordReset/PasswordResetHandler.test.mjs | 3 +-- .../web/test/unit/src/Project/ProjectListController.test.mjs | 3 +-- services/web/test/unit/src/Referal/ReferalHandler.test.mjs | 3 +-- .../web/test/unit/src/References/ReferencesHandler.test.mjs | 4 +--- .../test/unit/src/Subscription/TeamInvitesController.test.mjs | 4 ++-- services/web/test/unit/src/Tags/TagsController.test.mjs | 4 ++-- .../test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs | 3 +-- .../unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs | 3 +-- .../test/unit/src/TokenAccess/TokenAccessController.test.mjs | 3 +-- .../test/unit/src/Uploads/ProjectUploadController.test.mjs | 3 +-- services/web/test/unit/src/User/UserPagesController.test.mjs | 3 +-- .../unit/src/UserMembership/UserMembershipController.test.mjs | 3 +-- .../test/unit/src/infrastructure/ServeStaticWrapper.test.mjs | 3 +-- 30 files changed, 32 insertions(+), 61 deletions(-) diff --git a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs index 89bc165305..e34a3583b0 100644 --- a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs +++ b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import * as path from 'node:path' -import { expect } from 'chai' import sinon from 'sinon' import MockResponse from '../../../../../test/unit/src/helpers/MockResponse.js' diff --git a/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs index fff5224b48..463407b180 100644 --- a/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs +++ b/services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs @@ -1,8 +1,7 @@ -import { vi } from 'vitest' +import { assert, vi } from 'vitest' import sinon from 'sinon' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' -import { assert } from 'chai' const MODULE_PATH = new URL( '../../../../app/src/Features/Analytics/AnalyticsUTMTrackingMiddleware', diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs index e2160cca08..23dd4dc1c8 100644 --- a/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs +++ b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs @@ -1,7 +1,6 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import path from 'node:path' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' import { fileURLToPath } from 'node:url' diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs index 14438a8ed7..4034835666 100644 --- a/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs +++ b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs @@ -1,8 +1,7 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import path from 'node:path' import sinon from 'sinon' -import { expect } from 'chai' import { fileURLToPath } from 'node:url' const __dirname = fileURLToPath(new URL('.', import.meta.url)) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs index 9bb9c4b3c0..1d8345a195 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockRequest from '../helpers/MockRequest.js' diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs index d948e69ed4..edac9c6c92 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import mongodb from 'mongodb-legacy' diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs index ec8f453536..5d6690d7c0 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Crypto from 'crypto' diff --git a/services/web/test/unit/src/Contact/ContactController.test.mjs b/services/web/test/unit/src/Contact/ContactController.test.mjs index 2defc2c3a7..13f70c81f6 100644 --- a/services/web/test/unit/src/Contact/ContactController.test.mjs +++ b/services/web/test/unit/src/Contact/ContactController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' const modulePath = '../../../../app/src/Features/Contacts/ContactController.mjs' diff --git a/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs b/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs index 2bb1ed81dd..846a54d4ce 100644 --- a/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs +++ b/services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' const modulePath = new URL( '../../../../app/src/Features/Cooldown/CooldownMiddleware.mjs', import.meta.url diff --git a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs index 095e598d39..5a60903552 100644 --- a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs +++ b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' const MODULE_PATH = diff --git a/services/web/test/unit/src/Exports/ExportsController.test.mjs b/services/web/test/unit/src/Exports/ExportsController.test.mjs index af9c1483fb..cd8f4ba7a9 100644 --- a/services/web/test/unit/src/Exports/ExportsController.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsController.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' const modulePath = new URL( '../../../../app/src/Features/Exports/ExportsController.mjs', diff --git a/services/web/test/unit/src/Exports/ExportsHandler.test.mjs b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs index 0eb8a98e26..a7944beced 100644 --- a/services/web/test/unit/src/Exports/ExportsHandler.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' const modulePath = '../../../../app/src/Features/Exports/ExportsHandler.mjs' describe('ExportsHandler', function () { diff --git a/services/web/test/unit/src/FileStore/FileStoreController.test.mjs b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs index 5c46e516a0..ba0670d49c 100644 --- a/services/web/test/unit/src/FileStore/FileStoreController.test.mjs +++ b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockResponse from '../helpers/MockResponse.js' diff --git a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs index b29d10bba4..e712d17198 100644 --- a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs +++ b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs' diff --git a/services/web/test/unit/src/Metadata/MetaController.test.mjs b/services/web/test/unit/src/Metadata/MetaController.test.mjs index 00b3568ae2..ee3488137a 100644 --- a/services/web/test/unit/src/Metadata/MetaController.test.mjs +++ b/services/web/test/unit/src/Metadata/MetaController.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' import MockResponse from '../helpers/MockResponse.js' const modulePath = '../../../../app/src/Features/Metadata/MetaController.mjs' diff --git a/services/web/test/unit/src/Metadata/MetaHandler.test.mjs b/services/web/test/unit/src/Metadata/MetaHandler.test.mjs index c6009a2dd6..48d5cc51a4 100644 --- a/services/web/test/unit/src/Metadata/MetaHandler.test.mjs +++ b/services/web/test/unit/src/Metadata/MetaHandler.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Metadata/MetaHandler.mjs' diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs index e4cf6e569f..05bbfdb433 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' const MODULE_PATH = new URL( diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs index 25d664b795..aab46ae2bf 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' const modulePath = new URL( '../../../../app/src/Features/PasswordReset/PasswordResetHandler', import.meta.url diff --git a/services/web/test/unit/src/Project/ProjectListController.test.mjs b/services/web/test/unit/src/Project/ProjectListController.test.mjs index a051382279..2b3007e047 100644 --- a/services/web/test/unit/src/Project/ProjectListController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectListController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' diff --git a/services/web/test/unit/src/Referal/ReferalHandler.test.mjs b/services/web/test/unit/src/Referal/ReferalHandler.test.mjs index 5174918bd7..5c042f2ef9 100644 --- a/services/web/test/unit/src/Referal/ReferalHandler.test.mjs +++ b/services/web/test/unit/src/Referal/ReferalHandler.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Referal/ReferalHandler.mjs' diff --git a/services/web/test/unit/src/References/ReferencesHandler.test.mjs b/services/web/test/unit/src/References/ReferencesHandler.test.mjs index ae7b86822a..92666e6bcc 100644 --- a/services/web/test/unit/src/References/ReferencesHandler.test.mjs +++ b/services/web/test/unit/src/References/ReferencesHandler.test.mjs @@ -1,6 +1,4 @@ -import { vi } from 'vitest' - -import { expect } from 'chai' +import { expect, vi } from 'vitest' import sinon from 'sinon' import Errors from '../../../../app/src/Features/Errors/Errors.js' const modulePath = diff --git a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs index 87fc435a26..be5fe26670 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs +++ b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs @@ -1,6 +1,6 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' + const modulePath = '../../../../app/src/Features/Subscription/TeamInvitesController' diff --git a/services/web/test/unit/src/Tags/TagsController.test.mjs b/services/web/test/unit/src/Tags/TagsController.test.mjs index 927c6283a5..c8cb739d0e 100644 --- a/services/web/test/unit/src/Tags/TagsController.test.mjs +++ b/services/web/test/unit/src/Tags/TagsController.test.mjs @@ -1,6 +1,6 @@ -import { vi } from 'vitest' +import { assert, vi } from 'vitest' import sinon from 'sinon' -import { assert } from 'chai' + const modulePath = '../../../../app/src/Features/Tags/TagsController.mjs' describe('TagsController', function () { diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs index 313f2d2456..29daa00efc 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import mongodb from 'mongodb-legacy' -import { expect } from 'chai' import sinon from 'sinon' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockResponse from '../helpers/MockResponse.js' diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs index 96cc22279e..08a7dcf494 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs index 3408c3bb32..96d2d19b04 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs +++ b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import mongodb from 'mongodb-legacy' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' diff --git a/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs index 1f6fd7adb9..443578f747 100644 --- a/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs +++ b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs @@ -5,9 +5,8 @@ * DS206: Consider reworking classes to avoid initClass * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import ArchiveErrors from '../../../../app/src/Features/Uploads/ArchiveErrors.js' diff --git a/services/web/test/unit/src/User/UserPagesController.test.mjs b/services/web/test/unit/src/User/UserPagesController.test.mjs index 181c9513ae..1fa908d1be 100644 --- a/services/web/test/unit/src/User/UserPagesController.test.mjs +++ b/services/web/test/unit/src/User/UserPagesController.test.mjs @@ -1,7 +1,6 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import assert from 'assert' import sinon from 'sinon' -import { expect } from 'chai' import MockResponse from '../helpers/MockResponse.js' import MockRequest from '../helpers/MockRequest.js' diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index 55bc62cd2d..47932a7fe1 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs @@ -1,6 +1,5 @@ -import { vi } from 'vitest' +import { expect, vi } from 'vitest' import sinon from 'sinon' -import { expect } from 'chai' import MockRequest from '../helpers/MockRequest.js' import MockResponse from '../helpers/MockResponse.js' import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js' diff --git a/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs b/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs index 4d8479a9cb..619fe74a2b 100644 --- a/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs +++ b/services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs @@ -1,5 +1,4 @@ -import { vi } from 'vitest' -import { expect } from 'chai' +import { expect, vi } from 'vitest' import Path from 'node:path' import sinon from 'sinon' import MockResponse from '../helpers/MockResponse.js' From edc7634007af7e30d9e9071a60d48a6512b6b8ff Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 5 Jun 2025 16:21:51 +0100 Subject: [PATCH 121/259] Update bootstrap process to use vitest chai GitOrigin-RevId: 5576223019c0e2b4554707f0025e82ab3a7ca514 --- services/web/test/unit/bootstrap.js | 17 +++++++++++++++++ services/web/test/unit/common_bootstrap.js | 19 ------------------- services/web/test/unit/vitest_bootstrap.mjs | 20 +++++++++++++++++++- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/services/web/test/unit/bootstrap.js b/services/web/test/unit/bootstrap.js index f3d3f382f2..00bcc3e958 100644 --- a/services/web/test/unit/bootstrap.js +++ b/services/web/test/unit/bootstrap.js @@ -1,7 +1,24 @@ const Path = require('path') const sinon = require('sinon') require('./common_bootstrap') +const chai = require('chai') +/* + * Chai configuration + */ + +// add chai.should() +chai.should() + +// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc') +// has a nicer failure messages +chai.use(require('sinon-chai')) + +// Load promise support for chai +chai.use(require('chai-as-promised')) + +// Do not truncate assertion errors +chai.config.truncateThreshold = 0 /* * Global stubs */ diff --git a/services/web/test/unit/common_bootstrap.js b/services/web/test/unit/common_bootstrap.js index d74fee60b2..a77aad61c6 100644 --- a/services/web/test/unit/common_bootstrap.js +++ b/services/web/test/unit/common_bootstrap.js @@ -1,22 +1,3 @@ -const chai = require('chai') - -/* - * Chai configuration - */ - -// add chai.should() -chai.should() - -// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc') -// has a nicer failure messages -chai.use(require('sinon-chai')) - -// Load promise support for chai -chai.use(require('chai-as-promised')) - -// Do not truncate assertion errors -chai.config.truncateThreshold = 0 - // add support for mongoose in sinon require('sinon-mongoose') diff --git a/services/web/test/unit/vitest_bootstrap.mjs b/services/web/test/unit/vitest_bootstrap.mjs index 2244faefd3..5a39b2d587 100644 --- a/services/web/test/unit/vitest_bootstrap.mjs +++ b/services/web/test/unit/vitest_bootstrap.mjs @@ -1,8 +1,26 @@ -import { vi } from 'vitest' +import { chai, vi } from 'vitest' import './common_bootstrap.js' import sinon from 'sinon' import logger from '@overleaf/logger' +import sinonChai from 'sinon-chai' +import chaiAsPromised from 'chai-as-promised' +/* + * Chai configuration + */ + +// add chai.should() +chai.should() + +// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc') +// has a nicer failure messages +chai.use(sinonChai) + +// Load promise support for chai +chai.use(chaiAsPromised) + +// Do not truncate assertion errors +chai.config.truncateThreshold = 0 vi.mock('@overleaf/logger', async () => { return { default: { From e0f6ee8b206eab4f30a15aa57cf1ef4aa3746391 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 9 Jun 2025 10:16:41 +0100 Subject: [PATCH 122/259] Merge pull request #26133 from overleaf/mj-ide-keyboard-shortcuts [web] Editor redesign: Add keyboard shortcuts to menu bar GitOrigin-RevId: 8fe844389de70a919ba836d03f0390f585532bb1 --- .../context/command-registry-context.tsx | 133 +++++++++++++++++- .../components/toolbar/command-dropdown.tsx | 35 +++-- 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx index e8bec19b8b..ff54c21f2a 100644 --- a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx @@ -1,4 +1,11 @@ -import { createContext, useCallback, useContext, useState } from 'react' +import { isMac } from '@/shared/utils/os' +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' type CommandInvocationContext = { location?: string @@ -10,17 +17,21 @@ export type Command = { handler?: (context: CommandInvocationContext) => void href?: string disabled?: boolean - // TODO: Keybinding? } const CommandRegistryContext = createContext( undefined ) +export type Shortcut = { key: string } + +export type Shortcuts = Record + type CommandRegistry = { registry: Map register: (...elements: Command[]) => void unregister: (...id: string[]) => void + shortcuts: Shortcuts } export const CommandRegistryProvider: React.FC = ({ @@ -43,8 +54,35 @@ export const CommandRegistryProvider: React.FC = ({ ) }, []) + // NOTE: This is where we'd add functionality for customising shortcuts. + const shortcuts: Record = useMemo( + () => ({ + undo: [ + { + key: 'Mod-z', + }, + ], + redo: [ + { + key: 'Mod-y', + }, + { + key: 'Mod-Shift-Z', + }, + ], + find: [{ key: 'Mod-f' }], + 'select-all': [{ key: 'Mod-a' }], + 'insert-comment': [{ key: 'Mod-Shift-C' }], + 'format-bold': [{ key: 'Mod-b' }], + 'format-italics': [{ key: 'Mod-i' }], + }), + [] + ) + return ( - + {children} ) @@ -59,3 +97,92 @@ export const useCommandRegistry = (): CommandRegistry => { } return context } + +function parseShortcut(shortcut: Shortcut) { + // Based on KeyBinding type of CodeMirror 6 + let alt = false + let ctrl = false + let shift = false + let meta = false + + let character = null + // isMac ? shortcut.mac : shortcut.key etc. + const shortcutString = shortcut.key ?? '' + const keys = shortcutString.split(/-(?!$)/) ?? [] + + for (let i = 0; i < keys.length; i++) { + const isLast = i === keys.length - 1 + const key = keys[i] + if (!key) { + throw new Error('Empty key in shortcut: ' + shortcutString) + } + if (key === 'Alt' || (!isLast && key === 'a')) { + alt = true + } else if ( + key === 'Ctrl' || + key === 'Control' || + (!isLast && key === 'c') + ) { + ctrl = true + } else if (key === 'Shift' || (!isLast && key === 's')) { + shift = true + } else if (key === 'Meta' || key === 'Cmd' || (!isLast && key === 'm')) { + meta = true + } else if (key === 'Mod') { + if (isMac) { + meta = true + } else { + ctrl = true + } + } else { + if (key === 'Space') { + character = ' ' + } + if (!isLast) { + throw new Error( + 'Character key must be last in shortcut: ' + shortcutString + ) + } + if (key.length !== 1) { + throw new Error(`Invalid key '${key}' in shortcut: ${shortcutString}`) + } + if (character) { + throw new Error('Multiple characters in shortcut: ' + shortcutString) + } + character = key + } + } + if (!character) { + throw new Error('No character in shortcut: ' + shortcutString) + } + + return { + alt, + ctrl, + shift, + meta, + character, + } +} + +export const formatShortcut = (shortcut: Shortcut): string => { + const { alt, ctrl, shift, meta, character } = parseShortcut(shortcut) + + if (isMac) { + return [ + ctrl ? '⌃' : '', + alt ? '⌥' : '', + shift ? '⇧' : '', + meta ? '⌘' : '', + character.toUpperCase(), + ].join('') + } + + return [ + ctrl ? 'Ctrl' : '', + shift ? 'Shift' : '', + meta ? 'Meta' : '', + alt ? 'Alt' : '', + character.toUpperCase(), + ].join(' ') +} diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx index e08cf8873a..2dc696cdbf 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx @@ -1,5 +1,7 @@ import { Command, + formatShortcut, + Shortcuts, useCommandRegistry, } from '@/features/ide-react/context/command-registry-context' import { @@ -14,7 +16,10 @@ import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option' import { Fragment, useCallback, useMemo } from 'react' type CommandId = string -type TaggedCommand = Command & { type: 'command' } +type TaggedCommand = Command & { + type: 'command' + shortcuts?: Shortcuts[CommandId] +} type Entry = T | GroupStructure type GroupStructure = { id: string @@ -37,13 +42,13 @@ const CommandDropdown = ({ title: string id: string }) => { - const { registry } = useCommandRegistry() + const { registry, shortcuts } = useCommandRegistry() const populatedSections = useMemo( () => menu - .map(section => populateSectionOrGroup(section, registry)) + .map(section => populateSectionOrGroup(section, registry, shortcuts)) .filter(x => x.children.length > 0), - [menu, registry] + [menu, registry, shortcuts] ) if (populatedSections.length === 0) { @@ -76,8 +81,8 @@ export const CommandSection = ({ }: { section: MenuSectionStructure }) => { - const { registry } = useCommandRegistry() - const section = populateSectionOrGroup(sectionStructure, registry) + const { registry, shortcuts } = useCommandRegistry() + const section = populateSectionOrGroup(sectionStructure, registry, shortcuts) if (section.children.length === 0) { return null } @@ -108,6 +113,9 @@ const CommandDropdownChild = ({ item }: { item: Entry }) => { onClick={onClickHandler} href={item.href} disabled={item.disabled} + trailingIcon={ + item.shortcuts && {formatShortcut(item.shortcuts[0])} + } /> ) } else { @@ -127,7 +135,8 @@ function populateSectionOrGroup< T extends { children: Array> }, >( section: T, - registry: Map + registry: Map, + shortcuts: Shortcuts ): Omit & { children: Array> } { @@ -137,7 +146,11 @@ function populateSectionOrGroup< children: children .map(child => { if (typeof child !== 'string') { - const populatedChild = populateSectionOrGroup(child, registry) + const populatedChild = populateSectionOrGroup( + child, + registry, + shortcuts + ) if (populatedChild.children.length === 0) { // Skip empty groups return undefined @@ -146,7 +159,11 @@ function populateSectionOrGroup< } const command = registry.get(child) if (command) { - return { ...command, type: 'command' as const } + return { + ...command, + shortcuts: shortcuts[command.id], + type: 'command' as const, + } } return undefined }) From d3a9b4943a22bb8e670abc48e3a29d7ab3c8f09a Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 9 Jun 2025 10:16:49 +0100 Subject: [PATCH 123/259] Merge pull request #26257 from overleaf/mj-ide-breadcrumbs-crash [web] Avoid editor crash when breadcrumbs can't find open entity GitOrigin-RevId: 7c7f198c82e102ee9f8e2a59ca1755c3550bdf37 --- .../context/online-users-context.tsx | 2 +- .../ide-redesign/components/breadcrumbs.tsx | 33 ++++++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/context/online-users-context.tsx b/services/web/frontend/js/features/ide-react/context/online-users-context.tsx index 1dba40e6d7..1195f9ae7c 100644 --- a/services/web/frontend/js/features/ide-react/context/online-users-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/online-users-context.tsx @@ -95,7 +95,7 @@ export const OnlineUsersProvider: FC = ({ for (const [clientId, user] of Object.entries(onlineUsers)) { const decoratedUser = { ...user } const docId = user.doc_id - if (docId) { + if (docId && fileTreeData) { decoratedUser.doc = findDocEntityById(fileTreeData, docId) } diff --git a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx index f148e0142e..9949b98c7f 100644 --- a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx @@ -1,4 +1,7 @@ -import { findInTreeOrThrow } from '@/features/file-tree/util/find-in-tree' +import { + findInTree, + findInTreeOrThrow, +} from '@/features/file-tree/util/find-in-tree' import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context' import { useOutlineContext } from '@/features/ide-react/context/outline-context' import useNestedOutline from '@/features/outline/hooks/use-nested-outline' @@ -39,35 +42,41 @@ export default function Breadcrumbs() { const { highlightedLine, canShowOutline } = useOutlineContext() const folderHierarchy = useMemo(() => { - if (!openEntity || !fileTreeData) { + if (openEntity?.type !== 'doc' || !fileTreeData) { return [] } - return openEntity.path - .filter(id => id !== fileTreeData._id) // Filter out the root folder - .map(id => { - return findInTreeOrThrow(fileTreeData, id)?.entity - }) + try { + return openEntity.path + .filter(id => id !== fileTreeData._id) // Filter out the root folder + .map(id => { + return findInTreeOrThrow(fileTreeData, id)?.entity + }) + } catch { + // If any of the folders in the path are not found, the entire hierarchy + // is invalid. + return [] + } }, [openEntity, fileTreeData]) const fileName = useMemo(() => { // NOTE: openEntity.entity.name may not always be accurate, so we read it // from the file tree data instead. - if (!openEntity || !fileTreeData) { + if (openEntity?.type !== 'doc' || !fileTreeData) { return undefined } - return findInTreeOrThrow(fileTreeData, openEntity.entity._id)?.entity.name + return findInTree(fileTreeData, openEntity.entity._id)?.entity.name }, [fileTreeData, openEntity]) const outlineHierarchy = useMemo(() => { - if (!canShowOutline || !outline) { + if (openEntity?.type !== 'doc' || !canShowOutline || !outline) { return [] } return constructOutlineHierarchy(outline.items, highlightedLine) - }, [outline, highlightedLine, canShowOutline]) + }, [outline, highlightedLine, canShowOutline, openEntity]) - if (!openEntity || !fileTreeData) { + if (openEntity?.type !== 'doc' || !fileTreeData) { return null } From ff63215d73c538798f6d8d55f5e8a928416a090d Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Mon, 9 Jun 2025 10:18:36 +0100 Subject: [PATCH 124/259] Merge pull request #26155 from overleaf/dp-content-info Add content-info and content-info-dark to standard colours and use in editor redesign logs GitOrigin-RevId: 40c026a9ccfe511cab2bf4e28fbfbed7cf218642 --- .../bootstrap-5/abstracts/themes-common-variables.scss | 2 ++ .../frontend/stylesheets/bootstrap-5/foundations/colors.scss | 4 ++++ .../frontend/stylesheets/bootstrap-5/pages/editor/logs.scss | 4 +--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss index 562dfb3efd..969a861c67 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/themes-common-variables.scss @@ -12,6 +12,7 @@ --content-danger-themed: var(--content-danger-dark); --content-warning-themed: var(--content-warning-dark); --content-positive-themed: var(--content-positive-dark); + --content-info-themed: var(--content-info-dark); --border-primary-themed: var(--border-primary-dark); --border-hover-themed: var(--border-hover-dark); --border-disabled-themed: var(--border-disabled-dark); @@ -39,6 +40,7 @@ --content-danger-themed: var(--content-danger); --content-warning-themed: var(--content-warning); --content-positive-themed: var(--content-positive); + --content-info-themed: var(--content-info); --border-primary-themed: var(--border-primary); --border-hover-themed: var(--border-hover); --border-disabled-themed: var(--border-disabled); diff --git a/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss b/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss index 73e93273c4..9d0bd2ac95 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss @@ -83,6 +83,7 @@ $content-placeholder: $neutral-60; $content-danger: $red-50; $content-warning: $yellow-50; $content-positive: $green-50; +$content-info: $blue-50; $border-primary: $neutral-60; $border-hover: $neutral-70; $border-disabled: $neutral-20; @@ -102,6 +103,7 @@ $content-placeholder-dark: $neutral-50; $content-danger-dark: $red-40; $content-warning-dark: $yellow-40; $content-positive-dark: $green-40; +$content-info-dark: $blue-30; $border-primary-dark: $neutral-30; $border-hover-dark: $neutral-20; $border-disabled-dark: $neutral-80; @@ -193,6 +195,7 @@ $link-ui-visited-dark: $blue-40; --content-danger: var(--red-50); --content-warning: var(--yellow-50); --content-positive: var(--green-50); + --content-info: var(--blue-50); --border-primary: var(--neutral-60); --border-hover: var(--neutral-70); --border-disabled: var(--neutral-20); @@ -213,6 +216,7 @@ $link-ui-visited-dark: $blue-40; --content-danger-dark: var(--red-40); --content-warning-dark: var(--yellow-40); --content-positive-dark: var(--green-40); + --content-info-dark: var(--blue-30); --border-primary-dark: var(--neutral-30); --border-hover-dark: var(--neutral-20); --border-disabled-dark: var(--neutral-80); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss index 06f97545d3..95c5a83ddc 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss @@ -1,11 +1,9 @@ :root { --logs-pane-bg: var(--bg-dark-secondary); - --logs-info-color: var(--blue-40); } @include theme('light') { --logs-pane-bg: var(--bg-light-secondary); - --logs-info-color: var(--blue-60); } .ide-redesign-main { @@ -106,7 +104,7 @@ } .log-entry-header-text-info { - color: var(--logs-info-color); + color: var(--content-info-themed); } .log-entry-header-text-success { From 45c6ce221972afba7e6e72ddca66202683854de8 Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Mon, 9 Jun 2025 11:17:52 +0100 Subject: [PATCH 125/259] Merge pull request #25842 from overleaf/ds-cms-bs5-migration-enterprises-2 [B2C] Bootstrap 5 migration of Enterprises page GitOrigin-RevId: 63c4095ddb2ee688bc1780883b86f5a994b262c0 --- .../bootstrap-5/pages/website-redesign.scss | 395 +++++++++++++++++- 1 file changed, 392 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss index 2e069d0599..7ee9a98c35 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss @@ -258,6 +258,7 @@ .round-background { border-radius: 50%; vertical-align: middle; + margin-right: var(--spacing-04); width: 20px; height: 20px; } @@ -292,11 +293,11 @@ .resources-card { display: flex; flex-flow: column wrap; - margin-bottom: 48px; + margin-bottom: var(--spacing-11); align-content: flex-start; @include media-breakpoint-down(lg) { - margin-bottom: 16px; + margin-bottom: var(--spacing-06); } img { @@ -384,7 +385,114 @@ } } + .inline-green-link { + color: var(--green-50); + padding: 0; + text-decoration: underline; + + // text-decoration-skip-ink is for letters with descenders (like 'g' and 'y') + // this will force underline to not skip the descender + text-decoration-skip-ink: none; + + &:hover { + color: var(--green-60); + } + + // TODO: this is copied directly from the `.less` file, migrate this to scss + // &:focus { + // @extend .input-focus-style; + // } + } + + .customer-story-card-title { + @include heading-md; + + margin-top: var(--spacing-08); + margin-bottom: var(--spacing-05); + } + + .plans-bottom-text { + font-size: var(--font-size-04); + } + + .plans-cards { + @include media-breakpoint-up(lg) { + display: flex; + } + + .plans-card-container { + min-height: 348px; + padding-left: var(--spacing-05); + padding-right: var(--spacing-05); + + @include media-breakpoint-down(lg) { + margin-bottom: var(--spacing-06); + min-height: unset; + } + } + + .plans-card { + border-radius: 8px; + padding: 0; + height: 100%; + + .plans-card-inner { + padding: var(--spacing-09); + height: 100%; + display: flex; + flex-direction: column; + font-size: var(--font-size-03); + + .plans-card-inner-title { + font-size: var(--font-size-05); + line-height: var(--line-height-04); + font-weight: 600; + margin-top: 0; + } + + ul { + list-style-type: none; + padding: 0; + margin: 0; + + li { + margin-bottom: var(--spacing-04); + } + } + + .plans-card-inner-footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-05); + + @include media-breakpoint-down(lg) { + margin-top: var(--spacing-06); + } + } + } + + &.grey-border { + border: 2px solid var(--neutral-20); + } + + &.blue-border { + border: solid 2px var(--sapphire-blue); + border-radius: 8px; + + .plans-card-inner-title { + color: var(--sapphire-blue); + } + } + } + } + .heading-section-md-align-left { + h2, + p { + text-align: center; + } + @include media-breakpoint-down(lg) { display: flex; flex-direction: column; @@ -410,7 +518,7 @@ } &.align-left-button-sm { - @include media-breakpoint-down(md) { + @include media-breakpoint-down(lg) { justify-content: start; } } @@ -421,6 +529,28 @@ } } + .editor-pdf-video { + display: flex; + align-items: center; + justify-content: center; + height: 585px; + padding: 0 var(--spacing-06); + + @include media-breakpoint-down(lg) { + height: auto; + } + + video { + box-shadow: 0 60px 25px -15px rgb(16 24 40 / 20%); + max-height: 100%; + width: auto; + + @include media-breakpoint-down(lg) { + width: 100%; + } + } + } + .overleaf-sticker { width: unset; @@ -429,6 +559,130 @@ } } + .organization-logos-container { + display: flex; + justify-content: space-around; + align-items: center; + + @include media-breakpoint-down(xl) { + flex-wrap: wrap; + gap: 30px; + } + + .organization-logo { + object-fit: contain; + max-height: 62px; + + &.samsung-logo { + max-height: 110px; + height: 110px; + } + + @include media-breakpoint-down(xl) { + max-height: 40px; + flex-basis: 34%; + } + } + } + + .integrations-card { + display: flex; + + /* for center align */ + flex-wrap: wrap; + align-items: center; + + .integrations-icons { + img { + width: 6rem; // 96px + height: 6rem; // 96px + } + + .first-row, + .second-row { + display: flex; + } + + .first-row { + justify-content: space-between; + } + + .second-row { + margin-top: var(--spacing-10); + justify-content: space-evenly; + } + } + } + + .security-info { + .security-info-first-row { + margin-bottom: var(--spacing-09); + + @include media-breakpoint-down(lg) { + margin-bottom: 0; + } + } + + .security-info-item { + @include media-breakpoint-down(lg) { + margin-bottom: var(--spacing-06); + } + } + + h3 { + @include heading-sm; + } + } + + .security-heading-section { + @include media-breakpoint-down(lg) { + p { + text-align: left; + } + + h2 { + width: 100%; + text-align: left; + } + } + + .heading-and-stickers-container { + display: flex; + justify-content: center; + position: relative; + + .lock-sticker { + width: 70px; + position: absolute; + top: -95px; + right: -50px; + + @include media-breakpoint-down(xl) { + right: -105px; + } + + @include media-breakpoint-down(lg) { + display: none; + } + } + + .arrow-sticker { + width: 140px; + position: absolute; + top: -50px; + right: -15px; + + @include media-breakpoint-down(xl) { + right: -70px; + } + + @include media-breakpoint-down(lg) { + display: none; + } + } + } + } + .features-card { display: flex; /* equal heights */ flex-wrap: wrap; @@ -528,4 +782,139 @@ } } } + + .features-card-hero { + display: flex; + + /* equal heights */ + flex-wrap: wrap; + align-items: center; + position: relative; + height: 655px; + + // padding-top: @line-height-computed * 2; + + @include media-breakpoint-down(lg) { + height: unset; + padding-top: 0; + } + + .features-card-description { + display: flex; + flex-direction: column; + justify-content: center; + + h1 { + &.features-card-hero-smaller-title { + @include media-breakpoint-up(xl) { + // 3rem is the default, this is a workaround for big screen + // since 6-width column on md screen size will wrap the text in three lines + font-size: var(--font-size-09); + } + } + } + + p { + font-size: var(--font-size-05); + width: 90%; + + @include media-breakpoint-down(lg) { + font-size: var(--font-size-04); + line-height: var(--line-height-03); + width: unset; + } + } + } + + .features-card-image { + position: absolute; + + // on wide screen, image will be fixed without any variable width translation + transform: translateX(600px); + top: 100px; + width: 720px; + height: auto; + padding: 0 15px; + + // starting from 1500px, image will have a variable translation that depends on screen width + // this will make image "fixed" on a specific point on the screen + @media (width <= 1500px) { + transform: translateX(calc(50vw - 121px)); + } + + @media (width <= 1400px) { + width: 650px; + transform: translateX(calc(50vw - 52px)); + } + + // bootstrap layout changes on 1200px (@screen-lg), add a specific + // case for this exact width + @media (width >= 1200px) and (width <= 1200px) { + width: 600px; + transform: translateX(calc(50vw)); + } + + @media (width <= 1199px) { + width: 600px; + transform: translateX(calc(50vw - 106px)); + } + + @media (width <= 1100px) { + width: 550px; + transform: translateX(calc(50vw - 55px)); + } + + // 991px + @include media-breakpoint-down(lg) { + position: relative; + transform: none; + top: 0; + width: 100%; + margin-bottom: var(--spacing-11); + padding: var(--spacing-09) 0 0 0; + } + + img.img-responsive { + width: 100%; + } + } + + .sticky-tags { + position: absolute; + z-index: 2; + height: 160px; + bottom: -105px; + right: 55px; + + @media (width <= 1400px) { + height: 150px; + bottom: -103px; + right: 47px; + } + + @media (width <= 1200px) { + height: 130px; + bottom: -87px; + } + + @media (width <= 1100px) { + height: 120px; + bottom: -81px; + } + + // 991px + @include media-breakpoint-down(lg) { + height: 130px; + bottom: -75px; + right: 70px; + } + + // 767px + @include media-breakpoint-down(md) { + height: 24%; + bottom: -10vw; // scale with width + right: 9.5vw; // scale with width + } + } + } } From 86626ca44e8fce845245c0ba7cc6b77c736be856 Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Mon, 9 Jun 2025 11:18:11 +0100 Subject: [PATCH 126/259] Merge pull request #25856 from overleaf/ds-cms-bs5-migration-universities-2 [B2C] Bootstrap 5 migration of Universities page GitOrigin-RevId: b069c04131531e9f9774a9a53aaa53858ba568c7 --- .../stylesheets/bootstrap-5/pages/website-redesign.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss index 7ee9a98c35..1f6027d835 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss @@ -555,7 +555,7 @@ width: unset; @include media-breakpoint-down(lg) { - width: 74px; // 70% of 106px + width: 106px; } } From 5b08adc4ff18ff4e2d6a82c2c73fbc94b1b22987 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Mon, 9 Jun 2025 13:23:42 +0200 Subject: [PATCH 127/259] Merge pull request #26218 from overleaf/msm-bump-tar-fs-multer [clsi/web/history-v1] Bump `tar-fs` and `multer` GitOrigin-RevId: c76b964224c8367d68dc1190ff29627cc6919ade --- package-lock.json | 1678 +++++++++++++++++++++++------------- package.json | 2 +- services/clsi/package.json | 4 +- services/web/package.json | 2 +- 4 files changed, 1086 insertions(+), 600 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce941a1670..c0967e0977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5943,15 +5943,16 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@grpc/grpc-js": { - "version": "1.8.22", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.8.22.tgz", - "integrity": "sha512-oAjDdN7fzbUi+4hZjKG96MR6KTEubAeMpQEb+77qy+3r0Ua5xTFuie6JOLr4ZZgl5g+W5/uRTS2M1V8mVAFPuA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", + "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", + "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.0", - "@types/node": ">=12.12.47" + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^8.13.0 || >=10.10.0" + "node": ">=12.10.0" } }, "node_modules/@grpc/proto-loader": { @@ -6989,6 +6990,18 @@ "dev": true, "optional": true }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@node-oauth/formats": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@node-oauth/formats/-/formats-1.0.0.tgz", @@ -8643,6 +8656,15 @@ "resolved": "services/web", "link": true }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@phosphor-icons/react": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.7.tgz", @@ -15229,13 +15251,13 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { "node": ">= 0.4" @@ -15351,19 +15373,18 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" @@ -15457,6 +15478,15 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/async-lock": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", @@ -16026,24 +16056,32 @@ "optional": true }, "node_modules/bare-fs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.1.tgz", - "integrity": "sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", + "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "bare-events": "^2.0.0", + "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.0.0" + "bare-stream": "^2.6.4" }, "engines": { - "bare": ">=1.7.0" + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, "node_modules/bare-os": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.0.tgz", - "integrity": "sha512-BUrFS5TqSBdA0LwHop4OjPJwisqxGy6JsWVqV6qaFoe965qqtaKfDzHY5T2YA1gUL0ZeeQeA+4BBc1FJTcHiPw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", "license": "Apache-2.0", "optional": true, "engines": { @@ -16925,15 +16963,44 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -17422,7 +17489,8 @@ "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" }, "node_modules/chrome-trace-event": { "version": "1.0.3", @@ -17780,12 +17848,10 @@ "license": "MIT" }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "engines": { - "node": ">= 6" - } + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "license": "MIT" }, "node_modules/common-path-prefix": { "version": "3.0.0", @@ -17900,46 +17966,20 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "engines": [ - "node >= 0.8" + "node >= 6.0" ], + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", - "readable-stream": "^2.2.2", + "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, - "node_modules/concat-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/concat-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -18385,6 +18425,20 @@ "node": ">=10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -19430,14 +19484,14 @@ } }, "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -19447,29 +19501,29 @@ } }, "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/inspect-js" } }, "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" }, @@ -19880,7 +19934,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, "dependencies": { "asap": "^2.0.0", "wrappy": "1" @@ -19952,6 +20005,88 @@ "node": ">=6" } }, + "node_modules/docker-modem": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", + "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.7.tgz", + "integrity": "sha512-R+rgrSRTRdU5mH14PZTCPZtW/zw3HDWNTS/1ZAQpL/5Upe/ye5K9WQkIysu4wBoiMwKynsz0a8qWuGsHgEvSAA==", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.6", + "protobufjs": "^7.3.2", + "tar-fs": "~2.1.2", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/protobufjs": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -20179,6 +20314,20 @@ "node": ">=0.10" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexify": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", @@ -20510,57 +20659,65 @@ } }, "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", + "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -20570,12 +20727,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -20616,9 +20771,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -20628,14 +20783,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -20651,13 +20807,14 @@ } }, "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "license": "MIT", "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" }, "engines": { "node": ">= 0.4" @@ -22812,8 +22969,7 @@ "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, "node_modules/fast-text-encoding": { "version": "1.0.3", @@ -23308,11 +23464,18 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/for-in": { @@ -23474,6 +23637,7 @@ "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", + "license": "MIT", "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } @@ -23649,14 +23813,17 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" @@ -23768,15 +23935,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -23804,6 +23977,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -23820,14 +24006,14 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -24047,11 +24233,13 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -24598,11 +24786,12 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -24622,6 +24811,7 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", "dependencies": { "lodash": "^4.17.15" } @@ -24842,10 +25032,13 @@ } }, "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -24854,9 +25047,10 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -25814,14 +26008,14 @@ } }, "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -25999,13 +26193,14 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -26020,12 +26215,35 @@ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", "dependencies": { - "has-bigints": "^1.0.1" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -26044,12 +26262,13 @@ } }, "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -26114,11 +26333,13 @@ } }, "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "license": "MIT", "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" }, "engines": { @@ -26129,11 +26350,13 @@ } }, "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -26198,6 +26421,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -26295,10 +26533,13 @@ } }, "node_modules/is-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", - "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", - "dev": true, + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -26360,11 +26601,13 @@ } }, "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -26423,12 +26666,15 @@ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" }, "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -26438,10 +26684,13 @@ } }, "node_modules/is-set": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", - "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", - "dev": true, + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -26454,12 +26703,12 @@ "license": "MIT" }, "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -26480,11 +26729,13 @@ } }, "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -26494,11 +26745,14 @@ } }, "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -26508,12 +26762,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.14" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -26554,33 +26808,43 @@ "integrity": "sha512-X/kiF3Xndj6WI7l/yLyzR7V1IbQd6L4S4cewSL0fRciemPmHbaXIKR2qtf+zseH+lbMG0vFp4HvCUe7amGZVhw==" }, "node_modules/is-weakmap": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", - "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-weakset": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", - "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -27301,6 +27565,7 @@ "version": "3.0.15", "resolved": "https://registry.npmjs.org/json-refs/-/json-refs-3.0.15.tgz", "integrity": "sha512-0vOQd9eLNBL18EGl5yYaO44GhixmImes2wiYn9Z3sag3QnehWrYWlB9AFtMxCL2Bj3fyxgDYkxGFEU/chlYssw==", + "license": "MIT", "dependencies": { "commander": "~4.1.1", "graphlib": "^2.1.8", @@ -27322,14 +27587,25 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } }, + "node_modules/json-refs/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/json-refs/node_modules/js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -27342,6 +27618,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", "engines": { "node": ">=8" } @@ -28107,12 +28384,14 @@ "node_modules/lodash._arraypool": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._arraypool/-/lodash._arraypool-2.4.1.tgz", - "integrity": "sha1-6I7suS4ruEyQZWEv2VigcZzUf5Q=" + "integrity": "sha512-tC2aLC7bbkDXKNrjDu9OLiVx9pFIvjinID2eD9PzNdAQGZScWUd/h8faqOw5d6oLsOvFRCRbz1ASoB+deyMVUw==", + "license": "MIT" }, "node_modules/lodash._basebind": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._basebind/-/lodash._basebind-2.4.1.tgz", - "integrity": "sha1-6UC5690nwyfgqNqxtVkWxTQelXU=", + "integrity": "sha512-VGHm6DH+1UiuafQdE/DNMqxOcSyhRu0xO9+jPDq7xITRn5YOorGrHVQmavMVXCYmTm80YRTZZCn/jTW7MokwLg==", + "license": "MIT", "dependencies": { "lodash._basecreate": "~2.4.1", "lodash._setbinddata": "~2.4.1", @@ -28123,7 +28402,8 @@ "node_modules/lodash._baseclone": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._baseclone/-/lodash._baseclone-2.4.1.tgz", - "integrity": "sha1-MPgj5X4X43NdODvWK2Czh1Q7QYY=", + "integrity": "sha512-+zJVXs0VxC/Au+/7foiKzw8UaWvfSfPh20XhqK/6HFQiUeclL5fz05zY7G9yDAFItAKKZwB4cgpzGvxiwuG1wQ==", + "license": "MIT", "dependencies": { "lodash._getarray": "~2.4.1", "lodash._releasearray": "~2.4.1", @@ -28138,7 +28418,8 @@ "node_modules/lodash._basecreate": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-2.4.1.tgz", - "integrity": "sha1-+Ob1tXip405UEXm1a47uv0oofgg=", + "integrity": "sha512-8JJ3FnMPm54t3BwPLk8q8mPyQKQXm/rt9df+awr4NGtyJrtcCXM3Of1I86S6jVy1b4yAyFBb8wbKPEauuqzRmQ==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1", "lodash.isobject": "~2.4.1", @@ -28148,7 +28429,8 @@ "node_modules/lodash._basecreatecallback": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._basecreatecallback/-/lodash._basecreatecallback-2.4.1.tgz", - "integrity": "sha1-fQsmdknLKeehOdAQO3wR+uhOSFE=", + "integrity": "sha512-SLczhg860fGW7AKlYcuOFstDtJuQhaANlJ4Y/jrOoRxhmVtK41vbJDH3OefVRSRkSCQo4HI82QVkAVsoGa5gSw==", + "license": "MIT", "dependencies": { "lodash._setbinddata": "~2.4.1", "lodash.bind": "~2.4.1", @@ -28159,7 +28441,8 @@ "node_modules/lodash._basecreatewrapper": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._basecreatewrapper/-/lodash._basecreatewrapper-2.4.1.tgz", - "integrity": "sha1-TTHy595+E0+/KAN2K4FQsyUZZm8=", + "integrity": "sha512-x2ja1fa/qmzbizuXgVM4QAP9svtMbdxjG8Anl9bCeDAwLOVQ1vLrA0hLb/NkpbGi9evjtkl0aWLTEoOlUdBPQA==", + "license": "MIT", "dependencies": { "lodash._basecreate": "~2.4.1", "lodash._setbinddata": "~2.4.1", @@ -28170,7 +28453,8 @@ "node_modules/lodash._createwrapper": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._createwrapper/-/lodash._createwrapper-2.4.1.tgz", - "integrity": "sha1-UdaVeXPaTtVW43KQ2MGhjFPeFgc=", + "integrity": "sha512-5TCfLt1haQpsa7bgLYRKNNE4yqhO4ZxIayN1btQmazMchO6Q8JYFRMqbJ3W+uNmMm4R0Jw7KGkZX5YfDDnywuw==", + "license": "MIT", "dependencies": { "lodash._basebind": "~2.4.1", "lodash._basecreatewrapper": "~2.4.1", @@ -28181,7 +28465,8 @@ "node_modules/lodash._getarray": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._getarray/-/lodash._getarray-2.4.1.tgz", - "integrity": "sha1-+vH3+BD6mFolHCGHQESBCUg55e4=", + "integrity": "sha512-iIrScwY3atGvLVbQL/+CNUznaPwBJg78S/JO4cTUFXRkRsZgEBhscB27cVoT4tsIOUyFu/5M/0umfHNGJ6wYwg==", + "license": "MIT", "dependencies": { "lodash._arraypool": "~2.4.1" } @@ -28189,22 +28474,26 @@ "node_modules/lodash._isnative": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._isnative/-/lodash._isnative-2.4.1.tgz", - "integrity": "sha1-PqZAS3hKe+g2x7V1gOHN95sUgyw=" + "integrity": "sha512-BOlKGKNHhCHswGOWtmVb5zBygyxN7EmTuzVOSQI6QSoGhG+kvv71gICFS1TBpnqvT1n53txK8CDK3u5D2/GZxQ==", + "license": "MIT" }, "node_modules/lodash._maxpoolsize": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._maxpoolsize/-/lodash._maxpoolsize-2.4.1.tgz", - "integrity": "sha1-nUgvRjuOZq++WcLBTtsRcGAXIzQ=" + "integrity": "sha512-xKDem1BxoIfcCtaJHotjtyfdIvZO9qrF+mv3G1+ngQmaI3MJt3Qm46i9HLk/CbzABbavUrr1/EomQT8KxtsrYA==", + "license": "MIT" }, "node_modules/lodash._objecttypes": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._objecttypes/-/lodash._objecttypes-2.4.1.tgz", - "integrity": "sha1-fAt/admKH3ZSn4kLDNsbTf7BHBE=" + "integrity": "sha512-XpqGh1e7hhkOzftBfWE7zt+Yn9mVHFkDhicVttvKLsoCMLVVL+xTQjfjB4X4vtznauxv0QZ5ZAeqjvat0dh62Q==", + "license": "MIT" }, "node_modules/lodash._releasearray": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._releasearray/-/lodash._releasearray-2.4.1.tgz", - "integrity": "sha1-phOWMNdtFTawfdyAliiJsIL2pkE=", + "integrity": "sha512-wwCwWX8PK/mYR5VZjcU5JFl6py/qrfLGMxzpKOfSqgA1PaZ6Z625CZLCxH1KsqyxSkOFmNm+mEYjeDpXlM4hrg==", + "license": "MIT", "dependencies": { "lodash._arraypool": "~2.4.1", "lodash._maxpoolsize": "~2.4.1" @@ -28213,7 +28502,8 @@ "node_modules/lodash._setbinddata": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._setbinddata/-/lodash._setbinddata-2.4.1.tgz", - "integrity": "sha1-98IAzRuS7yNrOZ7s9zxkjReqlNI=", + "integrity": "sha512-Vx0XKzpg2DFbQw4wrp1xSWd2sfl3W/BG6bucSRZmftS1AzbWRemCmBQDxyQTNhlLNec428PXkuuja+VNBZgu2A==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1", "lodash.noop": "~2.4.1" @@ -28222,7 +28512,8 @@ "node_modules/lodash._shimkeys": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._shimkeys/-/lodash._shimkeys-2.4.1.tgz", - "integrity": "sha1-bpzJZm/wgfC1psl4uD4kLmlJ0gM=", + "integrity": "sha512-lBrglYxLD/6KAJ8IEa5Lg+YHgNAL7FyKqXg4XOUI+Du/vtniLs1ZqS+yHNKPkK54waAgkdUnDOYaWf+rv4B+AA==", + "license": "MIT", "dependencies": { "lodash._objecttypes": "~2.4.1" } @@ -28230,12 +28521,14 @@ "node_modules/lodash._slice": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash._slice/-/lodash._slice-2.4.1.tgz", - "integrity": "sha1-dFz0GlNZexj2iImFREBe+isG2Q8=" + "integrity": "sha512-+odPJa4PE2UgYnQgJgkLs0UD03QU78R2ivhrFnG9GdtYOZdE6ObxOj7KiUEUlqOOgatFT+ZqSypFjDSduTigKg==", + "license": "MIT" }, "node_modules/lodash.assign": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-2.4.1.tgz", - "integrity": "sha1-hMOVlt1xGBqXsGUpE6fJZ15Jsao=", + "integrity": "sha512-AqQ4AJz5buSx9ELXWt5dONwJyVPd4NTADMKhoVYWCugjoVf172/LpvVhwmSJn4g8/Dc0S8hxTe8rt5Dob3X9KQ==", + "license": "MIT", "dependencies": { "lodash._basecreatecallback": "~2.4.1", "lodash._objecttypes": "~2.4.1", @@ -28245,7 +28538,8 @@ "node_modules/lodash.bind": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-2.4.1.tgz", - "integrity": "sha1-XRn6AFyMTSNvr0dCx7eh/Kvikmc=", + "integrity": "sha512-hn2VWYZ+N9aYncRad4jORvlGgpFrn+axnPIWRvFxjk6CWcZH5b5alI8EymYsHITI23Z9wrW/+ORq+azrVFpOfw==", + "license": "MIT", "dependencies": { "lodash._createwrapper": "~2.4.1", "lodash._slice": "~2.4.1" @@ -28259,7 +28553,8 @@ "node_modules/lodash.clonedeep": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-2.4.1.tgz", - "integrity": "sha1-8pIDtAsS/uCkXTYxZIJZvrq8eGg=", + "integrity": "sha512-zj5vReFLkR+lJOBKP1wyteZ13zut/KSmXtdCBgxcy/m4UTitcBxpeVZT7gwk8BQrztPI5dIgO4bhBppXV4rpTQ==", + "license": "MIT", "dependencies": { "lodash._baseclone": "~2.4.1", "lodash._basecreatecallback": "~2.4.1" @@ -28289,7 +28584,8 @@ "node_modules/lodash.foreach": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-2.4.1.tgz", - "integrity": "sha1-/j/Do0yGyUyrb5UiVgKCdB4BYwk=", + "integrity": "sha512-AvOobAkE7qBtIiHU5QHQIfveWH5Usr9pIcFIzBv7u4S6bvb3FWpFrh9ltqBY7UeL5lw6e8d+SggiUXQVyh+FpA==", + "license": "MIT", "dependencies": { "lodash._basecreatecallback": "~2.4.1", "lodash.forown": "~2.4.1" @@ -28298,7 +28594,8 @@ "node_modules/lodash.forown": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.forown/-/lodash.forown-2.4.1.tgz", - "integrity": "sha1-eLQer+FAX6lmRZ6kGT/VAtCEUks=", + "integrity": "sha512-VC+CKm/zSs5t3i/MHv71HZoQphuqOvez1xhjWBwHU5zAbsCYrqwHr+MyQyMk14HzA3hSRNA5lCqDMSw5G2Qscg==", + "license": "MIT", "dependencies": { "lodash._basecreatecallback": "~2.4.1", "lodash._objecttypes": "~2.4.1", @@ -28319,7 +28616,8 @@ "node_modules/lodash.identity": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-2.4.1.tgz", - "integrity": "sha1-ZpTP+mX++TH3wxzobHRZfPVg9PE=" + "integrity": "sha512-VRYX+8XipeLjorag5bz3YBBRJ+5kj8hVBzfnaHgXPZAVTYowBdY5l0M5ZnOmlAMCOXBFabQtm7f5VqjMKEji0w==", + "license": "MIT" }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -28334,7 +28632,8 @@ "node_modules/lodash.isarray": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-2.4.1.tgz", - "integrity": "sha1-tSoybB9i9tfac6MdVAHfbvRPD6E=", + "integrity": "sha512-yRDd0z+APziDqbk0MqR6Qfwj/Qn3jLxFJbI9U8MuvdTnqIXdZ5YXyGLnwuzCpZmjr26F1GNOjKLMMZ10i/wy6A==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1" } @@ -28347,12 +28646,15 @@ "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" }, "node_modules/lodash.isfunction": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-2.4.1.tgz", - "integrity": "sha1-LP1XXHPkmKtX4xm3f6Aq3vE6lNE=" + "integrity": "sha512-6XcAB3izeQxPOQQNAJbbdjXbvWEt2Pn9ezPrjr4CwoLwmqsLVbsiEXD19cmmt4mbzOCOCdHzOQiUivUOJLra7w==", + "license": "MIT" }, "node_modules/lodash.isinteger": { "version": "4.0.4", @@ -28367,7 +28669,8 @@ "node_modules/lodash.isobject": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-2.4.1.tgz", - "integrity": "sha1-Wi5H/mmVPx7mMafrof5k0tBlWPU=", + "integrity": "sha512-sTebg2a1PoicYEZXD5PBdQcTlIJ6hUslrlWr7iV0O7n+i4596s2NQ9I5CaZ5FbXSfya/9WQsrYLANUJv9paYVA==", + "license": "MIT", "dependencies": { "lodash._objecttypes": "~2.4.1" } @@ -28385,7 +28688,8 @@ "node_modules/lodash.keys": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-2.4.1.tgz", - "integrity": "sha1-SN6kbfj/djKxDXBrissmWR4rNyc=", + "integrity": "sha512-ZpJhwvUXHSNL5wYd1RM6CUa2ZuqorG9ngoJ9Ix5Cce+uX7I5O/E06FCJdhSZ33b5dVyeQDnIlWH7B2s5uByZ7g==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1", "lodash._shimkeys": "~2.4.1", @@ -28406,7 +28710,8 @@ "node_modules/lodash.noop": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.noop/-/lodash.noop-2.4.1.tgz", - "integrity": "sha1-T7VPgWZS5a4Q6PcvcXo4jHMmU4o=" + "integrity": "sha512-uNcV98/blRhInPUGQEnj9ekXXfG+q+rfoNSFZgl/eBfog9yBDW9gfUv2AHX/rAF7zZRlzWhbslGhbGQFZlCkZA==", + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", @@ -28422,7 +28727,8 @@ "node_modules/lodash.support": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.support/-/lodash.support-2.4.1.tgz", - "integrity": "sha1-Mg4LZwMWc8KNeiu12eAzGkUkBRU=", + "integrity": "sha512-6SwqWwGFHhTXEiqB/yQgu8FYd//tm786d49y7kizHVCJH7zdzs191UQn3ES3tkkDbUddNRfkCRYqJFHtbLnbCw==", + "license": "MIT", "dependencies": { "lodash._isnative": "~2.4.1" } @@ -28812,6 +29118,15 @@ "dev": true, "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mathjax": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.2.tgz", @@ -29425,7 +29740,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, "bin": { "mime": "cli.js" }, @@ -29434,9 +29748,10 @@ } }, "node_modules/mime-db": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz", - "integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -29452,11 +29767,12 @@ } }, "node_modules/mime-types": { - "version": "2.1.34", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz", - "integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { - "mime-db": "1.51.0" + "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" @@ -29693,7 +30009,8 @@ "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" }, "node_modules/mlly": { "version": "1.7.4", @@ -30158,18 +30475,18 @@ } }, "node_modules/multer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.0.tgz", - "integrity": "sha512-bS8rPZurbAuHGAnApbM9d4h1wSoYqrOqkE+6a64KLMK9yWU7gJXBDDVklKQ3TPi9DRb85cRs6yXaC0+cjxRtRg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "type-is": "^1.6.18", + "xtend": "^4.0.2" }, "engines": { "node": ">= 10.16.0" @@ -30299,7 +30616,8 @@ "node_modules/native-promise-only": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", - "integrity": "sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=" + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" }, "node_modules/native-request": { "version": "1.1.0", @@ -30853,9 +31171,13 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -30897,14 +31219,16 @@ } }, "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { @@ -31155,6 +31479,23 @@ "resolved": "libraries/overleaf-editor-core", "link": true }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-event": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", @@ -31714,12 +32055,80 @@ } }, "node_modules/path-loader": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/path-loader/-/path-loader-1.0.10.tgz", - "integrity": "sha512-CMP0v6S6z8PHeJ6NFVyVJm6WyJjIwFvyz2b0n2/4bKdS/0uZa/9sKUlYZzubrn3zuDRU0zIuEDX9DZYQ2ZI8TA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/path-loader/-/path-loader-1.0.12.tgz", + "integrity": "sha512-n7oDG8B+k/p818uweWrOixY9/Dsr89o2TkCm6tOTex3fpdo2+BFDgR+KpB37mGKBRsBAlR8CIJMFN0OEy/7hIQ==", + "license": "MIT", "dependencies": { "native-promise-only": "^0.8.1", - "superagent": "^3.8.3" + "superagent": "^7.1.6" + } + }, + "node_modules/path-loader/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/path-loader/node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/path-loader/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/path-loader/node_modules/superagent": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", + "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", + "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net", + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.10.3", + "readable-stream": "^3.6.0", + "semver": "^7.3.7" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" } }, "node_modules/path-parse": { @@ -35056,6 +35465,28 @@ "node": ">=4.0.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -35120,15 +35551,17 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -35643,14 +36076,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, "engines": { @@ -35671,6 +36105,22 @@ "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", "optional": true }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -35681,14 +36131,14 @@ } }, "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "is-regex": "^1.1.4" + "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -36406,13 +36856,29 @@ } }, "node_modules/set-function-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", - "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -36523,14 +36989,69 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -37109,7 +37630,8 @@ "node_modules/spark-md5": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", - "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==" + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", + "license": "(WTFPL OR MIT)" }, "node_modules/sparse-bitfield": { "version": "3.0.3", @@ -37198,7 +37720,8 @@ "node_modules/split-ca": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", - "integrity": "sha1-bIOv82kvphJW4M0ZfgXp3hV2kaY=" + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "license": "ISC" }, "node_modules/split-string": { "version": "3.1.0", @@ -37233,6 +37756,23 @@ "es5-ext": "^0.10.53" } }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, "node_modules/sshpk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", @@ -37347,12 +37887,13 @@ "license": "MIT" }, "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", "dependencies": { - "internal-slot": "^1.0.4" + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -37525,15 +38066,18 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -37543,15 +38087,19 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -38040,7 +38588,8 @@ "version": "3.8.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", - "deprecated": "Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . Thanks to @shadowgate15, @spence-s, and @niftylettuce. Superagent is sponsored by Forward Email at .", + "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net", + "license": "MIT", "dependencies": { "component-emitter": "^1.2.0", "cookiejar": "^2.1.0", @@ -38061,32 +38610,58 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/superagent/node_modules/form-data": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz", + "integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" }, "engines": { "node": ">= 0.12" } }, + "node_modules/superagent/node_modules/form-data/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/superagent/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" }, "node_modules/superagent/node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -38095,9 +38670,10 @@ } }, "node_modules/superagent/node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -38112,6 +38688,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } @@ -38352,7 +38929,8 @@ "node_modules/swagger-converter": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/swagger-converter/-/swagger-converter-0.1.7.tgz", - "integrity": "sha1-oJdRnG8e5N1n4wjZtT3cnCslf5c=", + "integrity": "sha512-O2hZbWqq8x6j0uZ4qWj5dw45WPoAxKsJLJZqOgTqRtPNi8IqA+rDkDV/48S8qanS3KGv1QcVoPNLivMbyHHdAQ==", + "license": "MIT", "dependencies": { "lodash.clonedeep": "^2.4.1" } @@ -38403,12 +38981,6 @@ "lodash": "^4.17.14" } }, - "node_modules/swagger-tools/node_modules/commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", - "license": "MIT" - }, "node_modules/swagger-tools/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -38507,9 +39079,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", - "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", + "integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -39333,14 +39905,14 @@ } }, "node_modules/traverse": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", - "integrity": "sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==", + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.11.tgz", + "integrity": "sha512-vxXDZg8/+p3gblxB6BhhG5yWVn1kGRlaL8O78UDXc3wRnPizB5g83dcvWV1jpDMIPnjZjOFuxlMmE82XJ4407w==", "license": "MIT", "dependencies": { - "gopd": "^1.0.1", - "typedarray.prototype.slice": "^1.0.3", - "which-typed-array": "^1.1.15" + "gopd": "^1.2.0", + "typedarray.prototype.slice": "^1.0.5", + "which-typed-array": "^1.1.18" }, "engines": { "node": ">= 0.4" @@ -39497,30 +40069,30 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -39530,17 +40102,18 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" }, "engines": { "node": ">= 0.4" @@ -39550,17 +40123,17 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-proto": "^1.0.3", "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" }, "engines": { "node": ">= 0.4" @@ -39575,17 +40148,19 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, "node_modules/typedarray.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.3.tgz", - "integrity": "sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.5.tgz", + "integrity": "sha512-q7QNVDGTdl702bVFiI5eY4l/HkgCM6at9KhcFbgUAzezHFbOVy4+0O/lCjsABEQwbZPravVfBIiBVGo89yzHFg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", + "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-offset": "^1.0.2" + "get-proto": "^1.0.1", + "math-intrinsics": "^1.1.0", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-offset": "^1.0.4" }, "engines": { "node": ">= 0.4" @@ -39673,14 +40248,18 @@ } }, "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bound": "^1.0.3", "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -41365,30 +41944,64 @@ } }, "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/which-collection": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", - "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", "dependencies": { - "is-map": "^2.0.1", - "is-set": "^2.0.1", - "is-weakmap": "^2.0.1", - "is-weakset": "^2.0.1" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -41401,15 +42014,17 @@ "dev": true }, "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, "engines": { @@ -41926,6 +42541,7 @@ "version": "3.25.1", "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-3.25.1.tgz", "integrity": "sha512-7tDlwhrBG+oYFdXNOjILSurpfQyuVgkRe3hB2q8TEssamDHB7BbLWYkYO98nTn0FibfdFroFKDjndbgufAgS/Q==", + "license": "MIT", "dependencies": { "core-js": "^2.5.7", "lodash.get": "^4.0.0", @@ -41939,23 +42555,19 @@ "commander": "^2.7.1" } }, - "node_modules/z-schema/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "optional": true - }, "node_modules/z-schema/node_modules/core-js": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.4 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.", - "hasInstallScript": true + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true, + "license": "MIT" }, "node_modules/z-schema/node_modules/validator": { "version": "10.11.0", "resolved": "https://registry.npmjs.org/validator/-/validator-10.11.0.tgz", "integrity": "sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==", + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -42079,13 +42691,13 @@ "async": "^3.2.5", "body-parser": "^1.20.3", "bunyan": "^1.8.15", - "dockerode": "^4.0.5", + "dockerode": "^4.0.7", "express": "^4.21.2", "lodash": "^4.17.21", "p-limit": "^3.1.0", "request": "^2.88.2", "send": "^0.19.0", - "tar-fs": "^3.0.4", + "tar-fs": "^3.0.9", "workerpool": "^6.1.5" }, "devDependencies": { @@ -42152,33 +42764,6 @@ "node": ">= 0.6" } }, - "services/clsi/node_modules/@grpc/grpc-js": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.2.tgz", - "integrity": "sha512-nnR5nmL6lxF8YBqb6gWvEgLdLh/Fn+kvAdX5hUOnt48sNSb0riz/93ASd2E5gvanPA41X6Yp25bIfGRp1SMb2g==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.7.13", - "@js-sdsl/ordered-map": "^4.4.2" - }, - "engines": { - "node": ">=12.10.0" - } - }, - "services/clsi/node_modules/cpu-features": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", - "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "buildcheck": "~0.0.6", - "nan": "^2.19.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, "services/clsi/node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -42188,75 +42773,6 @@ "node": ">=0.3.1" } }, - "services/clsi/node_modules/docker-modem": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", - "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.1.1", - "readable-stream": "^3.5.0", - "split-ca": "^1.0.1", - "ssh2": "^1.15.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "services/clsi/node_modules/dockerode": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.5.tgz", - "integrity": "sha512-ZPmKSr1k1571Mrh7oIBS/j0AqAccoecY2yH420ni5j1KyNMgnoTh4Nu4FWunh0HZIJmRSmSysJjBIpa/zyWUEA==", - "license": "Apache-2.0", - "dependencies": { - "@balena/dockerignore": "^1.0.2", - "@grpc/grpc-js": "^1.11.1", - "@grpc/proto-loader": "^0.7.13", - "docker-modem": "^5.0.6", - "protobufjs": "^7.3.2", - "tar-fs": "~2.1.2", - "uuid": "^10.0.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "services/clsi/node_modules/dockerode/node_modules/tar-fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", - "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "services/clsi/node_modules/protobufjs": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", - "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "services/clsi/node_modules/sinon": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.0.3.tgz", @@ -42276,23 +42792,6 @@ "url": "https://opencollective.com/sinon" } }, - "services/clsi/node_modules/ssh2": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", - "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", - "hasInstallScript": true, - "dependencies": { - "asn1": "^0.2.6", - "bcrypt-pbkdf": "^1.0.2" - }, - "engines": { - "node": ">=10.16.0" - }, - "optionalDependencies": { - "cpu-features": "~0.0.10", - "nan": "^2.20.0" - } - }, "services/clsi/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -42305,19 +42804,6 @@ "node": ">=8" } }, - "services/clsi/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "services/contacts": { "name": "@overleaf/contacts", "dependencies": { @@ -44716,7 +45202,7 @@ "moment": "^2.29.4", "mongodb-legacy": "6.1.3", "mongoose": "8.9.5", - "multer": "overleaf/multer#199c5ff05bd375c508f4074498237baead7f5148", + "multer": "overleaf/multer#4dbceda355efc3fc8ac3cf5c66c3778c8a6fdb23", "nocache": "^2.1.0", "node-fetch": "^2.7.0", "nodemailer": "^6.7.0", @@ -46007,18 +46493,18 @@ } }, "services/web/node_modules/multer": { - "version": "2.0.0", - "resolved": "git+ssh://git@github.com/overleaf/multer.git#199c5ff05bd375c508f4074498237baead7f5148", - "integrity": "sha512-S5MlIoOgrDr+a2jLS8z7jQlbzvZ0m30U2tRwdyLrxhnnMUQZYEzkVysEv10Dw41RTpM5bQQDs563Vzl1LLhxhQ==", + "version": "2.0.1", + "resolved": "git+ssh://git@github.com/overleaf/multer.git#4dbceda355efc3fc8ac3cf5c66c3778c8a6fdb23", + "integrity": "sha512-kkvPK48OQibR5vIoTQBbZp1uWVCvT9MrW3Y0mqdhFYJP/HVJujb4eSCEU0yj+hyf0Y+H/BKCmPdM4fJnzqAO4w==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "type-is": "^1.6.18", + "xtend": "^4.0.2" }, "engines": { "node": ">= 10.16.0" diff --git a/package.json b/package.json index 64fbd258ed..a51bbcd743 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "swagger-tools": { "body-parser": "1.20.3", - "multer": "2.0.0", + "multer": "2.0.1", "path-to-regexp": "3.3.0", "qs": "6.13.0" } diff --git a/services/clsi/package.json b/services/clsi/package.json index 86566e0f59..b07430391a 100644 --- a/services/clsi/package.json +++ b/services/clsi/package.json @@ -27,13 +27,13 @@ "async": "^3.2.5", "body-parser": "^1.20.3", "bunyan": "^1.8.15", - "dockerode": "^4.0.5", + "dockerode": "^4.0.7", "express": "^4.21.2", "lodash": "^4.17.21", "p-limit": "^3.1.0", "request": "^2.88.2", "send": "^0.19.0", - "tar-fs": "^3.0.4", + "tar-fs": "^3.0.9", "workerpool": "^6.1.5" }, "devDependencies": { diff --git a/services/web/package.json b/services/web/package.json index 826e051a9d..59825e0e68 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -143,7 +143,7 @@ "moment": "^2.29.4", "mongodb-legacy": "6.1.3", "mongoose": "8.9.5", - "multer": "overleaf/multer#199c5ff05bd375c508f4074498237baead7f5148", + "multer": "overleaf/multer#4dbceda355efc3fc8ac3cf5c66c3778c8a6fdb23", "nocache": "^2.1.0", "node-fetch": "^2.7.0", "nodemailer": "^6.7.0", From 6d202432ffe5287d2d65b48689c00326401201cc Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Mon, 9 Jun 2025 15:28:12 -0400 Subject: [PATCH 128/259] Merge pull request #26209 from overleaf/em-multiple-edit-ops Support multiple ops in the history OT ShareJS type GitOrigin-RevId: fad1e9081ed1978de414c5130692d3b23fcd13d8 --- .../editor/share-js-history-ot-type.ts | 39 ++++- .../unit/share-js-history-ot-type.ts | 134 ++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index 0e70e93676..81243bb8c7 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -30,18 +30,49 @@ export const historyOTType = { api, transformX(ops1: EditOperation[], ops2: EditOperation[]) { - const [a, b] = EditOperationTransformer.transform(ops1[0], ops2[0]) - return [[a], [b]] + // Dynamic programming algorithm: gradually transform both sides in a nested + // loop. + const left = [...ops1] + const right = [...ops2] + for (let i = 0; i < left.length; i++) { + for (let j = 0; j < right.length; j++) { + // At this point: + // left[0..i] is ops1[0..i] rebased over ops2[0..j-1] + // right[0..j] is ops2[0..j] rebased over ops1[0..i-1] + const [a, b] = EditOperationTransformer.transform(left[i], right[j]) + left[i] = a + right[j] = b + } + } + return [left, right] }, apply(snapshot: StringFileData, ops: EditOperation[]) { const afterFile = StringFileData.fromRaw(snapshot.toRaw()) - afterFile.edit(ops[0]) + for (const op of ops) { + afterFile.edit(op) + } return afterFile }, compose(ops1: EditOperation[], ops2: EditOperation[]) { - return [ops1[0].compose(ops2[0])] + const ops = [...ops1, ...ops2] + let currentOp = ops.shift() + if (currentOp === undefined) { + // No ops to process + return [] + } + const result = [] + for (const op of ops) { + if (currentOp.canBeComposedWith(op)) { + currentOp = currentOp.compose(op) + } else { + result.push(currentOp) + currentOp = op + } + } + result.push(currentOp) + return result }, // Do not provide normalize, used by submitOp to fixup bad input. diff --git a/services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts b/services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts new file mode 100644 index 0000000000..8418c59ed0 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts @@ -0,0 +1,134 @@ +import { expect } from 'chai' +import { + StringFileData, + TextOperation, + AddCommentOperation, + Range, +} from 'overleaf-editor-core' +import { historyOTType } from '@/features/ide-react/editor/share-js-history-ot-type' + +describe('historyOTType', function () { + let snapshot: StringFileData + let opsA: TextOperation[] + let opsB: TextOperation[] + + beforeEach(function () { + snapshot = new StringFileData('one plus two equals three') + + // After opsA: "seven plus five equals twelve" + opsA = [new TextOperation(), new TextOperation(), new TextOperation()] + + opsA[0].remove(3) + opsA[0].insert('seven') + opsA[0].retain(22) + + opsA[1].retain(11) + opsA[1].remove(3) + opsA[1].insert('five') + opsA[1].retain(13) + + opsA[2].retain(23) + opsA[2].remove(5) + opsA[2].insert('twelve') + + // After ops2: "one times two equals two" + opsB = [new TextOperation(), new TextOperation()] + + opsB[0].retain(4) + opsB[0].remove(4) + opsB[0].insert('times') + opsB[0].retain(17) + + opsB[1].retain(21) + opsB[1].remove(5) + opsB[1].insert('two') + }) + + describe('apply', function () { + it('supports an empty operations array', function () { + const result = historyOTType.apply(snapshot, []) + expect(result.getContent()).to.equal('one plus two equals three') + }) + + it('applies operations to the snapshot (opsA)', function () { + const result = historyOTType.apply(snapshot, opsA) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + + it('applies operations to the snapshot (opsB)', function () { + const result = historyOTType.apply(snapshot, opsB) + expect(result.getContent()).to.equal('one times two equals two') + }) + }) + + describe('compose', function () { + it('supports empty operations', function () { + const ops = historyOTType.compose([], []) + expect(ops).to.deep.equal([]) + }) + + it('supports an empty operation on the left', function () { + const ops = historyOTType.compose([], opsA) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + + it('supports an empty operation on the right', function () { + const ops = historyOTType.compose(opsA, []) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + + it('supports operations on both sides', function () { + const ops = historyOTType.compose(opsA.slice(0, 2), opsA.slice(2)) + const result = historyOTType.apply(snapshot, ops) + expect(ops.length).to.equal(1) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + + it("supports operations that can't be composed", function () { + const comment = new AddCommentOperation('comment-id', [new Range(3, 10)]) + const ops = historyOTType.compose(opsA.slice(0, 2), [ + comment, + ...opsA.slice(2), + ]) + expect(ops.length).to.equal(3) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven plus five equals twelve') + }) + }) + + describe('transformX', function () { + it('supports empty operations', function () { + const [aPrime, bPrime] = historyOTType.transformX([], []) + expect(aPrime).to.deep.equal([]) + expect(bPrime).to.deep.equal([]) + }) + + it('supports an empty operation on the left', function () { + const [aPrime, bPrime] = historyOTType.transformX([], opsB) + expect(aPrime).to.deep.equal([]) + expect(bPrime).to.deep.equal(opsB) + }) + + it('supports an empty operation on the right', function () { + const [aPrime, bPrime] = historyOTType.transformX(opsA, []) + expect(aPrime).to.deep.equal(opsA) + expect(bPrime).to.deep.equal([]) + }) + + it('supports operations on both sides (a then b)', function () { + const [, bPrime] = historyOTType.transformX(opsA, opsB) + const ops = historyOTType.compose(opsA, bPrime) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven times five equals twelvetwo') + }) + + it('supports operations on both sides (b then a)', function () { + const [aPrime] = historyOTType.transformX(opsA, opsB) + const ops = historyOTType.compose(opsB, aPrime) + const result = historyOTType.apply(snapshot, ops) + expect(result.getContent()).to.equal('seven times five equals twelvetwo') + }) + }) +}) From 69e2a57769846927bb555ad65e725c9f53ab43d7 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:14:43 +0300 Subject: [PATCH 129/259] Merge pull request #26141 from overleaf/ii-managed-users-consent-screen [web] Joining managed group from projects page GitOrigin-RevId: 191203559fba94cad45f35de1af2427b2abb9326 --- .../Notifications/NotificationsController.mjs | 22 ++++++ services/web/app/src/router.mjs | 6 ++ .../use-group-invitation-notification.tsx | 76 ++++++++++--------- .../notifications/group-invitation.spec.tsx | 7 ++ .../NotificationsController.test.mjs | 21 +++++ 5 files changed, 98 insertions(+), 34 deletions(-) diff --git a/services/web/app/src/Features/Notifications/NotificationsController.mjs b/services/web/app/src/Features/Notifications/NotificationsController.mjs index ae1d9208f3..35b5f0a677 100644 --- a/services/web/app/src/Features/Notifications/NotificationsController.mjs +++ b/services/web/app/src/Features/Notifications/NotificationsController.mjs @@ -33,4 +33,26 @@ export default { res.sendStatus(200) ) }, + + getNotification(req, res, next) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { notificationId } = req.params + NotificationsHandler.getUserNotifications( + userId, + function (err, unreadNotifications) { + if (err) { + return next(err) + } + const notification = unreadNotifications.find( + n => n._id === notificationId + ) + + if (!notification) { + return res.status(404).end() + } + + res.json(notification) + } + ) + }, } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index a7e8d5e05f..7851a4a66f 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -915,6 +915,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { NotificationsController.markNotificationAsRead ) + webRouter.get( + '/user/notification/:notificationId', + AuthenticationController.requireLogin(), + NotificationsController.getNotification + ) + // Deprecated in favour of /internal/project/:project_id but still used by versioning privateApiRouter.get( '/project/:project_id/details', diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx index 15248f8c42..f62571b722 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx +++ b/services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/hooks/use-group-invitation-notification.tsx @@ -9,6 +9,7 @@ import type { NotificationGroupInvitation } from '../../../../../../../../../typ import useAsync from '../../../../../../../shared/hooks/use-async' import { FetchError, + getJSON, postJSON, putJSON, } from '../../../../../../../infrastructure/fetch-json' @@ -43,17 +44,12 @@ type UseGroupInvitationNotificationReturnType = { export function useGroupInvitationNotification( notification: NotificationGroupInvitation ): UseGroupInvitationNotificationReturnType { - const { - _id: notificationId, - messageOpts: { token, managedUsersEnabled }, - } = notification - + const { _id: notificationId } = notification const [groupInvitationStatus, setGroupInvitationStatus] = useState(GroupInvitationStatus.Idle) - const { runAsync, isLoading: isAcceptingInvitation } = useAsync< - never, - FetchError - >() + const { runAsync, isLoading } = useAsync() + const { runAsync: runAsyncNotification, isLoading: isLoadingNotification } = + useAsync() const location = useLocation() const { handleDismiss } = useAsyncDismiss() @@ -72,31 +68,41 @@ export function useGroupInvitationNotification( }, [hasIndividualPaidSubscription]) const acceptGroupInvite = useCallback(() => { - if (managedUsersEnabled) { - location.assign(`/subscription/invites/${token}/`) - } else { - runAsync( - putJSON(`/subscription/invites/${token}/`, { - body: { - _csrf: getMeta('ol-csrfToken'), - }, - }) - ) - .then(() => { - setGroupInvitationStatus(GroupInvitationStatus.SuccessfullyJoined) - }) - .catch(err => { - debugConsole.error(err) - setGroupInvitationStatus(GroupInvitationStatus.Error) - }) - .finally(() => { - // remove notification automatically in the browser - window.setTimeout(() => { - setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden) - }, SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN) - }) - } - }, [runAsync, token, location, managedUsersEnabled]) + // Fetch the latest notification data to ensure it's up-to-date + runAsyncNotification(getJSON(`/user/notification/${notificationId}`)) + .then(notification => { + const { + messageOpts: { token, managedUsersEnabled }, + } = notification + if (managedUsersEnabled) { + location.assign(`/subscription/invites/${token}/`) + } else { + runAsync( + putJSON(`/subscription/invites/${token}/`, { + body: { + _csrf: getMeta('ol-csrfToken'), + }, + }) + ) + .then(() => { + setGroupInvitationStatus(GroupInvitationStatus.SuccessfullyJoined) + }) + .catch(err => { + debugConsole.error(err) + setGroupInvitationStatus(GroupInvitationStatus.Error) + }) + .finally(() => { + // remove notification automatically in the browser + window.setTimeout(() => { + setGroupInvitationStatus( + GroupInvitationStatus.NotificationIsHidden + ) + }, SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN) + }) + } + }) + .catch(debugConsole.error) + }, [runAsync, runAsyncNotification, notificationId, location]) const cancelPersonalSubscription = useCallback(() => { setGroupInvitationStatus(GroupInvitationStatus.AskToJoin) @@ -114,6 +120,8 @@ export function useGroupInvitationNotification( setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden) }, []) + const isAcceptingInvitation = isLoadingNotification || isLoading + return { isAcceptingInvitation, groupInvitationStatus, diff --git a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx index 31114a2405..c29de58f98 100644 --- a/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx +++ b/services/web/test/frontend/components/project-list/notifications/group-invitation.spec.tsx @@ -27,6 +27,10 @@ describe('', function () { } beforeEach(function () { + cy.intercept('GET', `/user/notification/${notification._id}`, { + statusCode: 200, + body: notification, + }).as('getNotification') cy.intercept( 'PUT', `/subscription/invites/${notification.messageOpts.token}`, @@ -48,6 +52,7 @@ describe('', function () { cy.findByRole('button', { name: 'Join now' }).click() + cy.wait('@getNotification') cy.wait('@acceptInvite') cy.findByText( @@ -82,6 +87,7 @@ describe('', function () { cy.findByRole('button', { name: 'Join now' }).click() + cy.wait('@getNotification') cy.wait('@acceptInvite') cy.findByText( @@ -116,6 +122,7 @@ describe('', function () { cy.findByRole('button', { name: 'Join now' }).click() + cy.wait('@getNotification') cy.wait('@acceptInvite') cy.findByText( diff --git a/services/web/test/unit/src/Notifications/NotificationsController.test.mjs b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs index 6e1f9177c0..1bc5c51b31 100644 --- a/services/web/test/unit/src/Notifications/NotificationsController.test.mjs +++ b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs @@ -14,6 +14,9 @@ describe('NotificationsController', function () { ctx.handler = { getUserNotifications: sinon.stub().callsArgWith(1), markAsRead: sinon.stub().callsArgWith(2), + promises: { + getUserNotifications: sinon.stub().callsArgWith(1), + }, } ctx.req = { params: { @@ -77,4 +80,22 @@ describe('NotificationsController', function () { }) }) }) + + it('should get a notification by notification id', function (ctx) { + return new Promise(resolve => { + const notification = { _id: notificationId, user_id: userId } + ctx.handler.getUserNotifications = sinon + .stub() + .callsArgWith(1, null, [notification]) + ctx.controller.getNotification(ctx.req, { + json: body => { + body.should.deep.equal(notification) + resolve() + }, + status: () => ({ + end: () => {}, + }), + }) + }) + }) }) From 312664bd2da18d84cf15fab327d003c2e67b062e Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Tue, 10 Jun 2025 09:14:52 +0100 Subject: [PATCH 130/259] Merge pull request #26265 from overleaf/ds-cms-bs5-customer-stories-2 [B2C] Bootstrap 5 migration of Customer stories page GitOrigin-RevId: cca0d00412ab4ec5da15e26e4e7eb3c40de9e47c --- .../bootstrap-5/pages/website-redesign.scss | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss index 1f6027d835..80de22a186 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss @@ -551,6 +551,20 @@ } } + .customer-stories-hero-heading { + @include media-breakpoint-down(lg) { + @include heading-xl; + } + } + + .customer-stories-logos-text { + font-size: var(--font-size-05); + } + + .customer-stories-hero-text { + font-size: var(--font-size-05); + } + .overleaf-sticker { width: unset; From 3da4dc71f1fc8470ce6c6535dbe13db697edff27 Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 13:40:52 +0100 Subject: [PATCH 131/259] Modify no-unused-vars behaviour using @typescript-eslint/no-unused-vars reduces the number of false positives in TS code. The changes: 1. Allow the arguments to a function to be checked (reporting only after the last used variable) 2. Allow rest siblings to be checked 3. Allow these rules to be skipped with an _ prefix to a variable GitOrigin-RevId: 1f6eac4109859415218248d5b2068a22b34cfd7e --- services/web/.eslintrc.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/services/web/.eslintrc.js b/services/web/.eslintrc.js index 2fa9e8f547..ef3cf11de5 100644 --- a/services/web/.eslintrc.js +++ b/services/web/.eslintrc.js @@ -383,6 +383,18 @@ module.exports = { 'Modify location via customLocalStorage instead of calling window.localStorage methods directly', }, ], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: false, + caughtErrors: 'none', + vars: 'all', + varsIgnorePattern: '^_', + }, + ], }, }, { From 542008c61df61693e5933994d9b3dc566464ac7d Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 14:48:21 +0100 Subject: [PATCH 132/259] Remove unused event arguments GitOrigin-RevId: 25858d07865d6b9a7caa4997d031586a248d8e8b --- services/web/frontend/js/features/contact-form/index.js | 2 +- .../components/table-generator/toolbar/toolbar-button-menu.tsx | 2 +- .../features/source-editor/components/toolbar/math-dropdown.tsx | 2 +- .../source-editor/components/toolbar/table-dropdown.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/js/features/contact-form/index.js b/services/web/frontend/js/features/contact-form/index.js index 0b4a4898aa..51aff806e3 100644 --- a/services/web/frontend/js/features/contact-form/index.js +++ b/services/web/frontend/js/features/contact-form/index.js @@ -23,7 +23,7 @@ document }) document.querySelectorAll('[data-ol-contact-form]').forEach(el => { - el.addEventListener('submit', function (e) { + el.addEventListener('submit', function () { const emailValue = document.querySelector( '[data-ol-contact-form-email-input]' ).value diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx index 51c68872f6..d63ed7b706 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx @@ -36,7 +36,7 @@ export const ToolbarButtonMenu: FC< event.preventDefault() event.stopPropagation() }} - onClick={event => { + onClick={() => { onToggle(!open) }} disabled={disabled} diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx index b34a61c69d..748a04d7cb 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx @@ -34,7 +34,7 @@ export const MathDropdown = memo(function MathDropdown() { { + onClick={() => { writefullInstance?.openEquationGenerator() }} > diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx index 190d2e7c7d..a191b63600 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx @@ -46,7 +46,7 @@ export const TableDropdown = memo(function TableDropdown() { { + onClick={() => { writefullInstance?.openTableGenerator() }} > From eb60d364f62a7477c74fe6f47ff2f4036651023c Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 15:18:49 +0100 Subject: [PATCH 133/259] Fix instances of ...rest filtering GitOrigin-RevId: 9f2889b08ffed20466d7022a5aba69d3e87c5ed9 --- .../js/features/review-panel-new/context/threads-context.tsx | 2 +- .../ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx | 2 +- services/web/frontend/stories/contact-us-modal.stories.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx index d5cf34ef93..48c44feed7 100644 --- a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx @@ -89,7 +89,7 @@ export const ThreadsProvider: FC = ({ children }) => { ) => { setData(value => { if (value) { - const { submitting, ...thread } = value[threadId] ?? { + const { submitting: _1, ...thread } = value[threadId] ?? { messages: [], } diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx index cdf20e3dd3..719b936581 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx @@ -29,7 +29,7 @@ const DropdownToggleWithTooltip = forwardRef< toolTipDescription, overlayTriggerProps, tooltipProps, - id, + id: _id, ...toggleProps }, ref diff --git a/services/web/frontend/stories/contact-us-modal.stories.tsx b/services/web/frontend/stories/contact-us-modal.stories.tsx index b1c0e2c431..24c671ce10 100644 --- a/services/web/frontend/stories/contact-us-modal.stories.tsx +++ b/services/web/frontend/stories/contact-us-modal.stories.tsx @@ -69,7 +69,7 @@ const ContactUsModalWithAcknowledgement = ( } export const WithAcknowledgement = (args: ContactUsModalProps) => { - const { show, handleHide, ...rest } = args + const { show: _show, handleHide: _handleHide, ...rest } = args return } From 496056964862967d79c18fbcd4d5e523ae8590de Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 15:21:01 +0100 Subject: [PATCH 134/259] Remove unused full arguments As distinct from removing destructured props. GitOrigin-RevId: d02ad8d36fb532559ed2899268d7b699f2f2fa37 --- .../dropdown/history-dropdown-content.tsx | 6 +----- .../dropdown/menu-item/add-label.tsx | 10 ++-------- .../features/history/extensions/highlights.ts | 6 +++--- .../ide-react/editor/document-container.ts | 4 ++-- .../project-tools/buttons/tags-dropdown.tsx | 2 +- .../components/review-panel.tsx | 2 +- .../hooks/use-review-panel-styles.ts | 2 +- .../table-generator/toolbar/commands.ts | 6 +++--- .../extensions/cursor-highlights.ts | 2 +- .../source-editor/extensions/cursor-position.ts | 2 +- .../source-editor/extensions/draw-selection.ts | 4 ++-- .../extensions/empty-line-filler.ts | 4 ++-- .../source-editor/extensions/keybindings.ts | 17 +++++++---------- .../features/source-editor/extensions/ranges.ts | 2 +- .../source-editor/extensions/realtime.ts | 5 +---- .../extensions/vertical-overflow.ts | 2 +- .../extensions/visual/visual-widgets/end.ts | 2 +- .../visual/visual-widgets/environment-line.ts | 4 ++-- .../languages/latex/latex-indent-service.ts | 2 +- .../change-plan/individual-plans-table.tsx | 4 ++-- .../web/frontend/stories/decorators/scope.tsx | 2 +- .../web/frontend/stories/fixtures/compile.js | 4 ++-- .../stories/split-test-badge.stories.jsx | 2 +- .../stories/ui/dropdown-menu.stories.tsx | 4 ++-- .../stories/ui/split-button.stories.tsx | 4 +--- .../dictionary-modal-content.spec.jsx | 8 ++++---- .../components/emails/add-email-input.test.tsx | 2 +- .../frontend/ide/log-parser/logParserTests.js | 2 +- 28 files changed, 49 insertions(+), 67 deletions(-) diff --git a/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx b/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx index ac7a0044d8..43858f7eb3 100644 --- a/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx +++ b/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx @@ -28,11 +28,7 @@ function HistoryDropdownContent({ return ( <> {permissions.labelVersion && ( - + )} void } -function AddLabel({ - version, - projectId, - closeDropdown, - ...props -}: DownloadProps) { +function AddLabel({ version, closeDropdown, ...props }: AddLabelProps) { const { t } = useTranslation() const [showModal, setShowModal] = useState(false) diff --git a/services/web/frontend/js/features/history/extensions/highlights.ts b/services/web/frontend/js/features/history/extensions/highlights.ts index ce274cf724..1f81f82e74 100644 --- a/services/web/frontend/js/features/history/extensions/highlights.ts +++ b/services/web/frontend/js/features/history/extensions/highlights.ts @@ -238,7 +238,7 @@ class EmptyLineAdditionMarkerWidget extends WidgetType { super() } - toDOM(view: EditorView): HTMLElement { + toDOM(): HTMLElement { const element = document.createElement('span') element.classList.add( 'ol-cm-empty-line-addition-marker', @@ -255,7 +255,7 @@ class EmptyLineDeletionMarkerWidget extends WidgetType { super() } - toDOM(view: EditorView): HTMLElement { + toDOM(): HTMLElement { const element = document.createElement('span') element.classList.add( 'ol-cm-empty-line-deletion-marker', @@ -297,7 +297,7 @@ class ChangeGutterMarker extends GutterMarker { super() } - toDOM(view: EditorView) { + toDOM() { const el = document.createElement('div') el.className = 'ol-cm-changed-line-gutter' el.style.setProperty('--hue', this.hue.toString()) diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts index 2ded041fb1..28bcb955d1 100644 --- a/services/web/frontend/js/features/ide-react/editor/document-container.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -599,7 +599,7 @@ export class DocumentContainer extends EventEmitter { this.doc.on('remoteop', (...ops: AnyOperation[]) => { return this.trigger('remoteop', ...ops) }) - this.doc.on('op:sent', (op: AnyOperation) => { + this.doc.on('op:sent', () => { return this.trigger('op:sent') }) this.doc.on('op:acknowledged', (op: AnyOperation) => { @@ -609,7 +609,7 @@ export class DocumentContainer extends EventEmitter { }) return this.trigger('op:acknowledged') }) - this.doc.on('op:timeout', (op: AnyOperation) => { + this.doc.on('op:timeout', () => { this.trigger('op:timeout') return this.onError(new Error('op timed out')) }) diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx index 443962cc3c..06dd9b8ff3 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx @@ -91,7 +91,7 @@ function TagsDropdown() { data-testid="project-tools-more-dropdown-menu" > {t('add_to_tag')} - {sortBy(tags, tag => tag.name?.toLowerCase()).map((tag, index) => ( + {sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => (
  • diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx index 7d8b694f68..74405ba276 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx @@ -17,7 +17,7 @@ const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => { [choosenSubView, mini] ) - const style = useReviewPanelStyles(mini) + const style = useReviewPanelStyles() const className = classnames('review-panel-container', { 'review-panel-mini': mini, diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts index 7e7dda1850..727701ccc3 100644 --- a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts @@ -1,7 +1,7 @@ import { CSSProperties, useCallback, useEffect, useState } from 'react' import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context' -export const useReviewPanelStyles = (mini: boolean) => { +export const useReviewPanelStyles = () => { const view = useCodeMirrorViewContext() const [styles, setStyles] = useState({ diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts index 2645e853bd..ab58179586 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts @@ -45,16 +45,16 @@ const themeGenerators: Record = { left: true, right: number === numColumns - 1, }), - row: (number: number, numRows: number) => '\\hline', + row: () => '\\hline', multicolumn: () => ({ left: true, right: true }), lastRow: () => '\\hline', }, [BorderTheme.BOOKTABS]: { - column: (number: number, numColumns: number) => ({ + column: () => ({ left: false, right: false, }), - row: (number: number, numRows: number) => { + row: (number: number) => { if (number === 0) { return '\\toprule' } diff --git a/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts b/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts index 78d2903825..ccdc8b90e7 100644 --- a/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts +++ b/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts @@ -187,7 +187,7 @@ class CursorMarker extends RectangleMarker { const cursorHighlightsLayer = layer({ above: true, class: 'ol-cm-cursorHighlightsLayer', - update: (update, layer) => { + update: update => { return ( update.docChanged || update.selectionSet || diff --git a/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts b/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts index efde64f40e..0cd69d8b1f 100644 --- a/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts +++ b/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts @@ -42,7 +42,7 @@ export const cursorPosition = ({ // Asynchronously dispatch cursor position when the selection changes and // provide a little debouncing. Using requestAnimationFrame postpones it // until the next CM6 DOM update. - ViewPlugin.define(view => { + ViewPlugin.define(() => { let animationFrameRequest: number | null = null return { diff --git a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts index af31353a23..413317ec0a 100644 --- a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts +++ b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts @@ -71,7 +71,7 @@ const cursorLayer = layer({ updateHasMouseDownEffect(update) ) }, - mount(dom, view) { + mount(dom) { dom.style.animationDuration = '1200ms' }, class: 'cm-cursorLayer', @@ -90,7 +90,7 @@ const selectionLayer = layer({ } return markers }, - update(update, dom) { + update(update) { return ( update.docChanged || update.selectionSet || diff --git a/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts b/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts index 647463d608..49d9b195b9 100644 --- a/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts +++ b/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts @@ -9,13 +9,13 @@ import { import browser from './browser' class EmptyLineWidget extends WidgetType { - toDOM(view: EditorView): HTMLElement { + toDOM(): HTMLElement { const element = document.createElement('span') element.className = 'ol-cm-filler' return element } - eq(widget: EmptyLineWidget) { + eq() { return true } } diff --git a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts index 3e67b4b753..01c39d67ba 100644 --- a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts +++ b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts @@ -34,17 +34,14 @@ const customiseVimOnce = (_Vim: typeof Vim, _CodeMirror: typeof CodeMirror) => { // Allow copy via Ctrl-C in insert mode _Vim.unmap('', 'insert') - _Vim.defineAction( - 'insertModeCtrlC', - (cm: CodeMirror, actionArgs: object, state: any) => { - if (hasNonEmptySelection(cm)) { - navigator.clipboard.writeText(cm.getSelection()) - cm.setSelection(cm.getCursor(), cm.getCursor()) - } else { - _Vim.exitInsertMode(cm) - } + _Vim.defineAction('insertModeCtrlC', (cm: CodeMirror) => { + if (hasNonEmptySelection(cm)) { + navigator.clipboard.writeText(cm.getSelection()) + cm.setSelection(cm.getCursor(), cm.getCursor()) + } else { + _Vim.exitInsertMode(cm) } - ) + }) // Overwrite the moveByCharacters command with a decoration-aware version _Vim.defineMotion( diff --git a/services/web/frontend/js/features/source-editor/extensions/ranges.ts b/services/web/frontend/js/features/source-editor/extensions/ranges.ts index 8dc4489d57..7bde7a4adb 100644 --- a/services/web/frontend/js/features/source-editor/extensions/ranges.ts +++ b/services/web/frontend/js/features/source-editor/extensions/ranges.ts @@ -68,7 +68,7 @@ export const rangesDataField = StateField.define({ export const ranges = () => [ rangesDataField, // handle viewportChanged updates - ViewPlugin.define(view => { + ViewPlugin.define(() => { let timer: number return { diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index e9f5710338..58cfa8712a 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -334,10 +334,7 @@ class HistoryOTAdapter { } } - handleUpdateFromCM( - transactions: readonly Transaction[], - ranges?: RangesTracker - ) { + handleUpdateFromCM(transactions: readonly Transaction[]) { for (const transaction of transactions) { if ( this.maxDocLength && diff --git a/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts b/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts index 20505ed95d..873343c2bc 100644 --- a/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts +++ b/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts @@ -188,7 +188,7 @@ class TopPaddingWidget extends WidgetType { this.height = height } - toDOM(view: EditorView): HTMLElement { + toDOM(): HTMLElement { const element = document.createElement('div') element.style.height = this.height + 'px' return element diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts index 232399de3b..3ca2439ae1 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts @@ -7,7 +7,7 @@ export class EndWidget extends WidgetType { return element } - eq(widget: EndWidget) { + eq() { return true } diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts index d6ab42503e..d506ac2c38 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts @@ -1,4 +1,4 @@ -import { EditorView, WidgetType } from '@codemirror/view' +import { WidgetType } from '@codemirror/view' export class EnvironmentLineWidget extends WidgetType { constructor( @@ -8,7 +8,7 @@ export class EnvironmentLineWidget extends WidgetType { super() } - toDOM(view: EditorView) { + toDOM() { const element = document.createElement('div') element.classList.add(`ol-cm-environment-${this.environment}`) element.classList.add('ol-cm-environment-edge') diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts index 08c1798032..d1e8e84bc4 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts @@ -1,7 +1,7 @@ import { indentService } from '@codemirror/language' export const latexIndentService = () => - indentService.of((indentContext, pos) => { + indentService.of(indentContext => { // only use this for insertNewLineAndIndent if (indentContext.simulatedBreak) { // match the indentation of the previous line (if present) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx index a6ede01715..d8c98fc56b 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx @@ -20,7 +20,7 @@ function ChangeToPlanButton({ planCode }: { planCode: string }) { ) } -function KeepCurrentPlanButton({ plan }: { plan: Plan }) { +function KeepCurrentPlanButton() { const { t } = useTranslation() const { handleOpenModal } = useSubscriptionDashboardContext() @@ -43,7 +43,7 @@ function ChangePlanButton({ plan }: { plan: Plan }) { plan.planCode === personalSubscription.planCode.split('_')[0] if (isCurrentPlanForUser && personalSubscription.pendingPlan) { - return + return } else if (isCurrentPlanForUser && !personalSubscription.pendingPlan) { return ( diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx index e69ebd8d21..ae6e366eb8 100644 --- a/services/web/frontend/stories/decorators/scope.tsx +++ b/services/web/frontend/stories/decorators/scope.tsx @@ -72,7 +72,7 @@ const initialize = () => { } }, 0) }, - $on: (eventName: string, callback: () => void) => { + $on: () => { // }, $broadcast: () => {}, diff --git a/services/web/frontend/stories/fixtures/compile.js b/services/web/frontend/stories/fixtures/compile.js index 9471ff04ff..bc7ebfae8b 100644 --- a/services/web/frontend/stories/fixtures/compile.js +++ b/services/web/frontend/stories/fixtures/compile.js @@ -100,7 +100,7 @@ export const mockClearCache = fetchMock => }) export const mockBuildFile = fetchMock => - fetchMock.get('express:/build/:file', (url, options, request) => { + fetchMock.get('express:/build/:file', url => { const { pathname } = new URL(url, 'https://example.com') switch (pathname) { @@ -190,7 +190,7 @@ export const mockEventTracking = fetchMock => fetchMock.get('express:/event/:event', 204) export const mockValidPdf = fetchMock => - fetchMock.get('express:/build/output.pdf', (url, options, request) => { + fetchMock.get('express:/build/output.pdf', () => { return new Promise(resolve => { const xhr = new XMLHttpRequest() xhr.addEventListener('load', () => { diff --git a/services/web/frontend/stories/split-test-badge.stories.jsx b/services/web/frontend/stories/split-test-badge.stories.jsx index ecb74b71d1..331263e0cb 100644 --- a/services/web/frontend/stories/split-test-badge.stories.jsx +++ b/services/web/frontend/stories/split-test-badge.stories.jsx @@ -127,7 +127,7 @@ export default { displayOnVariants: ['active'], }, decorators: [ - (Story, context) => ( + Story => ( diff --git a/services/web/frontend/stories/ui/dropdown-menu.stories.tsx b/services/web/frontend/stories/ui/dropdown-menu.stories.tsx index 5d1ac376bb..5758640ebc 100644 --- a/services/web/frontend/stories/ui/dropdown-menu.stories.tsx +++ b/services/web/frontend/stories/ui/dropdown-menu.stories.tsx @@ -60,7 +60,7 @@ export const Active = (args: Args) => { ) } -export const MultipleSelection = (args: Args) => { +export const MultipleSelection = () => { return ( Header @@ -191,7 +191,7 @@ export const LeadingIcon = (args: Args) => { ) } -export const TrailingIcon = (args: Args) => { +export const TrailingIcon = () => { return ( diff --git a/services/web/frontend/stories/ui/split-button.stories.tsx b/services/web/frontend/stories/ui/split-button.stories.tsx index 674d2e796b..01cbab9ea4 100644 --- a/services/web/frontend/stories/ui/split-button.stories.tsx +++ b/services/web/frontend/stories/ui/split-button.stories.tsx @@ -11,9 +11,7 @@ import { import Button from '@/features/ui/components/bootstrap-5/button' import { ButtonGroup } from 'react-bootstrap' -type Args = React.ComponentProps - -export const Sizes = (args: Args) => { +export const Sizes = () => { const { t } = useTranslation() const sizes = { Large: 'lg', diff --git a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx index c28eef66ef..c8cdd931b3 100644 --- a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx +++ b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx @@ -19,7 +19,7 @@ describe('', function () { }) it('list words', function () { - cy.then(win => { + cy.then(() => { learnedWords.global = new Set(['foo', 'bar']) }) @@ -34,7 +34,7 @@ describe('', function () { }) it('shows message when empty', function () { - cy.then(win => { + cy.then(() => { learnedWords.global = new Set([]) }) @@ -50,7 +50,7 @@ describe('', function () { it('removes words', function () { cy.intercept('/spelling/unlearn', { statusCode: 200 }) - cy.then(win => { + cy.then(() => { learnedWords.global = new Set(['Foo', 'bar']) }) @@ -76,7 +76,7 @@ describe('', function () { it('handles errors', function () { cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn') - cy.then(win => { + cy.then(() => { learnedWords.global = new Set(['foo']) }) diff --git a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx b/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx index 50220152c6..694a13f32c 100644 --- a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx @@ -13,7 +13,7 @@ const testInstitutionData = [ describe('', function () { const defaultProps = { - onChange: (value: string) => {}, + onChange: () => {}, handleAddNewEmail: () => {}, } diff --git a/services/web/test/frontend/ide/log-parser/logParserTests.js b/services/web/test/frontend/ide/log-parser/logParserTests.js index 098ee056b9..59cdd5d22e 100644 --- a/services/web/test/frontend/ide/log-parser/logParserTests.js +++ b/services/web/test/frontend/ide/log-parser/logParserTests.js @@ -6,7 +6,7 @@ const fixturePath = '../../helpers/fixtures/logs/' const fs = require('fs') const path = require('path') -describe('logParser', function (done) { +describe('logParser', function () { it('should parse errors', function () { const { errors } = parseLatexLog('errors.log', { ignoreDuplicates: true }) expect(errors.map(e => [e.line, e.message])).to.deep.equal([ From c1f5d7c40c62a5c8cc34c8dcd4615744404d3933 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Fri, 4 Oct 2024 12:04:56 +0100 Subject: [PATCH 135/259] Ignore params that are needed for type integrity These params are either used in a descendent or ancestor of the relevant file and form part of the interface of the method even if they are not directly used. GitOrigin-RevId: 8bf64cecc69a9ae9e6c50797de5ce8db86757440 --- .../source-editor/extensions/visual/visual-widgets/begin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts index 70e508d93e..1826b48719 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts @@ -45,6 +45,7 @@ export class BeginWidget extends WidgetType { return element.getBoundingClientRect() } + // eslint-disable-next-line @typescript-eslint/no-unused-vars buildName(name: HTMLSpanElement, view: EditorView) { name.textContent = this.environment } From 25675ce2ba8210032c867eba5cfd66314c6931d7 Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 16:08:05 +0100 Subject: [PATCH 136/259] Remove unused params from destructuring GitOrigin-RevId: e47a16e2d99e923c314fd0fa2220c19b7b2c9b51 --- .../members-table/dropdown-button.tsx | 1 - ...imeout-message-after-paywall-dismissal.tsx | 30 ++----------------- .../features/pdf-preview/util/pdf-caching.js | 1 - .../components/dropdown/sort-by-dropdown.tsx | 2 +- .../project-list/components/tags-list.tsx | 3 +- .../settings/components/linking-section.tsx | 4 +-- .../components/select-collaborators.tsx | 2 +- 7 files changed, 8 insertions(+), 35 deletions(-) diff --git a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx index bd3b5ee10e..b62e9ce391 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx @@ -291,7 +291,6 @@ type MenuItemButtonProps = { function MenuItemButton({ children, onClick, - className, isLoading, variant, 'data-testid': dataTestId, diff --git a/services/web/frontend/js/features/pdf-preview/components/timeout-message-after-paywall-dismissal.tsx b/services/web/frontend/js/features/pdf-preview/components/timeout-message-after-paywall-dismissal.tsx index db6140085f..3a1d66fd3d 100644 --- a/services/web/frontend/js/features/pdf-preview/components/timeout-message-after-paywall-dismissal.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/timeout-message-after-paywall-dismissal.tsx @@ -1,40 +1,20 @@ import getMeta from '@/utils/meta' import { Trans, useTranslation } from 'react-i18next' -import { memo, useCallback, useEffect } from 'react' +import { memo, useEffect } from 'react' import { useDetachCompileContext } from '@/shared/context/detach-compile-context' import StartFreeTrialButton from '@/shared/components/start-free-trial-button' import MaterialIcon from '@/shared/components/material-icon' -import { useStopOnFirstError } from '@/shared/hooks/use-stop-on-first-error' import * as eventTracking from '@/infrastructure/event-tracking' import PdfLogEntry from './pdf-log-entry' function TimeoutMessageAfterPaywallDismissal() { - const { - startCompile, - lastCompileOptions, - setAnimateCompileDropdownArrow, - isProjectOwner, - } = useDetachCompileContext() - - const { enableStopOnFirstError } = useStopOnFirstError({ - eventSource: 'timeout-new', - }) - - const handleEnableStopOnFirstErrorClick = useCallback(() => { - enableStopOnFirstError() - startCompile({ stopOnFirstError: true }) - setAnimateCompileDropdownArrow(true) - }, [enableStopOnFirstError, startCompile, setAnimateCompileDropdownArrow]) + const { lastCompileOptions, isProjectOwner } = useDetachCompileContext() return (
    {getMeta('ol-ExposedSettings').enableSubscriptions && ( - + )}
    ) @@ -124,14 +104,10 @@ const CompileTimeout = memo(function CompileTimeout({ type PreventTimeoutHelpMessageProps = { lastCompileOptions: any - handleEnableStopOnFirstErrorClick: () => void - isProjectOwner: boolean } const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({ lastCompileOptions, - handleEnableStopOnFirstErrorClick, - isProjectOwner, }: PreventTimeoutHelpMessageProps) { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-caching.js b/services/web/frontend/js/features/pdf-preview/util/pdf-caching.js index 7fd17c87bf..c3dba41d8b 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-caching.js +++ b/services/web/frontend/js/features/pdf-preview/util/pdf-caching.js @@ -247,7 +247,6 @@ function usageAboveThreshold(chunk) { function cutRequestAmplification({ potentialChunks, usageScore, - cachedUrls, metrics, start, end, diff --git a/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx b/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx index 0d23aebf57..db92561728 100644 --- a/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx @@ -12,7 +12,7 @@ import { DropdownToggle, } from '@/features/ui/components/bootstrap-5/dropdown-menu' -function Item({ onClick, text, iconType, screenReaderText }: SortBtnProps) { +function Item({ onClick, text, iconType }: SortBtnProps) { return ( void - onEditClick?: () => void } -function TagsList({ onTagClick, onEditClick }: TagsListProps) { +function TagsList({ onTagClick }: TagsListProps) { const { t } = useTranslation() const { tags, untaggedProjectsCount, selectedTagId, selectTag } = useProjectListContext() diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index 0b9001927e..a198cb1328 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -115,7 +115,7 @@ function LinkingSection() { ) : null}
    {allIntegrationLinkingWidgets.map( - ({ import: importObject, path }, widgetIndex) => ( + ({ import: importObject }, widgetIndex) => ( {t('reference_managers')}
    {referenceLinkingWidgets.map( - ({ import: importObject, path }, widgetIndex) => ( + ({ import: importObject }, widgetIndex) => ( (item && item.name) || '', stateReducer, - onStateChange: ({ inputValue, type, selectedItem }) => { + onStateChange: ({ type, selectedItem }) => { switch (type) { // add a selected item on Enter (keypress), click or blur case useCombobox.stateChangeTypes.InputKeyDownEnter: From f87113077332234615bb707a05f9c5cfbbb1f07b Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 13 Sep 2024 16:08:47 +0100 Subject: [PATCH 137/259] Disable lint warnings for stubbed class GitOrigin-RevId: bcee2d1ea4fcb5543fa31fd2174641e55d6c4d39 --- .../js/features/ide-react/context/snapshot-context.tsx | 6 +++++- .../languages/latex/linter/latex-linter.worker.js | 3 +++ services/web/frontend/js/ide/connection/SocketIoShim.js | 8 ++++++++ services/web/test/frontend/bootstrap.js | 4 ++++ .../frontend/features/source-editor/helpers/mock-doc.ts | 1 + 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx b/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx index 70f170a8b0..817e03fe86 100644 --- a/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/snapshot-context.tsx @@ -24,10 +24,14 @@ export const StubSnapshotUtils = { throw new Error('not implemented') } }, + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars buildFileTree(snapshot: Snapshot): Folder { throw new Error('not implemented') }, - createFolder(_id: string, name: string): Folder { + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createFolder(id: string, name: string): Folder { throw new Error('not implemented') }, } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js index 0bfaf94d62..c496ce767f 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js +++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js @@ -2087,7 +2087,10 @@ if (typeof onmessage !== 'undefined') { } // export dummy class for testing export default class LintWorker { + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars postMessage(message) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars addEventListener(eventName, listener) {} Parse(text) { return Parse(text) diff --git a/services/web/frontend/js/ide/connection/SocketIoShim.js b/services/web/frontend/js/ide/connection/SocketIoShim.js index 9fb57ef1f1..6d9effd442 100644 --- a/services/web/frontend/js/ide/connection/SocketIoShim.js +++ b/services/web/frontend/js/ide/connection/SocketIoShim.js @@ -4,6 +4,8 @@ import { debugConsole } from '@/utils/debugging' import EventEmitter from '@/utils/EventEmitter' class SocketShimBase { + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars static connect(url, options) { return new SocketShimBase() } @@ -46,11 +48,15 @@ class SocketShimNoop extends SocketShimBase { }, connect() {}, + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars disconnect(reason) {}, } } connect() {} + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars disconnect(reason) {} emit() {} on() {} @@ -295,6 +301,8 @@ export class SocketIOMock extends SocketShimBase { }, connect() {}, + // unused vars kept to document the interface + // eslint-disable-next-line @typescript-eslint/no-unused-vars disconnect(reason) {}, } } diff --git a/services/web/test/frontend/bootstrap.js b/services/web/test/frontend/bootstrap.js index df4d3f1464..496b7b588d 100644 --- a/services/web/test/frontend/bootstrap.js +++ b/services/web/test/frontend/bootstrap.js @@ -65,8 +65,12 @@ globalThis.BroadcastChannel = global.BroadcastChannel = window.BroadcastChannel = class BroadcastChannel { + // Unused arguments left to document the signature of the stubbed function. + // eslint-disable-next-line @typescript-eslint/no-unused-vars addEventListener(type, listener) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars removeEventListener(type, listener) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars postMessage(message) {} } diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts index f13d9ad6bb..a4944c1e97 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts @@ -106,6 +106,7 @@ export const mockDoc = ( removeCommentId: () => {}, ...rangesOptions, }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars submitOp: (op: any) => {}, setTrackChangesIdSeeds: () => {}, getTrackingChanges: () => true, From 52280febf6ab0b3553727862891e983026d99314 Mon Sep 17 00:00:00 2001 From: andrew rumble Date: Fri, 27 Sep 2024 12:13:32 +0100 Subject: [PATCH 138/259] When filtering object members from rest use full name GitOrigin-RevId: 0c21c70b2512931744f18e79c8d9e4bb85e83dfa --- .../js/features/review-panel-new/context/threads-context.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx index 48c44feed7..0a5c737585 100644 --- a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx @@ -89,7 +89,7 @@ export const ThreadsProvider: FC = ({ children }) => { ) => { setData(value => { if (value) { - const { submitting: _1, ...thread } = value[threadId] ?? { + const { submitting: _submitting, ...thread } = value[threadId] ?? { messages: [], } From 2c07fa1f778829f8c1683ec6d9d77639abbaa712 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Fri, 30 May 2025 15:53:00 +0100 Subject: [PATCH 139/259] Skip unused array members GitOrigin-RevId: 5ea4dd880505e65fe7545e0c0d4301236ad103e7 --- .../share-project-modal/components/share-project-modal.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx index 88f3482c4b..6c43e548ea 100644 --- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx +++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx @@ -617,7 +617,7 @@ describe('', function () { fetchMock.post( 'express:/project/:projectId/invite', - ({ args: [url, req] }) => { + ({ args: [, req] }) => { const data = JSON.parse(req.body) if (data.email === 'a@b.c') { From ce3054713fce070cf369a0aacf313b14d4943a91 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 5 Jun 2025 13:53:38 +0100 Subject: [PATCH 140/259] Remove unused variable GitOrigin-RevId: 57b864aff3317513f981b101feafac28d3379403 --- services/web/frontend/js/features/tooltip/index-bs5.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/js/features/tooltip/index-bs5.ts b/services/web/frontend/js/features/tooltip/index-bs5.ts index 62c199e2e6..43d6bc015f 100644 --- a/services/web/frontend/js/features/tooltip/index-bs5.ts +++ b/services/web/frontend/js/features/tooltip/index-bs5.ts @@ -21,8 +21,8 @@ if (footerLanguageElement) { const allTooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]') allTooltips.forEach(element => { - // eslint-disable-next-line no-unused-vars - const tooltip = new Tooltip(element) + // eslint-disable-next-line no-new + new Tooltip(element) }) const possibleBadgeTooltips = document.querySelectorAll('[data-badge-tooltip]') @@ -36,8 +36,8 @@ possibleBadgeTooltips.forEach(element => { if (element.parentElement) { const parentWidth = getElementWidth(element.parentElement) if (element.scrollWidth > parentWidth) { - // eslint-disable-next-line no-unused-vars - const tooltip = new Tooltip(element) + // eslint-disable-next-line no-new + new Tooltip(element) } else { element.parentElement.style.maxWidth = 'none' } From 637312e4f8ee99e6337829dab4be8be9113ef4dc Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Tue, 10 Jun 2025 10:03:06 +0100 Subject: [PATCH 141/259] Merge pull request #26135 from overleaf/dp-error-logs-ai Add AI paywall to new error logs GitOrigin-RevId: 2d6dad11dfe3b27c8ff322a9778a53496cfe7277 --- services/web/config/settings.defaults.js | 1 + services/web/frontend/extracted-translations.json | 3 +++ .../ide-redesign/components/error-logs/error-logs.tsx | 11 ++++++++++- services/web/locales/en.json | 3 +++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 4d55f21db8..43544814fd 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1002,6 +1002,7 @@ module.exports = { fullProjectSearchPanel: [], integrationPanelComponents: [], referenceSearchSetting: [], + errorLogsComponents: [], }, moduleImportSequence: [ diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index fda4b6368b..6b730db1a1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1,7 +1,9 @@ { + "0_free_suggestions": "", "12x_more_compile_time": "", "1_2_width": "", "1_4_width": "", + "1_free_suggestion": "", "3_4_width": "", "About": "", "Account": "", @@ -624,6 +626,7 @@ "generic_if_problem_continues_contact_us": "", "generic_linked_file_compile_error": "", "generic_something_went_wrong": "", + "get_ai_assist": "", "get_collaborative_benefits": "", "get_discounted_plan": "", "get_error_assist": "", diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx index 7b54785295..64a96d677d 100644 --- a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { memo, useMemo, useState } from 'react' +import { ElementType, memo, useMemo, useState } from 'react' import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider' import StopOnFirstErrorPrompt from '@/features/pdf-preview/components/stop-on-first-error-prompt' import PdfPreviewError from '@/features/pdf-preview/components/pdf-preview-error' @@ -11,6 +11,12 @@ import { useDetachCompileContext as useCompileContext } from '@/shared/context/d import { Nav, NavLink, TabContainer, TabContent } from 'react-bootstrap' import { LogEntry as LogEntryData } from '@/features/pdf-preview/util/types' import LogEntry from './log-entry' +import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' + +const logsComponents: Array<{ + import: { default: ElementType } + path: string +}> = importOverleafModules('errorLogsComponents') type ErrorLogTab = { key: string @@ -52,6 +58,9 @@ function ErrorLogs() { ))} + {logsComponents.map(({ import: { default: Component }, path }) => ( + + ))}
    {stoppedOnFirstError && includeErrors && } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 78ea2d6463..837e4ea09a 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1,8 +1,10 @@ { + "0_free_suggestions": "0 free suggestions", "12x_basic": "12x Basic", "12x_more_compile_time": "12x more compile time on our fastest servers", "1_2_width": "½ width", "1_4_width": "¼ width", + "1_free_suggestion": "1 free suggestion", "3_4_width": "¾ width", "About": "About", "Account": "Account", @@ -824,6 +826,7 @@ "generic_if_problem_continues_contact_us": "If the problem continues please contact us", "generic_linked_file_compile_error": "This project’s output files are not available because it failed to compile. Please open the project to see the compilation error details.", "generic_something_went_wrong": "Sorry, something went wrong", + "get_ai_assist": "Get AI Assist", "get_collaborative_benefits": "Get the collaborative benefits from __appName__, even if you prefer to work offline", "get_discounted_plan": "Get discounted plan", "get_error_assist": "Get Error Assist", From c23e84eb372f55513d04baddd5e6d9c848a19a4e Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:06:21 +0100 Subject: [PATCH 142/259] Merge pull request #26273 from overleaf/bg-history-redis-add-persist-worker-to-cron modify existing run-chunk-lifecycle cron job to persist and expire redis queues GitOrigin-RevId: afb94b3e2fba7368cfec11997dfd5b2bbd6321a9 --- .../history-v1/storage/scripts/persist_and_expire_queues.sh | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 services/history-v1/storage/scripts/persist_and_expire_queues.sh diff --git a/services/history-v1/storage/scripts/persist_and_expire_queues.sh b/services/history-v1/storage/scripts/persist_and_expire_queues.sh new file mode 100644 index 0000000000..d9ff60ea31 --- /dev/null +++ b/services/history-v1/storage/scripts/persist_and_expire_queues.sh @@ -0,0 +1,3 @@ +#!/bin/sh +node storage/scripts/persist_redis_chunks.js +node storage/scripts/expire_redis_chunks.js From c227c1e2d9441eea611a0d3bda47367236ba826d Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Tue, 10 Jun 2025 10:10:55 +0100 Subject: [PATCH 143/259] Remove some unused variables These miseed the lint rule as they were merged between the last rebase and deploy. GitOrigin-RevId: 16b1117d56f2fc824509b9a0f340dba2ede9902f --- .../js/features/source-editor/extensions/history-ot.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts index b10a629189..5f6c8796f0 100644 --- a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -41,7 +41,7 @@ export const shareDocState = StateField.define({ return null }, - update(value, transaction) { + update(value) { // this state is constant return value }, @@ -134,7 +134,7 @@ class ChangeDeletedWidget extends WidgetType { return widget } - eq(old: ChangeDeletedWidget) { + eq() { return true } } @@ -407,7 +407,7 @@ class PositionMapper { }) this.offsets.toCM6.push({ pos: change.range.pos, - map: pos => deletePos - oldOffset, + map: () => deletePos - oldOffset, }) this.offsets.toCM6.push({ pos: change.range.pos + deleteLength, From f904933d6855c614b0ac98325ba57d7e27f93ff2 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:41:44 +0100 Subject: [PATCH 144/259] Merge pull request #26180 from overleaf/bg-history-redis-add-queueChanges add queueChanges method to history-v1 GitOrigin-RevId: fb6da79bd5ca40e7cbdcb077ad3a036cc5509ced --- services/history-v1/storage/index.js | 1 + .../history-v1/storage/lib/queue_changes.js | 75 ++++ .../js/storage/queue_changes.test.js | 416 ++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 services/history-v1/storage/lib/queue_changes.js create mode 100644 services/history-v1/test/acceptance/js/storage/queue_changes.test.js diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index 46fa63b689..a07c98c026 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -9,6 +9,7 @@ exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') exports.persistBuffer = require('./lib/persist_buffer') +exports.queueChanges = require('./lib/queue_changes') exports.ProjectArchive = require('./lib/project_archive') exports.streams = require('./lib/streams') exports.temp = require('./lib/temp') diff --git a/services/history-v1/storage/lib/queue_changes.js b/services/history-v1/storage/lib/queue_changes.js new file mode 100644 index 0000000000..6b8d4b22b4 --- /dev/null +++ b/services/history-v1/storage/lib/queue_changes.js @@ -0,0 +1,75 @@ +// @ts-check + +'use strict' + +const redisBackend = require('./chunk_store/redis') +const { BlobStore } = require('./blob_store') +const chunkStore = require('./chunk_store') +const core = require('overleaf-editor-core') +const Chunk = core.Chunk + +/** + * Queues an incoming set of changes after validating them against the current snapshot. + * + * @async + * @function queueChanges + * @param {string} projectId - The project to queue changes for. + * @param {Array} changesToQueue - An array of change objects to be applied and queued. + * @param {number} endVersion - The expected version of the project before these changes are applied. + * This is used for optimistic concurrency control. + * @param {Object} [opts] - Additional options for queuing changes. + * @throws {Chunk.ConflictingEndVersion} If the provided `endVersion` does not match the + * current version of the project. + * @returns {Promise} A promise that resolves with the status returned by the + * `redisBackend.queueChanges` operation. + */ +async function queueChanges(projectId, changesToQueue, endVersion, opts) { + const result = await redisBackend.getHeadSnapshot(projectId) + let currentSnapshot = null + let currentVersion = null + if (result) { + // If we have a snapshot in redis, we can use it to check the current state + // of the project and apply changes to it. + currentSnapshot = result.snapshot + currentVersion = result.version + } else { + // Otherwise, load the latest chunk from the chunk store. + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) + // Throw an error if no latest chunk is found, indicating the project has not been initialised. + if (!latestChunk) { + throw new Chunk.NotFoundError(projectId) + } + currentSnapshot = latestChunk.getSnapshot() + currentSnapshot.applyAll(latestChunk.getChanges()) + currentVersion = latestChunk.getEndVersion() + } + + // Ensure the endVersion matches the current version of the project. + if (endVersion !== currentVersion) { + throw new Chunk.ConflictingEndVersion(endVersion, currentVersion) + } + + // Compute the new hollow snapshot to be saved to redis. + const hollowSnapshot = currentSnapshot + const blobStore = new BlobStore(projectId) + await hollowSnapshot.loadFiles('hollow', blobStore) + // Clone the changes to avoid modifying the original ones when computing the hollow snapshot. + const hollowChanges = changesToQueue.map(change => change.clone()) + for (const change of hollowChanges) { + await change.loadFiles('hollow', blobStore) + } + hollowSnapshot.applyAll(hollowChanges, { strict: true }) + const baseVersion = currentVersion + const status = await redisBackend.queueChanges( + projectId, + hollowSnapshot, + baseVersion, + changesToQueue, + opts + ) + return status +} + +module.exports = queueChanges diff --git a/services/history-v1/test/acceptance/js/storage/queue_changes.test.js b/services/history-v1/test/acceptance/js/storage/queue_changes.test.js new file mode 100644 index 0000000000..dbfe8c7e56 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/queue_changes.test.js @@ -0,0 +1,416 @@ +'use strict' + +const { expect } = require('chai') +const sinon = require('sinon') + +const cleanup = require('./support/cleanup') +const fixtures = require('./support/fixtures') +const testFiles = require('./support/test_files.js') +const storage = require('../../../../storage') +const chunkStore = storage.chunkStore +const queueChanges = storage.queueChanges +const redisBackend = require('../../../../storage/lib/chunk_store/redis') + +const core = require('overleaf-editor-core') +const AddFileOperation = core.AddFileOperation +const EditFileOperation = core.EditFileOperation +const TextOperation = core.TextOperation +const Change = core.Change +const Chunk = core.Chunk +const File = core.File +const Snapshot = core.Snapshot +const BlobStore = storage.BlobStore +const persistChanges = storage.persistChanges + +describe('queueChanges', function () { + let limitsToPersistImmediately + before(function () { + // Used to provide a limit which forces us to persist all of the changes + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + limitsToPersistImmediately = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + maxChanges: 10, + maxChunkChanges: 10, + } + }) + + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + afterEach(function () { + sinon.restore() + }) + + it('queues changes when redis has no snapshot (falls back to chunkStore with an empty chunk)', async function () { + // Start with an empty chunk store for the project + const projectId = fixtures.docs.uninitializedProject.id + await chunkStore.initializeProject(projectId) + + // Ensure that the initial state in redis is empty + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState.headVersion).to.be.null + expect(initialRedisState.headSnapshot).to.be.null + expect(initialRedisState.changes).to.be.an('array').that.is.empty + + // Add a test file to the blob store + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + + // Prepare an initial change to add a single file to an empty project + const change = new Change( + [ + new AddFileOperation( + 'test.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ), + ], + new Date(), + [] + ) + const changesToQueue = [change] + const endVersion = 0 + + // Queue the changes to add the test file + const status = await queueChanges(projectId, changesToQueue, endVersion) + expect(status).to.equal('ok') + + // Verify that we now have some state in redis + const redisState = await redisBackend.getState(projectId) + expect(redisState).to.not.be.null + + // Compute the expected snapshot after applying the changes + const expectedSnapshot = new Snapshot() + await expectedSnapshot.loadFiles('hollow', blobStore) + for (const change of changesToQueue) { + const hollowChange = change.clone() + await hollowChange.loadFiles('hollow', blobStore) + hollowChange.applyTo(expectedSnapshot, { strict: true }) + } + + // Confirm that state in redis matches the expected snapshot and changes queue + const expectedVersionInRedis = endVersion + changesToQueue.length + expect(redisState.headVersion).to.equal(expectedVersionInRedis) + expect(redisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw()) + expect(redisState.changes).to.deep.equal(changesToQueue.map(c => c.toRaw())) + }) + + it('queues changes when redis has no snapshot (falls back to chunkStore with an existing chunk)', async function () { + const projectId = fixtures.docs.uninitializedProject.id + + // Initialise the project in the chunk store using the "Hello World" test file + await chunkStore.initializeProject(projectId) + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + const change = new Change( + [ + new AddFileOperation( + 'hello.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ), + ], + new Date(), + [] + ) + const initialChanges = [change] + const initialVersion = 0 + + const result = await persistChanges( + projectId, + initialChanges, + limitsToPersistImmediately, + initialVersion + ) + // Compute the state after the initial changes are persisted for later comparison + const endVersion = initialVersion + initialChanges.length + const { currentChunk } = result + const originalSnapshot = result.currentChunk.getSnapshot() + await originalSnapshot.loadFiles('hollow', blobStore) + originalSnapshot.applyAll(currentChunk.getChanges()) + + // Ensure that the initial state in redis is empty + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState.headVersion).to.be.null + expect(initialRedisState.headSnapshot).to.be.null + expect(initialRedisState.changes).to.be.an('array').that.is.empty + + // Prepare a change to edit the existing file + const editFileOp = new EditFileOperation( + 'hello.tex', + new TextOperation() + .insert('Hello') + .retain(testFiles.HELLO_TXT_UTF8_LENGTH) + ) + const editFileChange = new Change([editFileOp], new Date(), []) + const changesToQueue = [editFileChange] + + // Queue the changes to edit the existing file + const status = await queueChanges(projectId, changesToQueue, endVersion) + expect(status).to.equal('ok') + + // Verify that we now have some state in redis + const redisState = await redisBackend.getState(projectId) + expect(redisState).to.not.be.null + + // Compute the expected snapshot after applying the changes + const expectedSnapshot = originalSnapshot.clone() + await expectedSnapshot.loadFiles('hollow', blobStore) + expectedSnapshot.applyAll(changesToQueue) + + // Confirm that state in redis matches the expected snapshot and changes queue + const expectedVersionInRedis = endVersion + changesToQueue.length + expect(redisState.headVersion).to.equal(expectedVersionInRedis) + expect(redisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw()) + expect(redisState.changes).to.deep.equal(changesToQueue.map(c => c.toRaw())) + }) + + it('queues changes when redis has a snapshot with existing changes', async function () { + const projectId = fixtures.docs.uninitializedProject.id + + // Initialise the project in redis using the "Hello World" test file + await chunkStore.initializeProject(projectId) + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + const initialChangeOp = new AddFileOperation( + 'existing.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ) + const initialChange = new Change([initialChangeOp], new Date(), []) + const initialChangesToQueue = [initialChange] + const versionBeforeInitialQueue = 0 + + // Queue the initial changes + const status = await queueChanges( + projectId, + initialChangesToQueue, + versionBeforeInitialQueue + ) + // Confirm that the initial changes were queued successfully + expect(status).to.equal('ok') + const versionAfterInitialQueue = + versionBeforeInitialQueue + initialChangesToQueue.length + + // Compute the snapshot after the initial changes for later use + const initialSnapshot = new Snapshot() + await initialSnapshot.loadFiles('hollow', blobStore) + for (const change of initialChangesToQueue) { + const hollowChange = change.clone() + await hollowChange.loadFiles('hollow', blobStore) + hollowChange.applyTo(initialSnapshot, { strict: true }) + } + + // Now prepare some subsequent changes for the queue + await blobStore.putFile(testFiles.path('graph.png')) + const addFileOp = new AddFileOperation( + 'graph.png', + File.fromHash(testFiles.GRAPH_PNG_HASH) + ) + const addFileChange = new Change([addFileOp], new Date(), []) + const editFileOp = new EditFileOperation( + 'existing.tex', + new TextOperation() + .insert('Hello') + .retain(testFiles.HELLO_TXT_UTF8_LENGTH) + ) + const editFileChange = new Change([editFileOp], new Date(), []) + + const subsequentChangesToQueue = [addFileChange, editFileChange] + const versionBeforeSubsequentQueue = versionAfterInitialQueue + + // Queue the subsequent changes + const subsequentStatus = await queueChanges( + projectId, + subsequentChangesToQueue, + versionBeforeSubsequentQueue + ) + expect(subsequentStatus).to.equal('ok') + + // Compute the expected snapshot after applying all changes + const expectedSnapshot = initialSnapshot.clone() + await expectedSnapshot.loadFiles('hollow', blobStore) + for (const change of subsequentChangesToQueue) { + const hollowChange = change.clone() + await hollowChange.loadFiles('hollow', blobStore) + hollowChange.applyTo(expectedSnapshot, { strict: true }) + } + + // Confirm that state in redis matches the expected snapshot and changes queue + const finalRedisState = await redisBackend.getState(projectId) + expect(finalRedisState).to.not.be.null + const expectedFinalVersion = + versionBeforeSubsequentQueue + subsequentChangesToQueue.length + expect(finalRedisState.headVersion).to.equal(expectedFinalVersion) + expect(finalRedisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw()) + const allQueuedChangesRaw = initialChangesToQueue + .concat(subsequentChangesToQueue) + .map(c => c.toRaw()) + expect(finalRedisState.changes).to.deep.equal(allQueuedChangesRaw) + }) + + it('skips queuing changes when there is no snapshot and the onlyIfExists flag is set', async function () { + // Start with an empty chunk store for the project + const projectId = fixtures.docs.uninitializedProject.id + await chunkStore.initializeProject(projectId) + + // Ensure that the initial state in redis is empty + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState.headVersion).to.be.null + expect(initialRedisState.headSnapshot).to.be.null + expect(initialRedisState.changes).to.be.an('array').that.is.empty + + // Add a test file to the blob store + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + + // Prepare an initial change to add a single file to an empty project + const change = new Change( + [ + new AddFileOperation( + 'test.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ), + ], + new Date(), + [] + ) + const changesToQueue = [change] + const endVersion = 0 + + // Queue the changes to add the test file + const status = await queueChanges(projectId, changesToQueue, endVersion, { + onlyIfExists: true, + }) + expect(status).to.equal('ignore') + + // Verify that the state in redis has not changed + const redisState = await redisBackend.getState(projectId) + expect(redisState).to.deep.equal(initialRedisState) + }) + + it('creates an initial hollow snapshot when redis has no snapshot (falls back to chunkStore with an empty chunk)', async function () { + // Start with an empty chunk store for the project + const projectId = fixtures.docs.uninitializedProject.id + await chunkStore.initializeProject(projectId) + const blobStore = new BlobStore(projectId) + await blobStore.putFile(testFiles.path('hello.txt')) + + // Prepare an initial change to add a single file to an empty project + const change = new Change( + [ + new AddFileOperation( + 'test.tex', + File.fromHash(testFiles.HELLO_TXT_HASH) + ), + ], + new Date(), + [] + ) + const changesToQueue = [change] + const endVersion = 0 + + // Queue the changes to add the test file + const status = await queueChanges(projectId, changesToQueue, endVersion) + expect(status).to.equal('ok') + + // Verify that we now have some state in redis + const redisState = await redisBackend.getState(projectId) + expect(redisState).to.not.be.null + expect(redisState.headSnapshot.files['test.tex']).to.deep.equal({ + stringLength: testFiles.HELLO_TXT_UTF8_LENGTH, + }) + }) + + it('throws ConflictingEndVersion if endVersion does not match current version (from chunkStore)', async function () { + const projectId = fixtures.docs.uninitializedProject.id + // Initialise an empty project in the chunk store + await chunkStore.initializeProject(projectId) + + // Ensure that the initial state in redis is empty + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState.headVersion).to.be.null + + // Prepare a change to add a file + const change = new Change( + [new AddFileOperation('test.tex', File.fromString(''))], + new Date(), + [] + ) + const changesToQueue = [change] + const incorrectEndVersion = 1 + + // Attempt to queue the changes with an incorrect endVersion (1 instead of 0) + await expect(queueChanges(projectId, changesToQueue, incorrectEndVersion)) + .to.be.rejectedWith(Chunk.ConflictingEndVersion) + .and.eventually.satisfies(err => { + expect(err.info).to.have.property( + 'clientEndVersion', + incorrectEndVersion + ) + expect(err.info).to.have.property('latestEndVersion', 0) + return true + }) + + // Verify that the state in redis has not changed + const redisStateAfterError = await redisBackend.getState(projectId) + expect(redisStateAfterError).to.deep.equal(initialRedisState) + }) + + it('throws ConflictingEndVersion if endVersion does not match current version (from redis snapshot)', async function () { + const projectId = fixtures.docs.uninitializedProject.id + + // Initialise the project in the redis with a test file + await chunkStore.initializeProject(projectId) + const initialChange = new Change( + [new AddFileOperation('initial.tex', File.fromString('content'))], + new Date(), + [] + ) + const initialChangesToQueue = [initialChange] + const versionBeforeInitialQueue = 0 + + // Queue the initial changes + await queueChanges( + projectId, + initialChangesToQueue, + versionBeforeInitialQueue + ) + const versionInRedisAfterSetup = + versionBeforeInitialQueue + initialChangesToQueue.length + + // Confirm that the initial changes were queued successfully + const initialRedisState = await redisBackend.getState(projectId) + expect(initialRedisState).to.not.be.null + expect(initialRedisState.headVersion).to.equal(versionInRedisAfterSetup) + + // Now prepare a subsequent change for the queue + const subsequentChange = new Change( + [new AddFileOperation('another.tex', File.fromString(''))], + new Date(), + [] + ) + const subsequentChangesToQueue = [subsequentChange] + const incorrectEndVersion = 0 + + // Attempt to queue the changes with an incorrect endVersion (0 instead of 1) + await expect( + queueChanges(projectId, subsequentChangesToQueue, incorrectEndVersion) + ) + .to.be.rejectedWith(Chunk.ConflictingEndVersion) + .and.eventually.satisfies(err => { + expect(err.info).to.have.property( + 'clientEndVersion', + incorrectEndVersion + ) + expect(err.info).to.have.property( + 'latestEndVersion', + versionInRedisAfterSetup + ) + return true + }) + + // Verify that the state in redis has not changed + const redisStateAfterError = await redisBackend.getState(projectId) + expect(redisStateAfterError).to.not.be.null + expect(redisStateAfterError).to.deep.equal(initialRedisState) + }) +}) From 2d0706591bddce9539ac990006121da680834d69 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:41:55 +0100 Subject: [PATCH 145/259] Merge pull request #26219 from overleaf/bg-history-redis-fix-loadAtTimestamp correct startVersion calculation in loadAtTimestamp GitOrigin-RevId: ad46aae47c0769943e787199d68e895cf139bb56 --- services/history-v1/storage/lib/chunk_store/index.js | 3 ++- .../test/acceptance/js/storage/chunk_store.test.js | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index 6dab84f929..53b8fb8245 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -190,6 +190,7 @@ async function loadAtTimestamp(projectId, timestamp, opts = {}) { const chunkRecord = await backend.getChunkForTimestamp(projectId, timestamp) const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) const history = History.fromRaw(rawHistory) + const startVersion = chunkRecord.endVersion - history.countChanges() if (!opts.persistedOnly) { const nonPersistedChanges = await getChunkExtension( @@ -200,7 +201,7 @@ async function loadAtTimestamp(projectId, timestamp, opts = {}) { } await lazyLoadHistoryFiles(history, batchBlobStore) - return new Chunk(history, chunkRecord.endVersion - history.countChanges()) + return new Chunk(history, startVersion) } /** diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js index da70467934..bc2bae4660 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js @@ -509,6 +509,12 @@ describe('chunkStore', function () { .getChanges() .concat(queuedChanges) expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + thirdChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal( + thirdChunk.getEndVersion() + queuedChanges.length + ) }) it("doesn't include the queued changes when getting another chunk by timestamp", async function () { From c81cc4055e3bf325a2704f9dccfccd9971e66aa4 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:42:08 +0100 Subject: [PATCH 146/259] Merge pull request #26220 from overleaf/bg-history-redis-fix-loadAtVersion-startVersion correct startVersion calculation in loadAtVersion GitOrigin-RevId: b81c30dcab90b137169a4bddef3c22f44a957f68 --- .../storage/lib/chunk_store/index.js | 3 ++- .../acceptance/js/storage/chunk_store.test.js | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index 53b8fb8245..cbdf3d2dcf 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -157,6 +157,7 @@ async function loadAtVersion(projectId, version, opts = {}) { }) const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) const history = History.fromRaw(rawHistory) + const startVersion = chunkRecord.endVersion - history.countChanges() if (!opts.persistedOnly) { const nonPersistedChanges = await getChunkExtension( @@ -167,7 +168,7 @@ async function loadAtVersion(projectId, version, opts = {}) { } await lazyLoadHistoryFiles(history, batchBlobStore) - return new Chunk(history, chunkRecord.endVersion - history.countChanges()) + return new Chunk(history, startVersion) } /** diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js index bc2bae4660..c6c33404d6 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js @@ -498,6 +498,12 @@ describe('chunkStore', function () { .getChanges() .concat(queuedChanges) expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + thirdChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal( + thirdChunk.getEndVersion() + queuedChanges.length + ) }) it('includes the queued changes when getting the latest chunk by timestamp', async function () { @@ -524,6 +530,10 @@ describe('chunkStore', function () { ) const expectedChanges = secondChunk.getChanges() expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + secondChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal(secondChunk.getEndVersion()) }) it('includes the queued changes when getting the latest chunk by version', async function () { @@ -535,6 +545,12 @@ describe('chunkStore', function () { .getChanges() .concat(queuedChanges) expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + thirdChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal( + thirdChunk.getEndVersion() + queuedChanges.length + ) }) it("doesn't include the queued changes when getting another chunk by version", async function () { @@ -544,6 +560,10 @@ describe('chunkStore', function () { ) const expectedChanges = secondChunk.getChanges() expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + secondChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal(secondChunk.getEndVersion()) }) }) From fec6dde00fac4f75096c3d4d0b73b5baf681b40f Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:42:18 +0100 Subject: [PATCH 147/259] Merge pull request #26203 from overleaf/bg-history-redis-fix-loadAtVersion Extend loadAtVersion to handle nonpersisted versions GitOrigin-RevId: 22060605ea7bb89a8d4d61bafab8f63b94d59067 --- .../storage/lib/chunk_store/index.js | 30 +++++++++- .../acceptance/js/storage/chunk_store.test.js | 56 ++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index cbdf3d2dcf..286a8d8764 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -151,20 +151,44 @@ async function loadAtVersion(projectId, version, opts = {}) { const backend = getBackend(projectId) const blobStore = new BlobStore(projectId) const batchBlobStore = new BatchBlobStore(blobStore) + const latestChunkMetadata = await getLatestChunkMetadata(projectId) - const chunkRecord = await backend.getChunkForVersion(projectId, version, { - preferNewer: opts.preferNewer, - }) + // When loading a chunk for a version there are three cases to consider: + // 1. If `persistedOnly` is true, we always use the requested version + // to fetch the chunk. + // 2. If `persistedOnly` is false and the requested version is in the + // persisted chunk version range, we use the requested version. + // 3. If `persistedOnly` is false and the requested version is ahead of + // the persisted chunk versions, we fetch the latest chunk and see if + // the non-persisted changes include the requested version. + const targetChunkVersion = opts.persistedOnly + ? version + : Math.min(latestChunkMetadata.endVersion, version) + + const chunkRecord = await backend.getChunkForVersion( + projectId, + targetChunkVersion, + { + preferNewer: opts.preferNewer, + } + ) const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) const history = History.fromRaw(rawHistory) const startVersion = chunkRecord.endVersion - history.countChanges() if (!opts.persistedOnly) { + // Try to extend the chunk with any non-persisted changes that + // follow the chunk's end version. const nonPersistedChanges = await getChunkExtension( projectId, chunkRecord.endVersion ) history.pushChanges(nonPersistedChanges) + + // Check that the changes do actually contain the requested version + if (version > chunkRecord.endVersion + nonPersistedChanges.length) { + throw new Chunk.VersionNotFoundError(projectId, version) + } } await lazyLoadHistoryFiles(history, batchBlobStore) diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js index c6c33404d6..8b06b8e412 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js @@ -470,6 +470,8 @@ describe('chunkStore', function () { describe('with changes queued in the Redis buffer', function () { let queuedChanges + const firstQueuedChangeTimestamp = new Date('2017-01-01T00:01:00') + const lastQueuedChangeTimestamp = new Date('2017-01-01T00:02:00') beforeEach(async function () { const snapshot = thirdChunk.getSnapshot() @@ -481,7 +483,15 @@ describe('chunkStore', function () { 'in-redis.tex', File.createLazyFromBlobs(blob) ), - new Date() + firstQueuedChangeTimestamp + ), + makeChange( + // Add a second change to make the buffer more interesting + Operation.editFile( + 'in-redis.tex', + TextOperation.fromJSON({ textOperation: ['hello'] }) + ), + lastQueuedChangeTimestamp ), ] await redisBackend.queueChanges( @@ -504,6 +514,9 @@ describe('chunkStore', function () { expect(chunk.getEndVersion()).to.equal( thirdChunk.getEndVersion() + queuedChanges.length ) + expect(chunk.getEndTimestamp()).to.deep.equal( + lastQueuedChangeTimestamp + ) }) it('includes the queued changes when getting the latest chunk by timestamp', async function () { @@ -534,6 +547,7 @@ describe('chunkStore', function () { secondChunk.getStartVersion() ) expect(chunk.getEndVersion()).to.equal(secondChunk.getEndVersion()) + expect(chunk.getEndTimestamp()).to.deep.equal(secondChunkTimestamp) }) it('includes the queued changes when getting the latest chunk by version', async function () { @@ -551,6 +565,9 @@ describe('chunkStore', function () { expect(chunk.getEndVersion()).to.equal( thirdChunk.getEndVersion() + queuedChanges.length ) + expect(chunk.getEndTimestamp()).to.deep.equal( + lastQueuedChangeTimestamp + ) }) it("doesn't include the queued changes when getting another chunk by version", async function () { @@ -564,6 +581,43 @@ describe('chunkStore', function () { secondChunk.getStartVersion() ) expect(chunk.getEndVersion()).to.equal(secondChunk.getEndVersion()) + expect(chunk.getEndTimestamp()).to.deep.equal(secondChunkTimestamp) + }) + + it('loads a version that is only in the Redis buffer', async function () { + const versionInRedis = thirdChunk.getEndVersion() + 1 // the first change in Redis + const chunk = await chunkStore.loadAtVersion( + projectId, + versionInRedis + ) + // The chunk should contain changes from the thirdChunk and the queuedChanges + const expectedChanges = thirdChunk + .getChanges() + .concat(queuedChanges) + expect(chunk.getChanges()).to.deep.equal(expectedChanges) + expect(chunk.getStartVersion()).to.equal( + thirdChunk.getStartVersion() + ) + expect(chunk.getEndVersion()).to.equal( + thirdChunk.getEndVersion() + queuedChanges.length + ) + expect(chunk.getEndTimestamp()).to.deep.equal( + lastQueuedChangeTimestamp + ) + }) + + it('throws an error when loading a version beyond the Redis buffer', async function () { + const versionBeyondRedis = + thirdChunk.getEndVersion() + queuedChanges.length + 1 + await expect( + chunkStore.loadAtVersion(projectId, versionBeyondRedis) + ) + .to.be.rejectedWith(chunkStore.VersionOutOfBoundsError) + .and.eventually.satisfy(err => { + expect(err.info).to.have.property('projectId', projectId) + expect(err.info).to.have.property('version', versionBeyondRedis) + return true + }) }) }) From 2a833aa23a007eaa9dbb3ab2d667a11999f5f800 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 10:42:28 +0100 Subject: [PATCH 148/259] Merge pull request #26250 from overleaf/bg-history-redis-add-return-value-to-persistBuffer provide return value from persistBuffer GitOrigin-RevId: ba52ff42b91ffe9adc23ab0461fa836540735563 --- .../history-v1/storage/lib/persist_buffer.js | 14 ++- .../js/storage/persist_buffer.test.mjs | 96 +++++++++++++++++-- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/services/history-v1/storage/lib/persist_buffer.js b/services/history-v1/storage/lib/persist_buffer.js index 1f508c43f3..9534e5834a 100644 --- a/services/history-v1/storage/lib/persist_buffer.js +++ b/services/history-v1/storage/lib/persist_buffer.js @@ -58,7 +58,17 @@ async function persistBuffer(projectId, limits) { // to match the current endVersion. This shouldn't be needed // unless a worker failed to update the persisted version. await redisBackend.setPersistedVersion(projectId, endVersion) - return + const { chunk } = await chunkStore.loadByChunkRecord( + projectId, + latestChunkMetadata + ) + // Return the result in the same format as persistChanges + // so that the caller can handle it uniformly. + return { + numberOfChangesPersisted: changesToPersist.length, + originalEndVersion: endVersion, + currentChunk: chunk, + } } logger.debug( @@ -160,6 +170,8 @@ async function persistBuffer(projectId, limits) { { projectId, finalPersistedVersion: newEndVersion }, 'persistBuffer operation completed successfully' ) + + return persistResult } module.exports = persistBuffer diff --git a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs index 216399f676..138a70e626 100644 --- a/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/persist_buffer.test.mjs @@ -92,15 +92,34 @@ describe('persistBuffer', function () { await redisBackend.setPersistedVersion(projectId, initialVersion) // Persist the changes from Redis to the chunk store - await persistBuffer(projectId, limitsToPersistImmediately) + const persistResult = await persistBuffer( + projectId, + limitsToPersistImmediately + ) - const latestChunk = await chunkStore.loadLatest(projectId) + // Check the return value of persistBuffer + expect(persistResult).to.exist + expect(persistResult).to.have.property('numberOfChangesPersisted') + expect(persistResult).to.have.property('originalEndVersion') + expect(persistResult).to.have.property('currentChunk') + expect(persistResult).to.have.property('resyncNeeded') + expect(persistResult.numberOfChangesPersisted).to.equal( + changesToQueue.length + ) + expect(persistResult.originalEndVersion).to.equal(initialVersion + 1) + expect(persistResult.resyncNeeded).to.be.false + + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) expect(latestChunk).to.exist expect(latestChunk.getStartVersion()).to.equal(initialVersion) expect(latestChunk.getEndVersion()).to.equal(finalHeadVersion) expect(latestChunk.getChanges().length).to.equal( changesToQueue.length + 1 ) + // Check that chunk returned by persistBuffer matches the latest chunk + expect(latestChunk).to.deep.equal(persistResult.currentChunk) const chunkSnapshot = latestChunk.getSnapshot() expect(Object.keys(chunkSnapshot.getFileMap()).length).to.equal(1) @@ -196,9 +215,28 @@ describe('persistBuffer', function () { persistedChunkEndVersion ) - await persistBuffer(projectId, limitsToPersistImmediately) + const persistResult = await persistBuffer( + projectId, + limitsToPersistImmediately + ) - const latestChunk = await chunkStore.loadLatest(projectId) + // Check the return value of persistBuffer + expect(persistResult).to.exist + expect(persistResult).to.have.property('numberOfChangesPersisted') + expect(persistResult).to.have.property('originalEndVersion') + expect(persistResult).to.have.property('currentChunk') + expect(persistResult).to.have.property('resyncNeeded') + expect(persistResult.numberOfChangesPersisted).to.equal( + redisChangesToPush.length + ) + expect(persistResult.originalEndVersion).to.equal( + persistedChunkEndVersion + ) + expect(persistResult.resyncNeeded).to.be.false + + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) expect(latestChunk).to.exist expect(latestChunk.getStartVersion()).to.equal(0) expect(latestChunk.getEndVersion()).to.equal( @@ -215,6 +253,9 @@ describe('persistBuffer', function () { finalHeadVersionAfterRedisPush ) + // Check that chunk returned by persistBuffer matches the latest chunk + expect(persistResult.currentChunk).to.deep.equal(latestChunk) + const nonPersisted = await redisBackend.getNonPersistedChanges( projectId, finalHeadVersionAfterRedisPush @@ -287,8 +328,19 @@ describe('persistBuffer', function () { const chunksBefore = await chunkStore.getProjectChunks(projectId) - // Persist buffer (which should do nothing as there are no new changes) - await persistBuffer(projectId, limitsToPersistImmediately) + const persistResult = await persistBuffer( + projectId, + limitsToPersistImmediately + ) + + const currentChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) + expect(persistResult).to.deep.equal({ + numberOfChangesPersisted: 0, + originalEndVersion: persistedChunkEndVersion, + currentChunk, + }) const chunksAfter = await chunkStore.getProjectChunks(projectId) expect(chunksAfter.length).to.equal(chunksBefore.length) @@ -324,7 +376,20 @@ describe('persistBuffer', function () { const chunksBefore = await chunkStore.getProjectChunks(projectId) // Persist buffer (which should do nothing as there are no new changes) - await persistBuffer(projectId, limitsToPersistImmediately) + const persistResult = await persistBuffer( + projectId, + limitsToPersistImmediately + ) + + // Check the return value + const currentChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) + expect(persistResult).to.deep.equal({ + numberOfChangesPersisted: 0, + originalEndVersion: persistedChunkEndVersion, + currentChunk, + }) const chunksAfter = await chunkStore.getProjectChunks(projectId) expect(chunksAfter.length).to.equal(chunksBefore.length) @@ -411,7 +476,19 @@ describe('persistBuffer', function () { maxChangeTimestamp: new Date(twoHoursAgo), // they will be persisted if any change is older than 2 hours } - await persistBuffer(projectId, restrictiveLimits) + const persistResult = await persistBuffer(projectId, restrictiveLimits) + + // Check the return value of persistBuffer + expect(persistResult).to.exist + expect(persistResult).to.have.property('numberOfChangesPersisted') + expect(persistResult).to.have.property('originalEndVersion') + expect(persistResult).to.have.property('currentChunk') + expect(persistResult).to.have.property('resyncNeeded') + expect(persistResult.numberOfChangesPersisted).to.equal(2) // change1 + change2 + expect(persistResult.originalEndVersion).to.equal( + versionAfterInitialSetup + ) + expect(persistResult.resyncNeeded).to.be.false // Check the latest persisted chunk, it should only have the initial file and the first two changes const latestChunk = await chunkStore.loadLatest(projectId, { @@ -423,6 +500,9 @@ describe('persistBuffer', function () { const expectedEndVersion = versionAfterInitialSetup + 2 // Persisted two changes from the queue expect(latestChunk.getEndVersion()).to.equal(expectedEndVersion) + // Check that chunk returned by persistBuffer matches the latest chunk + expect(persistResult.currentChunk).to.deep.equal(latestChunk) + // Check persisted version in Redis const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.equal(expectedEndVersion) From fdd0d955547c21beab963671018855cb39af239d Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 11:46:18 +0100 Subject: [PATCH 149/259] Merge pull request #26293 from overleaf/bg-history-redis-fix-persist-worker add missing load global blobs from persist worker GitOrigin-RevId: ae9393f2353fb4d5afe349aa7d0a26bab80c7f53 --- services/history-v1/storage/scripts/persist_redis_chunks.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/history-v1/storage/scripts/persist_redis_chunks.js b/services/history-v1/storage/scripts/persist_redis_chunks.js index 414fbf3458..20963ba90f 100644 --- a/services/history-v1/storage/scripts/persist_redis_chunks.js +++ b/services/history-v1/storage/scripts/persist_redis_chunks.js @@ -7,6 +7,11 @@ const { client } = require('../lib/mongodb.js') const { scanAndProcessDueItems } = require('../lib/scan') const persistBuffer = require('../lib/persist_buffer') const { claimPersistJob } = require('../lib/chunk_store/redis') +const { loadGlobalBlobs } = require('../lib/blob_store/index.js') + +// Something is registering 11 listeners, over the limit of 10, which generates +// a lot of warning noise. +require('node:events').EventEmitter.defaultMaxListeners = 11 const rclient = redis.rclientHistory @@ -33,6 +38,7 @@ async function persistProjectAction(projectId) { } async function runPersistChunks() { + await loadGlobalBlobs() await scanAndProcessDueItems( rclient, 'persistChunks', From 7c23655c7927f5dc8e8022a185a0be3da87d705d Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 10 Jun 2025 13:26:28 +0100 Subject: [PATCH 150/259] Merge pull request #26177 from overleaf/mj-ide-history-file-tree [web] Editor redesign: Update history view file tree GitOrigin-RevId: bb0fe871837ffac6e1af6c18c7c1ae651dee7f81 --- .../file-tree/history-file-tree-doc.tsx | 22 ++++--- .../file-tree/history-file-tree-folder.tsx | 18 ++++-- .../bootstrap-5/pages/editor/history.scss | 58 ++++++++++++++++++- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx index 3b788eb046..e3543ef527 100644 --- a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx +++ b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-doc.tsx @@ -1,9 +1,12 @@ import { memo } from 'react' import classNames from 'classnames' import HistoryFileTreeItem from './history-file-tree-item' -import iconTypeFromName from '../../../file-tree/util/icon-type-from-name' +import iconTypeFromName, { + newEditorIconTypeFromName, +} from '../../../file-tree/util/icon-type-from-name' import type { FileDiff } from '../../services/types/file' import MaterialIcon from '@/shared/components/material-icon' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' type HistoryFileTreeDocProps = { file: FileDiff @@ -20,6 +23,16 @@ function HistoryFileTreeDoc({ onClick, onKeyDown, }: HistoryFileTreeDocProps) { + const newEditor = useIsNewEditorEnabled() + const icon = newEditor ? ( + + ) : ( + + ) return (
  • - } + icons={icon} />
  • ) diff --git a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx index 6c2c912f8c..44cb7f2921 100644 --- a/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx +++ b/services/web/frontend/js/features/history/components/file-tree/history-file-tree-folder.tsx @@ -6,6 +6,7 @@ import HistoryFileTreeFolderList from './history-file-tree-folder-list' import type { HistoryDoc, HistoryFileTree } from '../../utils/file-tree' import MaterialIcon from '@/shared/components/material-icon' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' type HistoryFileTreeFolderProps = { name: string @@ -35,6 +36,7 @@ function HistoryFileTreeFolder({ docs, }: HistoryFileTreeFolderProps) { const { t } = useTranslation() + const newEditor = useIsNewEditorEnabled() const [expanded, setExpanded] = useState(() => { return hasChanges({ name, folders, docs }) @@ -52,10 +54,12 @@ function HistoryFileTreeFolder({ className="file-tree-expand-icon" /> - + {!newEditor && ( + + )} ) @@ -79,7 +83,11 @@ function HistoryFileTreeFolder({ {expanded ? ( - + ) : null} ) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss index 1caeb22c1d..1a73840fb4 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss @@ -1,4 +1,5 @@ :root { + --history-react-icon-color: var(--content-disabled); --history-react-header-bg: var(--bg-dark-secondary); --history-react-header-color: var(--content-primary-dark); --history-react-separator-color: var(--border-divider-dark); @@ -10,6 +11,61 @@ --history-react-separator-color: var(--border-divider); } +.ide-redesign-main { + --history-react-header-bg: var(--bg-primary-themed); + --history-react-header-color: var(--content-primary-themed); + --history-react-icon-color: var(--file-tree-item-color); + + .history-file-tree { + ul.history-file-tree-list { + padding: var(--spacing-02); + + .history-file-tree-item > ul, + ul[role='tree'] { + border-left: 1px solid + color-mix(in srgb, var(--border-primary-themed) 24%, transparent); + margin-left: 14px !important; + margin-top: 0; + } + + li { + padding: var(--spacing-02); + padding-right: 0; + margin-left: 0; + } + + .history-file-tree-item { + border-radius: var(--border-radius-base); + + .history-file-tree-item-name-wrapper { + .history-file-tree-item-badge { + margin-right: var(--spacing-02); + } + } + + &::before { + display: none; + } + + .material-symbols { + &.file-tree-expand-icon { + margin-left: 0; + vertical-align: middle; + } + + &.file-tree-icon { + margin-left: 0; + } + } + } + } + } + + ul[role='tree'].history-file-tree-list-inner { + padding-left: 10px; + } +} + history-root { height: 100%; display: block; @@ -510,7 +566,7 @@ history-root { } .material-symbols { - color: var(--content-disabled); + color: var(--history-react-icon-color); &.file-tree-icon { margin-right: var(--spacing-02); From 0397b022145a43a933133c7f289694f10a9b3031 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 10 Jun 2025 13:26:38 +0100 Subject: [PATCH 151/259] Merge pull request #26221 from overleaf/mj-history-dark-mode-entries [web] Editor redesign: Add dark mode to history entries GitOrigin-RevId: 16c9743bdee85dc3825ce6e9901a0107956205ca --- .../bootstrap-5/pages/editor/history.scss | 91 ++++++++++++++----- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss index 1a73840fb4..db59639cd1 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss @@ -3,18 +3,53 @@ --history-react-header-bg: var(--bg-dark-secondary); --history-react-header-color: var(--content-primary-dark); --history-react-separator-color: var(--border-divider-dark); + --history-change-list-bg: var(--bg-light-primary); + --history-change-entry-color: var(--content-primary); + --history-change-entry-metadata-color: var(--content-secondary); + --history-change-list-divider: var(--border-divider); + --history-change-entry-hover-bg: var(--bg-light-secondary); + --history-loading-bg: var(--bg-light-secondary); + --history-change-entry-dropdown-button-bg: rgb(var(--bg-dark-primary) 0.08); + --history-change-entry-border-color: var(--green-50); + --history-change-entry-within-selected-bg: var(--bg-light-secondary); + --history-change-entry-within-selected-hover-bg: rgb($neutral-90, 8%); + --history-change-list-gradient: linear-gradient(black 35%, transparent); + --history-change-entry-selected-bg: var(--bg-accent-03); + --history-change-entry-selected-hover-bg: rgb($green-70, 16%); } @include theme('light') { --history-react-header-bg: var(--bg-light-primary); --history-react-header-color: var(--content-primary); --history-react-separator-color: var(--border-divider); + + .ide-redesign-main { + --history-change-entry-within-selected-bg: var(--bg-light-secondary); + --history-change-entry-within-selected-hover-bg: rgb(var(--neutral-90) 8%); + --history-change-entry-selected-bg: var(--bg-accent-03); + --history-change-entry-selected-hover-bg: rgb(var(--green-70 0.16)); + } } .ide-redesign-main { --history-react-header-bg: var(--bg-primary-themed); --history-react-header-color: var(--content-primary-themed); --history-react-icon-color: var(--file-tree-item-color); + --history-loading-bg: var(--bg-secondary-themed); + --history-change-list-gradient: linear-gradient(black 35%, transparent); + --history-change-list-bg: var(--bg-primary-themed); + --history-change-list-divider: var(--border-divider-themed); + --history-change-entry-metadata-color: var(--content-secondary-themed); + --history-change-entry-color: var(--content-primary-themed); + --history-change-entry-hover-bg: var(--bg-secondary-themed); + --history-change-entry-border-color: var(--green-50); + --history-change-entry-dropdown-button-bg: rgb(var(--bg-dark-primary) 0.08); + + // Dark mode specific variables + --history-change-entry-within-selected-bg: var(--neutral-80); + --history-change-entry-within-selected-hover-bg: rgb(var(--white) 0.08); + --history-change-entry-selected-bg: var(--green-70); + --history-change-entry-selected-hover-bg: rgb(var(--green-60) 0.08); .history-file-tree { ul.history-file-tree-list { @@ -83,7 +118,7 @@ history-root { display: flex; justify-content: center; height: 100%; - background-color: var(--bg-light-primary); + background-color: var(--history-change-list-bg); .history-header { @include body-sm; @@ -163,7 +198,7 @@ history-root { } .history-version-day { - background-color: white; + background-color: var(--history-change-list-bg); position: sticky; z-index: 1; top: 0; @@ -183,51 +218,56 @@ history-root { cursor: pointer; &:hover { - background-color: var(--bg-light-secondary); + background-color: var(--history-change-entry-hover-bg); } } &.history-version-selected { - background-color: var(--bg-accent-03); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-selected-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); padding-left: calc( var(--history-change-list-padding) - var(--spacing-02) ); } &.history-version-selected.history-version-selectable:hover { - background-color: rgb($green-70, 16%); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-selected-hover-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); } &.history-version-within-selected { - background-color: var(--bg-light-secondary); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-within-selected-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); } &.history-version-within-selected:hover { - background-color: rgb($neutral-90, 8%); + background-color: var(--history-change-entry-within-selected-hover-bg); } } .history-version-main-details { - color: var(--content-primary); + color: var(--history-change-entry-color); } .version-element-within-selected { - background-color: var(--bg-light-secondary); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-within-selected-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); } .version-element-selected { - background-color: var(--bg-accent-03); - border-left: var(--spacing-02) solid var(--green-50); + background-color: var(--history-change-entry-selected-bg); + border-left: var(--spacing-02) solid + var(--history-change-entry-border-color); } .history-version-metadata-time { display: block; margin-bottom: var(--spacing-02); - color: var(--content-primary); + color: var(--history-change-entry-color); &:last-child { margin-bottom: initial; @@ -282,7 +322,7 @@ history-root { .history-version-metadata-users, .history-version-origin, .history-version-saved-by { - color: var(--content-secondary); + color: var(--history-change-entry-metadata-color); } .history-version-change-action { @@ -290,7 +330,7 @@ history-root { } .history-version-change-doc { - color: var(--content-primary); + color: var(--history-change-entry-color); overflow-wrap: anywhere; white-space: pre-wrap; } @@ -301,7 +341,7 @@ history-root { .history-version-divider { margin: 0; - border-color: var(--border-divider); + border-color: var(--history-change-list-divider); } .history-version-badge { @@ -332,7 +372,7 @@ history-root { position: sticky; bottom: 0; padding: var(--spacing-05) 0; - background-color: var(--bg-light-secondary); + background-color: var(--history-loading-bg); text-align: center; } @@ -344,7 +384,7 @@ history-root { .dropdown.open { .history-version-dropdown-menu-btn { - background-color: rgb(var(--bg-dark-primary) 0.08); + background-color: var(--history-change-entry-dropdown-button-bg); box-shadow: initial; } } @@ -354,6 +394,7 @@ history-root { @include reset-button; @include action-button; + color: var(--history-change-entry-color); padding: 0; width: 30px; height: 30px; @@ -400,7 +441,7 @@ history-root { .history-version-faded .history-version-details { max-height: 6em; - @include mask-image(linear-gradient(black 35%, transparent)); + @include mask-image(var(--history-change-list-gradient)); overflow: hidden; } @@ -470,7 +511,7 @@ history-root { } .history-dropdown-icon { - color: var(--content-primary); + color: var(--history-change-entry-color); } .history-dropdown-icon-inverted { @@ -605,3 +646,7 @@ history-root { .history-error { padding: var(--spacing-06); } + +.doc-container { + background: var(--bg-light-primary); +} From 25c3699862ede6ca975ae90f9b747ad25e2df46e Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 10 Jun 2025 15:21:00 +0200 Subject: [PATCH 152/259] [docstore] finish async/await migration (#26295) * [docstore] DocManager.getDocLines returns flat content * [docstore] peekDoc throws NotFoundError, skip check in HttpController * [docstore] getFullDoc throws NotFoundError, skip check in HttpController * [docstore] migrate HealthChecker to async/await * [docstore] migrate HttpController to async/await * [docstore] remove .promises/callbackify wrapper from all the modules GitOrigin-RevId: a9938b03cdd2b5e80c2c999039e8f63b20d59dc5 --- package-lock.json | 1 + services/docstore/app/js/DocArchiveManager.js | 31 +- services/docstore/app/js/DocManager.js | 50 ++- services/docstore/app/js/Errors.js | 3 + services/docstore/app/js/HealthChecker.js | 84 ++--- services/docstore/app/js/HttpController.js | 288 +++++++----------- services/docstore/app/js/MongoManager.js | 44 +-- services/docstore/app/js/StreamToBuffer.js | 6 +- services/docstore/package.json | 1 + .../test/acceptance/js/HealthCheckerTest.js | 28 ++ .../acceptance/js/helpers/DocstoreClient.js | 7 + .../test/unit/js/DocArchiveManagerTests.js | 212 ++++++------- .../docstore/test/unit/js/DocManagerTests.js | 224 +++++++------- .../test/unit/js/HttpControllerTests.js | 159 +++++----- .../test/unit/js/MongoManagerTests.js | 40 ++- 15 files changed, 534 insertions(+), 644 deletions(-) create mode 100644 services/docstore/test/acceptance/js/HealthCheckerTest.js diff --git a/package-lock.json b/package-lock.json index c0967e0977..ce75c110c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42871,6 +42871,7 @@ "services/docstore": { "name": "@overleaf/docstore", "dependencies": { + "@overleaf/fetch-utils": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/o-error": "*", diff --git a/services/docstore/app/js/DocArchiveManager.js b/services/docstore/app/js/DocArchiveManager.js index 4390afe18f..d332a27651 100644 --- a/services/docstore/app/js/DocArchiveManager.js +++ b/services/docstore/app/js/DocArchiveManager.js @@ -1,5 +1,4 @@ -const { callbackify } = require('node:util') -const MongoManager = require('./MongoManager').promises +const MongoManager = require('./MongoManager') const Errors = require('./Errors') const logger = require('@overleaf/logger') const Settings = require('@overleaf/settings') @@ -8,29 +7,12 @@ const { ReadableString } = require('@overleaf/stream-utils') const RangeManager = require('./RangeManager') const PersistorManager = require('./PersistorManager') const pMap = require('p-map') -const { streamToBuffer } = require('./StreamToBuffer').promises +const { streamToBuffer } = require('./StreamToBuffer') const { BSON } = require('mongodb-legacy') const PARALLEL_JOBS = Settings.parallelArchiveJobs const UN_ARCHIVE_BATCH_SIZE = Settings.unArchiveBatchSize -module.exports = { - archiveAllDocs: callbackify(archiveAllDocs), - archiveDoc: callbackify(archiveDoc), - unArchiveAllDocs: callbackify(unArchiveAllDocs), - unarchiveDoc: callbackify(unarchiveDoc), - destroyProject: callbackify(destroyProject), - getDoc: callbackify(getDoc), - promises: { - archiveAllDocs, - archiveDoc, - unArchiveAllDocs, - unarchiveDoc, - destroyProject, - getDoc, - }, -} - async function archiveAllDocs(projectId) { if (!_isArchivingEnabled()) { return @@ -225,3 +207,12 @@ function _isArchivingEnabled() { return true } + +module.exports = { + archiveAllDocs, + archiveDoc, + unArchiveAllDocs, + unarchiveDoc, + destroyProject, + getDoc, +} diff --git a/services/docstore/app/js/DocManager.js b/services/docstore/app/js/DocManager.js index a9ed99425c..9b80f83eb9 100644 --- a/services/docstore/app/js/DocManager.js +++ b/services/docstore/app/js/DocManager.js @@ -5,7 +5,6 @@ const _ = require('lodash') const DocArchive = require('./DocArchiveManager') const RangeManager = require('./RangeManager') const Settings = require('@overleaf/settings') -const { callbackifyAll } = require('@overleaf/promise-utils') const { setTimeout } = require('node:timers/promises') /** @@ -29,7 +28,7 @@ const DocManager = { throw new Error('must include inS3 when getting doc') } - const doc = await MongoManager.promises.findDoc(projectId, docId, filter) + const doc = await MongoManager.findDoc(projectId, docId, filter) if (doc == null) { throw new Errors.NotFoundError( @@ -38,7 +37,7 @@ const DocManager = { } if (doc.inS3) { - await DocArchive.promises.unarchiveDoc(projectId, docId) + await DocArchive.unarchiveDoc(projectId, docId) return await DocManager._getDoc(projectId, docId, filter) } @@ -46,7 +45,7 @@ const DocManager = { }, async isDocDeleted(projectId, docId) { - const doc = await MongoManager.promises.findDoc(projectId, docId, { + const doc = await MongoManager.findDoc(projectId, docId, { deleted: true, }) @@ -74,7 +73,7 @@ const DocManager = { // returns the doc without any version information async _peekRawDoc(projectId, docId) { - const doc = await MongoManager.promises.findDoc(projectId, docId, { + const doc = await MongoManager.findDoc(projectId, docId, { lines: true, rev: true, deleted: true, @@ -91,7 +90,7 @@ const DocManager = { if (doc.inS3) { // skip the unarchiving to mongo when getting a doc - const archivedDoc = await DocArchive.promises.getDoc(projectId, docId) + const archivedDoc = await DocArchive.getDoc(projectId, docId) Object.assign(doc, archivedDoc) } @@ -102,7 +101,7 @@ const DocManager = { // without unarchiving it (avoids unnecessary writes to mongo) async peekDoc(projectId, docId) { const doc = await DocManager._peekRawDoc(projectId, docId) - await MongoManager.promises.checkRevUnchanged(doc) + await MongoManager.checkRevUnchanged(doc) return doc }, @@ -111,16 +110,18 @@ const DocManager = { lines: true, inS3: true, }) - return doc + if (!doc) throw new Errors.NotFoundError() + if (!Array.isArray(doc.lines)) throw new Errors.DocWithoutLinesError() + return doc.lines.join('\n') }, async getAllDeletedDocs(projectId, filter) { - return await MongoManager.promises.getProjectsDeletedDocs(projectId, filter) + return await MongoManager.getProjectsDeletedDocs(projectId, filter) }, async getAllNonDeletedDocs(projectId, filter) { - await DocArchive.promises.unArchiveAllDocs(projectId) - const docs = await MongoManager.promises.getProjectsDocs( + await DocArchive.unArchiveAllDocs(projectId) + const docs = await MongoManager.getProjectsDocs( projectId, { include_deleted: false }, filter @@ -132,11 +133,7 @@ const DocManager = { }, async projectHasRanges(projectId) { - const docs = await MongoManager.promises.getProjectsDocs( - projectId, - {}, - { _id: 1 } - ) + const docs = await MongoManager.getProjectsDocs(projectId, {}, { _id: 1 }) const docIds = docs.map(doc => doc._id) for (const docId of docIds) { const doc = await DocManager.peekDoc(projectId, docId) @@ -247,7 +244,7 @@ const DocManager = { } modified = true - await MongoManager.promises.upsertIntoDocCollection( + await MongoManager.upsertIntoDocCollection( projectId, docId, doc?.rev, @@ -262,11 +259,7 @@ const DocManager = { async patchDoc(projectId, docId, meta) { const projection = { _id: 1, deleted: true } - const doc = await MongoManager.promises.findDoc( - projectId, - docId, - projection - ) + const doc = await MongoManager.findDoc(projectId, docId, projection) if (!doc) { throw new Errors.NotFoundError( `No such project/doc to delete: ${projectId}/${docId}` @@ -275,7 +268,7 @@ const DocManager = { if (meta.deleted && Settings.docstore.archiveOnSoftDelete) { // The user will not read this doc anytime soon. Flush it out of mongo. - DocArchive.promises.archiveDoc(projectId, docId).catch(err => { + DocArchive.archiveDoc(projectId, docId).catch(err => { logger.warn( { projectId, docId, err }, 'archiving a single doc in the background failed' @@ -283,15 +276,8 @@ const DocManager = { }) } - await MongoManager.promises.patchDoc(projectId, docId, meta) + await MongoManager.patchDoc(projectId, docId, meta) }, } -module.exports = { - ...callbackifyAll(DocManager, { - multiResult: { - updateDoc: ['modified', 'rev'], - }, - }), - promises: DocManager, -} +module.exports = DocManager diff --git a/services/docstore/app/js/Errors.js b/services/docstore/app/js/Errors.js index bbdbe75c08..7b150cc0db 100644 --- a/services/docstore/app/js/Errors.js +++ b/services/docstore/app/js/Errors.js @@ -10,10 +10,13 @@ class DocRevValueError extends OError {} class DocVersionDecrementedError extends OError {} +class DocWithoutLinesError extends OError {} + module.exports = { Md5MismatchError, DocModifiedError, DocRevValueError, DocVersionDecrementedError, + DocWithoutLinesError, ...Errors, } diff --git a/services/docstore/app/js/HealthChecker.js b/services/docstore/app/js/HealthChecker.js index 34cd5c973c..a5b7ad7e9a 100644 --- a/services/docstore/app/js/HealthChecker.js +++ b/services/docstore/app/js/HealthChecker.js @@ -1,67 +1,35 @@ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ const { db, ObjectId } = require('./mongodb') -const request = require('request') -const async = require('async') const _ = require('lodash') const crypto = require('node:crypto') const settings = require('@overleaf/settings') const { port } = settings.internal.docstore const logger = require('@overleaf/logger') +const { fetchNothing, fetchJson } = require('@overleaf/fetch-utils') -module.exports = { - check(callback) { - const docId = new ObjectId() - const projectId = new ObjectId(settings.docstore.healthCheck.project_id) - const url = `http://127.0.0.1:${port}/project/${projectId}/doc/${docId}` - const lines = [ - 'smoke test - delete me', - `${crypto.randomBytes(32).toString('hex')}`, - ] - const getOpts = () => ({ - url, - timeout: 3000, +async function check() { + const docId = new ObjectId() + const projectId = new ObjectId(settings.docstore.healthCheck.project_id) + const url = `http://127.0.0.1:${port}/project/${projectId}/doc/${docId}` + const lines = [ + 'smoke test - delete me', + `${crypto.randomBytes(32).toString('hex')}`, + ] + logger.debug({ lines, url, docId, projectId }, 'running health check') + let body + try { + await fetchNothing(url, { + method: 'POST', + json: { lines, version: 42, ranges: {} }, + signal: AbortSignal.timeout(3_000), }) - logger.debug({ lines, url, docId, projectId }, 'running health check') - const jobs = [ - function (cb) { - const opts = getOpts() - opts.json = { lines, version: 42, ranges: {} } - return request.post(opts, cb) - }, - function (cb) { - const opts = getOpts() - opts.json = true - return request.get(opts, function (err, res, body) { - if (err != null) { - logger.err({ err }, 'docstore returned a error in health check get') - return cb(err) - } else if (res == null) { - return cb(new Error('no response from docstore with get check')) - } else if ((res != null ? res.statusCode : undefined) !== 200) { - return cb(new Error(`status code not 200, its ${res.statusCode}`)) - } else if ( - _.isEqual(body != null ? body.lines : undefined, lines) && - (body != null ? body._id : undefined) === docId.toString() - ) { - return cb() - } else { - return cb( - new Error( - `health check lines not equal ${body.lines} != ${lines}` - ) - ) - } - }) - }, - cb => db.docs.deleteOne({ _id: docId, project_id: projectId }, cb), - ] - return async.series(jobs, callback) - }, + body = await fetchJson(url, { signal: AbortSignal.timeout(3_000) }) + } finally { + await db.docs.deleteOne({ _id: docId, project_id: projectId }) + } + if (!_.isEqual(body?.lines, lines)) { + throw new Error(`health check lines not equal ${body.lines} != ${lines}`) + } +} +module.exports = { + check, } diff --git a/services/docstore/app/js/HttpController.js b/services/docstore/app/js/HttpController.js index 1c4e137033..895e8e8e7b 100644 --- a/services/docstore/app/js/HttpController.js +++ b/services/docstore/app/js/HttpController.js @@ -4,143 +4,92 @@ const DocArchive = require('./DocArchiveManager') const HealthChecker = require('./HealthChecker') const Errors = require('./Errors') const Settings = require('@overleaf/settings') +const { expressify } = require('@overleaf/promise-utils') -function getDoc(req, res, next) { +async function getDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params const includeDeleted = req.query.include_deleted === 'true' logger.debug({ projectId, docId }, 'getting doc') - DocManager.getFullDoc(projectId, docId, function (error, doc) { - if (error) { - return next(error) - } - logger.debug({ docId, projectId }, 'got doc') - if (doc == null) { - res.sendStatus(404) - } else if (doc.deleted && !includeDeleted) { - res.sendStatus(404) - } else { - res.json(_buildDocView(doc)) - } - }) + const doc = await DocManager.getFullDoc(projectId, docId) + logger.debug({ docId, projectId }, 'got doc') + if (doc.deleted && !includeDeleted) { + res.sendStatus(404) + } else { + res.json(_buildDocView(doc)) + } } -function peekDoc(req, res, next) { +async function peekDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params logger.debug({ projectId, docId }, 'peeking doc') - DocManager.peekDoc(projectId, docId, function (error, doc) { - if (error) { - return next(error) - } - if (doc == null) { - res.sendStatus(404) - } else { - res.setHeader('x-doc-status', doc.inS3 ? 'archived' : 'active') - res.json(_buildDocView(doc)) - } - }) + const doc = await DocManager.peekDoc(projectId, docId) + res.setHeader('x-doc-status', doc.inS3 ? 'archived' : 'active') + res.json(_buildDocView(doc)) } -function isDocDeleted(req, res, next) { +async function isDocDeleted(req, res) { const { doc_id: docId, project_id: projectId } = req.params - DocManager.isDocDeleted(projectId, docId, function (error, deleted) { - if (error) { - return next(error) - } - res.json({ deleted }) - }) + const deleted = await DocManager.isDocDeleted(projectId, docId) + res.json({ deleted }) } -function getRawDoc(req, res, next) { +async function getRawDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params logger.debug({ projectId, docId }, 'getting raw doc') - DocManager.getDocLines(projectId, docId, function (error, doc) { - if (error) { - return next(error) - } - if (doc == null) { - res.sendStatus(404) - } else { - res.setHeader('content-type', 'text/plain') - res.send(_buildRawDocView(doc)) - } - }) + const content = await DocManager.getDocLines(projectId, docId) + res.setHeader('content-type', 'text/plain') + res.send(content) } -function getAllDocs(req, res, next) { +async function getAllDocs(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'getting all docs') - DocManager.getAllNonDeletedDocs( - projectId, - { lines: true, rev: true }, - function (error, docs) { - if (docs == null) { - docs = [] - } - if (error) { - return next(error) - } - const docViews = _buildDocsArrayView(projectId, docs) - for (const docView of docViews) { - if (!docView.lines) { - logger.warn({ projectId, docId: docView._id }, 'missing doc lines') - docView.lines = [] - } - } - res.json(docViews) + const docs = await DocManager.getAllNonDeletedDocs(projectId, { + lines: true, + rev: true, + }) + const docViews = _buildDocsArrayView(projectId, docs) + for (const docView of docViews) { + if (!docView.lines) { + logger.warn({ projectId, docId: docView._id }, 'missing doc lines') + docView.lines = [] } - ) + } + res.json(docViews) } -function getAllDeletedDocs(req, res, next) { +async function getAllDeletedDocs(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'getting all deleted docs') - DocManager.getAllDeletedDocs( - projectId, - { name: true, deletedAt: true }, - function (error, docs) { - if (error) { - return next(error) - } - res.json( - docs.map(doc => ({ - _id: doc._id.toString(), - name: doc.name, - deletedAt: doc.deletedAt, - })) - ) - } + const docs = await DocManager.getAllDeletedDocs(projectId, { + name: true, + deletedAt: true, + }) + res.json( + docs.map(doc => ({ + _id: doc._id.toString(), + name: doc.name, + deletedAt: doc.deletedAt, + })) ) } -function getAllRanges(req, res, next) { +async function getAllRanges(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'getting all ranges') - DocManager.getAllNonDeletedDocs( - projectId, - { ranges: true }, - function (error, docs) { - if (docs == null) { - docs = [] - } - if (error) { - return next(error) - } - res.json(_buildDocsArrayView(projectId, docs)) - } - ) -} - -function projectHasRanges(req, res, next) { - const { project_id: projectId } = req.params - DocManager.projectHasRanges(projectId, (err, projectHasRanges) => { - if (err) { - return next(err) - } - res.json({ projectHasRanges }) + const docs = await DocManager.getAllNonDeletedDocs(projectId, { + ranges: true, }) + res.json(_buildDocsArrayView(projectId, docs)) } -function updateDoc(req, res, next) { +async function projectHasRanges(req, res) { + const { project_id: projectId } = req.params + const projectHasRanges = await DocManager.projectHasRanges(projectId) + res.json({ projectHasRanges }) +} + +async function updateDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params const lines = req.body?.lines const version = req.body?.version @@ -172,25 +121,20 @@ function updateDoc(req, res, next) { } logger.debug({ projectId, docId }, 'got http request to update doc') - DocManager.updateDoc( + const { modified, rev } = await DocManager.updateDoc( projectId, docId, lines, version, - ranges, - function (error, modified, rev) { - if (error) { - return next(error) - } - res.json({ - modified, - rev, - }) - } + ranges ) + res.json({ + modified, + rev, + }) } -function patchDoc(req, res, next) { +async function patchDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params logger.debug({ projectId, docId }, 'patching doc') @@ -203,12 +147,8 @@ function patchDoc(req, res, next) { logger.fatal({ field }, 'joi validation for pathDoc is broken') } }) - DocManager.patchDoc(projectId, docId, meta, function (error) { - if (error) { - return next(error) - } - res.sendStatus(204) - }) + await DocManager.patchDoc(projectId, docId, meta) + res.sendStatus(204) } function _buildDocView(doc) { @@ -221,10 +161,6 @@ function _buildDocView(doc) { return docView } -function _buildRawDocView(doc) { - return (doc?.lines ?? []).join('\n') -} - function _buildDocsArrayView(projectId, docs) { const docViews = [] for (const doc of docs) { @@ -241,79 +177,67 @@ function _buildDocsArrayView(projectId, docs) { return docViews } -function archiveAllDocs(req, res, next) { +async function archiveAllDocs(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'archiving all docs') - DocArchive.archiveAllDocs(projectId, function (error) { - if (error) { - return next(error) - } - res.sendStatus(204) - }) + await DocArchive.archiveAllDocs(projectId) + res.sendStatus(204) } -function archiveDoc(req, res, next) { +async function archiveDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params logger.debug({ projectId, docId }, 'archiving a doc') - DocArchive.archiveDoc(projectId, docId, function (error) { - if (error) { - return next(error) - } - res.sendStatus(204) - }) + await DocArchive.archiveDoc(projectId, docId) + res.sendStatus(204) } -function unArchiveAllDocs(req, res, next) { +async function unArchiveAllDocs(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'unarchiving all docs') - DocArchive.unArchiveAllDocs(projectId, function (err) { - if (err) { - if (err instanceof Errors.DocRevValueError) { - logger.warn({ err }, 'Failed to unarchive doc') - return res.sendStatus(409) - } - return next(err) + try { + await DocArchive.unArchiveAllDocs(projectId) + } catch (err) { + if (err instanceof Errors.DocRevValueError) { + logger.warn({ err }, 'Failed to unarchive doc') + return res.sendStatus(409) } - res.sendStatus(200) - }) + throw err + } + res.sendStatus(200) } -function destroyProject(req, res, next) { +async function destroyProject(req, res) { const { project_id: projectId } = req.params logger.debug({ projectId }, 'destroying all docs') - DocArchive.destroyProject(projectId, function (error) { - if (error) { - return next(error) - } - res.sendStatus(204) - }) + await DocArchive.destroyProject(projectId) + res.sendStatus(204) } -function healthCheck(req, res) { - HealthChecker.check(function (err) { - if (err) { - logger.err({ err }, 'error performing health check') - res.sendStatus(500) - } else { - res.sendStatus(200) - } - }) +async function healthCheck(req, res) { + try { + await HealthChecker.check() + } catch (err) { + logger.err({ err }, 'error performing health check') + res.sendStatus(500) + return + } + res.sendStatus(200) } module.exports = { - getDoc, - peekDoc, - isDocDeleted, - getRawDoc, - getAllDocs, - getAllDeletedDocs, - getAllRanges, - projectHasRanges, - updateDoc, - patchDoc, - archiveAllDocs, - archiveDoc, - unArchiveAllDocs, - destroyProject, - healthCheck, + getDoc: expressify(getDoc), + peekDoc: expressify(peekDoc), + isDocDeleted: expressify(isDocDeleted), + getRawDoc: expressify(getRawDoc), + getAllDocs: expressify(getAllDocs), + getAllDeletedDocs: expressify(getAllDeletedDocs), + getAllRanges: expressify(getAllRanges), + projectHasRanges: expressify(projectHasRanges), + updateDoc: expressify(updateDoc), + patchDoc: expressify(patchDoc), + archiveAllDocs: expressify(archiveAllDocs), + archiveDoc: expressify(archiveDoc), + unArchiveAllDocs: expressify(unArchiveAllDocs), + destroyProject: expressify(destroyProject), + healthCheck: expressify(healthCheck), } diff --git a/services/docstore/app/js/MongoManager.js b/services/docstore/app/js/MongoManager.js index ad1a2d2b40..ef101f91c0 100644 --- a/services/docstore/app/js/MongoManager.js +++ b/services/docstore/app/js/MongoManager.js @@ -1,7 +1,6 @@ const { db, ObjectId } = require('./mongodb') const Settings = require('@overleaf/settings') const Errors = require('./Errors') -const { callbackify } = require('node:util') const ARCHIVING_LOCK_DURATION_MS = Settings.archivingLockDurationMs @@ -241,34 +240,17 @@ async function destroyProject(projectId) { } module.exports = { - findDoc: callbackify(findDoc), - getProjectsDeletedDocs: callbackify(getProjectsDeletedDocs), - getProjectsDocs: callbackify(getProjectsDocs), - getArchivedProjectDocs: callbackify(getArchivedProjectDocs), - getNonArchivedProjectDocIds: callbackify(getNonArchivedProjectDocIds), - getNonDeletedArchivedProjectDocs: callbackify( - getNonDeletedArchivedProjectDocs - ), - upsertIntoDocCollection: callbackify(upsertIntoDocCollection), - restoreArchivedDoc: callbackify(restoreArchivedDoc), - patchDoc: callbackify(patchDoc), - getDocForArchiving: callbackify(getDocForArchiving), - markDocAsArchived: callbackify(markDocAsArchived), - checkRevUnchanged: callbackify(checkRevUnchanged), - destroyProject: callbackify(destroyProject), - promises: { - findDoc, - getProjectsDeletedDocs, - getProjectsDocs, - getArchivedProjectDocs, - getNonArchivedProjectDocIds, - getNonDeletedArchivedProjectDocs, - upsertIntoDocCollection, - restoreArchivedDoc, - patchDoc, - getDocForArchiving, - markDocAsArchived, - checkRevUnchanged, - destroyProject, - }, + findDoc, + getProjectsDeletedDocs, + getProjectsDocs, + getArchivedProjectDocs, + getNonArchivedProjectDocIds, + getNonDeletedArchivedProjectDocs, + upsertIntoDocCollection, + restoreArchivedDoc, + patchDoc, + getDocForArchiving, + markDocAsArchived, + checkRevUnchanged, + destroyProject, } diff --git a/services/docstore/app/js/StreamToBuffer.js b/services/docstore/app/js/StreamToBuffer.js index 7de146cd11..09215a7367 100644 --- a/services/docstore/app/js/StreamToBuffer.js +++ b/services/docstore/app/js/StreamToBuffer.js @@ -2,13 +2,9 @@ const { LoggerStream, WritableBuffer } = require('@overleaf/stream-utils') const Settings = require('@overleaf/settings') const logger = require('@overleaf/logger/logging-manager') const { pipeline } = require('node:stream/promises') -const { callbackify } = require('node:util') module.exports = { - streamToBuffer: callbackify(streamToBuffer), - promises: { - streamToBuffer, - }, + streamToBuffer, } async function streamToBuffer(projectId, docId, stream) { diff --git a/services/docstore/package.json b/services/docstore/package.json index e505f731d3..bf5857fd49 100644 --- a/services/docstore/package.json +++ b/services/docstore/package.json @@ -17,6 +17,7 @@ "types:check": "tsc --noEmit" }, "dependencies": { + "@overleaf/fetch-utils": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/o-error": "*", diff --git a/services/docstore/test/acceptance/js/HealthCheckerTest.js b/services/docstore/test/acceptance/js/HealthCheckerTest.js new file mode 100644 index 0000000000..b25a45312b --- /dev/null +++ b/services/docstore/test/acceptance/js/HealthCheckerTest.js @@ -0,0 +1,28 @@ +const { db } = require('../../../app/js/mongodb') +const DocstoreApp = require('./helpers/DocstoreApp') +const DocstoreClient = require('./helpers/DocstoreClient') +const { expect } = require('chai') + +describe('HealthChecker', function () { + beforeEach('start', function (done) { + DocstoreApp.ensureRunning(done) + }) + beforeEach('clear docs collection', async function () { + await db.docs.deleteMany({}) + }) + let res + beforeEach('run health check', function (done) { + DocstoreClient.healthCheck((err, _res) => { + res = _res + done(err) + }) + }) + + it('should return 200', function () { + res.statusCode.should.equal(200) + }) + + it('should not leave any cruft behind', async function () { + expect(await db.docs.find({}).toArray()).to.deep.equal([]) + }) +}) diff --git a/services/docstore/test/acceptance/js/helpers/DocstoreClient.js b/services/docstore/test/acceptance/js/helpers/DocstoreClient.js index 790ec8f237..d8fe94829b 100644 --- a/services/docstore/test/acceptance/js/helpers/DocstoreClient.js +++ b/services/docstore/test/acceptance/js/helpers/DocstoreClient.js @@ -181,6 +181,13 @@ module.exports = DocstoreClient = { ) }, + healthCheck(callback) { + request.get( + `http://127.0.0.1:${settings.internal.docstore.port}/health_check`, + callback + ) + }, + getS3Doc(projectId, docId, callback) { getStringFromPersistor( Persistor, diff --git a/services/docstore/test/unit/js/DocArchiveManagerTests.js b/services/docstore/test/unit/js/DocArchiveManagerTests.js index a57f9806c8..fbc1667314 100644 --- a/services/docstore/test/unit/js/DocArchiveManagerTests.js +++ b/services/docstore/test/unit/js/DocArchiveManagerTests.js @@ -4,7 +4,7 @@ const modulePath = '../../../app/js/DocArchiveManager.js' const SandboxedModule = require('sandboxed-module') const { ObjectId } = require('mongodb-legacy') const Errors = require('../../../app/js/Errors') -const StreamToBuffer = require('../../../app/js/StreamToBuffer').promises +const StreamToBuffer = require('../../../app/js/StreamToBuffer') describe('DocArchiveManager', function () { let DocArchiveManager, @@ -142,37 +142,33 @@ describe('DocArchiveManager', function () { } MongoManager = { - promises: { - markDocAsArchived: sinon.stub().resolves(), - restoreArchivedDoc: sinon.stub().resolves(), - upsertIntoDocCollection: sinon.stub().resolves(), - getProjectsDocs: sinon.stub().resolves(mongoDocs), - getNonDeletedArchivedProjectDocs: getArchivedProjectDocs, - getNonArchivedProjectDocIds, - getArchivedProjectDocs, - findDoc: sinon.stub().callsFake(fakeGetDoc), - getDocForArchiving: sinon.stub().callsFake(fakeGetDoc), - destroyProject: sinon.stub().resolves(), - }, + markDocAsArchived: sinon.stub().resolves(), + restoreArchivedDoc: sinon.stub().resolves(), + upsertIntoDocCollection: sinon.stub().resolves(), + getProjectsDocs: sinon.stub().resolves(mongoDocs), + getNonDeletedArchivedProjectDocs: getArchivedProjectDocs, + getNonArchivedProjectDocIds, + getArchivedProjectDocs, + findDoc: sinon.stub().callsFake(fakeGetDoc), + getDocForArchiving: sinon.stub().callsFake(fakeGetDoc), + destroyProject: sinon.stub().resolves(), } // Wrap streamToBuffer so that we can pass in something that it expects (in // this case, a Promise) rather than a stubbed stream object streamToBuffer = { - promises: { - streamToBuffer: async () => { - const inputStream = new Promise(resolve => { - stream.on('data', data => resolve(data)) - }) + streamToBuffer: async () => { + const inputStream = new Promise(resolve => { + stream.on('data', data => resolve(data)) + }) - const value = await StreamToBuffer.streamToBuffer( - 'testProjectId', - 'testDocId', - inputStream - ) + const value = await StreamToBuffer.streamToBuffer( + 'testProjectId', + 'testDocId', + inputStream + ) - return value - }, + return value }, } @@ -192,9 +188,8 @@ describe('DocArchiveManager', function () { describe('archiveDoc', function () { it('should resolve when passed a valid document', async function () { - await expect( - DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - ).to.eventually.be.fulfilled + await expect(DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)).to + .eventually.be.fulfilled }) it('should throw an error if the doc has no lines', async function () { @@ -202,26 +197,26 @@ describe('DocArchiveManager', function () { doc.lines = null await expect( - DocArchiveManager.promises.archiveDoc(projectId, doc._id) + DocArchiveManager.archiveDoc(projectId, doc._id) ).to.eventually.be.rejectedWith('doc has no lines') }) it('should add the schema version', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[1]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[1]._id) expect(StreamUtils.ReadableString).to.have.been.calledWith( sinon.match(/"schema_v":1/) ) }) it('should calculate the hex md5 sum of the content', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) expect(Crypto.createHash).to.have.been.calledWith('md5') expect(HashUpdate).to.have.been.calledWith(archivedDocJson) expect(HashDigest).to.have.been.calledWith('hex') }) it('should pass the md5 hash to the object persistor for verification', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) expect(PersistorManager.sendStream).to.have.been.calledWith( sinon.match.any, @@ -232,7 +227,7 @@ describe('DocArchiveManager', function () { }) it('should pass the correct bucket and key to the persistor', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) expect(PersistorManager.sendStream).to.have.been.calledWith( Settings.docstore.bucket, @@ -241,7 +236,7 @@ describe('DocArchiveManager', function () { }) it('should create a stream from the encoded json and send it', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) expect(StreamUtils.ReadableString).to.have.been.calledWith( archivedDocJson ) @@ -253,8 +248,8 @@ describe('DocArchiveManager', function () { }) it('should mark the doc as archived', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith( + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) + expect(MongoManager.markDocAsArchived).to.have.been.calledWith( projectId, mongoDocs[0]._id, mongoDocs[0].rev @@ -267,8 +262,8 @@ describe('DocArchiveManager', function () { }) it('should bail out early', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - expect(MongoManager.promises.getDocForArchiving).to.not.have.been.called + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) + expect(MongoManager.getDocForArchiving).to.not.have.been.called }) }) @@ -285,7 +280,7 @@ describe('DocArchiveManager', function () { it('should return an error', async function () { await expect( - DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) + DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) ).to.eventually.be.rejectedWith('null bytes detected') }) }) @@ -296,21 +291,19 @@ describe('DocArchiveManager', function () { describe('when the doc is in S3', function () { beforeEach(function () { - MongoManager.promises.findDoc = sinon - .stub() - .resolves({ inS3: true, rev }) + MongoManager.findDoc = sinon.stub().resolves({ inS3: true, rev }) docId = mongoDocs[0]._id lines = ['doc', 'lines'] rev = 123 }) it('should resolve when passed a valid document', async function () { - await expect(DocArchiveManager.promises.unarchiveDoc(projectId, docId)) - .to.eventually.be.fulfilled + await expect(DocArchiveManager.unarchiveDoc(projectId, docId)).to + .eventually.be.fulfilled }) it('should test md5 validity with the raw buffer', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) + await DocArchiveManager.unarchiveDoc(projectId, docId) expect(HashUpdate).to.have.been.calledWith( sinon.match.instanceOf(Buffer) ) @@ -319,15 +312,17 @@ describe('DocArchiveManager', function () { it('should throw an error if the md5 does not match', async function () { PersistorManager.getObjectMd5Hash.resolves('badf00d') await expect( - DocArchiveManager.promises.unarchiveDoc(projectId, docId) + DocArchiveManager.unarchiveDoc(projectId, docId) ).to.eventually.be.rejected.and.be.instanceof(Errors.Md5MismatchError) }) it('should restore the doc in Mongo', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, archivedDoc) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + archivedDoc + ) }) describe('when archiving is not configured', function () { @@ -337,15 +332,15 @@ describe('DocArchiveManager', function () { it('should error out on archived doc', async function () { await expect( - DocArchiveManager.promises.unarchiveDoc(projectId, docId) + DocArchiveManager.unarchiveDoc(projectId, docId) ).to.eventually.be.rejected.and.match( /found archived doc, but archiving backend is not configured/ ) }) it('should return early on non-archived doc', async function () { - MongoManager.promises.findDoc = sinon.stub().resolves({ rev }) - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) + MongoManager.findDoc = sinon.stub().resolves({ rev }) + await DocArchiveManager.unarchiveDoc(projectId, docId) expect(PersistorManager.getObjectMd5Hash).to.not.have.been.called }) }) @@ -363,10 +358,12 @@ describe('DocArchiveManager', function () { }) it('should return the docs lines', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, { lines, rev }) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + { lines, rev } + ) }) }) @@ -385,14 +382,16 @@ describe('DocArchiveManager', function () { }) it('should return the doc lines and ranges', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, { - lines, - ranges: { mongo: 'ranges' }, - rev: 456, - }) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + { + lines, + ranges: { mongo: 'ranges' }, + rev: 456, + } + ) }) }) @@ -406,10 +405,12 @@ describe('DocArchiveManager', function () { }) it('should return only the doc lines', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, { lines, rev: 456 }) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + { lines, rev: 456 } + ) }) }) @@ -423,10 +424,12 @@ describe('DocArchiveManager', function () { }) it('should use the rev obtained from Mongo', async function () { - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) - expect( - MongoManager.promises.restoreArchivedDoc - ).to.have.been.calledWith(projectId, docId, { lines, rev }) + await DocArchiveManager.unarchiveDoc(projectId, docId) + expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith( + projectId, + docId, + { lines, rev } + ) }) }) @@ -441,7 +444,7 @@ describe('DocArchiveManager', function () { it('should throw an error', async function () { await expect( - DocArchiveManager.promises.unarchiveDoc(projectId, docId) + DocArchiveManager.unarchiveDoc(projectId, docId) ).to.eventually.be.rejectedWith( "I don't understand the doc format in s3" ) @@ -451,8 +454,8 @@ describe('DocArchiveManager', function () { }) it('should not do anything if the file is already unarchived', async function () { - MongoManager.promises.findDoc.resolves({ inS3: false }) - await DocArchiveManager.promises.unarchiveDoc(projectId, docId) + MongoManager.findDoc.resolves({ inS3: false }) + await DocArchiveManager.unarchiveDoc(projectId, docId) expect(PersistorManager.getObjectStream).not.to.have.been.called }) @@ -461,7 +464,7 @@ describe('DocArchiveManager', function () { .stub() .rejects(new Errors.NotFoundError()) await expect( - DocArchiveManager.promises.unarchiveDoc(projectId, docId) + DocArchiveManager.unarchiveDoc(projectId, docId) ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError) }) }) @@ -469,13 +472,11 @@ describe('DocArchiveManager', function () { describe('destroyProject', function () { describe('when archiving is enabled', function () { beforeEach(async function () { - await DocArchiveManager.promises.destroyProject(projectId) + await DocArchiveManager.destroyProject(projectId) }) it('should delete the project in Mongo', function () { - expect(MongoManager.promises.destroyProject).to.have.been.calledWith( - projectId - ) + expect(MongoManager.destroyProject).to.have.been.calledWith(projectId) }) it('should delete the project in the persistor', function () { @@ -489,13 +490,11 @@ describe('DocArchiveManager', function () { describe('when archiving is disabled', function () { beforeEach(async function () { Settings.docstore.backend = '' - await DocArchiveManager.promises.destroyProject(projectId) + await DocArchiveManager.destroyProject(projectId) }) it('should delete the project in Mongo', function () { - expect(MongoManager.promises.destroyProject).to.have.been.calledWith( - projectId - ) + expect(MongoManager.destroyProject).to.have.been.calledWith(projectId) }) it('should not delete the project in the persistor', function () { @@ -506,33 +505,35 @@ describe('DocArchiveManager', function () { describe('archiveAllDocs', function () { it('should resolve with valid arguments', async function () { - await expect(DocArchiveManager.promises.archiveAllDocs(projectId)).to - .eventually.be.fulfilled + await expect(DocArchiveManager.archiveAllDocs(projectId)).to.eventually.be + .fulfilled }) it('should archive all project docs which are not in s3', async function () { - await DocArchiveManager.promises.archiveAllDocs(projectId) + await DocArchiveManager.archiveAllDocs(projectId) // not inS3 - expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith( + expect(MongoManager.markDocAsArchived).to.have.been.calledWith( projectId, mongoDocs[0]._id ) - expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith( + expect(MongoManager.markDocAsArchived).to.have.been.calledWith( projectId, mongoDocs[1]._id ) - expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith( + expect(MongoManager.markDocAsArchived).to.have.been.calledWith( projectId, mongoDocs[4]._id ) // inS3 - expect( - MongoManager.promises.markDocAsArchived - ).not.to.have.been.calledWith(projectId, mongoDocs[2]._id) - expect( - MongoManager.promises.markDocAsArchived - ).not.to.have.been.calledWith(projectId, mongoDocs[3]._id) + expect(MongoManager.markDocAsArchived).not.to.have.been.calledWith( + projectId, + mongoDocs[2]._id + ) + expect(MongoManager.markDocAsArchived).not.to.have.been.calledWith( + projectId, + mongoDocs[3]._id + ) }) describe('when archiving is not configured', function () { @@ -541,21 +542,20 @@ describe('DocArchiveManager', function () { }) it('should bail out early', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - expect(MongoManager.promises.getNonArchivedProjectDocIds).to.not.have - .been.called + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) + expect(MongoManager.getNonArchivedProjectDocIds).to.not.have.been.called }) }) }) describe('unArchiveAllDocs', function () { it('should resolve with valid arguments', async function () { - await expect(DocArchiveManager.promises.unArchiveAllDocs(projectId)).to - .eventually.be.fulfilled + await expect(DocArchiveManager.unArchiveAllDocs(projectId)).to.eventually + .be.fulfilled }) it('should unarchive all inS3 docs', async function () { - await DocArchiveManager.promises.unArchiveAllDocs(projectId) + await DocArchiveManager.unArchiveAllDocs(projectId) for (const doc of archivedDocs) { expect(PersistorManager.getObjectStream).to.have.been.calledWith( @@ -571,9 +571,9 @@ describe('DocArchiveManager', function () { }) it('should bail out early', async function () { - await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0]._id) - expect(MongoManager.promises.getNonDeletedArchivedProjectDocs).to.not - .have.been.called + await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id) + expect(MongoManager.getNonDeletedArchivedProjectDocs).to.not.have.been + .called }) }) }) diff --git a/services/docstore/test/unit/js/DocManagerTests.js b/services/docstore/test/unit/js/DocManagerTests.js index 8405520e6e..f207f8e993 100644 --- a/services/docstore/test/unit/js/DocManagerTests.js +++ b/services/docstore/test/unit/js/DocManagerTests.js @@ -17,19 +17,15 @@ describe('DocManager', function () { this.version = 42 this.MongoManager = { - promises: { - findDoc: sinon.stub(), - getProjectsDocs: sinon.stub(), - patchDoc: sinon.stub().resolves(), - upsertIntoDocCollection: sinon.stub().resolves(), - }, + findDoc: sinon.stub(), + getProjectsDocs: sinon.stub(), + patchDoc: sinon.stub().resolves(), + upsertIntoDocCollection: sinon.stub().resolves(), } this.DocArchiveManager = { - promises: { - unarchiveDoc: sinon.stub(), - unArchiveAllDocs: sinon.stub(), - archiveDoc: sinon.stub().resolves(), - }, + unarchiveDoc: sinon.stub(), + unArchiveAllDocs: sinon.stub(), + archiveDoc: sinon.stub().resolves(), } this.RangeManager = { jsonRangesToMongo(r) { @@ -52,7 +48,7 @@ describe('DocManager', function () { describe('getFullDoc', function () { beforeEach(function () { - this.DocManager.promises._getDoc = sinon.stub() + this.DocManager._getDoc = sinon.stub() this.doc = { _id: this.doc_id, lines: ['2134'], @@ -60,13 +56,10 @@ describe('DocManager', function () { }) it('should call get doc with a quick filter', async function () { - this.DocManager.promises._getDoc.resolves(this.doc) - const doc = await this.DocManager.promises.getFullDoc( - this.project_id, - this.doc_id - ) + this.DocManager._getDoc.resolves(this.doc) + const doc = await this.DocManager.getFullDoc(this.project_id, this.doc_id) doc.should.equal(this.doc) - this.DocManager.promises._getDoc + this.DocManager._getDoc .calledWith(this.project_id, this.doc_id, { lines: true, rev: true, @@ -79,27 +72,27 @@ describe('DocManager', function () { }) it('should return error when get doc errors', async function () { - this.DocManager.promises._getDoc.rejects(this.stubbedError) + this.DocManager._getDoc.rejects(this.stubbedError) await expect( - this.DocManager.promises.getFullDoc(this.project_id, this.doc_id) + this.DocManager.getFullDoc(this.project_id, this.doc_id) ).to.be.rejectedWith(this.stubbedError) }) }) describe('getRawDoc', function () { beforeEach(function () { - this.DocManager.promises._getDoc = sinon.stub() + this.DocManager._getDoc = sinon.stub() this.doc = { lines: ['2134'] } }) it('should call get doc with a quick filter', async function () { - this.DocManager.promises._getDoc.resolves(this.doc) - const doc = await this.DocManager.promises.getDocLines( + this.DocManager._getDoc.resolves(this.doc) + const content = await this.DocManager.getDocLines( this.project_id, this.doc_id ) - doc.should.equal(this.doc) - this.DocManager.promises._getDoc + content.should.equal(this.doc.lines.join('\n')) + this.DocManager._getDoc .calledWith(this.project_id, this.doc_id, { lines: true, inS3: true, @@ -108,11 +101,25 @@ describe('DocManager', function () { }) it('should return error when get doc errors', async function () { - this.DocManager.promises._getDoc.rejects(this.stubbedError) + this.DocManager._getDoc.rejects(this.stubbedError) await expect( - this.DocManager.promises.getDocLines(this.project_id, this.doc_id) + this.DocManager.getDocLines(this.project_id, this.doc_id) ).to.be.rejectedWith(this.stubbedError) }) + + it('should return error when get doc does not exist', async function () { + this.DocManager._getDoc.resolves(null) + await expect( + this.DocManager.getDocLines(this.project_id, this.doc_id) + ).to.be.rejectedWith(Errors.NotFoundError) + }) + + it('should return error when get doc has no lines', async function () { + this.DocManager._getDoc.resolves({}) + await expect( + this.DocManager.getDocLines(this.project_id, this.doc_id) + ).to.be.rejectedWith(Errors.DocWithoutLinesError) + }) }) describe('getDoc', function () { @@ -128,26 +135,25 @@ describe('DocManager', function () { describe('when using a filter', function () { beforeEach(function () { - this.MongoManager.promises.findDoc.resolves(this.doc) + this.MongoManager.findDoc.resolves(this.doc) }) it('should error if inS3 is not set to true', async function () { await expect( - this.DocManager.promises._getDoc(this.project_id, this.doc_id, { + this.DocManager._getDoc(this.project_id, this.doc_id, { inS3: false, }) ).to.be.rejected }) it('should always get inS3 even when no filter is passed', async function () { - await expect( - this.DocManager.promises._getDoc(this.project_id, this.doc_id) - ).to.be.rejected - this.MongoManager.promises.findDoc.called.should.equal(false) + await expect(this.DocManager._getDoc(this.project_id, this.doc_id)).to + .be.rejected + this.MongoManager.findDoc.called.should.equal(false) }) it('should not error if inS3 is set to true', async function () { - await this.DocManager.promises._getDoc(this.project_id, this.doc_id, { + await this.DocManager._getDoc(this.project_id, this.doc_id, { inS3: true, }) }) @@ -155,8 +161,8 @@ describe('DocManager', function () { describe('when the doc is in the doc collection', function () { beforeEach(async function () { - this.MongoManager.promises.findDoc.resolves(this.doc) - this.result = await this.DocManager.promises._getDoc( + this.MongoManager.findDoc.resolves(this.doc) + this.result = await this.DocManager._getDoc( this.project_id, this.doc_id, { version: true, inS3: true } @@ -164,7 +170,7 @@ describe('DocManager', function () { }) it('should get the doc from the doc collection', function () { - this.MongoManager.promises.findDoc + this.MongoManager.findDoc .calledWith(this.project_id, this.doc_id) .should.equal(true) }) @@ -177,9 +183,9 @@ describe('DocManager', function () { describe('when MongoManager.findDoc errors', function () { it('should return the error', async function () { - this.MongoManager.promises.findDoc.rejects(this.stubbedError) + this.MongoManager.findDoc.rejects(this.stubbedError) await expect( - this.DocManager.promises._getDoc(this.project_id, this.doc_id, { + this.DocManager._getDoc(this.project_id, this.doc_id, { version: true, inS3: true, }) @@ -202,15 +208,15 @@ describe('DocManager', function () { version: 2, inS3: false, } - this.MongoManager.promises.findDoc.resolves(this.doc) - this.DocArchiveManager.promises.unarchiveDoc.callsFake( + this.MongoManager.findDoc.resolves(this.doc) + this.DocArchiveManager.unarchiveDoc.callsFake( async (projectId, docId) => { - this.MongoManager.promises.findDoc.resolves({ + this.MongoManager.findDoc.resolves({ ...this.unarchivedDoc, }) } ) - this.result = await this.DocManager.promises._getDoc( + this.result = await this.DocManager._getDoc( this.project_id, this.doc_id, { @@ -221,13 +227,13 @@ describe('DocManager', function () { }) it('should call the DocArchive to unarchive the doc', function () { - this.DocArchiveManager.promises.unarchiveDoc + this.DocArchiveManager.unarchiveDoc .calledWith(this.project_id, this.doc_id) .should.equal(true) }) it('should look up the doc twice', function () { - this.MongoManager.promises.findDoc.calledTwice.should.equal(true) + this.MongoManager.findDoc.calledTwice.should.equal(true) }) it('should return the doc', function () { @@ -239,9 +245,9 @@ describe('DocManager', function () { describe('when the doc does not exist in the docs collection', function () { it('should return a NotFoundError', async function () { - this.MongoManager.promises.findDoc.resolves(null) + this.MongoManager.findDoc.resolves(null) await expect( - this.DocManager.promises._getDoc(this.project_id, this.doc_id, { + this.DocManager._getDoc(this.project_id, this.doc_id, { version: true, inS3: true, }) @@ -262,17 +268,17 @@ describe('DocManager', function () { lines: ['mock-lines'], }, ] - this.MongoManager.promises.getProjectsDocs.resolves(this.docs) - this.DocArchiveManager.promises.unArchiveAllDocs.resolves(this.docs) + this.MongoManager.getProjectsDocs.resolves(this.docs) + this.DocArchiveManager.unArchiveAllDocs.resolves(this.docs) this.filter = { lines: true } - this.result = await this.DocManager.promises.getAllNonDeletedDocs( + this.result = await this.DocManager.getAllNonDeletedDocs( this.project_id, this.filter ) }) it('should get the project from the database', function () { - this.MongoManager.promises.getProjectsDocs.should.have.been.calledWith( + this.MongoManager.getProjectsDocs.should.have.been.calledWith( this.project_id, { include_deleted: false }, this.filter @@ -286,13 +292,10 @@ describe('DocManager', function () { describe('when there are no docs for the project', function () { it('should return a NotFoundError', async function () { - this.MongoManager.promises.getProjectsDocs.resolves(null) - this.DocArchiveManager.promises.unArchiveAllDocs.resolves(null) + this.MongoManager.getProjectsDocs.resolves(null) + this.DocArchiveManager.unArchiveAllDocs.resolves(null) await expect( - this.DocManager.promises.getAllNonDeletedDocs( - this.project_id, - this.filter - ) + this.DocManager.getAllNonDeletedDocs(this.project_id, this.filter) ).to.be.rejectedWith(`No docs for project ${this.project_id}`) }) }) @@ -303,7 +306,7 @@ describe('DocManager', function () { beforeEach(function () { this.lines = ['mock', 'doc', 'lines'] this.rev = 77 - this.MongoManager.promises.findDoc.resolves({ + this.MongoManager.findDoc.resolves({ _id: new ObjectId(this.doc_id), }) this.meta = {} @@ -311,7 +314,7 @@ describe('DocManager', function () { describe('standard path', function () { beforeEach(async function () { - await this.DocManager.promises.patchDoc( + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -319,14 +322,14 @@ describe('DocManager', function () { }) it('should get the doc', function () { - expect(this.MongoManager.promises.findDoc).to.have.been.calledWith( + expect(this.MongoManager.findDoc).to.have.been.calledWith( this.project_id, this.doc_id ) }) it('should persist the meta', function () { - expect(this.MongoManager.promises.patchDoc).to.have.been.calledWith( + expect(this.MongoManager.patchDoc).to.have.been.calledWith( this.project_id, this.doc_id, this.meta @@ -339,7 +342,7 @@ describe('DocManager', function () { this.settings.docstore.archiveOnSoftDelete = false this.meta.deleted = true - await this.DocManager.promises.patchDoc( + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -347,8 +350,7 @@ describe('DocManager', function () { }) it('should not flush the doc out of mongo', function () { - expect(this.DocArchiveManager.promises.archiveDoc).to.not.have.been - .called + expect(this.DocArchiveManager.archiveDoc).to.not.have.been.called }) }) @@ -356,7 +358,7 @@ describe('DocManager', function () { beforeEach(async function () { this.settings.docstore.archiveOnSoftDelete = false this.meta.deleted = false - await this.DocManager.promises.patchDoc( + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -364,8 +366,7 @@ describe('DocManager', function () { }) it('should not flush the doc out of mongo', function () { - expect(this.DocArchiveManager.promises.archiveDoc).to.not.have.been - .called + expect(this.DocArchiveManager.archiveDoc).to.not.have.been.called }) }) @@ -377,7 +378,7 @@ describe('DocManager', function () { describe('when the background flush succeeds', function () { beforeEach(async function () { - await this.DocManager.promises.patchDoc( + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -389,17 +390,18 @@ describe('DocManager', function () { }) it('should flush the doc out of mongo', function () { - expect( - this.DocArchiveManager.promises.archiveDoc - ).to.have.been.calledWith(this.project_id, this.doc_id) + expect(this.DocArchiveManager.archiveDoc).to.have.been.calledWith( + this.project_id, + this.doc_id + ) }) }) describe('when the background flush fails', function () { beforeEach(async function () { this.err = new Error('foo') - this.DocArchiveManager.promises.archiveDoc.rejects(this.err) - await this.DocManager.promises.patchDoc( + this.DocArchiveManager.archiveDoc.rejects(this.err) + await this.DocManager.patchDoc( this.project_id, this.doc_id, this.meta @@ -422,9 +424,9 @@ describe('DocManager', function () { describe('when the doc does not exist', function () { it('should return a NotFoundError', async function () { - this.MongoManager.promises.findDoc.resolves(null) + this.MongoManager.findDoc.resolves(null) await expect( - this.DocManager.promises.patchDoc(this.project_id, this.doc_id, {}) + this.DocManager.patchDoc(this.project_id, this.doc_id, {}) ).to.be.rejectedWith( `No such project/doc to delete: ${this.project_id}/${this.doc_id}` ) @@ -470,13 +472,13 @@ describe('DocManager', function () { ranges: this.originalRanges, } - this.DocManager.promises._getDoc = sinon.stub() + this.DocManager._getDoc = sinon.stub() }) describe('when only the doc lines have changed', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -486,7 +488,7 @@ describe('DocManager', function () { }) it('should get the existing doc', function () { - this.DocManager.promises._getDoc + this.DocManager._getDoc .calledWith(this.project_id, this.doc_id, { version: true, rev: true, @@ -498,7 +500,7 @@ describe('DocManager', function () { }) it('should upsert the document to the doc collection', function () { - this.MongoManager.promises.upsertIntoDocCollection + this.MongoManager.upsertIntoDocCollection .calledWith(this.project_id, this.doc_id, this.rev, { lines: this.newDocLines, }) @@ -512,9 +514,9 @@ describe('DocManager', function () { describe('when the doc ranges have changed', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) + this.DocManager._getDoc = sinon.stub().resolves(this.doc) this.RangeManager.shouldUpdateRanges.returns(true) - this.result = await this.DocManager.promises.updateDoc( + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.oldDocLines, @@ -524,7 +526,7 @@ describe('DocManager', function () { }) it('should upsert the ranges', function () { - this.MongoManager.promises.upsertIntoDocCollection + this.MongoManager.upsertIntoDocCollection .calledWith(this.project_id, this.doc_id, this.rev, { ranges: this.newRanges, }) @@ -538,8 +540,8 @@ describe('DocManager', function () { describe('when only the version has changed', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.oldDocLines, @@ -549,7 +551,7 @@ describe('DocManager', function () { }) it('should update the version', function () { - this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith( + this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( this.project_id, this.doc_id, this.rev, @@ -564,8 +566,8 @@ describe('DocManager', function () { describe('when the doc has not changed at all', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.oldDocLines, @@ -575,9 +577,7 @@ describe('DocManager', function () { }) it('should not update the ranges or lines or version', function () { - this.MongoManager.promises.upsertIntoDocCollection.called.should.equal( - false - ) + this.MongoManager.upsertIntoDocCollection.called.should.equal(false) }) it('should return the old rev and modified == false', function () { @@ -588,7 +588,7 @@ describe('DocManager', function () { describe('when the version is null', function () { it('should return an error', async function () { await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -602,7 +602,7 @@ describe('DocManager', function () { describe('when the lines are null', function () { it('should return an error', async function () { await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, null, @@ -616,7 +616,7 @@ describe('DocManager', function () { describe('when the ranges are null', function () { it('should return an error', async function () { await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -630,9 +630,9 @@ describe('DocManager', function () { describe('when there is a generic error getting the doc', function () { beforeEach(async function () { this.error = new Error('doc could not be found') - this.DocManager.promises._getDoc = sinon.stub().rejects(this.error) + this.DocManager._getDoc = sinon.stub().rejects(this.error) await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -643,16 +643,15 @@ describe('DocManager', function () { }) it('should not upsert the document to the doc collection', function () { - this.MongoManager.promises.upsertIntoDocCollection.should.not.have.been - .called + this.MongoManager.upsertIntoDocCollection.should.not.have.been.called }) }) describe('when the version was decremented', function () { it('should return an error', async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) + this.DocManager._getDoc = sinon.stub().resolves(this.doc) await expect( - this.DocManager.promises.updateDoc( + this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -665,8 +664,8 @@ describe('DocManager', function () { describe('when the doc lines have not changed', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.oldDocLines.slice(), @@ -676,9 +675,7 @@ describe('DocManager', function () { }) it('should not update the doc', function () { - this.MongoManager.promises.upsertIntoDocCollection.called.should.equal( - false - ) + this.MongoManager.upsertIntoDocCollection.called.should.equal(false) }) it('should return the existing rev', function () { @@ -688,8 +685,8 @@ describe('DocManager', function () { describe('when the doc does not exist', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(null) - this.result = await this.DocManager.promises.updateDoc( + this.DocManager._getDoc = sinon.stub().resolves(null) + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -699,7 +696,7 @@ describe('DocManager', function () { }) it('should upsert the document to the doc collection', function () { - this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith( + this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( this.project_id, this.doc_id, undefined, @@ -718,12 +715,12 @@ describe('DocManager', function () { describe('when another update is racing', function () { beforeEach(async function () { - this.DocManager.promises._getDoc = sinon.stub().resolves(this.doc) - this.MongoManager.promises.upsertIntoDocCollection + this.DocManager._getDoc = sinon.stub().resolves(this.doc) + this.MongoManager.upsertIntoDocCollection .onFirstCall() .rejects(new Errors.DocRevValueError()) this.RangeManager.shouldUpdateRanges.returns(true) - this.result = await this.DocManager.promises.updateDoc( + this.result = await this.DocManager.updateDoc( this.project_id, this.doc_id, this.newDocLines, @@ -733,7 +730,7 @@ describe('DocManager', function () { }) it('should upsert the doc twice', function () { - this.MongoManager.promises.upsertIntoDocCollection.should.have.been.calledWith( + this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( this.project_id, this.doc_id, this.rev, @@ -743,8 +740,7 @@ describe('DocManager', function () { version: this.version + 1, } ) - this.MongoManager.promises.upsertIntoDocCollection.should.have.been - .calledTwice + this.MongoManager.upsertIntoDocCollection.should.have.been.calledTwice }) it('should return the new rev', function () { diff --git a/services/docstore/test/unit/js/HttpControllerTests.js b/services/docstore/test/unit/js/HttpControllerTests.js index bf78696890..ab491ec150 100644 --- a/services/docstore/test/unit/js/HttpControllerTests.js +++ b/services/docstore/test/unit/js/HttpControllerTests.js @@ -14,7 +14,7 @@ describe('HttpController', function () { max_doc_length: 2 * 1024 * 1024, } this.DocArchiveManager = { - unArchiveAllDocs: sinon.stub().yields(), + unArchiveAllDocs: sinon.stub().returns(), } this.DocManager = {} this.HttpController = SandboxedModule.require(modulePath, { @@ -54,15 +54,13 @@ describe('HttpController', function () { describe('getDoc', function () { describe('without deleted docs', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId, doc_id: this.docId, } - this.DocManager.getFullDoc = sinon - .stub() - .callsArgWith(2, null, this.doc) - this.HttpController.getDoc(this.req, this.res, this.next) + this.DocManager.getFullDoc = sinon.stub().resolves(this.doc) + await this.HttpController.getDoc(this.req, this.res, this.next) }) it('should get the document with the version (including deleted)', function () { @@ -89,26 +87,24 @@ describe('HttpController', function () { project_id: this.projectId, doc_id: this.docId, } - this.DocManager.getFullDoc = sinon - .stub() - .callsArgWith(2, null, this.deletedDoc) + this.DocManager.getFullDoc = sinon.stub().resolves(this.deletedDoc) }) - it('should get the doc from the doc manager', function () { - this.HttpController.getDoc(this.req, this.res, this.next) + it('should get the doc from the doc manager', async function () { + await this.HttpController.getDoc(this.req, this.res, this.next) this.DocManager.getFullDoc .calledWith(this.projectId, this.docId) .should.equal(true) }) - it('should return 404 if the query string delete is not set ', function () { - this.HttpController.getDoc(this.req, this.res, this.next) + it('should return 404 if the query string delete is not set ', async function () { + await this.HttpController.getDoc(this.req, this.res, this.next) this.res.sendStatus.calledWith(404).should.equal(true) }) - it('should return the doc as JSON if include_deleted is set to true', function () { + it('should return the doc as JSON if include_deleted is set to true', async function () { this.req.query.include_deleted = 'true' - this.HttpController.getDoc(this.req, this.res, this.next) + await this.HttpController.getDoc(this.req, this.res, this.next) this.res.json .calledWith({ _id: this.docId, @@ -123,13 +119,15 @@ describe('HttpController', function () { }) describe('getRawDoc', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId, doc_id: this.docId, } - this.DocManager.getDocLines = sinon.stub().callsArgWith(2, null, this.doc) - this.HttpController.getRawDoc(this.req, this.res, this.next) + this.DocManager.getDocLines = sinon + .stub() + .resolves(this.doc.lines.join('\n')) + await this.HttpController.getRawDoc(this.req, this.res, this.next) }) it('should get the document without the version', function () { @@ -154,7 +152,7 @@ describe('HttpController', function () { describe('getAllDocs', function () { describe('normally', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } this.docs = [ { @@ -168,10 +166,8 @@ describe('HttpController', function () { rev: 4, }, ] - this.DocManager.getAllNonDeletedDocs = sinon - .stub() - .callsArgWith(2, null, this.docs) - this.HttpController.getAllDocs(this.req, this.res, this.next) + this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs) + await this.HttpController.getAllDocs(this.req, this.res, this.next) }) it('should get all the (non-deleted) docs', function () { @@ -199,7 +195,7 @@ describe('HttpController', function () { }) describe('with null lines', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } this.docs = [ { @@ -213,10 +209,8 @@ describe('HttpController', function () { rev: 4, }, ] - this.DocManager.getAllNonDeletedDocs = sinon - .stub() - .callsArgWith(2, null, this.docs) - this.HttpController.getAllDocs(this.req, this.res, this.next) + this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs) + await this.HttpController.getAllDocs(this.req, this.res, this.next) }) it('should return the doc with fallback lines', function () { @@ -238,7 +232,7 @@ describe('HttpController', function () { }) describe('with a null doc', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } this.docs = [ { @@ -253,10 +247,8 @@ describe('HttpController', function () { rev: 4, }, ] - this.DocManager.getAllNonDeletedDocs = sinon - .stub() - .callsArgWith(2, null, this.docs) - this.HttpController.getAllDocs(this.req, this.res, this.next) + this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs) + await this.HttpController.getAllDocs(this.req, this.res, this.next) }) it('should return the non null docs as JSON', function () { @@ -292,7 +284,7 @@ describe('HttpController', function () { describe('getAllRanges', function () { describe('normally', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } this.docs = [ { @@ -304,10 +296,8 @@ describe('HttpController', function () { ranges: { mock_ranges: 'two' }, }, ] - this.DocManager.getAllNonDeletedDocs = sinon - .stub() - .callsArgWith(2, null, this.docs) - this.HttpController.getAllRanges(this.req, this.res, this.next) + this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs) + await this.HttpController.getAllRanges(this.req, this.res, this.next) }) it('should get all the (non-deleted) doc ranges', function () { @@ -342,16 +332,17 @@ describe('HttpController', function () { }) describe('when the doc lines exist and were updated', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { lines: (this.lines = ['hello', 'world']), version: (this.version = 42), ranges: (this.ranges = { changes: 'mock' }), } + this.rev = 5 this.DocManager.updateDoc = sinon .stub() - .yields(null, true, (this.rev = 5)) - this.HttpController.updateDoc(this.req, this.res, this.next) + .resolves({ modified: true, rev: this.rev }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should update the document', function () { @@ -374,16 +365,17 @@ describe('HttpController', function () { }) describe('when the doc lines exist and were not updated', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { lines: (this.lines = ['hello', 'world']), version: (this.version = 42), ranges: {}, } + this.rev = 5 this.DocManager.updateDoc = sinon .stub() - .yields(null, false, (this.rev = 5)) - this.HttpController.updateDoc(this.req, this.res, this.next) + .resolves({ modified: false, rev: this.rev }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should return a modified status', function () { @@ -394,10 +386,12 @@ describe('HttpController', function () { }) describe('when the doc lines are not provided', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { version: 42, ranges: {} } - this.DocManager.updateDoc = sinon.stub().yields(null, false) - this.HttpController.updateDoc(this.req, this.res, this.next) + this.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should not update the document', function () { @@ -410,10 +404,12 @@ describe('HttpController', function () { }) describe('when the doc version are not provided', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { version: 42, lines: ['hello world'] } - this.DocManager.updateDoc = sinon.stub().yields(null, false) - this.HttpController.updateDoc(this.req, this.res, this.next) + this.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should not update the document', function () { @@ -426,10 +422,12 @@ describe('HttpController', function () { }) describe('when the doc ranges is not provided', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { lines: ['foo'], version: 42 } - this.DocManager.updateDoc = sinon.stub().yields(null, false) - this.HttpController.updateDoc(this.req, this.res, this.next) + this.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await this.HttpController.updateDoc(this.req, this.res, this.next) }) it('should not update the document', function () { @@ -442,13 +440,20 @@ describe('HttpController', function () { }) describe('when the doc body is too large', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { lines: (this.lines = Array(2049).fill('a'.repeat(1024))), version: (this.version = 42), ranges: (this.ranges = { changes: 'mock' }), } - this.HttpController.updateDoc(this.req, this.res, this.next) + this.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await this.HttpController.updateDoc(this.req, this.res, this.next) + }) + + it('should not update the document', function () { + this.DocManager.updateDoc.called.should.equal(false) }) it('should return a 413 (too large) response', function () { @@ -462,14 +467,14 @@ describe('HttpController', function () { }) describe('patchDoc', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId, doc_id: this.docId, } this.req.body = { name: 'foo.tex' } - this.DocManager.patchDoc = sinon.stub().yields(null) - this.HttpController.patchDoc(this.req, this.res, this.next) + this.DocManager.patchDoc = sinon.stub().resolves() + await this.HttpController.patchDoc(this.req, this.res, this.next) }) it('should delete the document', function () { @@ -484,11 +489,11 @@ describe('HttpController', function () { }) describe('with an invalid payload', function () { - beforeEach(function () { + beforeEach(async function () { this.req.body = { cannot: 'happen' } - this.DocManager.patchDoc = sinon.stub().yields(null) - this.HttpController.patchDoc(this.req, this.res, this.next) + this.DocManager.patchDoc = sinon.stub().resolves() + await this.HttpController.patchDoc(this.req, this.res, this.next) }) it('should log a message', function () { @@ -509,10 +514,10 @@ describe('HttpController', function () { }) describe('archiveAllDocs', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } - this.DocArchiveManager.archiveAllDocs = sinon.stub().callsArg(1) - this.HttpController.archiveAllDocs(this.req, this.res, this.next) + this.DocArchiveManager.archiveAllDocs = sinon.stub().resolves() + await this.HttpController.archiveAllDocs(this.req, this.res, this.next) }) it('should archive the project', function () { @@ -532,9 +537,12 @@ describe('HttpController', function () { }) describe('on success', function () { - beforeEach(function (done) { - this.res.sendStatus.callsFake(() => done()) - this.HttpController.unArchiveAllDocs(this.req, this.res, this.next) + beforeEach(async function () { + await this.HttpController.unArchiveAllDocs( + this.req, + this.res, + this.next + ) }) it('returns a 200', function () { @@ -543,12 +551,15 @@ describe('HttpController', function () { }) describe("when the archived rev doesn't match", function () { - beforeEach(function (done) { - this.res.sendStatus.callsFake(() => done()) - this.DocArchiveManager.unArchiveAllDocs.yields( + beforeEach(async function () { + this.DocArchiveManager.unArchiveAllDocs.rejects( new Errors.DocRevValueError('bad rev') ) - this.HttpController.unArchiveAllDocs(this.req, this.res, this.next) + await this.HttpController.unArchiveAllDocs( + this.req, + this.res, + this.next + ) }) it('returns a 409', function () { @@ -558,10 +569,10 @@ describe('HttpController', function () { }) describe('destroyProject', function () { - beforeEach(function () { + beforeEach(async function () { this.req.params = { project_id: this.projectId } - this.DocArchiveManager.destroyProject = sinon.stub().callsArg(1) - this.HttpController.destroyProject(this.req, this.res, this.next) + this.DocArchiveManager.destroyProject = sinon.stub().resolves() + await this.HttpController.destroyProject(this.req, this.res, this.next) }) it('should destroy the docs', function () { diff --git a/services/docstore/test/unit/js/MongoManagerTests.js b/services/docstore/test/unit/js/MongoManagerTests.js index 4f8467db76..b96b661df4 100644 --- a/services/docstore/test/unit/js/MongoManagerTests.js +++ b/services/docstore/test/unit/js/MongoManagerTests.js @@ -41,7 +41,7 @@ describe('MongoManager', function () { this.doc = { name: 'mock-doc' } this.db.docs.findOne = sinon.stub().resolves(this.doc) this.filter = { lines: true } - this.result = await this.MongoManager.promises.findDoc( + this.result = await this.MongoManager.findDoc( this.projectId, this.docId, this.filter @@ -70,11 +70,7 @@ describe('MongoManager', function () { describe('patchDoc', function () { beforeEach(async function () { this.meta = { name: 'foo.tex' } - await this.MongoManager.promises.patchDoc( - this.projectId, - this.docId, - this.meta - ) + await this.MongoManager.patchDoc(this.projectId, this.docId, this.meta) }) it('should pass the parameter along', function () { @@ -104,7 +100,7 @@ describe('MongoManager', function () { describe('with included_deleted = false', function () { beforeEach(async function () { - this.result = await this.MongoManager.promises.getProjectsDocs( + this.result = await this.MongoManager.getProjectsDocs( this.projectId, { include_deleted: false }, this.filter @@ -132,7 +128,7 @@ describe('MongoManager', function () { describe('with included_deleted = true', function () { beforeEach(async function () { - this.result = await this.MongoManager.promises.getProjectsDocs( + this.result = await this.MongoManager.getProjectsDocs( this.projectId, { include_deleted: true }, this.filter @@ -167,7 +163,7 @@ describe('MongoManager', function () { this.db.docs.find = sinon.stub().returns({ toArray: sinon.stub().resolves([this.doc1, this.doc2, this.doc3]), }) - this.result = await this.MongoManager.promises.getProjectsDeletedDocs( + this.result = await this.MongoManager.getProjectsDeletedDocs( this.projectId, this.filter ) @@ -203,7 +199,7 @@ describe('MongoManager', function () { }) it('should upsert the document', async function () { - await this.MongoManager.promises.upsertIntoDocCollection( + await this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, this.oldRev, @@ -223,7 +219,7 @@ describe('MongoManager', function () { it('should handle update error', async function () { this.db.docs.updateOne.rejects(this.stubbedErr) await expect( - this.MongoManager.promises.upsertIntoDocCollection( + this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, this.rev, @@ -235,7 +231,7 @@ describe('MongoManager', function () { }) it('should insert without a previous rev', async function () { - await this.MongoManager.promises.upsertIntoDocCollection( + await this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, null, @@ -254,7 +250,7 @@ describe('MongoManager', function () { it('should handle generic insert error', async function () { this.db.docs.insertOne.rejects(this.stubbedErr) await expect( - this.MongoManager.promises.upsertIntoDocCollection( + this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, null, @@ -266,7 +262,7 @@ describe('MongoManager', function () { it('should handle duplicate insert error', async function () { this.db.docs.insertOne.rejects({ code: 11000 }) await expect( - this.MongoManager.promises.upsertIntoDocCollection( + this.MongoManager.upsertIntoDocCollection( this.projectId, this.docId, null, @@ -280,7 +276,7 @@ describe('MongoManager', function () { beforeEach(async function () { this.projectId = new ObjectId() this.db.docs.deleteMany = sinon.stub().resolves() - await this.MongoManager.promises.destroyProject(this.projectId) + await this.MongoManager.destroyProject(this.projectId) }) it('should destroy all docs', function () { @@ -297,13 +293,13 @@ describe('MongoManager', function () { it('should not error when the rev has not changed', async function () { this.db.docs.findOne = sinon.stub().resolves({ rev: 1 }) - await this.MongoManager.promises.checkRevUnchanged(this.doc) + await this.MongoManager.checkRevUnchanged(this.doc) }) it('should return an error when the rev has changed', async function () { this.db.docs.findOne = sinon.stub().resolves({ rev: 2 }) await expect( - this.MongoManager.promises.checkRevUnchanged(this.doc) + this.MongoManager.checkRevUnchanged(this.doc) ).to.be.rejectedWith(Errors.DocModifiedError) }) @@ -311,14 +307,14 @@ describe('MongoManager', function () { this.db.docs.findOne = sinon.stub().resolves({ rev: 2 }) this.doc = { _id: new ObjectId(), name: 'mock-doc', rev: NaN } await expect( - this.MongoManager.promises.checkRevUnchanged(this.doc) + this.MongoManager.checkRevUnchanged(this.doc) ).to.be.rejectedWith(Errors.DocRevValueError) }) it('should return a value error if checked doc rev is NaN', async function () { this.db.docs.findOne = sinon.stub().resolves({ rev: NaN }) await expect( - this.MongoManager.promises.checkRevUnchanged(this.doc) + this.MongoManager.checkRevUnchanged(this.doc) ).to.be.rejectedWith(Errors.DocRevValueError) }) }) @@ -334,7 +330,7 @@ describe('MongoManager', function () { describe('complete doc', function () { beforeEach(async function () { - await this.MongoManager.promises.restoreArchivedDoc( + await this.MongoManager.restoreArchivedDoc( this.projectId, this.docId, this.archivedDoc @@ -364,7 +360,7 @@ describe('MongoManager', function () { describe('without ranges', function () { beforeEach(async function () { delete this.archivedDoc.ranges - await this.MongoManager.promises.restoreArchivedDoc( + await this.MongoManager.restoreArchivedDoc( this.projectId, this.docId, this.archivedDoc @@ -395,7 +391,7 @@ describe('MongoManager', function () { it('throws a DocRevValueError', async function () { this.db.docs.updateOne.resolves({ matchedCount: 0 }) await expect( - this.MongoManager.promises.restoreArchivedDoc( + this.MongoManager.restoreArchivedDoc( this.projectId, this.docId, this.archivedDoc From b946c2abff72510e406facd50f4fafb44ae2fcd2 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 15:42:42 +0100 Subject: [PATCH 153/259] Merge pull request #26304 from overleaf/bg-history-redis-clear-persist-time-on-persist add persist time handling to setPersistedVersion method GitOrigin-RevId: 5e115b49116ee4604e3e478c206c7e9cf147cbc8 --- .../storage/lib/chunk_store/redis.js | 11 ++++- .../storage/chunk_store_redis_backend.test.js | 44 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/services/history-v1/storage/lib/chunk_store/redis.js b/services/history-v1/storage/lib/chunk_store/redis.js index 9163536342..b43bdf8117 100644 --- a/services/history-v1/storage/lib/chunk_store/redis.js +++ b/services/history-v1/storage/lib/chunk_store/redis.js @@ -480,11 +480,12 @@ async function getNonPersistedChanges(projectId, baseVersion) { } rclient.defineCommand('set_persisted_version', { - numberOfKeys: 3, + numberOfKeys: 4, lua: ` local headVersionKey = KEYS[1] local persistedVersionKey = KEYS[2] - local changesKey = KEYS[3] + local persistTimeKey = KEYS[3] + local changesKey = KEYS[4] local newPersistedVersion = tonumber(ARGV[1]) local maxPersistedChanges = tonumber(ARGV[2]) @@ -509,6 +510,11 @@ rclient.defineCommand('set_persisted_version', { -- Set the persisted version redis.call('SET', persistedVersionKey, newPersistedVersion) + -- Clear the persist time if the persisted version now matches the head version + if newPersistedVersion == headVersion then + redis.call('DEL', persistTimeKey) + end + -- Calculate the starting index, to keep only maxPersistedChanges beyond the persisted version -- Using negative indexing to count backwards from the end of the list local startIndex = newPersistedVersion - headVersion - maxPersistedChanges @@ -535,6 +541,7 @@ async function setPersistedVersion(projectId, persistedVersion) { const keys = [ keySchema.headVersion({ projectId }), keySchema.persistedVersion({ projectId }), + keySchema.persistTime({ projectId }), keySchema.changes({ projectId }), ] diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js index 04d801c73d..d34cd701d0 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js @@ -699,6 +699,8 @@ describe('chunk buffer Redis backend', function () { }) describe('setPersistedVersion', function () { + const persistTime = Date.now() + 60 * 1000 // 1 minute from now + it('should return not_found when project does not exist', async function () { const result = await redisBackend.setPersistedVersion(projectId, 5) expect(result).to.equal('not_found') @@ -709,6 +711,7 @@ describe('chunk buffer Redis backend', function () { await setupState(projectId, { headVersion: 5, persistedVersion: null, + persistTime, changes: 5, }) }) @@ -720,6 +723,13 @@ describe('chunk buffer Redis backend', function () { expect(state.persistedVersion).to.equal(3) }) + it('should leave the persist time if the persisted version is not current', async function () { + const status = await redisBackend.setPersistedVersion(projectId, 3) + expect(status).to.equal('ok') + const state = await redisBackend.getState(projectId) + expect(state.persistTime).to.deep.equal(persistTime) // Persist time should remain unchanged + }) + it('should refuse to set a persisted version greater than the head version', async function () { await expect( redisBackend.setPersistedVersion(projectId, 10) @@ -728,6 +738,14 @@ describe('chunk buffer Redis backend', function () { const state = await redisBackend.getState(projectId) expect(state.persistedVersion).to.be.null }) + + it('should clear the persist time when the persisted version is current', async function () { + const status = await redisBackend.setPersistedVersion(projectId, 5) + expect(status).to.equal('ok') + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(5) + expect(state.persistTime).to.be.null // Persist time should be cleared + }) }) describe('when the persisted version is set', function () { @@ -735,6 +753,7 @@ describe('chunk buffer Redis backend', function () { await setupState(projectId, { headVersion: 5, persistedVersion: 3, + persistTime, changes: 5, }) }) @@ -746,6 +765,22 @@ describe('chunk buffer Redis backend', function () { expect(state.persistedVersion).to.equal(5) }) + it('should clear the persist time when the persisted version is current', async function () { + const status = await redisBackend.setPersistedVersion(projectId, 5) + expect(status).to.equal('ok') + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(5) + expect(state.persistTime).to.be.null // Persist time should be cleared + }) + + it('should leave the persist time if the persisted version is not current', async function () { + const status = await redisBackend.setPersistedVersion(projectId, 4) + expect(status).to.equal('ok') + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(4) + expect(state.persistTime).to.deep.equal(persistTime) // Persist time should remain unchanged + }) + it('should not decrease the persisted version', async function () { const status = await redisBackend.setPersistedVersion(projectId, 2) expect(status).to.equal('too_low') @@ -1183,6 +1218,8 @@ function makeChange() { * @param {object} params * @param {number} params.headVersion * @param {number | null} params.persistedVersion + * @param {number | null} params.persistTime - time when the project should be persisted + * @param {number | null} params.expireTime - time when the project should expire * @param {number} params.changes - number of changes to create * @return {Promise} dummy changes that have been created */ @@ -1194,7 +1231,12 @@ async function setupState(projectId, params) { params.persistedVersion ) } - + if (params.persistTime) { + await rclient.set(keySchema.persistTime({ projectId }), params.persistTime) + } + if (params.expireTime) { + await rclient.set(keySchema.expireTime({ projectId }), params.expireTime) + } const changes = [] for (let i = 1; i <= params.changes; i++) { const change = new Change( From 07b47606c10c3d97a96324deb9f7aa9e6b282982 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Tue, 10 Jun 2025 15:41:19 +0100 Subject: [PATCH 154/259] Disable script in production GitOrigin-RevId: 81fe077a5816a23fa20c78a6271fbdf62021e3b2 --- services/web/scripts/recurly/resync_subscriptions.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/web/scripts/recurly/resync_subscriptions.mjs b/services/web/scripts/recurly/resync_subscriptions.mjs index a0b5ca1438..4965835bf4 100644 --- a/services/web/scripts/recurly/resync_subscriptions.mjs +++ b/services/web/scripts/recurly/resync_subscriptions.mjs @@ -181,6 +181,13 @@ const setup = () => { } } +if (process.env.NODE_ENV !== 'development') { + console.warn( + 'This script can cause issues with manually amended subscriptions and can also exhaust our rate-limit with Recurly so is not intended to be run in production. Please use it in development environments only.' + ) + process.exit(1) +} + setup() await run() process.exit() From 5799d534a99cc57fa6fcd2594e9486065d1c4f0d Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Tue, 10 Jun 2025 15:42:01 +0100 Subject: [PATCH 155/259] Ensure we wait after processing each subscription GitOrigin-RevId: f6a184bc8a65934f24857cfc4f71f95574576b9d --- .../scripts/recurly/resync_subscriptions.mjs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/services/web/scripts/recurly/resync_subscriptions.mjs b/services/web/scripts/recurly/resync_subscriptions.mjs index 4965835bf4..6a03c8e3c1 100644 --- a/services/web/scripts/recurly/resync_subscriptions.mjs +++ b/services/web/scripts/recurly/resync_subscriptions.mjs @@ -89,20 +89,18 @@ const syncSubscription = async subscription => { ScriptLogger.recordMismatch(subscription, recurlySubscription) - if (!COMMIT) { - return + if (COMMIT) { + try { + await SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + recurlySubscription, + subscription, + {} + ) + } catch (error) { + await handleSyncSubscriptionError(subscription, error) + } } - try { - await SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - recurlySubscription, - subscription, - {} - ) - } catch (error) { - await handleSyncSubscriptionError(subscription, error) - return - } await setTimeout(80) } From b290e93441247fab8aa2785b306a3c33d6730ce7 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 10 Jun 2025 17:45:02 +0100 Subject: [PATCH 156/259] Merge pull request #26270 from overleaf/bg-history-redis-commit-change-manager replace redis logic in persistChanges with new commitChanges method GitOrigin-RevId: e06f9477b9d5548fa92ef87fb6e1f4f672001a35 --- .../api/controllers/project_import.js | 4 +- services/history-v1/storage/index.js | 1 + .../history-v1/storage/lib/commit_changes.js | 93 +++++++++++++++++++ .../history-v1/storage/lib/persist_changes.js | 67 +------------ 4 files changed, 97 insertions(+), 68 deletions(-) create mode 100644 services/history-v1/storage/lib/commit_changes.js diff --git a/services/history-v1/api/controllers/project_import.js b/services/history-v1/api/controllers/project_import.js index dee45efce8..e71eee93f9 100644 --- a/services/history-v1/api/controllers/project_import.js +++ b/services/history-v1/api/controllers/project_import.js @@ -21,7 +21,7 @@ const BatchBlobStore = storage.BatchBlobStore const BlobStore = storage.BlobStore const chunkStore = storage.chunkStore const HashCheckBlobStore = storage.HashCheckBlobStore -const persistChanges = storage.persistChanges +const commitChanges = storage.commitChanges const InvalidChangeError = storage.InvalidChangeError const render = require('./render') @@ -110,7 +110,7 @@ async function importChanges(req, res, next) { let result try { - result = await persistChanges(projectId, changes, limits, endVersion, { + result = await commitChanges(projectId, changes, limits, endVersion, { queueChangesInRedis: true, }) } catch (err) { diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index a07c98c026..82a51583be 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -9,6 +9,7 @@ exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') exports.persistBuffer = require('./lib/persist_buffer') +exports.commitChanges = require('./lib/commit_changes') exports.queueChanges = require('./lib/queue_changes') exports.ProjectArchive = require('./lib/project_archive') exports.streams = require('./lib/streams') diff --git a/services/history-v1/storage/lib/commit_changes.js b/services/history-v1/storage/lib/commit_changes.js new file mode 100644 index 0000000000..fa22e05bbf --- /dev/null +++ b/services/history-v1/storage/lib/commit_changes.js @@ -0,0 +1,93 @@ +// @ts-check + +'use strict' + +const metrics = require('@overleaf/metrics') +const redisBackend = require('./chunk_store/redis') +const logger = require('@overleaf/logger') +const queueChanges = require('./queue_changes') +const persistChanges = require('./persist_changes') + +/** + * @typedef {import('overleaf-editor-core').Change} Change + */ + +/** + * Handle incoming changes by processing them according to the specified options. + * @param {string} projectId + * @param {Change[]} changes + * @param {Object} limits + * @param {number} endVersion + * @param {Object} options + * @param {Boolean} [options.queueChangesInRedis] + * If true, queue the changes in Redis for testing purposes. + * @return {Promise.} + */ + +async function commitChanges( + projectId, + changes, + limits, + endVersion, + options = {} +) { + if (options.queueChangesInRedis) { + try { + await queueChanges(projectId, changes, endVersion) + await fakePersistRedisChanges(projectId, changes, endVersion) + } catch (err) { + logger.error({ err }, 'Chunk buffer verification failed') + } + } + const result = await persistChanges(projectId, changes, limits, endVersion) + return result +} + +/** + * Simulates the persistence of changes by verifying a given set of changes against + * what is currently stored as non-persisted in Redis, and then updates the + * persisted version number in Redis. + * + * @async + * @param {string} projectId - The ID of the project. + * @param {Change[]} changesToPersist - An array of changes that are expected to be + * persisted. These are used for verification + * against the changes currently in Redis. + * @param {number} baseVersion - The base version number from which to calculate + * the new persisted version. + * @returns {Promise} A promise that resolves when the persisted version + * in Redis has been updated. + */ +async function fakePersistRedisChanges( + projectId, + changesToPersist, + baseVersion +) { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + baseVersion + ) + + if ( + serializeChanges(nonPersistedChanges) === serializeChanges(changesToPersist) + ) { + metrics.inc('persist_redis_changes_verification', 1, { status: 'match' }) + } else { + logger.warn({ projectId }, 'mismatch of non-persisted changes from Redis') + metrics.inc('persist_redis_changes_verification', 1, { + status: 'mismatch', + }) + } + + const persistedVersion = baseVersion + nonPersistedChanges.length + await redisBackend.setPersistedVersion(projectId, persistedVersion) +} + +/** + * @param {Change[]} changes + */ +function serializeChanges(changes) { + return JSON.stringify(changes.map(change => change.toRaw())) +} + +module.exports = commitChanges diff --git a/services/history-v1/storage/lib/persist_changes.js b/services/history-v1/storage/lib/persist_changes.js index 95ffdc67d2..d2ca00053f 100644 --- a/services/history-v1/storage/lib/persist_changes.js +++ b/services/history-v1/storage/lib/persist_changes.js @@ -4,7 +4,6 @@ const _ = require('lodash') const logger = require('@overleaf/logger') -const metrics = require('@overleaf/metrics') const core = require('overleaf-editor-core') const Chunk = core.Chunk @@ -15,7 +14,6 @@ const chunkStore = require('./chunk_store') const { BlobStore } = require('./blob_store') const { InvalidChangeError } = require('./errors') const { getContentHash } = require('./content_hash') -const redisBackend = require('./chunk_store/redis') function countChangeBytes(change) { // Note: This is not quite accurate, because the raw change may contain raw @@ -57,18 +55,9 @@ Timer.prototype.elapsed = function () { * @param {core.Change[]} allChanges * @param {Object} limits * @param {number} clientEndVersion - * @param {Object} options - * @param {Boolean} [options.queueChangesInRedis] - * If true, queue the changes in Redis for testing purposes. * @return {Promise.} */ -async function persistChanges( - projectId, - allChanges, - limits, - clientEndVersion, - options = {} -) { +async function persistChanges(projectId, allChanges, limits, clientEndVersion) { assert.projectId(projectId) assert.array(allChanges) assert.maybe.object(limits) @@ -211,45 +200,6 @@ async function persistChanges( currentSnapshot.applyAll(currentChunk.getChanges()) } - async function queueChangesInRedis() { - const hollowSnapshot = currentSnapshot.clone() - // We're transforming a lazy snapshot to a hollow snapshot, so loadFiles() - // doesn't really need a blobStore, but its signature still requires it. - const blobStore = new BlobStore(projectId) - await hollowSnapshot.loadFiles('hollow', blobStore) - hollowSnapshot.applyAll(changesToPersist, { strict: true }) - const baseVersion = currentChunk.getEndVersion() - await redisBackend.queueChanges( - projectId, - hollowSnapshot, - baseVersion, - changesToPersist - ) - } - - async function fakePersistRedisChanges() { - const baseVersion = currentChunk.getEndVersion() - const nonPersistedChanges = await redisBackend.getNonPersistedChanges( - projectId, - baseVersion - ) - - if ( - serializeChanges(nonPersistedChanges) === - serializeChanges(changesToPersist) - ) { - metrics.inc('persist_redis_changes_verification', 1, { status: 'match' }) - } else { - logger.warn({ projectId }, 'mismatch of non-persisted changes from Redis') - metrics.inc('persist_redis_changes_verification', 1, { - status: 'mismatch', - }) - } - - const persistedVersion = baseVersion + nonPersistedChanges.length - await redisBackend.setPersistedVersion(projectId, persistedVersion) - } - async function extendLastChunkIfPossible() { const timer = new Timer() const changesPushed = await fillChunk(currentChunk, changesToPersist) @@ -298,14 +248,6 @@ async function persistChanges( const numberOfChangesToPersist = oldChanges.length await loadLatestChunk() - if (options.queueChangesInRedis) { - try { - await queueChangesInRedis() - await fakePersistRedisChanges() - } catch (err) { - logger.error({ err }, 'Chunk buffer verification failed') - } - } await extendLastChunkIfPossible() await createNewChunksAsNeeded() @@ -320,11 +262,4 @@ async function persistChanges( } } -/** - * @param {core.Change[]} changes - */ -function serializeChanges(changes) { - return JSON.stringify(changes.map(change => change.toRaw())) -} - module.exports = persistChanges From 6a951e2ff0c8e6eaa85f173f55dfed9b961126ad Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Wed, 11 Jun 2025 10:28:53 +0200 Subject: [PATCH 157/259] [web] Migrate general Pug pages to BS5 (2) (#26121) * Reapply "[web] Migrate general Pug pages to BS5 (#25937)" This reverts commit c0afd7db2dde6a051043ab3e85a969c1eeb7d6a3. * Fixup layouts in `404` and `closed` pages Oversight from https://github.com/overleaf/internal/pull/25937 * Use `.container-custom-sm` and `.container-custom-md` instead of inconsistent page widths * Copy error-pages.less * Convert error-pages.lss to SCSS * Revert changes to pug files * Nest CSS in `.error-container` so nothing leaks to other pages * Remove `font-family-serif` * Use CSS variables * Remove `padding: 0` from `.full-height`: it breaks the layout in BS5 * Fix error-actions buttons * Revert changes to .container-custom... * Update services/web/app/views/external/planned_maintenance.pug Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> * Update services/web/app/views/general/closed.pug Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> --------- Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> GitOrigin-RevId: 313f04782a72fae7cc66d36f9d6467bad135fd60 --- services/web/app/views/general/400.pug | 2 - services/web/app/views/general/404.pug | 3 - services/web/app/views/general/500.pug | 1 - services/web/app/views/general/closed.pug | 5 +- .../app/views/general/unsupported-browser.pug | 1 - .../web/app/views/layout/layout-no-js.pug | 2 +- .../stylesheets/bootstrap-5/base/layout.scss | 4 ++ .../stylesheets/bootstrap-5/pages/all.scss | 1 + .../bootstrap-5/pages/error-pages.scss | 56 +++++++++++++++++++ 9 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss diff --git a/services/web/app/views/general/400.pug b/services/web/app/views/general/400.pug index 9fc97823c5..fcc3007e6f 100644 --- a/services/web/app/views/general/400.pug +++ b/services/web/app/views/general/400.pug @@ -2,7 +2,6 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } - - bootstrap5PageStatus = 'disabled' block body body.full-height @@ -27,5 +26,4 @@ block body | . p.error-actions a.error-btn(href="javascript:history.back()") Back - |   a.btn.btn-secondary(href="/") Home diff --git a/services/web/app/views/general/404.pug b/services/web/app/views/general/404.pug index f4b5800cf2..f76eac6997 100644 --- a/services/web/app/views/general/404.pug +++ b/services/web/app/views/general/404.pug @@ -1,8 +1,5 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content.content-alt#main-content .container diff --git a/services/web/app/views/general/500.pug b/services/web/app/views/general/500.pug index 90cb1e3606..41e7440e0d 100644 --- a/services/web/app/views/general/500.pug +++ b/services/web/app/views/general/500.pug @@ -2,7 +2,6 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Something went wrong' } - - bootstrap5PageStatus = 'disabled' block body body.full-height diff --git a/services/web/app/views/general/closed.pug b/services/web/app/views/general/closed.pug index f4012997bd..b3f8ea2c04 100644 --- a/services/web/app/views/general/closed.pug +++ b/services/web/app/views/general/closed.pug @@ -1,13 +1,10 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content#main-content .container .row - .col-md-8.col-md-offset-2.text-center + .col-lg-8.offset-lg-2.text-center .page-header h1 Maintenance p diff --git a/services/web/app/views/general/unsupported-browser.pug b/services/web/app/views/general/unsupported-browser.pug index f8806cf8d2..a2c2216315 100644 --- a/services/web/app/views/general/unsupported-browser.pug +++ b/services/web/app/views/general/unsupported-browser.pug @@ -2,7 +2,6 @@ extends ../layout/layout-no-js block vars - metadata = { title: 'Unsupported browser' } - - bootstrap5PageStatus = 'disabled' block body body.full-height diff --git a/services/web/app/views/layout/layout-no-js.pug b/services/web/app/views/layout/layout-no-js.pug index c86721a810..b5bf3cc434 100644 --- a/services/web/app/views/layout/layout-no-js.pug +++ b/services/web/app/views/layout/layout-no-js.pug @@ -13,6 +13,6 @@ html(lang="en") link(rel="icon", href="/favicon.ico") if buildCssPath - link(rel="stylesheet", href=buildCssPath()) + link(rel="stylesheet", href=buildCssPath('', 5)) block body diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss index 0733a04304..4a8d517ba6 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss @@ -91,3 +91,7 @@ hr { .container-custom-sm { max-width: 400px; } + +.full-height { + height: 100%; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index a3adc98819..f10f00842d 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -37,6 +37,7 @@ @import 'editor/math-preview'; @import 'editor/references-search'; @import 'editor/editor-survey'; +@import 'error-pages'; @import 'website-redesign'; @import 'group-settings'; @import 'templates-v2'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss new file mode 100644 index 0000000000..ac21364f9d --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/error-pages.scss @@ -0,0 +1,56 @@ +.error-container { + display: flex; + align-items: center; + + &.full-height { + margin-top: calc(-1 * ($header-height + var(--spacing-08)) / 2); + } + + .error-details { + flex: 1 1 50%; + padding: var(--spacing-08); + max-width: 90%; + + @include media-breakpoint-up(sm) { + padding: var(--spacing-11); + } + } + + .error-status { + @include heading-lg; + + margin-bottom: var(--spacing-08); + color: var(--content-secondary); + } + + .error-description { + @include heading-sm; + + color: var(--content-disabled-dark); + margin-bottom: var(--spacing-08); + } + + .error-box { + background-color: var(--bg-light-tertiary); + padding: var(--spacing-04); + font-family: $font-family-monospace; + margin-bottom: var(--spacing-08); + } + + .error-actions { + margin-top: var(--spacing-11); + display: flex; + gap: var(--spacing-06); + } + + .error-btn { + @extend .btn; + @extend .btn-primary; + + display: block; + + @include media-breakpoint-up(sm) { + display: inline-block; + } + } +} From b3dc0097fd60da4aff16d908c614cccd13c8a1d6 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Wed, 11 Jun 2025 10:29:30 +0200 Subject: [PATCH 158/259] Merge pull request #26188 from overleaf/ac-bs5-fix-redundant-carets [web] Fix redundant carets in BS5 marketing pages GitOrigin-RevId: 479687d982db23e4f5f2efcc3f5f39bb78f0eb24 --- .../views/layout/navbar-marketing-bootstrap-5.pug | 3 --- .../components/emails/add-email/country-input.tsx | 2 +- .../stylesheets/bootstrap-5/components/all.scss | 1 + .../stylesheets/bootstrap-5/components/caret.scss | 15 +++++++++++++++ .../stylesheets/bootstrap-5/components/form.scss | 5 +++++ 5 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 services/web/frontend/stylesheets/bootstrap-5/components/caret.scss diff --git a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug index 92e2d4301d..c581ab29ce 100644 --- a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug +++ b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug @@ -55,7 +55,6 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ event-segmentation={"item": "admin", "location": "top-menu"} ) | Admin - span.caret +dropdown-menu.dropdown-menu-end if canDisplayAdminMenu +dropdown-menu-link-item()(href="/admin") Manage Site @@ -97,7 +96,6 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ event-segmentation={"item": item.trackingKey, "location": "top-menu"} ) | !{translate(item.text)} - span.caret +dropdown-menu.dropdown-menu-end each child in item.dropdown if child.divider @@ -173,7 +171,6 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ event-segmentation={"item": "account", "location": "top-menu"} ) | #{translate('Account')} - span.caret +dropdown-menu.dropdown-menu-end +dropdown-menu-item div.disabled.dropdown-item #{getSessionUser().email} diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx index f55344a2f2..e94b39c935 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx @@ -60,9 +60,9 @@ function Downshift({ setValue, inputRef }: CountryInputProps) { }, ref: inputRef, })} + append={} placeholder={t('country')} /> -
  • + setCollapsed(collapsed => !collapsed)} + id={id} + logEntry={logEntry} + /> + + {!collapsed && ( + <> +
    + + + )} +
    + ) +} + +export default memo(LogEntry) diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/old-error-pane.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/old-error-pane.tsx new file mode 100644 index 0000000000..7794747d30 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/error-logs/old-error-pane.tsx @@ -0,0 +1,10 @@ +import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer' +import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' + +export default function OldErrorPane() { + return ( + + + + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/integrations-panel/integrations-panel.tsx b/services/web/frontend/js/features/ide-redesign/components/integrations-panel/integrations-panel.tsx index d1e4358907..e477602e3e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/integrations-panel/integrations-panel.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/integrations-panel/integrations-panel.tsx @@ -1,7 +1,7 @@ import { ElementType } from 'react' import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' -import { RailPanelHeader } from '../rail' import { useTranslation } from 'react-i18next' +import RailPanelHeader from '../rail-panel-header' const integrationPanelComponents = importOverleafModules( 'integrationPanelComponents' diff --git a/services/web/frontend/js/features/ide-redesign/components/rail-panel-header.tsx b/services/web/frontend/js/features/ide-redesign/components/rail-panel-header.tsx new file mode 100644 index 0000000000..94ac2f42af --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/rail-panel-header.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import { useRailContext } from '../contexts/rail-context' +import OLIconButton from '@/features/ui/components/ol/ol-icon-button' +import React from 'react' + +export default function RailPanelHeader({ + title, + actions, +}: { + title: string + actions?: React.ReactNode[] +}) { + const { t } = useTranslation() + const { handlePaneCollapse } = useRailContext() + return ( +
    +

    {title}

    + +
    + {actions} + +
    +
    + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/rail.tsx b/services/web/frontend/js/features/ide-redesign/components/rail.tsx index 9bd70ac4bb..5edcba55a9 100644 --- a/services/web/frontend/js/features/ide-redesign/components/rail.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/rail.tsx @@ -6,7 +6,7 @@ import MaterialIcon, { } from '@/shared/components/material-icon' import { Panel } from 'react-resizable-panels' import { useLayoutContext } from '@/shared/context/layout-context' -import { ErrorIndicator, ErrorPane } from './errors' +import ErrorIndicator from './error-logs/error-indicator' import { RailModalKey, RailTabKey, @@ -39,6 +39,10 @@ import { hasFullProjectSearch, } from './full-project-search-panel' import { sendSearchEvent } from '@/features/event-tracking/search-events' +import ErrorLogsPanel from './error-logs/error-logs-panel' +import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' +import OldErrorPane from './error-logs/old-error-pane' +import { useFeatureFlag } from '@/shared/context/split-test-context' type RailElement = { icon: AvailableUnfilledIcon @@ -47,6 +51,7 @@ type RailElement = { indicator?: ReactElement title: string hide?: boolean + disabled?: boolean } type RailActionButton = { @@ -96,6 +101,8 @@ export const RailLayout = () => { togglePane, setResizing, } = useRailContext() + const { logEntries } = useCompileContext() + const errorLogsDisabled = !logEntries const { view, setLeftMenuShown } = useLayoutContext() @@ -103,6 +110,8 @@ export const RailLayout = () => { const isHistoryView = view === 'history' + const newErrorlogs = useFeatureFlag('new-editor-error-logs-redesign') + const railTabs: RailElement[] = useMemo( () => [ { @@ -142,11 +151,12 @@ export const RailLayout = () => { key: 'errors', icon: 'report', title: t('error_log'), - component: , + component: newErrorlogs ? : , indicator: , + disabled: errorLogsDisabled, }, ], - [t] + [t, errorLogsDisabled, newErrorlogs] ) const railActions: RailAction[] = useMemo( @@ -217,7 +227,7 @@ export const RailLayout = () => {