import { vi, expect } from 'vitest' import sinon from 'sinon' import tk from 'timekeeper' import moment from 'moment' import { Project } from '../../../../app/src/models/Project.mjs' import { DeletedProject } from '../../../../app/src/models/DeletedProject.mjs' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' const modulePath = '../../../../app/src/Features/Project/ProjectDeleter' const { ObjectId, ReadPreference } = mongodb vi.mock('../../../../app/src/Features/Errors/Errors.js', () => vi.importActual('../../../../app/src/Features/Errors/Errors.js') ) describe('ProjectDeleter', function () { beforeEach(async function (ctx) { tk.freeze(Date.now()) ctx.ip = '192.170.18.1' ctx.project = dummyProject() ctx.user = { _id: '588f3ddae8ebc1bac07c9fa4', first_name: 'bjkdsjfk', features: {}, } ctx.doc = { _id: '5bd975f54f62e803cb8a8fec', lines: ['a bunch of lines', 'for a sunny day', 'in London town'], ranges: {}, project_id: '5cf9270b4eff6e186cf8b05e', } ctx.deletedProjects = [ { _id: '5cf7f145c1401f0ca0eb1aaa', deleterData: { _id: '5cf7f145c1401f0ca0eb1aac', deletedAt: moment().subtract(95, 'days').toDate(), deleterId: '588f3ddae8ebc1bac07c9fa4', deleterIpAddress: '172.19.0.1', deletedProjectId: '5cf9270b4eff6e186cf8b05e', deletedProjectOwnerId: ctx.user._id, }, project: { _id: '5cf9270b4eff6e186cf8b05e', overleaf: { history: { id: new ObjectId(), }, }, }, }, { _id: '5cf8eb11c1401f0ca0eb1ad7', deleterData: { _id: '5b74360c0fbe57011ae9938f', deletedAt: moment().subtract(95, 'days').toDate(), deleterId: '588f3ddae8ebc1bac07c9fa4', deleterIpAddress: '172.20.0.1', deletedProjectId: '5cf8f95a0c87371362c23919', }, project: { _id: '5cf8f95a0c87371362c23919', }, }, ] ctx.DocumentUpdaterHandler = { promises: { flushProjectToMongoAndDelete: sinon.stub().resolves(), }, } ctx.EditorRealTimeController = { emitToRoom: sinon.stub(), } ctx.TagsHandler = { promises: { removeProjectFromAllTags: sinon.stub().resolves(), }, } ctx.CollaboratorsHandler = { promises: { removeUserFromAllProjects: sinon.stub().resolves(), }, } ctx.CollaboratorsGetter = { promises: { getMemberIds: sinon .stub() .withArgs(ctx.project._id) .resolves(['member-id-1', 'member-id-2']), }, } ctx.ProjectDetailsHandler = { promises: { generateUniqueName: sinon.stub().resolves(ctx.project.name), }, } ctx.db = { projects: { insertOne: sinon.stub().resolves(), }, } ctx.DocstoreManager = { promises: { archiveProject: sinon.stub().resolves(), destroyProject: sinon.stub().resolves(), }, } ctx.HistoryManager = { promises: { deleteProject: sinon.stub().resolves(), }, } ctx.ProjectMock = sinon.mock(Project) ctx.DeletedProjectMock = sinon.mock(DeletedProject) ctx.Features = { hasFeature: sinon.stub().returns(true), } ctx.ChatApiHandler = { promises: { destroyProject: sinon.stub().resolves(), }, } ctx.ProjectAuditLogEntry = { deleteMany: sinon.stub().returns({ exec: sinon.stub().resolves() }), } vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ default: { promises: { hooks: { fire: sinon.stub().resolves() } }, }, })) vi.doMock('../../../../app/src/infrastructure/Features', () => ({ default: ctx.Features, })) vi.doMock( '../../../../app/src/Features/Editor/EditorRealTimeController', () => ({ default: ctx.EditorRealTimeController, }) ) vi.doMock('../../../../app/src/models/Project', () => ({ Project, })) vi.doMock('../../../../app/src/models/DeletedProject', () => ({ DeletedProject, })) vi.doMock( '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler', () => ({ default: ctx.DocumentUpdaterHandler, }) ) vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({ default: ctx.TagsHandler, })) vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler', () => ({ default: ctx.ChatApiHandler, })) vi.doMock( '../../../../app/src/Features/Collaborators/CollaboratorsHandler', () => ({ default: ctx.CollaboratorsHandler, }) ) vi.doMock( '../../../../app/src/Features/Collaborators/CollaboratorsGetter', () => ({ default: ctx.CollaboratorsGetter, }) ) vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({ default: ctx.DocstoreManager, })) vi.doMock( '../../../../app/src/Features/Project/ProjectDetailsHandler', () => ({ default: ctx.ProjectDetailsHandler, }) ) vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({ db: ctx.db, ObjectId, READ_PREFERENCE_SECONDARY: ReadPreference.secondaryPreferred.mode, })) vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({ default: ctx.HistoryManager, })) vi.doMock('../../../../app/src/models/ProjectAuditLogEntry', () => ({ ProjectAuditLogEntry: ctx.ProjectAuditLogEntry, })) ctx.ProjectDeleter = (await import(modulePath)).default }) afterEach(function (ctx) { tk.reset() ctx.DeletedProjectMock.restore() ctx.ProjectMock.restore() }) describe('mark as deleted by external source', function () { beforeEach(function (ctx) { ctx.ProjectMock.expects('updateOne') .withArgs( { _id: ctx.project._id }, { deletedByExternalDataSource: true } ) .chain('exec') .resolves() }) it('should update the project with the flag set to true', async function (ctx) { await ctx.ProjectDeleter.promises.markAsDeletedByExternalSource( ctx.project._id ) ctx.ProjectMock.verify() }) it('should tell the editor controler so users are notified', async function (ctx) { await ctx.ProjectDeleter.promises.markAsDeletedByExternalSource( ctx.project._id ) expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( ctx.project._id, 'projectRenamedOrDeletedByExternalSource' ) }) }) describe('unmarkAsDeletedByExternalSource', function () { beforeEach(async function (ctx) { ctx.ProjectMock.expects('updateOne') .withArgs( { _id: ctx.project._id }, { deletedByExternalDataSource: false } ) .chain('exec') .resolves() await ctx.ProjectDeleter.promises.unmarkAsDeletedByExternalSource( ctx.project._id ) }) it('should remove the flag from the project', function (ctx) { ctx.ProjectMock.verify() }) }) describe('deleteUsersProjects', function () { beforeEach(function (ctx) { ctx.projects = [dummyProject(), dummyProject()] ctx.ProjectMock.expects('find') .withArgs({ owner_ref: ctx.user._id }) .chain('exec') .resolves(ctx.projects) for (const project of ctx.projects) { ctx.ProjectMock.expects('findOne') .withArgs({ _id: project._id }) .chain('exec') .resolves(project) ctx.ProjectMock.expects('deleteOne') .withArgs({ _id: project._id }) .chain('exec') .resolves() ctx.DeletedProjectMock.expects('updateOne') .withArgs( { 'deleterData.deletedProjectId': project._id }, { project, deleterData: sinon.match.has('deletedReason', 'account-deletion'), }, { upsert: true } ) .resolves() } }) it('should delete all projects owned by the user', async function (ctx) { await ctx.ProjectDeleter.promises.deleteUsersProjects(ctx.user._id) ctx.ProjectMock.verify() ctx.DeletedProjectMock.verify() }) it('should remove any collaboration from this user', async function (ctx) { await ctx.ProjectDeleter.promises.deleteUsersProjects(ctx.user._id) sinon.assert.calledWith( ctx.CollaboratorsHandler.promises.removeUserFromAllProjects, ctx.user._id ) sinon.assert.calledOnce( ctx.CollaboratorsHandler.promises.removeUserFromAllProjects ) }) }) describe('deleteProject', function () { beforeEach(function (ctx) { ctx.deleterData = { deletedAt: new Date(), deletedProjectId: ctx.project._id, deletedProjectOwnerId: ctx.project.owner_ref, deletedProjectCollaboratorIds: ctx.project.collaberator_refs, deletedProjectReadOnlyIds: ctx.project.readOnly_refs, deletedProjectReviewerIds: ctx.project.reviewer_refs, deletedProjectReadWriteTokenAccessIds: ctx.project.tokenAccessReadAndWrite_refs, deletedProjectReadOnlyTokenAccessIds: ctx.project.tokenAccessReadOnly_refs, deletedProjectReadWriteToken: ctx.project.tokens.readAndWrite, deletedProjectReadOnlyToken: ctx.project.tokens.readOnly, deletedProjectOverleafId: ctx.project.overleaf.id, deletedProjectOverleafHistoryId: ctx.project.overleaf.history.id, deletedProjectLastUpdatedAt: ctx.project.lastUpdated, } ctx.ProjectMock.expects('findOne') .withArgs({ _id: ctx.project._id }) .chain('exec') .resolves(ctx.project) }) it('should save a DeletedProject with additional deleterData', async function (ctx) { ctx.deleterData.deleterIpAddress = ctx.ip ctx.deleterData.deleterId = ctx.user._id ctx.deleterData.deletedReason = 'user' ctx.ProjectMock.expects('deleteOne').chain('exec').resolves() ctx.DeletedProjectMock.expects('updateOne') .withArgs( { 'deleterData.deletedProjectId': ctx.project._id }, { project: ctx.project, deleterData: ctx.deleterData, }, { upsert: true } ) .resolves() await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, { deleterUser: ctx.user, ipAddress: ctx.ip, deletedReason: 'user', }) ctx.DeletedProjectMock.verify() }) it('should flushProjectToMongoAndDelete in doc updater', async function (ctx) { ctx.ProjectMock.expects('deleteOne').chain('exec').resolves() ctx.DeletedProjectMock.expects('updateOne').resolves() await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, { deleterUser: ctx.user, ipAddress: ctx.ip, }) ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete .calledWith(ctx.project._id) .should.equal(true) }) it('should flush docs out of mongo', async function (ctx) { ctx.ProjectMock.expects('deleteOne').chain('exec').resolves() ctx.DeletedProjectMock.expects('updateOne').resolves() await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, { deleterUser: ctx.user, ipAddress: ctx.ip, }) expect( ctx.DocstoreManager.promises.archiveProject ).to.have.been.calledWith(ctx.project._id) }) it('should flush docs out of mongo and ignore errors', async function (ctx) { ctx.ProjectMock.expects('deleteOne').chain('exec').resolves() ctx.DeletedProjectMock.expects('updateOne').resolves() ctx.DocstoreManager.promises.archiveProject.rejects(new Error('foo')) await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, { deleterUser: ctx.user, ipAddress: ctx.ip, }) }) it('should removeProjectFromAllTags', async function (ctx) { ctx.ProjectMock.expects('deleteOne').chain('exec').resolves() ctx.DeletedProjectMock.expects('updateOne').resolves() await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id) sinon.assert.calledWith( ctx.TagsHandler.promises.removeProjectFromAllTags, 'member-id-1', ctx.project._id ) sinon.assert.calledWith( ctx.TagsHandler.promises.removeProjectFromAllTags, 'member-id-2', ctx.project._id ) }) it('should remove the project from Mongo', async function (ctx) { ctx.ProjectMock.expects('deleteOne') .withArgs({ _id: ctx.project._id }) .chain('exec') .resolves() ctx.DeletedProjectMock.expects('updateOne').resolves() await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id) ctx.ProjectMock.verify() }) }) describe('expireDeletedProjectsAfterDuration', function () { beforeEach(async function (ctx) { for (const deletedProject of ctx.deletedProjects) { ctx.ProjectMock.expects('findById') .withArgs(deletedProject.deleterData.deletedProjectId) .chain('exec') .resolves(null) } ctx.DeletedProjectMock.expects('find') .withArgs({ 'deleterData.deletedAt': { $lt: new Date(moment().subtract(90, 'days')), }, project: { $type: 'object', }, }) .chain('exec') .resolves(ctx.deletedProjects) for (const deletedProject of ctx.deletedProjects) { ctx.DeletedProjectMock.expects('findOne') .withArgs({ 'deleterData.deletedProjectId': deletedProject.project._id, }) .chain('exec') .resolves(deletedProject) ctx.DeletedProjectMock.expects('updateOne') .withArgs( { _id: deletedProject._id, }, { $set: { 'deleterData.deleterIpAddress': null, project: null, }, } ) .chain('exec') .resolves() } await ctx.ProjectDeleter.promises.expireDeletedProjectsAfterDuration() }) it('should expire projects older than 90 days', function (ctx) { ctx.DeletedProjectMock.verify() }) }) describe('expireDeletedProject', function () { describe('on an inactive project', function () { beforeEach(async function (ctx) { ctx.ProjectMock.expects('findById') .withArgs(ctx.deletedProjects[0].deleterData.deletedProjectId) .chain('exec') .resolves(null) ctx.DeletedProjectMock.expects('updateOne') .withArgs( { _id: ctx.deletedProjects[0]._id, }, { $set: { 'deleterData.deleterIpAddress': null, project: null, }, } ) .chain('exec') .resolves() ctx.DeletedProjectMock.expects('findOne') .withArgs({ 'deleterData.deletedProjectId': ctx.deletedProjects[0].project._id, }) .chain('exec') .resolves(ctx.deletedProjects[0]) await ctx.ProjectDeleter.promises.expireDeletedProject( ctx.deletedProjects[0].project._id ) }) it('should find the specified deletedProject and remove its project and ip address', function (ctx) { ctx.DeletedProjectMock.verify() }) it('should destroy the docs in docstore', function (ctx) { expect( ctx.DocstoreManager.promises.destroyProject ).to.have.been.calledWith(ctx.deletedProjects[0].project._id) }) it('should delete the project in history', function (ctx) { expect( ctx.HistoryManager.promises.deleteProject ).to.have.been.calledWith( ctx.deletedProjects[0].project._id, ctx.deletedProjects[0].project.overleaf.history.id ) }) it('should destroy the chat threads and messages', function (ctx) { expect( ctx.ChatApiHandler.promises.destroyProject ).to.have.been.calledWith(ctx.deletedProjects[0].project._id) }) it('should delete audit logs', async function (ctx) { expect(ctx.ProjectAuditLogEntry.deleteMany).to.have.been.calledWith({ projectId: ctx.deletedProjects[0].project._id, }) }) it('should log a completed deletion', async function (ctx) { expect(ctx.logger.info).toHaveBeenCalledWith( { projectId: ctx.deletedProjects[0].project._id, userId: ctx.user._id, }, 'expired deleted project successfully' ) }) }) describe('on an active project (from an incomplete delete)', function () { beforeEach(async function (ctx) { ctx.ProjectMock.expects('findById') .withArgs(ctx.deletedProjects[0].deleterData.deletedProjectId) .chain('exec') .resolves(ctx.deletedProjects[0].project) ctx.DeletedProjectMock.expects('deleteOne') .withArgs({ 'deleterData.deletedProjectId': ctx.deletedProjects[0].project._id, }) .chain('exec') .resolves() await ctx.ProjectDeleter.promises.expireDeletedProject( ctx.deletedProjects[0].project._id ) }) it('should delete the spurious deleted project record', function (ctx) { ctx.DeletedProjectMock.verify() }) it('should not destroy the docs in docstore', function (ctx) { expect(ctx.DocstoreManager.promises.destroyProject).to.not.have.been .called }) it('should not delete the project in history', function (ctx) { expect(ctx.HistoryManager.promises.deleteProject).to.not.have.been .called }) it('should not destroy the chat threads and messages', function (ctx) { expect(ctx.ChatApiHandler.promises.destroyProject).to.not.have.been .called }) }) }) describe('archiveProject', function () { beforeEach(function (ctx) { ctx.ProjectMock.expects('updateOne') .withArgs( { _id: ctx.project._id }, { $addToSet: { archived: new ObjectId(ctx.user._id) }, $pull: { trashed: new ObjectId(ctx.user._id) }, } ) .resolves() }) it('should update the project', async function (ctx) { await ctx.ProjectDeleter.promises.archiveProject( ctx.project._id, ctx.user._id ) ctx.ProjectMock.verify() }) }) describe('unarchiveProject', function () { beforeEach(function (ctx) { ctx.ProjectMock.expects('updateOne') .withArgs( { _id: ctx.project._id }, { $pull: { archived: new ObjectId(ctx.user._id) } } ) .resolves() }) it('should update the project', async function (ctx) { await ctx.ProjectDeleter.promises.unarchiveProject( ctx.project._id, ctx.user._id ) ctx.ProjectMock.verify() }) }) describe('trashProject', function () { beforeEach(function (ctx) { ctx.ProjectMock.expects('updateOne') .withArgs( { _id: ctx.project._id }, { $addToSet: { trashed: new ObjectId(ctx.user._id) }, $pull: { archived: new ObjectId(ctx.user._id) }, } ) .resolves() }) it('should update the project', async function (ctx) { await ctx.ProjectDeleter.promises.trashProject( ctx.project._id, ctx.user._id ) ctx.ProjectMock.verify() }) }) describe('untrashProject', function () { beforeEach(function (ctx) { ctx.ProjectMock.expects('updateOne') .withArgs( { _id: ctx.project._id }, { $pull: { trashed: new ObjectId(ctx.user._id) } } ) .resolves() }) it('should update the project', async function (ctx) { await ctx.ProjectDeleter.promises.untrashProject( ctx.project._id, ctx.user._id ) ctx.ProjectMock.verify() }) }) describe('restoreProject', function () { beforeEach(function (ctx) { ctx.ProjectMock.expects('updateOne') .withArgs( { _id: ctx.project._id, }, { $unset: { archived: true }, } ) .chain('exec') .resolves() }) it('should unset the archive attribute', async function (ctx) { await ctx.ProjectDeleter.promises.restoreProject(ctx.project._id) }) }) describe('undeleteProject', function () { beforeEach(function (ctx) { ctx.unknownProjectId = new ObjectId() ctx.purgedProjectId = new ObjectId() ctx.deletedProject = { _id: 'deleted', project: ctx.project, deleterData: { deletedProjectId: ctx.project._id, deletedProjectOwnerId: ctx.project.owner_ref, }, } ctx.purgedProject = { _id: 'purged', deleterData: { deletedProjectId: ctx.purgedProjectId, deletedProjectOwnerId: 'potato', }, } ctx.DeletedProjectMock.expects('findOne') .withArgs({ 'deleterData.deletedProjectId': ctx.project._id }) .chain('exec') .resolves(ctx.deletedProject) ctx.DeletedProjectMock.expects('findOne') .withArgs({ 'deleterData.deletedProjectId': ctx.purgedProjectId }) .chain('exec') .resolves(ctx.purgedProject) ctx.DeletedProjectMock.expects('findOne') .withArgs({ 'deleterData.deletedProjectId': ctx.unknownProjectId }) .chain('exec') .resolves(null) ctx.DeletedProjectMock.expects('deleteOne').chain('exec').resolves() }) it('should return not found if the project does not exist', async function (ctx) { await expect( ctx.ProjectDeleter.promises.undeleteProject( ctx.unknownProjectId.toString() ) ).to.be.rejectedWith(Errors.NotFoundError, 'project_not_found') }) it('should return not found if the project has been expired', async function (ctx) { await expect( ctx.ProjectDeleter.promises.undeleteProject( ctx.purgedProjectId.toString() ) ).to.be.rejectedWith(Errors.NotFoundError, 'project_too_old_to_restore') }) it('should insert the project into the collection', async function (ctx) { await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id) sinon.assert.calledWith( ctx.db.projects.insertOne, sinon.match({ _id: ctx.project._id, name: ctx.project.name, }) ) }) it('should clear the archive bit', async function (ctx) { ctx.project.archived = true await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id) sinon.assert.calledWith( ctx.db.projects.insertOne, sinon.match({ archived: undefined }) ) }) it('should generate a unique name for the project', async function (ctx) { await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id) sinon.assert.calledWith( ctx.ProjectDetailsHandler.promises.generateUniqueName, ctx.project.owner_ref ) }) it('should add a suffix to the project name', async function (ctx) { await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id) sinon.assert.calledWith( ctx.ProjectDetailsHandler.promises.generateUniqueName, ctx.project.owner_ref, ctx.project.name + ' (Restored)' ) }) it('should remove the DeletedProject', async function (ctx) { // need to change the mock just to include the methods we want ctx.DeletedProjectMock.restore() ctx.DeletedProjectMock = sinon.mock(DeletedProject) ctx.DeletedProjectMock.expects('findOne') .withArgs({ 'deleterData.deletedProjectId': ctx.project._id }) .chain('exec') .resolves(ctx.deletedProject) ctx.DeletedProjectMock.expects('deleteOne') .withArgs({ _id: 'deleted' }) .chain('exec') .resolves() await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id) ctx.DeletedProjectMock.verify() }) }) }) function dummyProject() { return { _id: new ObjectId(), lastUpdated: new Date(), rootFolder: [], collaberator_refs: [new ObjectId(), new ObjectId()], readOnly_refs: [new ObjectId(), new ObjectId()], reviewer_refs: [new ObjectId()], tokenAccessReadAndWrite_refs: [new ObjectId(), new ObjectId()], tokenAccessReadOnly_refs: [new ObjectId(), new ObjectId()], owner_ref: new ObjectId(), tokens: { readOnly: 'wombat', readAndWrite: 'potato', }, overleaf: { id: 1234, history: { id: 5678, }, }, name: 'a very scientific analysis of spooky ghosts', } }