Просмотр исходного кода

[web] update the projects lastUpdated timestamp when changing file-tree (#24867)

* [misc] freeze time before any other unit test setup steps

Freezing it after other work (notably sandboxed-module imports) will
result in flaky tests.

* [web] update the projects lastUpdated timestamp when changing file-tree

GitOrigin-RevId: b82b2ff74dc31886f3c4bd300375117eead6e0cd
Jakob Ackermann 1 год назад
Родитель
Сommit
f0edc7ba00
21 измененных файлов с 357 добавлено и 165 удалено
  1. 1 1
      services/real-time/test/unit/js/ConnectedUsersManagerTests.js
  2. 3 1
      services/web/app/src/Features/Editor/EditorController.js
  3. 7 4
      services/web/app/src/Features/History/RestoreManager.js
  4. 53 30
      services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js
  5. 48 40
      services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js
  6. 5 1
      services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs
  7. 3 2
      services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js
  8. 3 3
      services/web/app/src/Features/Uploads/ProjectUploadController.mjs
  9. 4 2
      services/web/scripts/check_project_files.js
  10. 4 2
      services/web/scripts/delete_dangling_file_refs.mjs
  11. 29 4
      services/web/scripts/fix_malformed_filetree.mjs
  12. 2 1
      services/web/scripts/fix_oversized_docs.mjs
  13. 53 3
      services/web/test/acceptance/src/MalformedFiletreesTests.mjs
  14. 2 1
      services/web/test/unit/src/Compile/ClsiManagerTests.js
  15. 9 2
      services/web/test/unit/src/Editor/EditorControllerTests.js
  16. 5 4
      services/web/test/unit/src/History/RestoreManagerTests.js
  17. 69 23
      services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js
  18. 52 38
      services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js
  19. 1 1
      services/web/test/unit/src/Subscription/RecurlyWrapperTests.js
  20. 2 1
      services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandlerTests.mjs
  21. 2 1
      services/web/test/unit/src/Uploads/ProjectUploadControllerTests.mjs

+ 1 - 1
services/real-time/test/unit/js/ConnectedUsersManagerTests.js

@@ -20,6 +20,7 @@ const tk = require('timekeeper')
 
 describe('ConnectedUsersManager', function () {
   beforeEach(function () {
+    tk.freeze(new Date())
     this.settings = {
       redis: {
         realtime: {
@@ -56,7 +57,6 @@ describe('ConnectedUsersManager', function () {
         return this.rClient
       },
     }
-    tk.freeze(new Date())
     this.Metrics = {
       inc: sinon.stub(),
       histogram: sinon.stub(),

+ 3 - 1
services/web/app/src/Features/Editor/EditorController.js

@@ -307,6 +307,7 @@ const EditorController = {
       projectId,
       folderId,
       folderName,
+      userId,
       (err, folder, folderId) => {
         if (err) {
           OError.tag(err, 'could not add folder', {
@@ -333,11 +334,12 @@ const EditorController = {
     )
   },
 
-  mkdirp(projectId, path, callback) {
+  mkdirp(projectId, path, userId, callback) {
     logger.debug({ projectId, path }, "making directories if they don't exist")
     ProjectEntityUpdateHandler.mkdirp(
       projectId,
       path,
+      userId,
       (err, newFolders, lastFolder) => {
         if (err) {
           OError.tag(err, 'could not mkdirp', {

+ 7 - 4
services/web/app/src/Features/History/RestoreManager.js

@@ -33,7 +33,8 @@ const RestoreManager = {
     }
     const parentFolderId = await RestoreManager._findOrCreateFolder(
       projectId,
-      dirname
+      dirname,
+      userId
     )
     const addEntityWithName = async name =>
       await FileSystemImportManager.promises.addEntity(
@@ -71,7 +72,8 @@ const RestoreManager = {
     }
     const parentFolderId = await RestoreManager._findOrCreateFolder(
       projectId,
-      dirname
+      dirname,
+      userId
     )
     const file = await ProjectLocator.promises
       .findElementByPath({
@@ -264,10 +266,11 @@ const RestoreManager = {
     }
   },
 
-  async _findOrCreateFolder(projectId, dirname) {
+  async _findOrCreateFolder(projectId, dirname, userId) {
     const { lastFolder } = await EditorController.promises.mkdirp(
       projectId,
-      dirname
+      dirname,
+      userId
     )
     return lastFolder?._id
   },

+ 53 - 30
services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js

@@ -105,7 +105,7 @@ function wrapWithLock(methodWithoutLock) {
   return methodWithLock
 }
 
-async function addDoc(projectId, folderId, doc) {
+async function addDoc(projectId, folderId, doc, userId) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     {
@@ -119,12 +119,13 @@ async function addDoc(projectId, folderId, doc) {
     project,
     folderId,
     doc,
-    'doc'
+    'doc',
+    userId
   )
   return { result, project: newProject }
 }
 
-async function addFile(projectId, folderId, fileRef) {
+async function addFile(projectId, folderId, fileRef, userId) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     { rootFolder: true, name: true, overleaf: true }
@@ -134,23 +135,24 @@ async function addFile(projectId, folderId, fileRef) {
     project,
     folderId,
     fileRef,
-    'file'
+    'file',
+    userId
   )
   return { result, project: newProject }
 }
 
-async function addFolder(projectId, parentFolderId, folderName) {
+async function addFolder(projectId, parentFolderId, folderName, userId) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     { rootFolder: true, name: true, overleaf: true }
   )
   parentFolderId = _confirmFolder(project, parentFolderId)
   const folder = new Folder({ name: folderName })
-  await _putElement(project, parentFolderId, folder, 'folder')
+  await _putElement(project, parentFolderId, folder, 'folder', userId)
   return { folder, parentFolderId }
 }
 
-async function replaceFileWithNew(projectId, fileId, newFileRef) {
+async function replaceFileWithNew(projectId, fileId, newFileRef, userId) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     { rootFolder: true, name: true, overleaf: true }
@@ -169,6 +171,8 @@ async function replaceFileWithNew(projectId, fileId, newFileRef) {
         [`${path.mongo}.created`]: new Date(),
         [`${path.mongo}.linkedFileData`]: newFileRef.linkedFileData,
         [`${path.mongo}.hash`]: newFileRef.hash,
+        lastUpdated: new Date(),
+        lastUpdatedBy: userId,
       },
       $inc: {
         version: 1,
@@ -194,7 +198,7 @@ async function replaceFileWithNew(projectId, fileId, newFileRef) {
   return { oldFileRef: fileRef, project, path, newProject, newFileRef }
 }
 
-async function replaceDocWithFile(projectId, docId, fileRef) {
+async function replaceDocWithFile(projectId, docId, fileRef, userId) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     { rootFolder: true, name: true, overleaf: true }
@@ -215,6 +219,7 @@ async function replaceDocWithFile(projectId, docId, fileRef) {
         [`${folderMongoPath}.fileRefs`]: fileRef,
       },
       $inc: { version: 1 },
+      $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
     },
     { new: true }
   ).exec()
@@ -227,7 +232,7 @@ async function replaceDocWithFile(projectId, docId, fileRef) {
   return newProject
 }
 
-async function replaceFileWithDoc(projectId, fileId, newDoc) {
+async function replaceFileWithDoc(projectId, fileId, newDoc, userId) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     { rootFolder: true, name: true, overleaf: true }
@@ -248,6 +253,7 @@ async function replaceFileWithDoc(projectId, fileId, newDoc) {
         [`${folderMongoPath}.docs`]: newDoc,
       },
       $inc: { version: 1 },
+      $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
     },
     { new: true }
   ).exec()
@@ -260,7 +266,7 @@ async function replaceFileWithDoc(projectId, fileId, newDoc) {
   return newProject
 }
 
-async function mkdirp(projectId, path, options = {}) {
+async function mkdirp(projectId, path, userId, options = {}) {
   // defaults to case insensitive paths, use options {exactCaseMatch:true}
   // to make matching case-sensitive
   const folders = path.split('/').filter(folder => folder.length !== 0)
@@ -289,7 +295,7 @@ async function mkdirp(projectId, path, options = {}) {
       // Folder couldn't be found. Create it.
       const parentFolderId = lastFolder && lastFolder._id
       const { folder: newFolder, parentFolderId: newParentFolderId } =
-        await addFolder(projectId, parentFolderId, folderName)
+        await addFolder(projectId, parentFolderId, folderName, userId)
       newFolder.parentFolder_id = newParentFolderId
       lastFolder = newFolder
       newFolders.push(newFolder)
@@ -298,7 +304,13 @@ async function mkdirp(projectId, path, options = {}) {
   return { folder: lastFolder, newFolders }
 }
 
-async function moveEntity(projectId, entityId, destFolderId, entityType) {
+async function moveEntity(
+  projectId,
+  entityId,
+  destFolderId,
+  entityType,
+  userId
+) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     { rootFolder: true, name: true, overleaf: true }
@@ -326,7 +338,8 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) {
     project,
     destFolderId,
     entity,
-    entityType
+    entityType,
+    userId
   )
   // Note: putElement always pushes onto the end of an
   // array so it will never change an existing mongo
@@ -337,10 +350,10 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) {
   // is done by _checkValidMove above) because that
   // would lead to it being deleted.
   const newProject = await _removeElementFromMongoArray(
-    Project,
     projectId,
     entityPath.mongo,
-    entityId
+    entityId,
+    userId
   )
   const { docs: newDocs, files: newFiles } =
     ProjectEntityHandler.getAllEntitiesFromProject(newProject)
@@ -375,7 +388,7 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) {
   return { project, startPath, endPath, rev: entity.rev, changes }
 }
 
-async function deleteEntity(projectId, entityId, entityType) {
+async function deleteEntity(projectId, entityId, entityType, userId) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     { name: true, rootFolder: true, overleaf: true, rootDoc_id: true }
@@ -399,22 +412,16 @@ async function deleteEntity(projectId, entityId, entityType) {
     type: entityType,
   })
   const newProject = await _removeElementFromMongoArray(
-    Project,
     projectId,
     path.mongo,
     entityId,
+    userId,
     deleteRootDoc
   )
   return { entity, path, projectBeforeDeletion: project, newProject }
 }
 
-async function renameEntity(
-  projectId,
-  entityId,
-  entityType,
-  newName,
-  callback
-) {
+async function renameEntity(projectId, entityId, entityType, newName, userId) {
   const project = await ProjectGetter.promises.getProjectWithoutLock(
     projectId,
     { rootFolder: true, name: true, overleaf: true }
@@ -445,7 +452,14 @@ async function renameEntity(
   // we need to increment the project version number for any structure change
   const newProject = await Project.findOneAndUpdate(
     { _id: projectId, [entPath.mongo]: { $exists: true } },
-    { $set: { [`${entPath.mongo}.name`]: newName }, $inc: { version: 1 } },
+    {
+      $set: {
+        [`${entPath.mongo}.name`]: newName,
+        lastUpdated: new Date(),
+        lastUpdatedBy: userId,
+      },
+      $inc: { version: 1 },
+    },
     { new: true }
   ).exec()
   if (newProject == null) {
@@ -478,10 +492,10 @@ async function _insertDeletedFileReference(projectId, fileRef) {
 }
 
 async function _removeElementFromMongoArray(
-  model,
   modelId,
   path,
   elementId,
+  userId,
   deleteRootDoc = false
 ) {
   const nonArrayPath = path.slice(0, path.lastIndexOf('.'))
@@ -490,11 +504,12 @@ async function _removeElementFromMongoArray(
   const update = {
     $pull: { [nonArrayPath]: { _id: elementId } },
     $inc: { version: 1 },
+    $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
   }
   if (deleteRootDoc) {
     update.$unset = { rootDoc_id: 1 }
   }
-  return model.findOneAndUpdate(query, update, options).exec()
+  return Project.findOneAndUpdate(query, update, options).exec()
 }
 
 function _countElements(project) {
@@ -522,7 +537,7 @@ function _countElements(project) {
   return countFolder(project.rootFolder[0])
 }
 
-async function _putElement(project, folderId, element, type) {
+async function _putElement(project, folderId, element, type, userId) {
   if (element == null || element._id == null) {
     logger.warn(
       { projectId: project._id, folderId, element, type },
@@ -578,7 +593,11 @@ async function _putElement(project, folderId, element, type) {
   const mongoPath = `${path.mongo}.${pathSegment}`
   const newProject = await Project.findOneAndUpdate(
     { _id: project._id, [path.mongo]: { $exists: true } },
-    { $push: { [mongoPath]: element }, $inc: { version: 1 } },
+    {
+      $push: { [mongoPath]: element },
+      $inc: { version: 1 },
+      $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
+    },
     { new: true }
   ).exec()
   if (newProject == null) {
@@ -709,7 +728,11 @@ async function createNewFolderStructure(projectId, docEntries, fileEntries) {
         'rootFolder.0.files.0': { $exists: false },
       },
       {
-        $set: { rootFolder: [rootFolder] },
+        $set: {
+          rootFolder: [rootFolder],
+          // NOTE: Do not set lastUpdated/lastUpdatedBy here. They are both set when creating the initial record.
+          // The newly created clsi-cache record uses the lastUpdated timestamp of the initial record. Updating the lastUpdated timestamp here invalidates the cache record.
+        },
         $inc: { version: 1 },
       },
       {

+ 48 - 40
services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js

@@ -296,7 +296,8 @@ const addDocWithRanges = wrapWithLock({
       await ProjectEntityUpdateHandler._addDocAndSendToTpds(
         projectId,
         folderId,
-        doc
+        doc,
+        userId
       )
     const docPath = result?.path?.fileSystem
     const projectHistoryId = project?.overleaf?.history?.id
@@ -364,7 +365,8 @@ const addFile = wrapWithLock({
       await ProjectEntityUpdateHandler._addFileAndSendToTpds(
         projectId,
         folderId,
-        fileRef
+        fileRef,
+        userId
       )
     const projectHistoryId = project.overleaf?.history?.id
     const newFiles = [
@@ -382,12 +384,6 @@ const addFile = wrapWithLock({
       { newFiles, newProject: project },
       source
     )
-
-    ProjectUpdateHandler.promises
-      .markAsUpdated(projectId, new Date(), userId)
-      .catch(error => {
-        logger.error({ error }, 'failed to mark project as updated')
-      })
     return { fileRef, folderId, createdBlob }
   },
 })
@@ -434,7 +430,8 @@ const upsertDoc = wrapWithLock(
         await ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc(
           projectId,
           existingFile._id,
-          doc
+          doc,
+          userId
         )
 
       await TpdsUpdateSender.promises.addDoc({
@@ -616,7 +613,8 @@ const upsertFile = wrapWithLock({
         await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
           projectId,
           existingDoc._id,
-          fileRef
+          fileRef,
+          userId
         )
       const projectHistoryId = project.overleaf?.history?.id
       await TpdsUpdateSender.promises.addFile({
@@ -699,7 +697,8 @@ const upsertDocWithPath = wrapWithLock(
     const { newFolders, folder } =
       await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
         projectId,
-        folderPath
+        folderPath,
+        userId
       )
     const { isNew, doc } =
       await ProjectEntityUpdateHandler.promises.upsertDoc.withoutLock(
@@ -772,7 +771,8 @@ const upsertFileWithPath = wrapWithLock({
     const { newFolders, folder } =
       await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
         projectId,
-        folderPath
+        folderPath,
+        userId
       )
     // this calls directly into the upsertFile main task (without the beforeLock part)
     const {
@@ -818,7 +818,8 @@ const deleteEntity = wrapWithLock(
       await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
         projectId,
         entityId,
-        entityType
+        entityType,
+        userId
       )
     const subtreeListing = await ProjectEntityUpdateHandler._cleanUpEntity(
       projectBeforeDeletion,
@@ -866,7 +867,7 @@ const deleteEntityWithPath = wrapWithLock(
   }
 )
 
-const mkdirp = wrapWithLock(async function (projectId, path) {
+const mkdirp = wrapWithLock(async function (projectId, path, userId) {
   for (const folder of path.split('/')) {
     if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
       throw new Errors.InvalidNameError('invalid element name')
@@ -875,32 +876,37 @@ const mkdirp = wrapWithLock(async function (projectId, path) {
   return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
     projectId,
     path,
+    userId,
     { exactCaseMatch: false }
   )
 })
 
-const mkdirpWithExactCase = wrapWithLock(async function (projectId, path) {
-  for (const folder of path.split('/')) {
-    if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
-      throw new Errors.InvalidNameError('invalid element name')
+const mkdirpWithExactCase = wrapWithLock(
+  async function (projectId, path, userId) {
+    for (const folder of path.split('/')) {
+      if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
+        throw new Errors.InvalidNameError('invalid element name')
+      }
     }
+    return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
+      projectId,
+      path,
+      userId,
+      { exactCaseMatch: true }
+    )
   }
-  return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
-    projectId,
-    path,
-    { exactCaseMatch: true }
-  )
-})
+)
 
 const addFolder = wrapWithLock(
-  async function (projectId, parentFolderId, folderName) {
+  async function (projectId, parentFolderId, folderName, userId) {
     if (!SafePath.isCleanFilename(folderName)) {
       throw new Errors.InvalidNameError('invalid element name')
     }
     return await ProjectEntityMongoUpdateHandler.promises.addFolder(
       projectId,
       parentFolderId,
-      folderName
+      folderName,
+      userId
     )
   }
 )
@@ -929,7 +935,8 @@ const moveEntity = wrapWithLock(
         projectId,
         entityId,
         destFolderId,
-        entityType
+        entityType,
+        userId
       )
 
     const projectHistoryId = project.overleaf?.history?.id
@@ -988,7 +995,8 @@ const renameEntity = wrapWithLock(
         projectId,
         entityId,
         entityType,
-        newName
+        newName,
+        userId
       )
 
     const projectHistoryId = project.overleaf?.history?.id
@@ -1115,7 +1123,8 @@ const convertDocToFile = wrapWithLock({
       await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
         projectId,
         doc._id,
-        fileRef
+        fileRef,
+        userId
       )
     const projectHistoryId = project.overleaf?.history?.id
     await DocumentUpdaterHandler.promises.updateProjectStructure(
@@ -1276,14 +1285,15 @@ const ProjectEntityUpdateHandler = {
     appendToDocWithPath: appendToDoc,
   },
 
-  async _addDocAndSendToTpds(projectId, folderId, doc) {
+  async _addDocAndSendToTpds(projectId, folderId, doc, userId) {
     let result, project
     try {
       ;({ result, project } =
         await ProjectEntityMongoUpdateHandler.promises.addDoc(
           projectId,
           folderId,
-          doc
+          doc,
+          userId
         ))
     } catch (err) {
       throw OError.tag(err, 'error adding file with project', {
@@ -1328,14 +1338,15 @@ const ProjectEntityUpdateHandler = {
     }
   },
 
-  async _addFileAndSendToTpds(projectId, folderId, fileRef) {
+  async _addFileAndSendToTpds(projectId, folderId, fileRef, userId) {
     let result, project
     try {
       ;({ result, project } =
         await ProjectEntityMongoUpdateHandler.promises.addFile(
           projectId,
           folderId,
-          fileRef
+          fileRef,
+          userId
         ))
     } catch (err) {
       throw OError.tag(err, 'error adding file with project', {
@@ -1382,7 +1393,8 @@ const ProjectEntityUpdateHandler = {
     } = await ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew(
       projectId,
       fileId,
-      newFileRef
+      newFileRef,
+      userId
     )
 
     const oldFiles = [
@@ -1410,11 +1422,6 @@ const ProjectEntityUpdateHandler = {
       projectName: project.name,
       folderId,
     })
-    ProjectUpdateHandler.promises
-      .markAsUpdated(projectId, new Date(), userId)
-      .catch(error => {
-        logger.error({ error }, 'failed to mark project as updated')
-      })
 
     await DocumentUpdaterHandler.promises.updateProjectStructure(
       projectId,
@@ -1538,7 +1545,8 @@ const ProjectEntityUpdateHandler = {
           projectId,
           entityId,
           entityType,
-          rename.newName
+          rename.newName,
+          null // unset lastUpdatedBy
         )
 
       // update the renamed entity for the resync

+ 5 - 1
services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs

@@ -180,7 +180,11 @@ async function createFolder(userId, projectId, projectName, path) {
     return null
   }
 
-  const folder = await UpdateMerger.promises.createFolder(project._id, path)
+  const folder = await UpdateMerger.promises.createFolder(
+    project._id,
+    path,
+    userId
+  )
   return {
     folderId: folder._id,
     parentFolderId: folder.parentFolder_id,

+ 3 - 2
services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js

@@ -176,10 +176,11 @@ async function _readFileIntoTextArray(path) {
   return lines
 }
 
-async function createFolder(projectId, path) {
+async function createFolder(projectId, path, userId) {
   const { lastFolder: folder } = await EditorController.promises.mkdirp(
     projectId,
-    path
+    path,
+    userId
   )
   return folder
 }

+ 3 - 3
services/web/app/src/Features/Uploads/ProjectUploadController.mjs

@@ -68,6 +68,7 @@ async function uploadFile(req, res, next) {
   const name = req.body.name
   const path = req.file?.path
   const projectId = req.params.Project_id
+  const userId = SessionManager.getLoggedInUserId(req.session)
   let { folder_id: folderId } = req.query
   if (name == null || name.length === 0 || name.length > 150) {
     return res.status(422).json({
@@ -87,13 +88,12 @@ async function uploadFile(req, res, next) {
     })
     const { lastFolder } = await EditorController.promises.mkdirp(
       projectId,
-      Path.dirname(Path.join('/', path.fileSystem, relativePath))
+      Path.dirname(Path.join('/', path.fileSystem, relativePath)),
+      userId
     )
     folderId = lastFolder._id
   }
 
-  const userId = SessionManager.getLoggedInUserId(req.session)
-
   return FileSystemImportManager.addEntity(
     userId,
     projectId,

+ 4 - 2
services/web/scripts/check_project_files.js

@@ -136,7 +136,8 @@ async function createRecoveryFolder(projectId) {
   const recoveryFolder = `recovered-${Date.now()}`
   const { folder } = await ProjectEntityMongoUpdateHandler.promises.mkdirp(
     new ObjectId(projectId),
-    recoveryFolder
+    recoveryFolder,
+    null // unset lastUpdatedBy
   )
   console.log('Created recovery folder:', folder._id.toString())
   return folder
@@ -149,7 +150,8 @@ async function restoreMissingDocs(projectId, folder, missingDocs) {
       await ProjectEntityMongoUpdateHandler.promises.addDoc(
         new ObjectId(projectId),
         folder._id,
-        doc
+        doc,
+        null // unset lastUpdatedBy
       )
       console.log('Restored doc to filetree:', doc._id.toString())
     } catch (err) {

+ 4 - 2
services/web/scripts/delete_dangling_file_refs.mjs

@@ -104,7 +104,8 @@ async function deleteDoc(projectId, docId) {
     await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
       projectId,
       docId,
-      'doc'
+      'doc',
+      null // unset lastUpdatedBy
     )
   }
 }
@@ -115,7 +116,8 @@ async function deleteFile(projectId, fileId) {
     await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
       projectId,
       fileId,
-      'file'
+      'file',
+      null // unset lastUpdatedBy
     )
   }
 }

+ 29 - 4
services/web/scripts/fix_malformed_filetree.mjs

@@ -17,6 +17,7 @@ import fs from 'node:fs'
 import logger from '@overleaf/logger'
 
 const { ObjectId } = mongodb
+const lastUpdated = new Date()
 
 const argv = minimist(process.argv.slice(2), {
   string: ['logs'],
@@ -157,6 +158,8 @@ async function fixRootFolder(projectId) {
             fileRefs: [],
           },
         ],
+        lastUpdated,
+        lastUpdatedBy: null, // unset lastUpdatedBy
       },
     }
   )
@@ -185,6 +188,10 @@ async function removeNulls(projectId, _id) {
         [`${path}.docs`]: null,
         [`${path}.fileRefs`]: null,
       },
+      $set: {
+        lastUpdated,
+        lastUpdatedBy: null, // unset lastUpdatedBy
+      },
     }
   )
   return result.modifiedCount
@@ -196,7 +203,7 @@ async function removeNulls(projectId, _id) {
 async function fixArray(projectId, path) {
   const result = await db.projects.updateOne(
     { _id: new ObjectId(projectId), [path]: { $not: { $type: 'array' } } },
-    { $set: { [path]: [] } }
+    { $set: { [path]: [], lastUpdated, lastUpdatedBy: null } }
   )
   return result.modifiedCount
 }
@@ -207,7 +214,13 @@ async function fixArray(projectId, path) {
 async function fixFolderId(projectId, path) {
   const result = await db.projects.updateOne(
     { _id: new ObjectId(projectId), [path]: { $exists: false } },
-    { $set: { [path]: new ObjectId() } }
+    {
+      $set: {
+        [path]: new ObjectId(),
+        lastUpdated,
+        lastUpdatedBy: null, // unset lastUpdatedBy
+      },
+    }
   )
   return result.modifiedCount
 }
@@ -218,7 +231,13 @@ async function fixFolderId(projectId, path) {
 async function removeElementsWithoutIds(projectId, path) {
   const result = await db.projects.updateOne(
     { _id: new ObjectId(projectId), [path]: { $type: 'array' } },
-    { $pull: { [path]: { _id: null } } }
+    {
+      $pull: { [path]: { _id: null } },
+      $set: {
+        lastUpdated,
+        lastUpdatedBy: null, // unset lastUpdatedBy
+      },
+    }
   )
   return result.modifiedCount
 }
@@ -245,7 +264,13 @@ async function fixName(projectId, _id) {
   const pathToName = `${path}.name`
   const result = await db.projects.updateOne(
     { _id: new ObjectId(projectId), [pathToName]: { $in: [null, ''] } },
-    { $set: { [pathToName]: name } }
+    {
+      $set: {
+        [pathToName]: name,
+        lastUpdated,
+        lastUpdatedBy: null, // unset lastUpdatedBy
+      },
+    }
   )
   return result.modifiedCount
 }

+ 2 - 1
services/web/scripts/fix_oversized_docs.mjs

@@ -76,7 +76,8 @@ async function processDoc(projectId, docId) {
       await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
         new ObjectId(projectId),
         new ObjectId(docId),
-        fileRef
+        fileRef,
+        null // unset lastUpdatedBy
       )
       await deleteDocFromMongo(projectId, doc)
       await deleteDocFromRedis(projectId, docId)

+ 53 - 3
services/web/test/acceptance/src/MalformedFiletreesTests.mjs

@@ -5,6 +5,10 @@ import logger from '@overleaf/logger'
 import { filterOutput } from './helpers/settings.mjs'
 import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
 
+const lastUpdated = new Date(42)
+const lastUpdatedBy = new ObjectId()
+const lastUpdatedChanged = new Date(1337)
+
 async function runScriptFind() {
   try {
     const result = await promisify(exec)(
@@ -36,7 +40,18 @@ async function runScriptFix(instructions) {
 
 const findProjects = () =>
   db.projects
-    .find({}, { projection: { rootFolder: 1, _id: 1, version: 1 } })
+    .find(
+      {},
+      {
+        projection: {
+          rootFolder: 1,
+          _id: 1,
+          version: 1,
+          lastUpdated: 1,
+          lastUpdatedBy: 1,
+        },
+      }
+    )
     .toArray()
 
 const projectId = new ObjectId()
@@ -75,13 +90,15 @@ const wellFormedProject = {
       fileRefs: [wellFormedFileRef('fr00'), wellFormedFileRef('fr01')],
     },
   ],
+  lastUpdated,
+  lastUpdatedBy,
 }
 
 const testCases = [
   ...[{}, { rootFolder: undefined }, { rootFolder: '1234' }].map(
     (project, idx) => ({
       name: `bad rootFolder ${idx + 1}`,
-      project: { _id: projectId, ...project },
+      project: { _id: projectId, ...project, lastUpdated, lastUpdatedBy },
       expectFind: [
         {
           _id: null,
@@ -98,7 +115,7 @@ const testCases = [
 
   {
     name: `missing rootFolder`,
-    project: { _id: projectId, rootFolder: [] },
+    project: { _id: projectId, rootFolder: [], lastUpdated, lastUpdatedBy },
     expectFind: [
       {
         _id: null,
@@ -123,6 +140,8 @@ const testCases = [
             docs: [],
           },
         ],
+        lastUpdated: lastUpdatedChanged,
+        lastUpdatedBy: null,
       })
     },
   },
@@ -132,6 +151,8 @@ const testCases = [
     project: {
       _id: projectId,
       rootFolder: [{ _id: '1234' }],
+      lastUpdated,
+      lastUpdatedBy,
     },
     expectFind: [
       { reason: 'bad folder id', path: 'rootFolder.0._id' },
@@ -154,6 +175,8 @@ const testCases = [
     project: {
       _id: projectId,
       rootFolder: [{ _id: rootFolderId }],
+      lastUpdated,
+      lastUpdatedBy,
     },
     expectFind: [
       { reason: 'bad folder name', path: 'rootFolder.0.name' },
@@ -180,6 +203,8 @@ const testCases = [
             name: 'rootFolder',
           },
         ],
+        lastUpdated: lastUpdatedChanged,
+        lastUpdatedBy: null,
       })
     },
   },
@@ -197,6 +222,8 @@ const testCases = [
           fileRefs: [null, null],
         },
       ],
+      lastUpdated,
+      lastUpdatedBy,
     },
     expectFind: [
       {
@@ -235,6 +262,8 @@ const testCases = [
             folders: [],
           },
         ],
+        lastUpdated: lastUpdatedChanged,
+        lastUpdatedBy: null,
       })
     },
   },
@@ -255,6 +284,8 @@ const testCases = [
           fileRefs: [{ _id: null, name: 'ref-a' }, { name: 'ref-b' }],
         },
       ],
+      lastUpdated,
+      lastUpdatedBy,
     },
     expectFind: [
       { reason: 'bad folder id', path: 'rootFolder.0.folders.0._id', _id: 123 },
@@ -291,6 +322,8 @@ const testCases = [
             fileRefs: [],
           },
         ],
+        lastUpdated: lastUpdatedChanged,
+        lastUpdatedBy: null,
       })
     },
   },
@@ -314,6 +347,8 @@ const testCases = [
           ],
         },
       ],
+      lastUpdated,
+      lastUpdatedBy,
     },
     expectFind: [
       {
@@ -386,6 +421,8 @@ const testCases = [
             ],
           },
         ],
+        lastUpdated: lastUpdatedChanged,
+        lastUpdatedBy: null,
       })
     },
   },
@@ -403,6 +440,8 @@ const testCases = [
           ],
         },
       ],
+      lastUpdated,
+      lastUpdatedBy,
     },
     expectFind: [
       { path: 'rootFolder.0.fileRefs.0.hash', _id: strId('fa') },
@@ -442,6 +481,8 @@ const testCases = [
           fileRefs: [null, null, { ...wellFormedFileRef('fr02'), name: null }],
         },
       ],
+      lastUpdated,
+      lastUpdatedBy,
     },
     expectFind: [
       {
@@ -508,6 +549,8 @@ const testCases = [
             fileRefs: [{ ...wellFormedFileRef('fr02'), name: 'untitled' }],
           },
         ],
+        lastUpdated: lastUpdatedChanged,
+        lastUpdatedBy: null,
       })
     },
   },
@@ -539,6 +582,8 @@ const testCases = [
           fileRefs: [],
         },
       ],
+      lastUpdated,
+      lastUpdatedBy,
     },
     expectFind: [
       {
@@ -637,6 +682,8 @@ const testCases = [
             fileRefs: [],
           },
         ],
+        lastUpdated: lastUpdatedChanged,
+        lastUpdatedBy: null,
       })
     },
   },
@@ -674,6 +721,9 @@ describe('find_malformed_filetrees and fix_malformed_filetree scripts', function
             expect(expectFixStdout).to.be.a('string')
             expect(stdout).to.include(expectFixStdout)
             const [updatedProject] = await findProjects()
+            if (updatedProject.lastUpdated > lastUpdated) {
+              updatedProject.lastUpdated = lastUpdatedChanged
+            }
             expectProject(updatedProject)
           })
         }

+ 2 - 1
services/web/test/unit/src/Compile/ClsiManagerTests.js

@@ -13,6 +13,8 @@ const GLOBAL_BLOB_HASH = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
 
 describe('ClsiManager', function () {
   beforeEach(function () {
+    tk.freeze(Date.now())
+
     this.user_id = 'user-id'
     this.project = {
       _id: 'project-id',
@@ -182,7 +184,6 @@ describe('ClsiManager', function () {
         '../History/HistoryManager': this.HistoryManager,
       },
     })
-    tk.freeze(Date.now())
   })
 
   after(function () {

+ 9 - 2
services/web/test/unit/src/Editor/EditorControllerTests.js

@@ -518,7 +518,12 @@ describe('EditorController', function () {
 
     it('should add the folder using the project entity handler', function () {
       return this.ProjectEntityUpdateHandler.addFolder
-        .calledWith(this.project_id, this.folder_id, this.folderName)
+        .calledWith(
+          this.project_id,
+          this.folder_id,
+          this.folderName,
+          this.user_id
+        )
         .should.equal(true)
     })
 
@@ -540,6 +545,7 @@ describe('EditorController', function () {
         (this.folderA = { _id: 2, parentFolder_id: 1 }),
         (this.folderB = { _id: 3, parentFolder_id: 2 }),
       ]
+      this.userId = new ObjectId().toString()
       this.EditorController._notifyProjectUsersOfNewFolders = sinon
         .stub()
         .yields()
@@ -549,13 +555,14 @@ describe('EditorController', function () {
       return this.EditorController.mkdirp(
         this.project_id,
         this.path,
+        this.userId,
         this.callback
       )
     })
 
     it('should create the folder using the project entity handler', function () {
       return this.ProjectEntityUpdateHandler.mkdirp
-        .calledWith(this.project_id, this.path)
+        .calledWith(this.project_id, this.path, this.userId)
         .should.equal(true)
     })
 

+ 5 - 4
services/web/test/unit/src/History/RestoreManagerTests.js

@@ -81,7 +81,7 @@ describe('RestoreManager', function () {
 
       it('should find the root folder', function () {
         this.RestoreManager.promises._findOrCreateFolder
-          .calledWith(this.project_id, '')
+          .calledWith(this.project_id, '', this.user_id)
           .should.equal(true)
       })
 
@@ -116,7 +116,7 @@ describe('RestoreManager', function () {
 
       it('should find the folder', function () {
         this.RestoreManager.promises._findOrCreateFolder
-          .calledWith(this.project_id, 'foo')
+          .calledWith(this.project_id, 'foo', this.user_id)
           .should.equal(true)
       })
 
@@ -143,13 +143,14 @@ describe('RestoreManager', function () {
       })
       this.result = await this.RestoreManager.promises._findOrCreateFolder(
         this.project_id,
-        'folder/name'
+        'folder/name',
+        this.user_id
       )
     })
 
     it('should look up or create the folder', function () {
       this.EditorController.promises.mkdirp
-        .calledWith(this.project_id, 'folder/name')
+        .calledWith(this.project_id, 'folder/name', this.user_id)
         .should.equal(true)
     })
 

+ 69 - 23
services/web/test/unit/src/Project/ProjectEntityMongoUpdateHandlerTests.js

@@ -12,6 +12,7 @@ const MODULE_PATH =
 
 describe('ProjectEntityMongoUpdateHandler', function () {
   beforeEach(function () {
+    tk.freeze(new Date())
     this.doc = {
       _id: new ObjectId(),
       name: 'test-doc.txt',
@@ -209,19 +210,13 @@ describe('ProjectEntityMongoUpdateHandler', function () {
   afterEach(function () {
     this.DeletedFileMock.restore()
     this.ProjectMock.restore()
-  })
-
-  beforeEach(function () {
-    tk.freeze(Date.now())
-  })
-
-  afterEach(function () {
     tk.reset()
   })
 
   describe('addDoc', function () {
     beforeEach(async function () {
       const doc = { _id: new ObjectId(), name: 'other.txt' }
+      const userId = new ObjectId().toString()
       this.ProjectMock.expects('findOneAndUpdate')
         .withArgs(
           {
@@ -231,6 +226,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
           {
             $push: { 'rootFolder.0.folders.0.docs': doc },
             $inc: { version: 1 },
+            $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
           }
         )
         .chain('exec')
@@ -238,7 +234,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
       this.result = await this.subject.promises.addDoc(
         this.project._id,
         this.folder._id,
-        doc
+        doc,
+        userId
       )
     })
 
@@ -260,7 +257,9 @@ describe('ProjectEntityMongoUpdateHandler', function () {
   })
 
   describe('addFile', function () {
+    let userId
     beforeEach(function () {
+      userId = new ObjectId().toString()
       this.newFile = { _id: new ObjectId(), name: 'picture.jpg' }
       this.ProjectMock.expects('findOneAndUpdate')
         .withArgs(
@@ -271,6 +270,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
           {
             $push: { 'rootFolder.0.folders.0.fileRefs': this.newFile },
             $inc: { version: 1 },
+            $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
           }
         )
         .chain('exec')
@@ -282,7 +282,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
         this.result = await this.subject.promises.addFile(
           this.project._id,
           this.folder._id,
-          this.newFile
+          this.newFile,
+          userId
         )
       })
 
@@ -318,7 +319,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
           this.subject.promises.addFile(
             this.project._id,
             this.folder._id,
-            this.newFile
+            this.newFile,
+            userId
           )
         ).to.be.rejected
       })
@@ -327,6 +329,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
 
   describe('addFolder', function () {
     beforeEach(async function () {
+      const userId = new ObjectId().toString()
       const folderName = 'New folder'
       this.FolderModel.withArgs({ name: folderName }).returns({
         _id: new ObjectId(),
@@ -345,6 +348,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
               }),
             },
             $inc: { version: 1 },
+            $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
           }
         )
         .chain('exec')
@@ -352,7 +356,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
       await this.subject.promises.addFolder(
         this.project._id,
         this.folder._id,
-        folderName
+        folderName,
+        userId
       )
     })
 
@@ -393,6 +398,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
               'rootFolder.0.fileRefs.0.created': sinon.match.date,
               'rootFolder.0.fileRefs.0.linkedFileData': newFile.linkedFileData,
               'rootFolder.0.fileRefs.0.hash': newFile.hash,
+              lastUpdated: new Date(),
+              lastUpdatedBy: 'userId',
             },
             $inc: {
               version: 1,
@@ -408,7 +415,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
       await this.subject.promises.replaceFileWithNew(
         this.project._id,
         this.file._id,
-        newFile
+        newFile,
+        'userId'
       )
     })
 
@@ -460,6 +468,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
 
     describe('when the path is a new folder at the top level', function () {
       beforeEach(async function () {
+        const userId = new ObjectId().toString()
         this.newFolder = { _id: new ObjectId(), name: 'new-folder' }
         this.FolderModel.returns(this.newFolder)
         this.exactCaseMatch = false
@@ -469,6 +478,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
             {
               $push: { 'rootFolder.0.folders': this.newFolder },
               $inc: { version: 1 },
+              $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
             }
           )
           .chain('exec')
@@ -476,6 +486,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
         this.result = await this.subject.promises.mkdirp(
           this.project._id,
           '/new-folder/',
+          userId,
           { exactCaseMatch: this.exactCaseMatch }
         )
       })
@@ -504,6 +515,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
 
     describe('adding a subfolder', function () {
       beforeEach(async function () {
+        const userId = new ObjectId().toString()
         this.newFolder = { _id: new ObjectId(), name: 'new-folder' }
         this.FolderModel.returns(this.newFolder)
         this.ProjectMock.expects('findOneAndUpdate')
@@ -519,13 +531,15 @@ describe('ProjectEntityMongoUpdateHandler', function () {
                 }),
               },
               $inc: { version: 1 },
+              $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
             }
           )
           .chain('exec')
           .resolves(this.project)
         this.result = await this.subject.promises.mkdirp(
           this.project._id,
-          '/test-folder/new-folder'
+          '/test-folder/new-folder',
+          userId
         )
       })
 
@@ -547,7 +561,9 @@ describe('ProjectEntityMongoUpdateHandler', function () {
     })
 
     describe('when mutliple folders are missing', async function () {
+      let userId
       beforeEach(function () {
+        userId = new ObjectId().toString()
         this.folder1 = { _id: new ObjectId(), name: 'folder1' }
         this.folder1Path = {
           fileSystem: '/test-folder/folder1',
@@ -593,6 +609,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
                 }),
               },
               $inc: { version: 1 },
+              $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
             }
           )
           .chain('exec')
@@ -610,6 +627,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
                 }),
               },
               $inc: { version: 1 },
+              $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
             }
           )
           .chain('exec')
@@ -629,7 +647,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
           beforeEach(async function () {
             this.result = await this.subject.promises.mkdirp(
               this.project._id,
-              path
+              path,
+              userId
             )
           })
 
@@ -661,6 +680,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
   describe('moveEntity', function () {
     describe('moving a doc into a different folder', function () {
       beforeEach(async function () {
+        const userId = new ObjectId().toString()
         this.pathAfterMove = {
           fileSystem: '/somewhere/else.txt',
         }
@@ -685,6 +705,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
             {
               $push: { 'rootFolder.0.folders.0.docs': this.doc },
               $inc: { version: 1 },
+              $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
             }
           )
           .chain('exec')
@@ -695,6 +716,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
             {
               $pull: { 'rootFolder.0.docs': { _id: this.doc._id } },
               $inc: { version: 1 },
+              $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
             }
           )
           .chain('exec')
@@ -703,7 +725,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
           this.project._id,
           this.doc._id,
           this.folder._id,
-          'doc'
+          'doc',
+          userId
         )
       })
 
@@ -770,12 +793,14 @@ describe('ProjectEntityMongoUpdateHandler', function () {
 
   describe('deleteEntity', function () {
     beforeEach(async function () {
+      const userId = new ObjectId().toString()
       this.ProjectMock.expects('findOneAndUpdate')
         .withArgs(
           { _id: this.project._id },
           {
             $pull: { 'rootFolder.0.docs': { _id: this.doc._id } },
             $inc: { version: 1 },
+            $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
           }
         )
         .chain('exec')
@@ -783,7 +808,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
       await this.subject.promises.deleteEntity(
         this.project._id,
         this.doc._id,
-        'doc'
+        'doc',
+        userId
       )
     })
 
@@ -795,6 +821,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
   describe('renameEntity', function () {
     describe('happy path', function () {
       beforeEach(async function () {
+        const userId = new ObjectId().toString()
         this.newName = 'new.tex'
         this.oldDocs = ['old-doc']
         this.oldFiles = ['old-file']
@@ -812,7 +839,11 @@ describe('ProjectEntityMongoUpdateHandler', function () {
           .withArgs(
             { _id: this.project._id, 'rootFolder.0.docs.0': { $exists: true } },
             {
-              $set: { 'rootFolder.0.docs.0.name': this.newName },
+              $set: {
+                'rootFolder.0.docs.0.name': this.newName,
+                lastUpdated: new Date(),
+                lastUpdatedBy: userId,
+              },
               $inc: { version: 1 },
             }
           )
@@ -822,7 +853,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
           this.project._id,
           this.doc._id,
           'doc',
-          this.newName
+          this.newName,
+          userId
         )
       })
 
@@ -864,7 +896,9 @@ describe('ProjectEntityMongoUpdateHandler', function () {
   describe('_putElement', function () {
     describe('updating the project', function () {
       describe('when the parent folder is given', function () {
+        let userId
         beforeEach(function () {
+          userId = new ObjectId().toString()
           this.newFile = { _id: new ObjectId(), name: 'new file.png' }
           this.ProjectMock.expects('findOneAndUpdate')
             .withArgs(
@@ -875,6 +909,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
               {
                 $push: { 'rootFolder.0.folders.0.fileRefs': this.newFile },
                 $inc: { version: 1 },
+                $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
               }
             )
             .chain('exec')
@@ -886,7 +921,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
             this.project,
             this.folder._id,
             this.newFile,
-            'files'
+            'files',
+            userId
           )
           this.ProjectMock.verify()
         })
@@ -896,7 +932,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
             this.project,
             this.folder._id,
             this.newFile,
-            'file'
+            'file',
+            userId
           )
           this.ProjectMock.verify()
         })
@@ -998,6 +1035,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
 
     describe('when the parent folder is not given', function () {
       it('should default to root folder insert', async function () {
+        const userId = new ObjectId().toString()
         this.newFile = { _id: new ObjectId(), name: 'new file.png' }
         this.ProjectMock.expects('findOneAndUpdate')
           .withArgs(
@@ -1005,6 +1043,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
             {
               $push: { 'rootFolder.0.fileRefs': this.newFile },
               $inc: { version: 1 },
+              $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
             }
           )
           .chain('exec')
@@ -1013,7 +1052,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
           this.project,
           this.rootFolder._id,
           this.newFile,
-          'file'
+          'file',
+          userId
         )
       })
     })
@@ -1098,6 +1138,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
 
   describe('replaceDocWithFile', function () {
     it('should simultaneously remove the doc and add the file', async function () {
+      const userId = new ObjectId().toString()
       this.ProjectMock.expects('findOneAndUpdate')
         .withArgs(
           { _id: this.project._id, 'rootFolder.0': { $exists: true } },
@@ -1105,6 +1146,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
             $pull: { 'rootFolder.0.docs': { _id: this.doc._id } },
             $push: { 'rootFolder.0.fileRefs': this.file },
             $inc: { version: 1 },
+            $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
           },
           { new: true }
         )
@@ -1113,7 +1155,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
       await this.subject.promises.replaceDocWithFile(
         this.project._id,
         this.doc._id,
-        this.file
+        this.file,
+        userId
       )
       this.ProjectMock.verify()
     })
@@ -1121,6 +1164,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
 
   describe('replaceFileWithDoc', function () {
     it('should simultaneously remove the file and add the doc', async function () {
+      const userId = new ObjectId().toString()
       this.ProjectMock.expects('findOneAndUpdate')
         .withArgs(
           { _id: this.project._id, 'rootFolder.0': { $exists: true } },
@@ -1128,6 +1172,7 @@ describe('ProjectEntityMongoUpdateHandler', function () {
             $pull: { 'rootFolder.0.fileRefs': { _id: this.file._id } },
             $push: { 'rootFolder.0.docs': this.doc },
             $inc: { version: 1 },
+            $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
           },
           { new: true }
         )
@@ -1136,7 +1181,8 @@ describe('ProjectEntityMongoUpdateHandler', function () {
       await this.subject.promises.replaceFileWithDoc(
         this.project._id,
         this.file._id,
-        this.doc
+        this.doc,
+        userId
       )
       this.ProjectMock.verify()
     })

+ 52 - 38
services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js

@@ -747,13 +747,6 @@ describe('ProjectEntityUpdateHandler', function () {
           .should.equal(true)
       })
 
-      it('should mark the project as updated', function () {
-        const args = this.ProjectUpdater.promises.markAsUpdated.args[0]
-        args[0].should.equal(projectId)
-        args[1].should.exist
-        args[2].should.equal(userId)
-      })
-
       it('sends the change in project structure to the doc updater', function () {
         const newFiles = [
           {
@@ -1181,7 +1174,8 @@ describe('ProjectEntityUpdateHandler', function () {
         this.ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew.should.have.been.calledWith(
           projectId,
           this.existingFile._id,
-          this.file
+          this.file,
+          userId
         )
       })
 
@@ -1198,13 +1192,6 @@ describe('ProjectEntityUpdateHandler', function () {
         })
       })
 
-      it('should mark the project as updated', function () {
-        const args = this.ProjectUpdater.promises.markAsUpdated.args[0]
-        args[0].should.equal(projectId)
-        args[1].should.exist
-        args[2].should.equal(userId)
-      })
-
       it('updates the project structure in the doc updater', function () {
         const oldFiles = [
           {
@@ -1394,7 +1381,12 @@ describe('ProjectEntityUpdateHandler', function () {
       it('replaces the existing doc with a file', function () {
         expect(
           this.ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile
-        ).to.have.been.calledWith(projectId, this.existingDoc._id, this.newFile)
+        ).to.have.been.calledWith(
+          projectId,
+          this.existingDoc._id,
+          this.newFile,
+          userId
+        )
       })
 
       it('updates the doc structure', function () {
@@ -1475,7 +1467,7 @@ describe('ProjectEntityUpdateHandler', function () {
 
       it('creates any necessary folders', function () {
         this.ProjectEntityUpdateHandler.promises.mkdirp.withoutLock
-          .calledWith(projectId, '/folder')
+          .calledWith(projectId, '/folder', userId)
           .should.equal(true)
       })
 
@@ -1620,7 +1612,7 @@ describe('ProjectEntityUpdateHandler', function () {
 
       it('creates any necessary folders', function () {
         this.ProjectEntityUpdateHandler.promises.mkdirp.withoutLock
-          .calledWith(projectId, '/folder')
+          .calledWith(projectId, '/folder', userId)
           .should.equal(true)
       })
 
@@ -1767,7 +1759,7 @@ describe('ProjectEntityUpdateHandler', function () {
 
     it('deletes the entity in mongo', function () {
       this.ProjectEntityMongoUpdateHandler.promises.deleteEntity
-        .calledWith(projectId, docId, 'doc')
+        .calledWith(projectId, docId, 'doc', userId)
         .should.equal(true)
     })
 
@@ -1873,12 +1865,17 @@ describe('ProjectEntityUpdateHandler', function () {
     beforeEach(function (done) {
       this.docPath = '/folder/doc.tex'
       this.ProjectEntityMongoUpdateHandler.promises.mkdirp.resolves({})
-      this.ProjectEntityUpdateHandler.mkdirp(projectId, this.docPath, done)
+      this.ProjectEntityUpdateHandler.mkdirp(
+        projectId,
+        this.docPath,
+        userId,
+        done
+      )
     })
 
     it('calls ProjectEntityMongoUpdateHandler', function () {
       this.ProjectEntityMongoUpdateHandler.promises.mkdirp
-        .calledWith(projectId, this.docPath)
+        .calledWith(projectId, this.docPath, userId)
         .should.equal(true)
     })
   })
@@ -1890,13 +1887,14 @@ describe('ProjectEntityUpdateHandler', function () {
       this.ProjectEntityUpdateHandler.mkdirpWithExactCase(
         projectId,
         this.docPath,
+        userId,
         done
       )
     })
 
     it('calls ProjectEntityMongoUpdateHandler', function () {
       this.ProjectEntityMongoUpdateHandler.promises.mkdirp
-        .calledWith(projectId, this.docPath, { exactCaseMatch: true })
+        .calledWith(projectId, this.docPath, userId, { exactCaseMatch: true })
         .should.equal(true)
     })
   })
@@ -1911,13 +1909,14 @@ describe('ProjectEntityUpdateHandler', function () {
           projectId,
           this.parentFolderId,
           this.folderName,
+          userId,
           done
         )
       })
 
       it('calls ProjectEntityMongoUpdateHandler', function () {
         this.ProjectEntityMongoUpdateHandler.promises.addFolder
-          .calledWith(projectId, this.parentFolderId, this.folderName)
+          .calledWith(projectId, this.parentFolderId, this.folderName, userId)
           .should.equal(true)
       })
     })
@@ -1973,7 +1972,7 @@ describe('ProjectEntityUpdateHandler', function () {
 
     it('moves the entity in mongo', function () {
       this.ProjectEntityMongoUpdateHandler.promises.moveEntity
-        .calledWith(projectId, docId, folderId, 'doc')
+        .calledWith(projectId, docId, folderId, 'doc', userId)
         .should.equal(true)
     })
 
@@ -2035,7 +2034,7 @@ describe('ProjectEntityUpdateHandler', function () {
 
       it('moves the entity in mongo', function () {
         this.ProjectEntityMongoUpdateHandler.promises.renameEntity
-          .calledWith(projectId, docId, 'doc', this.newDocName)
+          .calledWith(projectId, docId, 'doc', this.newDocName, userId)
           .should.equal(true)
       })
 
@@ -2320,25 +2319,29 @@ describe('ProjectEntityUpdateHandler', function () {
           projectId,
           'doc3',
           'doc',
-          'duplicate.tex (1)'
+          'duplicate.tex (1)',
+          null
         )
         expect(renameEntity).to.have.been.calledWith(
           projectId,
           'doc5',
           'doc',
-          'duplicate.tex (2)'
+          'duplicate.tex (2)',
+          null
         )
         expect(renameEntity).to.have.been.calledWith(
           projectId,
           'file3',
           'file',
-          'duplicate.jpg (1)'
+          'duplicate.jpg (1)',
+          null
         )
         expect(renameEntity).to.have.been.calledWith(
           projectId,
           'file4',
           'file',
-          'another dupe (23)'
+          'another dupe (23)',
+          null
         )
       })
 
@@ -2410,25 +2413,29 @@ describe('ProjectEntityUpdateHandler', function () {
           projectId,
           'doc1',
           'doc',
-          '_d_e_f_test.tex'
+          '_d_e_f_test.tex',
+          null
         )
         expect(renameEntity).to.have.been.calledWith(
           projectId,
           'doc2',
           'doc',
-          'untitled'
+          'untitled',
+          null
         )
         expect(renameEntity).to.have.been.calledWith(
           projectId,
           'file1',
           'file',
-          'A_.png'
+          'A_.png',
+          null
         )
         expect(renameEntity).to.have.been.calledWith(
           projectId,
           'file2',
           'file',
-          'A_.png (1)'
+          'A_.png (1)',
+          null
         )
       })
 
@@ -2501,7 +2508,8 @@ describe('ProjectEntityUpdateHandler', function () {
           projectId,
           'folder2',
           'folder',
-          'bad_'
+          'bad_',
+          null
         )
       })
 
@@ -2558,7 +2566,8 @@ describe('ProjectEntityUpdateHandler', function () {
           projectId,
           'doc1',
           'doc',
-          'chapters (1)'
+          'chapters (1)',
+          null
         )
       })
 
@@ -2929,7 +2938,7 @@ describe('ProjectEntityUpdateHandler', function () {
         this.ProjectEntityUpdateHandler.convertDocToFile(
           this.project._id,
           this.doc._id,
-          this.user._id,
+          userId,
           this.source,
           done
         )
@@ -2960,7 +2969,12 @@ describe('ProjectEntityUpdateHandler', function () {
       it('replaces the doc with the file', function () {
         expect(
           this.ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile
-        ).to.have.been.calledWith(this.project._id, this.doc._id, this.file)
+        ).to.have.been.calledWith(
+          this.project._id,
+          this.doc._id,
+          this.file,
+          userId
+        )
       })
 
       it('notifies document updater of changes', function () {
@@ -2969,7 +2983,7 @@ describe('ProjectEntityUpdateHandler', function () {
         ).to.have.been.calledWith(
           this.project._id,
           this.project.overleaf.history.id,
-          this.user._id,
+          userId,
           {
             oldDocs: [{ doc: this.doc, path: this.path }],
             newFiles: [

+ 1 - 1
services/web/test/unit/src/Subscription/RecurlyWrapperTests.js

@@ -107,6 +107,7 @@ const mockApiRequest = function (options) {
 
 describe('RecurlyWrapper', function () {
   beforeEach(function () {
+    tk.freeze(Date.now()) // freeze the time for these tests
     this.settings = {
       plans: [
         {
@@ -134,7 +135,6 @@ describe('RecurlyWrapper', function () {
       fetchStringWithResponse: sinon.stub(),
       RequestFailedError,
     }
-    tk.freeze(Date.now()) // freeze the time for these tests
     this.RecurlyWrapper = SandboxedModule.require(modulePath, {
       requires: {
         '@overleaf/settings': this.settings,

+ 2 - 1
services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandlerTests.mjs

@@ -540,7 +540,8 @@ function expectFolderUpdateProcessed() {
   it('processes the folder update', function () {
     expect(this.UpdateMerger.promises.createFolder).to.have.been.calledWith(
       this.projects.active1._id,
-      this.folderPath
+      this.folderPath,
+      this.userId
     )
   })
 }

+ 2 - 1
services/web/test/unit/src/Uploads/ProjectUploadControllerTests.mjs

@@ -267,7 +267,8 @@ describe('ProjectUploadController', function () {
 
         this.EditorController.promises.mkdirp.should.be.calledWith(
           this.project_id,
-          '/test/foo/bar'
+          '/test/foo/bar',
+          this.user_id
         )
 
         this.FileSystemImportManager.addEntity.should.be.calledOnceWith(